cyber-sdd 0.0.0 → 0.2.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/.plugin/pins.json +3 -0
- package/LICENSE +21 -0
- package/agents/sdd-automaton.md +13 -2
- package/agents/sdd-scanner.md +85 -0
- package/agents/sdd-spec-judge.md +32 -2
- package/agents/sdd-warden.md +9 -0
- package/package.json +30 -23
- package/skills/align-spec/scripts/align-spec.mts +3 -2
- package/skills/architect-spec-governance/README.md +1 -0
- package/skills/architect-spec-governance/SKILL.md +12 -1
- package/skills/blast-estimate/README.md +3 -5
- package/skills/blast-estimate/SKILL.md +2 -2
- package/skills/blast-estimate/scripts/blast-estimate.mts +7 -4
- package/skills/builder-impl-governance/SKILL.md +9 -1
- package/skills/builder-spec-governance/README.md +1 -0
- package/skills/builder-spec-governance/SKILL.md +31 -3
- package/skills/check-partition-quality/scripts/check-partition-quality.mts +3 -1
- package/skills/check-plan-safety/scripts/check-plan-safety.mts +5 -2
- package/skills/check-project-specs/scripts/check-project-specs.mts +67 -5
- package/skills/check-retired-terms/README.md +18 -0
- package/skills/check-retired-terms/SKILL.md +81 -0
- package/skills/check-retired-terms/scripts/check-retired-terms.mts +293 -0
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +3 -2
- package/skills/check-spec-structure/scripts/check-spec-structure.mts +3 -2
- package/skills/collision-ladder/README.md +3 -5
- package/skills/collision-ladder/scripts/collision-ladder.mts +7 -4
- package/skills/combat-log-governance/SKILL.md +43 -4
- package/skills/concept-index/scripts/concept-index.mts +3 -2
- package/skills/discover-plans/scripts/discover-plans.mts +5 -2
- package/skills/discover-specs/scripts/discover-specs.mts +5 -2
- package/skills/doctrine-loop/README.md +6 -0
- package/skills/doctrine-loop/SKILL.md +136 -2
- package/skills/formation-loop/SKILL.md +21 -1
- package/skills/gate-validation-governance/SKILL.md +2 -2
- package/skills/impl-producer-governance/SKILL.md +10 -1
- package/skills/init/scripts/wire-statusline.mts +5 -2
- package/skills/lifecycle-governance/SKILL.md +1 -1
- package/skills/manage-ignore/scripts/manage-ignore.mts +5 -2
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +5 -2
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +5 -2
- package/skills/mission-graph/README.md +3 -5
- package/skills/mission-graph/SKILL.md +72 -5
- package/skills/mission-graph/scripts/mission-graph.mts +505 -16
- package/skills/oracle-spec-governance/README.md +7 -2
- package/skills/oracle-spec-governance/SKILL.md +21 -4
- package/skills/place-node/scripts/place-node.mts +3 -2
- package/skills/plan-retirement/README.md +5 -2
- package/skills/plan-retirement/SKILL.md +5 -1
- package/skills/plan-retirement/scripts/retire-plans.mts +5 -2
- package/skills/plugin-contract-governance/SKILL.md +7 -1
- package/skills/remediation-governance/SKILL.md +36 -1
- package/skills/resolve-governances/scripts/resolve-governances.mts +5 -2
- package/skills/resolve-tracking/SKILL.md +2 -2
- package/skills/resolve-tracking/scripts/resolve-tracking.mts +5 -4
- package/skills/sdd/SKILL.md +1 -1
- package/skills/spec-format-governance/README.md +1 -1
- package/skills/spec-format-governance/SKILL.md +76 -8
- package/skills/spec-gate/SKILL.md +18 -2
- package/skills/spec-gate/scripts/check-spec-state.mts +47 -11
- package/skills/spec-gate/scripts/check-suite.mts +53 -17
- package/skills/spec-gate/scripts/classify-edit-class.mts +10 -7
- package/skills/spec-producer-governance/README.md +1 -1
- package/skills/spec-producer-governance/SKILL.md +7 -3
- package/skills/ssa-lowering/README.md +3 -5
- package/skills/start-mission/README.md +1 -1
- package/skills/start-mission/SKILL.md +9 -5
- package/skills/suite-format-governance/SKILL.md +43 -4
- package/skills/touch-set-correction/README.md +3 -5
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +7 -5
- package/skills/verify-scenarios/SKILL.md +10 -3
- package/skills/verify-scenarios/scripts/verify-scenarios.mts +92 -8
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
// node:test; running the file directly drives the CLI.
|
|
20
20
|
|
|
21
21
|
import { execFileSync } from 'node:child_process'
|
|
22
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
22
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'
|
|
23
23
|
import { dirname, join } from 'node:path'
|
|
24
|
+
import { pathToFileURL } from 'node:url'
|
|
24
25
|
|
|
25
26
|
// ── Schema (v:1) ──
|
|
26
27
|
|
|
@@ -50,6 +51,12 @@ export interface NodeEvent {
|
|
|
50
51
|
origin?: string[]
|
|
51
52
|
/** Set only on an Operation node: the Mission that marks it "done enough to ship". */
|
|
52
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
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
export interface EdgeEvent {
|
|
@@ -89,6 +96,8 @@ export interface GraphNode {
|
|
|
89
96
|
briefPointer: string
|
|
90
97
|
origin: string[]
|
|
91
98
|
capstone?: string
|
|
99
|
+
project: string
|
|
100
|
+
barrier: boolean
|
|
92
101
|
/** The schema version stamped on the node's latest event (forward-compat placeholder — v1
|
|
93
102
|
* only exists today; a later version arrives on newer events and rides along unread). */
|
|
94
103
|
schemaVersion: number
|
|
@@ -105,6 +114,10 @@ export interface Graph {
|
|
|
105
114
|
edges: GraphEdge[]
|
|
106
115
|
}
|
|
107
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
|
+
|
|
108
121
|
const NODE_DEFAULTS = {
|
|
109
122
|
kind: 'mission' as NodeKind,
|
|
110
123
|
status: 'open' as NodeStatus,
|
|
@@ -112,6 +125,8 @@ const NODE_DEFAULTS = {
|
|
|
112
125
|
hitlOrAfk: 'hitl' as HitlOrAfk,
|
|
113
126
|
modelTier: 'unspecified',
|
|
114
127
|
briefPointer: '',
|
|
128
|
+
project: DEFAULT_PROJECT,
|
|
129
|
+
barrier: false,
|
|
115
130
|
}
|
|
116
131
|
|
|
117
132
|
function edgeKey(kind: EdgeKind, from: string, to: string): string {
|
|
@@ -163,6 +178,8 @@ export function fold(events: readonly MissionGraphEvent[]): Graph {
|
|
|
163
178
|
briefPointer: typeof fields.briefPointer === 'string' ? fields.briefPointer : NODE_DEFAULTS.briefPointer,
|
|
164
179
|
origin: Array.isArray(fields.origin) ? [...(fields.origin as string[])] : [],
|
|
165
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,
|
|
166
183
|
schemaVersion: typeof fields.v === 'number' ? fields.v : 1,
|
|
167
184
|
})
|
|
168
185
|
}
|
|
@@ -338,6 +355,93 @@ export function proposeEdge(
|
|
|
338
355
|
reason: `would close a RAW cycle (${proposed.to} already reaches ${proposed.from})`,
|
|
339
356
|
}
|
|
340
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 }
|
|
341
445
|
return { accepted: true }
|
|
342
446
|
}
|
|
343
447
|
|
|
@@ -442,6 +546,59 @@ function whyReady(graph: Graph, id: string): string {
|
|
|
442
546
|
return `RAW-satisfied: ${[...preds].sort(compareIds).join(', ')} retired`
|
|
443
547
|
}
|
|
444
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
|
+
|
|
445
602
|
/**
|
|
446
603
|
* ready — reads the graph without changing it and returns every Mission that is both RAW-satisfied
|
|
447
604
|
* (every RAW predecessor retired, transitively — realized here as each direct predecessor's own
|
|
@@ -450,17 +607,43 @@ function whyReady(graph: Graph, id: string): string {
|
|
|
450
607
|
* (claimed) mission's is held; (2) among the remaining RAW-satisfied candidates, an intersecting
|
|
451
608
|
* pair never both surface — the pinned lowest-id tie-break (compareIds) admits one and holds the
|
|
452
609
|
* 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.
|
|
454
|
-
*
|
|
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`.
|
|
455
622
|
*/
|
|
456
623
|
export function ready(graph: Graph): FrontierEntry[] {
|
|
457
624
|
const quarantined = quarantinedIds(graph)
|
|
458
625
|
const missions = [...graph.nodes.values()].filter((n) => n.kind === 'mission')
|
|
459
626
|
const inFlightTouchSets = missions.filter((n) => n.status === 'claimed').map((n) => n.touchSet)
|
|
460
627
|
|
|
461
|
-
const
|
|
628
|
+
const rawSatisfiedOpen = missions.filter(
|
|
462
629
|
(n) => n.status === 'open' && !quarantined.has(n.id) && isRawSatisfied(graph, quarantined, n.id),
|
|
463
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
|
+
|
|
464
647
|
const notHeldByInFlight = candidates.filter((n) => !inFlightTouchSets.some((t) => intersects(t, n.touchSet)))
|
|
465
648
|
|
|
466
649
|
// Trigger 2: the pinned lowest-id tie-break. Processing in ascending id order and admitting a
|
|
@@ -626,22 +809,293 @@ export interface MigrateResult {
|
|
|
626
809
|
migrated: boolean
|
|
627
810
|
reason: string
|
|
628
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' }
|
|
629
855
|
}
|
|
630
856
|
|
|
631
857
|
/**
|
|
632
858
|
* 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
|
-
*
|
|
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.
|
|
635
865
|
*/
|
|
636
866
|
export function migrate(root: string): MigrateResult {
|
|
637
|
-
if (!isGitWorkTree(root))
|
|
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
|
|
638
879
|
if (orphanRefExists(root)) {
|
|
639
|
-
|
|
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,
|
|
640
1098
|
}
|
|
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
1099
|
}
|
|
646
1100
|
|
|
647
1101
|
/** Append a proposed RAW/parent-child/discovered-from edge through the write-time cycle guard;
|
|
@@ -657,6 +1111,15 @@ export function appendEdgeChecked(
|
|
|
657
1111
|
return decision
|
|
658
1112
|
}
|
|
659
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
|
+
|
|
660
1123
|
// ── Render (TOON — the token-efficient tabular form the repo's other sdd engines emit) ──
|
|
661
1124
|
|
|
662
1125
|
function toonQuote(v: string): string {
|
|
@@ -680,6 +1143,17 @@ export function renderCyclesToon(items: RepairItem[]): string {
|
|
|
680
1143
|
return [header, ...rows].join('\n')
|
|
681
1144
|
}
|
|
682
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
|
+
|
|
683
1157
|
export function renderOperationToon(id: string, check: OperationCheck): string {
|
|
684
1158
|
return [
|
|
685
1159
|
`operation: ${id}`,
|
|
@@ -736,7 +1210,14 @@ function runAppendNode(rest: string[], root: string): number {
|
|
|
736
1210
|
if (briefPointer !== undefined) event.briefPointer = briefPointer
|
|
737
1211
|
const capstone = flag(rest, '--capstone')
|
|
738
1212
|
if (capstone !== undefined) event.capstone = capstone
|
|
739
|
-
|
|
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
|
+
}
|
|
740
1221
|
process.stdout.write(`mission-graph: appended node ${id}\n`)
|
|
741
1222
|
return 0
|
|
742
1223
|
}
|
|
@@ -829,16 +1310,24 @@ export function main(argv: string[]): number {
|
|
|
829
1310
|
`${
|
|
830
1311
|
format === 'json'
|
|
831
1312
|
? JSON.stringify(result, null, 2)
|
|
832
|
-
: `migrated: ${result.migrated}\nreason: ${result.reason}\ncount: ${result.count}`
|
|
1313
|
+
: `migrated: ${result.migrated}\nreason: ${result.reason}\ncount: ${result.count}\nretired: ${result.retired}\nretiredReason: ${result.retiredReason}`
|
|
833
1314
|
}\n`,
|
|
834
1315
|
)
|
|
835
1316
|
return result.reason === 'not a git work-tree' ? 1 : 0
|
|
836
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
|
+
}
|
|
837
1324
|
|
|
838
1325
|
process.stderr.write(
|
|
839
|
-
'mission-graph: usage: ready | cycles | operation --id <id> | append <node|edge|tombstone> ... | migrate\n',
|
|
1326
|
+
'mission-graph: usage: ready | cycles | operation --id <id> | append <node|edge|tombstone> ... | migrate | sync [--remote <name>]\n',
|
|
840
1327
|
)
|
|
841
1328
|
return 1
|
|
842
1329
|
}
|
|
843
1330
|
|
|
844
|
-
if (import.meta.
|
|
1331
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
1332
|
+
process.exit(main(process.argv.slice(2)))
|
|
1333
|
+
}
|
|
@@ -4,7 +4,10 @@ This is an internal SDD governance about scope and the kill-or-ship call at the
|
|
|
4
4
|
|
|
5
5
|
Before a capability's spec is approved, someone has to ask the uncomfortable questions: is this one
|
|
6
6
|
thing or two things glued together? Is it worth building at all? Does every scenario in its suite
|
|
7
|
-
actually belong to it?
|
|
7
|
+
actually belong to it? Are the **actors enumerated**, and does that enumeration close both ways — no
|
|
8
|
+
actor without a use case, no use case without a listed actor? Is every element the capability
|
|
9
|
+
**exposes** bought by a use case, or unbought scope to cut? This governance is that question list —
|
|
10
|
+
the **Oracle** bar. It judges the
|
|
8
11
|
**capability itself**, read from its spec and suite together — not how the document is written,
|
|
9
12
|
which is a different bar (`spec-format-governance`).
|
|
10
13
|
|
|
@@ -16,7 +19,9 @@ which is a different bar (`spec-format-governance`).
|
|
|
16
19
|
| **Bounded, stated scope** | What is out of scope is named. A capability that keeps absorbing adjacent problems is scope creep — cut it back. |
|
|
17
20
|
| **The node owns its decisions** | Every scenario tests a decision the node owns. A property co-owned across a seam — activation/routing, a sibling's behavior, harness wiring — is out of scope: relocate it to the node that owns it, or kill it. |
|
|
18
21
|
| **Strict — non-decisions are killed** | An invariant that always holds is not acceptance and does not enter the suite. The one exception is a user `@pinned` scenario, kept whatever strict prunes. |
|
|
19
|
-
| **
|
|
22
|
+
| **Every surface element is paid for by a use case** | An element the capability exposes — a flag, an option, a parameter, a prop, an event — that **no use case needs** is unbought scope: cut it, or name the use case. This is the same kill-or-ship judgment one level down — the capability answers for its cost, and so does each thing it exposes. |
|
|
23
|
+
| **The actors are enumerated, and the enumeration closes** | The Why names a real problem and **who feels it**, so the use cases are derived from a stated set of actors rather than from the interface. Graded **both ways**: an actor carrying no use case, and a use case whose actor is absent from the list, are each a hole. A goal that restates the mechanism is an unanswered Why, not a filled-in field. On a backfill, an enumeration drawn only from source covers the *served* use cases by construction. |
|
|
24
|
+
| **Worth shipping, or kill** | If the value does not clear the cost of building, the verdict is kill. |
|
|
20
25
|
| **Kill-or-revert** | A capability that passes every check but proves fatal goes back to Draft — surface the deal-breaker. |
|
|
21
26
|
| **No premature commitment** | A decision that need not be made yet is deferred to the last responsible moment. |
|
|
22
27
|
|