cyber-sdd 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +17 -0
- package/.codex-plugin/plugin.json +17 -0
- package/.plugin/plugin.json +17 -0
- package/README.md +159 -0
- package/agents/sdd-automaton.md +97 -0
- package/agents/sdd-impl-judge.md +214 -0
- package/agents/sdd-scanner.md +120 -0
- package/agents/sdd-spec-judge.md +224 -0
- package/agents/sdd-warden.md +101 -0
- package/package.json +24 -0
- package/skills/align-spec/README.md +20 -0
- package/skills/align-spec/SKILL.md +111 -0
- package/skills/align-spec/scripts/align-spec.mts +187 -0
- package/skills/architect-impl-governance/README.md +46 -0
- package/skills/architect-impl-governance/SKILL.md +45 -0
- package/skills/architect-spec-governance/README.md +48 -0
- package/skills/architect-spec-governance/SKILL.md +59 -0
- package/skills/blast-estimate/README.md +47 -0
- package/skills/blast-estimate/SKILL.md +133 -0
- package/skills/blast-estimate/scripts/blast-estimate.mts +583 -0
- package/skills/builder-impl-governance/README.md +47 -0
- package/skills/builder-impl-governance/SKILL.md +47 -0
- package/skills/builder-spec-governance/README.md +49 -0
- package/skills/builder-spec-governance/SKILL.md +36 -0
- package/skills/check-partition-quality/README.md +22 -0
- package/skills/check-partition-quality/SKILL.md +51 -0
- package/skills/check-partition-quality/scripts/check-partition-quality.mts +336 -0
- package/skills/check-plan-safety/README.md +17 -0
- package/skills/check-plan-safety/SKILL.md +60 -0
- package/skills/check-plan-safety/scripts/check-plan-safety.mts +145 -0
- package/skills/check-project-specs/README.md +19 -0
- package/skills/check-project-specs/SKILL.md +69 -0
- package/skills/check-project-specs/scripts/check-project-specs.mts +217 -0
- package/skills/check-scenario-overlap/README.md +19 -0
- package/skills/check-scenario-overlap/SKILL.md +74 -0
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +249 -0
- package/skills/check-spec-structure/README.md +17 -0
- package/skills/check-spec-structure/SKILL.md +66 -0
- package/skills/check-spec-structure/scripts/check-spec-structure.mts +346 -0
- package/skills/collision-ladder/README.md +18 -0
- package/skills/collision-ladder/SKILL.md +83 -0
- package/skills/collision-ladder/scripts/collision-ladder.mts +657 -0
- package/skills/combat-log-governance/README.md +13 -0
- package/skills/combat-log-governance/SKILL.md +257 -0
- package/skills/concept-index/README.md +13 -0
- package/skills/concept-index/SKILL.md +38 -0
- package/skills/concept-index/scripts/concept-index.mts +245 -0
- package/skills/discover-plans/README.md +16 -0
- package/skills/discover-plans/SKILL.md +74 -0
- package/skills/discover-plans/scripts/discover-plans.mts +212 -0
- package/skills/discover-specs/README.md +15 -0
- package/skills/discover-specs/SKILL.md +76 -0
- package/skills/discover-specs/scripts/discover-specs.mts +396 -0
- package/skills/doctrine-loop/README.md +15 -0
- package/skills/doctrine-loop/SKILL.md +97 -0
- package/skills/formation-loop/README.md +17 -0
- package/skills/formation-loop/SKILL.md +140 -0
- package/skills/gate-validation-governance/README.md +12 -0
- package/skills/gate-validation-governance/SKILL.md +87 -0
- package/skills/impl-producer-governance/README.md +48 -0
- package/skills/impl-producer-governance/SKILL.md +85 -0
- package/skills/init/README.md +27 -0
- package/skills/init/SKILL.md +68 -0
- package/skills/init/scripts/wire-statusline.mts +276 -0
- package/skills/lifecycle-governance/README.md +11 -0
- package/skills/lifecycle-governance/SKILL.md +168 -0
- package/skills/manage/README.md +9 -0
- package/skills/manage/SKILL.md +62 -0
- package/skills/manage-ignore/README.md +19 -0
- package/skills/manage-ignore/SKILL.md +52 -0
- package/skills/manage-ignore/scripts/manage-ignore.mts +294 -0
- package/skills/manage-scenario-bridge/README.md +20 -0
- package/skills/manage-scenario-bridge/SKILL.md +60 -0
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +156 -0
- package/skills/manage-spec-anchors/README.md +18 -0
- package/skills/manage-spec-anchors/SKILL.md +56 -0
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +328 -0
- package/skills/mission-graph/README.md +15 -0
- package/skills/mission-graph/SKILL.md +67 -0
- package/skills/mission-graph/scripts/mission-graph.mts +844 -0
- package/skills/oracle-spec-governance/README.md +45 -0
- package/skills/oracle-spec-governance/SKILL.md +45 -0
- package/skills/ownership-governance/README.md +65 -0
- package/skills/ownership-governance/SKILL.md +104 -0
- package/skills/pause-mission/README.md +18 -0
- package/skills/pause-mission/SKILL.md +112 -0
- package/skills/place-node/README.md +12 -0
- package/skills/place-node/SKILL.md +47 -0
- package/skills/place-node/scripts/place-node.mts +157 -0
- package/skills/plan-retirement/README.md +32 -0
- package/skills/plan-retirement/SKILL.md +90 -0
- package/skills/plan-retirement/scripts/retire-plans.mts +196 -0
- package/skills/plugin-contract-governance/README.md +12 -0
- package/skills/plugin-contract-governance/SKILL.md +112 -0
- package/skills/remediation-governance/README.md +46 -0
- package/skills/remediation-governance/SKILL.md +78 -0
- package/skills/resolve-governances/README.md +18 -0
- package/skills/resolve-governances/SKILL.md +50 -0
- package/skills/resolve-governances/scripts/resolve-governances.mts +515 -0
- package/skills/resolve-tracking/SKILL.md +64 -0
- package/skills/resolve-tracking/scripts/resolve-tracking.mts +213 -0
- package/skills/resume-mission/README.md +12 -0
- package/skills/resume-mission/SKILL.md +53 -0
- package/skills/scaffold-project-spec/README.md +7 -0
- package/skills/scaffold-project-spec/SKILL.md +192 -0
- package/skills/sdd/README.md +7 -0
- package/skills/sdd/SKILL.md +92 -0
- package/skills/solution-producer-governance/README.md +9 -0
- package/skills/solution-producer-governance/SKILL.md +44 -0
- package/skills/spec-format-governance/README.md +73 -0
- package/skills/spec-format-governance/SKILL.md +114 -0
- package/skills/spec-gate/README.md +26 -0
- package/skills/spec-gate/SKILL.md +201 -0
- package/skills/spec-gate/scripts/check-spec-state.mts +601 -0
- package/skills/spec-gate/scripts/check-suite.mts +501 -0
- package/skills/spec-gate/scripts/classify-edit-class.mts +411 -0
- package/skills/spec-producer-governance/README.md +7 -0
- package/skills/spec-producer-governance/SKILL.md +86 -0
- package/skills/spec-structure-governance/README.md +40 -0
- package/skills/spec-structure-governance/SKILL.md +169 -0
- package/skills/ssa-lowering/README.md +26 -0
- package/skills/ssa-lowering/SKILL.md +181 -0
- package/skills/start-mission/README.md +7 -0
- package/skills/start-mission/SKILL.md +115 -0
- package/skills/suite-format-governance/README.md +75 -0
- package/skills/suite-format-governance/SKILL.md +299 -0
- package/skills/suite-format-governance/references/rubric.md +313 -0
- package/skills/touch-set-correction/README.md +16 -0
- package/skills/touch-set-correction/SKILL.md +67 -0
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +418 -0
- package/skills/verify-scenarios/README.md +17 -0
- package/skills/verify-scenarios/SKILL.md +109 -0
- package/skills/verify-scenarios/scripts/verify-scenarios.mts +386 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Static state check for an SDD project spec (the project-spec model). Two
|
|
3
|
+
// fail-closed responsibilities, both the safety net that makes an illegal state
|
|
4
|
+
// uncommittable:
|
|
5
|
+
// 1. the root spec.md lifecycle tuple — status / open-markers / approval
|
|
6
|
+
// attribution. The frontmatter is the router's upfront index (ADR-0017):
|
|
7
|
+
// minimal status + project-path. `aligned` was dropped — impl-sync is the
|
|
8
|
+
// impl gate's runtime suite run, contract-sync is judged, per-node settled
|
|
9
|
+
// state is the @frozen scan. The root is a descriptive index that owns no
|
|
10
|
+
// .feature of its own, so there is no per-spec .feature requirement — the
|
|
11
|
+
// behavior suite lives in the behavioral nodes.
|
|
12
|
+
// 2. the per-node spec-type reconcile — a node README's `spec-type` marker must
|
|
13
|
+
// agree with its shape: reference => ## Subject and NO sibling .feature;
|
|
14
|
+
// behavioral => ## Use Cases; descriptive (no marker) => no requirement.
|
|
15
|
+
// A node README also carries `spec-type` as its ONLY frontmatter: lifecycle
|
|
16
|
+
// (status / project-path / approval / produced-by / freeze) is root-spec.md-
|
|
17
|
+
// only (lifecycle-governance), so a stray lifecycle field on a node fails closed.
|
|
18
|
+
// 3. referenced-artifact-exists (--files only) — diff-scoped + surface-for-
|
|
19
|
+
// judgment: a backtick path a CR *introduces* (vs its committed baseline)
|
|
20
|
+
// that does not resolve on disk is a judgment finding, not a fail-closed
|
|
21
|
+
// block; a pre-existing ref the CR left untouched is never gated. The
|
|
22
|
+
// use-case-coverage check in the same pass stays fail-closed.
|
|
23
|
+
// 4. the gate-line floor — a root spec.md at `approved`/`implemented` must have the
|
|
24
|
+
// DURABLE proof of the gate in its sibling `ledger/` shards: a `gate` line with the
|
|
25
|
+
// matching `verdict: approve`. The spec.md `approval` map is the overwritten
|
|
26
|
+
// current-state twin (checked in 1); the ledger line is the immutable durable twin,
|
|
27
|
+
// and a status advance with no ledger gate line is an unenforced gate. `draft`
|
|
28
|
+
// requires no ledger (no gate ran). Provenance write is the conductor/gate's job
|
|
29
|
+
// (combat-log-governance); this is the static floor that makes the missing-line
|
|
30
|
+
// state uncommittable.
|
|
31
|
+
// `project-path` (the source dir a spec governs) is parsed for the router; its
|
|
32
|
+
// presence is the producer's job, not a lifecycle-legality concern, so it is not
|
|
33
|
+
// enforced here. See sdd:lifecycle-governance (legal-state tuples + per-node
|
|
34
|
+
// spec-type checks; spec types). Pure functions are
|
|
35
|
+
// exported for node:test; running the file directly drives the CLI. No dependencies.
|
|
36
|
+
|
|
37
|
+
import { execFileSync } from 'node:child_process'
|
|
38
|
+
import { type Dirent, existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
39
|
+
import { basename, dirname, join } from 'node:path'
|
|
40
|
+
|
|
41
|
+
export interface GateVerdict {
|
|
42
|
+
verdict?: string
|
|
43
|
+
by?: string
|
|
44
|
+
hasWhy: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SpecState {
|
|
48
|
+
status: string
|
|
49
|
+
projectPath: string | null
|
|
50
|
+
markerCount: number
|
|
51
|
+
approval: Record<string, GateVerdict> | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NodeSpec {
|
|
55
|
+
type: string | null
|
|
56
|
+
hasSubject: boolean
|
|
57
|
+
hasUseCases: boolean
|
|
58
|
+
lifecycleFields: string[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LedgerGate {
|
|
62
|
+
gate: string
|
|
63
|
+
verdict: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const GATES = ['spec', 'impl']
|
|
67
|
+
const VERDICTS = ['approve', 'pause', 'reject']
|
|
68
|
+
const SPEC_TYPES = ['reference', 'behavioral']
|
|
69
|
+
// Lifecycle frontmatter is root-spec.md-only (lifecycle-governance). A node README
|
|
70
|
+
// carries `spec-type` and nothing else; any of these on a node fails closed —
|
|
71
|
+
// including the retired schema fields, which must never reappear on a node.
|
|
72
|
+
const NODE_FORBIDDEN_FIELDS = ['status', 'project-path', 'approval', 'produced-by', 'aligned', 'spec-layout', 'leash']
|
|
73
|
+
|
|
74
|
+
function frontmatter(text: string): string[] {
|
|
75
|
+
const m = /^---\n([\s\S]*?)\n---/.exec(text)
|
|
76
|
+
return m ? m[1].split('\n') : []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Strip fenced + inline code so a doc that *documents* a marker or a heading
|
|
80
|
+
// (e.g. "wraps `## Subject` in backticks") does not trip its own checks.
|
|
81
|
+
function prose(text: string): string {
|
|
82
|
+
return text.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, '')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseSpecState(text: string): SpecState {
|
|
86
|
+
const lines = frontmatter(text)
|
|
87
|
+
let status = ''
|
|
88
|
+
let projectPath: string | null = null
|
|
89
|
+
let approval: Record<string, GateVerdict> | null = null
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < lines.length; i++) {
|
|
92
|
+
const s = /^status:\s*(.+)$/.exec(lines[i])
|
|
93
|
+
if (s) {
|
|
94
|
+
status = s[1].trim().replace(/^["']|["']$/g, '')
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
const p = /^project-path:\s*(.+)$/.exec(lines[i])
|
|
98
|
+
if (p) {
|
|
99
|
+
projectPath = p[1].trim().replace(/^["']|["']$/g, '')
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
const ap = /^approval:\s*(.*)$/.exec(lines[i])
|
|
103
|
+
if (ap) {
|
|
104
|
+
approval = {}
|
|
105
|
+
if (ap[1].trim() && ap[1].trim() !== '{}') continue // inline non-empty unsupported; treat as empty map
|
|
106
|
+
let gate: string | null = null
|
|
107
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
108
|
+
if (!/^\s/.test(lines[j])) break // dedent to top level ends the block
|
|
109
|
+
const g = /^ {2}(\w+):\s*$/.exec(lines[j])
|
|
110
|
+
if (g) {
|
|
111
|
+
gate = g[1]
|
|
112
|
+
approval[gate] = { hasWhy: false }
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (!gate) continue
|
|
116
|
+
const verdict = /^ {4}verdict:\s*(.+)$/.exec(lines[j])
|
|
117
|
+
if (verdict) approval[gate].verdict = verdict[1].trim().replace(/^["']|["']$/g, '')
|
|
118
|
+
const by = /^ {4}by:\s*(.+)$/.exec(lines[j])
|
|
119
|
+
if (by) approval[gate].by = by[1].trim().replace(/^["']|["']$/g, '')
|
|
120
|
+
if (/^ {4}why:/.test(lines[j])) approval[gate].hasWhy = true
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const markerCount = (prose(text).match(/<!--\s*open:/g) ?? []).length
|
|
126
|
+
return { status, projectPath, markerCount, approval }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The root project spec.md lifecycle tuple.
|
|
130
|
+
export function checkSpec(slug: string, state: SpecState): string[] {
|
|
131
|
+
const { status, markerCount, approval } = state
|
|
132
|
+
const v: string[] = []
|
|
133
|
+
const tag = (msg: string) => v.push(`${slug}: ${msg}`)
|
|
134
|
+
|
|
135
|
+
// `implemented` is backed by the impl gate's runtime suite run (ADR-0017), not a
|
|
136
|
+
// stored flag — the static guard here is the recorded approval.impl ratification
|
|
137
|
+
// (below). No `aligned` cross-check.
|
|
138
|
+
if ((status === 'approved' || status === 'implemented') && markerCount > 0)
|
|
139
|
+
tag(`illegal state — ${markerCount} open marker(s) but status is ${status} (markers block the gate)`)
|
|
140
|
+
|
|
141
|
+
if (approval) {
|
|
142
|
+
for (const [gate, entry] of Object.entries(approval)) {
|
|
143
|
+
if (!GATES.includes(gate)) tag(`approval has unknown gate "${gate}" (expected spec | impl)`)
|
|
144
|
+
if (entry.verdict && !VERDICTS.includes(entry.verdict))
|
|
145
|
+
tag(`approval.${gate} has unknown verdict "${entry.verdict}" (expected approve | pause | reject)`)
|
|
146
|
+
if (entry.verdict === 'pause' && entry.by)
|
|
147
|
+
tag(`approval.${gate} is a pause but carries by — a pause is always the agent's act and omits by`)
|
|
148
|
+
if (entry.verdict === 'approve' && !entry.by)
|
|
149
|
+
tag(`approval.${gate} is an approve with no by — an approve must record its approver`)
|
|
150
|
+
if (entry.by === 'agent' && !entry.hasWhy)
|
|
151
|
+
tag(`approval.${gate} is by:agent but has no why block (a self-assertion must record its derivation)`)
|
|
152
|
+
const passed =
|
|
153
|
+
(gate === 'spec' && (status === 'approved' || status === 'implemented')) ||
|
|
154
|
+
(gate === 'impl' && status === 'implemented')
|
|
155
|
+
if (entry.verdict === 'pause' && passed)
|
|
156
|
+
tag(`approval.${gate} is a pause but the ${gate} gate is already passed (status ${status})`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (
|
|
160
|
+
(status === 'approved' || status === 'implemented') &&
|
|
161
|
+
!(approval?.spec?.verdict === 'approve' && approval?.spec?.by)
|
|
162
|
+
)
|
|
163
|
+
tag(
|
|
164
|
+
`status is ${status} but approval.spec has no approve verdict with an approver — the spec gate has no recorded ratification`,
|
|
165
|
+
)
|
|
166
|
+
if (status === 'implemented' && !(approval?.impl?.verdict === 'approve' && approval?.impl?.by))
|
|
167
|
+
tag(
|
|
168
|
+
'status is implemented but approval.impl has no approve verdict with an approver — the impl gate has no recorded ratification',
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return v
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Referenced-artifact-exists: a backtick-wrapped token shaped like a path (a
|
|
175
|
+
// relative `./`/`../` reference, or repo-root-relative under a known top-level
|
|
176
|
+
// dir) must resolve to a real file/dir. Bounded to these prefixes deliberately —
|
|
177
|
+
// unprefixed slash-containing tokens ("Given / When / Then", "oracle/builder")
|
|
178
|
+
// are prose, not paths, and must never false-positive. Diff-scoped + surface-
|
|
179
|
+
// for-judgment: only paths a CR *introduces* vs the committed baseline are
|
|
180
|
+
// checked — a pre-existing reference the CR left untouched is never gated, and
|
|
181
|
+
// an unresolved introduced ref is a judgment finding, not a hard fail-closed
|
|
182
|
+
// block (referenced ≠ must-exist).
|
|
183
|
+
const PATH_PREFIXES = ['.agents/', 'plugins/', 'packages/', 'apps/', 'docs/', '.claude/']
|
|
184
|
+
|
|
185
|
+
export function extractPathRefs(text: string): string[] {
|
|
186
|
+
const out: string[] = []
|
|
187
|
+
const re = /`([^`\n]+)`/g
|
|
188
|
+
let m: RegExpExecArray | null
|
|
189
|
+
while ((m = re.exec(text))) {
|
|
190
|
+
const token = m[1].trim()
|
|
191
|
+
// A template placeholder (`<project>`) or glob (`*.plan.md`) names a pattern,
|
|
192
|
+
// not a real file — never a violation regardless of prefix.
|
|
193
|
+
if (/[<*]/.test(token)) continue
|
|
194
|
+
const isRelative = token.startsWith('./') || token.startsWith('../')
|
|
195
|
+
const isRooted = PATH_PREFIXES.some((p) => token.startsWith(p))
|
|
196
|
+
if (isRelative || isRooted) out.push(token)
|
|
197
|
+
}
|
|
198
|
+
return out
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Diff at the ref-token level: which of `currentText`'s path refs are absent from
|
|
202
|
+
// `baselineText`'s. Empty baselineText (a brand-new file, or none supplied) means
|
|
203
|
+
// every ref in currentText is introduced.
|
|
204
|
+
export function introducedPathRefs(baselineText: string, currentText: string): string[] {
|
|
205
|
+
const baseRefs = new Set(extractPathRefs(baselineText))
|
|
206
|
+
return extractPathRefs(currentText).filter((ref) => !baseRefs.has(ref))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// `dir` is the checked file's own directory (root-relative, matching this
|
|
210
|
+
// script's CWD-is-repo-root convention) — the base a `./`/`../` token resolves
|
|
211
|
+
// against. A repo-root-relative token resolves against the CWD directly.
|
|
212
|
+
// `baselineText` (default '', i.e. every ref is introduced) scopes the check to
|
|
213
|
+
// the CR's own delta — a pre-existing unresolved ref the CR left untouched is
|
|
214
|
+
// never gated. An unresolved introduced ref is returned as a *finding* for
|
|
215
|
+
// judgment, not a hard violation.
|
|
216
|
+
export function checkReferencedArtifacts(slug: string, dir: string, text: string, baselineText = ''): string[] {
|
|
217
|
+
const v: string[] = []
|
|
218
|
+
const tag = (msg: string) => v.push(`${slug}: introduces unresolved reference \`${msg}\` — surfaced for judgment`)
|
|
219
|
+
for (const ref of introducedPathRefs(baselineText, text)) {
|
|
220
|
+
const clean = ref.replace(/#.*$/, '')
|
|
221
|
+
const isRelative = clean.startsWith('./') || clean.startsWith('../')
|
|
222
|
+
const resolved = isRelative ? join(dir, clean) : clean
|
|
223
|
+
if (!existsSync(resolved)) tag(clean)
|
|
224
|
+
}
|
|
225
|
+
return v
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function parseNode(text: string): NodeSpec {
|
|
229
|
+
let type: string | null = null
|
|
230
|
+
const lifecycleFields: string[] = []
|
|
231
|
+
for (const l of frontmatter(text)) {
|
|
232
|
+
const key = /^([\w-]+):/.exec(l) // top-level key (no leading whitespace)
|
|
233
|
+
if (!key) continue
|
|
234
|
+
if (key[1] === 'spec-type') {
|
|
235
|
+
const m = /^spec-type:\s*(.+)$/.exec(l)
|
|
236
|
+
if (m) type = m[1].trim().replace(/^["']|["']$/g, '')
|
|
237
|
+
} else if (NODE_FORBIDDEN_FIELDS.includes(key[1])) {
|
|
238
|
+
lifecycleFields.push(key[1])
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const body = prose(text)
|
|
242
|
+
return {
|
|
243
|
+
type,
|
|
244
|
+
hasSubject: /^##\s+Subject\b/m.test(body),
|
|
245
|
+
hasUseCases: /^##\s+Use Cases\b/m.test(body),
|
|
246
|
+
lifecycleFields,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The per-node spec-type reconcile. A node README's marker must agree with its
|
|
251
|
+
// shape; descriptive (no marker) carries no requirement.
|
|
252
|
+
export function checkNode(slug: string, node: NodeSpec, hasFeature: boolean): string[] {
|
|
253
|
+
const { type, hasSubject, hasUseCases, lifecycleFields } = node
|
|
254
|
+
const v: string[] = []
|
|
255
|
+
const tag = (msg: string) => v.push(`${slug}: ${msg}`)
|
|
256
|
+
|
|
257
|
+
// Lifecycle is root-spec.md-only — flagged for every node, descriptive included.
|
|
258
|
+
for (const f of lifecycleFields)
|
|
259
|
+
tag(
|
|
260
|
+
`carries lifecycle field "${f}" — lifecycle frontmatter is root-spec.md-only (a node README carries only spec-type)`,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if (type === null) return v
|
|
264
|
+
if (!SPEC_TYPES.includes(type)) {
|
|
265
|
+
tag(`unknown spec-type "${type}" (expected reference | behavioral)`)
|
|
266
|
+
return v
|
|
267
|
+
}
|
|
268
|
+
if (type === 'reference') {
|
|
269
|
+
if (hasFeature)
|
|
270
|
+
tag('spec-type: reference but a sibling .feature exists — a reference artifact is suite-less by design')
|
|
271
|
+
if (!hasSubject) tag('spec-type: reference but no ## Subject section')
|
|
272
|
+
}
|
|
273
|
+
if (type === 'behavioral' && !hasUseCases) tag('spec-type: behavioral but no ## Use Cases section')
|
|
274
|
+
|
|
275
|
+
return v
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// The durable gate-line floor. Parse the sibling ledger (see readLedgerText) for its `gate` lines — the
|
|
279
|
+
// immutable durable verdicts (one JSON object per line; a malformed or non-gate line is skipped,
|
|
280
|
+
// integrity being a separate concern).
|
|
281
|
+
export function parseLedgerGates(text: string): LedgerGate[] {
|
|
282
|
+
const gates: LedgerGate[] = []
|
|
283
|
+
for (const line of text.split('\n')) {
|
|
284
|
+
const s = line.trim()
|
|
285
|
+
if (!s) continue
|
|
286
|
+
let obj: { kind?: string; gate?: string; verdict?: string }
|
|
287
|
+
try {
|
|
288
|
+
obj = JSON.parse(s)
|
|
289
|
+
} catch {
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
if (obj.kind === 'gate' && typeof obj.gate === 'string' && typeof obj.verdict === 'string')
|
|
293
|
+
gates.push({ gate: obj.gate, verdict: obj.verdict })
|
|
294
|
+
}
|
|
295
|
+
return gates
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The durable ledger is a `ledger/` directory of per-CR-per-writer shard files (ADR-0020),
|
|
299
|
+
// with a legacy single-file `ledger.jsonl` tolerated for pre-shard corpora. Concatenate every
|
|
300
|
+
// `*.jsonl` shard plus the legacy file so the gate floor sees the whole durable history across
|
|
301
|
+
// shards, regardless of storage generation. Returns "" when neither exists.
|
|
302
|
+
export function readLedgerText(root: string, slug: string): string {
|
|
303
|
+
const parts: string[] = []
|
|
304
|
+
const legacy = join(root, slug, 'ledger.jsonl')
|
|
305
|
+
if (existsSync(legacy)) parts.push(readFileSync(legacy, 'utf8'))
|
|
306
|
+
const dir = join(root, slug, 'ledger')
|
|
307
|
+
if (existsSync(dir)) {
|
|
308
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
309
|
+
if (e.isFile() && e.name.endsWith('.jsonl')) parts.push(readFileSync(join(dir, e.name), 'utf8'))
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return parts.join('\n')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// A root spec.md at `approved`/`implemented` must carry the matching durable `gate` approve line
|
|
316
|
+
// in its sibling ledger — the `approval` map (checked in checkSpec) is the overwritten current-state
|
|
317
|
+
// twin; this is the immutable durable twin. A status advance with no ledger gate line is an
|
|
318
|
+
// unenforced gate. `draft` requires no ledger (no gate ran).
|
|
319
|
+
export function checkGateFloor(slug: string, status: string, gates: LedgerGate[]): string[] {
|
|
320
|
+
const v: string[] = []
|
|
321
|
+
const tag = (msg: string) => v.push(`${slug}: ${msg}`)
|
|
322
|
+
const approved = (gate: string) => gates.some((g) => g.gate === gate && g.verdict === 'approve')
|
|
323
|
+
|
|
324
|
+
if ((status === 'approved' || status === 'implemented') && !approved('spec'))
|
|
325
|
+
tag(`status is ${status} but the ledger has no spec gate approve line — the durable gate floor is missing`)
|
|
326
|
+
if (status === 'implemented' && !approved('impl'))
|
|
327
|
+
tag('status is implemented but the ledger has no impl gate approve line — the durable gate floor is missing')
|
|
328
|
+
|
|
329
|
+
return v
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function hasFeatureFile(dir: string): boolean {
|
|
333
|
+
try {
|
|
334
|
+
return readdirSync(dir).some((f) => f.endsWith('.feature'))
|
|
335
|
+
} catch {
|
|
336
|
+
return false
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Walk the tree for every dir holding a file named `name`; the slug is the
|
|
341
|
+
// root-relative dir path. Nested projects are real and must be enforced too.
|
|
342
|
+
function discoverDirsWith(root: string, name: string): string[] {
|
|
343
|
+
const out: string[] = []
|
|
344
|
+
const walk = (dir: string, rel: string) => {
|
|
345
|
+
let entries: Dirent[]
|
|
346
|
+
try {
|
|
347
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
348
|
+
} catch {
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
if (entries.some((e) => e.isFile() && e.name === name)) out.push(rel)
|
|
352
|
+
for (const e of entries) {
|
|
353
|
+
if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue
|
|
354
|
+
walk(join(dir, e.name), rel ? join(rel, e.name) : e.name)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
walk(root, '')
|
|
358
|
+
return out
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export const discoverSpecDirs = (root: string): string[] => discoverDirsWith(root, 'spec.md')
|
|
362
|
+
export const discoverNodeDirs = (root: string): string[] => discoverDirsWith(root, 'README.md')
|
|
363
|
+
|
|
364
|
+
// referenced-artifact-exists is deliberately CR-scoped only (--files), never part of
|
|
365
|
+
// the --root tree sweep: the existing corpus's accumulated prose legitimately names
|
|
366
|
+
// example/convention paths (an opt-in config not yet created, a hypothetical nested
|
|
367
|
+
// project) that a blind tree-wide scan cannot distinguish from a real broken
|
|
368
|
+
// reference. Scoped to a CR's own touched files, same shape as check-suite.mts.
|
|
369
|
+
export function parseFilesArg(argv: string[]): string[] {
|
|
370
|
+
const idx = argv.indexOf('--files')
|
|
371
|
+
if (idx === -1) return []
|
|
372
|
+
const paths: string[] = []
|
|
373
|
+
for (let i = idx + 1; i < argv.length; i++) {
|
|
374
|
+
if (argv[i].startsWith('--')) break
|
|
375
|
+
paths.push(argv[i])
|
|
376
|
+
}
|
|
377
|
+
return paths
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Reads a path's content at a given git ref (the committed baseline the CR diffs
|
|
381
|
+
// against). Any failure (new file at base, path not tracked at base, git absent)
|
|
382
|
+
// returns '' — the safe default that treats every ref in the file as introduced.
|
|
383
|
+
function readBaselineFromGit(base: string, path: string): string {
|
|
384
|
+
try {
|
|
385
|
+
return execFileSync('git', ['show', `${base}:${path}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
386
|
+
} catch {
|
|
387
|
+
return ''
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function checkReferencedArtifactsInFiles(
|
|
392
|
+
paths: string[],
|
|
393
|
+
baseline: (path: string) => string = () => '',
|
|
394
|
+
): { findings: string[]; violations: string[] } {
|
|
395
|
+
const findings: string[] = []
|
|
396
|
+
const violations: string[] = []
|
|
397
|
+
for (const p of paths) {
|
|
398
|
+
let text: string
|
|
399
|
+
try {
|
|
400
|
+
text = readFileSync(p, 'utf8')
|
|
401
|
+
} catch {
|
|
402
|
+
violations.push(`${p}: cannot read file`)
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
findings.push(...checkReferencedArtifacts(p, dirname(p), text, baseline(p)))
|
|
406
|
+
}
|
|
407
|
+
return { findings, violations }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The referenced-artifact-exists sweep is widened from the two hardcoded names
|
|
411
|
+
// (spec.md/README.md) to every touched prose `.md` under the spec tree — a
|
|
412
|
+
// `design/*.md` or nested node doc gets the same diff-scoped, surface-for-
|
|
413
|
+
// judgment treatment. This is the boundary: a `.md` the CR touched but that lies
|
|
414
|
+
// outside the three fixed spec-tree roots (`.agents/spec/`, `.agents/specs/`, a
|
|
415
|
+
// nested `<project-path>/.agents/spec/` — mirroring discover-specs.mts) is never
|
|
416
|
+
// swept. Deliberately filters the caller-supplied `--files` list only; it is
|
|
417
|
+
// never a tree-wide `--root` sweep, same rationale as
|
|
418
|
+
// checkReferencedArtifactsInFiles above.
|
|
419
|
+
export function isUnderSpecTree(path: string): boolean {
|
|
420
|
+
return /(^|\/)\.agents\/specs?(\/|$)/.test(path)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function filterProseMdInSpecTree(paths: string[]): string[] {
|
|
424
|
+
return paths.filter((p) => p.endsWith('.md') && isUnderSpecTree(p))
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---- Use-case-coverage pre-filter ----
|
|
428
|
+
// The row->scenario link (spec-format-governance): when a behavioral node's
|
|
429
|
+
// "## Use Cases" section is written as a table with a `Scenario` column, each
|
|
430
|
+
// row names its covering scenario in a backtick-wrapped `Scenario: <title>` (or
|
|
431
|
+
// a shared `@tag`). This is non-mandating — a reference/descriptive doc with no
|
|
432
|
+
// Use Cases section, or a behavioral doc whose Use Cases are prose/EARS (or a
|
|
433
|
+
// table with no Scenario column) carries no row to link, so it raises nothing
|
|
434
|
+
// and stays the spec-judge's coverage backstop.
|
|
435
|
+
|
|
436
|
+
export interface UseCaseScenarioRefs {
|
|
437
|
+
hasSection: boolean
|
|
438
|
+
refs: string[]
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// A markdown table row: strip the leading/trailing `|` then split on `|`, trimming each cell.
|
|
442
|
+
function splitTableRow(line: string): string[] {
|
|
443
|
+
return line
|
|
444
|
+
.trim()
|
|
445
|
+
.replace(/^\|/, '')
|
|
446
|
+
.replace(/\|$/, '')
|
|
447
|
+
.split('|')
|
|
448
|
+
.map((c) => c.trim())
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Slices out the body between a `## <heading>` line and the next top-level `## `
|
|
452
|
+
// heading (or end of text). Manual index-based slicing avoids the multiline-`$`
|
|
453
|
+
// pitfall a lookahead-based regex would hit (`$` in `/m` mode matches every line end).
|
|
454
|
+
function extractSection(body: string, heading: string): string | null {
|
|
455
|
+
const start = new RegExp(`(^|\\n)##\\s+${heading}\\b`).exec(body)
|
|
456
|
+
if (!start) return null
|
|
457
|
+
const rest = body.slice(start.index + start[0].length)
|
|
458
|
+
const end = /\n##\s/.exec(rest)
|
|
459
|
+
return end ? rest.slice(0, end.index) : rest
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function extractUseCaseScenarioRefs(text: string): UseCaseScenarioRefs {
|
|
463
|
+
// Strip fenced code blocks only (not inline `code` spans — the row->scenario
|
|
464
|
+
// link itself lives inside a backtick span, so `prose()`'s inline-span strip
|
|
465
|
+
// would erase the very refs this function extracts).
|
|
466
|
+
const body = text.replace(/```[\s\S]*?```/g, '')
|
|
467
|
+
const section = extractSection(body, 'Use Cases')
|
|
468
|
+
if (section === null) return { hasSection: false, refs: [] }
|
|
469
|
+
const lines = section.split('\n')
|
|
470
|
+
const headerIdx = lines.findIndex((l) => l.trim().startsWith('|'))
|
|
471
|
+
if (headerIdx === -1) return { hasSection: true, refs: [] } // prose or EARS — no table
|
|
472
|
+
const header = splitTableRow(lines[headerIdx])
|
|
473
|
+
const scenarioIdx = header.findIndex((c) => /^scenario$/i.test(c))
|
|
474
|
+
if (scenarioIdx === -1) return { hasSection: true, refs: [] } // table with no Scenario column
|
|
475
|
+
|
|
476
|
+
const refs: string[] = []
|
|
477
|
+
// data rows start after the header separator (`|---|---|`); stop at the first
|
|
478
|
+
// non-`|` line, which ends the contiguous table block.
|
|
479
|
+
for (let i = headerIdx + 2; i < lines.length; i++) {
|
|
480
|
+
if (!lines[i].trim().startsWith('|')) break
|
|
481
|
+
const cells = splitTableRow(lines[i])
|
|
482
|
+
const cell = cells[scenarioIdx]
|
|
483
|
+
if (!cell) continue
|
|
484
|
+
const ref = /`([^`\n]+)`/.exec(cell)
|
|
485
|
+
if (ref) refs.push(ref[1].trim())
|
|
486
|
+
}
|
|
487
|
+
return { hasSection: true, refs }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function escapeRegExp(s: string): string {
|
|
491
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// A ref is either a shared `@tag` (present anywhere as a whole token in the
|
|
495
|
+
// feature text) or a `Scenario: <title>` (an exact sibling `Scenario:` line).
|
|
496
|
+
export function resolveScenarioRef(ref: string, featureText: string): boolean {
|
|
497
|
+
if (ref.startsWith('@')) return new RegExp(`(^|\\s)${escapeRegExp(ref)}(\\s|$)`, 'm').test(featureText)
|
|
498
|
+
const m = /^Scenario:\s*(.+)$/.exec(ref)
|
|
499
|
+
if (!m) return false
|
|
500
|
+
const title = m[1].trim()
|
|
501
|
+
return new RegExp(`^\\s*Scenario:\\s*${escapeRegExp(title)}\\s*$`, 'm').test(featureText)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// `dir` is the checked spec.md/README's own directory — the sibling `.feature` is
|
|
505
|
+
// `<node>.feature` (named after the containing dir) or, failing that, the single
|
|
506
|
+
// `.feature` file in the dir (mirrors hasFeatureFile's discovery, disambiguated by name).
|
|
507
|
+
export function findSiblingFeature(dir: string): string | null {
|
|
508
|
+
let entries: string[]
|
|
509
|
+
try {
|
|
510
|
+
entries = readdirSync(dir).filter((f) => f.endsWith('.feature'))
|
|
511
|
+
} catch {
|
|
512
|
+
return null
|
|
513
|
+
}
|
|
514
|
+
if (entries.length === 0) return null
|
|
515
|
+
const named = `${basename(dir)}.feature`
|
|
516
|
+
if (entries.includes(named)) return join(dir, named)
|
|
517
|
+
return join(dir, entries[0])
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function checkUseCaseCoverage(slug: string, dir: string, text: string): string[] {
|
|
521
|
+
const v: string[] = []
|
|
522
|
+
const tag = (msg: string) => v.push(`${slug}: ${msg}`)
|
|
523
|
+
const { hasSection, refs } = extractUseCaseScenarioRefs(text)
|
|
524
|
+
if (!hasSection || refs.length === 0) return v
|
|
525
|
+
|
|
526
|
+
const featurePath = findSiblingFeature(dir)
|
|
527
|
+
const featureText = featurePath ? readFileSync(featurePath, 'utf8') : ''
|
|
528
|
+
for (const ref of refs) {
|
|
529
|
+
if (!resolveScenarioRef(ref, featureText))
|
|
530
|
+
tag(`Use Cases table names scenario \`${ref}\` that does not resolve in the sibling .feature`)
|
|
531
|
+
}
|
|
532
|
+
return v
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function checkUseCaseCoverageInFiles(paths: string[]): string[] {
|
|
536
|
+
const violations: string[] = []
|
|
537
|
+
for (const p of paths) {
|
|
538
|
+
let text: string
|
|
539
|
+
try {
|
|
540
|
+
text = readFileSync(p, 'utf8')
|
|
541
|
+
} catch {
|
|
542
|
+
continue // an unreadable file is already reported by the referenced-artifact check
|
|
543
|
+
}
|
|
544
|
+
violations.push(...checkUseCaseCoverage(p, dirname(p), text))
|
|
545
|
+
}
|
|
546
|
+
return violations
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function main(argv: string[]): number {
|
|
550
|
+
if (argv.includes('--files')) {
|
|
551
|
+
const paths = parseFilesArg(argv)
|
|
552
|
+
if (paths.length === 0) {
|
|
553
|
+
console.error('✗ --files requires at least one .md path under the spec tree')
|
|
554
|
+
return 1
|
|
555
|
+
}
|
|
556
|
+
const proseFiles = filterProseMdInSpecTree(paths)
|
|
557
|
+
const base = argv.includes('--base') ? argv[argv.indexOf('--base') + 1] : undefined
|
|
558
|
+
const baseline = base ? (p: string) => readBaselineFromGit(base, p) : () => ''
|
|
559
|
+
const { findings, violations: refReadErrors } = checkReferencedArtifactsInFiles(proseFiles, baseline)
|
|
560
|
+
const ucViolations = checkUseCaseCoverageInFiles(proseFiles)
|
|
561
|
+
const violations = [...refReadErrors, ...ucViolations]
|
|
562
|
+
for (const f of findings) process.stdout.write(`⚠ ${f}\n`)
|
|
563
|
+
if (violations.length) {
|
|
564
|
+
for (const line of violations) console.error(`✗ ${line}`)
|
|
565
|
+
return 1
|
|
566
|
+
}
|
|
567
|
+
process.stdout.write(
|
|
568
|
+
findings.length
|
|
569
|
+
? `referenced-artifact findings surfaced for judgment (${findings.length}); use-case-coverage OK\n`
|
|
570
|
+
: 'referenced-artifact and use-case-coverage checks OK\n',
|
|
571
|
+
)
|
|
572
|
+
return 0
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const root = argv.includes('--root') ? argv[argv.indexOf('--root') + 1] : '.agents/specs'
|
|
576
|
+
let violations: string[] = []
|
|
577
|
+
|
|
578
|
+
for (const slug of discoverSpecDirs(root)) {
|
|
579
|
+
const specPath = join(root, slug, 'spec.md')
|
|
580
|
+
if (!existsSync(specPath)) continue
|
|
581
|
+
const state = parseSpecState(readFileSync(specPath, 'utf8'))
|
|
582
|
+
violations = violations.concat(checkSpec(slug, state))
|
|
583
|
+
const gates = parseLedgerGates(readLedgerText(root, slug))
|
|
584
|
+
violations = violations.concat(checkGateFloor(slug, state.status, gates))
|
|
585
|
+
}
|
|
586
|
+
for (const slug of discoverNodeDirs(root)) {
|
|
587
|
+
const dir = join(root, slug)
|
|
588
|
+
const readme = join(dir, 'README.md')
|
|
589
|
+
if (!existsSync(readme)) continue
|
|
590
|
+
violations = violations.concat(checkNode(slug, parseNode(readFileSync(readme, 'utf8')), hasFeatureFile(dir)))
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (violations.length) {
|
|
594
|
+
for (const line of violations) console.error(`✗ ${line}`)
|
|
595
|
+
return 1
|
|
596
|
+
}
|
|
597
|
+
process.stdout.write('spec states OK\n')
|
|
598
|
+
return 0
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)))
|