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,423 @@
1
+ #!/usr/bin/env node
2
+ // Stats — the telemetry read-plane (v1.2, absorb-audit P3).
3
+ //
4
+ // SHIP S.6 has been writing fact rows to shapeup/metrics/<machine-id>.jsonl since
5
+ // v0.x with exactly one documented reader: `cat *.jsonl`. This script is the missing
6
+ // projection: rounds per pitch, hammer-cut rate, attempt-budget exhaustions, QA promotion
7
+ // rate, and the round_count trend — the "is the KB flywheel actually working?" chart.
8
+ //
9
+ // Hard rules, inherited from the MetricsRow contract:
10
+ // • READ-ONLY. Only read APIs are imported; the shards stay the single source of truth.
11
+ // • FACTS ONLY. Aggregates copied fields (counts, rates, trends); it never grades — a
12
+ // computed score here would be a second judge behind spec-evaluator.
13
+ // • Fail-open per row: a malformed line is skipped and counted, never fatal. Pathology
14
+ // rows (sandbox-guard / safety-spine denials share the shards) are partitioned into a
15
+ // histogram, not treated as runs or errors.
16
+ //
17
+ // Output is a StatsReport (domain.schema.json#/$defs/StatsReport), self-validated before it
18
+ // is emitted; --format table renders the human view from the already-validated object.
19
+ //
20
+ // Usage: node stats.mjs [--cwd <dir>] [--metrics-dir <dir>] [--slug <slug>] [--format json|table]
21
+
22
+ import { readFileSync, readdirSync, existsSync } 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, decisions as decisionsPath, metricsDir as metricsDirPath, SHARED } from "./lib/paths.mjs";
28
+
29
+ /**
30
+ * Read every metrics shard, partitioning valid rows, pathology rows, and malformed lines.
31
+ * @param {string} metricsDir - Directory of `*.jsonl` metric shards (absent → empty result).
32
+ * @returns {{rows:Array<object>, pathologies:Array<object>, sources:string[], rows_malformed:number}}
33
+ * Harvest rows (each tagged with a per-shard `_seq`), pathology rows, the shard filenames read,
34
+ * and the count of unparseable/incomplete lines skipped.
35
+ */
36
+ export function readShards(metricsDir) {
37
+ const out = { rows: [], pathologies: [], sources: [], rows_malformed: 0 };
38
+ if (!existsSync(metricsDir)) return out;
39
+ for (const shard of readdirSync(metricsDir).filter((f) => f.endsWith(".jsonl")).sort()) {
40
+ out.sources.push(shard);
41
+ const lines = readFileSync(join(metricsDir, shard), "utf8").split("\n");
42
+ let seq = 0;
43
+ for (const line of lines) {
44
+ if (!line.trim()) continue;
45
+ let row;
46
+ try { row = JSON.parse(line); } catch { out.rows_malformed++; continue; }
47
+ if (row?.kind === "pathology") { out.pathologies.push(row); continue; }
48
+ if (!row?.feature_slug || !row?.terminal_state) { out.rows_malformed++; continue; }
49
+ out.rows.push({ ...row, _seq: seq++ });
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * @param {number} n - A number.
57
+ * @returns {number} `n` rounded to two decimal places.
58
+ */
59
+ const round2 = (n) => Math.round(n * 100) / 100;
60
+
61
+ /**
62
+ * Aggregate harvest rows into the StatsReport body (pure — no I/O, no grading).
63
+ * @param {{rows:Array<object>, pathologies:Array<object>, sources:string[], rows_malformed:number}}
64
+ * shards - Output of {@link readShards}.
65
+ * @param {{metricsDir?:string, slugFilter?:(string|null)}} [opts] - metricsDir echoed into the
66
+ * report; slugFilter restricts to one feature slug.
67
+ * @returns {object} A StatsReport (domain.schema.json#/$defs/StatsReport): per-slug run counts,
68
+ * terminal-state histograms, hammer-cut rate, attempt exhaustions, QA promotion rates, a
69
+ * pathology histogram, and the round_count trend.
70
+ */
71
+ export function aggregate({ rows, pathologies, sources, rows_malformed }, { metricsDir = "", slugFilter = null } = {}) {
72
+ const filtered = slugFilter ? rows.filter((r) => r.feature_slug === slugFilter) : rows;
73
+
74
+ const bySlug = new Map();
75
+ for (const row of filtered) {
76
+ if (!bySlug.has(row.feature_slug)) bySlug.set(row.feature_slug, []);
77
+ bySlug.get(row.feature_slug).push(row);
78
+ }
79
+
80
+ const per_slug = [...bySlug.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([slug, runs]) => {
81
+ const rounds = runs.map((r) => r.round_count).filter((n) => Number.isInteger(n));
82
+ const terminal_states = {};
83
+ for (const r of runs) terminal_states[r.terminal_state] = (terminal_states[r.terminal_state] || 0) + 1;
84
+ const cuts = runs.filter((r) => (r.scope_cut_count || 0) > 0).length;
85
+ const qaRows = runs.filter((r) => r.qa_findings);
86
+ const qa = {
87
+ total: qaRows.reduce((s, r) => s + (r.qa_findings.total || 0), 0),
88
+ promoted: qaRows.reduce((s, r) => s + (r.qa_findings.promoted || 0), 0),
89
+ held: qaRows.reduce((s, r) => s + (r.qa_findings.held || 0), 0),
90
+ };
91
+ qa.promotion_rate = qa.total > 0 ? round2(qa.promoted / qa.total) : 0;
92
+ const surprises = runs.map((r) => r.surprise_count).filter((n) => Number.isInteger(n));
93
+ const perSlice = runs
94
+ .filter((r) => Number.isInteger(r.round_count) && Number.isInteger(r.slice_count) && r.slice_count > 0)
95
+ .map((r) => r.round_count / r.slice_count);
96
+ const entry = {
97
+ feature_slug: slug,
98
+ runs: runs.length,
99
+ terminal_states,
100
+ hammer_cut_rate: round2(cuts / runs.length),
101
+ scope_cuts_total: runs.reduce((s, r) => s + (r.scope_cut_count || 0), 0),
102
+ attempt_exhaustions: runs.reduce((s, r) => s + (Number.isInteger(r.attempt_exhaustions) ? r.attempt_exhaustions : 0), 0),
103
+ qa,
104
+ };
105
+ if (rounds.length > 0) {
106
+ entry.rounds = {
107
+ min: Math.min(...rounds),
108
+ max: Math.max(...rounds),
109
+ avg: round2(rounds.reduce((s, n) => s + n, 0) / rounds.length),
110
+ };
111
+ }
112
+ if (surprises.length > 0) entry.surprise_avg = round2(surprises.reduce((s, n) => s + n, 0) / surprises.length);
113
+ if (perSlice.length > 0) entry.rounds_per_slice_avg = round2(perSlice.reduce((s, n) => s + n, 0) / perSlice.length);
114
+ return entry;
115
+ });
116
+
117
+ const pathologyHistogram = {};
118
+ for (const p of pathologies) {
119
+ const key = p.pathology || "unknown";
120
+ pathologyHistogram[key] = (pathologyHistogram[key] || 0) + 1;
121
+ }
122
+
123
+ const trend = filtered
124
+ .filter((r) => Number.isInteger(r.round_count))
125
+ .sort((a, b) => (a.at && b.at) ? a.at.localeCompare(b.at) : a._seq - b._seq)
126
+ .map((r) => {
127
+ const t = { feature_slug: r.feature_slug, seq: r._seq, round_count: r.round_count };
128
+ if (r.at) t.at = r.at;
129
+ return t;
130
+ });
131
+
132
+ return {
133
+ schema_version: 1,
134
+ generated_at: new Date().toISOString(),
135
+ metrics_dir: metricsDir,
136
+ sources,
137
+ rows_total: filtered.length,
138
+ rows_malformed,
139
+ rows_pathology: pathologies.length,
140
+ pathologies: pathologyHistogram,
141
+ per_slug,
142
+ trend,
143
+ };
144
+ }
145
+
146
+ // --- the two exit measurements (v1.5) ------------------------------------------------------
147
+ //
148
+ // Both answer questions this project has never been able to ask, and both cost ZERO model tokens:
149
+ // they reduce over artifacts the harness already writes while doing its ordinary work.
150
+
151
+ /**
152
+ * `--ratchet` — DAY 1's exit criterion, measured.
153
+ *
154
+ * Every measurement in this project's record so far has been HARNESS VERSUS BARE AGENT — a
155
+ * question already answered. This one is THE LOOP VERSUS ITS OWN FIRST ATTEMPT, and it cannot be
156
+ * won by a one-sentence control, because a one-sentence control has no second attempt to compare.
157
+ *
158
+ * A monotone series is a ratchet working. A flat or sawtooth series says the loop is still a
159
+ * budgeted retry loop, and that widening `inspect()` is load-bearing rather than tidy.
160
+ *
161
+ * @param {Array<object>} trials - TrialRow records (from one run's `trials.jsonl`, or many).
162
+ * @returns {{trials:number, scopes:number, scopes_multi_trial:number, improvement_rate:number,
163
+ * monotone_rate:number, sawtooth_count:number, mean_trials_to_green:(number|null),
164
+ * status_histogram:Object<string,number>, per_scope:Array<object>}} The ratchet report.
165
+ */
166
+ export function ratchetReport(trials) {
167
+ const byScope = new Map();
168
+ const status_histogram = {};
169
+ for (const t of trials || []) {
170
+ status_histogram[t.status] = (status_histogram[t.status] || 0) + 1;
171
+ if (!byScope.has(t.scope_id)) byScope.set(t.scope_id, []);
172
+ byScope.get(t.scope_id).push(t);
173
+ }
174
+
175
+ let afterFirst = 0, kept = 0, sawtooth_count = 0, monotone = 0, multi = 0;
176
+ const toGreen = [];
177
+ const per_scope = [];
178
+
179
+ for (const [scope_id, rows] of [...byScope.entries()].sort(([a], [b]) => String(a).localeCompare(String(b)))) {
180
+ const seq = rows.slice().sort((a, b) => a.trial - b.trial);
181
+ // "Improvement rate" is measured over trials AFTER the first: the first trial is the baseline
182
+ // by construction and counting it as an improvement would inflate every run to ≥ 1/1.
183
+ const later = seq.slice(1);
184
+ afterFirst += later.length;
185
+ kept += later.filter((t) => t.status === "kept").length;
186
+ for (let i = 1; i < seq.length; i++) {
187
+ if (seq[i].status === "reverted" && seq[i - 1].status === "kept") sawtooth_count++;
188
+ }
189
+ let scopeMonotone = true;
190
+ if (seq.length >= 2) {
191
+ multi++;
192
+ for (let i = 1; i < seq.length; i++) {
193
+ // A `rebased` step changed the denominator, so it is not a decrease — it is a new series.
194
+ if (seq[i].status === "reverted" || seq[i].status === "crash") { scopeMonotone = false; break; }
195
+ }
196
+ if (scopeMonotone) monotone++;
197
+ }
198
+ const greenAt = seq.findIndex((t) => t.score && t.score.fixtures_total > 0 && t.score.fixtures_passed === t.score.fixtures_total && t.score.regressions === 0);
199
+ if (greenAt !== -1) toGreen.push(greenAt + 1);
200
+ per_scope.push({
201
+ scope_id, trials: seq.length,
202
+ first_score: seq[0]?.score ?? null,
203
+ last_score: seq[seq.length - 1]?.score ?? null,
204
+ monotone: seq.length >= 2 ? scopeMonotone : null,
205
+ reached_green_at_trial: greenAt === -1 ? null : greenAt + 1,
206
+ });
207
+ }
208
+
209
+ return {
210
+ trials: (trials || []).length,
211
+ scopes: byScope.size,
212
+ scopes_multi_trial: multi,
213
+ improvement_rate: afterFirst > 0 ? round2(kept / afterFirst) : 0,
214
+ monotone_rate: multi > 0 ? round2(monotone / multi) : 0,
215
+ sawtooth_count,
216
+ mean_trials_to_green: toGreen.length ? round2(toGreen.reduce((s, n) => s + n, 0) / toGreen.length) : null,
217
+ status_histogram,
218
+ per_scope,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * `--hooks` — DAY 2's instrument.
224
+ *
225
+ * Of the eight tools built against a MEASURED failure, several scores were previously unobtainable
226
+ * because "never had to fire" and "never ran" produced the same evidence. With a decision row per
227
+ * evaluation, `compact-snapshot` (0 PreCompact events across 1.2M tokens) and `gate-zerowork`
228
+ * ("never had to fire after the fix") become SEPARABLE FACTS rather than the same blank.
229
+ *
230
+ * @param {Array<object>} decisions - Rows from `.shapeup/decisions.jsonl`.
231
+ * @returns {{evaluations:number, hooks:number, per_hook:Array<object>}} Per-hook fire, allow, deny,
232
+ * block and error counts, plus the rules that fired.
233
+ */
234
+ export function hooksReport(decisions) {
235
+ const byHook = new Map();
236
+ for (const d of decisions || []) {
237
+ const name = d.hook || "unknown";
238
+ if (!byHook.has(name)) byHook.set(name, { hook: name, evaluations: 0, allow: 0, deny: 0, block: 0, error: 0, rules: {} });
239
+ const h = byHook.get(name);
240
+ h.evaluations++;
241
+ if (h[d.verdict] !== undefined) h[d.verdict]++;
242
+ if (d.rule) h.rules[d.rule] = (h.rules[d.rule] || 0) + 1;
243
+ }
244
+ const per_hook = [...byHook.values()].sort((a, b) => a.hook.localeCompare(b.hook));
245
+ return { evaluations: (decisions || []).length, hooks: per_hook.length, per_hook };
246
+ }
247
+
248
+ /**
249
+ * Read every trial ledger under a run root (or all runs).
250
+ * @param {string} cwd - Project root.
251
+ * @param {(string|null)} [slug] - Restrict to one run; null reads every run's ledger.
252
+ * @returns {Array<object>} TrialRow records, in file order per run.
253
+ */
254
+ export function readAllTrials(cwd, slug = null) {
255
+ const root = localDir(cwd);
256
+ if (!existsSync(root)) return [];
257
+ let slugs;
258
+ try { slugs = slug ? [slug] : readdirSync(root); } catch { return []; }
259
+ const out = [];
260
+ for (const s of slugs) {
261
+ const p = join(root, s, "t0", "trials.jsonl");
262
+ if (!existsSync(p)) continue;
263
+ try {
264
+ for (const line of readFileSync(p, "utf8").split("\n")) {
265
+ if (!line.trim()) continue;
266
+ try { out.push(JSON.parse(line)); } catch { /* a torn row is not a trial */ }
267
+ }
268
+ } catch { /* unreadable ledger → skip */ }
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /**
274
+ * Read the checkout-wide decision ledger.
275
+ * @param {string} cwd - Project root.
276
+ * @returns {Array<object>} Decision rows; [] when no hook has ever recorded one.
277
+ */
278
+ export function readDecisions(cwd) {
279
+ const p = decisionsPath(cwd);
280
+ if (!existsSync(p)) return [];
281
+ try {
282
+ return readFileSync(p, "utf8").split("\n").filter((l) => l.trim())
283
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
284
+ .filter(Boolean);
285
+ } catch { return []; }
286
+ }
287
+
288
+ /**
289
+ * Render a StatsReport as a human-readable fixed-width table.
290
+ * @param {object} report - A validated StatsReport (see {@link aggregate}).
291
+ * @returns {string} The multi-line table text (header + one row per slug + optional trend line).
292
+ */
293
+ function renderTable(report) {
294
+ const lines = [];
295
+ lines.push(`metrics: ${report.metrics_dir || "(none)"} — ${report.rows_total} run row(s), ` +
296
+ `${report.rows_pathology} pathology, ${report.rows_malformed} malformed (skipped)`);
297
+ if (Object.keys(report.pathologies).length > 0) {
298
+ lines.push(`pathologies: ${Object.entries(report.pathologies).map(([k, n]) => `${k}=${n}`).join(" ")}`);
299
+ }
300
+ if (report.per_slug.length === 0) {
301
+ lines.push("(no harvest rows yet — rows appear at SHIP S.6)");
302
+ return lines.join("\n");
303
+ }
304
+ lines.push("");
305
+ lines.push("slug runs rounds(min/avg/max) cut-rate exhaust qa(prom/total)");
306
+ for (const s of report.per_slug) {
307
+ const r = s.rounds ? `${s.rounds.min}/${s.rounds.avg}/${s.rounds.max}` : "—";
308
+ lines.push(
309
+ `${s.feature_slug.padEnd(28).slice(0, 28)}${String(s.runs).padEnd(6)}` +
310
+ `${r.padEnd(21)}${String(s.hammer_cut_rate).padEnd(10)}${String(s.attempt_exhaustions).padEnd(9)}` +
311
+ `${s.qa.promoted}/${s.qa.total}`
312
+ );
313
+ }
314
+ if (report.trend.length > 1) {
315
+ lines.push("");
316
+ lines.push("round_count trend: " + report.trend.map((t) => `${t.feature_slug}:${t.round_count}`).join(" → "));
317
+ }
318
+ return lines.join("\n");
319
+ }
320
+
321
+ // --- CLI -----------------------------------------------------------------------
322
+
323
+ /** The typed argv contract (see `./lib/argv.mjs`). */
324
+ export const ARGV_SPEC = {
325
+ usage: "stats.mjs [--cwd <dir>] [--metrics-dir <dir>] [--slug <slug>] [--format json|table] " +
326
+ "[--ratchet] [--hooks]",
327
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
328
+ cwd: { type: "path" },
329
+ "metrics-dir": { type: "path" },
330
+ slug: { type: "str" },
331
+ format: { type: "enum", values: ["json", "table"], default: "json" },
332
+ ratchet: { type: "flag" },
333
+ hooks: { type: "flag" },
334
+ };
335
+
336
+ /**
337
+ * Format one score vector for the ratchet table.
338
+ * @param {(object|null)} x - A T0Score, or null.
339
+ * @returns {string} e.g. "4/5" or "2/5 +1reg", or "—" when absent.
340
+ */
341
+ function fmtScore(x) {
342
+ return x ? `${x.fixtures_passed}/${x.fixtures_total}${x.regressions ? ` +${x.regressions}reg` : ""}` : "—";
343
+ }
344
+
345
+ /**
346
+ * Render the ratchet report as text.
347
+ * @param {object} r - Output of {@link ratchetReport}.
348
+ * @returns {string} The multi-line report.
349
+ */
350
+ function renderRatchet(r) {
351
+ const lines = [
352
+ `ratchet: ${r.trials} trial(s) across ${r.scopes} scope(s); ${r.scopes_multi_trial} with ≥2 trials`,
353
+ ` improvement_rate ${r.improvement_rate} (kept ÷ trials after the first)`,
354
+ ` monotone_rate ${r.monotone_rate} (scopes whose score never decreased)`,
355
+ ` sawtooth_count ${r.sawtooth_count} (a revert immediately after a keep)`,
356
+ ` mean_trials_to_green ${r.mean_trials_to_green ?? "—"}`,
357
+ ];
358
+ const hist = Object.entries(r.status_histogram);
359
+ if (hist.length) lines.push(` status: ${hist.map(([k, n]) => `${k}=${n}`).join(" ")}`);
360
+ if (r.scopes_multi_trial === 0) {
361
+ lines.push("", "(no scope has a second trial yet — the Day-1 question needs at least one retry to answer)");
362
+ }
363
+ for (const s of r.per_scope) {
364
+ lines.push(` ${String(s.scope_id).padEnd(16)} ${String(s.trials).padStart(2)} trial(s) ` +
365
+ `${fmtScore(s.first_score)} → ${fmtScore(s.last_score)} ` +
366
+ `${s.monotone === null ? "" : s.monotone ? "monotone" : "sawtooth"}${s.reached_green_at_trial ? ` green@t${s.reached_green_at_trial}` : ""}`);
367
+ }
368
+ return lines.join("\n");
369
+ }
370
+
371
+ /**
372
+ * Render the hook report as text.
373
+ * @param {object} r - Output of {@link hooksReport}.
374
+ * @returns {string} The multi-line report.
375
+ */
376
+ function renderHooks(r) {
377
+ const lines = [`hooks: ${r.evaluations} evaluation(s) across ${r.hooks} hook(s)`, ""];
378
+ lines.push("hook evals allow deny block error");
379
+ for (const h of r.per_hook) {
380
+ lines.push(`${h.hook.padEnd(26)}${String(h.evaluations).padEnd(7)}${String(h.allow).padEnd(7)}` +
381
+ `${String(h.deny).padEnd(6)}${String(h.block).padEnd(7)}${h.error}`);
382
+ }
383
+ if (r.evaluations === 0) {
384
+ lines.push("", "(zero rows: either no hook has run in this checkout, or the enforcement layer is inert —");
385
+ lines.push(" and that distinction is exactly what this ledger exists to make.)");
386
+ }
387
+ return lines.join("\n");
388
+ }
389
+
390
+ const isMainModule = isMain(import.meta.url);
391
+ if (isMainModule) {
392
+ const args = runArgs(ARGV_SPEC);
393
+ const cwd = resolve(args.cwd || process.cwd());
394
+ const metricsDir = args.metricsDir ? resolve(cwd, args.metricsDir) : metricsDirPath(cwd);
395
+ const format = args.format;
396
+
397
+ // The two exit measurements are separate modes: each reads a different ledger, and neither is a
398
+ // StatsReport (which is schema-locked to the harvest shards).
399
+ if (args.ratchet || args.hooks) {
400
+ const out = {};
401
+ if (args.ratchet) out.ratchet = ratchetReport(readAllTrials(cwd, args.slug ?? null));
402
+ if (args.hooks) out.hooks = hooksReport(readDecisions(cwd));
403
+ if (format === "table") {
404
+ const parts = [];
405
+ if (out.ratchet) parts.push(renderRatchet(out.ratchet));
406
+ if (out.hooks) parts.push(renderHooks(out.hooks));
407
+ console.log(parts.join("\n\n"));
408
+ } else {
409
+ console.log(JSON.stringify(out, null, 2));
410
+ }
411
+ process.exit(0);
412
+ }
413
+
414
+ const report = aggregate(readShards(metricsDir), { metricsDir, slugFilter: args.slug ?? null });
415
+
416
+ const { valid, errors } = validate(report, { $ref: "domain.schema.json#/$defs/StatsReport" });
417
+ if (!valid) {
418
+ console.error(` ✗ stats report drifted from domain.schema.json#/$defs/StatsReport:`);
419
+ for (const e of errors) console.error(` ✗ ${e}`);
420
+ process.exit(1);
421
+ }
422
+ console.log(format === "table" ? renderTable(report) : JSON.stringify(report, null, 2));
423
+ }