cyber-sdd 0.7.0 → 0.7.2

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 (150) hide show
  1. package/.claude-plugin/plugin.json +1 -2
  2. package/.codex-plugin/plugin.json +1 -2
  3. package/CHANGELOG.md +320 -0
  4. package/agents/sdd-scanner.md +1 -1
  5. package/package.json +11 -12
  6. package/plugin.json +55 -0
  7. package/skills/align-spec/SKILL.md +1 -1
  8. package/skills/align-spec/scripts/align-spec.mjs +6755 -12
  9. package/skills/blast-estimate/README.md +3 -3
  10. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
  11. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
  12. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
  13. package/skills/check-plan-safety/SKILL.md +2 -3
  14. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
  15. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
  16. package/skills/check-project-specs/SKILL.md +1 -1
  17. package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
  18. package/skills/check-retired-terms/SKILL.md +2 -3
  19. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
  20. package/skills/check-scenario-overlap/SKILL.md +1 -2
  21. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
  22. package/skills/check-spec-references/SKILL.md +2 -2
  23. package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
  24. package/skills/check-spec-structure/SKILL.md +1 -2
  25. package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
  26. package/skills/collision-ladder/README.md +2 -2
  27. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
  28. package/skills/combat-log-governance/SKILL.md +1 -1
  29. package/skills/concept-index/SKILL.md +1 -1
  30. package/skills/concept-index/scripts/concept-index.mjs +183 -12
  31. package/skills/discover-plans/README.md +3 -3
  32. package/skills/discover-plans/SKILL.md +1 -1
  33. package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
  34. package/skills/discover-specs/README.md +3 -3
  35. package/skills/discover-specs/SKILL.md +1 -1
  36. package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
  37. package/skills/doctrine-loop/SKILL.md +1 -1
  38. package/skills/gate-validation-governance/SKILL.md +1 -1
  39. package/skills/init/README.md +2 -2
  40. package/skills/init/scripts/wire-statusline.mjs +168 -12
  41. package/skills/manage-ignore/README.md +3 -3
  42. package/skills/manage-ignore/SKILL.md +1 -2
  43. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
  44. package/skills/manage-scenario-bridge/README.md +3 -3
  45. package/skills/manage-scenario-bridge/SKILL.md +1 -1
  46. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
  47. package/skills/manage-spec-anchors/README.md +3 -3
  48. package/skills/manage-spec-anchors/SKILL.md +1 -1
  49. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
  50. package/skills/mission-graph/README.md +2 -2
  51. package/skills/mission-graph/SKILL.md +1 -1
  52. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
  53. package/skills/pause-mission/SKILL.md +1 -1
  54. package/skills/place-node/SKILL.md +2 -2
  55. package/skills/place-node/scripts/place-node.mjs +117 -12
  56. package/skills/plan-retirement/README.md +2 -2
  57. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
  58. package/skills/resolve-governances/README.md +3 -3
  59. package/skills/resolve-governances/SKILL.md +1 -1
  60. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
  61. package/skills/resolve-tracking/SKILL.md +1 -1
  62. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
  63. package/skills/scaffold-project-spec/SKILL.md +1 -1
  64. package/skills/spec-gate/README.md +12 -9
  65. package/skills/spec-gate/SKILL.md +2 -2
  66. package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
  67. package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
  68. package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
  69. package/skills/spec-producer-governance/SKILL.md +2 -2
  70. package/skills/start-mission/SKILL.md +1 -1
  71. package/skills/suite-format-governance/README.md +1 -1
  72. package/skills/suite-format-governance/SKILL.md +1 -1
  73. package/skills/touch-set-correction/README.md +5 -5
  74. package/skills/touch-set-correction/SKILL.md +1 -1
  75. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
  76. package/skills/verify-scenarios/README.md +6 -6
  77. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
  78. package/.plugin/pins.json +0 -3
  79. package/.plugin/plugin.json +0 -19
  80. package/dist/align-spec/scripts/align-spec.js +0 -102
  81. package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
  82. package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
  83. package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
  84. package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
  85. package/dist/concept-index/scripts/concept-index.js +0 -181
  86. package/dist/discover-specs/scripts/discover-specs.js +0 -318
  87. package/dist/spec-gate/scripts/check-spec-state.js +0 -420
  88. package/dist/spec-gate/scripts/check-suite.js +0 -317
  89. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  90. package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
  91. package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
  92. package/skills/align-spec/scripts/align-spec.mts +0 -188
  93. package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
  94. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  95. package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
  96. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  97. package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
  98. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  99. package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
  100. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  101. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
  102. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  103. package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
  104. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
  105. package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
  106. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  107. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
  108. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  109. package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
  110. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  111. package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
  112. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  113. package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
  114. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
  115. package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
  116. package/skills/concept-index/scripts/concept-index.mts +0 -246
  117. package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
  118. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  119. package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
  120. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  121. package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
  122. package/skills/init/scripts/wire-statusline.mts +0 -279
  123. package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
  124. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  125. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
  126. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  127. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
  128. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  129. package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
  130. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  131. package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
  132. package/skills/place-node/scripts/place-node.mts +0 -158
  133. package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
  134. package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
  135. package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
  136. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  137. package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
  138. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  139. package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
  140. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  141. package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
  142. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  143. package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
  144. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  145. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  146. package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
  147. package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
  148. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
  149. package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
  150. 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
- }
@@ -1,166 +0,0 @@
1
- #!/usr/bin/env node
2
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import { pathToFileURL } from "node:url";
6
- //#region skills/init/scripts/wire-statusline.mts
7
- const STATUS_FILE = ".agents/sdd/statusline";
8
- const SETTINGS_FILE = ".claude/settings.json";
9
- const GITIGNORE_FILE = ".gitignore";
10
- const GLOBAL_SETTINGS_FILE = join(homedir(), ".claude", "settings.json");
11
- const MARKER_BEGIN = "# sdd-statusline:begin";
12
- const MARKER_END = "# sdd-statusline:end";
13
- const MODE_PREFIX = "# sdd-statusline:mode:";
14
- const ORIG_OPEN = "__sdd_orig() {";
15
- const ORIG_CLOSE = "}";
16
- const NO_BASE = ":";
17
- function buildCommand(base, mode) {
18
- const origBody = base && base.trim() !== "" ? base : NO_BASE;
19
- const render = mode === "own-line" ? `if [ -s "$__sdd_file" ]; then\n __sdd_val="$(cat "$__sdd_file")"\n printf '%s\\n%s\\n' "$__sdd_base" "$__sdd_val"\nelse\n printf '%s\\n' "$__sdd_base"\nfi` : `if [ -s "$__sdd_file" ]; then\n __sdd_val="$(cat "$__sdd_file")"\n printf '%s | %s\\n' "$__sdd_base" "$__sdd_val"\nelse\n printf '%s\\n' "$__sdd_base"\nfi`;
20
- return [
21
- MARKER_BEGIN,
22
- `${MODE_PREFIX}${mode}`,
23
- ORIG_OPEN,
24
- origBody,
25
- ORIG_CLOSE,
26
- "__sdd_base=\"$(__sdd_orig 2>/dev/null)\"",
27
- `__sdd_file="${STATUS_FILE}"`,
28
- render,
29
- MARKER_END
30
- ].join("\n");
31
- }
32
- function parseCommand(command) {
33
- if (!command?.includes(MARKER_BEGIN)) return { wired: false };
34
- const modeMatch = new RegExp(`${MODE_PREFIX}(own-line|same-line)`).exec(command);
35
- const origMatch = /__sdd_orig\(\) \{\n([\s\S]*?)\n\}/.exec(command);
36
- const mode = modeMatch?.[1] ?? "own-line";
37
- const origBody = origMatch?.[1] ?? NO_BASE;
38
- return {
39
- wired: true,
40
- base: origBody === NO_BASE ? void 0 : origBody,
41
- mode
42
- };
43
- }
44
- function baseToWrap(existingCommand) {
45
- const parsed = parseCommand(existingCommand);
46
- return parsed.wired ? parsed.base : existingCommand;
47
- }
48
- function readSettingsFile(file) {
49
- if (!existsSync(file)) return {};
50
- try {
51
- return JSON.parse(readFileSync(file, "utf8"));
52
- } catch {
53
- return {};
54
- }
55
- }
56
- function readSettings(root) {
57
- return readSettingsFile(join(root, SETTINGS_FILE));
58
- }
59
- function readStatusLineCommand(file) {
60
- const command = readSettingsFile(file).statusLine?.command;
61
- if (typeof command !== "string" || command.trim() === "") return void 0;
62
- return command;
63
- }
64
- function writeSettings(root, settings) {
65
- const file = join(root, SETTINGS_FILE);
66
- mkdirSync(dirname(file), { recursive: true });
67
- writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
68
- }
69
- function composeStatusLine(root, mode, globalCommand) {
70
- const settings = readSettings(root);
71
- const existing = settings.statusLine?.command;
72
- const hasProjectStatusLine = typeof existing === "string" && existing.trim() !== "";
73
- const globalBaseComposed = !hasProjectStatusLine && globalCommand !== void 0;
74
- const command = buildCommand(hasProjectStatusLine ? baseToWrap(existing) : globalCommand, mode);
75
- if (existing === command) return {
76
- changed: false,
77
- command,
78
- globalBaseComposed: false
79
- };
80
- settings.statusLine = {
81
- ...settings.statusLine,
82
- type: "command",
83
- command
84
- };
85
- writeSettings(root, settings);
86
- return {
87
- changed: true,
88
- command,
89
- globalBaseComposed
90
- };
91
- }
92
- function isGitRepo(root) {
93
- return existsSync(join(root, ".git"));
94
- }
95
- function splitLines(text) {
96
- const lines = text.split("\n");
97
- if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
98
- return lines;
99
- }
100
- function addGitignoreEntry(root) {
101
- const file = join(root, GITIGNORE_FILE);
102
- const lines = existsSync(file) ? splitLines(readFileSync(file, "utf8")) : [];
103
- if (lines.some((l) => l.trim() === ".agents/sdd/statusline")) return { changed: false };
104
- const next = [...lines, STATUS_FILE];
105
- writeFileSync(file, `${next.join("\n")}\n`);
106
- return { changed: true };
107
- }
108
- function detectStatusLines(root, globalSettingsFile) {
109
- const projectCommand = readStatusLineCommand(join(root, SETTINGS_FILE));
110
- const project = projectCommand === void 0 ? "absent" : parseCommand(projectCommand).wired ? "wired" : "foreign";
111
- const global = readStatusLineCommand(globalSettingsFile ?? GLOBAL_SETTINGS_FILE) !== void 0 ? "present" : "absent";
112
- return {
113
- project,
114
- global,
115
- shadow: project === "absent" && global === "present"
116
- };
117
- }
118
- function wireStatusline(root, mode, opts) {
119
- return {
120
- settings: composeStatusLine(root, mode, opts?.globalBase ?? true ? readStatusLineCommand(opts?.globalSettingsFile ?? GLOBAL_SETTINGS_FILE) : void 0),
121
- gitignore: isGitRepo(root) ? {
122
- ...addGitignoreEntry(root),
123
- skipped: false
124
- } : {
125
- changed: false,
126
- skipped: true
127
- }
128
- };
129
- }
130
- function flag(argv, name) {
131
- const i = argv.indexOf(name);
132
- return i === -1 ? void 0 : argv[i + 1];
133
- }
134
- function main(argv) {
135
- const root = flag(argv, "--root") ?? ".";
136
- const globalSettingsFile = flag(argv, "--global-settings");
137
- const w = (s) => process.stdout.write(`${s}\n`);
138
- if (argv.includes("--detect")) {
139
- const result = detectStatusLines(root, globalSettingsFile);
140
- w(`project statusLine: ${result.project}`);
141
- w(`global statusLine: ${result.global}`);
142
- w(result.shadow ? "shadow risk: yes — wiring would shadow the global statusline; compose it as the base (default) or pass --no-global-base to shadow deliberately" : "shadow risk: no");
143
- return 0;
144
- }
145
- if (argv.includes("--wire")) {
146
- const mode = flag(argv, "--mode");
147
- if (mode !== "own-line" && mode !== "same-line") {
148
- w("refused: --mode must be own-line or same-line");
149
- return 1;
150
- }
151
- const result = wireStatusline(root, mode, {
152
- globalSettingsFile,
153
- globalBase: !argv.includes("--no-global-base")
154
- });
155
- w(result.settings.changed ? `wired statusLine (${mode})` : "statusLine already wired (unchanged)");
156
- if (result.settings.globalBaseComposed) w("composed the global statusLine command as the wrapped base");
157
- if (result.gitignore.skipped) w("not a git repo — gitignore skipped");
158
- else w(result.gitignore.changed ? `added ${STATUS_FILE} to .gitignore` : ".gitignore already up to date");
159
- return 0;
160
- }
161
- w("usage: wire-statusline --root <dir> --wire --mode own-line|same-line [--global-settings <file>] [--no-global-base]\n wire-statusline --root <dir> --detect [--global-settings <file>]");
162
- return 0;
163
- }
164
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
165
- //#endregion
166
- export { GITIGNORE_FILE, GLOBAL_SETTINGS_FILE, SETTINGS_FILE, STATUS_FILE, addGitignoreEntry, buildCommand, composeStatusLine, detectStatusLines, isGitRepo, main, parseCommand, readStatusLineCommand, wireStatusline };