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
@@ -54,7 +54,7 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
54
54
  /** @typedef {Indicator & {unknownCount: number}} JudgeCostShareIndicator */
55
55
  /** @typedef {{value: Record<string, number>|null, direction: Direction, count: number, numerator?: Record<string, number>, denominator?: Record<string, number>, excludedRunIds?: string[], missingSources?: string[]}} GroupedIndicator */
56
56
  /** @typedef {{atMs: number, index: number, event: JsonObject}} RunEvent */
57
- /** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null}} RunNode */
57
+ /** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null, judgeIndependence?: string|null}} RunNode */
58
58
 
59
59
  /**
60
60
  * @typedef {{
@@ -148,7 +148,34 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
148
148
  intentToVerifiedSeconds: northStar,
149
149
  humanTouches: touches,
150
150
  };
151
- return applyMissingSources(metrics, missingSources);
151
+ const projected = /** @type {CampaignMetrics & {effectiveJudgeIndependence: string|null}} */ (applyMissingSources(metrics, missingSources));
152
+ // R36: the effective judge-independence mode the launch recorded on the run
153
+ // snapshots, read back from the reduced node input rather than re-derived
154
+ // from the contract or the machine config. It is metadata about the sources,
155
+ // like the run count, not a section-6 indicator: non-enumerable so the
156
+ // indicator set -- and every renderer that walks it -- is unchanged.
157
+ Object.defineProperty(projected, "effectiveJudgeIndependence", {
158
+ value: recordedJudgeIndependenceOf(eligibleNodes),
159
+ enumerable: false,
160
+ });
161
+ return projected;
162
+ }
163
+
164
+ /**
165
+ * The effective judge-independence mode the launch recorded on the run
166
+ * snapshots, reduced to the `judgeIndependence` field of the node input. The
167
+ * launch composes one mode per contract, so the first node that recorded one
168
+ * answers for the campaign; a caller that re-derives it from the contract or
169
+ * the machine config would miss a machine-config opt-in the launch recorded.
170
+ *
171
+ * @param {RunNode[]} nodes
172
+ * @returns {string|null}
173
+ */
174
+ export function recordedJudgeIndependenceOf(nodes) {
175
+ for (const node of nodes) {
176
+ if (typeof node.judgeIndependence === "string" && node.judgeIndependence) return node.judgeIndependence;
177
+ }
178
+ return null;
152
179
  }
153
180
 
154
181
  /**
@@ -9,6 +9,7 @@
9
9
  import { CAMPAIGN_FILE, basenameSafe } from "./layout.mjs";
10
10
  import { errorCode } from "../util.mjs";
11
11
  import { join } from "node:path";
12
+ import { randomUUID } from "node:crypto";
12
13
  import { readFileSync } from "node:fs";
13
14
  import { assertObject, requireId, requirePacketHash, requireString, requireText, requireTimestamp } from "../contract/assert.mjs";
14
15
 
@@ -35,6 +36,11 @@ export function readCampaign(path) {
35
36
  record.contracts ??= [];
36
37
  record.landBranch ??= `campaign/${String(record.id)}`;
37
38
  record.promotions ??= [];
39
+ // The preservation state of the campaign's ledger. A record written before
40
+ // the marker existed, or written by a close that kept the ledger only in the
41
+ // operator home, reads as not preserved: removal refuses on the default
42
+ // rather than deleting evidence it cannot prove is safe.
43
+ record.ledgerPreserved ??= false;
38
44
  return /** @type {Campaign} */ (campaign);
39
45
  }
40
46
  /**
@@ -48,6 +54,28 @@ export function campaignIdOf(campaignPath) {
48
54
  return basenameSafe(campaignPath);
49
55
  }
50
56
  }
57
+ /**
58
+ * A generated identifier for a note that arrives without one: the note text
59
+ * reduced to a lowercase ASCII slug, plus a short random suffix so two notes
60
+ * with identical text still get distinct ids. The slug is capped so a long
61
+ * note cannot produce an unusably long identifier, and an empty slug falls
62
+ * back to a bare `note-<suffix>`.
63
+ *
64
+ * @param {string} text
65
+ * @returns {string}
66
+ */
67
+ export function generatedNoteId(text) {
68
+ const slug = text
69
+ .normalize("NFKD")
70
+ .replace(/[\u0300-\u036f]/gu, "")
71
+ .toLowerCase()
72
+ .replace(/[^a-z0-9]+/gu, "-")
73
+ .replace(/^-+|-+$/gu, "")
74
+ .slice(0, 40)
75
+ .replace(/-+$/gu, "");
76
+ const suffix = randomUUID().replace(/-/gu, "").slice(0, 6);
77
+ return slug ? `${slug}-${suffix}` : `note-${suffix}`;
78
+ }
51
79
  /**
52
80
  * The campaign record's schema, in one home. Exported so the repair verb in
53
81
  * `run/migrate.mjs` can prove a record's only defect is the absent `status`
@@ -94,6 +122,12 @@ export function validateCampaign(campaign) {
94
122
  }
95
123
  }
96
124
  if (record.landBranch !== undefined) requireText(record.landBranch, "campaign.landBranch");
125
+ // The ledger preservation marker is optional on read (a record written
126
+ // before it existed reads as not preserved), but once written it must be a
127
+ // boolean so removal can rely on the state it carries.
128
+ if (record.ledgerPreserved !== undefined && typeof record.ledgerPreserved !== "boolean") {
129
+ throw new TypeError("campaign.ledgerPreserved must be a boolean");
130
+ }
97
131
  // The chain's durable park: the campaign stays active but carries the reason
98
132
  // it stopped, naming the contract, the node and the status when they exist.
99
133
  if (record.attention !== undefined && record.attention !== null) {
@@ -18,7 +18,7 @@ import { syncAgentSignal } from "../repo/signal.mjs";
18
18
  import { acknowledgeJournalEvent, appendJournal, appendSeatAllowanceEvent, readJournal, watchJournal } from "../campaign/journal.mjs";
19
19
  import { driveCampaignChain } from "../campaign/chain.mjs";
20
20
  import { unparkCampaign } from "../campaign/unpark.mjs";
21
- import { readCampaign } from "../campaign/record.mjs";
21
+ import { generatedNoteId, readCampaign } from "../campaign/record.mjs";
22
22
  import { projectMetrics } from "../campaign/metrics.mjs";
23
23
  import { readMetricsSources } from "../campaign/metrics-command.mjs";
24
24
  import { DEFAULT_WAKE_POLL_MS, watchCampaignWake } from "../campaign/watch.mjs";
@@ -49,7 +49,8 @@ const NOTE_KIND_FLAGS = {
49
49
  };
50
50
 
51
51
  /** Flags are scoped to the operations that declare them; all other flags are rejected. */
52
- /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
52
+ /** @typedef {{type: "string"|"boolean", multiple?: boolean, required?: boolean}} CampaignOption */
53
+ /** @type {Record<string, Record<string, CampaignOption>>} */
53
54
  const OPERATION_OPTIONS = {
54
55
  list: { cwd: { type: "string" } },
55
56
  init: { cwd: { type: "string" }, goal: { type: "string" }, contract: { type: "string", multiple: true }, "land-branch": { type: "string" } },
@@ -67,8 +68,8 @@ const OPERATION_OPTIONS = {
67
68
  note: {
68
69
  cwd: { type: "string" },
69
70
  "session-id": { type: "string" },
70
- kind: { type: "string" },
71
- text: { type: "string" },
71
+ kind: { type: "string", required: true },
72
+ text: { type: "string", required: true },
72
73
  "event-id": { type: "string" },
73
74
  "decision-id": { type: "string" },
74
75
  supersedes: { type: "string" },
@@ -82,7 +83,7 @@ const OPERATION_OPTIONS = {
82
83
  text: { type: "string" },
83
84
  "event-id": { type: "string" },
84
85
  },
85
- close: { cwd: { type: "string" }, "event-id": { type: "string" } },
86
+ close: { cwd: { type: "string" }, "event-id": { type: "string" }, "ledger-in-repo": { type: "boolean" } },
86
87
  reledger: { cwd: { type: "string" } },
87
88
  supervise: { cwd: { type: "string" }, "allow-main": { type: "boolean" }, "refresh-controller": { type: "boolean" } },
88
89
  unpark: { cwd: { type: "string" }, force: { type: "boolean" }, "event-id": { type: "string" } },
@@ -94,7 +95,7 @@ const OPERATION_OPTIONS = {
94
95
  ack: { cwd: { type: "string" }, "session-id": { type: "string" }, "event-id": { type: "string" } },
95
96
  };
96
97
 
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, refreshController?: boolean, force?: boolean, path?: string, replace?: string, phase?: string}} CliValues */
98
+ /** @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, refreshController?: boolean, force?: boolean, path?: string, replace?: string, phase?: string, ledgerInRepo?: boolean}} CliValues */
98
99
  /** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
99
100
 
100
101
  /**
@@ -273,16 +274,36 @@ function note(campaignId, values) {
273
274
  type: kind,
274
275
  eventId: values.eventId ?? randomUUID(),
275
276
  at: new Date().toISOString(),
276
- sessionId: required(values.sessionId, "--session-id"),
277
+ sessionId: values.sessionId ?? lastAttachedSessionId(path),
277
278
  text: textValue(values.text, "--text"),
278
279
  };
279
- if (kind === "decision") entry.decisionId = required(values.decisionId, "--decision-id");
280
+ if (kind === "decision") entry.decisionId = values.decisionId ?? generatedNoteId(String(entry.text));
280
281
  if (kind === "supersede") entry.supersedes = required(values.supersedes, "--supersedes");
281
- if (kind === "open-question") entry.questionId = required(values.questionId, "--question-id");
282
+ if (kind === "open-question") entry.questionId = values.questionId ?? generatedNoteId(String(entry.text));
282
283
  if (kind === "outcome" && values.runId !== undefined) entry.runId = required(values.runId, "--run-id");
283
284
  appendJournal(path, entry);
284
285
  renderHandoff(path, runsDir);
285
- process.stdout.write(`[campaign] ${kind} noted\n`);
286
+ const generatedId = entry.decisionId ?? entry.questionId;
287
+ process.stdout.write(`[campaign] ${kind} noted${generatedId ? ` · ${generatedId}` : ""}\n`);
288
+ }
289
+
290
+ /**
291
+ * The newest session recorded by a `session.attached` journal entry, used when
292
+ * a note arrives without `--session-id`. Refuses with the flag it needs when
293
+ * the campaign has no attached session at all.
294
+ *
295
+ * @param {string} campaignPath
296
+ * @returns {string}
297
+ */
298
+ function lastAttachedSessionId(campaignPath) {
299
+ const attaches = readJournal(campaignPath).filter(
300
+ (entry) => entry.type === "session.attached" && typeof entry.sessionId === "string" && entry.sessionId,
301
+ );
302
+ const sessionId = attaches.at(-1)?.sessionId;
303
+ if (typeof sessionId !== "string" || !sessionId) {
304
+ throw new TypeError("--session-id requires a value and no session is attached to the campaign");
305
+ }
306
+ return sessionId;
286
307
  }
287
308
 
288
309
  /**
@@ -311,10 +332,12 @@ function resolveQuestion(campaignId, values) {
311
332
  */
312
333
  function close(campaignId, values) {
313
334
  const { path, runsDir } = selectCampaign(campaignId, values);
314
- const closed = closeCampaign(path, { eventId: values.eventId ?? randomUUID() });
335
+ const ledgerInRepo = values.ledgerInRepo === true;
336
+ const closed = closeCampaign(path, { eventId: values.eventId ?? randomUUID(), ledgerInRepo });
315
337
  renderHandoff(path, runsDir);
316
338
  process.stdout.write(`[campaign] ${closed.campaign.id} closed\n`);
317
- process.stdout.write(`[campaign] ledger · docs/campaigns/${closed.campaign.id}/ledger · ${closed.ledgerFiles.length} files\n`);
339
+ const ledgerLocation = ledgerInRepo ? `docs/campaigns/${closed.campaign.id}/ledger` : join(path, "ledger");
340
+ process.stdout.write(`[campaign] ledger · ${ledgerLocation} · ${closed.ledgerFiles.length} files\n`);
318
341
  reportUnknownCostFraction(path, runsDir);
319
342
  for (const skipped of closed.ledgerSkipped) {
320
343
  process.stdout.write(`[campaign] ledger skipped · ${skipped.runId}/${skipped.source}\n`);
@@ -8,6 +8,7 @@ import { existsSync, readFileSync } from "node:fs";
8
8
  import { dirname, join, resolve } from "node:path";
9
9
  import { parseArgs as parseFlags } from "node:util";
10
10
  import { loadPersistedContract, validateContract } from "../contract/index.mjs";
11
+ import { assertRuntimeAssignment } from "../engine/assignment.mjs";
11
12
 
12
13
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
13
14
 
@@ -52,9 +53,18 @@ export function contractCli(args) {
52
53
  */
53
54
  export function validateContractFile(path, { strictTraceability = false } = {}) {
54
55
  const runJsonPath = join(dirname(path), "run.json");
55
- const contract = existsSync(runJsonPath)
56
+ const persisted = existsSync(runJsonPath);
57
+ const contract = persisted
56
58
  ? loadPersistedContract(path, readRunDigest(runJsonPath))
57
59
  : validateContract(JSON.parse(readFileSync(path, "utf8")), path);
60
+ // R35: the launch assigns roles before any node dispatches, so an authored
61
+ // contract must also survive that assignment. The live launch probes
62
+ // providers, so this dry run gives every runtime as available and refuses
63
+ // only what no probe could rescue -- an omitted judge with no cross-vendor
64
+ // candidate, a same-vendor pair below the admitted tier, an exhausted judge
65
+ // list. A persisted run's contract is a replay of an assignment already
66
+ // made, so it is not re-decided here.
67
+ if (!persisted) assertRuntimeAssignment(contract);
58
68
  const count = contract.warnings.length;
59
69
  const blockingTraceability = strictTraceability
60
70
  ? contract.warnings.filter((warning) => isTraceabilityFinding(warning))
@@ -14,9 +14,9 @@
14
14
  import { SETTLED } from "../engine/prompts.mjs";
15
15
  import { bootstrapAckPath, bootstrapAttemptPath, bootstrapPath, cleanupBootstrapAttempts, readJson, writeJsonAtomic } from "../run/store.mjs";
16
16
  import { bootstrapFailureMatchesChild, bootstrapMatchesChild, processStartToken, readLock, sameProcessStartToken, validBootstrapNonce } from "../run/lock.mjs";
17
- import { cleanupBootstrapNonce } from "../engine/detach.mjs";
17
+ import { cleanupBootstrapNonce, discardNodeLessRunDirectory } from "../engine/detach.mjs";
18
18
  import { delay, errorCode } from "../util.mjs";
19
- import { existsSync, readFileSync } from "node:fs";
19
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { join, resolve } from "node:path";
22
22
  import { randomUUID } from "node:crypto";
@@ -112,6 +112,7 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bo
112
112
  const childIdentity = bootstrapMatchesChild(bootstrap, pid, nonce, expectedProcessStartToken);
113
113
  if (bootstrap.status === "failed" && bootstrapFailureMatchesChild(bootstrap, pid, nonce, expectedProcessStartToken)) {
114
114
  cleanupBootstrapAttempts(runDir);
115
+ discardNodeLessRunDirectory(runDir);
115
116
  throw new Error(`detached bootstrap failed: ${bootstrap.error}`);
116
117
  }
117
118
  const lock = readLock(runDir);
@@ -139,6 +140,7 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bo
139
140
  const failure = /** @type {BootstrapRecord} */ (readJson(bootstrapAttemptPath(runDir, nonce)));
140
141
  if (failure.status === "failed" && bootstrapFailureMatchesChild(failure, pid, nonce, expectedProcessStartToken)) {
141
142
  cleanupBootstrapAttempts(runDir);
143
+ discardNodeLessRunDirectory(runDir);
142
144
  throw new Error(`detached bootstrap failed: ${failure.error}`);
143
145
  }
144
146
  } catch (error) {
@@ -150,6 +152,7 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bo
150
152
  }
151
153
  cleanupBootstrapNonce(runDir, nonce);
152
154
  cleanupBootstrapAttempts(runDir);
155
+ discardNodeLessRunDirectory(runDir);
153
156
  throw new Error(`detached bootstrap failed before readiness for pid ${pid}`);
154
157
  }
155
158
  await delay(50);
@@ -229,10 +232,34 @@ export function bootstrapRunDir(command, target) {
229
232
  */
230
233
  export function writeBootstrapFailure(command, target, error) {
231
234
  const runDir = bootstrapRunDir(command, target);
232
- if (!runDir || !existsSync(runDir)) return;
235
+ if (!runDir) return;
233
236
  const nonce = validBootstrapNonce(process.env.FABERUN_BOOTSTRAP_NONCE) ? process.env.FABERUN_BOOTSTRAP_NONCE : null;
234
237
  const failure = { status: "failed", pid: process.pid, processStartToken: processStartToken(process.pid), runDir, nonce, at: new Date().toISOString(), error: error.message };
235
- if (command === "run" && existsSync(join(runDir, "contract.json"))) return;
238
+ const detachedChild = nonce !== null && !process.argv.includes("--detach");
239
+ if (!existsSync(runDir)) {
240
+ // A refusal that happens before the run directory exists — a dirty tree, a
241
+ // base ref that does not resolve — is the one the launcher cannot
242
+ // otherwise learn: the child's stdio is discarded, so all it could say was
243
+ // "failed before readiness for pid N". Measured 2026-09-27 (AP10): a whole
244
+ // `plan` stage died that way over two uncommitted paths. Only a detached
245
+ // child creates the directory, and only to carry this record;
246
+ // `waitForBootstrap` removes a node-less one as it reads it.
247
+ if (!detachedChild) return;
248
+ try {
249
+ mkdirSync(runDir, { recursive: true });
250
+ } catch {
251
+ // The refusal being reported matters more than the directory that would
252
+ // have carried it; without one the launcher falls back to its own message.
253
+ return;
254
+ }
255
+ }
256
+ // A `run` that names an existing run directory is refused before it writes
257
+ // anything, and its failure must not be stamped into that run's bootstrap
258
+ // record. The detached child is the exception: it wrote contract.json and
259
+ // then died before readiness, and the launcher has no other channel to
260
+ // learn the controller's error, so it must record that error before it
261
+ // exits.
262
+ if (command === "run" && !detachedChild && existsSync(join(runDir, "contract.json"))) return;
236
263
  /** @type {BootstrapRecord|null} */
237
264
  let current = null;
238
265
  try {
@@ -5,11 +5,16 @@
5
5
  * reads/writes prose, examples, Related lines, and the four fixed sections)
6
6
  * is copied through unchanged, so the manual's prose stays hand-authored
7
7
  * while its command surface cannot drift from the code silently.
8
+ *
9
+ * `--check` is the docs check the repository runs: it verifies COMMANDS.md and
10
+ * then runs the getting-started walkthrough against a throwaway repository, so
11
+ * the one document a newcomer follows literally cannot drift either.
8
12
  */
9
13
  import { readFileSync, writeFileSync } from "node:fs";
10
14
  import { resolve } from "node:path";
11
15
  import { fileURLToPath } from "node:url";
12
16
  import { COMMAND_OPTIONS } from "../cli.mjs";
17
+ import { checkGettingStarted, renderWalkthroughFailure } from "./walkthrough.mjs";
13
18
  import { METRICS_MANUAL } from "../campaign/metrics-command.mjs";
14
19
  import CAMPAIGN_OPERATIONS from "./campaign.mjs";
15
20
  import SEAT_OPERATIONS from "./seat.mjs";
@@ -18,7 +23,7 @@ import SKILLS_OPERATIONS from "./skills.mjs";
18
23
  import SPEC_OPERATIONS from "./spec.mjs";
19
24
 
20
25
  /** @typedef {{value: string, effect: string, default: string}} ManualFlag */
21
- /** @typedef {{type: "string"|"boolean", multiple?: boolean, manual?: ManualFlag}} FlagSpec */
26
+ /** @typedef {{type: "string"|"boolean", multiple?: boolean, required?: boolean, manual?: ManualFlag}} FlagSpec */
22
27
  /** @typedef {{flags?: Record<string, FlagSpec>, operations?: Record<string, Record<string, FlagSpec>>}} VerbSurface */
23
28
  /** @typedef {{verbs: Record<string, VerbSurface>}} Surface */
24
29
 
@@ -223,9 +228,12 @@ function renderSynopsis(prefix, positional, flags) {
223
228
  const parts = [prefix];
224
229
  if (positional) parts.push(positional);
225
230
  for (const [name, spec] of Object.entries(flags)) {
226
- if (spec.type === "boolean") parts.push(`[--${name}]`);
227
- else if (spec.multiple) parts.push(`[--${name} <a>...]`);
228
- else parts.push(`[--${name} <value>]`);
231
+ const token = spec.type === "boolean"
232
+ ? `--${name}`
233
+ : spec.multiple
234
+ ? `--${name} <a>...`
235
+ : `--${name} <value>`;
236
+ parts.push(spec.required ? token : `[${token}]`);
229
237
  }
230
238
  return parts.join(" ");
231
239
  }
@@ -339,13 +347,21 @@ function main(argv) {
339
347
  }
340
348
  const current = readFileSync(MANUAL_PATH, "utf8");
341
349
  const next = renderManual(current, collectSurface());
342
- if (next === current) return;
343
350
  if (mode === "--write") {
344
- writeFileSync(MANUAL_PATH, next);
351
+ if (next !== current) writeFileSync(MANUAL_PATH, next);
345
352
  return;
346
353
  }
347
- process.stderr.write(`docs/COMMANDS.md is out of date; run \`npm run docs\`.\n${diffSummary(current, next)}\n`);
348
- process.exitCode = 1;
354
+ let failed = false;
355
+ if (next !== current) {
356
+ process.stderr.write(`docs/COMMANDS.md is out of date; run \`npm run docs\`.\n${diffSummary(current, next)}\n`);
357
+ failed = true;
358
+ }
359
+ const walkthrough = checkGettingStarted();
360
+ if (!walkthrough.ok) {
361
+ process.stderr.write(renderWalkthroughFailure(walkthrough));
362
+ failed = true;
363
+ }
364
+ if (failed) process.exitCode = 1;
349
365
  }
350
366
 
351
367
  if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) main(process.argv.slice(2));
package/src/cli/plan.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  * sequencing and every decision the pipeline makes.
6
6
  */
7
7
  import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
8
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
- import { dirname, join, resolve } from "node:path";
8
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { dirname, join, relative, resolve } from "node:path";
10
10
  import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
11
11
  import { classifyRunProgress } from "../campaign/chain.mjs";
12
12
  import { runProgress } from "../engine/supervise.mjs";
@@ -17,6 +17,7 @@ import { colorLevel, statusToken } from "./brand.mjs";
17
17
  import { delay } from "../util.mjs";
18
18
  import { runPlanningPipeline } from "../plan/pipeline.mjs";
19
19
  import { parseAnswerFlags, resolvePlanningPipeline } from "../plan/resolve.mjs";
20
+ import { abandonedPlanProgress } from "../plan/progress.mjs";
20
21
  import { resolveReviewerList } from "../plan/reviewer.mjs";
21
22
  import { campaignTree, runDirectory } from "../run/paths.mjs";
22
23
  import { readCampaign } from "../campaign/record.mjs";
@@ -112,6 +113,46 @@ export function loadRuntimesCatalogue(path) {
112
113
  return runtimes;
113
114
  }
114
115
 
116
+ /**
117
+ * The campaign's own checked-in planning inputs live beside the spec it
118
+ * already keeps in the repository: `docs/campaigns/<id>/plan-inputs/`. A file
119
+ * that exists there is used as if it had been passed on the command line,
120
+ * because that is what it is for; an absent one changes nothing.
121
+ *
122
+ * Measured 2026-09-27 (AP19): a campaign carried
123
+ * `plan-inputs/runtimes.json` with a raised output ceiling, `plan` fell back
124
+ * to the 8192 default without a word, and the operator found out only after
125
+ * burning rounds on a truncated draft.
126
+ *
127
+ * @param {string} cwd @param {string} campaignId @param {string} file
128
+ * @returns {string}
129
+ */
130
+ function campaignInputPath(cwd, campaignId, file) {
131
+ return join(cwd, "docs", "campaigns", campaignId, "plan-inputs", file);
132
+ }
133
+
134
+ /**
135
+ * The catalogue a `plan` reads runtimes from, and the file it came from when
136
+ * that file is the campaign's own. `--runtimes` wins over the checked-in one:
137
+ * an operator passing a path is overriding, not asking.
138
+ *
139
+ * `source` is returned rather than printed here so a caller decides the
140
+ * wording; `planCli` says it out loud because silence is the whole defect. The
141
+ * path is resolved from the launch cwd, never from the plan's own target, so
142
+ * the same command from a subdirectory reads the same file the campaign does.
143
+ *
144
+ * @param {{cwd: string, campaignId: string, runtimes?: unknown}} options
145
+ * @returns {{runtimes: Record<string, import("../contract/index.mjs").ValidatedRuntime>, source: string|null}}
146
+ */
147
+ export function resolvePlanRuntimes({ cwd, campaignId, runtimes }) {
148
+ if (typeof runtimes === "string" && runtimes) return { runtimes: loadRuntimesCatalogue(runtimes), source: null };
149
+ const campaignRuntimes = campaignInputPath(cwd, campaignId, "runtimes.json");
150
+ if (!existsSync(campaignRuntimes)) {
151
+ return { runtimes: /** @type {Record<string, import("../contract/index.mjs").ValidatedRuntime>} */ (DISCOVERY_RUNTIME_DEFINITIONS), source: null };
152
+ }
153
+ return { runtimes: loadRuntimesCatalogue(campaignRuntimes), source: campaignRuntimes };
154
+ }
155
+
115
156
  /**
116
157
  * A `--verification <path>` catalogue: a JSON object carrying either or both
117
158
  * of the contract's own suite keys, `sharedVerification` and
@@ -160,9 +201,10 @@ export async function planCli(target, values) {
160
201
  // R19: the planner's own reviewer list, never `runtimeDefaults.judge` --
161
202
  // `--reviewers` wins over the machine's own `config.reviewers` default.
162
203
  const reviewers = resolveReviewerList({ reviewers: parseReviewerList(values.reviewers) }, readUserConfig(process.env));
163
- const runtimes = typeof values.runtimes === "string" && values.runtimes
164
- ? loadRuntimesCatalogue(values.runtimes)
165
- : DISCOVERY_RUNTIME_DEFINITIONS;
204
+ const { runtimes, source: runtimesSource } = resolvePlanRuntimes({ cwd: process.cwd(), campaignId, runtimes: values.runtimes });
205
+ // Said out loud because silence is the whole defect: the default and the
206
+ // campaign's own catalogue look identical from the outside.
207
+ if (runtimesSource) process.stdout.write(`[plan] runtimes · ${relative(process.cwd(), runtimesSource)}\n`);
166
208
  const verification = typeof values.verification === "string" && values.verification
167
209
  ? loadVerificationSuites(values.verification)
168
210
  : {};
@@ -171,6 +213,15 @@ export async function planCli(target, values) {
171
213
  ? undefined
172
214
  : validateJudgeIndependence(values["judge-independence"], "--judge-independence");
173
215
 
216
+ // A record with no live process behind it is the only trace a planning run
217
+ // that died without a word leaves. Measured 2026-09-26 (AP4): `--detach` pid
218
+ // 64270 vanished during repo-facts and the phase directory stayed empty, so
219
+ // the next launch is the one place the operator can still be told.
220
+ const abandoned = abandonedPlanProgress(process.cwd(), campaignId, phase);
221
+ if (abandoned) {
222
+ process.stderr.write(`[plan] a previous planning process (pid ${String(abandoned.pid ?? "?")}) stopped during ${String(abandoned.stage ?? "?")} at ${String(abandoned.at ?? "?")} and never reached the end\n`);
223
+ }
224
+
174
225
  if (values.detach === true) {
175
226
  const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
176
227
  if (approveBelow !== undefined) argv.push("--approve-below", approveBelow);
package/src/cli/setup.mjs CHANGED
@@ -31,6 +31,7 @@ import { boundedGitSync } from "../repo/worktree.mjs";
31
31
  import { colorLevel, renderBanner, statusToken } from "./brand.mjs";
32
32
  import { packageVersion } from "../host/package.mjs";
33
33
  import { configPath, faberunHome } from "../host/home.mjs";
34
+ import { recordConfigInstall } from "../host/install-registry.mjs";
34
35
  import { readUserConfig, writeUserConfig } from "../host/config.mjs";
35
36
  import { discoverSkillTargets, registerSkills } from "./skills.mjs";
36
37
 
@@ -181,6 +182,10 @@ export async function setupCommand(options = {}) {
181
182
  updatedAt: new Date().toISOString(),
182
183
  };
183
184
  writeUserConfig(env, config);
185
+ // The config lives under FABERUN_HOME, which removal deletes wholesale, but
186
+ // it is still an out-of-repository write; recording it keeps the ledger a
187
+ // complete account of what setup left behind.
188
+ recordConfigInstall(env, configPath(faberunHome(env)));
184
189
  if (!json) stdout(`${statusToken("ok", level)} config · ${configPath(faberunHome(env))}\n`);
185
190
 
186
191
  // The offer comes after the config is durable, so a machine that answers
@@ -27,6 +27,7 @@ import { fileURLToPath } from "node:url";
27
27
  import { parseArgs as parseFlags } from "node:util";
28
28
 
29
29
  import { faberunHome, installedVersionDir } from "../host/home.mjs";
30
+ import { recordSkillInstall, skillSiteDir } from "../host/install-registry.mjs";
30
31
  import { findExecutable, linkDirectory } from "../host/platform.mjs";
31
32
  import { colorLevel, statusToken } from "./brand.mjs";
32
33
 
@@ -71,15 +72,19 @@ const OPERATION_OPTIONS = {
71
72
  * so it has no registration target. It is kept in the table to record the
72
73
  * measurement, and reported as `no skill support`.
73
74
  *
74
- * @type {Record<string, {binary: string|null, optional: boolean, dir: ((home: string) => string)|null}>}
75
+ * The destination paths themselves live in `host/install-registry.mjs`'s
76
+ * `KNOWN_INSTALL_SITES` and are resolved through `skillSiteDir`, so this table
77
+ * and the manifest removal scans cannot drift apart.
78
+ *
79
+ * @type {Record<string, {binary: string|null, optional: boolean, dir: ((home: string) => string|null)|null}>}
75
80
  */
76
81
  const HARNESS_SKILL_DIRS = {
77
- claude: { binary: "claude", optional: false, dir: (home) => join(home, ".claude", "skills") },
78
- codex: { binary: "codex", optional: false, dir: (home) => join(home, ".codex", "skills") },
79
- zcode: { binary: "zcode", optional: false, dir: (home) => join(home, ".agents", "skills") },
80
- agy: { binary: "agy", optional: false, dir: (home) => join(home, ".gemini", "config", "skills") },
81
- dsh: { binary: "dsh", optional: false, dir: null },
82
- agents: { binary: null, optional: true, dir: (home) => join(home, ".agents", "skills") },
82
+ claude: { binary: "claude", optional: false, dir: (home) => skillSiteDir("claude", home) },
83
+ codex: { binary: "codex", optional: false, dir: (home) => skillSiteDir("codex", home) },
84
+ zcode: { binary: "zcode", optional: false, dir: (home) => skillSiteDir("zcode", home) },
85
+ agy: { binary: "agy", optional: false, dir: (home) => skillSiteDir("agy", home) },
86
+ dsh: { binary: "dsh", optional: false, dir: (home) => skillSiteDir("dsh", home) },
87
+ agents: { binary: null, optional: true, dir: (home) => skillSiteDir("agents", home) },
83
88
  };
84
89
 
85
90
  /** @typedef {(text: string) => void} Writer */
@@ -266,6 +271,12 @@ export function registerSkills(options = {}) {
266
271
  }
267
272
  mkdirSync(dir, { recursive: true });
268
273
  const action = options.copy === true ? copySkill(source, destination, force) : linkSkill(source, destination, force);
274
+ // `skipped` is a real directory faberun refused to displace, so it is not
275
+ // faberun's to remove. Every other action left (or confirmed) a faberun
276
+ // skill at `destination`, so it is recorded with the directory that owns it.
277
+ if (action !== "skipped") {
278
+ recordSkillInstall(env, { path: destination, root: dir, harness: target.harness });
279
+ }
269
280
  results.push({ harness: target.harness, dir: destination, action });
270
281
  const path = displayPath(destination, home);
271
282
  if (action === "skipped") {