Compare commits
13 Commits
6e0828e6e7
...
557005219c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
557005219c | ||
|
|
c99635f318 | ||
|
|
7a5d7745f5 | ||
|
|
727a9dd9bc | ||
|
|
6800550cd5 | ||
|
|
183f8d8956 | ||
|
|
622dd32e1a | ||
|
|
1d35a3be2c | ||
|
|
a2453e35e8 | ||
|
|
37daa97915 | ||
| fc18bbd976 | |||
|
|
b81afaf59a | ||
|
|
1723806e16 |
67
dsh/PORT.md
67
dsh/PORT.md
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}`)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
187
dsh/bundle/lib/approval.js
Normal 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 })
|
||||
}
|
||||
7
dsh/bundle/lib/bootstrap.js
vendored
7
dsh/bundle/lib/bootstrap.js
vendored
@ -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
|
||||
|
||||
@ -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
144
dsh/bundle/tool-approve.js
Normal 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))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'])
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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):
|
||||
|
||||
272
openspec/changes/dsh-prestep/specs/session-prestep/spec.md
Normal file
272
openspec/changes/dsh-prestep/specs/session-prestep/spec.md
Normal 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** в нём есть требование обязательного падающего теста до кода, и это
|
||||
требование закреплено тестом
|
||||
@ -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`, её файловые цели
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user