shapeup-sdlc 1.6.2

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 (135) hide show
  1. package/.claude/settings.local.example.json +20 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +18 -0
  4. package/.env.shapeup.example +14 -0
  5. package/AGENTS.md +133 -0
  6. package/LICENSE +21 -0
  7. package/README.md +362 -0
  8. package/SECURITY.md +72 -0
  9. package/bin/init.mjs +329 -0
  10. package/commands/build.md +14 -0
  11. package/commands/eval.md +15 -0
  12. package/commands/hammer.md +12 -0
  13. package/commands/orient.md +11 -0
  14. package/commands/qa.md +12 -0
  15. package/commands/retro.md +13 -0
  16. package/commands/scopes.md +14 -0
  17. package/commands/shape.md +12 -0
  18. package/commands/ship.md +53 -0
  19. package/commands/wire.md +11 -0
  20. package/hooks/anti-rationalization.mjs +244 -0
  21. package/hooks/compact-snapshot.mjs +47 -0
  22. package/hooks/gate-deadline.mjs +151 -0
  23. package/hooks/gate-intake.mjs +110 -0
  24. package/hooks/gate-l2.mjs +161 -0
  25. package/hooks/gate-zerowork.mjs +264 -0
  26. package/hooks/hooks.json +118 -0
  27. package/hooks/lib/decision.mjs +183 -0
  28. package/hooks/safety-spine.mjs +296 -0
  29. package/hooks/sandbox-guard.mjs +172 -0
  30. package/hooks/session-rehydrate.mjs +109 -0
  31. package/hooks/slop-cleaner.mjs +176 -0
  32. package/oracles/_shared.mjs +46 -0
  33. package/oracles/http-oracle.mjs +155 -0
  34. package/oracles/index.mjs +36 -0
  35. package/oracles/process-oracle.mjs +146 -0
  36. package/oracles/snapshot-oracle.mjs +119 -0
  37. package/oracles/test-oracle.mjs +138 -0
  38. package/package.json +49 -0
  39. package/skills/advisor-protocol/SKILL.md +171 -0
  40. package/skills/ba-pitch-analyzer/SKILL.md +175 -0
  41. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +84 -0
  42. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +65 -0
  43. package/skills/ba-pitch-analyzer/assets/templates/assess-report.tmpl.md +127 -0
  44. package/skills/ba-pitch-analyzer/assets/templates/contracts/be-service.contract.tmpl.md +62 -0
  45. package/skills/ba-pitch-analyzer/assets/templates/contracts/offline-storage.contract.tmpl.md +92 -0
  46. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +66 -0
  47. package/skills/ba-pitch-analyzer/assets/templates/cross-context/context-map.tmpl.md +64 -0
  48. package/skills/ba-pitch-analyzer/assets/templates/cross-context/event-choreography.tmpl.md +77 -0
  49. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +104 -0
  50. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +79 -0
  51. package/skills/ba-pitch-analyzer/assets/templates/domain-model.tmpl.md +79 -0
  52. package/skills/ba-pitch-analyzer/assets/templates/feedback.tmpl.md +87 -0
  53. package/skills/ba-pitch-analyzer/assets/templates/integration.tmpl.md +64 -0
  54. package/skills/ba-pitch-analyzer/assets/templates/run-state.tmpl.md +77 -0
  55. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +105 -0
  56. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +215 -0
  57. package/skills/ba-pitch-analyzer/assets/templates/task-board.tmpl.md +35 -0
  58. package/skills/ba-pitch-analyzer/assets/templates/task-spike.tmpl.md +86 -0
  59. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +84 -0
  60. package/skills/ba-pitch-analyzer/assets/templates/usecase.tmpl.md +105 -0
  61. package/skills/ba-pitch-analyzer/assets/templates/ux-behavior.tmpl.md +65 -0
  62. package/skills/ba-pitch-analyzer/references/contract-patterns.md +152 -0
  63. package/skills/ba-pitch-analyzer/references/ddd-patterns.md +173 -0
  64. package/skills/ba-pitch-analyzer/references/doc-schemas.md +275 -0
  65. package/skills/ba-pitch-analyzer/references/integration-analysis.md +152 -0
  66. package/skills/ba-pitch-analyzer/references/task-generation.md +623 -0
  67. package/skills/ba-pitch-analyzer/references/test-surface.md +102 -0
  68. package/skills/ba-pitch-analyzer/references/ux-behavior-patterns.md +269 -0
  69. package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +241 -0
  70. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +239 -0
  71. package/skills/coach/SKILL.md +202 -0
  72. package/skills/orient/SKILL.md +262 -0
  73. package/skills/qa-edge-hunter/SKILL.md +369 -0
  74. package/skills/scope-architect/SKILL.md +111 -0
  75. package/skills/scope-hammer/SKILL.md +186 -0
  76. package/skills/shapeup/SKILL.md +403 -0
  77. package/skills/shapeup/resources/breadboard-reflection.md +88 -0
  78. package/skills/shapeup/resources/breadboarding.md +334 -0
  79. package/skills/shapeup/resources/context-compaction.md +240 -0
  80. package/skills/shapeup/resources/framing-doc.md +92 -0
  81. package/skills/shapeup/resources/kickoff-doc.md +95 -0
  82. package/skills/shapeup/resources/shaping.md +194 -0
  83. package/skills/shapeup/resources/spike.md +86 -0
  84. package/skills/solution-architect/SKILL.md +129 -0
  85. package/skills/spec-evaluator/README.md +93 -0
  86. package/skills/spec-evaluator/SKILL.md +213 -0
  87. package/skills/spec-evaluator/references/anti-leniency.md +50 -0
  88. package/skills/spec-evaluator/references/dimension-contract.md +126 -0
  89. package/skills/spec-evaluator/references/dimensions/_registry.md +40 -0
  90. package/skills/spec-evaluator/references/dimensions/completeness.md +102 -0
  91. package/skills/spec-evaluator/references/dimensions/integration.md +129 -0
  92. package/skills/spec-evaluator/references/dimensions/performance.md +48 -0
  93. package/skills/spec-evaluator/references/dimensions/security.md +60 -0
  94. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +134 -0
  95. package/skills/spec-evaluator/references/dimensions/tdd-surface.md +110 -0
  96. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +100 -0
  97. package/skills/spec-evaluator/references/probing.md +177 -0
  98. package/skills/spec-evaluator/references/report-schema.md +101 -0
  99. package/skills/spec-evaluator/references/verdict-ledger.md +92 -0
  100. package/skills/spec-evaluator/scripts/verdict-ledger.mjs +166 -0
  101. package/skills/task-executor/SKILL.md +194 -0
  102. package/skills/tech-lead/README.md +71 -0
  103. package/skills/tech-lead/SKILL.md +459 -0
  104. package/skills/tech-lead/references/delegation.md +254 -0
  105. package/skills/tech-lead/references/gates.md +379 -0
  106. package/skills/tech-lead/references/invocation.md +45 -0
  107. package/skills/tech-lead/references/ledger-schema.md +214 -0
  108. package/skills/tech-lead/references/round-protocol.md +184 -0
  109. package/skills/tech-lead/references/state-model.md +66 -0
  110. package/skills/tech-lead/references/tiny-lane.md +52 -0
  111. package/skills/tech-lead/schemas/domain.schema.json +2294 -0
  112. package/skills/tech-lead/schemas/gate-answers.schema.json +92 -0
  113. package/skills/tech-lead/schemas/work-order.schema.json +21 -0
  114. package/skills/tech-lead/schemas/work-result.schema.json +40 -0
  115. package/skills/tech-lead/scripts/aegis-digest.mjs +124 -0
  116. package/skills/tech-lead/scripts/budget-check.mjs +156 -0
  117. package/skills/tech-lead/scripts/compile-order.mjs +518 -0
  118. package/skills/tech-lead/scripts/fit-check.mjs +196 -0
  119. package/skills/tech-lead/scripts/gate-answers.mjs +338 -0
  120. package/skills/tech-lead/scripts/ingest-result.mjs +270 -0
  121. package/skills/tech-lead/scripts/init-run.mjs +326 -0
  122. package/skills/tech-lead/scripts/lib/argv.mjs +224 -0
  123. package/skills/tech-lead/scripts/lib/contract-md.mjs +481 -0
  124. package/skills/tech-lead/scripts/lib/is-main.mjs +82 -0
  125. package/skills/tech-lead/scripts/lib/paths.mjs +278 -0
  126. package/skills/tech-lead/scripts/lib/ratchet-tree.mjs +112 -0
  127. package/skills/tech-lead/scripts/run-snapshot.mjs +269 -0
  128. package/skills/tech-lead/scripts/ship-report.mjs +275 -0
  129. package/skills/tech-lead/scripts/stats.mjs +423 -0
  130. package/skills/tech-lead/scripts/t0-verify.mjs +470 -0
  131. package/skills/tech-lead/scripts/trace-lint.mjs +367 -0
  132. package/skills/tech-lead/scripts/validate-envelope.mjs +283 -0
  133. package/skills/translator/README.md +66 -0
  134. package/skills/translator/SKILL.md +258 -0
  135. package/skills/translator/references/preservation-rules.md +102 -0
@@ -0,0 +1,278 @@
1
+ // paths — the single source of truth for where the harness writes.
2
+ //
3
+ // WHY THIS FILE EXISTS (measured by grepping the shipped tree, not theorized).
4
+ //
5
+ // The two storage roots were hard-coded in ~90 files, in TWO syntaxes that no single search finds:
6
+ //
7
+ // "docs/shapeup-sdlc/<slug>/scopes/..." 568 string literals
8
+ // join(cwd, "docs", "shapeup-sdlc", slug, ...) 48 segment-built sites
9
+ //
10
+ // A find/replace over the first set leaves the second silently pointing at the old root. That is
11
+ // the failure mode this project keeps rediscovering: a change that appears complete, produces no
12
+ // error, and is wrong — `lib/is-main.mjs` (a guard duplicated 18 times, inert under a symlink) and
13
+ // `lib/argv.mjs` (`rNaN-a1.json` written with exit 0) are the same defect at different layers. The
14
+ // remedy each time is to give the duplicated thing one home and add a test that no one may bypass
15
+ // it (`tests/structural/45-paths.mjs`, mirroring #11a).
16
+ //
17
+ // It also removes a live ambiguity. `gate-answers.mjs` resolved three candidate paths and
18
+ // `sandbox-guard.mjs` built a fourth independently; the same filename meant "my personal lane" at
19
+ // one path and "team policy" at another, auto-discovered with no flag (ADR-0001 §Context).
20
+ //
21
+ // TIER DISCIPLINE, restated here because this is where it becomes mechanical:
22
+ // SHARED (committed) — prose a teammate reads: shaping, spec, contracts, requirements, report.
23
+ // LOCAL (gitignored) — run state, envelopes, verification artifacts, machine policy.
24
+ // See docs/design/adr/0001-consumer-file-organization.md.
25
+ //
26
+ // Zero dependencies. Pure — every function takes `cwd` and returns a path; nothing here touches
27
+ // the filesystem, so importing this module can never have a side effect.
28
+
29
+ import { join } from "node:path";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // The two roots. Renaming a root is these two lines plus migration 0006.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ // Both roots are POSIX strings, deliberately. They are used two ways — fed to `join()` (which
36
+ // normalises separators on Windows) and interpolated into SUBSTRATE GLOBS, which are matched
37
+ // against repo-relative POSIX paths by `sandbox-guard`. A backslash reaching a glob would silently
38
+ // stop matching, so the canonical form is the one the globs need.
39
+
40
+ /**
41
+ * Committed tier — the authored deliverable a teammate gets on `git pull`.
42
+ *
43
+ * Was `docs/shapeup-sdlc`. Moved out of `docs/` because many projects publish that directory
44
+ * through a static-site generator, which either publishes the spec tree by accident or fails the
45
+ * site build on it (ADR-0001).
46
+ */
47
+ export const SHARED = "shapeup";
48
+
49
+ /** Gitignored tier — run state for the machine that invoked the harness. Was `.shapeup-sdlc`. */
50
+ export const LOCAL = ".shapeup";
51
+
52
+ /**
53
+ * The pre-ADR-0001 roots. Migration `0006` moves a project from these to the pair above;
54
+ * `bin/init.mjs` writes ignore rules covering both so a half-migrated checkout cannot commit a
55
+ * run trace. Nothing else should read these.
56
+ */
57
+ export const LEGACY = { shared: "docs/shapeup-sdlc", local: ".shapeup-sdlc" };
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Roots
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * The committed tier root.
65
+ * @param {string} cwd - Project root.
66
+ * @returns {string} `<cwd>/shapeup`.
67
+ */
68
+ export const sharedDir = (cwd) => join(cwd, SHARED);
69
+
70
+ /**
71
+ * The gitignored tier root.
72
+ * @param {string} cwd - Project root.
73
+ * @returns {string} `<cwd>/.shapeup`.
74
+ */
75
+ export const localDir = (cwd) => join(cwd, LOCAL);
76
+
77
+ /**
78
+ * A feature's committed root.
79
+ * @param {string} cwd - Project root.
80
+ * @param {string} slug - Feature slug.
81
+ * @returns {string} `<cwd>/shapeup/<slug>`.
82
+ */
83
+ export const sharedRoot = (cwd, slug) => join(cwd, SHARED, slug);
84
+
85
+ /**
86
+ * A feature's run-trace root.
87
+ * @param {string} cwd - Project root.
88
+ * @param {string} slug - Feature slug.
89
+ * @returns {string} `<cwd>/.shapeup/<slug>`.
90
+ */
91
+ export const localRoot = (cwd, slug) => join(cwd, LOCAL, slug);
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // SHARED — the deliverable
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /** The committed spec tree — the evaluator's grading truth. */
98
+ export const specDir = (cwd, slug) => join(sharedRoot(cwd, slug), "spec");
99
+ /** Use-case directory inside the spec tree. */
100
+ export const usecasesDir = (cwd, slug) => join(specDir(cwd, slug), "usecases");
101
+ /** Shaping artifacts — pitch, framing, breadboard, baseline, glossary. */
102
+ export const shapingDir = (cwd, slug) => join(sharedRoot(cwd, slug), "shaping");
103
+ // The three contracts are markdown on disk and JSON on the wire (ADR-0001) — see
104
+ // `lib/contract-md.mjs`. `readContract()` accepts either extension, so a project mid-migration
105
+ // keeps working; these builders name the form the harness WRITES.
106
+
107
+ /** Scope contracts, one markdown file per vertical slice. */
108
+ export const scopesDir = (cwd, slug) => join(sharedRoot(cwd, slug), "scopes");
109
+ /** One scope contract by id. */
110
+ export const scopeContract = (cwd, slug, id) => join(scopesDir(cwd, slug), `${id}.md`);
111
+ /** The wiring map — engine → seam → entry-point call site → affordance. */
112
+ export const wiringMap = (cwd, slug) => join(sharedRoot(cwd, slug), "wiring-map.md");
113
+ /** Archetype + entry_point; gates the reachability arm of trace-lint. */
114
+ export const projectProfile = (cwd, slug) => join(sharedRoot(cwd, slug), "project-profile.md");
115
+ /** The REQ clause registry that covers-closure checks against. */
116
+ export const requirements = (cwd, slug) => join(sharedRoot(cwd, slug), "requirements.md");
117
+ /** Hill shards — mechanical phase per scope. */
118
+ export const hillDir = (cwd, slug) => join(sharedRoot(cwd, slug), "hill");
119
+ /** The frozen ship report, written once at GATE L4. */
120
+ export const report = (cwd, slug) => join(sharedRoot(cwd, slug), "REPORT.md");
121
+ /** Team-shared coaching rules, read back by the three coachable workers. */
122
+ export const knowledgeBaseDir = (cwd) => join(sharedDir(cwd), "knowledge-base");
123
+ /** One worker's coaching file. */
124
+ export const knowledgeBase = (cwd, skill) => join(knowledgeBaseDir(cwd), `${skill}.md`);
125
+ /** Migration bookkeeping — committed by necessity (ADR-0001 exception 3). */
126
+ export const harnessVersion = (cwd) => join(sharedDir(cwd), ".harness-version");
127
+ /** Applied-migration record — committed by necessity (ADR-0001 exception 3). */
128
+ export const harnessMigrations = (cwd) => join(sharedDir(cwd), ".harness-migrations");
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // LOCAL — run state
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /** The run receipt — the mechanical fact that a run started (GATE L0.1). */
135
+ export const receipt = (cwd, slug) => join(localRoot(cwd, slug), "receipt.json");
136
+ /** The intake, verbatim, next to its digest in the receipt. */
137
+ export const intake = (cwd, slug) => join(localRoot(cwd, slug), "intake.md");
138
+ /** The run ledger — rounds, decisions, status frontmatter. */
139
+ export const harnessRun = (cwd, slug) => join(localRoot(cwd, slug), "harness-run.md");
140
+ /** File-derived mid-run digest, frozen before compaction. */
141
+ export const runSnapshot = (cwd, slug) => join(localRoot(cwd, slug), "run-snapshot.json");
142
+ /** The task board directory. */
143
+ export const tasksDir = (cwd, slug) => join(localRoot(cwd, slug), "tasks");
144
+ /** The board index. */
145
+ export const boardIndex = (cwd, slug) => join(tasksDir(cwd, slug), "_index.md");
146
+ /** Compiled WorkOrders. */
147
+ export const ordersDir = (cwd, slug) => join(localRoot(cwd, slug), "orders");
148
+ /** Returned WorkResults. */
149
+ export const resultsDir = (cwd, slug) => join(localRoot(cwd, slug), "results");
150
+ /** T0 verification artifacts. */
151
+ export const t0Dir = (cwd, slug) => join(localRoot(cwd, slug), "t0");
152
+ /** Immutable per-attempt verdict artifacts the evaluator must cite. */
153
+ export const verdictsDir = (cwd, slug) => join(t0Dir(cwd, slug), "verdicts");
154
+ /** The append-only trial ledger the ratchet reduces over. */
155
+ export const trials = (cwd, slug) => join(t0Dir(cwd, slug), "trials.jsonl");
156
+ /** Finished-scope fixture registry for the seesaw regression check. */
157
+ export const seesawRegistry = (cwd, slug) => join(localRoot(cwd, slug), "seesaw", "registry.json");
158
+ /** Evaluator output — report, evidence, verdict ledger. */
159
+ export const evaluationDir = (cwd, slug) => join(localRoot(cwd, slug), "evaluation");
160
+ /** QA hunt output. */
161
+ export const qaDir = (cwd, slug) => join(localRoot(cwd, slug), "qa");
162
+ /** Scout recon — code-surface map, spikes, discovered seed. */
163
+ export const orientDir = (cwd, slug) => join(localRoot(cwd, slug), "orient");
164
+ /** Time-boxed spike workspace. */
165
+ export const spikesDir = (cwd, slug) => join(localRoot(cwd, slug), "spikes");
166
+ /** Covers-closure + reachability run trace. */
167
+ export const traceDir = (cwd, slug) => join(localRoot(cwd, slug), "trace");
168
+ /** The discovered-task ledger every discovery flow appends to. */
169
+ export const discoveryLedger = (cwd, slug) => join(localRoot(cwd, slug), "discovery", "ledger.md");
170
+ /** Queued worker escalations awaiting adjudication. */
171
+ export const escalatesDir = (cwd, slug) => join(localRoot(cwd, slug), "escalates");
172
+ /**
173
+ * Adjudicated decisions, read back by `compile-order` as binding precedent.
174
+ *
175
+ * LOCAL since ADR-0001. It was committed, and it is appended to DURING a build round — so a run
176
+ * left the working tree dirty in the deliverable tier while it was still building. Its
177
+ * conclusions reach the team in `REPORT.md` at GATE L4 instead, frozen once.
178
+ */
179
+ export const roundLedger = (cwd, slug) => join(localRoot(cwd, slug), "round-ledger.md");
180
+
181
+ /**
182
+ * Spec WORKING NOTES — analysis that informed the contract but is not the contract.
183
+ *
184
+ * `synthesis.md`, `assess-report.md`, `feedback.md`, `api-feasibility.md`, `integration.md`. The
185
+ * committed `spec/` keeps only what the evaluator grades against and a reviewer needs (ADR-0001
186
+ * "contract vs working artifact").
187
+ */
188
+ export const workingDir = (cwd, slug) => join(localRoot(cwd, slug), "working");
189
+
190
+ // --- checkout-wide ---------------------------------------------------------
191
+
192
+ /** The pointer the sandbox guard reads to answer "which scope is checked out?". */
193
+ export const activeScope = (cwd) => join(localDir(cwd), "active-scope");
194
+ /** Hook receipts — one row per evaluation, so `allow` carries evidence. */
195
+ export const decisions = (cwd) => join(localDir(cwd), "decisions.jsonl");
196
+ /** Human-authored safety escape hatch. LOCAL so no PR can widen another machine's envelope. */
197
+ export const safetyOverrides = (cwd) => join(localDir(cwd), "safety-overrides.json");
198
+ /**
199
+ * Telemetry shards.
200
+ *
201
+ * LOCAL since ADR-0001. Committed, they put `process.env.HOSTNAME` — a person's laptop name —
202
+ * into the repository, and append-only JSONL in git only grows. The cost, stated plainly: with
203
+ * these local, `stats.mjs` becomes a personal tool and "is the KB flywheel working across the
204
+ * team?" is no longer answerable from the repo.
205
+ */
206
+ export const metricsDir = (cwd) => join(localDir(cwd), "metrics");
207
+ /** This machine's telemetry shard. */
208
+ export const metricsShard = (cwd, id = process.env.HOSTNAME || "local") =>
209
+ join(metricsDir(cwd), `${id}.jsonl`);
210
+ /** Archived pitches. */
211
+ export const pitchArchiveDir = (cwd) => join(localDir(cwd), "pitch-archive");
212
+
213
+ /**
214
+ * Gate answer sets, in resolution order (first hit wins).
215
+ *
216
+ * ONE TIER ONLY, since ADR-0001. There used to be a third candidate — a COMMITTED
217
+ * `gate-answers.json`, auto-discovered with no flag — so a file with `preset: ci` pre-approved
218
+ * GATE L4 ship sign-off for everyone who pulled the repo, and the same filename meant "my
219
+ * personal lane" at one path and "team policy" at another. Consent is now per-machine by
220
+ * construction: no committed file can cross a gate on another person's behalf.
221
+ *
222
+ * @param {string} cwd - Project root.
223
+ * @param {(string|null)} [slug] - Feature slug; adds the per-run candidate when given.
224
+ * @returns {string[]} Candidate paths, most specific first.
225
+ */
226
+ export const gateAnswerCandidates = (cwd, slug = null) => [
227
+ ...(slug ? [join(localRoot(cwd, slug), "gate-answers.json")] : []),
228
+ join(localDir(cwd), "gate-answers.json"),
229
+ ];
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Relative forms — for messages, receipts and anything a human reads
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * A run-trace path relative to the project root, for display.
237
+ * @param {string} slug - Feature slug.
238
+ * @param {...string} parts - Path segments under the feature's local root.
239
+ * @returns {string} e.g. `.shapeup/checkout/t0/verdicts`.
240
+ */
241
+ export const relLocal = (slug, ...parts) => join(LOCAL, slug, ...parts);
242
+
243
+ /**
244
+ * A deliverable path relative to the project root, for display.
245
+ * @param {string} slug - Feature slug.
246
+ * @param {...string} parts - Path segments under the feature's shared root.
247
+ * @returns {string} e.g. `shapeup/checkout/spec`.
248
+ */
249
+ export const relShared = (slug, ...parts) => join(SHARED, slug, ...parts);
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Glob forms — substrate whitelists, matched against repo-relative POSIX paths
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /**
256
+ * A run-trace glob. Always POSIX-separated: `sandbox-guard` matches these against
257
+ * `path.relative()` output normalised to forward slashes, so a `join()` here would stop matching
258
+ * on Windows without any error to notice.
259
+ * @param {string} slug - Feature slug.
260
+ * @param {...string} parts - Glob segments under the feature's local root.
261
+ * @returns {string} e.g. `.shapeup-sdlc/checkout/tasks/**`.
262
+ */
263
+ export const globLocal = (slug, ...parts) => [LOCAL, slug, ...parts].join("/");
264
+
265
+ /**
266
+ * A deliverable glob, POSIX-separated for the same reason as {@link globLocal}.
267
+ * @param {string} slug - Feature slug.
268
+ * @param {...string} parts - Glob segments under the feature's shared root.
269
+ * @returns {string} e.g. `docs/shapeup-sdlc/checkout/scopes/*.json`.
270
+ */
271
+ export const globShared = (slug, ...parts) => [SHARED, slug, ...parts].join("/");
272
+
273
+ /**
274
+ * The coaching file a coachable worker reads, as a repo-relative path for the WorkOrder payload.
275
+ * @param {string} skill - Worker name (task-executor | ba-pitch-analyzer | qa-edge-hunter).
276
+ * @returns {string} e.g. `docs/shapeup-sdlc/knowledge-base/task-executor.md`.
277
+ */
278
+ export const relKnowledgeBase = (skill) => [SHARED, "knowledge-base", `${skill}.md`].join("/");
@@ -0,0 +1,112 @@
1
+ // ratchet-tree — `keep(commit)` and `revert(commit)` for the T0 attempt loop.
2
+ //
3
+ // WHY THIS FILE EXISTS.
4
+ //
5
+ // The attempt loop branched a red T0 two ways, and only one of them reverted anything:
6
+ //
7
+ // • a SEESAW regression (a previously-finished scope broke) → `git stash push -u`;
8
+ // • a red on the scope's OWN fixtures → "loop to the next attempt", and no revert at all.
9
+ //
10
+ // So the failing tree stayed on the branch, and attempt N+1's fresh, zero-memory subagent began
11
+ // from code it did not write, cannot see the history of, and is told about only through error
12
+ // strings. The paper's `revert(commit)` is the MORE COMMON branch, and it was the absent one.
13
+ //
14
+ // Under a ratchet there is one rule for both (see `t0-verify.mjs` → `decideStatus`): keep the tree
15
+ // when the score strictly improved, restore the last kept tree when it did not.
16
+ //
17
+ // NO COMMITS ON THE BRANCH UNDER TEST. The harness's standing convention is that it never writes
18
+ // commits to the user's branch, so the snapshot is a `git stash create` object published under a
19
+ // SHADOW REF — `refs/shapeup/<scope_id>/kept` — which is invisible to `git log`, `git status` and
20
+ // every branch operation, and is reachable for `git restore` and for forensics afterwards.
21
+ //
22
+ // NON-REGRESSION (plan §7). Every operation here is best-effort: outside a git work tree, with no
23
+ // `git` on PATH, or with no snapshot yet taken, the functions report `{ ok: false, reason }` and
24
+ // the caller proceeds exactly as it does today. A ratchet that can break a build by failing to
25
+ // take a snapshot would get the whole mechanism disabled, which costs more than it saves.
26
+
27
+ import { spawnSync } from "node:child_process";
28
+
29
+ /**
30
+ * Run one git subcommand, capturing its outcome. Never throws.
31
+ * @param {string[]} argv - Git arguments (without the leading "git").
32
+ * @param {string} cwd - Repository working directory.
33
+ * @returns {{ok:boolean, stdout:string, stderr:string, status:(number|null)}} Trimmed output and
34
+ * the exit status; `ok` is true only on status 0.
35
+ */
36
+ function git(argv, cwd) {
37
+ const r = spawnSync("git", argv, { cwd, encoding: "utf8", timeout: 60_000 });
38
+ return {
39
+ ok: r.status === 0,
40
+ stdout: (r.stdout || "").trim(),
41
+ stderr: (r.stderr || "").trim(),
42
+ status: r.status ?? null,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * The shadow ref a scope's kept tree is published under.
48
+ * @param {string} scopeId - Scope id (e.g. "SC-02").
49
+ * @returns {string} The full ref name, with anything ref-unsafe in the id replaced by "-".
50
+ */
51
+ export function keptRef(scopeId) {
52
+ return `refs/shapeup/${String(scopeId || "unscoped").replace(/[^\w.-]/g, "-")}/kept`;
53
+ }
54
+
55
+ /**
56
+ * Is `cwd` inside a git work tree with git available?
57
+ * @param {string} cwd - Directory to test.
58
+ * @returns {boolean} True when git can answer for this directory.
59
+ */
60
+ export function isRepo(cwd) {
61
+ return git(["rev-parse", "--is-inside-work-tree"], cwd).stdout === "true";
62
+ }
63
+
64
+ /**
65
+ * `keep` — publish the current working tree (including untracked files) as this scope's kept tree.
66
+ *
67
+ * `git stash create` builds the stash commit WITHOUT touching the index, the working tree, or the
68
+ * stash list; `update-ref` then points the shadow ref at it. Nothing the user can see changes.
69
+ *
70
+ * @param {string} scopeId - Scope id the snapshot belongs to.
71
+ * @param {string} cwd - Repository working directory.
72
+ * @returns {{ok:boolean, ref?:string, sha?:string, reason?:string}} `ok:true` with the ref and the
73
+ * snapshot commit sha; `ok:false` with a reason when git is unavailable, the directory is not a
74
+ * repo, or there was nothing to snapshot (a clean tree — the caller has nothing to restore to
75
+ * that HEAD does not already provide).
76
+ */
77
+ export function snapshot(scopeId, cwd) {
78
+ if (!isRepo(cwd)) return { ok: false, reason: "not a git work tree" };
79
+ const created = git(["stash", "create", `shapeup: kept tree for ${scopeId}`], cwd);
80
+ if (!created.ok) return { ok: false, reason: created.stderr || "git stash create failed" };
81
+ // An empty stdout means the tree is clean relative to HEAD — a legitimate state, not an error.
82
+ const sha = created.stdout || git(["rev-parse", "HEAD"], cwd).stdout;
83
+ if (!sha) return { ok: false, reason: "no commit to snapshot" };
84
+ const ref = keptRef(scopeId);
85
+ const updated = git(["update-ref", ref, sha], cwd);
86
+ if (!updated.ok) return { ok: false, reason: updated.stderr || "git update-ref failed" };
87
+ return { ok: true, ref, sha };
88
+ }
89
+
90
+ /**
91
+ * `revert` — restore the working tree from this scope's last kept snapshot.
92
+ *
93
+ * Deliberately `git restore --source=<ref> --worktree -- .`: it rewrites tracked files back to the
94
+ * kept state and leaves the index and HEAD alone. It does NOT delete files created since the
95
+ * snapshot — removing a file the harness cannot prove it created is not a revert, it is data loss,
96
+ * and the attempt's own fixtures are what decide whether the leftover matters.
97
+ *
98
+ * @param {string} scopeId - Scope id whose kept tree should be restored.
99
+ * @param {string} cwd - Repository working directory.
100
+ * @returns {{ok:boolean, ref?:string, sha?:string, reason?:string}} `ok:true` when the tree was
101
+ * restored; `ok:false` with a reason when there is no snapshot yet (the first trial, by
102
+ * definition) or git refused.
103
+ */
104
+ export function restore(scopeId, cwd) {
105
+ if (!isRepo(cwd)) return { ok: false, reason: "not a git work tree" };
106
+ const ref = keptRef(scopeId);
107
+ const resolved = git(["rev-parse", "--verify", "--quiet", ref], cwd);
108
+ if (!resolved.ok || !resolved.stdout) return { ok: false, reason: `no snapshot at ${ref}` };
109
+ const restored = git(["restore", `--source=${resolved.stdout}`, "--worktree", "--", "."], cwd);
110
+ if (!restored.ok) return { ok: false, reason: restored.stderr || "git restore failed" };
111
+ return { ok: true, ref, sha: resolved.stdout };
112
+ }
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+ // Run snapshot — the compaction-resilience derivation (v1.2, absorb-audit P4).
3
+ //
4
+ // Derives a RunSnapshot (domain.schema.json#/$defs/RunSnapshot) from FILES ONLY: the
5
+ // active-scope pointer, harness-run.md frontmatter, board task frontmatter, t0/verdicts
6
+ // filenames, and the orders/ vs results/ diff. Never from conversation memory — that is the
7
+ // point: after a context compaction the summary may be lossy, but the files are not. The
8
+ // danger this record exists to prevent is the orchestrator continuing on a degraded summary:
9
+ // re-dispatching an already-ingested order, miscounting attempts (breaking the inner circuit
10
+ // breaker), or "remembering" a hill phase instead of re-deriving it.
11
+ //
12
+ // Consumers (both in hooks/): compact-snapshot.mjs persists it before compaction (audit
13
+ // anchor); session-rehydrate.mjs re-derives it fresh after compaction and injects the
14
+ // rehydrate_hint as additionalContext.
15
+ //
16
+ // Output is self-validated against the registry before it is emitted — the same
17
+ // refuse-to-emit-schema-drift discipline as compile-order.mjs.
18
+ //
19
+ // Usage: node run-snapshot.mjs [--cwd <dir>] [--format json|text] [--write]
20
+ // exit 0 with empty stdout when no run is active (fail-open), 1 on schema drift.
21
+
22
+ import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
23
+ import { resolve, join } from "node:path";
24
+ import { validate } from "./validate-envelope.mjs";
25
+ import { isMain } from "./lib/is-main.mjs";
26
+ import { runArgs } from "./lib/argv.mjs";
27
+ import { localDir, localRoot, relLocal, globLocal, runSnapshot as runSnapshotPath } from "./lib/paths.mjs";
28
+
29
+ /**
30
+ * Read a JSON file, tolerating absence/parse errors.
31
+ * @param {string} p - Path to the JSON file.
32
+ * @returns {(*|null)} The parsed value, or null when the file is missing or not valid JSON.
33
+ */
34
+ function readJSON(p) {
35
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
36
+ }
37
+
38
+ /**
39
+ * Parse a Markdown frontmatter block into a flat scalar map.
40
+ * @param {string} text - Full document text ("" / null tolerated).
41
+ * @returns {Object<string,string>} Top-level `key: value` pairs (quotes stripped); {} when absent.
42
+ */
43
+ function frontmatter(text) {
44
+ const m = /^---\n([\s\S]*?)\n---/.exec(text || "");
45
+ if (!m) return {};
46
+ const fm = {};
47
+ for (const line of m[1].split("\n")) {
48
+ const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line.trim());
49
+ if (kv) fm[kv[1]] = kv[2].replace(/^['"]|['"]$/g, "");
50
+ }
51
+ return fm;
52
+ }
53
+
54
+ const MID_RUN = new Set(["orienting", "mapping", "building", "evaluating"]);
55
+
56
+ /**
57
+ * Is this run still open, per its own ledger? Fails CLOSED — a missing or unreadable
58
+ * `harness-run.md` cannot be evidence that a run is in progress.
59
+ * @param {string} root - The `.shapeup` directory.
60
+ * @param {string} slug - Run slug (a child directory of root).
61
+ * @returns {boolean} True when the run's frontmatter status is one of MID_RUN.
62
+ */
63
+ function isMidRun(root, slug) {
64
+ const runPath = join(root, slug, "harness-run.md");
65
+ if (!existsSync(runPath)) return false;
66
+ try {
67
+ return MID_RUN.has(frontmatter(readFileSync(runPath, "utf8")).status);
68
+ } catch {
69
+ return false; // unreadable ledger → cannot claim the run is open
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Find the active run from files alone — the active-scope pointer, else a mid-run harness-run.md.
75
+ * Either way the run must be OPEN per its own ledger; a pointer alone is not enough.
76
+ * @param {string} cwd - Working-directory root.
77
+ * @returns {({slug:string, scope_id?:string}|null)} The active run's slug (and scope when pointed
78
+ * at one), or null when no run is in progress.
79
+ */
80
+ function findRun(cwd) {
81
+ const root = localDir(cwd);
82
+ if (!existsSync(root)) return null;
83
+
84
+ // The pointer names the run the sandbox guard is scoping, NOT necessarily a run still open.
85
+ // `.shapeup/active-scope` has existed since v0.3 and nothing has ever cleared it, because
86
+ // its original reader (sandbox-guard) fails OPEN on a stale one — a pointer at a finished run
87
+ // simply stops matching any substrate. session-rehydrate is the opposite kind of reader: it
88
+ // turns a pointer into "a run is ALREADY OPEN in this workspace … do NOT open a new run", and
89
+ // it now fires on `startup`/`clear`. Following the pointer without checking the run's status
90
+ // therefore made every cold session in every repo that had EVER run the harness open with a
91
+ // false claim about its own workspace — including sessions with nothing to do with the harness,
92
+ // and including the case where the user's next act is to open the run the injection forbids.
93
+ // This function's own contract (and session-rehydrate's header) always said "a run only for an
94
+ // active-scope pointer or a mid-run harness-run.md"; the status check is what makes that true.
95
+ const pointer = readJSON(join(root, "active-scope"));
96
+ if (pointer?.slug && isMidRun(root, pointer.slug)) {
97
+ return { slug: pointer.slug, scope_id: pointer.scope_id };
98
+ }
99
+
100
+ for (const entry of readdirSync(root)) {
101
+ if (isMidRun(root, entry)) return { slug: entry };
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Derive a RunSnapshot for the active run from FILES ONLY (never conversation memory).
108
+ * @param {string} cwd - Working-directory root.
109
+ * @returns {(object|null)} A RunSnapshot (domain.schema.json#/$defs/RunSnapshot): slug, optional
110
+ * scope_id/status/rounds, latest T0 verdict, board done/total + unfinished, open escalates,
111
+ * dispatched-not-ingested `pending_orders`, and a human `rehydrate_hint`. null when no run is active.
112
+ */
113
+ export function deriveSnapshot(cwd) {
114
+ const run = findRun(cwd);
115
+ if (!run) return null;
116
+ const root = localRoot(cwd, run.slug);
117
+
118
+ const snapshot = {
119
+ schema_version: 1,
120
+ at: new Date().toISOString(),
121
+ slug: run.slug,
122
+ };
123
+ if (run.scope_id) snapshot.scope_id = run.scope_id;
124
+
125
+ const runPath = join(root, "harness-run.md");
126
+ if (existsSync(runPath)) {
127
+ try {
128
+ const fm = frontmatter(readFileSync(runPath, "utf8"));
129
+ if (MID_RUN.has(fm.status) || ["shipped", "escalated"].includes(fm.status)) snapshot.status = fm.status;
130
+ if (/^\d+$/.test(fm.rounds_used || "")) snapshot.rounds_used = Number(fm.rounds_used);
131
+ if (/^\d+$/.test(fm.max_rounds || "")) snapshot.max_rounds = Number(fm.max_rounds);
132
+ if (fm.auto_level) snapshot.auto_level = fm.auto_level;
133
+ if (fm.spec_folder) snapshot.spec_folder = fm.spec_folder;
134
+ } catch { /* run ledger unreadable → snapshot stays partial; the files still win */ }
135
+ }
136
+
137
+ const verdictsDir = join(root, "t0", "verdicts");
138
+ if (existsSync(verdictsDir)) {
139
+ let latest = null;
140
+ for (const f of readdirSync(verdictsDir)) {
141
+ const m = /^r(\d+)-a(\d+)\.json$/.exec(f);
142
+ if (!m) continue;
143
+ const round = Number(m[1]), attempt = Number(m[2]);
144
+ if (!latest || round > latest.round || (round === latest.round && attempt > latest.attempt)) {
145
+ latest = { round, attempt, file: f };
146
+ }
147
+ }
148
+ if (latest) {
149
+ snapshot.round = latest.round;
150
+ snapshot.attempt = latest.attempt;
151
+ const verdict = readJSON(join(verdictsDir, latest.file));
152
+ if (verdict?.overall === "green" || verdict?.overall === "red") {
153
+ snapshot.latest_t0 = {
154
+ path: relLocal(run.slug, "t0", "verdicts", latest.file),
155
+ overall: verdict.overall,
156
+ round: latest.round,
157
+ attempt: latest.attempt,
158
+ };
159
+ }
160
+ }
161
+ }
162
+ if (snapshot.round === undefined && snapshot.rounds_used !== undefined) snapshot.round = snapshot.rounds_used;
163
+
164
+ const tasksDir = join(root, "tasks");
165
+ if (existsSync(tasksDir)) {
166
+ const board = { total: 0, done: 0, unfinished: [] };
167
+ for (const f of readdirSync(tasksDir)) {
168
+ if (!/^TASK-.*\.md$/.test(f)) continue;
169
+ board.total++;
170
+ try {
171
+ const fm = frontmatter(readFileSync(join(tasksDir, f), "utf8"));
172
+ if (fm.status === "done") board.done++;
173
+ else if (board.unfinished.length < 10) board.unfinished.push(fm.id || f.replace(/\.md$/, ""));
174
+ } catch { /* unreadable task counts as unfinished-unknown; skip naming it */ }
175
+ }
176
+ if (board.total > 0) snapshot.board = board;
177
+ }
178
+
179
+ const escDir = join(root, "escalates");
180
+ snapshot.open_escalates = existsSync(escDir)
181
+ ? readdirSync(escDir).filter((f) => f.endsWith(".json")).length
182
+ : 0;
183
+
184
+ const ordersDir = join(root, "orders");
185
+ const resultsDir = join(root, "results");
186
+ const results = new Set(existsSync(resultsDir) ? readdirSync(resultsDir) : []);
187
+ snapshot.pending_orders = existsSync(ordersDir)
188
+ ? readdirSync(ordersDir).filter((f) => f.endsWith(".json") && !results.has(f))
189
+ : [];
190
+
191
+ snapshot.rehydrate_hint =
192
+ `mid-run harness state: slug "${snapshot.slug}"` +
193
+ (snapshot.scope_id ? `, scope "${snapshot.scope_id}"` : "") +
194
+ (snapshot.round !== undefined ? `, round ${snapshot.round}` : "") +
195
+ (snapshot.attempt !== undefined ? `, attempt ${snapshot.attempt}` : "") +
196
+ (snapshot.status ? `, status ${snapshot.status}` : "") +
197
+ (snapshot.board ? `, board ${snapshot.board.done}/${snapshot.board.total} done` : "") +
198
+ (snapshot.latest_t0 ? `, latest T0 ${snapshot.latest_t0.overall}` : "") +
199
+ (snapshot.pending_orders.length ? `, ${snapshot.pending_orders.length} dispatched-not-ingested order(s): ${snapshot.pending_orders.join(", ")}` : "") +
200
+ `. Re-read ${globLocal(snapshot.slug, "harness-run.md")} and the board before continuing — ` +
201
+ `trust the files, not the conversation summary. Never re-dispatch an order that already has a result; ` +
202
+ `re-derive round/attempt/hill from the files, never from memory.`;
203
+
204
+ return snapshot;
205
+ }
206
+
207
+ /**
208
+ * @param {string} cwd - Working-directory root.
209
+ * @param {string} slug - Feature slug.
210
+ * @returns {string} The LOCAL run-trace path `.shapeup/<slug>/run-snapshot.json`.
211
+ */
212
+ export function snapshotPath(cwd, slug) {
213
+ return runSnapshotPath(cwd, slug);
214
+ }
215
+
216
+ /**
217
+ * Validate then persist a snapshot to its LOCAL run-trace home.
218
+ * @param {string} cwd - Working-directory root.
219
+ * @param {object} snapshot - The RunSnapshot to persist (its `slug` names the path).
220
+ * @returns {void} Side effect: writes the snapshot JSON.
221
+ * @throws {Error} If the snapshot drifts from domain.schema.json#/$defs/RunSnapshot.
222
+ */
223
+ export function writeSnapshot(cwd, snapshot) {
224
+ assertValid(snapshot);
225
+ writeFileSync(snapshotPath(cwd, snapshot.slug), JSON.stringify(snapshot, null, 2) + "\n");
226
+ }
227
+
228
+ /**
229
+ * Assert a snapshot conforms to its registry definition.
230
+ * @param {object} snapshot - The RunSnapshot to check.
231
+ * @returns {void}
232
+ * @throws {Error} If it fails domain.schema.json#/$defs/RunSnapshot (message lists each error).
233
+ */
234
+ function assertValid(snapshot) {
235
+ const { valid, errors } = validate(snapshot, { $ref: "domain.schema.json#/$defs/RunSnapshot" });
236
+ if (!valid) {
237
+ throw new Error(`run-snapshot drifted from domain.schema.json#/$defs/RunSnapshot:\n ${errors.join("\n ")}`);
238
+ }
239
+ }
240
+
241
+ // --- CLI -----------------------------------------------------------------------
242
+
243
+ /** The typed argv contract (see `./lib/argv.mjs`). */
244
+ export const ARGV_SPEC = {
245
+ usage: "run-snapshot.mjs [--cwd <dir>] [--format json|text] [--write]",
246
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
247
+ cwd: { type: "path" },
248
+ format: { type: "enum", values: ["json", "text"], default: "json" },
249
+ write: { type: "flag" },
250
+ };
251
+
252
+ const isMainModule = isMain(import.meta.url);
253
+ if (isMainModule) {
254
+ const args = runArgs(ARGV_SPEC);
255
+ const cwd = resolve(args.cwd || process.cwd());
256
+ const format = args.format;
257
+
258
+ const snapshot = deriveSnapshot(cwd);
259
+ if (!snapshot) process.exit(0); // no active run → nothing to say (fail-open)
260
+
261
+ try {
262
+ assertValid(snapshot);
263
+ if (args.write) writeSnapshot(cwd, snapshot);
264
+ } catch (e) {
265
+ console.error(` ✗ ${e.message}`);
266
+ process.exit(1);
267
+ }
268
+ console.log(format === "text" ? snapshot.rehydrate_hint : JSON.stringify(snapshot, null, 2));
269
+ }