shapeup-sdlc 1.6.3 → 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 +54 -107
- package/README.md +165 -151
- package/SECURITY.md +49 -27
- package/bin/init.mjs +93 -108
- package/bin/lib/grant.mjs +145 -0
- package/commands/build.md +20 -0
- package/commands/eval.md +5 -4
- package/commands/scopes.md +5 -4
- package/commands/shape.md +1 -1
- package/commands/ship.md +53 -7
- package/commands/wire.md +1 -1
- package/hooks/dispatch-receipt.mjs +195 -0
- package/hooks/gate-intake.mjs +16 -17
- package/hooks/gate-zerowork.mjs +107 -25
- package/hooks/hooks.json +9 -48
- package/hooks/lib/decision.mjs +38 -19
- package/hooks/safety-spine.mjs +4 -4
- package/hooks/sandbox-guard.mjs +130 -50
- package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +344 -55
- package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +69 -26
- package/kernel/harness.mjs +134 -0
- package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +25 -22
- package/kernel/init/run.mjs +489 -0
- package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
- package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +261 -17
- 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} +10 -11
- package/kernel/probe/eval.mjs +77 -0
- package/kernel/probe/leg.mjs +125 -0
- package/kernel/probe/resume.mjs +528 -0
- package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +102 -21
- package/kernel/probe/t0.mjs +66 -0
- package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +104 -28
- package/kernel/reduce/graph.mjs +437 -0
- package/kernel/reduce/hill.mjs +152 -0
- 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} +93 -14
- package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +22 -21
- package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +14 -8
- 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} +22 -25
- 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} +138 -28
- package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +28 -13
- package/oracles/_shared.mjs +1 -1
- package/oracles/http-oracle.mjs +2 -2
- package/oracles/index.mjs +1 -1
- package/oracles/process-oracle.mjs +2 -2
- package/oracles/snapshot-oracle.mjs +2 -2
- package/oracles/test-oracle.mjs +2 -2
- package/package.json +11 -13
- package/skills/ba-pitch-analyzer/SKILL.md +24 -19
- 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 +3 -1
- package/skills/ba-pitch-analyzer/references/task-generation.md +8 -8
- package/skills/ba-pitch-analyzer/references/test-surface.md +1 -1
- package/skills/coach/SKILL.md +3 -3
- package/skills/orient/SKILL.md +2 -1
- package/skills/qa-edge-hunter/SKILL.md +15 -41
- package/skills/scope-architect/SKILL.md +57 -18
- package/skills/scope-hammer/SKILL.md +5 -5
- package/skills/shapeup/SKILL.md +3 -3
- package/skills/shapeup/resources/context-compaction.md +4 -3
- package/skills/solution-architect/SKILL.md +37 -15
- package/skills/spec-evaluator/SKILL.md +24 -7
- package/skills/spec-evaluator/references/dimensions/_registry.md +2 -2
- package/skills/spec-evaluator/references/dimensions/completeness.md +1 -1
- package/skills/spec-evaluator/references/dimensions/integration.md +98 -67
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +4 -4
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +1 -1
- package/skills/spec-evaluator/references/probing.md +42 -23
- package/skills/spec-evaluator/references/report-schema.md +2 -2
- package/skills/task-executor/SKILL.md +32 -18
- package/skills/tech-lead/SKILL.md +127 -438
- package/skills/tech-lead/references/gates.md +140 -49
- package/skills/tech-lead/references/protocol.md +832 -0
- package/skills/tech-lead/schemas/domain.schema.json +645 -198
- package/skills/tech-lead/schemas/gate-answers.schema.json +2 -2
- package/skills/tech-lead/schemas/work-order.schema.json +11 -2
- package/skills/tech-lead/schemas/work-result.schema.json +56 -18
- package/skills/tech-lead/workflows/shapeup-run.js +1474 -0
- package/skills/translator/SKILL.md +2 -2
- package/hooks/anti-rationalization.mjs +0 -244
- package/hooks/compact-snapshot.mjs +0 -47
- package/hooks/gate-deadline.mjs +0 -151
- package/hooks/gate-l2.mjs +0 -161
- package/hooks/session-rehydrate.mjs +0 -109
- package/skills/advisor-protocol/SKILL.md +0 -171
- 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 -254
- package/skills/tech-lead/references/invocation.md +0 -45
- package/skills/tech-lead/references/ledger-schema.md +0 -214
- package/skills/tech-lead/references/round-protocol.md +0 -184
- package/skills/tech-lead/references/state-model.md +0 -66
- package/skills/tech-lead/scripts/ingest-result.mjs +0 -270
- package/skills/tech-lead/scripts/init-run.mjs +0 -326
- package/skills/tech-lead/scripts/lib/is-main.mjs +0 -82
- package/skills/tech-lead/scripts/lib/paths.mjs +0 -278
- package/skills/translator/README.md +0 -66
|
@@ -173,8 +173,8 @@ scan always stops (never ship half-translated intake to the harness).
|
|
|
173
173
|
| `glossary.md` | source→English term map | Persisted + reused; the shared vocabulary for this feature |
|
|
174
174
|
| `translation-report.md` | detect table, glossary diff, verification result | Audit trail; what was skipped/flagged |
|
|
175
175
|
|
|
176
|
-
**Destinations (two-root workspace).** `<name>.en.md` is
|
|
177
|
-
of its source — when the source already lives under the SHARED root
|
|
176
|
+
**Destinations (two-root workspace).** `<name>.en.md` is written as a sibling
|
|
177
|
+
of its source (or under `--out <dir>` when given) — when the source already lives under the SHARED root
|
|
178
178
|
`shapeup/<slug>/shaping/`, the copy lands there too. `glossary.md` is durable
|
|
179
179
|
shared vocabulary → write it to `shapeup/<slug>/shaping/glossary.md`.
|
|
180
180
|
`translation-report.md` is run-trace (audit) → write it to the LOCAL root
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Anti-rationalization — advisory Stop hook (v1.2, absorb-audit P2).
|
|
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, unanswered escalates), 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 the SDD
|
|
63
|
-
* harness benchmark produced a transcript this hook should have caught and structurally could
|
|
64
|
-
* not: the session ended on "The tech-lead 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, open_escalates: 0, 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 escDir = join(root, "escalates");
|
|
127
|
-
if (existsSync(escDir)) {
|
|
128
|
-
facts.open_escalates = readdirSync(escDir).filter((f) => f.endsWith(".json")).length;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const runPath = join(root, "harness-run.md");
|
|
132
|
-
if (existsSync(runPath)) {
|
|
133
|
-
try {
|
|
134
|
-
const fm = frontmatter(readFileSync(runPath, "utf8"));
|
|
135
|
-
facts.run_status = fm.status || null;
|
|
136
|
-
facts.final_verdict = fm.final_verdict || null;
|
|
137
|
-
} catch { /* no fact */ }
|
|
138
|
-
}
|
|
139
|
-
return facts;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* The facts that contradict a claim, as human-readable fragments.
|
|
144
|
-
*
|
|
145
|
-
* TENSE DECIDES WHICH FACTS COUNT, and getting this wrong is what makes a hook get disabled.
|
|
146
|
-
* An unfinished board, a red T0 and unanswered escalates all contradict "it is done". NONE of
|
|
147
|
-
* them contradict "I am about to run the evaluator" — they are that sentence's PREMISE. Checking
|
|
148
|
-
* a promise against them meant every healthy build round ended with the hook announcing that the
|
|
149
|
-
* turn's own plan "disagrees with the facts", naming as evidence the very work the plan exists to
|
|
150
|
-
* do. This file's header calls an always-on nag "exactly the annoyance that gets hooks disabled",
|
|
151
|
-
* and the sibling future-tense detector in gate-zerowork.mjs is marked advisory for the same
|
|
152
|
-
* reason. So a promise is contradicted by one thing only: a run that is already CLOSED, where the
|
|
153
|
-
* promised work is never going to happen.
|
|
154
|
-
*
|
|
155
|
-
* The zero-work case that motivated future-tense detection — a session that dispatches, narrates
|
|
156
|
-
* "it will: 1…", and stops having written nothing — is gate-zerowork.mjs's, which blocks on a
|
|
157
|
-
* mechanical absence rather than on phrasing. It is not this hook's to double-report.
|
|
158
|
-
*
|
|
159
|
-
* @param {string} claim - The matched claim fragment from detectClaim().
|
|
160
|
-
* @param {object} facts - Mechanical facts from gatherFacts().
|
|
161
|
-
* @returns {string[]} Human-readable contradiction fragments; empty means the claim stands.
|
|
162
|
-
*/
|
|
163
|
-
export function contradictions(claim, facts) {
|
|
164
|
-
const out = [];
|
|
165
|
-
|
|
166
|
-
if (isFutureClaim(claim)) {
|
|
167
|
-
if (facts.run_status === "shipped" || facts.run_status === "escalated") {
|
|
168
|
-
out.push(`run status is already "${facts.run_status}" — the run is closed, so the promised work cannot happen in it`);
|
|
169
|
-
}
|
|
170
|
-
return out;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (facts.unfinished.length > 0) {
|
|
174
|
-
const named = facts.unfinished.slice(0, 5).join(", ");
|
|
175
|
-
out.push(`${facts.unfinished.length} board task(s) not done (${named}${facts.unfinished.length > 5 ? ", …" : ""})`);
|
|
176
|
-
}
|
|
177
|
-
if (facts.red_t0) out.push(`latest T0 verdict ${facts.red_t0} is red`);
|
|
178
|
-
if (facts.open_escalates > 0) out.push(`${facts.open_escalates} escalate(s) unanswered`);
|
|
179
|
-
if (facts.final_verdict === "fail") out.push("harness-run records final_verdict: fail");
|
|
180
|
-
if (/ship/i.test(claim || "") && MID_RUN.has(facts.run_status)) out.push(`run status is still "${facts.run_status}"`);
|
|
181
|
-
return out;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function lastAssistantFromTranscript(transcriptPath) {
|
|
185
|
-
try {
|
|
186
|
-
if (!transcriptPath || !existsSync(transcriptPath)) return null;
|
|
187
|
-
if (statSync(transcriptPath).size > 20 * 1024 * 1024) return null; // stay cheap
|
|
188
|
-
const lines = readFileSync(transcriptPath, "utf8").trim().split("\n");
|
|
189
|
-
for (let i = lines.length - 1; i >= 0 && i >= lines.length - 64; i--) {
|
|
190
|
-
let entry;
|
|
191
|
-
try { entry = JSON.parse(lines[i]); } catch { continue; }
|
|
192
|
-
const msg = entry?.message;
|
|
193
|
-
if (entry?.type === "assistant" || msg?.role === "assistant") {
|
|
194
|
-
const content = msg?.content ?? entry?.content;
|
|
195
|
-
if (typeof content === "string") return content;
|
|
196
|
-
if (Array.isArray(content)) {
|
|
197
|
-
return content.filter((b) => b?.type === "text").map((b) => b.text).join("\n");
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
} catch { /* unreadable transcript → no claim source */ }
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
async function main() {
|
|
206
|
-
await runHook("anti-rationalization", async () => {
|
|
207
|
-
const raw = await readStdin();
|
|
208
|
-
let p;
|
|
209
|
-
/** Stay silent — with the reason on the record (hooks/lib/decision.mjs). */
|
|
210
|
-
const defer = (reason, rule) => settle({ verdict: "allow", event: "Stop", cwd: p?.cwd, reason, rule });
|
|
211
|
-
try { p = JSON.parse(raw || "{}"); }
|
|
212
|
-
catch (e) { settle({ verdict: "error", event: "Stop", reason: `unparseable payload: ${e.message}` }); }
|
|
213
|
-
|
|
214
|
-
if (p.stop_hook_active) defer("stop_hook_active — never participate in a stop-hook loop", "loop-guard");
|
|
215
|
-
|
|
216
|
-
const cwd = p.cwd || process.cwd();
|
|
217
|
-
const slug = activeSlug(cwd);
|
|
218
|
-
if (!slug) defer("no active run — nothing to check the claim against", "no-run");
|
|
219
|
-
|
|
220
|
-
const message = typeof p.last_assistant_message === "string" && p.last_assistant_message
|
|
221
|
-
? p.last_assistant_message
|
|
222
|
-
: lastAssistantFromTranscript(p.transcript_path);
|
|
223
|
-
const claim = detectClaim(message);
|
|
224
|
-
if (!claim) defer("the final message makes no completion claim", "no-claim");
|
|
225
|
-
|
|
226
|
-
const facts = gatherFacts(cwd, slug);
|
|
227
|
-
const contra = contradictions(claim, facts);
|
|
228
|
-
if (contra.length === 0) defer(`claim "${claim}" agrees with the run facts — inspected and permitted`, "claim-supported");
|
|
229
|
-
|
|
230
|
-
return {
|
|
231
|
-
verdict: "allow", event: "Stop", cwd, subject: slug, rule: "claim-contradicted", emit: true,
|
|
232
|
-
reason: `claim "${claim}" contradicted by ${contra.length} fact(s) — advisory note emitted, not a block`,
|
|
233
|
-
payload: {
|
|
234
|
-
systemMessage:
|
|
235
|
-
`anti-rationalization (advisory): the last message claims "${claim}" but run "${slug}" facts disagree — ` +
|
|
236
|
-
`${contra.join("; ")}. Not blocking (QA is a level-up, not a gate) — verify against the board and T0 before shipping.`,
|
|
237
|
-
},
|
|
238
|
-
};
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (isMain(import.meta.url)) {
|
|
243
|
-
main();
|
|
244
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Compact snapshot — PreCompact hook (v1.2, absorb-audit P4).
|
|
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`: across 1.2M
|
|
15
|
-
// benchmark tokens it was scored `Unfired` — 0 `PreCompact` events observed — and that score was
|
|
16
|
-
// UNOBTAINABLE, because "never had to fire" and "never ran" produced identical evidence (exit 0,
|
|
17
|
-
// no output). With a 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,151 +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 (measured, and it corrects an earlier diagnosis).
|
|
5
|
-
//
|
|
6
|
-
// SDD harness benchmark, F3 (Sonnet 5): this harness was killed at the declared 1800 s cap and
|
|
7
|
-
// published as a DNF — no acceptance, no cost, no turns, a dash in every column. The natural
|
|
8
|
-
// reading was "it stalled at a gate". Re-reading the retained transcript says the opposite:
|
|
9
|
-
//
|
|
10
|
-
// 327 assistant turns · 262 tool calls · 130 work calls · 37 file writes
|
|
11
|
-
// 19 gate markers, last gate L3 · narration_ratio 0.047 · stall_signals 0
|
|
12
|
-
//
|
|
13
|
-
// It was working — the least talkative shapeup run in the matrix — and it was still working when
|
|
14
|
-
// the clock ran out. Both existing breakers count EVENTS (`round_budget` per round,
|
|
15
|
-
// `attempt_budget` per T0 attempt), so neither can observe that a single round has been running
|
|
16
|
-
// for twenty-nine minutes. The run burned its whole budget with both breakers untouched.
|
|
17
|
-
//
|
|
18
|
-
// The cost of that is not the DNF row. It is that an externally killed run ships NOTHING, not
|
|
19
|
-
// even the scopes that were already green. A breaker that trips from the inside routes to GATE H
|
|
20
|
-
// instead: census, baseline comparison, ship the part that works. Same clock, different ending.
|
|
21
|
-
//
|
|
22
|
-
// WHAT IT DENIES, AND WHAT IT DELIBERATELY DOES NOT. Past the deadline this denies dispatches
|
|
23
|
-
// that START NEW WORK — `task-executor`. It never denies `spec-evaluator`, `scope-hammer`,
|
|
24
|
-
// `qa-edge-hunter` or `advisor-protocol`, because a run past its deadline must still be able to
|
|
25
|
-
// judge, hammer and close. A breaker that also blocked the exit would strand the run with green
|
|
26
|
-
// scopes it could not ship, which is the failure it exists to prevent.
|
|
27
|
-
//
|
|
28
|
-
// Non-regression: with no `wall_clock_budget_s` in the receipt (the default) this defers
|
|
29
|
-
// instantly. Everything ambiguous — no receipt, unparseable payload, missing timestamp — fails
|
|
30
|
-
// open, because a gate that blocks legitimate runs gets disabled, and a disabled gate enforces
|
|
31
|
-
// nothing.
|
|
32
|
-
//
|
|
33
|
-
// Contract: PreToolUse stdin JSON { tool_name, tool_input:{skill_name|skill, skill_args|args}, cwd }.
|
|
34
|
-
// Deny via { hookSpecificOutput: { hookEventName, permissionDecision:"deny", permissionDecisionReason } }.
|
|
35
|
-
|
|
36
|
-
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
|
|
37
|
-
import { join } from "node:path";
|
|
38
|
-
import { evaluateBudget } from "../skills/tech-lead/scripts/budget-check.mjs";
|
|
39
|
-
import { runHook, readStdin, settle } from "./lib/decision.mjs";
|
|
40
|
-
import { localDir, localRoot } from "../skills/tech-lead/scripts/lib/paths.mjs";
|
|
41
|
-
|
|
42
|
-
await runHook("gate-deadline", async () => {
|
|
43
|
-
/** Fail-open, with the reason on the record (hooks/lib/decision.mjs). */
|
|
44
|
-
const defer = (reason, rule) => settle({ verdict: "allow", event: "PreToolUse", tool: p?.tool_name ?? null, reason, rule });
|
|
45
|
-
|
|
46
|
-
// Only these start new build work. Everything else in the harness is how a run ENDS, and must
|
|
47
|
-
// stay reachable after the deadline.
|
|
48
|
-
const STARTS_NEW_WORK = new Set(["task-executor"]);
|
|
49
|
-
|
|
50
|
-
const raw = await readStdin();
|
|
51
|
-
|
|
52
|
-
let p;
|
|
53
|
-
try { p = JSON.parse(raw || "{}"); }
|
|
54
|
-
catch (e) { settle({ verdict: "error", event: "PreToolUse", reason: `unparseable payload: ${e.message}` }); }
|
|
55
|
-
|
|
56
|
-
if (p.tool_name !== "Skill") defer(`not a Skill call (${p.tool_name ?? "no tool_name"}) — out of scope`);
|
|
57
|
-
|
|
58
|
-
const skillRaw = p.tool_input?.skill_name ?? p.tool_input?.skill ?? "";
|
|
59
|
-
const skill = String(skillRaw).split(":").pop();
|
|
60
|
-
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");
|
|
61
|
-
|
|
62
|
-
const cwd = p.cwd || process.cwd();
|
|
63
|
-
|
|
64
|
-
/** The active run's receipt, or null. Mirrors budget-check.mjs's discovery. */
|
|
65
|
-
function findRun() {
|
|
66
|
-
const root = localDir(cwd);
|
|
67
|
-
if (!existsSync(root)) return null;
|
|
68
|
-
let slug = null;
|
|
69
|
-
try { slug = JSON.parse(readFileSync(join(root, "active-scope"), "utf8"))?.slug || null; } catch { /* scan instead */ }
|
|
70
|
-
const candidates = slug ? [slug] : (() => { try { return readdirSync(root); } catch { return []; } })();
|
|
71
|
-
for (const entry of candidates) {
|
|
72
|
-
const f = join(root, entry, "receipt.json");
|
|
73
|
-
if (!existsSync(f)) continue;
|
|
74
|
-
try { return { slug: entry, receipt: JSON.parse(readFileSync(f, "utf8")) }; } catch { /* not a run */ }
|
|
75
|
-
}
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const run = findRun();
|
|
80
|
-
if (!run) defer("no run receipt — nothing to time", "no-run");
|
|
81
|
-
|
|
82
|
-
const budget = Number(run.receipt.config?.wall_clock_budget_s || 0) || null;
|
|
83
|
-
if (!budget) defer("no wall_clock_budget_s in the receipt — breaker off (the default)", "breaker-off");
|
|
84
|
-
|
|
85
|
-
const startedAt = Date.parse(run.receipt.started_at || "");
|
|
86
|
-
if (Number.isNaN(startedAt)) defer("receipt has no parseable started_at — no clock, no claim", "no-clock");
|
|
87
|
-
|
|
88
|
-
const state = evaluateBudget((Date.now() - startedAt) / 1000, budget);
|
|
89
|
-
if (state.status !== "trip") defer(`within budget (${state.status}) — inspected and permitted`, "within-budget");
|
|
90
|
-
|
|
91
|
-
// STICKY TRIP. The first version of this hook denied and said what to do instead; the orchestrator
|
|
92
|
-
// then re-dispatched task-executor and was denied again, THIRTEEN times in one measured run. Each
|
|
93
|
-
// denial costs a turn out of the budget the breaker exists to protect, so a re-triable denial
|
|
94
|
-
// actively makes the failure it is preventing worse.
|
|
95
|
-
//
|
|
96
|
-
// The fix is to make the trip a FACT rather than an event: record it once, count the retries, and
|
|
97
|
-
// escalate the language so the second denial cannot read like the first. A model that ignored
|
|
98
|
-
// "route to GATE H" phrased gently is not helped by receiving the identical text again.
|
|
99
|
-
const tripPath = join(localRoot(cwd, run.slug), "deadline-tripped.json");
|
|
100
|
-
let denials = 0;
|
|
101
|
-
try { denials = JSON.parse(readFileSync(tripPath, "utf8"))?.denials || 0; } catch { /* first trip */ }
|
|
102
|
-
denials += 1;
|
|
103
|
-
try {
|
|
104
|
-
writeFileSync(tripPath, JSON.stringify({
|
|
105
|
-
type: "deadline-trip", slug: run.slug, denials,
|
|
106
|
-
first_tripped_at: denials === 1 ? new Date().toISOString() : undefined,
|
|
107
|
-
elapsed_s: state.elapsed_s, budget_s: budget,
|
|
108
|
-
}, null, 2) + "\n");
|
|
109
|
-
} catch { /* recording is best-effort; the denial still stands */ }
|
|
110
|
-
|
|
111
|
-
const repeat = denials > 1
|
|
112
|
-
? [
|
|
113
|
-
"",
|
|
114
|
-
`⛔ THIS IS DENIAL #${denials}. Re-dispatching task-executor will be denied every time, and each`,
|
|
115
|
-
"attempt spends wall-clock the run does not have. STOP TRYING TO BUILD. The only forward move is",
|
|
116
|
-
"GATE H, below. If you have already dispatched scope-hammer, wait for it and close the run.",
|
|
117
|
-
]
|
|
118
|
-
: [];
|
|
119
|
-
|
|
120
|
-
const reason = [
|
|
121
|
-
`✋ GATE D — DEADLINE BREAKER. Run "${run.slug}" has used ${state.elapsed_s}s of its ${budget}s wall-clock budget.`,
|
|
122
|
-
"",
|
|
123
|
-
"Dispatching task-executor would start work this run cannot finish. Denied — not because the work",
|
|
124
|
-
"is wrong, but because a run that is killed from outside ships nothing, including the scopes that",
|
|
125
|
-
"are already green.",
|
|
126
|
-
"",
|
|
127
|
-
"Go to GATE H instead:",
|
|
128
|
-
"",
|
|
129
|
-
" Skill(scope-hammer, \"--breaker deadline --feature " + run.slug + "\")",
|
|
130
|
-
"",
|
|
131
|
-
"Scope-hammer runs the must-have census, compares the shippable subset against the BASELINE (never",
|
|
132
|
-
"against the ideal), and produces a cut list. Ship what is green; the rest becomes a raw idea for",
|
|
133
|
-
"the next Betting Table.",
|
|
134
|
-
"",
|
|
135
|
-
"spec-evaluator, scope-hammer, qa-edge-hunter and advisor-protocol are all still permitted — a run",
|
|
136
|
-
"past its deadline must still be able to judge, hammer, and close.",
|
|
137
|
-
...repeat,
|
|
138
|
-
].join("\n");
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
verdict: "deny", event: "PreToolUse", tool: "Skill", subject: skill, rule: "deadline-tripped",
|
|
142
|
-
reason: `wall-clock budget of ${budget}s exhausted — routing to GATE H`,
|
|
143
|
-
payload: {
|
|
144
|
-
hookSpecificOutput: {
|
|
145
|
-
hookEventName: "PreToolUse",
|
|
146
|
-
permissionDecision: "deny",
|
|
147
|
-
permissionDecisionReason: reason,
|
|
148
|
-
},
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
});
|
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 (audit Stage E1).
|
|
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 (the island-escape
|
|
86
|
-
// hole: EVAL proceeded with 16/20 task files 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
|
-
});
|