feat(dsh): wiring harness-check/role-hint в pre-step, isInjectionVisible (#43)

This commit is contained in:
Andrey Epifancev 2026-10-25 11:47:28 +04:00
parent 37daa97915
commit a2453e35e8
5 changed files with 273 additions and 29 deletions

View File

@ -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
})
}

View File

@ -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

View File

@ -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', () => {

View File

@ -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',

View File

@ -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)
}
})