shapeup-sdlc 3.18.3 → 4.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/settings.local.example.json +2 -3
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +115 -56
- package/README.md +101 -143
- package/SECURITY.md +16 -25
- package/agents/orient.md +14 -0
- package/agents/qa-edge-hunter.md +13 -0
- package/agents/spec-evaluator.md +14 -0
- package/agents/task-executor.md +14 -0
- package/bin/init.mjs +13 -16
- package/bin/lib/grant.mjs +8 -19
- package/commands/build.md +9 -14
- package/commands/eval.md +3 -3
- package/commands/hammer.md +9 -5
- package/commands/retro.md +12 -11
- package/commands/scopes.md +1 -12
- package/commands/ship.md +45 -88
- package/commands/wire.md +1 -8
- package/hooks/dispatch-receipt.mjs +94 -8
- package/hooks/hooks.json +0 -41
- package/hooks/lib/decision.mjs +6 -9
- package/hooks/lib/tier.mjs +50 -0
- package/hooks/sandbox-guard.mjs +140 -64
- package/kernel/gate.mjs +203 -22
- package/kernel/harness.mjs +20 -67
- package/kernel/init/run.mjs +175 -523
- package/kernel/lib/contract.mjs +9 -93
- package/kernel/lib/floor.mjs +64 -0
- package/kernel/lib/paths.mjs +39 -112
- package/kernel/order.mjs +157 -0
- package/kernel/probe/digest.mjs +2 -38
- package/kernel/schemas/domain.schema.json +146 -1125
- package/kernel/schemas/work-order.schema.json +8 -4
- package/kernel/schemas/work-result.schema.json +1 -1
- package/kernel/verify/envelope.mjs +2 -2
- package/kernel/verify/skills.mjs +27 -8
- package/kernel/verify/t0.mjs +2 -2
- package/package.json +4 -2
- package/skills/building/SKILL.md +144 -0
- package/skills/building/resources/gates.md +76 -0
- package/skills/building/resources/guidance.md +23 -0
- package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
- package/skills/building/resources/intake.md +157 -0
- package/skills/building/resources/orders.md +69 -0
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
- package/skills/building/resources/plan-scopes.md +95 -0
- package/skills/building/resources/plan-spec.md +102 -0
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
- package/skills/building/resources/plan-wire.md +82 -0
- package/skills/building/resources/plan.md +98 -0
- package/skills/building/resources/retro-research.md +60 -0
- package/skills/building/resources/retro-scan.md +49 -0
- package/skills/building/resources/retro.md +225 -0
- package/skills/building/resources/rounds.md +78 -0
- package/skills/building/resources/stop.md +127 -0
- package/skills/orient/SKILL.md +37 -37
- package/skills/qa-edge-hunter/SKILL.md +14 -14
- package/skills/shapeup/SKILL.md +5 -5
- package/skills/shapeup/resources/breadboarding.md +1 -1
- package/skills/shapeup/resources/context-compaction.md +5 -5
- package/skills/shapeup/resources/shaping.md +1 -1
- package/skills/spec-evaluator/SKILL.md +17 -21
- package/skills/spec-evaluator/references/dimension-contract.md +1 -1
- package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
- package/skills/task-executor/SKILL.md +11 -15
- package/commands/hill.md +0 -10
- package/hooks/gate-intake.mjs +0 -109
- package/hooks/gate-zerowork.mjs +0 -349
- package/hooks/tier-guard.mjs +0 -162
- package/kernel/compile.mjs +0 -1419
- package/kernel/init/fit.mjs +0 -199
- package/kernel/init/run-args.mjs +0 -206
- package/kernel/lib/breadboard.mjs +0 -165
- package/kernel/probe/attempts.mjs +0 -161
- package/kernel/probe/concurrency.mjs +0 -535
- package/kernel/probe/eval.mjs +0 -405
- package/kernel/probe/hunt.mjs +0 -104
- package/kernel/probe/leg.mjs +0 -229
- package/kernel/probe/owner.mjs +0 -142
- package/kernel/probe/requirements.mjs +0 -328
- package/kernel/probe/resume.mjs +0 -1110
- package/kernel/probe/rounds.mjs +0 -115
- package/kernel/probe/staged.mjs +0 -83
- package/kernel/probe/stats.mjs +0 -477
- package/kernel/probe/t0.mjs +0 -115
- package/kernel/reduce/board.mjs +0 -350
- package/kernel/reduce/graph.mjs +0 -482
- package/kernel/reduce/hill.mjs +0 -322
- package/kernel/reduce/ingest.mjs +0 -767
- package/kernel/reduce/leftovers.mjs +0 -159
- package/kernel/reduce/ship.mjs +0 -529
- package/kernel/reduce/snapshot.mjs +0 -291
- package/kernel/reduce/verdict.mjs +0 -172
- package/kernel/report/export.mjs +0 -384
- package/kernel/report/facts.mjs +0 -234
- package/kernel/verify/budget.mjs +0 -153
- package/kernel/verify/build.mjs +0 -428
- package/kernel/verify/dispatch.mjs +0 -114
- package/kernel/verify/spec.mjs +0 -975
- package/kernel/verify/trace.mjs +0 -604
- package/skills/ba-pitch-analyzer/SKILL.md +0 -204
- package/skills/coach/SKILL.md +0 -397
- package/skills/hill-chart/SKILL.md +0 -154
- package/skills/hill-chart/assets/dashboard.template.html +0 -1432
- package/skills/scope-architect/SKILL.md +0 -174
- package/skills/scope-hammer/SKILL.md +0 -231
- package/skills/solution-architect/SKILL.md +0 -158
- package/skills/tech-lead/SKILL.md +0 -151
- package/skills/tech-lead/references/gates.md +0 -605
- package/skills/tech-lead/references/protocol.md +0 -889
- package/skills/tech-lead/references/tiny-lane.md +0 -53
- package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
- package/skills/translator/SKILL.md +0 -258
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
package/kernel/probe/resume.mjs
DELETED
|
@@ -1,1110 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// RESUME STATE — the fast-forward derivation, as a script rather than as a string.
|
|
3
|
-
//
|
|
4
|
-
// WHY THIS FILE EXISTS (observed, not theorized).
|
|
5
|
-
//
|
|
6
|
-
// `shapeup-run.js` derives "which phase do I resume at" from disk on every launch — that
|
|
7
|
-
// derivation is what retires the whole class of handoff where a fresh session rebuilds a pipeline
|
|
8
|
-
// already on disk. Until this file existed, it lived inside the workflow script as a
|
|
9
|
-
// `node --input-type=module -e "…"` blob passed to a courier agent. Three consequences, all of
|
|
10
|
-
// them realised:
|
|
11
|
-
//
|
|
12
|
-
// 1. IT COULD NOT BE TESTED. A Workflow script has no `import`, takes `args` as a runtime
|
|
13
|
-
// global, and is executed by the Workflow runtime — there is no seam a fixture can reach.
|
|
14
|
-
// So the derivation shipped unverified, and the kill/resume probe found it re-dispatching a
|
|
15
|
-
// COMPLETED ORIENT phase: three orient
|
|
16
|
-
// artifacts rewritten, a spike added, the discovery ledger and two task files mutated.
|
|
17
|
-
// The cause was one branch reading stored `status` instead of ORIENT's own artifacts,
|
|
18
|
-
// while WIRE and MAP SCOPES read artifacts and fast-forwarded correctly.
|
|
19
|
-
// 2. IT MATCHED NO PERMISSION GRANT. An inline `node -e` matches no rule in `permissions.allow`
|
|
20
|
-
// and passes only at the safety classifier's discretion. As a named script it is covered by
|
|
21
|
-
// the per-script rules the installer writes (`bin/lib/grant.mjs`).
|
|
22
|
-
// 3. TWO WRITES HAD NO READER. `setRunStatus` and the substrate pointer were the only
|
|
23
|
-
// call sites in the workflow whose return value was discarded — and they are the only two
|
|
24
|
-
// whose failure went unnoticed for two entire runs. `status` never left `orienting` across
|
|
25
|
-
// 46 dispatched agents, and `.shapeup/active-scope` still named scope 1 while scope 2 was
|
|
26
|
-
// being built, which is the pointer `hooks/sandbox-guard.mjs` reads to decide which
|
|
27
|
-
// substrate a worker may write. A courier write whose result nobody reads back is
|
|
28
|
-
// indistinguishable from one that succeeded. Both writes live here now, both refuse
|
|
29
|
-
// silently-wrong outcomes (a missing ledger, a status line that matched nothing), and both
|
|
30
|
-
// report a non-zero exit the caller is required to act on.
|
|
31
|
-
//
|
|
32
|
-
// WHAT THIS FILE DOES NOT DECIDE. It reports facts; the workflow decides phases. `next_phase` is
|
|
33
|
-
// offered as a derived convenience (and is what the fixture asserts over), but every underlying
|
|
34
|
-
// boolean travels too, so a caller is never forced to trust a summary it cannot check.
|
|
35
|
-
//
|
|
36
|
-
// WHY `--require` EXISTS (the same lesson, one layer up).
|
|
37
|
-
//
|
|
38
|
-
// Making the RESUME decision read artifacts left the COMPLETION decision reading
|
|
39
|
-
// nothing at all: shapeup-run.js dispatched a phase, ingested its result, and moved to the next
|
|
40
|
-
// gate without ever re-asking the predicate. A worker that returns `status: "escalated"` with
|
|
41
|
-
// `artifacts: []` — a legitimate outcome its own contract defines — satisfied that. So a phase
|
|
42
|
-
// that wrote no artifact was recorded as complete, the artifact-gated fast-forward then correctly
|
|
43
|
-
// found nothing on the next launch, re-dispatched it, and the worker escalated again: an
|
|
44
|
-
// unbounded loop, invisible inside one leg, which is why several runs and a status review never
|
|
45
|
-
// saw it.
|
|
46
|
-
//
|
|
47
|
-
// `--require <phase>` is the completion check, and it is deliberately the SAME derivation the
|
|
48
|
-
// fast-forward uses — not a second predicate that can drift from it. Two predicates that can
|
|
49
|
-
// disagree about "is this phase done" is the defect class itself.
|
|
50
|
-
//
|
|
51
|
-
// USAGE
|
|
52
|
-
// node harness probe resume --slug <slug> [--cwd <dir>] # derive, print ResumeState
|
|
53
|
-
// node harness probe resume --slug <slug> --require <phase> # post-condition: exit 6 if unmet
|
|
54
|
-
// node harness probe resume --slug <slug> --set-status <status> # write harness-run.md status
|
|
55
|
-
//
|
|
56
|
-
// Exit: 0 ok · 2 malformed argv (nothing ran) · 3 the target the operation needs is not on disk ·
|
|
57
|
-
// 6 the required phase's artifact is NOT on disk (the phase did not complete).
|
|
58
|
-
|
|
59
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
60
|
-
import { dirname, join, resolve } from "node:path";
|
|
61
|
-
import { runArgs } from "../lib/argv.mjs";
|
|
62
|
-
import { splitFrontmatter, uncoerce } from "../lib/contract.mjs";
|
|
63
|
-
import { globToRegExp } from "../verify/spec.mjs";
|
|
64
|
-
import { parseBoard } from "../reduce/board.mjs";
|
|
65
|
-
import { intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir, orientDir, activeOrder, activeScope, usecasesDir, breadboard, receipt, readReceipt, requirements, exportRunDir, lastRun, readRunId, tasksDir, gates, verdictsDir, roundBuildDir } from "../lib/paths.mjs";
|
|
66
|
-
import { evalVerdict } from "./eval.mjs";
|
|
67
|
-
import { deriveRounds } from "./rounds.mjs";
|
|
68
|
-
import { stagedWorkflowDrift, driftWarning } from "./staged.mjs";
|
|
69
|
-
import { collectRun, writeRun } from "../report/export.mjs";
|
|
70
|
-
|
|
71
|
-
/** The run-state values `references/protocol.md` (Part 4 — State) defines. A typo'd status is a rejection,
|
|
72
|
-
* not a write — the whole point of this file is that a write nobody validates is a write nobody
|
|
73
|
-
* can trust. `aborted` is the terminal counterpart `escalated` already was: a gate
|
|
74
|
-
* resolving "abort", or a hard stop, ends the run the same way an operator-declared escalation
|
|
75
|
-
* does — neither resumes on relaunch — so both are TERMINAL_STATUSES below. */
|
|
76
|
-
export const RUN_STATUSES = ["orienting", "mapping", "building", "evaluating", "shipped", "escalated", "aborted"];
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* The statuses a run does not come back from. `closeRun` refuses every other member of
|
|
80
|
-
* {@link RUN_STATUSES} — `orienting`/`mapping`/`building`/`evaluating` are mid-flight, and writing
|
|
81
|
-
* a close over one of those would stamp `closed_at` on a run a relaunch is still meant to resume.
|
|
82
|
-
*/
|
|
83
|
-
export const TERMINAL_STATUSES = ["shipped", "aborted", "escalated"];
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* The RunReturn union (`kernel/schemas/domain.schema.json` `$defs/RunReturn.properties.status.enum`)
|
|
87
|
-
* mapped to what closing the run means for each arm — the derivation `closeIfTerminal`
|
|
88
|
-
* (`skills/tech-lead/workflows/shapeup-run.js`) now reads instead of a hand-typed
|
|
89
|
-
* `status !== "aborted" && status !== "shipped"` pair that referenced {@link TERMINAL_STATUSES}
|
|
90
|
-
* zero times and so could not see when a new arm went unhandled.
|
|
91
|
-
*
|
|
92
|
-
* A lookup answers one of three ways, and the distinction is load-bearing for what this map must
|
|
93
|
-
* catch: an arm ABSENT from this object (never listed as a key) returns `undefined` — an arm the
|
|
94
|
-
* schema carries that nobody has mapped, which a caller must treat as a defect, never as "fine to
|
|
95
|
-
* skip". An arm mapped to a terminal status closes the run as that status. An arm mapped to `null`
|
|
96
|
-
* is EXPLICITLY non-terminal — `paused` resumes on relaunch and `ok` is one inner round finishing,
|
|
97
|
-
* not the run — so it is a key with a falsy value, not an omission a reader could mistake for "not
|
|
98
|
-
* decided yet".
|
|
99
|
-
*
|
|
100
|
-
* `gate_h` → `escalated`: the breaker that tripped (`outer`/`inner`/`deadline`) travels in
|
|
101
|
-
* `close_cause`, never as a new member of {@link TERMINAL_STATUSES} or {@link RUN_STATUSES} — a
|
|
102
|
-
* circuit breaker tripping is not a new way a run ends, it is the reason an existing one
|
|
103
|
-
* (`escalated`) fires this time.
|
|
104
|
-
*
|
|
105
|
-
* @type {Object<string, (string|null)>}
|
|
106
|
-
*/
|
|
107
|
-
export const RUN_RETURN_CLOSE = {
|
|
108
|
-
shipped: "shipped",
|
|
109
|
-
aborted: "aborted",
|
|
110
|
-
gate_h: "escalated",
|
|
111
|
-
paused: null,
|
|
112
|
-
ok: null,
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Resolve one RunReturn arm to a close outcome and, when the arm is terminal, perform the close —
|
|
117
|
-
* the single call `closeIfTerminal` makes instead of deciding locally which arms are terminal.
|
|
118
|
-
*
|
|
119
|
-
* @param {string} cwd - Project root.
|
|
120
|
-
* @param {string} slug - Feature slug.
|
|
121
|
-
* @param {string} arm - A `RunReturn.status` value.
|
|
122
|
-
* @param {(string|null)} [cause] - Why the run ended there; only used when `arm` is terminal.
|
|
123
|
-
* @param {boolean} [withExport] - Forwarded to {@link closeRun} — defaults true. The one caller
|
|
124
|
-
* that ever passes `false` is a test fixture proving the export assertion is real — a check that
|
|
125
|
-
* cannot fail did not happen; production call sites never set this.
|
|
126
|
-
* @returns {({ok:true, arm:string, terminal:false, reason:string} |
|
|
127
|
-
* {ok:false, arm:string, reason:string} |
|
|
128
|
-
* ({ok:boolean, arm:string, terminal:true} & ReturnType<typeof closeRun>))} `terminal:false` when
|
|
129
|
-
* `arm` maps to `null` (nothing closed, not an error). `ok:false` with no `terminal` field when
|
|
130
|
-
* `arm` is not a key of {@link RUN_RETURN_CLOSE} at all — a schema arm this map has not been
|
|
131
|
-
* taught, which must never be silently treated as non-terminal. Otherwise the {@link closeRun}
|
|
132
|
-
* outcome, tagged with the arm that produced it.
|
|
133
|
-
*/
|
|
134
|
-
export function closeArm(cwd, slug, arm, cause = null, withExport = true) {
|
|
135
|
-
if (!Object.hasOwn(RUN_RETURN_CLOSE, arm)) {
|
|
136
|
-
return { ok: false, arm, reason: `closeArm: "${arm}" is not a RunReturn arm this kernel maps — known arms: ${Object.keys(RUN_RETURN_CLOSE).join(", ")}` };
|
|
137
|
-
}
|
|
138
|
-
const status = RUN_RETURN_CLOSE[arm];
|
|
139
|
-
if (!status) {
|
|
140
|
-
return { ok: true, arm, terminal: false, reason: `"${arm}" is explicitly non-terminal — no close` };
|
|
141
|
-
}
|
|
142
|
-
return { ...closeRun(cwd, slug, { status, cause, withExport }), arm, terminal: true };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** ORIENT's four artifacts (skills/orient/SKILL.md §Outputs): three by exact name, plus a spike
|
|
146
|
-
* whose filename carries the area it spiked (`spike-<area>.md`, or `spike-not-needed.md` when
|
|
147
|
-
* the risk scan came back rank 0 — both count, because both are ORIENT having finished). */
|
|
148
|
-
export const ORIENT_REQUIRED = ["code-surface.md", "discovered-seed.md", "hill-signal.md"];
|
|
149
|
-
export const ORIENT_SPIKE = /^spike-.+\.md$/;
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Parse a leading `---` frontmatter block into a flat object, through the ONE library that reads
|
|
153
|
-
* this file form (`lib/contract.mjs`).
|
|
154
|
-
*
|
|
155
|
-
* It used to be a private scalar-only regex — every value came back a string, including a
|
|
156
|
-
* `key: [a, b]` list. That is why `eval_dimensions` was pinned: the ledger could carry the set the
|
|
157
|
-
* PO asked for and the `Array.isArray(hr.eval_dimensions)` branch below could never be true, so the
|
|
158
|
-
* fallback fired on every read and the run graded spec-conformance whatever the file said. A second
|
|
159
|
-
* parser for a format that already has one is the defect; the dialects diverge silently and the
|
|
160
|
-
* reader that loses a value looks identical to a file that never carried it.
|
|
161
|
-
*
|
|
162
|
-
* @param {string} text - Whole file contents.
|
|
163
|
-
* @returns {Object<string,*>} Frontmatter keys, coerced: `[a, b]` → string[], `~` → null,
|
|
164
|
-
* digits → number, true/false → boolean, everything else the unquoted string.
|
|
165
|
-
*/
|
|
166
|
-
export function parseFrontmatter(text) {
|
|
167
|
-
return splitFrontmatter(text).meta;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Has ORIENT actually produced its artifacts? This is the predicate the fast-forward's ORIENT
|
|
172
|
-
* branch was missing — the comment above that branch has always described it, and until now the
|
|
173
|
-
* code read a stored status field instead.
|
|
174
|
-
*
|
|
175
|
-
* @param {string} cwd - Project root.
|
|
176
|
-
* @param {string} slug - Feature slug.
|
|
177
|
-
* @returns {boolean} True when all three named artifacts and at least one spike file exist.
|
|
178
|
-
*/
|
|
179
|
-
/**
|
|
180
|
-
* Group the scopes into dependency WAVES: every scope in a wave can be built at the same time,
|
|
181
|
-
* and no wave contains a scope that depends on one still in flight.
|
|
182
|
-
*
|
|
183
|
-
* WHY THIS EXISTS, measured rather than reasoned. BUILD chunked the scopes by a fixed width over
|
|
184
|
-
* whatever order the directory listing gave — alphabetical. On the criterion-1 run that put
|
|
185
|
-
* `cli-integration` second, in the first wave, alongside the `foundation` and command scopes its own
|
|
186
|
-
* contract says it consumes ("replaces foundation's bin/todo.js placeholder", "routes to each command
|
|
187
|
-
* scope's module"). It burned all three attempts at 0/2 fixtures in round 1 and its leg died in round
|
|
188
|
-
* 2, so `bin/todo.js` stayed a two-line placeholder and four individually T0-green command modules
|
|
189
|
-
* were unreachable from the entry point. Five of six scopes went green; the sixth was the one that
|
|
190
|
-
* had to go last, and it was scheduled second by alphabet.
|
|
191
|
-
*
|
|
192
|
-
* THE ORDER LIVES IN THE SAME TIER AS THE THING IT ORDERS. Each scope contract declares
|
|
193
|
-
* `depends_on: [scope_id, …]`, and scope A follows scope B when A names B. This used to be derived
|
|
194
|
-
* instead — contract `tasks` → the board's task `depends_on` → back to the owning scope — which
|
|
195
|
-
* read an ordering off the LOCAL board while the contracts it ordered were COMMITTED. On a fresh
|
|
196
|
-
* clone the board is absent, every scope's task list resolved to nothing, and the whole relation
|
|
197
|
-
* collapsed to "no edges": the scheduler degraded to the unscheduled fan-out this exists to
|
|
198
|
-
* replace, and reported nothing, because an empty relation is the same value as no relation.
|
|
199
|
-
* `scope_id` is the stable cross-machine key, so an edge between two of them survives the clone
|
|
200
|
-
* that the two-hop join through renumbering task ids never could.
|
|
201
|
-
*
|
|
202
|
-
* NON-REGRESSION IS THE DEFAULT. Any missing or unreadable input — no contract declaring
|
|
203
|
-
* `depends_on`, an id naming a scope that is not here, a dependency cycle — falls back to one wave
|
|
204
|
-
* containing everything, which is exactly the pre-scheduler behavior. A scheduler that refuses to
|
|
205
|
-
* run is worse than one that runs unscheduled.
|
|
206
|
-
*
|
|
207
|
-
* @param {string} cwd - Project root.
|
|
208
|
-
* @param {string} slug - Feature slug.
|
|
209
|
-
* @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
|
|
210
|
-
* @returns {string[][]} Waves of resolved scope paths, dependencies first. Order within a wave is
|
|
211
|
-
* the input order, so the result is deterministic.
|
|
212
|
-
*/
|
|
213
|
-
export function scopeWaves(cwd, slug, scopes) {
|
|
214
|
-
const all = scopes.map((s) => s.path);
|
|
215
|
-
if (scopes.length < 2) return all.length ? [all] : [];
|
|
216
|
-
const deps = scopeDepGraph(scopes);
|
|
217
|
-
if (!deps) return [all];
|
|
218
|
-
|
|
219
|
-
// Kahn, one wave per level. A cycle cannot stall the build: the remainder ships as one wave.
|
|
220
|
-
const done = new Set();
|
|
221
|
-
const waves = [];
|
|
222
|
-
while (done.size < scopes.length) {
|
|
223
|
-
const ready = scopes.filter((s) => !done.has(s.scope_id)
|
|
224
|
-
&& [...deps.get(s.scope_id)].every((d) => done.has(d)));
|
|
225
|
-
if (!ready.length) {
|
|
226
|
-
waves.push(scopes.filter((s) => !done.has(s.scope_id)).map((s) => s.path));
|
|
227
|
-
break;
|
|
228
|
-
}
|
|
229
|
-
waves.push(ready.map((s) => s.path));
|
|
230
|
-
for (const s of ready) done.add(s.scope_id);
|
|
231
|
-
}
|
|
232
|
-
return waves;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* The dependency relation `scopeWaves` levels — parsed once, so the two answers cannot disagree.
|
|
237
|
-
*
|
|
238
|
-
* @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
|
|
239
|
-
* @returns {(Map<string,Set<string>>|null)} scope_id → the scope_ids it waits for, or null when the
|
|
240
|
-
* contracts carry no usable relation at all (none declares `depends_on`, or every id it names is
|
|
241
|
-
* a scope that is not in this run).
|
|
242
|
-
*/
|
|
243
|
-
function scopeDepGraph(scopes) {
|
|
244
|
-
const present = new Set(scopes.map((s) => s.scope_id));
|
|
245
|
-
const deps = new Map(scopes.map((s) => [s.scope_id, new Set()]));
|
|
246
|
-
let sawEdge = false;
|
|
247
|
-
for (const s of scopes) {
|
|
248
|
-
let declared;
|
|
249
|
-
try { declared = parseFrontmatter(readFileSync(s.path, "utf8")).depends_on; } catch { continue; }
|
|
250
|
-
if (!Array.isArray(declared)) continue;
|
|
251
|
-
for (const d of declared) {
|
|
252
|
-
const id = String(d).trim();
|
|
253
|
-
// An id naming a scope that is not in this run is DROPPED, not an error: a contract may
|
|
254
|
-
// legitimately name a scope superseded or cut since it was written, and a scheduler that
|
|
255
|
-
// stalls on a stale edge is worse than one that ignores it. spec-lint reports the dangling
|
|
256
|
-
// id — this is the scheduling path, and it fails open.
|
|
257
|
-
if (id && id !== s.scope_id && present.has(id)) { deps.get(s.scope_id).add(id); sawEdge = true; }
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
return sawEdge ? deps : null;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* The SAME relation as {@link scopeWaves}, as edges rather than levels.
|
|
265
|
-
*
|
|
266
|
-
* WHY BOTH SHAPES SHIP. A level says when it is definitely safe to start a scope; an edge says when
|
|
267
|
-
* it BECAME safe, and the two differ by however long the slowest sibling in the previous level takes.
|
|
268
|
-
* A build that releases per level holds `cli-integration` until the last command scope lands even
|
|
269
|
-
* when the only scope it consumes went green first. Levels remain the ORDER the fan-out considers
|
|
270
|
-
* scopes in; edges are what release them.
|
|
271
|
-
*
|
|
272
|
-
* ADDITIVE, in the same sense `scope_waves` is: a consumer that ignores this field can rebuild the
|
|
273
|
-
* level-release points from `scope_waves` alone, which is exactly the previous behaviour.
|
|
274
|
-
*
|
|
275
|
-
* @param {string} cwd - Project root.
|
|
276
|
-
* @param {string} slug - Feature slug.
|
|
277
|
-
* @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
|
|
278
|
-
* @returns {string[][]} `[dependant_path, dependency_path]` pairs, resolved paths on both ends so a
|
|
279
|
-
* consumer resolves ids the one way it already resolves `scope_files`. Empty when no relation is
|
|
280
|
-
* derivable — never a partial or invented one.
|
|
281
|
-
*/
|
|
282
|
-
/**
|
|
283
|
-
* Pairs of scopes that must not BUILD AT THE SAME TIME, because both may write the same path.
|
|
284
|
-
*
|
|
285
|
-
* WHY THIS IS A SCHEDULING FACT AND NOT A LINT. `shared_substrate` is the declared escape hatch from
|
|
286
|
-
* the disjointness rule: the spec lint passes an overlap both contracts declare, and the sandbox
|
|
287
|
-
* guard permits that path to every live order that names it. Every layer is individually correct and
|
|
288
|
-
* the join is wrong — concurrent writers to one declared-shared entry point lose each other's work,
|
|
289
|
-
* with every check green. An overlap that is NOT declared shared never reaches BUILD (the lint reds
|
|
290
|
-
* it and the run stops at the board review), so what this returns is precisely the set the escape
|
|
291
|
-
* hatch created.
|
|
292
|
-
*
|
|
293
|
-
* It is an EXCLUSION, not a dependency: neither scope has to go first, they only have to not
|
|
294
|
-
* overlap. The consumer orients each pair by build position, which is what keeps the constraint from
|
|
295
|
-
* ever forming a cycle with a real dependency edge.
|
|
296
|
-
*
|
|
297
|
-
* ADDITIVE, like the two fields above it: a consumer that ignores it schedules exactly as before.
|
|
298
|
-
*
|
|
299
|
-
* @param {string} cwd - Project root.
|
|
300
|
-
* @param {string} slug - Feature slug.
|
|
301
|
-
* @param {Array<{scope_id:string, path:string}>} scopes - The scopes, in their existing order.
|
|
302
|
-
* @returns {string[][]} Unordered `[pathA, pathB]` pairs. Empty when nothing can collide.
|
|
303
|
-
*/
|
|
304
|
-
export function scopeExclusions(cwd, slug, scopes) {
|
|
305
|
-
if (scopes.length < 2) return [];
|
|
306
|
-
const globsOf = (s) => {
|
|
307
|
-
try {
|
|
308
|
-
const fm = parseFrontmatter(readFileSync(s.path, "utf8"));
|
|
309
|
-
return [...(fm.allowed_file_substrate || []), ...(fm.shared_substrate || [])].map((g) => String(g).trim()).filter(Boolean);
|
|
310
|
-
} catch { return []; }
|
|
311
|
-
};
|
|
312
|
-
const globs = new Map(scopes.map((s) => [s.scope_id, globsOf(s)]));
|
|
313
|
-
// Two globs MEET when either matches the other read as a literal path. It is an approximation of
|
|
314
|
-
// "these two patterns can name the same file", and it is deliberately the generous one: a false
|
|
315
|
-
// meet costs two scopes their overlap in time, a missed meet costs one of them its work.
|
|
316
|
-
const meet = (a, b) => a === b || globToRegExp(a).test(b) || globToRegExp(b).test(a);
|
|
317
|
-
const out = [];
|
|
318
|
-
for (let i = 0; i < scopes.length; i++) {
|
|
319
|
-
for (let j = i + 1; j < scopes.length; j++) {
|
|
320
|
-
const A = globs.get(scopes[i].scope_id) || [], B = globs.get(scopes[j].scope_id) || [];
|
|
321
|
-
if (A.some((a) => B.some((b) => meet(a, b)))) out.push([scopes[i].path, scopes[j].path]);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
return out;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export function scopeDeps(cwd, slug, scopes) {
|
|
328
|
-
if (scopes.length < 2) return [];
|
|
329
|
-
const deps = scopeDepGraph(scopes);
|
|
330
|
-
if (!deps) return [];
|
|
331
|
-
const pathOf = new Map(scopes.map((s) => [s.scope_id, s.path]));
|
|
332
|
-
const out = [];
|
|
333
|
-
for (const s of scopes) {
|
|
334
|
-
for (const d of deps.get(s.scope_id) || []) {
|
|
335
|
-
if (pathOf.has(d)) out.push([s.path, pathOf.get(d)]);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
return out;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
export function hasOrientArtifacts(cwd, slug) {
|
|
342
|
-
const dir = orientDir(cwd, slug);
|
|
343
|
-
if (!existsSync(dir)) return false;
|
|
344
|
-
let files;
|
|
345
|
-
try { files = readdirSync(dir); } catch { return false; }
|
|
346
|
-
const present = new Set(files);
|
|
347
|
-
return ORIENT_REQUIRED.every((f) => present.has(f)) && files.some((f) => ORIENT_SPIKE.test(f));
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* The use-case directory this run's spec tree lands in. The ledger may name a non-default
|
|
352
|
-
* `spec_folder` (`harness init run --spec-folder`), so honour it when present and fall back to the
|
|
353
|
-
* registry path otherwise — never a spelled-out root (test #45).
|
|
354
|
-
*
|
|
355
|
-
* @param {string} cwd - Project root.
|
|
356
|
-
* @param {string} slug - Feature slug.
|
|
357
|
-
* @param {string|null} specFolder - The ledger's `spec_folder`, if it names one.
|
|
358
|
-
* @returns {string} Absolute path to `usecases/`.
|
|
359
|
-
*/
|
|
360
|
-
export function usecasesPath(cwd, slug, specFolder) {
|
|
361
|
-
return specFolder ? resolve(cwd, specFolder, "usecases") : usecasesDir(cwd, slug);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Has ANALYZE actually produced the spec tree? `_index.md` alone is a tree with no use cases in
|
|
366
|
-
* it, and a wiring map is written one entry PER use case — so the index does not count.
|
|
367
|
-
*
|
|
368
|
-
* This predicate is why ANALYZE is in the phase chain at all. WIRE was dispatched before it ran, so
|
|
369
|
-
* `usecases/` did not exist, so `solution-architect` had nothing to wire and escalated — honestly,
|
|
370
|
-
* and identically on every relaunch (skills/solution-architect/SKILL.md:43-44, :108).
|
|
371
|
-
*
|
|
372
|
-
* @param {string} cwd - Project root.
|
|
373
|
-
* @param {string} slug - Feature slug.
|
|
374
|
-
* @param {string|null} specFolder - The ledger's `spec_folder`, if it names one.
|
|
375
|
-
* @returns {boolean} True when at least one use case (not the index) is on disk.
|
|
376
|
-
*/
|
|
377
|
-
/**
|
|
378
|
-
* Is the per-machine board on disk with at least one task? ANALYZE writes two artifacts in two
|
|
379
|
-
* tiers — the spec tree, committed, and the board, gitignored — and a fast-forward that asked only
|
|
380
|
-
* about the committed half walked every later run on a machine, and every run in a fresh checkout,
|
|
381
|
-
* into a build with a spec and no board: GATE L2 crossed over 0/0 tasks, the evaluator could read no
|
|
382
|
-
* `covers:` clause, and the requirements projection printed "no evidence" on a round whose static
|
|
383
|
-
* criteria all passed. Measured on a live consumer, fourth run of one pitch.
|
|
384
|
-
* @param {string} cwd - Project root.
|
|
385
|
-
* @param {string} slug - Feature slug.
|
|
386
|
-
* @returns {boolean} True when the board directory holds at least one task file.
|
|
387
|
-
*/
|
|
388
|
-
export function hasBoard(cwd, slug) {
|
|
389
|
-
try { return parseBoard(tasksDir(cwd, slug)).length > 0; } catch { return false; }
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
export function hasSpecTree(cwd, slug, specFolder) {
|
|
393
|
-
const dir = usecasesPath(cwd, slug, specFolder);
|
|
394
|
-
if (!existsSync(dir)) return false;
|
|
395
|
-
let files;
|
|
396
|
-
try { files = readdirSync(dir); } catch { return false; }
|
|
397
|
-
return files.some((f) => f.endsWith(".md") && f !== "_index.md");
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* Each dispatched phase and the artifact that IS its completion. One table, read by the resume
|
|
402
|
-
* decision (`nextPhase`) and by the post-condition check (`--require`) alike — see the banner.
|
|
403
|
-
*/
|
|
404
|
-
export const PHASE_ARTIFACT = {
|
|
405
|
-
orient: { fact: "has_orient_artifacts", artifact: "orient/{code-surface,discovered-seed,hill-signal}.md + spike-*.md" },
|
|
406
|
-
// BOTH HALVES. The spec tree is committed and survives a clone; the board is per-machine and does
|
|
407
|
-
// not. A phase is complete only when both are on disk — a committed spec with no board resumes AT
|
|
408
|
-
// analyze, where the workflow dispatches the board-only operation rather than the whole phase.
|
|
409
|
-
analyze: { fact: "has_spec_tree", also: "has_board", artifact: "spec/usecases/*.md + tasks/TASK-*.md" },
|
|
410
|
-
wire: { fact: "has_wiring_map", artifact: "wiring-map.md" },
|
|
411
|
-
"map-scopes": { fact: "scope_files", artifact: "scopes/*.md" },
|
|
412
|
-
};
|
|
413
|
-
export const PHASES = Object.keys(PHASE_ARTIFACT);
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Is this phase's artifact on disk? The ONE reading of "complete" in this pipeline.
|
|
417
|
-
*
|
|
418
|
-
* @param {object} state - A derived ResumeState.
|
|
419
|
-
* @param {string} phase - One of {@link PHASES}.
|
|
420
|
-
* @returns {boolean} True when the phase's artifact exists.
|
|
421
|
-
*/
|
|
422
|
-
export function phaseSatisfied(state, phase) {
|
|
423
|
-
const present = (k) => { const v = state[k]; return Array.isArray(v) ? v.length > 0 : Boolean(v); };
|
|
424
|
-
const p = PHASE_ARTIFACT[phase];
|
|
425
|
-
return present(p.fact) && (!p.also || present(p.also));
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* The first phase whose artifacts are incomplete — the design doc's §4 fast-forward, stated once
|
|
430
|
-
* so the workflow and the fixture cannot disagree about it.
|
|
431
|
-
*
|
|
432
|
-
* ⟐ ANALYZE sits between ORIENT and WIRE. The pipeline used to dispatch
|
|
433
|
-
* WIRE first, which is the position solution-architect's own input contract excludes — it reads
|
|
434
|
-
* `usecases/`, and `analyze` is what writes them.
|
|
435
|
-
*
|
|
436
|
-
* @param {object} f - Facts (a derived ResumeState, or the subset the phase predicates read).
|
|
437
|
-
* @returns {"orient"|"analyze"|"wire"|"map-scopes"|"build"} The phase to resume at.
|
|
438
|
-
*/
|
|
439
|
-
export function nextPhase(f) {
|
|
440
|
-
for (const p of PHASES) if (!phaseSatisfied(f, p)) return p;
|
|
441
|
-
return "build";
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Derive every path/status fact the outer pipeline needs, from files alone.
|
|
446
|
-
*
|
|
447
|
-
* @param {string} cwd - Project root.
|
|
448
|
-
* @param {string} slug - Feature slug.
|
|
449
|
-
* @param {object} [opts] - `pluginRoot`, when the caller knows where the plugin it is running from
|
|
450
|
-
* lives: the state then also reports whether the staged orchestrator is the installed one.
|
|
451
|
-
* @returns {object} The ResumeState record (domain.schema.json $defs/ResumeState).
|
|
452
|
-
*/
|
|
453
|
-
export function deriveResumeState(cwd, slug, { pluginRoot = null } = {}) {
|
|
454
|
-
const hrPath = harnessRun(cwd, slug);
|
|
455
|
-
const hr = existsSync(hrPath) ? parseFrontmatter(readFileSync(hrPath, "utf8")) : {};
|
|
456
|
-
|
|
457
|
-
// Resolved contract PATHS, not bare filenames: harness compile and harness verify t0 both resolve
|
|
458
|
-
// `--scope` against cwd, so a bare "SC-x.md" names a file that does not exist, compile-order
|
|
459
|
-
// exits 2, and the attempt loop reads that non-zero exit as the stagnation breaker — a resumed
|
|
460
|
-
// run would falsely trip the inner breaker and hammer-propose every scope instead of continuing.
|
|
461
|
-
const sdir = scopesDir(cwd, slug);
|
|
462
|
-
const scope_files = existsSync(sdir)
|
|
463
|
-
? readdirSync(sdir).filter((f) => f.endsWith(".md")).sort()
|
|
464
|
-
.map((f) => ({ scope_id: f.replace(/\.md$/, ""), path: join(sdir, f) }))
|
|
465
|
-
: [];
|
|
466
|
-
|
|
467
|
-
const resultFiles = existsSync(resultsDir(cwd, slug)) ? readdirSync(resultsDir(cwd, slug)) : [];
|
|
468
|
-
const orderFiles = existsSync(ordersDir(cwd, slug)) ? readdirSync(ordersDir(cwd, slug)) : [];
|
|
469
|
-
|
|
470
|
-
const facts = {
|
|
471
|
-
intake_path: intake(cwd, slug),
|
|
472
|
-
// The pitch's other half, staged by `init run` beside the intake. Null when the pitch had no
|
|
473
|
-
// separate breadboard — the planning dispatches then carry no `breadboard` key at all.
|
|
474
|
-
breadboard_path: existsSync(breadboard(cwd, slug)) ? breadboard(cwd, slug) : null,
|
|
475
|
-
// How it was found (flag | sibling | shaping-dir | shared-root | embedded), from the receipt;
|
|
476
|
-
// null when there was none or the receipt cannot be read.
|
|
477
|
-
breadboard_source: readReceipt(receipt(cwd, slug))?.breadboard?.source ?? null,
|
|
478
|
-
spec_folder: hr.spec_folder || null,
|
|
479
|
-
status: hr.status || null,
|
|
480
|
-
// The terminal status of a close nobody has taken back yet. A relaunch reopens the run before its
|
|
481
|
-
// first gate on this fact, so no gate a relaunch crosses is recorded inside a closed window.
|
|
482
|
-
closed_status: hr.closed_status ? String(hr.closed_status) : null,
|
|
483
|
-
lens: hr.lens || null,
|
|
484
|
-
stack: hr.stack || null,
|
|
485
|
-
run_cmd: hr.run_cmd || null,
|
|
486
|
-
app_url: hr.app_url || null,
|
|
487
|
-
// A list the PO named at L0.5, or [] — `auto`, or a ledger with no line — which the evaluate order
|
|
488
|
-
// resolves from the spec at compile time.
|
|
489
|
-
eval_dimensions: Array.isArray(hr.eval_dimensions) ? hr.eval_dimensions : [],
|
|
490
|
-
orient_dir: `.shapeup/${slug}/orient/`,
|
|
491
|
-
has_orient_artifacts: hasOrientArtifacts(cwd, slug),
|
|
492
|
-
has_spec_tree: hasSpecTree(cwd, slug, hr.spec_folder || null),
|
|
493
|
-
has_board: hasBoard(cwd, slug),
|
|
494
|
-
// A PLAIN FACT, DELIBERATELY NOT A PHASE. The requirements registry is dispatched once, before
|
|
495
|
-
// ANALYZE, and the orchestrator guards that one dispatch on this boolean. It is NOT an entry in
|
|
496
|
-
// PHASE_ARTIFACT, and adding it there would be a migration hazard rather than a tidier shape:
|
|
497
|
-
// that map is also `nextPhase()`'s ordered list, so every run recorded before the registry
|
|
498
|
-
// existed would fast-forward to the registry instead of to `build` on its next relaunch.
|
|
499
|
-
has_requirements: existsSync(requirements(cwd, slug)),
|
|
500
|
-
has_wiring_map: existsSync(wiringMap(cwd, slug)),
|
|
501
|
-
project_profile_path: projectProfile(cwd, slug),
|
|
502
|
-
has_project_profile: existsSync(projectProfile(cwd, slug)),
|
|
503
|
-
scope_files,
|
|
504
|
-
// The same scopes, grouped so a wave never contains a scope depending on one still in flight.
|
|
505
|
-
// Additive: a caller that ignores it gets exactly today's behavior.
|
|
506
|
-
scope_waves: scopeWaves(cwd, slug, scope_files),
|
|
507
|
-
// The same relation as edges. A wave says when a scope is definitely safe to start; an edge says
|
|
508
|
-
// when it BECAME safe. A fan-out that releases per wave holds a scope until the slowest member of
|
|
509
|
-
// the previous wave lands, even when the one scope it consumes finished first. Also additive: the
|
|
510
|
-
// wave list alone rebuilds the per-wave release points, which is the previous behaviour.
|
|
511
|
-
scope_deps: scopeDeps(cwd, slug, scope_files),
|
|
512
|
-
// Pairs that may write the same declared-shared path. Not an ordering — an exclusion: they may
|
|
513
|
-
// build in either order, and they may not build at the same time. Concurrent writers to one
|
|
514
|
-
// shared entry point lose each other's work while every check stays green, and the escape hatch
|
|
515
|
-
// that permits the overlap is the same one that makes it invisible to the disjointness lint.
|
|
516
|
-
scope_exclusions: scopeExclusions(cwd, slug, scope_files),
|
|
517
|
-
pending_orders: orderFiles.filter((f) => f.endsWith(".json") && !resultFiles.includes(f)),
|
|
518
|
-
// A round is DONE when it was graded, not when its result file exists. An evaluator that
|
|
519
|
-
// refused the round — no PASS/FAIL, or a scoped verdict citing no T0 artifact — still writes
|
|
520
|
-
// `evaluate-r<N>.json`; counted, the relaunch opened round N+1 over a round nobody judged, with
|
|
521
|
-
// no bugs to route, and rebuilt every scope. Left open, it re-enters round N, skips the scopes
|
|
522
|
-
// already green there, and evaluates again.
|
|
523
|
-
eval_rounds_done: resultFiles
|
|
524
|
-
.filter((f) => /^evaluate-r\d+\.json$/.test(f))
|
|
525
|
-
.map((f) => Number(f.match(/\d+/)[0]))
|
|
526
|
-
.filter((n) => evalVerdict(cwd, slug, n).found),
|
|
527
|
-
};
|
|
528
|
-
// WHICH ORCHESTRATOR THIS LAUNCH WILL RUN. A run keeps the workflow copy it opened with — right,
|
|
529
|
-
// and invisible: a relaunch after an upgrade executes the old one and reports normally, so every
|
|
530
|
-
// observation is of the previous release. Reported, never enforced (see probe/staged.mjs).
|
|
531
|
-
const staged = stagedWorkflowDrift(cwd, pluginRoot, slug);
|
|
532
|
-
const warning = driftWarning(staged);
|
|
533
|
-
return {
|
|
534
|
-
...facts,
|
|
535
|
-
staged_workflow: {
|
|
536
|
-
checked: staged.checked,
|
|
537
|
-
drift: staged.drift.map((d) => d.file),
|
|
538
|
-
installed_version: staged.installed_version,
|
|
539
|
-
run_version: staged.run_version,
|
|
540
|
-
...(warning ? { warning } : {}),
|
|
541
|
-
},
|
|
542
|
-
next_phase: nextPhase(facts),
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/**
|
|
547
|
-
* Rewrite `harness-run.md`'s status line. Refuses rather than silently no-ops: a ledger that is
|
|
548
|
-
* absent, or that carries no `status:` line to replace, is a fact the run must act on — that
|
|
549
|
-
* silent no-op is exactly what pinned a run at `orienting` for two complete legs.
|
|
550
|
-
*
|
|
551
|
-
* @param {string} cwd - Project root.
|
|
552
|
-
* @param {string} slug - Feature slug.
|
|
553
|
-
* @param {string} status - One of {@link RUN_STATUSES}.
|
|
554
|
-
* @returns {{ok: boolean, path: string, status: string, reason?: string}} Outcome record.
|
|
555
|
-
*/
|
|
556
|
-
export function setRunStatus(cwd, slug, status) {
|
|
557
|
-
const p = harnessRun(cwd, slug);
|
|
558
|
-
if (!existsSync(p)) {
|
|
559
|
-
return { ok: false, path: p, status, reason: `no harness-run.md for slug "${slug}" — open the run with harness init run (GATE L0.1) before setting its status` };
|
|
560
|
-
}
|
|
561
|
-
const body = readFileSync(p, "utf8");
|
|
562
|
-
if (!/^status:.*$/m.test(body)) {
|
|
563
|
-
return { ok: false, path: p, status, reason: `harness-run.md carries no "status:" line to replace — the ledger's frontmatter is malformed (references/protocol.md)` };
|
|
564
|
-
}
|
|
565
|
-
// A CLOSED RUN THAT MOVES AGAIN IS REOPENED, AND SAYS SO. A relaunch resumes the same run by
|
|
566
|
-
// design, so a run closed `aborted` can be carried on to a later ship. Its close was written once
|
|
567
|
-
// and the once-only guard in closeRun refuses to flip an outcome, which is right for a close
|
|
568
|
-
// nobody took back — but here somebody did, by resuming it. Leaving the old close in place made
|
|
569
|
-
// the ledger read `status: shipped` over `closed_status: aborted`, and every reader of the close
|
|
570
|
-
// reported an abort for a run that shipped. The move to a live status is the one act that takes a
|
|
571
|
-
// close back, so it is recorded here: the prior close joins `prior_closes`, the three close
|
|
572
|
-
// fields return to `~`, and the next terminal close is written as the run's own. Nothing is lost
|
|
573
|
-
// and nothing is flipped silently — the abort stays on the record beside the outcome that
|
|
574
|
-
// replaced it.
|
|
575
|
-
const fm = parseFrontmatter(body);
|
|
576
|
-
const priorStatus = fm.closed_status && fm.closed_status !== "~" ? String(fm.closed_status) : null;
|
|
577
|
-
let next = body.replace(/^status:.*$/m, `status: ${status}`);
|
|
578
|
-
let reopened = null;
|
|
579
|
-
if (priorStatus && TERMINAL_STATUSES.includes(priorStatus) && !TERMINAL_STATUSES.includes(status)) {
|
|
580
|
-
const priorAt = fm.closed_at && fm.closed_at !== "~" ? String(fm.closed_at) : null;
|
|
581
|
-
const priorCause = fm.close_cause && fm.close_cause !== "~" ? String(fm.close_cause) : null;
|
|
582
|
-
const reopenedAt = new Date().toISOString();
|
|
583
|
-
reopened = { closed_status: priorStatus, closed_at: priorAt, cause: priorCause, reopened_at: reopenedAt };
|
|
584
|
-
const entry = `${priorStatus} at ${priorAt ?? "?"} (reopened ${reopenedAt}): ${priorCause ?? "no cause recorded"}`;
|
|
585
|
-
const history = fm.prior_closes && fm.prior_closes !== "~" ? `${fm.prior_closes} | ${entry}` : entry;
|
|
586
|
-
const historyLine = `prior_closes: ${uncoerce(history)}`;
|
|
587
|
-
next = next
|
|
588
|
-
.replace(/^closed_at:.*$/m, "closed_at: ~")
|
|
589
|
-
.replace(/^closed_status:.*$/m, "closed_status: ~")
|
|
590
|
-
.replace(/^close_cause:.*$/m, "close_cause: ~");
|
|
591
|
-
next = /^prior_closes:.*$/m.test(next)
|
|
592
|
-
? next.replace(/^prior_closes:.*$/m, historyLine)
|
|
593
|
-
: next.replace(/^closed_at:.*$/m, (m) => `${m}\n${historyLine}`);
|
|
594
|
-
}
|
|
595
|
-
try {
|
|
596
|
-
writeFileSync(p, next);
|
|
597
|
-
} catch (e) {
|
|
598
|
-
return { ok: false, path: p, status, reason: `could not write the ledger: ${e.message}` };
|
|
599
|
-
}
|
|
600
|
-
const after = parseFrontmatter(readFileSync(p, "utf8"));
|
|
601
|
-
if (after.status !== status) {
|
|
602
|
-
return { ok: false, path: p, status, reason: `wrote "status: ${status}" but the ledger reads "${after.status}" — the write did not take` };
|
|
603
|
-
}
|
|
604
|
-
if (reopened) {
|
|
605
|
-
// THE RUN POINTER COMES BACK WITH THE RUN. The close retired it, and nothing but `init run` wrote
|
|
606
|
-
// it, so a resumed run ran to its next close with no pointer: every hook row it produced carried
|
|
607
|
-
// no run key, and the wall-clock budget had no run to read. Restored only when absent, so a
|
|
608
|
-
// pointer another run holds is left alone.
|
|
609
|
-
try {
|
|
610
|
-
if (!existsSync(activeScope(cwd))) {
|
|
611
|
-
mkdirSync(dirname(activeScope(cwd)), { recursive: true });
|
|
612
|
-
writeFileSync(activeScope(cwd), JSON.stringify({ slug, started_at: fm.started_at ?? null, reopened_at: reopened.reopened_at }, null, 2) + "\n");
|
|
613
|
-
}
|
|
614
|
-
} catch { /* the reopen stands; an unkeyed row is degraded, not wrong */ }
|
|
615
|
-
// The breadcrumb names a run that is OVER; this one no longer is. Removed only when it names
|
|
616
|
-
// this run, so another run's close is left alone.
|
|
617
|
-
try {
|
|
618
|
-
const crumb = JSON.parse(readFileSync(lastRun(cwd), "utf8"));
|
|
619
|
-
if (crumb?.run_id && crumb.run_id === readRunId(cwd, slug)) rmSync(lastRun(cwd), { force: true });
|
|
620
|
-
} catch { /* no breadcrumb, or unreadable — nothing to retire */ }
|
|
621
|
-
return { ok: true, path: p, status, reopened, decision: "reopened" };
|
|
622
|
-
}
|
|
623
|
-
return { ok: true, path: p, status };
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* Rewrite `status:`, `closed_at:`, `closed_status:` and `close_cause:` together, in one pass,
|
|
628
|
-
* appending any of the last three that a pre-migration ledger carries no line for yet (the same
|
|
629
|
-
* tolerant-of-old-ledgers discipline `close_cause` itself shipped under).
|
|
630
|
-
*
|
|
631
|
-
* @param {string} body - The ledger's current text.
|
|
632
|
-
* @param {{status:string, closedAt:string, cause:(string|null)}} o - What to write. `cause` is
|
|
633
|
-
* already normalized prose (newlines collapsed, truncated) — this function only `uncoerce`s it.
|
|
634
|
-
* @returns {string} The rewritten text.
|
|
635
|
-
*/
|
|
636
|
-
/**
|
|
637
|
-
* What the ledger's front matter and tables should say at a close, derived from the run's own
|
|
638
|
-
* records — the same ones the export reads. The counters (`final_verdict`, `rounds_used`) and the
|
|
639
|
-
* Rounds/Decisions tables used to be hand-shaped placeholders nothing filled: a run closed
|
|
640
|
-
* `shipped` beside `final_verdict: ~`, `rounds_used: 0`, an empty Decisions table and seven rows in
|
|
641
|
-
* `gates.jsonl`. A reader who trusted the counters concluded no round ran. Measured live.
|
|
642
|
-
*
|
|
643
|
-
* @param {string} cwd - Project root.
|
|
644
|
-
* @param {string} slug - Feature slug.
|
|
645
|
-
* @returns {{final_verdict:string, rounds_used:(number|null), decisions:object[], roundRows:string[]}}
|
|
646
|
-
*/
|
|
647
|
-
export function deriveLedgerFacts(cwd, slug) {
|
|
648
|
-
const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
649
|
-
const evalRows = [];
|
|
650
|
-
const rDir = resultsDir(cwd, slug);
|
|
651
|
-
// THIS RUN'S VERDICTS ONLY. A run opened afresh over a slug that already ran keeps the earlier
|
|
652
|
-
// run's `evaluate-r<N>.json` beside its own, and a later-numbered one from the earlier run was read
|
|
653
|
-
// as this run's final verdict: measured, a run whose last round PASSed closed as `final_verdict:
|
|
654
|
-
// FAIL` from a previous run's round 3. A result carries no run key; its order does.
|
|
655
|
-
const runId = readRunId(cwd, slug);
|
|
656
|
-
const thisRun = (n) => {
|
|
657
|
-
if (!runId) return true;
|
|
658
|
-
const o = readJson(join(ordersDir(cwd, slug), `evaluate-r${n}.json`));
|
|
659
|
-
return !o?.run_id || o.run_id === runId;
|
|
660
|
-
};
|
|
661
|
-
if (existsSync(rDir)) {
|
|
662
|
-
for (const f of readdirSync(rDir)) {
|
|
663
|
-
const m = f.match(/^evaluate-r(\d+)\.json$/);
|
|
664
|
-
if (!m) continue;
|
|
665
|
-
if (!thisRun(m[1])) continue;
|
|
666
|
-
const r = readJson(join(rDir, f));
|
|
667
|
-
const overall = r?.verdict?.overall;
|
|
668
|
-
if (overall) evalRows.push({ round: Number(m[1]), overall: String(overall), criteria: Array.isArray(r.verdict.criteria) ? r.verdict.criteria : [] });
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
evalRows.sort((a, b) => a.round - b.round);
|
|
672
|
-
const finalVerdict = evalRows.length ? evalRows[evalRows.length - 1].overall : "not-evaluated";
|
|
673
|
-
const decisions = [];
|
|
674
|
-
// WHICH LAUNCH TOOK EACH DECISION. A relaunch re-crosses the gates it fast-forwards, and the table
|
|
675
|
-
// listed each of them twice with nothing to tell a second launch from a double sign-off. Every
|
|
676
|
-
// reopen leaves its timestamp in `prior_closes`; a row taken after the n-th one is launch n+1's.
|
|
677
|
-
let reopens = [];
|
|
678
|
-
try {
|
|
679
|
-
const pc = parseFrontmatter(readFileSync(harnessRun(cwd, slug), "utf8")).prior_closes;
|
|
680
|
-
reopens = [...String(pc ?? "").matchAll(/\(reopened ([^)]+)\)/g)].map((m) => m[1]).sort();
|
|
681
|
-
} catch { /* no ledger, no reopen */ }
|
|
682
|
-
const launchOf = (at) => 1 + reopens.filter((t) => typeof at === "string" && at > t).length;
|
|
683
|
-
const gp = gates(cwd, slug);
|
|
684
|
-
if (existsSync(gp)) {
|
|
685
|
-
for (const line of readFileSync(gp, "utf8").split("\n")) {
|
|
686
|
-
if (!line.trim()) continue;
|
|
687
|
-
try {
|
|
688
|
-
const g = JSON.parse(line);
|
|
689
|
-
// Gate rows carry the run key; a row from an earlier run over the same slug is its history.
|
|
690
|
-
if (!runId || !g?.run_id || g.run_id === runId) decisions.push(reopens.length ? { ...g, launch: launchOf(g.at) } : g);
|
|
691
|
-
} catch { /* a torn line proves nothing */ }
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
const t0ByRound = {};
|
|
695
|
-
const vDir = verdictsDir(cwd, slug);
|
|
696
|
-
if (existsSync(vDir)) {
|
|
697
|
-
for (const f of readdirSync(vDir).filter((x) => x.endsWith(".json")).sort()) {
|
|
698
|
-
const v = readJson(join(vDir, f));
|
|
699
|
-
if (typeof v?.round !== "number") continue;
|
|
700
|
-
if (runId && v.run_id && v.run_id !== runId) continue;
|
|
701
|
-
const t = (t0ByRound[v.round] ||= { green: 0, red: 0 });
|
|
702
|
-
if (v.overall === "green") t.green++; else t.red++;
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
const gateByRound = {};
|
|
706
|
-
const bDir = roundBuildDir(cwd, slug);
|
|
707
|
-
if (existsSync(bDir)) {
|
|
708
|
-
for (const f of readdirSync(bDir).sort()) {
|
|
709
|
-
const m = f.match(/^r(\d+)-t\d+\.json$/);
|
|
710
|
-
if (!m) continue;
|
|
711
|
-
const g = readJson(join(bDir, f));
|
|
712
|
-
if (runId && g?.run_id && g.run_id !== runId) continue;
|
|
713
|
-
gateByRound[Number(m[1])] = g?.overall ?? "?";
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
const roundNums = [...new Set([...Object.keys(t0ByRound), ...Object.keys(gateByRound), ...evalRows.map((e) => e.round)].map(Number))].sort((a, b) => a - b);
|
|
717
|
-
const roundRows = [];
|
|
718
|
-
for (const r of roundNums) {
|
|
719
|
-
const t0 = t0ByRound[r], bg = gateByRound[r], ev = evalRows.find((e) => e.round === r);
|
|
720
|
-
roundRows.push(`| Build | ${r} | ${t0 ? `T0 ${t0.green} green / ${t0.red} red` : "no T0 verdict"} | — | round build gate: ${bg ?? "not run"} |`);
|
|
721
|
-
if (ev) {
|
|
722
|
-
const p = ev.criteria.filter((c) => c?.verdict === "PASS").length, f = ev.criteria.filter((c) => c?.verdict === "FAIL").length;
|
|
723
|
-
roundRows.push(`| Eval | ${r} | ${ev.overall} | — | ${p} PASS / ${f} FAIL criteria |`);
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
let roundsUsed = null;
|
|
727
|
-
try { roundsUsed = deriveRounds(cwd, slug, null)?.rounds_used ?? null; } catch { roundsUsed = null; }
|
|
728
|
-
if (roundsUsed == null && roundNums.length) roundsUsed = roundNums[roundNums.length - 1];
|
|
729
|
-
return { final_verdict: finalVerdict, rounds_used: roundsUsed, decisions, roundRows };
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/** Replace the rows of one markdown table (identified by its header line) with `rows`, keeping the header, the separator and any row the caller marks as kept. */
|
|
733
|
-
function rewriteTable(body, headerRe, rows, keepRow = null) {
|
|
734
|
-
return body.replace(headerRe, (m, header, sep, oldRows) => {
|
|
735
|
-
const kept = keepRow && oldRows ? oldRows.split("\n").filter((l) => l.startsWith(keepRow)) : [];
|
|
736
|
-
return header + sep + [...kept, ...rows].map((l) => `${l}\n`).join("");
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
function writeCloseLines(body, { status, closedAt, cause, derived = null }) {
|
|
741
|
-
const causeLine = `close_cause: ${uncoerce(cause || null)}`;
|
|
742
|
-
const closedStatusLine = `closed_status: ${status}`;
|
|
743
|
-
let out = body
|
|
744
|
-
.replace(/^status:.*$/m, `status: ${status}`)
|
|
745
|
-
.replace(/^closed_at:.*$/m, `closed_at: ${closedAt}`);
|
|
746
|
-
out = /^closed_status:.*$/m.test(out)
|
|
747
|
-
? out.replace(/^closed_status:.*$/m, closedStatusLine)
|
|
748
|
-
// A ledger written before this field existed carries no line to replace — appended right after
|
|
749
|
-
// `closed_at:`, the one line every TERMINAL_STATUSES write also touches.
|
|
750
|
-
: out.replace(/^closed_at:.*$/m, (m) => `${m}\n${closedStatusLine}`);
|
|
751
|
-
out = /^close_cause:.*$/m.test(out)
|
|
752
|
-
? out.replace(/^close_cause:.*$/m, causeLine)
|
|
753
|
-
: out.replace(/^closed_at:.*$/m, (m) => `${m}\n${causeLine}`);
|
|
754
|
-
if (derived) {
|
|
755
|
-
// THE COUNTERS AND TABLES ARE DERIVED, IN THE SAME PASS AS THE CLOSE LINE. Written from the
|
|
756
|
-
// run's own records so they cannot disagree with the export that reads the same records.
|
|
757
|
-
if (/^final_verdict:.*$/m.test(out)) out = out.replace(/^final_verdict:.*$/m, `final_verdict: ${derived.final_verdict}`);
|
|
758
|
-
if (typeof derived.rounds_used === "number" && /^rounds_used:.*$/m.test(out)) out = out.replace(/^rounds_used:.*$/m, `rounds_used: ${derived.rounds_used}`);
|
|
759
|
-
out = rewriteTable(out, /(\| Phase \| Round \| Result \| Duration \| Notes \|\n)(\|[-| ]+\|\n)((?:\|[^\n]*\n)*)/, derived.roundRows, "| Init");
|
|
760
|
-
const decisionRows = derived.decisions.map((g) => `| ${g.gate ?? "?"} | ${g.decision ?? g.status ?? "?"} | ${g.source ?? "?"} | ${g.launch > 1 ? `launch ${g.launch} (after a reopen) — ` : ""}${String(g.note ?? "").replace(/\|/g, "\\|").replace(/\s+/g, " ").slice(0, 160)} |`);
|
|
761
|
-
out = rewriteTable(out, /(\| Gate \| Decision \| Source \| Note \|\n)(\|[-| ]+\|\n)((?:\|[^\n]*\n)*)/, decisionRows);
|
|
762
|
-
}
|
|
763
|
-
return out;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* Export a just-closed run's own records into fact tables, best-effort
|
|
768
|
-
* (`report export`, `kernel/report/export.mjs`). {@link closeRun} calls this for every terminal
|
|
769
|
-
* status except `"shipped"`: the Ship phase (`skills/tech-lead/workflows/shapeup-run.js`) already
|
|
770
|
-
* calls `report export` itself, several lines before this file's own close-out runs, and that call
|
|
771
|
-
* site is left untouched on purpose — the shipped path's export stays byte-comparable to what it
|
|
772
|
-
* wrote before Stage 2. `aborted`, `escalated` and `gate_h` (which closes as `escalated`, see
|
|
773
|
-
* {@link RUN_RETURN_CLOSE}) never reached that call site at all, because it sits inside a phase
|
|
774
|
-
* those endings never enter — so a run ending any of them left its whole trace (orders, results,
|
|
775
|
-
* T0 verdicts, hook decisions, `graph.jsonl`) in the gitignored LOCAL tier with nothing durable
|
|
776
|
-
* surviving the next `init run`'s wipe. This is the read that was missing for those endings, run at
|
|
777
|
-
* the one point every one of them passes through: the close itself.
|
|
778
|
-
*
|
|
779
|
-
* FAIL-OPEN, the hook discipline (CLAUDE.md) extended to a write that is not a hook: an export that
|
|
780
|
-
* cannot write — a blocked or missing exports directory, a full disk — must never turn a close that
|
|
781
|
-
* DID take into one that looks like it did not. The three facts {@link closeRun} just wrote
|
|
782
|
-
* (`closed_status`/`close_cause`/`closed_at`) are never touched by this function; a failure here is
|
|
783
|
-
* handed back to the caller as a warning string, never thrown.
|
|
784
|
-
*
|
|
785
|
-
* @param {string} cwd - Project root.
|
|
786
|
-
* @param {string} slug - Feature slug.
|
|
787
|
-
* @returns {(string|null)} A one-line warning when the export did not complete; `null` on success.
|
|
788
|
-
*/
|
|
789
|
-
function exportOnClose(cwd, slug) {
|
|
790
|
-
try {
|
|
791
|
-
const collected = collectRun(cwd, slug);
|
|
792
|
-
if (!collected) return `export on close: no readable receipt for "${slug}" — nothing to export`;
|
|
793
|
-
writeRun(collected, exportRunDir(cwd, collected.run_id ?? slug));
|
|
794
|
-
return null;
|
|
795
|
-
} catch (e) {
|
|
796
|
-
return `export on close: ${e.message}`;
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
/**
|
|
801
|
-
* Close the run: a terminal status, its cause, and a close timestamp, written together in ONE
|
|
802
|
-
* pass.
|
|
803
|
-
*
|
|
804
|
-
* Measured: after an EVAL worker escalated and the run aborted, `harness-run.md` still read
|
|
805
|
-
* `status: evaluating`, `closed_at: ~`, with no cause recorded anywhere — a live EVAL and a dead
|
|
806
|
-
* one were indistinguishable from the trace alone. Two writers made that possible: `setRunStatus`
|
|
807
|
-
* above replaces the `status:` line and NOTHING ELSE, and `closed_at` was written exactly once, as
|
|
808
|
-
* the literal `~`, by `init run` — nothing ever replaced it. This function is the one call site
|
|
809
|
-
* that closes a run, so a terminal RunReturn cannot leave one of the three facts behind.
|
|
810
|
-
*
|
|
811
|
-
* Refuses rather than silently no-ops, the same discipline as `setRunStatus`: an absent ledger, one
|
|
812
|
-
* missing the lines this writes, or a non-terminal `status` (closing a run still `building` would
|
|
813
|
-
* stamp a live run as done) is a fact to act on, not a write to skip quietly.
|
|
814
|
-
*
|
|
815
|
-
* THE ONCE-ONLY GUARD READS `closed_status`, NEVER `status`. REWORK (round 2): the guard used to key
|
|
816
|
-
* on `before.status`, and `status:` is a LIVE field every phase rewrites via `setRunStatus` —
|
|
817
|
-
* including the product's own ship path, which stamps `status: shipped`
|
|
818
|
-
* (`skills/tech-lead/workflows/shapeup-run.js`'s Ship phase) immediately before this call runs. So a
|
|
819
|
-
* run closed `aborted` at Preflight on one launch, relaunched, and carried through to a `shipped`
|
|
820
|
-
* RunReturn on a later one had its `status:` line rewritten to `shipped` by that ordinary phase
|
|
821
|
-
* traffic BEFORE `closeIfTerminal` ever called this function — the guard read `before.status ===
|
|
822
|
-
* "shipped"`, matched the very close it was about to perform, and treated a run that was actually
|
|
823
|
-
* closed `aborted` as already closed `shipped`: `ok:true`, an "idempotent no-op" that silently kept
|
|
824
|
-
* the abort's own `closed_at`/`close_cause` under a `status:` line now reading `shipped`. `closed_status`
|
|
825
|
-
* is written ONLY here, exactly once per distinct close-writing call, so nothing between two calls to
|
|
826
|
-
* this function can move it — it is the one field that actually answers "has this run been closed,
|
|
827
|
-
* and to what" regardless of how many times `status:` has been rewritten since.
|
|
828
|
-
*
|
|
829
|
-
* WHAT A SECOND CLOSE MEANS, decided explicitly rather than left implicit. `run_id` is reused across
|
|
830
|
-
* relaunches by design (AGENTS.md), so "the run's close" and "this launch's own close" are two
|
|
831
|
-
* different facts a single `closed_at`/`close_cause` pair cannot both hold:
|
|
832
|
-
* - The IDENTICAL status and the IDENTICAL cause is the ordinary case a retried or duplicated call
|
|
833
|
-
* produces (the same `withWarnings` call, or a relaunch that re-executes an already-applied
|
|
834
|
-
* close) — a true no-op, `ok:true`, nothing rewritten.
|
|
835
|
-
* - The SAME terminal status but a DIFFERENT cause is a SECOND, real close — most often a later
|
|
836
|
-
* relaunch aborting again for its own reason, or shipping again after an earlier ship's close
|
|
837
|
-
* record was never superseded. Discarding it (the pre-rework behavior) silently drops the later
|
|
838
|
-
* launch's own reason with no trace of the loss. It is recorded instead: this call's cause
|
|
839
|
-
* becomes the ledger's `close_cause`, folded together with the prior cause it is superseding —
|
|
840
|
-
* the earlier fact survives inside the new line rather than the ledger simply losing it — and the
|
|
841
|
-
* return carries `superseded:true` so a caller (`closeIfTerminal`) can flag the trace as degraded
|
|
842
|
-
* rather than reporting a clean success.
|
|
843
|
-
* - A DIFFERENT terminal status altogether (aborted vs. shipped) is refused outright — flipping the
|
|
844
|
-
* actual OUTCOME of a run after the fact is not a fact a later launch gets to silently overwrite,
|
|
845
|
-
* so the original `closed_status`/`closed_at`/`close_cause` are left completely untouched and
|
|
846
|
-
* handed back to the caller.
|
|
847
|
-
*
|
|
848
|
-
* @param {string} cwd - Project root.
|
|
849
|
-
* @param {string} slug - Feature slug.
|
|
850
|
-
* @param {{status:string, cause:(string|null), withExport?:boolean}} o - The terminal status (one
|
|
851
|
-
* of {@link TERMINAL_STATUSES}) and why the run ended there. `cause` travels through `uncoerce`
|
|
852
|
-
* (the one dialect `harness-run.md`'s frontmatter is read and written in), so free prose — quotes
|
|
853
|
-
* and colons included — round-trips as one frontmatter line; an embedded newline is collapsed to
|
|
854
|
-
* a space first, because this dialect is line-based and could not carry one either way.
|
|
855
|
-
* `withExport` (default true) gates {@link exportOnClose} — every status except `"shipped"` runs
|
|
856
|
-
* it on a successful close; `false` exists only for a test fixture proving the export assertion
|
|
857
|
-
* is real, never for a production call site.
|
|
858
|
-
* @returns {{ok:boolean, path:string, status:string, closed_at?:string, cause?:(string|null),
|
|
859
|
-
* reason?:string, closed_status?:string, superseded?:boolean, decision?:string,
|
|
860
|
-
* prior_cause?:(string|null), prior_closed_at?:string, export_warning?:string}} Outcome. A refused
|
|
861
|
-
* overwrite (already closed with a DIFFERENT terminal status) carries
|
|
862
|
-
* `closed_status`/`closed_at`/`cause` naming what is actually on disk. A successful supersede
|
|
863
|
-
* (same status, different cause) carries `superseded:true`, `decision:"superseded"` (the
|
|
864
|
-
* one-token signal the courier boundary in `shapeup-run.js` relays verbatim — see its own
|
|
865
|
-
* `cmd()` banner) and the prior close it folded in. Any successful, non-`"shipped"` close carries
|
|
866
|
-
* `export_warning` when {@link exportOnClose} could not write the run's fact tables — the close
|
|
867
|
-
* itself still stands; this is advisory only.
|
|
868
|
-
*/
|
|
869
|
-
export function closeRun(cwd, slug, { status, cause = null, withExport = true } = {}) {
|
|
870
|
-
const p = harnessRun(cwd, slug);
|
|
871
|
-
if (!TERMINAL_STATUSES.includes(status)) {
|
|
872
|
-
return { ok: false, path: p, status, reason: `closeRun: "${status}" is not terminal — expected one of ${TERMINAL_STATUSES.join(" | ")}` };
|
|
873
|
-
}
|
|
874
|
-
if (!existsSync(p)) {
|
|
875
|
-
return { ok: false, path: p, status, reason: `no harness-run.md for slug "${slug}" — open the run with harness init run (GATE L0.1) before closing it` };
|
|
876
|
-
}
|
|
877
|
-
let body = readFileSync(p, "utf8");
|
|
878
|
-
if (!/^status:.*$/m.test(body) || !/^closed_at:.*$/m.test(body)) {
|
|
879
|
-
return { ok: false, path: p, status, reason: `harness-run.md carries no "status:"/"closed_at:" line to replace — the ledger's frontmatter is malformed (references/protocol.md)` };
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
// The Ship phase (`shapeup-run.js`) already exports a shipped run itself, before this call ever
|
|
883
|
-
// runs — Stage 2 adds the endings that wrote nothing, and leaves that path untouched.
|
|
884
|
-
const shouldExport = withExport && status !== "shipped";
|
|
885
|
-
// Derived once, from the run's own records, and written with the close line (see deriveLedgerFacts).
|
|
886
|
-
let derived = null;
|
|
887
|
-
try { derived = deriveLedgerFacts(cwd, slug); } catch { derived = null; }
|
|
888
|
-
|
|
889
|
-
/**
|
|
890
|
-
* Everything a close owes the checkout once the ledger line is written: export the run's
|
|
891
|
-
* records, then retire its pointers.
|
|
892
|
-
*
|
|
893
|
-
* The export runs FIRST, but not because it has to: `exportOnClose` is handed the slug and keys
|
|
894
|
-
* its output by the receipt's `run_id`, so it never reads the pointers this retires. (The bare
|
|
895
|
-
* `reduce export` CLI does read `active-scope` — only to work out which run the operator meant
|
|
896
|
-
* when they named none.) Reporting before teardown is a defensive default, not a correctness
|
|
897
|
-
* requirement, and it is recorded as such so the next reader does not defend an ordering that
|
|
898
|
-
* carries nothing. Measured: swapping the two leaves every check green.
|
|
899
|
-
*
|
|
900
|
-
* WHY RETIRE AT ALL. The substrate fence is enforced while an order is compiled and unanswered,
|
|
901
|
-
* and a run that ends any way other than shipping leaves exactly that by construction. Until this
|
|
902
|
-
* ran, the fence outlived the run: after a close that exited 0 and recorded everything, an
|
|
903
|
-
* ordinary write anywhere in the project was still denied, with no dispatch in flight. The
|
|
904
|
-
* operator's obvious remedy did not help either — `init run --force` is documented as "abandon
|
|
905
|
-
* the open run and start over", never as "release a stuck fence".
|
|
906
|
-
*
|
|
907
|
-
* AND RETIRING IS NOT ANSWERING. The pointer says "a run is in flight"; the order's missing
|
|
908
|
-
* result says "nobody came back". Only the first is untrue after a close. The abandoned order
|
|
909
|
-
* stays unanswered, the attempt census still sees nothing spent on it, and `init run --force`
|
|
910
|
-
* remains the one thing that writes a synthetic result — because a close that quietly claimed the
|
|
911
|
-
* work was answered would spend an attempt budget on work nobody did.
|
|
912
|
-
*
|
|
913
|
-
* Best-effort, like the export: a pointer that cannot be removed degrades the close and is
|
|
914
|
-
* reported in its return, but never turns a close into a non-close.
|
|
915
|
-
*/
|
|
916
|
-
const finishClose = (result) => {
|
|
917
|
-
const warning = shouldExport ? exportOnClose(cwd, slug) : null;
|
|
918
|
-
// The breadcrumb goes down before the pointers come up: the hooks that fire next — the
|
|
919
|
-
// scope-hammer census, the ship phase — resolve their run through whichever of the two exists,
|
|
920
|
-
// and there must be no instant in which neither does. Best-effort like the rest of this close.
|
|
921
|
-
try {
|
|
922
|
-
mkdirSync(dirname(lastRun(cwd)), { recursive: true });
|
|
923
|
-
writeFileSync(lastRun(cwd), JSON.stringify({
|
|
924
|
-
slug, run_id: readRunId(cwd, slug), closed_status: status, closed_at: new Date().toISOString(),
|
|
925
|
-
}) + "\n");
|
|
926
|
-
} catch { /* a missing breadcrumb costs the post-close rows their key, never the close */ }
|
|
927
|
-
const stuck = [];
|
|
928
|
-
for (const pointer of [activeOrder(cwd), activeScope(cwd)]) {
|
|
929
|
-
try { rmSync(pointer, { force: true }); } catch { /* fall through to the check below */ }
|
|
930
|
-
if (existsSync(pointer)) stuck.push(pointer);
|
|
931
|
-
}
|
|
932
|
-
return {
|
|
933
|
-
...result,
|
|
934
|
-
...(warning ? { export_warning: warning } : {}),
|
|
935
|
-
...(stuck.length ? { pointer_warning: `could not retire ${stuck.join(", ")} — the substrate fence may still deny writes until it is removed by hand` } : {}),
|
|
936
|
-
};
|
|
937
|
-
};
|
|
938
|
-
|
|
939
|
-
// Truncated, not elided: a cause this long has already done its job in the run's own log — the
|
|
940
|
-
// ledger line is a pointer back to it, not the full transcript. Newlines are collapsed to spaces
|
|
941
|
-
// FIRST — this dialect is line-based, so a raw embedded newline would split one field into a value
|
|
942
|
-
// line and a stray, unparsed one.
|
|
943
|
-
const normCause = String(cause ?? "").replace(/\r?\n/g, " ").trim().slice(0, 4000) || null;
|
|
944
|
-
|
|
945
|
-
const before = parseFrontmatter(body);
|
|
946
|
-
const priorClosedStatus = before.closed_status && before.closed_status !== "~" ? before.closed_status : null;
|
|
947
|
-
const priorClosedAt = before.closed_at && before.closed_at !== "~" ? before.closed_at : null;
|
|
948
|
-
const priorCause = before.close_cause && before.close_cause !== "~" ? before.close_cause : null;
|
|
949
|
-
|
|
950
|
-
if (priorClosedStatus && priorClosedAt) {
|
|
951
|
-
if (priorClosedStatus === status && normCause === priorCause) {
|
|
952
|
-
// The identical fact, restated — a retried or duplicated call costs nothing.
|
|
953
|
-
return finishClose({ ok: true, path: p, status, closed_at: priorClosedAt, cause: priorCause, decision: "idempotent", reason: `already closed as "${status}" at ${priorClosedAt} — idempotent no-op` });
|
|
954
|
-
}
|
|
955
|
-
if (priorClosedStatus !== status) {
|
|
956
|
-
// A DIFFERENT terminal status over an already-closed run — refused outright, the original
|
|
957
|
-
// close left completely untouched so the caller can see what it was refused permission to
|
|
958
|
-
// destroy, rather than losing it silently.
|
|
959
|
-
return {
|
|
960
|
-
ok: false, path: p, status,
|
|
961
|
-
reason: `closeRun: this run is already closed as "${priorClosedStatus}" at ${priorClosedAt} (cause: ${JSON.stringify(priorCause)}) — refusing to overwrite it with "${status}". A terminal close is a once-only fact; the first cause is not destroyed.`,
|
|
962
|
-
closed_status: priorClosedStatus, closed_at: priorClosedAt, cause: priorCause,
|
|
963
|
-
};
|
|
964
|
-
}
|
|
965
|
-
// SAME terminal status, a DIFFERENT cause — a second, real close (see the function banner's
|
|
966
|
-
// "what a second close means"). Superseded, not discarded: the prior cause is folded into the
|
|
967
|
-
// new line rather than lost, and the return says so explicitly.
|
|
968
|
-
const closedAt = new Date().toISOString();
|
|
969
|
-
const foldedCause = `${normCause || "no reason recorded"} — supersedes an earlier close recorded ${priorClosedAt} (cause: ${JSON.stringify(priorCause)})`.slice(0, 4000);
|
|
970
|
-
body = writeCloseLines(body, { status, closedAt, cause: foldedCause, derived });
|
|
971
|
-
try { writeFileSync(p, body); } catch (e) {
|
|
972
|
-
return { ok: false, path: p, status, reason: `could not write the ledger: ${e.message}` };
|
|
973
|
-
}
|
|
974
|
-
const afterSup = parseFrontmatter(readFileSync(p, "utf8"));
|
|
975
|
-
if (afterSup.status !== status || !afterSup.closed_at || afterSup.closed_at === "~") {
|
|
976
|
-
return { ok: false, path: p, status, reason: `wrote the superseding close but the ledger reads back status="${afterSup.status}" closed_at="${afterSup.closed_at}" — the write did not take` };
|
|
977
|
-
}
|
|
978
|
-
return finishClose({
|
|
979
|
-
ok: true, path: p, status, closed_at: afterSup.closed_at, cause: afterSup.close_cause ?? null,
|
|
980
|
-
superseded: true, decision: "superseded", prior_cause: priorCause, prior_closed_at: priorClosedAt,
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
const closedAt = new Date().toISOString();
|
|
985
|
-
body = writeCloseLines(body, { status, closedAt, cause: normCause, derived });
|
|
986
|
-
try { writeFileSync(p, body); } catch (e) {
|
|
987
|
-
return { ok: false, path: p, status, reason: `could not write the ledger: ${e.message}` };
|
|
988
|
-
}
|
|
989
|
-
const after = parseFrontmatter(readFileSync(p, "utf8"));
|
|
990
|
-
if (after.status !== status || !after.closed_at || after.closed_at === "~") {
|
|
991
|
-
return { ok: false, path: p, status, reason: `wrote the close but the ledger reads back status="${after.status}" closed_at="${after.closed_at}" — the write did not take` };
|
|
992
|
-
}
|
|
993
|
-
return finishClose({ ok: true, path: p, status, closed_at: after.closed_at, cause: after.close_cause ?? null, decision: "closed" });
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
/**
|
|
997
|
-
* Point the substrate pointer at the order about to be executed.
|
|
998
|
-
*
|
|
999
|
-
* @param {string} cwd - Project root.
|
|
1000
|
-
* @param {string} slug - Feature slug.
|
|
1001
|
-
* @param {string} orderPath - Path to the active order.
|
|
1002
|
-
* @returns {{ok: boolean, path: string, slug: string, order_path: string, reason?: string}} Outcome.
|
|
1003
|
-
*/
|
|
1004
|
-
export function writeActiveOrder(cwd, slug, orderPath) {
|
|
1005
|
-
const p = activeOrder(cwd);
|
|
1006
|
-
try {
|
|
1007
|
-
mkdirSync(dirname(p), { recursive: true });
|
|
1008
|
-
writeFileSync(p, `${JSON.stringify({ slug, order_path: orderPath }, null, 2)}\n`);
|
|
1009
|
-
} catch (e) {
|
|
1010
|
-
return { ok: false, path: p, slug, order_path: orderPath, reason: `could not write the active-order pointer: ${e.message}` };
|
|
1011
|
-
}
|
|
1012
|
-
let readBack;
|
|
1013
|
-
try { readBack = JSON.parse(readFileSync(p, "utf8")); } catch { readBack = null; }
|
|
1014
|
-
if (readBack?.order_path !== orderPath || readBack?.slug !== slug) {
|
|
1015
|
-
return { ok: false, path: p, slug, order_path: orderPath, reason: `pointer read back as ${JSON.stringify(readBack)}` };
|
|
1016
|
-
}
|
|
1017
|
-
return { ok: true, path: p, slug, order_path: orderPath };
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
1021
|
-
export const ARGV_SPEC = {
|
|
1022
|
-
usage: "harness.mjs probe resume --slug <slug> [--cwd <dir>] " +
|
|
1023
|
-
"[--require <phase> | --set-status <status> | --set-active-order <path> | " +
|
|
1024
|
-
"--close <status> | --close-arm <RunReturn.status> [--cause <text>]]",
|
|
1025
|
-
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
1026
|
-
slug: { type: "str", required: true },
|
|
1027
|
-
cwd: { type: "path" },
|
|
1028
|
-
require: { type: "enum", values: PHASES },
|
|
1029
|
-
"set-status": { type: "enum", values: RUN_STATUSES },
|
|
1030
|
-
"set-active-order": { type: "str" },
|
|
1031
|
-
// The one call site that stamps a terminal status, its cause and closed_at together.
|
|
1032
|
-
close: { type: "enum", values: TERMINAL_STATUSES },
|
|
1033
|
-
// Not `--close`: the caller (shapeup-run.js's closeIfTerminal) hands over a RunReturn arm, never
|
|
1034
|
-
// a status it decided was terminal itself — RUN_RETURN_CLOSE/closeArm above make that call.
|
|
1035
|
-
"close-arm": { type: "str" },
|
|
1036
|
-
// The launch hands its own plugin root so the state probe can say whether the orchestrator about
|
|
1037
|
-
// to run is the installed one. Optional: a caller that does not know it gets `checked: false`.
|
|
1038
|
-
"plugin-root": { type: "path" },
|
|
1039
|
-
cause: { type: "str" },
|
|
1040
|
-
};
|
|
1041
|
-
|
|
1042
|
-
/**
|
|
1043
|
-
* Derive the run's resume state, or set one of the run pointers.
|
|
1044
|
-
*
|
|
1045
|
-
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
1046
|
-
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
1047
|
-
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
1048
|
-
*/
|
|
1049
|
-
export function cli(rawArgv) {
|
|
1050
|
-
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
1051
|
-
const cwd = args.cwd || process.cwd();
|
|
1052
|
-
|
|
1053
|
-
const ops = [args.require && "--require", args.setStatus && "--set-status", args.setActiveOrder && "--set-active-order", args.close && "--close", args.closeArm && "--close-arm"].filter(Boolean);
|
|
1054
|
-
if (ops.length > 1) {
|
|
1055
|
-
process.stderr.write(JSON.stringify({ error: "conflicting_flags", flags: ops, expected: "one operation per invocation" }) + "\n");
|
|
1056
|
-
process.exit(2);
|
|
1057
|
-
}
|
|
1058
|
-
if (args.cause !== undefined && !args.close && !args.closeArm) {
|
|
1059
|
-
process.stderr.write(JSON.stringify({ error: "conflicting_flags", flags: ["--cause"], expected: "--cause is only meaningful with --close or --close-arm" }) + "\n");
|
|
1060
|
-
process.exit(2);
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
// The post-condition. It prints the SAME ResumeState the derivation prints — plus which phase was
|
|
1064
|
-
// asked about and whether its artifact is there — so a caller that wants to act on the facts
|
|
1065
|
-
// rather than on the exit code never has to make a second call.
|
|
1066
|
-
if (args.require) {
|
|
1067
|
-
const state = deriveResumeState(cwd, args.slug);
|
|
1068
|
-
const satisfied = phaseSatisfied(state, args.require);
|
|
1069
|
-
console.log(JSON.stringify({
|
|
1070
|
-
...state,
|
|
1071
|
-
required_phase: args.require,
|
|
1072
|
-
required_artifact: PHASE_ARTIFACT[args.require].artifact,
|
|
1073
|
-
satisfied,
|
|
1074
|
-
}));
|
|
1075
|
-
process.exit(satisfied ? 0 : 6);
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
if (args.setStatus) {
|
|
1079
|
-
const r = setRunStatus(cwd, args.slug, args.setStatus);
|
|
1080
|
-
console.log(JSON.stringify(r));
|
|
1081
|
-
process.exit(r.ok ? 0 : 3);
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
if (args.setActiveOrder) {
|
|
1085
|
-
const r = writeActiveOrder(cwd, args.slug, args.setActiveOrder);
|
|
1086
|
-
console.log(JSON.stringify(r));
|
|
1087
|
-
process.exit(r.ok ? 0 : 3);
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
if (args.close) {
|
|
1091
|
-
const r = closeRun(cwd, args.slug, { status: args.close, cause: args.cause ?? null });
|
|
1092
|
-
console.log(JSON.stringify(r));
|
|
1093
|
-
process.exit(r.ok ? 0 : 3);
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// The arm-derived close: the caller hands a RunReturn arm and this kernel decides — via
|
|
1097
|
-
// RUN_RETURN_CLOSE/closeArm above — whether it is terminal and, if so, what status it closes as.
|
|
1098
|
-
// Exit 0 for both a real close AND a correctly-declined non-terminal arm (`terminal:false`) —
|
|
1099
|
-
// neither is an error the caller (shapeup-run.js's closeIfTerminal) should treat as failed; only
|
|
1100
|
-
// an arm this map does not recognize at all, or a close `closeRun` itself refuses, exits non-zero.
|
|
1101
|
-
if (args.closeArm) {
|
|
1102
|
-
const r = closeArm(cwd, args.slug, args.closeArm, args.cause ?? null);
|
|
1103
|
-
console.log(JSON.stringify(r));
|
|
1104
|
-
process.exit(r.ok ? 0 : 3);
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
console.log(JSON.stringify(deriveResumeState(cwd, args.slug, { pluginRoot: args.pluginRoot ?? null })));
|
|
1108
|
-
process.exit(0);
|
|
1109
|
-
}
|
|
1110
|
-
|