From a2453e35e83907044d1519861a630679ce41c7fb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 11:47:28 +0400 Subject: [PATCH] =?UTF-8?q?feat(dsh):=20wiring=20harness-check/role-hint?= =?UTF-8?q?=20=D0=B2=20pre-step,=20isInjectionVisible=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/bundle/index.js | 114 +++++++++++++++++++----- dsh/bundle/lib/bootstrap.js | 7 +- dsh/tests/bootstrap.test.mjs | 15 ++-- dsh/tests/package.test.mjs | 1 + dsh/tests/prestep.test.mjs | 165 +++++++++++++++++++++++++++++++++++ 5 files changed, 273 insertions(+), 29 deletions(-) diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index 761578e..a0bb9a3 100644 --- a/dsh/bundle/index.js +++ b/dsh/bundle/index.js @@ -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 }) } diff --git a/dsh/bundle/lib/bootstrap.js b/dsh/bundle/lib/bootstrap.js index 99b49f4..7964af0 100644 --- a/dsh/bundle/lib/bootstrap.js +++ b/dsh/bundle/lib/bootstrap.js @@ -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 diff --git a/dsh/tests/bootstrap.test.mjs b/dsh/tests/bootstrap.test.mjs index 1af85ce..da4cd8b 100644 --- a/dsh/tests/bootstrap.test.mjs +++ b/dsh/tests/bootstrap.test.mjs @@ -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 = '' - 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', () => { diff --git a/dsh/tests/package.test.mjs b/dsh/tests/package.test.mjs index 010625d..3f25a34 100644 --- a/dsh/tests/package.test.mjs +++ b/dsh/tests/package.test.mjs @@ -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', diff --git a/dsh/tests/prestep.test.mjs b/dsh/tests/prestep.test.mjs index 5b013aa..972275c 100644 --- a/dsh/tests/prestep.test.mjs +++ b/dsh/tests/prestep.test.mjs @@ -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, /^/) + 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, /^/) + 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, /^/) + 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) + } +})