Compare commits

...

4 Commits

9 changed files with 545 additions and 43 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

@ -372,6 +372,10 @@ check() {
}
install_global() {
if ! command -v python3 >/dev/null 2>&1; then
echo "FAIL: python3 is required for --global (AGENTS.md is generated from global/CLAUDE.md)" >&2
return 1
fi
warn_mixed_dsh_home
mkdir -p "$DSH_HOME"
local target="$DSH_HOME/AGENTS.md"
@ -494,16 +498,41 @@ sync_skills() {
# Copy every <dir>/SKILL.md under $1 into $DSH_HOME/skills (rm -rf dest, cp -R).
# The optional label names where the skills came from in the report line.
# $DSH_HOME/.superpowers-manifest remembers the installed superpowers set (one
# name per line, sorted): a skill present in the previous manifest but missing
# from the new set is removed from $DSH_HOME/skills. Local skills (role, sdd)
# are installed by install_local_skills after this and never enter the manifest.
copy_skills_from() {
local src="$1" label="${2:-$1}" count=0 dir name dest
local src="$1" label="${2:-$1}" count=0 dir name dest line
local manifest="$DSH_HOME/.superpowers-manifest"
local old=() new=()
if [ -f "$manifest" ]; then
while IFS= read -r line; do [ -n "$line" ] && old+=("$line"); done < "$manifest"
fi
for dir in "$src"/*; do
[ -f "$dir/SKILL.md" ] || continue
name="$(basename "$dir")"
new+=("$name")
dest="$DSH_HOME/skills/$name"
rm -rf "$dest"
cp -R "$dir" "$dest"
count=$((count + 1))
done
local keep found
for name in ${old[@]+"${old[@]}"}; do
found=0
for keep in ${new[@]+"${new[@]}"}; do
if [ "$keep" = "$name" ]; then
found=1
break
fi
done
if [ "$found" -eq 0 ]; then
rm -rf "$DSH_HOME/skills/$name"
echo "removed $DSH_HOME/skills/$name (no longer in the superpowers source)"
fi
done
printf '%s\n' ${new[@]+"${new[@]}"} | sort -u > "$manifest"
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $label)"
}

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

@ -456,6 +456,44 @@ test('--skills syncs an explicit --superpowers-from source in a clean HOME', ()
}
})
test('--skills removes a skill that disappeared upstream (M-3)', () => {
const work = tempDir()
try {
const home = join(work, 'home')
const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers'))
const manifest = join(home, '.dsh', '.superpowers-manifest')
const installed = join(home, '.dsh', 'skills')
const first = runInstaller(home, ['--skills', '--superpowers-from', src])
assert.equal(first.status, 0, first.stdout + first.stderr)
assert.match(first.stdout, /copied 15 superpowers skills/)
assert.ok(existsSync(manifest), 'the superpowers manifest is written by --skills')
assert.match(readFileSync(manifest, 'utf8'), /using-superpowers/)
// Simulate a skill the upstream no longer ships: it stays on disk and in
// the old manifest, but the new set does not contain it. The sync itself
// rejects a changed 15-skill upstream set, so the manifest is the only
// place the install remembers the previous set.
write(join(installed, 'extra-skill', 'SKILL.md'), '---\nname: extra-skill\n---\nbody\n')
write(manifest, `${readFileSync(manifest, 'utf8')}extra-skill\n`)
const second = runInstaller(home, ['--skills', '--superpowers-from', src])
assert.equal(second.status, 0, second.stdout + second.stderr)
assert.match(second.stdout, /copied 15 superpowers skills/)
assert.ok(
second.stdout.includes(`removed ${join(installed, 'extra-skill')} (no longer in the superpowers source)`),
second.stdout,
)
assert.equal(existsSync(join(installed, 'extra-skill')), false, 'the stale skill directory is removed')
assert.ok(existsSync(join(installed, 'using-superpowers', 'SKILL.md')))
assert.ok(existsSync(join(installed, 'brainstorming', 'SKILL.md')))
assert.ok(existsSync(join(installed, 'role', 'SKILL.md')), 'local skills are not touched')
assert.doesNotMatch(readFileSync(manifest, 'utf8'), /extra-skill/, 'the manifest reflects the new set')
} finally {
cleanup(work)
}
})
test('--superpowers-from accepts the skills directory itself', () => {
const home = tempDir()
try {
@ -641,6 +679,24 @@ test('--global installs DSH role paths and explains the instruction format', ()
}
})
test('--global fails without python3 and writes no truncated AGENTS.md (M-2)', () => {
const home = tempDir()
try {
const emptyBin = join(home, 'empty-bin')
mkdirSync(emptyBin, { recursive: true })
const run = spawnSync('/bin/bash', [script, '--global'], {
env: { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: emptyBin },
encoding: 'utf8',
})
const out = run.stdout + run.stderr
assert.equal(run.status, 1, out)
assert.match(out, /python3 is required/)
assert.equal(existsSync(join(home, '.dsh', 'AGENTS.md')), false)
} finally {
cleanup(home)
}
})
test('--skills and --roles warn when DSH_HOME exists while HOME is isolated (O-2)', () => {
const work = tempDir()
try {

View File

@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { homedir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { apply } from '../bundle/index.js'
@ -135,6 +136,34 @@ function recordingLogger(errors) {
}
}
test('apply warns once when roles resolve to a legacy Claude Code path (M-1)', () => {
const warnings = []
const ctx = {
tools: { guard() {} },
on() {},
inject() {},
workingDirectory: { get() {} },
logger: {
info() {},
error() {},
warn(fmt, ...args) {
warnings.push(fmt.replace(/%s/g, () => String(args.shift() ?? '')))
},
},
}
apply(ctx, { rolesDir: join(homedir(), '.claude', 'roles') })
assert.ok(
warnings.some(
(line) =>
line.includes('legacy Claude Code source in use for roles') &&
line.includes(join(homedir(), '.claude', 'roles')),
),
`no legacy-roles warning among: ${warnings.join(' | ')}`,
)
})
test('apply does not throw when ctx.on and ctx.inject are missing', () => {
const calls = { guard: 0 }
const errors = []

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 их

View File

@ -26,6 +26,7 @@ assert_eq "вложенный worktree: git commit в нём" "$(rc "$W" Bash 'g
assert_eq "вложенный worktree: запись в основной checkout на main" "$(rc "$W" Bash "echo x > $S/n/a.md")" 2
assert_eq "вложенный worktree: Write в основной checkout рядом" "$(rc "$W" Write "$S/n/.claude/x.md")" 2
assert_eq "git worktree add .claude/worktrees/… на main" "$(rc "$S/n" Bash 'git worktree add .claude/worktrees/y -b feat/y origin/main')" 0
assert_eq "worktree add -b chore/x без start-point (O-1 #66)" "$(rc "$S/n" Bash 'git worktree add .claude/worktrees/o1 -b chore/x')" 0
# цепочка создания worktree и записи в нём одной командой (#63, O-4): цель под новым путём — по новой ветке
assert_eq "worktree add -b && cd && openspec archive (#63)" "$(rc "$S/n" Bash 'git worktree add .claude/worktrees/z -b feat/z origin/main && cd .claude/worktrees/z && openspec archive y -y')" 0
assert_eq "worktree add -C && cd && openspec archive (#63)" "$(rc "$S/ws" Bash "git -C $S/n worktree add .claude/worktrees/q -b feat/q origin/main && cd $S/n/.claude/worktrees/q && openspec archive y -y")" 0