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.
Files changed (136) hide show
  1. package/.claude-plugin/plugin.json +1 -2
  2. package/.codex-plugin/plugin.json +1 -2
  3. package/agents/sdd-scanner.md +1 -1
  4. package/agents/sdd-spec-judge.md +3 -3
  5. package/agents/sdd-warden.md +8 -0
  6. package/package.json +11 -13
  7. package/plugin.json +55 -0
  8. package/skills/align-spec/README.md +3 -3
  9. package/skills/align-spec/SKILL.md +2 -2
  10. package/skills/align-spec/scripts/align-spec.mjs +6755 -0
  11. package/skills/architect-impl-governance/SKILL.md +3 -1
  12. package/skills/architect-spec-governance/SKILL.md +5 -1
  13. package/skills/blast-estimate/README.md +4 -4
  14. package/skills/blast-estimate/SKILL.md +1 -1
  15. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -0
  16. package/skills/check-field-mandates/SKILL.md +1 -1
  17. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -0
  18. package/skills/check-partition-quality/SKILL.md +1 -1
  19. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -0
  20. package/skills/check-plan-safety/README.md +3 -3
  21. package/skills/check-plan-safety/SKILL.md +3 -4
  22. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -0
  23. package/skills/check-plugin-manifests/SKILL.md +1 -1
  24. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -0
  25. package/skills/check-project-specs/README.md +3 -3
  26. package/skills/check-project-specs/SKILL.md +3 -3
  27. package/{dist/check-project-specs/scripts/check-project-specs.js → skills/check-project-specs/scripts/check-project-specs.mjs} +300 -18
  28. package/skills/check-retired-terms/README.md +2 -2
  29. package/skills/check-retired-terms/SKILL.md +4 -5
  30. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -0
  31. package/skills/check-scenario-overlap/README.md +2 -2
  32. package/skills/check-scenario-overlap/SKILL.md +2 -3
  33. package/{dist/check-scenario-overlap/scripts/check-scenario-overlap.js → skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs} +3 -1
  34. package/skills/check-spec-references/README.md +1 -1
  35. package/skills/check-spec-references/SKILL.md +3 -3
  36. package/{dist/check-spec-references/scripts/check-spec-references.js → skills/check-spec-references/scripts/check-spec-references.mjs} +3 -1
  37. package/skills/check-spec-structure/README.md +2 -2
  38. package/skills/check-spec-structure/SKILL.md +2 -3
  39. package/{dist/check-spec-structure/scripts/check-spec-structure.js → skills/check-spec-structure/scripts/check-spec-structure.mjs} +3 -1
  40. package/skills/collision-ladder/README.md +2 -2
  41. package/skills/collision-ladder/SKILL.md +2 -2
  42. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -0
  43. package/skills/combat-log-governance/SKILL.md +1 -1
  44. package/skills/concept-index/README.md +2 -2
  45. package/skills/concept-index/SKILL.md +2 -2
  46. package/{dist/concept-index/scripts/concept-index.js → skills/concept-index/scripts/concept-index.mjs} +3 -1
  47. package/skills/discover-plans/README.md +5 -5
  48. package/skills/discover-plans/SKILL.md +2 -2
  49. package/skills/discover-plans/scripts/discover-plans.mjs +137 -0
  50. package/skills/discover-specs/README.md +4 -4
  51. package/skills/discover-specs/SKILL.md +2 -2
  52. package/{dist/discover-specs/scripts/discover-specs.js → skills/discover-specs/scripts/discover-specs.mjs} +4 -1
  53. package/skills/doctrine-loop/SKILL.md +2 -2
  54. package/skills/formation-loop/SKILL.md +10 -0
  55. package/skills/gate-validation-governance/SKILL.md +2 -2
  56. package/skills/init/README.md +5 -5
  57. package/skills/init/SKILL.md +3 -3
  58. package/skills/init/scripts/wire-statusline.mjs +168 -0
  59. package/skills/manage-ignore/README.md +8 -8
  60. package/skills/manage-ignore/SKILL.md +2 -3
  61. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -0
  62. package/skills/manage-scenario-bridge/README.md +6 -6
  63. package/skills/manage-scenario-bridge/SKILL.md +2 -2
  64. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -0
  65. package/skills/manage-spec-anchors/README.md +7 -7
  66. package/skills/manage-spec-anchors/SKILL.md +2 -2
  67. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -0
  68. package/skills/mission-graph/README.md +2 -2
  69. package/skills/mission-graph/SKILL.md +10 -10
  70. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -0
  71. package/skills/pause-mission/SKILL.md +2 -2
  72. package/skills/place-node/README.md +1 -1
  73. package/skills/place-node/SKILL.md +3 -3
  74. package/skills/place-node/scripts/place-node.mjs +117 -0
  75. package/skills/plan-retirement/README.md +2 -2
  76. package/skills/plan-retirement/SKILL.md +1 -1
  77. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -0
  78. package/skills/resolve-governances/README.md +4 -4
  79. package/skills/resolve-governances/SKILL.md +2 -2
  80. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -0
  81. package/skills/resolve-tracking/SKILL.md +2 -2
  82. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -0
  83. package/skills/scaffold-project-spec/SKILL.md +3 -3
  84. package/skills/sdd/SKILL.md +1 -1
  85. package/skills/spec-gate/README.md +12 -9
  86. package/skills/spec-gate/SKILL.md +6 -6
  87. package/{dist/spec-gate/scripts/check-spec-state.js → skills/spec-gate/scripts/check-spec-state.mjs} +3 -1
  88. package/{dist/spec-gate/vendor/gherkin-cli.js → skills/spec-gate/scripts/check-suite.mjs} +319 -188
  89. package/skills/spec-gate/{vendor/gherkin-cli.mjs → scripts/classify-edit-class.mjs} +243 -209
  90. package/skills/spec-producer-governance/SKILL.md +4 -4
  91. package/skills/spec-structure-governance/README.md +4 -2
  92. package/skills/spec-structure-governance/SKILL.md +42 -7
  93. package/skills/start-mission/SKILL.md +2 -2
  94. package/skills/suite-format-governance/README.md +1 -1
  95. package/skills/suite-format-governance/SKILL.md +1 -1
  96. package/skills/touch-set-correction/README.md +5 -5
  97. package/skills/touch-set-correction/SKILL.md +2 -2
  98. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -0
  99. package/skills/verify-scenarios/README.md +9 -9
  100. package/skills/verify-scenarios/SKILL.md +2 -2
  101. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -0
  102. package/.plugin/pins.json +0 -3
  103. package/.plugin/plugin.json +0 -19
  104. package/dist/align-spec/scripts/align-spec.js +0 -102
  105. package/dist/spec-gate/scripts/check-suite.js +0 -317
  106. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  107. package/skills/align-spec/scripts/align-spec.mts +0 -188
  108. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  109. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  110. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  111. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  112. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  113. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -369
  114. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  115. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  116. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  117. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  118. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -660
  119. package/skills/concept-index/scripts/concept-index.mts +0 -246
  120. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  121. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  122. package/skills/init/scripts/wire-statusline.mts +0 -279
  123. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  124. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  125. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  126. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  127. package/skills/place-node/scripts/place-node.mts +0 -158
  128. package/skills/plan-retirement/scripts/retire-plans.mts +0 -199
  129. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  130. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  131. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  132. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  133. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  134. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  135. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -420
  136. 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
- }