cyber-sdd 0.6.1 → 0.7.1

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.
Files changed (136) hide show
  1. package/.claude-plugin/plugin.json +1 -2
  2. package/.codex-plugin/plugin.json +1 -2
  3. package/agents/sdd-scanner.md +1 -1
  4. package/agents/sdd-spec-judge.md +3 -3
  5. package/agents/sdd-warden.md +8 -0
  6. package/package.json +11 -13
  7. package/plugin.json +55 -0
  8. package/skills/align-spec/README.md +3 -3
  9. package/skills/align-spec/SKILL.md +2 -2
  10. package/skills/align-spec/scripts/align-spec.mjs +6755 -0
  11. package/skills/architect-impl-governance/SKILL.md +3 -1
  12. package/skills/architect-spec-governance/SKILL.md +5 -1
  13. package/skills/blast-estimate/README.md +4 -4
  14. package/skills/blast-estimate/SKILL.md +1 -1
  15. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -0
  16. package/skills/check-field-mandates/SKILL.md +1 -1
  17. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -0
  18. package/skills/check-partition-quality/SKILL.md +1 -1
  19. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -0
  20. package/skills/check-plan-safety/README.md +3 -3
  21. package/skills/check-plan-safety/SKILL.md +3 -4
  22. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -0
  23. package/skills/check-plugin-manifests/SKILL.md +1 -1
  24. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -0
  25. package/skills/check-project-specs/README.md +3 -3
  26. package/skills/check-project-specs/SKILL.md +3 -3
  27. package/{dist/check-project-specs/scripts/check-project-specs.js → skills/check-project-specs/scripts/check-project-specs.mjs} +300 -18
  28. package/skills/check-retired-terms/README.md +2 -2
  29. package/skills/check-retired-terms/SKILL.md +4 -5
  30. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -0
  31. package/skills/check-scenario-overlap/README.md +2 -2
  32. package/skills/check-scenario-overlap/SKILL.md +2 -3
  33. package/{dist/check-scenario-overlap/scripts/check-scenario-overlap.js → skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs} +3 -1
  34. package/skills/check-spec-references/README.md +1 -1
  35. package/skills/check-spec-references/SKILL.md +3 -3
  36. package/{dist/check-spec-references/scripts/check-spec-references.js → skills/check-spec-references/scripts/check-spec-references.mjs} +3 -1
  37. package/skills/check-spec-structure/README.md +2 -2
  38. package/skills/check-spec-structure/SKILL.md +2 -3
  39. package/{dist/check-spec-structure/scripts/check-spec-structure.js → skills/check-spec-structure/scripts/check-spec-structure.mjs} +3 -1
  40. package/skills/collision-ladder/README.md +2 -2
  41. package/skills/collision-ladder/SKILL.md +2 -2
  42. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -0
  43. package/skills/combat-log-governance/SKILL.md +1 -1
  44. package/skills/concept-index/README.md +2 -2
  45. package/skills/concept-index/SKILL.md +2 -2
  46. package/{dist/concept-index/scripts/concept-index.js → skills/concept-index/scripts/concept-index.mjs} +3 -1
  47. package/skills/discover-plans/README.md +5 -5
  48. package/skills/discover-plans/SKILL.md +2 -2
  49. package/skills/discover-plans/scripts/discover-plans.mjs +137 -0
  50. package/skills/discover-specs/README.md +4 -4
  51. package/skills/discover-specs/SKILL.md +2 -2
  52. package/{dist/discover-specs/scripts/discover-specs.js → skills/discover-specs/scripts/discover-specs.mjs} +4 -1
  53. package/skills/doctrine-loop/SKILL.md +2 -2
  54. package/skills/formation-loop/SKILL.md +10 -0
  55. package/skills/gate-validation-governance/SKILL.md +2 -2
  56. package/skills/init/README.md +5 -5
  57. package/skills/init/SKILL.md +3 -3
  58. package/skills/init/scripts/wire-statusline.mjs +168 -0
  59. package/skills/manage-ignore/README.md +8 -8
  60. package/skills/manage-ignore/SKILL.md +2 -3
  61. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -0
  62. package/skills/manage-scenario-bridge/README.md +6 -6
  63. package/skills/manage-scenario-bridge/SKILL.md +2 -2
  64. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -0
  65. package/skills/manage-spec-anchors/README.md +7 -7
  66. package/skills/manage-spec-anchors/SKILL.md +2 -2
  67. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -0
  68. package/skills/mission-graph/README.md +2 -2
  69. package/skills/mission-graph/SKILL.md +10 -10
  70. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -0
  71. package/skills/pause-mission/SKILL.md +2 -2
  72. package/skills/place-node/README.md +1 -1
  73. package/skills/place-node/SKILL.md +3 -3
  74. package/skills/place-node/scripts/place-node.mjs +117 -0
  75. package/skills/plan-retirement/README.md +2 -2
  76. package/skills/plan-retirement/SKILL.md +1 -1
  77. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -0
  78. package/skills/resolve-governances/README.md +4 -4
  79. package/skills/resolve-governances/SKILL.md +2 -2
  80. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -0
  81. package/skills/resolve-tracking/SKILL.md +2 -2
  82. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -0
  83. package/skills/scaffold-project-spec/SKILL.md +3 -3
  84. package/skills/sdd/SKILL.md +1 -1
  85. package/skills/spec-gate/README.md +12 -9
  86. package/skills/spec-gate/SKILL.md +6 -6
  87. package/{dist/spec-gate/scripts/check-spec-state.js → skills/spec-gate/scripts/check-spec-state.mjs} +3 -1
  88. package/{dist/spec-gate/vendor/gherkin-cli.js → skills/spec-gate/scripts/check-suite.mjs} +319 -188
  89. package/skills/spec-gate/{vendor/gherkin-cli.mjs → scripts/classify-edit-class.mjs} +243 -209
  90. package/skills/spec-producer-governance/SKILL.md +4 -4
  91. package/skills/spec-structure-governance/README.md +4 -2
  92. package/skills/spec-structure-governance/SKILL.md +42 -7
  93. package/skills/start-mission/SKILL.md +2 -2
  94. package/skills/suite-format-governance/README.md +1 -1
  95. package/skills/suite-format-governance/SKILL.md +1 -1
  96. package/skills/touch-set-correction/README.md +5 -5
  97. package/skills/touch-set-correction/SKILL.md +2 -2
  98. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -0
  99. package/skills/verify-scenarios/README.md +9 -9
  100. package/skills/verify-scenarios/SKILL.md +2 -2
  101. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -0
  102. package/.plugin/pins.json +0 -3
  103. package/.plugin/plugin.json +0 -19
  104. package/dist/align-spec/scripts/align-spec.js +0 -102
  105. package/dist/spec-gate/scripts/check-suite.js +0 -317
  106. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  107. package/skills/align-spec/scripts/align-spec.mts +0 -188
  108. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  109. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  110. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  111. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  112. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  113. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -369
  114. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  115. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  116. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  117. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  118. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -660
  119. package/skills/concept-index/scripts/concept-index.mts +0 -246
  120. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  121. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  122. package/skills/init/scripts/wire-statusline.mts +0 -279
  123. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  124. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  125. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  126. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  127. package/skills/place-node/scripts/place-node.mts +0 -158
  128. package/skills/plan-retirement/scripts/retire-plans.mts +0 -199
  129. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  130. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  131. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  132. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  133. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  134. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  135. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -420
  136. package/skills/verify-scenarios/scripts/verify-scenarios.mts +0 -470
@@ -1,420 +0,0 @@
1
- #!/usr/bin/env node
2
- // discover-specs — corpus/discovery's concrete frontmatter engine. Scans the three
3
- // SDD spec locations, filters candidates by the lifecycle `status` shape, parses each
4
- // spec.md's frontmatter ONLY (never its body), and emits a TOON list of the specs found,
5
- // each carrying its project NAME (so a consumer can resolve a name → spec).
6
- //
7
- // Recognition is location-bounded AND shape-confirmed (ADR-0017, narrowed):
8
- // 1. <root>/.agents/spec/spec.md — repo-root single-project
9
- // 2. <root>/.agents/specs/<project>/spec.md — repo-root multi-project
10
- // 3. <project-path>/.agents/spec/spec.md — a nested project (** = project-path, any depth)
11
- // 4. any extra anchor declared in .agents/sdd/spec-anchors.toml (ADR-0019) — opt-in and additive;
12
- // absent config ⇒ only 1–3 are scanned (today's behavior). A pattern may carry a <project>
13
- // capture token that both globs a segment and names the spec from it; `**` globs zero or more
14
- // segments (any depth), for a spec whose depth under an anchor root varies.
15
- // A spec.md at one of these locations is a spec only if its frontmatter `status` is in the
16
- // lifecycle enum; a status-bearing spec.md elsewhere, or a stray spec.md at a spec location
17
- // with no lifecycle status, is NOT loaded (so the scan never grabs the wrong file by accident).
18
- //
19
- // The project NAME cannot always be derived: it is `declared` (frontmatter `name`, authoritative),
20
- // else `derived` (the repo-root single-project → `repo`; a `.agents/specs/<project>` folder → the
21
- // folder), else `guessed` (a nested project's folder basename — may not be the name the user uses).
22
- // `name-source` flags which, so a consumer knows when to confirm a guessed name.
23
- //
24
- // Pure functions are exported for node:test; running the file directly drives the CLI.
25
- // No dependencies (the repo's node-≥23.6 / no-deps convention). --format json for a flat array;
26
- // default output is TOON (the token-efficient tabular form the gateway scans). --resolve <name>
27
- // filters to the exact name matches (0 rows = none, 1 = resolved, >1 = ambiguous).
28
-
29
- import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
30
- import { join } from 'node:path'
31
- import { pathToFileURL } from 'node:url'
32
-
33
- export const LIFECYCLE_STATUSES = new Set(['draft', 'approved', 'implemented', 'deprecated'])
34
-
35
- // The repo-root single-project (`.agents/spec`) has no folder to name it; this assumable label does.
36
- const ROOT_PROJECT_NAME = 'repo'
37
-
38
- // Dirs the scan never descends into (keep `.agents` — specs live under it).
39
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
40
-
41
- /**
42
- * A directory that is itself a checkout is a DIFFERENT repository's tree, not part of this corpus.
43
- *
44
- * A name blocklist cannot express this: it can only say "this is called X", never "this is a
45
- * boundary". `SKIP_DIRS` already holds `.git`, so the walk skipped the METADATA directory while
46
- * descending straight into the checkout that directory marks — one level off from where the guard
47
- * was needed. Agent-harness worktree isolation checks this repo out inside itself, and the scan then
48
- * found the whole corpus once per worktree: 38 spec files where 10 exist, and every corpus-wide
49
- * guard silently auditing a tree nobody has.
50
- *
51
- * `.git` is a DIRECTORY in a clone and a FILE in a worktree or submodule, so both forms count.
52
- */
53
- function isNestedCheckout(abs: string): boolean {
54
- return existsSync(join(abs, '.git'))
55
- }
56
-
57
- // The opt-in extra-anchor registry (ADR-0019). Scanned IN ADDITION TO the three fixed conventions;
58
- // absent ⇒ only the fixed conventions are scanned (today's behavior, unchanged).
59
- const ANCHORS_CONFIG = '.agents/sdd/spec-anchors.toml'
60
-
61
- export type NameSource = 'declared' | 'derived' | 'guessed'
62
-
63
- export interface SpecRecord {
64
- /** Root-relative directory of the spec.md (its slug). */
65
- path: string
66
- /** The project name — declared in frontmatter, else derived, else guessed. */
67
- name: string
68
- /** Where `name` came from, so a consumer knows when to confirm a guess. */
69
- nameSource: NameSource
70
- status: string
71
- /** Frontmatter project-path (the governed source dir), or '' when absent. */
72
- projectPath: string
73
- /** Gate verdicts as `<gate>:<verdict>` pairs, in spec→impl order; '' when none. */
74
- approvals: string
75
- }
76
-
77
- export interface Frontmatter {
78
- status?: string
79
- projectPath?: string
80
- /** Declared project name (authoritative over derivation), when present. */
81
- name?: string
82
- /** gate → verdict (e.g. spec → approve). */
83
- approval: Record<string, string>
84
- }
85
-
86
- // ── Frontmatter parse (a minimal YAML subset — only the router-index schema) ──
87
- // Extracts the leading `---` … `---` block and reads status, name, project-path, and each
88
- // approval gate's verdict. Returns null when there is no frontmatter block at all.
89
- export function parseFrontmatter(text: string): Frontmatter | null {
90
- const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text)
91
- if (!m) return null
92
- const fm: Frontmatter = { approval: {} }
93
- let gate: string | null = null // current gate under `approval:`
94
- for (const raw of m[1].split('\n')) {
95
- const line = raw.replace(/\r$/, '')
96
- if (line.trim() === '' || line.trim().startsWith('#')) continue
97
- const indent = line.length - line.trimStart().length
98
- const [key, ...rest] = line.trim().split(':')
99
- const value = rest.join(':').trim()
100
- if (indent === 0) {
101
- gate = null
102
- if (key === 'status') fm.status = unquote(value)
103
- else if (key === 'project-path') fm.projectPath = unquote(value)
104
- else if (key === 'name') fm.name = unquote(value)
105
- else if (key === 'approval') gate = null // enter the approval block
106
- } else if (indent === 2 && value === '' && insideApproval(m[1], line)) {
107
- gate = key // a gate name under approval:
108
- } else if (indent >= 4 && key === 'verdict' && gate) {
109
- fm.approval[gate] = unquote(value)
110
- }
111
- }
112
- return fm
113
- }
114
-
115
- // True when `line` sits under the top-level `approval:` key (the nearest indent-0 parent is it).
116
- function insideApproval(block: string, line: string): boolean {
117
- const lines = block.split('\n')
118
- const idx = lines.indexOf(line)
119
- for (let i = idx - 1; i >= 0; i--) {
120
- const l = lines[i].replace(/\r$/, '')
121
- if (l.trim() === '' || l.trim().startsWith('#')) continue
122
- if (l.length - l.trimStart().length === 0) return l.trim().replace(/:.*$/, '') === 'approval'
123
- }
124
- return false
125
- }
126
-
127
- function unquote(v: string): string {
128
- return v.replace(/^["']|["']$/g, '')
129
- }
130
-
131
- // ── Location recognition ──
132
- // Classify a root-relative spec.md path to one of the three spec locations, returning the
133
- // pattern + the location dir it implies (the <project> folder for root-multi, the project-path
134
- // for nested, '' for root-single). Returns null for any other location.
135
- type LocationPattern = 'root-single' | 'root-multi' | 'nested' | 'extra'
136
-
137
- export interface Location {
138
- pattern: LocationPattern
139
- locationDir: string
140
- /** For an extra anchor whose pattern carried a <project> token: the captured name segment. */
141
- capturedName?: string
142
- }
143
-
144
- export function classifyLocation(relPath: string): Location | null {
145
- const p = relPath.replace(/\\/g, '/')
146
- if (p === '.agents/spec/spec.md') return { pattern: 'root-single', locationDir: '' }
147
- const multi = /^\.agents\/specs\/([^/]+)\/spec\.md$/.exec(p)
148
- if (multi) return { pattern: 'root-multi', locationDir: multi[1] }
149
- const nested = /^(.+)\/\.agents\/spec\/spec\.md$/.exec(p)
150
- if (nested) return { pattern: 'nested', locationDir: nested[1] }
151
- return null
152
- }
153
-
154
- // Derive a project's name + the confidence in it. A declared frontmatter `name` is authoritative;
155
- // otherwise the repo-root single-project is the assumable `repo`, a `.agents/specs/<project>` folder
156
- // names itself (derived), and a nested project falls back to its folder basename (guessed — it may
157
- // not be the name the user uses; a consumer should confirm).
158
- export function deriveName(loc: Location, fm: Frontmatter): { name: string; nameSource: NameSource } {
159
- if (fm.name) return { name: fm.name, nameSource: 'declared' }
160
- if (loc.pattern === 'root-single') return { name: ROOT_PROJECT_NAME, nameSource: 'derived' }
161
- if (loc.pattern === 'root-multi') return { name: loc.locationDir, nameSource: 'derived' }
162
- // An extra anchor with a <project> capture names the spec from the captured segment (derived);
163
- // without a capture it falls back to the folder basename (guessed), like a nested project.
164
- if (loc.pattern === 'extra' && loc.capturedName) {
165
- return { name: loc.capturedName, nameSource: 'derived' }
166
- }
167
- return { name: loc.locationDir.split('/').pop() ?? loc.locationDir, nameSource: 'guessed' }
168
- }
169
-
170
- // ── Scan ──
171
- // Walk the tree under root, returning root-relative paths of every spec.md sitting at one of
172
- // the three spec locations. The walk finds `.agents` dirs at any depth (pattern 3) and probes
173
- // `spec/spec.md` (patterns 1 & 3) plus, only at the repo root, `specs/<project>/spec.md`
174
- // (pattern 2).
175
- export function discoverSpecFiles(root: string): string[] {
176
- const found: string[] = []
177
- const walk = (relDir: string): void => {
178
- let entries: import('node:fs').Dirent[]
179
- try {
180
- entries = readdirSync(join(root, relDir), { withFileTypes: true })
181
- } catch {
182
- return
183
- }
184
- for (const e of entries) {
185
- if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue
186
- const childRel = relDir ? `${relDir}/${e.name}` : e.name
187
- if (isNestedCheckout(join(root, childRel))) continue
188
- if (e.name === '.agents') {
189
- probeAgents(root, childRel, found)
190
- continue // spec locations live directly under .agents, no deeper walk needed
191
- }
192
- walk(childRel)
193
- }
194
- }
195
- walk('')
196
- return found
197
- }
198
-
199
- // Probe a discovered `.agents` dir for the spec locations beneath it.
200
- function probeAgents(root: string, agentsRel: string, found: string[]): void {
201
- const single = `${agentsRel}/spec/spec.md`
202
- if (existsSync(join(root, single))) found.push(single)
203
- if (agentsRel === '.agents') {
204
- // pattern 2 — repo-root multi-project — is root-only (no ** prefix).
205
- const specsDir = join(root, '.agents', 'specs')
206
- let projects: import('node:fs').Dirent[]
207
- try {
208
- projects = readdirSync(specsDir, { withFileTypes: true })
209
- } catch {
210
- projects = []
211
- }
212
- for (const p of projects) {
213
- if (!p.isDirectory()) continue
214
- const rel = `.agents/specs/${p.name}/spec.md`
215
- if (existsSync(join(root, rel))) found.push(rel)
216
- }
217
- }
218
- }
219
-
220
- // ── Extra anchors — the opt-in registry (ADR-0019) ──
221
- // A minimal TOML read: pull the string entries out of the `anchors = [ … ]` array. Paths carry no
222
- // `#`, so entries are the quoted strings inside the array literal (across newlines). Returns [] when
223
- // there is no anchors array (a config that commented them all out is a valid empty set).
224
- export function parseAnchorsToml(text: string): string[] {
225
- const m = /(^|\n)\s*anchors\s*=\s*\[([\s\S]*?)\]/.exec(text)
226
- if (!m) return []
227
- const out: string[] = []
228
- for (const q of m[2].matchAll(/"([^"]*)"|'([^']*)'/g)) out.push((q[1] ?? q[2]).trim())
229
- return out.filter((s) => s !== '')
230
- }
231
-
232
- // Read the extra-anchor patterns, FAIL-SAFE: an absent config yields []; an unreadable/malformed one
233
- // warns and yields [] so the gateway's status scan never crashes on a hand-corrupted config.
234
- export function readAnchors(root: string): string[] {
235
- const file = join(root, ANCHORS_CONFIG)
236
- if (!existsSync(file)) return []
237
- try {
238
- return parseAnchorsToml(readFileSync(file, 'utf8'))
239
- } catch {
240
- process.stderr.write(`discover-specs: ignoring unreadable ${ANCHORS_CONFIG}\n`)
241
- return []
242
- }
243
- }
244
-
245
- // Every dir reachable from `startDir` by descending zero or more levels (startDir itself included),
246
- // skipping SKIP_DIRS. Backs the `**` segment (any-depth glob) below.
247
- function collectDescendants(root: string, startDir: string): string[] {
248
- const out = [startDir]
249
- let entries: import('node:fs').Dirent[]
250
- try {
251
- entries = readdirSync(join(root, startDir), { withFileTypes: true })
252
- } catch {
253
- return out
254
- }
255
- for (const e of entries) {
256
- if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue
257
- const childRel = startDir ? `${startDir}/${e.name}` : e.name
258
- if (isNestedCheckout(join(root, childRel))) continue
259
- out.push(...collectDescendants(root, childRel))
260
- }
261
- return out
262
- }
263
-
264
- // Expand one anchor pattern against the filesystem into the spec dirs it matches. A `*` segment globs
265
- // one directory level; a `**` segment globs zero or more levels (any depth); a `<project>` segment
266
- // globs AND captures one level as the spec name. A literal segment must exist. The matched dir is a
267
- // spec dir iff it holds a spec.md.
268
- export function expandAnchor(root: string, pattern: string): { rel: string; capturedName?: string }[] {
269
- const segs = pattern
270
- .replace(/\\/g, '/')
271
- .replace(/^\/+|\/+$/g, '')
272
- .split('/')
273
- .filter(Boolean)
274
- let frontier: { dir: string; capturedName?: string }[] = [{ dir: '' }]
275
- for (const seg of segs) {
276
- const next: { dir: string; capturedName?: string }[] = []
277
- if (seg === '**') {
278
- for (const node of frontier) {
279
- for (const dir of collectDescendants(root, node.dir)) next.push({ dir, capturedName: node.capturedName })
280
- }
281
- frontier = next
282
- continue
283
- }
284
- const isGlob = seg === '*' || seg === '<project>'
285
- for (const node of frontier) {
286
- if (isGlob) {
287
- let entries: import('node:fs').Dirent[]
288
- try {
289
- entries = readdirSync(join(root, node.dir), { withFileTypes: true })
290
- } catch {
291
- continue
292
- }
293
- for (const e of entries) {
294
- if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue
295
- const childRel = node.dir ? `${node.dir}/${e.name}` : e.name
296
- if (isNestedCheckout(join(root, childRel))) continue
297
- next.push({
298
- dir: childRel,
299
- capturedName: seg === '<project>' ? e.name : node.capturedName,
300
- })
301
- }
302
- } else {
303
- next.push({ dir: node.dir ? `${node.dir}/${seg}` : seg, capturedName: node.capturedName })
304
- }
305
- }
306
- frontier = next
307
- }
308
- const out: { rel: string; capturedName?: string }[] = []
309
- for (const node of frontier) {
310
- const rel = node.dir ? `${node.dir}/spec.md` : 'spec.md'
311
- if (existsSync(join(root, rel))) out.push({ rel, capturedName: node.capturedName })
312
- }
313
- return out
314
- }
315
-
316
- // ── Collect ──
317
- // Build a SpecRecord from a spec.md at `rel` under `loc`, or null when it fails the status shape
318
- // filter (or the file is unreadable). Shared by the fixed-convention and extra-anchor passes.
319
- function recordFor(root: string, rel: string, loc: Location): SpecRecord | null {
320
- let fm: Frontmatter | null
321
- try {
322
- fm = parseFrontmatter(readFileSync(join(root, rel), 'utf8'))
323
- } catch {
324
- return null
325
- }
326
- if (!fm?.status || !LIFECYCLE_STATUSES.has(fm.status)) return null // shape filter
327
- const { name, nameSource } = deriveName(loc, fm)
328
- return {
329
- path: rel.replace(/\/spec\.md$/, ''),
330
- name,
331
- nameSource,
332
- status: fm.status,
333
- projectPath: fm.projectPath ?? '',
334
- approvals: Object.entries(fm.approval)
335
- .map(([gate, verdict]) => `${gate}:${verdict}`)
336
- .join(';'),
337
- }
338
- }
339
-
340
- // The list of specs under root: every spec.md at one of the three fixed conventions PLUS every
341
- // spec.md at a declared extra anchor, whose frontmatter status is in the lifecycle enum, keyed by
342
- // its folder slug, sorted by path. Extra anchors are additive and deduped against the fixed set.
343
- export function collectSpecs(root: string): SpecRecord[] {
344
- const out: SpecRecord[] = []
345
- const seen = new Set<string>()
346
- for (const rel of discoverSpecFiles(root)) {
347
- const loc = classifyLocation(rel)
348
- if (!loc) continue
349
- const rec = recordFor(root, rel, loc)
350
- if (rec) {
351
- out.push(rec)
352
- seen.add(rel)
353
- }
354
- }
355
- for (const pattern of readAnchors(root)) {
356
- for (const { rel, capturedName } of expandAnchor(root, pattern)) {
357
- if (seen.has(rel)) continue // already found at a fixed convention
358
- const rec = recordFor(root, rel, {
359
- pattern: 'extra',
360
- locationDir: rel.replace(/\/spec\.md$/, ''),
361
- capturedName,
362
- })
363
- if (rec) {
364
- out.push(rec)
365
- seen.add(rel)
366
- }
367
- }
368
- }
369
- return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
370
- }
371
-
372
- // ── Resolve a name (the deterministic half of resolve-a-name) ──
373
- // Exact, case-insensitive name match over the discovered list. `match` resolves to one spec;
374
- // `ambiguous` returns the candidate set for a consumer to disambiguate WITH THE USER (the agentic
375
- // half, not this function's job); `none` when no name matches.
376
- export type ResolveResult =
377
- | { kind: 'match'; spec: SpecRecord }
378
- | { kind: 'ambiguous'; candidates: SpecRecord[] }
379
- | { kind: 'none' }
380
-
381
- export function resolveByName(specs: SpecRecord[], name: string): ResolveResult {
382
- const want = name.trim().toLowerCase()
383
- const hits = specs.filter((s) => s.name.toLowerCase() === want)
384
- if (hits.length === 0) return { kind: 'none' }
385
- if (hits.length === 1) return { kind: 'match', spec: hits[0] }
386
- return { kind: 'ambiguous', candidates: hits }
387
- }
388
-
389
- // ── Output ──
390
- const COLUMNS = ['path', 'name', 'nameSource', 'status', 'projectPath', 'approvals'] as const
391
-
392
- // Quote a TOON field only when it carries the delimiter, a quote, or edge whitespace.
393
- function toonField(v: string): string {
394
- if (v === '' || /[",]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
395
- return v
396
- }
397
-
398
- export function toToon(specs: SpecRecord[]): string {
399
- const header = `specs[${specs.length}]{${COLUMNS.join(',')}}:`
400
- const rows = specs.map((s) => ` ${COLUMNS.map((c) => toonField(s[c])).join(',')}`)
401
- return [header, ...rows].join('\n')
402
- }
403
-
404
- export function main(argv: string[]): number {
405
- const root = argv.includes('--root') ? (argv[argv.indexOf('--root') + 1] ?? '.') : '.'
406
- const format = argv.includes('--format') ? argv[argv.indexOf('--format') + 1] : 'toon'
407
- let specs = collectSpecs(root)
408
- if (argv.includes('--resolve')) {
409
- const name = argv[argv.indexOf('--resolve') + 1] ?? ''
410
- const r = resolveByName(specs, name)
411
- specs = r.kind === 'match' ? [r.spec] : r.kind === 'ambiguous' ? r.candidates : []
412
- }
413
- const out = format === 'json' ? JSON.stringify(specs, null, 2) : toToon(specs)
414
- process.stdout.write(`${out}\n`)
415
- return 0
416
- }
417
-
418
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
419
- process.exit(main(process.argv.slice(2)))
420
- }