shapeup-sdlc 1.7.0 → 3.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 (98) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +17 -3
  4. package/README.md +124 -103
  5. package/SECURITY.md +37 -30
  6. package/bin/init.mjs +77 -47
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +7 -6
  9. package/commands/ship.md +29 -16
  10. package/commands/wire.md +1 -1
  11. package/hooks/dispatch-receipt.mjs +195 -0
  12. package/hooks/gate-intake.mjs +3 -3
  13. package/hooks/gate-zerowork.mjs +25 -24
  14. package/hooks/hooks.json +9 -48
  15. package/hooks/lib/decision.mjs +19 -8
  16. package/hooks/safety-spine.mjs +3 -3
  17. package/hooks/sandbox-guard.mjs +84 -40
  18. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
  19. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
  20. package/kernel/harness.mjs +134 -0
  21. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
  22. package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
  23. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  24. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
  25. package/kernel/lib/paths.mjs +491 -0
  26. package/kernel/probe/concurrency.mjs +510 -0
  27. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
  28. package/kernel/probe/eval.mjs +77 -0
  29. package/kernel/probe/leg.mjs +125 -0
  30. package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
  31. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +93 -11
  32. package/kernel/probe/t0.mjs +66 -0
  33. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
  34. package/kernel/reduce/graph.mjs +437 -0
  35. package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
  36. package/kernel/reduce/ingest.mjs +633 -0
  37. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  38. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
  39. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
  40. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
  41. package/kernel/report/export.mjs +325 -0
  42. package/kernel/report/facts.mjs +347 -0
  43. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
  44. package/kernel/verify/dispatch.mjs +114 -0
  45. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  46. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  47. package/kernel/verify/skills.mjs +125 -0
  48. package/kernel/verify/spec.mjs +559 -0
  49. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
  50. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
  51. package/oracles/http-oracle.mjs +1 -1
  52. package/oracles/process-oracle.mjs +1 -1
  53. package/oracles/snapshot-oracle.mjs +1 -1
  54. package/oracles/test-oracle.mjs +1 -1
  55. package/package.json +11 -9
  56. package/skills/ba-pitch-analyzer/SKILL.md +5 -5
  57. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  58. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  59. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  60. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  61. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  62. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  63. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  64. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  65. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  66. package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
  67. package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
  68. package/skills/qa-edge-hunter/SKILL.md +7 -3
  69. package/skills/scope-architect/SKILL.md +40 -6
  70. package/skills/solution-architect/SKILL.md +19 -4
  71. package/skills/spec-evaluator/SKILL.md +12 -2
  72. package/skills/task-executor/SKILL.md +7 -6
  73. package/skills/tech-lead/SKILL.md +50 -38
  74. package/skills/tech-lead/references/gates.md +69 -20
  75. package/skills/tech-lead/references/protocol.md +832 -0
  76. package/skills/tech-lead/schemas/domain.schema.json +341 -66
  77. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  78. package/skills/tech-lead/schemas/work-result.schema.json +1 -1
  79. package/skills/tech-lead/workflows/shapeup-run.js +1313 -750
  80. package/hooks/anti-rationalization.mjs +0 -238
  81. package/hooks/compact-snapshot.mjs +0 -47
  82. package/hooks/gate-deadline.mjs +0 -147
  83. package/hooks/gate-l2.mjs +0 -161
  84. package/hooks/session-rehydrate.mjs +0 -108
  85. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  86. package/skills/spec-evaluator/README.md +0 -93
  87. package/skills/tech-lead/README.md +0 -71
  88. package/skills/tech-lead/references/delegation.md +0 -261
  89. package/skills/tech-lead/references/hard-rules.md +0 -34
  90. package/skills/tech-lead/references/invocation.md +0 -45
  91. package/skills/tech-lead/references/ledger-schema.md +0 -213
  92. package/skills/tech-lead/references/round-protocol.md +0 -234
  93. package/skills/tech-lead/references/state-model.md +0 -66
  94. package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
  95. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
  96. package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
  97. package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
  98. package/skills/translator/README.md +0 -66
@@ -0,0 +1,491 @@
1
+ // paths — where the harness writes, and which run a record belongs to.
2
+ //
3
+ // CONTRACT. Every generated path in this plugin resolves through this module; nothing else may
4
+ // spell a storage root. The structural suite enforces that, because a root hard-coded in two
5
+ // syntaxes (a string literal and a `join(cwd, "…", …)` chain) is a rename that no single search
6
+ // completes.
7
+ //
8
+ // TIER DISCIPLINE (ADR-0001), the reason there are two roots:
9
+ // SHARED (committed) — prose a teammate reads: shaping, spec, contracts, requirements, report.
10
+ // LOCAL (gitignored) — run state, envelopes, verification artifacts, machine policy.
11
+ //
12
+ // RUN KEY. The bottom half of this file mints and reads `run_id`, the only field that separates
13
+ // two runs of the same feature — `order_id`, round and attempt all repeat. It lives here because a
14
+ // run key is an addressing question, and because every reader of it also needs a path.
15
+ //
16
+ // Zero dependencies. The path half is pure — every function takes `cwd` and returns a string,
17
+ // touching no filesystem. The run-key half reads receipts and fails open (`null`, never a throw),
18
+ // because hooks call it and telemetry must never break a tool call.
19
+
20
+ import { join } from "node:path";
21
+ import { readFileSync } from "node:fs";
22
+ import { createHash } from "node:crypto";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // The two roots. Renaming a root is these two lines plus migration 0006.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ // Both roots are POSIX strings, deliberately. They are used two ways — fed to `join()` (which
29
+ // normalises separators on Windows) and interpolated into SUBSTRATE GLOBS, which are matched
30
+ // against repo-relative POSIX paths by `sandbox-guard`. A backslash reaching a glob would silently
31
+ // stop matching, so the canonical form is the one the globs need.
32
+
33
+ /**
34
+ * Committed tier — the authored deliverable a teammate gets on `git pull`.
35
+ *
36
+ * Was `docs/shapeup-sdlc`. Moved out of `docs/` because many projects publish that directory
37
+ * through a static-site generator, which either publishes the spec tree by accident or fails the
38
+ * site build on it (ADR-0001).
39
+ */
40
+ export const SHARED = "shapeup";
41
+
42
+ /** Gitignored tier — run state for the machine that invoked the harness. Was `.shapeup-sdlc`. */
43
+ export const LOCAL = ".shapeup";
44
+
45
+ /**
46
+ * The pre-ADR-0001 roots. Migration `0006` moves a project from these to the pair above;
47
+ * `bin/init.mjs` writes ignore rules covering both so a half-migrated checkout cannot commit a
48
+ * run trace. Nothing else should read these.
49
+ */
50
+ export const LEGACY = { shared: "docs/shapeup-sdlc", local: ".shapeup-sdlc" };
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Roots
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * The committed tier root.
58
+ * @param {string} cwd - Project root.
59
+ * @returns {string} `<cwd>/shapeup`.
60
+ */
61
+ export const sharedDir = (cwd) => join(cwd, SHARED);
62
+
63
+ /**
64
+ * The gitignored tier root.
65
+ * @param {string} cwd - Project root.
66
+ * @returns {string} `<cwd>/.shapeup`.
67
+ */
68
+ export const localDir = (cwd) => join(cwd, LOCAL);
69
+
70
+ /**
71
+ * A feature's committed root.
72
+ * @param {string} cwd - Project root.
73
+ * @param {string} slug - Feature slug.
74
+ * @returns {string} `<cwd>/shapeup/<slug>`.
75
+ */
76
+ export const sharedRoot = (cwd, slug) => join(cwd, SHARED, slug);
77
+
78
+ /**
79
+ * A feature's run-trace root.
80
+ * @param {string} cwd - Project root.
81
+ * @param {string} slug - Feature slug.
82
+ * @returns {string} `<cwd>/.shapeup/<slug>`.
83
+ */
84
+ export const localRoot = (cwd, slug) => join(cwd, LOCAL, slug);
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // SHARED — the deliverable
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /** The committed spec tree — the evaluator's grading truth. */
91
+ export const specDir = (cwd, slug) => join(sharedRoot(cwd, slug), "spec");
92
+ /** Use-case directory inside the spec tree. */
93
+ export const usecasesDir = (cwd, slug) => join(specDir(cwd, slug), "usecases");
94
+ /** Shaping artifacts — pitch, framing, breadboard, baseline, glossary. */
95
+ export const shapingDir = (cwd, slug) => join(sharedRoot(cwd, slug), "shaping");
96
+ // The three contracts are markdown on disk and JSON on the wire (ADR-0001) — see
97
+ // `lib/contract.mjs`. `readContract()` accepts either extension, so a project mid-migration
98
+ // keeps working; these builders name the form the harness WRITES.
99
+
100
+ /** Scope contracts, one markdown file per vertical slice. */
101
+ export const scopesDir = (cwd, slug) => join(sharedRoot(cwd, slug), "scopes");
102
+ /** One scope contract by id. */
103
+ export const scopeContract = (cwd, slug, id) => join(scopesDir(cwd, slug), `${id}.md`);
104
+ /** The wiring map — engine → seam → entry-point call site → affordance. */
105
+ export const wiringMap = (cwd, slug) => join(sharedRoot(cwd, slug), "wiring-map.md");
106
+ /** Archetype + entry_point; gates the reachability arm of trace-lint. */
107
+ export const projectProfile = (cwd, slug) => join(sharedRoot(cwd, slug), "project-profile.md");
108
+ /** The REQ clause registry that covers-closure checks against. */
109
+ export const requirements = (cwd, slug) => join(sharedRoot(cwd, slug), "requirements.md");
110
+ /** Hill shards — mechanical phase per scope. */
111
+ export const hillDir = (cwd, slug) => join(sharedRoot(cwd, slug), "hill");
112
+ /** The frozen ship report, written once at GATE L4. */
113
+ export const report = (cwd, slug) => join(sharedRoot(cwd, slug), "REPORT.md");
114
+ /** Team-shared coaching rules, read back by the three coachable workers. */
115
+ export const knowledgeBaseDir = (cwd) => join(sharedDir(cwd), "knowledge-base");
116
+ /** One worker's coaching file. */
117
+ export const knowledgeBase = (cwd, skill) => join(knowledgeBaseDir(cwd), `${skill}.md`);
118
+ // `.harness-version` and `.harness-migrations` were resolved here, committed by necessity as
119
+ // ADR-0001's third exception. Their sole writer was the upgrade path's data-migration runner, which
120
+ // has been removed; no code in the plugin has written either file since. The helpers went with
121
+ // them rather than remaining as resolvable paths to files nothing produces — a path helper is a
122
+ // claim that the file is part of the layout, and this one would have been false.
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // LOCAL — run state
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * The receipt's filename, as a constant rather than a literal at each call site.
130
+ *
131
+ * {@link runIdFromRoot} resolves a receipt from a run root it was handed directly (`verify t0` gets
132
+ * `--out <run root>` and never derives a slug), so it cannot go through {@link receipt}, which
133
+ * takes `(cwd, slug)`.
134
+ */
135
+ export const RECEIPT_FILE = "receipt.json";
136
+
137
+ /** The run receipt — the mechanical fact that a run started (GATE L0.1). */
138
+ export const receipt = (cwd, slug) => join(localRoot(cwd, slug), RECEIPT_FILE);
139
+ /** The intake, verbatim, next to its digest in the receipt. */
140
+ export const intake = (cwd, slug) => join(localRoot(cwd, slug), "intake.md");
141
+ /** The run ledger — rounds, decisions, status frontmatter. */
142
+ export const harnessRun = (cwd, slug) => join(localRoot(cwd, slug), "harness-run.md");
143
+ /** File-derived mid-run digest, frozen by `reduce snapshot --write` as an audit anchor. */
144
+ export const runSnapshot = (cwd, slug) => join(localRoot(cwd, slug), "run-snapshot.json");
145
+ /** The task board directory. */
146
+ export const tasksDir = (cwd, slug) => join(localRoot(cwd, slug), "tasks");
147
+ /** The board index. */
148
+ export const boardIndex = (cwd, slug) => join(tasksDir(cwd, slug), "_index.md");
149
+ /** Compiled WorkOrders. */
150
+ export const ordersDir = (cwd, slug) => join(localRoot(cwd, slug), "orders");
151
+ /** Returned WorkResults. */
152
+ export const resultsDir = (cwd, slug) => join(localRoot(cwd, slug), "results");
153
+ /**
154
+ * Dispatch receipts — the attestation that the SHIPPED skill ran, one appended row per dispatch.
155
+ *
156
+ * IT HAS ITS OWN DIRECTORY, and that is not tidiness. A receipt filed as a sibling of the order it
157
+ * answers (`orders/orient.receipt.json`) would land inside a directory three readers enumerate —
158
+ * `probe resume`, `reduce graph` and `report export` all `readdirSync` `orders/` — where it survives
159
+ * today only because each of them happens to filter `.json`. `orient.receipt.json` passes that
160
+ * filter and becomes a bogus `Order` node in the run graph. A separate directory means no future
161
+ * reader has to know this file exists.
162
+ *
163
+ * IT IS APPEND-ONLY JSONL, not one file per order, for the same reason `trials.jsonl` is: order
164
+ * paths repeat (`orders/orient.json` is re-dispatched verbatim on a relaunch) and scopes dispatch
165
+ * concurrently. One small `O_APPEND` line per dispatch needs no lock and loses no history, where a
166
+ * per-order file would silently overwrite the very staleness the reader has to detect.
167
+ *
168
+ * (`receipt`, above, is the RUN receipt — a different fact, hence the different name.)
169
+ */
170
+ export const dispatchReceipts = (cwd, slug) => join(localRoot(cwd, slug), "receipts", "dispatch.jsonl");
171
+ /**
172
+ * Leg-completion rows — the moment a dispatched leg was closed by the reducer.
173
+ *
174
+ * WHY IT IS A SEPARATE LEDGER FROM {@link dispatchReceipts}. A dispatch receipt is written by a
175
+ * `PostToolUse` hook, which fires when the skill RESOLVES — measured across archived runs, 1.8 to
176
+ * 47 seconds after the order was compiled, on legs that then ran for minutes. So the receipt is a
177
+ * hook-attested START and there is no end anywhere in the record set: concurrency and span are
178
+ * guesses without one. This ledger is the end, written by the one component that closes a leg.
179
+ *
180
+ * WHY IT SNAPSHOTS RATHER THAN POINTS. A row carries the order's `compiled_at` as a VALUE, not a
181
+ * path to read it from later. Order paths are reused verbatim on relaunch, so the `compiled_at` in
182
+ * `orders/<id>.json` is the LAST compile's; a row that deferred to the file would silently re-date
183
+ * itself to a dispatch that is not the one it recorded.
184
+ *
185
+ * APPEND-ONLY JSONL, for the same two reasons `dispatch.jsonl` is: order ids repeat across
186
+ * relaunches and rounds, and scopes close concurrently. One `O_APPEND` line needs no lock.
187
+ *
188
+ * A LEG THAT DIES LEAVES NO ROW, deliberately. The absence is the fact — a reader compares starts
189
+ * to completions and reports the hole rather than averaging over it.
190
+ */
191
+ export const legLedger = (cwd, slug) => join(localRoot(cwd, slug), "legs.jsonl");
192
+ /** T0 verification artifacts. */
193
+ export const t0Dir = (cwd, slug) => join(localRoot(cwd, slug), "t0");
194
+ /** Immutable per-attempt verdict artifacts the evaluator must cite. */
195
+ export const verdictsDir = (cwd, slug) => join(t0Dir(cwd, slug), "verdicts");
196
+ /** The append-only trial ledger the ratchet reduces over. */
197
+ export const trials = (cwd, slug) => join(t0Dir(cwd, slug), "trials.jsonl");
198
+ /**
199
+ * The gate-crossing ledger — one append-only row per resolved gate, written by `kernel/gate.mjs`.
200
+ *
201
+ * `resolve()` in `gate.mjs` has always computed a `ledger_row` string but never persisted it, so a
202
+ * gate crossing left no durable trace for the run graph to project a `GateDecision` node from. This
203
+ * is that write's home: same tier, same append-only-JSONL shape as {@link trials} and
204
+ * {@link decisions}, one small file with one writer.
205
+ */
206
+ export const gates = (cwd, slug) => join(localRoot(cwd, slug), "gates.jsonl");
207
+ /** Finished-scope fixture registry for the seesaw regression check. */
208
+ export const seesawRegistry = (cwd, slug) => join(localRoot(cwd, slug), "seesaw", "registry.json");
209
+ /** Evaluator output — report, evidence, verdict ledger. */
210
+ export const evaluationDir = (cwd, slug) => join(localRoot(cwd, slug), "evaluation");
211
+ /** The workflow launcher's run directory — `journal.jsonl` and the launch `result.json`. */
212
+ export const workflowRunDir = (cwd, slug) => join(localRoot(cwd, slug), "workflow-run");
213
+ /** QA hunt output. */
214
+ export const qaDir = (cwd, slug) => join(localRoot(cwd, slug), "qa");
215
+ /** Scout recon — code-surface map, spikes, discovered seed. */
216
+ export const orientDir = (cwd, slug) => join(localRoot(cwd, slug), "orient");
217
+ /** Time-boxed spike workspace. */
218
+ export const spikesDir = (cwd, slug) => join(localRoot(cwd, slug), "spikes");
219
+ /** Covers-closure + reachability run trace. */
220
+ export const traceDir = (cwd, slug) => join(localRoot(cwd, slug), "trace");
221
+ /** The discovered-task ledger every discovery flow appends to. */
222
+ export const discoveryLedger = (cwd, slug) => join(localRoot(cwd, slug), "discovery", "ledger.md");
223
+ /**
224
+ * Adjudicated decisions, read back by `compile-order` as binding precedent.
225
+ *
226
+ * LOCAL since ADR-0001. It was committed, and it is appended to DURING a build round — so a run
227
+ * left the working tree dirty in the deliverable tier while it was still building. Its
228
+ * conclusions reach the team in `REPORT.md` at GATE L4 instead, frozen once.
229
+ */
230
+ export const roundLedger = (cwd, slug) => join(localRoot(cwd, slug), "round-ledger.md");
231
+
232
+ /**
233
+ * Spec WORKING NOTES — analysis that informed the contract but is not the contract.
234
+ *
235
+ * `synthesis.md`, `assess-report.md`, `feedback.md`, `api-feasibility.md`, `integration.md`. The
236
+ * committed `spec/` keeps only what the evaluator grades against and a reviewer needs (ADR-0001
237
+ * "contract vs working artifact").
238
+ */
239
+ export const workingDir = (cwd, slug) => join(localRoot(cwd, slug), "working");
240
+
241
+ // --- checkout-wide ---------------------------------------------------------
242
+
243
+ /**
244
+ * The RUN pointer — "which run is open in this checkout?" — written once by ``harness init run``.
245
+ *
246
+ * IT NO LONGER NAMES A SCOPE, and the name is the last trace of what it used to be. It was the
247
+ * branch-per-scope substrate pointer, rewritten as each scope was checked out, and a single mutable
248
+ * pointer cannot survive scopes building side by side: the last writer wins it and another leg's
249
+ * write is then judged against the wrong contract. That pointer is gone — the writer that moved it
250
+ * was removed, and `sandbox-guard` resolves the LIVE ORDER SET instead, which is why the guard is
251
+ * unaffected by this file's presence or absence.
252
+ *
253
+ * What is left is written once, at run open, and never moved again, so it is not a concurrency
254
+ * hazard. Its readers all degrade rather than fail without it: the budget check and the snapshot
255
+ * both fall back to scanning for a receipt or a mid-run ledger, and `report export` refuses with
256
+ * instructions rather than guessing. The one thing it uniquely supplies is the slug that
257
+ * {@link resolveRunId} turns into a `run_id` for callers that were handed no slug — hook decision
258
+ * rows, above all, which without it are written unjoinable to any run.
259
+ */
260
+ export const activeScope = (cwd) => join(localDir(cwd), "active-scope");
261
+ /** The pointer the sandbox guard reads to answer "which order is executing?". */
262
+ export const activeOrder = (cwd) => join(localDir(cwd), "active-order");
263
+ /** Hook receipts — one row per evaluation, so `allow` carries evidence. */
264
+ export const decisions = (cwd) => join(localDir(cwd), "decisions.jsonl");
265
+ /** Human-authored safety escape hatch. LOCAL so no PR can widen another machine's envelope. */
266
+ export const safetyOverrides = (cwd) => join(localDir(cwd), "safety-overrides.json");
267
+ /**
268
+ * Telemetry shards.
269
+ *
270
+ * LOCAL since ADR-0001. Committed, they put `process.env.HOSTNAME` — a person's laptop name —
271
+ * into the repository, and append-only JSONL in git only grows. The cost, stated plainly: with
272
+ * these local, ``harness probe stats`` becomes a personal tool and "is the KB flywheel working across the
273
+ * team?" is no longer answerable from the repo.
274
+ */
275
+ export const metricsDir = (cwd) => join(localDir(cwd), "metrics");
276
+ /** This machine's telemetry shard. */
277
+ export const metricsShard = (cwd, id = process.env.HOSTNAME || "local") =>
278
+ join(metricsDir(cwd), `${id}.jsonl`);
279
+
280
+ /**
281
+ * The default landing tier for exported run records — the analysis plane's staging area.
282
+ *
283
+ * DELIBERATELY LOCAL, and the trade-off is stated rather than dodged. The run trace it reads from
284
+ * (`.shapeup/<slug>/`) is regenerable and gets wiped per-slug; an export keyed by run id survives
285
+ * that, which is the failure this directory closes. What it does NOT do is cross a machine
286
+ * boundary: making it SHARED would put per-run structured data and a machine id back in the
287
+ * repository, which is exactly what ADR-0001 moved the metrics shards out of git to prevent. A
288
+ * cross-machine warehouse is therefore an explicit `--out <dir>` to a destination the operator
289
+ * owns, never a default that commits telemetry on their behalf.
290
+ */
291
+ export const exportsDir = (cwd) => join(localDir(cwd), "exports");
292
+
293
+ /** One exported run's table directory, keyed by the run id (see {@link mintRunId}). */
294
+ export const exportRunDir = (cwd, runId) => join(exportsDir(cwd), String(runId ?? "unkeyed"));
295
+ /** Archived pitches. */
296
+ export const pitchArchiveDir = (cwd) => join(localDir(cwd), "pitch-archive");
297
+
298
+ /**
299
+ * Gate answer sets, in resolution order (first hit wins).
300
+ *
301
+ * ONE TIER ONLY, since ADR-0001. There used to be a third candidate — a COMMITTED
302
+ * `gate-answers.json`, auto-discovered with no flag — so a file with `preset: ci` pre-approved
303
+ * GATE L4 ship sign-off for everyone who pulled the repo, and the same filename meant "my
304
+ * personal lane" at one path and "team policy" at another. Consent is now per-machine by
305
+ * construction: no committed file can cross a gate on another person's behalf.
306
+ *
307
+ * @param {string} cwd - Project root.
308
+ * @param {(string|null)} [slug] - Feature slug; adds the per-run candidate when given.
309
+ * @returns {string[]} Candidate paths, most specific first.
310
+ */
311
+ export const gateAnswerCandidates = (cwd, slug = null) => [
312
+ ...(slug ? [join(localRoot(cwd, slug), "gate-answers.json")] : []),
313
+ join(localDir(cwd), "gate-answers.json"),
314
+ ];
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Relative forms — for messages, receipts and anything a human reads
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /**
321
+ * A run-trace path relative to the project root, for display.
322
+ * @param {string} slug - Feature slug.
323
+ * @param {...string} parts - Path segments under the feature's local root.
324
+ * @returns {string} e.g. `.shapeup/checkout/t0/verdicts`.
325
+ */
326
+ export const relLocal = (slug, ...parts) => join(LOCAL, slug, ...parts);
327
+
328
+ /**
329
+ * A deliverable path relative to the project root, for display.
330
+ * @param {string} slug - Feature slug.
331
+ * @param {...string} parts - Path segments under the feature's shared root.
332
+ * @returns {string} e.g. `shapeup/checkout/spec`.
333
+ */
334
+ export const relShared = (slug, ...parts) => join(SHARED, slug, ...parts);
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Glob forms — substrate whitelists, matched against repo-relative POSIX paths
338
+ // ---------------------------------------------------------------------------
339
+
340
+ /**
341
+ * A run-trace glob. Always POSIX-separated: `sandbox-guard` matches these against
342
+ * `path.relative()` output normalised to forward slashes, so a `join()` here would stop matching
343
+ * on Windows without any error to notice.
344
+ * @param {string} slug - Feature slug.
345
+ * @param {...string} parts - Glob segments under the feature's local root.
346
+ * @returns {string} e.g. `.shapeup-sdlc/checkout/tasks/**`.
347
+ */
348
+ export const globLocal = (slug, ...parts) => [LOCAL, slug, ...parts].join("/");
349
+
350
+ /**
351
+ * A deliverable glob, POSIX-separated for the same reason as {@link globLocal}.
352
+ * @param {string} slug - Feature slug.
353
+ * @param {...string} parts - Glob segments under the feature's shared root.
354
+ * @returns {string} e.g. `shapeup/checkout/scopes/*.md`.
355
+ */
356
+ export const globShared = (slug, ...parts) => [SHARED, slug, ...parts].join("/");
357
+
358
+ /**
359
+ * The coaching file a coachable worker reads, as a repo-relative path for the WorkOrder payload.
360
+ * @param {string} skill - Worker name (task-executor | ba-pitch-analyzer | qa-edge-hunter).
361
+ * @returns {string} e.g. `shapeup/knowledge-base/task-executor.md`.
362
+ */
363
+ export const relKnowledgeBase = (skill) => [SHARED, "knowledge-base", `${skill}.md`].join("/");
364
+
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // The run key — `<slug>-<YYYYMMDDTHHMMSSZ>-<8 hex>`.
368
+ // ---------------------------------------------------------------------------
369
+ //
370
+ // DERIVED, NEVER DRAWN. `randomUUID()` would be one line and would forfeit re-derivability: a
371
+ // random key exists only where it was first written, so a record that missed the stamp could never
372
+ // be joined afterwards. This id is a pure function of three fields the receipt already holds —
373
+ // slug, started_at, intake_sha256 — so every writer that can see the receipt computes the same id,
374
+ // and runs that predate the field are backfillable.
375
+ //
376
+ // Slug first because it is the aggregate root every path is already keyed off; timestamp second so
377
+ // a lexical sort within a slug is chronological; hash last as the tiebreak. Filesystem-safe by
378
+ // construction — the export tier uses it as a directory name.
379
+
380
+
381
+ /**
382
+ * The id's shape, as one regex. Exported so the schema, the tests and the export tier check the
383
+ * same pattern instead of three drifting copies of it.
384
+ */
385
+ export const RUN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*-\d{8}T\d{6}Z-[0-9a-f]{8}$/;
386
+
387
+ /**
388
+ * Compact an ISO timestamp into the id's sortable middle segment.
389
+ * @param {string} iso - An ISO-8601 timestamp, e.g. `2026-08-13T09:12:33.456Z`.
390
+ * @returns {(string|null)} e.g. `20260813T091233Z`, or null when the input is not ISO-shaped.
391
+ */
392
+ export function compactStamp(iso) {
393
+ const m = String(iso ?? "").match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/);
394
+ return m ? `${m[1]}${m[2]}${m[3]}T${m[4]}${m[5]}${m[6]}Z` : null;
395
+ }
396
+
397
+ /**
398
+ * Mint the run key from the three receipt fields it is a function of.
399
+ *
400
+ * Pure and total: same inputs → same id, on every machine and at any later date. That is what makes
401
+ * an unstamped record joinable after the fact.
402
+ *
403
+ * @param {object} o - The identity inputs (destructured):
404
+ * @param {string} o.slug - The feature slug — the aggregate root, and the id's partition prefix.
405
+ * @param {string} o.startedAt - The run's ISO start time (`receipt.started_at`).
406
+ * @param {string} [o.intakeSha256=""] - The intake digest, which separates two runs of the same
407
+ * slug started in the same second.
408
+ * @returns {(string|null)} `<slug>-<YYYYMMDDTHHMMSSZ>-<8 hex>`, or null when slug or timestamp is
409
+ * missing or malformed — never a partial id, which would join wrongly rather than not at all.
410
+ */
411
+ export function mintRunId({ slug, startedAt, intakeSha256 = "" }) {
412
+ const stamp = compactStamp(startedAt);
413
+ const clean = String(slug ?? "").toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "");
414
+ if (!clean || !stamp) return null;
415
+ // NUL as the field separator — it cannot occur in a slug, an ISO timestamp or a hex digest, so
416
+ // no two different field triples can concatenate to the same string. Written as the ESCAPE
417
+ // `\u0000`, never as a literal control byte: a source file carrying a raw NUL is classified as
418
+ // binary, and every line-oriented tool — `grep -r`, a diff viewer, this repo's own
419
+ // non-delivered-content sweep — skips it in silence. A file no grep can see is a file no audit
420
+ // can check, and this one hid a real finding until it was read another way.
421
+ const h = createHash("sha256")
422
+ .update(`${clean}\u0000${startedAt}\u0000${intakeSha256 ?? ""}`, "utf8")
423
+ .digest("hex").slice(0, 8);
424
+ return `${clean}-${stamp}-${h}`;
425
+ }
426
+
427
+ /**
428
+ * The run key for a parsed receipt — stamped if present, minted if not.
429
+ *
430
+ * The backfill branch is the load-bearing one: a receipt written before this field existed still
431
+ * yields the id it would have been given, so the export tier can key runs it never stamped.
432
+ *
433
+ * @param {(object|null)} r - A parsed `receipt.json`.
434
+ * @returns {(string|null)} The run key, or null when the receipt is absent or lacks identity fields.
435
+ */
436
+ export function runIdFromReceipt(r) {
437
+ if (!r || typeof r !== "object") return null;
438
+ if (typeof r.run_id === "string" && RUN_ID_PATTERN.test(r.run_id)) return r.run_id;
439
+ return mintRunId({ slug: r.slug, startedAt: r.started_at, intakeSha256: r.intake_sha256 });
440
+ }
441
+
442
+ /**
443
+ * Read a receipt from disk without throwing.
444
+ * @param {string} path - Path to a `receipt.json`.
445
+ * @returns {(object|null)} The parsed receipt, or null when missing or unparseable.
446
+ */
447
+ export function readReceipt(path) {
448
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
449
+ }
450
+
451
+ /**
452
+ * The run key for a run whose LOCAL root is known directly.
453
+ *
454
+ * ``harness verify t0`` is the caller this exists for: it is handed the run root as `--out` and never
455
+ * derives a slug, so asking it for one would mean inferring identity from a directory name.
456
+ *
457
+ * @param {string} runRoot - The feature's LOCAL root, e.g. `<cwd>/.shapeup/<slug>`.
458
+ * @returns {(string|null)} The run key, or null when no readable receipt lives there.
459
+ */
460
+ export function runIdFromRoot(runRoot) {
461
+ return runIdFromReceipt(readReceipt(join(runRoot, RECEIPT_FILE)));
462
+ }
463
+
464
+ /**
465
+ * The run key for a feature slug under a project root.
466
+ * @param {string} cwd - Project root.
467
+ * @param {string} slug - Feature slug.
468
+ * @returns {(string|null)} The run key, or null when that run has no readable receipt.
469
+ */
470
+ export function readRunId(cwd, slug) {
471
+ return runIdFromReceipt(readReceipt(receipt(cwd, slug)));
472
+ }
473
+
474
+ /**
475
+ * Best-effort run key for a caller that may not know the slug — the shape hooks need.
476
+ *
477
+ * Resolution order: the slug it was given, else the `active-scope` pointer ``harness init run`` writes.
478
+ * A hook firing outside any run resolves to null, which is the correct answer and not an error:
479
+ * "this row belongs to no run" is a fact the warehouse must be able to record.
480
+ *
481
+ * @param {string} cwd - Project root.
482
+ * @param {(string|null)} [slug=null] - Feature slug when the caller already knows it.
483
+ * @returns {(string|null)} The run key, or null when no run is active or readable.
484
+ */
485
+ export function resolveRunId(cwd, slug = null) {
486
+ if (slug) return readRunId(cwd, slug);
487
+ try {
488
+ const ptr = JSON.parse(readFileSync(activeScope(cwd), "utf8"));
489
+ return ptr?.slug ? readRunId(cwd, ptr.slug) : null;
490
+ } catch { return null; }
491
+ }