diff --git a/dsh/bundle/lib/approval.js b/dsh/bundle/lib/approval.js index 3980a4e..f3b0a05 100644 --- a/dsh/bundle/lib/approval.js +++ b/dsh/bundle/lib/approval.js @@ -18,12 +18,15 @@ */ import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' -import { dirname, join, relative, resolve } from 'node:path' +import { dirname, join, relative } from 'node:path' import { APPROVAL_FILE, CHANGE_DIR, PLANS_DIR, + candidateRoots, extractSpec, + findChangeRoot, + findRepoRoot, hasDesignApproval, hashPlan, parseApproval, @@ -68,24 +71,29 @@ function writeApproval(root, changeId, mutate) { return path } -/** Existing plans under docs/superpowers/plans/ whose **Spec:** is ``. */ +/** + * Existing plans whose **Spec:** is ``, across all root candidates (the + * session repository, then its linked worktrees; names sorted inside a root). + */ 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) + for (const candidate of candidateRoots(root)) { + const plansDir = join(candidate, PLANS_DIR) + let names + try { + names = readdirSync(plansDir) + } catch { + continue + } + + 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 } @@ -101,9 +109,12 @@ async function confirmed(ask, questionText, detail) { } 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 changeRoot = findChangeRoot(root, changeId) + const missing = + changeRoot === null + ? [...DESIGN_ARTIFACTS] + : DESIGN_ARTIFACTS.filter((name) => !isFile(join(changeRoot, CHANGE_DIR, changeId, name))) + if (changeRoot === null || missing.length > 0) { const listed = missing.map((name) => `openspec/changes/${changeId}/${name}`).join(', ') return refusal(`design approval requires the change artifacts; missing: ${listed}.`) } @@ -114,22 +125,26 @@ async function approveDesign({ root, changeId, ask, now }) { } const at = new Date(now()).toISOString() - const path = writeApproval(root, changeId, (approval) => ({ + const path = writeApproval(changeRoot, changeId, (approval) => ({ ...approval, design: { by: 'owner', at }, })) return { approved: true, - message: `Approved ${changeId} design at ${at}; wrote ${approvalText(root, path)}.`, + message: `Approved ${changeId} design at ${at}; wrote ${approvalText(changeRoot, path)}.`, } } async function approvePlan({ root, changeId, ask, now }) { - const approval = parseApproval(readText(join(root, CHANGE_DIR, changeId, APPROVAL_FILE))) + // The design marker lives with the change: in the session root or a worktree. + const changeRoot = findChangeRoot(root, changeId) ?? root + const approval = parseApproval(readText(join(changeRoot, CHANGE_DIR, changeId, APPROVAL_FILE))) if (!hasDesignApproval(approval)) { return refusal(`design not approved; run /approve ${changeId} design`) } + // Plans may live in different roots; the listing is rooted at the session + // root, so a worktree plan shows its `.claude/worktrees//` prefix. const plans = plansForChange(root, changeId) if (plans.length === 0) { return refusal( @@ -153,13 +168,13 @@ async function approvePlan({ root, changeId, ask, now }) { } const at = new Date(now()).toISOString() - const path = writeApproval(root, changeId, (current) => ({ + const path = writeApproval(changeRoot, changeId, (current) => ({ ...current, plan: { by: 'owner', at, sha256 }, })) return { approved: true, - message: `Approved ${changeId} plan at ${at} (sha256 ${sha256}); wrote ${approvalText(root, path)}.`, + message: `Approved ${changeId} plan at ${at} (sha256 ${sha256}); wrote ${approvalText(changeRoot, path)}.`, } } @@ -181,7 +196,7 @@ export async function runApproval({ repoRoot, changeId, kind, ask, now = () => n return refusal('no confirmation function; the approval requires the owner') } - const root = resolve(repoRoot) + const root = findRepoRoot(repoRoot) if (kind === 'design') return approveDesign({ root, changeId, ask, now }) return approvePlan({ root, changeId, ask, now }) } diff --git a/dsh/bundle/lib/gates.js b/dsh/bundle/lib/gates.js index 8500aa6..2f55f28 100644 --- a/dsh/bundle/lib/gates.js +++ b/dsh/bundle/lib/gates.js @@ -13,14 +13,20 @@ */ import { createHash } from 'node:crypto' -import { readFileSync, statSync } from 'node:fs' -import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' export const APPROVAL_FILE = 'approval.md' export const PLANS_DIR = join('docs', 'superpowers', 'plans') export const CHANGE_DIR = join('openspec', 'changes') export const SDD_DIR = join('.superpowers', 'sdd') +/** + * Subdirectories of a repository that may hold linked worktrees. A session in + * the main checkout must see the change/plan that live in one of them. + */ +const WORKTREE_DIRS = [join('.claude', 'worktrees'), '.wt'] + const SPEC_DENY = 'plan must carry **Spec:** pointing to openspec/changes/' const APPROVAL_DENY = 'approval.md is written only by /approve' const PLAN_NO_APPROVAL = 'no plan approval' @@ -79,6 +85,64 @@ function isFile(path) { } } +export function isDir(path) { + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +/** + * Nearest ancestor of `dir` (inclusive) that carries a `.git` entry: a + * directory in a normal checkout, a file in a linked worktree. Falls back to + * `dir` itself when nothing is found, so the gates also work in a fixture + * repository that was never `git init`-ed. + */ +export function findRepoRoot(dir) { + const start = resolve(dir || '.') + for (let current = start; ; current = dirname(current)) { + if (existsSync(join(current, '.git'))) return current + if (dirname(current) === current) return start + } +} + +/** + * Roots the artifacts may live in, in a deterministic order: the repository + * root of the session, then its linked worktrees under `.claude/worktrees/*` + * and `.wt/*` (existing directories with a `.git` entry, names sorted). + */ +export function candidateRoots(repoRoot) { + const root = findRepoRoot(repoRoot) + const roots = [root] + for (const wtdir of WORKTREE_DIRS) { + let entries + try { + entries = readdirSync(join(root, wtdir), { withFileTypes: true }) + } catch { + continue + } + const names = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + for (const name of names) { + const candidate = join(root, wtdir, name) + if (existsSync(join(candidate, '.git'))) roots.push(candidate) + } + } + return roots +} + +/** First candidate root whose `openspec/changes/` is a directory, or null. */ +export function findChangeRoot(repoRoot, changeId) { + if (typeof changeId !== 'string' || changeId === '') return null + for (const candidate of candidateRoots(repoRoot)) { + if (isDir(join(candidate, CHANGE_DIR, changeId))) return candidate + } + return null +} + function readText(path) { try { return readFileSync(path, 'utf8') @@ -216,7 +280,8 @@ export function resolveChangeFromSpec(specPath) { /** * Plan file behind an sdd ledger path: `.superpowers/sdd/(/...)` -> - * `/docs/superpowers/plans/.md`, when that file exists. + * `/docs/superpowers/plans/.md`, first existing root among + * `candidateRoots(repoRoot)` (the session root, then its linked worktrees). */ export function resolvePlanFromSdd(sddPath, repoRoot) { if (typeof sddPath !== 'string' || sddPath === '' || !repoRoot) return null @@ -224,8 +289,11 @@ export function resolvePlanFromSdd(sddPath, repoRoot) { if (!match) return null const name = match[1].replace(/[/.,;:]+$/u, '') if (name === '' || name === '.' || name === '..') return null - const planPath = join(resolve(repoRoot), PLANS_DIR, `${name}.md`) - return isFile(planPath) ? planPath : null + for (const root of candidateRoots(repoRoot)) { + const planPath = join(root, PLANS_DIR, `${name}.md`) + if (isFile(planPath)) return planPath + } + return null } /** sha256 hex of the plan content; '' when the file cannot be read. */ @@ -282,11 +350,13 @@ export function checkPlanWrite({ filePath, content, repoRoot } = {}) { if (typeof filePath !== 'string' || filePath === '') return {} const root = resolve(repoRoot || '.') const target = resolve(root, filePath) - if (!isInside(join(root, PLANS_DIR), target)) return {} + const roots = candidateRoots(root) + if (!roots.some((candidate) => isInside(join(candidate, PLANS_DIR), target))) return {} const changeId = resolveChangeFromSpec(extractSpec(content)) if (changeId === null) return { deny: SPEC_DENY } - if (!hasDesignApproval(readApproval(root, changeId))) { + const changeRoot = findChangeRoot(root, changeId) ?? root + if (!hasDesignApproval(readApproval(changeRoot, changeId))) { return { deny: `design not approved; run /approve ${changeId} design` } } return {} @@ -340,6 +410,22 @@ function denyPlanNotApproved(changeId) { return { deny: `plan not approved; run /approve ${changeId ?? ''} plan` } } +/** + * Repository-relative plan path of a direct reference: the session root first, + * then the linked worktrees (first existing file). When no root has the file, + * the session-root path is returned so the caller fails closed as before. + */ +function directPlanPath(reference, repoRoot) { + const local = resolve(repoRoot, reference.path) + if (isFile(local)) return local + for (const candidate of candidateRoots(repoRoot)) { + if (candidate === repoRoot) continue + const planPath = resolve(candidate, reference.path) + if (isFile(planPath)) return planPath + } + return local +} + /** Plan path a parsed reference points to (the file may be missing). */ function referencePlanPath(reference, repoRoot) { if (typeof reference.name === 'string') { @@ -348,13 +434,14 @@ function referencePlanPath(reference, repoRoot) { join(repoRoot, PLANS_DIR, `${reference.name}.md`) ) } - return isAbsolute(reference.path) ? resolve(reference.path) : resolve(repoRoot, reference.path) + return isAbsolute(reference.path) ? resolve(reference.path) : directPlanPath(reference, repoRoot) } /** * Full gate-B chain for one referenced plan: read it, take its `**Spec:**`, * resolve the change, verify the plan approval against the file content. - * Missing/spec-less plans fail closed. + * Missing/spec-less plans fail closed. The approval is read from the root + * where the change lives (a plan may live in a linked worktree). */ function planVerdict(repoRoot, planPath) { const content = readText(planPath) @@ -362,7 +449,8 @@ function planVerdict(repoRoot, planPath) { const changeId = resolveChangeFromSpec(extractSpec(content)) if (changeId === null) return denyPlanNotApproved() - const verdict = planApproved(readApproval(repoRoot, changeId), planPath) + const changeRoot = findChangeRoot(repoRoot, changeId) ?? repoRoot + const verdict = planApproved(readApproval(changeRoot, changeId), planPath) if (verdict.ok) return {} if (verdict.reason === PLAN_NO_APPROVAL) return denyPlanNotApproved(changeId) return { deny: PLAN_CHANGED_DENY } @@ -394,8 +482,10 @@ export function checkSddWrite({ filePath, repoRoot } = {}) { if (typeof filePath !== 'string' || filePath === '') return {} const root = resolve(repoRoot || '.') const target = resolve(root, filePath) - const sddDir = join(root, SDD_DIR) - if (!isInside(sddDir, target)) return {} + const sddDir = candidateRoots(root) + .map((candidate) => join(candidate, SDD_DIR)) + .find((dir) => isInside(dir, target)) + if (sddDir === undefined) return {} const segments = relative(sddDir, target).split(sep) if (segments.length < 2) return {} diff --git a/dsh/tests/approve.test.mjs b/dsh/tests/approve.test.mjs index 0976d2c..ff8f534 100644 --- a/dsh/tests/approve.test.mjs +++ b/dsh/tests/approve.test.mjs @@ -41,6 +41,23 @@ function approvalPath(repo) { return join(repo, 'openspec', 'changes', 'demo', 'approval.md') } +/** + * A session repository (no `.git`) whose demo change and plan live in a linked + * worktree `/.claude/worktrees/foo` (its `.git` is a file). + */ +function worktreeFixture() { + const tmp = tempDir('dsh-approve-wt-') + const repo = join(tmp, 'repo') + const wt = join(repo, '.claude', 'worktrees', 'foo') + write(join(wt, '.git'), 'gitdir: /fixture/.git/worktrees/foo\n') + write(join(wt, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n') + write(join(wt, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n') + write(join(wt, 'openspec', 'changes', 'demo', 'builder-check.md'), '# Check\n') + const planPath = join(wt, 'docs', 'superpowers', 'plans', 'plan-x.md') + write(planPath, PLAN_TEXT) + return { tmp, repo, wt, planPath } +} + /** An ask() answer batch shaped like the DSH UserQuestionService result. */ function approveAnswer(kind = 'design') { return { answers: [{ id: `approve-${kind}`, selected: [APPROVE], custom: undefined }] } @@ -563,3 +580,74 @@ test('approve reports a userQuestions failure and writes nothing', async () => { cleanup(tmp) } }) + +// issue #76: `/approve` resolves the change and the plan across the linked +// worktrees of the session repository (`.claude/worktrees/*`, `.wt/*`). + +test('approve design finds a change that lives in a linked worktree', async () => { + const { tmp, repo, wt } = worktreeFixture() + try { + const harness = setup(repo, { answer: approveAnswer('design') }) + const result = await harness.run('demo design') + + assert.equal(result.kind, 'success') + assert.equal(harness.calls.length, 1, 'the owner is asked once') + const parsed = parseApproval(readFileSync(approvalPath(wt), 'utf8')) + assert.equal(parsed.design.by, 'owner') + assert.match(parsed.design.at, ISO) + assert.equal(existsSync(approvalPath(repo)), false, 'the main checkout is untouched') + } finally { + cleanup(tmp) + } +}) + +test('approve plan finds the plan in a worktree and writes sha256 into the change worktree', async () => { + const { tmp, repo, wt, planPath } = worktreeFixture() + try { + const design = { by: 'aep', at: '2026-10-25T10:00:00+04:00' } + write(approvalPath(wt), serializeApproval({ design })) + const before = readFileSync(approvalPath(wt), 'utf8') + + const harness = setup(repo, { answer: approveAnswer('plan') }) + const result = await harness.run('demo plan') + + assert.equal(result.kind, 'success') + const parsed = parseApproval(readFileSync(approvalPath(wt), 'utf8')) + assert.equal(parsed.plan.by, 'owner') + assert.match(parsed.plan.at, ISO) + assert.equal(parsed.plan.sha256, shaOf(planPath)) + assert.deepEqual(parsed.design, design, 'the design section is untouched') + assert.notEqual(readFileSync(approvalPath(wt), 'utf8'), before) + assert.equal(existsSync(approvalPath(repo)), false, 'the main checkout is untouched') + } finally { + cleanup(tmp) + } +}) + +test('approve plan sees plans in different roots and refuses with both', async () => { + const { tmp, repo, wt } = worktreeFixture() + try { + write(join(repo, 'docs', 'superpowers', 'plans', 'plan-y.md'), PLAN_TEXT) + write( + approvalPath(wt), + serializeApproval({ design: { by: 'aep', at: '2026-10-25T10:00:00+04:00' } }), + ) + const before = readFileSync(approvalPath(wt), 'utf8') + + const harness = setup(repo, { answer: approveAnswer('plan') }) + const result = await harness.run('demo plan') + + assert.equal(result.kind, 'error') + assert.match(result.text, /plan-x\.md/u) + assert.match(result.text, /plan-y\.md/u) + assert.match(result.text, /\.claude\/worktrees\/foo/u, 'the worktree plan is shown with its root') + assert.ok( + result.text.indexOf('plan-y.md') < result.text.indexOf('plan-x.md'), + 'plans are listed by root (session root first), then by name', + ) + assert.equal(readFileSync(approvalPath(wt), 'utf8'), before, 'approval.md is untouched') + assert.equal(harness.calls.length, 0, 'the owner is not asked when the plan is ambiguous') + } finally { + cleanup(tmp) + } +}) diff --git a/dsh/tests/gates.test.mjs b/dsh/tests/gates.test.mjs index 8ed1e26..6eb390d 100644 --- a/dsh/tests/gates.test.mjs +++ b/dsh/tests/gates.test.mjs @@ -4,13 +4,17 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import test from 'node:test' import { + candidateRoots, checkApprovalWrite, checkBashTargets, checkDispatch, checkPlanWrite, checkSddWrite, extractSpec, + findChangeRoot, + findRepoRoot, hashPlan, + isDir, parseApproval, planApproved, resolveChangeFromSpec, @@ -48,6 +52,25 @@ function approvalPath(repo) { return join(repo, 'openspec', 'changes', 'demo', 'approval.md') } +/** + * A session repository (no `.git`, as everywhere in these fixtures) whose demo + * change and plan live only in linked worktrees: `.claude/worktrees/foo` and + * `.wt/bar`, each with a `.git` *file* exactly like a real linked worktree. + */ +function worktreeFixture() { + const tmp = tempDir('dsh-gates-wt-') + const repo = join(tmp, 'repo') + const foo = join(repo, '.claude', 'worktrees', 'foo') + const bar = join(repo, '.wt', 'bar') + write(join(foo, '.git'), 'gitdir: /fixture/.git/worktrees/foo\n') + write(join(bar, '.git'), 'gitdir: /fixture/.git/worktrees/bar\n') + write(join(foo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n') + write(join(foo, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n') + const planPath = join(foo, 'docs', 'superpowers', 'plans', 'plan-x.md') + write(planPath, PLAN_TEXT) + return { tmp, repo, foo, bar, planPath } +} + /** Independent sha256, so the tests do not trust the implementation's own io. */ function shaOf(path) { return createHash('sha256').update(readFileSync(path)).digest('hex') @@ -466,3 +489,145 @@ test('checkPlanWrite: empty design section counts as not approved', () => { cleanup(tmp) } }) + +// issue #76: a session in the main checkout must still see the change and the +// plan of a linked worktree (`/.claude/worktrees//`, `/.wt//`). + +test('findRepoRoot: nearest .git upward, fallback to the start directory', () => { + const tmp = tempDir('dsh-gates-root-') + try { + const repo = join(tmp, 'repo') + write(join(repo, '.git'), 'gitdir: /fixture/.git/worktrees/repo\n') + const nested = join(repo, 'a', 'b') + write(join(nested, 'file.txt'), 'x\n') + assert.equal(findRepoRoot(nested), repo) + assert.equal(findRepoRoot(repo), repo) + + const plain = join(tmp, 'plain') + write(join(plain, 'file.txt'), 'x\n') + assert.equal(findRepoRoot(plain), plain, 'no .git anywhere -> the dir itself') + } finally { + cleanup(tmp) + } +}) + +test('candidateRoots: existing worktrees under .claude/worktrees and .wt, sorted', () => { + const { tmp, repo, foo, bar } = worktreeFixture() + try { + const aaa = join(repo, '.wt', 'aaa') + write(join(aaa, '.git'), 'gitdir: /fixture/.git/worktrees/aaa\n') + write(join(repo, '.claude', 'worktrees', 'plain', 'README.md'), '# no .git\n') + assert.deepEqual(candidateRoots(repo), [repo, foo, aaa, bar]) + } finally { + cleanup(tmp) + } +}) + +test('isDir: directories only', () => { + const { tmp, repo } = repoFixture() + try { + assert.equal(isDir(repo), true) + assert.equal(isDir(join(repo, 'openspec')), true) + assert.equal(isDir(join(repo, 'openspec', 'changes', 'demo', 'proposal.md')), false) + assert.equal(isDir(join(repo, 'ghost')), false) + } finally { + cleanup(tmp) + } +}) + +test('findChangeRoot: change in a linked worktree', () => { + const { tmp, repo, foo } = worktreeFixture() + try { + assert.equal(findChangeRoot(repo, 'demo'), foo) + assert.equal(findChangeRoot(repo, 'ghost'), null) + } finally { + cleanup(tmp) + } +}) + +test('findChangeRoot: the session root wins over a worktree copy', () => { + const { tmp, repo } = worktreeFixture() + try { + write(join(repo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n') + assert.equal(findChangeRoot(repo, 'demo'), repo) + } finally { + cleanup(tmp) + } +}) + +test('checkPlanWrite: change and plan in a worktree, design approved there -> ok', () => { + const { tmp, repo, foo, planPath } = worktreeFixture() + try { + writeApproval(foo) + const result = checkPlanWrite({ filePath: planPath, content: PLAN_TEXT, repoRoot: repo }) + assert.equal(result.deny, undefined) + } finally { + cleanup(tmp) + } +}) + +test('checkPlanWrite: change in a worktree without design approval -> deny', () => { + const { tmp, repo, planPath } = worktreeFixture() + try { + const result = checkPlanWrite({ filePath: planPath, content: PLAN_TEXT, repoRoot: repo }) + assert.equal(result.deny, 'design not approved; run /approve demo design') + } finally { + cleanup(tmp) + } +}) + +test('checkDispatch: plan only in a worktree, approved there -> ok', () => { + const { tmp, repo, foo, planPath } = worktreeFixture() + try { + writeApproval(foo, { planSha: shaOf(planPath) }) + const result = checkDispatch({ + prompt: 'Implement task 1. Read docs/superpowers/plans/plan-x.md first.', + repoRoot: repo, + }) + assert.equal(result.deny, undefined) + } finally { + cleanup(tmp) + } +}) + +test('checkDispatch: plan only in a worktree, no approval -> deny', () => { + const { tmp, repo } = worktreeFixture() + try { + const result = checkDispatch({ + prompt: 'Implement task 1. Read docs/superpowers/plans/plan-x.md first.', + repoRoot: repo, + }) + assert.equal(result.deny, 'plan not approved; run /approve demo plan') + } finally { + cleanup(tmp) + } +}) + +test('checkSddWrite: plan only in a worktree, approved there -> ok', () => { + const { tmp, repo, foo, planPath } = worktreeFixture() + try { + writeApproval(foo, { planSha: shaOf(planPath) }) + for (const ledgerRoot of [repo, foo]) { + const result = checkSddWrite({ + filePath: join(ledgerRoot, '.superpowers', 'sdd', 'plan-x', 'progress.md'), + repoRoot: repo, + }) + assert.equal(result.deny, undefined, `ledger under ${ledgerRoot}`) + } + } finally { + cleanup(tmp) + } +}) + +test('checkSddWrite: plan only in a worktree, no approval -> deny', () => { + const { tmp, repo } = worktreeFixture() + try { + const result = checkSddWrite({ + filePath: join(repo, '.superpowers', 'sdd', 'plan-x', 'progress.md'), + repoRoot: repo, + }) + assert.equal(result.deny, 'plan not approved; run /approve demo plan') + } finally { + cleanup(tmp) + } +}) diff --git a/openspec/specs/approval-gates/spec.md b/openspec/specs/approval-gates/spec.md index 2fcf7ea..5a215ac 100644 --- a/openspec/specs/approval-gates/spec.md +++ b/openspec/specs/approval-gates/spec.md @@ -97,6 +97,21 @@ MUST NOT создавать план и не трогать `proposal.md`, `desi - **THEN** plan-секция содержит `by: owner`, `at` в ISO 8601 и sha256 содержимого плана +#### Scenario: /approve design при change в связанном worktree + +- **WHEN** change `openspec/changes//` с proposal, design и builder-check + существует в `/.claude/worktrees//` (или `.wt//`), а сессия + работает в основном checkout +- **THEN** команда находит артефакты и пишет `approval.md` в change этого + worktree + +#### Scenario: /approve plan при плане в связанном worktree + +- **WHEN** план со `**Spec:**` на change лежит в `docs/superpowers/plans/` + связанного worktree +- **THEN** команда находит его, фиксирует sha256 и пишет plan-секцию в + `approval.md` change этого worktree + #### Scenario: /approve без сервиса вопросов - **WHEN** `ctx.userQuestions` отсутствует @@ -275,10 +290,11 @@ fail closed. Фрагмент `edit` проверяется по своему `n Вызов без план-ссылки MUST NOT гейтиться. Разбор промпта — эвристика по подстрокам, поэтому разбор fail open: вызов, в котором ссылка не распознана, не -блокируется. Диспатч-гейт резолвит ссылки против cwd сессии и игнорирует -`arguments.cwd`: диспатч с cwd в другом worktree и ссылкой на план этого worktree -получает отказ (fail closed, over-blocking, T3.8) — план должен быть утверждён в -репозитории сессии. +блокируется. Ссылки на план резолвятся от корня репозитория сессии: сначала +файл ищется в самом корне, затем в связанных worktree `.claude/worktrees/*` и +`.wt/*` (первый существующий файл); `arguments.cwd` игнорируется. План, +найденный в связанном worktree, гейтится по `approval.md` своего change — в том +же worktree. #### Scenario: Ledger до утверждения плана @@ -292,6 +308,14 @@ fail closed. Фрагмент `edit` проверяется по своему `n plan-секцией - **THEN** диспатч разрешён +#### Scenario: План в связанном worktree + +- **WHEN** промпт диспатча ссылается на `docs/superpowers/plans/<имя>.md`, который + существует только в связанном worktree сессии +- **THEN** гейт проверяет approval этого плана в change того же worktree; + утверждённый план разрешает диспатч, без approval — отказ + `plan not approved; run /approve plan` + #### Scenario: Один из двух планов не утверждён - **WHEN** промпт `subagent` ссылается на два плана, и второй не утверждён @@ -360,6 +384,11 @@ write-гейт B; возвращается первый отказ. Сбой з Bounded-задача без change и плана MUST NOT гейтиться (D-6): у неё нет план-ссылок, записей в `docs/superpowers/plans/` и sdd-каталога. +Если каталог change существует в нескольких корнях, MUST использоваться первый в +порядке [корень сессии, `.claude/worktrees/*`, `.wt/*`]; имена worktree +сортируются. Несколько планов на один change — MUST отказ с перечислением, как и +раньше; план из другого корня показывается с префиксом корня. + Гейты SHALL жить в guard-цепочке и быть активными только при `enabled: true`, `guards: true`, успешном self-check `guards` (`guardsOk`) и `approvalGates: true`; `approvalGates: true` при выключенных guards или проваленном self-check MUST NOT их