feat(dsh): wiring harness-check/role-hint в pre-step, isInjectionVisible (#43)
This commit is contained in:
parent
37daa97915
commit
a2453e35e8
@ -5,7 +5,8 @@
|
|||||||
* - PreToolUse Edit/Write/Bash -> `ctx.tools.guard` calling the existing
|
* - PreToolUse Edit/Write/Bash -> `ctx.tools.guard` calling the existing
|
||||||
* `hooks/guard-main.sh` and `hooks/require-plan.sh` through a thin adapter;
|
* `hooks/guard-main.sh` and `hooks/require-plan.sh` through a thin adapter;
|
||||||
* - SessionStart superpowers -> `agent/pre-step` injecting
|
* - SessionStart superpowers -> `agent/pre-step` injecting
|
||||||
* `using-superpowers` once per session (and again after compaction);
|
* `using-superpowers` once per session (and again after compaction),
|
||||||
|
* plus the harness-check and role-hint messages;
|
||||||
* - `/role` skill -> a real `/role` command registering on
|
* - `/role` skill -> a real `/role` command registering on
|
||||||
* `ctx.commands`.
|
* `ctx.commands`.
|
||||||
*
|
*
|
||||||
@ -33,9 +34,17 @@ import {
|
|||||||
checkPlanWrite,
|
checkPlanWrite,
|
||||||
checkSddWrite,
|
checkSddWrite,
|
||||||
} from './lib/gates.js'
|
} from './lib/gates.js'
|
||||||
import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js'
|
import { isInjectionVisible, prepareBootstrap, textOf } from './lib/bootstrap.js'
|
||||||
import { isLegacyClaudePath, warnLegacyOnce } from './lib/legacy.js'
|
import { isLegacyClaudePath, warnLegacyOnce } from './lib/legacy.js'
|
||||||
import { createUserMessage } from './lib/message.js'
|
import { createUserMessage } from './lib/message.js'
|
||||||
|
import {
|
||||||
|
buildHarnessCheckText,
|
||||||
|
buildRoleHintText,
|
||||||
|
detectAdoption,
|
||||||
|
detectRole,
|
||||||
|
isHarnessMessage,
|
||||||
|
makePrestepMarker,
|
||||||
|
} from './lib/prestep.js'
|
||||||
import { DEFAULT_ROLE_NAMES } from './lib/role.js'
|
import { DEFAULT_ROLE_NAMES } from './lib/role.js'
|
||||||
import { collectSelfCheck } from './lib/selfcheck.js'
|
import { collectSelfCheck } from './lib/selfcheck.js'
|
||||||
|
|
||||||
@ -186,6 +195,8 @@ const DEFAULTS = {
|
|||||||
approvalGates: true,
|
approvalGates: true,
|
||||||
bootstrap: true,
|
bootstrap: true,
|
||||||
bootstrapSubagents: false,
|
bootstrapSubagents: false,
|
||||||
|
harnessCheck: true,
|
||||||
|
roleHint: true,
|
||||||
roleCommand: true,
|
roleCommand: true,
|
||||||
selfCheck: true,
|
selfCheck: true,
|
||||||
checkCommand: true,
|
checkCommand: true,
|
||||||
@ -256,31 +267,90 @@ export function apply(ctx, rawConfig = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.enabled && config.bootstrap && bootstrapOk) {
|
// The pre-step listener is registered when any of the three injections is
|
||||||
|
// wanted: the bootstrap needs its service from the self-check, while
|
||||||
|
// harness-check and role-hint gate directly on ctx.on (D-5) so they stay
|
||||||
|
// alive with `bootstrap:false`.
|
||||||
|
const prestepsWanted =
|
||||||
|
(config.bootstrap && bootstrapOk) || config.harnessCheck || config.roleHint
|
||||||
|
if (config.enabled && prestepsWanted && typeof ctx.on === 'function') {
|
||||||
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
||||||
const decision = await next()
|
const decision = await next()
|
||||||
if (decision.kind === 'reject' || signal?.aborted) return decision
|
if (decision.kind === 'reject' || signal?.aborted) return decision
|
||||||
if (!config.bootstrapSubagents && isSubagent(agent)) return decision
|
const onOk = typeof ctx.on === 'function'
|
||||||
|
let result = decision
|
||||||
|
|
||||||
const prepared = prepareBootstrap({
|
if (config.bootstrap && bootstrapOk) {
|
||||||
skillPath: config.bootstrapSkillPath,
|
if (config.bootstrapSubagents || !isSubagent(agent)) {
|
||||||
mappingPath: config.toolMappingPath || join(bundleDir, 'dsh-tools.md'),
|
const prepared = prepareBootstrap({
|
||||||
maxBytes: config.bootstrapMaxBytes,
|
skillPath: config.bootstrapSkillPath,
|
||||||
logger: ctx.logger,
|
mappingPath: config.toolMappingPath || join(bundleDir, 'dsh-tools.md'),
|
||||||
})
|
maxBytes: config.bootstrapMaxBytes,
|
||||||
if (!prepared) return decision
|
logger: ctx.logger,
|
||||||
if (isBootstrapVisible(agent, decision.messages, prepared.marker)) return decision
|
})
|
||||||
|
if (prepared && !isInjectionVisible(agent, result.messages, prepared.marker)) {
|
||||||
return {
|
result = {
|
||||||
...decision,
|
...result,
|
||||||
messages: [
|
messages: [
|
||||||
...(decision.messages ?? []),
|
...(result.messages ?? []),
|
||||||
createUserMessage({
|
createUserMessage({
|
||||||
content: [{ type: 'text', text: prepared.text }],
|
content: [{ type: 'text', text: prepared.text }],
|
||||||
source: { kind: 'user' },
|
source: { kind: 'user' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.enabled && config.harnessCheck && onOk && !isSubagent(agent)) {
|
||||||
|
const cwd = sessionCwd(ctx.workingDirectory, agent)
|
||||||
|
const res = detectAdoption(cwd)
|
||||||
|
if (!res.adopted) {
|
||||||
|
const { marker, body } = makePrestepMarker(
|
||||||
|
buildHarnessCheckText(res.root ?? cwd, res.missing),
|
||||||
|
)
|
||||||
|
if (!isInjectionVisible(agent, result.messages, marker)) {
|
||||||
|
result = {
|
||||||
|
...result,
|
||||||
|
messages: [
|
||||||
|
...(result.messages ?? []),
|
||||||
|
createUserMessage({
|
||||||
|
content: [{ type: 'text', text: body }],
|
||||||
|
source: { kind: 'user' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.enabled && config.roleHint && onOk && !isSubagent(agent)) {
|
||||||
|
const firstUser = (result.messages ?? []).find((message) => {
|
||||||
|
if (message?.source?.kind !== 'user') return false
|
||||||
|
const text = textOf(message)
|
||||||
|
return text !== '' && !isHarnessMessage(text)
|
||||||
|
})
|
||||||
|
if (firstUser) {
|
||||||
|
const { marker, body } = makePrestepMarker(
|
||||||
|
buildRoleHintText(detectRole(textOf(firstUser))),
|
||||||
|
)
|
||||||
|
if (!isInjectionVisible(agent, result.messages, marker)) {
|
||||||
|
result = {
|
||||||
|
...result,
|
||||||
|
messages: [
|
||||||
|
...(result.messages ?? []),
|
||||||
|
createUserMessage({
|
||||||
|
content: [{ type: 'text', text: body }],
|
||||||
|
source: { kind: 'user' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
dsh/bundle/lib/bootstrap.js
vendored
7
dsh/bundle/lib/bootstrap.js
vendored
@ -195,11 +195,11 @@ export function textOf(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this exact bootstrap marker is still visible in the session.
|
* Whether this exact injection marker is still visible in the session.
|
||||||
* Messages under consideration by the current pre-step are checked first;
|
* Messages under consideration by the current pre-step are checked first;
|
||||||
* then the durable visible surface, which drops a message after compaction.
|
* then the durable visible surface, which drops a message after compaction.
|
||||||
*/
|
*/
|
||||||
export function isBootstrapVisible(agent, messages, marker) {
|
export function isInjectionVisible(agent, messages, marker) {
|
||||||
if (!marker) return false
|
if (!marker) return false
|
||||||
if (Array.isArray(messages) && messages.some((message) => textOf(message).includes(marker))) return true
|
if (Array.isArray(messages) && messages.some((message) => textOf(message).includes(marker))) return true
|
||||||
|
|
||||||
@ -218,3 +218,6 @@ export function isBootstrapVisible(agent, messages, marker) {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Back-compat name for the pre-refactor bootstrap visibility check. */
|
||||||
|
export const isBootstrapVisible = isInjectionVisible
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import test from 'node:test'
|
|||||||
import {
|
import {
|
||||||
buildBootstrapText,
|
buildBootstrapText,
|
||||||
isBootstrapVisible,
|
isBootstrapVisible,
|
||||||
|
isInjectionVisible,
|
||||||
prepareBootstrap,
|
prepareBootstrap,
|
||||||
resolveBootstrapSkill,
|
resolveBootstrapSkill,
|
||||||
stripFrontmatter,
|
stripFrontmatter,
|
||||||
@ -53,10 +54,10 @@ test('buildBootstrapText is deterministic for the same content', () => {
|
|||||||
assert.equal(buildBootstrapText(args), buildBootstrapText(args))
|
assert.equal(buildBootstrapText(args), buildBootstrapText(args))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('isBootstrapVisible sees the marker in messages and in the visible surface', () => {
|
test('isInjectionVisible sees the marker in messages and in the visible surface', () => {
|
||||||
const marker = '<!-- DSH-HARNESS-BOOTSTRAP:abc -->'
|
const marker = '<!-- DSH-HARNESS-BOOTSTRAP:abc -->'
|
||||||
assert.equal(isBootstrapVisible({}, [{ content: [{ type: 'text', text: marker }] }], marker), true)
|
assert.equal(isInjectionVisible({}, [{ content: [{ type: 'text', text: marker }] }], marker), true)
|
||||||
assert.equal(isBootstrapVisible({}, [{ content: [{ type: 'text', text: 'hello' }] }], marker), false)
|
assert.equal(isInjectionVisible({}, [{ content: [{ type: 'text', text: 'hello' }] }], marker), false)
|
||||||
|
|
||||||
const event = { type: 'user/message', data: { content: [{ type: 'text', text: `prefix ${marker}` }] } }
|
const event = { type: 'user/message', data: { content: [{ type: 'text', text: `prefix ${marker}` }] } }
|
||||||
const visibleAgent = {
|
const visibleAgent = {
|
||||||
@ -66,12 +67,16 @@ test('isBootstrapVisible sees the marker in messages and in the visible surface'
|
|||||||
eventAt: (seq) => (seq === 1 ? event : undefined),
|
eventAt: (seq) => (seq === 1 ? event : undefined),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
assert.equal(isBootstrapVisible(visibleAgent, [], marker), true)
|
assert.equal(isInjectionVisible(visibleAgent, [], marker), true)
|
||||||
|
|
||||||
const compactedAgent = {
|
const compactedAgent = {
|
||||||
session: { seq: 3, surface: { nodes: new Set() }, eventAt: () => undefined },
|
session: { seq: 3, surface: { nodes: new Set() }, eventAt: () => undefined },
|
||||||
}
|
}
|
||||||
assert.equal(isBootstrapVisible(compactedAgent, [], marker), false)
|
assert.equal(isInjectionVisible(compactedAgent, [], marker), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isBootstrapVisible is an alias of isInjectionVisible', () => {
|
||||||
|
assert.equal(isBootstrapVisible, isInjectionVisible)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('resolveBootstrapSkill prefers an explicit path and finds the user root', () => {
|
test('resolveBootstrapSkill prefers an explicit path and finds the user root', () => {
|
||||||
|
|||||||
@ -62,6 +62,7 @@ test('runtime files have no external @deepseek-ai imports', () => {
|
|||||||
'lib/guards.js',
|
'lib/guards.js',
|
||||||
'lib/gates.js',
|
'lib/gates.js',
|
||||||
'lib/bootstrap.js',
|
'lib/bootstrap.js',
|
||||||
|
'lib/prestep.js',
|
||||||
'lib/role.js',
|
'lib/role.js',
|
||||||
'lib/legacy.js',
|
'lib/legacy.js',
|
||||||
'lib/selfcheck.js',
|
'lib/selfcheck.js',
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
import { apply } from '../bundle/index.js'
|
||||||
import {
|
import {
|
||||||
buildHarnessCheckText,
|
buildHarnessCheckText,
|
||||||
buildRoleHintText,
|
buildRoleHintText,
|
||||||
@ -212,3 +213,167 @@ test('makePrestepMarker: marker format, body prefix, determinism, uniqueness', (
|
|||||||
assert.notEqual(makePrestepMarker('Пока').marker, marker, 'different text, different marker')
|
assert.notEqual(makePrestepMarker('Пока').marker, marker, 'different text, different marker')
|
||||||
assert.notEqual(makePrestepMarker('привет').marker, marker, 'case matters')
|
assert.notEqual(makePrestepMarker('привет').marker, marker, 'case matters')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// apply(): wiring of harness-check and role-hint into agent/pre-step
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Text of the first text block of a message. */
|
||||||
|
function messageText(message) {
|
||||||
|
return message.content[0].text
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* apply() against a fake ctx: the `agent/pre-step` listener is captured and
|
||||||
|
* can be run with a canned decision. The session cwd is served by the fake
|
||||||
|
* workingDirectory; bootstrap is off in most cases so its big text does not
|
||||||
|
* pollute the message lists.
|
||||||
|
*/
|
||||||
|
function prestepHarness(config = {}, cwd) {
|
||||||
|
const listeners = {}
|
||||||
|
const ctx = {
|
||||||
|
tools: { guard() {} },
|
||||||
|
on(event, handler) { listeners[event] = handler },
|
||||||
|
inject() {},
|
||||||
|
workingDirectory: { get() { return cwd } },
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
}
|
||||||
|
apply(ctx, config)
|
||||||
|
const handler = listeners['agent/pre-step']
|
||||||
|
assert.ok(handler, 'agent/pre-step listener is registered')
|
||||||
|
return {
|
||||||
|
run({ agent = {}, messages = [] } = {}) {
|
||||||
|
return handler({ agent, signal: {} }, async () => ({ kind: 'continue', messages }))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tree with `.git` but none of the adoption signals. */
|
||||||
|
function nonAdoptedRepo() {
|
||||||
|
const repo = tempDir('dsh-harness-noadopt-')
|
||||||
|
write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n')
|
||||||
|
write(join(repo, 'CLAUDE.md'), '# Проект без секции\n')
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(text) {
|
||||||
|
return { content: [{ type: 'text', text }], source: { kind: 'user' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('apply: harness-check injects when adoption signals are missing', async () => {
|
||||||
|
const repo = nonAdoptedRepo()
|
||||||
|
try {
|
||||||
|
const harness = prestepHarness({ bootstrap: false }, repo)
|
||||||
|
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
|
||||||
|
|
||||||
|
assert.equal(decision.messages.length, 1)
|
||||||
|
const text = messageText(decision.messages[0])
|
||||||
|
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
|
||||||
|
assert.match(text, /\[dsh-harness\] Проект .* не подключён к обвязке/)
|
||||||
|
assert.match(text, /openspec init --tools agents/)
|
||||||
|
assert.equal(decision.messages[0].source.kind, 'user')
|
||||||
|
} finally {
|
||||||
|
cleanup(repo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('apply: harness-check stays silent for an adopted repo and outside git', async () => {
|
||||||
|
const repo = adoptedRepo()
|
||||||
|
try {
|
||||||
|
const harness = prestepHarness({ bootstrap: false }, repo)
|
||||||
|
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
|
||||||
|
assert.deepEqual(decision.messages, [])
|
||||||
|
} finally {
|
||||||
|
cleanup(repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
const outside = tempDir('dsh-harness-outside-')
|
||||||
|
try {
|
||||||
|
const harness = prestepHarness({ bootstrap: false }, outside)
|
||||||
|
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
|
||||||
|
assert.deepEqual(decision.messages, [])
|
||||||
|
} finally {
|
||||||
|
cleanup(outside)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('apply: role-hint injects Роль сессии: dev for a keyword user message', async () => {
|
||||||
|
const outside = tempDir('dsh-harness-outside-')
|
||||||
|
try {
|
||||||
|
const harness = prestepHarness({ bootstrap: false }, outside)
|
||||||
|
const user = userMessage('Напиши тест для модуля')
|
||||||
|
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
|
||||||
|
|
||||||
|
assert.equal(decision.messages.length, 2)
|
||||||
|
const text = messageText(decision.messages[1])
|
||||||
|
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
|
||||||
|
assert.match(text, /\[dsh-harness\] Роль сессии: dev\./)
|
||||||
|
} finally {
|
||||||
|
cleanup(outside)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('apply: role-hint asks the four-variant question when no keyword matches', async () => {
|
||||||
|
const outside = tempDir('dsh-harness-outside-')
|
||||||
|
try {
|
||||||
|
const harness = prestepHarness({ bootstrap: false }, outside)
|
||||||
|
const user = userMessage('Привет, как дела?')
|
||||||
|
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
|
||||||
|
|
||||||
|
assert.equal(decision.messages.length, 2)
|
||||||
|
const text = messageText(decision.messages[1])
|
||||||
|
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
|
||||||
|
assert.match(text, /\[dsh-harness\] Роль сессии неясна\./)
|
||||||
|
assert.match(text, /четырьмя вариантами/)
|
||||||
|
} finally {
|
||||||
|
cleanup(outside)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('apply: role-hint stays silent when the step has no new user message', async () => {
|
||||||
|
const outside = tempDir('dsh-harness-outside-')
|
||||||
|
try {
|
||||||
|
const harness = prestepHarness({ bootstrap: false }, outside)
|
||||||
|
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
|
||||||
|
assert.deepEqual(decision.messages, [])
|
||||||
|
} finally {
|
||||||
|
cleanup(outside)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('apply: a user message that equals the role-hint question does not re-trigger role-hint', async () => {
|
||||||
|
const outside = tempDir('dsh-harness-outside-')
|
||||||
|
try {
|
||||||
|
const harness = prestepHarness({ bootstrap: false }, outside)
|
||||||
|
const user = userMessage(buildRoleHintText(null))
|
||||||
|
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
|
||||||
|
assert.deepEqual(decision.messages, [user], 'isHarnessMessage filters before detectRole')
|
||||||
|
} finally {
|
||||||
|
cleanup(outside)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('apply: a [claude-harness]-prefixed user message is skipped by role-hint', async () => {
|
||||||
|
const outside = tempDir('dsh-harness-outside-')
|
||||||
|
try {
|
||||||
|
const harness = prestepHarness({ bootstrap: false }, outside)
|
||||||
|
const user = userMessage('[claude-harness] Роль сессии: dev. Вызови skill role.')
|
||||||
|
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
|
||||||
|
assert.deepEqual(decision.messages, [user], 'legacy prefix is filtered too')
|
||||||
|
} finally {
|
||||||
|
cleanup(outside)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('apply: bootstrap:false leaves harness-check wired (the gate is ctx.on, not bootstrapOk)', async () => {
|
||||||
|
const repo = nonAdoptedRepo()
|
||||||
|
try {
|
||||||
|
const harness = prestepHarness({ bootstrap: false, roleHint: false }, repo)
|
||||||
|
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
|
||||||
|
|
||||||
|
assert.equal(decision.messages.length, 1)
|
||||||
|
assert.match(messageText(decision.messages[0]), /не подключён к обвязке/)
|
||||||
|
} finally {
|
||||||
|
cleanup(repo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user