diff --git a/docs/superpowers/plans/2026-10-25-dsh-prestep.md b/docs/superpowers/plans/2026-10-25-dsh-prestep.md new file mode 100644 index 0000000..8737894 --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-dsh-prestep.md @@ -0,0 +1,83 @@ +# dsh-prestep Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use the `sdd` skill (DSH). Steps use checkbox syntax. + +**Goal:** Портировать SessionStart-хуки harness-check и role-hint в DSH как неблокирующие agent/pre-step инъекции + закрыть follow-ups #62 (T2.1, T3.4, O-2). + +**Architecture:** `dsh/bundle/lib/prestep.js` — чистые функции (detectAdoption с upward-walk, detectRole по ключевым словам первого пользовательского сообщения, тексты инъекций); общий хелпер видимости `isInjectionVisible` (рефакторинг из isBootstrapVisible); wiring в index.js (конфиг harnessCheck/roleHint, гейт — `typeof ctx.on === 'function'`). + +**Tech Stack:** Node builtins; тесты node:test; bash для install.sh-правки. + +**Spec:** `openspec/changes/dsh-prestep/` (proposal.md + design.md, D-1…D-10). + +**Models:** Task 1, 3, 4 — deepseek-flash; Task 2 — deepseek-v4-pro; ревью — v4-pro. + +## Global Constraints +- Node builtins only; package.test.mjs enforcement обновляется для новых файлов. +- Инъекции неблокирующие; reject/abort — пропуск; сабагенты — пропуск (как bootstrap). +- Маркер ``; общая видимость через isInjectionVisible. +- Роль-ключевики ровно из AGENTS.md (admin: jira, гант, спринт; qa: стенд, прогон, скриншот, макет; analyst: тз, oq, спека, методика; dev: срез, issue, pr, тест, миграция); `pr` — отдельным словом. +- Вопрос с четырьмя вариантами — текст из design.md D-7. +- Префикс инъекций `[dsh-harness]` (D-10; фильтр знает и `[claude-harness]`). +- CC-сторона (hooks/) не трогается. + +## Review Focus +1. Инъекции бандла не принимаются за первое пользовательское сообщение (фильтр маркеров). +2. Автономный раунд без нового сообщения — role-hint не стреляет. +3. cwd вне git-репозитория — harness-check молчит. +4. Подкаталог репозитория (cwd = /dsh) — upward-walk находит корень, ложной подсказки нет. +5. bootstrap:false — инъекции живут (гейт на ctx.on, не на bootstrapOk). + +--- + +### Task 1: prestep.js — чистые функции + +**Files:** Create `dsh/bundle/lib/prestep.js`, `dsh/tests/prestep.test.mjs`. + +**Interfaces:** +- `detectAdoption(cwd) -> {adopted:boolean, root:string|null, missing:string[]}` — upward-walk: ближайший каталог с `.git` (или root ФС); сигналы: `openspec/`, `.codegraph/`, `## OpenSpec и superpowers` в `CLAUDE.md` (без `.claude/code-paths`, D-9); вне git → `{adopted:true, missing:[]}` (молчим). +- `buildHarnessCheckText(root, missing) -> string` — DSH-нативный текст (D-9): `[dsh-harness] Проект не подключён к обвязке: нет: … . Предложи владельцу настройку: openspec init --tools agents, codegraph init и секцию «OpenSpec и superpowers» в CLAUDE.md.`. +- `detectRole(firstUserText) -> 'dev'|'admin'|'analyst'|'qa'|null`. +- `buildRoleHintText(role|null) -> string` — `[dsh-harness] Роль сессии: . Вызови skill role. Если не так — скажи.` или вопрос с четырьмя вариантами (D-7). +- `isHarnessMessage(text) -> boolean` — текст начинается с ` +``` + ## Tests ```bash @@ -153,6 +170,7 @@ dsh/ lib/guards.js DSH tool call -> claude-harness hook JSON lib/gates.js approval gates A/B/C and approval.md/plan io (pure) lib/bootstrap.js superpowers skill resolution, marker, dedupe + lib/prestep.js harness-check/role-hint detection and texts (pure) lib/role.js role layers and text (pure) lib/selfcheck.js bundle contract self-check (pure) dsh-tools.md superpowers tool mapping @@ -190,6 +208,8 @@ repository layout: selfCheck: true # activation check of the DSH contract; not-ok is logged checkCommand: true # register the dsh-harness-check command bootstrapSubagents: false + harnessCheck: true # pre-step: unadopted project -> setup steps (non-blocking) + roleHint: true # pre-step: first user message -> role hint or question hooksDir: '' # empty -> bundle/hooks -> ../../hooks rolesDir: '' # empty -> ~/.dsh/roles, legacy ~/.claude/roles, repo roles/ projectRolesDir: '' # empty -> /.claude/roles @@ -240,6 +260,22 @@ repository layout: skill and `openspec/changes/dsh-sdd/`. - OpenSpec-скиллы проект получает сам (`openspec init --tools agents` или `--openspec`); обвязка ставит их только в проект, не в `~/.dsh`. +- Pre-step injections (`harness-check`, `role-hint`) are active only in a session + with the reinstalled bundle (D-13): an old install has neither the texts nor the + wiring, and a session in an unadopted project stays silent. +- `role-hint` matches keywords as substrings, so a word form that changes the stem + does not match: «миграцию»/«миграции» do not hit `миграция`, «спеку» does not + hit `спека`. Forms that contain the keyword whole («теста», «issue-трекер») do + match; `pr` matches only as a whole word, so `preview` is not dev. +- Injection dedupe is per content (marker = hash of the text), not "exactly once + per session": a later step whose first user message carries no keyword adds the + four-variant question even after an earlier role hint. Both texts stay in the + session and the model follows the one that fits; this is the declared contract + (D-11), not a defect. +- `harness-check` is a hint, never adoption: it writes nothing, and there is no + DSH port of `adopt-project.sh`; the owner runs `openspec init --tools agents`, + `codegraph init`, and adds the `## OpenSpec и superpowers` section to + `CLAUDE.md`. - The bundle self-checks the DSH contract it depends on at activation: `ctx.tools.guard`, `ctx.on` for `agent/pre-step`, `ctx.inject` for commands, `workingDirectory.get`. Each missing service is logged as diff --git a/dsh/bundle/dsh-tools.md b/dsh/bundle/dsh-tools.md index e786051..c4f011a 100644 --- a/dsh/bundle/dsh-tools.md +++ b/dsh/bundle/dsh-tools.md @@ -21,6 +21,7 @@ On DeepSeek Harness these actions resolve to the tools below. | Load a skill | `skill` | | Present a deliverable | `present` | | Approve a plan | `exit_plan_mode` | +| Request an approval | `request_approval {change_id, kind}` — the model initiates it; the owner clicks Approve/Cancel | | Change directory | `working_directory` | | Create a worktree | `create_worktree` when the bundle is enabled, otherwise `bash` with `git worktree` | @@ -42,5 +43,10 @@ On DeepSeek Harness these actions resolve to the tools below. working directory. The `CLAUDE.md` files of this harness need no rewrite. - **`todo_write` is the task-tracking action.** Older `TodoWrite` references mean this tool. +- **An approval is the owner's click, not the model's claim.** + `request_approval({change_id, kind})` starts the flow: the harness writes the + approval marker only after the owner answers Approve; Cancel writes nothing. + Never treat "I asked" or "the tool was called" as approval. `/approve` in the + Claude Code text is the same idea through a slash command. - **Plan mode is a real mode.** Use `exit_plan_mode` to submit a plan; prose agreement does not leave plan mode. diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index 72d0690..2324b29 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`. * @@ -34,9 +35,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' @@ -65,10 +74,11 @@ export function resolveRolesDir(bundleDir, configured, home = homedir()) { function isSubagent(agent) { - if (!agent) return false - if (agent.parent || agent.parentAgent || agent.parentSessionId) return true - if (typeof agent.depth === 'number' && agent.depth > 0) return true - return false + return Boolean( + agent?.session?.header?.delegationDepth > 0 || + agent?.session?.header?.origin === 'subagent' || + agent?.options?.subagentDepth > 0, + ) } function sessionCwd(workingDirectory, agent) { @@ -187,6 +197,8 @@ const DEFAULTS = { approvalGates: true, bootstrap: true, bootstrapSubagents: false, + harnessCheck: true, + roleHint: true, roleCommand: true, selfCheck: true, checkCommand: true, @@ -257,31 +269,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/bundle/lib/prestep.js b/dsh/bundle/lib/prestep.js new file mode 100644 index 0000000..886f4a8 --- /dev/null +++ b/dsh/bundle/lib/prestep.js @@ -0,0 +1,164 @@ +/** + * Pre-step injections of the DSH harness. + * + * Claude Code runs two SessionStart hooks: `harness-check` (an unadopted project + * is offered the setup steps) and `role-hint` (the session title hints the + * role). DSH has no shell hook layer, so both are injected once per session + * from `agent/pre-step`, with the same marker machinery as the bootstrap. + * + * This module is pure: fs reads only, no ctx services, no spawn. The wiring + * decides when to call it. + */ + +import { createHash } from 'node:crypto' +import { readFileSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' + +const SECTION_LABEL = 'секция «OpenSpec и superpowers» в CLAUDE.md' +const SECTION_RE = /^##[ \t]+OpenSpec и superpowers[ \t]*\r?$/m + +/** Project ownership signals, in the order they are reported. */ +const SIGNALS = [ + { kind: 'dir', name: 'openspec', label: 'openspec/' }, + { kind: 'dir', name: '.codegraph', label: '.codegraph/' }, + { kind: 'section', name: 'CLAUDE.md', label: SECTION_LABEL }, +] + +/** Keyword classes, highest priority first; the first matching class wins. */ +const ROLE_KEYWORDS = [ + ['admin', ['jira', 'гант', 'спринт']], + ['qa', ['стенд', 'прогон', 'скриншот', 'макет']], + ['analyst', ['тз', 'oq', 'спека', 'методика']], + ['dev', ['срез', 'issue', 'pr', 'тест', 'миграция']], +] + +/** `pr` is too short for a substring match: it must be a whole word. */ +const PR_WORD = /\bpr\b/u + +function exists(path) { + try { + statSync(path) + return true + } catch { + return false + } +} + +function isDirectory(path) { + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +function readText(path) { + try { + return readFileSync(path, 'utf8') + } catch { + return undefined + } +} + +function hasSection(text) { + return typeof text === 'string' && SECTION_RE.test(text) +} + +function findRepoRoot(cwd) { + let dir = resolve(cwd || '.') + for (;;) { + if (exists(join(dir, '.git'))) return dir + const parent = dirname(dir) + if (parent === dir) return null + dir = parent + } +} + +/** + * Whether the project around `cwd` is wired into the harness. + * + * Walks up to the nearest `.git` (directory or file — worktrees use a file). + * Outside any repository the answer is "adopted" so the check stays silent. + * @param {string} cwd - session working directory + * @returns {{adopted: boolean, root: string|null, missing: string[]}} + */ +export function detectAdoption(cwd) { + const root = findRepoRoot(cwd) + if (root === null) return { adopted: true, root: null, missing: [] } + + const missing = [] + for (const signal of SIGNALS) { + const present = + signal.kind === 'dir' + ? isDirectory(join(root, signal.name)) + : hasSection(readText(join(root, signal.name))) + if (!present) missing.push(signal.label) + } + return { adopted: missing.length === 0, root, missing } +} + +/** Non-blocking harness-check text: what is missing and how to set it up. */ +export function buildHarnessCheckText(root, missing) { + const absent = Array.isArray(missing) ? missing : [] + return [ + `[dsh-harness] Проект ${root} не подключён к обвязке: нет: ${absent.join('; ')}.`, + 'Предложи владельцу настройку: openspec init --tools agents, codegraph init и секцию «OpenSpec и superpowers» в CLAUDE.md.', + ].join(' ') +} + +function matchesKeyword(text, keyword) { + return keyword === 'pr' ? PR_WORD.test(text) : text.includes(keyword) +} + +/** + * Guess the session role from the first user message. + * + * Keywords are matched case-insensitively; Russian keywords, `jira`, `issue`, + * `oq`, and `тз` are substrings, `pr` is a whole word only. + * @param {string} firstUserText + * @returns {'dev'|'admin'|'analyst'|'qa'|null} + */ +export function detectRole(firstUserText) { + if (typeof firstUserText !== 'string' || firstUserText.trim() === '') return null + const text = firstUserText.toLowerCase() + for (const [role, keywords] of ROLE_KEYWORDS) { + if (keywords.some((keyword) => matchesKeyword(text, keyword))) return role + } + return null +} + +/** Role-hint text: a concrete role, or one question with the four variants. */ +export function buildRoleHintText(role) { + if (role) return `[dsh-harness] Роль сессии: ${role}. Вызови skill role. Если не так — скажи.` + return ( + '[dsh-harness] Роль сессии неясна. Вызови skill role: dev — код, issue, PR, тест, миграция; ' + + 'admin — Jira, Гант, спринт; analyst — ТЗ, OQ, спека, методика; qa — стенд, прогон, скриншот, макет. ' + + 'Задай владельцу один вопрос с этими четырьмя вариантами.' + ) +} + +/** + * Whether a message was produced by the harness itself (bootstrap, a previous + * injection, or the legacy Claude Code prefix). Such a message is not the + * "first user message" for the role hint. + */ +export function isHarnessMessage(text) { + if (typeof text !== 'string') return false + return ( + text.startsWith('` + return { marker, body: `${marker}\n${value}` } +} diff --git a/dsh/install.sh b/dsh/install.sh index 5ce3d81..f9f9cf8 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -21,6 +21,14 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" BUNDLE="$ROOT/dsh/bundle" +# O-2: remember whether DSH_HOME came from the environment. Isolating HOME +# alone is not enough: an explicit DSH_HOME still writes outside the isolated +# tree (a temp HOME with the real DSH_HOME, typically a test or a detached +# shell), so the install steps warn about that mixed state. +DSH_HOME_FROM_ENV=0 +if [ -n "${DSH_HOME:-}" ]; then + DSH_HOME_FROM_ENV=1 +fi DSH_HOME="${DSH_HOME:-$HOME/.dsh}" DSH_SUPERPOWERS_REF="${DSH_SUPERPOWERS_REF:-v7.0.1}" DSH_SUPERPOWERS_REPO="${DSH_SUPERPOWERS_REPO:-https://github.com/obra/superpowers}" @@ -364,6 +372,7 @@ check() { } install_global() { + warn_mixed_dsh_home mkdir -p "$DSH_HOME" local target="$DSH_HOME/AGENTS.md" if [ -f "$target" ]; then @@ -498,7 +507,21 @@ copy_skills_from() { echo "copied $count superpowers skills into $DSH_HOME/skills (source: $label)" } +# O-2: an explicit DSH_HOME that already exists while $HOME is another tree is +# the mixed case: the install writes into the existing DSH_HOME instead of the +# isolated one. Warn once per run and continue — the target is valid, only the +# HOME/DSH_HOME pair is not isolated together. +warn_mixed_dsh_home() { + if [ "$DSH_HOME_FROM_ENV" != 1 ]; then return 0; fi + if [ "$DSH_HOME" = "$HOME/.dsh" ]; then return 0; fi + if [ ! -d "$DSH_HOME" ]; then return 0; fi + if [ "${DSH_HOME_WARNED:-0}" = 1 ]; then return 0; fi + DSH_HOME_WARNED=1 + echo "WARN: DSH_HOME=$DSH_HOME points to an existing directory with a non-standard HOME; both HOME and DSH_HOME must be isolated together" >&2 +} + install_skills() { + warn_mixed_dsh_home mkdir -p "$DSH_HOME/skills" # Explicit source: sync it directly, no network, no pin enforcement. @@ -576,6 +599,7 @@ install_local_skills() { } install_roles() { + warn_mixed_dsh_home mkdir -p "$DSH_HOME/roles" local src="$ROOT/roles" local count=0 diff --git a/dsh/skills/sdd/SKILL.md b/dsh/skills/sdd/SKILL.md index e9fefab..c86da11 100644 --- a/dsh/skills/sdd/SKILL.md +++ b/dsh/skills/sdd/SKILL.md @@ -9,7 +9,7 @@ description: Use when executing an implementation plan in DeepSeek Harness (DSH) только в DSH-сессиях: у Claude Code синхронный `Task`, у DSH — асинхронный `subagent`. Это **не паритет** с Claude-SDD и не имитация: протокол построен на том, что DSH умеет (уведомление о завершении, `send_message`, ledger), а не -на том, чего у него нет (возврат результата из вызова инструмента). +на том, чего у него нет. ## Когда применять @@ -62,6 +62,9 @@ overlay ребёнок наследует модель сессии. 6. Замеченное расхождение с брифом или спекой — это `DONE_WITH_CONCERNS`, даже если задача формально выполнена: ревью его проверит, контроллер — решит. +**Порядок TDD.** Шаг «напиши тест и убедись, что он падает (RED)» обязателен +ДО кода; реализация без RED — `DONE_WITH_CONCERNS`, ревью это проверяет. + ## Ожидание без блокировки `subagent` возвращает id и сразу завершается; результата в значении @@ -135,9 +138,8 @@ ledger лежит в git-ignored scratch (после `git clean -fdx` восст фиксирует это наблюдением `O-n`. `model` нельзя передать один, «унаследовав» провайдера: DSH требует `provider` и `model` вместе и на односторонний маршрут отвечает «child LLM provider and model must be supplied together». -Эта ошибка означает, что в вызове не хватает `provider` (в прогоне #41 так -случилось дважды), а не сбой валидации: проверь аргументы и повтори вызов, -передав оба поля. +Эта ошибка означает, что в вызове не хватает `provider`, а не сбой валидации: +проверь аргументы и повтори вызов, передав оба поля. ## Отличие от Claude-SDD 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/install.test.mjs b/dsh/tests/install.test.mjs index 373817f..d43c708 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -54,6 +54,9 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', () assert.match(sddText, /name: sdd/) assert.match(sddText, /\/approve/, 'the sdd skill must check the plan approval marker before dispatch') assert.match(sddText, /approval\.md/, 'the sdd skill must name the approval marker file') + // T3.4: TDD order is part of the dispatch protocol: RED before the code. + assert.match(sddText, /падает \(RED\)/, 'the sdd skill must require a failing test (RED) before the code') + assert.match(sddText, /реализация без RED — `DONE_WITH_CONCERNS`/, 'a missing RED must degrade the report status') for (const role of ['dev', 'admin', 'analyst', 'qa']) { assert.ok(existsSync(join(dshHome, 'roles', `${role}.md`)), `missing ${role}.md`) @@ -638,6 +641,57 @@ test('--global installs DSH role paths and explains the instruction format', () } }) +test('--skills and --roles warn when DSH_HOME exists while HOME is isolated (O-2)', () => { + const work = tempDir() + try { + const home = join(work, 'home') + // An existing DSH_HOME outside the isolated HOME is the mixed state: the + // install would write outside the sandbox. The warning must not abort. + const sharedDsh = join(work, 'shared-dsh') + mkdirSync(home, { recursive: true }) + mkdirSync(sharedDsh, { recursive: true }) + const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers')) + const warn = `WARN: DSH_HOME=${sharedDsh} points to an existing directory with a non-standard HOME; both HOME and DSH_HOME must be isolated together` + + const skills = runInstaller(home, ['--skills', '--superpowers-from', src], { DSH_HOME: sharedDsh }) + const skillsOut = skills.stdout + skills.stderr + assert.equal(skills.status, 0, skillsOut) + assert.ok(skillsOut.includes(warn), skillsOut) + assert.ok( + existsSync(join(sharedDsh, 'skills', 'using-superpowers', 'SKILL.md')), + 'the install still runs after the warning', + ) + + const roles = runInstaller(home, ['--roles'], { DSH_HOME: sharedDsh }) + const rolesOut = roles.stdout + roles.stderr + assert.equal(roles.status, 0, rolesOut) + assert.ok(rolesOut.includes(warn), rolesOut) + assert.ok(existsSync(join(sharedDsh, 'roles', 'dev.md')), 'roles land in the warned DSH_HOME') + + // --global overwrites AGENTS.md (after a backup): the destructive step must + // warn too (T3.1). + const global = runInstaller(home, ['--global'], { DSH_HOME: sharedDsh }) + const globalOut = global.stdout + global.stderr + assert.equal(global.status, 0, globalOut) + assert.ok(globalOut.includes(warn), globalOut) + assert.ok(existsSync(join(sharedDsh, 'AGENTS.md')), 'AGENTS.md is installed after the warning') + + // One run touching several steps warns exactly once (T3.2). + const combined = runInstaller(home, ['--skills', '--roles', '--superpowers-from', src], { DSH_HOME: sharedDsh }) + const combinedOut = combined.stdout + combined.stderr + assert.equal(combined.status, 0, combinedOut) + assert.equal((combinedOut.match(/WARN: DSH_HOME=/g) ?? []).length, 1, combinedOut) + + // Isolated together ($DSH_HOME=$HOME/.dsh): no warning. + const normal = runInstaller(home, ['--skills', '--superpowers-from', src]) + const normalOut = normal.stdout + normal.stderr + assert.equal(normal.status, 0, normalOut) + assert.doesNotMatch(normalOut, /WARN: DSH_HOME=/) + } finally { + cleanup(work) + } +}) + test('a legacy cache without using-superpowers is not a source', () => { const home = tempDir() try { diff --git a/dsh/tests/openspec.test.mjs b/dsh/tests/openspec.test.mjs index ad71c8b..eb940cd 100644 --- a/dsh/tests/openspec.test.mjs +++ b/dsh/tests/openspec.test.mjs @@ -19,6 +19,32 @@ const NAMES = [ 'openspec-update-change', ] +// #62 T2.1/M-2: a negative test must not inherit the host PATH — on Linux a +// global npm prefix puts openspec into /usr/bin, so a fixed system PATH is not +// openspec-free. The child gets a guard dir holding symlinks to the binaries +// the script needs and nothing else. `bash` is addressed by absolute path +// because libuv resolves the command with the child's restricted PATH. +function which(name) { + const out = spawnSync('bash', ['-c', `command -v ${name}`], { encoding: 'utf8' }) + return out.stdout.trim() +} + +const BASH = which('bash') || '/bin/bash' + +function isolatedPath(home) { + const bin = join(home, 'guard-bin') + mkdirSync(bin) + // The toolchain --check verifies (python3, git, node) plus the POSIX tools + // install.sh calls on this path (dirname for ROOT, head for versions, the + // ls/sort/tail legacy lookup). openspec is deliberately absent. + for (const name of ['python3', 'git', 'node', 'dirname', 'head', 'ls', 'sort', 'tail']) { + const target = which(name) + assert.ok(target, `cannot resolve ${name}: the isolated PATH would be incomplete`) + symlinkSync(target, join(bin, name)) + } + return bin +} + test('repository ships six agents skills for DSH', () => { assert.equal(readFileSync(join(agentsSkills, '.openspec-target'), 'utf8').trim(), 'agents') for (const name of NAMES) { @@ -49,17 +75,16 @@ test('install.sh --check reports the openspec CLI and the agents skills', () => test('install.sh --check warns but does not fail without the openspec CLI', () => { const home = tempDir() try { - const bin = join(home, 'bin') - mkdirSync(bin, { recursive: true }) - symlinkSync(process.execPath, join(bin, 'node')) - // openspec ставится в /opt/homebrew/bin; здесь только системные каталоги — CLI не найден + // #62 T2.1: the guard dir is the whole PATH, so a host openspec CLI (e.g. + // /usr/bin on Linux) can never leak into this negative test. + const bin = isolatedPath(home) const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), - PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`, + PATH: bin, } - const run = spawnSync('bash', [script, '--check'], { env, encoding: 'utf8' }) + const run = spawnSync(BASH, [script, '--check'], { env, encoding: 'utf8' }) assert.equal(run.status, 0, run.stderr) assert.match(run.stdout + run.stderr, /WARN: openspec not found/) } finally { @@ -134,17 +159,15 @@ test('--openspec fails without the openspec CLI', () => { try { const project = join(home, 'project') mkdirSync(join(project, 'openspec'), { recursive: true }) - const bin = join(home, 'bin') - mkdirSync(bin, { recursive: true }) - symlinkSync(process.execPath, join(bin, 'node')) - // openspec ставится в /opt/homebrew/bin; здесь только системные каталоги — CLI не найден + // #62 T2.1: same guard-dir isolation as the --check negative test. + const bin = isolatedPath(home) const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), - PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`, + PATH: bin, } - const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' }) + const run = spawnSync(BASH, [script, '--openspec'], { cwd: project, env, encoding: 'utf8' }) assert.notEqual(run.status, 0) assert.match(run.stdout + run.stderr, /FAIL: openspec CLI not found/) } finally { diff --git a/dsh/tests/package.test.mjs b/dsh/tests/package.test.mjs index 6ea27f6..c8f846f 100644 --- a/dsh/tests/package.test.mjs +++ b/dsh/tests/package.test.mjs @@ -66,6 +66,7 @@ test('runtime files have no external @deepseek-ai imports', () => { 'lib/gates.js', 'lib/approval.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 new file mode 100644 index 0000000..81ac083 --- /dev/null +++ b/dsh/tests/prestep.test.mjs @@ -0,0 +1,452 @@ +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, + detectAdoption, + detectRole, + isHarnessMessage, + makePrestepMarker, +} from '../bundle/lib/prestep.js' +import { cleanup, tempDir, write } from './helpers.mjs' + +const SECTION = '## OpenSpec и superpowers\n\nСекция подключения.\n' + +/** + * A throwaway directory tree that looks like a harness-adopted repository: + * `.git/`, `openspec/`, `.codegraph/`, and the CLAUDE.md section. + */ +function adoptedRepo() { + const repo = tempDir('dsh-harness-adopted-') + write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n') + write(join(repo, 'openspec', 'specs', '.keep'), '') + write(join(repo, '.codegraph', '.keep'), '') + write(join(repo, 'CLAUDE.md'), `# Проект\n\n${SECTION}`) + return repo +} + +// --------------------------------------------------------------------------- +// detectAdoption +// --------------------------------------------------------------------------- + +test('detectAdoption: adopted repo reports adopted with no missing signals', () => { + const repo = adoptedRepo() + try { + assert.deepEqual(detectAdoption(repo), { adopted: true, root: repo, missing: [] }) + } finally { + cleanup(repo) + } +}) + +test('detectAdoption: repo without openspec/ and the CLAUDE.md section is not adopted', () => { + const repo = tempDir('dsh-harness-partial-') + try { + write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n') + write(join(repo, '.codegraph', '.keep'), '') + write(join(repo, 'CLAUDE.md'), '# Проект без секции\n') + + assert.deepEqual(detectAdoption(repo), { + adopted: false, + root: repo, + missing: ['openspec/', 'секция «OpenSpec и superpowers» в CLAUDE.md'], + }) + } finally { + cleanup(repo) + } +}) + +test('detectAdoption: cwd in a subdirectory walks up to the repo root', () => { + const repo = adoptedRepo() + try { + const subdir = join(repo, 'dsh', 'bundle', 'lib') + write(join(subdir, 'module.js'), 'export {}\n') + + assert.deepEqual(detectAdoption(subdir), { adopted: true, root: repo, missing: [] }) + } finally { + cleanup(repo) + } +}) + +test('detectAdoption: cwd outside any git repo stays silent', () => { + const outside = tempDir('dsh-harness-outside-') + try { + assert.deepEqual(detectAdoption(outside), { adopted: true, root: null, missing: [] }) + } finally { + cleanup(outside) + } +}) + +test('detectAdoption: a .git file (worktree checkout) is a repo root too', () => { + const repo = tempDir('dsh-harness-worktree-') + try { + write(join(repo, '.git'), `gitdir: ${join(repo, '..', 'main', '.git', 'worktrees', 'x')}\n`) + write(join(repo, 'openspec', 'specs', '.keep'), '') + write(join(repo, '.codegraph', '.keep'), '') + write(join(repo, 'CLAUDE.md'), SECTION) + + assert.deepEqual(detectAdoption(join(repo, 'openspec')), { + adopted: true, + root: repo, + missing: [], + }) + } finally { + cleanup(repo) + } +}) + +// --------------------------------------------------------------------------- +// buildHarnessCheckText +// --------------------------------------------------------------------------- + +test('buildHarnessCheckText names the root, missing signals, and next steps', () => { + const text = buildHarnessCheckText('/tmp/proj', [ + 'openspec/', + 'секция «OpenSpec и superpowers» в CLAUDE.md', + ]) + assert.match( + text, + /^\[dsh-harness\] Проект \/tmp\/proj не подключён к обвязке: нет: openspec\/; секция «OpenSpec и superpowers» в CLAUDE\.md\. /, + ) + assert.match(text, /openspec init --tools agents/) + assert.match(text, /codegraph init/) + assert.match(text, /Предложи владельцу настройку/) +}) + +// --------------------------------------------------------------------------- +// detectRole +// --------------------------------------------------------------------------- + +test('detectRole: admin keywords (jira, гант, спринт)', () => { + assert.equal(detectRole('Разбери jira-задачу'), 'admin') + assert.equal(detectRole('Покажи гант проекта'), 'admin') + assert.equal(detectRole('Новый спринт начинается'), 'admin') + assert.equal(detectRole('JIRA-тикет'), 'admin') +}) + +test('detectRole: qa keywords (стенд, прогон, скриншот, макет)', () => { + assert.equal(detectRole('Подними стенд'), 'qa') + assert.equal(detectRole('Нужен прогон сценария'), 'qa') + assert.equal(detectRole('Сделай скриншот'), 'qa') + assert.equal(detectRole('Сверь с макетом'), 'qa') +}) + +test('detectRole: analyst keywords (тз, oq, спека, методика)', () => { + assert.equal(detectRole('Оформи ТЗ'), 'analyst') + assert.equal(detectRole('Закрой OQ'), 'analyst') + assert.equal(detectRole('Спека готова'), 'analyst') + assert.equal(detectRole('Нужна методика'), 'analyst') +}) + +test('detectRole: dev keywords (срез, issue, pr, тест, миграция)', () => { + assert.equal(detectRole('Сними срез метрик'), 'dev') + assert.equal(detectRole('Открой issue'), 'dev') + assert.equal(detectRole('Проверь тест'), 'dev') + assert.equal(detectRole('Нужна миграция'), 'dev') +}) + +test('detectRole: pr matches only as a whole word', () => { + assert.equal(detectRole('сделай PR'), 'dev') + assert.equal(detectRole('просто посмотри'), null) + assert.equal(detectRole('preview the branch'), null) +}) + +test('detectRole: priority is admin before qa, analyst, and dev', () => { + assert.equal(detectRole('спринт и тест'), 'admin') + assert.equal(detectRole('прогон и issue'), 'qa') + assert.equal(detectRole('спека и тест'), 'analyst') +}) + +test('detectRole: no keyword or empty input yields null', () => { + assert.equal(detectRole('Привет, как дела?'), null) + assert.equal(detectRole(''), null) +}) + +// --------------------------------------------------------------------------- +// buildRoleHintText +// --------------------------------------------------------------------------- + +test('buildRoleHintText: known role names it and asks to load the skill', () => { + const text = buildRoleHintText('qa') + assert.ok(text.startsWith('[dsh-harness]')) + assert.match(text, /Роль сессии: qa\./) + assert.match(text, /Вызови skill role\./) +}) + +test('buildRoleHintText: null asks one question with all four roles', () => { + const text = buildRoleHintText(null) + assert.ok(text.startsWith('[dsh-harness]')) + assert.match(text, /Роль сессии неясна\./) + for (const role of ['dev', 'admin', 'analyst', 'qa']) { + assert.ok(text.includes(`${role} —`), `${role} variant is listed`) + } + assert.match(text, /четырьмя вариантами/) +}) + +// --------------------------------------------------------------------------- +// isHarnessMessage +// --------------------------------------------------------------------------- + +test('isHarnessMessage: bootstrap, DSH, and legacy claude-harness texts are harness messages', () => { + assert.equal(isHarnessMessage('\nYou have superpowers.'), true) + assert.equal(isHarnessMessage('[dsh-harness] Роль сессии: dev.'), true) + assert.equal(isHarnessMessage('[claude-harness] Роль сессии: dev'), true) +}) + +test('isHarnessMessage: plain user text is not a harness message', () => { + assert.equal(isHarnessMessage('Привет! Разбери задачу.'), false) + assert.equal(isHarnessMessage(''), false) +}) + +// --------------------------------------------------------------------------- +// makePrestepMarker +// --------------------------------------------------------------------------- + +test('makePrestepMarker: marker format, body prefix, determinism, uniqueness', () => { + const { marker, body } = makePrestepMarker('Привет') + assert.match(marker, /^$/) + assert.ok(body.startsWith(marker)) + assert.equal(body, `${marker}\nПривет`) + + assert.equal(makePrestepMarker('Привет').marker, marker, 'same text, same marker') + 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) + } +}) + +// --------------------------------------------------------------------------- +// Subagent skip (T2.1): real runtime delegation signals +// --------------------------------------------------------------------------- + +/** + * Agent shapes that the runtime uses for a delegated step: the session header + * carries `origin: 'subagent'` (or a positive delegationDepth / subagentDepth + * as a robust fallback). + */ +const SUBAGENT_AGENTS = [ + { session: { header: { delegationDepth: 1 } } }, + { session: { header: { origin: 'subagent', parentSession: 's0' } } }, + { options: { subagentDepth: 1 } }, +] + +test('apply: a delegated pre-step injects nothing (bootstrap, harness-check, role-hint)', async () => { + const repo = nonAdoptedRepo() + const skill = tempDir('dsh-harness-skill-') + try { + write(join(skill, 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n') + const harness = prestepHarness({ bootstrapSkillPath: join(skill, 'SKILL.md') }, repo) + + for (const agent of SUBAGENT_AGENTS) { + const decision = await harness.run({ agent, messages: [userMessage('Напиши тест')] }) + assert.equal(decision.messages.length, 1, `no injection for ${JSON.stringify(agent)}`) + assert.equal(messageText(decision.messages[0]), 'Напиши тест') + } + } finally { + cleanup(repo) + cleanup(skill) + } +}) + +test('apply: a plain pre-step still injects bootstrap, harness-check, and role-hint', async () => { + const repo = nonAdoptedRepo() + const skill = tempDir('dsh-harness-skill-') + try { + write(join(skill, 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n') + const harness = prestepHarness({ bootstrapSkillPath: join(skill, 'SKILL.md') }, repo) + const decision = await harness.run({ agent: { session: 's1' }, messages: [userMessage('Напиши тест')] }) + + assert.equal(decision.messages.length, 4) + assert.equal(messageText(decision.messages[0]), 'Напиши тест') + const texts = decision.messages.map(messageText) + assert.ok(texts.some((text) => text.includes('DSH-HARNESS-BOOTSTRAP')), 'bootstrap injected') + assert.ok(texts.some((text) => text.includes('не подключён к обвязке')), 'harness-check injected') + assert.ok(texts.some((text) => text.includes('Роль сессии: dev')), 'role-hint injected') + } finally { + cleanup(repo) + cleanup(skill) + } +}) + +test('apply: a forked session (parentSession without origin) still injects all three', async () => { + const repo = nonAdoptedRepo() + const skill = tempDir('dsh-harness-skill-') + try { + write(join(skill, 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n') + const harness = prestepHarness({ bootstrapSkillPath: join(skill, 'SKILL.md') }, repo) + const forkAgent = { session: { header: { parentSession: 's0', isSeeded: true } } } + const decision = await harness.run({ agent: forkAgent, messages: [userMessage('Напиши тест')] }) + + assert.equal(decision.messages.length, 4, 'forks are not subagents: all three injections expected') + const texts = decision.messages.map(messageText) + assert.ok(texts.some((text) => text.includes('DSH-HARNESS-BOOTSTRAP')), 'bootstrap injected') + assert.ok(texts.some((text) => text.includes('не подключён к обвязке')), 'harness-check injected') + assert.ok(texts.some((text) => text.includes('Роль сессии: dev')), 'role-hint injected') + } finally { + cleanup(repo) + cleanup(skill) + } +}) diff --git a/openspec/changes/dsh-prestep/.openspec.yaml b/openspec/changes/dsh-prestep/.openspec.yaml new file mode 100644 index 0000000..7ccf7c3 --- /dev/null +++ b/openspec/changes/dsh-prestep/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-25 diff --git a/openspec/changes/dsh-prestep/approval.md b/openspec/changes/dsh-prestep/approval.md new file mode 100644 index 0000000..c9075df --- /dev/null +++ b/openspec/changes/dsh-prestep/approval.md @@ -0,0 +1,8 @@ +design: + by: owner + at: 2026-10-25T06:34:43.041Z + +plan: + by: owner + at: 2026-10-25T07:34:55.231Z + sha256: dd03b3ad25b29024bbca9b0957a07020b316bd52a1aaa9e81df69ff17fd975e2 diff --git a/openspec/changes/dsh-prestep/builder-check.md b/openspec/changes/dsh-prestep/builder-check.md new file mode 100644 index 0000000..6229ff5 --- /dev/null +++ b/openspec/changes/dsh-prestep/builder-check.md @@ -0,0 +1,19 @@ +# Builder-check: dsh-prestep + +Дата: 25.10.2026. Свежий субагент (v4-pro). Три вопроса — все адьюдицированы +контроллером в design.md: + +1. Триггер role-hint видит собственные инъекции (bootstrap/harness-check — + user-source сообщения) → добавлен фильтр: сообщение не считается первым, + если начинается с `` и тот же механизм видимости, что у +bootstrap (`isBootstrapVisible` переименован в общий +`isInjectionVisible(agent, messages, marker)`). +Порядок в pre-step: другие инъекции → harness-check → role-hint (обе +неблокирующие; reject/abort — пропуск; сабагенты — пропуск, как +bootstrap). + +### harness-check + +- Сигнал подключения (проектные, без CC-артефактов): upward-walk от + sessionCwd до корня ФС: ближайший каталог с `.git` (или без него до + упора) — «корень проекта»; сигналы: `openspec/`, `.codegraph/`, + секция `## OpenSpec и superpowers` в `CLAUDE.md`. Всё на месте — + молчим. Вне git-репозитория (`.git` не найден до корня) — пропуск. + Чего-то нет — неблокирующая инъекция: + «[dsh-harness] Проект не подключён к обвязке: нет: … . + Предложи владельцу настройку: `openspec init --tools agents`, + `codegraph init` и секцию «OpenSpec и superpowers» в CLAUDE.md.» + (DSH-нативный текст: без `~/.claude`-путей и adopt-скрипта CC — их + порт под DSH — отдельная фича, D-9). HARNESS_AUTO_ADOPT — вне скоупа + (D-2). +- Реализация — fs-чтения (stat/readFile) по путям, без spawn git. + +### role-hint + +- Триггер: **новое** пользовательское сообщение текущего pre-step + (`decision.messages`), source.kind === 'user', И текст не начинается с + `` (формат bootstrap, не `<--`); `isBootstrapVisible` → общий `isInjectionVisible` (переименование трогает imports bootstrap.test.mjs — учтено в плане) | Одна машинерия видимости на все инъекции | Регрессия bootstrap — страхуется его тестами | +| D-7 | Текст вопроса с четырьмя вариантами сочинён в этом дизайне (в репо его не было) | Builder-check подтвердил отсутствие | Нет | +| D-8 | T3.4: в sdd-скилле предложения про TDD-порядок нет — текст добавляется с нуля (пин в тесте), а не «усиливается» | Факт builder-check | Нет | +| D-9 | DSH-автономность (замечание владельца 25.10): в текстах и сигналах дизайна нет CC-артефактов — ни `~/.claude`-путей, ни `adopt-project.sh`, ни `.claude/code-paths`; CC-хуки harness-check.sh/role-hint.sh живут только для CC-сессий; DSH-порт adopt-скрипта — отдельная фича (non-goal этого change) | Порт должен жить самостоятельно; CC-сторона — параллельный мир | Подключение проекта в DSH остаётся ручным до порта adopt-скрипта — инъекция даёт точный список шагов | +| D-10 | Префикс DSH-инъекций — `[dsh-harness]` (не `[claude-harness]`); фильтр собственных сообщений — по нему и по `` + общий `isInjectionVisible` | реализовано (a2453e3) | `lib/prestep.js` `makePrestepMarker`, `lib/bootstrap.js` | +| D-7 | Текст вопроса с четырьмя вариантами | реализовано | `buildRoleHintText(null)`; пины в тестах | +| D-8 | TDD-текст в `sdd`-скилле добавлен с нуля | реализовано (183f8d8) | `dsh/skills/sdd/SKILL.md`, «Порядок TDD» | +| D-9 | DSH-автономность: без `~/.claude`-путей и adopt-скрипта | реализовано | тексты `lib/prestep.js` | +| D-10 | Префикс `[dsh-harness]`, фильтр обоих префиксов | реализовано | `isHarnessMessage` | +| D-11 | Дедуп по содержимому, не «ровно раз за сессию» | реализовано как факт (a2453e3) | заявлено в спеке `session-prestep`, требование «Повторная инъекция и видимость» | + +Статусы `T1.2`, `T2.2`, `T1.3` и `T1.5` (оба из #77) — в спеке-дельте +`session-prestep` и `openspec/specs/approval-gates/spec.md`; ledger — +`.superpowers/sdd/2026-10-25-dsh-prestep/progress.md`. diff --git a/openspec/changes/dsh-prestep/proposal.md b/openspec/changes/dsh-prestep/proposal.md new file mode 100644 index 0000000..0944f93 --- /dev/null +++ b/openspec/changes/dsh-prestep/proposal.md @@ -0,0 +1,64 @@ +# Proposal: dsh-prestep — harness-check и role-hint через agent/pre-step + +Issue: #43 (+#62 follow-ups). Источники: роадмап #48 (закрыт), дизайн #69, +AGENTS.md (правило первого сообщения), архив #42 (§6 приёмка). + +## Why + +У Claude Code два SessionStart-хука: `harness-check.sh` (проект не подключён +→ предложить adopt) и `role-hint.sh` (подсказка роли по заголовку сессии). +В DSH их нет (#43): сессия в неподключённом проекте молча работает без +обвязки, а выбор роли держится только на дисциплине модели (первое правило +AGENTS.md). Плюс три мелких follow-ups из #42 (#62: T2.1 — PATH в негативном +тесте, T3.4 — TDD-порядок в брифах, O-2 — изоляция HOME/DSH_HOME в брифах). + +## What Changes + +1. **harness-check как pre-step**: один раз за сессию (маркер-дедуп, как у + bootstrap) проверяем, что проект подключён (upward-walk до корня + git-проекта; сигналы — `openspec/`, `.codegraph/`, секция «OpenSpec и + superpowers» в `CLAUDE.md`); нет — неблокирующая DSH-нативная + инъекция со списком отсутствующего и шагами настройки + (`openspec init --tools agents`, `codegraph init`, секция в + `CLAUDE.md`). Без `~/.claude`-путей и CC-adopt-скрипта (D-9). + Конфиг `harnessCheck: true`. +2. **role-hint как pre-step**: на первом пользовательском сообщении — + ключевые слова из AGENTS.md (Jira/Гант/спринт → admin; стенд/прогон/ + скриншот/макет → qa; ТЗ/OQ/спека/методика → analyst; срез/issue/PR/ + тест/миграция → dev) → инъекция `[dsh-harness] Роль сессии: ` с + указанием вызвать `skill role`; нет совпадения — инъекция вопроса с + четырьмя вариантами. Дедуп — по содержимому (per-text, см. D-11). Конфиг `roleHint: true`. + Заголовка сессии в DSH нет — сигнал берём из первого сообщения + (ровно то правило, что уже написано в AGENTS.md). +3. **Follow-ups #62** (задачами этого же change): + - T2.1/M-2: негативный тест `--check` без openspec — изоляция PATH + вместо фиксированного `bin:/usr/bin:…`; + - T3.4: sdd-skill и шаблоны брифов — «шаг „тест падает“ обязателен до + кода» формулируется жёстче; + - O-2: брифы/рецепты изолируют и HOME, и DSH_HOME; `install.sh` + предупреждает, когда DSH_HOME указывает на существующий каталог при + нестандартном HOME. +4. Обе инъекции используют общую маркер/видимость-машинерию bootstrap + (выносим хелперы, поведение bootstrap не меняется). + +## Impact + +- Affected: `dsh/bundle/lib/prestep.js` (новое), `dsh/bundle/lib/bootstrap.js` + (рефакторинг хелперов), `dsh/bundle/index.js` (wiring, аддитивно), + `dsh/skills/sdd/SKILL.md`, `dsh/install.sh` (WARN O-2), + `dsh/tests/openspec.test.mjs` (T2.1), тесты prestep, спека-дельта + (capability `session-prestep`), README/PORT. +- Не трогаем: CC-сторону (хуки harness-check.sh/role-hint.sh остаются), + гейты, bootstrap-инъекцию. +- Breaking: нет (обе инъекции — неблокирующие; конфиг отключает). + +## Non-goals + +HARNESS_AUTO_ADOPT (авто-подключение) — потом, при запросе; порт +`adopt-project.sh` под DSH — отдельная фича (D-9, замечание владельца о +CC-независимости); портирование `briefing` (#45) — отдельно; анализ +заголовка сессии (в DSH его нет). + +## Открытые вопросы + +Нет — решения в design.md; владелец утверждает через `/approve dsh-prestep design`. diff --git a/openspec/changes/dsh-prestep/specs/session-prestep/spec.md b/openspec/changes/dsh-prestep/specs/session-prestep/spec.md new file mode 100644 index 0000000..56bbdfe --- /dev/null +++ b/openspec/changes/dsh-prestep/specs/session-prestep/spec.md @@ -0,0 +1,272 @@ +# Spec Delta: session-prestep + +## Purpose + +Две неблокирующие инъекции шага `agent/pre-step` в DSH-бандле: `harness-check` +(проект не подключён к обвязке → список отсутствующего и шаги настройки) и +`role-hint` (первое пользовательское сообщение шага подсказывает роль или задаёт +один вопрос с четырьмя вариантами). В Claude Code это SessionStart-хуки +`harness-check.sh` и `role-hint.sh`; в DSH слоя shell-хуков нет, поэтому та же +логика живёт в бандле и использует общую с bootstrap машинерию маркера и +видимости. Обе инъекции — текстовые, неблокирующие и отключаются конфигом. +Здесь же едут follow-ups #62 того же цикла: изоляция PATH в негативном тесте, +WARN смешанного HOME/DSH_HOME и обязательный TDD-порядок в `sdd`-скилле. +Тексты собираются модулем `dsh/bundle/lib/prestep.js` (чистые функции: только +fs-чтения, без `ctx`-сервисов и без `spawn`), а решает об инъекции +`dsh/bundle/index.js`. + +## ADDED Requirements + +### Requirement: Инъекция harness-check + +Инъекция SHALL проверять подключение проекта к обвязке и MUST оставаться +неблокирующей: при неподключённом проекте шаг продолжается, к сообщениям шага +добавляется одно пользовательское сообщение с текстом подсказки. + +Корень проекта MUST определяться upward-walk от рабочей директории сессии до +ближайшего каталога с `.git` (каталог или файл — worktree-чекаут тоже корень); +при отсутствии `.git` до корня ФС проект считается вне git и инъекции нет. +Сигналы подключения MUST быть ровно три и проверяться в этом порядке: +`openspec/` (каталог), `.codegraph/` (каталог), секция `## OpenSpec и +superpowers` в `CLAUDE.md` (строка заголовка второго уровня). Все сигналы на +месте — инъекция MUST NOT появляться; часть отсутствует — инъекция SHALL +перечислять отсутствующие в этом порядке. + +Текст инъекции MUST быть: `[dsh-harness] Проект не подключён к обвязке: +нет: <список отсутствующего через '; '>. Предложи владельцу настройку: +openspec init --tools agents, codegraph init и секцию «OpenSpec и superpowers» в +CLAUDE.md.` В тексте MUST NOT быть Claude Code-артефактов: ни путей `~/.claude`, +ни `adopt-project.sh`, ни `.claude/code-paths` (D-9). + +Инъекция SHALL управляться конфигом `harnessCheck` (по умолчанию `true`) вместе +с общим `enabled` бандла и MUST требовать только наличия `ctx.on` +(`typeof ctx.on === 'function'`, D-5), а не успеха self-check `bootstrap`: при +`bootstrap: false` harness-check обязан работать. Реализация MUST использовать +fs-чтения (`stat`/`readFile`) и MUST NOT запускать `git`. + +#### Scenario: Проект не подключён + +- **WHEN** сессия стартует в git-репозитории, где нет `openspec/` и секции в + `CLAUDE.md`, а `.codegraph/` есть +- **THEN** в сообщения шага добавляется `[dsh-harness] Проект не + подключён к обвязке: нет: openspec/; секция «OpenSpec и superpowers» в + CLAUDE.md.` и готовые команды настройки + +#### Scenario: Проект подключён + +- **WHEN** в корне проекта есть `openspec/`, `.codegraph/` и секция `## OpenSpec + и superpowers` в `CLAUDE.md` +- **THEN** harness-check молчит, сообщений не добавляется + +#### Scenario: Вне git-репозитория + +- **WHEN** рабочая директория сессии не имеет `.git` ни в одном родительском + каталоге до корня ФС +- **THEN** инъекции нет: harness-check не подсказывает настройку вне проекта + +#### Scenario: Рабочая директория в подкаталоге проекта + +- **WHEN** сессия стартует в `/dsh/bundle`, а сигналы лежат в `` +- **THEN** корнем считается ``, и текст инъекции называет `` + +#### Scenario: Отключённый bootstrap не выключает harness-check + +- **WHEN** профиль задаёт `bootstrap: false` и `harnessCheck: true` +- **THEN** harness-check по-прежнему инъецирует подсказку: гейт — `ctx.on`, а не + `bootstrapOk` + +#### Scenario: Конфиг выключает инъекцию + +- **WHEN** профиль задаёт `harnessCheck: false` +- **THEN** harness-check не инъецируется, остальные pre-step инъекции не + затрагиваются + +### Requirement: Инъекция role-hint + +Инъекция SHALL выбирать роль по первому пользовательскому сообщению шага; она +MUST NOT блокировать шаг и MUST NOT утверждать роль как факт. + +Триггер MUST быть таким: в сообщениях текущего шага есть сообщение с +`source.kind === 'user'`, чей текст непуст и не является собственной инъекцией +обвязки. Собственной инъекцией MUST считаться текст, начинающийся с +``, который строится из хэша текста +инъекции и стоит первой строкой тела; одна и та же видимая инъекция MUST NOT +добавляться дважды. Видимость SHALL проверяться общим хелпером +`isInjectionVisible(agent, messages, marker)`: сначала сообщения текущего шага, +затем durable-поверхность `session.surface.nodes` + `eventAt(seq)`; после +компакции маркер исчезает с поверхности и инъекция повторяется (D-6). + +Дедупликация MUST быть по содержимому (маркеру), а не «ровно один раз за +сессию» (T2.2). Фактическое поведение, которое и заявляется контрактом: + +- роль, определённая на другом шаге иначе, даёт другой текст и другой маркер — + повторная подсказка роли возможна; +- если на более позднем шаге первый пользовательский текст бесключевой, + role-hint может выдать вопрос с четырьмя вариантами после уже выданной + подсказки роли: это следствие per-text дедупа, а не дефект (D-11); +- harness-check при неизменном состоянии проекта даёт тот же текст и тот же + маркер — повтор возможен только после компакции или изменения набора + отсутствующих сигналов. + +#### Scenario: Та же инъекция не дублируется + +- **WHEN** тот же текст инъекции уже виден в сообщениях шага или на поверхности + сессии +- **THEN** вторая копия не добавляется + +#### Scenario: Другой текст даёт вторую инъекцию (T2.2) + +- **WHEN** подсказка роли уже выдана на первом шаге, а на следующем шаге первое + пользовательское сообщение бесключевое +- **THEN** добавляется вопрос с четырьмя вариантами: маркер другого текста не + виден, и дедуп по содержимому его не подавляет + +#### Scenario: Повтор после компакции + +- **WHEN** маркер инъекции пропал с поверхности сессии после `/compact` +- **THEN** следующий шаг добавляет инъекцию заново + +#### Scenario: Смена роли на новом шаге + +- **WHEN** на первом шаге роль была dev, а на следующем сообщение содержит + `jira` +- **THEN** добавляется подсказка admin: текст и маркер другие + +### Requirement: Follow-ups #62 в этом же change + +Пункты #62 SHALL ехать задачами этого change, а не отдельными фичами (D-4), и +каждый MUST быть закрыт тестом или пином текста: + +- T2.1/M-2: негативный тест `install.sh --check` без `openspec` MUST изолировать + PATH (каталог-заглушка как весь PATH), а не наследовать host PATH с + фиксированным системным префиксом: глобальный npm-префикс на Linux кладёт + `openspec` в `/usr/bin`, и тест «бинарника нет» иначе проверяет не то. +- O-2: `install.sh` MUST предупреждать (WARN, прогон продолжается) о смешанном + состоянии, когда `DSH_HOME` пришёл из окружения, указывает на существующий + каталог и не равен `$HOME/.dsh`, при нестандартном HOME: изоляция только HOME + пишет в реальный `DSH_HOME`. WARN MUST быть один на прогон и MUST + срабатывать и в `--global` (`install_global`), а не только в `--skills`. +- T3.4: `dsh/skills/sdd/SKILL.md` MUST содержать порядок TDD: шаг «напиши тест и + убедись, что он падает (RED)» обязателен до кода; текст добавляется с нуля и + пинится тестом (D-8). + +#### Scenario: Негативный тест `--check` без openspec + +- **WHEN** тест запускает `install.sh --check` с PATH, состоящим только из + каталога-заглушки без `openspec` +- **THEN** проверка сообщает об отсутствии `openspec`, а host-бинарник не влияет + на результат + +#### Scenario: Смешанный HOME и DSH_HOME + +- **WHEN** `--global` запускается с нестандартным HOME и `DSH_HOME`, указывающим + на существующий каталог +- **THEN** печатается `WARN: DSH_HOME=… both HOME and DSH_HOME must be isolated + together`, и установка продолжается + +#### Scenario: TDD-порядок в sdd-скилле + +- **WHEN** модель читает `sdd`-скилл перед первым диспатчем +- **THEN** в нём есть требование обязательного падающего теста до кода, и это + требование закреплено тестом diff --git a/openspec/specs/approval-gates/spec.md b/openspec/specs/approval-gates/spec.md index 4d68763..2fcf7ea 100644 --- a/openspec/specs/approval-gates/spec.md +++ b/openspec/specs/approval-gates/spec.md @@ -66,8 +66,13 @@ MUST отказать с перечислением отсутствующих Подтверждение владельца MUST идти через `ctx.userQuestions.ask` (D-12): вопрос с вариантами Approve и Cancel; без сервиса команда MUST отказать, а не писать. При подтверждении пишутся `by: owner` и `at` в ISO 8601 UTC; при отмене или -неподтверждении — отказ `not confirmed`, файл не меняется. Команда MUST NOT -создавать план и не трогать `proposal.md`, `design.md`, `builder-check.md`. +неподтверждении — отказ `not confirmed`, файл не меняется. Сбой самого диалога +(исключение из `ctx.userQuestions.ask`) MUST давать отказ с текстом ошибки — +`userQuestions failed: `, — а не литерал `not confirmed` (T1.5 #77): +владелец должен видеть причину, по которой вопрос не дошёл, а отмена и сбой +различаются в логе. `approval.md` не меняется ни в одном из этих случаев. Команда +MUST NOT создавать план и не трогать `proposal.md`, `design.md`, +`builder-check.md`. #### Scenario: /approve design без builder-check @@ -98,6 +103,17 @@ MUST отказать с перечислением отсутствующих - **THEN** команда отказывает с `userQuestions service unavailable; /approve requires it` и `approval.md` не пишется +#### Scenario: Сбой диалога вопросов + +- **WHEN** `ctx.userQuestions.ask` завершается исключением +- **THEN** команда отказывает с `userQuestions failed: `, а не с + литералом `not confirmed`, и `approval.md` не меняется + +#### Scenario: Отмена в диалоге + +- **WHEN** владелец ответил Cancel (или вопрос остался без ответа) +- **THEN** команда отказывает с `not confirmed` и `approval.md` не меняется + ### Requirement: Инструмент request_approval Инструмент модели `request_approval` SHALL быть вторым писателем `approval.md`: @@ -114,10 +130,10 @@ MUST отказать с перечислением отсутствующих владельцу; проверки предпосылок и отказы те же, что у `/approve` (design — proposal/design/builder-check, plan — утверждённый дизайн, ровно один план, sha256), и любой отказ MUST NOT писать `approval.md`. Подтверждением SHALL -считаться только ответ `answers[0].selected[0] === 'Approve'`; Cancel, -отсутствие ответа и ошибка диалога — отказ `not confirmed` без записи. При -отсутствии сервиса `userQuestions` инструмент MUST отказать (`userQuestions -service unavailable`), а не писать. +считаться только ответ `answers[0].selected[0] === 'Approve'`; Cancel и +отсутствие ответа — отказ `not confirmed` без записи; ошибка диалога — отказ +с текстом ошибки без записи. При отсутствии сервиса `userQuestions` +инструмент MUST отказать (`userQuestions service unavailable`), а не писать. #### Scenario: Модель просит утвердить дизайн