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 { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||||
import { dirname, join, relative, resolve } from 'node:path'
|
import { dirname, join, relative } from 'node:path'
|
||||||
import {
|
import {
|
||||||
APPROVAL_FILE,
|
APPROVAL_FILE,
|
||||||
CHANGE_DIR,
|
CHANGE_DIR,
|
||||||
PLANS_DIR,
|
PLANS_DIR,
|
||||||
|
candidateRoots,
|
||||||
extractSpec,
|
extractSpec,
|
||||||
|
findChangeRoot,
|
||||||
|
findRepoRoot,
|
||||||
hasDesignApproval,
|
hasDesignApproval,
|
||||||
hashPlan,
|
hashPlan,
|
||||||
parseApproval,
|
parseApproval,
|
||||||
@ -68,24 +71,29 @@ function writeApproval(root, changeId, mutate) {
|
|||||||
return path
|
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) {
|
function plansForChange(root, changeId) {
|
||||||
const plansDir = join(root, PLANS_DIR)
|
|
||||||
let names
|
|
||||||
try {
|
|
||||||
names = readdirSync(plansDir)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const found = []
|
const found = []
|
||||||
for (const name of [...names].sort()) {
|
for (const candidate of candidateRoots(root)) {
|
||||||
if (!name.endsWith('.md')) continue
|
const plansDir = join(candidate, PLANS_DIR)
|
||||||
const path = join(plansDir, name)
|
let names
|
||||||
if (!isFile(path)) continue
|
try {
|
||||||
const content = readText(path)
|
names = readdirSync(plansDir)
|
||||||
if (content === null) continue
|
} catch {
|
||||||
if (resolveChangeFromSpec(extractSpec(content)) === changeId) found.push(path)
|
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
|
return found
|
||||||
}
|
}
|
||||||
@ -101,9 +109,12 @@ async function confirmed(ask, questionText, detail) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function approveDesign({ root, changeId, ask, now }) {
|
async function approveDesign({ root, changeId, ask, now }) {
|
||||||
const changeDir = join(root, CHANGE_DIR, changeId)
|
const changeRoot = findChangeRoot(root, changeId)
|
||||||
const missing = DESIGN_ARTIFACTS.filter((name) => !isFile(join(changeDir, name)))
|
const missing =
|
||||||
if (missing.length > 0) {
|
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(', ')
|
const listed = missing.map((name) => `openspec/changes/${changeId}/${name}`).join(', ')
|
||||||
return refusal(`design approval requires the change artifacts; missing: ${listed}.`)
|
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 at = new Date(now()).toISOString()
|
||||||
const path = writeApproval(root, changeId, (approval) => ({
|
const path = writeApproval(changeRoot, changeId, (approval) => ({
|
||||||
...approval,
|
...approval,
|
||||||
design: { by: 'owner', at },
|
design: { by: 'owner', at },
|
||||||
}))
|
}))
|
||||||
return {
|
return {
|
||||||
approved: true,
|
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 }) {
|
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)) {
|
if (!hasDesignApproval(approval)) {
|
||||||
return refusal(`design not approved; run /approve ${changeId} design`)
|
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)
|
const plans = plansForChange(root, changeId)
|
||||||
if (plans.length === 0) {
|
if (plans.length === 0) {
|
||||||
return refusal(
|
return refusal(
|
||||||
@ -153,13 +168,13 @@ async function approvePlan({ root, changeId, ask, now }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const at = new Date(now()).toISOString()
|
const at = new Date(now()).toISOString()
|
||||||
const path = writeApproval(root, changeId, (current) => ({
|
const path = writeApproval(changeRoot, changeId, (current) => ({
|
||||||
...current,
|
...current,
|
||||||
plan: { by: 'owner', at, sha256 },
|
plan: { by: 'owner', at, sha256 },
|
||||||
}))
|
}))
|
||||||
return {
|
return {
|
||||||
approved: true,
|
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')
|
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 })
|
if (kind === 'design') return approveDesign({ root, changeId, ask, now })
|
||||||
return approvePlan({ root, changeId, ask, now })
|
return approvePlan({ root, changeId, ask, now })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,14 +13,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { readFileSync, statSync } from 'node:fs'
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
||||||
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||||
|
|
||||||
export const APPROVAL_FILE = 'approval.md'
|
export const APPROVAL_FILE = 'approval.md'
|
||||||
export const PLANS_DIR = join('docs', 'superpowers', 'plans')
|
export const PLANS_DIR = join('docs', 'superpowers', 'plans')
|
||||||
export const CHANGE_DIR = join('openspec', 'changes')
|
export const CHANGE_DIR = join('openspec', 'changes')
|
||||||
export const SDD_DIR = join('.superpowers', 'sdd')
|
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 SPEC_DENY = 'plan must carry **Spec:** pointing to openspec/changes/<id>'
|
||||||
const APPROVAL_DENY = 'approval.md is written only by /approve'
|
const APPROVAL_DENY = 'approval.md is written only by /approve'
|
||||||
const PLAN_NO_APPROVAL = 'no plan approval'
|
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) {
|
function readText(path) {
|
||||||
try {
|
try {
|
||||||
return readFileSync(path, 'utf8')
|
return readFileSync(path, 'utf8')
|
||||||
@ -216,7 +280,8 @@ export function resolveChangeFromSpec(specPath) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Plan file behind an sdd ledger path: `.superpowers/sdd/<plan-name>(/...)` ->
|
* 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) {
|
export function resolvePlanFromSdd(sddPath, repoRoot) {
|
||||||
if (typeof sddPath !== 'string' || sddPath === '' || !repoRoot) return null
|
if (typeof sddPath !== 'string' || sddPath === '' || !repoRoot) return null
|
||||||
@ -224,8 +289,11 @@ export function resolvePlanFromSdd(sddPath, repoRoot) {
|
|||||||
if (!match) return null
|
if (!match) return null
|
||||||
const name = match[1].replace(/[/.,;:]+$/u, '')
|
const name = match[1].replace(/[/.,;:]+$/u, '')
|
||||||
if (name === '' || name === '.' || name === '..') return null
|
if (name === '' || name === '.' || name === '..') return null
|
||||||
const planPath = join(resolve(repoRoot), PLANS_DIR, `${name}.md`)
|
for (const root of candidateRoots(repoRoot)) {
|
||||||
return isFile(planPath) ? planPath : null
|
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. */
|
/** 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 {}
|
if (typeof filePath !== 'string' || filePath === '') return {}
|
||||||
const root = resolve(repoRoot || '.')
|
const root = resolve(repoRoot || '.')
|
||||||
const target = resolve(root, filePath)
|
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))
|
const changeId = resolveChangeFromSpec(extractSpec(content))
|
||||||
if (changeId === null) return { deny: SPEC_DENY }
|
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 { deny: `design not approved; run /approve ${changeId} design` }
|
||||||
}
|
}
|
||||||
return {}
|
return {}
|
||||||
@ -340,6 +410,22 @@ function denyPlanNotApproved(changeId) {
|
|||||||
return { deny: `plan not approved; run /approve ${changeId ?? '<id>'} plan` }
|
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). */
|
/** Plan path a parsed reference points to (the file may be missing). */
|
||||||
function referencePlanPath(reference, repoRoot) {
|
function referencePlanPath(reference, repoRoot) {
|
||||||
if (typeof reference.name === 'string') {
|
if (typeof reference.name === 'string') {
|
||||||
@ -348,13 +434,14 @@ function referencePlanPath(reference, repoRoot) {
|
|||||||
join(repoRoot, PLANS_DIR, `${reference.name}.md`)
|
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:**`,
|
* Full gate-B chain for one referenced plan: read it, take its `**Spec:**`,
|
||||||
* resolve the change, verify the plan approval against the file content.
|
* 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) {
|
function planVerdict(repoRoot, planPath) {
|
||||||
const content = readText(planPath)
|
const content = readText(planPath)
|
||||||
@ -362,7 +449,8 @@ function planVerdict(repoRoot, planPath) {
|
|||||||
const changeId = resolveChangeFromSpec(extractSpec(content))
|
const changeId = resolveChangeFromSpec(extractSpec(content))
|
||||||
if (changeId === null) return denyPlanNotApproved()
|
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.ok) return {}
|
||||||
if (verdict.reason === PLAN_NO_APPROVAL) return denyPlanNotApproved(changeId)
|
if (verdict.reason === PLAN_NO_APPROVAL) return denyPlanNotApproved(changeId)
|
||||||
return { deny: PLAN_CHANGED_DENY }
|
return { deny: PLAN_CHANGED_DENY }
|
||||||
@ -394,8 +482,10 @@ export function checkSddWrite({ filePath, repoRoot } = {}) {
|
|||||||
if (typeof filePath !== 'string' || filePath === '') return {}
|
if (typeof filePath !== 'string' || filePath === '') return {}
|
||||||
const root = resolve(repoRoot || '.')
|
const root = resolve(repoRoot || '.')
|
||||||
const target = resolve(root, filePath)
|
const target = resolve(root, filePath)
|
||||||
const sddDir = join(root, SDD_DIR)
|
const sddDir = candidateRoots(root)
|
||||||
if (!isInside(sddDir, target)) return {}
|
.map((candidate) => join(candidate, SDD_DIR))
|
||||||
|
.find((dir) => isInside(dir, target))
|
||||||
|
if (sddDir === undefined) return {}
|
||||||
|
|
||||||
const segments = relative(sddDir, target).split(sep)
|
const segments = relative(sddDir, target).split(sep)
|
||||||
if (segments.length < 2) return {}
|
if (segments.length < 2) return {}
|
||||||
|
|||||||
@ -41,6 +41,23 @@ function approvalPath(repo) {
|
|||||||
return join(repo, 'openspec', 'changes', 'demo', 'approval.md')
|
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. */
|
/** An ask() answer batch shaped like the DSH UserQuestionService result. */
|
||||||
function approveAnswer(kind = 'design') {
|
function approveAnswer(kind = 'design') {
|
||||||
return { answers: [{ id: `approve-${kind}`, selected: [APPROVE], custom: undefined }] }
|
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)
|
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 { join } from 'node:path'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import {
|
import {
|
||||||
|
candidateRoots,
|
||||||
checkApprovalWrite,
|
checkApprovalWrite,
|
||||||
checkBashTargets,
|
checkBashTargets,
|
||||||
checkDispatch,
|
checkDispatch,
|
||||||
checkPlanWrite,
|
checkPlanWrite,
|
||||||
checkSddWrite,
|
checkSddWrite,
|
||||||
extractSpec,
|
extractSpec,
|
||||||
|
findChangeRoot,
|
||||||
|
findRepoRoot,
|
||||||
hashPlan,
|
hashPlan,
|
||||||
|
isDir,
|
||||||
parseApproval,
|
parseApproval,
|
||||||
planApproved,
|
planApproved,
|
||||||
resolveChangeFromSpec,
|
resolveChangeFromSpec,
|
||||||
@ -48,6 +52,25 @@ function approvalPath(repo) {
|
|||||||
return join(repo, 'openspec', 'changes', 'demo', 'approval.md')
|
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. */
|
/** Independent sha256, so the tests do not trust the implementation's own io. */
|
||||||
function shaOf(path) {
|
function shaOf(path) {
|
||||||
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
||||||
@ -466,3 +489,145 @@ test('checkPlanWrite: empty design section counts as not approved', () => {
|
|||||||
cleanup(tmp)
|
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 содержимого
|
- **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 без сервиса вопросов
|
#### Scenario: /approve без сервиса вопросов
|
||||||
|
|
||||||
- **WHEN** `ctx.userQuestions` отсутствует
|
- **WHEN** `ctx.userQuestions` отсутствует
|
||||||
@ -275,10 +290,11 @@ fail closed. Фрагмент `edit` проверяется по своему `n
|
|||||||
|
|
||||||
Вызов без план-ссылки MUST NOT гейтиться. Разбор промпта — эвристика по
|
Вызов без план-ссылки MUST NOT гейтиться. Разбор промпта — эвристика по
|
||||||
подстрокам, поэтому разбор fail open: вызов, в котором ссылка не распознана, не
|
подстрокам, поэтому разбор fail open: вызов, в котором ссылка не распознана, не
|
||||||
блокируется. Диспатч-гейт резолвит ссылки против cwd сессии и игнорирует
|
блокируется. Ссылки на план резолвятся от корня репозитория сессии: сначала
|
||||||
`arguments.cwd`: диспатч с cwd в другом worktree и ссылкой на план этого worktree
|
файл ищется в самом корне, затем в связанных worktree `.claude/worktrees/*` и
|
||||||
получает отказ (fail closed, over-blocking, T3.8) — план должен быть утверждён в
|
`.wt/*` (первый существующий файл); `arguments.cwd` игнорируется. План,
|
||||||
репозитории сессии.
|
найденный в связанном worktree, гейтится по `approval.md` своего change — в том
|
||||||
|
же worktree.
|
||||||
|
|
||||||
#### Scenario: Ledger до утверждения плана
|
#### Scenario: Ledger до утверждения плана
|
||||||
|
|
||||||
@ -292,6 +308,14 @@ fail closed. Фрагмент `edit` проверяется по своему `n
|
|||||||
plan-секцией
|
plan-секцией
|
||||||
- **THEN** диспатч разрешён
|
- **THEN** диспатч разрешён
|
||||||
|
|
||||||
|
#### Scenario: План в связанном worktree
|
||||||
|
|
||||||
|
- **WHEN** промпт диспатча ссылается на `docs/superpowers/plans/<имя>.md`, который
|
||||||
|
существует только в связанном worktree сессии
|
||||||
|
- **THEN** гейт проверяет approval этого плана в change того же worktree;
|
||||||
|
утверждённый план разрешает диспатч, без approval — отказ
|
||||||
|
`plan not approved; run /approve <id> plan`
|
||||||
|
|
||||||
#### Scenario: Один из двух планов не утверждён
|
#### Scenario: Один из двух планов не утверждён
|
||||||
|
|
||||||
- **WHEN** промпт `subagent` ссылается на два плана, и второй не утверждён
|
- **WHEN** промпт `subagent` ссылается на два плана, и второй не утверждён
|
||||||
@ -360,6 +384,11 @@ write-гейт B; возвращается первый отказ. Сбой з
|
|||||||
Bounded-задача без change и плана MUST NOT гейтиться (D-6): у неё нет план-ссылок,
|
Bounded-задача без change и плана MUST NOT гейтиться (D-6): у неё нет план-ссылок,
|
||||||
записей в `docs/superpowers/plans/` и sdd-каталога.
|
записей в `docs/superpowers/plans/` и sdd-каталога.
|
||||||
|
|
||||||
|
Если каталог change существует в нескольких корнях, MUST использоваться первый в
|
||||||
|
порядке [корень сессии, `.claude/worktrees/*`, `.wt/*`]; имена worktree
|
||||||
|
сортируются. Несколько планов на один change — MUST отказ с перечислением, как и
|
||||||
|
раньше; план из другого корня показывается с префиксом корня.
|
||||||
|
|
||||||
Гейты SHALL жить в guard-цепочке и быть активными только при `enabled: true`,
|
Гейты SHALL жить в guard-цепочке и быть активными только при `enabled: true`,
|
||||||
`guards: true`, успешном self-check `guards` (`guardsOk`) и `approvalGates: true`;
|
`guards: true`, успешном self-check `guards` (`guardsOk`) и `approvalGates: true`;
|
||||||
`approvalGates: true` при выключенных guards или проваленном self-check MUST NOT их
|
`approvalGates: true` при выключенных guards или проваленном self-check MUST NOT их
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user