519 lines
18 KiB
JavaScript
519 lines
18 KiB
JavaScript
/**
|
|
* Approval gates A/B/C of the upstream loop: the pure decision core plus the
|
|
* approval.md / plan io it needs.
|
|
*
|
|
* No DSH runtime is imported here. Every decision function takes plain values;
|
|
* where it must touch the filesystem it does so only through an explicit
|
|
* repository root and only synchronously (`readFileSync` / `existsSync`).
|
|
*
|
|
* Failure policy mirrors the bash hooks: a call that references no plan is
|
|
* never blocked, while a *referenced* plan that cannot be read or tied to an
|
|
* approved change is denied (fail closed). Parsing never throws: malformed
|
|
* YAML or markdown degrades to "missing", which the gates reject.
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto'
|
|
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'
|
|
const PLAN_CHANGED = 'plan changed after approval'
|
|
const PLAN_CHANGED_DENY = 'plan changed after approval; re-approve'
|
|
|
|
/** Scalar keys of each approval.md section, in serialization order. */
|
|
const SECTION_FIELDS = {
|
|
design: ['by', 'at'],
|
|
plan: ['by', 'at', 'sha256'],
|
|
}
|
|
|
|
/** `openspec/changes/<id>` anywhere in a path (also inside an absolute path). */
|
|
const CHANGE_PATH = /(?:^|\/)openspec\/changes\/([^/\s]+)/u
|
|
/** `.superpowers/sdd/<plan-name>` anywhere in a path or a prompt. */
|
|
const SDD_PATH = /(?:^|\/)\.superpowers\/sdd\/([^/\s]+)/u
|
|
/** A direct plan reference; the match ends at `.md`, prose may follow it. */
|
|
const DIRECT_PLAN = /^docs\/superpowers\/plans\/([^/\s"'`()<>[\]{}]+\.md)/u
|
|
|
|
const DIRECT_PREFIX = 'docs/superpowers/plans/'
|
|
const SDD_PREFIX = '.superpowers/sdd/'
|
|
|
|
/**
|
|
* Characters that end a path token when scanning a prompt backwards. `=`
|
|
* separates `--flag=<path>` and `:` ends a prose label like `Plan:<path>`.
|
|
*/
|
|
const TOKEN_STOP = new Set([
|
|
' ',
|
|
'\t',
|
|
'\n',
|
|
'\r',
|
|
'"',
|
|
"'",
|
|
'`',
|
|
'(',
|
|
')',
|
|
'[',
|
|
']',
|
|
'{',
|
|
'}',
|
|
'<',
|
|
'>',
|
|
',',
|
|
';',
|
|
':',
|
|
'=',
|
|
'&',
|
|
'|',
|
|
])
|
|
|
|
function isFile(path) {
|
|
try {
|
|
return statSync(path).isFile()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
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')
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Strip a comment / quotes / backticks from one yaml scalar. */
|
|
function scalar(raw) {
|
|
let value = String(raw ?? '').trim()
|
|
const comment = value.search(/\s#/u)
|
|
if (comment !== -1 && !/^['"`]/u.test(value)) value = value.slice(0, comment).trim()
|
|
if (
|
|
(value.startsWith('`') && value.endsWith('`')) ||
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1).trim()
|
|
}
|
|
return value
|
|
}
|
|
|
|
/** `openspec/changes/<id>` when the value points into a change, else null. */
|
|
function changeSpec(value) {
|
|
const match = CHANGE_PATH.exec(value)
|
|
if (!match) return null
|
|
const id = match[1]
|
|
if (id === '.' || id === '..') return null
|
|
return `openspec/changes/${id}`
|
|
}
|
|
|
|
function isInside(parent, child) {
|
|
const rel = relative(parent, child)
|
|
return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
|
|
}
|
|
|
|
/**
|
|
* Parse the tolerant yaml subset used by approval.md: top-level `design:` and
|
|
* `plan:` blocks with scalar `by` / `at` / `sha256` children. A section key is
|
|
* kept even when its block is empty; unknown sections and malformed lines are
|
|
* ignored. Any non-string input yields `{}` — the caller passes null when the
|
|
* file is missing.
|
|
*/
|
|
export function parseApproval(text) {
|
|
const parsed = {}
|
|
if (typeof text !== 'string' || text === '') return parsed
|
|
|
|
let section = null
|
|
for (const rawLine of text.split(/\r?\n/u)) {
|
|
const line = rawLine.replace(/[ \t]+$/u, '')
|
|
if (line.trim() === '' || /^\s*#/u.test(line)) continue
|
|
|
|
const top = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line)
|
|
if (top) {
|
|
// Own-key check, not `in`: a crafted `__proto__:`/`constructor:` line
|
|
// must be ignored instead of touching the prototype.
|
|
const known = Object.prototype.hasOwnProperty.call(SECTION_FIELDS, top[1])
|
|
section = known ? {} : null
|
|
if (section) parsed[top[1]] = section
|
|
continue
|
|
}
|
|
|
|
if (section === null) continue
|
|
const child = /^\s+([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line)
|
|
if (!child) continue
|
|
const value = scalar(child[2])
|
|
if (value !== '') section[child[1]] = value
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
/**
|
|
* Deterministic approval.md text: sections in `design`, `plan` order, fields
|
|
* in a fixed order, one blank line between sections, no trailing spaces, and
|
|
* a final newline. An absent or non-object section is skipped.
|
|
*/
|
|
export function serializeApproval(parsed) {
|
|
const sections = []
|
|
for (const [section, fields] of Object.entries(SECTION_FIELDS)) {
|
|
const source = parsed?.[section]
|
|
if (!source || typeof source !== 'object') continue
|
|
const lines = [`${section}:`]
|
|
for (const field of fields) {
|
|
const value = source[field]
|
|
if (value === undefined || value === null) continue
|
|
const text = String(value).trim()
|
|
if (text === '') continue
|
|
lines.push(` ${field}: ${text}`)
|
|
}
|
|
sections.push(lines.join('\n'))
|
|
}
|
|
return sections.length === 0 ? '' : `${sections.join('\n\n')}\n`
|
|
}
|
|
|
|
/**
|
|
* Normalized spec path named by the plan's `**Spec:**` line, or null.
|
|
*
|
|
* Backticks, a markdown link target and a trailing slash are stripped. A path
|
|
* into `openspec/changes/<id>/...` is normalized to `openspec/changes/<id>`;
|
|
* any other path (legacy `docs/superpowers/specs/...`) is returned as written
|
|
* so the caller can reject it explicitly (D-9).
|
|
*/
|
|
export function extractSpec(content) {
|
|
if (typeof content !== 'string' || content === '') return null
|
|
const match = /^[ \t]*\*\*Spec:\*\*[ \t]*(.+?)[ \t]*$/mu.exec(content)
|
|
if (!match) return null
|
|
|
|
let value = match[1].trim()
|
|
const backtick = /^`([^`]+)`/u.exec(value)
|
|
const link = /^\[[^\]]*\]\(([^)]+)\)/u.exec(value)
|
|
if (backtick) value = backtick[1]
|
|
else if (link) value = link[1]
|
|
else value = value.split(/\s+/u)[0]
|
|
|
|
value = value
|
|
.trim()
|
|
.replace(/^[`"']+/u, '')
|
|
.replace(/[`"']+$/u, '')
|
|
.replace(/^\.\//u, '')
|
|
.replace(/[/.,;:]+$/u, '')
|
|
if (value === '') return null
|
|
return changeSpec(value) ?? value
|
|
}
|
|
|
|
/**
|
|
* Change id of a spec path: `openspec/changes/<id>(/...)` -> `<id>`.
|
|
* Legacy `docs/superpowers/specs/...` and everything else -> null.
|
|
*/
|
|
export function resolveChangeFromSpec(specPath) {
|
|
if (typeof specPath !== 'string' || specPath === '') return null
|
|
const change = changeSpec(specPath)
|
|
return change === null ? null : change.slice('openspec/changes/'.length)
|
|
}
|
|
|
|
/**
|
|
* Plan file behind an sdd ledger path: `.superpowers/sdd/<plan-name>(/...)` ->
|
|
* `<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
|
|
const match = SDD_PATH.exec(sddPath)
|
|
if (!match) return null
|
|
const name = match[1].replace(/[/.,;:]+$/u, '')
|
|
if (name === '' || name === '.' || name === '..') return 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. */
|
|
export function hashPlan(planPath) {
|
|
try {
|
|
return createHash('sha256').update(readFileSync(planPath)).digest('hex')
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Is the plan approved for its current content (D-10)?
|
|
* Fails with `no plan approval` when the section or its sha256 is missing and
|
|
* with `plan changed after approval` when the hash does not match (a missing
|
|
* plan file is a hash mismatch by construction).
|
|
*/
|
|
export function planApproved(approval, planPath) {
|
|
const plan = approval?.plan
|
|
if (!plan || typeof plan !== 'object') return { ok: false, reason: PLAN_NO_APPROVAL }
|
|
const expected = typeof plan.sha256 === 'string' ? plan.sha256.trim().toLowerCase() : ''
|
|
if (expected === '') return { ok: false, reason: PLAN_NO_APPROVAL }
|
|
const actual = hashPlan(planPath)
|
|
if (actual === '' || actual !== expected) return { ok: false, reason: PLAN_CHANGED }
|
|
return { ok: true, reason: '' }
|
|
}
|
|
|
|
function readApproval(repoRoot, changeId) {
|
|
return parseApproval(readText(join(repoRoot, CHANGE_DIR, changeId, APPROVAL_FILE)))
|
|
}
|
|
|
|
/**
|
|
* Does approval.md carry a real design approval? An empty `design:` block is
|
|
* treated as no approval (R-4/T1.6f): the section must carry the `by` and `at`
|
|
* scalars `/approve` writes, so a bare header cannot unlock the plan gate.
|
|
*/
|
|
export function hasDesignApproval(approval) {
|
|
const design = approval?.design
|
|
if (!design || typeof design !== 'object') return false
|
|
return (
|
|
typeof design.by === 'string' &&
|
|
design.by !== '' &&
|
|
typeof design.at === 'string' &&
|
|
design.at !== ''
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Gate A: a plan may only be written for a change whose design is approved.
|
|
* The `**Spec:**` reference is mandatory and must resolve to an openspec
|
|
* change (D-9); a plan written elsewhere is not gated.
|
|
*/
|
|
export function checkPlanWrite({ filePath, content, repoRoot } = {}) {
|
|
if (typeof filePath !== 'string' || filePath === '') return {}
|
|
const root = resolve(repoRoot || '.')
|
|
const target = resolve(root, filePath)
|
|
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 }
|
|
const changeRoot = findChangeRoot(root, changeId) ?? root
|
|
if (!hasDesignApproval(readApproval(changeRoot, changeId))) {
|
|
return { deny: `design not approved; run /approve ${changeId} design` }
|
|
}
|
|
return {}
|
|
}
|
|
|
|
/**
|
|
* Gate C: `approval.md` is written only by `/approve`, never by a tool (D-1).
|
|
* Applies wherever the file lives, not only inside a change directory.
|
|
*/
|
|
export function checkApprovalWrite({ filePath, repoRoot } = {}) {
|
|
if (typeof filePath !== 'string' || filePath === '') return {}
|
|
const target = resolve(resolve(repoRoot || '.'), filePath)
|
|
if (basename(target) === APPROVAL_FILE) return { deny: APPROVAL_DENY }
|
|
return {}
|
|
}
|
|
|
|
function tokenStart(text, index) {
|
|
let start = index
|
|
while (start > 0 && !TOKEN_STOP.has(text[start - 1])) start -= 1
|
|
return start
|
|
}
|
|
|
|
/**
|
|
* All plan references in one string, in textual order: a direct
|
|
* `docs/superpowers/plans/x.md` path (absolute or repository-relative) or an
|
|
* `.superpowers/sdd/x/` ledger path. Empty when the string references none.
|
|
*/
|
|
function planReferences(text) {
|
|
if (typeof text !== 'string' || text === '') return []
|
|
const found = []
|
|
|
|
for (let at = text.indexOf(DIRECT_PREFIX); at !== -1; at = text.indexOf(DIRECT_PREFIX, at + 1)) {
|
|
const match = DIRECT_PLAN.exec(text.slice(at))
|
|
if (!match) continue
|
|
const start = tokenStart(text, at)
|
|
found.push({ index: at, path: text.slice(start, at + match[0].length) })
|
|
}
|
|
|
|
for (let at = text.indexOf(SDD_PREFIX); at !== -1; at = text.indexOf(SDD_PREFIX, at + 1)) {
|
|
const match = SDD_PATH.exec(text.slice(at))
|
|
if (!match) continue
|
|
const name = match[1].replace(/[/.,;:]+$/u, '')
|
|
if (name !== '' && name !== '.' && name !== '..') found.push({ index: at, name })
|
|
}
|
|
|
|
return found.sort((left, right) => left.index - right.index)
|
|
}
|
|
|
|
/** Deny text for an unapproved plan; `<id>` stays literal when unknown. */
|
|
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') {
|
|
return (
|
|
resolvePlanFromSdd(`${SDD_PREFIX}${reference.name}`, repoRoot) ??
|
|
join(repoRoot, PLANS_DIR, `${reference.name}.md`)
|
|
)
|
|
}
|
|
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. 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)
|
|
if (content === null) return denyPlanNotApproved()
|
|
const changeId = resolveChangeFromSpec(extractSpec(content))
|
|
if (changeId === null) return denyPlanNotApproved()
|
|
|
|
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 }
|
|
}
|
|
|
|
/**
|
|
* Gate B: a dispatch may only start when every plan it references is approved
|
|
* for its current content (D-10). The check covers the prompt and, when
|
|
* given, the workflow script; the first failing reference denies, so an
|
|
* approved plan cannot mask a later unapproved one. No plan reference at all
|
|
* means no gate.
|
|
*/
|
|
export function checkDispatch({ prompt, script, repoRoot } = {}) {
|
|
const root = resolve(repoRoot || '.')
|
|
const references = [...planReferences(prompt), ...planReferences(script)]
|
|
for (const reference of references) {
|
|
const verdict = planVerdict(root, referencePlanPath(reference, root))
|
|
if (verdict.deny !== undefined) return verdict
|
|
}
|
|
return {}
|
|
}
|
|
|
|
/**
|
|
* Gate B for Mode 1 starts: a write under `.superpowers/sdd/<plan-name>/`
|
|
* (ledger, briefs) is allowed only when that plan is approved for its current
|
|
* content (D-7). A missing plan file fails closed with `plan not found`.
|
|
*/
|
|
export function checkSddWrite({ filePath, repoRoot } = {}) {
|
|
if (typeof filePath !== 'string' || filePath === '') return {}
|
|
const root = resolve(repoRoot || '.')
|
|
const target = resolve(root, filePath)
|
|
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 {}
|
|
const name = segments[0]
|
|
if (name === '' || name === '.' || name === '..') return {}
|
|
|
|
const planPath = resolvePlanFromSdd(`${SDD_PREFIX}${name}`, root)
|
|
if (planPath === null) return { deny: `plan not found: ${name}` }
|
|
return planVerdict(root, planPath)
|
|
}
|
|
|
|
/**
|
|
* Gates A and C for a list of absolute write targets (from
|
|
* `harness_target.py targets`). First denial wins. A target under plans/ has
|
|
* no content here, so it can never carry a `**Spec:**` line and is denied —
|
|
* exactly the bash-heredoc case the tool-level gates would otherwise miss.
|
|
*/
|
|
export function checkBashTargets(targets, repoRoot) {
|
|
if (!Array.isArray(targets)) return {}
|
|
const root = resolve(repoRoot || '.')
|
|
for (const raw of targets) {
|
|
if (typeof raw !== 'string' || raw === '') continue
|
|
const target = resolve(root, raw)
|
|
const approval = checkApprovalWrite({ filePath: target, repoRoot: root })
|
|
if (approval.deny !== undefined) return approval
|
|
const plan = checkPlanWrite({ filePath: target, content: null, repoRoot: root })
|
|
if (plan.deny !== undefined) return plan
|
|
}
|
|
return {}
|
|
}
|