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/hooks.json
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
|
-
"SessionStart": [
|
|
4
|
-
{
|
|
5
|
-
"hooks": [
|
|
6
|
-
{
|
|
7
|
-
"type": "command",
|
|
8
|
-
"command": "echo \"shapeup-sdlc-plugin loaded from ${CLAUDE_PLUGIN_ROOT}\""
|
|
9
|
-
}
|
|
10
|
-
]
|
|
11
|
-
}
|
|
12
|
-
],
|
|
13
3
|
"PreToolUse": [
|
|
14
4
|
{
|
|
15
5
|
"matcher": "Bash|Read|Write|Edit|MultiEdit",
|
|
@@ -21,16 +11,6 @@
|
|
|
21
11
|
}
|
|
22
12
|
]
|
|
23
13
|
},
|
|
24
|
-
{
|
|
25
|
-
"matcher": "Skill",
|
|
26
|
-
"hooks": [
|
|
27
|
-
{
|
|
28
|
-
"type": "command",
|
|
29
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gate-intake.mjs\"",
|
|
30
|
-
"timeout": 10
|
|
31
|
-
}
|
|
32
|
-
]
|
|
33
|
-
},
|
|
34
14
|
{
|
|
35
15
|
"matcher": "Skill|Agent",
|
|
36
16
|
"hooks": [
|
|
@@ -50,16 +30,6 @@
|
|
|
50
30
|
"timeout": 10
|
|
51
31
|
}
|
|
52
32
|
]
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
"matcher": "Edit|Write|MultiEdit",
|
|
56
|
-
"hooks": [
|
|
57
|
-
{
|
|
58
|
-
"type": "command",
|
|
59
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/tier-guard.mjs\"",
|
|
60
|
-
"timeout": 10
|
|
61
|
-
}
|
|
62
|
-
]
|
|
63
33
|
}
|
|
64
34
|
],
|
|
65
35
|
"PostToolUse": [
|
|
@@ -73,17 +43,6 @@
|
|
|
73
43
|
}
|
|
74
44
|
]
|
|
75
45
|
}
|
|
76
|
-
],
|
|
77
|
-
"Stop": [
|
|
78
|
-
{
|
|
79
|
-
"hooks": [
|
|
80
|
-
{
|
|
81
|
-
"type": "command",
|
|
82
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gate-zerowork.mjs\"",
|
|
83
|
-
"timeout": 10
|
|
84
|
-
}
|
|
85
|
-
]
|
|
86
|
-
}
|
|
87
46
|
]
|
|
88
47
|
}
|
|
89
48
|
}
|
package/hooks/lib/decision.mjs
CHANGED
|
@@ -6,10 +6,8 @@
|
|
|
6
6
|
// input, every gate in this repo answered the same way:
|
|
7
7
|
//
|
|
8
8
|
// $ echo 'NOT JSON AT ALL {{{' | node hooks/<gate>.mjs
|
|
9
|
-
// gate-zerowork exit=0 stdout_len=0
|
|
10
9
|
// sandbox-guard exit=0 stdout_len=0
|
|
11
10
|
// safety-spine exit=0 stdout_len=0
|
|
12
|
-
// gate-intake exit=0 stdout_len=0
|
|
13
11
|
// verify envelope exit=0 stdout_len=0
|
|
14
12
|
//
|
|
15
13
|
// exit 0 + silence = allow. But that is ALSO what "inspected the board and deferred" looks like,
|
|
@@ -32,13 +30,12 @@
|
|
|
32
30
|
// all four become distinguishable facts in the decisions ledger.
|
|
33
31
|
//
|
|
34
32
|
// TIER: LOCAL, and checkout-wide rather than per-slug — hooks fire outside any run, so there is
|
|
35
|
-
// frequently no `<slug>` to file under. Pure run-trace
|
|
36
|
-
// committed metrics shard, which `stats --hooks` aggregates into.
|
|
33
|
+
// frequently no `<slug>` to file under. Pure run-trace: one JSON object per line, read directly.
|
|
37
34
|
//
|
|
38
35
|
// THE PATH IS RESOLVED, NEVER SPELLED. It comes from `lib/paths.mjs` — the same resolver
|
|
39
|
-
//
|
|
36
|
+
// every reader goes through. This file used to hardcode the earlier root, so every
|
|
40
37
|
// hook wrote its receipts to `.shapeup-sdlc/` while the only reader looked in `.shapeup/`:
|
|
41
|
-
//
|
|
38
|
+
// the ledger showed zero hook activity on every project, which is indistinguishable from
|
|
42
39
|
// the inert-enforcement-layer failure this file exists to make visible. A telemetry channel with
|
|
43
40
|
// a hardcoded root is a telemetry channel with a silent disconnect in it.
|
|
44
41
|
//
|
|
@@ -97,7 +94,7 @@ export function projectRoot(cwd) {
|
|
|
97
94
|
*
|
|
98
95
|
* `SHAPEUP_DECISIONS_PATH` redirects the ledger. It exists because this project's OWN test suite
|
|
99
96
|
* executes the real hooks, and without a redirect every `npm test` appended ~21 rows to the
|
|
100
|
-
* developer's live `decisions.jsonl` — which would then be read back
|
|
97
|
+
* developer's live `decisions.jsonl` — which would then be read back as if
|
|
101
98
|
* they were evaluations from a real run. A measurement instrument that its own test suite
|
|
102
99
|
* contaminates is not an instrument.
|
|
103
100
|
*
|
|
@@ -241,11 +238,11 @@ export async function runHook(name, fn) {
|
|
|
241
238
|
// job is to SAY something on an allow — an injected context hint, for instance — so
|
|
242
239
|
// a permitting hook can still write to stdout without pretending to be a denial.
|
|
243
240
|
//
|
|
244
|
-
// WHY `warn` IS ITS OWN VERDICT
|
|
241
|
+
// WHY `warn` IS ITS OWN VERDICT. An advisory gate permits the call, so the obvious
|
|
245
242
|
// encoding is `verdict: "allow"`. That would make "permitted because the rule was satisfied"
|
|
246
243
|
// byte-identical to "permitted DESPITE the rule being broken" — the exact indistinguishability
|
|
247
244
|
// this file exists to eliminate, reintroduced one level up. A gate that downgrades from deny to
|
|
248
|
-
// advisory must stay countable, or
|
|
245
|
+
// advisory must stay countable, or the ledger silently loses the measurement.
|
|
249
246
|
if (d.payload && (d.verdict === "deny" || d.verdict === "block" || d.verdict === "warn" || d.emit)) {
|
|
250
247
|
process.stdout.write(JSON.stringify(d.payload));
|
|
251
248
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The tier-direction rule, on a string: a committed file cannot carry a reference that only resolves
|
|
2
|
+
// on the machine that wrote it. Two forms — a board id (`TASK-NNN`, renumbered per machine) and a path
|
|
3
|
+
// into the gitignored run tier. The substrate fence calls this at the moment a tool writes the text,
|
|
4
|
+
// while the writer still holds the context to rephrase it.
|
|
5
|
+
|
|
6
|
+
import { LOCAL } from "../../kernel/lib/paths.mjs";
|
|
7
|
+
|
|
8
|
+
/** Text forms worth scanning; anything else in a committed tree is not a reference carrier. */
|
|
9
|
+
export const SCANNED = /\.(md|markdown|yml|yaml|json|txt)$/i;
|
|
10
|
+
|
|
11
|
+
/** A machine-local board id. Strict on purpose: a committed tree has no reason to carry one at all. */
|
|
12
|
+
const TASK_ID = /\bTASK-[A-Za-z0-9][\w.-]*/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The tier-direction violations one piece of text carries — the whole rule, on a string.
|
|
16
|
+
*
|
|
17
|
+
* The detail strings are the message the writer reads, so they carry the remedy, not just the
|
|
18
|
+
* verdict: a board id resolves on the machine that wrote it and nowhere else, and a path into the
|
|
19
|
+
* gitignored tier dangles on every clone.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} text - The file body, or the fragment a tool is about to write.
|
|
22
|
+
* @returns {Array<{kind:("board-id"|"local-path"), token:string, line:number, detail:string}>}
|
|
23
|
+
* One entry per offending line and form, in file order; [] when clean.
|
|
24
|
+
*/
|
|
25
|
+
export function tierLeaks(text) {
|
|
26
|
+
// Built from the LOCAL constant, never a literal — the storage roots have exactly one home.
|
|
27
|
+
const esc = LOCAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
28
|
+
// `\S+` where the walk's own rule is `\S`: the same LINES match either way (a path with one
|
|
29
|
+
// non-space character after the slash has at least one), and the longer form yields the token to
|
|
30
|
+
// quote back at the writer. Trailing punctuation the prose wrapped it in is trimmed off the
|
|
31
|
+
// quote only — never off the test.
|
|
32
|
+
const localPath = new RegExp(`${esc}/\\S+`);
|
|
33
|
+
const leaks = [];
|
|
34
|
+
String(text ?? "").split(/\r?\n/).forEach((line, i) => {
|
|
35
|
+
const task = line.match(TASK_ID);
|
|
36
|
+
if (task) {
|
|
37
|
+
leaks.push({ kind: "board-id", token: task[0], line: i + 1, detail:
|
|
38
|
+
`names ${task[0]} — a committed file cannot carry a board id. Boards live in ${LOCAL}/ ` +
|
|
39
|
+
"(gitignored) and renumber on every regeneration, so this resolves on the machine that wrote it " +
|
|
40
|
+
"and nowhere else. Cite the use case or the scope_id, which are stable." });
|
|
41
|
+
}
|
|
42
|
+
const path = line.match(localPath);
|
|
43
|
+
if (path) {
|
|
44
|
+
leaks.push({ kind: "local-path", token: path[0].replace(/[`)\]},.;:'"]+$/, ""), line: i + 1, detail:
|
|
45
|
+
`points into ${LOCAL}/ — a committed file cannot reference the gitignored tier; the path ` +
|
|
46
|
+
"dangles on every other clone. Name the committed artifact, or describe the tier without a path." });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return leaks;
|
|
50
|
+
}
|
package/hooks/sandbox-guard.mjs
CHANGED
|
@@ -1,68 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Sandbox guard — PreToolUse hook
|
|
2
|
+
// Sandbox guard — PreToolUse hook: writes outside the live order's substrate are refused.
|
|
3
3
|
//
|
|
4
4
|
// Blocks Edit/Write/MultiEdit calls that no LIVE ORDER's `substrate` block permits. Turns "a worker
|
|
5
5
|
// only writes what the run authorised" from prose into a precondition the model cannot talk past.
|
|
6
|
+
// It also refuses, at the write, a committed-tier file whose text names a run-trace path or a board id
|
|
7
|
+
// (`committedTierLeaks` below) — the tier-direction rule, asked where the writer can still rephrase.
|
|
6
8
|
//
|
|
7
|
-
// IT ENFORCES THE ORDER, NOT THE SCOPE CONTRACT
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// the
|
|
11
|
-
//
|
|
12
|
-
// compiler emits had no enforcer at all. Reading the order makes the contract the compiler writes
|
|
13
|
-
// and the contract the hook enforces the same object, for every operation, with no per-operation
|
|
14
|
-
// code here.
|
|
9
|
+
// IT ENFORCES THE ORDER, NOT THE SCOPE CONTRACT. The loop stamps a write contract onto every order
|
|
10
|
+
// it writes — allowed, shared, append_only, frozen, own — and reading the order makes that contract
|
|
11
|
+
// and the one this hook enforces the same object, for every operation, with no per-operation code
|
|
12
|
+
// here. Resolving the scope contract instead would cover only the build, because only build orders
|
|
13
|
+
// carry a scope.
|
|
15
14
|
//
|
|
16
|
-
// IT READS EVERY LIVE ORDER, NOT A POINTER TO ONE. `.shapeup/active-order` names the run and
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// A
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// order under `orders/` that is not yet ANSWERED, and a write is permitted when SOME live contract
|
|
23
|
-
// covers it.
|
|
15
|
+
// IT READS EVERY LIVE ORDER, NOT A POINTER TO ONE. `.shapeup/active-order` names the run and nothing
|
|
16
|
+
// more; the loop sets it with `harness order open` and `harness order close` retires it. A single
|
|
17
|
+
// pointer cannot survive concurrency: with scopes building side by side the last order opened wins
|
|
18
|
+
// it, and a write from scope A would be judged against scope B's contract — a false block or a false
|
|
19
|
+
// permit depending on which way the race fell. So the candidate set is every order under `orders/`
|
|
20
|
+
// that is not yet ANSWERED, and a write is permitted when SOME live contract covers it.
|
|
24
21
|
//
|
|
25
|
-
// THE POINTER IS NOT A LIVENESS SIGNAL
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// the rule below — and it cost the checkout permanently. The pointer has one writer and no eraser,
|
|
29
|
-
// so the LAST dispatch of a FINISHED run stayed live for good and fenced everything to that one
|
|
30
|
-
// substrate: after a ship, an ordinary edit anywhere in the repo was denied, and the next feature
|
|
31
|
-
// could not write even its own run trace, because the carve-out below is keyed to the slug the
|
|
32
|
-
// stale pointer names. The documented fail-open state ("no pointer — not inside a dispatch") became
|
|
33
|
-
// unreachable after the first run, and the only way out was to delete a file nothing documents.
|
|
34
|
-
// The arm is gone; `reduce ship` and ``harness init run --force`` retire the pointer as well, so a
|
|
35
|
-
// leftover one is untidy rather than load-bearing.
|
|
22
|
+
// THE POINTER IS NOT A LIVENESS SIGNAL. It arms the fence — with no pointer every write is allowed —
|
|
23
|
+
// but liveness is "written, no result yet", read off the order set, so a run that ended and retired
|
|
24
|
+
// its pointer fences nothing and a leftover pointer is untidy rather than load-bearing.
|
|
36
25
|
//
|
|
37
|
-
// ANSWERED IS A COMPARISON, NOT A PRESENCE TEST
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// touch cannot perturb. An order carrying no stamp falls back to presence, which is all it ever had.
|
|
26
|
+
// ANSWERED IS A COMPARISON, NOT A PRESENCE TEST. Order filenames for the run-level operations carry
|
|
27
|
+
// no round, so re-dispatching one inside the same run rewrites the order beside the PREVIOUS
|
|
28
|
+
// dispatch's result — and a presence test would read that as finished and run the new dispatch
|
|
29
|
+
// unfenced. An order counts as answered only when its result file is at least as new as the order's
|
|
30
|
+
// own `compiled_at`, the stamp the loop writes INTO the order, which a copy or a touch cannot
|
|
31
|
+
// perturb. An order carrying no stamp falls back to presence.
|
|
44
32
|
//
|
|
45
|
-
// A RUN-LEVEL ORDER RETIRES AT ITS PHASE BOUNDARY
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// the
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
// compiles its own order. So an order for a RUN-LEVEL operation stops being live once an order for a
|
|
53
|
-
// DIFFERENT operation has been compiled after it — the window the committed tier already has, a
|
|
54
|
-
// phase boundary rather than the middle of somebody else's dispatch. Build legs are exempt: they run
|
|
55
|
-
// concurrently, finish out of order, and an abandoned one is resolved by ``harness init run --force``
|
|
56
|
-
// rather than by a sibling's compile stamp. Two concurrent legs of the SAME operation never retire
|
|
57
|
-
// each other, for that same reason. An order carrying no operation or no `compiled_at` keeps
|
|
33
|
+
// A RUN-LEVEL ORDER RETIRES AT ITS PHASE BOUNDARY. A phase dispatch whose worker never returned — a
|
|
34
|
+
// killed evaluation, a QA leg that escalated without a result — would otherwise stay live for the rest
|
|
35
|
+
// of the run and fence everything its `frozen` list names. The next phase writing its own order is
|
|
36
|
+
// the signal: an order for a RUN-LEVEL operation stops being live once an order for a DIFFERENT
|
|
37
|
+
// operation has been written after it. Build legs are exempt: they run concurrently, finish out of
|
|
38
|
+
// order, and an abandoned one is resolved by `harness init run --force`. Two concurrent legs of the
|
|
39
|
+
// SAME operation never retire each other. An order carrying no operation or no `compiled_at` keeps
|
|
58
40
|
// fencing — an unreadable claim is not a retired one.
|
|
59
41
|
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
42
|
+
// "The writer's own contract" and "some live contract" are the same question because scope
|
|
43
|
+
// substrates are disjoint by construction: the loop reads the contracts for overlap before it
|
|
44
|
+
// releases scopes together. `frozen` is checked across all of them, so a path one scope froze stays
|
|
45
|
+
// frozen while another scope is in flight.
|
|
64
46
|
//
|
|
65
|
-
// Design (deliberately conservative
|
|
47
|
+
// Design (deliberately conservative):
|
|
66
48
|
// • Fail-OPEN whenever there is nothing to enforce: no active-order pointer (not running inside
|
|
67
49
|
// a harness dispatch), pointer names an order that doesn't exist or is unparsable, the order
|
|
68
50
|
// declares no boundaries at all, or the tool call carries no resolvable file path. A guard
|
|
@@ -72,14 +54,11 @@
|
|
|
72
54
|
// an `allowed` glob that would otherwise match; `append_only` permits Edit and denies Write,
|
|
73
55
|
// because Write overwrites what the append was supposed to preserve.
|
|
74
56
|
// • Run-trace carve-out — writes under the ACTIVE feature's LOCAL gitignored root
|
|
75
|
-
// (`.shapeup/<slug>/`) are
|
|
76
|
-
// is REQUIRED to write
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// root — `active-order` (this guard's own) and `active-scope` (the run pointer) — sit OUTSIDE
|
|
81
|
-
// the carve-out by construction, so a worker cannot widen its own sandbox by rewriting the
|
|
82
|
-
// thing that defines it.
|
|
57
|
+
// (`.shapeup/<slug>/`) are allowed unless a live order freezes them: that root is bookkeeping
|
|
58
|
+
// the doer is REQUIRED to write. Deliberately narrow: only the active slug's root. The pointers
|
|
59
|
+
// at the `.shapeup/` root — `active-order` (this guard's own) and `active-scope` (the run
|
|
60
|
+
// pointer) — sit OUTSIDE the carve-out by construction, so a worker cannot widen its own
|
|
61
|
+
// sandbox by rewriting the thing that defines it.
|
|
83
62
|
// • Every denial is also appended to the metrics pathology log (telemetry, not just defense).
|
|
84
63
|
//
|
|
85
64
|
// Contract: PreToolUse stdin JSON { tool_name, tool_input:{file_path | edits[].file_path}, cwd }.
|
|
@@ -90,6 +69,7 @@ import { resolve, join, relative, dirname, basename, sep } from "node:path";
|
|
|
90
69
|
import { isMain } from "../kernel/lib/argv.mjs";
|
|
91
70
|
import { LOCAL, SHARED, activeOrder, ordersDir, resultsDir, metricsShard, dispatchReceipts } from "../kernel/lib/paths.mjs";
|
|
92
71
|
import { runHook, readStdin, settle, projectRoot } from "./lib/decision.mjs";
|
|
72
|
+
import { SCANNED, tierLeaks } from "./lib/tier.mjs";
|
|
93
73
|
|
|
94
74
|
// --- tiny glob matcher: supports *, **, ? — enough for substrate globs, zero dependencies ---
|
|
95
75
|
export function globToRegExp(glob) {
|
|
@@ -313,6 +293,77 @@ export function logPathology(metricsPath, event) {
|
|
|
313
293
|
} catch { /* telemetry is best-effort; never block on a logging failure */ }
|
|
314
294
|
}
|
|
315
295
|
|
|
296
|
+
/** Committed-tier trees that hold instructions rather than references — outside the lint's walk. */
|
|
297
|
+
const NOT_A_SLUG = new Set(["knowledge-base"]);
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The (path, content) pairs one write tool call is about to commit to disk.
|
|
301
|
+
*
|
|
302
|
+
* THE THREE WRITE TOOLS SPELL CONTENT THREE WAYS, and a check that reads only `content` is blind to
|
|
303
|
+
* the Edit that adds the same line — which is the form the measured recurrence took, since the file
|
|
304
|
+
* already existed by then.
|
|
305
|
+
*
|
|
306
|
+
* @param {object} toolInput - The PreToolUse `tool_input` block.
|
|
307
|
+
* @returns {Array<{path:string, content:string}>} Pairs with a usable path and string content.
|
|
308
|
+
*/
|
|
309
|
+
export function extractWrites(toolInput) {
|
|
310
|
+
const out = [];
|
|
311
|
+
const base = toolInput?.file_path;
|
|
312
|
+
if (typeof toolInput?.content === "string" && base) out.push({ path: base, content: toolInput.content });
|
|
313
|
+
if (typeof toolInput?.new_string === "string" && base) out.push({ path: base, content: toolInput.new_string });
|
|
314
|
+
if (Array.isArray(toolInput?.edits)) {
|
|
315
|
+
for (const e of toolInput.edits) {
|
|
316
|
+
const p = e?.file_path || base;
|
|
317
|
+
if (p && typeof e?.new_string === "string") out.push({ path: p, content: e.new_string });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Is this path inside the committed tier the lint walks — `shapeup/<slug>/…`, scanned form?
|
|
325
|
+
*
|
|
326
|
+
* @param {string} rel - Path relative to the project root, as the lint would name it.
|
|
327
|
+
* @returns {boolean} True when a leak here is a leak the lint would red.
|
|
328
|
+
*/
|
|
329
|
+
export function inCommittedTier(rel) {
|
|
330
|
+
if (!rel || rel.startsWith("..") || rel.startsWith(sep)) return false;
|
|
331
|
+
const parts = rel.split(/[\\/]/);
|
|
332
|
+
// `shapeup/<slug>/<file>` — the lint walks a slug's tree, so a file at the tier root belongs to
|
|
333
|
+
// no run and is left alone, and `knowledge-base` is a sibling of the slugs, not one of them.
|
|
334
|
+
if (parts[0] !== SHARED || parts.length < 3 || NOT_A_SLUG.has(parts[1])) return false;
|
|
335
|
+
return SCANNED.test(rel);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The committed tier's write boundary: the tier-direction leaks this call would commit.
|
|
340
|
+
*
|
|
341
|
+
* It is the tier-direction rule, asked at the write — the one
|
|
342
|
+
* moment the writer still knows what it meant to say, because a worker carries no lesson across a
|
|
343
|
+
* dispatch and the lint reds the same file a phase later. The predicate is the lint's own scanner
|
|
344
|
+
* (`tierLeaks`), imported and never restated, so this cannot become narrower or wider than the rule.
|
|
345
|
+
* `shapeup/knowledge-base/` is outside, exactly as it is outside the lint's walk. Fail-OPEN on
|
|
346
|
+
* everything it cannot positively prove: no path, no content, a path outside `shapeup/<slug>/`, a
|
|
347
|
+
* file form the lint does not scan.
|
|
348
|
+
*
|
|
349
|
+
* @param {object} toolInput - The PreToolUse `tool_input` block.
|
|
350
|
+
* @param {string} cwd - The shell's working directory, against which a raw tool path resolves.
|
|
351
|
+
* @param {string} root - The project root the committed tier lives under.
|
|
352
|
+
* @returns {{rel:string, blocked:string[]}} The first in-tier path and one line per leak; `blocked`
|
|
353
|
+
* is empty when the write carries none.
|
|
354
|
+
*/
|
|
355
|
+
export function committedTierLeaks(toolInput, cwd, root) {
|
|
356
|
+
const inTier = extractWrites(toolInput)
|
|
357
|
+
.map((w) => ({ ...w, rel: relative(root, resolve(cwd, w.path)) }))
|
|
358
|
+
.filter((w) => inCommittedTier(w.rel));
|
|
359
|
+
const blocked = [];
|
|
360
|
+
// THE TOKEN IS QUOTED BACK, which the lint's own message does not need to do — it reports against a
|
|
361
|
+
// file on disk the reader can open at that line. Here the line does not exist yet, so naming the
|
|
362
|
+
// exact string is the difference between a denial that is acted on and one that is retried verbatim.
|
|
363
|
+
for (const w of inTier) for (const leak of tierLeaks(w.content)) blocked.push(`${w.rel}:${leak.line} — \`${leak.token}\` ${leak.detail}`);
|
|
364
|
+
return { rel: inTier[0]?.rel ?? "", blocked };
|
|
365
|
+
}
|
|
366
|
+
|
|
316
367
|
async function main() {
|
|
317
368
|
await runHook("sandbox-guard", async () => {
|
|
318
369
|
const raw = await readStdin();
|
|
@@ -336,6 +387,31 @@ async function main() {
|
|
|
336
387
|
// the shell's own cwd, because that is what a relative path in the tool input means.
|
|
337
388
|
const cwd = p.cwd || process.cwd();
|
|
338
389
|
const root = projectRoot(cwd);
|
|
390
|
+
|
|
391
|
+
// THE COMMITTED TIER FIRST, and before every "no live order" exit below: the tier-direction rule
|
|
392
|
+
// holds with no run in flight, and a write that is clean here falls through to the substrate fence.
|
|
393
|
+
const tier = committedTierLeaks(p.tool_input, cwd, root);
|
|
394
|
+
if (tier.blocked.length) {
|
|
395
|
+
return {
|
|
396
|
+
verdict: "deny", event: "PreToolUse", tool: p.tool_name, subject: tier.rel, cwd: root,
|
|
397
|
+
rule: "tier-direction",
|
|
398
|
+
reason: `${tier.blocked.length} tier-direction leak(s) refused at the write boundary: ${tier.blocked.join("; ")}`,
|
|
399
|
+
payload: {
|
|
400
|
+
hookSpecificOutput: {
|
|
401
|
+
hookEventName: "PreToolUse",
|
|
402
|
+
permissionDecision: "deny",
|
|
403
|
+
permissionDecisionReason:
|
|
404
|
+
"Tier guard (TIER-DIRECTION) — this write would put a reference into the committed tier that " +
|
|
405
|
+
"cannot survive the trip to another machine:\n" +
|
|
406
|
+
`${tier.blocked.join("\n")}\n` +
|
|
407
|
+
"Refused here, while the writer still knows what it meant to say. " +
|
|
408
|
+
"Rephrase the line now: cite the committed artifact, the use case or the scope_id, or describe " +
|
|
409
|
+
"the tier without naming a path.",
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
339
415
|
const activeOrderPath = activeOrder(root);
|
|
340
416
|
if (!existsSync(activeOrderPath)) defer("no active-order pointer — no tracked task running", "no-round");
|
|
341
417
|
|
|
@@ -343,9 +419,9 @@ async function main() {
|
|
|
343
419
|
if (!active?.slug || !active?.order_path) defer("active-order pointer is unreadable or incomplete", "bad-pointer");
|
|
344
420
|
|
|
345
421
|
// EVERY LIVE ORDER, not just the pointer's. The pointer names one order, and with scopes building
|
|
346
|
-
// CONCURRENTLY the last
|
|
422
|
+
// CONCURRENTLY the last order opened wins it — so a write from scope A would be judged against scope
|
|
347
423
|
// B's contract, which is either a false block or a false permit depending on which way the race
|
|
348
|
-
// fell. Scope substrates are disjoint by construction (
|
|
424
|
+
// fell. Scope substrates are disjoint by construction (the loop's planning read-back fails a spec
|
|
349
425
|
// where they are not), so "is this write inside SOME live contract" and "is it inside the
|
|
350
426
|
// writer's own contract" are the same question — and only the first can be asked without a
|
|
351
427
|
// shared mutable pointer.
|
|
@@ -364,7 +440,7 @@ async function main() {
|
|
|
364
440
|
allowed: [...(o.substrate.allowed || []), ...(o.substrate.shared || [])],
|
|
365
441
|
appendOnly: o.substrate.append_only || [],
|
|
366
442
|
frozen: o.substrate.frozen || [],
|
|
367
|
-
// The one exception to "frozen outranks everything", and it is the
|
|
443
|
+
// The one exception to "frozen outranks everything", and it is the loop's to grant, never
|
|
368
444
|
// the worker's to request: the paths THIS order authors, named from its own identity. A build
|
|
369
445
|
// leg's substrate freezes the whole run trace, so without this its own WorkResult — its
|
|
370
446
|
// documented last step — would be denied along with every channel it must not touch.
|