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.
- package/.claude-plugin/plugin.json +17 -0
- package/.codex-plugin/plugin.json +17 -0
- package/.plugin/plugin.json +17 -0
- package/README.md +159 -0
- package/agents/sdd-automaton.md +97 -0
- package/agents/sdd-impl-judge.md +214 -0
- package/agents/sdd-scanner.md +120 -0
- package/agents/sdd-spec-judge.md +224 -0
- package/agents/sdd-warden.md +101 -0
- package/package.json +24 -0
- package/skills/align-spec/README.md +20 -0
- package/skills/align-spec/SKILL.md +111 -0
- package/skills/align-spec/scripts/align-spec.mts +187 -0
- package/skills/architect-impl-governance/README.md +46 -0
- package/skills/architect-impl-governance/SKILL.md +45 -0
- package/skills/architect-spec-governance/README.md +48 -0
- package/skills/architect-spec-governance/SKILL.md +59 -0
- package/skills/blast-estimate/README.md +47 -0
- package/skills/blast-estimate/SKILL.md +133 -0
- package/skills/blast-estimate/scripts/blast-estimate.mts +583 -0
- package/skills/builder-impl-governance/README.md +47 -0
- package/skills/builder-impl-governance/SKILL.md +47 -0
- package/skills/builder-spec-governance/README.md +49 -0
- package/skills/builder-spec-governance/SKILL.md +36 -0
- package/skills/check-partition-quality/README.md +22 -0
- package/skills/check-partition-quality/SKILL.md +51 -0
- package/skills/check-partition-quality/scripts/check-partition-quality.mts +336 -0
- package/skills/check-plan-safety/README.md +17 -0
- package/skills/check-plan-safety/SKILL.md +60 -0
- package/skills/check-plan-safety/scripts/check-plan-safety.mts +145 -0
- package/skills/check-project-specs/README.md +19 -0
- package/skills/check-project-specs/SKILL.md +69 -0
- package/skills/check-project-specs/scripts/check-project-specs.mts +217 -0
- package/skills/check-scenario-overlap/README.md +19 -0
- package/skills/check-scenario-overlap/SKILL.md +74 -0
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +249 -0
- package/skills/check-spec-structure/README.md +17 -0
- package/skills/check-spec-structure/SKILL.md +66 -0
- package/skills/check-spec-structure/scripts/check-spec-structure.mts +346 -0
- package/skills/collision-ladder/README.md +18 -0
- package/skills/collision-ladder/SKILL.md +83 -0
- package/skills/collision-ladder/scripts/collision-ladder.mts +657 -0
- package/skills/combat-log-governance/README.md +13 -0
- package/skills/combat-log-governance/SKILL.md +257 -0
- package/skills/concept-index/README.md +13 -0
- package/skills/concept-index/SKILL.md +38 -0
- package/skills/concept-index/scripts/concept-index.mts +245 -0
- package/skills/discover-plans/README.md +16 -0
- package/skills/discover-plans/SKILL.md +74 -0
- package/skills/discover-plans/scripts/discover-plans.mts +212 -0
- package/skills/discover-specs/README.md +15 -0
- package/skills/discover-specs/SKILL.md +76 -0
- package/skills/discover-specs/scripts/discover-specs.mts +396 -0
- package/skills/doctrine-loop/README.md +15 -0
- package/skills/doctrine-loop/SKILL.md +97 -0
- package/skills/formation-loop/README.md +17 -0
- package/skills/formation-loop/SKILL.md +140 -0
- package/skills/gate-validation-governance/README.md +12 -0
- package/skills/gate-validation-governance/SKILL.md +87 -0
- package/skills/impl-producer-governance/README.md +48 -0
- package/skills/impl-producer-governance/SKILL.md +85 -0
- package/skills/init/README.md +27 -0
- package/skills/init/SKILL.md +68 -0
- package/skills/init/scripts/wire-statusline.mts +276 -0
- package/skills/lifecycle-governance/README.md +11 -0
- package/skills/lifecycle-governance/SKILL.md +168 -0
- package/skills/manage/README.md +9 -0
- package/skills/manage/SKILL.md +62 -0
- package/skills/manage-ignore/README.md +19 -0
- package/skills/manage-ignore/SKILL.md +52 -0
- package/skills/manage-ignore/scripts/manage-ignore.mts +294 -0
- package/skills/manage-scenario-bridge/README.md +20 -0
- package/skills/manage-scenario-bridge/SKILL.md +60 -0
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +156 -0
- package/skills/manage-spec-anchors/README.md +18 -0
- package/skills/manage-spec-anchors/SKILL.md +56 -0
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +328 -0
- package/skills/mission-graph/README.md +15 -0
- package/skills/mission-graph/SKILL.md +67 -0
- package/skills/mission-graph/scripts/mission-graph.mts +844 -0
- package/skills/oracle-spec-governance/README.md +45 -0
- package/skills/oracle-spec-governance/SKILL.md +45 -0
- package/skills/ownership-governance/README.md +65 -0
- package/skills/ownership-governance/SKILL.md +104 -0
- package/skills/pause-mission/README.md +18 -0
- package/skills/pause-mission/SKILL.md +112 -0
- package/skills/place-node/README.md +12 -0
- package/skills/place-node/SKILL.md +47 -0
- package/skills/place-node/scripts/place-node.mts +157 -0
- package/skills/plan-retirement/README.md +32 -0
- package/skills/plan-retirement/SKILL.md +90 -0
- package/skills/plan-retirement/scripts/retire-plans.mts +196 -0
- package/skills/plugin-contract-governance/README.md +12 -0
- package/skills/plugin-contract-governance/SKILL.md +112 -0
- package/skills/remediation-governance/README.md +46 -0
- package/skills/remediation-governance/SKILL.md +78 -0
- package/skills/resolve-governances/README.md +18 -0
- package/skills/resolve-governances/SKILL.md +50 -0
- package/skills/resolve-governances/scripts/resolve-governances.mts +515 -0
- package/skills/resolve-tracking/SKILL.md +64 -0
- package/skills/resolve-tracking/scripts/resolve-tracking.mts +213 -0
- package/skills/resume-mission/README.md +12 -0
- package/skills/resume-mission/SKILL.md +53 -0
- package/skills/scaffold-project-spec/README.md +7 -0
- package/skills/scaffold-project-spec/SKILL.md +192 -0
- package/skills/sdd/README.md +7 -0
- package/skills/sdd/SKILL.md +92 -0
- package/skills/solution-producer-governance/README.md +9 -0
- package/skills/solution-producer-governance/SKILL.md +44 -0
- package/skills/spec-format-governance/README.md +73 -0
- package/skills/spec-format-governance/SKILL.md +114 -0
- package/skills/spec-gate/README.md +26 -0
- package/skills/spec-gate/SKILL.md +201 -0
- package/skills/spec-gate/scripts/check-spec-state.mts +601 -0
- package/skills/spec-gate/scripts/check-suite.mts +501 -0
- package/skills/spec-gate/scripts/classify-edit-class.mts +411 -0
- package/skills/spec-producer-governance/README.md +7 -0
- package/skills/spec-producer-governance/SKILL.md +86 -0
- package/skills/spec-structure-governance/README.md +40 -0
- package/skills/spec-structure-governance/SKILL.md +169 -0
- package/skills/ssa-lowering/README.md +26 -0
- package/skills/ssa-lowering/SKILL.md +181 -0
- package/skills/start-mission/README.md +7 -0
- package/skills/start-mission/SKILL.md +115 -0
- package/skills/suite-format-governance/README.md +75 -0
- package/skills/suite-format-governance/SKILL.md +299 -0
- package/skills/suite-format-governance/references/rubric.md +313 -0
- package/skills/touch-set-correction/README.md +16 -0
- package/skills/touch-set-correction/SKILL.md +67 -0
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +418 -0
- package/skills/verify-scenarios/README.md +17 -0
- package/skills/verify-scenarios/SKILL.md +109 -0
- 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`.
|