shapeup-sdlc 1.7.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.example.json +5 -5
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +17 -3
- package/README.md +124 -103
- package/SECURITY.md +37 -30
- package/bin/init.mjs +77 -47
- package/bin/lib/grant.mjs +145 -0
- package/commands/build.md +7 -6
- package/commands/ship.md +29 -16
- package/commands/wire.md +1 -1
- package/hooks/dispatch-receipt.mjs +195 -0
- package/hooks/gate-intake.mjs +3 -3
- package/hooks/gate-zerowork.mjs +25 -24
- package/hooks/hooks.json +9 -48
- package/hooks/lib/decision.mjs +19 -8
- package/hooks/safety-spine.mjs +3 -3
- package/hooks/sandbox-guard.mjs +84 -40
- package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
- package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
- package/kernel/harness.mjs +134 -0
- package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
- package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
- package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
- package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
- package/kernel/lib/paths.mjs +491 -0
- package/kernel/probe/concurrency.mjs +510 -0
- package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
- package/kernel/probe/eval.mjs +77 -0
- package/kernel/probe/leg.mjs +125 -0
- package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
- package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +93 -11
- package/kernel/probe/t0.mjs +66 -0
- package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
- package/kernel/reduce/graph.mjs +437 -0
- package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
- package/kernel/reduce/ingest.mjs +633 -0
- package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
- package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
- package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
- package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
- package/kernel/report/export.mjs +325 -0
- package/kernel/report/facts.mjs +347 -0
- package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
- package/kernel/verify/dispatch.mjs +114 -0
- package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
- package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
- package/kernel/verify/skills.mjs +125 -0
- package/kernel/verify/spec.mjs +559 -0
- package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
- package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
- package/oracles/http-oracle.mjs +1 -1
- package/oracles/process-oracle.mjs +1 -1
- package/oracles/snapshot-oracle.mjs +1 -1
- package/oracles/test-oracle.mjs +1 -1
- package/package.json +11 -9
- package/skills/ba-pitch-analyzer/SKILL.md +5 -5
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
- package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
- package/skills/qa-edge-hunter/SKILL.md +7 -3
- package/skills/scope-architect/SKILL.md +40 -6
- package/skills/solution-architect/SKILL.md +19 -4
- package/skills/spec-evaluator/SKILL.md +12 -2
- package/skills/task-executor/SKILL.md +7 -6
- package/skills/tech-lead/SKILL.md +50 -38
- package/skills/tech-lead/references/gates.md +69 -20
- package/skills/tech-lead/references/protocol.md +832 -0
- package/skills/tech-lead/schemas/domain.schema.json +341 -66
- package/skills/tech-lead/schemas/work-order.schema.json +11 -2
- package/skills/tech-lead/schemas/work-result.schema.json +1 -1
- package/skills/tech-lead/workflows/shapeup-run.js +1313 -750
- package/hooks/anti-rationalization.mjs +0 -238
- package/hooks/compact-snapshot.mjs +0 -47
- package/hooks/gate-deadline.mjs +0 -147
- package/hooks/gate-l2.mjs +0 -161
- package/hooks/session-rehydrate.mjs +0 -108
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
- package/skills/spec-evaluator/README.md +0 -93
- package/skills/tech-lead/README.md +0 -71
- package/skills/tech-lead/references/delegation.md +0 -261
- package/skills/tech-lead/references/hard-rules.md +0 -34
- package/skills/tech-lead/references/invocation.md +0 -45
- package/skills/tech-lead/references/ledger-schema.md +0 -213
- package/skills/tech-lead/references/round-protocol.md +0 -234
- package/skills/tech-lead/references/state-model.md +0 -66
- package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
- package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
- package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
- package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
- package/skills/translator/README.md +0 -66
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Anti-rationalization — advisory Stop hook (v1.2).
|
|
3
|
-
//
|
|
4
|
-
// When the session's final message claims completion ("done", "all tests pass", "ready to
|
|
5
|
-
// ship") while the harness's own mechanical facts disagree (unfinished board tasks, a red T0
|
|
6
|
-
// verdict), this hook says so — to the user, out loud, with the facts.
|
|
7
|
-
//
|
|
8
|
-
// ADVISORY ONLY, by architectural invariant: "QA is a level-up, not a gate." This hook exits
|
|
9
|
-
// 0 always and emits at most { systemMessage } — never { decision: "block" }, never exit 2.
|
|
10
|
-
// A blocking Stop hook would be a second gate behind the single judge (spec-evaluator).
|
|
11
|
-
//
|
|
12
|
-
// Harness-scoped: fires only when a run is actually active (.shapeup/active-scope
|
|
13
|
-
// exists, or some .shapeup/*/harness-run.md is mid-build). An always-on nag on
|
|
14
|
-
// non-harness work is exactly the annoyance that gets hooks disabled.
|
|
15
|
-
//
|
|
16
|
-
// Contract: Stop stdin JSON { cwd, stop_hook_active, last_assistant_message, transcript_path }.
|
|
17
|
-
|
|
18
|
-
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
19
|
-
import { join } from "node:path";
|
|
20
|
-
import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
|
|
21
|
-
import { localDir, localRoot, activeScope } from "../skills/tech-lead/scripts/lib/paths.mjs";
|
|
22
|
-
|
|
23
|
-
import { runHook, readStdin, settle } from "./lib/decision.mjs";
|
|
24
|
-
|
|
25
|
-
function readJSON(p) {
|
|
26
|
-
try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function frontmatter(text) {
|
|
30
|
-
const m = /^---\n([\s\S]*?)\n---/.exec(text || "");
|
|
31
|
-
if (!m) return {};
|
|
32
|
-
const fm = {};
|
|
33
|
-
for (const line of m[1].split("\n")) {
|
|
34
|
-
const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line.trim());
|
|
35
|
-
if (kv) fm[kv[1]] = kv[2].replace(/^['"]|['"]$/g, "");
|
|
36
|
-
}
|
|
37
|
-
return fm;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const MID_RUN = new Set(["orienting", "mapping", "building", "evaluating"]);
|
|
41
|
-
|
|
42
|
-
/** The active harness slug, or null when no run is in progress. */
|
|
43
|
-
export function activeSlug(cwd) {
|
|
44
|
-
const pointer = readJSON(activeScope(cwd));
|
|
45
|
-
if (pointer?.slug) return pointer.slug;
|
|
46
|
-
const root = localDir(cwd);
|
|
47
|
-
if (!existsSync(root)) return null;
|
|
48
|
-
for (const entry of readdirSync(root)) {
|
|
49
|
-
const runPath = join(root, entry, "harness-run.md");
|
|
50
|
-
if (!existsSync(runPath)) continue;
|
|
51
|
-
try {
|
|
52
|
-
const fm = frontmatter(readFileSync(runPath, "utf8"));
|
|
53
|
-
if (MID_RUN.has(fm.status)) return entry;
|
|
54
|
-
} catch { /* unreadable run file → not this one */ }
|
|
55
|
-
}
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Does the text claim the work is finished — or promise that it is about to be?
|
|
61
|
-
*
|
|
62
|
-
* The past-tense half is the original detector. The future-tense half was added after a transcript
|
|
63
|
-
* this hook should have caught and structurally could not: the session ended on "The tech-lead
|
|
64
|
-
* skill is orchestrating the full harness. It will: 1…".
|
|
65
|
-
* A promise at the END of a session is a completion claim wearing different grammar — the run is
|
|
66
|
-
* over, and the thing it says it will do is never going to happen. Matching only past tense meant
|
|
67
|
-
* the emptiest failures were the least detectable, which is backwards.
|
|
68
|
-
*
|
|
69
|
-
* (The zero-work case — dispatched and never started — belongs to `gate-zerowork.mjs`, which
|
|
70
|
-
* blocks on a mechanical absence rather than on phrasing. This one covers narration INSIDE a run
|
|
71
|
-
* that did start.)
|
|
72
|
-
*/
|
|
73
|
-
const FUTURE_CLAIM = /\b(will (?:now )?(?:run|orchestrate|execute|proceed|begin|start)|is orchestrating|I'?ll (?:now )?(?:run|start|begin|orchestrate)|going to (?:run|orchestrate|execute))\b/i;
|
|
74
|
-
|
|
75
|
-
export function detectClaim(text) {
|
|
76
|
-
if (!text || typeof text !== "string") return null;
|
|
77
|
-
const past = /\b(done|complete(?:d)?|finished|shipped|ready to ship|all (?:tests|tasks) pass(?:ing|ed)?|everything works)\b/i.exec(text);
|
|
78
|
-
if (past) return past[1];
|
|
79
|
-
const future = FUTURE_CLAIM.exec(text);
|
|
80
|
-
return future ? future[0] : null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Is this claim a promise about work still to come, rather than an assertion that work is done?
|
|
85
|
-
* The two are checked against DIFFERENT facts — see contradictions().
|
|
86
|
-
* @param {string} claim - The matched claim fragment from detectClaim().
|
|
87
|
-
* @returns {boolean} True for a future-tense promise.
|
|
88
|
-
*/
|
|
89
|
-
export function isFutureClaim(claim) {
|
|
90
|
-
return typeof claim === "string" && FUTURE_CLAIM.test(claim);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Read-only mechanical facts about the run — the evidence the claim is checked against. */
|
|
94
|
-
export function gatherFacts(cwd, slug) {
|
|
95
|
-
const root = localRoot(cwd, slug);
|
|
96
|
-
const facts = { unfinished: [], red_t0: null, run_status: null, final_verdict: null };
|
|
97
|
-
|
|
98
|
-
const tasksDir = join(root, "tasks");
|
|
99
|
-
if (existsSync(tasksDir)) {
|
|
100
|
-
for (const f of readdirSync(tasksDir)) {
|
|
101
|
-
if (!/^TASK-.*\.md$/.test(f)) continue;
|
|
102
|
-
try {
|
|
103
|
-
const fm = frontmatter(readFileSync(join(tasksDir, f), "utf8"));
|
|
104
|
-
if (fm.status && fm.status !== "done") facts.unfinished.push(fm.id || f.replace(/\.md$/, ""));
|
|
105
|
-
} catch { /* unreadable task file → no fact */ }
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const verdictsDir = join(root, "t0", "verdicts");
|
|
110
|
-
if (existsSync(verdictsDir)) {
|
|
111
|
-
let latest = null;
|
|
112
|
-
for (const f of readdirSync(verdictsDir)) {
|
|
113
|
-
const m = /^r(\d+)-a(\d+)\.json$/.exec(f);
|
|
114
|
-
if (!m) continue;
|
|
115
|
-
const key = [Number(m[1]), Number(m[2])];
|
|
116
|
-
if (!latest || key[0] > latest.key[0] || (key[0] === latest.key[0] && key[1] > latest.key[1])) {
|
|
117
|
-
latest = { key, file: f };
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
if (latest) {
|
|
121
|
-
const verdict = readJSON(join(verdictsDir, latest.file));
|
|
122
|
-
if (verdict?.overall === "red") facts.red_t0 = latest.file.replace(/\.json$/, "");
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const runPath = join(root, "harness-run.md");
|
|
127
|
-
if (existsSync(runPath)) {
|
|
128
|
-
try {
|
|
129
|
-
const fm = frontmatter(readFileSync(runPath, "utf8"));
|
|
130
|
-
facts.run_status = fm.status || null;
|
|
131
|
-
facts.final_verdict = fm.final_verdict || null;
|
|
132
|
-
} catch { /* no fact */ }
|
|
133
|
-
}
|
|
134
|
-
return facts;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* The facts that contradict a claim, as human-readable fragments.
|
|
139
|
-
*
|
|
140
|
-
* TENSE DECIDES WHICH FACTS COUNT, and getting this wrong is what makes a hook get disabled.
|
|
141
|
-
* An unfinished board and a red T0 both contradict "it is done". NEITHER of
|
|
142
|
-
* them contradict "I am about to run the evaluator" — they are that sentence's PREMISE. Checking
|
|
143
|
-
* a promise against them meant every healthy build round ended with the hook announcing that the
|
|
144
|
-
* turn's own plan "disagrees with the facts", naming as evidence the very work the plan exists to
|
|
145
|
-
* do. This file's header calls an always-on nag "exactly the annoyance that gets hooks disabled",
|
|
146
|
-
* and the sibling future-tense detector in gate-zerowork.mjs is marked advisory for the same
|
|
147
|
-
* reason. So a promise is contradicted by one thing only: a run that is already CLOSED, where the
|
|
148
|
-
* promised work is never going to happen.
|
|
149
|
-
*
|
|
150
|
-
* The zero-work case that motivated future-tense detection — a session that dispatches, narrates
|
|
151
|
-
* "it will: 1…", and stops having written nothing — is gate-zerowork.mjs's, which blocks on a
|
|
152
|
-
* mechanical absence rather than on phrasing. It is not this hook's to double-report.
|
|
153
|
-
*
|
|
154
|
-
* @param {string} claim - The matched claim fragment from detectClaim().
|
|
155
|
-
* @param {object} facts - Mechanical facts from gatherFacts().
|
|
156
|
-
* @returns {string[]} Human-readable contradiction fragments; empty means the claim stands.
|
|
157
|
-
*/
|
|
158
|
-
export function contradictions(claim, facts) {
|
|
159
|
-
const out = [];
|
|
160
|
-
|
|
161
|
-
if (isFutureClaim(claim)) {
|
|
162
|
-
if (facts.run_status === "shipped" || facts.run_status === "escalated") {
|
|
163
|
-
out.push(`run status is already "${facts.run_status}" — the run is closed, so the promised work cannot happen in it`);
|
|
164
|
-
}
|
|
165
|
-
return out;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (facts.unfinished.length > 0) {
|
|
169
|
-
const named = facts.unfinished.slice(0, 5).join(", ");
|
|
170
|
-
out.push(`${facts.unfinished.length} board task(s) not done (${named}${facts.unfinished.length > 5 ? ", …" : ""})`);
|
|
171
|
-
}
|
|
172
|
-
if (facts.red_t0) out.push(`latest T0 verdict ${facts.red_t0} is red`);
|
|
173
|
-
if (facts.final_verdict === "fail") out.push("harness-run records final_verdict: fail");
|
|
174
|
-
if (/ship/i.test(claim || "") && MID_RUN.has(facts.run_status)) out.push(`run status is still "${facts.run_status}"`);
|
|
175
|
-
return out;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function lastAssistantFromTranscript(transcriptPath) {
|
|
179
|
-
try {
|
|
180
|
-
if (!transcriptPath || !existsSync(transcriptPath)) return null;
|
|
181
|
-
if (statSync(transcriptPath).size > 20 * 1024 * 1024) return null; // stay cheap
|
|
182
|
-
const lines = readFileSync(transcriptPath, "utf8").trim().split("\n");
|
|
183
|
-
for (let i = lines.length - 1; i >= 0 && i >= lines.length - 64; i--) {
|
|
184
|
-
let entry;
|
|
185
|
-
try { entry = JSON.parse(lines[i]); } catch { continue; }
|
|
186
|
-
const msg = entry?.message;
|
|
187
|
-
if (entry?.type === "assistant" || msg?.role === "assistant") {
|
|
188
|
-
const content = msg?.content ?? entry?.content;
|
|
189
|
-
if (typeof content === "string") return content;
|
|
190
|
-
if (Array.isArray(content)) {
|
|
191
|
-
return content.filter((b) => b?.type === "text").map((b) => b.text).join("\n");
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
} catch { /* unreadable transcript → no claim source */ }
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async function main() {
|
|
200
|
-
await runHook("anti-rationalization", async () => {
|
|
201
|
-
const raw = await readStdin();
|
|
202
|
-
let p;
|
|
203
|
-
/** Stay silent — with the reason on the record (hooks/lib/decision.mjs). */
|
|
204
|
-
const defer = (reason, rule) => settle({ verdict: "allow", event: "Stop", cwd: p?.cwd, reason, rule });
|
|
205
|
-
try { p = JSON.parse(raw || "{}"); }
|
|
206
|
-
catch (e) { settle({ verdict: "error", event: "Stop", reason: `unparseable payload: ${e.message}` }); }
|
|
207
|
-
|
|
208
|
-
if (p.stop_hook_active) defer("stop_hook_active — never participate in a stop-hook loop", "loop-guard");
|
|
209
|
-
|
|
210
|
-
const cwd = p.cwd || process.cwd();
|
|
211
|
-
const slug = activeSlug(cwd);
|
|
212
|
-
if (!slug) defer("no active run — nothing to check the claim against", "no-run");
|
|
213
|
-
|
|
214
|
-
const message = typeof p.last_assistant_message === "string" && p.last_assistant_message
|
|
215
|
-
? p.last_assistant_message
|
|
216
|
-
: lastAssistantFromTranscript(p.transcript_path);
|
|
217
|
-
const claim = detectClaim(message);
|
|
218
|
-
if (!claim) defer("the final message makes no completion claim", "no-claim");
|
|
219
|
-
|
|
220
|
-
const facts = gatherFacts(cwd, slug);
|
|
221
|
-
const contra = contradictions(claim, facts);
|
|
222
|
-
if (contra.length === 0) defer(`claim "${claim}" agrees with the run facts — inspected and permitted`, "claim-supported");
|
|
223
|
-
|
|
224
|
-
return {
|
|
225
|
-
verdict: "allow", event: "Stop", cwd, subject: slug, rule: "claim-contradicted", emit: true,
|
|
226
|
-
reason: `claim "${claim}" contradicted by ${contra.length} fact(s) — advisory note emitted, not a block`,
|
|
227
|
-
payload: {
|
|
228
|
-
systemMessage:
|
|
229
|
-
`anti-rationalization (advisory): the last message claims "${claim}" but run "${slug}" facts disagree — ` +
|
|
230
|
-
`${contra.join("; ")}. Not blocking (QA is a level-up, not a gate) — verify against the board and T0 before shipping.`,
|
|
231
|
-
},
|
|
232
|
-
};
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (isMain(import.meta.url)) {
|
|
237
|
-
main();
|
|
238
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Compact snapshot — PreCompact hook (v1.2).
|
|
3
|
-
//
|
|
4
|
-
// PreCompact provably CANNOT inject context (no additionalContext/systemMessage channel), so
|
|
5
|
-
// this hook is a pure side effect: when a harness run is mid-flight, freeze a RunSnapshot to
|
|
6
|
-
// .shapeup/<slug>/run-snapshot.json before the conversation is compacted. It is the
|
|
7
|
-
// audit anchor ("what did the files say the moment the summary was made?") and the fallback
|
|
8
|
-
// hooks/session-rehydrate.mjs reads if live derivation ever throws post-compact.
|
|
9
|
-
//
|
|
10
|
-
// Never blocks compaction: the whole body is fail-open, exit 0 always.
|
|
11
|
-
//
|
|
12
|
-
// Contract: PreCompact stdin JSON { cwd, trigger: "manual"|"auto" }.
|
|
13
|
-
|
|
14
|
-
// RECEIPTS (v1.5). This hook is the sharpest case for `hooks/lib/decision.mjs`: it is routinely
|
|
15
|
-
// scored `Unfired` — 0 `PreCompact` events observed — and that score is UNOBTAINABLE, because
|
|
16
|
-
// "never had to fire" and "never ran" produce identical evidence (exit 0, no output). With a
|
|
17
|
-
// decision row per invocation the two become separable facts.
|
|
18
|
-
|
|
19
|
-
import { deriveSnapshot, writeSnapshot } from "../skills/tech-lead/scripts/run-snapshot.mjs";
|
|
20
|
-
import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
|
|
21
|
-
import { runHook, readStdin } from "./lib/decision.mjs";
|
|
22
|
-
|
|
23
|
-
async function main() {
|
|
24
|
-
await runHook("compact-snapshot", async () => {
|
|
25
|
-
const raw = await readStdin();
|
|
26
|
-
let p;
|
|
27
|
-
try { p = JSON.parse(raw || "{}"); }
|
|
28
|
-
catch (e) { return { verdict: "error", event: "PreCompact", reason: `unparseable payload: ${e.message}` }; }
|
|
29
|
-
const cwd = p.cwd || process.cwd();
|
|
30
|
-
try {
|
|
31
|
-
const snapshot = deriveSnapshot(cwd);
|
|
32
|
-
if (!snapshot) return { verdict: "allow", event: "PreCompact", cwd, reason: "no active run — nothing to freeze" };
|
|
33
|
-
writeSnapshot(cwd, snapshot);
|
|
34
|
-
return {
|
|
35
|
-
verdict: "allow", event: "PreCompact", cwd, rule: "snapshot-written",
|
|
36
|
-
subject: snapshot.slug ?? null, reason: "RunSnapshot frozen before compaction",
|
|
37
|
-
};
|
|
38
|
-
} catch (e) {
|
|
39
|
-
// A snapshot failure must never block compaction — but it is now a fact rather than silence.
|
|
40
|
-
return { verdict: "error", event: "PreCompact", cwd, reason: `snapshot failed: ${e.message}` };
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (isMain(import.meta.url)) {
|
|
46
|
-
main();
|
|
47
|
-
}
|
package/hooks/gate-deadline.mjs
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// GATE D — DEADLINE. PreToolUse hook. The wall-clock breaker, enforced rather than requested.
|
|
3
|
-
//
|
|
4
|
-
// WHY THIS EXISTS (and it corrects an earlier diagnosis).
|
|
5
|
-
//
|
|
6
|
-
// A run killed at an external time cap looks exactly like a stall from outside: no acceptance, no
|
|
7
|
-
// verdict, nothing to show. The natural reading is "it hung at a gate". Often the opposite is
|
|
8
|
-
// true — the run was working, steadily, and was still working when the clock ran out.
|
|
9
|
-
//
|
|
10
|
-
// Both existing breakers count EVENTS (`round_budget` per round, `attempt_budget` per T0
|
|
11
|
-
// attempt), so neither can observe that a single round has been running for half an hour. A run
|
|
12
|
-
// can burn its whole budget with both breakers untouched.
|
|
13
|
-
//
|
|
14
|
-
// The cost of that is not the missing verdict. It is that an externally killed run ships NOTHING, not
|
|
15
|
-
// even the scopes that were already green. A breaker that trips from the inside routes to GATE H
|
|
16
|
-
// instead: census, baseline comparison, ship the part that works. Same clock, different ending.
|
|
17
|
-
//
|
|
18
|
-
// WHAT IT DENIES, AND WHAT IT DELIBERATELY DOES NOT. Past the deadline this denies dispatches
|
|
19
|
-
// that START NEW WORK — `task-executor`. It never denies `spec-evaluator`, `scope-hammer` or
|
|
20
|
-
// `qa-edge-hunter`, because a run past its deadline must still be able to
|
|
21
|
-
// judge, hammer and close. A breaker that also blocked the exit would strand the run with green
|
|
22
|
-
// scopes it could not ship, which is the failure it exists to prevent.
|
|
23
|
-
//
|
|
24
|
-
// Non-regression: with no `wall_clock_budget_s` in the receipt (the default) this defers
|
|
25
|
-
// instantly. Everything ambiguous — no receipt, unparseable payload, missing timestamp — fails
|
|
26
|
-
// open, because a gate that blocks legitimate runs gets disabled, and a disabled gate enforces
|
|
27
|
-
// nothing.
|
|
28
|
-
//
|
|
29
|
-
// Contract: PreToolUse stdin JSON { tool_name, tool_input:{skill_name|skill, skill_args|args}, cwd }.
|
|
30
|
-
// Deny via { hookSpecificOutput: { hookEventName, permissionDecision:"deny", permissionDecisionReason } }.
|
|
31
|
-
|
|
32
|
-
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
|
|
33
|
-
import { join } from "node:path";
|
|
34
|
-
import { evaluateBudget } from "../skills/tech-lead/scripts/budget-check.mjs";
|
|
35
|
-
import { runHook, readStdin, settle } from "./lib/decision.mjs";
|
|
36
|
-
import { localDir, localRoot } from "../skills/tech-lead/scripts/lib/paths.mjs";
|
|
37
|
-
|
|
38
|
-
await runHook("gate-deadline", async () => {
|
|
39
|
-
/** Fail-open, with the reason on the record (hooks/lib/decision.mjs). */
|
|
40
|
-
const defer = (reason, rule) => settle({ verdict: "allow", event: "PreToolUse", tool: p?.tool_name ?? null, reason, rule });
|
|
41
|
-
|
|
42
|
-
// Only these start new build work. Everything else in the harness is how a run ENDS, and must
|
|
43
|
-
// stay reachable after the deadline.
|
|
44
|
-
const STARTS_NEW_WORK = new Set(["task-executor"]);
|
|
45
|
-
|
|
46
|
-
const raw = await readStdin();
|
|
47
|
-
|
|
48
|
-
let p;
|
|
49
|
-
try { p = JSON.parse(raw || "{}"); }
|
|
50
|
-
catch (e) { settle({ verdict: "error", event: "PreToolUse", reason: `unparseable payload: ${e.message}` }); }
|
|
51
|
-
|
|
52
|
-
if (p.tool_name !== "Skill") defer(`not a Skill call (${p.tool_name ?? "no tool_name"}) — out of scope`);
|
|
53
|
-
|
|
54
|
-
const skillRaw = p.tool_input?.skill_name ?? p.tool_input?.skill ?? "";
|
|
55
|
-
const skill = String(skillRaw).split(":").pop();
|
|
56
|
-
if (!STARTS_NEW_WORK.has(skill)) defer(`Skill(${skill || "?"}) does not start new build work — a run past its deadline must still be able to close`, "not-new-work");
|
|
57
|
-
|
|
58
|
-
const cwd = p.cwd || process.cwd();
|
|
59
|
-
|
|
60
|
-
/** The active run's receipt, or null. Mirrors budget-check.mjs's discovery. */
|
|
61
|
-
function findRun() {
|
|
62
|
-
const root = localDir(cwd);
|
|
63
|
-
if (!existsSync(root)) return null;
|
|
64
|
-
let slug = null;
|
|
65
|
-
try { slug = JSON.parse(readFileSync(join(root, "active-scope"), "utf8"))?.slug || null; } catch { /* scan instead */ }
|
|
66
|
-
const candidates = slug ? [slug] : (() => { try { return readdirSync(root); } catch { return []; } })();
|
|
67
|
-
for (const entry of candidates) {
|
|
68
|
-
const f = join(root, entry, "receipt.json");
|
|
69
|
-
if (!existsSync(f)) continue;
|
|
70
|
-
try { return { slug: entry, receipt: JSON.parse(readFileSync(f, "utf8")) }; } catch { /* not a run */ }
|
|
71
|
-
}
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const run = findRun();
|
|
76
|
-
if (!run) defer("no run receipt — nothing to time", "no-run");
|
|
77
|
-
|
|
78
|
-
const budget = Number(run.receipt.config?.wall_clock_budget_s || 0) || null;
|
|
79
|
-
if (!budget) defer("no wall_clock_budget_s in the receipt — breaker off (the default)", "breaker-off");
|
|
80
|
-
|
|
81
|
-
const startedAt = Date.parse(run.receipt.started_at || "");
|
|
82
|
-
if (Number.isNaN(startedAt)) defer("receipt has no parseable started_at — no clock, no claim", "no-clock");
|
|
83
|
-
|
|
84
|
-
const state = evaluateBudget((Date.now() - startedAt) / 1000, budget);
|
|
85
|
-
if (state.status !== "trip") defer(`within budget (${state.status}) — inspected and permitted`, "within-budget");
|
|
86
|
-
|
|
87
|
-
// STICKY TRIP. The first version of this hook denied and said what to do instead; the orchestrator
|
|
88
|
-
// then re-dispatched task-executor and was denied again, THIRTEEN times in one measured run. Each
|
|
89
|
-
// denial costs a turn out of the budget the breaker exists to protect, so a re-triable denial
|
|
90
|
-
// actively makes the failure it is preventing worse.
|
|
91
|
-
//
|
|
92
|
-
// The fix is to make the trip a FACT rather than an event: record it once, count the retries, and
|
|
93
|
-
// escalate the language so the second denial cannot read like the first. A model that ignored
|
|
94
|
-
// "route to GATE H" phrased gently is not helped by receiving the identical text again.
|
|
95
|
-
const tripPath = join(localRoot(cwd, run.slug), "deadline-tripped.json");
|
|
96
|
-
let denials = 0;
|
|
97
|
-
try { denials = JSON.parse(readFileSync(tripPath, "utf8"))?.denials || 0; } catch { /* first trip */ }
|
|
98
|
-
denials += 1;
|
|
99
|
-
try {
|
|
100
|
-
writeFileSync(tripPath, JSON.stringify({
|
|
101
|
-
type: "deadline-trip", slug: run.slug, denials,
|
|
102
|
-
first_tripped_at: denials === 1 ? new Date().toISOString() : undefined,
|
|
103
|
-
elapsed_s: state.elapsed_s, budget_s: budget,
|
|
104
|
-
}, null, 2) + "\n");
|
|
105
|
-
} catch { /* recording is best-effort; the denial still stands */ }
|
|
106
|
-
|
|
107
|
-
const repeat = denials > 1
|
|
108
|
-
? [
|
|
109
|
-
"",
|
|
110
|
-
`⛔ THIS IS DENIAL #${denials}. Re-dispatching task-executor will be denied every time, and each`,
|
|
111
|
-
"attempt spends wall-clock the run does not have. STOP TRYING TO BUILD. The only forward move is",
|
|
112
|
-
"GATE H, below. If you have already dispatched scope-hammer, wait for it and close the run.",
|
|
113
|
-
]
|
|
114
|
-
: [];
|
|
115
|
-
|
|
116
|
-
const reason = [
|
|
117
|
-
`✋ GATE D — DEADLINE BREAKER. Run "${run.slug}" has used ${state.elapsed_s}s of its ${budget}s wall-clock budget.`,
|
|
118
|
-
"",
|
|
119
|
-
"Dispatching task-executor would start work this run cannot finish. Denied — not because the work",
|
|
120
|
-
"is wrong, but because a run that is killed from outside ships nothing, including the scopes that",
|
|
121
|
-
"are already green.",
|
|
122
|
-
"",
|
|
123
|
-
"Go to GATE H instead:",
|
|
124
|
-
"",
|
|
125
|
-
" Skill(scope-hammer, \"--breaker deadline --feature " + run.slug + "\")",
|
|
126
|
-
"",
|
|
127
|
-
"Scope-hammer runs the must-have census, compares the shippable subset against the BASELINE (never",
|
|
128
|
-
"against the ideal), and produces a cut list. Ship what is green; the rest becomes a raw idea for",
|
|
129
|
-
"the next Betting Table.",
|
|
130
|
-
"",
|
|
131
|
-
"spec-evaluator, scope-hammer and qa-edge-hunter are all still permitted — a run past its",
|
|
132
|
-
"deadline must still be able to judge, hammer, and close.",
|
|
133
|
-
...repeat,
|
|
134
|
-
].join("\n");
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
verdict: "deny", event: "PreToolUse", tool: "Skill", subject: skill, rule: "deadline-tripped",
|
|
138
|
-
reason: `wall-clock budget of ${budget}s exhausted — routing to GATE H`,
|
|
139
|
-
payload: {
|
|
140
|
-
hookSpecificOutput: {
|
|
141
|
-
hookEventName: "PreToolUse",
|
|
142
|
-
permissionDecision: "deny",
|
|
143
|
-
permissionDecisionReason: reason,
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
};
|
|
147
|
-
});
|
package/hooks/gate-l2.mjs
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// GATE L2 — board-green check on the once-per-round EVAL. PreToolUse hook.
|
|
3
|
-
//
|
|
4
|
-
// ADVISORY SINCE ADR-0001, and the downgrade was a deliberate product decision rather than a
|
|
5
|
-
// retreat. This hook used to hard-DENY the EVAL delegation (tech-lead → spec-evaluator) while the
|
|
6
|
-
// task board was not green. It now permits the dispatch and says what it found.
|
|
7
|
-
//
|
|
8
|
-
// WHY. The board is LOCAL and per-machine (`.shapeup/<slug>/tasks/`), and the harness runs only on
|
|
9
|
-
// the machine that invoked it — a teammate reads the committed design, they never resume someone
|
|
10
|
-
// else's run. So this gate never protected a team boundary; it protected the operator from their
|
|
11
|
-
// own agent, at the cost of denying a call the operator had asked for. The project chose the
|
|
12
|
-
// signal over the denial.
|
|
13
|
-
//
|
|
14
|
-
// WHAT IS LOST, stated plainly: nothing now mechanically prevents an EVAL on a half-green board.
|
|
15
|
-
// That is a defect this project has measured before (a run reached EVAL with 16/20 task files
|
|
16
|
-
// still `status: ready`). The warning names the offending tasks; it cannot stop the call.
|
|
17
|
-
//
|
|
18
|
-
// WHAT IS RETAINED: the detection is unchanged. Both independent reads still run — per-task
|
|
19
|
-
// frontmatter AND the board table — and the verdict is recorded as `warn`, which is its own row
|
|
20
|
-
// in `decisions.jsonl` precisely so "permitted because green" and "permitted despite not green"
|
|
21
|
-
// never collapse into the same fact (see hooks/lib/decision.mjs).
|
|
22
|
-
//
|
|
23
|
-
// Design (deliberate, conservative):
|
|
24
|
-
// • Scope — only ever looks at `Skill` → `spec-evaluator` in ROUND mode (`--single-pass`/
|
|
25
|
-
// `--feature`, no `--task`). A per-task eval (`--task TASK-NNN`) grades one task and is not
|
|
26
|
-
// in scope: the board-green rule is about the round. Anything else defers instantly.
|
|
27
|
-
// • Fail-OPEN whenever there is nothing to verify (no --spec, no board file, unparseable input,
|
|
28
|
-
// zero discoverable tasks) — it only speaks when it can prove the board is partial.
|
|
29
|
-
//
|
|
30
|
-
// Contract: PreToolUse stdin JSON { tool_name, tool_input:{skill_name, skill_args}, cwd, ... }.
|
|
31
|
-
// Advises via { systemMessage } — never { permissionDecision: "deny" }.
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
// RECEIPTS (v1.5). Every `defer()` below now names WHICH of its fail-open conditions was met, and
|
|
35
|
-
// records it (hooks/lib/decision.mjs). The fail-open direction is unchanged and defended above;
|
|
36
|
-
// what changes is that "read the board and deferred" is no longer byte-identical to "did not run".
|
|
37
|
-
|
|
38
|
-
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
39
|
-
import { resolve, join, basename, dirname } from "node:path";
|
|
40
|
-
import { runHook, readStdin, settle } from "./lib/decision.mjs";
|
|
41
|
-
// Aliased: the body binds a local `tasksDir` to whichever candidate actually holds a board.
|
|
42
|
-
import { tasksDir as localTasksDir } from "../skills/tech-lead/scripts/lib/paths.mjs";
|
|
43
|
-
|
|
44
|
-
await runHook("gate-l2", async () => {
|
|
45
|
-
/** Allow normal permission flow — with the reason on the record. */
|
|
46
|
-
const defer = (reason, rule) => settle({ verdict: "allow", event: "PreToolUse", tool: p?.tool_name ?? null, reason, rule });
|
|
47
|
-
|
|
48
|
-
// 1. Read & parse the PreToolUse payload.
|
|
49
|
-
const raw = await readStdin();
|
|
50
|
-
let p;
|
|
51
|
-
try { p = JSON.parse(raw || "{}"); }
|
|
52
|
-
catch (e) { settle({ verdict: "error", event: "PreToolUse", reason: `unparseable payload: ${e.message}` }); }
|
|
53
|
-
|
|
54
|
-
// 2. Only Skill → spec-evaluator is in scope.
|
|
55
|
-
if (p.tool_name !== "Skill") defer(`not a Skill call (${p.tool_name ?? "no tool_name"}) — out of scope`);
|
|
56
|
-
const skill = p.tool_input?.skill_name || "";
|
|
57
|
-
const args = p.tool_input?.skill_args || "";
|
|
58
|
-
if (skill !== "spec-evaluator") defer(`Skill(${skill || "?"}) is not the judge — out of scope`);
|
|
59
|
-
|
|
60
|
-
// 3. Round mode only. Two shapes qualify:
|
|
61
|
-
// (a) legacy flags: --single-pass / --feature, without --task (per-task eval is not gated);
|
|
62
|
-
// (b) pure-skill envelope (v1.0): --order <WorkOrder> whose operation is "evaluate" — the
|
|
63
|
-
// round dispatch tech-lead compiles. Slug/spec come from the order itself.
|
|
64
|
-
let orderSlug = null, orderSpec = null;
|
|
65
|
-
const om = args.match(/--order(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
|
|
66
|
-
if (om) {
|
|
67
|
-
try {
|
|
68
|
-
const order = JSON.parse(readFileSync(resolve(p.cwd || process.cwd(), om[1] || om[2] || om[3]), "utf8"));
|
|
69
|
-
if (order.worker === "spec-evaluator" && (order.operation || "evaluate") === "evaluate") {
|
|
70
|
-
orderSlug = String(order.order_id || "").split("/")[0] || order.payload?.feature || null;
|
|
71
|
-
orderSpec = order.payload?.spec_folder || null;
|
|
72
|
-
} else defer("order is for another job — not the round EVAL", "--order"); // not the round EVAL
|
|
73
|
-
} catch (e) {
|
|
74
|
-
if (e?.name === "HookDecision") throw e;
|
|
75
|
-
/* unreadable order → validate-envelope denies it; nothing to gate here */
|
|
76
|
-
defer(`order unreadable (${e.message}) — validate-envelope owns that denial`, "--order");
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
const hasTask = /--task(?:\s|=)/.test(args);
|
|
80
|
-
const roundMode = orderSlug !== null || (!hasTask && (/--single-pass\b/.test(args) || /--feature(?:\s|=)/.test(args)));
|
|
81
|
-
if (!roundMode) defer(hasTask ? "per-task eval — the board-green rule is about the round" : "not a round dispatch", "round-mode");
|
|
82
|
-
|
|
83
|
-
// 4. Locate the board. Since v0.4.0 (Local Tasks Architecture) it lives under the LOCAL
|
|
84
|
-
// gitignored root `.shapeup/<slug>/tasks/`, NOT the committed spec dir — resolving only
|
|
85
|
-
// `<spec>/tasks/` made this hook silently fail-open on every v0.4.0+ run — EVAL proceeded with
|
|
86
|
-
// most of the board still `status: ready`.
|
|
87
|
-
// <slug> comes from --feature (the round invocation always carries it: tech-lead's eval plan
|
|
88
|
-
// is `--spec <path> --feature <slug> --single-pass`), falling back to the spec-path
|
|
89
|
-
// convention shapeup/<slug>/spec → parent dir name. `<spec>/tasks/` is kept as the
|
|
90
|
-
// legacy fallback so pre-v0.4.0 boards stay gated. A missing board on every candidate is a
|
|
91
|
-
// legitimate state — spec-evaluator v0.9 grades from the committed spec on machines that
|
|
92
|
-
// never generated a local board — so it stays fail-open.
|
|
93
|
-
const m = args.match(/--spec(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
|
|
94
|
-
const specPath = orderSpec || (m ? (m[1] || m[2] || m[3]) : null);
|
|
95
|
-
if (!specPath && !orderSlug) defer("no --spec and no order slug — nothing to locate a board from", "no-spec");
|
|
96
|
-
const cwd = p.cwd || process.cwd();
|
|
97
|
-
const specDir = specPath ? resolve(cwd, specPath) : null;
|
|
98
|
-
const fm = args.match(/--feature(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
|
|
99
|
-
const slug = orderSlug || (fm && (fm[1] || fm[2] || fm[3])) ||
|
|
100
|
-
(basename(specDir) === "spec" ? basename(dirname(specDir)) : basename(specDir));
|
|
101
|
-
const tasksDir = [localTasksDir(cwd, slug), ...(specDir ? [join(specDir, "tasks")] : [])]
|
|
102
|
-
.find((d) => existsSync(join(d, "_index.md")));
|
|
103
|
-
// no board on this machine → nothing to verify, don't break the run
|
|
104
|
-
if (!tasksDir) defer(`no board for "${slug}" on this machine — nothing to verify`, "no-board");
|
|
105
|
-
const board = join(tasksDir, "_index.md");
|
|
106
|
-
|
|
107
|
-
// 5. Assert the board is green, from two independent reads; fail-closed if EITHER shows unfinished
|
|
108
|
-
// work. (a) per-task frontmatter `status:` (the authoritative field); (b) the board table's
|
|
109
|
-
// status cell (what GATE L2 literally reads). Done = `status: done` / a ✅ in the row.
|
|
110
|
-
const DONE_FRONTMATTER = /^status:\s*done\s*$/im;
|
|
111
|
-
const NOT_DONE_MARK = /⬜|🔄|🚫|\b(ready|in-progress|blocked)\b/i;
|
|
112
|
-
const unfinished = new Set();
|
|
113
|
-
|
|
114
|
-
// (a) task files
|
|
115
|
-
let sawTaskFile = false;
|
|
116
|
-
try {
|
|
117
|
-
for (const f of readdirSync(tasksDir)) {
|
|
118
|
-
if (!/^TASK-[\w.-]+\.md$/i.test(f)) continue; // skip _index.md and non-task files
|
|
119
|
-
sawTaskFile = true;
|
|
120
|
-
const body = readFileSync(join(tasksDir, f), "utf8");
|
|
121
|
-
const fm = body.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
122
|
-
const id = (body.match(/^id:\s*(TASK-[\w.-]+)/im) || [])[1] || f.replace(/\.md$/, "");
|
|
123
|
-
if (!fm || !DONE_FRONTMATTER.test(fm[1])) unfinished.add(id);
|
|
124
|
-
}
|
|
125
|
-
} catch { /* unreadable tasks dir → fall back to board table below */ }
|
|
126
|
-
|
|
127
|
-
// (b) board table rows — any row naming a task whose status cell is not ✅/done.
|
|
128
|
-
let sawBoardRow = false;
|
|
129
|
-
for (const line of readFileSync(board, "utf8").split(/\r?\n/)) {
|
|
130
|
-
const idm = line.match(/\bTASK-[\w.-]+/);
|
|
131
|
-
if (!idm || !line.includes("|")) continue;
|
|
132
|
-
sawBoardRow = true;
|
|
133
|
-
const done = line.includes("✅") || /\bdone\b/i.test(line);
|
|
134
|
-
if (!done || NOT_DONE_MARK.test(line)) unfinished.add(idm[0]);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// 6. If neither source yielded a single task, there's nothing to assert → defer.
|
|
138
|
-
if (!sawTaskFile && !sawBoardRow) defer("board directory holds zero tasks — nothing to assert", "empty-board");
|
|
139
|
-
|
|
140
|
-
// 7. Verdict.
|
|
141
|
-
// Board fully green → allow the EVAL. THIS is the row that used to be indistinguishable from the
|
|
142
|
-
// gate never having run: same exit code, same empty stdout, opposite meaning.
|
|
143
|
-
if (unfinished.size === 0) defer(`board green — ${sawTaskFile ? "task files" : "board rows"} all done, EVAL permitted`, "board-green");
|
|
144
|
-
|
|
145
|
-
// Board is partial. Advise, and record it as its own verdict — a `warn` row is what keeps
|
|
146
|
-
// "evaluated a non-green board" countable after the denial was removed (ADR-0001).
|
|
147
|
-
const list = [...unfinished].sort().join(", ");
|
|
148
|
-
return {
|
|
149
|
-
verdict: "warn", event: "PreToolUse", tool: "Skill", subject: slug, rule: "board-not-green",
|
|
150
|
-
reason: `${unfinished.size} unfinished task(s): ${list}`,
|
|
151
|
-
payload: {
|
|
152
|
-
systemMessage:
|
|
153
|
-
`⚠ GATE L2 — the board is NOT green and the EVAL is proceeding anyway.\n` +
|
|
154
|
-
`Unfinished (${unfinished.size}): ${list}\n` +
|
|
155
|
-
`EVAL is designed to run once per round, after every task is done. A verdict taken now grades ` +
|
|
156
|
-
`a partial board, so a PASS does not mean the feature is complete — it means the finished part ` +
|
|
157
|
-
`passed. Route back to BUILD (task-executor) to close these, or use --task for a deliberate ` +
|
|
158
|
-
`single-task check.`,
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
});
|