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,196 @@
1
+ #!/usr/bin/env node
2
+ // GATE L0.3 — FIT CHECK. Which lane does this change belong in? Decided by measurement.
3
+ //
4
+ // WHY THIS EXISTS (measured, and it is the root cause the other three fixes did not touch).
5
+ //
6
+ // On the SDD harness benchmark, F3 ("add a `summary` command" to a six-file CLI — one new module
7
+ // plus one dispatcher wiring) was run through the full eleven-gate pipeline and never finished.
8
+ // Not once, across four attempts. It was killed at the 1800s cap mid-build; then, with a
9
+ // wall-clock breaker fitted, it reached ship-triage and was killed there; then it produced an
10
+ // honest report saying its two must-haves were 0% started.
11
+ //
12
+ // Every one of those fixes made the FAILURE better. None of them made the RUN finish, because
13
+ // none addressed why a three-file change was consuming half an hour: **the ceremony was not
14
+ // sized to the change.**
15
+ //
16
+ // The harness already knew. In the pilot transcripts, tech-lead identified F1 at GATE L0 as
17
+ // "about as small as they come… squarely inside the --tiny lane" — and then ran the full pipeline
18
+ // anyway, because the lane was a judgment the model was free to talk itself out of. That is the
19
+ // same class of defect as narration and as prose consent: an invariant living somewhere a model
20
+ // can re-decide it. The project's rule is that such invariants move into the runtime.
21
+ //
22
+ // So the lane stops being a recommendation and becomes a computed value, recorded in the receipt
23
+ // alongside the evidence for it. A PO can still override — with `--lane`, which is recorded AS an
24
+ // override, so "we ran the heavy lane on a two-file change" is visible rather than accidental.
25
+ //
26
+ // WHAT IT MEASURES. Only things that are true before any work starts:
27
+ // • the size of the tree being changed (a 6-file CLI is not a monorepo)
28
+ // • how many distinct deliverables the intake asks for
29
+ // • whether the intake introduces a new dependency, datastore, or external seam
30
+ // • whether the intake itself signals breadth ("migration", "across", "end to end")
31
+ //
32
+ // It is deliberately CONSERVATIVE: `full` is the default and `tiny` must be earned. A wrong
33
+ // `tiny` skips review on something that needed it; a wrong `full` only costs money. Those are not
34
+ // symmetric, and the tie goes to the gates.
35
+ //
36
+ // USAGE
37
+ // node fit-check.mjs --intake-file <path> [--cwd <root>] [--json]
38
+ // node fit-check.mjs --intake-text "<requirement>" [--cwd <root>]
39
+ //
40
+ // Exit 0 always — this informs a decision, it does not deny a tool call. The orchestrator reads
41
+ // `lane` and acts on it; `init-run.mjs` records it in the receipt.
42
+
43
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
44
+ import { join } from "node:path";
45
+ import { isMain } from "./lib/is-main.mjs";
46
+ import { LOCAL } from "./lib/paths.mjs";
47
+ import { runArgs } from "./lib/argv.mjs";
48
+
49
+ /** Directories that are never part of "the tree being changed". */
50
+ const IGNORE_DIRS = new Set([".git", "node_modules", LOCAL, "dist", "build", ".next", "coverage", ".claude"]);
51
+
52
+ /** Source extensions that count toward tree size. */
53
+ const SOURCE_EXT = /\.(m?[jt]sx?|py|rb|go|rs|java|kt|swift|php|cs)$/i;
54
+
55
+ /** Intake phrases that mean "this is bigger than it looks". */
56
+ const BREADTH_SIGNALS = [
57
+ /\bmigrat(e|ion)\b/i,
58
+ /\bacross\b.*\b(files?|modules?|services?|commands?)\b/i,
59
+ /\bend[- ]to[- ]end\b/i,
60
+ /\brefactor\b/i,
61
+ /\bbackfill\b/i,
62
+ /\bschema\b/i,
63
+ /\bbreaking change\b/i,
64
+ /\bnew (dependency|package|library|service|datastore|database|table)\b/i,
65
+ /\bauth(entication|orization)\b/i,
66
+ /\bexternal (api|service|provider)\b/i,
67
+ ];
68
+
69
+ /** Countable deliverables: things the intake asks to exist that do not exist yet. */
70
+ const DELIVERABLE_SIGNALS = [
71
+ /\badd (?:a )?new\b/gi,
72
+ /\badd (?:a|an|the)\b/gi,
73
+ /\bnew (command|module|flag|endpoint|screen|field|option)\b/gi,
74
+ /\bimplement\b/gi,
75
+ /\bcreate\b/gi,
76
+ /\bsupport\b/gi,
77
+ ];
78
+
79
+ /** Count source files in the tree, bounded so a huge repo cannot make this slow. */
80
+ export function treeSize(root, cap = 400) {
81
+ let files = 0;
82
+ const walk = (dir, depth) => {
83
+ if (files >= cap || depth > 6) return;
84
+ let entries;
85
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
86
+ for (const e of entries) {
87
+ if (files >= cap) return;
88
+ if (e.name.startsWith(".") && e.name !== ".claude") { if (IGNORE_DIRS.has(e.name)) continue; }
89
+ if (IGNORE_DIRS.has(e.name)) continue;
90
+ const p = join(dir, e.name);
91
+ if (e.isDirectory()) walk(p, depth + 1);
92
+ else if (SOURCE_EXT.test(e.name)) {
93
+ try { if (statSync(p).size > 0) files++; } catch { /* unreadable */ }
94
+ }
95
+ }
96
+ };
97
+ walk(root, 0);
98
+ return files;
99
+ }
100
+
101
+ /**
102
+ * Decide the lane from measurable facts.
103
+ *
104
+ * @param {{intake: string, files: number}} input
105
+ * @returns {{lane: "tiny"|"full", confidence: "clear"|"borderline", reasons: string[], signals: object}}
106
+ */
107
+ export function decideLane({ intake, files }) {
108
+ const text = String(intake || "");
109
+ const breadth = BREADTH_SIGNALS.filter((re) => re.test(text)).map((re) => String(re));
110
+ let deliverables = 0;
111
+ for (const re of DELIVERABLE_SIGNALS) {
112
+ re.lastIndex = 0;
113
+ deliverables += (text.match(re) || []).length;
114
+ }
115
+
116
+ const signals = {
117
+ tree_source_files: files,
118
+ intake_chars: text.length,
119
+ deliverable_mentions: deliverables,
120
+ breadth_signals: breadth.length,
121
+ };
122
+
123
+ // CONJUNCTIVE, and it must be. The first version asked "is there evidence this is BIG?" and
124
+ // defaulted to tiny when it found none — which classified all three benchmark features as tiny,
125
+ // including the five-seam one that genuinely needs the pipeline and used two evaluation rounds
126
+ // to pass. A router that confident and that wrong is worse than no router: it would skip review
127
+ // on exactly the change that needed it.
128
+ //
129
+ // So `tiny` now requires positive evidence of smallness on EVERY axis, and `full` is what you
130
+ // get by default. A wrong `tiny` skips gates on something that needed them; a wrong `full` only
131
+ // costs money. Those are not symmetric.
132
+ const TINY = { files: 10, chars: 1500, deliverables: 2 };
133
+ const checks = [
134
+ { ok: files > 0 && files <= TINY.files, why: `tree has ${files} source file(s) (tiny needs 1–${TINY.files})` },
135
+ { ok: text.length <= TINY.chars, why: `intake is ${text.length} chars (tiny needs ≤${TINY.chars})` },
136
+ { ok: deliverables <= TINY.deliverables, why: `intake names ~${deliverables} deliverable(s) (tiny needs ≤${TINY.deliverables})` },
137
+ { ok: breadth.length === 0, why: breadth.length ? `${breadth.length} breadth signal(s) present` : "no breadth signals" },
138
+ ];
139
+ const reasons = checks.map((c) => `${c.ok ? "✓" : "✗"} ${c.why}`);
140
+ const tiny = checks.every((c) => c.ok);
141
+ const failed = checks.filter((c) => !c.ok).length;
142
+
143
+ // "Borderline" is not decoration: it is the signal that a human should look. One failed check
144
+ // means the change sits on the boundary and the lane is a judgment, not a measurement.
145
+ const confidence = tiny ? "clear" : failed === 1 ? "borderline" : "clear";
146
+
147
+ return {
148
+ lane: tiny ? "tiny" : "full",
149
+ confidence,
150
+ reasons,
151
+ signals,
152
+ // Say it on every invocation rather than burying it in a doc. These thresholds are calibrated
153
+ // against THREE features. That is enough to stop the router being obviously wrong and nowhere
154
+ // near enough to trust it silently, so it recommends and never denies.
155
+ calibration: "advisory — thresholds fitted on 3 benchmark features (n=3); `full` is the default and the PO may override with --lane",
156
+ };
157
+ }
158
+
159
+ // ---- CLI -------------------------------------------------------------------
160
+
161
+ /** The typed argv contract (see `./lib/argv.mjs`). */
162
+ export const ARGV_SPEC = {
163
+ usage: 'fit-check.mjs (--intake-file <path> | --intake-text "<requirement>") [--cwd <dir>]',
164
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
165
+ cwd: { type: "path" },
166
+ "intake-text": { type: "str" },
167
+ "intake-file": { type: "path" },
168
+ };
169
+
170
+ export function main() {
171
+ const args = runArgs(ARGV_SPEC);
172
+ const cwd = args.cwd || process.cwd();
173
+ let intake = args.intakeText ?? null;
174
+ const f = args.intakeFile ?? null;
175
+ if (f) {
176
+ const p = f.startsWith("/") ? f : join(cwd, f);
177
+ if (!existsSync(p)) { console.error(`--intake-file not found: ${p}`); process.exit(2); }
178
+ intake = readFileSync(p, "utf8");
179
+ }
180
+ if (!intake) { console.error("fit-check: pass --intake-file <path> or --intake-text \"<requirement>\""); process.exit(2); }
181
+
182
+ const files = treeSize(cwd);
183
+ const result = decideLane({ intake, files });
184
+
185
+ console.log(JSON.stringify({
186
+ ...result,
187
+ // The sentence the orchestrator must act on, so the decision is not re-derived from the JSON.
188
+ action: result.lane === "tiny"
189
+ ? "TINY LANE. Run: orient (light) → single-task board → build → T0 → ⏸ L4. Skip WIRE, scope contracts, spec tree, EVAL and QA. The full pipeline on a change this size is the measured cause of a benchmark run that never finished, four attempts running."
190
+ : "FULL LANE. Run the complete pipeline: ORIENT → WIRE → MAP SCOPES → BUILD → EVAL → QA → GATE H → L4.",
191
+ }, null, 2));
192
+ }
193
+
194
+ if (isMain(import.meta.url)) {
195
+ main();
196
+ }
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ // GATE ANSWER SET — cross a gate with a pre-recorded decision instead of a live human.
3
+ //
4
+ // WHY THIS EXISTS (measured, not theorized).
5
+ //
6
+ // This harness pauses at every ⏸ gate for PO sign-off by default. That is the point of it. But
7
+ // on the SDD harness benchmark it produced two distinct failures, both of which look like the
8
+ // harness being slow or broken rather than the harness being safe:
9
+ //
10
+ // 1. STALL → TIMEOUT. F3 (Sonnet 5): the run was killed at the declared 1800s cap having
11
+ // produced nothing scoreable, while the no-harness control finished the same feature in
12
+ // 51 seconds. A run with no human at the keyboard sitting at a gate does not fail — it
13
+ // waits, and a wait is indistinguishable from work until the budget runs out.
14
+ // 2. CONSENT-BY-PROSE. The workaround was a paragraph of English in the prompt ("treat this
15
+ // message as advance sign-off for every gate"). On Sonnet that worked. On Haiku 4.5 the
16
+ // model read the paragraph, read the 450-line gate list, and narrated the pipeline instead
17
+ // of running it — 29% acceptance, n=5, zero variance. Consent carried in prose is consent
18
+ // that can be re-summarized instead of acted on.
19
+ //
20
+ // The organising rule of this project is that every invariant that matters lives in the runtime,
21
+ // not in a prompt. Gate sign-off was the last big one still living in a prompt. So it becomes a
22
+ // file and a script: the orchestrator RESOLVES each gate through this tool, and the tool's stdout
23
+ // — not the model's reading of a paragraph — is what crosses the gate.
24
+ //
25
+ // WHAT THIS IS NOT. It is not "gates off". Every gate still emits its ⏸ block, still records a
26
+ // decision, and still writes that decision to the ledger with its SOURCE ("gate-answers:ci,
27
+ // authorized_by: …") instead of "PO (live)". An audited bypass and a rubber stamp differ by
28
+ // exactly that record, which is why `note` and `authorized_by` are in the schema and why
29
+ // `--resolve` refuses to invent an answer that the set does not contain.
30
+ //
31
+ // The `on_missing: "abort"` default for headless presets is the direct fix for failure (1): a
32
+ // gate with no answer in a lane with no human is a fast, attributable abort ("GATE L4 has no
33
+ // pre-recorded answer") rather than a silent wait that gets reported as a slow harness.
34
+ //
35
+ // USAGE
36
+ // node gate-answers.mjs --init [--preset ci|guarded|interactive] [--out <path>] [--by "<name>"]
37
+ // node gate-answers.mjs --resolve <gate-id> [--file <path>|--preset <name>] [--slug <slug>]
38
+ // node gate-answers.mjs --list [--file <path>|--preset <name>]
39
+ // node gate-answers.mjs --verify [--file <path>|--preset <name>] [--auto-level unattended]
40
+ //
41
+ // RESOLUTION ORDER for --resolve / --list / --verify, first hit wins:
42
+ // 1. --file <path>
43
+ // 2. --preset <name> (built-in, below)
44
+ // 3. .shapeup/<slug>/gate-answers.json
45
+ // 4. .shapeup/gate-answers.json
46
+ // 5. shapeup/gate-answers.json (committed, team-shared)
47
+ //
48
+ // EXIT CODES (they are the contract — the orchestrator branches on them, not on the prose):
49
+ // 0 answer resolved; JSON on stdout with { gate, decision, source, note }
50
+ // 4 gate is answered "ask" → the orchestrator must stop and put the block to the human
51
+ // 5 no answer and on_missing=abort → the run aborts here, attributably
52
+ // 2 usage / validation error
53
+
54
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
55
+ import { join, dirname } from "node:path";
56
+ import { isMain } from "./lib/is-main.mjs";
57
+ import { runArgs } from "./lib/argv.mjs";
58
+ import { gateAnswerCandidates, LOCAL } from "./lib/paths.mjs";
59
+
60
+ export const GATE_IDS = ["L0", "L1a", "L1a.5", "L1b", "L2", "L3", "QA", "H", "L4", "COACH-1"];
61
+
62
+ // Which decisions are meaningful at which gate. A set that says `L4: "proceed"` is a set whose
63
+ // author did not know what L4 asks, so it is rejected rather than coerced.
64
+ export const VALID_BY_GATE = {
65
+ "L0": ["proceed", "ask", "abort"],
66
+ "L1a": ["proceed", "ask", "abort"],
67
+ "L1a.5": ["proceed", "ask", "abort"],
68
+ "L1b": ["proceed", "ask", "abort"],
69
+ "L2": ["proceed", "ask", "abort"],
70
+ "L3": ["loop", "stop", "ask"],
71
+ "QA": ["run", "skip", "ask"],
72
+ "H": ["accept-cut-list", "ship-all", "ask"],
73
+ "L4": ["ship", "hold", "ask"],
74
+ "COACH-1": ["skip", "ask"],
75
+ };
76
+
77
+ // ---- built-in presets ------------------------------------------------------
78
+
79
+ const CI_NOTE = "Pre-approved for a headless lane. No human is present; the decision is recorded here so the ledger still names a source.";
80
+
81
+ export const PRESETS = {
82
+ // Everything pre-approved. The lane a CI step or a benchmark runs in.
83
+ ci: {
84
+ version: 1,
85
+ preset: "ci",
86
+ authorized_by: "unattended lane (--unattended)",
87
+ on_missing: "abort",
88
+ escalation: { default: "assume-and-record", budget_per_scope_per_round: 2 },
89
+ answers: {
90
+ "L0": { decision: "proceed", note: CI_NOTE },
91
+ "L1a": { decision: "proceed", note: CI_NOTE },
92
+ "L1a.5": { decision: "proceed", note: CI_NOTE },
93
+ "L1b": { decision: "proceed", note: CI_NOTE },
94
+ "L2": { decision: "proceed", note: CI_NOTE },
95
+ "L3": { decision: "loop", max_rounds: 3, note: "FAIL → fix round r+1, up to the run's max_rounds. The circuit breaker, not this file, ends the loop." },
96
+ "QA": { decision: "run", note: "QA is a level-up, not a gate — it discovers, it cannot block." },
97
+ "H": { decision: "accept-cut-list", note: "Scope-hammer's cut list is accepted as proposed; baseline comparison still runs and is still recorded." },
98
+ "L4": { decision: "ship", note: "Ship sign-off pre-approved. THIS is the one a reviewer should look at first when auditing a headless run." },
99
+ "COACH-1": { decision: "skip", note: "No live PO to categorize feedback; retro rules are not filed from an unattended run." },
100
+ },
101
+ },
102
+
103
+ // Low-risk gates pre-approved; the two that need judgment still stop. Matches `--auto`.
104
+ guarded: {
105
+ version: 1,
106
+ preset: "guarded",
107
+ authorized_by: "auto lane (--auto)",
108
+ on_missing: "ask",
109
+ escalation: { default: "ask", budget_per_scope_per_round: 2 },
110
+ answers: {
111
+ "L0": { decision: "proceed", note: "Config gate — mechanical." },
112
+ "L1a": { decision: "proceed", note: "Orient review — advisory read." },
113
+ "L1a.5": { decision: "proceed", note: "Wiring review — checked by trace-lint." },
114
+ "L1b": { decision: "ask", note: "Board review is where scope is actually decided. Not pre-approvable." },
115
+ "L2": { decision: "proceed", note: "Board-green is verified by hook, not by opinion." },
116
+ "L3": { decision: "loop", max_rounds: 3, note: "Loop on FAIL; the breaker ends it." },
117
+ "QA": { decision: "run" },
118
+ "H": { decision: "ask", note: "The cut list changes what ships." },
119
+ "L4": { decision: "ask", note: "Ship sign-off always stops in the auto lane." },
120
+ "COACH-1": { decision: "ask" },
121
+ },
122
+ },
123
+
124
+ // The default lane. Present so `--preset interactive` is a valid, explicit choice rather than
125
+ // an absent file, and so `--verify` can report "nothing is pre-approved" as a positive fact.
126
+ interactive: {
127
+ version: 1,
128
+ preset: "interactive",
129
+ authorized_by: "none — every gate is put to the PO live",
130
+ on_missing: "ask",
131
+ escalation: { default: "ask", budget_per_scope_per_round: 2 },
132
+ answers: Object.fromEntries(GATE_IDS.map((g) => [g, { decision: "ask" }])),
133
+ },
134
+ };
135
+
136
+ // ---- validation ------------------------------------------------------------
137
+
138
+ /** Structural + semantic validation. Returns a list of human-readable problems (empty = valid). */
139
+ export function validate(set) {
140
+ const errs = [];
141
+ if (!set || typeof set !== "object") return ["not an object"];
142
+ if (set.version !== 1) errs.push(`version must be 1 (got ${JSON.stringify(set.version)})`);
143
+ if (!["ci", "guarded", "interactive", "custom"].includes(set.preset)) {
144
+ errs.push(`preset must be ci|guarded|interactive|custom (got ${JSON.stringify(set.preset)})`);
145
+ }
146
+ if (set.on_missing && !["ask", "proceed", "abort"].includes(set.on_missing)) {
147
+ errs.push(`on_missing must be ask|proceed|abort (got ${JSON.stringify(set.on_missing)})`);
148
+ }
149
+ if (!set.answers || typeof set.answers !== "object") {
150
+ errs.push("answers is required");
151
+ return errs;
152
+ }
153
+ for (const [gate, a] of Object.entries(set.answers)) {
154
+ if (!GATE_IDS.includes(gate)) { errs.push(`unknown gate id "${gate}"`); continue; }
155
+ if (!a || typeof a !== "object" || typeof a.decision !== "string") {
156
+ errs.push(`${gate}: answer must be an object with a "decision" string`); continue;
157
+ }
158
+ if (!VALID_BY_GATE[gate].includes(a.decision)) {
159
+ errs.push(`${gate}: decision "${a.decision}" is not valid here — expected one of ${VALID_BY_GATE[gate].join("|")}`);
160
+ }
161
+ if (a.max_rounds !== undefined && gate !== "L3") {
162
+ errs.push(`${gate}: max_rounds is only meaningful on L3`);
163
+ }
164
+ }
165
+ return errs;
166
+ }
167
+
168
+ /**
169
+ * Resolve one gate against a set.
170
+ * Returns { gate, decision, source, note, status } where status ∈ ok | ask | abort.
171
+ * Pure: the CLI turns `status` into the exit code, nothing here exits.
172
+ */
173
+ export function resolve(set, gate, source) {
174
+ if (!GATE_IDS.includes(gate)) {
175
+ return { gate, status: "error", reason: `unknown gate "${gate}" — known: ${GATE_IDS.join(", ")}` };
176
+ }
177
+ const a = set.answers?.[gate];
178
+ if (!a) {
179
+ const onMissing = set.on_missing || "ask";
180
+ if (onMissing === "abort") {
181
+ return {
182
+ gate, status: "abort", source,
183
+ reason: `GATE ${gate} has no pre-recorded answer and this answer set aborts on a missing gate. ` +
184
+ `A headless lane must not wait at a gate no one will answer — that spends the wall-clock ` +
185
+ `budget and reports as a slow harness. Add an answer for ${gate} or run in a lane with a human.`,
186
+ };
187
+ }
188
+ if (onMissing === "proceed") {
189
+ return { gate, status: "ok", decision: "proceed", source: `${source} (on_missing:proceed)`, note: "no explicit answer; set proceeds by default" };
190
+ }
191
+ return { gate, status: "ask", source, reason: `GATE ${gate} has no pre-recorded answer — put the block to the PO.` };
192
+ }
193
+ if (a.decision === "ask") {
194
+ return { gate, status: "ask", source, decision: "ask", note: a.note, reason: `GATE ${gate} is answered "ask" — stop and put the block to the PO.` };
195
+ }
196
+ if (a.decision === "abort") {
197
+ return { gate, status: "abort", source, decision: "abort", note: a.note, reason: `GATE ${gate} is answered "abort".` };
198
+ }
199
+ return {
200
+ gate, status: "ok", decision: a.decision, source, note: a.note,
201
+ ...(a.max_rounds ? { max_rounds: a.max_rounds } : {}),
202
+ // The ledger line the orchestrator must write. Provided here so the record is generated by
203
+ // the tool that made the decision, not re-typed by the model that read it.
204
+ ledger_row: `| ${gate} | ${a.decision} | ${source} | ${(a.note || "").replace(/\|/g, "\\|")} |`,
205
+ };
206
+ }
207
+
208
+ /** Gates this lane will actually hit — used by --verify to catch a set that stalls halfway. */
209
+ export function requiredGates({ autoLevel = "unattended", tiny = false, qa = true } = {}) {
210
+ if (tiny) return ["L0", "L4"];
211
+ const base = ["L0", "L1a", "L1a.5", "L1b", "L2", "L3", "H", "L4"];
212
+ if (qa) base.splice(base.indexOf("H"), 0, "QA");
213
+ if (autoLevel === "unattended") base.push("COACH-1");
214
+ return base;
215
+ }
216
+
217
+ // ---- file discovery --------------------------------------------------------
218
+
219
+ export function discover({ cwd = process.cwd(), file = null, preset = null, slug = null } = {}) {
220
+ if (file) {
221
+ const p = file.startsWith("/") ? file : join(cwd, file);
222
+ if (!existsSync(p)) return { set: null, source: null, error: `answer set not found: ${p}` };
223
+ try { return { set: JSON.parse(readFileSync(p, "utf8")), source: `file:${file}` }; }
224
+ catch (e) { return { set: null, source: null, error: `answer set is not valid JSON: ${p} (${e.message})` }; }
225
+ }
226
+ if (preset) {
227
+ if (!PRESETS[preset]) return { set: null, source: null, error: `unknown preset "${preset}" — known: ${Object.keys(PRESETS).join(", ")}` };
228
+ return { set: PRESETS[preset], source: `preset:${preset}` };
229
+ }
230
+ const candidates = gateAnswerCandidates(cwd, slug);
231
+ for (const p of candidates) {
232
+ if (!existsSync(p)) continue;
233
+ try { return { set: JSON.parse(readFileSync(p, "utf8")), source: `file:${p.replace(cwd + "/", "")}` }; }
234
+ catch (e) { return { set: null, source: null, error: `answer set is not valid JSON: ${p} (${e.message})` }; }
235
+ }
236
+ return { set: null, source: null, error: `no gate answer set found (looked for --file, --preset, ${gateAnswerCandidates(cwd, slug).map((p) => p.replace(cwd + "/", "")).join(", ")})` };
237
+ }
238
+
239
+ // ---- CLI -------------------------------------------------------------------
240
+
241
+ /** The typed argv contract (see `./lib/argv.mjs`). */
242
+ export const ARGV_SPEC = {
243
+ usage: "gate-answers.mjs (--init | --list | --verify | --resolve <gate-id>) [--preset <name>] " +
244
+ "[--file <path>] [--slug <slug>] [--cwd <dir>] [--out <path>] [--by <who>] " +
245
+ "[--auto-level <level>] [--tiny] [--no-qa]",
246
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
247
+ cwd: { type: "path" },
248
+ init: { type: "flag" },
249
+ list: { type: "flag" },
250
+ verify: { type: "flag" },
251
+ resolve: { type: "str" },
252
+ preset: { type: "str" },
253
+ file: { type: "path" },
254
+ slug: { type: "str" },
255
+ out: { type: "path" },
256
+ by: { type: "str" },
257
+ "auto-level": { type: "str" },
258
+ tiny: { type: "flag" },
259
+ "no-qa": { type: "flag" },
260
+ };
261
+
262
+ function out(obj, code = 0) {
263
+ console.log(JSON.stringify(obj, null, 2));
264
+ process.exit(code);
265
+ }
266
+ function die(msg, code = 2) {
267
+ console.error(msg);
268
+ process.exit(code);
269
+ }
270
+
271
+ export function main() {
272
+ const args = runArgs(ARGV_SPEC);
273
+ const cwd = args.cwd || process.cwd();
274
+
275
+ if (args.init) {
276
+ const presetName = args.preset ?? "ci";
277
+ const base = PRESETS[presetName];
278
+ if (!base) die(`unknown preset "${presetName}" — known: ${Object.keys(PRESETS).join(", ")}`);
279
+ const set = JSON.parse(JSON.stringify(base));
280
+ set.preset = presetName === "interactive" ? "interactive" : presetName;
281
+ const by = args.by ?? null;
282
+ if (by) set.authorized_by = by;
283
+ const dest = args.out ?? `${LOCAL}/gate-answers.json`;
284
+ const p = dest.startsWith("/") ? dest : join(cwd, dest);
285
+ mkdirSync(dirname(p), { recursive: true });
286
+ writeFileSync(p, JSON.stringify(set, null, 2) + "\n", "utf8");
287
+ out({ ok: true, wrote: dest, preset: presetName, authorized_by: set.authorized_by, gates: Object.keys(set.answers).length });
288
+ }
289
+
290
+ const found = discover({ cwd, file: args.file ?? null, preset: args.preset ?? null, slug: args.slug ?? null });
291
+ if (found.error) die(found.error);
292
+ const errs = validate(found.set);
293
+ if (errs.length) die(`invalid gate answer set (${found.source}):\n - ${errs.join("\n - ")}`);
294
+
295
+ if (args.list) {
296
+ out({
297
+ ok: true, source: found.source, preset: found.set.preset,
298
+ authorized_by: found.set.authorized_by ?? null,
299
+ on_missing: found.set.on_missing || "ask",
300
+ escalation: found.set.escalation || { default: "ask" },
301
+ answers: found.set.answers,
302
+ });
303
+ }
304
+
305
+ if (args.verify) {
306
+ const autoLevel = args.autoLevel ?? "unattended";
307
+ const need = requiredGates({ autoLevel, tiny: !!args.tiny, qa: !args.noQa });
308
+ const missing = need.filter((g) => !found.set.answers?.[g]);
309
+ const willAsk = need.filter((g) => found.set.answers?.[g]?.decision === "ask");
310
+ const headless = autoLevel === "unattended";
311
+ // In a headless lane an "ask" is a stall, and a stall is the failure mode this whole file
312
+ // exists to remove. Report it as a hard problem there and as information elsewhere.
313
+ const ok = missing.length === 0 && (!headless || willAsk.length === 0);
314
+ out({
315
+ ok, source: found.source, preset: found.set.preset,
316
+ required_gates: need, missing, will_ask: willAsk,
317
+ ...(ok ? {} : {
318
+ reason: headless && willAsk.length
319
+ ? `answer set stops at ${willAsk.join(", ")} — in an unattended lane that is a stall, not a safeguard. ` +
320
+ `Use --preset ci, or run this lane with a human.`
321
+ : `answer set has no answer for ${missing.join(", ")}.`,
322
+ }),
323
+ }, ok ? 0 : 2);
324
+ }
325
+
326
+ const gate = args.resolve ?? null;
327
+ if (!gate) die("nothing to do — pass --init, --list, --verify, or --resolve <gate-id>");
328
+
329
+ const r = resolve(found.set, gate, found.source);
330
+ if (r.status === "error") die(r.reason);
331
+ if (r.status === "ask") out({ ...r, ok: false }, 4);
332
+ if (r.status === "abort") out({ ...r, ok: false }, 5);
333
+ out({ ...r, ok: true }, 0);
334
+ }
335
+
336
+ if (isMain(import.meta.url)) {
337
+ main();
338
+ }