cyber-sdd 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.plugin/pins.json +3 -0
- package/LICENSE +21 -0
- package/agents/sdd-automaton.md +13 -2
- package/agents/sdd-scanner.md +85 -0
- package/agents/sdd-spec-judge.md +32 -2
- package/agents/sdd-warden.md +9 -0
- package/package.json +30 -23
- package/skills/align-spec/scripts/align-spec.mts +3 -2
- package/skills/blast-estimate/README.md +3 -5
- package/skills/blast-estimate/SKILL.md +2 -2
- package/skills/blast-estimate/scripts/blast-estimate.mts +7 -4
- package/skills/builder-impl-governance/SKILL.md +9 -1
- package/skills/builder-spec-governance/SKILL.md +13 -1
- package/skills/check-partition-quality/scripts/check-partition-quality.mts +3 -1
- package/skills/check-plan-safety/scripts/check-plan-safety.mts +5 -2
- package/skills/check-project-specs/scripts/check-project-specs.mts +67 -5
- package/skills/check-retired-terms/README.md +18 -0
- package/skills/check-retired-terms/SKILL.md +81 -0
- package/skills/check-retired-terms/scripts/check-retired-terms.mts +293 -0
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +3 -2
- package/skills/check-spec-structure/scripts/check-spec-structure.mts +3 -2
- package/skills/collision-ladder/README.md +3 -5
- package/skills/collision-ladder/scripts/collision-ladder.mts +7 -4
- package/skills/combat-log-governance/SKILL.md +43 -4
- package/skills/concept-index/scripts/concept-index.mts +3 -2
- package/skills/discover-plans/scripts/discover-plans.mts +5 -2
- package/skills/discover-specs/scripts/discover-specs.mts +5 -2
- package/skills/doctrine-loop/README.md +6 -0
- package/skills/doctrine-loop/SKILL.md +136 -2
- package/skills/formation-loop/SKILL.md +21 -1
- package/skills/gate-validation-governance/SKILL.md +2 -2
- package/skills/impl-producer-governance/SKILL.md +10 -1
- package/skills/init/scripts/wire-statusline.mts +5 -2
- package/skills/lifecycle-governance/SKILL.md +1 -1
- package/skills/manage-ignore/scripts/manage-ignore.mts +5 -2
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +5 -2
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +5 -2
- package/skills/mission-graph/README.md +3 -5
- package/skills/mission-graph/SKILL.md +72 -5
- package/skills/mission-graph/scripts/mission-graph.mts +505 -16
- package/skills/place-node/scripts/place-node.mts +3 -2
- package/skills/plan-retirement/README.md +5 -2
- package/skills/plan-retirement/SKILL.md +5 -1
- package/skills/plan-retirement/scripts/retire-plans.mts +5 -2
- package/skills/plugin-contract-governance/SKILL.md +7 -1
- package/skills/remediation-governance/SKILL.md +36 -1
- package/skills/resolve-governances/scripts/resolve-governances.mts +5 -2
- package/skills/resolve-tracking/SKILL.md +2 -2
- package/skills/resolve-tracking/scripts/resolve-tracking.mts +5 -4
- package/skills/sdd/SKILL.md +1 -1
- package/skills/spec-format-governance/SKILL.md +5 -0
- package/skills/spec-gate/SKILL.md +18 -2
- package/skills/spec-gate/scripts/check-spec-state.mts +47 -11
- package/skills/spec-gate/scripts/check-suite.mts +53 -17
- package/skills/spec-gate/scripts/classify-edit-class.mts +10 -7
- package/skills/spec-producer-governance/SKILL.md +2 -2
- package/skills/ssa-lowering/README.md +3 -5
- package/skills/start-mission/SKILL.md +8 -4
- package/skills/suite-format-governance/SKILL.md +43 -4
- package/skills/touch-set-correction/README.md +3 -5
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +7 -5
- package/skills/verify-scenarios/SKILL.md +10 -3
- package/skills/verify-scenarios/scripts/verify-scenarios.mts +92 -8
|
@@ -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 ===
|
|
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 ===
|
|
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;
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
9
|
-
//
|
|
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.
|
|
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).
|
|
125
|
-
|
|
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
|
|
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
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
// reaches the output. No dependencies (the repo's node-≥23.6 / no-deps convention). Pure functions
|
|
10
10
|
// are exported for node:test; running the file directly drives the CLI.
|
|
11
11
|
|
|
12
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
13
13
|
import { join } from 'node:path'
|
|
14
|
+
import { pathToFileURL } from 'node:url'
|
|
14
15
|
|
|
15
16
|
export const BEGIN_MARKER = '<!-- BEGIN generated: by-concept (project-spec/concept-index) -->'
|
|
16
17
|
export const END_MARKER = '<!-- END generated: by-concept -->'
|
|
@@ -240,6 +241,6 @@ export function main(argv: string[]): number {
|
|
|
240
241
|
return 0
|
|
241
242
|
}
|
|
242
243
|
|
|
243
|
-
if (import.meta.url ===
|
|
244
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
244
245
|
process.exit(main(process.argv.slice(2)))
|
|
245
246
|
}
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
// No dependencies (the repo's node-≥23.6 / no-deps convention). --format json for a flat
|
|
19
19
|
// array; default output is TOON (the token-efficient tabular form the gateway scans).
|
|
20
20
|
|
|
21
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
22
22
|
import { join } from 'node:path'
|
|
23
|
+
import { pathToFileURL } from 'node:url'
|
|
23
24
|
|
|
24
25
|
export const TODO_STATUSES = new Set(['pending', 'in_progress', 'completed'])
|
|
25
26
|
|
|
@@ -209,4 +210,6 @@ export function main(argv: string[]): number {
|
|
|
209
210
|
return 0
|
|
210
211
|
}
|
|
211
212
|
|
|
212
|
-
if (import.meta.
|
|
213
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
214
|
+
process.exit(main(process.argv.slice(2)))
|
|
215
|
+
}
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
// default output is TOON (the token-efficient tabular form the gateway scans). --resolve <name>
|
|
27
27
|
// filters to the exact name matches (0 rows = none, 1 = resolved, >1 = ambiguous).
|
|
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
|
export const LIFECYCLE_STATUSES = new Set(['draft', 'approved', 'implemented', 'deprecated'])
|
|
33
34
|
|
|
@@ -393,4 +394,6 @@ export function main(argv: string[]): number {
|
|
|
393
394
|
return 0
|
|
394
395
|
}
|
|
395
396
|
|
|
396
|
-
if (import.meta.
|
|
397
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
398
|
+
process.exit(main(process.argv.slice(2)))
|
|
399
|
+
}
|
|
@@ -12,4 +12,10 @@ the one project `ledger/` directory. The entry **shape** and the matchable `caus
|
|
|
12
12
|
`sdd:combat-log-governance` (deferred, never restated). The Council holds keep-or-cut; the `sdd`
|
|
13
13
|
gateway surfaces the count of pending unratified strategy when the Council re-enters.
|
|
14
14
|
|
|
15
|
+
Separately, during its pass, the Scanner also cross-checks each plan brief's `todos-all-done`
|
|
16
|
+
against its `source-closed` to **derive the retirement clearance set** — it never autofixes a
|
|
17
|
+
plan's `status`; agreement feeds `sdd:plan-retirement`'s existing `--retire` input, disagreement
|
|
18
|
+
surfaces a flagged finding in the Scanner's pass summary. This is distinct from strategy-drafting
|
|
19
|
+
above: a flagged finding is never a ledger write.
|
|
20
|
+
|
|
15
21
|
Plan retirement (doctrine's last retro step) is the sibling `sdd:plan-retirement` skill.
|