faberun 0.26.3 → 0.28.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 (88) hide show
  1. package/README.md +11 -4
  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 +12 -2
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/setup.mjs +6 -1
  18. package/src/cli/skills.mjs +18 -7
  19. package/src/cli/uninstall.mjs +542 -0
  20. package/src/cli/walkthrough.mjs +462 -0
  21. package/src/cli.mjs +68 -8
  22. package/src/contract/articles.mjs +30 -0
  23. package/src/contract/definition-of-done.mjs +109 -0
  24. package/src/contract/index.mjs +7 -4
  25. package/src/contract/judge-independence.mjs +18 -0
  26. package/src/contract/runtime.mjs +6 -2
  27. package/src/contract/scope-findings.mjs +120 -0
  28. package/src/contract/snapshot.mjs +19 -5
  29. package/src/contract/task-packet.mjs +49 -1
  30. package/src/contract/worker-result.mjs +10 -0
  31. package/src/engine/assignment.mjs +45 -0
  32. package/src/engine/detach.mjs +18 -0
  33. package/src/engine/gate.mjs +10 -2
  34. package/src/engine/judge-gate.mjs +24 -9
  35. package/src/engine/live-preflight.mjs +114 -18
  36. package/src/engine/prompts.mjs +41 -3
  37. package/src/engine/resume.mjs +119 -12
  38. package/src/engine/retry.mjs +11 -11
  39. package/src/engine/run-command.mjs +80 -4
  40. package/src/engine/run-identity.mjs +27 -14
  41. package/src/engine/runtime-discovery.mjs +61 -13
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +54 -2
  45. package/src/harnesses/claude/index.mjs +177 -2
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +18 -116
  48. package/src/harnesses/dsh/runner.mjs +1 -17
  49. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  50. package/src/harnesses/fx/home.mjs +57 -0
  51. package/src/harnesses/fx/index.mjs +137 -0
  52. package/src/harnesses/fx/native/build.zig +20 -0
  53. package/src/harnesses/fx/native/build.zig.zon +7 -0
  54. package/src/harnesses/fx/native/home.zig +85 -0
  55. package/src/harnesses/fx/native/main.zig +503 -0
  56. package/src/harnesses/fx/native/permissions.zig +51 -0
  57. package/src/harnesses/fx/native/relay.zig +253 -0
  58. package/src/harnesses/fx/native/usage.zig +112 -0
  59. package/src/harnesses/fx/permissions.mjs +35 -0
  60. package/src/harnesses/fx/runner.mjs +350 -0
  61. package/src/harnesses/fx/usage-proxy.mjs +176 -0
  62. package/src/harnesses/index.mjs +29 -14
  63. package/src/harnesses/replay/index.mjs +12 -0
  64. package/src/harnesses/runner-io.mjs +25 -0
  65. package/src/harnesses/runner-transcript.mjs +127 -0
  66. package/src/harnesses/session-metrics.mjs +38 -13
  67. package/src/harnesses/zcode/index.mjs +19 -0
  68. package/src/host/config.mjs +8 -2
  69. package/src/host/environment-report.mjs +85 -0
  70. package/src/host/home.mjs +27 -0
  71. package/src/host/install-registry.mjs +511 -0
  72. package/src/host/preflight.mjs +34 -10
  73. package/src/host/uninstall-roots.mjs +196 -0
  74. package/src/plan/proof-check.mjs +15 -0
  75. package/src/plan/repo-facts.mjs +95 -11
  76. package/src/plan/rounds.mjs +27 -0
  77. package/src/repo/declared-paths.mjs +74 -6
  78. package/src/repo/integrate.mjs +1 -1
  79. package/src/repo/scope-closure.mjs +59 -0
  80. package/src/repo/signal-block.mjs +26 -0
  81. package/src/repo/signal.mjs +9 -24
  82. package/src/repo/workspace.mjs +189 -14
  83. package/src/report/campaign-brief.mjs +4 -0
  84. package/src/report/message.mjs +37 -6
  85. package/src/report/progress.mjs +13 -2
  86. package/src/run/paths.mjs +14 -1
  87. package/src/run/usage.mjs +66 -1
  88. 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,7 +14,7 @@
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
19
  import { existsSync, readFileSync } from "node:fs";
20
20
  import { fileURLToPath } from "node:url";
@@ -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);
@@ -232,7 +235,14 @@ export function writeBootstrapFailure(command, target, error) {
232
235
  if (!runDir || !existsSync(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
+ // A `run` that names an existing run directory is refused before it writes
239
+ // anything, and its failure must not be stamped into that run's bootstrap
240
+ // record. The detached child is the exception: it wrote contract.json and
241
+ // then died before readiness, and the launcher has no other channel to
242
+ // learn the controller's error, so it must record that error before it
243
+ // exits.
244
+ const detachedChild = nonce !== null && !process.argv.includes("--detach");
245
+ if (command === "run" && !detachedChild && existsSync(join(runDir, "contract.json"))) return;
236
246
  /** @type {BootstrapRecord|null} */
237
247
  let current = null;
238
248
  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/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
 
@@ -64,7 +65,7 @@ import { discoverSkillTargets, registerSkills } from "./skills.mjs";
64
65
  const MIN_NODE_MAJOR = 22;
65
66
 
66
67
  /** The harnesses a fresh machine can install, named in the failure hint. */
67
- const INSTALL_HARNESSES = ["claude", "codex", "agy", "dsh", "zcode"];
68
+ const INSTALL_HARNESSES = ["claude", "codex", "agy", "dsh", "fx", "zcode"];
68
69
 
69
70
  /**
70
71
  * @param {SetupOptions} [options]
@@ -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") {