cyber-sdd 0.7.0 → 0.7.2

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 (150) hide show
  1. package/.claude-plugin/plugin.json +1 -2
  2. package/.codex-plugin/plugin.json +1 -2
  3. package/CHANGELOG.md +320 -0
  4. package/agents/sdd-scanner.md +1 -1
  5. package/package.json +11 -12
  6. package/plugin.json +55 -0
  7. package/skills/align-spec/SKILL.md +1 -1
  8. package/skills/align-spec/scripts/align-spec.mjs +6755 -12
  9. package/skills/blast-estimate/README.md +3 -3
  10. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
  11. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
  12. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
  13. package/skills/check-plan-safety/SKILL.md +2 -3
  14. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
  15. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
  16. package/skills/check-project-specs/SKILL.md +1 -1
  17. package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
  18. package/skills/check-retired-terms/SKILL.md +2 -3
  19. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
  20. package/skills/check-scenario-overlap/SKILL.md +1 -2
  21. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
  22. package/skills/check-spec-references/SKILL.md +2 -2
  23. package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
  24. package/skills/check-spec-structure/SKILL.md +1 -2
  25. package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
  26. package/skills/collision-ladder/README.md +2 -2
  27. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
  28. package/skills/combat-log-governance/SKILL.md +1 -1
  29. package/skills/concept-index/SKILL.md +1 -1
  30. package/skills/concept-index/scripts/concept-index.mjs +183 -12
  31. package/skills/discover-plans/README.md +3 -3
  32. package/skills/discover-plans/SKILL.md +1 -1
  33. package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
  34. package/skills/discover-specs/README.md +3 -3
  35. package/skills/discover-specs/SKILL.md +1 -1
  36. package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
  37. package/skills/doctrine-loop/SKILL.md +1 -1
  38. package/skills/gate-validation-governance/SKILL.md +1 -1
  39. package/skills/init/README.md +2 -2
  40. package/skills/init/scripts/wire-statusline.mjs +168 -12
  41. package/skills/manage-ignore/README.md +3 -3
  42. package/skills/manage-ignore/SKILL.md +1 -2
  43. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
  44. package/skills/manage-scenario-bridge/README.md +3 -3
  45. package/skills/manage-scenario-bridge/SKILL.md +1 -1
  46. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
  47. package/skills/manage-spec-anchors/README.md +3 -3
  48. package/skills/manage-spec-anchors/SKILL.md +1 -1
  49. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
  50. package/skills/mission-graph/README.md +2 -2
  51. package/skills/mission-graph/SKILL.md +1 -1
  52. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
  53. package/skills/pause-mission/SKILL.md +1 -1
  54. package/skills/place-node/SKILL.md +2 -2
  55. package/skills/place-node/scripts/place-node.mjs +117 -12
  56. package/skills/plan-retirement/README.md +2 -2
  57. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
  58. package/skills/resolve-governances/README.md +3 -3
  59. package/skills/resolve-governances/SKILL.md +1 -1
  60. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
  61. package/skills/resolve-tracking/SKILL.md +1 -1
  62. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
  63. package/skills/scaffold-project-spec/SKILL.md +1 -1
  64. package/skills/spec-gate/README.md +12 -9
  65. package/skills/spec-gate/SKILL.md +2 -2
  66. package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
  67. package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
  68. package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
  69. package/skills/spec-producer-governance/SKILL.md +2 -2
  70. package/skills/start-mission/SKILL.md +1 -1
  71. package/skills/suite-format-governance/README.md +1 -1
  72. package/skills/suite-format-governance/SKILL.md +1 -1
  73. package/skills/touch-set-correction/README.md +5 -5
  74. package/skills/touch-set-correction/SKILL.md +1 -1
  75. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
  76. package/skills/verify-scenarios/README.md +6 -6
  77. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
  78. package/.plugin/pins.json +0 -3
  79. package/.plugin/plugin.json +0 -19
  80. package/dist/align-spec/scripts/align-spec.js +0 -102
  81. package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
  82. package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
  83. package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
  84. package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
  85. package/dist/concept-index/scripts/concept-index.js +0 -181
  86. package/dist/discover-specs/scripts/discover-specs.js +0 -318
  87. package/dist/spec-gate/scripts/check-spec-state.js +0 -420
  88. package/dist/spec-gate/scripts/check-suite.js +0 -317
  89. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  90. package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
  91. package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
  92. package/skills/align-spec/scripts/align-spec.mts +0 -188
  93. package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
  94. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  95. package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
  96. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  97. package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
  98. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  99. package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
  100. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  101. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
  102. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  103. package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
  104. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
  105. package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
  106. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  107. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
  108. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  109. package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
  110. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  111. package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
  112. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  113. package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
  114. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
  115. package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
  116. package/skills/concept-index/scripts/concept-index.mts +0 -246
  117. package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
  118. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  119. package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
  120. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  121. package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
  122. package/skills/init/scripts/wire-statusline.mts +0 -279
  123. package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
  124. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  125. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
  126. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  127. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
  128. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  129. package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
  130. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  131. package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
  132. package/skills/place-node/scripts/place-node.mts +0 -158
  133. package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
  134. package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
  135. package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
  136. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  137. package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
  138. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  139. package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
  140. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  141. package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
  142. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  143. package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
  144. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  145. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  146. package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
  147. package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
  148. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
  149. package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
  150. package/skills/verify-scenarios/scripts/verify-scenarios.mts +0 -470
@@ -1,1333 +0,0 @@
1
- #!/usr/bin/env node
2
- // mission-graph — Op1.M1's concrete engine. A git-tracked, append-only event log (nodes, edges,
3
- // status changes, tombstones — schema v:1) plus a zero-dependency fold into two read-only views:
4
- // `ready` (the RAW-satisfied, not-WAW-held frontier) and `cycles` (the SCC-quarantine repair
5
- // view), plus the Operation deliverability check (`checkOperation`).
6
- //
7
- // Architecture — pure derivations are kept separate from I/O, on purpose:
8
- // - fold / ready / cycles / checkOperation / proposeEdge and their helpers are PURE: they take
9
- // plain data (an event list, or an already-folded Graph) and return plain data. No fs access.
10
- // Tests exercise these directly over CONSTRUCTED fixtures — never the live store, which
11
- // mutates on every retirement (that would make pinned assertions flaky).
12
- // - readEvents / appendEvent / appendEdgeChecked are the thin store-IO SEAM: v1 reads/writes an
13
- // in-tree JSONL file. A later swap (to a shared, branch-independent store) only touches this
14
- // seam — the pure functions above never change.
15
- // - main() is a thin CLI: argv -> ready | cycles | operation | append(...), rendering TOON by
16
- // default (the token-efficient tabular form other sdd engines emit) or `--format json`.
17
- //
18
- // No dependencies (the repo's node-≥23.6 / no-deps convention). Pure functions are exported for
19
- // node:test; running the file directly drives the CLI.
20
-
21
- import { execFileSync } from 'node:child_process'
22
- import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'
23
- import { dirname, join } from 'node:path'
24
- import { pathToFileURL } from 'node:url'
25
-
26
- // ── Schema (v:1) ──
27
-
28
- export type NodeKind = 'mission' | 'operation'
29
- export type NodeStatus = 'open' | 'claimed' | 'retired'
30
- export type HitlOrAfk = 'hitl' | 'afk'
31
- export type EdgeKind = 'RAW' | 'parent-child' | 'discovered-from'
32
-
33
- /**
34
- * A node event: an add, or a partial update, for one node id. Later events sharing an id are
35
- * shallow-merged onto earlier ones (only the fields a later event actually carries overwrite —
36
- * this is how a bare status change folds without repeating the rest of the node). Every field
37
- * beyond `v`/`type`/`id` is optional so the write path can emit a lean status-only event.
38
- */
39
- export interface NodeEvent {
40
- v: 1
41
- type: 'node'
42
- id: string
43
- kind?: NodeKind
44
- status?: NodeStatus
45
- touchSet?: string[]
46
- blast?: string
47
- hitlOrAfk?: HitlOrAfk
48
- modelTier?: string
49
- briefPointer?: string
50
- /** The originating request(s), kept for the record (README "Non-goals" — never acted on). */
51
- origin?: string[]
52
- /** Set only on an Operation node: the Mission that marks it "done enough to ship". */
53
- capstone?: string
54
- /** Which project the node belongs to. Absent -> the default project (a real project, the
55
- * sentinel `DEFAULT_PROJECT` — NOT a wildcard). */
56
- project?: string
57
- /** Marks the node a barrier — a project-wide mission the fleet must rebase onto before fanning
58
- * out again. Only a Mission may ever be a barrier (write-time INV-1). */
59
- barrier?: boolean
60
- }
61
-
62
- export interface EdgeEvent {
63
- v: 1
64
- type: 'edge'
65
- kind: EdgeKind
66
- from: string
67
- to: string
68
- }
69
-
70
- /**
71
- * A tombstone retracts one prior node or edge from the fold. An edge tombstone must name the
72
- * edge's kind — a (from, to) pair can carry more than one edge kind at once.
73
- */
74
- export interface TombstoneEvent {
75
- v: 1
76
- type: 'tombstone'
77
- target: 'node' | 'edge'
78
- id?: string
79
- kind?: EdgeKind
80
- from?: string
81
- to?: string
82
- }
83
-
84
- export type MissionGraphEvent = NodeEvent | EdgeEvent | TombstoneEvent
85
-
86
- // ── The folded graph ──
87
-
88
- export interface GraphNode {
89
- id: string
90
- kind: NodeKind
91
- status: NodeStatus
92
- touchSet: string[]
93
- blast: string
94
- hitlOrAfk: HitlOrAfk
95
- modelTier: string
96
- briefPointer: string
97
- origin: string[]
98
- capstone?: string
99
- project: string
100
- barrier: boolean
101
- /** The schema version stamped on the node's latest event (forward-compat placeholder — v1
102
- * only exists today; a later version arrives on newer events and rides along unread). */
103
- schemaVersion: number
104
- }
105
-
106
- export interface GraphEdge {
107
- kind: EdgeKind
108
- from: string
109
- to: string
110
- }
111
-
112
- export interface Graph {
113
- nodes: Map<string, GraphNode>
114
- edges: GraphEdge[]
115
- }
116
-
117
- /** The shared default project — a REAL project every project-less node belongs to together, not a
118
- * wildcard that matches every project. */
119
- export const DEFAULT_PROJECT = ''
120
-
121
- const NODE_DEFAULTS = {
122
- kind: 'mission' as NodeKind,
123
- status: 'open' as NodeStatus,
124
- blast: 'unknown',
125
- hitlOrAfk: 'hitl' as HitlOrAfk,
126
- modelTier: 'unspecified',
127
- briefPointer: '',
128
- project: DEFAULT_PROJECT,
129
- barrier: false,
130
- }
131
-
132
- function edgeKey(kind: EdgeKind, from: string, to: string): string {
133
- return JSON.stringify([kind, from, to])
134
- }
135
-
136
- /**
137
- * fold — read the whole append-only event log and work out the current picture: the latest
138
- * fields of each node win, a tombstone drops its target (node or edge) from the result, and a
139
- * field the reader doesn't recognize rides along unread — no known derivation looks at it, so a
140
- * later additive schema field never needs a migration or breaks the fold.
141
- */
142
- export function fold(events: readonly MissionGraphEvent[]): Graph {
143
- const nodeFields = new Map<string, Record<string, unknown>>()
144
- const tombstonedNodes = new Set<string>()
145
- const edges = new Map<string, GraphEdge>()
146
-
147
- for (const ev of events) {
148
- if (ev.type === 'node') {
149
- const prev = nodeFields.get(ev.id) ?? {}
150
- const merged: Record<string, unknown> = { ...prev }
151
- for (const [key, value] of Object.entries(ev)) {
152
- if (key === 'type' || value === undefined) continue
153
- merged[key] = value
154
- }
155
- nodeFields.set(ev.id, merged)
156
- } else if (ev.type === 'edge') {
157
- edges.set(edgeKey(ev.kind, ev.from, ev.to), { kind: ev.kind, from: ev.from, to: ev.to })
158
- } else if (ev.type === 'tombstone') {
159
- if (ev.target === 'node' && ev.id !== undefined) {
160
- tombstonedNodes.add(ev.id)
161
- } else if (ev.target === 'edge' && ev.kind !== undefined && ev.from !== undefined && ev.to !== undefined) {
162
- edges.delete(edgeKey(ev.kind, ev.from, ev.to))
163
- }
164
- }
165
- }
166
-
167
- const nodes = new Map<string, GraphNode>()
168
- for (const [id, fields] of nodeFields) {
169
- if (tombstonedNodes.has(id)) continue
170
- nodes.set(id, {
171
- id,
172
- kind: (fields.kind as NodeKind | undefined) ?? NODE_DEFAULTS.kind,
173
- status: (fields.status as NodeStatus | undefined) ?? NODE_DEFAULTS.status,
174
- touchSet: Array.isArray(fields.touchSet) ? [...(fields.touchSet as string[])] : [],
175
- blast: typeof fields.blast === 'string' ? fields.blast : NODE_DEFAULTS.blast,
176
- hitlOrAfk: (fields.hitlOrAfk as HitlOrAfk | undefined) ?? NODE_DEFAULTS.hitlOrAfk,
177
- modelTier: typeof fields.modelTier === 'string' ? fields.modelTier : NODE_DEFAULTS.modelTier,
178
- briefPointer: typeof fields.briefPointer === 'string' ? fields.briefPointer : NODE_DEFAULTS.briefPointer,
179
- origin: Array.isArray(fields.origin) ? [...(fields.origin as string[])] : [],
180
- capstone: typeof fields.capstone === 'string' ? fields.capstone : undefined,
181
- project: typeof fields.project === 'string' ? fields.project : NODE_DEFAULTS.project,
182
- barrier: typeof fields.barrier === 'boolean' ? fields.barrier : NODE_DEFAULTS.barrier,
183
- schemaVersion: typeof fields.v === 'number' ? fields.v : 1,
184
- })
185
- }
186
-
187
- // Defensive: an edge whose endpoint was tombstoned (or never existed) never surfaces to a
188
- // consumer — none of the frozen scenarios exercise this ordering, but a dangling reference
189
- // must never crash a downstream derivation.
190
- const liveEdges = [...edges.values()].filter((e) => nodes.has(e.from) && nodes.has(e.to))
191
- return { nodes, edges: liveEdges }
192
- }
193
-
194
- // ── The pinned tie-break — numeric-aware, never random ──
195
-
196
- function extractNumber(id: string): number | null {
197
- const m = /(\d+)/.exec(id)
198
- return m ? Number(m[1]) : null
199
- }
200
-
201
- /**
202
- * compareIds — the pinned mission-ref tie-break: numeric-aware so "#136" sorts before "#137" and
203
- * "M2" before "M10" (a plain string sort would get the latter backwards); falls back to plain
204
- * string order when either side carries no digits. Deterministic, never random.
205
- */
206
- export function compareIds(a: string, b: string): number {
207
- const na = extractNumber(a)
208
- const nb = extractNumber(b)
209
- if (na !== null && nb !== null && na !== nb) return na - nb
210
- return a < b ? -1 : a > b ? 1 : 0
211
- }
212
-
213
- function intersects(a: readonly string[], b: readonly string[]): boolean {
214
- if (a.length === 0 || b.length === 0) return false
215
- const set = new Set(a)
216
- return b.some((x) => set.has(x))
217
- }
218
-
219
- // ── RAW — the dependency edges ──
220
-
221
- /** Every node id with a RAW edge pointing at `id` (its direct "must finish before" predecessors). */
222
- export function rawPredecessors(graph: Graph, id: string): string[] {
223
- return graph.edges.filter((e) => e.kind === 'RAW' && e.to === id).map((e) => e.from)
224
- }
225
-
226
- // ── cycles — SCC over RAW edges only; parent-child/discovered-from never form a "wait for" loop ──
227
-
228
- export interface RepairItem {
229
- members: string[]
230
- }
231
-
232
- function rawAdjacency(graph: Graph): Map<string, string[]> {
233
- const adj = new Map<string, string[]>()
234
- for (const id of graph.nodes.keys()) adj.set(id, [])
235
- for (const e of graph.edges) {
236
- if (e.kind !== 'RAW') continue
237
- const list = adj.get(e.from) ?? []
238
- list.push(e.to)
239
- adj.set(e.from, list)
240
- }
241
- return adj
242
- }
243
-
244
- /** Tarjan's strongly-connected-components pass, restricted to RAW edges. */
245
- export function computeSCCs(graph: Graph): string[][] {
246
- const adj = rawAdjacency(graph)
247
- let index = 0
248
- const indices = new Map<string, number>()
249
- const lowlink = new Map<string, number>()
250
- const onStack = new Set<string>()
251
- const stack: string[] = []
252
- const sccs: string[][] = []
253
-
254
- function strongconnect(v: string): void {
255
- indices.set(v, index)
256
- lowlink.set(v, index)
257
- index += 1
258
- stack.push(v)
259
- onStack.add(v)
260
- for (const w of adj.get(v) ?? []) {
261
- if (!indices.has(w)) {
262
- strongconnect(w)
263
- lowlink.set(v, Math.min(lowlink.get(v) ?? 0, lowlink.get(w) ?? 0))
264
- } else if (onStack.has(w)) {
265
- lowlink.set(v, Math.min(lowlink.get(v) ?? 0, indices.get(w) ?? 0))
266
- }
267
- }
268
- if (lowlink.get(v) === indices.get(v)) {
269
- const component: string[] = []
270
- let w: string | undefined
271
- do {
272
- w = stack.pop()
273
- if (w === undefined) break
274
- onStack.delete(w)
275
- component.push(w)
276
- } while (w !== v)
277
- sccs.push(component)
278
- }
279
- }
280
-
281
- for (const v of [...graph.nodes.keys()].sort(compareIds)) {
282
- if (!indices.has(v)) strongconnect(v)
283
- }
284
- return sccs
285
- }
286
-
287
- /**
288
- * cycles — reports every RAW strongly-connected component of size > 1, plus any single-node RAW
289
- * self-loop, as a repair item. An acyclic graph reports none. Never throws — a knotted plan is
290
- * surfaced, not crashed on.
291
- */
292
- export function cycles(graph: Graph): RepairItem[] {
293
- const sccs = computeSCCs(graph)
294
- const selfLoop = new Set(graph.edges.filter((e) => e.kind === 'RAW' && e.from === e.to).map((e) => e.from))
295
- const items: RepairItem[] = []
296
- for (const comp of sccs) {
297
- const isCycle = comp.length > 1 || selfLoop.has(comp[0])
298
- if (!isCycle) continue
299
- items.push({ members: [...comp].sort(compareIds) })
300
- }
301
- return items.sort((a, b) => compareIds(a.members[0], b.members[0]))
302
- }
303
-
304
- /** Every mission id sitting on a RAW cycle — excluded from `ready` and from unblocking anything
305
- * that depends on it; the only way clear is repairing the plan (a merge, or a tombstoned edge). */
306
- export function quarantinedIds(graph: Graph): Set<string> {
307
- const ids = new Set<string>()
308
- for (const item of cycles(graph)) for (const m of item.members) ids.add(m)
309
- return ids
310
- }
311
-
312
- // ── The write-time cycle guard (a pure decision — the seam applies it before appending) ──
313
-
314
- function canReach(graph: Graph, start: string, target: string): boolean {
315
- const visited = new Set<string>()
316
- const stack = [start]
317
- while (stack.length > 0) {
318
- const cur = stack.pop()
319
- if (cur === undefined) continue
320
- if (cur === target) return true
321
- if (visited.has(cur)) continue
322
- visited.add(cur)
323
- for (const e of graph.edges) {
324
- if (e.kind === 'RAW' && e.from === cur) stack.push(e.to)
325
- }
326
- }
327
- return false
328
- }
329
-
330
- /** Would appending this edge close a RAW cycle? Only RAW ("wait for") edges can — parent-child
331
- * and discovered-from are exempt from the guard entirely. */
332
- export function wouldCloseCycle(graph: Graph, proposed: { kind: EdgeKind; from: string; to: string }): boolean {
333
- if (proposed.kind !== 'RAW') return false
334
- if (proposed.from === proposed.to) return true
335
- return canReach(graph, proposed.to, proposed.from)
336
- }
337
-
338
- export interface EdgeDecision {
339
- accepted: boolean
340
- reason?: string
341
- }
342
-
343
- /**
344
- * proposeEdge — the gentle write-time guard: rejects a RAW edge that would close a cycle unless
345
- * the writer passes `override: true` to record a genuinely-discovered mutual dependency.
346
- */
347
- export function proposeEdge(
348
- graph: Graph,
349
- proposed: { kind: EdgeKind; from: string; to: string },
350
- override = false,
351
- ): EdgeDecision {
352
- if (!override && wouldCloseCycle(graph, proposed)) {
353
- return {
354
- accepted: false,
355
- reason: `would close a RAW cycle (${proposed.to} already reaches ${proposed.from})`,
356
- }
357
- }
358
- if (proposed.kind === 'RAW') {
359
- const candidate: Graph = {
360
- nodes: graph.nodes,
361
- edges: [...graph.edges, { kind: proposed.kind, from: proposed.from, to: proposed.to }],
362
- }
363
- const violation = checkBarrierInvariants(candidate)
364
- if (violation) return { accepted: false, reason: violation }
365
- }
366
- return { accepted: true }
367
- }
368
-
369
- // ── The barrier write-time guards — all fold-then-check: apply the proposed change to the
370
- // already-folded graph, then reject if the resulting state violates an invariant. Re-run on
371
- // EVERY append (node or edge) so no ordering of "mark barrier" vs "add the offending edge"
372
- // can slip a violation through. ──
373
-
374
- /**
375
- * checkBarrierInvariants — runs INV-1/INV-2/INV-3 over an already-simulated (post-append) graph.
376
- * Returns the first violation's reason, or null when the graph is clean.
377
- * INV-1: only a Mission may carry a barrier marking.
378
- * INV-2: no un-retired barrier may have a RAW predecessor that is an Operation (an Operation
379
- * never retires, so such a barrier would fence its project forever, invisibly to `cycles`).
380
- * INV-3: no two un-retired barriers of one project may both be `claimed` at once.
381
- */
382
- function checkBarrierInvariants(graph: Graph): string | null {
383
- for (const n of graph.nodes.values()) {
384
- if (n.barrier && n.kind !== 'mission') {
385
- return 'INV-1: a barrier marking is only valid on a mission node'
386
- }
387
- }
388
- for (const n of graph.nodes.values()) {
389
- if (!n.barrier || n.status === 'retired') continue
390
- for (const predId of rawPredecessors(graph, n.id)) {
391
- if (graph.nodes.get(predId)?.kind === 'operation') {
392
- return 'INV-2: an un-retired barrier may not have a RAW predecessor that is an operation'
393
- }
394
- }
395
- }
396
- const claimedBarriersByProject = new Map<string, number>()
397
- for (const n of graph.nodes.values()) {
398
- if (n.barrier && n.status === 'claimed') {
399
- claimedBarriersByProject.set(n.project, (claimedBarriersByProject.get(n.project) ?? 0) + 1)
400
- }
401
- }
402
- for (const count of claimedBarriersByProject.values()) {
403
- if (count > 1) return 'INV-3: at most one barrier of a project may be claimed at once'
404
- }
405
- return null
406
- }
407
-
408
- /**
409
- * applyNodeEventSimulated — shallow-merges one more node event onto an already-folded graph's
410
- * existing node (or NODE_DEFAULTS when absent), mirroring `fold`'s per-field merge semantics
411
- * without re-folding the whole event log — the guard only needs the ONE resulting node.
412
- */
413
- function applyNodeEventSimulated(graph: Graph, event: NodeEvent): Graph {
414
- const prev = graph.nodes.get(event.id)
415
- const merged: GraphNode = {
416
- id: event.id,
417
- kind: event.kind ?? prev?.kind ?? NODE_DEFAULTS.kind,
418
- status: event.status ?? prev?.status ?? NODE_DEFAULTS.status,
419
- touchSet: event.touchSet ? [...event.touchSet] : (prev?.touchSet ?? []),
420
- blast: event.blast ?? prev?.blast ?? NODE_DEFAULTS.blast,
421
- hitlOrAfk: event.hitlOrAfk ?? prev?.hitlOrAfk ?? NODE_DEFAULTS.hitlOrAfk,
422
- modelTier: event.modelTier ?? prev?.modelTier ?? NODE_DEFAULTS.modelTier,
423
- briefPointer: event.briefPointer ?? prev?.briefPointer ?? NODE_DEFAULTS.briefPointer,
424
- origin: event.origin ? [...event.origin] : (prev?.origin ?? []),
425
- capstone: event.capstone ?? prev?.capstone,
426
- project: event.project ?? prev?.project ?? NODE_DEFAULTS.project,
427
- barrier: event.barrier ?? prev?.barrier ?? NODE_DEFAULTS.barrier,
428
- schemaVersion: typeof event.v === 'number' ? event.v : (prev?.schemaVersion ?? 1),
429
- }
430
- const nodes = new Map(graph.nodes)
431
- nodes.set(event.id, merged)
432
- return { nodes, edges: graph.edges }
433
- }
434
-
435
- /**
436
- * proposeNode — the write-time guard for a node event: simulates the merge onto the already-
437
- * folded graph, then rejects (INV-1/INV-2/INV-3) if the resulting state is invalid. Fold-then-
438
- * check, exactly like `proposeEdge` — so marking a node a barrier AFTER an Operation RAW edge
439
- * already exists is caught exactly like the reverse order.
440
- */
441
- export function proposeNode(graph: Graph, event: NodeEvent): EdgeDecision {
442
- const candidate = applyNodeEventSimulated(graph, event)
443
- const violation = checkBarrierInvariants(candidate)
444
- if (violation) return { accepted: false, reason: violation }
445
- return { accepted: true }
446
- }
447
-
448
- // ── Operations — grouping (parent-child) ──
449
-
450
- function declaredMembers(graph: Graph, operationId: string): string[] {
451
- return graph.edges.filter((e) => e.kind === 'parent-child' && e.from === operationId).map((e) => e.to)
452
- }
453
-
454
- /** The Operation a Mission is declared under (via a parent-child edge), or null when it is its
455
- * own Mission with no group. */
456
- export function operationOf(graph: Graph, missionId: string): string | null {
457
- const edge = graph.edges.find((e) => e.kind === 'parent-child' && e.to === missionId)
458
- return edge ? edge.from : null
459
- }
460
-
461
- export interface OperationSummary {
462
- id: string
463
- capstone?: string
464
- members: string[]
465
- }
466
-
467
- /** Every Operation node and its declared Mission members, sorted for stable reading. */
468
- export function listOperations(graph: Graph): OperationSummary[] {
469
- const ops: OperationSummary[] = []
470
- for (const n of graph.nodes.values()) {
471
- if (n.kind !== 'operation') continue
472
- ops.push({ id: n.id, capstone: n.capstone, members: declaredMembers(graph, n.id).sort(compareIds) })
473
- }
474
- return ops.sort((a, b) => compareIds(a.id, b.id))
475
- }
476
-
477
- function rawClosure(graph: Graph, start: string): Set<string> {
478
- const seen = new Set<string>()
479
- const stack = [start]
480
- while (stack.length > 0) {
481
- const cur = stack.pop()
482
- if (cur === undefined || seen.has(cur)) continue
483
- seen.add(cur)
484
- for (const e of graph.edges) {
485
- if (e.kind === 'RAW' && e.to === cur) stack.push(e.from)
486
- }
487
- }
488
- return seen
489
- }
490
-
491
- export interface OperationCheck {
492
- dependencyClosed: boolean
493
- missingPrereqs: string[]
494
- releaseFloor: string[]
495
- progress: { completed: number; total: number }
496
- }
497
-
498
- /**
499
- * checkOperation — the shippable-group view for one Operation: dependency-closed iff the
500
- * capstone's RAW closure is a subset of the declared set (a missing prerequisite is flagged; a
501
- * declared support member outside the closure is legal and never flagged); the release floor is
502
- * the capstone's closure alone (support members never gate release); progress is completed/total
503
- * over the declared Missions only.
504
- */
505
- export function checkOperation(graph: Graph, operationId: string): OperationCheck {
506
- const declared = declaredMembers(graph, operationId)
507
- const declaredSet = new Set(declared)
508
- const capstone = graph.nodes.get(operationId)?.capstone
509
- const closure = capstone !== undefined ? rawClosure(graph, capstone) : new Set<string>()
510
- const missingPrereqs = [...closure].filter((id) => !declaredSet.has(id)).sort(compareIds)
511
- const completed = declared.filter((id) => graph.nodes.get(id)?.status === 'retired').length
512
-
513
- return {
514
- dependencyClosed: missingPrereqs.length === 0,
515
- missingPrereqs,
516
- releaseFloor: [...closure].sort(compareIds),
517
- progress: { completed, total: declared.length },
518
- }
519
- }
520
-
521
- // ── ready — the RAW-satisfied, not-WAW-held frontier ──
522
-
523
- export interface FrontierEntry {
524
- id: string
525
- /** The node's kind — always 'mission' in v1: an Operation is a container, never itself
526
- * scheduled, so it never becomes a frontier candidate. */
527
- node: NodeKind
528
- operation: string | null
529
- blast: string
530
- hitlOrAfk: HitlOrAfk
531
- modelTier: string
532
- briefPointer: string
533
- whyReady: string
534
- }
535
-
536
- function isRawSatisfied(graph: Graph, quarantined: ReadonlySet<string>, id: string): boolean {
537
- return rawPredecessors(graph, id).every((p) => {
538
- if (quarantined.has(p)) return false
539
- return graph.nodes.get(p)?.status === 'retired'
540
- })
541
- }
542
-
543
- function whyReady(graph: Graph, id: string): string {
544
- const preds = rawPredecessors(graph, id)
545
- if (preds.length === 0) return 'no RAW predecessors'
546
- return `RAW-satisfied: ${[...preds].sort(compareIds).join(', ')} retired`
547
- }
548
-
549
- /** An un-retired barrier — open or claimed — is "live": it grants exemption (clause 1), fences its
550
- * project (clause 3), and occupies its project's at-most-one slot (clause 2). */
551
- function isLiveBarrier(n: GraphNode): boolean {
552
- return n.barrier && n.status !== 'retired'
553
- }
554
-
555
- /**
556
- * fenceExempt — clause 1's exempt set: every NON-barrier mission in the STRICT RAW-predecessor
557
- * closure of any live (un-retired) barrier, graph-global (not scoped to the barrier's own
558
- * project — an acyclic two-project deadlock needs the exemption to cross project lines). Barriers
559
- * are NEVER exempt, so a barrier that is itself a RAW predecessor of another project's barrier
560
- * cannot use exemption to dodge its own project's at-most-one cap (clause 2).
561
- */
562
- function fenceExempt(graph: Graph): Set<string> {
563
- const exempt = new Set<string>()
564
- for (const b of graph.nodes.values()) {
565
- if (!isLiveBarrier(b)) continue
566
- for (const id of rawClosure(graph, b.id)) {
567
- if (id === b.id) continue // strict — rawClosure is reflexive
568
- const m = graph.nodes.get(id)
569
- if (m && !m.barrier) exempt.add(id)
570
- }
571
- }
572
- return exempt
573
- }
574
-
575
- /**
576
- * barrierHeldByCap — clause 2, the at-most-one-barrier-per-project offer cap, evaluated over
577
- * UN-RETIRED barriers of `candidate`'s project (open ∪ claimed, never just the open `candidates`
578
- * set — a claimed barrier still fills the slot even though it's no longer itself a candidate).
579
- * Holds `candidate` when either another un-retired barrier of the project is already claimed, or a
580
- * lower-id OPEN RAW-satisfied barrier of the project exists (a quarantined or RAW-blocked one never
581
- * fills the slot).
582
- */
583
- function barrierHeldByCap(
584
- graph: Graph,
585
- quarantined: ReadonlySet<string>,
586
- liveBarriersOfProject: readonly GraphNode[],
587
- candidate: GraphNode,
588
- ): boolean {
589
- const claimedElsewhere = liveBarriersOfProject.some((b) => b.id !== candidate.id && b.status === 'claimed')
590
- if (claimedElsewhere) return true
591
- const lowerIdOpenRawSatisfied = liveBarriersOfProject.some(
592
- (b) =>
593
- b.id !== candidate.id &&
594
- b.status === 'open' &&
595
- !quarantined.has(b.id) &&
596
- isRawSatisfied(graph, quarantined, b.id) &&
597
- compareIds(b.id, candidate.id) < 0,
598
- )
599
- return lowerIdOpenRawSatisfied
600
- }
601
-
602
- /**
603
- * ready — reads the graph without changing it and returns every Mission that is both RAW-satisfied
604
- * (every RAW predecessor retired, transitively — realized here as each direct predecessor's own
605
- * recorded status, which composes: a blocked predecessor can never itself be retired) and not
606
- * WAW-held. Two WAW triggers: (1) an open candidate whose touch-set intersects an in-flight
607
- * (claimed) mission's is held; (2) among the remaining RAW-satisfied candidates, an intersecting
608
- * pair never both surface — the pinned lowest-id tie-break (compareIds) admits one and holds the
609
- * rest, guaranteeing the frontier never contains a WAW pair. A cycle-quarantined mission (and
610
- * anything depending on it) is excluded regardless of its recorded status.
611
- *
612
- * Before the WAW-mutex, the RAW-satisfied non-quarantined candidates pass through the barrier
613
- * fence (fold-time, never a stored edge — see the .feature "ready — the barrier fence" block):
614
- * clause 1 lifts a non-barrier in any live barrier's strict RAW-predecessor closure from every
615
- * fence (graph-global); clause 2 caps each project to at most one offered barrier (counting
616
- * open+claimed, so an in-flight barrier still fills the slot); clause 3 holds every other
617
- * non-barrier, non-exempt candidate of a fenced project outright, regardless of touch-set (an
618
- * empty-touch-set barrier still fences — the fence never delegates to the WAW-mutex).
619
- *
620
- * Deterministic: same graph in, same frontier in the same order out. Read-only: never mutates
621
- * `graph`.
622
- */
623
- export function ready(graph: Graph): FrontierEntry[] {
624
- const quarantined = quarantinedIds(graph)
625
- const missions = [...graph.nodes.values()].filter((n) => n.kind === 'mission')
626
- const inFlightTouchSets = missions.filter((n) => n.status === 'claimed').map((n) => n.touchSet)
627
-
628
- const rawSatisfiedOpen = missions.filter(
629
- (n) => n.status === 'open' && !quarantined.has(n.id) && isRawSatisfied(graph, quarantined, n.id),
630
- )
631
-
632
- // ── The barrier fence (fold-time, applied to the RAW-satisfied candidates before the WAW-mutex) ──
633
- const liveBarriers = missions.filter(isLiveBarrier)
634
- const exempt = fenceExempt(graph)
635
- const fencedProjects = new Set(liveBarriers.map((b) => b.project))
636
-
637
- const candidates = rawSatisfiedOpen.filter((c) => {
638
- if (c.barrier) {
639
- const ofProject = liveBarriers.filter((b) => b.project === c.project)
640
- return !barrierHeldByCap(graph, quarantined, ofProject, c)
641
- }
642
- if (exempt.has(c.id)) return true // clause 1 — lifted from every fence, graph-global
643
- if (fencedProjects.has(c.project)) return false // clause 3 — held outright, regardless of touch-set
644
- return true
645
- })
646
-
647
- const notHeldByInFlight = candidates.filter((n) => !inFlightTouchSets.some((t) => intersects(t, n.touchSet)))
648
-
649
- // Trigger 2: the pinned lowest-id tie-break. Processing in ascending id order and admitting a
650
- // candidate only when its touch-set is disjoint from every already-admitted (necessarily
651
- // lower-id) candidate's guarantees no two admitted candidates ever collide.
652
- const sorted = [...notHeldByInFlight].sort((a, b) => compareIds(a.id, b.id))
653
- const admitted: GraphNode[] = []
654
- const claimedTouch: string[] = []
655
- for (const n of sorted) {
656
- if (intersects(n.touchSet, claimedTouch)) continue
657
- admitted.push(n)
658
- claimedTouch.push(...n.touchSet)
659
- }
660
-
661
- return admitted
662
- .sort((a, b) => compareIds(a.id, b.id))
663
- .map((n) => ({
664
- id: n.id,
665
- node: n.kind,
666
- operation: operationOf(graph, n.id),
667
- blast: n.blast,
668
- hitlOrAfk: n.hitlOrAfk,
669
- modelTier: n.modelTier,
670
- briefPointer: n.briefPointer,
671
- whyReady: whyReady(graph, n.id),
672
- }))
673
- }
674
-
675
- // ── Store IO seam (v1: in-tree JSONL under <root>/.agents/mission-graph/events.jsonl;
676
- // F3 adds a second backend: a git orphan ref, branch-independent and working-tree-clean) ──
677
- // Isolated so a store swap never touches any pure function above — every one of them takes/
678
- // returns plain data, with no fs access of its own.
679
-
680
- const STORE_RELATIVE_PATH = ['.agents', 'mission-graph', 'events.jsonl']
681
-
682
- function storePath(root: string): string {
683
- return join(root, ...STORE_RELATIVE_PATH)
684
- }
685
-
686
- /** Read the in-tree JSONL store's raw (trimmed, non-empty) lines — used by both the in-tree
687
- * backend and `migrate` (which copies these verbatim, never re-serialized). */
688
- function readInTreeLines(root: string): string[] {
689
- const path = storePath(root)
690
- if (!existsSync(path)) return []
691
- return readFileSync(path, 'utf8')
692
- .split('\n')
693
- .map((line) => line.trim())
694
- .filter((line) => line.length > 0)
695
- }
696
-
697
- function readInTreeEvents(root: string): MissionGraphEvent[] {
698
- return readInTreeLines(root).map((line) => JSON.parse(line) as MissionGraphEvent)
699
- }
700
-
701
- function appendInTreeEvent(root: string, event: MissionGraphEvent): void {
702
- const path = storePath(root)
703
- mkdirSync(dirname(path), { recursive: true })
704
- appendFileSync(path, `${JSON.stringify(event)}\n`)
705
- }
706
-
707
- // ── F3: the orphan-ref backend — refs/sdd/mission-graph, read/written via git plumbing ──
708
-
709
- export const ORPHAN_REF = 'refs/sdd/mission-graph'
710
- const ORPHAN_STORE_FILE = 'events.jsonl'
711
-
712
- function git(root: string, args: string[], input?: string): string {
713
- return execFileSync('git', args, { cwd: root, input, encoding: 'utf8' }).trim()
714
- }
715
-
716
- function isGitWorkTree(root: string): boolean {
717
- try {
718
- return git(root, ['rev-parse', '--is-inside-work-tree']) === 'true'
719
- } catch {
720
- return false
721
- }
722
- }
723
-
724
- function orphanRefExists(root: string): boolean {
725
- try {
726
- git(root, ['rev-parse', '--verify', '--quiet', ORPHAN_REF])
727
- return true
728
- } catch {
729
- return false
730
- }
731
- }
732
-
733
- export function orphanHead(root: string): string | null {
734
- if (!orphanRefExists(root)) return null
735
- return git(root, ['rev-parse', ORPHAN_REF])
736
- }
737
-
738
- export function readOrphanLines(root: string): string[] {
739
- if (!orphanRefExists(root)) return []
740
- const text = git(root, ['cat-file', '-p', `${ORPHAN_REF}:${ORPHAN_STORE_FILE}`])
741
- return text
742
- .split('\n')
743
- .map((line) => line.trim())
744
- .filter((line) => line.length > 0)
745
- }
746
-
747
- function readOrphanEvents(root: string): MissionGraphEvent[] {
748
- return readOrphanLines(root).map((line) => JSON.parse(line) as MissionGraphEvent)
749
- }
750
-
751
- /** blob -> tree -> commit -> update-ref, compare-and-swap on `expectedOld` (the ref value the
752
- * caller last observed). `null` means "the ref must not yet exist"; a stale/mismatched old value
753
- * makes `update-ref` throw — that throw IS the CAS rejection, and the ref is left unchanged. */
754
- export function commitOrphan(root: string, lines: string[], expectedOld: string | null): string {
755
- const content = lines.length ? `${lines.join('\n')}\n` : ''
756
- const blob = git(root, ['hash-object', '-w', '--stdin'], content)
757
- const tree = git(root, ['mktree'], `100644 blob ${blob}\t${ORPHAN_STORE_FILE}\n`)
758
- const commitArgs = ['commit-tree', tree]
759
- if (expectedOld !== null) commitArgs.push('-p', expectedOld)
760
- const commit = git(root, commitArgs, 'mission-graph: append')
761
- git(root, ['update-ref', ORPHAN_REF, commit, expectedOld ?? ''])
762
- return commit
763
- }
764
-
765
- export function appendOrphanEvent(root: string, event: MissionGraphEvent): void {
766
- const old = orphanHead(root)
767
- const lines = readOrphanLines(root)
768
- lines.push(JSON.stringify(event))
769
- commitOrphan(root, lines, old)
770
- }
771
-
772
- // ── Backend selection ──
773
-
774
- export type StoreBackend = 'in-tree' | 'orphan-ref'
775
-
776
- /**
777
- * resolveBackend — an explicit `MISSION_GRAPH_STORE` override wins outright; otherwise a
778
- * non-git-work-tree always stays in-tree (the orphan-ref backend needs git plumbing); inside a
779
- * work-tree, an existing orphan ref wins (already migrated); otherwise an existing in-tree store
780
- * keeps the in-tree backend so a pre-migrate v1 adopter is never silently orphaned; only a fresh
781
- * work-tree with neither picks the orphan-ref backend outright.
782
- */
783
- export function resolveBackend(root: string, env: Record<string, string | undefined> = process.env): StoreBackend {
784
- const override = env.MISSION_GRAPH_STORE
785
- if (override === 'in-tree' || override === 'orphan-ref') return override
786
- if (!isGitWorkTree(root)) return 'in-tree'
787
- if (orphanRefExists(root)) return 'orphan-ref'
788
- if (existsSync(storePath(root))) return 'in-tree'
789
- return 'orphan-ref'
790
- }
791
-
792
- /** Read the append-only event log back. An absent store reads as the empty log (a fresh project). */
793
- export function readEvents(root: string): MissionGraphEvent[] {
794
- if (resolveBackend(root) === 'orphan-ref') return readOrphanEvents(root)
795
- return readInTreeEvents(root)
796
- }
797
-
798
- /** Append one event line. The one writer for now is a person at the CLI; later, one automated
799
- * coordinator — never split across writers, so there is nothing to reconcile. */
800
- export function appendEvent(root: string, event: MissionGraphEvent): void {
801
- if (resolveBackend(root) === 'orphan-ref') {
802
- appendOrphanEvent(root, event)
803
- return
804
- }
805
- appendInTreeEvent(root, event)
806
- }
807
-
808
- export interface MigrateResult {
809
- migrated: boolean
810
- reason: string
811
- count: number
812
- /** Whether the in-tree seed was retired (deleted + staged) THIS run. */
813
- retired: boolean
814
- retiredReason: string
815
- }
816
-
817
- function relativeStorePath(): string {
818
- return STORE_RELATIVE_PATH.join('/')
819
- }
820
-
821
- /** Every raw seed line must already be present among the orphan ref's lines — the guard that
822
- * keeps retirement from ever being the data loss the migration exists to prevent. An empty seed
823
- * vacuously counts as fully carried (nothing to lose). */
824
- function seedFullyCarried(root: string): boolean {
825
- const seedLines = readInTreeLines(root)
826
- if (seedLines.length === 0) return true
827
- const orphanLines = new Set(readOrphanLines(root))
828
- return seedLines.every((line) => orphanLines.has(line))
829
- }
830
-
831
- /**
832
- * retireSeed — migrate's final, guarded act: once (and only once) the orphan ref already carries
833
- * every line of the in-tree seed, deletes the seed file and stages the deletion (never commits
834
- * it — landing that commit is the caller's next step, and is what actually makes the retirement
835
- * reach other clones). Refuses when the ref does not yet carry every seed line. Idempotent and
836
- * safe to re-run: a pre-fix project whose seed was left behind by an earlier migrate is repaired
837
- * simply by calling migrate again.
838
- */
839
- function retireSeed(root: string): { retired: boolean; retiredReason: string } {
840
- const path = storePath(root)
841
- if (!existsSync(path)) {
842
- return { retired: false, retiredReason: 'no in-tree seed remains to retire' }
843
- }
844
- if (!seedFullyCarried(root)) {
845
- return { retired: false, retiredReason: 'will not retire a seed the orphan ref does not carry' }
846
- }
847
- rmSync(path)
848
- try {
849
- git(root, ['add', '--', relativeStorePath()])
850
- } catch {
851
- // The seed was never tracked by git (e.g. a hand-built test fixture) — nothing to stage;
852
- // the file is still deleted from the working tree, which is all that case can promise.
853
- }
854
- return { retired: true, retiredReason: 'seed deleted; the staged deletion must be committed to reach other clones' }
855
- }
856
-
857
- /**
858
- * migrate — one-time, idempotent: seeds the orphan ref from the existing in-tree store, copying
859
- * its raw lines verbatim (never parsed and re-serialized, so exact bytes/order are preserved),
860
- * then retires the seed (see `retireSeed`) as its final, guarded act — a seed left tracked would
861
- * travel to every clone and shadow the (never-fetched) ref there. A no-op on the seeding half
862
- * (never throws) when there is nothing to seed from, or the ref is already seeded; the retirement
863
- * half still runs either way, so a pre-fix project (ref seeded, seed left behind) is repaired by
864
- * simply re-running migrate.
865
- */
866
- export function migrate(root: string): MigrateResult {
867
- if (!isGitWorkTree(root)) {
868
- return {
869
- migrated: false,
870
- reason: 'not a git work-tree',
871
- count: 0,
872
- retired: false,
873
- retiredReason: 'not a git work-tree',
874
- }
875
- }
876
- let migrated = false
877
- let reason: string
878
- let count: number
879
- if (orphanRefExists(root)) {
880
- reason = 'orphan ref already seeded'
881
- count = readOrphanLines(root).length
882
- } else {
883
- const rawLines = readInTreeLines(root)
884
- if (rawLines.length === 0) {
885
- return {
886
- migrated: false,
887
- reason: 'no in-tree store to seed from',
888
- count: 0,
889
- retired: false,
890
- retiredReason: 'no in-tree seed remains to retire',
891
- }
892
- }
893
- commitOrphan(root, rawLines, null)
894
- migrated = true
895
- reason = 'seeded orphan ref from in-tree store'
896
- count = rawLines.length
897
- }
898
- const { retired, retiredReason } = retireSeed(root)
899
- return { migrated, reason, count, retired, retiredReason }
900
- }
901
-
902
- // ── sync — the one deliberate step that carries the orphan ref to/from a shared remote ──
903
- // refs/sdd/* sits outside refs/heads/*, which git's default refspec is the only thing it copies,
904
- // so the ref is shared by every WORKTREE of one clone and travels no further on its own. `sync`
905
- // names the ref explicitly on every command it runs — so it needs no git configuration and writes
906
- // none — and is fast-forward only in both directions: a diverged pair is refused, never merged.
907
- // See the node README's sync table for the exhaustive 7-case partition this function realizes.
908
-
909
- export interface SyncResult {
910
- backend: StoreBackend
911
- ok: boolean
912
- action: 'no-op' | 'no-remote' | 'unreachable' | 'both-empty' | 'push' | 'collect' | 'agree' | 'refuse'
913
- message: string
914
- localHead: string | null
915
- remoteHead: string | null
916
- }
917
-
918
- function refTransferSpec(refName: string): string {
919
- return `${refName}:${refName}`
920
- }
921
-
922
- /** A remote NAME is configured at all (distinct from being reachable — see `remoteReachable`). */
923
- function remoteConfigured(root: string, remote: string): boolean {
924
- try {
925
- git(root, ['remote', 'get-url', remote])
926
- return true
927
- } catch {
928
- return false
929
- }
930
- }
931
-
932
- /** Whether the remote can actually be talked to right now. Deliberately checked SEPARATELY from
933
- * "does it have the ref" — `git ls-remote` reports an unreachable remote and an empty one the
934
- * same way, so establishing reachability first (and failing loudly when it cannot be) is the
935
- * only way to avoid conflating the two. */
936
- function remoteReachable(root: string, remote: string): boolean {
937
- try {
938
- git(root, ['ls-remote', remote])
939
- return true
940
- } catch {
941
- return false
942
- }
943
- }
944
-
945
- /** The remote's current orphan-ref tip, or null when the remote has no such ref (but IS
946
- * reachable — reachability is checked by the caller before this is ever consulted). */
947
- function remoteOrphanHead(root: string, remote: string): string | null {
948
- const out = git(root, ['ls-remote', remote, ORPHAN_REF])
949
- if (out === '') return null
950
- const [hash] = out.split('\n')[0].split('\t')
951
- return hash ?? null
952
- }
953
-
954
- function gitFetchOrphanRef(root: string, remote: string): void {
955
- git(root, ['fetch', remote, refTransferSpec(ORPHAN_REF)])
956
- }
957
-
958
- function gitPushOrphanRef(root: string, remote: string): void {
959
- git(root, ['push', remote, refTransferSpec(ORPHAN_REF)])
960
- }
961
-
962
- function isAncestor(root: string, ancestor: string, descendant: string): boolean {
963
- try {
964
- execFileSync('git', ['merge-base', '--is-ancestor', ancestor, descendant], { cwd: root })
965
- return true
966
- } catch {
967
- return false
968
- }
969
- }
970
-
971
- /**
972
- * syncStore — carries the orphan ref between this clone and `remote`, fast-forward only. Two
973
- * prechecks run first, in this exact order: (1) backend — under the in-tree backend, sync is a
974
- * no-op that reports the active backend, even when the remote is unreachable (an in-tree project
975
- * must not fail merely for being offline); (2) reachability — an unreachable remote, or no remote
976
- * configured at all, is a loud non-zero failure, never reported as "no list there". Only once both
977
- * are settled does the 7-case partition of (local ref, remote ref) apply — see the README's sync
978
- * table. Writes no git configuration, ever; never uses the forced (`+`-prefixed) refspec form.
979
- */
980
- export function syncStore(root: string, remote = 'origin'): SyncResult {
981
- const backend = resolveBackend(root)
982
- if (backend !== 'orphan-ref') {
983
- return {
984
- backend,
985
- ok: true,
986
- action: 'no-op',
987
- message: `the ${backend} backend is in use; sync only carries the shared orphan ref, so this is a no-op`,
988
- localHead: null,
989
- remoteHead: null,
990
- }
991
- }
992
- if (!remoteConfigured(root, remote)) {
993
- return {
994
- backend,
995
- ok: false,
996
- action: 'no-remote',
997
- message: `no remote '${remote}' is configured to reach`,
998
- localHead: orphanHead(root),
999
- remoteHead: null,
1000
- }
1001
- }
1002
- if (!remoteReachable(root, remote)) {
1003
- return {
1004
- backend,
1005
- ok: false,
1006
- action: 'unreachable',
1007
- message: `could not reach remote '${remote}'`,
1008
- localHead: orphanHead(root),
1009
- remoteHead: null,
1010
- }
1011
- }
1012
-
1013
- const localHead = orphanHead(root)
1014
- const remoteHead = remoteOrphanHead(root, remote)
1015
-
1016
- if (localHead === null && remoteHead === null) {
1017
- return {
1018
- backend,
1019
- ok: true,
1020
- action: 'both-empty',
1021
- message: 'neither side holds a store ref',
1022
- localHead: null,
1023
- remoteHead: null,
1024
- }
1025
- }
1026
- if (localHead !== null && remoteHead === null) {
1027
- gitPushOrphanRef(root, remote)
1028
- return {
1029
- backend,
1030
- ok: true,
1031
- action: 'push',
1032
- message: 'published the local-only store ref to the remote',
1033
- localHead,
1034
- remoteHead: localHead,
1035
- }
1036
- }
1037
- if (localHead === null && remoteHead !== null) {
1038
- gitFetchOrphanRef(root, remote)
1039
- return {
1040
- backend,
1041
- ok: true,
1042
- action: 'collect',
1043
- message: 'collected the remote store ref',
1044
- localHead: remoteHead,
1045
- remoteHead,
1046
- }
1047
- }
1048
- // Both present.
1049
- if (localHead === remoteHead) {
1050
- return {
1051
- backend,
1052
- ok: true,
1053
- action: 'agree',
1054
- message: 'the store refs already agree',
1055
- localHead,
1056
- remoteHead,
1057
- }
1058
- }
1059
-
1060
- // Both sides hold a ref and they differ, so which way (if either) this fast-forwards is an
1061
- // ancestry question — and answering it needs the remote's tip OBJECT in this clone's object
1062
- // database. A DESTINATION-LESS fetch brings exactly that and writes no ref: nothing to clean up
1063
- // afterwards, nothing left behind by a crashed run, and no fixed ref name for a concurrent sync
1064
- // to collide on. The tip HASH is already in hand from `ls-remote` above, so nothing reads
1065
- // FETCH_HEAD either. Still the plain, non-forced refspec: it names one ref and cannot overwrite.
1066
- git(root, ['fetch', remote, ORPHAN_REF])
1067
- if (isAncestor(root, localHead as string, remoteHead as string)) {
1068
- // The local ref is an ancestor of the remote's — the remote is ahead; fast-forward local.
1069
- gitFetchOrphanRef(root, remote)
1070
- return {
1071
- backend,
1072
- ok: true,
1073
- action: 'collect',
1074
- message: 'fast-forwarded the local store ref to the remote',
1075
- localHead: remoteHead,
1076
- remoteHead,
1077
- }
1078
- }
1079
- if (isAncestor(root, remoteHead as string, localHead as string)) {
1080
- // The remote's ref is an ancestor of the local's — this clone is ahead; publish.
1081
- gitPushOrphanRef(root, remote)
1082
- return {
1083
- backend,
1084
- ok: true,
1085
- action: 'push',
1086
- message: 'published the ahead store ref to the remote',
1087
- localHead,
1088
- remoteHead: localHead,
1089
- }
1090
- }
1091
- return {
1092
- backend,
1093
- ok: false,
1094
- action: 'refuse',
1095
- message: `refused: the store refs have diverged (local ${localHead}, remote ${remoteHead}) — see the node README's "Getting out of a refusal"`,
1096
- localHead,
1097
- remoteHead,
1098
- }
1099
- }
1100
-
1101
- /** Append a proposed RAW/parent-child/discovered-from edge through the write-time cycle guard;
1102
- * only appends when the guard accepts (or the caller overrides it). */
1103
- export function appendEdgeChecked(
1104
- root: string,
1105
- proposed: { kind: EdgeKind; from: string; to: string },
1106
- override = false,
1107
- ): EdgeDecision {
1108
- const graph = fold(readEvents(root))
1109
- const decision = proposeEdge(graph, proposed, override)
1110
- if (decision.accepted) appendEvent(root, { v: 1, type: 'edge', ...proposed })
1111
- return decision
1112
- }
1113
-
1114
- /** Append a node event through the write-time barrier guards (INV-1/INV-2/INV-3); only appends
1115
- * when the guard accepts. */
1116
- export function appendNodeChecked(root: string, event: NodeEvent): EdgeDecision {
1117
- const graph = fold(readEvents(root))
1118
- const decision = proposeNode(graph, event)
1119
- if (decision.accepted) appendEvent(root, event)
1120
- return decision
1121
- }
1122
-
1123
- // ── Render (TOON — the token-efficient tabular form the repo's other sdd engines emit) ──
1124
-
1125
- function toonQuote(v: string): string {
1126
- if (v === '' || /[",;]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
1127
- return v
1128
- }
1129
-
1130
- export function renderFrontierToon(entries: FrontierEntry[]): string {
1131
- const header = `ready[${entries.length}]{id,node,operation,blast,hitlOrAfk,modelTier,briefPointer,whyReady}:`
1132
- const rows = entries.map((e) =>
1133
- [e.id, e.node, e.operation ?? '', e.blast, e.hitlOrAfk, e.modelTier, e.briefPointer, e.whyReady]
1134
- .map((v) => toonQuote(v))
1135
- .join(','),
1136
- )
1137
- return [header, ...rows.map((r) => ` ${r}`)].join('\n')
1138
- }
1139
-
1140
- export function renderCyclesToon(items: RepairItem[]): string {
1141
- const header = `cycles[${items.length}]{scc,members}:`
1142
- const rows = items.map((item, i) => ` scc-${i + 1},"${item.members.join(';')}"`)
1143
- return [header, ...rows].join('\n')
1144
- }
1145
-
1146
- export function renderSyncToon(result: SyncResult): string {
1147
- return [
1148
- `backend: ${result.backend}`,
1149
- `action: ${result.action}`,
1150
- `ok: ${result.ok}`,
1151
- `message: ${result.message}`,
1152
- `localHead: ${result.localHead ?? ''}`,
1153
- `remoteHead: ${result.remoteHead ?? ''}`,
1154
- ].join('\n')
1155
- }
1156
-
1157
- export function renderOperationToon(id: string, check: OperationCheck): string {
1158
- return [
1159
- `operation: ${id}`,
1160
- `dependencyClosed: ${check.dependencyClosed}`,
1161
- `missingPrereqs: ${check.missingPrereqs.join(';')}`,
1162
- `releaseFloor: ${check.releaseFloor.join(';')}`,
1163
- `progress: ${check.progress.completed}/${check.progress.total}`,
1164
- ].join('\n')
1165
- }
1166
-
1167
- // ── CLI ──
1168
-
1169
- function flag(argv: string[], name: string): string | undefined {
1170
- const i = argv.indexOf(name)
1171
- return i === -1 ? undefined : argv[i + 1]
1172
- }
1173
-
1174
- function hasFlag(argv: string[], name: string): boolean {
1175
- return argv.includes(name)
1176
- }
1177
-
1178
- function splitCsv(v: string | undefined): string[] {
1179
- if (v === undefined || v === '') return []
1180
- return v
1181
- .split(',')
1182
- .map((s) => s.trim())
1183
- .filter((s) => s.length > 0)
1184
- }
1185
-
1186
- function isEdgeKind(v: string | undefined): v is EdgeKind {
1187
- return v === 'RAW' || v === 'parent-child' || v === 'discovered-from'
1188
- }
1189
-
1190
- function runAppendNode(rest: string[], root: string): number {
1191
- const id = flag(rest, '--id')
1192
- if (id === undefined) {
1193
- process.stderr.write('mission-graph append node: --id is required\n')
1194
- return 1
1195
- }
1196
- const event: NodeEvent = { v: 1, type: 'node', id }
1197
- const nodeKind = flag(rest, '--kind')
1198
- if (nodeKind === 'mission' || nodeKind === 'operation') event.kind = nodeKind
1199
- const status = flag(rest, '--status')
1200
- if (status === 'open' || status === 'claimed' || status === 'retired') event.status = status
1201
- const touchSet = flag(rest, '--touch-set')
1202
- if (touchSet !== undefined) event.touchSet = splitCsv(touchSet)
1203
- const blast = flag(rest, '--blast')
1204
- if (blast !== undefined) event.blast = blast
1205
- if (hasFlag(rest, '--hitl')) event.hitlOrAfk = 'hitl'
1206
- if (hasFlag(rest, '--afk')) event.hitlOrAfk = 'afk'
1207
- const modelTier = flag(rest, '--model-tier')
1208
- if (modelTier !== undefined) event.modelTier = modelTier
1209
- const briefPointer = flag(rest, '--brief-pointer')
1210
- if (briefPointer !== undefined) event.briefPointer = briefPointer
1211
- const capstone = flag(rest, '--capstone')
1212
- if (capstone !== undefined) event.capstone = capstone
1213
- const project = flag(rest, '--project')
1214
- if (project !== undefined) event.project = project
1215
- if (hasFlag(rest, '--barrier')) event.barrier = true
1216
- const decision = appendNodeChecked(root, event)
1217
- if (!decision.accepted) {
1218
- process.stderr.write(`mission-graph append node: rejected — ${decision.reason}\n`)
1219
- return 1
1220
- }
1221
- process.stdout.write(`mission-graph: appended node ${id}\n`)
1222
- return 0
1223
- }
1224
-
1225
- function runAppendEdge(rest: string[], root: string): number {
1226
- const edgeKind = flag(rest, '--kind')
1227
- const from = flag(rest, '--from')
1228
- const to = flag(rest, '--to')
1229
- if (!isEdgeKind(edgeKind) || from === undefined || to === undefined) {
1230
- process.stderr.write(
1231
- 'mission-graph append edge: --kind <RAW|parent-child|discovered-from> --from <id> --to <id> are required\n',
1232
- )
1233
- return 1
1234
- }
1235
- const decision = appendEdgeChecked(root, { kind: edgeKind, from, to }, hasFlag(rest, '--override'))
1236
- if (!decision.accepted) {
1237
- process.stderr.write(`mission-graph append edge: rejected — ${decision.reason}\n`)
1238
- return 1
1239
- }
1240
- process.stdout.write(`mission-graph: appended edge ${edgeKind} ${from} -> ${to}\n`)
1241
- return 0
1242
- }
1243
-
1244
- function runAppendTombstone(rest: string[], root: string): number {
1245
- const target = flag(rest, '--target')
1246
- if (target === 'node') {
1247
- const id = flag(rest, '--id')
1248
- if (id === undefined) {
1249
- process.stderr.write('mission-graph append tombstone: --id is required for a node tombstone\n')
1250
- return 1
1251
- }
1252
- appendEvent(root, { v: 1, type: 'tombstone', target: 'node', id })
1253
- process.stdout.write(`mission-graph: tombstoned node ${id}\n`)
1254
- return 0
1255
- }
1256
- if (target === 'edge') {
1257
- const edgeKind = flag(rest, '--kind')
1258
- const from = flag(rest, '--from')
1259
- const to = flag(rest, '--to')
1260
- if (!isEdgeKind(edgeKind) || from === undefined || to === undefined) {
1261
- process.stderr.write('mission-graph append tombstone: --kind --from --to are required for an edge tombstone\n')
1262
- return 1
1263
- }
1264
- appendEvent(root, { v: 1, type: 'tombstone', target: 'edge', kind: edgeKind, from, to })
1265
- process.stdout.write(`mission-graph: tombstoned edge ${edgeKind} ${from} -> ${to}\n`)
1266
- return 0
1267
- }
1268
- process.stderr.write('mission-graph append tombstone: --target <node|edge> is required\n')
1269
- return 1
1270
- }
1271
-
1272
- function runAppend(argv: string[], root: string): number {
1273
- const [kind, ...rest] = argv
1274
- if (kind === 'node') return runAppendNode(rest, root)
1275
- if (kind === 'edge') return runAppendEdge(rest, root)
1276
- if (kind === 'tombstone') return runAppendTombstone(rest, root)
1277
- process.stderr.write('mission-graph append: usage: append <node|edge|tombstone> ...\n')
1278
- return 1
1279
- }
1280
-
1281
- export function main(argv: string[]): number {
1282
- const [cmd, ...rest] = argv
1283
- const root = flag(rest, '--root') ?? '.'
1284
- const format = flag(rest, '--format') === 'json' ? 'json' : 'toon'
1285
-
1286
- if (cmd === 'ready') {
1287
- const entries = ready(fold(readEvents(root)))
1288
- process.stdout.write(`${format === 'json' ? JSON.stringify(entries, null, 2) : renderFrontierToon(entries)}\n`)
1289
- return 0
1290
- }
1291
- if (cmd === 'cycles') {
1292
- const items = cycles(fold(readEvents(root)))
1293
- process.stdout.write(`${format === 'json' ? JSON.stringify(items, null, 2) : renderCyclesToon(items)}\n`)
1294
- return 0
1295
- }
1296
- if (cmd === 'operation') {
1297
- const id = flag(rest, '--id')
1298
- if (id === undefined) {
1299
- process.stderr.write('mission-graph operation: --id <operation-id> is required\n')
1300
- return 1
1301
- }
1302
- const check = checkOperation(fold(readEvents(root)), id)
1303
- process.stdout.write(`${format === 'json' ? JSON.stringify(check, null, 2) : renderOperationToon(id, check)}\n`)
1304
- return 0
1305
- }
1306
- if (cmd === 'append') return runAppend(rest, root)
1307
- if (cmd === 'migrate') {
1308
- const result = migrate(root)
1309
- process.stdout.write(
1310
- `${
1311
- format === 'json'
1312
- ? JSON.stringify(result, null, 2)
1313
- : `migrated: ${result.migrated}\nreason: ${result.reason}\ncount: ${result.count}\nretired: ${result.retired}\nretiredReason: ${result.retiredReason}`
1314
- }\n`,
1315
- )
1316
- return result.reason === 'not a git work-tree' ? 1 : 0
1317
- }
1318
- if (cmd === 'sync') {
1319
- const remote = flag(rest, '--remote') ?? 'origin'
1320
- const result = syncStore(root, remote)
1321
- process.stdout.write(`${format === 'json' ? JSON.stringify(result, null, 2) : renderSyncToon(result)}\n`)
1322
- return result.ok ? 0 : 1
1323
- }
1324
-
1325
- process.stderr.write(
1326
- 'mission-graph: usage: ready | cycles | operation --id <id> | append <node|edge|tombstone> ... | migrate | sync [--remote <name>]\n',
1327
- )
1328
- return 1
1329
- }
1330
-
1331
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
1332
- process.exit(main(process.argv.slice(2)))
1333
- }