shapeup-sdlc 3.18.2 → 4.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 +2 -3
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +115 -56
- package/README.md +101 -143
- package/SECURITY.md +16 -25
- package/agents/orient.md +14 -0
- package/agents/qa-edge-hunter.md +13 -0
- package/agents/spec-evaluator.md +14 -0
- package/agents/task-executor.md +14 -0
- package/bin/init.mjs +13 -16
- package/bin/lib/grant.mjs +8 -19
- package/commands/build.md +9 -14
- package/commands/eval.md +3 -3
- package/commands/hammer.md +9 -5
- package/commands/retro.md +12 -11
- package/commands/scopes.md +1 -12
- package/commands/ship.md +45 -88
- package/commands/wire.md +1 -8
- package/hooks/dispatch-receipt.mjs +94 -8
- package/hooks/hooks.json +0 -41
- package/hooks/lib/decision.mjs +6 -9
- package/hooks/lib/tier.mjs +50 -0
- package/hooks/sandbox-guard.mjs +140 -64
- package/kernel/gate.mjs +203 -22
- package/kernel/harness.mjs +20 -67
- package/kernel/init/run.mjs +175 -523
- package/kernel/lib/contract.mjs +9 -93
- package/kernel/lib/floor.mjs +64 -0
- package/kernel/lib/paths.mjs +39 -112
- package/kernel/order.mjs +157 -0
- package/kernel/probe/digest.mjs +2 -38
- package/kernel/schemas/domain.schema.json +146 -1125
- package/kernel/schemas/work-order.schema.json +8 -4
- package/kernel/schemas/work-result.schema.json +1 -1
- package/kernel/verify/envelope.mjs +2 -2
- package/kernel/verify/skills.mjs +27 -8
- package/kernel/verify/t0.mjs +2 -2
- package/package.json +4 -2
- package/skills/building/SKILL.md +144 -0
- package/skills/building/resources/gates.md +76 -0
- package/skills/building/resources/guidance.md +23 -0
- package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
- package/skills/building/resources/intake.md +157 -0
- package/skills/building/resources/orders.md +69 -0
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
- package/skills/building/resources/plan-scopes.md +95 -0
- package/skills/building/resources/plan-spec.md +102 -0
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
- package/skills/building/resources/plan-wire.md +82 -0
- package/skills/building/resources/plan.md +98 -0
- package/skills/building/resources/retro-research.md +60 -0
- package/skills/building/resources/retro-scan.md +49 -0
- package/skills/building/resources/retro.md +225 -0
- package/skills/building/resources/rounds.md +78 -0
- package/skills/building/resources/stop.md +127 -0
- package/skills/orient/SKILL.md +37 -37
- package/skills/qa-edge-hunter/SKILL.md +29 -14
- package/skills/shapeup/SKILL.md +5 -5
- package/skills/shapeup/resources/breadboarding.md +1 -1
- package/skills/shapeup/resources/context-compaction.md +5 -5
- package/skills/shapeup/resources/shaping.md +1 -1
- package/skills/spec-evaluator/SKILL.md +17 -21
- package/skills/spec-evaluator/references/dimension-contract.md +1 -1
- package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
- package/skills/task-executor/SKILL.md +11 -15
- package/commands/hill.md +0 -10
- package/hooks/gate-intake.mjs +0 -109
- package/hooks/gate-zerowork.mjs +0 -349
- package/hooks/tier-guard.mjs +0 -162
- package/kernel/compile.mjs +0 -1419
- package/kernel/init/fit.mjs +0 -199
- package/kernel/init/run-args.mjs +0 -206
- package/kernel/lib/breadboard.mjs +0 -165
- package/kernel/probe/attempts.mjs +0 -161
- package/kernel/probe/concurrency.mjs +0 -535
- package/kernel/probe/eval.mjs +0 -405
- package/kernel/probe/hunt.mjs +0 -104
- package/kernel/probe/leg.mjs +0 -229
- package/kernel/probe/owner.mjs +0 -142
- package/kernel/probe/requirements.mjs +0 -328
- package/kernel/probe/resume.mjs +0 -1110
- package/kernel/probe/rounds.mjs +0 -115
- package/kernel/probe/staged.mjs +0 -83
- package/kernel/probe/stats.mjs +0 -477
- package/kernel/probe/t0.mjs +0 -115
- package/kernel/reduce/board.mjs +0 -350
- package/kernel/reduce/graph.mjs +0 -482
- package/kernel/reduce/hill.mjs +0 -322
- package/kernel/reduce/ingest.mjs +0 -767
- package/kernel/reduce/leftovers.mjs +0 -159
- package/kernel/reduce/ship.mjs +0 -529
- package/kernel/reduce/snapshot.mjs +0 -291
- package/kernel/reduce/verdict.mjs +0 -172
- package/kernel/report/export.mjs +0 -384
- package/kernel/report/facts.mjs +0 -234
- package/kernel/verify/budget.mjs +0 -153
- package/kernel/verify/build.mjs +0 -428
- package/kernel/verify/dispatch.mjs +0 -114
- package/kernel/verify/spec.mjs +0 -975
- package/kernel/verify/trace.mjs +0 -604
- package/skills/ba-pitch-analyzer/SKILL.md +0 -204
- package/skills/coach/SKILL.md +0 -397
- package/skills/hill-chart/SKILL.md +0 -154
- package/skills/hill-chart/assets/dashboard.template.html +0 -1432
- package/skills/scope-architect/SKILL.md +0 -174
- package/skills/scope-hammer/SKILL.md +0 -231
- package/skills/solution-architect/SKILL.md +0 -158
- package/skills/tech-lead/SKILL.md +0 -151
- package/skills/tech-lead/references/gates.md +0 -605
- package/skills/tech-lead/references/protocol.md +0 -889
- package/skills/tech-lead/references/tiny-lane.md +0 -53
- package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
- package/skills/translator/SKILL.md +0 -258
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
package/hooks/gate-zerowork.mjs
DELETED
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// GATE Z — ZERO-WORK. Blocking Stop hook. The detector for "the harness described itself".
|
|
3
|
-
//
|
|
4
|
-
// WHY THIS EXISTS (reproduced, not theorized).
|
|
5
|
-
//
|
|
6
|
-
// Dispatched with a valid spec, the orchestrator returned this, in full:
|
|
7
|
-
//
|
|
8
|
-
// TOOL Skill(tech-lead, "--unattended --rounds 3\n\n# F2 — category budgets…")
|
|
9
|
-
// TEXT "The tech-lead skill is orchestrating the full Shape Up harness. It will: 1. …"
|
|
10
|
-
// FINAL (same text — session ends)
|
|
11
|
-
//
|
|
12
|
-
// No code. No board. No gate artifacts. Prose that reads exactly like a successful run.
|
|
13
|
-
// That is the "agent claims done" pathology this project exists to prevent, reproduced by the
|
|
14
|
-
// project, at its own front door.
|
|
15
|
-
//
|
|
16
|
-
// THE TWO STRUCTURAL MISSES IT EXPOSED. the ship report's census is the guard for exactly
|
|
17
|
-
// this class of failure, and it could not see this one for two independent reasons:
|
|
18
|
-
//
|
|
19
|
-
// 1. SCOPE. It defers unless a run is active (`activeSlug()` → `.shapeup/<slug>/`).
|
|
20
|
-
// A run that never started produces none of the files it reads. It catches "claimed done
|
|
21
|
-
// on a half-green board" and misses "claimed done with no board at all". The emptier the
|
|
22
|
-
// failure, the less of it there is to detect.
|
|
23
|
-
// 2. CLAIM SHAPE. Its detector matches PAST-tense completion — done, finished, shipped, all
|
|
24
|
-
// tests pass. Narration is FUTURE-tense: "it will". Fix the scope and this transcript
|
|
25
|
-
// still slips through, because narration never claims completion. It promises it.
|
|
26
|
-
//
|
|
27
|
-
// So this hook does not look at what the message says. It looks at whether anything happened.
|
|
28
|
-
// The predicate is mechanical and model-independent:
|
|
29
|
-
//
|
|
30
|
-
// the session dispatched the orchestrator AND the run left no receipt
|
|
31
|
-
//
|
|
32
|
-
// ``harness init run`` writes that receipt as the orchestrator's first tool call. Its absence is the
|
|
33
|
-
// fact. Nothing here parses intent, so nothing here can be talked past.
|
|
34
|
-
//
|
|
35
|
-
// WHY THIS ONE BLOCKS, WHEN the ship report's census DOES NOT. The invariant is "QA is a level-up,
|
|
36
|
-
// not a gate" — no second judge behind `spec-evaluator`. That governs quality JUDGMENTS. This
|
|
37
|
-
// hook makes no judgment: it reports that no work exists to judge. Blocking is also uniquely
|
|
38
|
-
// safe here, because a session with no artifacts has nothing to lose by continuing, and a
|
|
39
|
-
// blocking Stop is the only mechanism that turns narration back into execution — an advisory
|
|
40
|
-
// note at the end of a narrated run just gets narrated too.
|
|
41
|
-
//
|
|
42
|
-
// Loop safety: `stop_hook_active` defers unconditionally, so this fires at most once per stop
|
|
43
|
-
// chain. Everything ambiguous fails open — a hook that blocks legitimate sessions gets disabled,
|
|
44
|
-
// and a disabled hook enforces nothing.
|
|
45
|
-
//
|
|
46
|
-
// Contract: Stop stdin JSON { cwd, stop_hook_active, last_assistant_message, transcript_path }.
|
|
47
|
-
// Blocks via { decision: "block", reason }. Always exits 0.
|
|
48
|
-
|
|
49
|
-
// SECOND CONDITION (v1.5). Since `hooks/lib/decision.mjs` gives every hook a receipt, this gate
|
|
50
|
-
// gains a second, independent fact it can assert at `Stop`: the orchestrator was dispatched, and
|
|
51
|
-
// `decisions.jsonl` holds ZERO rows for this pid — meaning the enforcement layer itself never ran.
|
|
52
|
-
// That is a whole CLASS of failure, not one instance: under a symlinked install every gate can be
|
|
53
|
-
// inert while every gate reports success. The detector for "the gates didn't run" therefore stops
|
|
54
|
-
// depending on the gates running.
|
|
55
|
-
|
|
56
|
-
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
57
|
-
import { join } from "node:path";
|
|
58
|
-
import { isMain } from "../kernel/lib/argv.mjs";
|
|
59
|
-
import { localDir, globLocal, globWorkflowsStage } from "../kernel/lib/paths.mjs";
|
|
60
|
-
import { runHook, readStdin, settle, decisionsPath, projectRoot } from "./lib/decision.mjs";
|
|
61
|
-
|
|
62
|
-
const MAX_TRANSCRIPT_BYTES = 20 * 1024 * 1024;
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Tool names that constitute doing something to the project, as opposed to looking at it.
|
|
66
|
-
*
|
|
67
|
-
* The census these produce is now DESCRIPTIVE ONLY — it sharpens the block message and nothing
|
|
68
|
-
* decides on it. Until v1.7.1 a count above two was a fail-open ("the session did work by other
|
|
69
|
-
* means"), and it was retired for the reason spelled out at the `work-done` note below. `Workflow` stays absent because a launch is not work — it is the work this hook is
|
|
70
|
-
* asking about — and it is a DISPATCH signal instead (`dispatchedOrchestrator`).
|
|
71
|
-
*/
|
|
72
|
-
const WORK_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash", "Task", "Agent"]);
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Is this block a launch of the orchestrator's own workflow script, by either surface?
|
|
76
|
-
*
|
|
77
|
-
* The shipped front door is `Workflow({scriptPath})`; `npx shapeup-sdlc init` writes the grant it
|
|
78
|
-
* needs. The Bash arm below is kept because a project that declined that grant, or one still on a
|
|
79
|
-
* v1 install, launches the same script through a Bash runtime — a gate that knew only one surface
|
|
80
|
-
* would go blind on the lane those users actually run, which is the same "the emptier the failure,
|
|
81
|
-
* the less of it there is to detect" hole the banner above describes.
|
|
82
|
-
*
|
|
83
|
-
* Matched on the BASENAME, anchored. `CLAUDE_PLUGIN_ROOT` itself ends in `shapeup-sdlc-plugin/` on
|
|
84
|
-
* a normal install — so a substring match on the whole path would count EVERY workflow script that
|
|
85
|
-
* happens to live under the plugin root, including one a user wrote for something else. The
|
|
86
|
-
* basename is the part the skill controls and the part `SKILL.md` names.
|
|
87
|
-
*
|
|
88
|
-
* @param {object} block - A `tool_use` content block.
|
|
89
|
-
* @returns {boolean} True when the block launches a `shapeup-*` workflow.
|
|
90
|
-
*/
|
|
91
|
-
function launchedShapeupWorkflow(block) {
|
|
92
|
-
if (block.name === "Bash") {
|
|
93
|
-
const cmd = String(block.input?.command ?? "");
|
|
94
|
-
// Both halves required: a node launcher AND an orchestrator script. A launcher carrying
|
|
95
|
-
// somebody else's workflow is not a harness dispatch, and neither is a bare mention of the
|
|
96
|
-
// script in an unrelated command (`ls`, `cat`).
|
|
97
|
-
return /\bnode\b/.test(cmd) && /[\\/]shapeup-[\w.-]*\.[cm]?js\b/.test(cmd);
|
|
98
|
-
}
|
|
99
|
-
if (block.name !== "Workflow") return false;
|
|
100
|
-
const scriptPath = String(block.input?.scriptPath ?? "");
|
|
101
|
-
const base = scriptPath.split(/[\\/]/).pop().replace(/\.[cm]?js$/, "");
|
|
102
|
-
const named = String(block.input?.name ?? "");
|
|
103
|
-
return /^shapeup-/.test(base) || /^shapeup-/.test(named);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Did this session dispatch the orchestrator? Three surface spellings, all equivalent:
|
|
108
|
-
* `Skill(tech-lead)`, a leading `/ship` slash command, and — since the workflow cutover (v1.7) —
|
|
109
|
-
* a `Workflow` tool_use launching one of the orchestrator's own `shapeup-*` scripts.
|
|
110
|
-
*
|
|
111
|
-
* The third arm is a correctness repair, not a new detector. `SKILL.md`'s Step 2 makes the
|
|
112
|
-
* Workflow launch the scoped lane's front door, so a session can now reach the orchestrator
|
|
113
|
-
* without ever emitting `Skill(tech-lead)` — and before this arm existed such a session was
|
|
114
|
-
* invisible to the gate, exactly the "the emptier the failure, the less of it there is to detect"
|
|
115
|
-
* hole the banner above describes. It is a TRIGGER, never an escape: a launch that left no
|
|
116
|
-
* receipt is still a blocked stop, because a `Workflow` call that returned without starting a run
|
|
117
|
-
* is precisely the narration case wearing a tool call.
|
|
118
|
-
*/
|
|
119
|
-
export function dispatchedOrchestrator(events) {
|
|
120
|
-
for (const ev of events) {
|
|
121
|
-
for (const block of toolUses(ev)) {
|
|
122
|
-
if (launchedShapeupWorkflow(block)) return true;
|
|
123
|
-
if (block.name !== "Skill") continue;
|
|
124
|
-
const skill = String(block.input?.skill ?? block.input?.skill_name ?? "");
|
|
125
|
-
if (skill.split(":").pop() === "tech-lead") return true;
|
|
126
|
-
}
|
|
127
|
-
// The slash command is the other front door: `/shapeup-sdlc-plugin:ship …` arrives as user
|
|
128
|
-
// text, and reaches tech-lead through commands/ship.md.
|
|
129
|
-
//
|
|
130
|
-
// ANCHORED TO THE START OF THE MESSAGE, deliberately. A slash command IS the message — the CLI
|
|
131
|
-
// only dispatches one when it leads. Matching `/ship` anywhere in the text meant "how does
|
|
132
|
-
// /ship decide the lane?" counted as a dispatch, and in a repo with no run that answer is a
|
|
133
|
-
// Stop block: the session refuses to end and the model is told to bootstrap a feature nobody
|
|
134
|
-
// asked for. A gate that fires on a session merely TALKING about the harness is a gate users
|
|
135
|
-
// turn off, and it takes the real zero-work block down with it.
|
|
136
|
-
if (ev?.type === "user") {
|
|
137
|
-
const c = ev?.message?.content ?? ev?.content;
|
|
138
|
-
const text = typeof c === "string" ? c : Array.isArray(c) ? c.map((b) => b?.text || "").join("\n") : "";
|
|
139
|
-
if (/^\s*\/(?:[\w-]+:)?ship\b/.test(text)) return true;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function toolUses(ev) {
|
|
146
|
-
const c = ev?.message?.content ?? ev?.content;
|
|
147
|
-
return Array.isArray(c) ? c.filter((b) => b?.type === "tool_use") : [];
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** How much actual work the session did, by tool class. */
|
|
151
|
-
export function workCensus(events) {
|
|
152
|
-
const census = { tool_calls: 0, work_calls: 0, writes: 0, by_tool: {} };
|
|
153
|
-
for (const ev of events) {
|
|
154
|
-
for (const block of toolUses(ev)) {
|
|
155
|
-
census.tool_calls++;
|
|
156
|
-
census.by_tool[block.name] = (census.by_tool[block.name] || 0) + 1;
|
|
157
|
-
if (WORK_TOOLS.has(block.name)) census.work_calls++;
|
|
158
|
-
if (block.name === "Write" || block.name === "Edit" || block.name === "MultiEdit") census.writes++;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return census;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Any run receipt on disk, from any run. Written by `harness init run` as the run's first act. */
|
|
165
|
-
export function findReceipts(cwd) {
|
|
166
|
-
const root = localDir(cwd);
|
|
167
|
-
if (!existsSync(root)) return [];
|
|
168
|
-
const out = [];
|
|
169
|
-
let entries;
|
|
170
|
-
try { entries = readdirSync(root); } catch { return []; }
|
|
171
|
-
for (const entry of entries) {
|
|
172
|
-
const p = join(root, entry, "receipt.json");
|
|
173
|
-
if (!existsSync(p)) continue;
|
|
174
|
-
try {
|
|
175
|
-
const r = JSON.parse(readFileSync(p, "utf8"));
|
|
176
|
-
if (r?.started) out.push({ slug: entry, receipt: r, mtime: statSync(p).mtimeMs });
|
|
177
|
-
} catch { /* unreadable receipt is not a receipt */ }
|
|
178
|
-
}
|
|
179
|
-
return out;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Future-tense narration: the signature of describing a pipeline instead of running it.
|
|
184
|
-
* Advisory only — it sharpens the message, it never decides the block. The block is decided
|
|
185
|
-
* by the absence of a receipt, which no phrasing can change.
|
|
186
|
-
*/
|
|
187
|
-
export function detectNarration(text) {
|
|
188
|
-
if (!text || typeof text !== "string") return null;
|
|
189
|
-
const m = /\b(will (?:now )?(?:run|orchestrate|execute|proceed|begin|start)|is orchestrating|I'?ll (?:now )?(?:run|start|begin|orchestrate)|going to (?:run|orchestrate|execute)|here'?s (?:what|how) (?:it|the harness) will)\b/i.exec(text);
|
|
190
|
-
return m ? m[0] : null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function readEvents(transcriptPath) {
|
|
194
|
-
try {
|
|
195
|
-
if (!transcriptPath || !existsSync(transcriptPath)) return null;
|
|
196
|
-
if (statSync(transcriptPath).size > MAX_TRANSCRIPT_BYTES) return null;
|
|
197
|
-
return readFileSync(transcriptPath, "utf8")
|
|
198
|
-
.split("\n")
|
|
199
|
-
.filter(Boolean)
|
|
200
|
-
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
201
|
-
.filter(Boolean);
|
|
202
|
-
} catch { return null; }
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Did the enforcement layer leave any evidence of itself in this checkout?
|
|
207
|
-
*
|
|
208
|
-
* A hook that never runs and a hook that inspected-and-permitted used to produce identical
|
|
209
|
-
* evidence (exit 0, empty stdout). With `hooks/lib/decision.mjs` every evaluation appends a row,
|
|
210
|
-
* so ZERO rows across the whole file is now a positive fact about the layer rather than an absence
|
|
211
|
-
* of information about it.
|
|
212
|
-
*
|
|
213
|
-
* @param {string} cwd - Project root.
|
|
214
|
-
* @returns {{rows:number, readable:boolean}} How many decision rows exist and whether the ledger
|
|
215
|
-
* could be read at all (an unreadable ledger proves nothing and must not sharpen the message).
|
|
216
|
-
*/
|
|
217
|
-
export function enforcementCensus(cwd) {
|
|
218
|
-
const p = decisionsPath(cwd);
|
|
219
|
-
if (!existsSync(p)) return { rows: 0, readable: false };
|
|
220
|
-
try {
|
|
221
|
-
return { rows: readFileSync(p, "utf8").split("\n").filter((l) => l.trim()).length, readable: true };
|
|
222
|
-
} catch { return { rows: 0, readable: false }; }
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export function buildReason({ narration, census, enforcement }) {
|
|
226
|
-
return [
|
|
227
|
-
"✋ GATE Z — ZERO WORK. This session dispatched the tech-lead orchestrator and produced no run receipt.",
|
|
228
|
-
"",
|
|
229
|
-
`Mechanical facts: ${census.tool_calls} tool call(s), ${census.work_calls} of them work calls, ` +
|
|
230
|
-
`${census.writes} file write(s), and no \`${globLocal("<slug>", "receipt.json")}\`.`,
|
|
231
|
-
narration ? `The final message reads as a plan, not a result (matched: "${narration}").` : null,
|
|
232
|
-
census.work_calls > 2
|
|
233
|
-
? `Those ${census.work_calls} work calls are why this block exists, not a reason to waive it: a busy ` +
|
|
234
|
-
"session used to switch this gate off. Work done AROUND the harness has no board, no T0 verdict and " +
|
|
235
|
-
"no receipt — a hand-built feature can pass its own oracle while the pipeline never ran."
|
|
236
|
-
: null,
|
|
237
|
-
enforcement && enforcement.readable && enforcement.rows === 0
|
|
238
|
-
? "AND the enforcement layer left zero decision rows — the gates did not merely permit this run, they never ran. " +
|
|
239
|
-
"Check the plugin install — a symlinked path or a path with a space is the usual cause."
|
|
240
|
-
: null,
|
|
241
|
-
"",
|
|
242
|
-
"A run that describes its own pipeline and stops is the exact failure this harness exists to",
|
|
243
|
-
"prevent: it reads like a clean run and leaves escaped defects behind it.",
|
|
244
|
-
"Loading the instructions is not running them.",
|
|
245
|
-
"",
|
|
246
|
-
"Do the work now, starting with the first step of the runbook:",
|
|
247
|
-
"",
|
|
248
|
-
" # write the requirement to a file first — inlining multi-line text into a shell",
|
|
249
|
-
" # argument is where this step goes wrong",
|
|
250
|
-
" node \"${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs\" init run \\",
|
|
251
|
-
" --slug <slug> --intake-file <path/to/requirement.md> \\",
|
|
252
|
-
" --auto-level <interactive|auto|unattended> [--gate-answers <preset|path>]",
|
|
253
|
-
"",
|
|
254
|
-
"Then launch the lane itself with the Workflow tool (`npx shapeup-sdlc init` writes the grant",
|
|
255
|
-
"it needs, unless --no-native-workflow was given, in which case approve the launch once):",
|
|
256
|
-
"",
|
|
257
|
-
" Workflow({",
|
|
258
|
-
` scriptPath: "${globWorkflowsStage("shapeup-run.js")}", // staged by \`init run\`, above`,
|
|
259
|
-
" args: <the RunArgs object>",
|
|
260
|
-
" })",
|
|
261
|
-
"",
|
|
262
|
-
"Resolve each gate the run pauses at with:",
|
|
263
|
-
"",
|
|
264
|
-
" node \"${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs\" gate --resolve <gate-id> …",
|
|
265
|
-
"",
|
|
266
|
-
"If the command comes back \"requires approval\", say so and stop — the harness's scripts ship with",
|
|
267
|
-
"the plugin and need a one-time permission grant (`npx shapeup-sdlc init` writes it). Do NOT route",
|
|
268
|
-
"around it with wrapper scripts, and do NOT hand-build the feature instead: a feature built outside",
|
|
269
|
-
"the harness has no board, no T0 and no verdict, which is the un-evidenced \"done\" this exists to stop.",
|
|
270
|
-
"",
|
|
271
|
-
"If the requirement genuinely cannot be built, say why in one sentence — an explicit refusal is",
|
|
272
|
-
"a real answer. A future-tense summary of what the harness would have done is not.",
|
|
273
|
-
].filter((l) => l !== null).join("\n");
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
async function main() {
|
|
277
|
-
await runHook("gate-zerowork", async () => {
|
|
278
|
-
const raw = await readStdin();
|
|
279
|
-
|
|
280
|
-
let p;
|
|
281
|
-
/** Fail-open, with the reason on the record (hooks/lib/decision.mjs). */
|
|
282
|
-
const defer = (reason, rule) => settle({ verdict: "allow", event: "Stop", cwd: p?.cwd, reason, rule });
|
|
283
|
-
try { p = JSON.parse(raw || "{}"); }
|
|
284
|
-
catch (e) { settle({ verdict: "error", event: "Stop", reason: `unparseable payload: ${e.message}` }); }
|
|
285
|
-
|
|
286
|
-
// At most one block per stop chain. Without this a session that cannot run the script at all
|
|
287
|
-
// (read-only cwd, missing node) would be held open forever.
|
|
288
|
-
if (p.stop_hook_active) defer("stop_hook_active — at most one block per stop chain", "loop-guard");
|
|
289
|
-
|
|
290
|
-
// Receipts and the decision ledger sit at the project root, wherever the session's shell ended
|
|
291
|
-
// up (see `projectRoot`); a run started from the top must not read as "never started" because
|
|
292
|
-
// the last command `cd`ed somewhere.
|
|
293
|
-
const cwd = projectRoot(p.cwd || process.cwd());
|
|
294
|
-
const events = readEvents(p.transcript_path);
|
|
295
|
-
// no transcript → no facts → fail open
|
|
296
|
-
if (!events || events.length === 0) defer("no readable transcript — no facts to assert", "no-transcript");
|
|
297
|
-
|
|
298
|
-
// not a harness session → not our business
|
|
299
|
-
if (!dispatchedOrchestrator(events)) defer("session never dispatched the orchestrator — not a harness run", "no-dispatch");
|
|
300
|
-
|
|
301
|
-
// A receipt means the run started. What happens after that is the ship report's census's job and
|
|
302
|
-
// the evaluator's; this hook only asks whether anything started at all.
|
|
303
|
-
const receipts = findReceipts(cwd);
|
|
304
|
-
if (receipts.length > 0) defer(`${receipts.length} run receipt(s) on disk — the run started`, "receipt-present");
|
|
305
|
-
|
|
306
|
-
const census = workCensus(events);
|
|
307
|
-
|
|
308
|
-
// ── The `work-done` fail-open is GONE, and its removal is the point.
|
|
309
|
-
//
|
|
310
|
-
// WHAT IT USED TO DO: `if (census.work_calls > 2) defer("the session did work by other means")`.
|
|
311
|
-
// The stated rationale was that a user may have run the harness steps by hand, or be on a
|
|
312
|
-
// pre-receipt version of the plugin.
|
|
313
|
-
//
|
|
314
|
-
// WHAT IT ACTUALLY DID, observed. `Skill(tech-lead)` was dispatched, the lane could not start,
|
|
315
|
-
// the agent hand-built the whole feature, and the hook ALLOWED the Stop on `"37 work calls — the
|
|
316
|
-
// session did work by other means"`. That session passed its own oracle and read like a clean
|
|
317
|
-
// run, with no board, no T0, no verdict and no receipt. A busy session was the one thing that
|
|
318
|
-
// could switch this gate off — so the harder the agent worked around the harness, the safer it
|
|
319
|
-
// was from the gate watching for exactly that.
|
|
320
|
-
//
|
|
321
|
-
// BOTH HALVES OF THE RATIONALE FAIL ON INSPECTION. Running the harness steps by hand starts with
|
|
322
|
-
// ``harness init run``, which writes the receipt — such a session already deferred at `receipt-present`
|
|
323
|
-
// two lines up and never reached here. And a pre-receipt plugin cannot be the one executing this
|
|
324
|
-
// hook: ``harness init run`` ships in the same install, beside the `lib/` this file imports from.
|
|
325
|
-
// Neither case needs an escape, so no replacement escape is added — an escape that cannot fire
|
|
326
|
-
// is the "row that cannot fail" this project keeps catching.
|
|
327
|
-
//
|
|
328
|
-
// WHAT KEEPS THIS SAFE is not a threshold, it is the loop guard: `stop_hook_active` defers
|
|
329
|
-
// unconditionally, so a session that genuinely did the work outside the harness costs one extra
|
|
330
|
-
// turn and then stops. One nudge, never a hang. That is the trade this gate is worth.
|
|
331
|
-
const message = typeof p.last_assistant_message === "string" ? p.last_assistant_message : "";
|
|
332
|
-
const narration = detectNarration(message);
|
|
333
|
-
const enforcement = enforcementCensus(cwd);
|
|
334
|
-
|
|
335
|
-
return {
|
|
336
|
-
verdict: "block", event: "Stop", cwd, rule: "no-receipt",
|
|
337
|
-
reason: `orchestrator dispatched, ${census.work_calls} work call(s), no run receipt` +
|
|
338
|
-
(enforcement.readable && enforcement.rows === 0 ? "; enforcement layer left zero decision rows" : ""),
|
|
339
|
-
payload: {
|
|
340
|
-
decision: "block",
|
|
341
|
-
reason: buildReason({ narration, census, enforcement }),
|
|
342
|
-
},
|
|
343
|
-
};
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (isMain(import.meta.url)) {
|
|
348
|
-
main();
|
|
349
|
-
}
|
package/hooks/tier-guard.mjs
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Tier guard — PreToolUse hook: the committed tier's write boundary.
|
|
3
|
-
//
|
|
4
|
-
// Refuses an Edit/Write/MultiEdit into `shapeup/<slug>/` whose CONTENT carries a reference the
|
|
5
|
-
// committed tier cannot hold — a path into the gitignored run trace, or a machine-local board id.
|
|
6
|
-
// It is the same rule spec-lint's TIER-DIRECTION already enforces, asked at a different moment.
|
|
7
|
-
//
|
|
8
|
-
// WHY A SECOND ENFORCEMENT POINT FOR A RULE THAT WAS NEVER IN DOUBT. Four different producers wrote
|
|
9
|
-
// committed files this harness's own lint then reds: the requirements registry, the ship report, the
|
|
10
|
-
// project profile, the coverage clauses. The lint was right every time and caught every one of them
|
|
11
|
-
// — at GATE L1b, a whole phase after the sentence was written, addressed to a worker that no longer
|
|
12
|
-
// holds the context needed to rephrase it. The run pays for the round trip, and the fix lands in
|
|
13
|
-
// whatever wording the next reader guesses at.
|
|
14
|
-
//
|
|
15
|
-
// TEACHING DOES NOT CLOSE IT, and that is measured rather than assumed. One of the four recurred
|
|
16
|
-
// INSIDE A SINGLE SESSION, an hour apart, on a different line of the same file, after that same
|
|
17
|
-
// author had fixed the first occurrence and watched its own lint go green. A worker carries no
|
|
18
|
-
// lesson across a dispatch. So the remedy has to be a precondition the model cannot talk past, and
|
|
19
|
-
// it has to speak AT THE WRITE — the one moment the writer still knows what it meant to say.
|
|
20
|
-
//
|
|
21
|
-
// THE PREDICATE IS IMPORTED, NEVER RESTATED. `tierLeaks` is the lint's own scanner
|
|
22
|
-
// (kernel/verify/spec.mjs), so this hook cannot become narrower than the rule it fronts — which
|
|
23
|
-
// would let the defect through to L1b exactly as before — nor wider, which is how a guard earns
|
|
24
|
-
// being switched off. The structural suite executes both over one corpus and requires agreement.
|
|
25
|
-
//
|
|
26
|
-
// Design (deliberately conservative, the shape `sandbox-guard.mjs` argues for):
|
|
27
|
-
// • Fail-OPEN on everything it cannot positively prove: not a write tool, no resolvable path, a
|
|
28
|
-
// path outside `shapeup/<slug>/`, a file form the lint does not scan, or a payload carrying no
|
|
29
|
-
// content to read. A guard that blocks legitimate work gets disabled, and a disabled guard
|
|
30
|
-
// enforces nothing.
|
|
31
|
-
// • `shapeup/knowledge-base/` is OUTSIDE by construction, exactly as it is outside the lint's
|
|
32
|
-
// walk: those files are instructions read by a worker at runtime, not references a reader is
|
|
33
|
-
// expected to resolve, and the defect register they hold has every reason to quote a local path.
|
|
34
|
-
// • Fail-CLOSED only on a leak the lint would red, with the file, the line, the offending token,
|
|
35
|
-
// why it is wrong and what to write instead — all in the denial, because a denial the writer
|
|
36
|
-
// cannot act on immediately just becomes the L1b round trip with extra steps.
|
|
37
|
-
//
|
|
38
|
-
// WHAT IT DOES NOT COVER, stated because the gap is the same one the substrate fence has: this is a
|
|
39
|
-
// PreToolUse hook, so it sees this assistant's own edit path and nothing else. A kernel subcommand
|
|
40
|
-
// writing a committed file, a shell heredoc, or any other editor writes straight through it. Those
|
|
41
|
-
// producers are answered where they are written; this closes the channel the four measured
|
|
42
|
-
// recurrences actually came through.
|
|
43
|
-
//
|
|
44
|
-
// Contract: PreToolUse stdin JSON { tool_name, tool_input:{file_path, content | new_string |
|
|
45
|
-
// edits[]}, cwd }. Deny via { hookSpecificOutput: { hookEventName, permissionDecision:"deny",
|
|
46
|
-
// permissionDecisionReason } }.
|
|
47
|
-
|
|
48
|
-
import { resolve, relative, sep } from "node:path";
|
|
49
|
-
import { isMain } from "../kernel/lib/argv.mjs";
|
|
50
|
-
import { SHARED } from "../kernel/lib/paths.mjs";
|
|
51
|
-
import { SCANNED, tierLeaks } from "../kernel/verify/spec.mjs";
|
|
52
|
-
import { runHook, readStdin, settle, projectRoot } from "./lib/decision.mjs";
|
|
53
|
-
|
|
54
|
-
/** Committed-tier trees that hold instructions rather than references — outside the lint's walk. */
|
|
55
|
-
const NOT_A_SLUG = new Set(["knowledge-base"]);
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The (path, content) pairs one write tool call is about to commit to disk.
|
|
59
|
-
*
|
|
60
|
-
* THE THREE WRITE TOOLS SPELL CONTENT THREE WAYS, and a guard that reads only `content` is blind to
|
|
61
|
-
* the Edit that adds the same line — which is the form the measured recurrence took, since the file
|
|
62
|
-
* already existed by then.
|
|
63
|
-
*
|
|
64
|
-
* @param {object} toolInput - The PreToolUse `tool_input` block.
|
|
65
|
-
* @returns {Array<{path:string, content:string}>} Pairs with a usable path and string content.
|
|
66
|
-
*/
|
|
67
|
-
export function extractWrites(toolInput) {
|
|
68
|
-
const out = [];
|
|
69
|
-
const base = toolInput?.file_path;
|
|
70
|
-
if (typeof toolInput?.content === "string" && base) out.push({ path: base, content: toolInput.content });
|
|
71
|
-
if (typeof toolInput?.new_string === "string" && base) out.push({ path: base, content: toolInput.new_string });
|
|
72
|
-
if (Array.isArray(toolInput?.edits)) {
|
|
73
|
-
for (const e of toolInput.edits) {
|
|
74
|
-
const p = e?.file_path || base;
|
|
75
|
-
if (p && typeof e?.new_string === "string") out.push({ path: p, content: e.new_string });
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return out;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Is this path inside the committed tier the lint walks — `shapeup/<slug>/…`, scanned form?
|
|
83
|
-
*
|
|
84
|
-
* @param {string} rel - Path relative to the project root, as the lint would name it.
|
|
85
|
-
* @returns {boolean} True when a leak here is a leak the lint would red.
|
|
86
|
-
*/
|
|
87
|
-
export function inCommittedTier(rel) {
|
|
88
|
-
if (!rel || rel.startsWith("..") || rel.startsWith(sep)) return false;
|
|
89
|
-
const parts = rel.split(/[\\/]/);
|
|
90
|
-
// `shapeup/<slug>/<file>` — the lint walks a slug's tree, so a file at the tier root belongs to
|
|
91
|
-
// no run and is left alone, and `knowledge-base` is a sibling of the slugs, not one of them.
|
|
92
|
-
if (parts[0] !== SHARED || parts.length < 3 || NOT_A_SLUG.has(parts[1])) return false;
|
|
93
|
-
return SCANNED.test(rel);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function main() {
|
|
97
|
-
await runHook("tier-guard", async () => {
|
|
98
|
-
const raw = await readStdin();
|
|
99
|
-
let p;
|
|
100
|
-
/** Fail-open, with the reason on the record (hooks/lib/decision.mjs). */
|
|
101
|
-
const defer = (reason, rule) => settle({
|
|
102
|
-
verdict: "allow", event: "PreToolUse", tool: p?.tool_name ?? null, cwd: p?.cwd, reason, rule,
|
|
103
|
-
});
|
|
104
|
-
try { p = JSON.parse(raw || "{}"); }
|
|
105
|
-
catch (e) { settle({ verdict: "error", event: "PreToolUse", reason: `unparseable payload: ${e.message}` }); }
|
|
106
|
-
|
|
107
|
-
if (!["Edit", "Write", "MultiEdit"].includes(p.tool_name)) {
|
|
108
|
-
defer(`${p.tool_name ?? "no tool_name"} is not a write tool — out of scope`, "not-write-tool");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// The shell's cwd is where the call fired; the tier lives at the project root. Same split the
|
|
112
|
-
// substrate fence has to make, and for the same reason: a worker that `cd`s into a sub-folder
|
|
113
|
-
// must not thereby leave the tier unguarded. Raw tool paths still resolve against the shell.
|
|
114
|
-
const cwd = p.cwd || process.cwd();
|
|
115
|
-
const root = projectRoot(cwd);
|
|
116
|
-
|
|
117
|
-
const writes = extractWrites(p.tool_input);
|
|
118
|
-
if (writes.length === 0) defer("no readable path+content pair in the tool input", "no-content");
|
|
119
|
-
|
|
120
|
-
const inTier = writes
|
|
121
|
-
.map((w) => ({ ...w, rel: relative(root, resolve(cwd, w.path)) }))
|
|
122
|
-
.filter((w) => inCommittedTier(w.rel));
|
|
123
|
-
if (inTier.length === 0) defer(`${writes.length} write(s), none inside ${SHARED}/<slug>/ in a scanned form`, "not-committed-tier");
|
|
124
|
-
|
|
125
|
-
const blocked = [];
|
|
126
|
-
for (const w of inTier) {
|
|
127
|
-
// THE TOKEN IS QUOTED BACK, which the lint's own message does not do and does not need to —
|
|
128
|
-
// it reports against a file on disk the reader can open at that line. Here the line does not
|
|
129
|
-
// exist yet, so "line 1 points into the local tier" leaves the writer hunting through a
|
|
130
|
-
// fragment it is holding in its head. Naming the exact string is the difference between a
|
|
131
|
-
// denial that is acted on and one that is retried verbatim.
|
|
132
|
-
for (const leak of tierLeaks(w.content)) blocked.push(`${w.rel}:${leak.line} — \`${leak.token}\` ${leak.detail}`);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (blocked.length === 0) {
|
|
136
|
-
defer(`${inTier.length} committed-tier write(s) carry no tier-direction leak — permitted`, "tier-clean");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return {
|
|
140
|
-
verdict: "deny", event: "PreToolUse", tool: p.tool_name, subject: inTier[0].rel, cwd: root,
|
|
141
|
-
rule: "tier-direction",
|
|
142
|
-
reason: `${blocked.length} tier-direction leak(s) refused at the write boundary: ${blocked.join("; ")}`,
|
|
143
|
-
payload: {
|
|
144
|
-
hookSpecificOutput: {
|
|
145
|
-
hookEventName: "PreToolUse",
|
|
146
|
-
permissionDecision: "deny",
|
|
147
|
-
permissionDecisionReason:
|
|
148
|
-
"Tier guard (TIER-DIRECTION) — this write would put a reference into the committed tier that " +
|
|
149
|
-
"cannot survive the trip to another machine:\n" +
|
|
150
|
-
`${blocked.join("\n")}\n` +
|
|
151
|
-
`Refused here rather than at GATE L1b, where spec-lint reds the same file after the phase is over. ` +
|
|
152
|
-
"Rephrase the line now: cite the committed artifact, the use case or the scope_id, or describe the " +
|
|
153
|
-
"tier without naming a path.",
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (isMain(import.meta.url)) {
|
|
161
|
-
main();
|
|
162
|
-
}
|