cyber-sdd 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +17 -0
- package/.codex-plugin/plugin.json +17 -0
- package/.plugin/plugin.json +17 -0
- package/README.md +159 -0
- package/agents/sdd-automaton.md +97 -0
- package/agents/sdd-impl-judge.md +214 -0
- package/agents/sdd-scanner.md +120 -0
- package/agents/sdd-spec-judge.md +224 -0
- package/agents/sdd-warden.md +101 -0
- package/package.json +24 -0
- package/skills/align-spec/README.md +20 -0
- package/skills/align-spec/SKILL.md +111 -0
- package/skills/align-spec/scripts/align-spec.mts +187 -0
- package/skills/architect-impl-governance/README.md +46 -0
- package/skills/architect-impl-governance/SKILL.md +45 -0
- package/skills/architect-spec-governance/README.md +48 -0
- package/skills/architect-spec-governance/SKILL.md +59 -0
- package/skills/blast-estimate/README.md +47 -0
- package/skills/blast-estimate/SKILL.md +133 -0
- package/skills/blast-estimate/scripts/blast-estimate.mts +583 -0
- package/skills/builder-impl-governance/README.md +47 -0
- package/skills/builder-impl-governance/SKILL.md +47 -0
- package/skills/builder-spec-governance/README.md +49 -0
- package/skills/builder-spec-governance/SKILL.md +36 -0
- package/skills/check-partition-quality/README.md +22 -0
- package/skills/check-partition-quality/SKILL.md +51 -0
- package/skills/check-partition-quality/scripts/check-partition-quality.mts +336 -0
- package/skills/check-plan-safety/README.md +17 -0
- package/skills/check-plan-safety/SKILL.md +60 -0
- package/skills/check-plan-safety/scripts/check-plan-safety.mts +145 -0
- package/skills/check-project-specs/README.md +19 -0
- package/skills/check-project-specs/SKILL.md +69 -0
- package/skills/check-project-specs/scripts/check-project-specs.mts +217 -0
- package/skills/check-scenario-overlap/README.md +19 -0
- package/skills/check-scenario-overlap/SKILL.md +74 -0
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +249 -0
- package/skills/check-spec-structure/README.md +17 -0
- package/skills/check-spec-structure/SKILL.md +66 -0
- package/skills/check-spec-structure/scripts/check-spec-structure.mts +346 -0
- package/skills/collision-ladder/README.md +18 -0
- package/skills/collision-ladder/SKILL.md +83 -0
- package/skills/collision-ladder/scripts/collision-ladder.mts +657 -0
- package/skills/combat-log-governance/README.md +13 -0
- package/skills/combat-log-governance/SKILL.md +257 -0
- package/skills/concept-index/README.md +13 -0
- package/skills/concept-index/SKILL.md +38 -0
- package/skills/concept-index/scripts/concept-index.mts +245 -0
- package/skills/discover-plans/README.md +16 -0
- package/skills/discover-plans/SKILL.md +74 -0
- package/skills/discover-plans/scripts/discover-plans.mts +212 -0
- package/skills/discover-specs/README.md +15 -0
- package/skills/discover-specs/SKILL.md +76 -0
- package/skills/discover-specs/scripts/discover-specs.mts +396 -0
- package/skills/doctrine-loop/README.md +15 -0
- package/skills/doctrine-loop/SKILL.md +97 -0
- package/skills/formation-loop/README.md +17 -0
- package/skills/formation-loop/SKILL.md +140 -0
- package/skills/gate-validation-governance/README.md +12 -0
- package/skills/gate-validation-governance/SKILL.md +87 -0
- package/skills/impl-producer-governance/README.md +48 -0
- package/skills/impl-producer-governance/SKILL.md +85 -0
- package/skills/init/README.md +27 -0
- package/skills/init/SKILL.md +68 -0
- package/skills/init/scripts/wire-statusline.mts +276 -0
- package/skills/lifecycle-governance/README.md +11 -0
- package/skills/lifecycle-governance/SKILL.md +168 -0
- package/skills/manage/README.md +9 -0
- package/skills/manage/SKILL.md +62 -0
- package/skills/manage-ignore/README.md +19 -0
- package/skills/manage-ignore/SKILL.md +52 -0
- package/skills/manage-ignore/scripts/manage-ignore.mts +294 -0
- package/skills/manage-scenario-bridge/README.md +20 -0
- package/skills/manage-scenario-bridge/SKILL.md +60 -0
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +156 -0
- package/skills/manage-spec-anchors/README.md +18 -0
- package/skills/manage-spec-anchors/SKILL.md +56 -0
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +328 -0
- package/skills/mission-graph/README.md +15 -0
- package/skills/mission-graph/SKILL.md +67 -0
- package/skills/mission-graph/scripts/mission-graph.mts +844 -0
- package/skills/oracle-spec-governance/README.md +45 -0
- package/skills/oracle-spec-governance/SKILL.md +45 -0
- package/skills/ownership-governance/README.md +65 -0
- package/skills/ownership-governance/SKILL.md +104 -0
- package/skills/pause-mission/README.md +18 -0
- package/skills/pause-mission/SKILL.md +112 -0
- package/skills/place-node/README.md +12 -0
- package/skills/place-node/SKILL.md +47 -0
- package/skills/place-node/scripts/place-node.mts +157 -0
- package/skills/plan-retirement/README.md +32 -0
- package/skills/plan-retirement/SKILL.md +90 -0
- package/skills/plan-retirement/scripts/retire-plans.mts +196 -0
- package/skills/plugin-contract-governance/README.md +12 -0
- package/skills/plugin-contract-governance/SKILL.md +112 -0
- package/skills/remediation-governance/README.md +46 -0
- package/skills/remediation-governance/SKILL.md +78 -0
- package/skills/resolve-governances/README.md +18 -0
- package/skills/resolve-governances/SKILL.md +50 -0
- package/skills/resolve-governances/scripts/resolve-governances.mts +515 -0
- package/skills/resolve-tracking/SKILL.md +64 -0
- package/skills/resolve-tracking/scripts/resolve-tracking.mts +213 -0
- package/skills/resume-mission/README.md +12 -0
- package/skills/resume-mission/SKILL.md +53 -0
- package/skills/scaffold-project-spec/README.md +7 -0
- package/skills/scaffold-project-spec/SKILL.md +192 -0
- package/skills/sdd/README.md +7 -0
- package/skills/sdd/SKILL.md +92 -0
- package/skills/solution-producer-governance/README.md +9 -0
- package/skills/solution-producer-governance/SKILL.md +44 -0
- package/skills/spec-format-governance/README.md +73 -0
- package/skills/spec-format-governance/SKILL.md +114 -0
- package/skills/spec-gate/README.md +26 -0
- package/skills/spec-gate/SKILL.md +201 -0
- package/skills/spec-gate/scripts/check-spec-state.mts +601 -0
- package/skills/spec-gate/scripts/check-suite.mts +501 -0
- package/skills/spec-gate/scripts/classify-edit-class.mts +411 -0
- package/skills/spec-producer-governance/README.md +7 -0
- package/skills/spec-producer-governance/SKILL.md +86 -0
- package/skills/spec-structure-governance/README.md +40 -0
- package/skills/spec-structure-governance/SKILL.md +169 -0
- package/skills/ssa-lowering/README.md +26 -0
- package/skills/ssa-lowering/SKILL.md +181 -0
- package/skills/start-mission/README.md +7 -0
- package/skills/start-mission/SKILL.md +115 -0
- package/skills/suite-format-governance/README.md +75 -0
- package/skills/suite-format-governance/SKILL.md +299 -0
- package/skills/suite-format-governance/references/rubric.md +313 -0
- package/skills/touch-set-correction/README.md +16 -0
- package/skills/touch-set-correction/SKILL.md +67 -0
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +418 -0
- package/skills/verify-scenarios/README.md +17 -0
- package/skills/verify-scenarios/SKILL.md +109 -0
- package/skills/verify-scenarios/scripts/verify-scenarios.mts +386 -0
|
@@ -0,0 +1,844 @@
|
|
|
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 } from 'node:fs'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
|
|
25
|
+
// ── Schema (v:1) ──
|
|
26
|
+
|
|
27
|
+
export type NodeKind = 'mission' | 'operation'
|
|
28
|
+
export type NodeStatus = 'open' | 'claimed' | 'retired'
|
|
29
|
+
export type HitlOrAfk = 'hitl' | 'afk'
|
|
30
|
+
export type EdgeKind = 'RAW' | 'parent-child' | 'discovered-from'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A node event: an add, or a partial update, for one node id. Later events sharing an id are
|
|
34
|
+
* shallow-merged onto earlier ones (only the fields a later event actually carries overwrite —
|
|
35
|
+
* this is how a bare status change folds without repeating the rest of the node). Every field
|
|
36
|
+
* beyond `v`/`type`/`id` is optional so the write path can emit a lean status-only event.
|
|
37
|
+
*/
|
|
38
|
+
export interface NodeEvent {
|
|
39
|
+
v: 1
|
|
40
|
+
type: 'node'
|
|
41
|
+
id: string
|
|
42
|
+
kind?: NodeKind
|
|
43
|
+
status?: NodeStatus
|
|
44
|
+
touchSet?: string[]
|
|
45
|
+
blast?: string
|
|
46
|
+
hitlOrAfk?: HitlOrAfk
|
|
47
|
+
modelTier?: string
|
|
48
|
+
briefPointer?: string
|
|
49
|
+
/** The originating request(s), kept for the record (README "Non-goals" — never acted on). */
|
|
50
|
+
origin?: string[]
|
|
51
|
+
/** Set only on an Operation node: the Mission that marks it "done enough to ship". */
|
|
52
|
+
capstone?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface EdgeEvent {
|
|
56
|
+
v: 1
|
|
57
|
+
type: 'edge'
|
|
58
|
+
kind: EdgeKind
|
|
59
|
+
from: string
|
|
60
|
+
to: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A tombstone retracts one prior node or edge from the fold. An edge tombstone must name the
|
|
65
|
+
* edge's kind — a (from, to) pair can carry more than one edge kind at once.
|
|
66
|
+
*/
|
|
67
|
+
export interface TombstoneEvent {
|
|
68
|
+
v: 1
|
|
69
|
+
type: 'tombstone'
|
|
70
|
+
target: 'node' | 'edge'
|
|
71
|
+
id?: string
|
|
72
|
+
kind?: EdgeKind
|
|
73
|
+
from?: string
|
|
74
|
+
to?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type MissionGraphEvent = NodeEvent | EdgeEvent | TombstoneEvent
|
|
78
|
+
|
|
79
|
+
// ── The folded graph ──
|
|
80
|
+
|
|
81
|
+
export interface GraphNode {
|
|
82
|
+
id: string
|
|
83
|
+
kind: NodeKind
|
|
84
|
+
status: NodeStatus
|
|
85
|
+
touchSet: string[]
|
|
86
|
+
blast: string
|
|
87
|
+
hitlOrAfk: HitlOrAfk
|
|
88
|
+
modelTier: string
|
|
89
|
+
briefPointer: string
|
|
90
|
+
origin: string[]
|
|
91
|
+
capstone?: string
|
|
92
|
+
/** The schema version stamped on the node's latest event (forward-compat placeholder — v1
|
|
93
|
+
* only exists today; a later version arrives on newer events and rides along unread). */
|
|
94
|
+
schemaVersion: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface GraphEdge {
|
|
98
|
+
kind: EdgeKind
|
|
99
|
+
from: string
|
|
100
|
+
to: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Graph {
|
|
104
|
+
nodes: Map<string, GraphNode>
|
|
105
|
+
edges: GraphEdge[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const NODE_DEFAULTS = {
|
|
109
|
+
kind: 'mission' as NodeKind,
|
|
110
|
+
status: 'open' as NodeStatus,
|
|
111
|
+
blast: 'unknown',
|
|
112
|
+
hitlOrAfk: 'hitl' as HitlOrAfk,
|
|
113
|
+
modelTier: 'unspecified',
|
|
114
|
+
briefPointer: '',
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function edgeKey(kind: EdgeKind, from: string, to: string): string {
|
|
118
|
+
return JSON.stringify([kind, from, to])
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* fold — read the whole append-only event log and work out the current picture: the latest
|
|
123
|
+
* fields of each node win, a tombstone drops its target (node or edge) from the result, and a
|
|
124
|
+
* field the reader doesn't recognize rides along unread — no known derivation looks at it, so a
|
|
125
|
+
* later additive schema field never needs a migration or breaks the fold.
|
|
126
|
+
*/
|
|
127
|
+
export function fold(events: readonly MissionGraphEvent[]): Graph {
|
|
128
|
+
const nodeFields = new Map<string, Record<string, unknown>>()
|
|
129
|
+
const tombstonedNodes = new Set<string>()
|
|
130
|
+
const edges = new Map<string, GraphEdge>()
|
|
131
|
+
|
|
132
|
+
for (const ev of events) {
|
|
133
|
+
if (ev.type === 'node') {
|
|
134
|
+
const prev = nodeFields.get(ev.id) ?? {}
|
|
135
|
+
const merged: Record<string, unknown> = { ...prev }
|
|
136
|
+
for (const [key, value] of Object.entries(ev)) {
|
|
137
|
+
if (key === 'type' || value === undefined) continue
|
|
138
|
+
merged[key] = value
|
|
139
|
+
}
|
|
140
|
+
nodeFields.set(ev.id, merged)
|
|
141
|
+
} else if (ev.type === 'edge') {
|
|
142
|
+
edges.set(edgeKey(ev.kind, ev.from, ev.to), { kind: ev.kind, from: ev.from, to: ev.to })
|
|
143
|
+
} else if (ev.type === 'tombstone') {
|
|
144
|
+
if (ev.target === 'node' && ev.id !== undefined) {
|
|
145
|
+
tombstonedNodes.add(ev.id)
|
|
146
|
+
} else if (ev.target === 'edge' && ev.kind !== undefined && ev.from !== undefined && ev.to !== undefined) {
|
|
147
|
+
edges.delete(edgeKey(ev.kind, ev.from, ev.to))
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const nodes = new Map<string, GraphNode>()
|
|
153
|
+
for (const [id, fields] of nodeFields) {
|
|
154
|
+
if (tombstonedNodes.has(id)) continue
|
|
155
|
+
nodes.set(id, {
|
|
156
|
+
id,
|
|
157
|
+
kind: (fields.kind as NodeKind | undefined) ?? NODE_DEFAULTS.kind,
|
|
158
|
+
status: (fields.status as NodeStatus | undefined) ?? NODE_DEFAULTS.status,
|
|
159
|
+
touchSet: Array.isArray(fields.touchSet) ? [...(fields.touchSet as string[])] : [],
|
|
160
|
+
blast: typeof fields.blast === 'string' ? fields.blast : NODE_DEFAULTS.blast,
|
|
161
|
+
hitlOrAfk: (fields.hitlOrAfk as HitlOrAfk | undefined) ?? NODE_DEFAULTS.hitlOrAfk,
|
|
162
|
+
modelTier: typeof fields.modelTier === 'string' ? fields.modelTier : NODE_DEFAULTS.modelTier,
|
|
163
|
+
briefPointer: typeof fields.briefPointer === 'string' ? fields.briefPointer : NODE_DEFAULTS.briefPointer,
|
|
164
|
+
origin: Array.isArray(fields.origin) ? [...(fields.origin as string[])] : [],
|
|
165
|
+
capstone: typeof fields.capstone === 'string' ? fields.capstone : undefined,
|
|
166
|
+
schemaVersion: typeof fields.v === 'number' ? fields.v : 1,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Defensive: an edge whose endpoint was tombstoned (or never existed) never surfaces to a
|
|
171
|
+
// consumer — none of the frozen scenarios exercise this ordering, but a dangling reference
|
|
172
|
+
// must never crash a downstream derivation.
|
|
173
|
+
const liveEdges = [...edges.values()].filter((e) => nodes.has(e.from) && nodes.has(e.to))
|
|
174
|
+
return { nodes, edges: liveEdges }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── The pinned tie-break — numeric-aware, never random ──
|
|
178
|
+
|
|
179
|
+
function extractNumber(id: string): number | null {
|
|
180
|
+
const m = /(\d+)/.exec(id)
|
|
181
|
+
return m ? Number(m[1]) : null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* compareIds — the pinned mission-ref tie-break: numeric-aware so "#136" sorts before "#137" and
|
|
186
|
+
* "M2" before "M10" (a plain string sort would get the latter backwards); falls back to plain
|
|
187
|
+
* string order when either side carries no digits. Deterministic, never random.
|
|
188
|
+
*/
|
|
189
|
+
export function compareIds(a: string, b: string): number {
|
|
190
|
+
const na = extractNumber(a)
|
|
191
|
+
const nb = extractNumber(b)
|
|
192
|
+
if (na !== null && nb !== null && na !== nb) return na - nb
|
|
193
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function intersects(a: readonly string[], b: readonly string[]): boolean {
|
|
197
|
+
if (a.length === 0 || b.length === 0) return false
|
|
198
|
+
const set = new Set(a)
|
|
199
|
+
return b.some((x) => set.has(x))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── RAW — the dependency edges ──
|
|
203
|
+
|
|
204
|
+
/** Every node id with a RAW edge pointing at `id` (its direct "must finish before" predecessors). */
|
|
205
|
+
export function rawPredecessors(graph: Graph, id: string): string[] {
|
|
206
|
+
return graph.edges.filter((e) => e.kind === 'RAW' && e.to === id).map((e) => e.from)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── cycles — SCC over RAW edges only; parent-child/discovered-from never form a "wait for" loop ──
|
|
210
|
+
|
|
211
|
+
export interface RepairItem {
|
|
212
|
+
members: string[]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function rawAdjacency(graph: Graph): Map<string, string[]> {
|
|
216
|
+
const adj = new Map<string, string[]>()
|
|
217
|
+
for (const id of graph.nodes.keys()) adj.set(id, [])
|
|
218
|
+
for (const e of graph.edges) {
|
|
219
|
+
if (e.kind !== 'RAW') continue
|
|
220
|
+
const list = adj.get(e.from) ?? []
|
|
221
|
+
list.push(e.to)
|
|
222
|
+
adj.set(e.from, list)
|
|
223
|
+
}
|
|
224
|
+
return adj
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Tarjan's strongly-connected-components pass, restricted to RAW edges. */
|
|
228
|
+
export function computeSCCs(graph: Graph): string[][] {
|
|
229
|
+
const adj = rawAdjacency(graph)
|
|
230
|
+
let index = 0
|
|
231
|
+
const indices = new Map<string, number>()
|
|
232
|
+
const lowlink = new Map<string, number>()
|
|
233
|
+
const onStack = new Set<string>()
|
|
234
|
+
const stack: string[] = []
|
|
235
|
+
const sccs: string[][] = []
|
|
236
|
+
|
|
237
|
+
function strongconnect(v: string): void {
|
|
238
|
+
indices.set(v, index)
|
|
239
|
+
lowlink.set(v, index)
|
|
240
|
+
index += 1
|
|
241
|
+
stack.push(v)
|
|
242
|
+
onStack.add(v)
|
|
243
|
+
for (const w of adj.get(v) ?? []) {
|
|
244
|
+
if (!indices.has(w)) {
|
|
245
|
+
strongconnect(w)
|
|
246
|
+
lowlink.set(v, Math.min(lowlink.get(v) ?? 0, lowlink.get(w) ?? 0))
|
|
247
|
+
} else if (onStack.has(w)) {
|
|
248
|
+
lowlink.set(v, Math.min(lowlink.get(v) ?? 0, indices.get(w) ?? 0))
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (lowlink.get(v) === indices.get(v)) {
|
|
252
|
+
const component: string[] = []
|
|
253
|
+
let w: string | undefined
|
|
254
|
+
do {
|
|
255
|
+
w = stack.pop()
|
|
256
|
+
if (w === undefined) break
|
|
257
|
+
onStack.delete(w)
|
|
258
|
+
component.push(w)
|
|
259
|
+
} while (w !== v)
|
|
260
|
+
sccs.push(component)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const v of [...graph.nodes.keys()].sort(compareIds)) {
|
|
265
|
+
if (!indices.has(v)) strongconnect(v)
|
|
266
|
+
}
|
|
267
|
+
return sccs
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* cycles — reports every RAW strongly-connected component of size > 1, plus any single-node RAW
|
|
272
|
+
* self-loop, as a repair item. An acyclic graph reports none. Never throws — a knotted plan is
|
|
273
|
+
* surfaced, not crashed on.
|
|
274
|
+
*/
|
|
275
|
+
export function cycles(graph: Graph): RepairItem[] {
|
|
276
|
+
const sccs = computeSCCs(graph)
|
|
277
|
+
const selfLoop = new Set(graph.edges.filter((e) => e.kind === 'RAW' && e.from === e.to).map((e) => e.from))
|
|
278
|
+
const items: RepairItem[] = []
|
|
279
|
+
for (const comp of sccs) {
|
|
280
|
+
const isCycle = comp.length > 1 || selfLoop.has(comp[0])
|
|
281
|
+
if (!isCycle) continue
|
|
282
|
+
items.push({ members: [...comp].sort(compareIds) })
|
|
283
|
+
}
|
|
284
|
+
return items.sort((a, b) => compareIds(a.members[0], b.members[0]))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Every mission id sitting on a RAW cycle — excluded from `ready` and from unblocking anything
|
|
288
|
+
* that depends on it; the only way clear is repairing the plan (a merge, or a tombstoned edge). */
|
|
289
|
+
export function quarantinedIds(graph: Graph): Set<string> {
|
|
290
|
+
const ids = new Set<string>()
|
|
291
|
+
for (const item of cycles(graph)) for (const m of item.members) ids.add(m)
|
|
292
|
+
return ids
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── The write-time cycle guard (a pure decision — the seam applies it before appending) ──
|
|
296
|
+
|
|
297
|
+
function canReach(graph: Graph, start: string, target: string): boolean {
|
|
298
|
+
const visited = new Set<string>()
|
|
299
|
+
const stack = [start]
|
|
300
|
+
while (stack.length > 0) {
|
|
301
|
+
const cur = stack.pop()
|
|
302
|
+
if (cur === undefined) continue
|
|
303
|
+
if (cur === target) return true
|
|
304
|
+
if (visited.has(cur)) continue
|
|
305
|
+
visited.add(cur)
|
|
306
|
+
for (const e of graph.edges) {
|
|
307
|
+
if (e.kind === 'RAW' && e.from === cur) stack.push(e.to)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return false
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Would appending this edge close a RAW cycle? Only RAW ("wait for") edges can — parent-child
|
|
314
|
+
* and discovered-from are exempt from the guard entirely. */
|
|
315
|
+
export function wouldCloseCycle(graph: Graph, proposed: { kind: EdgeKind; from: string; to: string }): boolean {
|
|
316
|
+
if (proposed.kind !== 'RAW') return false
|
|
317
|
+
if (proposed.from === proposed.to) return true
|
|
318
|
+
return canReach(graph, proposed.to, proposed.from)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface EdgeDecision {
|
|
322
|
+
accepted: boolean
|
|
323
|
+
reason?: string
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* proposeEdge — the gentle write-time guard: rejects a RAW edge that would close a cycle unless
|
|
328
|
+
* the writer passes `override: true` to record a genuinely-discovered mutual dependency.
|
|
329
|
+
*/
|
|
330
|
+
export function proposeEdge(
|
|
331
|
+
graph: Graph,
|
|
332
|
+
proposed: { kind: EdgeKind; from: string; to: string },
|
|
333
|
+
override = false,
|
|
334
|
+
): EdgeDecision {
|
|
335
|
+
if (!override && wouldCloseCycle(graph, proposed)) {
|
|
336
|
+
return {
|
|
337
|
+
accepted: false,
|
|
338
|
+
reason: `would close a RAW cycle (${proposed.to} already reaches ${proposed.from})`,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return { accepted: true }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Operations — grouping (parent-child) ──
|
|
345
|
+
|
|
346
|
+
function declaredMembers(graph: Graph, operationId: string): string[] {
|
|
347
|
+
return graph.edges.filter((e) => e.kind === 'parent-child' && e.from === operationId).map((e) => e.to)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The Operation a Mission is declared under (via a parent-child edge), or null when it is its
|
|
351
|
+
* own Mission with no group. */
|
|
352
|
+
export function operationOf(graph: Graph, missionId: string): string | null {
|
|
353
|
+
const edge = graph.edges.find((e) => e.kind === 'parent-child' && e.to === missionId)
|
|
354
|
+
return edge ? edge.from : null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export interface OperationSummary {
|
|
358
|
+
id: string
|
|
359
|
+
capstone?: string
|
|
360
|
+
members: string[]
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Every Operation node and its declared Mission members, sorted for stable reading. */
|
|
364
|
+
export function listOperations(graph: Graph): OperationSummary[] {
|
|
365
|
+
const ops: OperationSummary[] = []
|
|
366
|
+
for (const n of graph.nodes.values()) {
|
|
367
|
+
if (n.kind !== 'operation') continue
|
|
368
|
+
ops.push({ id: n.id, capstone: n.capstone, members: declaredMembers(graph, n.id).sort(compareIds) })
|
|
369
|
+
}
|
|
370
|
+
return ops.sort((a, b) => compareIds(a.id, b.id))
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function rawClosure(graph: Graph, start: string): Set<string> {
|
|
374
|
+
const seen = new Set<string>()
|
|
375
|
+
const stack = [start]
|
|
376
|
+
while (stack.length > 0) {
|
|
377
|
+
const cur = stack.pop()
|
|
378
|
+
if (cur === undefined || seen.has(cur)) continue
|
|
379
|
+
seen.add(cur)
|
|
380
|
+
for (const e of graph.edges) {
|
|
381
|
+
if (e.kind === 'RAW' && e.to === cur) stack.push(e.from)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return seen
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface OperationCheck {
|
|
388
|
+
dependencyClosed: boolean
|
|
389
|
+
missingPrereqs: string[]
|
|
390
|
+
releaseFloor: string[]
|
|
391
|
+
progress: { completed: number; total: number }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* checkOperation — the shippable-group view for one Operation: dependency-closed iff the
|
|
396
|
+
* capstone's RAW closure is a subset of the declared set (a missing prerequisite is flagged; a
|
|
397
|
+
* declared support member outside the closure is legal and never flagged); the release floor is
|
|
398
|
+
* the capstone's closure alone (support members never gate release); progress is completed/total
|
|
399
|
+
* over the declared Missions only.
|
|
400
|
+
*/
|
|
401
|
+
export function checkOperation(graph: Graph, operationId: string): OperationCheck {
|
|
402
|
+
const declared = declaredMembers(graph, operationId)
|
|
403
|
+
const declaredSet = new Set(declared)
|
|
404
|
+
const capstone = graph.nodes.get(operationId)?.capstone
|
|
405
|
+
const closure = capstone !== undefined ? rawClosure(graph, capstone) : new Set<string>()
|
|
406
|
+
const missingPrereqs = [...closure].filter((id) => !declaredSet.has(id)).sort(compareIds)
|
|
407
|
+
const completed = declared.filter((id) => graph.nodes.get(id)?.status === 'retired').length
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
dependencyClosed: missingPrereqs.length === 0,
|
|
411
|
+
missingPrereqs,
|
|
412
|
+
releaseFloor: [...closure].sort(compareIds),
|
|
413
|
+
progress: { completed, total: declared.length },
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── ready — the RAW-satisfied, not-WAW-held frontier ──
|
|
418
|
+
|
|
419
|
+
export interface FrontierEntry {
|
|
420
|
+
id: string
|
|
421
|
+
/** The node's kind — always 'mission' in v1: an Operation is a container, never itself
|
|
422
|
+
* scheduled, so it never becomes a frontier candidate. */
|
|
423
|
+
node: NodeKind
|
|
424
|
+
operation: string | null
|
|
425
|
+
blast: string
|
|
426
|
+
hitlOrAfk: HitlOrAfk
|
|
427
|
+
modelTier: string
|
|
428
|
+
briefPointer: string
|
|
429
|
+
whyReady: string
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function isRawSatisfied(graph: Graph, quarantined: ReadonlySet<string>, id: string): boolean {
|
|
433
|
+
return rawPredecessors(graph, id).every((p) => {
|
|
434
|
+
if (quarantined.has(p)) return false
|
|
435
|
+
return graph.nodes.get(p)?.status === 'retired'
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function whyReady(graph: Graph, id: string): string {
|
|
440
|
+
const preds = rawPredecessors(graph, id)
|
|
441
|
+
if (preds.length === 0) return 'no RAW predecessors'
|
|
442
|
+
return `RAW-satisfied: ${[...preds].sort(compareIds).join(', ')} retired`
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* ready — reads the graph without changing it and returns every Mission that is both RAW-satisfied
|
|
447
|
+
* (every RAW predecessor retired, transitively — realized here as each direct predecessor's own
|
|
448
|
+
* recorded status, which composes: a blocked predecessor can never itself be retired) and not
|
|
449
|
+
* WAW-held. Two WAW triggers: (1) an open candidate whose touch-set intersects an in-flight
|
|
450
|
+
* (claimed) mission's is held; (2) among the remaining RAW-satisfied candidates, an intersecting
|
|
451
|
+
* pair never both surface — the pinned lowest-id tie-break (compareIds) admits one and holds the
|
|
452
|
+
* rest, guaranteeing the frontier never contains a WAW pair. A cycle-quarantined mission (and
|
|
453
|
+
* anything depending on it) is excluded regardless of its recorded status. Deterministic: same
|
|
454
|
+
* graph in, same frontier in the same order out. Read-only: never mutates `graph`.
|
|
455
|
+
*/
|
|
456
|
+
export function ready(graph: Graph): FrontierEntry[] {
|
|
457
|
+
const quarantined = quarantinedIds(graph)
|
|
458
|
+
const missions = [...graph.nodes.values()].filter((n) => n.kind === 'mission')
|
|
459
|
+
const inFlightTouchSets = missions.filter((n) => n.status === 'claimed').map((n) => n.touchSet)
|
|
460
|
+
|
|
461
|
+
const candidates = missions.filter(
|
|
462
|
+
(n) => n.status === 'open' && !quarantined.has(n.id) && isRawSatisfied(graph, quarantined, n.id),
|
|
463
|
+
)
|
|
464
|
+
const notHeldByInFlight = candidates.filter((n) => !inFlightTouchSets.some((t) => intersects(t, n.touchSet)))
|
|
465
|
+
|
|
466
|
+
// Trigger 2: the pinned lowest-id tie-break. Processing in ascending id order and admitting a
|
|
467
|
+
// candidate only when its touch-set is disjoint from every already-admitted (necessarily
|
|
468
|
+
// lower-id) candidate's guarantees no two admitted candidates ever collide.
|
|
469
|
+
const sorted = [...notHeldByInFlight].sort((a, b) => compareIds(a.id, b.id))
|
|
470
|
+
const admitted: GraphNode[] = []
|
|
471
|
+
const claimedTouch: string[] = []
|
|
472
|
+
for (const n of sorted) {
|
|
473
|
+
if (intersects(n.touchSet, claimedTouch)) continue
|
|
474
|
+
admitted.push(n)
|
|
475
|
+
claimedTouch.push(...n.touchSet)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return admitted
|
|
479
|
+
.sort((a, b) => compareIds(a.id, b.id))
|
|
480
|
+
.map((n) => ({
|
|
481
|
+
id: n.id,
|
|
482
|
+
node: n.kind,
|
|
483
|
+
operation: operationOf(graph, n.id),
|
|
484
|
+
blast: n.blast,
|
|
485
|
+
hitlOrAfk: n.hitlOrAfk,
|
|
486
|
+
modelTier: n.modelTier,
|
|
487
|
+
briefPointer: n.briefPointer,
|
|
488
|
+
whyReady: whyReady(graph, n.id),
|
|
489
|
+
}))
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── Store IO seam (v1: in-tree JSONL under <root>/.agents/mission-graph/events.jsonl;
|
|
493
|
+
// F3 adds a second backend: a git orphan ref, branch-independent and working-tree-clean) ──
|
|
494
|
+
// Isolated so a store swap never touches any pure function above — every one of them takes/
|
|
495
|
+
// returns plain data, with no fs access of its own.
|
|
496
|
+
|
|
497
|
+
const STORE_RELATIVE_PATH = ['.agents', 'mission-graph', 'events.jsonl']
|
|
498
|
+
|
|
499
|
+
function storePath(root: string): string {
|
|
500
|
+
return join(root, ...STORE_RELATIVE_PATH)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Read the in-tree JSONL store's raw (trimmed, non-empty) lines — used by both the in-tree
|
|
504
|
+
* backend and `migrate` (which copies these verbatim, never re-serialized). */
|
|
505
|
+
function readInTreeLines(root: string): string[] {
|
|
506
|
+
const path = storePath(root)
|
|
507
|
+
if (!existsSync(path)) return []
|
|
508
|
+
return readFileSync(path, 'utf8')
|
|
509
|
+
.split('\n')
|
|
510
|
+
.map((line) => line.trim())
|
|
511
|
+
.filter((line) => line.length > 0)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function readInTreeEvents(root: string): MissionGraphEvent[] {
|
|
515
|
+
return readInTreeLines(root).map((line) => JSON.parse(line) as MissionGraphEvent)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function appendInTreeEvent(root: string, event: MissionGraphEvent): void {
|
|
519
|
+
const path = storePath(root)
|
|
520
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
521
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ── F3: the orphan-ref backend — refs/sdd/mission-graph, read/written via git plumbing ──
|
|
525
|
+
|
|
526
|
+
export const ORPHAN_REF = 'refs/sdd/mission-graph'
|
|
527
|
+
const ORPHAN_STORE_FILE = 'events.jsonl'
|
|
528
|
+
|
|
529
|
+
function git(root: string, args: string[], input?: string): string {
|
|
530
|
+
return execFileSync('git', args, { cwd: root, input, encoding: 'utf8' }).trim()
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function isGitWorkTree(root: string): boolean {
|
|
534
|
+
try {
|
|
535
|
+
return git(root, ['rev-parse', '--is-inside-work-tree']) === 'true'
|
|
536
|
+
} catch {
|
|
537
|
+
return false
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function orphanRefExists(root: string): boolean {
|
|
542
|
+
try {
|
|
543
|
+
git(root, ['rev-parse', '--verify', '--quiet', ORPHAN_REF])
|
|
544
|
+
return true
|
|
545
|
+
} catch {
|
|
546
|
+
return false
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function orphanHead(root: string): string | null {
|
|
551
|
+
if (!orphanRefExists(root)) return null
|
|
552
|
+
return git(root, ['rev-parse', ORPHAN_REF])
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function readOrphanLines(root: string): string[] {
|
|
556
|
+
if (!orphanRefExists(root)) return []
|
|
557
|
+
const text = git(root, ['cat-file', '-p', `${ORPHAN_REF}:${ORPHAN_STORE_FILE}`])
|
|
558
|
+
return text
|
|
559
|
+
.split('\n')
|
|
560
|
+
.map((line) => line.trim())
|
|
561
|
+
.filter((line) => line.length > 0)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function readOrphanEvents(root: string): MissionGraphEvent[] {
|
|
565
|
+
return readOrphanLines(root).map((line) => JSON.parse(line) as MissionGraphEvent)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** blob -> tree -> commit -> update-ref, compare-and-swap on `expectedOld` (the ref value the
|
|
569
|
+
* caller last observed). `null` means "the ref must not yet exist"; a stale/mismatched old value
|
|
570
|
+
* makes `update-ref` throw — that throw IS the CAS rejection, and the ref is left unchanged. */
|
|
571
|
+
export function commitOrphan(root: string, lines: string[], expectedOld: string | null): string {
|
|
572
|
+
const content = lines.length ? `${lines.join('\n')}\n` : ''
|
|
573
|
+
const blob = git(root, ['hash-object', '-w', '--stdin'], content)
|
|
574
|
+
const tree = git(root, ['mktree'], `100644 blob ${blob}\t${ORPHAN_STORE_FILE}\n`)
|
|
575
|
+
const commitArgs = ['commit-tree', tree]
|
|
576
|
+
if (expectedOld !== null) commitArgs.push('-p', expectedOld)
|
|
577
|
+
const commit = git(root, commitArgs, 'mission-graph: append')
|
|
578
|
+
git(root, ['update-ref', ORPHAN_REF, commit, expectedOld ?? ''])
|
|
579
|
+
return commit
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function appendOrphanEvent(root: string, event: MissionGraphEvent): void {
|
|
583
|
+
const old = orphanHead(root)
|
|
584
|
+
const lines = readOrphanLines(root)
|
|
585
|
+
lines.push(JSON.stringify(event))
|
|
586
|
+
commitOrphan(root, lines, old)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── Backend selection ──
|
|
590
|
+
|
|
591
|
+
export type StoreBackend = 'in-tree' | 'orphan-ref'
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* resolveBackend — an explicit `MISSION_GRAPH_STORE` override wins outright; otherwise a
|
|
595
|
+
* non-git-work-tree always stays in-tree (the orphan-ref backend needs git plumbing); inside a
|
|
596
|
+
* work-tree, an existing orphan ref wins (already migrated); otherwise an existing in-tree store
|
|
597
|
+
* keeps the in-tree backend so a pre-migrate v1 adopter is never silently orphaned; only a fresh
|
|
598
|
+
* work-tree with neither picks the orphan-ref backend outright.
|
|
599
|
+
*/
|
|
600
|
+
export function resolveBackend(root: string, env: Record<string, string | undefined> = process.env): StoreBackend {
|
|
601
|
+
const override = env.MISSION_GRAPH_STORE
|
|
602
|
+
if (override === 'in-tree' || override === 'orphan-ref') return override
|
|
603
|
+
if (!isGitWorkTree(root)) return 'in-tree'
|
|
604
|
+
if (orphanRefExists(root)) return 'orphan-ref'
|
|
605
|
+
if (existsSync(storePath(root))) return 'in-tree'
|
|
606
|
+
return 'orphan-ref'
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Read the append-only event log back. An absent store reads as the empty log (a fresh project). */
|
|
610
|
+
export function readEvents(root: string): MissionGraphEvent[] {
|
|
611
|
+
if (resolveBackend(root) === 'orphan-ref') return readOrphanEvents(root)
|
|
612
|
+
return readInTreeEvents(root)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Append one event line. The one writer for now is a person at the CLI; later, one automated
|
|
616
|
+
* coordinator — never split across writers, so there is nothing to reconcile. */
|
|
617
|
+
export function appendEvent(root: string, event: MissionGraphEvent): void {
|
|
618
|
+
if (resolveBackend(root) === 'orphan-ref') {
|
|
619
|
+
appendOrphanEvent(root, event)
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
appendInTreeEvent(root, event)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export interface MigrateResult {
|
|
626
|
+
migrated: boolean
|
|
627
|
+
reason: string
|
|
628
|
+
count: number
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* migrate — one-time, idempotent: seeds the orphan ref from the existing in-tree store, copying
|
|
633
|
+
* its raw lines verbatim (never parsed and re-serialized, so exact bytes/order are preserved).
|
|
634
|
+
* A no-op (never throws) when there is nothing to seed from, or the ref is already seeded.
|
|
635
|
+
*/
|
|
636
|
+
export function migrate(root: string): MigrateResult {
|
|
637
|
+
if (!isGitWorkTree(root)) return { migrated: false, reason: 'not a git work-tree', count: 0 }
|
|
638
|
+
if (orphanRefExists(root)) {
|
|
639
|
+
return { migrated: false, reason: 'orphan ref already seeded', count: readOrphanLines(root).length }
|
|
640
|
+
}
|
|
641
|
+
const rawLines = readInTreeLines(root)
|
|
642
|
+
if (rawLines.length === 0) return { migrated: false, reason: 'no in-tree store to seed from', count: 0 }
|
|
643
|
+
commitOrphan(root, rawLines, null)
|
|
644
|
+
return { migrated: true, reason: 'seeded orphan ref from in-tree store', count: rawLines.length }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Append a proposed RAW/parent-child/discovered-from edge through the write-time cycle guard;
|
|
648
|
+
* only appends when the guard accepts (or the caller overrides it). */
|
|
649
|
+
export function appendEdgeChecked(
|
|
650
|
+
root: string,
|
|
651
|
+
proposed: { kind: EdgeKind; from: string; to: string },
|
|
652
|
+
override = false,
|
|
653
|
+
): EdgeDecision {
|
|
654
|
+
const graph = fold(readEvents(root))
|
|
655
|
+
const decision = proposeEdge(graph, proposed, override)
|
|
656
|
+
if (decision.accepted) appendEvent(root, { v: 1, type: 'edge', ...proposed })
|
|
657
|
+
return decision
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ── Render (TOON — the token-efficient tabular form the repo's other sdd engines emit) ──
|
|
661
|
+
|
|
662
|
+
function toonQuote(v: string): string {
|
|
663
|
+
if (v === '' || /[",;]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
|
|
664
|
+
return v
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function renderFrontierToon(entries: FrontierEntry[]): string {
|
|
668
|
+
const header = `ready[${entries.length}]{id,node,operation,blast,hitlOrAfk,modelTier,briefPointer,whyReady}:`
|
|
669
|
+
const rows = entries.map((e) =>
|
|
670
|
+
[e.id, e.node, e.operation ?? '', e.blast, e.hitlOrAfk, e.modelTier, e.briefPointer, e.whyReady]
|
|
671
|
+
.map((v) => toonQuote(v))
|
|
672
|
+
.join(','),
|
|
673
|
+
)
|
|
674
|
+
return [header, ...rows.map((r) => ` ${r}`)].join('\n')
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function renderCyclesToon(items: RepairItem[]): string {
|
|
678
|
+
const header = `cycles[${items.length}]{scc,members}:`
|
|
679
|
+
const rows = items.map((item, i) => ` scc-${i + 1},"${item.members.join(';')}"`)
|
|
680
|
+
return [header, ...rows].join('\n')
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export function renderOperationToon(id: string, check: OperationCheck): string {
|
|
684
|
+
return [
|
|
685
|
+
`operation: ${id}`,
|
|
686
|
+
`dependencyClosed: ${check.dependencyClosed}`,
|
|
687
|
+
`missingPrereqs: ${check.missingPrereqs.join(';')}`,
|
|
688
|
+
`releaseFloor: ${check.releaseFloor.join(';')}`,
|
|
689
|
+
`progress: ${check.progress.completed}/${check.progress.total}`,
|
|
690
|
+
].join('\n')
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── CLI ──
|
|
694
|
+
|
|
695
|
+
function flag(argv: string[], name: string): string | undefined {
|
|
696
|
+
const i = argv.indexOf(name)
|
|
697
|
+
return i === -1 ? undefined : argv[i + 1]
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function hasFlag(argv: string[], name: string): boolean {
|
|
701
|
+
return argv.includes(name)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function splitCsv(v: string | undefined): string[] {
|
|
705
|
+
if (v === undefined || v === '') return []
|
|
706
|
+
return v
|
|
707
|
+
.split(',')
|
|
708
|
+
.map((s) => s.trim())
|
|
709
|
+
.filter((s) => s.length > 0)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function isEdgeKind(v: string | undefined): v is EdgeKind {
|
|
713
|
+
return v === 'RAW' || v === 'parent-child' || v === 'discovered-from'
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function runAppendNode(rest: string[], root: string): number {
|
|
717
|
+
const id = flag(rest, '--id')
|
|
718
|
+
if (id === undefined) {
|
|
719
|
+
process.stderr.write('mission-graph append node: --id is required\n')
|
|
720
|
+
return 1
|
|
721
|
+
}
|
|
722
|
+
const event: NodeEvent = { v: 1, type: 'node', id }
|
|
723
|
+
const nodeKind = flag(rest, '--kind')
|
|
724
|
+
if (nodeKind === 'mission' || nodeKind === 'operation') event.kind = nodeKind
|
|
725
|
+
const status = flag(rest, '--status')
|
|
726
|
+
if (status === 'open' || status === 'claimed' || status === 'retired') event.status = status
|
|
727
|
+
const touchSet = flag(rest, '--touch-set')
|
|
728
|
+
if (touchSet !== undefined) event.touchSet = splitCsv(touchSet)
|
|
729
|
+
const blast = flag(rest, '--blast')
|
|
730
|
+
if (blast !== undefined) event.blast = blast
|
|
731
|
+
if (hasFlag(rest, '--hitl')) event.hitlOrAfk = 'hitl'
|
|
732
|
+
if (hasFlag(rest, '--afk')) event.hitlOrAfk = 'afk'
|
|
733
|
+
const modelTier = flag(rest, '--model-tier')
|
|
734
|
+
if (modelTier !== undefined) event.modelTier = modelTier
|
|
735
|
+
const briefPointer = flag(rest, '--brief-pointer')
|
|
736
|
+
if (briefPointer !== undefined) event.briefPointer = briefPointer
|
|
737
|
+
const capstone = flag(rest, '--capstone')
|
|
738
|
+
if (capstone !== undefined) event.capstone = capstone
|
|
739
|
+
appendEvent(root, event)
|
|
740
|
+
process.stdout.write(`mission-graph: appended node ${id}\n`)
|
|
741
|
+
return 0
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function runAppendEdge(rest: string[], root: string): number {
|
|
745
|
+
const edgeKind = flag(rest, '--kind')
|
|
746
|
+
const from = flag(rest, '--from')
|
|
747
|
+
const to = flag(rest, '--to')
|
|
748
|
+
if (!isEdgeKind(edgeKind) || from === undefined || to === undefined) {
|
|
749
|
+
process.stderr.write(
|
|
750
|
+
'mission-graph append edge: --kind <RAW|parent-child|discovered-from> --from <id> --to <id> are required\n',
|
|
751
|
+
)
|
|
752
|
+
return 1
|
|
753
|
+
}
|
|
754
|
+
const decision = appendEdgeChecked(root, { kind: edgeKind, from, to }, hasFlag(rest, '--override'))
|
|
755
|
+
if (!decision.accepted) {
|
|
756
|
+
process.stderr.write(`mission-graph append edge: rejected — ${decision.reason}\n`)
|
|
757
|
+
return 1
|
|
758
|
+
}
|
|
759
|
+
process.stdout.write(`mission-graph: appended edge ${edgeKind} ${from} -> ${to}\n`)
|
|
760
|
+
return 0
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function runAppendTombstone(rest: string[], root: string): number {
|
|
764
|
+
const target = flag(rest, '--target')
|
|
765
|
+
if (target === 'node') {
|
|
766
|
+
const id = flag(rest, '--id')
|
|
767
|
+
if (id === undefined) {
|
|
768
|
+
process.stderr.write('mission-graph append tombstone: --id is required for a node tombstone\n')
|
|
769
|
+
return 1
|
|
770
|
+
}
|
|
771
|
+
appendEvent(root, { v: 1, type: 'tombstone', target: 'node', id })
|
|
772
|
+
process.stdout.write(`mission-graph: tombstoned node ${id}\n`)
|
|
773
|
+
return 0
|
|
774
|
+
}
|
|
775
|
+
if (target === 'edge') {
|
|
776
|
+
const edgeKind = flag(rest, '--kind')
|
|
777
|
+
const from = flag(rest, '--from')
|
|
778
|
+
const to = flag(rest, '--to')
|
|
779
|
+
if (!isEdgeKind(edgeKind) || from === undefined || to === undefined) {
|
|
780
|
+
process.stderr.write('mission-graph append tombstone: --kind --from --to are required for an edge tombstone\n')
|
|
781
|
+
return 1
|
|
782
|
+
}
|
|
783
|
+
appendEvent(root, { v: 1, type: 'tombstone', target: 'edge', kind: edgeKind, from, to })
|
|
784
|
+
process.stdout.write(`mission-graph: tombstoned edge ${edgeKind} ${from} -> ${to}\n`)
|
|
785
|
+
return 0
|
|
786
|
+
}
|
|
787
|
+
process.stderr.write('mission-graph append tombstone: --target <node|edge> is required\n')
|
|
788
|
+
return 1
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function runAppend(argv: string[], root: string): number {
|
|
792
|
+
const [kind, ...rest] = argv
|
|
793
|
+
if (kind === 'node') return runAppendNode(rest, root)
|
|
794
|
+
if (kind === 'edge') return runAppendEdge(rest, root)
|
|
795
|
+
if (kind === 'tombstone') return runAppendTombstone(rest, root)
|
|
796
|
+
process.stderr.write('mission-graph append: usage: append <node|edge|tombstone> ...\n')
|
|
797
|
+
return 1
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export function main(argv: string[]): number {
|
|
801
|
+
const [cmd, ...rest] = argv
|
|
802
|
+
const root = flag(rest, '--root') ?? '.'
|
|
803
|
+
const format = flag(rest, '--format') === 'json' ? 'json' : 'toon'
|
|
804
|
+
|
|
805
|
+
if (cmd === 'ready') {
|
|
806
|
+
const entries = ready(fold(readEvents(root)))
|
|
807
|
+
process.stdout.write(`${format === 'json' ? JSON.stringify(entries, null, 2) : renderFrontierToon(entries)}\n`)
|
|
808
|
+
return 0
|
|
809
|
+
}
|
|
810
|
+
if (cmd === 'cycles') {
|
|
811
|
+
const items = cycles(fold(readEvents(root)))
|
|
812
|
+
process.stdout.write(`${format === 'json' ? JSON.stringify(items, null, 2) : renderCyclesToon(items)}\n`)
|
|
813
|
+
return 0
|
|
814
|
+
}
|
|
815
|
+
if (cmd === 'operation') {
|
|
816
|
+
const id = flag(rest, '--id')
|
|
817
|
+
if (id === undefined) {
|
|
818
|
+
process.stderr.write('mission-graph operation: --id <operation-id> is required\n')
|
|
819
|
+
return 1
|
|
820
|
+
}
|
|
821
|
+
const check = checkOperation(fold(readEvents(root)), id)
|
|
822
|
+
process.stdout.write(`${format === 'json' ? JSON.stringify(check, null, 2) : renderOperationToon(id, check)}\n`)
|
|
823
|
+
return 0
|
|
824
|
+
}
|
|
825
|
+
if (cmd === 'append') return runAppend(rest, root)
|
|
826
|
+
if (cmd === 'migrate') {
|
|
827
|
+
const result = migrate(root)
|
|
828
|
+
process.stdout.write(
|
|
829
|
+
`${
|
|
830
|
+
format === 'json'
|
|
831
|
+
? JSON.stringify(result, null, 2)
|
|
832
|
+
: `migrated: ${result.migrated}\nreason: ${result.reason}\ncount: ${result.count}`
|
|
833
|
+
}\n`,
|
|
834
|
+
)
|
|
835
|
+
return result.reason === 'not a git work-tree' ? 1 : 0
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
process.stderr.write(
|
|
839
|
+
'mission-graph: usage: ready | cycles | operation --id <id> | append <node|edge|tombstone> ... | migrate\n',
|
|
840
|
+
)
|
|
841
|
+
return 1
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)))
|