cyber-sdd 0.6.1 → 0.7.1
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 +1 -2
- package/.codex-plugin/plugin.json +1 -2
- package/agents/sdd-scanner.md +1 -1
- package/agents/sdd-spec-judge.md +3 -3
- package/agents/sdd-warden.md +8 -0
- package/package.json +11 -13
- package/plugin.json +55 -0
- package/skills/align-spec/README.md +3 -3
- package/skills/align-spec/SKILL.md +2 -2
- package/skills/align-spec/scripts/align-spec.mjs +6755 -0
- package/skills/architect-impl-governance/SKILL.md +3 -1
- package/skills/architect-spec-governance/SKILL.md +5 -1
- package/skills/blast-estimate/README.md +4 -4
- package/skills/blast-estimate/SKILL.md +1 -1
- package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -0
- package/skills/check-field-mandates/SKILL.md +1 -1
- package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -0
- package/skills/check-partition-quality/SKILL.md +1 -1
- package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -0
- package/skills/check-plan-safety/README.md +3 -3
- package/skills/check-plan-safety/SKILL.md +3 -4
- package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -0
- package/skills/check-plugin-manifests/SKILL.md +1 -1
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -0
- package/skills/check-project-specs/README.md +3 -3
- package/skills/check-project-specs/SKILL.md +3 -3
- package/{dist/check-project-specs/scripts/check-project-specs.js → skills/check-project-specs/scripts/check-project-specs.mjs} +300 -18
- package/skills/check-retired-terms/README.md +2 -2
- package/skills/check-retired-terms/SKILL.md +4 -5
- package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -0
- package/skills/check-scenario-overlap/README.md +2 -2
- package/skills/check-scenario-overlap/SKILL.md +2 -3
- package/{dist/check-scenario-overlap/scripts/check-scenario-overlap.js → skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs} +3 -1
- package/skills/check-spec-references/README.md +1 -1
- package/skills/check-spec-references/SKILL.md +3 -3
- package/{dist/check-spec-references/scripts/check-spec-references.js → skills/check-spec-references/scripts/check-spec-references.mjs} +3 -1
- package/skills/check-spec-structure/README.md +2 -2
- package/skills/check-spec-structure/SKILL.md +2 -3
- package/{dist/check-spec-structure/scripts/check-spec-structure.js → skills/check-spec-structure/scripts/check-spec-structure.mjs} +3 -1
- package/skills/collision-ladder/README.md +2 -2
- package/skills/collision-ladder/SKILL.md +2 -2
- package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -0
- package/skills/combat-log-governance/SKILL.md +1 -1
- package/skills/concept-index/README.md +2 -2
- package/skills/concept-index/SKILL.md +2 -2
- package/{dist/concept-index/scripts/concept-index.js → skills/concept-index/scripts/concept-index.mjs} +3 -1
- package/skills/discover-plans/README.md +5 -5
- package/skills/discover-plans/SKILL.md +2 -2
- package/skills/discover-plans/scripts/discover-plans.mjs +137 -0
- package/skills/discover-specs/README.md +4 -4
- package/skills/discover-specs/SKILL.md +2 -2
- package/{dist/discover-specs/scripts/discover-specs.js → skills/discover-specs/scripts/discover-specs.mjs} +4 -1
- package/skills/doctrine-loop/SKILL.md +2 -2
- package/skills/formation-loop/SKILL.md +10 -0
- package/skills/gate-validation-governance/SKILL.md +2 -2
- package/skills/init/README.md +5 -5
- package/skills/init/SKILL.md +3 -3
- package/skills/init/scripts/wire-statusline.mjs +168 -0
- package/skills/manage-ignore/README.md +8 -8
- package/skills/manage-ignore/SKILL.md +2 -3
- package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -0
- package/skills/manage-scenario-bridge/README.md +6 -6
- package/skills/manage-scenario-bridge/SKILL.md +2 -2
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -0
- package/skills/manage-spec-anchors/README.md +7 -7
- package/skills/manage-spec-anchors/SKILL.md +2 -2
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -0
- package/skills/mission-graph/README.md +2 -2
- package/skills/mission-graph/SKILL.md +10 -10
- package/skills/mission-graph/scripts/mission-graph.mjs +1040 -0
- package/skills/pause-mission/SKILL.md +2 -2
- package/skills/place-node/README.md +1 -1
- package/skills/place-node/SKILL.md +3 -3
- package/skills/place-node/scripts/place-node.mjs +117 -0
- package/skills/plan-retirement/README.md +2 -2
- package/skills/plan-retirement/SKILL.md +1 -1
- package/skills/plan-retirement/scripts/retire-plans.mjs +123 -0
- package/skills/resolve-governances/README.md +4 -4
- package/skills/resolve-governances/SKILL.md +2 -2
- package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -0
- package/skills/resolve-tracking/SKILL.md +2 -2
- package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -0
- package/skills/scaffold-project-spec/SKILL.md +3 -3
- package/skills/sdd/SKILL.md +1 -1
- package/skills/spec-gate/README.md +12 -9
- package/skills/spec-gate/SKILL.md +6 -6
- package/{dist/spec-gate/scripts/check-spec-state.js → skills/spec-gate/scripts/check-spec-state.mjs} +3 -1
- package/{dist/spec-gate/vendor/gherkin-cli.js → skills/spec-gate/scripts/check-suite.mjs} +319 -188
- package/skills/spec-gate/{vendor/gherkin-cli.mjs → scripts/classify-edit-class.mjs} +243 -209
- package/skills/spec-producer-governance/SKILL.md +4 -4
- package/skills/spec-structure-governance/README.md +4 -2
- package/skills/spec-structure-governance/SKILL.md +42 -7
- package/skills/start-mission/SKILL.md +2 -2
- package/skills/suite-format-governance/README.md +1 -1
- package/skills/suite-format-governance/SKILL.md +1 -1
- package/skills/touch-set-correction/README.md +5 -5
- package/skills/touch-set-correction/SKILL.md +2 -2
- package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -0
- package/skills/verify-scenarios/README.md +9 -9
- package/skills/verify-scenarios/SKILL.md +2 -2
- package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -0
- package/.plugin/pins.json +0 -3
- package/.plugin/plugin.json +0 -19
- package/dist/align-spec/scripts/align-spec.js +0 -102
- package/dist/spec-gate/scripts/check-suite.js +0 -317
- package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
- package/skills/align-spec/scripts/align-spec.mts +0 -188
- package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
- package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
- package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
- package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
- package/skills/check-project-specs/scripts/check-project-specs.mts +0 -369
- package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
- package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
- package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
- package/skills/collision-ladder/scripts/collision-ladder.mts +0 -660
- package/skills/concept-index/scripts/concept-index.mts +0 -246
- package/skills/discover-plans/scripts/discover-plans.mts +0 -215
- package/skills/discover-specs/scripts/discover-specs.mts +0 -420
- package/skills/init/scripts/wire-statusline.mts +0 -279
- package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
- package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
- package/skills/place-node/scripts/place-node.mts +0 -158
- package/skills/plan-retirement/scripts/retire-plans.mts +0 -199
- package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
- package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
- package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
- package/skills/spec-gate/scripts/check-suite.mts +0 -538
- package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
- package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -420
- package/skills/verify-scenarios/scripts/verify-scenarios.mts +0 -470
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// concept-index — project-spec/concept-index's concrete engine. Scans one project-spec for every node's
|
|
3
|
-
// `concept:` frontmatter and renders the by-concept view (concept → its nodes across every folder),
|
|
4
|
-
// which re-unifies a cross-cutting concern the capability folder tree scatters
|
|
5
|
-
// (the concept axis).
|
|
6
|
-
//
|
|
7
|
-
// Pure derivation from the `concept:` tags: rendering twice is byte-identical, and the generated
|
|
8
|
-
// block in the root spec.md is the only thing a --write touches. Frontmatter only — no node body
|
|
9
|
-
// reaches the output. No dependencies (the repo's node-≥23.6 / no-deps convention). Pure functions
|
|
10
|
-
// are exported for node:test; running the file directly drives the CLI.
|
|
11
|
-
|
|
12
|
-
import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
13
|
-
import { join, relative } from 'node:path'
|
|
14
|
-
import { pathToFileURL } from 'node:url'
|
|
15
|
-
|
|
16
|
-
export const BEGIN_MARKER = '<!-- BEGIN generated: by-concept (project-spec/concept-index) -->'
|
|
17
|
-
export const END_MARKER = '<!-- END generated: by-concept -->'
|
|
18
|
-
// Where the block is inserted when the markers are absent: just before this heading.
|
|
19
|
-
const ANCHOR_HEADING = '## Invariants'
|
|
20
|
-
|
|
21
|
-
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
|
|
22
|
-
|
|
23
|
-
export type FacetKind = 'rule' | 'workflow' | 'reference' | 'behavior' | 'index'
|
|
24
|
-
|
|
25
|
-
export interface NodeRecord {
|
|
26
|
-
/** Path relative to the spec directory (POSIX). */
|
|
27
|
-
relPath: string
|
|
28
|
-
/** Display form — a README.md node shows its folder, a design doc shows the file. */
|
|
29
|
-
display: string
|
|
30
|
-
concepts: string[]
|
|
31
|
-
facet: FacetKind
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface NodeFrontmatter {
|
|
35
|
-
concepts: string[]
|
|
36
|
-
specType?: string
|
|
37
|
-
model: boolean
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ── Frontmatter parse (a minimal YAML subset — only the node classification schema) ──
|
|
41
|
-
// Reads the leading `---` … `---` block for `concept` (scalar | flow list | block list),
|
|
42
|
-
// `spec-type`, and `model`. Returns null when there is no frontmatter block.
|
|
43
|
-
export function parseFrontmatter(text: string): NodeFrontmatter | null {
|
|
44
|
-
const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text)
|
|
45
|
-
if (!m) return null
|
|
46
|
-
const fm: NodeFrontmatter = { concepts: [], model: false }
|
|
47
|
-
const lines = m[1].split('\n').map((l) => l.replace(/\r$/, ''))
|
|
48
|
-
for (let i = 0; i < lines.length; i++) {
|
|
49
|
-
const line = lines[i]
|
|
50
|
-
if (line.trim() === '' || line.trim().startsWith('#')) continue
|
|
51
|
-
if (line.length - line.trimStart().length !== 0) continue // only top-level keys
|
|
52
|
-
const [key, ...rest] = line.trim().split(':')
|
|
53
|
-
const value = rest.join(':').trim()
|
|
54
|
-
if (key === 'spec-type') fm.specType = unquote(value)
|
|
55
|
-
else if (key === 'model') fm.model = unquote(value) === 'true'
|
|
56
|
-
else if (key === 'concept') {
|
|
57
|
-
if (value === '' || value === '|' || value === '>') {
|
|
58
|
-
// block list on following more-indented `- item` lines
|
|
59
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
60
|
-
const item = lines[j]
|
|
61
|
-
if (item.trim() === '') continue
|
|
62
|
-
if (item.length - item.trimStart().length === 0) break
|
|
63
|
-
const dash = /^\s*-\s+(.*)$/.exec(item)
|
|
64
|
-
if (dash) fm.concepts.push(unquote(dash[1].trim()))
|
|
65
|
-
}
|
|
66
|
-
} else {
|
|
67
|
-
fm.concepts.push(...parseScalarOrFlow(value))
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return fm
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// `resolution` | `[governance, resolution]` → string[]
|
|
75
|
-
export function parseScalarOrFlow(value: string): string[] {
|
|
76
|
-
const v = value.trim()
|
|
77
|
-
if (v.startsWith('[') && v.endsWith(']')) {
|
|
78
|
-
return v
|
|
79
|
-
.slice(1, -1)
|
|
80
|
-
.split(',')
|
|
81
|
-
.map((s) => unquote(s.trim()))
|
|
82
|
-
.filter((s) => s.length > 0)
|
|
83
|
-
}
|
|
84
|
-
const single = unquote(v)
|
|
85
|
-
return single.length > 0 ? [single] : []
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function unquote(v: string): string {
|
|
89
|
-
return v.replace(/^["']|["']$/g, '')
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── Facet kind — where this node's facet sits, mechanically ──
|
|
93
|
-
export function facetKind(relPath: string, specType: string | undefined): FacetKind {
|
|
94
|
-
const p = relPath.replace(/\\/g, '/')
|
|
95
|
-
if (p.startsWith('design/')) return 'rule'
|
|
96
|
-
if (p.startsWith('workflows/')) return 'workflow'
|
|
97
|
-
if (specType === 'reference') return 'reference'
|
|
98
|
-
if (specType === 'behavioral') return 'behavior'
|
|
99
|
-
return 'index'
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function displayPath(relPath: string): string {
|
|
103
|
-
const p = relPath.replace(/\\/g, '/')
|
|
104
|
-
return p.endsWith('/README.md') ? p.slice(0, -'README.md'.length) : p
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ── Scan the project-spec — every *.md node carrying a concept tag ──
|
|
108
|
-
export function scanProjectSpec(specDir: string): NodeRecord[] {
|
|
109
|
-
const records: NodeRecord[] = []
|
|
110
|
-
walk(specDir, specDir, records)
|
|
111
|
-
return records
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function walk(dir: string, specDir: string, out: NodeRecord[]): void {
|
|
115
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
116
|
-
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
|
|
117
|
-
const full = join(dir, entry.name)
|
|
118
|
-
if (entry.isDirectory()) {
|
|
119
|
-
walk(full, specDir, out)
|
|
120
|
-
} else if (entry.name.endsWith('.md')) {
|
|
121
|
-
const fm = parseFrontmatter(readFileSync(full, 'utf8'))
|
|
122
|
-
if (!fm || fm.concepts.length === 0) continue
|
|
123
|
-
const relPath = relative(specDir, full).replace(/\\/g, '/')
|
|
124
|
-
out.push({
|
|
125
|
-
relPath,
|
|
126
|
-
display: displayPath(relPath),
|
|
127
|
-
concepts: fm.concepts,
|
|
128
|
-
facet: facetKind(relPath, fm.specType),
|
|
129
|
-
})
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ── Group + render ──
|
|
135
|
-
export function groupByConcept(records: NodeRecord[]): Map<string, NodeRecord[]> {
|
|
136
|
-
const grouped = new Map<string, NodeRecord[]>()
|
|
137
|
-
for (const rec of records) {
|
|
138
|
-
for (const concept of rec.concepts) {
|
|
139
|
-
const list = grouped.get(concept) ?? []
|
|
140
|
-
list.push(rec)
|
|
141
|
-
grouped.set(concept, list)
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
// stable order: concepts alphabetical, nodes by display path
|
|
145
|
-
return new Map(
|
|
146
|
-
[...grouped.entries()]
|
|
147
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
148
|
-
.map(([k, v]) => [k, [...v].sort((x, y) => x.display.localeCompare(y.display))]),
|
|
149
|
-
)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function renderTable(grouped: Map<string, NodeRecord[]>): string {
|
|
153
|
-
const rows = [...grouped.entries()].map(([concept, nodes]) => {
|
|
154
|
-
const facets = nodes.map((n) => `\`${n.display}\` (${n.facet})`).join(' · ')
|
|
155
|
-
return `| \`${concept}\` | ${facets} |`
|
|
156
|
-
})
|
|
157
|
-
return ['| Concept | Facets |', '|---|---|', ...rows].join('\n')
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// The full generated section, markers included.
|
|
161
|
-
export function renderSection(grouped: Map<string, NodeRecord[]>): string {
|
|
162
|
-
return [
|
|
163
|
-
BEGIN_MARKER,
|
|
164
|
-
'',
|
|
165
|
-
'## By concept',
|
|
166
|
-
'',
|
|
167
|
-
'> Generated from `concept:` frontmatter by `project-spec/concept-index` — do not edit by hand.',
|
|
168
|
-
'',
|
|
169
|
-
renderTable(grouped),
|
|
170
|
-
'',
|
|
171
|
-
END_MARKER,
|
|
172
|
-
].join('\n')
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ── spec.md block maintenance ──
|
|
176
|
-
// Replace the BEGIN…END block with `section`; insert at the anchor (or append) when absent.
|
|
177
|
-
export function applySection(specText: string, section: string): string {
|
|
178
|
-
const begin = specText.indexOf(BEGIN_MARKER)
|
|
179
|
-
const end = specText.indexOf(END_MARKER)
|
|
180
|
-
if (begin !== -1 && end !== -1 && end > begin) {
|
|
181
|
-
const before = specText.slice(0, begin)
|
|
182
|
-
const after = specText.slice(end + END_MARKER.length)
|
|
183
|
-
return before + section + after
|
|
184
|
-
}
|
|
185
|
-
const anchor = specText.indexOf(ANCHOR_HEADING)
|
|
186
|
-
if (anchor !== -1) {
|
|
187
|
-
return specText.slice(0, anchor) + section + '\n\n' + specText.slice(anchor)
|
|
188
|
-
}
|
|
189
|
-
const trimmed = specText.replace(/\s*$/, '')
|
|
190
|
-
return `${trimmed}\n\n${section}\n`
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Extract the current block (BEGIN…END inclusive), or null when absent.
|
|
194
|
-
export function extractSection(specText: string): string | null {
|
|
195
|
-
const begin = specText.indexOf(BEGIN_MARKER)
|
|
196
|
-
const end = specText.indexOf(END_MARKER)
|
|
197
|
-
if (begin === -1 || end === -1 || end < begin) return null
|
|
198
|
-
return specText.slice(begin, end + END_MARKER.length)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ── CLI ──
|
|
202
|
-
function parseArgs(argv: string[]): { specDir: string; mode: 'print' | 'write' | 'check' } {
|
|
203
|
-
let specDir = '.'
|
|
204
|
-
let mode: 'print' | 'write' | 'check' = 'print'
|
|
205
|
-
for (let i = 0; i < argv.length; i++) {
|
|
206
|
-
const a = argv[i]
|
|
207
|
-
if (a === '--spec-dir') specDir = argv[++i] ?? '.'
|
|
208
|
-
else if (a === '--write') mode = 'write'
|
|
209
|
-
else if (a === '--check') mode = 'check'
|
|
210
|
-
}
|
|
211
|
-
return { specDir, mode }
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export function main(argv: string[]): number {
|
|
215
|
-
const { specDir, mode } = parseArgs(argv)
|
|
216
|
-
const grouped = groupByConcept(scanProjectSpec(specDir))
|
|
217
|
-
const section = renderSection(grouped)
|
|
218
|
-
const specPath = join(specDir, 'spec.md')
|
|
219
|
-
if (mode === 'print') {
|
|
220
|
-
process.stdout.write(`${section}\n`)
|
|
221
|
-
return 0
|
|
222
|
-
}
|
|
223
|
-
if (!existsSync(specPath)) {
|
|
224
|
-
process.stderr.write(`concept-index: no spec.md at ${specPath}\n`)
|
|
225
|
-
return 2
|
|
226
|
-
}
|
|
227
|
-
const specText = readFileSync(specPath, 'utf8')
|
|
228
|
-
if (mode === 'check') {
|
|
229
|
-
const current = extractSection(specText)
|
|
230
|
-
if (current === section) {
|
|
231
|
-
process.stdout.write('concept-index: no drift\n')
|
|
232
|
-
return 0
|
|
233
|
-
}
|
|
234
|
-
process.stderr.write('concept-index: drift — spec.md by-concept block is stale; run --write\n')
|
|
235
|
-
return 1
|
|
236
|
-
}
|
|
237
|
-
// write
|
|
238
|
-
const next = applySection(specText, section)
|
|
239
|
-
if (next !== specText) writeFileSync(specPath, next)
|
|
240
|
-
process.stdout.write(`concept-index: ${next === specText ? 'unchanged' : 'updated'} ${specPath}\n`)
|
|
241
|
-
return 0
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
245
|
-
process.exit(main(process.argv.slice(2)))
|
|
246
|
-
}
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// discover-plans — intake/plan-discovery's concrete frontmatter engine. Scans the SDD
|
|
3
|
-
// plan directory (.agents/plans) for *.plan.md mission briefs, parses each plan's
|
|
4
|
-
// frontmatter (name + the todos tally by status) plus the lead line of its `## NEXT`
|
|
5
|
-
// resume anchor, and emits a TOON list of the unretired / resumable missions.
|
|
6
|
-
//
|
|
7
|
-
// Recognition is location-bounded AND shape-confirmed:
|
|
8
|
-
// - a file named `<cr-ref>.plan.md` sitting directly under <root>/.agents/plans/ is a
|
|
9
|
-
// mission brief. A present plan brief is by definition UNRETIRED — the doctrine loop's
|
|
10
|
-
// plan-retirement deletes a plan once its CR is done/merged AND distilled — so every
|
|
11
|
-
// plan the scan finds is a resumable mission.
|
|
12
|
-
// - shape: the file must carry a frontmatter block (the basic plan template intake
|
|
13
|
-
// scaffolds). A `*.plan.md` with no frontmatter is skipped (a stray, not a brief).
|
|
14
|
-
// Sibling files in the same directory that are NOT `*.plan.md` (a combat log `*.log.jsonl`,
|
|
15
|
-
// a loose `*.md`) are never plan briefs and are ignored.
|
|
16
|
-
//
|
|
17
|
-
// Pure functions are exported for node:test; running the file directly drives the CLI.
|
|
18
|
-
// No dependencies (the repo's node-≥23.6 / no-deps convention). --format json for a flat
|
|
19
|
-
// array; default output is TOON (the token-efficient tabular form the gateway scans).
|
|
20
|
-
|
|
21
|
-
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
22
|
-
import { join } from 'node:path'
|
|
23
|
-
import { pathToFileURL } from 'node:url'
|
|
24
|
-
|
|
25
|
-
export const TODO_STATUSES = new Set(['pending', 'in_progress', 'completed'])
|
|
26
|
-
|
|
27
|
-
/** The default mission dispatch status — the meaning of an absent top-level `status`. */
|
|
28
|
-
export const DEFAULT_PLAN_STATUS = 'active'
|
|
29
|
-
|
|
30
|
-
export interface PlanRecord {
|
|
31
|
-
/** The CR ref — the plan's filename without the `.plan.md` suffix. */
|
|
32
|
-
cr: string
|
|
33
|
-
/** Frontmatter `name`, or '' when absent. */
|
|
34
|
-
name: string
|
|
35
|
-
/** Total todos in the brief. */
|
|
36
|
-
total: number
|
|
37
|
-
/** Todos with status `completed`. */
|
|
38
|
-
completed: number
|
|
39
|
-
/** Todos with status `in_progress`. */
|
|
40
|
-
inProgress: number
|
|
41
|
-
/** The mission dispatch flag — the top-level `status` (`active` when unset). */
|
|
42
|
-
status: string
|
|
43
|
-
/** The lead line of the `## NEXT` resume anchor, or '' when there is none. */
|
|
44
|
-
next: string
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface PlanFrontmatter {
|
|
48
|
-
name: string
|
|
49
|
-
/** The mission dispatch flag — the top-level `status` (`active` when unset). */
|
|
50
|
-
status: string
|
|
51
|
-
total: number
|
|
52
|
-
completed: number
|
|
53
|
-
inProgress: number
|
|
54
|
-
pending: number
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// ── Frontmatter parse (a minimal YAML subset — only the plan-brief schema) ──
|
|
58
|
-
// Extracts the leading `---` … `---` block and reads `name` plus the `todos:` list's tally
|
|
59
|
-
// by `status`. Returns null when there is no frontmatter block at all (a stray file).
|
|
60
|
-
export function parsePlanFrontmatter(text: string): PlanFrontmatter | null {
|
|
61
|
-
const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text)
|
|
62
|
-
if (!m) return null
|
|
63
|
-
const fm: PlanFrontmatter = {
|
|
64
|
-
name: '',
|
|
65
|
-
status: DEFAULT_PLAN_STATUS,
|
|
66
|
-
total: 0,
|
|
67
|
-
completed: 0,
|
|
68
|
-
inProgress: 0,
|
|
69
|
-
pending: 0,
|
|
70
|
-
}
|
|
71
|
-
let inTodos = false // currently inside the top-level `todos:` block
|
|
72
|
-
for (const raw of m[1].split('\n')) {
|
|
73
|
-
const line = raw.replace(/\r$/, '')
|
|
74
|
-
if (line.trim() === '' || line.trim().startsWith('#')) continue
|
|
75
|
-
const indent = line.length - line.trimStart().length
|
|
76
|
-
const trimmed = line.trim()
|
|
77
|
-
if (indent === 0) {
|
|
78
|
-
inTodos = false
|
|
79
|
-
const [key, ...rest] = trimmed.split(':')
|
|
80
|
-
const value = rest.join(':').trim()
|
|
81
|
-
if (key === 'name') fm.name = unquote(value)
|
|
82
|
-
// The top-level `status` is the plan's dispatch flag (distinct from a todo's
|
|
83
|
-
// `status`, which sits indented inside `todos:`). An empty value stays the default.
|
|
84
|
-
else if (key === 'status') {
|
|
85
|
-
const v = unquote(value)
|
|
86
|
-
if (v !== '') fm.status = v
|
|
87
|
-
} else if (key === 'todos') inTodos = true // value is empty; the list follows, indented
|
|
88
|
-
continue
|
|
89
|
-
}
|
|
90
|
-
// Inside the todos list — count each item's status. A list item's keys sit at
|
|
91
|
-
// indent ≥ 2 (`- id:` / `status:`); the status line is `status: <value>`.
|
|
92
|
-
if (inTodos) {
|
|
93
|
-
const sm = /^(?:-\s+)?status:\s*(.+)$/.exec(trimmed)
|
|
94
|
-
if (sm) {
|
|
95
|
-
const status = unquote(sm[1].trim())
|
|
96
|
-
fm.total++
|
|
97
|
-
if (status === 'completed') fm.completed++
|
|
98
|
-
else if (status === 'in_progress') fm.inProgress++
|
|
99
|
-
else if (status === 'pending') fm.pending++
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return fm
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function unquote(v: string): string {
|
|
107
|
-
return v.replace(/^["']|["']$/g, '')
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ── The `## NEXT` resume anchor lead ──
|
|
111
|
-
// Find the `## NEXT` section (the resume anchor pause-mission writes) and return its first
|
|
112
|
-
// content line — the next concrete action — trimmed of markdown bullet/emphasis markers and
|
|
113
|
-
// capped. Returns '' when there is no NEXT section or it is empty. Body read is bounded to
|
|
114
|
-
// this one section; the rest of the body is never parsed.
|
|
115
|
-
export function nextLead(text: string): string {
|
|
116
|
-
const lines = text.split('\n')
|
|
117
|
-
let i = lines.findIndex((l) => /^##\s+NEXT\b/i.test(l.trim()))
|
|
118
|
-
if (i === -1) return ''
|
|
119
|
-
for (i += 1; i < lines.length; i++) {
|
|
120
|
-
const l = lines[i].replace(/\r$/, '').trim()
|
|
121
|
-
if (l === '') continue
|
|
122
|
-
if (l.startsWith('#')) return '' // hit the next heading with no content between
|
|
123
|
-
const cleaned = l
|
|
124
|
-
.replace(/^[-*]\s+/, '')
|
|
125
|
-
.replace(/\*\*/g, '')
|
|
126
|
-
.trim()
|
|
127
|
-
return cleaned.length > 200 ? `${cleaned.slice(0, 197)}...` : cleaned
|
|
128
|
-
}
|
|
129
|
-
return ''
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ── Scan ──
|
|
133
|
-
// List the plan-brief filenames (relative names) directly under <root>/.agents/plans. Only
|
|
134
|
-
// files ending `.plan.md` are briefs; a missing plans dir yields the empty set.
|
|
135
|
-
function discoverPlanFiles(root: string): string[] {
|
|
136
|
-
const dir = join(root, '.agents', 'plans')
|
|
137
|
-
if (!existsSync(dir)) return []
|
|
138
|
-
let entries: import('node:fs').Dirent[]
|
|
139
|
-
try {
|
|
140
|
-
entries = readdirSync(dir, { withFileTypes: true })
|
|
141
|
-
} catch {
|
|
142
|
-
return []
|
|
143
|
-
}
|
|
144
|
-
return entries
|
|
145
|
-
.filter((e) => e.isFile() && e.name.endsWith('.plan.md'))
|
|
146
|
-
.map((e) => e.name)
|
|
147
|
-
.sort()
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── Collect ──
|
|
151
|
-
// The list of resumable missions: every plan brief carrying a frontmatter block, keyed by
|
|
152
|
-
// its CR ref, with its todo tally and NEXT lead. Sorted by cr ref for stable output.
|
|
153
|
-
export function collectPlans(root: string): PlanRecord[] {
|
|
154
|
-
const out: PlanRecord[] = []
|
|
155
|
-
for (const name of discoverPlanFiles(root)) {
|
|
156
|
-
let text: string
|
|
157
|
-
try {
|
|
158
|
-
text = readFileSync(join(root, '.agents', 'plans', name), 'utf8')
|
|
159
|
-
} catch {
|
|
160
|
-
continue
|
|
161
|
-
}
|
|
162
|
-
const fm = parsePlanFrontmatter(text)
|
|
163
|
-
if (!fm) continue // no frontmatter — a stray, not a brief
|
|
164
|
-
out.push({
|
|
165
|
-
cr: name.replace(/\.plan\.md$/, ''),
|
|
166
|
-
name: fm.name,
|
|
167
|
-
total: fm.total,
|
|
168
|
-
completed: fm.completed,
|
|
169
|
-
inProgress: fm.inProgress,
|
|
170
|
-
status: fm.status,
|
|
171
|
-
next: nextLead(text),
|
|
172
|
-
})
|
|
173
|
-
}
|
|
174
|
-
return out.sort((a, b) => (a.cr < b.cr ? -1 : a.cr > b.cr ? 1 : 0))
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ── Filter ──
|
|
178
|
-
// Narrow a plan set to one dispatch status — the opt-in selector the gateway's dispatch loop
|
|
179
|
-
// uses to build the approved queue. Records already carry a concrete `status` (unset → the
|
|
180
|
-
// default `active`), so a filter to `active` includes the unset briefs and a value no brief
|
|
181
|
-
// carries (an off-enum or simply-absent status) yields the empty set.
|
|
182
|
-
export function filterByStatus(plans: PlanRecord[], status: string): PlanRecord[] {
|
|
183
|
-
return plans.filter((p) => p.status === status)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// ── Output ──
|
|
187
|
-
const COLUMNS = ['cr', 'name', 'total', 'completed', 'inProgress', 'status', 'next'] as const
|
|
188
|
-
|
|
189
|
-
// Quote a TOON field only when it carries the delimiter, a quote, or edge whitespace.
|
|
190
|
-
function toonField(v: string): string {
|
|
191
|
-
if (v === '' || /[",]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
|
|
192
|
-
return v
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function toToon(plans: PlanRecord[]): string {
|
|
196
|
-
const header = `plans[${plans.length}]{${COLUMNS.join(',')}}:`
|
|
197
|
-
const rows = plans.map((p) => ` ${COLUMNS.map((c) => toonField(String(p[c]))).join(',')}`)
|
|
198
|
-
return [header, ...rows].join('\n')
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function main(argv: string[]): number {
|
|
202
|
-
const root = argv.includes('--root') ? (argv[argv.indexOf('--root') + 1] ?? '.') : '.'
|
|
203
|
-
const format = argv.includes('--format') ? argv[argv.indexOf('--format') + 1] : 'toon'
|
|
204
|
-
const statusFilter = argv.includes('--status') ? argv[argv.indexOf('--status') + 1] : undefined
|
|
205
|
-
let plans = collectPlans(root)
|
|
206
|
-
// Opt-in: `--status <value>` narrows to the dispatch queue; absent, no status filter is applied.
|
|
207
|
-
if (statusFilter !== undefined) plans = filterByStatus(plans, statusFilter)
|
|
208
|
-
const out = format === 'json' ? JSON.stringify(plans, null, 2) : toToon(plans)
|
|
209
|
-
process.stdout.write(`${out}\n`)
|
|
210
|
-
return 0
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
214
|
-
process.exit(main(process.argv.slice(2)))
|
|
215
|
-
}
|