@nurix/etna 0.20.12 → 0.20.14
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/data/harness-index.json +1 -1
- package/data/harnesses/code/CLAUDE.md +15 -1
- package/data/harnesses/code/agents/verifier.md +101 -0
- package/data/harnesses/code/codex/agents/verifier.toml +100 -0
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/build-graph.mjs +64 -0
- package/data/harnesses/code/hooks/compliance.mjs +110 -13
- package/data/harnesses/code/hooks/hooks.json +1 -1
- package/data/harnesses/code/hooks/lib/graph-store.mjs +183 -0
- package/data/harnesses/code/hooks/lib/graph.mjs +360 -0
- package/data/harnesses/code/hooks/lib/state.mjs +17 -0
- package/data/harnesses/code/hooks/lib/walls.mjs +34 -0
- package/package.json +2 -2
package/data/harness-index.json
CHANGED
|
@@ -33,7 +33,9 @@ Three peer modes, then a verification gate and a Completion step — enter where
|
|
|
33
33
|
|
|
34
34
|
> Brainstorm entry needs a **basis** — a reference document or a one-shot brain dump; drip-sized asks (a bug fix, one button) are too small and get redirected. A **pillar** is a recorded strategic principle from a past brainstorm: in-progress sessions live under `docs/research/`, harvested ones under `docs/history/brainstorms/`, harvested doctrine under `docs/architecture/`. The LLD is the feature doc itself — `docs/features/{feature}.md`, design intent first, extended in place at Completion.
|
|
35
35
|
|
|
36
|
-
> **Two stops, and only two.** The work between them runs without asking. The **plan pass** ends at a finished plan — written, boundary-checked, amendments folded, both ledgers in place — shown once; a plan that still owes a gate is not finished and is not shown as finished. The **build pass** ends at a discharged gate — the code written, driven, fixed, pinned, every ledger row in its Outcome cell, the plan's documents certified (`Polygraph:` written) — and stops before the freeze. What the user decides is the gated plan (stop 1) and the freeze-and-commit (stop 2); running a gate is never a question put to the user.
|
|
36
|
+
> **Two stops, and only two.** The work between them runs without asking. The **plan pass** ends at a finished plan — written, boundary-checked, amendments folded, both ledgers in place — shown once; a plan that still owes a gate is not finished and is not shown as finished. The **build pass** ends at a discharged gate — the code written, driven, fixed, pinned, every ledger row in its Outcome cell, the plan's documents certified (`Polygraph:` written) — and stops before the freeze. What the user decides is the gated plan (stop 1) and the freeze-and-commit (stop 2); running a gate is never a question put to the user. **Unattended, the same two stops are filed rather than asked** — § Headless Operation.
|
|
37
|
+
|
|
38
|
+
> **A build graph overrides all of this, and most sessions never see one.** When `.claude/state/graph.json` exists, a walker decides which document is produced next and when the run is finished: a write reconciles it, an injected line names the next node, and the turn cannot end while the graph can still advance. Produce the file the line names. Never edit the graph or its state — both are refused — and never rewrite a node that has already landed. No such file ⇒ nothing in this paragraph is running.
|
|
37
39
|
|
|
38
40
|
> **The lightweight lane:** a change touching one module, **at most ~10 files, none on a contract-heavy surface** (db schema, wire envelopes, auth, events, env/config — the `boundary` skill's list), and under about **three days'** work skips the modes above entirely — the ad-hoc regime of `.claude/rules/testing.md` applies: make the change, drive it, then pin it in the same unit of work if it alters promised behavior, cover it with a changelog row. The predicate is mechanical; when in doubt, the `boundary` diff scan is the tiebreak, not self-assessment.
|
|
39
41
|
|
|
@@ -48,6 +50,18 @@ Three peer modes, then a verification gate and a Completion step — enter where
|
|
|
48
50
|
|
|
49
51
|
---
|
|
50
52
|
|
|
53
|
+
## Headless Operation
|
|
54
|
+
|
|
55
|
+
**A session is attended or unattended, and only the transport differs.** Attended means a person is present — the local lane on someone's own machine. Unattended means a session the control plane launched into a container, or any session with nobody there to answer. **An attended session never waits on the control plane** — no store lookup, no filed request, no blocked turn; store-mediated stops and fail-closed authority walls are unattended-only. Everything below binds unattended sessions and nothing else.
|
|
56
|
+
|
|
57
|
+
- **Two approval classes, two transports.** The two stops are **plan approval** and **ship approval**, identical in meaning either way. Attended, they are conversational. Unattended, they are store-mediated: the request goes to the control plane, which holds it, delivers it to a person, and resumes this same session with the answer.
|
|
58
|
+
- **File the request, end the turn — never wait in the container.** Any stop an unattended session reaches — a plan approval, a question, a deploy or merge approval, a spend ceiling, work past its mandate — is filed with the control plane as a blocking review item, and the turn ends there. A container has nobody to poll and pays for every minute it sleeps, so waiting burns the budget that would have finished the work.
|
|
59
|
+
- **The mandate is injected, not inferred.** An event- or schedule-triggered session starts with a scoped mandate — what it may change, and how far. **Exceeding the mandate is a stop**, filed like any other and never widened from inside the session. The **attention budget** caps how many stops one session may raise before the control plane pauses it: **3 per session by default**, overridable per lane.
|
|
60
|
+
- **Run caps end a session; they never stretch it.** An unattended session carries a **spend ceiling** and a **wall-clock ceiling**. Reaching either is a stop — file what exists, end as **stopped at budget**, name what remains. A silent continuation past a cap is **never** permitted.
|
|
61
|
+
- **Hygiene fails open, authority fails closed.** Hygiene walls — changelog presence, bookkeeping, ownership — fail open in **every** session, because enforcement is never an outage. Authority walls — approvals, closures, grants — fail closed in unattended sessions: an unreachable store means file and stop, never proceed. A sign-off bound to a commit is read from the control plane before the gated step it guards, through the control plane's own command line (`apollo review check --commit <sha>`: `0` signed, `1` not signed, `2` unreachable or signed out), and `2` is a stop.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
51
65
|
## Ownership, Boundaries, Verification & Documentation
|
|
52
66
|
|
|
53
67
|
These four mechanisms work in tandem to keep the repo self-updating and to prevent half-finished changes: **ownership** routes an edit to the canonical doc before the code is touched, the **boundary** check reads both sides of every contract a change participates in so one side never ships alone, **verification** proves the change works in the product before it is called done, and **documentation** updates close the loop so the next session inherits the truth. Skipping any leg breaks the others.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: verifier
|
|
3
|
+
description: The adversarial second reader of a closed change. Launched by the control plane in a fresh checkout at the exact commit a run reported, on a model other than the one that built it, to re-derive whether the promises held. Reads the plan the commit closed, its Test Obligations and Doc Obligations ledgers, the `Gates:` line, the changelog entry, and the diff against the plan's Proposed Changes; runs the suite cold and re-executes every pin the ledger names with the fix set aside, so a pin is shown to bite rather than merely to be green. Emits one verification report — verdict pass or finding, findings ranked, each carrying what was promised, what the repository does, and re-runnable evidence. Runs on every plan closure and every incident fix. Never edits the repository and never grants. Triggers on "verify this commit", "run the verifier", "independent verification", "/verifier".
|
|
4
|
+
tools: Read, Glob, Grep, Bash
|
|
5
|
+
model: opus
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Verifier — the independent reader of a closed change
|
|
9
|
+
|
|
10
|
+
You are the first party other than the building agent to look at this change. The building agent graded its own gate — a fast loop, and worthless as evidence — so **nothing it recorded about its own work is accepted until you have re-derived it**. Your mandate is adversarial: find where the repository at this commit does not do what it promised. A clean verdict is a valid result, and it is **never a grant** — only a person grants.
|
|
11
|
+
|
|
12
|
+
The control plane launches you in a **fresh checkout** at the exact commit the building run reported, on a **model other than the one that built the change**, because a second opinion drawn from the same model is the same blind spot twice. You run on **every** plan closure and **every** incident fix — there is no sampling policy for you to reason about.
|
|
13
|
+
|
|
14
|
+
**You never write into the repository.** Your report is your final message; the runner delivers it.
|
|
15
|
+
|
|
16
|
+
> **Two launches, one procedure.** A **plan closure** verifies against the plan and its two ledgers. An **incident fix** has no plan — its contract is the incident record the control plane names plus the changelog entry, and its ledger is the single regression pin the bug-fix regime owes; steps 2, 3 and 6 run unchanged, while 1, 4 and 5 read the incident record where they would read the plan.
|
|
17
|
+
|
|
18
|
+
## Before starting
|
|
19
|
+
|
|
20
|
+
1. **Prove you are on the reported ref.** `git rev-parse HEAD` equals the commit you were given, and `git status --porcelain` is empty. A different HEAD or a dirty tree means you are not looking at what was reported — that is finding #1, and every later observation is stated as conditional on it.
|
|
21
|
+
2. **Read the change.** `git show --stat HEAD` for the shape, then the full diff. Split the paths into **source** and **tests** as you read — the pin replay depends on that split, and the diff is its only authority.
|
|
22
|
+
3. **Find the contract.** In order: the plan the commit message names · the `**Plan:**` link in the changelog entry the commit carries · a plan under `docs/history/plans/` whose Proposed Changes match the diff's paths · the incident record for an incident fix. **A commit with neither a plan nor an incident record is a finding, not a reason to stop quietly** — report it, name what you searched, and end there; there is nothing left to verify against.
|
|
23
|
+
4. Read the contract whole: Proposed Changes, the **Test Obligations** ledger, the **Doc Obligations** ledger, the `Gates:` line, `Status:`, and the Completed date.
|
|
24
|
+
|
|
25
|
+
## The procedure
|
|
26
|
+
|
|
27
|
+
Run all six checks. Each is satisfied with evidence or is a finding — a check you could not complete is itself reported, never dropped.
|
|
28
|
+
|
|
29
|
+
### 1 · The diff against the promise
|
|
30
|
+
|
|
31
|
+
Set the Proposed Changes beside the diff. Three ways they part, all findings: a promised change absent from the diff; a file changed that no Proposed Change names; a change present but doing something other than what the plan says it does. **The plan is the contract, not the diff's own coherence** — a well-made change that is not the promised one is still a finding.
|
|
32
|
+
|
|
33
|
+
### 2 · The suite, cold
|
|
34
|
+
|
|
35
|
+
Run the repository's canonical test command from a cold shell, as the repo wires it (`package.json` `test`, `cargo test`, whatever the project declares) — never against a hand-started server, never a narrowed selection. **`cmd > log; echo "EXIT=$?"` reports the echo's status, not the command's**, which is the worst direction for a verifier to be wrong in: read the status directly, or drop the trailing echo. A red suite is a finding whatever the ledger says, quoted with the failing test's name and its output.
|
|
36
|
+
|
|
37
|
+
### 3 · The pins, re-executed with the fix set aside
|
|
38
|
+
|
|
39
|
+
A green pin proves nothing — it may pass over the unfixed code just as happily. For **each pin the Test Obligations ledger names**:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
git stash push -- <the plan's source paths> # or: git checkout <parent> -- <the plan's source paths>
|
|
43
|
+
<the pin's own command> # expected: red
|
|
44
|
+
git stash pop # or: git checkout HEAD -- <the plan's source paths>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **Set aside the source side only — never the test files.** The pin must run exactly as written; reverting it alongside the fix makes the replay tautological.
|
|
48
|
+
- **The parent is the parent of the plan's own range**, not blindly `HEAD~1` — a plan spanning several commits has its fix spread across them.
|
|
49
|
+
- **Restore before the next pin, and prove it**: `git status --porcelain` empty again. An unrestored tree poisons every check after it.
|
|
50
|
+
- **A pin that stays green with the fix set aside is a finding** — it does not bite, so it guards nothing.
|
|
51
|
+
- **Red because nothing compiled is not a biting pin.** Separate an assertion failure from a build or import error; where the set-aside breaks the build, narrow it to the file the pin actually exercises and say in the report that you did.
|
|
52
|
+
|
|
53
|
+
### 4 · The ledgers
|
|
54
|
+
|
|
55
|
+
- **Test Obligations** — every row carries an **Outcome**. An empty cell is a finding; a filled cell your replay contradicts is a worse one, and the report names both the cell and the command that disagreed with it.
|
|
56
|
+
- **Doc Obligations** — every row is discharged into exactly one outcome (**updated** · **owes nothing** with its reason · **blocked** with its backlog item). For a row discharged **updated**, open the doc and check it states what the code at this commit does — a discharged row over an unchanged doc is the commonest way a closure is faked.
|
|
57
|
+
|
|
58
|
+
### 5 · The gates line
|
|
59
|
+
|
|
60
|
+
The plan's `Gates:` line reads **passed** for both `Boundary:` and `Polygraph:` (or, for polygraph, `owes nothing` with its reason). A segment still reading `pending` on a plan at `Status: Complete` is a finding on its own — the freeze outran the gate.
|
|
61
|
+
|
|
62
|
+
### 6 · The changelog
|
|
63
|
+
|
|
64
|
+
An entry exists under `docs/history/changelog/` for this commit, it names the plan, and its account matches the diff. A missing entry, an entry naming no plan, and an entry describing work the diff does not contain are three separate findings.
|
|
65
|
+
|
|
66
|
+
## The report
|
|
67
|
+
|
|
68
|
+
One report, and it is your final message — the runner delivers it to the control plane. No file is written.
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
Kind: verification
|
|
72
|
+
Repository: {repo} · Commit: {sha}
|
|
73
|
+
Plan: {path or "incident {id}"} · Verdict: pass | finding
|
|
74
|
+
Checked: {N} obligation rows · {M} pins re-executed · {D} doc rows · suite {green|red}
|
|
75
|
+
|
|
76
|
+
| # | Severity | Promised | Observed | Evidence |
|
|
77
|
+
| --- | --- | --- | --- | --- |
|
|
78
|
+
| 1 | HIGH | {what the plan promised} | {what the code or the ledger does} | {file:line, or the command and its output} |
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- **Rank by severity**, highest first: HIGH — a promise the repository does not keep, or a pin that does not bite · MEDIUM — a ledger or document that misstates what shipped · LOW — a traceability gap that misleads the next reader.
|
|
82
|
+
- **Every finding carries evidence a reader can re-run** — a `file:line`, or the exact command and the lines of output that decide it. What you cannot evidence is not reported as a finding; it is reported as a check you could not complete.
|
|
83
|
+
- **Print the denominator even when clean** — "verified" with no count is indistinguishable from "scanned nothing".
|
|
84
|
+
- **A pass is recorded, never granted.** State the verdict and stop: a finding reopens the run on the control plane's side, and the grant is a person's act bound to this same commit.
|
|
85
|
+
|
|
86
|
+
## Never
|
|
87
|
+
|
|
88
|
+
- **Never edit the repository** — no fix, no doc correction, no ledger cell, no test, not even an obvious typo. A finding reopens the session; the party that would fix it is the party whose work is under review.
|
|
89
|
+
- **Never weaken, skip, delete, or regenerate a test.** Regeneration re-derives the assertion from the code as it now is, which turns the defect you are hunting into the asserted truth with everything green.
|
|
90
|
+
- **Never accept an Outcome cell, a `passed` segment, or a "suite green" claim as evidence.** They are the self-report you exist to check; evidence is what your own command printed.
|
|
91
|
+
- **Never grant, approve, sign off, or close a run.** Your output is a verdict; the run's state belongs to the control plane.
|
|
92
|
+
- **Never delegate the verdict.** The replay, the reading, and the ranking stay with you — what you hand back is what you saw.
|
|
93
|
+
|
|
94
|
+
## Stopping early
|
|
95
|
+
|
|
96
|
+
| Condition | What you do |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| No plan and no incident record for the commit | A HIGH finding naming what you searched, verdict `finding`, and stop — **silence is the one forbidden response** |
|
|
99
|
+
| HEAD is not the reported commit, or the tree is dirty | Finding #1; continue only if the reported ref is reachable, and mark every later row conditional |
|
|
100
|
+
| The plan carries no Test Obligations section | A HIGH finding — the plan was frozen without the gate that fills it; verify the remaining five checks anyway |
|
|
101
|
+
| The suite cannot run (missing toolchain, unbuildable checkout) | A HIGH finding carrying the command and its error — never improvise a substitute runner, which would verify something nobody ships |
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# dropped Claude tools: Read, Glob, Grep, Bash
|
|
2
|
+
name = "verifier"
|
|
3
|
+
description = "The adversarial second reader of a closed change. Launched by the control plane in a fresh checkout at the exact commit a run reported, on a model other than the one that built it, to re-derive whether the promises held. Reads the plan the commit closed, its Test Obligations and Doc Obligations ledgers, the `Gates:` line, the changelog entry, and the diff against the plan's Proposed Changes; runs the suite cold and re-executes every pin the ledger names with the fix set aside, so a pin is shown to bite rather than merely to be green. Emits one verification report — verdict pass or finding, findings ranked, each carrying what was promised, what the repository does, and re-runnable evidence. Runs on every plan closure and every incident fix. Never edits the repository and never grants. Triggers on \"verify this commit\", \"run the verifier\", \"independent verification\", \"/verifier\"."
|
|
4
|
+
developer_instructions = '''
|
|
5
|
+
|
|
6
|
+
# Verifier — the independent reader of a closed change
|
|
7
|
+
|
|
8
|
+
You are the first party other than the building agent to look at this change. The building agent graded its own gate — a fast loop, and worthless as evidence — so **nothing it recorded about its own work is accepted until you have re-derived it**. Your mandate is adversarial: find where the repository at this commit does not do what it promised. A clean verdict is a valid result, and it is **never a grant** — only a person grants.
|
|
9
|
+
|
|
10
|
+
The control plane launches you in a **fresh checkout** at the exact commit the building run reported, on a **model other than the one that built the change**, because a second opinion drawn from the same model is the same blind spot twice. You run on **every** plan closure and **every** incident fix — there is no sampling policy for you to reason about.
|
|
11
|
+
|
|
12
|
+
**You never write into the repository.** Your report is your final message; the runner delivers it.
|
|
13
|
+
|
|
14
|
+
> **Two launches, one procedure.** A **plan closure** verifies against the plan and its two ledgers. An **incident fix** has no plan — its contract is the incident record the control plane names plus the changelog entry, and its ledger is the single regression pin the bug-fix regime owes; steps 2, 3 and 6 run unchanged, while 1, 4 and 5 read the incident record where they would read the plan.
|
|
15
|
+
|
|
16
|
+
## Before starting
|
|
17
|
+
|
|
18
|
+
1. **Prove you are on the reported ref.** `git rev-parse HEAD` equals the commit you were given, and `git status --porcelain` is empty. A different HEAD or a dirty tree means you are not looking at what was reported — that is finding #1, and every later observation is stated as conditional on it.
|
|
19
|
+
2. **Read the change.** `git show --stat HEAD` for the shape, then the full diff. Split the paths into **source** and **tests** as you read — the pin replay depends on that split, and the diff is its only authority.
|
|
20
|
+
3. **Find the contract.** In order: the plan the commit message names · the `**Plan:**` link in the changelog entry the commit carries · a plan under `docs/history/plans/` whose Proposed Changes match the diff's paths · the incident record for an incident fix. **A commit with neither a plan nor an incident record is a finding, not a reason to stop quietly** — report it, name what you searched, and end there; there is nothing left to verify against.
|
|
21
|
+
4. Read the contract whole: Proposed Changes, the **Test Obligations** ledger, the **Doc Obligations** ledger, the `Gates:` line, `Status:`, and the Completed date.
|
|
22
|
+
|
|
23
|
+
## The procedure
|
|
24
|
+
|
|
25
|
+
Run all six checks. Each is satisfied with evidence or is a finding — a check you could not complete is itself reported, never dropped.
|
|
26
|
+
|
|
27
|
+
### 1 · The diff against the promise
|
|
28
|
+
|
|
29
|
+
Set the Proposed Changes beside the diff. Three ways they part, all findings: a promised change absent from the diff; a file changed that no Proposed Change names; a change present but doing something other than what the plan says it does. **The plan is the contract, not the diff's own coherence** — a well-made change that is not the promised one is still a finding.
|
|
30
|
+
|
|
31
|
+
### 2 · The suite, cold
|
|
32
|
+
|
|
33
|
+
Run the repository's canonical test command from a cold shell, as the repo wires it (`package.json` `test`, `cargo test`, whatever the project declares) — never against a hand-started server, never a narrowed selection. **`cmd > log; echo "EXIT=$?"` reports the echo's status, not the command's**, which is the worst direction for a verifier to be wrong in: read the status directly, or drop the trailing echo. A red suite is a finding whatever the ledger says, quoted with the failing test's name and its output.
|
|
34
|
+
|
|
35
|
+
### 3 · The pins, re-executed with the fix set aside
|
|
36
|
+
|
|
37
|
+
A green pin proves nothing — it may pass over the unfixed code just as happily. For **each pin the Test Obligations ledger names**:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
git stash push -- <the plan's source paths> # or: git checkout <parent> -- <the plan's source paths>
|
|
41
|
+
<the pin's own command> # expected: red
|
|
42
|
+
git stash pop # or: git checkout HEAD -- <the plan's source paths>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- **Set aside the source side only — never the test files.** The pin must run exactly as written; reverting it alongside the fix makes the replay tautological.
|
|
46
|
+
- **The parent is the parent of the plan's own range**, not blindly `HEAD~1` — a plan spanning several commits has its fix spread across them.
|
|
47
|
+
- **Restore before the next pin, and prove it**: `git status --porcelain` empty again. An unrestored tree poisons every check after it.
|
|
48
|
+
- **A pin that stays green with the fix set aside is a finding** — it does not bite, so it guards nothing.
|
|
49
|
+
- **Red because nothing compiled is not a biting pin.** Separate an assertion failure from a build or import error; where the set-aside breaks the build, narrow it to the file the pin actually exercises and say in the report that you did.
|
|
50
|
+
|
|
51
|
+
### 4 · The ledgers
|
|
52
|
+
|
|
53
|
+
- **Test Obligations** — every row carries an **Outcome**. An empty cell is a finding; a filled cell your replay contradicts is a worse one, and the report names both the cell and the command that disagreed with it.
|
|
54
|
+
- **Doc Obligations** — every row is discharged into exactly one outcome (**updated** · **owes nothing** with its reason · **blocked** with its backlog item). For a row discharged **updated**, open the doc and check it states what the code at this commit does — a discharged row over an unchanged doc is the commonest way a closure is faked.
|
|
55
|
+
|
|
56
|
+
### 5 · The gates line
|
|
57
|
+
|
|
58
|
+
The plan's `Gates:` line reads **passed** for both `Boundary:` and `Polygraph:` (or, for polygraph, `owes nothing` with its reason). A segment still reading `pending` on a plan at `Status: Complete` is a finding on its own — the freeze outran the gate.
|
|
59
|
+
|
|
60
|
+
### 6 · The changelog
|
|
61
|
+
|
|
62
|
+
An entry exists under `docs/history/changelog/` for this commit, it names the plan, and its account matches the diff. A missing entry, an entry naming no plan, and an entry describing work the diff does not contain are three separate findings.
|
|
63
|
+
|
|
64
|
+
## The report
|
|
65
|
+
|
|
66
|
+
One report, and it is your final message — the runner delivers it to the control plane. No file is written.
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
Kind: verification
|
|
70
|
+
Repository: {repo} · Commit: {sha}
|
|
71
|
+
Plan: {path or "incident {id}"} · Verdict: pass | finding
|
|
72
|
+
Checked: {N} obligation rows · {M} pins re-executed · {D} doc rows · suite {green|red}
|
|
73
|
+
|
|
74
|
+
| # | Severity | Promised | Observed | Evidence |
|
|
75
|
+
| --- | --- | --- | --- | --- |
|
|
76
|
+
| 1 | HIGH | {what the plan promised} | {what the code or the ledger does} | {file:line, or the command and its output} |
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- **Rank by severity**, highest first: HIGH — a promise the repository does not keep, or a pin that does not bite · MEDIUM — a ledger or document that misstates what shipped · LOW — a traceability gap that misleads the next reader.
|
|
80
|
+
- **Every finding carries evidence a reader can re-run** — a `file:line`, or the exact command and the lines of output that decide it. What you cannot evidence is not reported as a finding; it is reported as a check you could not complete.
|
|
81
|
+
- **Print the denominator even when clean** — "verified" with no count is indistinguishable from "scanned nothing".
|
|
82
|
+
- **A pass is recorded, never granted.** State the verdict and stop: a finding reopens the run on the control plane's side, and the grant is a person's act bound to this same commit.
|
|
83
|
+
|
|
84
|
+
## Never
|
|
85
|
+
|
|
86
|
+
- **Never edit the repository** — no fix, no doc correction, no ledger cell, no test, not even an obvious typo. A finding reopens the session; the party that would fix it is the party whose work is under review.
|
|
87
|
+
- **Never weaken, skip, delete, or regenerate a test.** Regeneration re-derives the assertion from the code as it now is, which turns the defect you are hunting into the asserted truth with everything green.
|
|
88
|
+
- **Never accept an Outcome cell, a `passed` segment, or a "suite green" claim as evidence.** They are the self-report you exist to check; evidence is what your own command printed.
|
|
89
|
+
- **Never grant, approve, sign off, or close a run.** Your output is a verdict; the run's state belongs to the control plane.
|
|
90
|
+
- **Never delegate the verdict.** The replay, the reading, and the ranking stay with you — what you hand back is what you saw.
|
|
91
|
+
|
|
92
|
+
## Stopping early
|
|
93
|
+
|
|
94
|
+
| Condition | What you do |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| No plan and no incident record for the commit | A HIGH finding naming what you searched, verdict `finding`, and stop — **silence is the one forbidden response** |
|
|
97
|
+
| HEAD is not the reported commit, or the tree is dirty | Finding #1; continue only if the reported ref is reachable, and mark every later row conditional |
|
|
98
|
+
| The plan carries no Test Obligations section | A HIGH finding — the plan was frozen without the gate that fills it; verify the remaining five checks anyway |
|
|
99
|
+
| The suite cannot run (missing toolchain, unbuildable checkout) | A HIGH finding carrying the command and its error — never improvise a substitute runner, which would verify something nobody ships |
|
|
100
|
+
'''
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"harness": "code",
|
|
3
3
|
"name": "code",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.16.2",
|
|
5
5
|
"files": [
|
|
6
6
|
".prettierignore",
|
|
7
7
|
".prettierrc.json",
|
|
@@ -25,5 +25,5 @@
|
|
|
25
25
|
}
|
|
26
26
|
],
|
|
27
27
|
"docsRoot": "docs",
|
|
28
|
-
"digest": "
|
|
28
|
+
"digest": "f105e8f211b0"
|
|
29
29
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// harness/code/hooks/build-graph.mjs — the build-graph walker's Claude Code adapter (PostToolUse).
|
|
2
|
+
//
|
|
3
|
+
// A file was written. Reconcile the declared paths against the disk, and if the graph can now
|
|
4
|
+
// advance, tell the model what to produce next. That is the whole job: the deciding lives in
|
|
5
|
+
// `lib/graph.mjs`, the reading and writing in `lib/graph-store.mjs`, and this file is the six
|
|
6
|
+
// lines that turn a hook payload into a call and a call into stdout.
|
|
7
|
+
//
|
|
8
|
+
// PostToolUse only. The Stop side of the walker lives in `compliance.mjs`, because the runtime
|
|
9
|
+
// runs every hook of an event in parallel — two Stop hooks that both block leave which reason
|
|
10
|
+
// the model sees up to a race, and the walker's `mode.walls` already needs compliance to read
|
|
11
|
+
// the graph.
|
|
12
|
+
//
|
|
13
|
+
// DORMANT BY ABSENCE: no `.claude/state/graph.json` ⇒ this prints nothing, writes nothing, and
|
|
14
|
+
// creates no state. Every harness install ships it; only a run that was handed a graph notices.
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { readStdin, resolveProjectDir } from "./lib/ownership.mjs";
|
|
19
|
+
import { afterWrite } from "./lib/graph-store.mjs";
|
|
20
|
+
|
|
21
|
+
function main() {
|
|
22
|
+
const raw = readStdin();
|
|
23
|
+
let payload = {};
|
|
24
|
+
try {
|
|
25
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
26
|
+
} catch (e) {
|
|
27
|
+
payload = {};
|
|
28
|
+
}
|
|
29
|
+
if (payload.hook_event_name && payload.hook_event_name !== "PostToolUse") return;
|
|
30
|
+
|
|
31
|
+
const projectDir = resolveProjectDir(payload);
|
|
32
|
+
const step = afterWrite(projectDir);
|
|
33
|
+
if (!step) return; // dormant
|
|
34
|
+
|
|
35
|
+
// An invalid graph is never a silent no-op. The Stop wall blocks on the same reason, so a run
|
|
36
|
+
// handed a broken graph hears about it twice rather than generating in the dark.
|
|
37
|
+
const context = step.refusal
|
|
38
|
+
? `BUILD GRAPH REFUSED — \`.claude/state/graph.json\` exists but ${step.refusal}. Nothing is being steered: fix the graph or remove it. The walker is dormant only when the file is ABSENT, never when it is wrong.`
|
|
39
|
+
: step.line;
|
|
40
|
+
if (!context) return;
|
|
41
|
+
|
|
42
|
+
process.stdout.write(
|
|
43
|
+
JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: context } })
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
main();
|
|
49
|
+
} catch (e) {
|
|
50
|
+
// Fail open, and leave a trace: a walker that crashes must not take the session with it, but a
|
|
51
|
+
// walker that stopped steering silently is a run nobody knows went unguided.
|
|
52
|
+
try {
|
|
53
|
+
const dir = path.join(process.env.CLAUDE_PROJECT_DIR || process.cwd(), ".claude", "state");
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
fs.appendFileSync(path.join(dir, "hook-failures.log"), `${new Date().toISOString()} build-graph.mjs ${e?.message || e}\n`);
|
|
56
|
+
} catch (e2) {
|
|
57
|
+
/* truly nowhere to write — fail open stays absolute */
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
// NEVER process.exit() here. Node does not flush pending async stdout writes on exit, so an
|
|
61
|
+
// injection larger than the pipe buffer is severed mid-write and arrives as unparseable JSON —
|
|
62
|
+
// which reads downstream as "no steer" rather than as an error. (awareness.mjs, same note.)
|
|
63
|
+
process.exitCode = 0;
|
|
64
|
+
}
|
|
@@ -24,6 +24,38 @@ import { undischargedRows } from "./lib/ledger.mjs";
|
|
|
24
24
|
import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } from "./lib/session-log.mjs";
|
|
25
25
|
import { approvedPackages, rejectedPackages } from "./lib/approved.mjs";
|
|
26
26
|
import { claimActivity, releaseActivity, recentForeignTouches } from "./lib/claims.mjs";
|
|
27
|
+
import { atStop, graphDenial, wallsAllowlist } from "./lib/graph-store.mjs";
|
|
28
|
+
import { WALLS, wallEnabled } from "./lib/walls.mjs";
|
|
29
|
+
|
|
30
|
+
// --- the build graph's switch ------------------------------------------------------------
|
|
31
|
+
// A run handed a build graph (docs/harness/code/build-graph.md) may declare `mode.walls`, an
|
|
32
|
+
// allowlist over lib/walls.mjs. Every wall it does not name is off for that session — because
|
|
33
|
+
// the compliance set assumes a session that can dispatch a bookkeeper and a user who can answer
|
|
34
|
+
// an ask, and a generation box has neither: its walls would be unresolvable blocks, not guard
|
|
35
|
+
// rails. No graph, or a graph with no `mode` block, leaves every wall exactly as it was.
|
|
36
|
+
let allowlistCache;
|
|
37
|
+
function wallOn(projectDir, name) {
|
|
38
|
+
if (allowlistCache === undefined) {
|
|
39
|
+
try {
|
|
40
|
+
allowlistCache = wallsAllowlist(projectDir);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
allowlistCache = null; // a broken graph never switches a wall off
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return wallEnabled(allowlistCache, name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The graph's own wall: a landed node is written once, and the graph and its state are machinery.
|
|
49
|
+
// It answers with decision("deny", …) and NEVER deny() — a steer is not a failure, and a logged
|
|
50
|
+
// denial in a generation box would need a `resolved` line nobody is there to write.
|
|
51
|
+
function graphGate(projectDir, rel) {
|
|
52
|
+
try {
|
|
53
|
+
const reason = graphDenial(projectDir, rel);
|
|
54
|
+
return reason ? decision("deny", reason) : null;
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return null; // the walker never becomes an outage
|
|
57
|
+
}
|
|
58
|
+
}
|
|
27
59
|
|
|
28
60
|
function decision(permissionDecision, reason) {
|
|
29
61
|
return {
|
|
@@ -63,7 +95,7 @@ function protectedPaths(projectDir) {
|
|
|
63
95
|
}
|
|
64
96
|
|
|
65
97
|
function fileGate(projectDir, rel, sessionId) {
|
|
66
|
-
const frozen = frozenStatusAtHead(projectDir, rel);
|
|
98
|
+
const frozen = wallOn(projectDir, "frozen-record") ? frozenStatusAtHead(projectDir, rel) : { frozen: false };
|
|
67
99
|
if (frozen.frozen) {
|
|
68
100
|
return deny(
|
|
69
101
|
projectDir,
|
|
@@ -74,15 +106,22 @@ function fileGate(projectDir, rel, sessionId) {
|
|
|
74
106
|
);
|
|
75
107
|
}
|
|
76
108
|
|
|
77
|
-
if (protectedPaths(projectDir).includes(rel)) {
|
|
109
|
+
if (wallOn(projectDir, "protected-file") && protectedPaths(projectDir).includes(rel)) {
|
|
78
110
|
return deny(projectDir, sessionId, "protected-file", rel, `BLOCKED — protected file. \`${rel}\` is on the protected list.`);
|
|
79
111
|
}
|
|
80
112
|
|
|
113
|
+
// Before the claim ask and outside the plan gate's `inDocs` skip — the graph's own files live
|
|
114
|
+
// under .claude/, which that branch deliberately excludes.
|
|
115
|
+
const graphWall = graphGate(projectDir, rel);
|
|
116
|
+
if (graphWall) return graphWall;
|
|
117
|
+
|
|
81
118
|
// The file half of the claim board: a path another LIVE session touched minutes ago belongs to
|
|
82
119
|
// someone else right now. Advisory by design — one ask per sibling per session, never a wall —
|
|
83
120
|
// this is the presence protocol ("git status archaeology") moved into the gate that fires anyway.
|
|
84
121
|
try {
|
|
85
|
-
const holder =
|
|
122
|
+
const holder = wallOn(projectDir, "foreign-claim")
|
|
123
|
+
? recentForeignTouches(projectDir, sessionId).find((f) => f.paths.some((p) => p.rel === rel))
|
|
124
|
+
: null;
|
|
86
125
|
if (holder) {
|
|
87
126
|
const recent = holder.paths.find((p) => p.rel === rel);
|
|
88
127
|
appendEvent(projectDir, sessionId, "coord", "file-claim", rel, `touched ${recent.ageMin}m ago by sid=${holder.sid8}`);
|
|
@@ -99,7 +138,7 @@ function fileGate(projectDir, rel, sessionId) {
|
|
|
99
138
|
}
|
|
100
139
|
|
|
101
140
|
const inDocs = rel.startsWith(`${docsRoot(projectDir)}/`) || rel.startsWith(".claude/") || rel.endsWith(".md");
|
|
102
|
-
if (!inDocs && SOURCE_EXT.test(rel)) {
|
|
141
|
+
if (!inDocs && SOURCE_EXT.test(rel) && wallOn(projectDir, "plan-gate")) {
|
|
103
142
|
for (const plan of openGatedPlans(projectDir)) {
|
|
104
143
|
// The gate binds the plan's own paths, so disjoint plans run in parallel.
|
|
105
144
|
// A scopeless plan stays repo-wide — fail closed.
|
|
@@ -167,7 +206,7 @@ function commitGate(projectDir, sessionId) {
|
|
|
167
206
|
.filter(Boolean);
|
|
168
207
|
const allRenames = nameStatus.length > 0 && nameStatus.every((line) => line.startsWith("R"));
|
|
169
208
|
const root = docsRoot(projectDir);
|
|
170
|
-
if (staged.length > 0 && !stagedExempt(projectDir, staged) && !allRenames) {
|
|
209
|
+
if (wallOn(projectDir, "changelog") && staged.length > 0 && !stagedExempt(projectDir, staged) && !allRenames) {
|
|
171
210
|
if (!staged.some((f) => f.startsWith(`${root}/history/changelog/`))) {
|
|
172
211
|
return deny(
|
|
173
212
|
projectDir,
|
|
@@ -184,7 +223,9 @@ function commitGate(projectDir, sessionId) {
|
|
|
184
223
|
// action that clears it. The read goes through `liveRows` first: a gate tests conditions at gate
|
|
185
224
|
// time, never a cached record of them — a row whose condition died since the last sweep must not
|
|
186
225
|
// wall anything. Pure by design; the detector's next sweep deletes what this filtered out.
|
|
187
|
-
const { blocking, unrelated } =
|
|
226
|
+
const { blocking, unrelated } = wallOn(projectDir, "bookkeeping")
|
|
227
|
+
? rowsBlockingCommit(liveRows(projectDir, readQueue(projectDir)), staged)
|
|
228
|
+
: { blocking: [], unrelated: [] };
|
|
188
229
|
if (blocking.length > 0) {
|
|
189
230
|
const shown = blocking.slice(0, 8);
|
|
190
231
|
const lines = shown.map(({ row, hits }) =>
|
|
@@ -221,6 +262,7 @@ function commitGate(projectDir, sessionId) {
|
|
|
221
262
|
const PKG_SPEC = /^(@[a-z0-9~-][\w.-]*\/)?[a-z0-9~-][\w.-]*(@[\w.^~*<>=|x-]+)?$/;
|
|
222
263
|
|
|
223
264
|
function packageGate(projectDir, command, sessionId) {
|
|
265
|
+
if (!wallOn(projectDir, "package-table")) return null;
|
|
224
266
|
const m = new RegExp(
|
|
225
267
|
String.raw`(?:^|\n|&&|;|\|)\s*(?:sudo\s+)?(?:pnpm|npm|yarn|bun)\s+(?:add|install|i)(?=\s|$)([^&;|\n]*)`
|
|
226
268
|
).exec(command);
|
|
@@ -288,6 +330,7 @@ function bashGate(projectDir, command, sessionId) {
|
|
|
288
330
|
if (!command) return null;
|
|
289
331
|
|
|
290
332
|
if (statement(String.raw`git\s+commit\b`).test(command)) {
|
|
333
|
+
if (!wallOn(projectDir, "commit-claim")) return commitGate(projectDir, sessionId);
|
|
291
334
|
// The claim board serializes the commit sequence: disjoint files do not make disjoint
|
|
292
335
|
// commits — every session writes the same branch through the same index, so one committer
|
|
293
336
|
// at a time. A held claim is a plain wait (no denied/resolved pair to resolve — the retry
|
|
@@ -313,7 +356,7 @@ function bashGate(projectDir, command, sessionId) {
|
|
|
313
356
|
}
|
|
314
357
|
|
|
315
358
|
// `git branch <name>` creates; flag-led forms (--show-current, --list, -d, -vv) query or manage
|
|
316
|
-
if (statement(String.raw`git\s+(checkout\s+-b|switch\s+-c|branch\s+(?!-)\S+|worktree\s+add)`).test(command)) {
|
|
359
|
+
if (wallOn(projectDir, "branch-worktree") && statement(String.raw`git\s+(checkout\s+-b|switch\s+-c|branch\s+(?!-)\S+|worktree\s+add)`).test(command)) {
|
|
317
360
|
return askOnce(
|
|
318
361
|
projectDir,
|
|
319
362
|
sessionId,
|
|
@@ -323,6 +366,7 @@ function bashGate(projectDir, command, sessionId) {
|
|
|
323
366
|
}
|
|
324
367
|
|
|
325
368
|
if (
|
|
369
|
+
wallOn(projectDir, "live-command") &&
|
|
326
370
|
statement(String.raw`wrangler(?:@\S+)?\b.*(\bd1\s+execute\b.*--remote|\bsecret\s+(put|bulk)\b|\bdeploy\b)`).test(command) &&
|
|
327
371
|
!/--dry-run\b|--local\b/.test(command)
|
|
328
372
|
) {
|
|
@@ -366,7 +410,11 @@ function bashWriteScreen(projectDir, command, sessionId) {
|
|
|
366
410
|
for (const target of bashWriteTargets(command)) {
|
|
367
411
|
const rel = relIfInside(projectDir, target);
|
|
368
412
|
if (!rel) continue;
|
|
369
|
-
|
|
413
|
+
// A shell write is still a write: the build graph's wall runs here too, or `cat > docs/prd.md`
|
|
414
|
+
// over a landed node ships green while the Edit tool is refused.
|
|
415
|
+
const graphWall = graphGate(projectDir, rel);
|
|
416
|
+
if (graphWall) return graphWall;
|
|
417
|
+
const frozen = wallOn(projectDir, "frozen-record") ? frozenStatusAtHead(projectDir, rel) : { frozen: false };
|
|
370
418
|
if (frozen.frozen) {
|
|
371
419
|
return deny(
|
|
372
420
|
projectDir,
|
|
@@ -376,7 +424,7 @@ function bashWriteScreen(projectDir, command, sessionId) {
|
|
|
376
424
|
`BLOCKED — frozen record. \`${rel}\` is committed history and is never edited (${frozen.reason}) — a shell write is still a write. A correction is a new dated entry that links back.`
|
|
377
425
|
);
|
|
378
426
|
}
|
|
379
|
-
if (protectedPaths(projectDir).includes(rel)) {
|
|
427
|
+
if (wallOn(projectDir, "protected-file") && protectedPaths(projectDir).includes(rel)) {
|
|
380
428
|
return deny(
|
|
381
429
|
projectDir,
|
|
382
430
|
sessionId,
|
|
@@ -443,7 +491,54 @@ function planHasChangelogEntry(projectDir, planRel) {
|
|
|
443
491
|
return names.some((name) => name.endsWith(".md") && readSafe(path.join(dir, name)).includes(slug));
|
|
444
492
|
}
|
|
445
493
|
|
|
494
|
+
// The Stop half of the build-graph walker. Blocks with the next injection line while the graph
|
|
495
|
+
// can still advance, allows the moment it is terminal, and answers null when no graph exists —
|
|
496
|
+
// which is every session in every repo that was never handed one.
|
|
497
|
+
function graphStop(projectDir, stopHookActive) {
|
|
498
|
+
let step;
|
|
499
|
+
try {
|
|
500
|
+
step = atStop(projectDir);
|
|
501
|
+
} catch (e) {
|
|
502
|
+
return null; // the walker never becomes an outage
|
|
503
|
+
}
|
|
504
|
+
if (!step) return null;
|
|
505
|
+
if (step.refusal) {
|
|
506
|
+
// A refusal the model cannot repair (the wall denies writes to the graph) must not become an
|
|
507
|
+
// unbreakable Stop loop: say it once, loudly, and let the second Stop through. Fixing the
|
|
508
|
+
// graph is the run owner's job, not the model's — so this branch, alone in this function,
|
|
509
|
+
// honours the runtime's loop guard.
|
|
510
|
+
if (stopHookActive) return null;
|
|
511
|
+
return {
|
|
512
|
+
decision: "block",
|
|
513
|
+
reason:
|
|
514
|
+
`BUILD GRAPH REFUSED — \`.claude/state/graph.json\` exists but ${step.refusal}. Nothing is being steered. ` +
|
|
515
|
+
"Fix the graph or remove it: the walker is dormant only when that file is ABSENT, never when it is wrong. (Said once — the next Stop passes.)",
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
if (!step.line) return null;
|
|
519
|
+
return {
|
|
520
|
+
decision: "block",
|
|
521
|
+
reason:
|
|
522
|
+
`${step.line} The build graph decides this run's order and completion — ${step.terminal.summary} ` +
|
|
523
|
+
"Write the file the line names; the run ends by itself when the graph is complete, blocked, or out of attempts.",
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
446
527
|
function stopGate(projectDir, payload) {
|
|
528
|
+
// The build graph is asked BEFORE the runtime's loop guard, and deliberately ignores it. The
|
|
529
|
+
// guard exists because an unbounded Stop hook is a hang; the walker is bounded instead — every
|
|
530
|
+
// block charges an attempt on the node it names, so a model that answers "done" spends that
|
|
531
|
+
// node's budget and the run ends `exhausted`. Honouring the flag would leave the walker able to
|
|
532
|
+
// steer exactly once per turn chain and then go quiet with documents still owed, which is the
|
|
533
|
+
// liveness hole the whole design exists to close.
|
|
534
|
+
//
|
|
535
|
+
// Consequence, and it is deliberate: while a graph is live the first block flips
|
|
536
|
+
// `stop_hook_active` for the rest of the chain, so the four sub-walls below are suspended until
|
|
537
|
+
// the graph is terminal. A generation box wants exactly that; a human session running a
|
|
538
|
+
// hand-written graph should know it.
|
|
539
|
+
const graph = graphStop(projectDir, payload.stop_hook_active);
|
|
540
|
+
if (graph) return graph;
|
|
541
|
+
|
|
447
542
|
if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
|
|
448
543
|
|
|
449
544
|
// The Stop wall is scoped to this session's own rows (plus sid-less ones) — a concurrent
|
|
@@ -456,7 +551,7 @@ function stopGate(projectDir, payload) {
|
|
|
456
551
|
readQueue(projectDir).filter((row) => !/\bsid=/.test(row.body) || row.body.includes(`sid=${payload.session_id}`))
|
|
457
552
|
);
|
|
458
553
|
const counts = countRows(mine);
|
|
459
|
-
if (counts.unresolved > 0 && !bookkeeperDispatched(payload.transcript_path)) {
|
|
554
|
+
if (wallOn(projectDir, "queue") && counts.unresolved > 0 && !bookkeeperDispatched(payload.transcript_path)) {
|
|
460
555
|
return {
|
|
461
556
|
decision: "block",
|
|
462
557
|
reason:
|
|
@@ -472,7 +567,7 @@ function stopGate(projectDir, payload) {
|
|
|
472
567
|
// Open gate denials wall the stop: silence after a block is the one ending the log exists to
|
|
473
568
|
// make impossible. Resolution appends to the log — retried-ok is machinery-written on a
|
|
474
569
|
// successful retry; waived/disclosed are the session's hand-written, auditable claims.
|
|
475
|
-
const openDenials = unresolvedDenials(projectDir, payload.session_id);
|
|
570
|
+
const openDenials = wallOn(projectDir, "open-denials") ? unresolvedDenials(projectDir, payload.session_id) : [];
|
|
476
571
|
if (openDenials.length > 0) {
|
|
477
572
|
const rel = path.relative(projectDir, sessionLogPath(projectDir, payload.session_id));
|
|
478
573
|
// A resolution that doesn't parse reads to the session as "my resolution didn't count"
|
|
@@ -502,7 +597,7 @@ function stopGate(projectDir, payload) {
|
|
|
502
597
|
// The Test Obligations ledger IS walled per row: the Outcome cell lives in the plan file, so the
|
|
503
598
|
// only way past this block is reading the file — a context-held copy of the ledger is exactly
|
|
504
599
|
// how rows get missed. (Tables without an Outcome column are legacy and pass; see lib/ledger.)
|
|
505
|
-
for (const rel of completed) {
|
|
600
|
+
for (const rel of wallOn(projectDir, "ledger") ? completed : []) {
|
|
506
601
|
const rows = undischargedRows(readSafe(path.join(projectDir, rel)));
|
|
507
602
|
if (rows.length > 0) {
|
|
508
603
|
return {
|
|
@@ -514,7 +609,9 @@ function stopGate(projectDir, payload) {
|
|
|
514
609
|
}
|
|
515
610
|
}
|
|
516
611
|
|
|
517
|
-
const undocumented =
|
|
612
|
+
const undocumented = wallOn(projectDir, "changelog-entry")
|
|
613
|
+
? completed.filter((rel) => !planHasChangelogEntry(projectDir, rel))
|
|
614
|
+
: [];
|
|
518
615
|
if (undocumented.length > 0) {
|
|
519
616
|
return {
|
|
520
617
|
decision: "block",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/awareness.mjs\"", "timeout": 10 } ] }
|
|
12
12
|
],
|
|
13
13
|
"PostToolUse": [
|
|
14
|
-
{ "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/awareness.mjs\"", "timeout": 10 } ] }
|
|
14
|
+
{ "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/awareness.mjs\"", "timeout": 10 }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/build-graph.mjs\"", "timeout": 10 } ] }
|
|
15
15
|
],
|
|
16
16
|
"PreToolUse": [
|
|
17
17
|
{ "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/compliance.mjs\"", "timeout": 10 } ] }
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// harness/code/hooks/lib/graph-store.mjs — the build-graph walker: the filesystem half.
|
|
2
|
+
//
|
|
3
|
+
// `lib/graph.mjs` decides; this module reads and writes. It exists because the Claude Code side
|
|
4
|
+
// of the walker has TWO entry points — the PostToolUse adapter (`build-graph.mjs`) and the Stop
|
|
5
|
+
// wall inside `compliance.mjs` — and a walker whose two triggers disagreed about how state is
|
|
6
|
+
// loaded would be worse than no walker at all.
|
|
7
|
+
//
|
|
8
|
+
// Everything here fails toward dormancy: no graph file, an unreadable one, a crash mid-scan —
|
|
9
|
+
// none of them may block a session that never asked for a build graph.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { graphFile, graphStateFile, atomicWrite, GRAPH_FILE, GRAPH_STATE_FILE } from "./state.mjs";
|
|
14
|
+
import { readGraph, reconcile, rebuildState, initialState, nextEligible, inProgress, select, isTerminal, normalizeGraphPath, STATE_VERSION } from "./graph.mjs";
|
|
15
|
+
|
|
16
|
+
// The two paths the model may never write, spelled once.
|
|
17
|
+
export const GRAPH_PATHS = [`.claude/state/${GRAPH_FILE}`, `.claude/state/${GRAPH_STATE_FILE}`];
|
|
18
|
+
|
|
19
|
+
// Absent → { present: false }: dormancy, and the caller does nothing at all.
|
|
20
|
+
// Present but unparseable → { present: true, refusal }: loud, never silent.
|
|
21
|
+
export function loadGraph(projectDir) {
|
|
22
|
+
let text;
|
|
23
|
+
try {
|
|
24
|
+
text = fs.readFileSync(graphFile(projectDir), "utf8");
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return { present: false };
|
|
27
|
+
}
|
|
28
|
+
const parsed = readGraph(text, projectDir);
|
|
29
|
+
if (!parsed.ok) return { present: true, refusal: parsed.reason };
|
|
30
|
+
return { present: true, graph: parsed.graph };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Read every DECLARED path off the disk. Never the tool's argument: `bash` writes files too, a
|
|
34
|
+
// single command writes several, and an argument naming a path proves nothing about what landed.
|
|
35
|
+
export function observe(projectDir, graph) {
|
|
36
|
+
return graph.nodes.map((node) => {
|
|
37
|
+
const full = path.join(projectDir, node.path);
|
|
38
|
+
try {
|
|
39
|
+
const text = fs.readFileSync(full, "utf8");
|
|
40
|
+
return { path: node.path, exists: true, bytes: Buffer.byteLength(text, "utf8"), text };
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return { path: node.path, exists: false };
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// An unreadable or version-mismatched state file is REBUILT from the disk, never refused: the
|
|
48
|
+
// graph plus the files that exist are the whole truth, and the state file is only a cache of
|
|
49
|
+
// counters. Refusing here would strand a run over a byte nobody can repair.
|
|
50
|
+
export function loadState(projectDir, graph, observed) {
|
|
51
|
+
let parsed = null;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(fs.readFileSync(graphStateFile(projectDir), "utf8"));
|
|
54
|
+
} catch (e) {
|
|
55
|
+
parsed = null;
|
|
56
|
+
}
|
|
57
|
+
if (!parsed || typeof parsed !== "object" || parsed.v !== STATE_VERSION || !parsed.nodes || typeof parsed.nodes !== "object") {
|
|
58
|
+
return { state: rebuildState(graph, observed, parsed), rebuilt: true };
|
|
59
|
+
}
|
|
60
|
+
return { state: parsed, rebuilt: false };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function saveState(projectDir, state) {
|
|
64
|
+
atomicWrite(graphStateFile(projectDir), `${JSON.stringify(state, null, 2)}\n`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- the two triggers ---------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
// PostToolUse: a write happened. Reconcile the disk, then steer only when there is somewhere new
|
|
70
|
+
// to steer — a node still in progress gets silence, which is what keeps a six-edit document from
|
|
71
|
+
// spending six attempts.
|
|
72
|
+
//
|
|
73
|
+
// `writes` counts EVENTS, not files: one shell command that writes three files is one write,
|
|
74
|
+
// because the cap exists to bound the session, not to audit the filesystem.
|
|
75
|
+
export function afterWrite(projectDir) {
|
|
76
|
+
const loaded = loadGraph(projectDir);
|
|
77
|
+
if (!loaded.present) return null;
|
|
78
|
+
if (loaded.refusal) return { refusal: loaded.refusal };
|
|
79
|
+
const graph = loaded.graph;
|
|
80
|
+
|
|
81
|
+
const observed = observe(projectDir, graph);
|
|
82
|
+
const before = loadState(projectDir, graph, observed);
|
|
83
|
+
const running = inProgress(graph, before.state);
|
|
84
|
+
let state = reconcile(graph, before.state, observed);
|
|
85
|
+
state.writes = (state.writes || 0) + 1;
|
|
86
|
+
|
|
87
|
+
const terminal = isTerminal(graph, state);
|
|
88
|
+
if (terminal.terminal) {
|
|
89
|
+
saveState(projectDir, state);
|
|
90
|
+
return { graph, state, terminal, line: terminal.summary };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Still working on the node it was told to produce — say nothing.
|
|
94
|
+
const stillRunning = inProgress(graph, state);
|
|
95
|
+
if (stillRunning && running && stillRunning.id === running.id) {
|
|
96
|
+
saveState(projectDir, state);
|
|
97
|
+
return { graph, state, terminal, line: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const next = nextEligible(graph, state);
|
|
101
|
+
if (!next) {
|
|
102
|
+
saveState(projectDir, state);
|
|
103
|
+
return { graph, state, terminal, line: null };
|
|
104
|
+
}
|
|
105
|
+
const picked = select(graph, state, next.id);
|
|
106
|
+
saveState(projectDir, picked.state);
|
|
107
|
+
return { graph, state: picked.state, terminal, line: picked.line };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Stop: the model is trying to end its turn. While the graph can still advance, it does not get
|
|
111
|
+
// to — and the block CHARGES an attempt on the node it names, which is the whole reason the loop
|
|
112
|
+
// terminates: a model that answers "done" three times spends the node's budget and the run ends
|
|
113
|
+
// `exhausted` instead of circling forever.
|
|
114
|
+
//
|
|
115
|
+
// This deliberately ignores the runtime's `stop_hook_active` loop guard (the caller checks the
|
|
116
|
+
// graph before consulting it). The guard exists because an unbounded Stop hook is a hang; this
|
|
117
|
+
// one is bounded by maxAttempts × nodes and by maxWrites, so the guard would only cost the
|
|
118
|
+
// walker its liveness — it would steer exactly once per turn chain and then go quiet with nodes
|
|
119
|
+
// still owed.
|
|
120
|
+
export function atStop(projectDir) {
|
|
121
|
+
const loaded = loadGraph(projectDir);
|
|
122
|
+
if (!loaded.present) return null;
|
|
123
|
+
if (loaded.refusal) return { refusal: loaded.refusal };
|
|
124
|
+
const graph = loaded.graph;
|
|
125
|
+
|
|
126
|
+
const observed = observe(projectDir, graph);
|
|
127
|
+
const before = loadState(projectDir, graph, observed);
|
|
128
|
+
let state = reconcile(graph, before.state, observed);
|
|
129
|
+
|
|
130
|
+
const terminal = isTerminal(graph, state);
|
|
131
|
+
if (terminal.terminal) {
|
|
132
|
+
saveState(projectDir, state);
|
|
133
|
+
return { graph, state, terminal, line: null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const target = inProgress(graph, state) || nextEligible(graph, state);
|
|
137
|
+
if (!target) {
|
|
138
|
+
saveState(projectDir, state);
|
|
139
|
+
return { graph, state, terminal, line: null };
|
|
140
|
+
}
|
|
141
|
+
const picked = select(graph, state, target.id);
|
|
142
|
+
saveState(projectDir, picked.state);
|
|
143
|
+
return { graph, state: picked.state, terminal: isTerminal(graph, picked.state), line: picked.line };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- the walls the graph owns ---------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
// A done node is frozen, and the graph and its state are machinery. Returns a deny REASON (the
|
|
149
|
+
// caller wraps it in its own runtime's envelope) or null.
|
|
150
|
+
//
|
|
151
|
+
// Note what this never does: append a `denied` event to the session gate log. A steer is not a
|
|
152
|
+
// failure, and in a generation box nobody is there to write the `resolved` line that a logged
|
|
153
|
+
// denial would need — the Stop wall would block forever on the walker's own refusals.
|
|
154
|
+
export function graphDenial(projectDir, rel) {
|
|
155
|
+
const target = normalizeGraphPath(rel, projectDir);
|
|
156
|
+
if (!target) return null;
|
|
157
|
+
if (GRAPH_PATHS.includes(target)) {
|
|
158
|
+
return `BLOCKED — build-graph machinery. \`${target}\` is the run's graph or its state; the walker owns both. Write the document the injected line names instead.`;
|
|
159
|
+
}
|
|
160
|
+
const loaded = loadGraph(projectDir);
|
|
161
|
+
if (!loaded.present || loaded.refusal) return null;
|
|
162
|
+
const graph = loaded.graph;
|
|
163
|
+
const node = graph.nodes.find((n) => n.path === target);
|
|
164
|
+
if (!node) return null;
|
|
165
|
+
const observed = observe(projectDir, graph);
|
|
166
|
+
const { state } = loadState(projectDir, graph, observed);
|
|
167
|
+
const settled = reconcile(graph, state, observed);
|
|
168
|
+
if (settled.nodes[node.id]?.status !== "done") return null;
|
|
169
|
+
return `BLOCKED — frozen by the build graph. \`${target}\` is node \`${node.id}\`, which has already landed; the graph's outputs are written once. If it is genuinely wrong, the run's owner edits the graph, not the model.`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// `mode.walls` is an allowlist over the gate ids in lib/walls.mjs: when a graph declares one,
|
|
173
|
+
// every wall it does not name is off for the session. No `mode` block at all ⇒ every wall on,
|
|
174
|
+
// which is what every repo without a graph already has.
|
|
175
|
+
//
|
|
176
|
+
// The reason a generation box wants this: the compliance set assumes a session that can dispatch
|
|
177
|
+
// a bookkeeper and a user who can answer an ask. A box has neither, so its walls would be
|
|
178
|
+
// unresolvable blocks rather than guard rails.
|
|
179
|
+
export function wallsAllowlist(projectDir) {
|
|
180
|
+
const loaded = loadGraph(projectDir);
|
|
181
|
+
if (!loaded.present || loaded.refusal || !loaded.graph.mode) return null;
|
|
182
|
+
return loaded.graph.mode.walls;
|
|
183
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// harness/code/hooks/lib/graph.mjs — the build-graph walker: the pure half.
|
|
2
|
+
//
|
|
3
|
+
// A build graph names the documents a run owes, the edges between them, and the caps that bound
|
|
4
|
+
// it. This module decides WHAT happens next; it never touches the filesystem, the transcript, or
|
|
5
|
+
// the clock beyond an injected timestamp — every adapter (Claude Code's hooks here, OpenCode's
|
|
6
|
+
// plugin in Apollo's generation box) hands it what it read and applies what it returns.
|
|
7
|
+
//
|
|
8
|
+
// The division is the point: one library decides, several runtimes carry the decision, and the
|
|
9
|
+
// two can never disagree about what "done" means. The export set below is a cross-repo contract
|
|
10
|
+
// (Apollo imports this file from the installed `.claude/hooks/lib/graph.mjs`) — a rename is a
|
|
11
|
+
// versioned change, not a refactor, and `tests/parity.test.mjs` pins it.
|
|
12
|
+
//
|
|
13
|
+
// Absence is dormancy; invalidity is a loud refusal. No graph file ⇒ nothing here ever runs. A
|
|
14
|
+
// graph that exists but does not parse is never silently ignored — `readGraph` answers a typed
|
|
15
|
+
// refusal the adapter surfaces.
|
|
16
|
+
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
export const GRAPH_VERSION = 1;
|
|
20
|
+
export const STATE_VERSION = 1;
|
|
21
|
+
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
22
|
+
export const DEFAULT_MAX_WRITES = 200;
|
|
23
|
+
|
|
24
|
+
// pending → in_progress → done | failed. `failed` is derived, never authored: a node that is not
|
|
25
|
+
// done once it has spent its attempts can no longer advance, and saying so is what lets the run
|
|
26
|
+
// end instead of circling.
|
|
27
|
+
export const STATUSES = ["pending", "in_progress", "done", "failed"];
|
|
28
|
+
|
|
29
|
+
// --- paths ------------------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
// Graph paths, tool arguments and disk scans must compare as one spelling: `./docs/prd.md`,
|
|
32
|
+
// `docs//prd.md` and an absolute path inside the project all name the same node. Pure string math
|
|
33
|
+
// plus path.relative — no filesystem.
|
|
34
|
+
export function normalizeGraphPath(p, projectDir) {
|
|
35
|
+
if (typeof p !== "string") return null;
|
|
36
|
+
let s = p.trim().split("\\").join("/");
|
|
37
|
+
if (!s) return null;
|
|
38
|
+
if (projectDir && path.isAbsolute(s)) {
|
|
39
|
+
const rel = path.relative(projectDir, path.resolve(s));
|
|
40
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
41
|
+
s = rel.split(path.sep).join("/");
|
|
42
|
+
}
|
|
43
|
+
if (path.isAbsolute(s)) return null;
|
|
44
|
+
s = path.posix.normalize(s);
|
|
45
|
+
while (s.startsWith("./")) s = s.slice(2);
|
|
46
|
+
while (s.endsWith("/")) s = s.slice(0, -1);
|
|
47
|
+
if (!s || s === "." || s.startsWith("../")) return null;
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- reading the graph ------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const refuse = (reason) => ({ ok: false, reason });
|
|
54
|
+
|
|
55
|
+
function positiveInt(value, fallback) {
|
|
56
|
+
if (value === undefined || value === null) return fallback;
|
|
57
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Unknown keys are ignored by design, so a v:1 writer can add a field (a `docType` check, say)
|
|
61
|
+
// without every installed harness refusing the graph it has always been able to walk.
|
|
62
|
+
function readValidate(value, id) {
|
|
63
|
+
if (value === undefined || value === null) return { value: null };
|
|
64
|
+
if (typeof value !== "object" || Array.isArray(value)) return { error: "`validate` is not an object" };
|
|
65
|
+
const out = {};
|
|
66
|
+
if (value.minBytes !== undefined && value.minBytes !== null) {
|
|
67
|
+
if (!Number.isInteger(value.minBytes) || value.minBytes < 0) return { error: "`validate.minBytes` is not a non-negative integer" };
|
|
68
|
+
out.minBytes = value.minBytes;
|
|
69
|
+
}
|
|
70
|
+
if (value.headings !== undefined && value.headings !== null) {
|
|
71
|
+
if (!Array.isArray(value.headings) || value.headings.some((h) => typeof h !== "string" || !h.trim())) {
|
|
72
|
+
return { error: "`validate.headings` is not a list of non-empty strings" };
|
|
73
|
+
}
|
|
74
|
+
out.headings = value.headings.map((h) => h.trim());
|
|
75
|
+
}
|
|
76
|
+
return { value: Object.keys(out).length ? out : null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Depth-first colouring; returns the cycle as a readable chain, or null.
|
|
80
|
+
function findCycle(nodes, byId) {
|
|
81
|
+
const state = new Map();
|
|
82
|
+
const stack = [];
|
|
83
|
+
let found = null;
|
|
84
|
+
const visit = (id) => {
|
|
85
|
+
if (found) return;
|
|
86
|
+
const colour = state.get(id);
|
|
87
|
+
if (colour === "done") return;
|
|
88
|
+
if (colour === "open") {
|
|
89
|
+
found = [...stack.slice(stack.indexOf(id)), id];
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
state.set(id, "open");
|
|
93
|
+
stack.push(id);
|
|
94
|
+
for (const dep of byId.get(id).deps) visit(dep);
|
|
95
|
+
stack.pop();
|
|
96
|
+
state.set(id, "done");
|
|
97
|
+
};
|
|
98
|
+
for (const node of nodes) visit(node.id);
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// text → { ok: true, graph } | { ok: false, reason }. Never throws: an adapter calls this on
|
|
103
|
+
// whatever bytes were on disk.
|
|
104
|
+
export function readGraph(text, projectDir) {
|
|
105
|
+
let raw;
|
|
106
|
+
try {
|
|
107
|
+
raw = JSON.parse(String(text ?? ""));
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return refuse(`it is not valid JSON (${e?.message || e})`);
|
|
110
|
+
}
|
|
111
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return refuse("it is not a JSON object");
|
|
112
|
+
if (raw.v !== GRAPH_VERSION) {
|
|
113
|
+
return refuse(`its version is ${JSON.stringify(raw.v)}, and this harness reads v${GRAPH_VERSION} graphs only`);
|
|
114
|
+
}
|
|
115
|
+
if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) return refuse("`nodes` is missing or empty");
|
|
116
|
+
|
|
117
|
+
const nodes = [];
|
|
118
|
+
const byId = new Map();
|
|
119
|
+
const byPath = new Map();
|
|
120
|
+
for (const [i, entry] of raw.nodes.entries()) {
|
|
121
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return refuse(`node #${i + 1} is not an object`);
|
|
122
|
+
const id = typeof entry.id === "string" ? entry.id.trim() : "";
|
|
123
|
+
if (!id) return refuse(`node #${i + 1} has no \`id\``);
|
|
124
|
+
if (byId.has(id)) return refuse(`two nodes share the id \`${id}\``);
|
|
125
|
+
const nodePath = normalizeGraphPath(entry.path, projectDir);
|
|
126
|
+
if (!nodePath) return refuse(`node \`${id}\` has no usable \`path\` (it must be relative and inside the project)`);
|
|
127
|
+
if (byPath.has(nodePath)) return refuse(`nodes \`${byPath.get(nodePath)}\` and \`${id}\` both claim \`${nodePath}\``);
|
|
128
|
+
const rawDeps = entry.deps === undefined || entry.deps === null ? [] : entry.deps;
|
|
129
|
+
if (!Array.isArray(rawDeps) || rawDeps.some((d) => typeof d !== "string" || !d.trim())) {
|
|
130
|
+
return refuse(`node \`${id}\` has a \`deps\` entry that is not a node id`);
|
|
131
|
+
}
|
|
132
|
+
const validate = readValidate(entry.validate, id);
|
|
133
|
+
if (validate.error) return refuse(`node \`${id}\`: ${validate.error}`);
|
|
134
|
+
const node = { id, path: nodePath, deps: rawDeps.map((d) => d.trim()), validate: validate.value };
|
|
135
|
+
byId.set(id, node);
|
|
136
|
+
byPath.set(nodePath, id);
|
|
137
|
+
nodes.push(node);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const node of nodes) {
|
|
141
|
+
for (const dep of node.deps) {
|
|
142
|
+
if (dep === node.id) return refuse(`node \`${node.id}\` depends on itself`);
|
|
143
|
+
if (!byId.has(dep)) return refuse(`node \`${node.id}\` depends on \`${dep}\`, which no node declares`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const cycle = findCycle(nodes, byId);
|
|
147
|
+
if (cycle) return refuse(`the nodes form a cycle: ${cycle.join(" → ")}`);
|
|
148
|
+
|
|
149
|
+
const maxAttempts = positiveInt(raw.maxAttempts, DEFAULT_MAX_ATTEMPTS);
|
|
150
|
+
if (maxAttempts === null) return refuse("`maxAttempts` is not a positive integer");
|
|
151
|
+
const maxWrites = positiveInt(raw.maxWrites, DEFAULT_MAX_WRITES);
|
|
152
|
+
if (maxWrites === null) return refuse("`maxWrites` is not a positive integer");
|
|
153
|
+
|
|
154
|
+
let mode = null;
|
|
155
|
+
if (raw.mode !== undefined && raw.mode !== null) {
|
|
156
|
+
if (typeof raw.mode !== "object" || Array.isArray(raw.mode)) return refuse("`mode` is not an object");
|
|
157
|
+
const walls = raw.mode.walls;
|
|
158
|
+
if (walls !== undefined && walls !== null) {
|
|
159
|
+
if (!Array.isArray(walls) || walls.some((w) => typeof w !== "string")) return refuse("`mode.walls` is not a list of wall names");
|
|
160
|
+
mode = { walls: walls.map((w) => w.trim()).filter(Boolean) };
|
|
161
|
+
} else {
|
|
162
|
+
mode = { walls: null };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { ok: true, graph: { v: GRAPH_VERSION, nodes, maxAttempts, maxWrites, mode } };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- state ------------------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
export function initialState(graph, runId) {
|
|
172
|
+
const nodes = {};
|
|
173
|
+
for (const node of graph.nodes) nodes[node.id] = { status: "pending", attempts: 0 };
|
|
174
|
+
return { v: STATE_VERSION, runId: runId ?? null, writes: 0, nodes };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Every entry point starts here: a state carried across a graph edit (a node added, one renamed)
|
|
178
|
+
// is completed rather than refused, and a foreign key is dropped rather than walked.
|
|
179
|
+
function cloneState(graph, state) {
|
|
180
|
+
const next = { v: STATE_VERSION, runId: state?.runId ?? null, writes: Number.isInteger(state?.writes) ? state.writes : 0, nodes: {} };
|
|
181
|
+
for (const node of graph.nodes) {
|
|
182
|
+
const cell = state?.nodes?.[node.id];
|
|
183
|
+
const status = STATUSES.includes(cell?.status) ? cell.status : "pending";
|
|
184
|
+
next.nodes[node.id] = { status, attempts: Number.isInteger(cell?.attempts) ? cell.attempts : 0 };
|
|
185
|
+
if (cell?.writtenAt) next.nodes[node.id].writtenAt = cell.writtenAt;
|
|
186
|
+
if (cell?.lastError) next.nodes[node.id].lastError = cell.lastError;
|
|
187
|
+
}
|
|
188
|
+
return next;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The structural gate: exists, non-empty, big enough, carries the headings the graph asked for.
|
|
192
|
+
// Semantic quality is never this module's business — it decides whether a node LANDED, not
|
|
193
|
+
// whether what landed is any good.
|
|
194
|
+
function validationFailure(node, observation) {
|
|
195
|
+
const text = typeof observation.text === "string" ? observation.text : null;
|
|
196
|
+
const bytes = Number.isInteger(observation.bytes) ? observation.bytes : text === null ? null : Buffer.byteLength(text, "utf8");
|
|
197
|
+
if (bytes === null) return null;
|
|
198
|
+
if (bytes === 0) return "the file is empty";
|
|
199
|
+
const minBytes = node.validate?.minBytes;
|
|
200
|
+
if (Number.isInteger(minBytes) && bytes < minBytes) return `the file is ${bytes} bytes, under the ${minBytes} the graph requires`;
|
|
201
|
+
const headings = node.validate?.headings;
|
|
202
|
+
if (headings?.length) {
|
|
203
|
+
if (text === null) return "the file could not be read to check its headings";
|
|
204
|
+
const missing = headings.filter((h) => !text.includes(h));
|
|
205
|
+
if (missing.length) return `it is missing ${missing.map((h) => `"${h}"`).join(", ")}`;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// `observed` is what the adapter found on disk for the DECLARED paths — never a tool argument,
|
|
211
|
+
// because a shell command writes files too and names none of them in a way a hook can trust.
|
|
212
|
+
// Absent from the list (or `exists: false`) means the file is not there: the node is untouched.
|
|
213
|
+
export function reconcile(graph, state, observed, now = new Date().toISOString()) {
|
|
214
|
+
const next = cloneState(graph, state);
|
|
215
|
+
const seen = new Map();
|
|
216
|
+
for (const entry of observed || []) {
|
|
217
|
+
if (!entry || entry.exists === false) continue;
|
|
218
|
+
const p = normalizeGraphPath(entry.path);
|
|
219
|
+
if (p) seen.set(p, entry);
|
|
220
|
+
}
|
|
221
|
+
for (const node of graph.nodes) {
|
|
222
|
+
const cell = next.nodes[node.id];
|
|
223
|
+
if (cell.status === "done") continue; // a done node is frozen — its file is never re-judged
|
|
224
|
+
const observation = seen.get(node.path);
|
|
225
|
+
if (!observation) continue;
|
|
226
|
+
const failure = validationFailure(node, observation);
|
|
227
|
+
if (failure) {
|
|
228
|
+
if (cell.status === "in_progress") cell.status = "pending";
|
|
229
|
+
cell.lastError = failure;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
cell.status = "done";
|
|
233
|
+
delete cell.lastError;
|
|
234
|
+
if (!cell.writtenAt) cell.writtenAt = now;
|
|
235
|
+
}
|
|
236
|
+
// Derived, and derived last: a node that is not done and has spent its attempts can never
|
|
237
|
+
// advance again. Saying so here is what turns "the model keeps not writing it" into a
|
|
238
|
+
// terminal run instead of an unbounded loop.
|
|
239
|
+
for (const node of graph.nodes) {
|
|
240
|
+
const cell = next.nodes[node.id];
|
|
241
|
+
if (cell.status !== "done" && cell.attempts >= graph.maxAttempts) cell.status = "failed";
|
|
242
|
+
}
|
|
243
|
+
return next;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The corrupt-state path, and Apollo's resume path: derive a state from the disk. `prev` carries
|
|
247
|
+
// only the attempt counters — a resumed run must not get its budget back.
|
|
248
|
+
export function rebuildState(graph, observed, prev = null) {
|
|
249
|
+
const base = initialState(graph, prev?.runId ?? null);
|
|
250
|
+
if (prev?.nodes) {
|
|
251
|
+
for (const node of graph.nodes) {
|
|
252
|
+
const attempts = prev.nodes[node.id]?.attempts;
|
|
253
|
+
if (Number.isInteger(attempts) && attempts > 0) base.nodes[node.id].attempts = attempts;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return reconcile(graph, base, observed);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- selection --------------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
// Declaration order is the tie-break, so a graph reads top to bottom and two runs of the same
|
|
262
|
+
// graph pick the same node.
|
|
263
|
+
export function nextEligible(graph, state) {
|
|
264
|
+
const s = cloneState(graph, state);
|
|
265
|
+
for (const node of graph.nodes) {
|
|
266
|
+
const cell = s.nodes[node.id];
|
|
267
|
+
if (cell.status !== "pending") continue;
|
|
268
|
+
if (cell.attempts >= graph.maxAttempts) continue;
|
|
269
|
+
if (node.deps.some((dep) => s.nodes[dep]?.status !== "done")) continue;
|
|
270
|
+
return node;
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The node the model was last told to produce and has not finished.
|
|
276
|
+
export function inProgress(graph, state) {
|
|
277
|
+
const s = cloneState(graph, state);
|
|
278
|
+
return graph.nodes.find((node) => s.nodes[node.id].status === "in_progress") || null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function injectionLine(node) {
|
|
282
|
+
return `Now generate ${node.id} at ${node.path}.`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// One selection, one attempt — never one write, one attempt. A document built by six edits spends
|
|
286
|
+
// one attempt; a turn that ends without landing the document spends the next one. That is what
|
|
287
|
+
// makes "the model keeps almost doing it" a bounded condition.
|
|
288
|
+
export function select(graph, state, id) {
|
|
289
|
+
const next = cloneState(graph, state);
|
|
290
|
+
const node = graph.nodes.find((n) => n.id === id) || null;
|
|
291
|
+
if (!node) return { state: next, node: null, line: null };
|
|
292
|
+
const cell = next.nodes[id];
|
|
293
|
+
cell.status = "in_progress";
|
|
294
|
+
cell.attempts = cell.attempts + 1;
|
|
295
|
+
return { state: next, node, line: injectionLine(node) };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- termination ------------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
// Nodes that can never run because something they depend on failed — transitively.
|
|
301
|
+
function unreachable(graph, state) {
|
|
302
|
+
const failed = new Set(graph.nodes.filter((n) => state.nodes[n.id].status === "failed").map((n) => n.id));
|
|
303
|
+
const blocked = new Set();
|
|
304
|
+
let moved = true;
|
|
305
|
+
while (moved) {
|
|
306
|
+
moved = false;
|
|
307
|
+
for (const node of graph.nodes) {
|
|
308
|
+
if (failed.has(node.id) || blocked.has(node.id)) continue;
|
|
309
|
+
if (state.nodes[node.id].status === "done") continue;
|
|
310
|
+
if (node.deps.some((dep) => failed.has(dep) || blocked.has(dep))) {
|
|
311
|
+
blocked.add(node.id);
|
|
312
|
+
moved = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { failed: [...failed], blocked: [...blocked] };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The run ends when nothing can advance — and the outcome says WHY, because "the box stopped" and
|
|
320
|
+
// "the box finished" must never look alike to the coordinator reading the seal.
|
|
321
|
+
export function isTerminal(graph, state) {
|
|
322
|
+
const s = cloneState(graph, state);
|
|
323
|
+
const remaining = graph.nodes.filter((n) => s.nodes[n.id].status !== "done");
|
|
324
|
+
if (remaining.length === 0) {
|
|
325
|
+
return { terminal: true, outcome: "complete", summary: `The build graph is complete — all ${graph.nodes.length} node(s) landed.` };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const overWrites = s.writes > graph.maxWrites;
|
|
329
|
+
if (!overWrites) {
|
|
330
|
+
const running = inProgress(graph, s);
|
|
331
|
+
if (running) {
|
|
332
|
+
return { terminal: false, outcome: null, summary: `In progress: ${running.id} at ${running.path}.` };
|
|
333
|
+
}
|
|
334
|
+
const next = nextEligible(graph, s);
|
|
335
|
+
if (next) {
|
|
336
|
+
return { terminal: false, outcome: null, summary: `Next: ${next.id} at ${next.path}.` };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const { failed, blocked } = unreachable(graph, s);
|
|
341
|
+
if (overWrites) {
|
|
342
|
+
return {
|
|
343
|
+
terminal: true,
|
|
344
|
+
outcome: "exhausted",
|
|
345
|
+
summary: `The build graph is exhausted — ${s.writes} writes passed the cap of ${graph.maxWrites} with ${remaining.map((n) => n.id).join(", ")} still owed.`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
if (blocked.length) {
|
|
349
|
+
return {
|
|
350
|
+
terminal: true,
|
|
351
|
+
outcome: "blocked",
|
|
352
|
+
summary: `The build graph is blocked — ${blocked.join(", ")} can never run because ${failed.join(", ")} failed.`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
terminal: true,
|
|
357
|
+
outcome: "exhausted",
|
|
358
|
+
summary: `The build graph is exhausted — ${failed.join(", ")} spent ${graph.maxAttempts} attempt(s) without landing.`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -335,6 +335,23 @@ export function stateDir(projectDir) {
|
|
|
335
335
|
return dir;
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
+
// The build graph's two machine-local files. Named here so every reader — the walker's adapters,
|
|
339
|
+
// the compliance gates, a composer writing a graph before any session has run — spells them once.
|
|
340
|
+
export const GRAPH_FILE = "graph.json";
|
|
341
|
+
export const GRAPH_STATE_FILE = "build-graph-state.json";
|
|
342
|
+
|
|
343
|
+
// The graph is READ from a plain join: a composer may drop it in before any hook has run, and
|
|
344
|
+
// asking for it must never create the folder (absence is the walker's dormancy signal).
|
|
345
|
+
export function graphFile(projectDir) {
|
|
346
|
+
return path.join(projectDir, ".claude", "state", GRAPH_FILE);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The state file is WRITTEN, so its accessor goes through stateDir() — which lays down the
|
|
350
|
+
// folder's `*` .gitignore on first use.
|
|
351
|
+
export function graphStateFile(projectDir) {
|
|
352
|
+
return path.join(stateDir(projectDir), GRAPH_STATE_FILE);
|
|
353
|
+
}
|
|
354
|
+
|
|
338
355
|
// Session-scoped ask memory: an `ask` gate fires once per (session, class); after the user has
|
|
339
356
|
// answered it once, repeats in the same session pass silently (the runtime's own permission
|
|
340
357
|
// prompts still apply). Records live in .claude/state/asked.tsv — machine-local, self-ignored.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// harness/code/hooks/lib/walls.mjs — the wall vocabulary.
|
|
2
|
+
//
|
|
3
|
+
// One name per gate the compliance module can refuse or ask at, in one place, because a build
|
|
4
|
+
// graph's `mode.walls` is an allowlist over exactly these strings. A name that drifts from the
|
|
5
|
+
// id the code passes to `deny()` silently switches a wall off — `tests/parity.test.mjs` asserts
|
|
6
|
+
// the two sets agree, so a rename fails there rather than in a generation box at 3am.
|
|
7
|
+
//
|
|
8
|
+
// The first ten are PreToolUse gate ids as recorded in the session log; the last four name Stop
|
|
9
|
+
// sub-walls that had no id at all until `mode.walls` needed to talk about them.
|
|
10
|
+
export const WALLS = [
|
|
11
|
+
"frozen-record",
|
|
12
|
+
"protected-file",
|
|
13
|
+
"plan-gate",
|
|
14
|
+
"changelog",
|
|
15
|
+
"bookkeeping",
|
|
16
|
+
"package-table",
|
|
17
|
+
"foreign-claim",
|
|
18
|
+
"branch-worktree",
|
|
19
|
+
"live-command",
|
|
20
|
+
"commit-claim",
|
|
21
|
+
"queue",
|
|
22
|
+
"open-denials",
|
|
23
|
+
"ledger",
|
|
24
|
+
"changelog-entry",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// The build graph's own denials are deliberately absent: they are not skippable, because they
|
|
28
|
+
// are the graph enforcing itself. A name in this list is a name a graph can switch off.
|
|
29
|
+
|
|
30
|
+
// `allowlist` is null when no graph declares one — every wall on, today's behaviour.
|
|
31
|
+
export function wallEnabled(allowlist, name) {
|
|
32
|
+
if (!Array.isArray(allowlist)) return true;
|
|
33
|
+
return allowlist.includes(name);
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.14",
|
|
4
4
|
"description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
"agent",
|
|
43
43
|
"plugin"
|
|
44
44
|
],
|
|
45
|
-
"etnaFingerprint": "
|
|
45
|
+
"etnaFingerprint": "7f155d8901ee"
|
|
46
46
|
}
|