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.
- package/.claude/settings.local.example.json +20 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.env.shapeup.example +14 -0
- package/AGENTS.md +133 -0
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/SECURITY.md +72 -0
- package/bin/init.mjs +329 -0
- package/commands/build.md +14 -0
- package/commands/eval.md +15 -0
- package/commands/hammer.md +12 -0
- package/commands/orient.md +11 -0
- package/commands/qa.md +12 -0
- package/commands/retro.md +13 -0
- package/commands/scopes.md +14 -0
- package/commands/shape.md +12 -0
- package/commands/ship.md +53 -0
- package/commands/wire.md +11 -0
- package/hooks/anti-rationalization.mjs +244 -0
- package/hooks/compact-snapshot.mjs +47 -0
- package/hooks/gate-deadline.mjs +151 -0
- package/hooks/gate-intake.mjs +110 -0
- package/hooks/gate-l2.mjs +161 -0
- package/hooks/gate-zerowork.mjs +264 -0
- package/hooks/hooks.json +118 -0
- package/hooks/lib/decision.mjs +183 -0
- package/hooks/safety-spine.mjs +296 -0
- package/hooks/sandbox-guard.mjs +172 -0
- package/hooks/session-rehydrate.mjs +109 -0
- package/hooks/slop-cleaner.mjs +176 -0
- package/oracles/_shared.mjs +46 -0
- package/oracles/http-oracle.mjs +155 -0
- package/oracles/index.mjs +36 -0
- package/oracles/process-oracle.mjs +146 -0
- package/oracles/snapshot-oracle.mjs +119 -0
- package/oracles/test-oracle.mjs +138 -0
- package/package.json +49 -0
- package/skills/advisor-protocol/SKILL.md +171 -0
- package/skills/ba-pitch-analyzer/SKILL.md +175 -0
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +84 -0
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +65 -0
- package/skills/ba-pitch-analyzer/assets/templates/assess-report.tmpl.md +127 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/be-service.contract.tmpl.md +62 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/offline-storage.contract.tmpl.md +92 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +66 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/context-map.tmpl.md +64 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/event-choreography.tmpl.md +77 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +104 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +79 -0
- package/skills/ba-pitch-analyzer/assets/templates/domain-model.tmpl.md +79 -0
- package/skills/ba-pitch-analyzer/assets/templates/feedback.tmpl.md +87 -0
- package/skills/ba-pitch-analyzer/assets/templates/integration.tmpl.md +64 -0
- package/skills/ba-pitch-analyzer/assets/templates/run-state.tmpl.md +77 -0
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +105 -0
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +215 -0
- package/skills/ba-pitch-analyzer/assets/templates/task-board.tmpl.md +35 -0
- package/skills/ba-pitch-analyzer/assets/templates/task-spike.tmpl.md +86 -0
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +84 -0
- package/skills/ba-pitch-analyzer/assets/templates/usecase.tmpl.md +105 -0
- package/skills/ba-pitch-analyzer/assets/templates/ux-behavior.tmpl.md +65 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +152 -0
- package/skills/ba-pitch-analyzer/references/ddd-patterns.md +173 -0
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +275 -0
- package/skills/ba-pitch-analyzer/references/integration-analysis.md +152 -0
- package/skills/ba-pitch-analyzer/references/task-generation.md +623 -0
- package/skills/ba-pitch-analyzer/references/test-surface.md +102 -0
- package/skills/ba-pitch-analyzer/references/ux-behavior-patterns.md +269 -0
- package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +241 -0
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +239 -0
- package/skills/coach/SKILL.md +202 -0
- package/skills/orient/SKILL.md +262 -0
- package/skills/qa-edge-hunter/SKILL.md +369 -0
- package/skills/scope-architect/SKILL.md +111 -0
- package/skills/scope-hammer/SKILL.md +186 -0
- package/skills/shapeup/SKILL.md +403 -0
- package/skills/shapeup/resources/breadboard-reflection.md +88 -0
- package/skills/shapeup/resources/breadboarding.md +334 -0
- package/skills/shapeup/resources/context-compaction.md +240 -0
- package/skills/shapeup/resources/framing-doc.md +92 -0
- package/skills/shapeup/resources/kickoff-doc.md +95 -0
- package/skills/shapeup/resources/shaping.md +194 -0
- package/skills/shapeup/resources/spike.md +86 -0
- package/skills/solution-architect/SKILL.md +129 -0
- package/skills/spec-evaluator/README.md +93 -0
- package/skills/spec-evaluator/SKILL.md +213 -0
- package/skills/spec-evaluator/references/anti-leniency.md +50 -0
- package/skills/spec-evaluator/references/dimension-contract.md +126 -0
- package/skills/spec-evaluator/references/dimensions/_registry.md +40 -0
- package/skills/spec-evaluator/references/dimensions/completeness.md +102 -0
- package/skills/spec-evaluator/references/dimensions/integration.md +129 -0
- package/skills/spec-evaluator/references/dimensions/performance.md +48 -0
- package/skills/spec-evaluator/references/dimensions/security.md +60 -0
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +134 -0
- package/skills/spec-evaluator/references/dimensions/tdd-surface.md +110 -0
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +100 -0
- package/skills/spec-evaluator/references/probing.md +177 -0
- package/skills/spec-evaluator/references/report-schema.md +101 -0
- package/skills/spec-evaluator/references/verdict-ledger.md +92 -0
- package/skills/spec-evaluator/scripts/verdict-ledger.mjs +166 -0
- package/skills/task-executor/SKILL.md +194 -0
- package/skills/tech-lead/README.md +71 -0
- package/skills/tech-lead/SKILL.md +459 -0
- package/skills/tech-lead/references/delegation.md +254 -0
- package/skills/tech-lead/references/gates.md +379 -0
- package/skills/tech-lead/references/invocation.md +45 -0
- package/skills/tech-lead/references/ledger-schema.md +214 -0
- package/skills/tech-lead/references/round-protocol.md +184 -0
- package/skills/tech-lead/references/state-model.md +66 -0
- package/skills/tech-lead/references/tiny-lane.md +52 -0
- package/skills/tech-lead/schemas/domain.schema.json +2294 -0
- package/skills/tech-lead/schemas/gate-answers.schema.json +92 -0
- package/skills/tech-lead/schemas/work-order.schema.json +21 -0
- package/skills/tech-lead/schemas/work-result.schema.json +40 -0
- package/skills/tech-lead/scripts/aegis-digest.mjs +124 -0
- package/skills/tech-lead/scripts/budget-check.mjs +156 -0
- package/skills/tech-lead/scripts/compile-order.mjs +518 -0
- package/skills/tech-lead/scripts/fit-check.mjs +196 -0
- package/skills/tech-lead/scripts/gate-answers.mjs +338 -0
- package/skills/tech-lead/scripts/ingest-result.mjs +270 -0
- package/skills/tech-lead/scripts/init-run.mjs +326 -0
- package/skills/tech-lead/scripts/lib/argv.mjs +224 -0
- package/skills/tech-lead/scripts/lib/contract-md.mjs +481 -0
- package/skills/tech-lead/scripts/lib/is-main.mjs +82 -0
- package/skills/tech-lead/scripts/lib/paths.mjs +278 -0
- package/skills/tech-lead/scripts/lib/ratchet-tree.mjs +112 -0
- package/skills/tech-lead/scripts/run-snapshot.mjs +269 -0
- package/skills/tech-lead/scripts/ship-report.mjs +275 -0
- package/skills/tech-lead/scripts/stats.mjs +423 -0
- package/skills/tech-lead/scripts/t0-verify.mjs +470 -0
- package/skills/tech-lead/scripts/trace-lint.mjs +367 -0
- package/skills/tech-lead/scripts/validate-envelope.mjs +283 -0
- package/skills/translator/README.md +66 -0
- package/skills/translator/SKILL.md +258 -0
- 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
|
+
}
|