cyber-sdd 0.0.0 → 0.2.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 (71) hide show
  1. package/.plugin/pins.json +3 -0
  2. package/LICENSE +21 -0
  3. package/agents/sdd-automaton.md +13 -2
  4. package/agents/sdd-scanner.md +85 -0
  5. package/agents/sdd-spec-judge.md +32 -2
  6. package/agents/sdd-warden.md +9 -0
  7. package/package.json +30 -23
  8. package/skills/align-spec/scripts/align-spec.mts +3 -2
  9. package/skills/architect-spec-governance/README.md +1 -0
  10. package/skills/architect-spec-governance/SKILL.md +12 -1
  11. package/skills/blast-estimate/README.md +3 -5
  12. package/skills/blast-estimate/SKILL.md +2 -2
  13. package/skills/blast-estimate/scripts/blast-estimate.mts +7 -4
  14. package/skills/builder-impl-governance/SKILL.md +9 -1
  15. package/skills/builder-spec-governance/README.md +1 -0
  16. package/skills/builder-spec-governance/SKILL.md +31 -3
  17. package/skills/check-partition-quality/scripts/check-partition-quality.mts +3 -1
  18. package/skills/check-plan-safety/scripts/check-plan-safety.mts +5 -2
  19. package/skills/check-project-specs/scripts/check-project-specs.mts +67 -5
  20. package/skills/check-retired-terms/README.md +18 -0
  21. package/skills/check-retired-terms/SKILL.md +81 -0
  22. package/skills/check-retired-terms/scripts/check-retired-terms.mts +293 -0
  23. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +3 -2
  24. package/skills/check-spec-structure/scripts/check-spec-structure.mts +3 -2
  25. package/skills/collision-ladder/README.md +3 -5
  26. package/skills/collision-ladder/scripts/collision-ladder.mts +7 -4
  27. package/skills/combat-log-governance/SKILL.md +43 -4
  28. package/skills/concept-index/scripts/concept-index.mts +3 -2
  29. package/skills/discover-plans/scripts/discover-plans.mts +5 -2
  30. package/skills/discover-specs/scripts/discover-specs.mts +5 -2
  31. package/skills/doctrine-loop/README.md +6 -0
  32. package/skills/doctrine-loop/SKILL.md +136 -2
  33. package/skills/formation-loop/SKILL.md +21 -1
  34. package/skills/gate-validation-governance/SKILL.md +2 -2
  35. package/skills/impl-producer-governance/SKILL.md +10 -1
  36. package/skills/init/scripts/wire-statusline.mts +5 -2
  37. package/skills/lifecycle-governance/SKILL.md +1 -1
  38. package/skills/manage-ignore/scripts/manage-ignore.mts +5 -2
  39. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +5 -2
  40. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +5 -2
  41. package/skills/mission-graph/README.md +3 -5
  42. package/skills/mission-graph/SKILL.md +72 -5
  43. package/skills/mission-graph/scripts/mission-graph.mts +505 -16
  44. package/skills/oracle-spec-governance/README.md +7 -2
  45. package/skills/oracle-spec-governance/SKILL.md +21 -4
  46. package/skills/place-node/scripts/place-node.mts +3 -2
  47. package/skills/plan-retirement/README.md +5 -2
  48. package/skills/plan-retirement/SKILL.md +5 -1
  49. package/skills/plan-retirement/scripts/retire-plans.mts +5 -2
  50. package/skills/plugin-contract-governance/SKILL.md +7 -1
  51. package/skills/remediation-governance/SKILL.md +36 -1
  52. package/skills/resolve-governances/scripts/resolve-governances.mts +5 -2
  53. package/skills/resolve-tracking/SKILL.md +2 -2
  54. package/skills/resolve-tracking/scripts/resolve-tracking.mts +5 -4
  55. package/skills/sdd/SKILL.md +1 -1
  56. package/skills/spec-format-governance/README.md +1 -1
  57. package/skills/spec-format-governance/SKILL.md +76 -8
  58. package/skills/spec-gate/SKILL.md +18 -2
  59. package/skills/spec-gate/scripts/check-spec-state.mts +47 -11
  60. package/skills/spec-gate/scripts/check-suite.mts +53 -17
  61. package/skills/spec-gate/scripts/classify-edit-class.mts +10 -7
  62. package/skills/spec-producer-governance/README.md +1 -1
  63. package/skills/spec-producer-governance/SKILL.md +7 -3
  64. package/skills/ssa-lowering/README.md +3 -5
  65. package/skills/start-mission/README.md +1 -1
  66. package/skills/start-mission/SKILL.md +9 -5
  67. package/skills/suite-format-governance/SKILL.md +43 -4
  68. package/skills/touch-set-correction/README.md +3 -5
  69. package/skills/touch-set-correction/scripts/touch-set-correction.mts +7 -5
  70. package/skills/verify-scenarios/SKILL.md +10 -3
  71. package/skills/verify-scenarios/scripts/verify-scenarios.mts +92 -8
@@ -10,10 +10,15 @@
10
10
  // `plugins/cyberfleet` is governed by `.agents/specs/cyberfleet-plugin`.
11
11
 
12
12
  import { execFileSync } from 'node:child_process'
13
- import { existsSync, readFileSync } from 'node:fs'
13
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
14
14
  import { dirname, join, relative, resolve } from 'node:path'
15
- import { fileURLToPath } from 'node:url'
16
- import { collectSpecs, discoverSpecFiles, type SpecRecord } from '../../discover-specs/scripts/discover-specs.mts'
15
+ import { fileURLToPath, pathToFileURL } from 'node:url'
16
+ import {
17
+ collectSpecs,
18
+ discoverSpecFiles,
19
+ parseFrontmatter,
20
+ type SpecRecord,
21
+ } from '../../discover-specs/scripts/discover-specs.mts'
17
22
 
18
23
  const SKILLS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
19
24
 
@@ -129,6 +134,36 @@ export function findCoverageGaps(
129
134
  return gaps
130
135
  }
131
136
 
137
+ /**
138
+ * A spec.md sitting at a recognized location that discovery DROPPED (its status is not
139
+ * in the lifecycle enum) and that belongs to `projectRel` — by the `project-path` it
140
+ * declares, or by sitting inside the project (`<project>/.agents/spec/spec.md`, which
141
+ * survives frontmatter corruption because it is location-derived).
142
+ *
143
+ * Without this, a per-project run resolves such a spec to `none` and prints "no spec
144
+ * governs <project> — skipped" with exit 0: a status typo silently exempts the whole
145
+ * project from every engine. A spec that exists but cannot be classified is escalated,
146
+ * not exempted — the same call the corpus-level `--check-coverage` guard makes.
147
+ */
148
+ export function findDroppedSpecFor(
149
+ specFiles: string[],
150
+ specs: SpecRecord[],
151
+ projectRel: string,
152
+ readText: (rel: string) => string | null,
153
+ ): { file: string; status: string }[] {
154
+ const recognized = new Set(specs.map((s) => (s.path === '' ? 'spec.md' : `${s.path}/spec.md`)))
155
+ const out: { file: string; status: string }[] = []
156
+ for (const f of specFiles) {
157
+ if (recognized.has(f)) continue
158
+ const text = readText(f)
159
+ const fm = text === null ? null : parseFrontmatter(text)
160
+ const dir = f.replace(/(^|\/)spec\.md$/, '')
161
+ const nested = /^(.+)\/\.agents\/spec$/.exec(dir)?.[1] ?? ''
162
+ if (fm?.projectPath === projectRel || nested === projectRel) out.push({ file: f, status: fm?.status ?? '' })
163
+ }
164
+ return out
165
+ }
166
+
132
167
  const REASON_TEXT: Record<CoverageGap['reason'], string> = {
133
168
  unrecognized:
134
169
  'sits at a spec location but its status is not in the lifecycle enum, so discovery drops it and nothing checks it',
@@ -137,6 +172,15 @@ const REASON_TEXT: Record<CoverageGap['reason'], string> = {
137
172
  'no-check-script': 'names a project that defines no `check:spec` script',
138
173
  }
139
174
 
175
+ /** Read a file, or null when it cannot be read. */
176
+ function readTextOrNull(path: string): string | null {
177
+ try {
178
+ return readFileSync(path, 'utf8')
179
+ } catch {
180
+ return null
181
+ }
182
+ }
183
+
140
184
  function checkCoverage(root: string): number {
141
185
  const gaps = findCoverageGaps(root, discoverSpecFiles(root), collectSpecs(root), (p) => {
142
186
  try {
@@ -179,9 +223,25 @@ function checkProject(argv: string[]): number {
179
223
  }
180
224
 
181
225
  const projectRel = relative(repoRoot, projectDir) || '.'
182
- const res = resolveSpecFor(collectSpecs(repoRoot), projectRel)
226
+ const specs = collectSpecs(repoRoot)
227
+ const res = resolveSpecFor(specs, projectRel)
183
228
 
184
229
  if (res.kind === 'none') {
230
+ // "No spec" is only legal when there is genuinely no spec file. A spec.md that
231
+ // exists but was dropped by the status filter is unclassifiable, not absent.
232
+ const dropped = findDroppedSpecFor(discoverSpecFiles(repoRoot), specs, projectRel, (rel) =>
233
+ readTextOrNull(join(repoRoot, rel)),
234
+ )
235
+ for (const d of dropped) {
236
+ process.stderr.write(
237
+ `check-project-specs: \`${d.file}\` sits at a spec location and governs \`${projectRel}\` but ` +
238
+ (d.status === ''
239
+ ? 'declares no lifecycle status'
240
+ : `its status \`${d.status}\` is not in the lifecycle enum`) +
241
+ ' (draft | approved | implemented | deprecated) — discovery drops it, so it is checked by nothing\n',
242
+ )
243
+ }
244
+ if (dropped.length) return 1
185
245
  process.stdout.write(`check-project-specs: no spec governs \`${projectRel}\` — skipped\n`)
186
246
  return 0
187
247
  }
@@ -214,4 +274,6 @@ function checkProject(argv: string[]): number {
214
274
  return failed === 0 ? 0 : 1
215
275
  }
216
276
 
217
- if (import.meta.main) process.exit(main(process.argv.slice(2)))
277
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
278
+ process.exit(main(process.argv.slice(2)))
279
+ }
@@ -0,0 +1,18 @@
1
+ # check-retired-terms
2
+
3
+ Internal SDD skill — the concrete guard engine for the **retired-terms registry and its
4
+ corpus-wide sweep**. Scans every git-tracked file for a literal, case-sensitive occurrence of a
5
+ term registered in `.agents/sdd/retired-terms.toml` as retired by a design decision.
6
+
7
+ ```bash
8
+ node scripts/check-retired-terms.mts --root . # the verify-time sweep
9
+ node scripts/check-retired-terms.mts --root . --list # what is registered
10
+ ```
11
+
12
+ Reports every survivor as `file:line:term — replace with: <replacement>`, then a count, and exits
13
+ non-zero. A malformed registry exits non-zero and names the parse error rather than reporting
14
+ clean. Built-in exclusions (the registry, the engine's own source/test, this node's own
15
+ README/`.feature`, every `ledger/` directory, `.agents/plans/`) are always applied and never
16
+ configurable; per-entry `scope` and `allow` narrow further. Read-only; writes nothing. See
17
+ [`SKILL.md`](./SKILL.md) for the full contract; the `corpus/retired-terms` node of the SDD project
18
+ spec (repo-only) carries the frozen spec. Not user-invocable.
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: check-retired-terms
3
+ description: "Partial Skill: invoke by name only — retired-terms' guard engine against survivors of a retired path, directory, or naming convention across the whole tracked corpus — the CI guard, not triggered by users directly."
4
+ user-invocable: false
5
+ metadata:
6
+ internal: true
7
+ ---
8
+
9
+ # Check Retired Terms
10
+
11
+ The concrete engine for the **retired-terms registry plus corpus-wide sweep**. A design decision
12
+ retires a path, directory, or naming convention, and records it once in
13
+ `.agents/sdd/retired-terms.toml`. This engine guards that the retirement holds: it scans every
14
+ git-tracked file in the repo — not only the node someone happened to touch — for a literal,
15
+ case-sensitive occurrence of a registered term, and reports every survivor as `file:line:term` with
16
+ the replacement to use. It carries a self-contained `.mts` script (the repo's node-≥23.6 / no-deps
17
+ convention), parsing the registry with the same minimal hand-rolled TOML subset `discover-specs`
18
+ already uses for `spec-anchors.toml`.
19
+
20
+ It is the corpus-wide, declared-data sibling of `check:metaphor-free`
21
+ (`packages/cyberlegion/src/metaphor-free.ts`): same banned-term / allow-list / exclusion / scope
22
+ shape, but the banned list here is registry data any CR can append to, not a hardcoded package
23
+ charter — the `corpus/retired-terms` node of the SDD project spec (repo-only) carries the full
24
+ design rationale.
25
+
26
+ ## Registry format
27
+
28
+ ```toml
29
+ [[retired]]
30
+ term = "old/retired/path/" # the literal text that is retired
31
+ since = "the-cr-that-retired-it" # provenance
32
+ replacement = "what to write instead"
33
+ scope = ["plugins/", "docs/"] # optional: only scan under these prefixes
34
+ allow = [ # optional: sanctioned occurrences
35
+ "docs/HISTORY.md", # whole file: superseded, kept for history
36
+ "docs/glossary.md :: the still-live sibling", # one line: a usage that stays correct
37
+ ]
38
+ ```
39
+
40
+ > The example above uses placeholder values on purpose. This file is **not** on the guard's
41
+ > self-exclusion list — only the registry, the engine, its test, and the spec node's own
42
+ > `README.md` / `.feature` are — so writing a real registered term here would make the guard
43
+ > report its own documentation. See the live registry at `.agents/sdd/retired-terms.toml` for
44
+ > the real entries.
45
+
46
+ - **`term` is matched as literal text, case-sensitive** — no globs, no regular expressions.
47
+ - **`scope`** lists repo-relative include prefixes. No `scope` scans the whole tracked tree.
48
+ - **`allow`** has two forms: a bare path sanctions every occurrence in that file; a
49
+ `path :: substring` entry sanctions only the lines carrying that substring. An `allow` entry is
50
+ for an occurrence that is still correct, never one that is merely inconvenient — a genuine
51
+ survivor is fixed, not allow-listed.
52
+ - **Built-in exclusions**, always applied, never configurable: the registry file itself, this
53
+ engine's own source and test, this node's own `README.md` and `retired-terms.feature`, every
54
+ `ledger/` directory, and everything under `.agents/plans/`.
55
+
56
+ ## Run the scan
57
+
58
+ ```bash
59
+ node "<skill>/scripts/check-retired-terms.mts" [--root .] # the verify-time sweep
60
+ node "<skill>/scripts/check-retired-terms.mts" [--root .] --list # what is registered
61
+ ```
62
+
63
+ - Default `--root` is the current directory.
64
+ - The **sweep** (default, no verb) exits **0** on a clean corpus (or an absent registry) and
65
+ **non-zero** on any survivor, printing each as `file:line:term — replace with: <replacement>`,
66
+ then a count. A **malformed registry never reports clean** — it names the parse error and exits
67
+ non-zero, because the registry *is* the check.
68
+ - **`--list`** prints each registered term with its `since` and `replacement`, and exits 0. With no
69
+ registry, it states plainly that nothing is registered — a definitive empty state, not silence.
70
+ - Wired into `check:specs` (`node …/check-retired-terms.mts --root .`), so it runs on every
71
+ `pnpm verify` and in CI.
72
+
73
+ When `node` is absent, an agent performs the same derivation by hand: read
74
+ `.agents/sdd/retired-terms.toml`, then grep every registered `term` across `git ls-files`, applying
75
+ the same exclusion / scope / allow rules by hand.
76
+
77
+ ## Boundaries
78
+
79
+ Read-only — it writes nothing and fixes no survivor (a person or a follow-up CR edits). It does not
80
+ decide that something is retired (a CR does, then registers it), does not curate the registry (the
81
+ file is hand-edited), and reads no spec frontmatter.
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ // check-retired-terms — corpus-wide sweep for survivors of a retired path, directory, or naming
3
+ // convention. A design decision retires a term (a path, a convention) and records the retirement
4
+ // once in the registry (.agents/sdd/retired-terms.toml); this engine reads that registry, scans
5
+ // every git-tracked file in the repo — not only the node someone happened to touch — and reports
6
+ // every literal, case-sensitive occurrence as a violation, with the replacement to use.
7
+ //
8
+ // It is the corpus-wide, declared-data sibling of check:metaphor-free (packages/cyberlegion/src/
9
+ // metaphor-free.ts): same "banned term, allow-list, exclusion list, scope" shape, but the banned
10
+ // list here is registry data appended by any CR (a changelog), not a hardcoded package charter.
11
+ //
12
+ // The registry is parsed with a hand-rolled minimal TOML subset — the same spirit as
13
+ // discover-specs's parseAnchorsToml (plugins/sdd/skills/discover-specs/scripts/discover-specs.mts)
14
+ // — an array of `[[retired]]` tables with string keys (term/since/replacement) and string-array
15
+ // keys (scope/allow). `allow` entries are flat strings, either a bare path (sanctions the whole
16
+ // file) or `path :: substring` (sanctions only lines carrying that substring) — deliberately, so
17
+ // the parser never needs nested inline tables.
18
+ //
19
+ // A malformed registry is NOT a fail-safe empty list (contrast discover-specs's readAnchors, which
20
+ // warns and falls back for an unrelated scan): here the registry IS the check, so a parse failure
21
+ // must be loud and non-zero, never a false-clean.
22
+ //
23
+ // Pure functions are exported for node:test; running the file directly drives the CLI. No
24
+ // dependencies (the repo's node-≥23.6 / no-deps convention).
25
+
26
+ import { execFileSync } from 'node:child_process'
27
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
28
+ import { join } from 'node:path'
29
+ import { pathToFileURL } from 'node:url'
30
+
31
+ // ── Registry ──
32
+
33
+ export interface RetiredEntry {
34
+ /** The literal, case-sensitive text that is retired. */
35
+ term: string
36
+ /** The CR that retired it. */
37
+ since: string
38
+ /** What to use instead. */
39
+ replacement: string
40
+ /** Repo-relative include prefixes. Absent/empty means the whole tracked tree. */
41
+ scope?: string[]
42
+ /** Sanctioned occurrences: a bare path (whole file) or "path :: substring" (matching lines only). */
43
+ allow?: string[]
44
+ }
45
+
46
+ export class RegistryParseError extends Error {}
47
+
48
+ const REGISTRY_PATH = '.agents/sdd/retired-terms.toml'
49
+
50
+ function isBlankOrComment(line: string): boolean {
51
+ const t = line.trim()
52
+ return t === '' || t.startsWith('#')
53
+ }
54
+
55
+ function extractString(block: string, key: string): string {
56
+ const m = new RegExp(`(^|\\n)\\s*${key}\\s*=\\s*"([^"]*)"[^\\n]*`).exec(block)
57
+ if (!m) throw new RegistryParseError(`[[retired]] entry is missing a string "${key}"`)
58
+ return m[2]
59
+ }
60
+
61
+ // A `[key = [...]]` array, tolerant of the array spanning several lines (as the README's worked
62
+ // example does). An opened-but-never-closed array is a parse error, not a silently empty result —
63
+ // silently swallowing it would be exactly the false-green this guard exists to prevent.
64
+ function extractArray(block: string, key: string): string[] {
65
+ const openRe = new RegExp(`(^|\\n)\\s*${key}\\s*=\\s*\\[`)
66
+ const openMatch = openRe.exec(block)
67
+ if (!openMatch) return []
68
+ const openIdx = openMatch.index + openMatch[0].length - 1 // index of the "["
69
+ const closeIdx = block.indexOf(']', openIdx)
70
+ if (closeIdx === -1) throw new RegistryParseError(`"${key}" array is never closed with "]"`)
71
+ const arrText = block.slice(openIdx, closeIdx)
72
+ const out: string[] = []
73
+ for (const q of arrText.matchAll(/"([^"]*)"/g)) out.push(q[1])
74
+ return out
75
+ }
76
+
77
+ /** Parses the registry's minimal TOML subset: an array of `[[retired]]` tables carrying string
78
+ * keys (term/since/replacement, all required) and optional string-array keys (scope/allow). Throws
79
+ * `RegistryParseError` on anything that does not fit that shape — an absent registry is handled by
80
+ * the caller (`readRegistry`), not here; an empty/comment-only file parses to `[]`. */
81
+ export function parseRegistryToml(text: string): RetiredEntry[] {
82
+ const marker = '[[retired]]'
83
+ const firstIdx = text.indexOf(marker)
84
+ if (firstIdx === -1) {
85
+ if (text.split('\n').every(isBlankOrComment)) return []
86
+ throw new RegistryParseError('no [[retired]] table found and the file is not empty/comment-only')
87
+ }
88
+ const preamble = text.slice(0, firstIdx)
89
+ if (!preamble.split('\n').every(isBlankOrComment)) {
90
+ throw new RegistryParseError('content found before the first [[retired]] table header')
91
+ }
92
+ const blocks = text.slice(firstIdx).split(marker).slice(1)
93
+ return blocks.map((block) => {
94
+ const term = extractString(block, 'term')
95
+ const since = extractString(block, 'since')
96
+ const replacement = extractString(block, 'replacement')
97
+ const scope = extractArray(block, 'scope')
98
+ const allow = extractArray(block, 'allow')
99
+ const entry: RetiredEntry = { term, since, replacement }
100
+ if (scope.length) entry.scope = scope
101
+ if (allow.length) entry.allow = allow
102
+ return entry
103
+ })
104
+ }
105
+
106
+ /** Reads and parses the registry at `<root>/.agents/sdd/retired-terms.toml`. An absent file yields
107
+ * `[]` (an "an absent registry sweeps clean" registry, not an error). A present-but-malformed file
108
+ * throws `RegistryParseError` — the caller must surface that loudly and exit non-zero. */
109
+ export function readRegistry(root: string): RetiredEntry[] {
110
+ const file = join(root, REGISTRY_PATH)
111
+ if (!existsSync(file)) return []
112
+ return parseRegistryToml(readFileSync(file, 'utf8'))
113
+ }
114
+
115
+ // ── Built-in exclusions — always applied, never configurable ──
116
+ // The rule: a surface whose job is to name the retired term is not drift. Two kinds qualify:
117
+ // (a) the guard's own definition — the registry, this engine's source + test, and this node's own
118
+ // README + .feature (it states the banned text to define it), and
119
+ // (b) durable provenance — every ledger/ directory, and everything under .agents/plans/.
120
+ // Nothing else is excluded: a spec README that merely *mentions* a retired convention is drift.
121
+
122
+ const ENGINE_SOURCE = 'plugins/sdd/skills/check-retired-terms/scripts/check-retired-terms.mts'
123
+ const ENGINE_TEST = 'plugins/sdd/skills/check-retired-terms/scripts/check-retired-terms.test.mts'
124
+ const NODE_README = '.agents/specs/sdd/corpus/retired-terms/README.md'
125
+ const NODE_FEATURE = '.agents/specs/sdd/corpus/retired-terms/retired-terms.feature'
126
+
127
+ const DEFAULT_BUILTIN_EXCLUDED_FILES: readonly string[] = [
128
+ REGISTRY_PATH,
129
+ ENGINE_SOURCE,
130
+ ENGINE_TEST,
131
+ NODE_README,
132
+ NODE_FEATURE,
133
+ ]
134
+
135
+ const DEFAULT_BUILTIN_EXCLUDED_PREFIXES: readonly string[] = ['.agents/plans/']
136
+
137
+ // Any file sitting inside a directory literally named "ledger", at any depth (e.g.
138
+ // ".agents/specs/aced/ledger/x.jsonl") — durable provenance, not a directory this guard walks by
139
+ // prefix from the root.
140
+ function isUnderLedgerDir(relPath: string): boolean {
141
+ const segs = relPath.split('/')
142
+ return segs.slice(0, -1).includes('ledger')
143
+ }
144
+
145
+ function isBuiltinExcluded(
146
+ relPath: string,
147
+ excludedFiles: readonly string[],
148
+ excludedPrefixes: readonly string[],
149
+ ): boolean {
150
+ if (excludedFiles.includes(relPath)) return true
151
+ if (excludedPrefixes.some((p) => relPath.startsWith(p))) return true
152
+ if (isUnderLedgerDir(relPath)) return true
153
+ return false
154
+ }
155
+
156
+ // ── Scope + allow ──
157
+
158
+ function inScope(relPath: string, scope?: string[]): boolean {
159
+ if (!scope || scope.length === 0) return true
160
+ return scope.some((prefix) => relPath.startsWith(prefix))
161
+ }
162
+
163
+ interface ParsedAllow {
164
+ file: string
165
+ substring?: string
166
+ }
167
+
168
+ function parseAllowEntry(raw: string): ParsedAllow {
169
+ const idx = raw.indexOf('::')
170
+ if (idx === -1) return { file: raw.trim() }
171
+ return { file: raw.slice(0, idx).trim(), substring: raw.slice(idx + 2).trim() }
172
+ }
173
+
174
+ // A bare-path allow entry sanctions every line of that file; a `path :: substring` entry sanctions
175
+ // only lines carrying that substring, leaving the rest of the file guarded.
176
+ function isAllowed(relPath: string, lineText: string, allow: string[] | undefined): boolean {
177
+ if (!allow) return false
178
+ for (const raw of allow) {
179
+ const parsed = parseAllowEntry(raw)
180
+ if (parsed.file !== relPath) continue
181
+ if (parsed.substring === undefined) return true
182
+ if (lineText.includes(parsed.substring)) return true
183
+ }
184
+ return false
185
+ }
186
+
187
+ // ── Tracked files ──
188
+
189
+ /** The git-tracked file set, repo-relative, one per line. An untracked file is outside the sweep
190
+ * by construction — it is never in this list. */
191
+ export function listTrackedFiles(root: string): string[] {
192
+ const out = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' })
193
+ return out.split('\n').filter((l) => l !== '')
194
+ }
195
+
196
+ // ── Sweep ──
197
+
198
+ export interface Violation {
199
+ file: string
200
+ line: number
201
+ term: string
202
+ replacement: string
203
+ }
204
+
205
+ export interface SweepOptions {
206
+ /** Substitute the tracked-file lister (tests inject a fixed list instead of shelling to git). */
207
+ listTrackedFiles?: (root: string) => string[]
208
+ /** Substitute the built-in excluded-file set (tests probe the exclusion mechanism in isolation). */
209
+ builtinExcludedFiles?: readonly string[]
210
+ /** Substitute the built-in excluded-prefix set. */
211
+ builtinExcludedPrefixes?: readonly string[]
212
+ }
213
+
214
+ /** Sweeps `root` for survivors of every entry in `entries`. `options` lets callers (tests)
215
+ * substitute fixture-scoped config — the tracked-file source and the built-in exclusion lists —
216
+ * in place of the real repo and the real defaults, mirroring `findMetaphorViolations`'s
217
+ * `ScanOptions` (packages/cyberlegion/src/metaphor-free.ts). */
218
+ export function sweep(root: string, entries: RetiredEntry[], options: SweepOptions = {}): Violation[] {
219
+ const listFiles = options.listTrackedFiles ?? listTrackedFiles
220
+ const excludedFiles = options.builtinExcludedFiles ?? DEFAULT_BUILTIN_EXCLUDED_FILES
221
+ const excludedPrefixes = options.builtinExcludedPrefixes ?? DEFAULT_BUILTIN_EXCLUDED_PREFIXES
222
+
223
+ const violations: Violation[] = []
224
+ for (const relPath of listFiles(root)) {
225
+ if (isBuiltinExcluded(relPath, excludedFiles, excludedPrefixes)) continue
226
+
227
+ let text: string
228
+ try {
229
+ text = readFileSync(join(root, relPath), 'utf8')
230
+ } catch {
231
+ continue
232
+ }
233
+ const lines = text.split('\n')
234
+
235
+ for (const entry of entries) {
236
+ if (!inScope(relPath, entry.scope)) continue
237
+ lines.forEach((lineText, i) => {
238
+ if (!lineText.includes(entry.term)) return
239
+ if (isAllowed(relPath, lineText, entry.allow)) return
240
+ violations.push({ file: relPath, line: i + 1, term: entry.term, replacement: entry.replacement })
241
+ })
242
+ }
243
+ }
244
+ return violations.sort((a, b) => (a.file !== b.file ? (a.file < b.file ? -1 : 1) : a.line - b.line))
245
+ }
246
+
247
+ // ── CLI ──
248
+
249
+ /** Renders `--list` output: one line per registered term with its `since` and `replacement`, or a
250
+ * definitive empty-state line when nothing is registered — never silence. */
251
+ export function formatList(entries: RetiredEntry[]): string {
252
+ if (entries.length === 0) return 'check-retired-terms: no term is registered\n'
253
+ return entries.map((e) => `${e.term} (since ${e.since}) -> ${e.replacement}\n`).join('')
254
+ }
255
+
256
+ /** Renders the sweep report: `file:line:term` plus the declared replacement, one per survivor
257
+ * (every survivor, not just the first), then a count summary. */
258
+ export function formatViolations(violations: Violation[]): string {
259
+ const lines = violations.map((v) => `${v.file}:${v.line}:${v.term} — replace with: ${v.replacement}\n`)
260
+ lines.push(`check-retired-terms: ${violations.length} survivor(s) found\n`)
261
+ return lines.join('')
262
+ }
263
+
264
+ export function main(argv: string[]): number {
265
+ const root = argv.includes('--root') ? (argv[argv.indexOf('--root') + 1] ?? '.') : '.'
266
+ const list = argv.includes('--list')
267
+
268
+ let entries: RetiredEntry[]
269
+ try {
270
+ entries = readRegistry(root)
271
+ } catch (err) {
272
+ const message = err instanceof Error ? err.message : String(err)
273
+ process.stderr.write(`check-retired-terms: malformed registry — ${message}\n`)
274
+ return 1
275
+ }
276
+
277
+ if (list) {
278
+ process.stdout.write(formatList(entries))
279
+ return 0
280
+ }
281
+
282
+ const violations = sweep(root, entries)
283
+ if (violations.length === 0) {
284
+ process.stdout.write('check-retired-terms: clean — no survivors found\n')
285
+ return 0
286
+ }
287
+ process.stdout.write(formatViolations(violations))
288
+ return 1
289
+ }
290
+
291
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
292
+ process.exit(main(process.argv.slice(2)))
293
+ }
@@ -23,8 +23,9 @@
23
23
  // no-deps convention). Pure functions are exported for node:test; running the file directly drives
24
24
  // the CLI.
25
25
 
26
- import { readdirSync, readFileSync } from 'node:fs'
26
+ import { readdirSync, readFileSync, realpathSync } from 'node:fs'
27
27
  import { join } from 'node:path'
28
+ import { pathToFileURL } from 'node:url'
28
29
 
29
30
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
30
31
 
@@ -244,6 +245,6 @@ export function main(argv: string[]): number {
244
245
  return 0
245
246
  }
246
247
 
247
- if (import.meta.url === `file://${process.argv[1]}`) {
248
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
248
249
  process.exit(main(process.argv.slice(2)))
249
250
  }
@@ -26,8 +26,9 @@
26
26
  // dependencies (the repo's node-≥23.6 / no-deps convention). Pure functions are exported for
27
27
  // node:test; running the file directly drives the CLI.
28
28
 
29
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
29
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
30
30
  import { join } from 'node:path'
31
+ import { pathToFileURL } from 'node:url'
31
32
 
32
33
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
33
34
  export const DEFAULT_MAX_SCENARIOS = 40
@@ -341,6 +342,6 @@ export function main(argv: string[]): number {
341
342
  return 0
342
343
  }
343
344
 
344
- if (import.meta.url === `file://${process.argv[1]}`) {
345
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
345
346
  process.exit(main(process.argv.slice(2)))
346
347
  }
@@ -6,11 +6,9 @@ region → semantic) to classify the clash **hard** (must serialize) or **soft**
6
6
  plus the shared-thin-file **hard→soft downgrade** + smell flag. It reuses the sibling
7
7
  [`touch-set-correction`](../touch-set-correction/SKILL.md) composition (`resolve-governances` +
8
8
  `gherkin-cli diff`) and adds a `git diff -U0` region source. Built for the Op2 second-bullet of the
9
- cyberfleet-batch change request; see
10
- [`.agents/specs/sdd/collision-ladder/README.md`](../../../../.agents/specs/sdd/collision-ladder/README.md)
11
- for the authoritative behavior description and
12
- [`collision-ladder.feature`](../../../../.agents/specs/sdd/collision-ladder/collision-ladder.feature)
13
- for the frozen 18-scenario contract.
9
+ cyberfleet-batch change request; the `collision-ladder` node of the SDD project spec (in the
10
+ cyberplace repository, not shipped in this package) carries the authoritative behavior description
11
+ and the frozen 18-scenario contract.
14
12
 
15
13
  - **Skill contract:** [`SKILL.md`](./SKILL.md)
16
14
  - **Script:** [`scripts/collision-ladder.mts`](./scripts/collision-ladder.mts)
@@ -5,8 +5,8 @@
5
5
  // — and stop at the first rung that classifies the clash HARD (must serialize) vs SOFT (can run in
6
6
  // parallel, reconciled by rebase). Plus the shared-thin-file hard→soft downgrade: a file touched by
7
7
  // many missions (router/barrel/registry) that would over-serialize gets the region/semantic descent
8
- // to downgrade, and is flagged as an architectural smell. See
9
- // .agents/specs/sdd/collision-ladder/README.md for the full contract.
8
+ // to downgrade, and is flagged as an architectural smell. The collision-ladder node of the SDD
9
+ // project spec (repo-only) carries the full contract.
10
10
  //
11
11
  // Architecture — pure derivation kept apart from IO, on purpose (touch-set-correction.mts's
12
12
  // convention it mirrors):
@@ -35,8 +35,9 @@
35
35
  // node:test; running the file directly drives the CLI.
36
36
 
37
37
  import { execFileSync } from 'node:child_process'
38
+ import { realpathSync } from 'node:fs'
38
39
  import { dirname, join } from 'node:path'
39
- import { fileURLToPath } from 'node:url'
40
+ import { fileURLToPath, pathToFileURL } from 'node:url'
40
41
  import {
41
42
  collectChangedFiles,
42
43
  fileToNode,
@@ -654,4 +655,6 @@ export function main(argv: string[]): number {
654
655
  return 0
655
656
  }
656
657
 
657
- if (import.meta.main) process.exit(main(process.argv.slice(2)))
658
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
659
+ process.exit(main(process.argv.slice(2)))
660
+ }
@@ -121,8 +121,17 @@ the ledger's `strategy` count.
121
121
 
122
122
  **Growth:** closed at any moment, discovered from usage — a new value is added only when a real
123
123
  recurring correction has no category. Adding one is an **edit to this governance, ratified by the
124
- Council** (a producer/judge/conductor never edits the enum). An **absent or off-enum `cause` fails
125
- closed** (it breaks cross-mission matchability).
124
+ Council** (a producer/judge/conductor never edits the enum).
125
+
126
+ **Off-enum candidate discipline (the write-time nudge).** When the conductor writes a `cause` and
127
+ **no enum value fits**, it writes the off-enum string into `cause` anyway **and flags the line
128
+ `cause-candidate: true`** — so the value stays **countable** as a proposal for enum growth instead of
129
+ silently failing closed. This is a **visibility nudge, not a write-blocking linter**: the write always
130
+ succeeds, and forcing an ill-fitting enum value would only relabel the silent drop as a mislabel. An
131
+ **absent `cause` still fails closed** (it breaks cross-mission matchability) — the nudge governs only
132
+ the *no-value-fits* case and licenses no omission. A `cause-candidate` value that recurs is exactly
133
+ the signal the Council reads when deciding the ratified growth above; the flag makes the accumulating
134
+ candidate legible instead of invisible.
126
135
 
127
136
  **Efficiency** is a categorical correction class the committed log is designed to carry — the
128
137
  conductor flagging notable token-waste (a class, **never raw counts**), so the post-merge doctrine
@@ -130,6 +139,11 @@ the ledger's `strategy` count.
130
139
  the same Council-ratified growth, and the numeric depth stays transcript-only (the floor admits no
131
140
  raw token number).
132
141
 
142
+ - **`cause-candidate`** — optional boolean. `true` marks an off-enum `cause` written under the
143
+ off-enum candidate discipline above as a proposed enum-growth value (kept present and countable, not
144
+ silently dropped). Omitted or `false` on an on-enum `cause`. The same flag applies to a `gate` line's
145
+ off-enum stop cause (below).
146
+
133
147
  - **Durability discipline (the conductor's write duty).** A `correction` is a discrete line, never
134
148
  left folded only into a verdict `why` (the doctrine loop matches `cause`, not prose):
135
149
  - **At a gate reached via a judge-reject→fix→pass**, the self-asserting conductor appends the
@@ -164,8 +178,11 @@ doctrine loop reads only the committed log post-merge.
164
178
 
165
179
  - **`gate`** — `spec | impl`. **`verdict`** — `approve | pause | reject`. **`by`** — a human name
166
180
  (ratified) or `agent` (self-asserted, provisional; carries the `why` derivation).
167
- - **`cause`** — `dimension | ceiling` (the **stop cause**, distinct from a `correction`'s matchable
168
- `cause`).
181
+ - **`cause`** — `dimension | clearance | ceiling` (the **stop cause**, distinct from a `correction`'s
182
+ matchable `cause`): a gradient risk `dimension`, the `clearance` hard floor (a narrowing), or the
183
+ `ceiling` (Compatibility) cap. The **off-enum candidate discipline** applies here too — a stop cause
184
+ with no enum fit (e.g. a novel floor) is written off-enum **and** flagged `cause-candidate: true`,
185
+ never silently dropped.
169
186
  - **`frozen`** — the suite files this verdict froze (spec-gate `approve` only), so the ledger answers
170
187
  *"what was frozen as of CR #34"* standalone — no git walk.
171
188
 
@@ -211,6 +228,28 @@ its combat log (`sdd:plan-retirement` — the gate keys on `distills`, **never**
211
228
  and an **unratified** entry still counts). Milestone / drift / token-waste strategy that has **no
212
229
  single subject mission omits `distills`** — only a Ship or Kill distillation gates a retirement.
213
230
 
231
+ **The `disposition` subject (`open | resolved`).** Before drafting, the Scanner validates each
232
+ plan/log-surfaced candidate against **current code** (a persisted plan or log is history, a
233
+ *hypothesis* about a gap — not present truth). The `disposition` field records that validation verdict:
234
+
235
+ - **`disposition: open`** (the default; a line without the field grandfathers as `open`) — current
236
+ code does **not** resolve the candidate. It is an actionable recommendation: it **counts toward
237
+ pending strategy** and drives the Scanner's issue emission.
238
+ - **`disposition: resolved`** — current code **already resolves** the candidate (built / fixed /
239
+ superseded). The entry is a **tombstone**, not a recommendation: it carries the resolving
240
+ current-code evidence in `evidence`, emits **no** issue, and is **not counted toward pending
241
+ strategy**. It exists so the cut is auditable and a later run does not silently re-surface the same
242
+ closed candidate.
243
+
244
+ ```jsonl
245
+ {"seq": 3, "handle": "sdd-scanner", "kind": "strategy", "disposition": "resolved", "recommendation": "no action — coverage-gap mechanism already shipped", "evidence": ["current-code: checkReferencedArtifacts + checkUseCaseCoverage live in spec-gate/scripts/check-spec-state.mts"], "ratified": false}
246
+ ```
247
+
248
+ `disposition` is set **once at write** and never flipped (the append-only invariant) — the Scanner's
249
+ pre-draft validation cut is a distinct act from the **Council's** keep-or-cut on a drafted
250
+ `disposition: open` line. **Pending strategy** counted at the gateway is `kind: strategy`,
251
+ `ratified: false`, `disposition: open`-or-absent — a `disposition: resolved` line is excluded.
252
+
214
253
  ### `followup` — a recorded follow-up (ledger)
215
254
 
216
255
  The durable record of work handoff identified but held out of scope. Written by the **conductor at