faberun 0.27.0 → 0.29.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.
Files changed (83) hide show
  1. package/README.md +9 -3
  2. package/package.json +1 -1
  3. package/skills/faberun/SKILL.md +3 -3
  4. package/skills/faberun/references/contract.md +9 -9
  5. package/skills/faberun/references/local-env.md +11 -0
  6. package/skills/faberun/references/operations.md +26 -33
  7. package/src/campaign/brief.mjs +41 -9
  8. package/src/campaign/campaign-brief.mjs +18 -3
  9. package/src/campaign/index.mjs +22 -9
  10. package/src/campaign/ledger.mjs +72 -4
  11. package/src/campaign/metrics.mjs +29 -2
  12. package/src/campaign/record.mjs +34 -0
  13. package/src/cli/campaign.mjs +35 -12
  14. package/src/cli/contract.mjs +11 -1
  15. package/src/cli/launch.mjs +31 -4
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/plan.mjs +56 -5
  18. package/src/cli/setup.mjs +5 -0
  19. package/src/cli/skills.mjs +18 -7
  20. package/src/cli/uninstall.mjs +542 -0
  21. package/src/cli/walkthrough.mjs +462 -0
  22. package/src/cli.mjs +72 -8
  23. package/src/contract/articles.mjs +30 -0
  24. package/src/contract/definition-of-done.mjs +164 -0
  25. package/src/contract/index.mjs +8 -5
  26. package/src/contract/judge-independence.mjs +18 -0
  27. package/src/contract/runtime.mjs +20 -6
  28. package/src/contract/scope-findings.mjs +120 -0
  29. package/src/contract/snapshot.mjs +19 -5
  30. package/src/contract/task-packet.mjs +49 -1
  31. package/src/contract/worker-result.mjs +37 -11
  32. package/src/engine/assignment.mjs +45 -0
  33. package/src/engine/detach.mjs +18 -0
  34. package/src/engine/gate.mjs +10 -2
  35. package/src/engine/judge-gate.mjs +24 -9
  36. package/src/engine/live-preflight.mjs +117 -18
  37. package/src/engine/prompts.mjs +58 -4
  38. package/src/engine/resume.mjs +119 -12
  39. package/src/engine/retry.mjs +11 -11
  40. package/src/engine/run-command.mjs +80 -4
  41. package/src/engine/run-identity.mjs +27 -14
  42. package/src/engine/runtime-discovery.mjs +15 -9
  43. package/src/engine/scheduler.mjs +8 -1
  44. package/src/engine/worker-env.mjs +179 -0
  45. package/src/harnesses/agy/index.mjs +11 -0
  46. package/src/harnesses/catalogue.mjs +29 -0
  47. package/src/harnesses/claude/index.mjs +98 -1
  48. package/src/harnesses/codex/index.mjs +15 -0
  49. package/src/harnesses/dsh/index.mjs +14 -0
  50. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  51. package/src/harnesses/fx/index.mjs +13 -0
  52. package/src/harnesses/index.mjs +21 -10
  53. package/src/harnesses/replay/index.mjs +12 -0
  54. package/src/harnesses/zcode/index.mjs +19 -0
  55. package/src/host/config.mjs +8 -2
  56. package/src/host/environment-report.mjs +85 -0
  57. package/src/host/home.mjs +27 -0
  58. package/src/host/install-registry.mjs +511 -0
  59. package/src/host/preflight.mjs +32 -9
  60. package/src/host/uninstall-roots.mjs +196 -0
  61. package/src/plan/freeze.mjs +2 -0
  62. package/src/plan/pipeline.mjs +56 -4
  63. package/src/plan/progress.mjs +101 -0
  64. package/src/plan/proof-check.mjs +27 -0
  65. package/src/plan/proof-scope.mjs +1 -1
  66. package/src/plan/repo-facts.mjs +129 -14
  67. package/src/plan/rounds.mjs +27 -0
  68. package/src/plan/spec.mjs +82 -6
  69. package/src/plan/template.mjs +9 -0
  70. package/src/repo/declared-paths.mjs +74 -6
  71. package/src/repo/integrate.mjs +1 -1
  72. package/src/repo/scope-closure.mjs +59 -0
  73. package/src/repo/signal-block.mjs +26 -0
  74. package/src/repo/signal.mjs +9 -24
  75. package/src/repo/workspace.mjs +189 -14
  76. package/src/repo/worktree-prune.mjs +13 -5
  77. package/src/repo/worktree.mjs +54 -0
  78. package/src/report/campaign-brief.mjs +4 -0
  79. package/src/report/message.mjs +37 -6
  80. package/src/report/progress.mjs +13 -2
  81. package/src/run/paths.mjs +14 -1
  82. package/src/run/usage.mjs +66 -1
  83. package/src/util.mjs +18 -2
package/README.md CHANGED
@@ -108,8 +108,9 @@ checkout. The operator keeps working while the campaign runs.
108
108
  ### Durable campaigns
109
109
 
110
110
  A run is not tied to the terminal that launched it. The controller runs
111
- detached, state is persisted under `.runs/`, `supervise` resumes a controller
112
- that died, and a campaign carries its intent across many runs and sessions. If a
111
+ detached, state is persisted under the project's runs directory in
112
+ `$FABERUN_HOME` (default `~/.faberun`), `supervise` resumes a controller that
113
+ died, and a campaign carries its intent across many runs and sessions. If a
113
114
  provider goes down or a harness session ends, the work does not have to be
114
115
  re-authored.
115
116
 
@@ -218,6 +219,11 @@ An installed copy updates itself from the newest GitHub release:
218
219
  faberun update
219
220
  ```
220
221
 
222
+ To leave cleanly, `faberun uninstall --dry-run` lists what the install wrote
223
+ outside a repository — registered skills, status-line and hook entries, and
224
+ `$FABERUN_HOME` — and `faberun uninstall` removes it after confirming, refusing
225
+ while a campaign's ledger is not preserved at its repository unless `--force`.
226
+
221
227
  ## Quickstart
222
228
 
223
229
  The full walkthrough, from a fresh machine to a first verified node, is in
@@ -226,7 +232,7 @@ The full walkthrough, from a fresh machine to a first verified node, is in
226
232
  | Step | Command | What it does |
227
233
  | --- | --- | --- |
228
234
  | 1 | `faberun setup` | Onboards the machine: checks node and git, discovers the harnesses, and writes the default worker and judge. |
229
- | 2 | `faberun init` | Prepares a repository: confirms a git work tree, ignores `.runs/`, and installs the `faberun` skill. |
235
+ | 2 | `faberun init` | Prepares a repository: confirms a git work tree, ignores the legacy in-tree runs directory, and installs the `faberun` skill. |
230
236
  | 3 | `faberun campaign init <id> --goal "..."` | Opens the durable campaign that carries the intent across runs. |
231
237
  | 4 | write a contract | Fixes `contract.json`: the node DAG, each packet's read and write scope, and each definition of done. |
232
238
  | 5 | `faberun validate contract.json` | Parses the contract and prints the report the authoring turn reads. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,11 +16,11 @@ Read [rules](references/rules.md) first.
16
16
  | Dispatch a node | [handoffs](references/handoffs.md) |
17
17
  | Supervise, answer | [operations](references/operations.md), [handoffs](references/handoffs.md) |
18
18
  | Verify, judge | [engineering](references/engineering.md) |
19
- | Install, set up, update | [operations](references/operations.md) |
19
+ | Install, set up, update, uninstall | [operations](references/operations.md) |
20
+ | Doctor --env | [local-env](references/local-env.md) |
20
21
 
21
- Watchdog re-invocations:
22
+ Watchdog:
22
23
 
23
- faberun supervise <run-dir>
24
24
  faberun supervise campaign <id> [--allow-main]
25
25
 
26
26
  launchd: `StartInterval 300`; `launchctl load -w ~/Library/LaunchAgents/faberun.plist`.
@@ -187,17 +187,15 @@ never zero.
187
187
 
188
188
  Non-empty `taskPacket.verification` is rejected when the resolved worker or a
189
189
  worker fallback cannot execute commands. Adapters declare
190
- `permissionExecution`: `claude` only `bypassPermissions` (default
191
- `acceptEdits`), `zcode` only `yolo` (also default), `dsh` both its default
192
- `workspace-write` (measured: executes and writes inside the worktree) and
193
- `danger-full-access` (only for effects outside it); every `codex` sandbox mode
194
- executes, and `agy`/`exec-jsonl`/`replay` expose no denying mode. Each adapter
195
- also declares `signalsProcesses` (`true`, `false`, or `null` when unmeasured):
190
+ `permissionExecution`: `claude` `bypassPermissions` (default `acceptEdits`),
191
+ `zcode` `yolo` (also default), `dsh` both its default `workspace-write` and
192
+ `danger-full-access`; every `codex` sandbox mode executes, and
193
+ `agy`/`exec-jsonl`/`replay` expose no denying mode. Each adapter
194
+ also declares `signalsProcesses` (`true`, `false`, or unmeasured `null`):
196
195
  a worker whose adapter declares `false` gets a `## Sandbox` prompt warning not
197
196
  to run tests that start and terminate child processes, and
198
197
  `requiredCapabilities.signalsProcesses: true` admits only an adapter declaring
199
- `true`. Judge modes
200
- are excluded because judges review captured results.
198
+ `true`. Judge modes are excluded: they review captured results.
201
199
 
202
200
  - `claude`: `permissionMode` (a node that runs commands needs
203
201
  `bypassPermissions`, or the worker can only return `blocked_context`).
@@ -237,7 +235,9 @@ are excluded because judges review captured results.
237
235
  (`deepseek-flash` is the default); unknown ids fail in the harness.
238
236
  Authentication stays in `DEEPSEEK_API_KEY`;
239
237
  `config["api_key.env_key"]` only names it for `preflight`. `sandbox` maps to
240
- `DSH_PERMISSION_MODE`, default `workspace-write` (above). Every attempt loads
238
+ `DSH_PERMISSION_MODE`, default `workspace-write`: a toolchain cache outside
239
+ the worktree gets `ReadOnlyFileSystem`, so compiling needs
240
+ `danger-full-access`. Every attempt loads
241
241
  `dsh/closed-packet.patch.yml`; `config.patch` stacks one layer. Executable
242
242
  override: `executable` or `FABERUN_DSH_BIN`. No default vendor,
243
243
  continuation (`session/resume` is ACP-only), or native schema flag; the judge
@@ -0,0 +1,11 @@
1
+ # Local environment
2
+
3
+ A worker or judge process gets only the environment it is allowed — the base
4
+ set (`PATH`, `HOME`, `USER`, `SHELL`, `LANG`, `LC_*`, `TERM`, `TMPDIR`, `TZ`,
5
+ plus the Windows names), the names a harness adapter declares, its runtimes'
6
+ `env_key` names and any `envPassthrough` — while this phase's
7
+ definition-of-done commands and `finalVerification` keep today's environment.
8
+
9
+ `faberun doctor --env [<contract.json>]` lists, per runtime, the names that
10
+ would pass and the names excluded, never the values; a credential-shaped
11
+ exclusion is marked retained.
@@ -9,17 +9,11 @@ source `gitHead`. Every worker attempt gets a linked worktree at
9
9
  `faberun/<run-id>/<node-id>/<attempt>`, cut from that ref; the node snapshot records
10
10
  `worktree.path`, `.branch`, `.baseSha` and the sealed `.commit`. Provider,
11
11
  scope, verification and judge processes all use that path; `contract.cwd` stays
12
- the home of run/control artifacts. An installed root `node_modules` is
13
- symlinked into every attempt worktree, never copied.
12
+ the home of run/control artifacts.
14
13
 
15
14
  A retried attempt never discards the previous one's edits: the controller seals
16
15
  the previous worktree first and, when that seal has a diff, cuts the next
17
- attempt from that sha (`worktree.previousAttempt`); an empty seal falls back to
18
- the run ref tip.
19
-
20
- `contract.maxParallel` bounds concurrent nodes; each tick dispatches every
21
- `pending` node whose dependencies are `done`, up to the free slots, each into
22
- its own worktree.
16
+ attempt from that sha (`worktree.previousAttempt`).
23
17
 
24
18
  ## Integration transaction
25
19
 
@@ -36,9 +30,8 @@ untouched, and keeps the attempt worktree. A conflict marks the node `attention`
36
30
  with the conflicting paths and cleans the scratch worktree.
37
31
 
38
32
  Resume replays `integration.jsonl`, never ancestry, to identify the one
39
- unfinished transaction and complete it idempotently. A resume that re-dispatches
40
- a failed/stalled/exhausted/canceled node cuts the next attempt from the previous
41
- attempt's sealed sha, the same continuation rule as any other retry.
33
+ unfinished transaction and complete it idempotently; a re-dispatch follows the
34
+ same continuation rule as any other retry.
42
35
 
43
36
  ## Controller lock and takeover
44
37
 
@@ -50,17 +43,16 @@ otherwise it exits `controller_active`, untouched. Takeover renames the lock
50
43
  aside and re-checks it is stale before installing its own. Worker/judge/
51
44
  verification children run detached in their own process group, so before
52
45
  dispatching new work `resume`'s recovery pass terminates (`SIGTERM` then
53
- `SIGKILL`, same as `cancel`) every invocation recorded for a `running` node —
54
- unless it is still inside its deadline, when it is adopted and its result read.
46
+ `SIGKILL`) every invocation recorded for a `running` node —
47
+ unless still inside its deadline, when it is adopted and its result read.
55
48
  `cancel` signals a live controller first, so its takeover never waits.
49
+ `resume --reauthor <node-id>` re-widens a refused packet under a hard round
50
+ budget.
56
51
 
57
- `supervise <run-dir> [--detach] [--interval <sec>]` is the watchdog above that.
58
- It holds no lock and writes no state: every interval (default 30s) it launches
59
- `resume --detach` when a node is unfinished and no controller is live, exits 0
60
- once all are terminal, and stops after three failed launches. An empty run
61
- directory is never resumed. A detached controller outlives its launcher,
62
- not the session scope (cgroup) holding it: run long work under `tmux`,
63
- `systemd-run` or the seat.
52
+ `supervise <run-dir> [--detach] [--interval <sec>]` is the watchdog;
53
+ it holds no lock and writes no state. A detached controller outlives its
54
+ launcher, not the session scope (cgroup) holding it: run long work under
55
+ `tmux`, `systemd-run` or the seat.
64
56
 
65
57
  ## Runtime discovery
66
58
 
@@ -101,7 +93,7 @@ The same server exposes the phone surface under `/api/*`, behind the
101
93
  same token and bind. Reads: `GET /api/campaigns`, `/api/campaigns/<id>`,
102
94
  `…/brief`, `/api/seats`, and `…/events?after=<cursor>` (one bounded page, ≤32 KiB
103
95
  and ≤100 entries, so a client starting at zero never drags the whole journal).
104
- Writes shell out to the runner CLI and touch no state themselves:
96
+ Writes shell out to the runner CLI:
105
97
  `POST …/decisions/<id>` → `campaign resolve`; `…/note` → `campaign note`;
106
98
  `…/pause` and `…/resume` → `cancel` / `resume --detach`; `/api/seats/<id>/switch`
107
99
  → `seat switch`. No replan, contract, routing or gate route exists on purpose:
@@ -110,7 +102,7 @@ the contract is frozen with a digest, and the phone's middle ground is a note.
110
102
  ## Notify
111
103
 
112
104
  On `node.terminal`, `run.terminal` and `attention` the controller renders one
113
- message from persisted state, in the operator's own language (detected from
105
+ message from persisted state, in the operator's language (from
114
106
  the campaign goal, journal notes and node objectives, or `FABERUN_NOTIFY_LANG`;
115
107
  English otherwise). Only `run.terminal`, `attention` and `advisory` leave by
116
108
  default -- a phase settling and a person being needed -- while a node settling
@@ -118,31 +110,31 @@ gets a `filtered` receipt; `FABERUN_NOTIFY_EVENTS` widens or narrows the list:
118
110
  line one is the outcome (`✅ <node> · done in 8m · $0.09`, `🏁 run 15 · <name>
119
111
  · 2/2 done`, or `👀 <node> needs you · <error>`), then asked / done / proof
120
112
  for a node, what every node delivered for a run, or why / asked / do for
121
- attention, then a progress bar, a rule and the `🐦 faberun` signature with
122
- campaign percent, cost and elapsed; an `⬆️` line names a newer release when
113
+ attention, then a progress bar, a rule and the `🐦 faberun` signature (campaign
114
+ percent, cost, elapsed); an `⬆️` line names a newer release when
123
115
  the cached update check has one. ≤2 KiB. It delivers that
124
- same text to every bound transport at once, appending one receipt
125
- (`delivered`, `failed`, `no_transport`, one entry per transport) to
116
+ text to every bound transport at once, appending one receipt
117
+ (`delivered`, `failed`, `no_transport`, per transport) to
126
118
  `<run-dir>/notify.jsonl`. Delivery is lossy: **exactly one attempt**, no retry,
127
119
  no backoff. `FABERUN_NOTIFY_BIN` names an executable called with the event as
128
120
  JSON on stdin (`os-macos` selects the bundled `osascript` adapter); it pushes to
129
121
  a person, `canWake: false`. `FABERUN_NOTIFY_SESSION` wakes harness sessions,
130
122
  `canWake: true`: `auto` is the Claude Code inbox and Codex thread the
131
123
  environment names; `claude:<socket>` adds another session's inbox, so the
132
- operator's own interactive session hears a run a background session launched
133
- (`echo $CLAUDE_CODE_MESSAGING_SOCKET` in that session gives the path). A seat
134
- window sets `auto`; nothing else does, so a test suite never wakes a session.
124
+ operator's own session hears a background run
125
+ (`echo $CLAUDE_CODE_MESSAGING_SOCKET` in that session gives the path). Only a seat
126
+ window sets `auto`, so a test suite never wakes a session.
135
127
  **On an inbound `🐦 faberun` message**: repeat it to the operator verbatim,
136
- then one line on what you do next -- the message exists to inform the person,
137
- not you, and it reaches them only through your reply; `👀`, act on the `do`
128
+ then one line on what you do next -- it informs the person, not
129
+ you, and reaches them only through your reply; `👀`, act on the `do`
138
130
  command it names. A
139
131
  resume never re-sends a notification already recorded for the same node, attempt
140
132
  and outcome. No transport is a default: `doctor`, `preflight` and the foreground
141
- launch warn when both variables are empty, and `--wake` names what will wake.
133
+ launch warn when both are empty, and `--wake` names what will wake.
142
134
  Campaign-level lines are queued in `.runs/inbox.jsonl`, the managed block's
143
135
  append-only record — one object per line `{schemaVersion, eventId, at, type,
144
136
  campaignId, runId, nodeId, status, errorCode, dedupeKey, summary}`, deduped on
145
- `dedupeKey` (first write wins) with one `O_APPEND` write per line.
137
+ `dedupeKey` with one `O_APPEND` write per line.
146
138
  `campaign watch --wake --detach` queues there and delivers through
147
139
  `<campaign-dir>/notify.jsonl`; a durable `watch.lock` plus the inbox dedupe keep
148
140
  two detached watchers from double-sending across a restart.
@@ -207,3 +199,4 @@ binary is absent, and only reattaching is lost.
207
199
  `faberun skills install [name]` installs the shipped skills.
208
200
  `faberun skills register [--harness a,b] [--copy] [--force]` links the faberun skill into each installed harness's skills directory.
209
201
  `faberun campaign unpark <id> [--force]` clears a parked campaign once its run is no longer parked so `supervise campaign` can continue.
202
+ `faberun uninstall [--dry-run]` removes what faberun wrote outside a target repository and `$FABERUN_HOME`; an unpreserved ledger refuses unless `--force`.
@@ -17,12 +17,13 @@ import { boundedText, compactCost, compactTokens, finite, readJsonTolerant } fro
17
17
  import { join } from "node:path";
18
18
  import { readCampaign } from "./record.mjs";
19
19
  import { readProjectionState } from "./projection.mjs";
20
+ import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
20
21
  import { writeTextAtomic } from "../run/store.mjs";
21
22
 
22
23
  /** @typedef {import("./index.mjs").Campaign} Campaign */
23
24
  /** @typedef {import("./index.mjs").JournalEntry} JournalEntry */
24
25
  /** @typedef {import("./index.mjs").Projection} Projection */
25
- /** @typedef {{id: string, exists: boolean, summary: string, controller: string, phase: string|null, done: number, total: number, costUsd: number|null, inputTokens: number|null, outputTokens: number|null, attention: {id: string, status: string, note: string}[]}} BriefRun */
26
+ /** @typedef {{id: string, exists: boolean, summary: string, controller: string, phase: string|null, judgeIndependence?: string|null, done: number, total: number, costUsd: number|null, inputTokens: number|null, outputTokens: number|null, attention: {id: string, status: string, note: string}[]}} BriefRun */
26
27
  /** @typedef {{campaign: Campaign, updatedAt: string, phase: string|null, checkpoints: {done: number, total: number}, runs: BriefRun[], openQuestions: JournalEntry[], transitions: JournalEntry[], budget: {costUsd: number|null, inputTokens: number|null, outputTokens: number|null}, notes: JournalEntry[], commands: string[]}} Brief */
27
28
  /** @typedef {{limit: number, used: number, lines: string[]}} BriefBudget */
28
29
 
@@ -75,9 +76,11 @@ export function materializeBrief(campaignPath, brief) {
75
76
  }
76
77
 
77
78
  /**
78
- * Read the campaign's live projection and each linked run's `status.json`, then
79
+ * Read the campaign's live projection, each linked run's `status.json` and the
80
+ * effective judge-independence mode recorded on that run's node snapshots, then
79
81
  * materialize the brief. `heartbeat.json` does not exist in this tree -- the
80
- * projection and per-run status are the durable sources.
82
+ * projection, the per-run status and the persisted node snapshots are the
83
+ * durable sources.
81
84
  *
82
85
  * @param {string} campaignPath
83
86
  * @param {string} runsDir
@@ -95,16 +98,42 @@ export function renderBrief(campaignPath, runsDir) {
95
98
  * @returns {BriefRun[]}
96
99
  */
97
100
  function readRunBriefs(runsDir, runIds) {
98
- return runIds.map((runId) => runBrief(runId, readJsonTolerant(join(runsDir, runId, "status.json"))));
101
+ return runIds.map((runId) => {
102
+ const runDir = join(runsDir, runId);
103
+ return runBrief(runId, readJsonTolerant(join(runDir, "status.json")), recordedJudgeIndependence(runDir));
104
+ });
105
+ }
106
+
107
+ /**
108
+ * The effective judge-independence mode the launch recorded on this run's node
109
+ * snapshots (`routing.assignments.judgeIndependence`). The launch composes one
110
+ * mode per contract, so the first node that recorded one answers for the run;
111
+ * reading it here keeps the brief from re-deriving the mode from the contract,
112
+ * which cannot see a machine-config opt-in. A run with no persisted node or no
113
+ * recorded mode has no recorded opt-in (the cross-vendor default).
114
+ *
115
+ * @param {string} runDir
116
+ * @returns {string|null}
117
+ */
118
+ export function recordedJudgeIndependence(runDir) {
119
+ for (const name of [...listNodeSnapshots(runDir)].sort()) {
120
+ const snapshot = readJsonTolerant(nodeSnapshotPath(runDir, name.slice(0, -".json".length)));
121
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) continue;
122
+ const routing = /** @type {Record<string, any>} */ (snapshot).routing;
123
+ const mode = routing && typeof routing === "object" ? routing.assignments?.judgeIndependence : undefined;
124
+ if (typeof mode === "string" && mode) return mode;
125
+ }
126
+ return null;
99
127
  }
100
128
 
101
129
  /**
102
130
  * @param {string} runId
103
131
  * @param {unknown} status
132
+ * @param {string|null} [judgeIndependence] the mode the launch recorded on the run snapshot
104
133
  * @returns {BriefRun}
105
134
  */
106
- function runBrief(runId, status) {
107
- if (!status || typeof status !== "object" || Array.isArray(status)) return missingRunBrief(runId);
135
+ function runBrief(runId, status, judgeIndependence = null) {
136
+ if (!status || typeof status !== "object" || Array.isArray(status)) return missingRunBrief(runId, judgeIndependence);
108
137
  const record = /** @type {Record<string, any>} */ (status);
109
138
  const nodes = Array.isArray(record.nodes) ? record.nodes : [];
110
139
  const usage = record.usage && typeof record.usage === "object" ? /** @type {Record<string, unknown>} */ (record.usage) : {};
@@ -122,6 +151,7 @@ function runBrief(runId, status) {
122
151
  summary: typeof record.summary === "string" ? record.summary : "",
123
152
  controller: typeof record.controller?.state === "string" ? record.controller.state : "none",
124
153
  phase,
154
+ judgeIndependence,
125
155
  done: nodes.filter((node) => node.status === "done" || node.status === "no-op").length,
126
156
  total: nodes.length,
127
157
  costUsd: finite(usage.costUsd),
@@ -139,10 +169,11 @@ function runBrief(runId, status) {
139
169
 
140
170
  /**
141
171
  * @param {string} runId
172
+ * @param {string|null} [judgeIndependence]
142
173
  * @returns {BriefRun}
143
174
  */
144
- function missingRunBrief(runId) {
145
- return { id: runId, exists: false, summary: "no status.json yet", controller: "none", phase: null, done: 0, total: 0, costUsd: null, inputTokens: null, outputTokens: null, attention: [] };
175
+ function missingRunBrief(runId, judgeIndependence = null) {
176
+ return { id: runId, exists: false, summary: "no status.json yet", controller: "none", phase: null, judgeIndependence, done: 0, total: 0, costUsd: null, inputTokens: null, outputTokens: null, attention: [] };
146
177
  }
147
178
 
148
179
  /**
@@ -367,7 +398,8 @@ function runLine(run, cap, idCap) {
367
398
  const hidden = run.attention.length - ATTENTION_LINE_LIMIT;
368
399
  if (hidden > 0) attention.push(`${hidden} more`);
369
400
  const detail = attention.length ? ` · ${attention.join("; ")}` : "";
370
- return `- ${id}: ${boundedText(run.summary, cap)} · checkpoints ${run.done}/${run.total} · controller ${boundedText(run.controller, cap)}${detail}`;
401
+ const independence = run.judgeIndependence ? ` · judge independence ${boundedText(run.judgeIndependence, cap)}` : "";
402
+ return `- ${id}: ${boundedText(run.summary, cap)} · checkpoints ${run.done}/${run.total} · controller ${boundedText(run.controller, cap)}${independence}${detail}`;
371
403
  }
372
404
 
373
405
  /**
@@ -51,7 +51,7 @@ import { projectCampaignDecisions } from "./projection.mjs";
51
51
  /** @typedef {{risk: string, impact: string, mitigation: string}} BriefRisk */
52
52
  /** @typedef {{intent: string|null, expectedOutcome: string|null, successCriteria: BriefSuccessCriterion[], humanFacts: string[], calculatedFacts: string[], gaps: string[]}} BriefOpening */
53
53
  /** @typedef {{human: string[], delegated: string[], journal: ProjectedDecision[], risks: BriefRisk[], evals: string[], gaps: string[]}} BriefDecisions */
54
- /** @typedef {{campaign: string, specBaseline: string|null, specDigest: string, specPath: string, targetGitHead: string|null, planPath: string, planDigest: string, contractDigest: string, journalCursor: number, usageSampleCutoff: string|null}} BriefIdentity */
54
+ /** @typedef {{campaign: string, specBaseline: string|null, specDigest: string, specPath: string, targetGitHead: string|null, planPath: string, planDigest: string, contractDigest: string, judgeIndependence?: string|null, journalCursor: number, usageSampleCutoff: string|null}} BriefIdentity */
55
55
  /** @typedef {{identity: BriefIdentity, opening: BriefOpening, coverage: BriefCoverage, graph: BriefGraph, decisions: BriefDecisions, estimate: BriefEstimate, decisionState: BriefDecisionState}} BriefModel */
56
56
 
57
57
  /**
@@ -71,6 +71,7 @@ import { projectCampaignDecisions } from "./projection.mjs";
71
71
  * @property {string|null} [usageSampleCutoff]
72
72
  * @property {Projection} [projection]
73
73
  * @property {BriefEstimateInput} [estimate]
74
+ * @property {string|null} [effectiveJudgeIndependence] the mode the launch recorded on the run snapshot; `undefined` falls back to the contract's own declaration.
74
75
  */
75
76
 
76
77
  const SAMPLE_FLOOR = 5;
@@ -152,7 +153,19 @@ export function buildBriefModel(options) {
152
153
  const contractNodes = asArray(contract.nodes);
153
154
  const declarations = normalizeDeclarations(plan.phases);
154
155
  const coverage = buildCoverage(parsedSpec, declarations, contractNodes, specPath, planPath);
155
- const graph = buildGraph(contract, contractNodes);
156
+ // R36: the effective judge-independence mode the launch recorded on the run
157
+ // snapshot wins over the contract's own declaration, which never sees the
158
+ // machine config's opt-in. With no recorded value the contract remains the
159
+ // only source, exactly as before.
160
+ const judgeIndependence = options.effectiveJudgeIndependence === "same-vendor"
161
+ ? "same-vendor"
162
+ : options.effectiveJudgeIndependence !== undefined
163
+ ? null
164
+ : contract.judgeIndependence === "same-vendor" ? "same-vendor" : null;
165
+ const graphContract = { ...contract };
166
+ if (judgeIndependence === "same-vendor") graphContract.judgeIndependence = "same-vendor";
167
+ else delete graphContract.judgeIndependence;
168
+ const graph = buildGraph(graphContract, contractNodes);
156
169
  const journalDecisions = options.projection ? projectCampaignDecisions(options.projection) : [];
157
170
  // R16: an operator step the frozen plan carries as an explicit stop
158
171
  // (`plan.humanSteps`, `src/plan/human-step.mjs`) is a human decision the
@@ -170,6 +183,7 @@ export function buildBriefModel(options) {
170
183
  contractDigest: expectedContractDigest,
171
184
  specPath,
172
185
  specBaseline: typeof parsedSpec.frontMatter?.baseline === "string" ? parsedSpec.frontMatter.baseline : null,
186
+ judgeIndependence,
173
187
  journalCursor: options.journalCursor ?? 0,
174
188
  usageSampleCutoff: options.usageSampleCutoff ?? null,
175
189
  });
@@ -576,7 +590,7 @@ function buildOpening(parsedSpec, coverage, graph, estimate, decisions) {
576
590
  }
577
591
 
578
592
  /**
579
- * @param {{campaign: string, plan: AnyRecord, planPath: string, planDigest: string, contractDigest: string, specPath: string, specBaseline: string|null, journalCursor: number, usageSampleCutoff: string|null}} input
593
+ * @param {{campaign: string, plan: AnyRecord, planPath: string, planDigest: string, contractDigest: string, specPath: string, specBaseline: string|null, judgeIndependence: string|null, journalCursor: number, usageSampleCutoff: string|null}} input
580
594
  * @returns {BriefIdentity}
581
595
  */
582
596
  function buildIdentity(input) {
@@ -590,6 +604,7 @@ function buildIdentity(input) {
590
604
  planPath: input.planPath,
591
605
  planDigest: input.planDigest,
592
606
  contractDigest: input.contractDigest,
607
+ judgeIndependence: input.judgeIndependence,
593
608
  journalCursor: input.journalCursor,
594
609
  usageSampleCutoff: input.usageSampleCutoff,
595
610
  };
@@ -18,7 +18,7 @@ import { readProjectionState } from "./projection.mjs";
18
18
  import { handoffFromState, materializeHandoff } from "./handoff.mjs";
19
19
  import { preserveCampaignLedger, reledgerCampaignLedger } from "./ledger.mjs";
20
20
 
21
- export { preserveCampaignLedger, reledgerCampaignLedger } from "./ledger.mjs";
21
+ export { markCampaignLedgerPreserved, preserveCampaignLedger, reledgerCampaignLedger, unpreservedLedgerCampaigns } from "./ledger.mjs";
22
22
 
23
23
  /** @typedef {Record<string, unknown>} JsonObject */
24
24
  /** @typedef {{path: string, digest: string}} CampaignContract */
@@ -27,7 +27,7 @@ export { preserveCampaignLedger, reledgerCampaignLedger } from "./ledger.mjs";
27
27
  /** @typedef {{runId: string, node: string, passed: boolean|null, verdict: string|null}} RequirementNodeEvidence */
28
28
  /** @typedef {{requirementId: string, status: "covered"|"open", nodes: RequirementNodeEvidence[]}} RequirementClosure */
29
29
  /** @typedef {{oldPath: string, newPath: string, runIds: string[], at: string}} ContractReplacement */
30
- /** @typedef {{id: string, goal: string, status: "active"|"closed", linkedRunIds: string[], contracts: CampaignContract[], replacements?: ContractReplacement[], landBranch: string, promotions: PromotionRecord[], attention?: CampaignAttention, requirements?: RequirementClosure[], createdAt: string, updatedAt: string, closedAt?: string}} Campaign */
30
+ /** @typedef {{id: string, goal: string, status: "active"|"closed", linkedRunIds: string[], contracts: CampaignContract[], replacements?: ContractReplacement[], landBranch: string, promotions: PromotionRecord[], attention?: CampaignAttention, requirements?: RequirementClosure[], ledgerPreserved?: boolean, createdAt: string, updatedAt: string, closedAt?: string}} Campaign */
31
31
  /** @typedef {{type: string, eventId: string, at: string, sessionId?: string, text?: string, tool?: string, transcript?: string|null, transcriptUnavailable?: boolean, format?: string|null, cursor?: string|null, decisionId?: string, supersedes?: string, runId?: string, questionId?: string, campaignId?: string, nodeId?: string|null, phase?: string, checkpointsDone?: number, checkpointsTotal?: number, runtime?: string|null, state?: string, lastProgressAt?: string, attention?: string|null}} JournalEntry */
32
32
  /** @typedef {{updatedAt: string|null, decisions: Record<string, JournalEntry>, questions: Record<string, JournalEntry>, constraints: JournalEntry[], intents: JournalEntry[], outcomes: JournalEntry[], sessions: JournalEntry[], next: JournalEntry|null, evicted: Record<string, number>}} Projection */
33
33
  /** @typedef {{cursor: number, byte: number, size: number, projection: Projection}} ProjectionRecord */
@@ -130,10 +130,10 @@ export function resolveCampaign(runsDir, campaignId) {
130
130
 
131
131
  /**
132
132
  * @param {string} campaignPath
133
- * @param {{at?: string, eventId?: string}} options
133
+ * @param {{at?: string, eventId?: string, ledgerInRepo?: boolean}} options
134
134
  * @returns {{path: string, campaign: Campaign, ledgerFiles: string[], ledgerSkipped: {runId: string, source: string}[], worktrees: {removed: number, archived: string[]}}}
135
135
  */
136
- export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID() } = {}) {
136
+ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID(), ledgerInRepo = true } = {}) {
137
137
  requireTimestamp(at, "at");
138
138
  const campaign = readCampaign(campaignPath);
139
139
  if (campaign.status === "closed") throw new Error(`campaign already closed: ${campaign.id}`);
@@ -154,23 +154,36 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
154
154
  appendJournal(campaignPath, { type: "campaign.closed", at, eventId });
155
155
  // Preserve after the closed record exists: the ledger is the recomputable
156
156
  // closed record, including requirements used by the north-star projector.
157
- const ledger = preserveCampaignLedger(campaignPath, campaignRepoRoot(campaignPath));
157
+ //
158
+ // A plain close keeps the evidence beside the campaign in the operator home.
159
+ // The versioned copy under docs/ is opt-in with --ledger-in-repo, so closing
160
+ // a campaign against a repository the operator does not own leaves the tree
161
+ // untouched. The library default stays the versioned copy: existing callers
162
+ // and already-committed ledgers keep their location, and the CLI is what
163
+ // defaults to the home.
164
+ const repoRoot = campaignRepoRoot(campaignPath);
165
+ const ledger = preserveCampaignLedger(campaignPath, repoRoot, ledgerInRepo ? undefined : join(campaignPath, "ledger"));
166
+ // A versioned preservation marks the record inside `preserveCampaignLedger`;
167
+ // re-reading returns the record the caller can read, marker included.
168
+ const persisted = readCampaign(campaignPath);
158
169
  // A closed campaign resumes nothing, so the attempt worktrees its runs left
159
170
  // go now, each archived under a ref first (releaseRunWorktrees).
160
171
  const runsDir = resolve(campaignPath, "..", "..");
161
172
  const worktrees = { removed: 0, archived: /** @type {string[]} */ ([]) };
162
173
  for (const runId of campaign.linkedRunIds) {
163
- const released = releaseRunWorktrees(campaignRepoRoot(campaignPath), join(runsDir, runId), runId);
174
+ const released = releaseRunWorktrees(repoRoot, join(runsDir, runId), runId);
164
175
  worktrees.removed += released.removed;
165
176
  worktrees.archived.push(...released.archived);
166
177
  }
167
- return { path: campaignPath, campaign: closed, ledgerFiles: ledger.written, ledgerSkipped: ledger.skipped, worktrees };
178
+ return { path: campaignPath, campaign: persisted, ledgerFiles: ledger.written, ledgerSkipped: ledger.skipped, worktrees };
168
179
  }
169
180
 
170
181
  /**
171
182
  * Complete the versioned evidence ledger of a closed campaign. Unlike close,
172
- * this operation never updates the campaign record or journal: it only adds
173
- * surviving run sources beneath the repository's ledger directory.
183
+ * this operation does not otherwise update the campaign record or journal: it
184
+ * adds surviving run sources beneath the repository's ledger directory and
185
+ * marks the record's `ledgerPreserved`, the one state the durable copy makes
186
+ * true.
174
187
  *
175
188
  * @param {string} campaignPath
176
189
  * @returns {import("./ledger.mjs").LedgerReledger & {ledgerDir: string}}
@@ -6,7 +6,8 @@ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync,
6
6
  import { dirname, join, resolve } from "node:path";
7
7
 
8
8
  import { readMetricNodeSnapshots } from "./metrics-command.mjs";
9
- import { CAMPAIGN_FILE, JOURNAL_FILE, LEDGER_SOURCE_MANIFEST_FILE } from "./layout.mjs";
9
+ import { writeJsonAtomic } from "../run/store.mjs";
10
+ import { CAMPAIGN_FILE, JOURNAL_FILE, LEDGER_SOURCE_MANIFEST_FILE, campaignsDir } from "./layout.mjs";
10
11
  import { readCampaign } from "./record.mjs";
11
12
 
12
13
  /** @typedef {{runId: string, source: string}} LedgerSkip */
@@ -27,19 +28,74 @@ export const EVENT_PROJECTION_FIELDS = Object.freeze([
27
28
  /** Fields consumed by `projectMetrics`; notification messages and transport details never cross the ledger boundary. */
28
29
  export const NOTIFY_PROJECTION_FIELDS = Object.freeze(["at", "dedupeKey", "attempt", "status"]);
29
30
 
31
+ /**
32
+ * Mark a campaign's record as having its ledger preserved at the durable,
33
+ * versioned location under its registered repository. A record that never
34
+ * carried the marker reads as not preserved (`readCampaign`), so removal
35
+ * refuses until this runs. Idempotent: an already marked record is returned
36
+ * untouched, which keeps a repeated reledger byte-stable.
37
+ *
38
+ * @param {string} campaignPath
39
+ * @returns {import("./index.mjs").Campaign}
40
+ */
41
+ export function markCampaignLedgerPreserved(campaignPath) {
42
+ const campaign = readCampaign(campaignPath);
43
+ if (campaign.ledgerPreserved) return campaign;
44
+ const marked = { ...campaign, ledgerPreserved: true };
45
+ writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), marked);
46
+ return marked;
47
+ }
48
+
49
+ /**
50
+ * The ids of the campaigns under `runsDir` whose ledger is not preserved at
51
+ * its durable, versioned repository location, sorted. A record written before
52
+ * the marker existed and a record that cannot be read both report as
53
+ * unpreserved: a removal that keeps evidence must refuse on the default and
54
+ * cannot let an unreadable record prove its evidence safe.
55
+ *
56
+ * @param {string} runsDir
57
+ * @returns {string[]}
58
+ */
59
+ export function unpreservedLedgerCampaigns(runsDir) {
60
+ const root = campaignsDir(runsDir);
61
+ if (!existsSync(root)) return [];
62
+ /** @type {string[]} */
63
+ const unpreserved = [];
64
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
65
+ if (!entry.isDirectory()) continue;
66
+ const campaignPath = join(root, entry.name);
67
+ if (!existsSync(join(campaignPath, CAMPAIGN_FILE))) continue;
68
+ try {
69
+ if (readCampaign(campaignPath).ledgerPreserved !== true) unpreserved.push(entry.name);
70
+ } catch {
71
+ unpreserved.push(entry.name);
72
+ }
73
+ }
74
+ return unpreserved.sort();
75
+ }
76
+
30
77
  /**
31
78
  * Copy a campaign's projector sources into its versioned ledger. Missing run
32
79
  * sources are reported so a pruned run cannot make the close fail or look
33
80
  * complete by accident. Repeated calls overwrite the same paths.
34
81
  *
82
+ * `ledgerDir` is the exact destination. It defaults to the versioned ledger
83
+ * under `repoRoot/docs/campaigns/<id>/ledger`; `closeCampaign` passes the
84
+ * campaign directory in the operator home instead when `--ledger-in-repo` was
85
+ * not given, so the target repository is not dirtied by a plain close. A
86
+ * versioned write marks the campaign record's `ledgerPreserved`; a home write
87
+ * does not, because it does not survive an uninstall.
88
+ *
35
89
  * @param {string} campaignPath
36
90
  * @param {string} repoRoot
91
+ * @param {string|undefined} [ledgerDir]
37
92
  * @returns {LedgerPreservation}
38
93
  */
39
- export function preserveCampaignLedger(campaignPath, repoRoot) {
94
+ export function preserveCampaignLedger(campaignPath, repoRoot, ledgerDir = undefined) {
40
95
  const campaign = readCampaign(campaignPath);
41
96
  const runsDir = resolve(campaignPath, "..", "..");
42
- const ledgerDir = join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
97
+ const versioned = ledgerDir === undefined;
98
+ ledgerDir ??= join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
43
99
  mkdirSync(ledgerDir, { recursive: true });
44
100
  /** @type {string[]} */
45
101
  const written = [];
@@ -96,6 +152,13 @@ export function preserveCampaignLedger(campaignPath, repoRoot) {
96
152
  const manifest = join(ledgerDir, LEDGER_SOURCE_MANIFEST_FILE);
97
153
  writeFileSync(manifest, `${JSON.stringify({ schemaVersion: 1, absent: [...absent].sort(), preserved: [...preserved].sort() })}\n`);
98
154
  written.push(manifest);
155
+ // The ledger exists, so a versioned preservation now marks the record. The
156
+ // already-copied campaign.json is refreshed afterwards so the durable copy
157
+ // carries the marker too; `written` keeps its original position and length.
158
+ if (versioned) {
159
+ markCampaignLedgerPreserved(campaignPath);
160
+ copyFileSync(join(campaignPath, CAMPAIGN_FILE), join(ledgerDir, CAMPAIGN_FILE));
161
+ }
99
162
  return { written, skipped };
100
163
  }
101
164
 
@@ -103,7 +166,9 @@ export function preserveCampaignLedger(campaignPath, repoRoot) {
103
166
  * Complete an already closed ledger from the run files still in the operator
104
167
  * home. A destination is replaced only when the source strictly extends its
105
168
  * bytes; this keeps a ledger's evidence stable when a run was rewritten or
106
- * truncated after close. Missing run sources are reported as gone.
169
+ * truncated after close. Missing run sources are reported as gone. The write
170
+ * is always versioned, so it marks the campaign record's `ledgerPreserved`
171
+ * and refreshes the ledger's copy of the record.
107
172
  *
108
173
  * @param {string} campaignPath
109
174
  * @param {string} repoRoot
@@ -164,6 +229,9 @@ export function reledgerCampaignLedger(campaignPath, repoRoot) {
164
229
  if (copyIfStrictExtension(source, destination)) copied.push({ runId: campaign.id, source: `proposals/${relative}` });
165
230
  }
166
231
  }
232
+ // The ledger is durable now, so mark the campaign record before refreshing
233
+ // the ledger's own copy of it when that copy is not already a closed record.
234
+ markCampaignLedgerPreserved(campaignPath);
167
235
  const ledgerCampaign = join(ledgerDir, CAMPAIGN_FILE);
168
236
  if (existsSync(ledgerCampaign)) {
169
237
  const existing = readJsonObject(ledgerCampaign);