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
package/hooks/lib/decision.mjs
CHANGED
|
@@ -6,43 +6,50 @@
|
|
|
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-l2 exit=0 stdout_len=0
|
|
10
9
|
// gate-zerowork exit=0 stdout_len=0
|
|
11
10
|
// sandbox-guard exit=0 stdout_len=0
|
|
12
11
|
// safety-spine exit=0 stdout_len=0
|
|
13
|
-
// gate-deadline exit=0 stdout_len=0
|
|
14
12
|
// gate-intake exit=0 stdout_len=0
|
|
15
|
-
//
|
|
13
|
+
// verify envelope exit=0 stdout_len=0
|
|
16
14
|
//
|
|
17
15
|
// exit 0 + silence = allow. But that is ALSO what "inspected the board and deferred" looks like,
|
|
18
|
-
// and what "no rule matched" looks like, and what a thrown exception looks like, and what
|
|
19
|
-
//
|
|
20
|
-
// orchestrator or auditor
|
|
21
|
-
//
|
|
16
|
+
// and what "no rule matched" looks like, and what a thrown exception looks like, and what an inert
|
|
17
|
+
// hook looks like — one whose entire body silently never ran. Four states, one signature. No test,
|
|
18
|
+
// orchestrator or auditor can tell them apart, which is how a whole enforcement layer can sit
|
|
19
|
+
// inert while every one of its checks reports success.
|
|
22
20
|
//
|
|
23
|
-
// FAIL-OPEN IS RETAINED, DELIBERATELY
|
|
24
|
-
//
|
|
21
|
+
// FAIL-OPEN IS RETAINED, DELIBERATELY, and every hook here argues for it in its own header: a gate
|
|
22
|
+
// that breaks legitimate or standalone runs just gets disabled, and a disabled gate enforces
|
|
25
23
|
// nothing. The defect was never the direction. It is that `allow` carried NO EVIDENCE.
|
|
26
24
|
//
|
|
27
|
-
// THE PREDICATE IS ALREADY INVENTED IN THIS REPO.
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// hooks, which closes
|
|
25
|
+
// THE PREDICATE IS ALREADY INVENTED IN THIS REPO. The structural suite calls its helper `spoke()`
|
|
26
|
+
// — did the script produce output? That is exactly the right question. It existed only in the test
|
|
27
|
+
// harness, applied to entry points. This promotes it to runtime and applies it to
|
|
28
|
+
// hooks, which closes the whole CLASS rather than one instance of it:
|
|
31
29
|
//
|
|
32
30
|
// inspected-and-permitted · no-rule-matched · threw · never ran
|
|
33
31
|
//
|
|
34
|
-
// all four become distinguishable facts in
|
|
32
|
+
// all four become distinguishable facts in the decisions ledger.
|
|
35
33
|
//
|
|
36
34
|
// TIER: LOCAL, and checkout-wide rather than per-slug — hooks fire outside any run, so there is
|
|
37
35
|
// frequently no `<slug>` to file under. Pure run-trace; the durable cross-machine record is the
|
|
38
36
|
// committed metrics shard, which `stats --hooks` aggregates into.
|
|
39
37
|
//
|
|
38
|
+
// THE PATH IS RESOLVED, NEVER SPELLED. It comes from `lib/paths.mjs` — the same resolver
|
|
39
|
+
// ``harness probe stats` --hooks` reads through. This file used to hardcode the pre-ADR-0001 root, so every
|
|
40
|
+
// hook wrote its receipts to `.shapeup-sdlc/` while the only reader looked in `.shapeup/`:
|
|
41
|
+
// `stats --hooks` reported zero hook activity on every project, which is indistinguishable from
|
|
42
|
+
// the inert-enforcement-layer failure this file exists to make visible. A telemetry channel with
|
|
43
|
+
// a hardcoded root is a telemetry channel with a silent disconnect in it.
|
|
44
|
+
//
|
|
40
45
|
// THE RECEIPT IS BEST-EFFORT BY DESIGN. An unwritable `decisions.jsonl` must never turn into a
|
|
41
46
|
// failed tool call: a receipt that can break a run would get the whole layer disabled, which is
|
|
42
47
|
// the exact outcome this file exists to prevent. Every write here is inside a try/catch.
|
|
43
48
|
|
|
44
49
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
45
|
-
import {
|
|
50
|
+
import { dirname } from "node:path";
|
|
51
|
+
import { decisions } from "../../kernel/lib/paths.mjs";
|
|
52
|
+
import { resolveRunId } from "../../kernel/lib/paths.mjs";
|
|
46
53
|
|
|
47
54
|
/**
|
|
48
55
|
* Where the receipts land.
|
|
@@ -54,11 +61,11 @@ import { join, dirname } from "node:path";
|
|
|
54
61
|
* contaminates is not an instrument.
|
|
55
62
|
*
|
|
56
63
|
* @param {string} [cwd] - Project root; defaults to the process cwd.
|
|
57
|
-
* @returns {string} The ledger path — `SHAPEUP_DECISIONS_PATH` when set, else
|
|
58
|
-
*
|
|
64
|
+
* @returns {string} The ledger path — `SHAPEUP_DECISIONS_PATH` when set, else the LOCAL root's
|
|
65
|
+
* `decisions.jsonl`, resolved through `lib/paths.mjs`.
|
|
59
66
|
*/
|
|
60
67
|
export function decisionsPath(cwd) {
|
|
61
|
-
return process.env.SHAPEUP_DECISIONS_PATH ||
|
|
68
|
+
return process.env.SHAPEUP_DECISIONS_PATH || decisions(cwd || process.cwd());
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
/**
|
|
@@ -160,6 +167,18 @@ export async function runHook(name, fn) {
|
|
|
160
167
|
at: new Date().toISOString(),
|
|
161
168
|
hook: name,
|
|
162
169
|
pid: process.pid,
|
|
170
|
+
// WHICH RUN THIS DECISION BELONGS TO — resolved from the active-scope pointer, best-effort.
|
|
171
|
+
//
|
|
172
|
+
// The ledger is checkout-wide by design (a hook frequently fires with no `<slug>` to file
|
|
173
|
+
// under), which is exactly why the row needs the key: without it, "the enforcement layer denied
|
|
174
|
+
// 4 writes" cannot be attributed to a run, so a denial rate cannot be compared between runs and
|
|
175
|
+
// an inert layer in ONE run is invisible inside a healthy checkout-wide total.
|
|
176
|
+
//
|
|
177
|
+
// `null` is a real answer, not a failure: a hook firing outside any run genuinely belongs to no
|
|
178
|
+
// run, and recording that is what lets the export tier partition ambient decisions from run
|
|
179
|
+
// ones. Resolution reads two small files and swallows every error — a receipt must never be
|
|
180
|
+
// able to fail a tool call.
|
|
181
|
+
run_id: (() => { try { return resolveRunId(d.cwd || process.cwd()); } catch { return null; } })(),
|
|
163
182
|
event: d.event ?? null,
|
|
164
183
|
tool: d.tool ?? null,
|
|
165
184
|
subject: d.subject ?? null,
|
|
@@ -168,7 +187,7 @@ export async function runHook(name, fn) {
|
|
|
168
187
|
rule: d.rule ?? null,
|
|
169
188
|
}, d.cwd);
|
|
170
189
|
// Deny/block/warn payloads are emitted by definition. `emit: true` covers the hooks whose whole
|
|
171
|
-
// job is to SAY something on an allow —
|
|
190
|
+
// job is to SAY something on an allow — an injected context hint, for instance — so
|
|
172
191
|
// a permitting hook can still write to stdout without pretending to be a denial.
|
|
173
192
|
//
|
|
174
193
|
// WHY `warn` IS ITS OWN VERDICT (ADR-0001). An advisory gate permits the call, so the obvious
|
package/hooks/safety-spine.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Safety spine — PreToolUse hook (v1.2
|
|
2
|
+
// Safety spine — PreToolUse hook (v1.2).
|
|
3
3
|
//
|
|
4
4
|
// The three older hooks guard the PIPELINE (gate order, envelopes, substrates); this one
|
|
5
5
|
// guards the MACHINE and the git remote — the substrate everything else sits on. It denies
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// the model must never widen its own safety envelope (same principle
|
|
18
18
|
// as sandbox-guard's active-scope pointer rule)
|
|
19
19
|
//
|
|
20
|
-
// Deliberately conservative, mirrors
|
|
20
|
+
// Deliberately conservative, mirrors the GATE L2 block/sandbox-guard: fail-OPEN on unparseable input
|
|
21
21
|
// or an unmatched command (precision over reach — a spine that broke `rm -rf ./build` would
|
|
22
22
|
// just get disabled), fail-CLOSED the moment a rule provably matches.
|
|
23
23
|
//
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
import { readFileSync, existsSync } from "node:fs";
|
|
34
34
|
import { resolve, join, basename } from "node:path";
|
|
35
35
|
import { globToRegExp, logPathology } from "./sandbox-guard.mjs";
|
|
36
|
-
import { isMain } from "../
|
|
37
|
-
import { LOCAL, safetyOverrides, metricsShard } from "../
|
|
36
|
+
import { isMain } from "../kernel/lib/argv.mjs";
|
|
37
|
+
import { LOCAL, safetyOverrides, metricsShard } from "../kernel/lib/paths.mjs";
|
|
38
38
|
|
|
39
39
|
import { runHook, readStdin, settle } from "./lib/decision.mjs";
|
|
40
40
|
|
package/hooks/sandbox-guard.mjs
CHANGED
|
@@ -1,36 +1,59 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Sandbox guard — PreToolUse hook (
|
|
2
|
+
// Sandbox guard — PreToolUse hook (the PA3 countermeasure: writes outside the order's substrate).
|
|
3
3
|
//
|
|
4
|
-
// Blocks Edit/Write/MultiEdit calls that
|
|
5
|
-
//
|
|
6
|
-
// own scope" from prose into a precondition the model cannot talk past — the same pattern as
|
|
7
|
-
// hooks/gate-l2.mjs for GATE L2.
|
|
4
|
+
// Blocks Edit/Write/MultiEdit calls that no LIVE ORDER's `substrate` block permits. Turns "a worker
|
|
5
|
+
// only writes what the run authorised" from prose into a precondition the model cannot talk past.
|
|
8
6
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
7
|
+
// IT ENFORCES THE ORDER, NOT THE SCOPE CONTRACT, and that is the whole design. ``harness compile``
|
|
8
|
+
// already stamps a write contract onto every order from `substrateFor(operation)` — allowed,
|
|
9
|
+
// shared, append_only, frozen. Resolving the scope contract instead covered exactly one operation,
|
|
10
|
+
// the build, because only build orders carry a scope; every other dispatch (`analyze`, `wire`,
|
|
11
|
+
// `evaluate`, `hunt`, `coach` …) ran unfenced, and the `frozen`/`append_only` surfaces the
|
|
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.
|
|
15
|
+
//
|
|
16
|
+
// IT READS EVERY LIVE ORDER, NOT A POINTER TO ONE. `.shapeup/active-order` still names the run and
|
|
17
|
+
// seeds the search — `harness compile` publishes it as it writes each order, which is what fences
|
|
18
|
+
// the lanes that never reach the workflow (`--tiny`, the prose round loop, a standalone `/build`).
|
|
19
|
+
// But a single pointer cannot survive concurrency: with scopes building side by side the last
|
|
20
|
+
// compile wins the pointer, and a write from scope A is then judged against scope B's contract —
|
|
21
|
+
// a false block or a false permit depending on which way the race fell. So the candidate set is
|
|
22
|
+
// every order under `orders/` with no matching file under `results/` (compiled, not yet ingested),
|
|
23
|
+
// and a write is permitted when SOME live contract covers it.
|
|
24
|
+
//
|
|
25
|
+
// That is the same question as "the writer's own contract" because scope substrates are disjoint by
|
|
26
|
+
// construction — `harness verify spec`'s DISJOINT rule fails a spec where two scopes claim the same
|
|
27
|
+
// path, and it runs at GATE L1b before any build starts. `frozen` is checked across all of them, so
|
|
28
|
+
// a path one scope froze stays frozen while another scope is in flight.
|
|
29
|
+
//
|
|
30
|
+
// Design (deliberately conservative, mirrors the GATE L2 block):
|
|
31
|
+
// • Fail-OPEN whenever there is nothing to enforce: no active-order pointer (not running inside
|
|
32
|
+
// a harness dispatch), pointer names an order that doesn't exist or is unparsable, the order
|
|
33
|
+
// declares no boundaries at all, or the tool call carries no resolvable file path. A guard
|
|
34
|
+
// that breaks legitimate non-harness edits would just get disabled.
|
|
35
|
+
// • Fail-CLOSED the moment an order IS live and the target is outside what it permits — deny,
|
|
36
|
+
// naming the reason so the model can self-correct. `frozen` outranks everything, including
|
|
37
|
+
// an `allowed` glob that would otherwise match; `append_only` permits Edit and denies Write,
|
|
38
|
+
// because Write overwrites what the append was supposed to preserve.
|
|
16
39
|
// • Run-trace carve-out — writes under the ACTIVE feature's LOCAL gitignored root
|
|
17
40
|
// (`.shapeup/<slug>/`) are always allowed: that root is harness bookkeeping the doer
|
|
18
41
|
// is REQUIRED to write (task-executor P3 status/AC ticks + tasks/_index.md, run-state,
|
|
19
42
|
// execution logs, the P3.7 discovery ledger). Substrate globs whitelist product code and
|
|
20
|
-
// never list the run-trace, so without the carve-out
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
43
|
+
// never list the run-trace, so without the carve-out a scoped round leaves its own task files
|
|
44
|
+
// stale. Deliberately narrow: only the active slug's root. The pointers at the `.shapeup/`
|
|
45
|
+
// root — `active-order` (this guard's own) and `active-scope` (the run pointer) — sit OUTSIDE
|
|
46
|
+
// the carve-out by construction, so a worker cannot widen its own sandbox by rewriting the
|
|
47
|
+
// thing that defines it.
|
|
24
48
|
// • Every denial is also appended to the metrics pathology log (telemetry, not just defense).
|
|
25
49
|
//
|
|
26
50
|
// Contract: PreToolUse stdin JSON { tool_name, tool_input:{file_path | edits[].file_path}, cwd }.
|
|
27
51
|
// Deny via { hookSpecificOutput: { hookEventName, permissionDecision:"deny", permissionDecisionReason } }.
|
|
28
52
|
|
|
29
|
-
import { readFileSync, existsSync, appendFileSync, mkdirSync } from "node:fs";
|
|
53
|
+
import { readFileSync, existsSync, appendFileSync, mkdirSync, readdirSync } from "node:fs";
|
|
30
54
|
import { resolve, join, relative, dirname, sep } from "node:path";
|
|
31
|
-
import { isMain } from "../
|
|
32
|
-
import { LOCAL,
|
|
33
|
-
import { readContract, SCOPE_CONTRACT } from "../skills/tech-lead/scripts/lib/contract-md.mjs";
|
|
55
|
+
import { isMain } from "../kernel/lib/argv.mjs";
|
|
56
|
+
import { LOCAL, activeOrder, ordersDir, resultsDir, metricsShard } from "../kernel/lib/paths.mjs";
|
|
34
57
|
import { runHook, readStdin, settle } from "./lib/decision.mjs";
|
|
35
58
|
|
|
36
59
|
// --- tiny glob matcher: supports *, **, ? — enough for substrate globs, zero dependencies ---
|
|
@@ -65,6 +88,31 @@ function readJSON(p) {
|
|
|
65
88
|
try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
|
|
66
89
|
}
|
|
67
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Every order for this run that has been compiled and not yet ingested.
|
|
93
|
+
*
|
|
94
|
+
* "Not yet ingested" is read off the filesystem — an order with a same-named file under `results/`
|
|
95
|
+
* has finished — because that is the only signal that survives a killed session. The pointer's own
|
|
96
|
+
* order is always included, even when its result has landed, so the single-order lane behaves
|
|
97
|
+
* exactly as it did before concurrency existed.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} cwd - Project root.
|
|
100
|
+
* @param {string} slug - The active run's slug.
|
|
101
|
+
* @param {string} pointerOrder - Absolute path of the order the pointer names.
|
|
102
|
+
* @returns {object[]} Parsed orders; unreadable files are skipped, never treated as permissive.
|
|
103
|
+
*/
|
|
104
|
+
function liveOrders(cwd, slug, pointerOrder) {
|
|
105
|
+
const dir = ordersDir(cwd, slug);
|
|
106
|
+
const done = new Set(existsSync(resultsDir(cwd, slug)) ? readdirSync(resultsDir(cwd, slug)) : []);
|
|
107
|
+
const paths = new Set(existsSync(pointerOrder) ? [pointerOrder] : []);
|
|
108
|
+
if (existsSync(dir)) {
|
|
109
|
+
for (const f of readdirSync(dir)) {
|
|
110
|
+
if (f.endsWith(".json") && !done.has(f)) paths.add(join(dir, f));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return [...paths].map(readJSON).filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
|
|
68
116
|
function extractPaths(toolInput) {
|
|
69
117
|
const paths = [];
|
|
70
118
|
if (toolInput?.file_path) paths.push(toolInput.file_path);
|
|
@@ -97,46 +145,78 @@ async function main() {
|
|
|
97
145
|
}
|
|
98
146
|
|
|
99
147
|
const cwd = p.cwd || process.cwd();
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
148
|
+
const activeOrderPath = activeOrder(cwd);
|
|
149
|
+
if (!existsSync(activeOrderPath)) defer("no active-order pointer — no tracked task running", "no-round");
|
|
150
|
+
|
|
151
|
+
const active = readJSON(activeOrderPath);
|
|
152
|
+
if (!active?.slug || !active?.order_path) defer("active-order pointer is unreadable or incomplete", "bad-pointer");
|
|
153
|
+
|
|
154
|
+
// EVERY LIVE ORDER, not just the pointer's. The pointer names one order, and with scopes building
|
|
155
|
+
// CONCURRENTLY the last compile wins it — so a write from scope A would be judged against scope
|
|
156
|
+
// B's contract, which is either a false block or a false permit depending on which way the race
|
|
157
|
+
// fell. Scope substrates are disjoint by construction (spec-lint's DISJOINT rule fails a spec
|
|
158
|
+
// where they are not), so "is this write inside SOME live contract" and "is it inside the
|
|
159
|
+
// writer's own contract" are the same question — and only the first can be asked without a
|
|
160
|
+
// shared mutable pointer.
|
|
161
|
+
//
|
|
162
|
+
// Live = compiled and not yet ingested. An order whose result is on disk has finished; leaving it
|
|
163
|
+
// in the candidate set would keep a finished scope's substrate open for the rest of the run.
|
|
164
|
+
const orders = liveOrders(cwd, active.slug, resolve(cwd, active.order_path));
|
|
165
|
+
if (orders.length === 0) defer(`no live order for ${active.slug}`, "no-order");
|
|
166
|
+
|
|
167
|
+
const withSubstrate = orders.filter((o) => o.substrate);
|
|
168
|
+
if (withSubstrate.length === 0) defer("no live order declares a substrate block", "no-substrate");
|
|
169
|
+
|
|
170
|
+
const contracts = withSubstrate.map((o) => ({
|
|
171
|
+
order_id: o.order_id,
|
|
172
|
+
allowed: [...(o.substrate.allowed || []), ...(o.substrate.shared || [])],
|
|
173
|
+
appendOnly: o.substrate.append_only || [],
|
|
174
|
+
frozen: o.substrate.frozen || [],
|
|
175
|
+
})).filter((c) => c.allowed.length || c.appendOnly.length || c.frozen.length);
|
|
176
|
+
|
|
177
|
+
if (contracts.length === 0) defer("no live order declares write/append/frozen boundaries", "no-whitelist");
|
|
119
178
|
|
|
120
179
|
const targetPaths = extractPaths(p.tool_input);
|
|
121
180
|
if (targetPaths.length === 0) defer("no writable path in the tool input", "no-target");
|
|
122
181
|
|
|
123
182
|
const metricsPath = metricsShard(cwd);
|
|
124
|
-
// Run-trace carve-out (see header): the active feature's LOCAL root only. The prefix ends
|
|
125
|
-
// with a separator so a sibling `<local>/<slug>-other/` can't ride along, and the active-scope
|
|
126
|
-
// pointer sits outside it by construction.
|
|
127
183
|
const runTracePrefix = join(LOCAL, active.slug) + sep;
|
|
128
184
|
const violations = [];
|
|
185
|
+
const blockReasons = [];
|
|
186
|
+
|
|
129
187
|
for (const raw of targetPaths) {
|
|
130
188
|
const abs = resolve(cwd, raw);
|
|
131
189
|
const rel = relative(cwd, abs);
|
|
132
190
|
if (rel.startsWith(runTracePrefix)) continue;
|
|
133
|
-
|
|
191
|
+
|
|
192
|
+
// Frozen takes absolute precedence, and it is checked across EVERY live contract: a path one
|
|
193
|
+
// scope froze stays frozen while another scope is in flight, which is the whole point of
|
|
194
|
+
// declaring it.
|
|
195
|
+
const freezer = contracts.find((c) => matchesAny(rel, c.frozen));
|
|
196
|
+
if (freezer) {
|
|
197
|
+
violations.push(rel);
|
|
198
|
+
blockReasons.push(`${rel} is frozen by ${freezer.order_id}`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (contracts.some((c) => matchesAny(rel, c.allowed))) continue; // inside a live contract
|
|
203
|
+
|
|
204
|
+
if (contracts.some((c) => matchesAny(rel, c.appendOnly))) {
|
|
205
|
+
if (p.tool_name === "Write") {
|
|
206
|
+
violations.push(rel);
|
|
207
|
+
blockReasons.push(`${rel} is append-only (Write overwrites, use Edit)`);
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
violations.push(rel);
|
|
213
|
+
blockReasons.push(`${rel} is outside every live order's allowed scopes`);
|
|
134
214
|
}
|
|
135
215
|
|
|
136
216
|
// Inside the substrate — the "inspected and permitted" row. Previously byte-identical to
|
|
137
217
|
// "this hook never ran", which is how 26 enforcement points sat inert behind 610 green checks.
|
|
138
218
|
if (violations.length === 0) {
|
|
139
|
-
defer(`${targetPaths.length} path(s) inside
|
|
219
|
+
defer(`${targetPaths.length} path(s) inside a live order's substrate (${contracts.length} live) — permitted`, "in-substrate");
|
|
140
220
|
}
|
|
141
221
|
|
|
142
222
|
logPathology(metricsPath, {
|
|
@@ -144,23 +224,23 @@ async function main() {
|
|
|
144
224
|
at: new Date().toISOString(),
|
|
145
225
|
kind: "pathology",
|
|
146
226
|
pathology: "PA3",
|
|
147
|
-
|
|
227
|
+
order: active.order_path,
|
|
148
228
|
slug: active.slug,
|
|
149
229
|
blocked_paths: violations,
|
|
150
230
|
});
|
|
151
231
|
|
|
152
232
|
return {
|
|
153
|
-
verdict: "deny", event: "PreToolUse", tool: p.tool_name, subject: active.
|
|
233
|
+
verdict: "deny", event: "PreToolUse", tool: p.tool_name, subject: active.order_path, cwd,
|
|
154
234
|
rule: "outside-substrate",
|
|
155
|
-
reason: `${violations.length} write(s)
|
|
235
|
+
reason: `${violations.length} write(s) rejected by substrate boundaries: ${blockReasons.join("; ")}`,
|
|
156
236
|
payload: {
|
|
157
237
|
hookSpecificOutput: {
|
|
158
238
|
hookEventName: "PreToolUse",
|
|
159
239
|
permissionDecision: "deny",
|
|
160
240
|
permissionDecisionReason:
|
|
161
|
-
`Sandbox guard (PA3) —
|
|
162
|
-
|
|
163
|
-
`the
|
|
241
|
+
`Sandbox guard (PA3) — no live order's substrate covers these writes:\n` +
|
|
242
|
+
`${blockReasons.join("\n")}\n` +
|
|
243
|
+
`If this write legitimately crosses scopes, the order's substrate needs to be expanded (e.g. via ba --remap).`,
|
|
164
244
|
},
|
|
165
245
|
},
|
|
166
246
|
};
|