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
|
||||
* `hooks/guard-main.sh` and `hooks/require-plan.sh` through a thin adapter;
|
||||
* - 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
|
||||
* `ctx.commands`.
|
||||
*
|
||||
@ -33,9 +34,17 @@ import {
|
||||
checkPlanWrite,
|
||||
checkSddWrite,
|
||||
} 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 { 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 { collectSelfCheck } from './lib/selfcheck.js'
|
||||
|
||||
@ -186,6 +195,8 @@ const DEFAULTS = {
|
||||
approvalGates: true,
|
||||
bootstrap: true,
|
||||
bootstrapSubagents: false,
|
||||
harnessCheck: true,
|
||||
roleHint: true,
|
||||
roleCommand: true,
|
||||
selfCheck: 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) => {
|
||||
const decision = await next()
|
||||
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({
|
||||
skillPath: config.bootstrapSkillPath,
|
||||
mappingPath: config.toolMappingPath || join(bundleDir, 'dsh-tools.md'),
|
||||
maxBytes: config.bootstrapMaxBytes,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
if (!prepared) return decision
|
||||
if (isBootstrapVisible(agent, decision.messages, prepared.marker)) return decision
|
||||
|
||||
return {
|
||||
...decision,
|
||||
messages: [
|
||||
...(decision.messages ?? []),
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text: prepared.text }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
],
|
||||
if (config.bootstrap && bootstrapOk) {
|
||||
if (config.bootstrapSubagents || !isSubagent(agent)) {
|
||||
const prepared = prepareBootstrap({
|
||||
skillPath: config.bootstrapSkillPath,
|
||||
mappingPath: config.toolMappingPath || join(bundleDir, 'dsh-tools.md'),
|
||||
maxBytes: config.bootstrapMaxBytes,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
if (prepared && !isInjectionVisible(agent, result.messages, prepared.marker)) {
|
||||
result = {
|
||||
...result,
|
||||
messages: [
|
||||
...(result.messages ?? []),
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text: prepared.text }],
|
||||
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;
|
||||
* 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 (Array.isArray(messages) && messages.some((message) => textOf(message).includes(marker))) return true
|
||||
|
||||
@ -218,3 +218,6 @@ export function isBootstrapVisible(agent, messages, marker) {
|
||||
}
|
||||
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 {
|
||||
buildBootstrapText,
|
||||
isBootstrapVisible,
|
||||
isInjectionVisible,
|
||||
prepareBootstrap,
|
||||
resolveBootstrapSkill,
|
||||
stripFrontmatter,
|
||||
@ -53,10 +54,10 @@ test('buildBootstrapText is deterministic for the same content', () => {
|
||||
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 -->'
|
||||
assert.equal(isBootstrapVisible({}, [{ content: [{ type: 'text', text: marker }] }], marker), true)
|
||||
assert.equal(isBootstrapVisible({}, [{ content: [{ type: 'text', text: 'hello' }] }], marker), false)
|
||||
assert.equal(isInjectionVisible({}, [{ content: [{ type: 'text', text: marker }] }], marker), true)
|
||||
assert.equal(isInjectionVisible({}, [{ content: [{ type: 'text', text: 'hello' }] }], marker), false)
|
||||
|
||||
const event = { type: 'user/message', data: { content: [{ type: 'text', text: `prefix ${marker}` }] } }
|
||||
const visibleAgent = {
|
||||
@ -66,12 +67,16 @@ test('isBootstrapVisible sees the marker in messages and in the visible surface'
|
||||
eventAt: (seq) => (seq === 1 ? event : undefined),
|
||||
},
|
||||
}
|
||||
assert.equal(isBootstrapVisible(visibleAgent, [], marker), true)
|
||||
assert.equal(isInjectionVisible(visibleAgent, [], marker), true)
|
||||
|
||||
const compactedAgent = {
|
||||
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', () => {
|
||||
|
||||
@ -62,6 +62,7 @@ test('runtime files have no external @deepseek-ai imports', () => {
|
||||
'lib/guards.js',
|
||||
'lib/gates.js',
|
||||
'lib/bootstrap.js',
|
||||
'lib/prestep.js',
|
||||
'lib/role.js',
|
||||
'lib/legacy.js',
|
||||
'lib/selfcheck.js',
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { apply } from '../bundle/index.js'
|
||||
import {
|
||||
buildHarnessCheckText,
|
||||
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, '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