Merge pull request 'fix(dsh): /approve и гейты резолвят change/план в worktree (#76)' (#82) from feat/76-approve-resolve into main
This commit is contained in:
commit
148ca131a7
@ -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 })
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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 их
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user