Compare commits

...

13 Commits

Author SHA1 Message Date
Andrey Epifancev
557005219c docs: удалён хвост конфликтного маркера в approval-gates (I-1, #43) 2026-10-25 13:03:50 +04:00
Andrey Epifancev
c99635f318 merge: main (request_approval #77) — конфликт спеки разрешён (оба блока + T1.5-формулировка), M-1/M-2 (#43) 2026-10-25 12:59:09 +04:00
Andrey Epifancev
7a5d7745f5 docs: design-механика per-text, бэктики в спеке, счёт сценариев 23 (T4.1-T4.4, #43) 2026-10-25 12:49:55 +04:00
Andrey Epifancev
727a9dd9bc docs(openspec): спека session-prestep, доки и хвосты T1.2/T2.2/T1.3/T1.5 (#43, #62) 2026-10-25 12:42:26 +04:00
Andrey Epifancev
6800550cd5 fix(dsh): O-2 — WARN и в install_global, ассерты артефактов (#62) 2026-10-25 12:29:30 +04:00
Andrey Epifancev
183f8d8956 fix(dsh): #62 — PATH-изоляция теста, WARN DSH_HOME, TDD-порядок в sdd (#62) 2026-10-25 12:19:53 +04:00
Andrey Epifancev
622dd32e1a fix(dsh): isSubagent — origin===subagent вместо parentSession (форки не сабагенты) (T2.3, #43) 2026-10-25 12:11:05 +04:00
Andrey Epifancev
1d35a3be2c fix(dsh): isSubagent по реальным сигналам рантайма (T2.1, #43) 2026-10-25 12:03:58 +04:00
Andrey Epifancev
a2453e35e8 feat(dsh): wiring harness-check/role-hint в pre-step, isInjectionVisible (#43) 2026-10-25 11:47:28 +04:00
Andrey Epifancev
37daa97915 docs: approval.md — пере-аппрув плана после строки ветки (#43) 2026-10-25 11:36:38 +04:00
aep
fc18bbd976 Merge pull request 'DSH: request_approval-инструмент — аппрув одним кликом владельца (#77)' (#78) from feat/77-approve-tool into main 2026-10-25 10:20:03 +03:00
Andrey Epifancev
b81afaf59a docs(openspec): спека approval-gates — request_approval как легитимный писатель (T1.1, #77) 2026-10-25 11:06:28 +04:00
Andrey Epifancev
1723806e16 feat(dsh): request_approval-инструмент — модель инициирует аппрув, владелец жмёт Approve (#77) 2026-10-25 10:58:31 +04:00
23 changed files with 1633 additions and 261 deletions

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

@ -1,11 +1,13 @@
/**
* Human-facing `/approve` command for DeepSeek Harness.
*
* `/approve <change-id> design|plan` is the only writer of
* `openspec/changes/<id>/approval.md` (D-1). The command checks the upstream
* artifacts, asks the owner through `userQuestions.ask` (D-12), and only then
* merges the new section into the existing file. A missing user-questions
* service refuses instead of writing, so no model tool can approve itself.
* `/approve <change-id> design|plan` is the manual front-end of the approval
* gate: it asks the owner through `userQuestions.ask` (D-12) and lets the
* shared `runApproval` (lib/approval.js) check the upstream artifacts and
* write `openspec/changes/<id>/approval.md` (D-1). The write itself lives in
* the shared module, which the model tool `request_approval` uses as well —
* both only write after the owner confirms. A missing user-questions service
* refuses instead of writing, so no model tool can approve itself.
*
* The repository root is resolved per invocation from the receiving agent's
* session (`workingDirectory.get(agent.session)`, T3.1) — the registration
@ -16,19 +18,8 @@
* `{ kind, text }` result shape.
*/
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import {
APPROVAL_FILE,
CHANGE_DIR,
PLANS_DIR,
extractSpec,
hasDesignApproval,
hashPlan,
parseApproval,
resolveChangeFromSpec,
serializeApproval,
} from './lib/gates.js'
import { resolve } from 'node:path'
import { CHANGE_ID, runApproval } from './lib/approval.js'
const USAGE = [
'Usage: /approve <change-id> design|plan',
@ -36,27 +27,8 @@ const USAGE = [
' plan — record the owner approval of the plan whose **Spec:** is <change-id>',
].join('\n')
const DESIGN_ARTIFACTS = ['proposal.md', 'design.md', 'builder-check.md']
const APPROVE_LABEL = 'Approve'
const CANCEL_LABEL = 'Cancel'
/** Change ids are directory names: keep them one safe path segment. */
const CHANGE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u
function isFile(path) {
try {
return statSync(path).isFile()
} catch {
return false
}
}
function readText(path) {
try {
return readFileSync(path, 'utf8')
} catch {
return null
}
}
/** Session working directory of the invoking agent; process.cwd() fallback. */
function currentDirectory(workingDirectory, agent) {
@ -99,148 +71,51 @@ function isApproved(answer) {
}
/**
* Ask the owner and return `{ ok: true }` or a refusal `{ ok: false, text }`.
* The detail shows exactly what is being approved; the answer is never
* inferred from the absence of an error.
* A refusal produced while asking the owner (missing service, failed dialog).
* The handler turns it into the same `Refused: ...` result as `runApproval`
* refusals; any other exception is reported as an `/approve failure`.
*/
async function askOwner(userQuestions, { changeId, kind, detail, agent, signal }) {
if (typeof userQuestions?.ask !== 'function') {
return { ok: false, text: 'userQuestions service unavailable; /approve requires it' }
}
class ConfirmationRefusal extends Error {}
const request = {
questions: [
{
id: `approve-${kind}`,
header: 'Approve',
question: `Approve ${changeId} ${kind}?`,
detail,
options: [
{
label: APPROVE_LABEL,
description: `Write the ${kind} approval to openspec/changes/${changeId}/approval.md.`,
},
{ label: CANCEL_LABEL, description: 'Write nothing.' },
],
},
],
}
if (agent !== undefined) request.agent = agent
if (signal !== undefined) request.signal = signal
/**
* The `ask` implementation of this front-end: one Approve/Cancel dialog whose
* detail shows exactly what is being approved. Returns `true` only for an
* explicit Approve answer; a missing service or a failed ask throws a
* `ConfirmationRefusal` carrying the reason, so nothing is written.
*/
function ownerAsker({ userQuestions, changeId, kind, agent, signal }) {
return async (questionText, detail) => {
if (typeof userQuestions?.ask !== 'function') {
throw new ConfirmationRefusal('userQuestions service unavailable; /approve requires it')
}
let answer
try {
answer = await userQuestions.ask(request)
} catch (error) {
return { ok: false, text: `userQuestions failed: ${String(error?.message ?? error)}` }
}
const request = {
questions: [
{
id: `approve-${kind}`,
header: 'Approve',
question: questionText,
detail,
options: [
{
label: APPROVE_LABEL,
description: `Write the ${kind} approval to openspec/changes/${changeId}/approval.md.`,
},
{ label: CANCEL_LABEL, description: 'Write nothing.' },
],
},
],
}
if (agent !== undefined) request.agent = agent
if (signal !== undefined) request.signal = signal
return isApproved(answer) ? { ok: true } : { ok: false, text: 'not confirmed' }
}
/** Merge one section into approval.md, leaving every other section as is. */
function writeApproval(root, changeId, mutate) {
const path = join(root, CHANGE_DIR, changeId, APPROVAL_FILE)
const next = mutate(parseApproval(readText(path)))
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, serializeApproval(next), 'utf8')
return path
}
/** Existing plans under docs/superpowers/plans/ whose **Spec:** is `<id>`. */
function plansForChange(root, changeId) {
const plansDir = join(root, PLANS_DIR)
let names
try {
names = readdirSync(plansDir)
} catch {
return []
}
const found = []
for (const name of [...names].sort()) {
if (!name.endsWith('.md')) continue
const path = join(plansDir, name)
if (!isFile(path)) continue
const content = readText(path)
if (content === null) continue
if (resolveChangeFromSpec(extractSpec(content)) === changeId) found.push(path)
}
return found
}
function approvalText(root, path) {
return relative(root, path).split('\\').join('/')
}
async function approveDesign({ root, changeId, userQuestions, agent, signal }) {
const changeDir = join(root, CHANGE_DIR, changeId)
const missing = DESIGN_ARTIFACTS.filter((name) => !isFile(join(changeDir, name)))
if (missing.length > 0) {
const listed = missing.map((name) => `openspec/changes/${changeId}/${name}`).join(', ')
return refusal(`design approval requires the change artifacts; missing: ${listed}.`)
}
const asked = await askOwner(userQuestions, {
changeId,
kind: 'design',
detail: `Change: openspec/changes/${changeId} (proposal.md, design.md, builder-check.md).`,
agent,
signal,
})
if (!asked.ok) return refusal(asked.text)
const at = new Date().toISOString()
const path = writeApproval(root, changeId, (approval) => ({
...approval,
design: { by: 'owner', at },
}))
return {
kind: 'success',
text: `Approved ${changeId} design at ${at}; wrote ${approvalText(root, path)}.`,
}
}
async function approvePlan({ root, changeId, userQuestions, agent, signal }) {
const approval = parseApproval(readText(join(root, CHANGE_DIR, changeId, APPROVAL_FILE)))
if (!hasDesignApproval(approval)) {
return refusal(`design not approved; run /approve ${changeId} design`)
}
const plans = plansForChange(root, changeId)
if (plans.length === 0) {
return refusal(
`no plan for ${changeId} found under ${PLANS_DIR.split('\\').join('/')}/ with **Spec:** openspec/changes/${changeId}.`,
)
}
if (plans.length > 1) {
const listed = plans.map((path) => approvalText(root, path)).join(', ')
return refusal(`several plans reference ${changeId}: ${listed}. Keep exactly one.`)
}
const [planPath] = plans
const sha256 = hashPlan(planPath)
if (sha256 === '') {
return refusal(`cannot read plan ${approvalText(root, planPath)}.`)
}
const asked = await askOwner(userQuestions, {
changeId,
kind: 'plan',
detail: `Plan: ${approvalText(root, planPath)} (sha256 ${sha256}).`,
agent,
signal,
})
if (!asked.ok) return refusal(asked.text)
const at = new Date().toISOString()
const path = writeApproval(root, changeId, (current) => ({
...current,
plan: { by: 'owner', at, sha256 },
}))
return {
kind: 'success',
text: `Approved ${changeId} plan at ${at} (sha256 ${sha256}); wrote ${approvalText(root, path)}.`,
let answer
try {
answer = await userQuestions.ask(request)
} catch (error) {
throw new ConfirmationRefusal(`userQuestions failed: ${String(error?.message ?? error)}`)
}
return isApproved(answer)
}
}
@ -267,11 +142,17 @@ export function registerApproveCommand({ commands, userQuestions, workingDirecto
const root = resolve(currentDirectory(workingDirectory, agent))
try {
if (kind === 'design') {
return await approveDesign({ root, changeId, userQuestions, agent, signal })
}
return await approvePlan({ root, changeId, userQuestions, agent, signal })
const result = await runApproval({
repoRoot: root,
changeId,
kind,
ask: ownerAsker({ userQuestions, changeId, kind, agent, signal }),
})
return result.approved
? { kind: 'success', text: result.message }
: refusal(result.message)
} catch (error) {
if (error instanceof ConfirmationRefusal) return refusal(error.message)
logger?.warn?.('[dsh-harness] /approve failed: %s', error?.message ?? error)
return refusal(`/approve failed: ${String(error?.message ?? error)}`)
}

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`.
*
@ -25,6 +26,7 @@ import { fileURLToPath } from 'node:url'
import { registerRoleCommand } from './command-role.js'
import { registerCheckCommand } from './command-check.js'
import { registerApproveCommand } from './command-approve.js'
import { createApproveTool } from './tool-approve.js'
import { createGuard } from './lib/guards.js'
import {
checkApprovalWrite,
@ -33,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'
@ -64,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) {
@ -186,6 +197,8 @@ const DEFAULTS = {
approvalGates: true,
bootstrap: true,
bootstrapSubagents: false,
harnessCheck: true,
roleHint: true,
roleCommand: true,
selfCheck: true,
checkCommand: true,
@ -256,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
})
}
@ -322,6 +394,23 @@ export function apply(ctx, rawConfig = {}) {
})
}
// `request_approval` is the model-driven front-end of the same gate: the
// model initiates the approval, the owner only confirms in the dialog, and
// the marker is still written by lib/approval.js. `userQuestions` is
// resolved lazily, never injected — a missing service must refuse this tool
// instead of parking the whole bundle.
if (config.enabled && config.approvalGates && typeof ctx.tools?.register === 'function') {
ctx.tools.register(
createApproveTool({
getWorkingDirectory: () => ctx.workingDirectory,
getUserQuestions: () => (typeof ctx.get === 'function' ? (ctx.get('userQuestions') ?? null) : null),
logger: ctx.logger,
now: () => new Date(),
}),
)
ctx.logger?.info?.('[dsh-harness] request_approval tool registered')
}
ctx.logger?.info?.(
'[dsh-harness] ready: hooks=%s, bootstrap=%s, /role=%s',
config.guards ? hooksDir : 'off',

187
dsh/bundle/lib/approval.js Normal file
View File

@ -0,0 +1,187 @@
/**
* Shared approval logic of the two front-ends: the `/approve` command and the
* `request_approval` model tool.
*
* `runApproval` owns everything except the question itself: the upstream
* artifact checks (design needs proposal/design/builder-check, plan needs an
* approved design and exactly one plan whose **Spec:** names the change) and
* the section write (`parseApproval` -> merge -> `serializeApproval`). The
* confirmation is injected as `ask(questionText, detail) -> Promise<boolean>`,
* so no model-facing caller can write `approval.md` without the owner: the
* command asks through `userQuestions.ask`, the tool does the same on behalf
* of the model, and this module never writes without a `true` answer.
*
* `now` is injected for deterministic tests; it returns a Date, so the written
* `at` is a UTC ISO timestamp exactly as before the extraction.
*
* No DSH runtime is imported here (only Node builtins and `./gates.js`).
*/
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import {
APPROVAL_FILE,
CHANGE_DIR,
PLANS_DIR,
extractSpec,
hasDesignApproval,
hashPlan,
parseApproval,
resolveChangeFromSpec,
serializeApproval,
} from './gates.js'
/** Design approval requires all three change artifacts, as before. */
export const DESIGN_ARTIFACTS = ['proposal.md', 'design.md', 'builder-check.md']
/** Change ids are directory names: keep them one safe path segment. */
export const CHANGE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u
export const KINDS = ['design', 'plan']
function isFile(path) {
try {
return statSync(path).isFile()
} catch {
return false
}
}
function readText(path) {
try {
return readFileSync(path, 'utf8')
} catch {
return null
}
}
function refusal(text) {
return { approved: false, message: text }
}
/** Merge one section into approval.md, leaving every other section as is. */
function writeApproval(root, changeId, mutate) {
const path = join(root, CHANGE_DIR, changeId, APPROVAL_FILE)
const next = mutate(parseApproval(readText(path)))
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, serializeApproval(next), 'utf8')
return path
}
/** Existing plans under docs/superpowers/plans/ whose **Spec:** is `<id>`. */
function plansForChange(root, changeId) {
const plansDir = join(root, PLANS_DIR)
let names
try {
names = readdirSync(plansDir)
} catch {
return []
}
const found = []
for (const name of [...names].sort()) {
if (!name.endsWith('.md')) continue
const path = join(plansDir, name)
if (!isFile(path)) continue
const content = readText(path)
if (content === null) continue
if (resolveChangeFromSpec(extractSpec(content)) === changeId) found.push(path)
}
return found
}
function approvalText(root, path) {
return relative(root, path).split('\\').join('/')
}
/** Ask the injected confirmation; anything but `true` is not an approval. */
async function confirmed(ask, questionText, detail) {
const answer = await ask(questionText, detail)
return answer === true
}
async function approveDesign({ root, changeId, ask, now }) {
const changeDir = join(root, CHANGE_DIR, changeId)
const missing = DESIGN_ARTIFACTS.filter((name) => !isFile(join(changeDir, name)))
if (missing.length > 0) {
const listed = missing.map((name) => `openspec/changes/${changeId}/${name}`).join(', ')
return refusal(`design approval requires the change artifacts; missing: ${listed}.`)
}
const detail = `Change: openspec/changes/${changeId} (proposal.md, design.md, builder-check.md).`
if (!(await confirmed(ask, `Approve ${changeId} design?`, detail))) {
return refusal('not confirmed')
}
const at = new Date(now()).toISOString()
const path = writeApproval(root, changeId, (approval) => ({
...approval,
design: { by: 'owner', at },
}))
return {
approved: true,
message: `Approved ${changeId} design at ${at}; wrote ${approvalText(root, path)}.`,
}
}
async function approvePlan({ root, changeId, ask, now }) {
const approval = parseApproval(readText(join(root, CHANGE_DIR, changeId, APPROVAL_FILE)))
if (!hasDesignApproval(approval)) {
return refusal(`design not approved; run /approve ${changeId} design`)
}
const plans = plansForChange(root, changeId)
if (plans.length === 0) {
return refusal(
`no plan for ${changeId} found under ${PLANS_DIR.split('\\').join('/')}/ with **Spec:** openspec/changes/${changeId}.`,
)
}
if (plans.length > 1) {
const listed = plans.map((path) => approvalText(root, path)).join(', ')
return refusal(`several plans reference ${changeId}: ${listed}. Keep exactly one.`)
}
const [planPath] = plans
const sha256 = hashPlan(planPath)
if (sha256 === '') {
return refusal(`cannot read plan ${approvalText(root, planPath)}.`)
}
const detail = `Plan: ${approvalText(root, planPath)} (sha256 ${sha256}).`
if (!(await confirmed(ask, `Approve ${changeId} plan?`, detail))) {
return refusal('not confirmed')
}
const at = new Date(now()).toISOString()
const path = writeApproval(root, changeId, (current) => ({
...current,
plan: { by: 'owner', at, sha256 },
}))
return {
approved: true,
message: `Approved ${changeId} plan at ${at} (sha256 ${sha256}); wrote ${approvalText(root, path)}.`,
}
}
/**
* Approve `<changeId> <kind>` and write the marker.
*
* Returns `{ approved, message }`; every refusal is `approved: false` with a
* human-readable reason. The change id and kind are re-validated here (not
* only in the front-ends): the id becomes a path segment.
*/
export async function runApproval({ repoRoot, changeId, kind, ask, now = () => new Date() }) {
if (typeof changeId !== 'string' || !CHANGE_ID.test(changeId)) {
return refusal(`Invalid change id "${String(changeId)}".`)
}
if (!KINDS.includes(kind)) {
return refusal(`Unknown kind "${String(kind)}"; expected design or plan.`)
}
if (typeof ask !== 'function') {
return refusal('no confirmation function; the approval requires the owner')
}
const root = resolve(repoRoot)
if (kind === 'design') return approveDesign({ root, changeId, ask, now })
return approvePlan({ root, changeId, ask, now })
}

View File

@ -195,11 +195,11 @@ export function textOf(value) {
}
/**
* Whether this exact bootstrap marker is still visible in the session.
* Whether this exact injection marker is still visible in the session.
* Messages under consideration by the current pre-step are checked first;
* then the durable visible surface, which drops a message after compaction.
*/
export function isBootstrapVisible(agent, messages, marker) {
export function isInjectionVisible(agent, messages, marker) {
if (!marker) return false
if (Array.isArray(messages) && messages.some((message) => textOf(message).includes(marker))) return true
@ -218,3 +218,6 @@ export function isBootstrapVisible(agent, messages, marker) {
}
return false
}
/** Back-compat name for the pre-refactor bootstrap visibility check. */
export const isBootstrapVisible = isInjectionVisible

View File

@ -14,6 +14,7 @@
"command-role.js",
"command-check.js",
"command-approve.js",
"tool-approve.js",
"lib",
"dsh-tools.md",
"cordis.patch.yml",

144
dsh/bundle/tool-approve.js Normal file
View File

@ -0,0 +1,144 @@
/**
* `request_approval` — the model-driven front-end of the approval gate.
*
* The owner no longer has to type `/approve <change-id> design|plan`: the model
* calls this tool, the owner only confirms in the userQuestions dialog, and the
* approval marker is still written by bundle code (`lib/approval.js`), never by
* the model. The gates keep their invariant: `approval.md` is written only by
* the harness after an explicit owner confirmation.
*
* The definition is hand-built plain JSON — no `@deepseek-ai/*` imports (the
* bundle is installed from a local directory, so DSH internals are not
* resolvable). `parameters` is object-rooted raw JSON Schema; DSH performs no
* argument validation, so `execute` validates the args itself and always
* returns the lossless `{ approved, message }` shape.
*
* `userQuestions` is resolved lazily (injected as `getUserQuestions`) and never
* declared in `inject`: a missing service must refuse this tool, not park the
* whole bundle. Likewise `getWorkingDirectory` is a service factory, because
* the repo root depends on the receiving agent's session.
*/
import { resolve } from 'node:path'
import { CHANGE_ID, KINDS, runApproval } from './lib/approval.js'
const APPROVE_LABEL = 'Approve'
const CANCEL_LABEL = 'Cancel'
const USAGE =
"usage: request_approval { change_id: '<id>', kind: 'design' | 'plan' }"
/** Session working directory of the receiving agent; process.cwd() fallback. */
function repoRootOf(getWorkingDirectory, agent) {
try {
const workingDirectory = getWorkingDirectory?.()
const cwd = workingDirectory?.get?.(agent?.session)
if (typeof cwd === 'string' && cwd !== '') return resolve(cwd)
} catch {
// Fall through to the process directory.
}
return process.cwd()
}
function refusal(message) {
return { approved: false, message }
}
/**
* Build the tool definition.
*
* @param {{
* getWorkingDirectory?: () => { get(session: unknown): string } | undefined,
* getUserQuestions?: () => { ask(request: unknown): Promise<unknown> } | null,
* logger?: { warn?: Function },
* now?: () => Date,
* }} deps
*/
export function createApproveTool({ getWorkingDirectory, getUserQuestions, logger, now = () => new Date() } = {}) {
return {
name: 'request_approval',
description:
'Ask the owner to approve an OpenSpec change design or an implementation plan. The approval marker is written by the harness only after the owner confirms.',
parameters: {
type: 'object',
properties: {
change_id: {
type: 'string',
description: 'Change id under openspec/changes/<id>/',
},
kind: {
type: 'string',
enum: ['design', 'plan'],
description: 'What is being approved',
},
},
required: ['change_id', 'kind'],
additionalProperties: false,
},
output: {
schema: {
type: 'object',
properties: {
approved: { type: 'boolean' },
message: { type: 'string' },
},
required: ['approved', 'message'],
additionalProperties: false,
},
render(args, value) {
return [{ type: 'text', text: value.message }]
},
},
async execute(args, exec = {}) {
try {
const changeId = typeof args?.change_id === 'string' ? args.change_id.trim() : ''
const kind = args?.kind
if (changeId === '') {
return refusal(`Missing change_id. ${USAGE}`)
}
if (!CHANGE_ID.test(changeId)) {
return refusal(`Invalid change id "${changeId}". ${USAGE}`)
}
if (!KINDS.includes(kind)) {
return refusal(`Unknown kind "${String(kind)}". ${USAGE}`)
}
const userQuestions = getUserQuestions?.() ?? null
if (typeof userQuestions?.ask !== 'function') {
return refusal(
'userQuestions service unavailable; request_approval requires the owner dialog (use /approve).',
)
}
// The ask implementation of this front-end: mirrors the /approve
// dialog, but a `selected[0] === 'Approve'` answer is the only yes.
const ask = async (questionText) => {
const answer = await userQuestions.ask({
questions: [
{
id: 'approve',
question: questionText,
header: 'Approval gate',
options: [{ label: APPROVE_LABEL }, { label: CANCEL_LABEL }],
},
],
...(exec.agent ? { agent: exec.agent } : {}),
signal: exec.signal,
})
return answer?.answers?.[0]?.selected?.[0] === APPROVE_LABEL
}
return await runApproval({
repoRoot: repoRootOf(getWorkingDirectory, exec.agent),
changeId,
kind,
ask,
now,
})
} catch (error) {
logger?.warn?.('[dsh-harness] request_approval failed: %s', error?.message ?? error)
return refusal(String(error?.message ?? error))
}
},
}
}

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

@ -5,6 +5,7 @@ import { join } from 'node:path'
import test from 'node:test'
import { registerApproveCommand } from '../bundle/command-approve.js'
import { hashPlan, parseApproval, serializeApproval } from '../bundle/lib/gates.js'
import { createApproveTool } from '../bundle/tool-approve.js'
import { cleanup, tempDir, write } from './helpers.mjs'
const PLAN_TEXT = [
@ -347,3 +348,218 @@ test('approve falls back to process.cwd() when the session cwd lookup throws (T3
cleanup(tmp)
}
})
// `request_approval` (bounded #77): the model drives the approval gate, the
// owner only confirms in the userQuestions dialog, and the marker is still
// written by bundle code through the same lib/approval.js as `/approve`.
const FIXED_ISO = '2026-10-25T12:00:00.000Z'
/**
* Build the tool with fakes: `getWorkingDirectory` is the DSH service factory
* (`workingDirectory.get(session)`), `getUserQuestions` resolves lazily, and
* `now` is pinned so the written `at` is exact.
*/
function setupTool(repo, { answer, missingService = false, at = FIXED_ISO } = {}) {
const calls = []
const getUserQuestions = missingService
? () => null
: () => ({
async ask(request) {
calls.push(request)
return answer
},
})
const tool = createApproveTool({
getWorkingDirectory: () => ({ get() { return repo } }),
getUserQuestions,
logger: { warn() {} },
now: () => new Date(at),
})
return { tool, calls, run: (args, exec = {}) => tool.execute(args, exec) }
}
test('request_approval design: owner confirms, section is written by the harness', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setupTool(repo, { answer: approveAnswer('design') })
assert.equal(harness.tool.name, 'request_approval')
assert.match(harness.tool.description, /written by the harness only after the owner confirms/u)
assert.deepEqual(harness.tool.parameters.required, ['change_id', 'kind'])
assert.deepEqual(harness.tool.parameters.properties.kind.enum, ['design', 'plan'])
assert.deepEqual(harness.tool.output.render({}, { approved: true, message: 'ok' }), [
{ type: 'text', text: 'ok' },
])
const result = await harness.run({ change_id: 'demo', kind: 'design' }, { agent: { session: 's1' } })
assert.equal(result.approved, true)
assert.equal(result.message, `Approved demo design at ${FIXED_ISO}; wrote openspec/changes/demo/approval.md.`)
const question = harness.calls[0].questions[0]
assert.equal(question.question, 'Approve demo design?')
assert.equal(question.header, 'Approval gate')
assert.deepEqual(question.options.map((option) => option.label), ['Approve', 'Cancel'])
assert.equal(harness.calls[0].agent.session, 's1', 'the receiving agent is forwarded')
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
assert.equal(parsed.design.by, 'owner')
assert.equal(parsed.design.at, FIXED_ISO)
} finally {
cleanup(tmp)
}
})
test('request_approval design: cancel writes nothing', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setupTool(repo, {
answer: { answers: [{ id: 'approve', selected: ['Cancel'], custom: undefined }] },
})
const result = await harness.run({ change_id: 'demo', kind: 'design' })
assert.equal(result.approved, false)
assert.match(result.message, /not confirmed/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
test('request_approval refuses when the userQuestions service is missing', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setupTool(repo, { missingService: true })
const result = await harness.run({ change_id: 'demo', kind: 'design' })
assert.equal(result.approved, false)
assert.match(result.message, /userQuestions service unavailable/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
test('request_approval refuses an unknown kind with usage', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setupTool(repo, { answer: approveAnswer('design') })
const result = await harness.run({ change_id: 'demo', kind: 'review' })
assert.equal(result.approved, false)
assert.match(result.message, /usage: request_approval/u)
assert.equal(harness.calls.length, 0, 'the owner is not asked')
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
test('request_approval refuses an unsafe change id', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setupTool(repo, { answer: approveAnswer('design') })
const result = await harness.run({ change_id: '../x', kind: 'design' })
assert.equal(result.approved, false)
assert.match(result.message, /Invalid change id "\.\.\/x"/u)
assert.equal(harness.calls.length, 0, 'the owner is not asked')
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
test('request_approval plan writes the sha256 of the plan', async () => {
const { tmp, repo, planPath } = repoFixture()
try {
const design = { by: 'aep', at: '2026-10-25T10:00:00+04:00' }
write(approvalPath(repo), serializeApproval({ design }))
const harness = setupTool(repo, { answer: approveAnswer('plan') })
const result = await harness.run({ change_id: 'demo', kind: 'plan' })
assert.equal(result.approved, true)
assert.equal(
result.message,
`Approved demo plan at ${FIXED_ISO} (sha256 ${shaOf(planPath)}); wrote openspec/changes/demo/approval.md.`,
)
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
assert.equal(parsed.plan.by, 'owner')
assert.equal(parsed.plan.at, FIXED_ISO)
assert.equal(parsed.plan.sha256, shaOf(planPath))
assert.deepEqual(parsed.design, design, 'the design section is untouched')
} finally {
cleanup(tmp)
}
})
test('request_approval design refuses without builder-check.md (prerequisites hold)', async () => {
const { tmp, repo } = repoFixture({ builderCheck: false })
try {
const harness = setupTool(repo, { answer: approveAnswer('design') })
const result = await harness.run({ change_id: 'demo', kind: 'design' })
assert.equal(result.approved, false)
assert.match(result.message, /builder-check\.md/u)
assert.equal(harness.calls.length, 0, 'the owner is not asked before the prerequisites pass')
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
test('request_approval falls back to process.cwd() when the session cwd lookup throws', async () => {
const { tmp, repo } = repoFixture()
const previous = process.cwd()
try {
process.chdir(repo)
const tool = createApproveTool({
getWorkingDirectory: () => ({
get() {
throw new Error('cannot get property "session" without inject')
},
}),
getUserQuestions: () => ({ async ask() { return approveAnswer('design') } }),
logger: { warn() {} },
now: () => new Date(FIXED_ISO),
})
const result = await tool.execute({ change_id: 'demo', kind: 'design' }, { agent: { session: 's1' } })
assert.equal(result.approved, true)
assert.equal(existsSync(approvalPath(repo)), true, 'approval.md is written into process.cwd()')
} finally {
process.chdir(previous)
cleanup(tmp)
}
})
// Refactor guard (bounded #77): `/approve` still reports a failing
// userQuestions service as a refusal with the service's reason, and writes
// nothing. The shared runApproval only writes after a `true` answer.
test('approve reports a userQuestions failure and writes nothing', async () => {
const { tmp, repo } = repoFixture()
try {
const captured = {}
const commands = { register(definition) { Object.assign(captured, definition) } }
registerApproveCommand({
commands,
userQuestions: {
async ask() {
throw new Error('dialog closed')
},
},
workingDirectory: { get() { return repo } },
logger: { warn() {} },
})
const result = await captured.handler({ rawInput: 'demo design' })
assert.equal(result.kind, 'error')
assert.equal(result.text, 'Refused: userQuestions failed: dialog closed')
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})

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

@ -3,6 +3,7 @@ import { existsSync, readFileSync, readlinkSync, lstatSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
import { apply } from '../bundle/index.js'
const dshDir = fileURLToPath(new URL('..', import.meta.url))
const bundleDir = join(dshDir, 'bundle')
@ -15,6 +16,7 @@ test('package declares a DSH bundle patch and the runtime files', () => {
assert.equal(pkg.exports?.['.'], './index.js')
assert.ok(pkg.files.includes('index.js'))
assert.ok(pkg.files.includes('command-approve.js'))
assert.ok(pkg.files.includes('tool-approve.js'))
assert.ok(pkg.files.includes('lib'))
assert.ok(pkg.files.includes('hooks'))
assert.ok(existsSync(join(bundleDir, 'cordis.patch.yml')))
@ -58,10 +60,13 @@ test('runtime files have no external @deepseek-ai imports', () => {
'command-role.js',
'command-check.js',
'command-approve.js',
'tool-approve.js',
'lib/message.js',
'lib/guards.js',
'lib/gates.js',
'lib/approval.js',
'lib/bootstrap.js',
'lib/prestep.js',
'lib/role.js',
'lib/legacy.js',
'lib/selfcheck.js',
@ -70,3 +75,24 @@ test('runtime files have no external @deepseek-ai imports', () => {
assert.doesNotMatch(source, /from ['"]@deepseek-ai\//, `${file} must not import DSH internals`)
}
})
test('apply registers the request_approval tool when tools.register exists', () => {
const registered = []
const ctx = {
tools: {
guard() {},
register(definition) {
registered.push(definition)
},
},
workingDirectory: { get() { return process.cwd() } },
logger: { info() {}, warn() {}, error() {} },
get() { return { ask: async () => ({ answers: [] }) } },
}
apply(ctx)
assert.equal(registered.length, 1, 'one tool is registered')
assert.equal(registered[0].name, 'request_approval')
assert.deepEqual(registered[0].parameters.required, ['change_id', 'kind'])
})

View File

@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import { join } from 'node:path'
import test from 'node:test'
import { apply } from '../bundle/index.js'
import {
buildHarnessCheckText,
buildRoleHintText,
@ -212,3 +213,240 @@ test('makePrestepMarker: marker format, body prefix, determinism, uniqueness', (
assert.notEqual(makePrestepMarker('Пока').marker, marker, 'different text, different marker')
assert.notEqual(makePrestepMarker('привет').marker, marker, 'case matters')
})
// ---------------------------------------------------------------------------
// apply(): wiring of harness-check and role-hint into agent/pre-step
// ---------------------------------------------------------------------------
/** Text of the first text block of a message. */
function messageText(message) {
return message.content[0].text
}
/**
* apply() against a fake ctx: the `agent/pre-step` listener is captured and
* can be run with a canned decision. The session cwd is served by the fake
* workingDirectory; bootstrap is off in most cases so its big text does not
* pollute the message lists.
*/
function prestepHarness(config = {}, cwd) {
const listeners = {}
const ctx = {
tools: { guard() {} },
on(event, handler) { listeners[event] = handler },
inject() {},
workingDirectory: { get() { return cwd } },
logger: { info() {}, warn() {}, error() {} },
}
apply(ctx, config)
const handler = listeners['agent/pre-step']
assert.ok(handler, 'agent/pre-step listener is registered')
return {
run({ agent = {}, messages = [] } = {}) {
return handler({ agent, signal: {} }, async () => ({ kind: 'continue', messages }))
},
}
}
/** A tree with `.git` but none of the adoption signals. */
function nonAdoptedRepo() {
const repo = tempDir('dsh-harness-noadopt-')
write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n')
write(join(repo, 'CLAUDE.md'), '# Проект без секции\n')
return repo
}
function userMessage(text) {
return { content: [{ type: 'text', text }], source: { kind: 'user' } }
}
test('apply: harness-check injects when adoption signals are missing', async () => {
const repo = nonAdoptedRepo()
try {
const harness = prestepHarness({ bootstrap: false }, repo)
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
assert.equal(decision.messages.length, 1)
const text = messageText(decision.messages[0])
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
assert.match(text, /\[dsh-harness\] Проект .* не подключён к обвязке/)
assert.match(text, /openspec init --tools agents/)
assert.equal(decision.messages[0].source.kind, 'user')
} finally {
cleanup(repo)
}
})
test('apply: harness-check stays silent for an adopted repo and outside git', async () => {
const repo = adoptedRepo()
try {
const harness = prestepHarness({ bootstrap: false }, repo)
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
assert.deepEqual(decision.messages, [])
} finally {
cleanup(repo)
}
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
assert.deepEqual(decision.messages, [])
} finally {
cleanup(outside)
}
})
test('apply: role-hint injects Роль сессии: dev for a keyword user message', async () => {
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const user = userMessage('Напиши тест для модуля')
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
assert.equal(decision.messages.length, 2)
const text = messageText(decision.messages[1])
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
assert.match(text, /\[dsh-harness\] Роль сессии: dev\./)
} finally {
cleanup(outside)
}
})
test('apply: role-hint asks the four-variant question when no keyword matches', async () => {
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const user = userMessage('Привет, как дела?')
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
assert.equal(decision.messages.length, 2)
const text = messageText(decision.messages[1])
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
assert.match(text, /\[dsh-harness\] Роль сессии неясна\./)
assert.match(text, /четырьмя вариантами/)
} finally {
cleanup(outside)
}
})
test('apply: role-hint stays silent when the step has no new user message', async () => {
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
assert.deepEqual(decision.messages, [])
} finally {
cleanup(outside)
}
})
test('apply: a user message that equals the role-hint question does not re-trigger role-hint', async () => {
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const user = userMessage(buildRoleHintText(null))
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
assert.deepEqual(decision.messages, [user], 'isHarnessMessage filters before detectRole')
} finally {
cleanup(outside)
}
})
test('apply: a [claude-harness]-prefixed user message is skipped by role-hint', async () => {
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const user = userMessage('[claude-harness] Роль сессии: dev. Вызови skill role.')
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
assert.deepEqual(decision.messages, [user], 'legacy prefix is filtered too')
} finally {
cleanup(outside)
}
})
test('apply: bootstrap:false leaves harness-check wired (the gate is ctx.on, not bootstrapOk)', async () => {
const repo = nonAdoptedRepo()
try {
const harness = prestepHarness({ bootstrap: false, roleHint: false }, repo)
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
assert.equal(decision.messages.length, 1)
assert.match(messageText(decision.messages[0]), /не подключён к обвязке/)
} finally {
cleanup(repo)
}
})
// ---------------------------------------------------------------------------
// 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

@ -23,7 +23,7 @@ ensure_bundle_hooks() {
ensure_bundle_hooks
for f in dsh/bundle/index.js dsh/bundle/command-role.js dsh/bundle/command-check.js dsh/bundle/command-approve.js dsh/bundle/lib/*.js dsh/tests/*.mjs; do
for f in dsh/bundle/index.js dsh/bundle/command-role.js dsh/bundle/command-check.js dsh/bundle/command-approve.js dsh/bundle/tool-approve.js dsh/bundle/lib/*.js dsh/tests/*.mjs; do
node --check "$f"
done

View File

@ -4,5 +4,5 @@ design:
plan:
by: owner
at: 2026-10-25T07:29:41.328Z
sha256: 94a10b74e44baec7d257de3dff992c1493fc46c3192511cc22867362c88cbd1d
at: 2026-10-25T07:34:55.231Z
sha256: dd03b3ad25b29024bbca9b0957a07020b316bd52a1aaa9e81df69ff17fd975e2

View File

@ -1,7 +1,9 @@
# Design: dsh-prestep — harness-check и role-hint через agent/pre-step
Issue: #43 (+#62). Статус: черновик до builder-check; утверждение —
`/approve dsh-prestep design`.
Issue: #43 (+#62). Статус: утверждён владельцем 25.10.2026
(`/approve dsh-prestep design`); реализация — ветка `feat/43-dsh-prestep`,
статусы решений — в конце файла. Спека-дельта —
`openspec/changes/dsh-prestep/specs/session-prestep/spec.md`.
## Контекст
@ -11,9 +13,11 @@ CC-хуки harness-check/role-hint — SessionStart; в DSH аналог — и
## Механика
Обе инъекции — раз за сессию, с маркером `<!-- DSH-HARNESS-PRESTEP:<hash> -->`
и тем же механизмом видимости, что у bootstrap (`isBootstrapVisible`
переименован в общий `isInjectionVisible(agent, messages, marker)`).
Каждая инъекция — по содержимому и маркеру (per-text dedupe); повторная
инъекция с другим текстом возможна — см. D-11. Маркер
`<!-- DSH-HARNESS-PRESTEP:<hash> -->` и тот же механизм видимости, что у
bootstrap (`isBootstrapVisible` переименован в общий
`isInjectionVisible(agent, messages, marker)`).
Порядок в pre-step: другие инъекции → harness-check → role-hint (обе
неблокирующие; reject/abort — пропуск; сабагенты — пропуск, как
bootstrap).
@ -71,6 +75,7 @@ bootstrap).
| 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-флаг потребовал бы нового состояния и не переживал бы компакцию |
## Тесты (идеи для плана)
@ -89,3 +94,23 @@ bootstrap).
- Разбивка: 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

@ -27,7 +27,7 @@ AGENTS.md). Плюс три мелких follow-ups из #42 (#62: T2.1 — PATH
скриншот/макет → qa; ТЗ/OQ/спека/методика → analyst; срез/issue/PR/
тест/миграция → dev) → инъекция `[dsh-harness] Роль сессии: <r>` с
указанием вызвать `skill role`; нет совпадения — инъекция вопроса с
четырьмя вариантами. Один раз за сессию. Конфиг `roleHint: true`.
четырьмя вариантами. Дедуп — по содержимому (per-text, см. D-11). Конфиг `roleHint: true`.
Заголовка сессии в DSH нет — сигнал берём из первого сообщения
(ровно то правило, что уже написано в AGENTS.md).
3. **Follow-ups #62** (задачами этого же change):

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

@ -16,9 +16,11 @@
builder-check субагента, скопированный в change контроллером; он MUST быть записан
после builder-check и до передачи дизайна владельцу.
`openspec/changes/<id>/approval.md` SHALL быть маркером утверждения владельцем;
его единственный писатель — команда `/approve` (D-1), инструменты модели писать
его MUST NOT (гейт C ниже).
`openspec/changes/<id>/approval.md` SHALL быть маркером утверждения владельцем.
Писать его MUST только код бандла — команда `/approve` или инструмент
`request_approval` (D-1), и оба MUST писать только после подтверждения владельца.
Прямые записи модели (`write`, `edit`, `multi_edit`, `str_replace_editor`, `bash`)
MUST NOT создавать или менять этот файл (гейт C ниже, без изменений).
Формат `approval.md` — yaml с секциями `design:` (поля `by`, `at`) и `plan:`
(поля `by`, `at`, `sha256`); `sha256` — hex-хэш sha256 содержимого файла плана на
@ -42,11 +44,14 @@ builder-check субагента, скопированный в change конт
### Requirement: Команда /approve
`/approve <change-id> design|plan` SHALL быть единственным писателем
`approval.md` и MUST регистрироваться при `enabled` и `approvalGates: true`,
независимо от флагов `roleCommand` и `checkCommand`: иначе гейты остаются без
команды-разблокировки (T3.2). `change-id` MUST быть одним безопасным сегментом
пути; небезопасный id или неизвестный вид — usage-отказ без записи.
`/approve <change-id> design|plan` SHALL быть ручным писателем `approval.md` —
одним из двух писателей бандла, наравне с инструментом `request_approval`
(следующее требование); оба пишут только после подтверждения владельца, прямые
записи модели — MUST NOT (гейт C). Команда MUST регистрироваться при `enabled` и
`approvalGates: true`, независимо от флагов `roleCommand` и `checkCommand`: иначе
гейты остаются без команды-разблокировки (T3.2). `change-id` MUST быть одним
безопасным сегментом пути; небезопасный id или неизвестный вид — usage-отказ без
записи.
Вид `design` SHALL требовать существования `proposal.md`, `design.md` и
`builder-check.md` в `openspec/changes/<id>/`; при отсутствии файла команда
@ -61,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
@ -93,6 +103,82 @@ 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`:
модель инициирует утверждение, владелец только подтверждает в диалоге, а маркер
пишет код бандла (`lib/approval.js`) — с теми же проверками предпосылок и той же
записью, что у `/approve`. Инструмент MUST регистрироваться при `enabled` и
`approvalGates: true`, если в контексте доступен `ctx.tools.register`;
`userQuestions` MUST резолвиться лениво (не через `inject`), чтобы отсутствие
сервиса не парковало весь бандл.
Параметры инструмента — `change_id` (строка) и `kind` (`design` | `plan`).
Валидация MUST быть собственной, в инструменте: пустой или небезопасный
`change_id` и неизвестный `kind` — usage-отказ без записи и без вопроса
владельцу; проверки предпосылок и отказы те же, что у `/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`), а не писать.
#### Scenario: Модель просит утвердить дизайн
- **WHEN** модель вызывает `request_approval` с `{change_id: <id>, kind: design}`,
предпосылки выполнены, а владелец выбрал Approve
- **THEN** в `approval.md` пишется design-секция (`by: owner`, `at` в ISO 8601
UTC), инструмент возвращает `{approved: true, message}`
#### Scenario: Модель просит утвердить план
- **WHEN** модель вызывает `request_approval` с `{change_id: <id>, kind: plan}`,
дизайн утверждён, план единственный, владелец выбрал Approve
- **THEN** plan-секция содержит `by: owner`, `at` в ISO 8601 и sha256 текущего
содержимого плана
#### Scenario: Владелец отменил утверждение
- **WHEN** в диалоге `request_approval` владелец выбрал Cancel
- **THEN** инструмент возвращает `{approved: false, message: 'not confirmed'}`, а
`approval.md` не меняется
#### Scenario: Некорректные аргументы request_approval
- **WHEN** модель передала неизвестный `kind` или небезопасный `change_id`
(например, `../x`)
- **THEN** инструмент возвращает usage-отказ без записи, владелец не спрашивается
#### Scenario: Отсутствует builder-check при утверждении дизайна моделью
- **WHEN** `kind: design`, а `builder-check.md` в change нет
- **THEN** инструмент отказывает с перечислением отсутствующего файла, владелец не
спрашивается, `approval.md` не создаётся
#### Scenario: request_approval plan без утверждённого дизайна
- **WHEN** `kind: plan`, а design-секции нет
- **THEN** инструмент отказывает `design not approved; run /approve <id> design`,
владелец не спрашивается, `approval.md` не меняется
#### Scenario: request_approval без сервиса вопросов
- **WHEN** `userQuestions` отсутствует
- **THEN** инструмент возвращает `{approved: false}` с `userQuestions service
unavailable`, владелец не спрашивается, `approval.md` не пишется
### Requirement: Гейт A — план до утверждения дизайна
Для write/edit/multi_edit/`str_replace_editor` с целевым путём под
@ -231,9 +317,12 @@ fail closed. Фрагмент `edit` проверяется по своему `n
### Requirement: Гейт C и bash-обход
`approval.md` MUST писаться только командой `/approve`: любой write/edit с целью,
чей basename — `approval.md`, и любая bash-цель с таким именем MUST получать
отказ `approval.md is written only by /approve` (D-1).
`approval.md` MUST писаться только кодом бандла — командой `/approve` или
инструментом `request_approval`: любой write/edit с целью, чей basename —
`approval.md`, и любая bash-цель с таким именем MUST получать отказ `approval.md
is written only by /approve` (D-1). Deny-текст не меняется: он адресован прямым
записям модели (`write`/`edit`/`bash`), а `request_approval` пишет маркер кодом
бандла и под этот запрет не попадает.
Bash-обход MUST быть закрыт (D-8): если команда содержит грубую подстроку
`docs/superpowers/plans`, `approval.md` или `.superpowers/sdd`, её файловые цели