fix(dsh): /approve и гейты резолвят change/план в worktree (#76) #82

Merged
aep merged 1 commits from feat/76-approve-resolve into main 2026-10-25 13:40:56 +03:00
5 changed files with 429 additions and 42 deletions

View File

@ -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 `<id>`. */
/**
* Existing plans whose **Spec:** is `<id>`, 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/<slug>/` 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 })
}

View File

@ -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/<id>'
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/<id>` 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/<plan-name>(/...)` ->
* `<repoRoot>/docs/superpowers/plans/<plan-name>.md`, when that file exists.
* `<root>/docs/superpowers/plans/<plan-name>.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 ?? '<id>'} 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 {}

View File

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

View File

@ -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 (`<repo>/.claude/worktrees/<slug>/`, `<repo>/.wt/<slug>/`).
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)
}
})

View File

@ -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/<id>/` с proposal, design и builder-check
существует в `<repo>/.claude/worktrees/<slug>/` (или `.wt/<slug>/`), а сессия
работает в основном 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 <id> 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 их