faberun 0.11.0 → 0.12.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 (57) hide show
  1. package/README.md +6 -0
  2. package/integrations/claude-code/statusline.sh +31 -2
  3. package/package.json +2 -2
  4. package/skills/faberun/references/spec-format.md +9 -0
  5. package/src/campaign/chain.mjs +2 -1
  6. package/src/campaign/index.mjs +102 -1
  7. package/src/campaign/metrics.mjs +2 -2
  8. package/src/cli/brand.mjs +2 -0
  9. package/src/cli/campaign-contract.mjs +55 -0
  10. package/src/cli/campaign.mjs +48 -13
  11. package/src/cli/init.mjs +8 -2
  12. package/src/cli/launch.mjs +2 -1
  13. package/src/cli/plan.mjs +3 -2
  14. package/src/cli/project.mjs +150 -0
  15. package/src/cli/skills.mjs +2 -3
  16. package/src/cli/update.mjs +7 -10
  17. package/src/cli.mjs +30 -4
  18. package/src/contract/index.mjs +44 -0
  19. package/src/engine/cancel.mjs +13 -0
  20. package/src/engine/dispatch.mjs +3 -2
  21. package/src/engine/lifecycle.mjs +7 -1
  22. package/src/engine/live-preflight.mjs +2 -1
  23. package/src/engine/notify-queue.mjs +9 -1
  24. package/src/engine/pricing-seed.json +543 -0
  25. package/src/engine/pricing-seed.mjs +54 -0
  26. package/src/engine/process.mjs +8 -1
  27. package/src/engine/result-file.mjs +2 -1
  28. package/src/engine/resume.mjs +6 -2
  29. package/src/engine/run-identity.mjs +9 -2
  30. package/src/engine/scheduler.mjs +40 -6
  31. package/src/engine/settle.mjs +1 -1
  32. package/src/harnesses/replay/bin.mjs +10 -1
  33. package/src/host/home.mjs +10 -3
  34. package/src/host/platform.mjs +111 -0
  35. package/src/host/preflight.mjs +24 -21
  36. package/src/host/projects.mjs +155 -0
  37. package/src/notify/index.mjs +88 -10
  38. package/src/plan/pipeline.mjs +32 -9
  39. package/src/plan/repo-facts.mjs +80 -2
  40. package/src/plan/spec.mjs +2 -1
  41. package/src/repo/declared-paths.mjs +7 -1
  42. package/src/repo/integrate.mjs +1 -1
  43. package/src/repo/scope-closure.mjs +2 -1
  44. package/src/repo/signal.mjs +24 -7
  45. package/src/repo/source-identity.mjs +7 -0
  46. package/src/repo/workspace.mjs +12 -2
  47. package/src/repo/worktree.mjs +89 -22
  48. package/src/report/next.mjs +66 -6
  49. package/src/report/progress.mjs +724 -0
  50. package/src/report/render.mjs +3 -3
  51. package/src/run/migrate.mjs +278 -0
  52. package/src/run/paths.mjs +189 -0
  53. package/src/seat/index.mjs +2 -1
  54. package/src/web/app.css +170 -0
  55. package/src/web/app.mjs +690 -0
  56. package/src/web/index.html +38 -283
  57. package/src/web/server.mjs +197 -182
package/README.md CHANGED
@@ -178,6 +178,12 @@ The installer resolves the newest release, checks the requirements and runs
178
178
  curl -fsSL https://raw.githubusercontent.com/feliperun/faberun/main/install.sh | sh
179
179
  ```
180
180
 
181
+ On Windows, in PowerShell:
182
+
183
+ ```powershell
184
+ irm https://raw.githubusercontent.com/feliperun/faberun/main/install.ps1 | iex
185
+ ```
186
+
181
187
  `faberun setup` runs at the end of the installer and registers the `faberun`
182
188
  skill for the harnesses it finds (Claude Code, Codex and the shared
183
189
  `~/.agents/skills`, plus any measured convention); `faberun skills register`
@@ -1,13 +1,17 @@
1
1
  #!/bin/sh
2
2
  # Claude Code statusLine renderer for faberun ambient liveness. Reads
3
- # the session JSON on stdin, reads that repo's .runs/status.json pointer
4
- # (rewritten every controller tick), and prints one line:
3
+ # the session JSON on stdin, resolves that repo's runs pointer (rewritten
4
+ # every controller tick), and prints one line:
5
5
  # <run-id> · <state> · <node> <elapsed> · $<usd> · needs you: <n>
6
6
  # elapsedSec, costUsd and needsYou are precomputed by the controller, so this
7
7
  # never touches a clock or a node process, only formats. No pointer, an
8
8
  # unreadable file, or one over the 1 KiB cap prints an empty line, exit 0.
9
9
  # jq is used when present; otherwise sed/grep pull the flat top-level fields.
10
10
  #
11
+ # This file is a hand-maintained seam: the repository's source-shape guard
12
+ # walks .mjs files only, so no test fails when this script and the resolver
13
+ # drift -- test/integrations/statusline.test.mjs is the only pin here.
14
+ #
11
15
  # Allowance guard. The same session JSON carries rate_limits.five_hour
12
16
  # (used_percentage plus a reset instant); the script otherwise reads stdin only
13
17
  # for cwd. At ALLOWANCE_WARN_PCT or above, the line appends the warning and the
@@ -27,7 +31,32 @@ ALLOWANCE_WARN_PCT=85
27
31
 
28
32
  session=$(cat)
29
33
  repo=$(printf '%s' "$session" | sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p;s/.*"current_dir":"\([^"]*\)".*/\1/p' | head -n 1)
34
+ # The pointer follows the state (R2): the project registry under the faberun
35
+ # home maps the repository's resolved path to an opaque id, and the pointer
36
+ # sits in that project's runs directory. A repository whose runs never moved
37
+ # still answers in-tree -- R7 keeps the reading side dual-layout until
38
+ # `faberun migrate` runs -- so the legacy path stays the fallback, and the
39
+ # home side wins when both exist, exactly like the resolver. Two fixed paths:
40
+ # no glob, no newest-by-mtime. That is the half of R6 already true and to
41
+ # keep; the one machine-wide pointer is a later phase, not this lookup.
42
+ home=${FABERUN_HOME:-$HOME/.faberun}
43
+ index="$home/projects/index.json"
44
+ id=
45
+ if [ -n "$repo" ] && [ -f "$index" ]; then
46
+ if command -v jq >/dev/null 2>&1; then
47
+ id=$(jq -r --arg p "$repo" '.[$p] // empty' "$index" 2>/dev/null) || id=
48
+ else
49
+ # The index is pretty-printed, one `"path": "id"` pair per line, so a
50
+ # fixed-string grep for the quoted key picks exactly that one line and
51
+ # the id follows its colon. Fixed-string on purpose: a repo path is
52
+ # data, not a regex.
53
+ id=$(grep -F "\"$repo\"" "$index" 2>/dev/null | sed -n 's/^.*:[[:space:]]*"\([^"]*\)".*/\1/p')
54
+ fi
55
+ fi
30
56
  pointer="$repo/.runs/status.json"
57
+ if [ -n "$id" ] && [ -d "$home/projects/$id/runs" ]; then
58
+ pointer="$home/projects/$id/runs/status.json"
59
+ fi
31
60
 
32
61
  # The nested five_hour.used_percentage. jq when present; otherwise flatten the
33
62
  # session JSON and pull the field out of the five_hour object with sed. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.11.0",
3
+ "version": "0.12.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": {
@@ -24,7 +24,7 @@
24
24
  "node": ">=22"
25
25
  },
26
26
  "scripts": {
27
- "check": "for f in bin/*.mjs .claude/hooks/*.mjs src/*.mjs src/*/*.mjs src/*/*/*.mjs evals/*.mjs test/*.mjs test/*/*.mjs; do node --check \"$f\" || exit 1; done",
27
+ "check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
28
28
  "typecheck": "tsc",
29
29
  "test": "node --test test/*.test.mjs test/*/*.test.mjs",
30
30
  "docs": "node src/cli/manual.mjs --write",
@@ -71,6 +71,15 @@ comparative arm, a follow-up spec). `statement` is the testable claim.
71
71
  A requirement may add its own `- **constraints:** ...` line for a rule
72
72
  scoped to it alone, distinct from the spec-wide Constraints section.
73
73
 
74
+ A requirement may also declare a measurement with
75
+ `- **measure:** command: <shell command>` — a read-only check the planner
76
+ runs against the repository *before* drafting anything, folded into the repo
77
+ facts the draft stage reads. The distinction from `proof` is timing: `proof`
78
+ is what the finished node satisfies, while `measure` is checkable before any
79
+ node exists — a grep, a `wc -l`, a small pipeline, run through the shell
80
+ exactly as written here, pipes included. Only the `command` kind is wired;
81
+ `path` and `judgment` measures parse but run nothing.
82
+
74
83
  ## What `faberun spec validate` checks
75
84
 
76
85
  Deterministic, no model call. Rejects:
@@ -41,6 +41,7 @@ import { HEARTBEAT_INTERVAL_MS, createHeartbeat, groupAlive, heartbeatBreach, re
41
41
  import { pidAlive, processStartToken } from "../run/lock.mjs";
42
42
  import { delay, errorCode, errorMessage } from "../util.mjs";
43
43
  import { writeJsonAtomic } from "../run/store.mjs";
44
+ import { runDirectory } from "../run/paths.mjs";
44
45
 
45
46
  /** @typedef {import("../contract/index.mjs").ControllerIdentity} ControllerIdentity */
46
47
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
@@ -333,7 +334,7 @@ function runIdentityFor(contractPath) {
333
334
  const raw = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(contractPath, "utf8")));
334
335
  const cwd = resolve(dirname(contractPath), typeof raw.cwd === "string" ? raw.cwd : ".");
335
336
  const id = String(raw.id);
336
- return { id, cwd, runDir: join(cwd, ".runs", id) };
337
+ return { id, cwd, runDir: runDirectory(cwd, id) };
337
338
  }
338
339
 
339
340
  /**
@@ -8,6 +8,7 @@ import {
8
8
  import { createHash, randomUUID } from "node:crypto";
9
9
  import { join, resolve } from "node:path";
10
10
  import { writeJsonAtomic } from "../run/store.mjs";
11
+ import { projectIdForRunsDir, repositoryForRunsDir } from "../run/paths.mjs";
11
12
  import { requireId, requirePacketHash, requireString, requireTimestamp } from "../contract/assert.mjs";
12
13
  import { promoteRun } from "../repo/integrate.mjs";
13
14
  import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, JOURNAL_FILE, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
@@ -132,7 +133,7 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
132
133
  if (!readJournalForDedupe(campaignPath).some((entry) => entry.type === "retrospective")) {
133
134
  throw new Error(`campaign ${campaign.id} has no recorded retrospective; record one with note --kind retrospective before close`);
134
135
  }
135
- const repoRoot = resolve(campaignPath, "..", "..", "..");
136
+ const repoRoot = campaignRepoRoot(campaignPath);
136
137
  const ledgerFiles = preserveCampaignLedger(campaignPath, repoRoot);
137
138
  const closed = /** @type {Campaign} */ ({ ...campaign, status: "closed", closedAt: at, updatedAt: at });
138
139
  writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), closed);
@@ -140,6 +141,28 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
140
141
  return { path: campaignPath, campaign: closed, ledgerFiles };
141
142
  }
142
143
 
144
+ /**
145
+ * The repository a campaign's ledger is preserved into. Under the home layout
146
+ * the campaign path carries the project id at the resolver's fixed position,
147
+ * so the repository is looked up live in the registry rather than derived by
148
+ * climbing: a project reassociated after the campaign was created preserves
149
+ * at its current repository, which a path cached anywhere would not. A
150
+ * campaign under a legacy `<repo>/.runs` keeps the old three-directory climb,
151
+ * which is exact there because the campaign path ends `<repo>/.runs/campaigns/<id>`.
152
+ *
153
+ * @param {string} campaignPath
154
+ * @returns {string}
155
+ */
156
+ function campaignRepoRoot(campaignPath) {
157
+ const runsDir = resolve(campaignPath, "..", "..");
158
+ const repository = repositoryForRunsDir(runsDir);
159
+ if (repository) return repository;
160
+ if (projectIdForRunsDir(runsDir)) {
161
+ throw new Error(`no project record for ${campaignPath}; the registry cannot name the repository its ledger belongs to`);
162
+ }
163
+ return resolve(campaignPath, "..", "..", "..");
164
+ }
165
+
143
166
  /**
144
167
  * Copy a campaign's journal, record and each linked run's usage into
145
168
  * `<repoRoot>/docs/campaigns/<id>/ledger/` so the comparative arm of the
@@ -237,6 +260,84 @@ export function assertContractManifestIntact(entry) {
237
260
  }
238
261
  }
239
262
 
263
+ /**
264
+ * Append a contract to an active campaign's manifest, digesting its authored
265
+ * bytes exactly as `campaign init --contract` does. This is what an operator
266
+ * used to do by hand-editing `campaign.json` and recomputing
267
+ * `authoredContractDigest` themselves -- a step that parks the campaign for
268
+ * good on a typo, since `assertContractManifestIntact` refuses a digest that
269
+ * does not match at launch.
270
+ *
271
+ * Idempotent by path and bytes: adding the same contract path a second time,
272
+ * with the file unchanged since, finds its digest already recorded and
273
+ * returns the campaign untouched. A second call after the file changed
274
+ * updates the recorded digest in place rather than duplicating the entry.
275
+ *
276
+ * @param {string} campaignPath
277
+ * @param {string} contractPath
278
+ * @param {{at?: string}} [options]
279
+ * @returns {{campaign: Campaign, added: boolean}}
280
+ */
281
+ export function addContractToCampaign(campaignPath, contractPath, { at = new Date().toISOString() } = {}) {
282
+ requireTimestamp(at, "at");
283
+ requireString(contractPath, "contractPath");
284
+ if (!existsSync(contractPath)) throw new Error(`contract not found: ${contractPath}`);
285
+ const campaign = readCampaign(campaignPath);
286
+ if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
287
+ const digest = authoredContractDigest(contractPath);
288
+ const existingIndex = campaign.contracts.findIndex((entry) => entry.path === contractPath);
289
+ if (existingIndex !== -1 && campaign.contracts[existingIndex].digest === digest) {
290
+ return { campaign, added: false };
291
+ }
292
+ const entry = { path: contractPath, digest };
293
+ const contracts = existingIndex === -1
294
+ ? [...campaign.contracts, entry]
295
+ : campaign.contracts.map((existing, index) => (index === existingIndex ? entry : existing));
296
+ const updated = /** @type {Campaign} */ ({ ...campaign, contracts, updatedAt: at });
297
+ writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
298
+ return { campaign: updated, added: true };
299
+ }
300
+
301
+ /**
302
+ * Replace a manifest entry -- matched by its current, recorded path -- with a
303
+ * freshly authored contract. Re-authoring a phase after a blocked or failed
304
+ * node is the normal case in this repository, not the exception, so this is
305
+ * the command form of the hand edit an operator otherwise repeats every time.
306
+ *
307
+ * When the campaign's `attention` names the contract being replaced (its
308
+ * `contractPath` matches `oldPath`), the attention is cleared in the same
309
+ * write, so a stale park cannot go on refusing `campaign supervise` once the
310
+ * contract it named is gone. This does not go through `unparkCampaign`
311
+ * (`./unpark.mjs`, which owns the `campaign.unparked` journal event): that
312
+ * module already imports `chain.mjs`, which imports this one, and importing
313
+ * it back here would close that cycle.
314
+ *
315
+ * @param {string} campaignPath
316
+ * @param {string} oldPath
317
+ * @param {string} newPath
318
+ * @param {{at?: string}} [options]
319
+ * @returns {{campaign: Campaign, replaced: CampaignContract, clearedAttention: CampaignAttention|null}}
320
+ */
321
+ export function replaceContractInCampaign(campaignPath, oldPath, newPath, { at = new Date().toISOString() } = {}) {
322
+ requireTimestamp(at, "at");
323
+ requireString(oldPath, "oldPath");
324
+ requireString(newPath, "newPath");
325
+ if (!existsSync(newPath)) throw new Error(`contract not found: ${newPath}`);
326
+ const campaign = readCampaign(campaignPath);
327
+ if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
328
+ const index = campaign.contracts.findIndex((entry) => entry.path === oldPath);
329
+ if (index === -1) throw new Error(`no contract at ${oldPath} in campaign ${campaign.id}`);
330
+ const replaced = { path: newPath, digest: authoredContractDigest(newPath) };
331
+ const contracts = campaign.contracts.map((entry, position) => (position === index ? replaced : entry));
332
+ const attention = campaign.attention;
333
+ const clearAttention = attention !== undefined && attention.contractPath === oldPath;
334
+ /** @type {Campaign} */
335
+ const updated = { ...campaign, contracts, updatedAt: at };
336
+ if (clearAttention) delete updated.attention;
337
+ writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
338
+ return { campaign: updated, replaced, clearedAttention: clearAttention ? /** @type {CampaignAttention} */ (attention) : null };
339
+ }
340
+
240
341
  /**
241
342
  * Persist one promotion in the campaign record. Idempotent by run id and the
242
343
  * sha it landed: a re-invocation after a crash between the branch move and
@@ -37,6 +37,7 @@ import { renderMetricsJson, renderMetricsReport } from "../report/metrics-report
37
37
  import { campaignDir } from "./layout.mjs";
38
38
  import { readCampaign } from "./record.mjs";
39
39
  import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
40
+ import { runsRoot } from "../run/paths.mjs";
40
41
 
41
42
  /** Node statuses that are not terminal: everything else settles a logical node. */
42
43
  const OPEN_STATUSES = new Set(["pending", "running"]);
@@ -401,7 +402,6 @@ function orderOf(entry) {
401
402
  /** @type {import("node:util").ParseArgsOptionsConfig} */
402
403
  export const METRICS_OPTIONS = { cwd: { type: "string" }, json: { type: "boolean" } };
403
404
 
404
- const RUNS_DIR_NAME = ".runs";
405
405
  const RUN_EVENTS_FILE = "events.jsonl";
406
406
  const USAGE_LOG_FILE = "usage.jsonl";
407
407
  const NOTIFY_LOG_FILE = "notify.jsonl";
@@ -488,7 +488,7 @@ function readRunNodes(runDir) {
488
488
  * @returns {string}
489
489
  */
490
490
  export function renderCampaignMetrics(campaignId, values = {}) {
491
- const runsDir = join(resolve(typeof values.cwd === "string" && values.cwd !== "" ? values.cwd : process.cwd()), RUNS_DIR_NAME);
491
+ const runsDir = runsRoot(resolve(typeof values.cwd === "string" && values.cwd !== "" ? values.cwd : process.cwd()));
492
492
  const sources = readMetricsSources(campaignDir(runsDir, campaignId), { runsDir });
493
493
  const metrics = projectMetrics(sources);
494
494
  return values.json === true ? renderMetricsJson(sources, metrics) : renderMetricsReport(sources, metrics);
package/src/cli/brand.mjs CHANGED
@@ -191,6 +191,8 @@ export function renderUsage() {
191
191
  "setup [--yes] [--no-skill] [--harnesses <a,b>] [--worker <id>] [--judge <id>] [--json]",
192
192
  "init [--cwd <dir>] [--yes] [--no-skill] [--agentkit] [--greenfield|--stable] [--json]",
193
193
  "update [--check] [--json]",
194
+ "project <new-path> [--from <old-path>]",
195
+ "migrate [--cwd <dir>]",
194
196
  "models [--probe] [--json]",
195
197
  "next [--cwd <dir>] [--json]",
196
198
  "bulk-read --question <text> --paths <a,b,c> [--json]",
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `campaign add-contract` and `campaign replace-contract`: give an active
3
+ * campaign's contract manifest a command, instead of the hand edit of
4
+ * `campaign.json` an operator otherwise repeats every time a phase is
5
+ * re-authored. `campaign.mjs` dispatches into this module; its
6
+ * `OPERATION_OPTIONS` entries live there so the command surface stays one
7
+ * table (`cli/campaign.mjs`'s own header explains why).
8
+ */
9
+ import { resolve } from "node:path";
10
+ import { addContractToCampaign, renderHandoff, replaceContractInCampaign, resolveCampaign } from "../campaign/index.mjs";
11
+ import { runsRoot } from "../run/paths.mjs";
12
+
13
+ /** @typedef {{cwd?: string, path?: string, replace?: string, eventId?: string}} CampaignContractValues */
14
+
15
+ /**
16
+ * @param {string} campaignId
17
+ * @param {CampaignContractValues} values
18
+ */
19
+ export function addContract(campaignId, values) {
20
+ const cwd = resolve(values.cwd ?? ".");
21
+ const runsDir = runsRoot(cwd);
22
+ const { path } = resolveCampaign(runsDir, campaignId);
23
+ const contractPath = requiredPath(values.path, "--path");
24
+ const { campaign, added } = addContractToCampaign(path, contractPath);
25
+ renderHandoff(path, runsDir);
26
+ process.stdout.write(added
27
+ ? `[campaign] contract added · ${contractPath} · ${campaign.contracts.length} contract(s)\n`
28
+ : `[campaign] contract already recorded · ${contractPath}\n`);
29
+ }
30
+
31
+ /**
32
+ * @param {string} campaignId
33
+ * @param {CampaignContractValues} values
34
+ */
35
+ export function replaceContract(campaignId, values) {
36
+ const cwd = resolve(values.cwd ?? ".");
37
+ const runsDir = runsRoot(cwd);
38
+ const { path } = resolveCampaign(runsDir, campaignId);
39
+ const contractPath = requiredPath(values.path, "--path");
40
+ const oldPath = requiredPath(values.replace, "--replace");
41
+ const { clearedAttention } = replaceContractInCampaign(path, oldPath, contractPath);
42
+ renderHandoff(path, runsDir);
43
+ process.stdout.write(`[campaign] contract replaced · ${oldPath} -> ${contractPath}\n`);
44
+ if (clearedAttention) process.stdout.write(`[campaign] attention cleared · ${clearedAttention.code}\n`);
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} value
49
+ * @param {string} label
50
+ * @returns {string}
51
+ */
52
+ function requiredPath(value, label) {
53
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`${label} requires a value`);
54
+ return resolve(value);
55
+ }
@@ -10,7 +10,9 @@ import {
10
10
  renderHandoff,
11
11
  resolveCampaign,
12
12
  } from "../campaign/index.mjs";
13
+ import { addContract, replaceContract } from "./campaign-contract.mjs";
13
14
  import { lockStale, pidAlive, processStartToken, readLock } from "../run/lock.mjs";
15
+ import { runsRoot } from "../run/paths.mjs";
14
16
  import { syncAgentSignal } from "../repo/signal.mjs";
15
17
  import { acknowledgeJournalEvent, appendJournal, appendSeatAllowanceEvent, readJournal, watchJournal } from "../campaign/journal.mjs";
16
18
  import { driveCampaignChain } from "../campaign/chain.mjs";
@@ -85,12 +87,14 @@ const OPERATION_OPTIONS = {
85
87
  close: { cwd: { type: "string" }, "event-id": { type: "string" } },
86
88
  supervise: { cwd: { type: "string" }, "allow-main": { type: "boolean" } },
87
89
  unpark: { cwd: { type: "string" }, force: { type: "boolean" }, "event-id": { type: "string" } },
90
+ "add-contract": { cwd: { type: "string" }, path: { type: "string" } },
91
+ "replace-contract": { cwd: { type: "string" }, path: { type: "string" }, replace: { type: "string" } },
88
92
  show: { cwd: { type: "string" } },
89
93
  sync: { cwd: { type: "string" }, "session-id": { type: "string" } },
90
94
  ack: { cwd: { type: "string" }, "session-id": { type: "string" }, "event-id": { type: "string" } },
91
95
  };
92
96
 
93
- /** @typedef {{cwd?: string, goal?: string, contract?: string[], landBranch?: string, tool?: string, sessionId?: string, transcript?: string, format?: string, cursor?: string, since?: string, kind?: string, text?: string, runId?: string, supersedes?: string, decisionId?: string, questionId?: string, eventId?: string, noTranscript?: boolean, wake?: boolean, detach?: boolean, interval?: string, once?: boolean, allowMain?: boolean, force?: boolean}} CliValues */
97
+ /** @typedef {{cwd?: string, goal?: string, contract?: string[], landBranch?: string, tool?: string, sessionId?: string, transcript?: string, format?: string, cursor?: string, since?: string, kind?: string, text?: string, runId?: string, supersedes?: string, decisionId?: string, questionId?: string, eventId?: string, noTranscript?: boolean, wake?: boolean, detach?: boolean, interval?: string, once?: boolean, allowMain?: boolean, force?: boolean, path?: string, replace?: string}} CliValues */
94
98
  /** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
95
99
 
96
100
  /**
@@ -118,6 +122,8 @@ export async function campaignCli(args) {
118
122
  if (operation === "show") return show(campaignId, values);
119
123
  if (operation === "sync") return sync(campaignId, values);
120
124
  if (operation === "ack") return ack(campaignId, values);
125
+ if (operation === "add-contract") return addContract(campaignId, values);
126
+ if (operation === "replace-contract") return replaceContract(campaignId, values);
121
127
  return usage();
122
128
  }
123
129
 
@@ -330,7 +336,7 @@ function watchLockStale(occupant) {
330
336
  */
331
337
  async function init(campaignId, values) {
332
338
  const cwd = resolve(values.cwd ?? ".");
333
- const runsDir = join(cwd, ".runs");
339
+ const runsDir = runsRoot(cwd);
334
340
  const goal = textValue(values.goal, "--goal");
335
341
  const contracts = contractManifest(values.contract);
336
342
  const created = initializeCampaign(runsDir, { campaignId, goal, contracts, landBranch: values.landBranch });
@@ -476,7 +482,7 @@ function close(campaignId, values) {
476
482
  */
477
483
  async function supervise(campaignId, values) {
478
484
  const cwd = resolve(values.cwd ?? ".");
479
- const runsDir = join(cwd, ".runs");
485
+ const runsDir = runsRoot(cwd);
480
486
  const { path } = resolveCampaign(runsDir, campaignId);
481
487
  const outcome = await driveCampaignChain(path, {
482
488
  repo: cwd,
@@ -502,13 +508,42 @@ async function supervise(campaignId, values) {
502
508
  * @param {CliValues} values
503
509
  */
504
510
  function unpark(campaignId, values) {
505
- const { path, runsDir } = selectCampaign(campaignId, values);
506
- const result = unparkCampaign(path, {
507
- runsDir,
508
- force: values.force === true,
509
- eventId: values.eventId ?? randomUUID(),
510
- });
511
- process.stdout.write(`[campaign] ${result.campaign.id} unparked · ${result.cleared.code} cleared\n`);
511
+ const { path, runsDir, campaign } = selectCampaign(campaignId, values);
512
+ try {
513
+ const result = unparkCampaign(path, {
514
+ runsDir,
515
+ force: values.force === true,
516
+ eventId: values.eventId ?? randomUUID(),
517
+ });
518
+ process.stdout.write(`[campaign] ${result.campaign.id} unparked · ${result.cleared.code} cleared\n`);
519
+ } catch (error) {
520
+ throw explainStillParked(error, campaign.attention);
521
+ }
522
+ }
523
+
524
+ /**
525
+ * `unparkCampaign`'s still-parked refusal names the run and its state but not
526
+ * the node holding the park, and tells the operator to pass `--force` without
527
+ * saying what that flag will and will not do -- an operator told only the
528
+ * flag types the flag. The campaign's own `attention` record already carries
529
+ * the node, its status, and the resume command the chain wrote when it
530
+ * parked (see `chain.mjs`'s `park`), so recompose the message from that
531
+ * instead of changing what the still-parked check itself reports. Any other
532
+ * refusal (closed campaign, not parked) is passed through unchanged.
533
+ *
534
+ * @param {unknown} error
535
+ * @param {Campaign["attention"]} attention
536
+ * @returns {unknown}
537
+ */
538
+ function explainStillParked(error, attention) {
539
+ if (!(error instanceof Error) || !attention) return error;
540
+ const match = /^run (\S+) is still (parked|canceled); resume it first or pass --force$/u.exec(error.message);
541
+ if (!match) return error;
542
+ const [, runId, state] = match;
543
+ const node = attention.node ? ` node ${attention.node}${attention.status ? ` (${attention.status})` : ""}` : "";
544
+ return new Error(
545
+ `run ${runId}${node} is still ${state}. --force clears only the campaign's parked attention so \`supervise campaign\` can drive the chain again; it does not resume, cancel, or otherwise change run ${runId}, which stays ${state} until you act on it directly. Resume the run, or pass --force to move on without resuming it.`,
546
+ );
512
547
  }
513
548
 
514
549
  /**
@@ -634,7 +669,7 @@ function sessionCursorId(sessionId) {
634
669
  */
635
670
  function listCampaigns(values) {
636
671
  const cwd = resolve(values.cwd ?? ".");
637
- const runsDir = join(cwd, ".runs");
672
+ const runsDir = runsRoot(cwd);
638
673
  const { campaigns, corrupt } = discoverCampaigns(runsDir);
639
674
  if (!campaigns.length && !corrupt.length) {
640
675
  process.stdout.write("[campaign] none\n");
@@ -658,7 +693,7 @@ function listCampaigns(values) {
658
693
  */
659
694
  function selectCampaign(campaignId, values) {
660
695
  const cwd = resolve(values.cwd ?? ".");
661
- const runsDir = join(cwd, ".runs");
696
+ const runsDir = runsRoot(cwd);
662
697
  return { ...resolveCampaign(runsDir, campaignId), runsDir };
663
698
  }
664
699
 
@@ -738,7 +773,7 @@ function positiveIntervalMs(value) {
738
773
 
739
774
  function usage() {
740
775
  process.stderr.write(
741
- "usage: faberun campaign <init|watch|attach|note|resolve|close|supervise|unpark|show|list|sync|ack> <campaign-id> [--cwd <dir>] ...\n",
776
+ "usage: faberun campaign <init|watch|attach|note|resolve|close|supervise|unpark|show|list|sync|ack|add-contract|replace-contract> <campaign-id> [--cwd <dir>] ...\n",
742
777
  );
743
778
  process.exitCode = 2;
744
779
  }
package/src/cli/init.mjs CHANGED
@@ -19,6 +19,7 @@ import { fileURLToPath } from "node:url";
19
19
  import { colorLevel, statusToken } from "./brand.mjs";
20
20
  import { installSkills } from "./skills.mjs";
21
21
  import { boundedGitSync } from "../repo/worktree.mjs";
22
+ import { RUNS_DIR_NAME } from "../run/paths.mjs";
22
23
 
23
24
  /** @typedef {(text: string) => void} Writer */
24
25
  /** @typedef {(question: string) => Promise<string>} Asker */
@@ -152,6 +153,11 @@ function isGitWorkTree(cwd) {
152
153
  * exists, creating the file when missing. A file that does not end in a newline
153
154
  * gets one before the appended line, so the result is always a whole line.
154
155
  *
156
+ * Run state itself lives under the faberun home now, but this line is not
157
+ * obsolete: every attempt worktree is still a git working tree, and R3 keeps
158
+ * the worker's result sidecar inside it, under `.runs/`, where the scope diff
159
+ * must not see it. Do not remove this as cleanup.
160
+ *
155
161
  * @param {string} cwd
156
162
  * @returns {string} the `.gitignore` path written or confirmed
157
163
  */
@@ -160,11 +166,11 @@ function ensureRunsIgnored(cwd) {
160
166
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
161
167
  const ignored = existing.split(/\r?\n/u).some((line) => {
162
168
  const trimmed = line.trim();
163
- return trimmed === ".runs/" || trimmed === ".runs";
169
+ return trimmed === `${RUNS_DIR_NAME}/` || trimmed === RUNS_DIR_NAME;
164
170
  });
165
171
  if (!ignored) {
166
172
  const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
167
- writeFileSync(path, `${existing}${separator}.runs/\n`);
173
+ writeFileSync(path, `${existing}${separator}${RUNS_DIR_NAME}/\n`);
168
174
  }
169
175
  return path;
170
176
  }
@@ -23,6 +23,7 @@ import { randomUUID } from "node:crypto";
23
23
  import { readRunNodes } from "../engine/scheduler.mjs";
24
24
  import { spawn } from "node:child_process";
25
25
  import { validateContract } from "../contract/index.mjs";
26
+ import { runDirectory } from "../run/paths.mjs";
26
27
 
27
28
  /**
28
29
  * The file a detached child is spawned as. It must be the CLI and not this
@@ -182,7 +183,7 @@ export function bootstrapRunDir(command, target) {
182
183
  if (!target) return null;
183
184
  const path = resolve(target);
184
185
  const contract = validateContract(JSON.parse(readFileSync(path, "utf8")), path);
185
- return join(contract.cwd, ".runs", contract.id);
186
+ return runDirectory(contract.cwd, contract.id);
186
187
  }
187
188
  if (["resume", "cancel"].includes(command)) {
188
189
  if (!target) return null;
package/src/cli/plan.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * sequencing and every decision the pipeline makes.
6
6
  */
7
7
  import { readFileSync } from "node:fs";
8
- import { join, resolve } from "node:path";
8
+ import { resolve } from "node:path";
9
9
  import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
10
10
  import { classifyRunProgress } from "../campaign/chain.mjs";
11
11
  import { runProgress } from "../engine/supervise.mjs";
@@ -13,6 +13,7 @@ import { DISCOVERY_RUNTIME_DEFINITIONS } from "../engine/runtime-discovery.mjs";
13
13
  import { validateRuntime } from "../contract/runtime.mjs";
14
14
  import { delay } from "../util.mjs";
15
15
  import { runPlanningPipeline } from "../plan/pipeline.mjs";
16
+ import { runDirectory } from "../run/paths.mjs";
16
17
 
17
18
  /** How often a foreground `plan` polls a launched stage's run directory. */
18
19
  const DEFAULT_POLL_MS = 1_000;
@@ -117,7 +118,7 @@ export async function planCli(target, values) {
117
118
  launch: async (contractPath, contract) => {
118
119
  const child = detachSelf("run", contractPath);
119
120
  if (child.pid === undefined) throw new Error("detached planning run has no pid");
120
- await waitForBootstrap(join(contract.cwd, ".runs", contract.id), child.pid, child);
121
+ await waitForBootstrap(runDirectory(contract.cwd, contract.id), child.pid, child);
121
122
  },
122
123
  wait: async (runDir) => {
123
124
  for (;;) {