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.
Files changed (133) hide show
  1. package/.claude-plugin/plugin.json +17 -0
  2. package/.codex-plugin/plugin.json +17 -0
  3. package/.plugin/plugin.json +17 -0
  4. package/README.md +159 -0
  5. package/agents/sdd-automaton.md +97 -0
  6. package/agents/sdd-impl-judge.md +214 -0
  7. package/agents/sdd-scanner.md +120 -0
  8. package/agents/sdd-spec-judge.md +224 -0
  9. package/agents/sdd-warden.md +101 -0
  10. package/package.json +24 -0
  11. package/skills/align-spec/README.md +20 -0
  12. package/skills/align-spec/SKILL.md +111 -0
  13. package/skills/align-spec/scripts/align-spec.mts +187 -0
  14. package/skills/architect-impl-governance/README.md +46 -0
  15. package/skills/architect-impl-governance/SKILL.md +45 -0
  16. package/skills/architect-spec-governance/README.md +48 -0
  17. package/skills/architect-spec-governance/SKILL.md +59 -0
  18. package/skills/blast-estimate/README.md +47 -0
  19. package/skills/blast-estimate/SKILL.md +133 -0
  20. package/skills/blast-estimate/scripts/blast-estimate.mts +583 -0
  21. package/skills/builder-impl-governance/README.md +47 -0
  22. package/skills/builder-impl-governance/SKILL.md +47 -0
  23. package/skills/builder-spec-governance/README.md +49 -0
  24. package/skills/builder-spec-governance/SKILL.md +36 -0
  25. package/skills/check-partition-quality/README.md +22 -0
  26. package/skills/check-partition-quality/SKILL.md +51 -0
  27. package/skills/check-partition-quality/scripts/check-partition-quality.mts +336 -0
  28. package/skills/check-plan-safety/README.md +17 -0
  29. package/skills/check-plan-safety/SKILL.md +60 -0
  30. package/skills/check-plan-safety/scripts/check-plan-safety.mts +145 -0
  31. package/skills/check-project-specs/README.md +19 -0
  32. package/skills/check-project-specs/SKILL.md +69 -0
  33. package/skills/check-project-specs/scripts/check-project-specs.mts +217 -0
  34. package/skills/check-scenario-overlap/README.md +19 -0
  35. package/skills/check-scenario-overlap/SKILL.md +74 -0
  36. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +249 -0
  37. package/skills/check-spec-structure/README.md +17 -0
  38. package/skills/check-spec-structure/SKILL.md +66 -0
  39. package/skills/check-spec-structure/scripts/check-spec-structure.mts +346 -0
  40. package/skills/collision-ladder/README.md +18 -0
  41. package/skills/collision-ladder/SKILL.md +83 -0
  42. package/skills/collision-ladder/scripts/collision-ladder.mts +657 -0
  43. package/skills/combat-log-governance/README.md +13 -0
  44. package/skills/combat-log-governance/SKILL.md +257 -0
  45. package/skills/concept-index/README.md +13 -0
  46. package/skills/concept-index/SKILL.md +38 -0
  47. package/skills/concept-index/scripts/concept-index.mts +245 -0
  48. package/skills/discover-plans/README.md +16 -0
  49. package/skills/discover-plans/SKILL.md +74 -0
  50. package/skills/discover-plans/scripts/discover-plans.mts +212 -0
  51. package/skills/discover-specs/README.md +15 -0
  52. package/skills/discover-specs/SKILL.md +76 -0
  53. package/skills/discover-specs/scripts/discover-specs.mts +396 -0
  54. package/skills/doctrine-loop/README.md +15 -0
  55. package/skills/doctrine-loop/SKILL.md +97 -0
  56. package/skills/formation-loop/README.md +17 -0
  57. package/skills/formation-loop/SKILL.md +140 -0
  58. package/skills/gate-validation-governance/README.md +12 -0
  59. package/skills/gate-validation-governance/SKILL.md +87 -0
  60. package/skills/impl-producer-governance/README.md +48 -0
  61. package/skills/impl-producer-governance/SKILL.md +85 -0
  62. package/skills/init/README.md +27 -0
  63. package/skills/init/SKILL.md +68 -0
  64. package/skills/init/scripts/wire-statusline.mts +276 -0
  65. package/skills/lifecycle-governance/README.md +11 -0
  66. package/skills/lifecycle-governance/SKILL.md +168 -0
  67. package/skills/manage/README.md +9 -0
  68. package/skills/manage/SKILL.md +62 -0
  69. package/skills/manage-ignore/README.md +19 -0
  70. package/skills/manage-ignore/SKILL.md +52 -0
  71. package/skills/manage-ignore/scripts/manage-ignore.mts +294 -0
  72. package/skills/manage-scenario-bridge/README.md +20 -0
  73. package/skills/manage-scenario-bridge/SKILL.md +60 -0
  74. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +156 -0
  75. package/skills/manage-spec-anchors/README.md +18 -0
  76. package/skills/manage-spec-anchors/SKILL.md +56 -0
  77. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +328 -0
  78. package/skills/mission-graph/README.md +15 -0
  79. package/skills/mission-graph/SKILL.md +67 -0
  80. package/skills/mission-graph/scripts/mission-graph.mts +844 -0
  81. package/skills/oracle-spec-governance/README.md +45 -0
  82. package/skills/oracle-spec-governance/SKILL.md +45 -0
  83. package/skills/ownership-governance/README.md +65 -0
  84. package/skills/ownership-governance/SKILL.md +104 -0
  85. package/skills/pause-mission/README.md +18 -0
  86. package/skills/pause-mission/SKILL.md +112 -0
  87. package/skills/place-node/README.md +12 -0
  88. package/skills/place-node/SKILL.md +47 -0
  89. package/skills/place-node/scripts/place-node.mts +157 -0
  90. package/skills/plan-retirement/README.md +32 -0
  91. package/skills/plan-retirement/SKILL.md +90 -0
  92. package/skills/plan-retirement/scripts/retire-plans.mts +196 -0
  93. package/skills/plugin-contract-governance/README.md +12 -0
  94. package/skills/plugin-contract-governance/SKILL.md +112 -0
  95. package/skills/remediation-governance/README.md +46 -0
  96. package/skills/remediation-governance/SKILL.md +78 -0
  97. package/skills/resolve-governances/README.md +18 -0
  98. package/skills/resolve-governances/SKILL.md +50 -0
  99. package/skills/resolve-governances/scripts/resolve-governances.mts +515 -0
  100. package/skills/resolve-tracking/SKILL.md +64 -0
  101. package/skills/resolve-tracking/scripts/resolve-tracking.mts +213 -0
  102. package/skills/resume-mission/README.md +12 -0
  103. package/skills/resume-mission/SKILL.md +53 -0
  104. package/skills/scaffold-project-spec/README.md +7 -0
  105. package/skills/scaffold-project-spec/SKILL.md +192 -0
  106. package/skills/sdd/README.md +7 -0
  107. package/skills/sdd/SKILL.md +92 -0
  108. package/skills/solution-producer-governance/README.md +9 -0
  109. package/skills/solution-producer-governance/SKILL.md +44 -0
  110. package/skills/spec-format-governance/README.md +73 -0
  111. package/skills/spec-format-governance/SKILL.md +114 -0
  112. package/skills/spec-gate/README.md +26 -0
  113. package/skills/spec-gate/SKILL.md +201 -0
  114. package/skills/spec-gate/scripts/check-spec-state.mts +601 -0
  115. package/skills/spec-gate/scripts/check-suite.mts +501 -0
  116. package/skills/spec-gate/scripts/classify-edit-class.mts +411 -0
  117. package/skills/spec-producer-governance/README.md +7 -0
  118. package/skills/spec-producer-governance/SKILL.md +86 -0
  119. package/skills/spec-structure-governance/README.md +40 -0
  120. package/skills/spec-structure-governance/SKILL.md +169 -0
  121. package/skills/ssa-lowering/README.md +26 -0
  122. package/skills/ssa-lowering/SKILL.md +181 -0
  123. package/skills/start-mission/README.md +7 -0
  124. package/skills/start-mission/SKILL.md +115 -0
  125. package/skills/suite-format-governance/README.md +75 -0
  126. package/skills/suite-format-governance/SKILL.md +299 -0
  127. package/skills/suite-format-governance/references/rubric.md +313 -0
  128. package/skills/touch-set-correction/README.md +16 -0
  129. package/skills/touch-set-correction/SKILL.md +67 -0
  130. package/skills/touch-set-correction/scripts/touch-set-correction.mts +418 -0
  131. package/skills/verify-scenarios/README.md +17 -0
  132. package/skills/verify-scenarios/SKILL.md +109 -0
  133. package/skills/verify-scenarios/scripts/verify-scenarios.mts +386 -0
@@ -0,0 +1,396 @@
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 } from 'node:fs'
30
+ import { join } from 'node:path'
31
+
32
+ export const LIFECYCLE_STATUSES = new Set(['draft', 'approved', 'implemented', 'deprecated'])
33
+
34
+ // The repo-root single-project (`.agents/spec`) has no folder to name it; this assumable label does.
35
+ const ROOT_PROJECT_NAME = 'repo'
36
+
37
+ // Dirs the scan never descends into (keep `.agents` — specs live under it).
38
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
39
+
40
+ // The opt-in extra-anchor registry (ADR-0019). Scanned IN ADDITION TO the three fixed conventions;
41
+ // absent ⇒ only the fixed conventions are scanned (today's behavior, unchanged).
42
+ const ANCHORS_CONFIG = '.agents/sdd/spec-anchors.toml'
43
+
44
+ export type NameSource = 'declared' | 'derived' | 'guessed'
45
+
46
+ export interface SpecRecord {
47
+ /** Root-relative directory of the spec.md (its slug). */
48
+ path: string
49
+ /** The project name — declared in frontmatter, else derived, else guessed. */
50
+ name: string
51
+ /** Where `name` came from, so a consumer knows when to confirm a guess. */
52
+ nameSource: NameSource
53
+ status: string
54
+ /** Frontmatter project-path (the governed source dir), or '' when absent. */
55
+ projectPath: string
56
+ /** Gate verdicts as `<gate>:<verdict>` pairs, in spec→impl order; '' when none. */
57
+ approvals: string
58
+ }
59
+
60
+ export interface Frontmatter {
61
+ status?: string
62
+ projectPath?: string
63
+ /** Declared project name (authoritative over derivation), when present. */
64
+ name?: string
65
+ /** gate → verdict (e.g. spec → approve). */
66
+ approval: Record<string, string>
67
+ }
68
+
69
+ // ── Frontmatter parse (a minimal YAML subset — only the router-index schema) ──
70
+ // Extracts the leading `---` … `---` block and reads status, name, project-path, and each
71
+ // approval gate's verdict. Returns null when there is no frontmatter block at all.
72
+ export function parseFrontmatter(text: string): Frontmatter | null {
73
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text)
74
+ if (!m) return null
75
+ const fm: Frontmatter = { approval: {} }
76
+ let gate: string | null = null // current gate under `approval:`
77
+ for (const raw of m[1].split('\n')) {
78
+ const line = raw.replace(/\r$/, '')
79
+ if (line.trim() === '' || line.trim().startsWith('#')) continue
80
+ const indent = line.length - line.trimStart().length
81
+ const [key, ...rest] = line.trim().split(':')
82
+ const value = rest.join(':').trim()
83
+ if (indent === 0) {
84
+ gate = null
85
+ if (key === 'status') fm.status = unquote(value)
86
+ else if (key === 'project-path') fm.projectPath = unquote(value)
87
+ else if (key === 'name') fm.name = unquote(value)
88
+ else if (key === 'approval') gate = null // enter the approval block
89
+ } else if (indent === 2 && value === '' && insideApproval(m[1], line)) {
90
+ gate = key // a gate name under approval:
91
+ } else if (indent >= 4 && key === 'verdict' && gate) {
92
+ fm.approval[gate] = unquote(value)
93
+ }
94
+ }
95
+ return fm
96
+ }
97
+
98
+ // True when `line` sits under the top-level `approval:` key (the nearest indent-0 parent is it).
99
+ function insideApproval(block: string, line: string): boolean {
100
+ const lines = block.split('\n')
101
+ const idx = lines.indexOf(line)
102
+ for (let i = idx - 1; i >= 0; i--) {
103
+ const l = lines[i].replace(/\r$/, '')
104
+ if (l.trim() === '' || l.trim().startsWith('#')) continue
105
+ if (l.length - l.trimStart().length === 0) return l.trim().replace(/:.*$/, '') === 'approval'
106
+ }
107
+ return false
108
+ }
109
+
110
+ function unquote(v: string): string {
111
+ return v.replace(/^["']|["']$/g, '')
112
+ }
113
+
114
+ // ── Location recognition ──
115
+ // Classify a root-relative spec.md path to one of the three spec locations, returning the
116
+ // pattern + the location dir it implies (the <project> folder for root-multi, the project-path
117
+ // for nested, '' for root-single). Returns null for any other location.
118
+ type LocationPattern = 'root-single' | 'root-multi' | 'nested' | 'extra'
119
+
120
+ export interface Location {
121
+ pattern: LocationPattern
122
+ locationDir: string
123
+ /** For an extra anchor whose pattern carried a <project> token: the captured name segment. */
124
+ capturedName?: string
125
+ }
126
+
127
+ export function classifyLocation(relPath: string): Location | null {
128
+ const p = relPath.replace(/\\/g, '/')
129
+ if (p === '.agents/spec/spec.md') return { pattern: 'root-single', locationDir: '' }
130
+ const multi = /^\.agents\/specs\/([^/]+)\/spec\.md$/.exec(p)
131
+ if (multi) return { pattern: 'root-multi', locationDir: multi[1] }
132
+ const nested = /^(.+)\/\.agents\/spec\/spec\.md$/.exec(p)
133
+ if (nested) return { pattern: 'nested', locationDir: nested[1] }
134
+ return null
135
+ }
136
+
137
+ // Derive a project's name + the confidence in it. A declared frontmatter `name` is authoritative;
138
+ // otherwise the repo-root single-project is the assumable `repo`, a `.agents/specs/<project>` folder
139
+ // names itself (derived), and a nested project falls back to its folder basename (guessed — it may
140
+ // not be the name the user uses; a consumer should confirm).
141
+ export function deriveName(loc: Location, fm: Frontmatter): { name: string; nameSource: NameSource } {
142
+ if (fm.name) return { name: fm.name, nameSource: 'declared' }
143
+ if (loc.pattern === 'root-single') return { name: ROOT_PROJECT_NAME, nameSource: 'derived' }
144
+ if (loc.pattern === 'root-multi') return { name: loc.locationDir, nameSource: 'derived' }
145
+ // An extra anchor with a <project> capture names the spec from the captured segment (derived);
146
+ // without a capture it falls back to the folder basename (guessed), like a nested project.
147
+ if (loc.pattern === 'extra' && loc.capturedName) {
148
+ return { name: loc.capturedName, nameSource: 'derived' }
149
+ }
150
+ return { name: loc.locationDir.split('/').pop() ?? loc.locationDir, nameSource: 'guessed' }
151
+ }
152
+
153
+ // ── Scan ──
154
+ // Walk the tree under root, returning root-relative paths of every spec.md sitting at one of
155
+ // the three spec locations. The walk finds `.agents` dirs at any depth (pattern 3) and probes
156
+ // `spec/spec.md` (patterns 1 & 3) plus, only at the repo root, `specs/<project>/spec.md`
157
+ // (pattern 2).
158
+ export function discoverSpecFiles(root: string): string[] {
159
+ const found: string[] = []
160
+ const walk = (relDir: string): void => {
161
+ let entries: import('node:fs').Dirent[]
162
+ try {
163
+ entries = readdirSync(join(root, relDir), { withFileTypes: true })
164
+ } catch {
165
+ return
166
+ }
167
+ for (const e of entries) {
168
+ if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue
169
+ const childRel = relDir ? `${relDir}/${e.name}` : e.name
170
+ if (e.name === '.agents') {
171
+ probeAgents(root, childRel, found)
172
+ continue // spec locations live directly under .agents, no deeper walk needed
173
+ }
174
+ walk(childRel)
175
+ }
176
+ }
177
+ walk('')
178
+ return found
179
+ }
180
+
181
+ // Probe a discovered `.agents` dir for the spec locations beneath it.
182
+ function probeAgents(root: string, agentsRel: string, found: string[]): void {
183
+ const single = `${agentsRel}/spec/spec.md`
184
+ if (existsSync(join(root, single))) found.push(single)
185
+ if (agentsRel === '.agents') {
186
+ // pattern 2 — repo-root multi-project — is root-only (no ** prefix).
187
+ const specsDir = join(root, '.agents', 'specs')
188
+ let projects: import('node:fs').Dirent[]
189
+ try {
190
+ projects = readdirSync(specsDir, { withFileTypes: true })
191
+ } catch {
192
+ projects = []
193
+ }
194
+ for (const p of projects) {
195
+ if (!p.isDirectory()) continue
196
+ const rel = `.agents/specs/${p.name}/spec.md`
197
+ if (existsSync(join(root, rel))) found.push(rel)
198
+ }
199
+ }
200
+ }
201
+
202
+ // ── Extra anchors — the opt-in registry (ADR-0019) ──
203
+ // A minimal TOML read: pull the string entries out of the `anchors = [ … ]` array. Paths carry no
204
+ // `#`, so entries are the quoted strings inside the array literal (across newlines). Returns [] when
205
+ // there is no anchors array (a config that commented them all out is a valid empty set).
206
+ export function parseAnchorsToml(text: string): string[] {
207
+ const m = /(^|\n)\s*anchors\s*=\s*\[([\s\S]*?)\]/.exec(text)
208
+ if (!m) return []
209
+ const out: string[] = []
210
+ for (const q of m[2].matchAll(/"([^"]*)"|'([^']*)'/g)) out.push((q[1] ?? q[2]).trim())
211
+ return out.filter((s) => s !== '')
212
+ }
213
+
214
+ // Read the extra-anchor patterns, FAIL-SAFE: an absent config yields []; an unreadable/malformed one
215
+ // warns and yields [] so the gateway's status scan never crashes on a hand-corrupted config.
216
+ export function readAnchors(root: string): string[] {
217
+ const file = join(root, ANCHORS_CONFIG)
218
+ if (!existsSync(file)) return []
219
+ try {
220
+ return parseAnchorsToml(readFileSync(file, 'utf8'))
221
+ } catch {
222
+ process.stderr.write(`discover-specs: ignoring unreadable ${ANCHORS_CONFIG}\n`)
223
+ return []
224
+ }
225
+ }
226
+
227
+ // Every dir reachable from `startDir` by descending zero or more levels (startDir itself included),
228
+ // skipping SKIP_DIRS. Backs the `**` segment (any-depth glob) below.
229
+ function collectDescendants(root: string, startDir: string): string[] {
230
+ const out = [startDir]
231
+ let entries: import('node:fs').Dirent[]
232
+ try {
233
+ entries = readdirSync(join(root, startDir), { withFileTypes: true })
234
+ } catch {
235
+ return out
236
+ }
237
+ for (const e of entries) {
238
+ if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue
239
+ out.push(...collectDescendants(root, startDir ? `${startDir}/${e.name}` : e.name))
240
+ }
241
+ return out
242
+ }
243
+
244
+ // Expand one anchor pattern against the filesystem into the spec dirs it matches. A `*` segment globs
245
+ // one directory level; a `**` segment globs zero or more levels (any depth); a `<project>` segment
246
+ // globs AND captures one level as the spec name. A literal segment must exist. The matched dir is a
247
+ // spec dir iff it holds a spec.md.
248
+ export function expandAnchor(root: string, pattern: string): { rel: string; capturedName?: string }[] {
249
+ const segs = pattern
250
+ .replace(/\\/g, '/')
251
+ .replace(/^\/+|\/+$/g, '')
252
+ .split('/')
253
+ .filter(Boolean)
254
+ let frontier: { dir: string; capturedName?: string }[] = [{ dir: '' }]
255
+ for (const seg of segs) {
256
+ const next: { dir: string; capturedName?: string }[] = []
257
+ if (seg === '**') {
258
+ for (const node of frontier) {
259
+ for (const dir of collectDescendants(root, node.dir)) next.push({ dir, capturedName: node.capturedName })
260
+ }
261
+ frontier = next
262
+ continue
263
+ }
264
+ const isGlob = seg === '*' || seg === '<project>'
265
+ for (const node of frontier) {
266
+ if (isGlob) {
267
+ let entries: import('node:fs').Dirent[]
268
+ try {
269
+ entries = readdirSync(join(root, node.dir), { withFileTypes: true })
270
+ } catch {
271
+ continue
272
+ }
273
+ for (const e of entries) {
274
+ if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue
275
+ next.push({
276
+ dir: node.dir ? `${node.dir}/${e.name}` : e.name,
277
+ capturedName: seg === '<project>' ? e.name : node.capturedName,
278
+ })
279
+ }
280
+ } else {
281
+ next.push({ dir: node.dir ? `${node.dir}/${seg}` : seg, capturedName: node.capturedName })
282
+ }
283
+ }
284
+ frontier = next
285
+ }
286
+ const out: { rel: string; capturedName?: string }[] = []
287
+ for (const node of frontier) {
288
+ const rel = node.dir ? `${node.dir}/spec.md` : 'spec.md'
289
+ if (existsSync(join(root, rel))) out.push({ rel, capturedName: node.capturedName })
290
+ }
291
+ return out
292
+ }
293
+
294
+ // ── Collect ──
295
+ // Build a SpecRecord from a spec.md at `rel` under `loc`, or null when it fails the status shape
296
+ // filter (or the file is unreadable). Shared by the fixed-convention and extra-anchor passes.
297
+ function recordFor(root: string, rel: string, loc: Location): SpecRecord | null {
298
+ let fm: Frontmatter | null
299
+ try {
300
+ fm = parseFrontmatter(readFileSync(join(root, rel), 'utf8'))
301
+ } catch {
302
+ return null
303
+ }
304
+ if (!fm?.status || !LIFECYCLE_STATUSES.has(fm.status)) return null // shape filter
305
+ const { name, nameSource } = deriveName(loc, fm)
306
+ return {
307
+ path: rel.replace(/\/spec\.md$/, ''),
308
+ name,
309
+ nameSource,
310
+ status: fm.status,
311
+ projectPath: fm.projectPath ?? '',
312
+ approvals: Object.entries(fm.approval)
313
+ .map(([gate, verdict]) => `${gate}:${verdict}`)
314
+ .join(';'),
315
+ }
316
+ }
317
+
318
+ // The list of specs under root: every spec.md at one of the three fixed conventions PLUS every
319
+ // spec.md at a declared extra anchor, whose frontmatter status is in the lifecycle enum, keyed by
320
+ // its folder slug, sorted by path. Extra anchors are additive and deduped against the fixed set.
321
+ export function collectSpecs(root: string): SpecRecord[] {
322
+ const out: SpecRecord[] = []
323
+ const seen = new Set<string>()
324
+ for (const rel of discoverSpecFiles(root)) {
325
+ const loc = classifyLocation(rel)
326
+ if (!loc) continue
327
+ const rec = recordFor(root, rel, loc)
328
+ if (rec) {
329
+ out.push(rec)
330
+ seen.add(rel)
331
+ }
332
+ }
333
+ for (const pattern of readAnchors(root)) {
334
+ for (const { rel, capturedName } of expandAnchor(root, pattern)) {
335
+ if (seen.has(rel)) continue // already found at a fixed convention
336
+ const rec = recordFor(root, rel, {
337
+ pattern: 'extra',
338
+ locationDir: rel.replace(/\/spec\.md$/, ''),
339
+ capturedName,
340
+ })
341
+ if (rec) {
342
+ out.push(rec)
343
+ seen.add(rel)
344
+ }
345
+ }
346
+ }
347
+ return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
348
+ }
349
+
350
+ // ── Resolve a name (the deterministic half of resolve-a-name) ──
351
+ // Exact, case-insensitive name match over the discovered list. `match` resolves to one spec;
352
+ // `ambiguous` returns the candidate set for a consumer to disambiguate WITH THE USER (the agentic
353
+ // half, not this function's job); `none` when no name matches.
354
+ export type ResolveResult =
355
+ | { kind: 'match'; spec: SpecRecord }
356
+ | { kind: 'ambiguous'; candidates: SpecRecord[] }
357
+ | { kind: 'none' }
358
+
359
+ export function resolveByName(specs: SpecRecord[], name: string): ResolveResult {
360
+ const want = name.trim().toLowerCase()
361
+ const hits = specs.filter((s) => s.name.toLowerCase() === want)
362
+ if (hits.length === 0) return { kind: 'none' }
363
+ if (hits.length === 1) return { kind: 'match', spec: hits[0] }
364
+ return { kind: 'ambiguous', candidates: hits }
365
+ }
366
+
367
+ // ── Output ──
368
+ const COLUMNS = ['path', 'name', 'nameSource', 'status', 'projectPath', 'approvals'] as const
369
+
370
+ // Quote a TOON field only when it carries the delimiter, a quote, or edge whitespace.
371
+ function toonField(v: string): string {
372
+ if (v === '' || /[",]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
373
+ return v
374
+ }
375
+
376
+ export function toToon(specs: SpecRecord[]): string {
377
+ const header = `specs[${specs.length}]{${COLUMNS.join(',')}}:`
378
+ const rows = specs.map((s) => ` ${COLUMNS.map((c) => toonField(s[c])).join(',')}`)
379
+ return [header, ...rows].join('\n')
380
+ }
381
+
382
+ export function main(argv: string[]): number {
383
+ const root = argv.includes('--root') ? (argv[argv.indexOf('--root') + 1] ?? '.') : '.'
384
+ const format = argv.includes('--format') ? argv[argv.indexOf('--format') + 1] : 'toon'
385
+ let specs = collectSpecs(root)
386
+ if (argv.includes('--resolve')) {
387
+ const name = argv[argv.indexOf('--resolve') + 1] ?? ''
388
+ const r = resolveByName(specs, name)
389
+ specs = r.kind === 'match' ? [r.spec] : r.kind === 'ambiguous' ? r.candidates : []
390
+ }
391
+ const out = format === 'json' ? JSON.stringify(specs, null, 2) : toToon(specs)
392
+ process.stdout.write(`${out}\n`)
393
+ return 0
394
+ }
395
+
396
+ if (import.meta.main) process.exit(main(process.argv.slice(2)))
@@ -0,0 +1,15 @@
1
+ # doctrine-loop
2
+
3
+ Internal, non-user-invocable SDD skill holding the **outer loop** — the Strategist's **doctrine
4
+ loop**, run by its delegate the Scanner (`sdd-scanner`) parallel to the conductor's mission loop.
5
+ It encodes the **six lifecycle-grained use cases** (ship, kill, milestone retro, recurring
6
+ pattern, drift, token-waste), the **detect-and-draft vs keep-or-cut** split, and the
7
+ **combat-log-vs-transcript** input model.
8
+
9
+ The Scanner is the **sole writer** of `strategy` entries; it fires at lifecycle granularity (never
10
+ per-gate), reads persisted artifacts post-hoc, and drafts **unratified** strategy to its own shard in
11
+ the one project `ledger/` directory. The entry **shape** and the matchable `cause` enum are owned by
12
+ `sdd:combat-log-governance` (deferred, never restated). The Council holds keep-or-cut; the `sdd`
13
+ gateway surfaces the count of pending unratified strategy when the Council re-enters.
14
+
15
+ Plan retirement (doctrine's last retro step) is the sibling `sdd:plan-retirement` skill.
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: doctrine-loop
3
+ description: "Partial Skill: invoke by name only — the SDD doctrine loop, the Strategist's outer loop run by the Scanner — invoked by the doctrine-loop delegate, not triggered by users directly."
4
+ user-invocable: false
5
+ metadata:
6
+ internal: true
7
+ ---
8
+
9
+ # SDD Doctrine Loop
10
+
11
+ The **outer loop** of the SDD model. Owned by the **Strategist** and run by its delegate, the
12
+ **Scanner in the Bunker** (`sdd-scanner`), parallel to the conductor running the mission loop. It
13
+ fires at **lifecycle granularity, never per-gate**: it watches every **mission** reach a terminal
14
+ state, drafts **strategy** (forward recommendations to revise governances, conventions, skills)
15
+ from persisted artifacts post-hoc, and surfaces it to the human **Council** for keep-or-cut.
16
+ Ratified, strategy re-enters as a CR that re-tunes the **doctrine** (the SDD design rules and
17
+ governances) and grows the **corpus**.
18
+
19
+ Load `sdd:combat-log-governance` for the **shape** of a `strategy` ledger entry and the matchable
20
+ `cause` enum — this skill defers the entry shape there and never restates it.
21
+
22
+ ## The split: detect-and-draft vs keep-or-cut
23
+
24
+ | Half | Holder | Cost | Effect |
25
+ |---|---|---|---|
26
+ | **Detect and draft** | the Scanner delegate | cheap, continuous, non-blocking | appends an **unratified** `strategy` entry to the ledger |
27
+ | **Keep or cut** | the human Council | accountable, high-blast-radius | ratify → re-enter as a CR that re-tunes doctrine + grows corpus; cut → strategy stays out |
28
+
29
+ No strategy enters the corpus without the Council's ratification. The Scanner is the **sole
30
+ writer** of `strategy` entries; the conductor (`report` / `correction`) and the producers (nothing)
31
+ never write them.
32
+
33
+ ## The six use cases
34
+
35
+ Each is an entry-point: a lifecycle-grained trigger, its post-hoc input, and the strategy drafted.
36
+ **A single gate passing is not a trigger** — the loop fires only at the lifecycle granularity below.
37
+
38
+ | Use case | Trigger | Input | Drafts |
39
+ |---|---|---|---|
40
+ | **Ship** | `→ implemented` (the impl gate writes it) | the concluded mission's combat log (**PRIMARY**) + *[opt]* transcripts | strategy from a successful mission |
41
+ | **Kill** | `→ deprecated` (the deprecation path writes it) | the concluded mission's combat log — why it failed (**PRIMARY**) + *[opt]* transcripts | strategy from the failure |
42
+ | **Milestone retro** | a human-held retro | the milestone's concluded combat logs | strategy across the milestone |
43
+ | **Recurring pattern** | the same correction recurs across missions | the **distilled `cause` recurrence count** in the ledger (maintained mission-over-mission), never a re-scan of many raw logs | strategy to codify the pattern |
44
+ | **Drift / staleness** | a now-false convention or governance contradiction | the corpus (conventions, governances) | a **PRUNE** strategy |
45
+ | **Token-waste** | a flagged-waste `correction` in the log, **or** session token cost over a **configurable bound** (pre-merge) | the **categorical** efficiency `correction` from the committed log (**post-merge**); raw transcripts add numeric depth (**pre-merge / same-machine only**) | efficiency strategy |
46
+
47
+ The Scanner **observes** the terminal transitions; it never writes a mission's `status`.
48
+
49
+ ## Inputs: combat log (contract) vs transcripts (enrichment)
50
+
51
+ The Scanner reads **persisted artifacts post-hoc** — never live subagent context.
52
+
53
+ - **Combat log** — PRIMARY input, the contract: the concluded mission's combat log (the plan's
54
+ `*.log.jsonl`), read once at retro. Strategy is draftable from it **alone** for **every
55
+ categorical dimension**; raw transcripts are additive, never required.
56
+ - **Raw `.jsonl` transcripts** — optional enrichment, harness-specific, and **may be absent
57
+ post-merge** (another machine, the session gone). The **sole** transcript-only piece is the
58
+ *numeric* token-waste depth.
59
+
60
+ The **token-waste dimension splits**: a coarse, **categorical** efficiency signal rides the
61
+ committed log as a `correction` (the conductor flags a class — **no raw counts**), so the
62
+ post-merge loop keeps the dimension; the **numeric** breakdown lives only in transcripts and is
63
+ **threshold-gated + pre-merge / same-machine only** (run over a configurable bound or on demand,
64
+ never under it without a request). No raw token-cost number is written to the committed log — only
65
+ the categorical class (the safe-to-publish floor, `sdd:combat-log-governance`).
66
+
67
+ ## Where strategy lands
68
+
69
+ Every `strategy` entry lands in the **one project ledger** — the `ledger/` directory sibling of the
70
+ root `spec.md` — written to the **Scanner's own shard** (`strategy.<hash>.jsonl`; mint `<hash>` as 6
71
+ random hex once per session), so two concurrent Scanner runs write distinct shards and never contend.
72
+ There is no per-spec log to route to under the project-spec model. The Scanner's `handle` is
73
+ `sdd-scanner`. Every entry is **unratified** (`ratified: false`) and carries its **driving evidence**
74
+ (the distilled `cause` recurrence that drove it), per the shape in `sdd:combat-log-governance`. The
75
+ shard is append-only — the next `seq` within it, never an edit; ledger lines carry **no `ts`**.
76
+
77
+ **Record the distilled subject.** When the entry is drafted from a **Ship** (`→ implemented`) or
78
+ **Kill** (`→ deprecated`), set `distills: <cr-ref>` to the **one mission it was distilled from** —
79
+ distinct from the cross-referenced cr-refs in `evidence`. This is the machine-checkable hook
80
+ `sdd:plan-retirement` keys on to confirm a plan was distilled before deleting its combat log, so a
81
+ Ship/Kill distillation **must** carry it. **Milestone / drift / token-waste** strategy has no single
82
+ subject mission and **omits** `distills` (`sdd:combat-log-governance`, *The `distills` subject*).
83
+
84
+ ## Surfacing and ratification
85
+
86
+ The Scanner **accumulates** unratified strategy and surfaces it **episodically** — never
87
+ synchronously blocking a mission. The `sdd` gateway surfaces the **count of pending (unratified)
88
+ strategy** when the Council re-enters; that is the entry point to keep-or-cut. On **ratify**, the
89
+ strategy re-enters as a **new CR** that re-tunes the doctrine and grows the corpus (a ratified
90
+ **PRUNE** removes the stale convention). On **cut**, it stays unratified and absent from the
91
+ corpus.
92
+
93
+ ## Plan retirement
94
+
95
+ Doctrine's **last retro step** — the gated, idempotent **tracked deletion** of a retired plan — is
96
+ a separate unit (`sdd:plan-retirement`). The distill (writing `strategy` here) fires early, at
97
+ `→ implemented`; the delete is a later step gated on source = `done`/merged **and** distilled.
@@ -0,0 +1,17 @@
1
+ # formation-loop
2
+
3
+ Internal, non-user-invocable SDD skill holding the **structure outer loop** — the Architect's
4
+ **formation loop**, run by its delegate the Warden (`sdd-warden`) parallel to the conductor's
5
+ mission loop. It encodes the **intra-spec structural acts** (audit node-shape, split an oversized node, reconcile
6
+ drift or a contradiction), the Warden's **self-clear-vs-escalate** verdict against the floor + gradient
7
+ (the conductor's autonomy bar, `start-mission`), the **frozen-contract guard** (keyed on contract impact, not the
8
+ bare freeze), and the **altitude routing** that keeps formation to corpus structure alone.
9
+
10
+ The loop fires **post-mission, corpus-wide and continuous**, asking one question — is what we have
11
+ organized right? Every run emits a **finding set covering every spec**; a pass scoped to a single
12
+ spec is not a formation run, and formation **declines** to act as the per-spec gate structural
13
+ check. Its input is the corpus **structure** + **discovery** (`corpus/` + `project-spec/`), optionally scoped forward
14
+ by a cursor over the public trail — never the combat log, never live subagent context. The Warden
15
+ runs stations in-session and **never** writes a spec's `status`.
16
+
17
+ The doctrine (process) sibling outer loop is `sdd:doctrine-loop`.