shapeup-sdlc 3.18.2 → 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.
Files changed (141) hide show
  1. package/.claude/settings.local.example.json +2 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +115 -56
  4. package/README.md +101 -143
  5. package/SECURITY.md +16 -25
  6. package/agents/orient.md +14 -0
  7. package/agents/qa-edge-hunter.md +13 -0
  8. package/agents/spec-evaluator.md +14 -0
  9. package/agents/task-executor.md +14 -0
  10. package/bin/init.mjs +13 -16
  11. package/bin/lib/grant.mjs +8 -19
  12. package/commands/build.md +9 -14
  13. package/commands/eval.md +3 -3
  14. package/commands/hammer.md +9 -5
  15. package/commands/retro.md +12 -11
  16. package/commands/scopes.md +1 -12
  17. package/commands/ship.md +45 -88
  18. package/commands/wire.md +1 -8
  19. package/hooks/dispatch-receipt.mjs +94 -8
  20. package/hooks/hooks.json +0 -41
  21. package/hooks/lib/decision.mjs +6 -9
  22. package/hooks/lib/tier.mjs +50 -0
  23. package/hooks/sandbox-guard.mjs +140 -64
  24. package/kernel/gate.mjs +203 -22
  25. package/kernel/harness.mjs +20 -67
  26. package/kernel/init/run.mjs +175 -523
  27. package/kernel/lib/contract.mjs +9 -93
  28. package/kernel/lib/floor.mjs +64 -0
  29. package/kernel/lib/paths.mjs +39 -112
  30. package/kernel/order.mjs +157 -0
  31. package/kernel/probe/digest.mjs +2 -38
  32. package/kernel/schemas/domain.schema.json +146 -1125
  33. package/kernel/schemas/work-order.schema.json +8 -4
  34. package/kernel/schemas/work-result.schema.json +1 -1
  35. package/kernel/verify/envelope.mjs +2 -2
  36. package/kernel/verify/skills.mjs +27 -8
  37. package/kernel/verify/t0.mjs +2 -2
  38. package/package.json +4 -2
  39. package/skills/building/SKILL.md +144 -0
  40. package/skills/building/resources/gates.md +76 -0
  41. package/skills/building/resources/guidance.md +23 -0
  42. package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
  43. package/skills/building/resources/intake.md +157 -0
  44. package/skills/building/resources/orders.md +69 -0
  45. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
  46. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
  47. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
  48. package/skills/building/resources/plan-scopes.md +95 -0
  49. package/skills/building/resources/plan-spec.md +102 -0
  50. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
  51. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
  52. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
  53. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
  54. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
  55. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
  56. package/skills/building/resources/plan-wire.md +82 -0
  57. package/skills/building/resources/plan.md +98 -0
  58. package/skills/building/resources/retro-research.md +60 -0
  59. package/skills/building/resources/retro-scan.md +49 -0
  60. package/skills/building/resources/retro.md +225 -0
  61. package/skills/building/resources/rounds.md +78 -0
  62. package/skills/building/resources/stop.md +127 -0
  63. package/skills/orient/SKILL.md +37 -37
  64. package/skills/qa-edge-hunter/SKILL.md +29 -14
  65. package/skills/shapeup/SKILL.md +5 -5
  66. package/skills/shapeup/resources/breadboarding.md +1 -1
  67. package/skills/shapeup/resources/context-compaction.md +5 -5
  68. package/skills/shapeup/resources/shaping.md +1 -1
  69. package/skills/spec-evaluator/SKILL.md +17 -21
  70. package/skills/spec-evaluator/references/dimension-contract.md +1 -1
  71. package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
  72. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
  73. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
  74. package/skills/task-executor/SKILL.md +11 -15
  75. package/commands/hill.md +0 -10
  76. package/hooks/gate-intake.mjs +0 -109
  77. package/hooks/gate-zerowork.mjs +0 -349
  78. package/hooks/tier-guard.mjs +0 -162
  79. package/kernel/compile.mjs +0 -1419
  80. package/kernel/init/fit.mjs +0 -199
  81. package/kernel/init/run-args.mjs +0 -206
  82. package/kernel/lib/breadboard.mjs +0 -165
  83. package/kernel/probe/attempts.mjs +0 -161
  84. package/kernel/probe/concurrency.mjs +0 -535
  85. package/kernel/probe/eval.mjs +0 -405
  86. package/kernel/probe/hunt.mjs +0 -104
  87. package/kernel/probe/leg.mjs +0 -229
  88. package/kernel/probe/owner.mjs +0 -142
  89. package/kernel/probe/requirements.mjs +0 -328
  90. package/kernel/probe/resume.mjs +0 -1110
  91. package/kernel/probe/rounds.mjs +0 -115
  92. package/kernel/probe/staged.mjs +0 -83
  93. package/kernel/probe/stats.mjs +0 -477
  94. package/kernel/probe/t0.mjs +0 -115
  95. package/kernel/reduce/board.mjs +0 -350
  96. package/kernel/reduce/graph.mjs +0 -482
  97. package/kernel/reduce/hill.mjs +0 -322
  98. package/kernel/reduce/ingest.mjs +0 -767
  99. package/kernel/reduce/leftovers.mjs +0 -159
  100. package/kernel/reduce/ship.mjs +0 -529
  101. package/kernel/reduce/snapshot.mjs +0 -291
  102. package/kernel/reduce/verdict.mjs +0 -172
  103. package/kernel/report/export.mjs +0 -384
  104. package/kernel/report/facts.mjs +0 -234
  105. package/kernel/verify/budget.mjs +0 -153
  106. package/kernel/verify/build.mjs +0 -428
  107. package/kernel/verify/dispatch.mjs +0 -114
  108. package/kernel/verify/spec.mjs +0 -975
  109. package/kernel/verify/trace.mjs +0 -604
  110. package/skills/ba-pitch-analyzer/SKILL.md +0 -204
  111. package/skills/coach/SKILL.md +0 -397
  112. package/skills/hill-chart/SKILL.md +0 -154
  113. package/skills/hill-chart/assets/dashboard.template.html +0 -1432
  114. package/skills/scope-architect/SKILL.md +0 -174
  115. package/skills/scope-hammer/SKILL.md +0 -231
  116. package/skills/solution-architect/SKILL.md +0 -158
  117. package/skills/tech-lead/SKILL.md +0 -151
  118. package/skills/tech-lead/references/gates.md +0 -605
  119. package/skills/tech-lead/references/protocol.md +0 -889
  120. package/skills/tech-lead/references/tiny-lane.md +0 -53
  121. package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
  122. package/skills/translator/SKILL.md +0 -258
  123. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
  124. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
  125. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
  126. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
  127. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
  128. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
  129. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
  130. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
  131. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
  132. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
  133. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
  134. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
  135. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
  136. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
  137. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
  138. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
  139. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
  140. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
  141. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
@@ -1,535 +0,0 @@
1
- #!/usr/bin/env node
2
- // probe concurrency — "how many legs actually ran at once, and what did that buy?"
3
- //
4
- // WHY IT EXISTS. Fanning the scope loop out has three acceptance questions — did two scopes ever
5
- // run at the same time, did shared state survive it, did wall-clock improve — and all three are
6
- // MEASUREMENTS. The pipeline emitted no measurement: the first claim that legs ran in parallel came
7
- // from a probe that counted green results, which is satisfied by three legs run one after another.
8
- // This is the missing instrument. It writes nothing and answers only from records on disk.
9
- //
10
- // THE RULE IT IS BUILT AROUND. A predicate that an ABSENCE can satisfy must report that absence in
11
- // the same value. `max_concurrent: 1` over four legs and one usable record is not a small number,
12
- // it is a false one — and it reads exactly like a run that was genuinely sequential. So every
13
- // figure here travels with the completeness of the record set it came from and with a `bound`
14
- // saying whether it is exact or a floor, and a speedup that cannot be supported is `null` with the
15
- // reason, never a plausible-looking ratio.
16
- //
17
- // WHERE THE TWO ENDS OF A LEG COME FROM.
18
- //
19
- // start `receipts/dispatch.jsonl` — a PostToolUse row, written when the Skill RESOLVED. Measured
20
- // across real runs it lands 1.8–47 s after the order was compiled, on legs that then ran
21
- // for minutes: it is a start, and being hook-written and append-only it is the one start
22
- // that survives a relaunch re-writing the order file.
23
- // end `legs.jsonl` — the reducer's leg-completion row. EXACT.
24
- // Falling back, for a run recorded before that ledger existed: the last T0 trial the leg
25
- // wrote inside its own dispatch window. That is a landmark near the end, not the end, so
26
- // every figure derived through it is marked a LOWER bound.
27
- //
28
- // WHAT IS DELIBERATELY NOT USED: result-file mtimes. A trace that has been copied carries the copy
29
- // time on every file, so a measurement built on them is fabricated rather than approximate.
30
- //
31
- // Usage: node kernel/harness.mjs probe concurrency --slug <slug> [--cwd <dir>] [--run-root <dir>]
32
- // [--round N] [--gap-s N] [--format json|table]
33
- // [--require-run-args]
34
- // Exit: 0 = a report was produced with at least one usable leg · 1 = ran, and no leg in scope had
35
- // a usable interval (the report still prints, and says why) · 2 = malformed argv.
36
- // With --require-run-args the report is skipped: 0 = run-args.json exists at this run root ·
37
- // 6 = it does not (mirrors `probe resume --require`'s "artifact absent" convention).
38
-
39
- import { existsSync, readFileSync } from "node:fs";
40
- import { join, resolve } from "node:path";
41
- import { runArgs } from "../lib/argv.mjs";
42
- import { localRoot, runIdFromRoot, RECEIPT_FILE, RUN_ARGS_FILE } from "../lib/paths.mjs";
43
-
44
- /** The round-addressed order id forms `<scope>-r<N>-a<M>` and `<phase>-r<N>`. */
45
- const ROUND_SUFFIX = /^(.*?)-r(\d+)(?:-a(\d+))?$/;
46
-
47
- /** Default fan-out width when a run's `run-args.json` declares none — mirrors the orchestrator. */
48
- export const DEFAULT_MAX_PARALLEL_SCOPES = 4;
49
-
50
- /** Default relaunch gap, in seconds. A run resumed after this long is a different launch. */
51
- export const DEFAULT_GAP_S = 900;
52
-
53
- /**
54
- * Read a JSONL ledger into rows, skipping torn lines rather than throwing.
55
- * @param {string} path - Path to a `.jsonl` file.
56
- * @returns {{rows:Array<object>, malformed:number, present:boolean}} Parsed rows and how many
57
- * lines could not be parsed — reported, because a ledger half of which is unreadable must not
58
- * look like a short one.
59
- */
60
- export function readLedger(path) {
61
- if (!existsSync(path)) return { rows: [], malformed: 0, present: false };
62
- let malformed = 0;
63
- const rows = [];
64
- for (const line of readFileSync(path, "utf8").split("\n")) {
65
- if (!line.trim()) continue;
66
- try { rows.push(JSON.parse(line)); } catch { malformed++; }
67
- }
68
- return { rows, malformed, present: true };
69
- }
70
-
71
- /**
72
- * Split an order id into the scope/phase name and the round-attempt address it encodes.
73
- * @param {string} orderId - e.g. `todo-cli/foundation-r2-a1`.
74
- * @returns {{name:(string|null), round:(number|null), attempt:(number|null)}}
75
- */
76
- export function addressOf(orderId) {
77
- const suffix = String(orderId ?? "").split("/").slice(1).join("/");
78
- if (!suffix) return { name: null, round: null, attempt: null };
79
- const m = suffix.match(ROUND_SUFFIX);
80
- if (!m) return { name: suffix, round: null, attempt: null };
81
- return { name: m[1], round: Number(m[2]), attempt: m[3] === undefined ? null : Number(m[3]) };
82
- }
83
-
84
- /**
85
- * Every build dispatch's start, from the hook-written receipt ledger.
86
- *
87
- * BUILD LEGS ONLY, and the count of what was skipped travels with the answer. ORIENT, ANALYZE,
88
- * WIRE, EVAL and GATE H are sequential phases in every lane; folding them into a concurrency figure
89
- * would dilute exactly the number the fan-out is judged on. A leg qualifies by carrying an attempt
90
- * address (`-r<N>-a<M>`), which only a scope build order does.
91
- *
92
- * @param {Array<object>} receipts - Rows from `receipts/dispatch.jsonl`.
93
- * @returns {{starts:Array<object>, phase_dispatches:number}} Starts sorted by time.
94
- */
95
- export function startsFrom(receipts) {
96
- const starts = [];
97
- let phase = 0;
98
- for (const r of receipts) {
99
- const { name, round, attempt } = addressOf(r.order_id);
100
- const at = Date.parse(r.at);
101
- if (attempt === null || !Number.isFinite(at)) { phase++; continue; }
102
- starts.push({ order_id: r.order_id, scope_id: name, round, attempt, start: at, start_source: "dispatch-receipt", run_id: r.run_id ?? null });
103
- }
104
- starts.sort((a, b) => a.start - b.start || String(a.order_id).localeCompare(String(b.order_id)));
105
- return { starts, phase_dispatches: phase };
106
- }
107
-
108
- /**
109
- * Attach an end to every start.
110
- *
111
- * THE PAIRING PROBLEM THIS SOLVES. One order id is dispatched many times — a relaunch re-uses the
112
- * path verbatim — so "the completion row for this order" is ambiguous and picking the newest would
113
- * hand every earlier dispatch the last one's end. Each start therefore claims only what falls
114
- * inside its own window: from its own instant up to the NEXT dispatch OF THE SAME SCOPE AND ROUND.
115
- *
116
- * The window is keyed by scope+round rather than by order id, and the difference is not cosmetic.
117
- * Windowing by order id let one archived leg claim a T0 artifact written by a LATER dispatch of the
118
- * same scope under a different attempt number — a leg that had actually died came back with a
119
- * seventeen-minute duration. A leg for scope S in round R is over the moment anything else is
120
- * dispatched for S in R, whatever attempt it is addressed by.
121
- *
122
- * Exact ends come from the leg ledger. When a start's window holds none — every run recorded before
123
- * that ledger existed, and every leg that died before ingest — the last T0 trial the scope wrote
124
- * inside the same window stands in, and the leg is marked a lower bound rather than a fact.
125
- *
126
- * @param {Array<object>} starts - From {@link startsFrom}.
127
- * @param {Array<object>} legRows - Rows from `legs.jsonl`.
128
- * @param {Array<object>} trials - Rows from `t0/trials.jsonl`.
129
- * @returns {Array<object>} One leg per start, `end` null when nothing closed it.
130
- */
131
- export function pairLegs(starts, legRows, trials) {
132
- const sameLeg = (a, b) => a.scope_id === b.scope_id && a.round === b.round;
133
- const nextOf = new Map();
134
- for (let i = 0; i < starts.length; i++) {
135
- for (let j = i + 1; j < starts.length; j++) {
136
- if (sameLeg(starts[j], starts[i])) { nextOf.set(i, starts[j].start); break; }
137
- }
138
- }
139
- return starts.map((s, i) => {
140
- const until = nextOf.get(i) ?? Infinity;
141
- const inWindow = (t) => Number.isFinite(t) && t >= s.start && t < until;
142
-
143
- const landmarks = trials
144
- .filter((t) => t.scope_id === s.scope_id && Number(t.round) === s.round && inWindow(Date.parse(t.at)))
145
- .map((t) => Date.parse(t.at)).sort((a, b) => a - b);
146
- // The INNER end, kept alongside the outer one rather than used only as a fallback. A T0 trial
147
- // was written BY THE LEG, mid-flight, so `[start, inner_end]` is an interval the leg provably
148
- // occupied. `ingested_at` is when the WRITER ran, which is normally the leg's last act and is
149
- // sometimes not the leg at all — an out-of-band ingest (a repair, a `--no-receipt-check` rescue,
150
- // a relaunch re-applying a result) stamps a later instant onto the same row and stretches the
151
- // leg over time it was not running. That is not hypothetical: one leg ingested out of band, minutes
152
- // after its own T0 landmark while its neighbours closed within seconds of theirs, is enough to
153
- // report two legs overlapping on a run whose dial was 1 and which built one scope at a time.
154
- const innerEnd = landmarks.length ? landmarks[landmarks.length - 1] : null;
155
-
156
- const exact = legRows
157
- .filter((r) => r.order_id === s.order_id && inWindow(Date.parse(r.ingested_at)))
158
- .map((r) => Date.parse(r.ingested_at)).sort((a, b) => a - b);
159
- if (exact.length) {
160
- return { ...s, end: exact[exact.length - 1], end_source: "leg-record", bound: "exact", inner_end: innerEnd };
161
- }
162
- if (innerEnd !== null) {
163
- return { ...s, end: innerEnd, end_source: "t0-landmark", bound: "lower", inner_end: innerEnd };
164
- }
165
- return { ...s, end: null, end_source: null, bound: null, inner_end: null };
166
- });
167
- }
168
-
169
- /**
170
- * Split legs into launches.
171
- *
172
- * A run keeps one `run_id` across every relaunch — the key is derived from the receipt, and a
173
- * resumed run reads the same receipt — so a span taken over all of a run's legs fuses launches that
174
- * were hours apart. Measured on one archived run: four launches under one key, 17:18 to 03:35, of
175
- * which the great majority is a laptop being closed.
176
- *
177
- * There is no launch id in the record set, so this is a GAP HEURISTIC and it says so: the rule and
178
- * its threshold are in the report, because a heuristic that hides is indistinguishable from a fact.
179
- *
180
- * @param {Array<object>} legs - Legs with a usable interval, sorted by start.
181
- * @param {number} gapMs - A quiet period longer than this starts a new launch.
182
- * @returns {Array<Array<object>>} One group per launch.
183
- */
184
- export function segment(legs, gapMs) {
185
- if (!legs.length) return [];
186
- const out = [[legs[0]]];
187
- let high = legs[0].end;
188
- for (const leg of legs.slice(1)) {
189
- if (leg.start - high > gapMs) { out.push([leg]); } else { out[out.length - 1].push(leg); }
190
- high = Math.max(high, leg.end);
191
- }
192
- return out;
193
- }
194
-
195
- /**
196
- * The greatest number of legs open at one instant, and when it happened.
197
- *
198
- * Ends are processed before starts at the same millisecond, so a leg that closes exactly as the
199
- * next one opens is not counted as an overlap. Two legs are concurrent only if they were both
200
- * genuinely running, and a tie at the boundary is the case where that is least certain.
201
- *
202
- * @param {Array<object>} legs - Legs with `start` and `end`.
203
- * @param {string} [endField] - Which end to measure against: `end` (the outer end, an ingest) or
204
- * `inner_end` (the last T0 trial the leg wrote, provably mid-flight). Legs lacking the named field
205
- * are dropped, so the caller must read the returned `legs` count before trusting `max`.
206
- * @returns {{max:(number|null), at:(number|null), legs:number}} `null` when there is nothing to
207
- * measure — never `0` or `1`, which a reader would take for a sequential run.
208
- */
209
- export function maxConcurrent(legs, endField = "end") {
210
- const usable = legs.filter((l) => Number.isFinite(l[endField]));
211
- if (!usable.length) return { max: null, at: null, legs: 0 };
212
- const events = [];
213
- for (const l of usable) { events.push([l.start, 1]); events.push([l[endField], -1]); }
214
- events.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
215
- let open = 0, max = 0, at = null;
216
- for (const [t, delta] of events) {
217
- open += delta;
218
- if (open > max) { max = open; at = t; }
219
- }
220
- return { max, at, legs: usable.length };
221
- }
222
-
223
- /**
224
- * The waves the run actually ran, derived from the timings rather than from the scheduler.
225
- *
226
- * A wave is a connected component of overlapping intervals: legs that ran together, bounded by
227
- * quiet moments where nothing was in flight. This is an INDEPENDENT reading of the schedule — if
228
- * the scheduler claims a dependency ordering, this is where it either shows up or does not.
229
- *
230
- * @param {Array<object>} legs - Legs with a usable interval, sorted by start.
231
- * @returns {Array<{from:string, to:string, scopes:string[]}>} Waves in start order.
232
- */
233
- export function wavesObserved(legs) {
234
- const waves = [];
235
- for (const leg of legs) {
236
- const last = waves[waves.length - 1];
237
- if (last && leg.start < last.endMs) {
238
- last.members.push(leg);
239
- last.endMs = Math.max(last.endMs, leg.end);
240
- } else {
241
- waves.push({ startMs: leg.start, endMs: leg.end, members: [leg] });
242
- }
243
- }
244
- return waves.map((w) => ({
245
- from: new Date(w.startMs).toISOString(),
246
- to: new Date(w.endMs).toISOString(),
247
- scopes: [...new Set(w.members.map((m) => m.scope_id))].sort(),
248
- }));
249
- }
250
-
251
- /**
252
- * Summarise one launch: overlap, span, work, and whether a speedup can honestly be stated.
253
- *
254
- * WHY A SPEEDUP IS REFUSED ON ANY LOWER-BOUND LEG, and it is not caution. Truncating every leg at
255
- * its T0 landmark shortens the numerator once per leg and the denominator only for whichever leg
256
- * finished last, so the ratio moves in a direction that depends on the shape of the round. Measured
257
- * on a real round that demonstrably ran four scopes at once, the truncated figure is 0.90 — a
258
- * "speedup" below 1. A ratio of two lower bounds is not a bound on the ratio.
259
- *
260
- * @param {number} index - 1-based launch number.
261
- * @param {Array<object>} legs - The launch's legs, each with a usable interval.
262
- * @returns {object} The launch block of the report.
263
- */
264
- export function summarise(index, legs) {
265
- const { max, at } = maxConcurrent(legs);
266
- // The same question asked of the inner intervals — dispatch to the leg's own T0 trial. Two legs
267
- // overlapping THERE were both provably mid-flight at one instant; two legs overlapping only on the
268
- // outer intervals may be one leg plus somebody else's later ingest of it.
269
- const inner = maxConcurrent(legs, "inner_end");
270
- const exact = legs.every((l) => l.bound === "exact");
271
- const from = Math.min(...legs.map((l) => l.start));
272
- const to = Math.max(...legs.map((l) => l.end));
273
- const span = to - from;
274
- const sum = legs.reduce((a, l) => a + (l.end - l.start), 0);
275
- const bound = exact ? "exact" : "lower";
276
- return {
277
- launch: index,
278
- from: new Date(from).toISOString(),
279
- to: new Date(to).toISOString(),
280
- legs: legs.length,
281
- max_concurrent: max,
282
- max_concurrent_at: at === null ? null : new Date(at).toISOString(),
283
- max_concurrent_bound: bound,
284
- // Reported beside the outer figure, never instead of it, and with its own coverage count: an
285
- // inner figure over 2 of 6 legs is not evidence the other four ran alone.
286
- max_concurrent_inner: inner.max,
287
- max_concurrent_inner_legs: inner.legs,
288
- // The two ends disagreeing is a fact about the RECORD, not about the run, and it is the only
289
- // signal that separates a leg that ran long from a leg something ingested later.
290
- ends_disputed: max !== null && inner.max !== null && inner.legs === legs.length && max > inner.max,
291
- span_ms: span,
292
- span_bound: bound,
293
- sum_leg_ms: sum,
294
- sum_leg_bound: bound,
295
- speedup: exact && span > 0 ? Math.round((sum / span) * 100) / 100 : null,
296
- speedup_refused_because: exact
297
- ? (span > 0 ? null : "the launch's legs span zero milliseconds — nothing to divide by")
298
- : `${legs.filter((l) => l.bound !== "exact").length} of ${legs.length} leg(s) end at a T0 landmark rather than a completion record, so both the span and the work sum are floors; their ratio bounds nothing`,
299
- waves_observed: wavesObserved(legs),
300
- scopes: [...new Set(legs.map((l) => l.scope_id))].sort(),
301
- };
302
- }
303
-
304
- /**
305
- * Which fan-out width the run was launched with — read, never assumed.
306
- *
307
- * The dial is written into `run-args.json` by `harness init run-args` (GATE L0.9b), the kernel
308
- * writer tech-lead invokes right before the launch. A run opened before that writer existed, or one
309
- * whose launcher never passed `--parallel-scopes`, still has no file or no key — so the honest
310
- * answer stays the effective default WITH the fact that it is a default: reporting `4` unqualified
311
- * would assert an operator choice nobody made.
312
- *
313
- * @param {string} runRoot - The run's LOCAL root.
314
- * @returns {{max_parallel_scopes:number, source:string}} The value and where it came from.
315
- */
316
- export function dialFrom(runRoot) {
317
- try {
318
- const a = JSON.parse(readFileSync(join(runRoot, RUN_ARGS_FILE), "utf8"));
319
- const n = Number(a?.maxParallelScopes);
320
- if (Number.isFinite(n) && n >= 1) return { max_parallel_scopes: n, source: "run-args" };
321
- return { max_parallel_scopes: DEFAULT_MAX_PARALLEL_SCOPES, source: "default (run-args.json declares none)" };
322
- } catch {
323
- return { max_parallel_scopes: DEFAULT_MAX_PARALLEL_SCOPES, source: "default (no run-args.json)" };
324
- }
325
- }
326
-
327
- /**
328
- * The whole report for one run root.
329
- *
330
- * @param {string} runRoot - The run's LOCAL root (`<cwd>/.shapeup/<slug>`, or an archived copy).
331
- * @param {object} [opts] - `{round, gapS}`.
332
- * @returns {object} A byte-stable report — no clock reading anywhere in it, arrays sorted, key
333
- * order fixed by this literal — so two runs over one tree produce identical bytes.
334
- */
335
- export function report(runRoot, { round = null, gapS = DEFAULT_GAP_S } = {}) {
336
- const receipts = readLedger(join(runRoot, "receipts", "dispatch.jsonl"));
337
- const legLedger = readLedger(join(runRoot, "legs.jsonl"));
338
- const trials = readLedger(join(runRoot, "t0", "trials.jsonl"));
339
-
340
- const { starts, phase_dispatches } = startsFrom(receipts.rows);
341
- const scoped = round === null ? starts : starts.filter((s) => s.round === round);
342
- const legs = pairLegs(scoped, legLedger.rows, trials.rows);
343
- const usable = legs.filter((l) => l.end !== null);
344
-
345
- const warnings = [];
346
- if (!receipts.present) {
347
- warnings.push("no receipts/dispatch.jsonl — nothing recorded a dispatch, so no leg has a start. " +
348
- "An empty report here means the record set is empty, not that the run was sequential.");
349
- }
350
- if (!legLedger.present) {
351
- warnings.push("no legs.jsonl — this run was recorded before leg completions were written, so every " +
352
- "end falls back to the T0 landmark and every figure below is a floor.");
353
- }
354
- for (const l of [["receipts/dispatch.jsonl", receipts], ["legs.jsonl", legLedger], ["t0/trials.jsonl", trials]]) {
355
- if (l[1].malformed) warnings.push(`${l[0]}: ${l[1].malformed} unparseable line(s) skipped`);
356
- }
357
- const unkeyed = legs.filter((l) => !l.run_id).length;
358
- if (unkeyed) {
359
- warnings.push(`${unkeyed} of ${legs.length} leg(s) carry no run_id — order_id alone repeats across ` +
360
- "runs of one slug, so those rows cannot be attributed to this run rather than another.");
361
- }
362
-
363
- const launches = segment(usable, gapS * 1000).map((g, i) => summarise(i + 1, g));
364
-
365
- const best = launches.reduce((a, b) => (b.max_concurrent > (a?.max_concurrent ?? -1) ? b : a), null);
366
- // The launch the headline verdict is answered from, chosen on the INNER figure. Taking the inner
367
- // number off `best` would compare two different launches: the widest outer overlap and the widest
368
- // inner one need not be the same launch, and mixing them would let one launch's ingest artefact
369
- // stand beside another launch's honest evidence.
370
- const bestInner = launches.reduce((a, b) => (b.max_concurrent_inner > (a?.max_concurrent_inner ?? -1) ? b : a), null);
371
- return {
372
- schema_version: 1,
373
- run_root: runRoot,
374
- run_id: runIdFromRoot(runRoot),
375
- receipt_present: existsSync(join(runRoot, RECEIPT_FILE)),
376
- round,
377
- dial: dialFrom(runRoot),
378
- completeness: {
379
- // The absence, in the same value as the number. `max_concurrent` below is meaningless without
380
- // these four counts, so they are not an appendix.
381
- legs_total: legs.length,
382
- legs_exact: legs.filter((l) => l.bound === "exact").length,
383
- legs_lower_bound: legs.filter((l) => l.bound === "lower").length,
384
- no_completion_record: legs.filter((l) => l.end === null).length,
385
- legs_unkeyed: unkeyed,
386
- phase_dispatches_excluded: phase_dispatches,
387
- end_sources: {
388
- "leg-record": legs.filter((l) => l.end_source === "leg-record").length,
389
- "t0-landmark": legs.filter((l) => l.end_source === "t0-landmark").length,
390
- none: legs.filter((l) => l.end_source === null).length,
391
- },
392
- },
393
- concurrency: {
394
- max_concurrent: best ? best.max_concurrent : null,
395
- bound: best ? best.max_concurrent_bound : null,
396
- at: best ? best.max_concurrent_at : null,
397
- launch: best ? best.launch : null,
398
- max_concurrent_inner: bestInner ? bestInner.max_concurrent_inner : null,
399
- // The acceptance predicate, answered rather than left to the reader — and `null` when the
400
- // record set cannot answer it, which is a third state and not a "no".
401
- //
402
- // ANSWERED FROM THE INNER INTERVALS WHEN THEY COVER EVERY LEG, because that is the only form
403
- // of the evidence an out-of-band ingest cannot fabricate. A run with dial 1 has been observed
404
- // reporting `max_concurrent: 2 (exact)` because one leg was ingested long after it finished; the
405
- // inner intervals said 1, which was the truth. Where the two disagree the answer is
406
- // `"disputed"` rather than either number — a fourth state, and the honest one.
407
- two_or_more_concurrent: !best ? null
408
- : (bestInner && bestInner.max_concurrent_inner_legs === bestInner.legs)
409
- ? (bestInner.max_concurrent_inner >= 2 ? true : (best.max_concurrent >= 2 ? "disputed" : false))
410
- : best.max_concurrent >= 2,
411
- disputed_because: (best && bestInner && bestInner.max_concurrent_inner_legs === bestInner.legs
412
- && bestInner.max_concurrent_inner < 2 && best.max_concurrent >= 2)
413
- ? "the overlap exists only between intervals that END AT AN INGEST; measured against each leg's own " +
414
- "T0 trial — an instant the leg provably occupied — no two legs were open together. An ingest run " +
415
- "outside its leg (a repair, a --no-receipt-check rescue, a relaunch re-applying a result) stamps a " +
416
- "later end onto the row and manufactures exactly this overlap."
417
- : null,
418
- },
419
- launches,
420
- segmentation: {
421
- rule: "a quiet period longer than gap_s starts a new launch",
422
- gap_s: gapS,
423
- launches: launches.length,
424
- note: "heuristic: nothing in the record set identifies a launch, and one run_id spans every relaunch",
425
- },
426
- legs: legs.map((l) => ({
427
- order_id: l.order_id,
428
- scope_id: l.scope_id,
429
- round: l.round,
430
- attempt: l.attempt,
431
- run_id: l.run_id,
432
- started_at: new Date(l.start).toISOString(),
433
- start_source: l.start_source,
434
- ended_at: l.end === null ? null : new Date(l.end).toISOString(),
435
- end_source: l.end_source,
436
- duration_ms: l.end === null ? null : l.end - l.start,
437
- bound: l.bound,
438
- // The leg's own mid-flight landmark and how far its recorded end sits past it. Reported for the
439
- // reader to judge rather than thresholded, deliberately: a leg that closed inside itself and one
440
- // ingested out of band differ by orders of magnitude on any single machine, and by nothing
441
- // reliable ACROSS machines — so no constant that separates them here would survive slower
442
- // hardware. The lag is the fact; what counts as suspicious is the reader's call.
443
- inner_ended_at: l.inner_end === null ? null : new Date(l.inner_end).toISOString(),
444
- end_lag_ms: (l.end === null || l.inner_end === null) ? null : l.end - l.inner_end,
445
- })),
446
- warnings,
447
- };
448
- }
449
-
450
- /**
451
- * Render the report as a table for a human, from the already-built object.
452
- * @param {object} r - A {@link report}.
453
- * @returns {string} The human view.
454
- */
455
- export function table(r) {
456
- const c = r.completeness;
457
- const lines = [
458
- `run_root ${r.run_root}`,
459
- `run_id ${r.run_id ?? "(none — records cannot be attributed to a run)"}`,
460
- `dial maxParallelScopes=${r.dial.max_parallel_scopes} (${r.dial.source})`,
461
- `legs ${c.legs_total} total · ${c.legs_exact} exact · ${c.legs_lower_bound} lower-bound · ${c.no_completion_record} with NO completion record` +
462
- `${c.phase_dispatches_excluded ? ` (+${c.phase_dispatches_excluded} non-build dispatch(es) excluded)` : ""}`,
463
- `concurrency ${r.concurrency.max_concurrent ?? "unmeasurable"}` +
464
- `${r.concurrency.max_concurrent === null ? "" : ` (${r.concurrency.bound}) at ${r.concurrency.at}`}` +
465
- `${r.concurrency.max_concurrent_inner === null ? "" : ` · ${r.concurrency.max_concurrent_inner} measured inside the legs' own T0 windows`}`,
466
- `≥2 concurrent ${r.concurrency.two_or_more_concurrent === null ? "UNKNOWN — no leg in scope has a usable interval" : r.concurrency.two_or_more_concurrent}`,
467
- ...(r.concurrency.disputed_because ? [` ! ${r.concurrency.disputed_because}`] : []),
468
- ];
469
- for (const l of r.launches) {
470
- lines.push(` launch ${l.launch} ${l.from} → ${l.to} legs=${l.legs} max=${l.max_concurrent} (${l.max_concurrent_bound})` +
471
- ` span=${(l.span_ms / 1000).toFixed(1)}s work=${(l.sum_leg_ms / 1000).toFixed(1)}s speedup=${l.speedup ?? "refused"}`);
472
- if (l.speedup === null) lines.push(` refused: ${l.speedup_refused_because}`);
473
- for (const w of l.waves_observed) lines.push(` wave ${w.scopes.join(" + ")}`);
474
- }
475
- for (const w of r.warnings) lines.push(` ! ${w}`);
476
- return lines.join("\n");
477
- }
478
-
479
- /** The typed argv contract (see `./lib/argv.mjs`). */
480
- export const ARGV_SPEC = {
481
- usage: "harness.mjs probe concurrency (--slug <slug> | --run-root <dir>) [--cwd <dir>] [--round N] " +
482
- "[--gap-s N] [--format json|table] [--require-run-args]",
483
- _: { arity: 0, max: 0, name: "(no positional operands)" },
484
- slug: { type: "str" },
485
- // The archived-trace and post-export cases, and the same escape `verify t0 --out` has: a caller
486
- // that already holds the run root should not have to reconstruct a slug from a directory name.
487
- "run-root": { type: "path" },
488
- cwd: { type: "path" },
489
- round: { type: "int", min: 1 },
490
- "gap-s": { type: "int", min: 1, default: DEFAULT_GAP_S },
491
- format: { type: "enum", values: ["json", "table"], default: "json" },
492
- // The positive enforcer for the launch record (see the banner below). Skips the concurrency
493
- // report entirely — this is a presence check, not a measurement, and the two must not be
494
- // confused by sharing an exit code.
495
- "require-run-args": { type: "flag" },
496
- };
497
-
498
- /**
499
- * Report a run's leg concurrency, span and observed waves.
500
- *
501
- * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
502
- * @returns {void} Exits 0 when at least one leg had a usable interval, 1 when none did — and the
503
- * report prints either way, because "nothing was measurable" is the answer, not an error.
504
- *
505
- * `--require-run-args` is a different question with its own exit convention, mirroring
506
- * `probe resume --require`'s 0 (satisfied) / 6 (artifact absent): it never reaches the report at
507
- * all. The launch record had exactly one reader (`dialFrom()`, below) and zero enforcers — a
508
- * run missing it proceeded green with a silently substituted default, "indistinguishable from an
509
- * operator choice" (`gates.md` L0.9b). This flag is what `shapeup-run.js` calls at Preflight, before
510
- * ORIENT, so that state stops being invisible: this module already owns run-root resolution and the
511
- * file's path, so the check is a few lines here rather than a new kernel entry point.
512
- */
513
- export function cli(rawArgv) {
514
- const args = runArgs(ARGV_SPEC, rawArgv);
515
- if (!args.slug && !args.runRoot) {
516
- process.stderr.write(JSON.stringify({ error: "missing_required", flag: "--slug", expected: "a slug, or --run-root <dir>" }) + "\n");
517
- process.stderr.write(`usage: ${ARGV_SPEC.usage}\n`);
518
- process.exit(2);
519
- }
520
- const runRoot = args.runRoot
521
- ? resolve(args.runRoot)
522
- : localRoot(resolve(args.cwd || process.cwd()), args.slug);
523
-
524
- if (args.requireRunArgs) {
525
- const path = join(runRoot, RUN_ARGS_FILE);
526
- const present = existsSync(path);
527
- console.log(JSON.stringify({ ok: present, run_root: runRoot, run_args_path: path,
528
- reason: present ? null : "no run-args.json at this run root — GATE L0.9b's launch record was never written before this launch" }));
529
- process.exit(present ? 0 : 6);
530
- }
531
-
532
- const r = report(runRoot, { round: args.round ?? null, gapS: args.gapS });
533
- console.log(args.format === "table" ? table(r) : JSON.stringify(r));
534
- process.exit(r.launches.length ? 0 : 1);
535
- }