DSH: harness-check + role-hint через pre-step, follow-ups #62 (#43) #79

Merged
aep merged 18 commits from feat/43-dsh-prestep into main 2026-10-25 12:54:36 +03:00
21 changed files with 1538 additions and 66 deletions

View File

@ -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).
- Маркер `<!-- DSH-HARNESS-PRESTEP:<hash> -->`; общая видимость через 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 = <repo>/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] Проект <root> не подключён к обвязке: нет: … . Предложи владельцу настройку: openspec init --tools agents, codegraph init и секцию «OpenSpec и superpowers» в CLAUDE.md.`.
- `detectRole(firstUserText) -> 'dev'|'admin'|'analyst'|'qa'|null`.
- `buildRoleHintText(role|null) -> string` — `[dsh-harness] Роль сессии: <r>. Вызови skill role. Если не так — скажи.` или вопрос с четырьмя вариантами (D-7).
- `isHarnessMessage(text) -> boolean` — текст начинается с `<!-- DSH-HARNESS-` или содержит `[dsh-harness]`/`[claude-harness]` (оба префикса до обновления AGENTS.md, D-10).
- `makePrestepMarker(text) -> {marker, body}`.
**Tests (TDD):** detectAdoption в temp-дереве (adopted repo, missing-openspec repo, subdir cwd, non-repo cwd); detectRole по каждому слову + `pr` как слово/не слово; buildRoleHintText оба варианта; isHarnessMessage на bootstrap-тексте; маркер уникален.
- [ ] Step 1: падающие тесты → Step 2: FAIL → Step 3: реализация → Step 4: PASS + `bash dsh/tests/run.sh` (161/0) → Step 5: коммит `feat(dsh): prestep — detectAdoption/detectRole/тексты инъекций (#43)`.
### Task 2: wiring + isInjectionVisible рефакторинг
**Files:** Modify `dsh/bundle/lib/bootstrap.js` (переименование экспорта + совместимый алиас), `dsh/bundle/index.js`, `dsh/tests/bootstrap.test.mjs` (imports), `dsh/tests/prestep.test.mjs` (apply-тесты).
**Interfaces:**
- `isInjectionVisible(agent, messages, marker)` — та же логика, что isBootstrapVisible; старый экспорт остаётся алиасом (обратная совместимость).
- index.js: `DEFAULTS += {harnessCheck:true, roleHint:true}`; в pre-step после bootstrap-блока: `const onOk = typeof ctx.on === 'function'`; harnessCheck && onOk → detectAdoption(sessionCwd) → не adopted → инъекция (маркер, isInjectionVisible); roleHint && onOk → первое user-сообщение текущего шага (source.kind user, не isHarnessMessage) → detectRole → инъекция.
**Tests:** apply с fake ctx: инъекция harness-check при missing-сигналах; молчание при adopted/non-repo; role-hint при user-сообщении с ключевиком; вопрос без ключевика; без нового user-сообщения — тишина; маркерные сообщения не считаются первыми; bootstrap:false + harnessCheck:true — инъекции работают (onOk прямой).
- [ ] Steps TDD → коммит `feat(dsh): wiring harness-check/role-hint в pre-step, isInjectionVisible (#43)`.
### Task 3: follow-ups #62
**Files:** Modify `dsh/tests/openspec.test.mjs` (T2.1), `dsh/install.sh` (O-2 WARN), `dsh/tests/install.test.mjs`, `dsh/skills/sdd/SKILL.md` (T3.4 + пин в install.test.mjs).
**Interfaces:**
- T2.1: негативный тест --check без openspec: вместо фиксированного PATH — временный каталог-сторож с symlink-ами на нужные бинарники без openspec (или skip-if-present; выбрать первое).
- O-2: install.sh WARN «DSH_HOME указывает на существующий каталог при нестандартном HOME» в начале install_skills/install_roles; тест с temp HOME + реальный DSH_HOME → WARN.
- T3.4: в sdd SKILL.md добавить предложение в протокол диспатча: «шаг „прогони тест и убедись, что он падает“ обязателен ДО кода»; пин текста в install.test.mjs (или sdd-skill.test.mjs — файл в списке брифа).
- [ ] Steps TDD → коммит `fix(dsh): #62 — PATH-изоляция теста, WARN DSH_HOME, TDD-порядок в sdd (#62)`.
### Task 4: спека-дельта session-prestep + доки
**Files:** Create `openspec/changes/dsh-prestep/specs/session-prestep/spec.md`; Modify `dsh/README.md`, `dsh/PORT.md`, `dsh/bundle/dsh-tools.md` (упомянуть request_approval — T1.3 из #77), `openspec/changes/dsh-prestep/design.md` (статусы), `openspec/specs/approval-gates/spec.md` (T1.5 из #77 — формулировка ошибки диалога).
**Requirements:** ADDED — harness-check инъекция (сигналы, upward-walk, вне-git молчание, неблокируемость, конфиг), role-hint инъекция (триггер/фильтр/ключевики/тексты, раз за сессию), общая видимость инъекций, #62-фиксы (T2.1 PATH, O-2 WARN, T3.4 текст); строка про request_approval в dsh-tools.md (T1.3); формулировка «ошибка диалога» в approval-gates (T1.5).
- [ ] Steps: `openspec validate dsh-prestep --strict` → valid; `bash dsh/tests/run.sh`; коммит `docs(openspec): спека session-prestep, доки (#43, #62)`.
## Предусловия
- Ветка: `feat/43-dsh-prestep` (require-plan сопоставляет ветку по строке в плане; строка добавляется ДО plan-аппрува — A4).
- baseline `bash dsh/tests/run.sh` = 161/0 на ветке; python3/git/node в PATH; сеть не нужна.
- Гейты активны: план исполняется после `/approve dsh-prestep plan` (это и есть приёмка #59).

View File

@ -2,7 +2,9 @@
Status: **spike 0.2.1, live guard/require-plan/bootstrap verified; `/role`
command works, the model-invocable `role` skill and the asynchronous SDD
protocol are verified live (three-task trial run)**. This directory is the
protocol are verified live (three-task trial run); the pre-step
`harness-check`/`role-hint` injections are implemented and tested but not yet
verified live — they need the reinstalled bundle**. This directory is the
DeepSeek Harness adapter for `claude-harness`; the Claude Code installation and
the repository's source of truth are unchanged.
@ -16,6 +18,7 @@ This is a port, not a copy. Three layers map differently:
| Skills | `~/.claude/skills`, `.claude/skills` | `~/.dsh/skills`, `.agents/skills`, `customSkillDirs` | 1:1 format |
| Roles | `roles/`, `/role` skill | same files + `/role` command + model-invocable `role` skill | close |
| Hard rules | `PreToolUse` shell hooks | `ctx.tools.guard` Cordis guard + approval gates A/B/C | logic reused, wrapper new |
| Session-start checks | `harness-check.sh`, `role-hint.sh` | `agent/pre-step` injections `harness-check`, `role-hint` | checks ported, texts new |
| Superpowers | plugin + `SessionStart` hook | pinned sync of tag `v7.0.1` (`DSH_SUPERPOWERS_REF`) into `~/.dsh/skills` + `agent/pre-step` bootstrap | content adapted by sync, bootstrap new |
| SDD | `Task` (synchronous) | `subagent` (asynchronous) / `workflow` | semantics differ |
| Gitea | CLI + skills | `bash` + skills | 1:1 |
@ -61,11 +64,22 @@ file has to be installed as `$DSH_HOME/AGENTS.md` because DSH does not read
проекта, а копия старела бы молча. Слэш-команд нет — только скиллы.
Установка в проект — `bash dsh/install.sh --openspec`; проверка — `--check`.
6. **`harness-check` and `role-hint`** through `agent/pre-step` (change
`dsh-prestep`, capability `session-prestep`). `harness-check` walks up to the
nearest `.git` and looks for `openspec/`, `.codegraph/`, and the
`## OpenSpec и superpowers` section in `CLAUDE.md`; a missing signal produces
one non-blocking `[dsh-harness]` message with the setup steps. `role-hint`
reads the first non-harness user message of the step and injects either
`Роль сессии: <r>` or one question with the four variants. Both are text-only
(`lib/prestep.js` uses fs reads, no spawn), skipped for subagents, gated by
`harnessCheck` / `roleHint` and by the same marker/visibility machinery as the
bootstrap. The Claude Code `SessionStart` scripts are not run under DSH.
## 3. What is deliberately deferred
- **`harness-check` and `role-hint`**: non-blocking SessionStart hooks. They can
become `agent/pre-step` injections later; the first message role rule already
covers most of `role-hint`.
- **CC `SessionStart` scripts**: `hooks/harness-check.sh` and
`hooks/role-hint.sh` stay for Claude Code sessions; DSH runs the ported checks
itself (§2 item 6), so the scripts and `settings.hooks.json` are not reused.
- **`briefing`**: needs an agent preset with read-only tools and a model route;
not part of the guard/bootstrap spike.
- **SDD parity**: the Claude `Task` is synchronous, while DSH `subagent` returns
@ -107,6 +121,7 @@ dsh/
command-role.js # /role command glue (DSH imports live only here)
lib/guards.js # Claude-hook JSON adapter, fail-open
lib/bootstrap.js # skill resolution, marker, dedupe, compaction
lib/prestep.js # harness-check/role-hint detection and texts (pure)
lib/role.js # role parsing and text building (pure)
dsh-tools.md # superpowers tool mapping
hooks -> ../../hooks # local-install symlink to the tested bash/Python hooks
@ -153,7 +168,7 @@ agent/pre-step
-> next() (other injections happen first)
-> skip if reject/aborted, or if subagent and bootstrapSubagents=false
-> prepareBootstrap(): resolve skill, read mapping, hash content, build text
-> isBootstrapVisible(agent, decision.messages, marker)?
-> isInjectionVisible(agent, decision.messages, marker)?
yes -> no-op
no -> append createUserMessage({ source: {kind:"user"} })
```
@ -162,6 +177,28 @@ The marker is an HTML comment at the top of the message. The visible-surface
scan uses `agent.session.surface.nodes` + `eventAt(seq)`, the same mechanism the
skill catalog uses, so a compacted message is re-injected on the next step.
### Pre-step injection flow (harness-check, role-hint)
```
agent/pre-step (after next(), same listener as the bootstrap)
-> harness-check: sessionCwd -> detectAdoption()
no .git up to the fs root -> silent
all signals present -> silent
otherwise -> [dsh-harness] Проект … не подключён …
-> role-hint: first user message of this step that is not a harness injection
no such message -> silent (autonomous round)
keyword match -> [dsh-harness] Роль сессии: <r> …
no match -> the four-variant question
-> skip both for subagents (origin=subagent, delegationDepth>0, subagentDepth>0)
-> dedupe by the PRESTEP marker, which is the hash of the injection text
```
The checks are gated by `typeof ctx.on === 'function'` rather than by the
`bootstrap` self-check (D-5), so `bootstrap: false` keeps them alive. The
`harness-check` texts contain no Claude Code paths or adopt script (D-9); the
pre-step prefix is `[dsh-harness]`, and the message filter also accepts the
legacy `[claude-harness]` (D-10).
## 5. Configuration
The bundle row can be configured from the profile patch. Defaults are suitable
@ -178,6 +215,8 @@ for the 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 -> <cwd>/.claude/roles
@ -401,7 +440,17 @@ profile was removed; the real `desktop` profile was not touched.
is still pending.
- **Subagent bootstrap.** `isSubagent` probes common fields; if none are present
on this version, the bootstrap reaches children too. The skill's
`<SUBAGENT-STOP>` makes that harmless but token-expensive.
`<SUBAGENT-STOP>` makes that harmless but token-expensive. The pre-step
injections use the same predicate and are skipped for subagents; a fork
(`parentSession` without `origin`) is not a subagent and keeps them (T2.3).
- **Pre-step injection limits.** `harness-check` and `role-hint` are text
injections, not enforcement: nothing stops a model from ignoring them, and they
only exist in a session with the reinstalled bundle (D-13). `role-hint` matches
keywords as substrings, so a different stem («миграцию», «спеку») does not
match. Dedupe is per content (marker = hash of the text), not "exactly once per
session": a later step whose first user message has no keyword adds the
four-variant question even after an earlier role hint (D-11). `harness-check`
never adopts a project — there is no DSH port of `adopt-project.sh` (D-9).
- **Model tiers.** `sonnet`/`haiku`/`opus` are not DSH models. The port uses two
live routes (`deepseek-flash`, `deepseek-v4-pro`) from the opt-in overlay in
`dsh/subagent-models.yml`; without it every dispatch runs on the session model.
@ -427,8 +476,10 @@ profile was removed; the real `desktop` profile was not touched.
1. Finish the live smoke test: the fresh-session `role` skill acceptance, plus
the bootstrap and `/compact` checks already verified.
2. Add `harness-check` and optional `role-hint` as non-blocking pre-step
injections.
2. `harness-check` and optional `role-hint` as non-blocking pre-step injections —
**done** (change `dsh-prestep`, capability `session-prestep`): implemented and
tested in `dsh/bundle/lib/prestep.js` + `index.js`; live verification waits for
the bundle reinstall.
3. Build `briefing` as an agent preset (read-only tools + model route).
4. SDD — **done** (change `dsh-sdd`): the `sdd` skill (async controller,
reports, review, fix-loop, ledger) plus the overlay in §5; proven by a

View File

@ -15,6 +15,11 @@ separate Cordis bundle plus a thin install helper.
`/approve <change-id> design|plan`;
- superpowers bootstrap — `using-superpowers` is injected once per session from
`agent/pre-step` and re-injected after compaction;
- pre-step injections — `harness-check` (a project that is not wired into the
harness is offered the setup steps: `openspec/`, `.codegraph/`, the
`## OpenSpec и superpowers` section in `CLAUDE.md`) and `role-hint` (the first
user message of a step names a role or asks one question with the four
variants); both are non-blocking, text-only, and skipped for subagents;
- roles — the model loads them with the `role` skill (`~/.dsh/roles`, then
`~/.claude/roles`, then repo `roles/`); `/role dev|admin|analyst|qa` remains
the user-facing command and injects the same text, without Claude's
@ -31,9 +36,11 @@ separate Cordis bundle plus a thin install helper.
and logs every missing service; the `dsh-harness-check` command re-runs the
same check on demand.
**Not yet:** `briefing`, the `harness-check` SessionStart hook, `role-hint`,
`harness-doctor`, auto-memory, caveman. See [PORT.md](PORT.md) for the design,
mapping table, and risks.
**Not yet:** `briefing`, `harness-doctor`, auto-memory, caveman. The Claude Code
`SessionStart` hooks themselves stay in `hooks/` for Claude Code sessions; the
DSH port runs the same two checks as pre-step injections (see above) and does
not run the CC scripts. See [PORT.md](PORT.md) for the design, mapping table,
and risks.
## Quick start
@ -119,6 +126,9 @@ step, never a background pull (design D-2, cadence D-7):
any code.
6. In a project with `.agents/skills`, ask `what skills do you have?` — the six
`openspec-*` skills must be listed.
7. In a repository with `.git` but no `openspec/`, expect one
`[dsh-harness] Проект … не подключён к обвязке` message; a first message like
`Напиши тест для модуля` must be followed by `[dsh-harness] Роль сессии: dev`.
The injected bootstrap starts with a marker:
@ -129,6 +139,13 @@ The injected bootstrap starts with a marker:
After compaction the marker disappears from `session.surface.nodes` and the next
step injects a fresh copy.
The two pre-step injections use the same shape; the hash is of the injection
text, so a different hint is a different marker:
```text
<!-- DSH-HARNESS-PRESTEP:<16-hex-hash> -->
```
## 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 -> <cwd>/.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

View File

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

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`.
*
@ -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
})
}

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

164
dsh/bundle/lib/prestep.js Normal file
View File

@ -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('<!-- DSH-HARNESS-') ||
text.includes('[dsh-harness]') ||
text.includes('[claude-harness]')
)
}
/**
* Idempotency marker for a pre-step injection.
* @param {string} text - injection body, hashed to make the marker unique
* @returns {{marker: string, body: string}}
*/
export function makePrestepMarker(text) {
const value = typeof text === 'string' ? text : String(text ?? '')
const hash = createHash('sha256').update(value).digest('hex').slice(0, 16)
const marker = `<!-- DSH-HARNESS-PRESTEP:${hash} -->`
return { marker, body: `${marker}\n${value}` }
}

View File

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

View File

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

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

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

View File

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

View File

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

452
dsh/tests/prestep.test.mjs Normal file
View File

@ -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('<!-- DSH-HARNESS-BOOTSTRAP:abc123 -->\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, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->$/)
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, /^<!-- 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)
}
})
// ---------------------------------------------------------------------------
// 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)
}
})

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-25

View File

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

View File

@ -0,0 +1,19 @@
# Builder-check: dsh-prestep
Дата: 25.10.2026. Свежий субагент (v4-pro). Три вопроса — все адьюдицированы
контроллером в design.md:
1. Триггер role-hint видит собственные инъекции (bootstrap/harness-check —
user-source сообщения) → добавлен фильтр: сообщение не считается первым,
если начинается с `<!-- DSH-HARNESS-` или содержит `[claude-harness]`;
скан — только новые сообщения текущего pre-step (`decision.messages`).
2. harness-check при literal-sessionCwd даёт ложную подсказку в подкаталоге
репозитория и вне git → upward-walk до ближайшего `.git`/корня ФС, вне
git-репозитория — пропуск (как CC-хук); без spawn git.
3. «грант» в admin-ключевиках — опечатка «Гант» → список приведён к
AGENTS.md точно (admin: jira, гант, спринт).
Подтверждено: текста вопроса с четырьмя вариантами в репо нет — сочинён в
дизайне (D-7); в sdd-скилле TDD-предложения нет — текст T3.4 добавляется
с нуля (D-8); гейт инъекций — прямое `typeof ctx.on` (D-5, selfcheck
пропускает bootstrap при config.bootstrap=false).

View File

@ -0,0 +1,116 @@
# Design: dsh-prestep — harness-check и role-hint через agent/pre-step
Issue: #43 (+#62). Статус: утверждён владельцем 25.10.2026
(`/approve dsh-prestep design`); реализация — ветка `feat/43-dsh-prestep`,
статусы решений — в конце файла. Спека-дельта —
`openspec/changes/dsh-prestep/specs/session-prestep/spec.md`.
## Контекст
CC-хуки harness-check/role-hint — SessionStart; в DSH аналог — инъекции
`agent/pre-step` (уже используем для superpowers-bootstrap). Гейты #59
живые: этот change идёт первым по гейтованному циклу (acceptance #59).
## Механика
Каждая инъекция — по содержимому и маркеру (per-text dedupe); повторная
инъекция с другим текстом возможна — см. D-11. Маркер
`<!-- DSH-HARNESS-PRESTEP:<hash> -->` и тот же механизм видимости, что у
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] Проект <root> не подключён к обвязке: нет: … .
Предложи владельцу настройку: `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', И текст не начинается с
`<!-- DSH-HARNESS-` и не содержит `[dsh-harness]` (фильтр собственных
инъекций — bootstrap/harness-check не считаются «первым сообщением»).
Нет такого — инъекции нет (в т.ч. автономные раунды без нового
сообщения).
- Ключевые слова (точно как в AGENTS.md; builder-check нашёл опечатку —
«грант» убран):
- admin: `jira`, `гант`, `спринт`;
- qa: `стенд`, `прогон`, `скриншот`, `макет`;
- analyst: `тз`, `oq`, `спека`, `методика`;
- dev: `срез`, `issue`, `pr`, `тест`, `миграция`.
Совпадение — инъекция `[dsh-harness] Роль сессии: <r>. Вызови
skill role. Если не так — скажи.` Нет — инъекция вопроса (текст сочинён
здесь, в репо его не было): «Роль сессии неясна. Вызови skill role:
dev — код, issue, PR, тест, миграция; admin — Jira, Гант, спринт;
analyst — ТЗ, OQ, спека, методика; qa — стенд, прогон, скриншот, макет.
Задай владельцу один вопрос с этими четырьмя вариантами.»
- Матчинг: нижний регистр; `pr` — только отдельным словом; остальные —
подстроки.
## Decisions made without discussion
| # | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| D-1 | Роль определяется по первому сообщению (не по заголовку — его в DSH нет) | Ровно то правило AGENTS.md, которое модель сейчас исполняет «по памяти»; переносим в технику | Ложная подсказка роли при неоднозначном сообщении — поправима (`/role`), не блокирует |
| D-2 | harness-check — только подсказка, без HARNESS_AUTO_ADOPT | YAGNI; авто-подключение — отдельная фича при запросе | Сессия в неподключённом проекте продолжает работу — как и сейчас |
| D-3 | Общий хелпер инъекций (рефакторинг bootstrap) без изменения поведения bootstrap | Два новых pre-step не должны дублировать маркер/дедуп | Регрессия bootstrap — страхуется существующими тестами bootstrap |
| D-4 | #62-пункты едут задачами этого change (не отдельными фичами) | Мелкие, один PR-цикл дешевле трёх | Смешивание тем в одном PR — компенсируется отдельными задачами плана |
| D-5 | Конфиг-ключи harnessCheck/roleHint — по умолчанию true; гейт — прямое `typeof ctx.on === 'function'` (не bootstrapOk: selfcheck пропускает запись bootstrap при config.bootstrap=false, а инъекциям нужен ctx.on независимо от bootstrap) | Симметрия с bootstrap; корректность при выключенном bootstrap | Нет |
| D-6 | Маркер инъекций — `<!-- DSH-HARNESS-PRESTEP:<hash> -->` (формат 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]`); фильтр собственных сообщений — по нему и по `<!-- DSH-HARNESS-` | Имя бандла — dsh-harness; старый префикс — след CC-происхождения | Установленный AGENTS.md пока содержит `[claude-harness]` — фильтр учитывает оба префикса до обновления install.sh |
| D-11 | Дедуп инъекций — по содержимому (маркер = хэш текста), а не «ровно раз за сессию»: role-hint на более позднем шаге с бесключевым первым сообщением может добавить вопрос с четырьмя вариантами после уже выданной подсказки роли | Это фактическое поведение общей машинерии bootstrap (маркер + `isInjectionVisible`), переиспользованной без изменения поведения (D-3); «раз за сессию» в разделе «Механика» было неточной формулировкой (T2.2) | Лишний вопрос-подсказка в сессии — не блокирует и правится моделью; глобальный session-флаг потребовал бы нового состояния и не переживал бы компакцию |
## Тесты (идеи для плана)
- pure: detectAdoption(files), detectRole(firstUserText) → role|null,
buildRoleHintText(role), buildHarnessCheckText(), маркер-дедуп;
- wiring: apply с fake ctx — инъекции появляются при наличии
пользовательского сообщения, один раз, пропускаются для сабагентов;
- T2.1: негативный тест openspec — PATH-изоляция (временный каталог без
openspec-бинарника);
- O-2: install.sh WARN при DSH_HOME→существующий каталог + нестандартный
HOME (тест в install.test.mjs);
- T3.4: пин текста в sdd-скилле («шаг „тест падает“ обязателен»).
## На потом (в план)
- Разбивка: Task 1 prestep.js (harness-check+role-hint pure) → Task 2
wiring+bootstrap-рефакторинг → Task 3 #62 (T2.1/O-2/T3.4) → Task 4
спека-дельта `session-prestep` + доки.
## Статус реализации (25.10.2026, ветка `feat/43-dsh-prestep`)
| # | Решение | Статус | Где |
|---|---|---|---|
| D-1 | Роль по первому сообщению | реализовано | `lib/prestep.js` `detectRole`; пин в `dsh/tests/prestep.test.mjs` |
| D-2 | harness-check без HARNESS_AUTO_ADOPT | реализовано | `lib/prestep.js` — только текст, без записи |
| D-3 | Общий хелпер инъекций, поведение bootstrap не меняется | реализовано (6e0828e, a2453e3; 1d35a3b чинит и доставку bootstrap сабагентам — реальные сигналы рантайма, T2.1) | `lib/bootstrap.js` `isInjectionVisible` (+ алиас `isBootstrapVisible`); тесты bootstrap зелёные |
| D-4 | #62-пункты — задачи этого change | реализовано (183f8d8, 6800550) | `dsh/tests/openspec.test.mjs`, `dsh/install.sh`, `dsh/skills/sdd/SKILL.md` |
| D-5 | `harnessCheck`/`roleHint` по умолчанию `true`; гейт — `typeof ctx.on === 'function'` | реализовано (a2453e3) | `index.js` wiring; тест «bootstrap:false оставляет harness-check» |
| D-6 | Маркер `<!-- DSH-HARNESS-PRESTEP:<hash> -->` + общий `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`.

View File

@ -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] Роль сессии: <r>` с
указанием вызвать `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`.

View File

@ -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] Проект <root> не подключён к обвязке:
нет: <список отсутствующего через '; '>. Предложи владельцу настройку:
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] Проект <root> не
подключён к обвязке: нет: 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** сессия стартует в `<repo>/dsh/bundle`, а сигналы лежат в `<repo>`
- **THEN** корнем считается `<repo>`, и текст инъекции называет `<repo>`
#### 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 считаться текст, начинающийся с
`<!-- DSH-HARNESS-`, либо содержащий `[dsh-harness]`, либо содержащий
`[claude-harness]` (legacy-префикс до обновления install.sh, D-10). Фильтр MUST
применяться до выбора роли: иначе текст вопроса сам содержит все ключевики.
Сообщений без нового пользовательского текста (в т.ч. автономный раунд) —
инъекции нет.
Ключевики SHALL проверяться по нижнему регистру, в порядке приоритета
admin → qa → analyst → dev; побеждает первый совпавший класс:
- admin: `jira`, `гант`, `спринт`;
- qa: `стенд`, `прогон`, `скриншот`, `макет`;
- analyst: `тз`, `oq`, `спека`, `методика`;
- dev: `срез`, `issue`, `pr`, `тест`, `миграция`.
Совпадение MUST быть подстрокой вхождения целиком, кроме `pr`, который MUST
матчиться только отдельным словом (`\bpr\b`): `PR`, `pr.` — совпадение,
`preview`, `пр` — нет. Семантика подстрок заявлена явно (T1.2): словоформы,
меняющие основу, ключевик не содержат и MUST NOT матчиться — «миграцию»,
«миграции», «спеку» остаются без совпадения, тогда как формы, содержащие
ключевик целиком («теста», «issue-трекер», «ТЗ-документ»), матчатся.
При совпадении инъекция MUST быть: `[dsh-harness] Роль сессии: <r>. Вызови
skill role. Если не так — скажи.` Без совпадения инъекция MUST быть вопросом:
`[dsh-harness] Роль сессии неясна. Вызови skill role: dev — код, issue, PR,
тест, миграция; admin — Jira, Гант, спринт; analyst — ТЗ, OQ, спека, методика;
qa — стенд, прогон, скриншот, макет. Задай владельцу один вопрос с этими
четырьмя вариантами.`
Инъекция SHALL управляться конфигом `roleHint` (по умолчанию `true`) и, как
harness-check, MUST требовать только `ctx.on` (D-5), независимо от `bootstrap`.
#### Scenario: Первое сообщение с ключевиком
- **WHEN** первое пользовательское сообщение шага — «Напиши тест для модуля»
- **THEN** добавляется `[dsh-harness] Роль сессии: dev. Вызови skill role. Если
не так — скажи.`
#### Scenario: Приоритет классов ролей
- **WHEN** сообщение содержит и `jira`, и `спринт`, и `тест`
- **THEN** подсказка называет admin: admin выигрывает у qa, analyst и dev
#### Scenario: Бесключевое сообщение
- **WHEN** первое пользовательское сообщение шага не содержит ни одного
ключевика
- **THEN** добавляется вопрос с четырьмя вариантами ролей
#### Scenario: Своя инъекция не считается сообщением
- **WHEN** единственное пользовательское сообщение шага дословно равно вопросу
role-hint
- **THEN** инъекции нет: фильтр собственных сообщений срабатывает до выбора роли
#### Scenario: Legacy-префикс Claude Code
- **WHEN** единственное пользовательское сообщение начинается с
`[claude-harness]`
- **THEN** роль по нему не выбирается и инъекции нет
#### Scenario: Шаг без нового пользовательского сообщения
- **WHEN** в сообщениях шага нет пользовательского сообщения с непустым текстом
- **THEN** role-hint молчит
#### Scenario: Флексия ключевика не матчится (T1.2)
- **WHEN** первое сообщение шага — «Сделай миграцию и обнови спеку»
- **THEN** ни `миграция`, ни `спека` не совпадают, подсказки роли нет — выдаётся
вопрос с четырьмя вариантами; это заявленный контракт подстрок, а не дефект
#### Scenario: `pr` только отдельным словом
- **WHEN** первое сообщение содержит `preview`, но не содержит слова `pr`
- **THEN** dev не выбирается; `PR` или `pr.` в сообщении дают dev
### Requirement: Инъекции пропускаются в сабагентах
Обе инъекции MUST пропускаться на делегированном шаге: шаг сабагента не должен
получать ни harness-check, ни role-hint. Делегирование SHALL определяться по
реальным сигналам рантайма: `agent.session.header.origin === 'subagent'`, либо
`agent.session.header.delegationDepth > 0`, либо
`agent.options.subagentDepth > 0` (T2.1). Форк сессии MUST NOT считаться
сабагентом: форк (в т.ч. `parentSession` без `origin`) получает все инъекции
(T2.3).
#### Scenario: Делегированный шаг
- **WHEN** pre-step выполняется для агента с `origin: 'subagent'` (или с
положительной глубиной делегирования)
- **THEN** ни harness-check, ни role-hint не инъецируются, даже если проект не
подключён, а сообщение содержит ключевик
#### Scenario: Форк сессии получает инъекции
- **WHEN** pre-step выполняется для форка (`parentSession` без `origin`)
- **THEN** пропуска нет: инъекции работают как в главной сессии
### Requirement: Повторная инъекция и видимость
Инъекции SHALL дедуплицироваться по маркеру
`<!-- DSH-HARNESS-PRESTEP:<16-hex> -->`, который строится из хэша текста
инъекции и стоит первой строкой тела; одна и та же видимая инъекция 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** в нём есть требование обязательного падающего теста до кода, и это
требование закреплено тестом

View File

@ -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: <message>`, — а не литерал `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: <message>`, а не с
литералом `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: Модель просит утвердить дизайн