faberun 0.10.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 (60) 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/contract/task-packet.mjs +12 -3
  20. package/src/engine/cancel.mjs +13 -0
  21. package/src/engine/dispatch.mjs +20 -2
  22. package/src/engine/lifecycle.mjs +38 -61
  23. package/src/engine/live-preflight.mjs +2 -1
  24. package/src/engine/notify-queue.mjs +9 -1
  25. package/src/engine/pricing-seed.json +543 -0
  26. package/src/engine/pricing-seed.mjs +54 -0
  27. package/src/engine/process.mjs +8 -1
  28. package/src/engine/result-file.mjs +2 -1
  29. package/src/engine/resume.mjs +6 -2
  30. package/src/engine/run-identity.mjs +9 -2
  31. package/src/engine/scheduler.mjs +40 -6
  32. package/src/engine/settle-judge.mjs +141 -0
  33. package/src/engine/settle.mjs +1 -1
  34. package/src/harnesses/protocol.mjs +46 -10
  35. package/src/harnesses/replay/bin.mjs +10 -1
  36. package/src/host/home.mjs +10 -3
  37. package/src/host/platform.mjs +111 -0
  38. package/src/host/preflight.mjs +43 -21
  39. package/src/host/projects.mjs +155 -0
  40. package/src/notify/index.mjs +88 -10
  41. package/src/plan/pipeline.mjs +32 -9
  42. package/src/plan/repo-facts.mjs +80 -2
  43. package/src/plan/spec.mjs +2 -1
  44. package/src/repo/declared-paths.mjs +7 -1
  45. package/src/repo/integrate.mjs +1 -1
  46. package/src/repo/scope-closure.mjs +2 -1
  47. package/src/repo/signal.mjs +24 -7
  48. package/src/repo/source-identity.mjs +7 -0
  49. package/src/repo/workspace.mjs +12 -2
  50. package/src/repo/worktree.mjs +89 -22
  51. package/src/report/next.mjs +66 -6
  52. package/src/report/progress.mjs +724 -0
  53. package/src/report/render.mjs +3 -3
  54. package/src/run/migrate.mjs +278 -0
  55. package/src/run/paths.mjs +189 -0
  56. package/src/seat/index.mjs +2 -1
  57. package/src/web/app.css +170 -0
  58. package/src/web/app.mjs +690 -0
  59. package/src/web/index.html +38 -283
  60. package/src/web/server.mjs +197 -182
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The project registry under `$FABERUN_HOME/projects/`.
3
+ *
4
+ * A project is keyed by the repository path, not by anything derived from it:
5
+ * `projects/index.json` maps a path to an opaque id, and `projects/<id>/project.json`
6
+ * is the record. The split matters because a project must be able to move —
7
+ * a later command re-associates a project whose path changed — and everything
8
+ * that will hang off a project id (campaigns, in a later phase) must survive
9
+ * that move untouched. Keying storage by path instead would mean a rename or
10
+ * a clone-to-a-new-directory orphans every one of them; keying storage by id
11
+ * and the *lookup* by path means a move rewrites one index entry while the
12
+ * project directory, and everything under it, stays put.
13
+ *
14
+ * The id is `randomUUID()`: opaque, and unrelated to the path it was minted
15
+ * for, which is the one property this registry actually needs from it.
16
+ *
17
+ * Both files are written with the same temp-then-rename discipline as
18
+ * `run/store.mjs`'s `writeJsonAtomic`, so a second faberun process reading
19
+ * either file — the operator routinely runs more than one at a time — never
20
+ * observes a half-written record. This does not arbitrate two processes
21
+ * writing at once: a race between two *new* registrations landing in the same
22
+ * tick can drop one index entry, and that is accepted here because the loser
23
+ * simply registers again the next time its path is looked up.
24
+ */
25
+ import { randomUUID } from "node:crypto";
26
+ import { realpathSync } from "node:fs";
27
+ import { join, resolve } from "node:path";
28
+ import { readJson, writeJsonAtomic } from "../run/store.mjs";
29
+ import { errorCode } from "../util.mjs";
30
+ import { projectsDir } from "./home.mjs";
31
+
32
+ export { projectsDir };
33
+
34
+ /** @typedef {{schemaVersion: 1, id: string, path: string, remotes: string[], createdAt: string, updatedAt: string}} ProjectRecord */
35
+
36
+ /**
37
+ * `path`, realpath-resolved so two different spellings of the same real
38
+ * directory key the same project — `$TMPDIR` itself is a symlink on macOS
39
+ * (`/var` -> `/private/var`), which is what actually surfaced this,
40
+ * measured 2026-09-19. Falls back to a plain `resolve` for a path that does
41
+ * not exist: `cwd` from a live process always exists, but a path this
42
+ * registry is asked about — a stale config entry, an operator's typo — need
43
+ * not, and nothing could already be registered under either spelling of a
44
+ * path that was never real to begin with, so there is nothing to reconcile.
45
+ *
46
+ * Exported because `cli/project.mjs` writes this same index directly (it has
47
+ * no registry operation that keeps an id while changing its path) and must
48
+ * key its write identically, or a write it makes becomes one this module's
49
+ * own lookups can never find again.
50
+ *
51
+ * @param {string} path
52
+ * @returns {string}
53
+ */
54
+ export function resolveIdentity(path) {
55
+ try {
56
+ return realpathSync(path);
57
+ } catch (error) {
58
+ if (errorCode(error) === "ENOENT") return resolve(path);
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ /** @param {string} home @returns {string} */
64
+ function projectIndexPath(home) {
65
+ return join(projectsDir(home), "index.json");
66
+ }
67
+
68
+ /** @param {string} home @param {string} id @returns {string} */
69
+ function projectRecordPath(home, id) {
70
+ return join(projectsDir(home), id, "project.json");
71
+ }
72
+
73
+ /** @param {string} home @returns {Record<string, string>} the path-to-id map, or `{}` when the registry has never been written */
74
+ function readIndex(home) {
75
+ try {
76
+ return /** @type {Record<string, string>} */ (readJson(projectIndexPath(home)));
77
+ } catch (error) {
78
+ if (errorCode(error) === "ENOENT") return {};
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The project registered at `id`, or null when no such project exists.
85
+ *
86
+ * @param {string} home
87
+ * @param {string} id
88
+ * @returns {ProjectRecord|null}
89
+ */
90
+ export function readProject(home, id) {
91
+ try {
92
+ return /** @type {ProjectRecord} */ (readJson(projectRecordPath(home, id)));
93
+ } catch (error) {
94
+ if (errorCode(error) === "ENOENT") return null;
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The project registered at `path`, or null when that path has never been
101
+ * registered. `path` is realpath-resolved first, the same normalization
102
+ * `registerProject` applies, so a caller need not agree in advance on
103
+ * relative-vs-absolute spelling, and two different spellings of the same
104
+ * real directory — a symlinked `$TMPDIR` on macOS is the case that actually
105
+ * surfaced this, measured 2026-09-19 — still answer the same project rather
106
+ * than silently minting two.
107
+ *
108
+ * @param {string} home
109
+ * @param {string} path
110
+ * @returns {ProjectRecord|null}
111
+ */
112
+ export function findProjectByPath(home, path) {
113
+ const id = readIndex(home)[resolveIdentity(path)];
114
+ return id ? readProject(home, id) : null;
115
+ }
116
+
117
+ /**
118
+ * Register `path` as a project, or update the existing one at that path.
119
+ *
120
+ * A path seen for the first time mints a new id and an index entry that
121
+ * outlives any single call. A path already in the index keeps its id — this
122
+ * is the "registers once, not twice" half of R4 — and only its record is
123
+ * refreshed: the remotes are replaced with what the caller passed just now.
124
+ * A changed remote list is not a new identity, since the project is keyed by
125
+ * path; it is simply the current fact about a path that can gain, lose or
126
+ * rename a remote over its lifetime, so each registration overwrites the
127
+ * list rather than accumulating history the caller no longer observes.
128
+ *
129
+ * @param {string} home
130
+ * @param {string} path
131
+ * @param {string[]} [remotes]
132
+ * @returns {ProjectRecord}
133
+ */
134
+ export function registerProject(home, path, remotes = []) {
135
+ const resolved = resolveIdentity(path);
136
+ const index = readIndex(home);
137
+ let id = index[resolved];
138
+ if (!id) {
139
+ id = randomUUID();
140
+ writeJsonAtomic(projectIndexPath(home), { ...index, [resolved]: id });
141
+ }
142
+ const existing = readProject(home, id);
143
+ const now = new Date().toISOString();
144
+ /** @type {ProjectRecord} */
145
+ const record = {
146
+ schemaVersion: 1,
147
+ id,
148
+ path: resolved,
149
+ remotes: [...remotes],
150
+ createdAt: existing?.createdAt ?? now,
151
+ updatedAt: now,
152
+ };
153
+ writeJsonAtomic(projectRecordPath(home, id), record);
154
+ return record;
155
+ }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Direct notification dispatcher (TECH-SPEC lean, rule 6). On `node.terminal`,
3
- * `run.terminal` and `attention` the controller renders a one-line message
4
- * from a fixed per-type template, calls `FABERUN_NOTIFY_BIN` with the
5
- * event as JSON on stdin, and appends a receipt (`delivered`, `failed` or
3
+ * `run.terminal` and `attention` the controller renders the message through
4
+ * `renderRunProgress` (`report/progress.mjs`) from the run's own persisted
5
+ * state, calls `FABERUN_NOTIFY_BIN` with the event as JSON on stdin, and
6
+ * appends a receipt (`delivered`, `failed` or
6
7
  * `no_transport`, with the timestamp) to `<run-dir>/notify.jsonl`. Delivery is
7
8
  * lossy: an event is attempted once, a failure schedules no further attempt and
8
9
  * is never requeued, and the controller never waits on a retry it will not
@@ -47,6 +48,25 @@ import { join } from "node:path";
47
48
  import { createMacosNotifier } from "./os-macos.mjs";
48
49
  import { errorMessage } from "../util.mjs";
49
50
 
51
+ /**
52
+ * `report/progress.mjs` reaches back to this module (through
53
+ * `run/node-store.mjs` -> `run/disk-gc.mjs` -> `host/preflight.mjs`, which
54
+ * reads `NOTIFY_BIN_ENV`), so a static top-level import of it here would be a
55
+ * real cycle: `host/preflight.mjs` would read `NOTIFY_BIN_ENV` while this
56
+ * module's own top level was still mid-evaluation, before the `const` is
57
+ * assigned. A dynamic import resolves this module first and defers loading
58
+ * `report/progress.mjs` until the first call, by which point this module has
59
+ * already finished initializing -- so the cycle is real but harmless. Cached
60
+ * after the first call so every subsequent render reuses the same module.
61
+ * @type {Promise<typeof import("../report/progress.mjs")>|null}
62
+ */
63
+ let progressModule = null;
64
+ /** @returns {Promise<typeof import("../report/progress.mjs")>} */
65
+ function loadProgressModule() {
66
+ progressModule ??= import("../report/progress.mjs");
67
+ return progressModule;
68
+ }
69
+
50
70
  export const NOTIFY_BIN_ENV = "FABERUN_NOTIFY_BIN";
51
71
  const MACOS_TRANSPORT = "os-macos";
52
72
  export const NOTIFY_LOG_FILE = "notify.jsonl";
@@ -68,6 +88,15 @@ export const MAX_ATTEMPTS = 3;
68
88
  */
69
89
  export const notificationDeliveryTimeoutMs = 5_000;
70
90
 
91
+ /**
92
+ * The one-line, character-based bound this module used to enforce on every
93
+ * rendered summary. The primary path (below) now defers to
94
+ * `PROGRESS_MESSAGE_MAX_BYTES` in `report/progress.mjs`, which bounds the
95
+ * whole rendered message in bytes rather than characters, because that
96
+ * message is no longer one line. `SUMMARY_CHARS` survives only to bound the
97
+ * degraded template `renderNotification` falls back to when a caller has no
98
+ * `runDir` to render from.
99
+ */
71
100
  const SUMMARY_CHARS = 200;
72
101
 
73
102
  /**
@@ -103,19 +132,68 @@ export const NOTIFY_NO_TRANSPORT_WARNING = "no human notification transport is c
103
132
  /** @typedef {{ok: boolean, error?: string, noTransport?: boolean}} DeliveryResult */
104
133
 
105
134
  /**
106
- * Render the fixed one-line message for an event, from counters and
107
- * identifiers only (node id, run id or directory, state, attempt, error
108
- * code, done/total, cost), never from model text:
135
+ * Render the message for an event. For `node.terminal`, `run.terminal` and
136
+ * `attention` this delegates to `renderRunProgress`, which reads the run's
137
+ * own persisted state (its contract, its node snapshots, its usage) from
138
+ * `event.runDir` -- so whatever calls this function once gets exactly the
139
+ * string every audience of that event sees: the inbox entry's `summary` and
140
+ * the transport's stdin are never rendered separately and never drift.
141
+ *
142
+ * `runDir` is absent, or names a directory with no readable run scaffold
143
+ * (no `contract.json`, no `run.json`, a node snapshot that fails validation),
144
+ * on two kinds of paths: a caller with no run directory at all, and a render
145
+ * that fails against a run that is present but broken. Both fall back to
146
+ * `degradedNotification` below, a fixed one-line, counters-only template --
147
+ * still one render, still the same text for every audience, just a coarser
148
+ * one. A render failure must never propagate: this function backs the lossy
149
+ * notify queue, and a broken render must not turn into a lost receipt.
150
+ *
151
+ * The unknown-type case is checked synchronously (it throws, it never
152
+ * returns a rejected promise a caller might forget to handle); the known
153
+ * types resolve asynchronously because loading `report/progress.mjs` is
154
+ * itself async (see `loadProgressModule`).
155
+ *
156
+ * @param {NotifyEvent} event
157
+ * @returns {Promise<string>}
158
+ */
159
+ export function renderNotification(event) {
160
+ switch (event.type) {
161
+ case "node.terminal":
162
+ case "run.terminal":
163
+ case "attention":
164
+ return renderDelegated(event);
165
+ default:
166
+ throw new TypeError(`renderNotification: unknown event type ${String(event.type)}`);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * @param {NotifyEvent} event
172
+ * @returns {Promise<string>}
173
+ */
174
+ async function renderDelegated(event) {
175
+ if (typeof event.runDir !== "string" || !event.runDir) return degradedNotification(event);
176
+ try {
177
+ const { renderRunProgress } = await loadProgressModule();
178
+ return renderRunProgress(event.runDir, event);
179
+ } catch {
180
+ return degradedNotification(event);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The fixed one-line message this module rendered for every event before
186
+ * `renderRunProgress` existed, from counters and identifiers only (node id,
187
+ * run id or directory, state, attempt, error code, done/total, cost), never
188
+ * from model text:
109
189
  * `node <id> failed · run <id> · attempt 2 · verification_failed · resume <run-dir>`
110
190
  * `run <id> done · 3/3 nodes · $4.21`
111
191
  * `node <id> needs you · run <id> · <error code>`
112
- * `runDir` and `costUsd`, when present on the event, come from the run's own
113
- * `status.json` (`NotifyQueue.enqueue` reads it) — never from the model.
114
192
  *
115
193
  * @param {NotifyEvent} event
116
194
  * @returns {string}
117
195
  */
118
- export function renderNotification(event) {
196
+ function degradedNotification(event) {
119
197
  const runId = event.runId ?? "-";
120
198
  switch (event.type) {
121
199
  case "node.terminal": {
@@ -366,7 +444,7 @@ export class NotifyQueue {
366
444
  // one that reaches the transport and the receipt.
367
445
  const summary = typeof enriched.summary === "string" && enriched.summary
368
446
  ? enriched.summary
369
- : renderNotification(enriched);
447
+ : await renderNotification(enriched);
370
448
  // Consumers deduplicate by eventId (the Ford adapter rejects an event without
371
449
  // one), so every delivery carries a stable id derived from the dedupe key.
372
450
  const eventId = enriched.eventId
@@ -16,18 +16,18 @@ import { validateContract } from "../contract/index.mjs";
16
16
  import { discoveryOutput } from "../contract/worker-result.mjs";
17
17
  import { readWorkerResultFile } from "../engine/result-file.mjs";
18
18
  import { classifyRunProgress } from "../campaign/chain.mjs";
19
- import { campaignDir } from "../campaign/layout.mjs";
20
19
  import { appendSeatAllowanceEvent, readJournal } from "../campaign/journal.mjs";
21
20
  import { readCampaign } from "../campaign/record.mjs";
22
21
  import { campaignCli } from "../cli/campaign.mjs";
23
22
  import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
24
23
  import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
25
- import { validateSpec } from "./spec.mjs";
24
+ import { parseSpec, validateSpec } from "./spec.mjs";
26
25
  import { collectRepoFacts } from "./repo-facts.mjs";
27
26
  import { RISK_TIERS, buildPlanningContract, validateFindings, validatePlanOutput } from "./template.mjs";
28
27
  import { applySizingRules } from "./sizing.mjs";
29
28
  import { resolveRuntimes } from "./routing.mjs";
30
29
  import { freezePlan } from "./freeze.mjs";
30
+ import { campaignTree, runDirectory } from "../run/paths.mjs";
31
31
 
32
32
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
33
33
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
@@ -48,6 +48,16 @@ import { freezePlan } from "./freeze.mjs";
48
48
  */
49
49
  export const PLANNER_SESSION_ID = "planner";
50
50
 
51
+ /**
52
+ * Where this pipeline stages the scratch files it must hand a discovery node
53
+ * through `readFiles`: `<cwd>/<this>/<campaignId>/<phase>/`, gitignored. Kept
54
+ * out of `RUNS_DIR_NAME` on purpose — a repository with nothing under the
55
+ * home yet treats a present `.runs` as an unmigrated legacy layout
56
+ * (`runsRoot` in `src/run/paths.mjs`), and this directory must never trip
57
+ * that check.
58
+ */
59
+ const PLAN_SCRATCH_DIR_NAME = ".faberun-plan";
60
+
51
61
  /**
52
62
  * No taskKind/riskTier row is opinionated by default: absent an operator
53
63
  * `--runtime-defaults` instruction, every sized node routes through plain
@@ -78,7 +88,7 @@ export async function runPlanningPipeline(options) {
78
88
  if (typeof wait !== "function") throw new TypeError("runPlanningPipeline requires a wait seam");
79
89
  const cwd = resolve(options.cwd ?? ".");
80
90
 
81
- const campaignPath = campaignDir(join(cwd, ".runs"), campaignId);
91
+ const campaignPath = campaignTree(cwd, campaignId);
82
92
  const campaign = readCampaign(campaignPath);
83
93
  if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaignId}`);
84
94
 
@@ -90,7 +100,7 @@ export async function runPlanningPipeline(options) {
90
100
  throw new Error(`spec ${specPath} fails strict traceability: ${detail}`);
91
101
  }
92
102
 
93
- const plansDir = join(cwd, ".runs", "campaigns", campaignId, "plans", phase);
103
+ const plansDir = join(campaignTree(cwd, campaignId), "plans", phase);
94
104
  mkdirSync(plansDir, { recursive: true });
95
105
  const pipelineLog = join(plansDir, "pipeline.jsonl");
96
106
  /** @param {string} stage @param {Record<string, unknown>} [extra] */
@@ -98,8 +108,21 @@ export async function runPlanningPipeline(options) {
98
108
  type: "plan.stage", at: new Date().toISOString(), campaignId, phase, stage, ...extra,
99
109
  });
100
110
 
101
- const repoFacts = collectRepoFacts(cwd);
102
- const repoFactsPath = join(plansDir, "repo-facts.json");
111
+ // A discovery node's `readFiles` must resolve inside `cwd` (the task packet's
112
+ // own containment rule), but `plansDir` lives under the home since R2 and no
113
+ // longer nests inside the repository. Repo facts, the working plan and each
114
+ // round's findings are relayed to a worker through `readFiles`, so they are
115
+ // staged here instead, gitignored and disposable — the durable record stays
116
+ // in `plansDir`.
117
+ const scratchDir = join(cwd, PLAN_SCRATCH_DIR_NAME, campaignId, phase);
118
+ mkdirSync(scratchDir, { recursive: true });
119
+
120
+ // A requirement's `measure` command runs here, before any node exists, so
121
+ // the draft stage reads what the planner measured instead of inferring it
122
+ // from the spec's prose.
123
+ const parsedSpec = parseSpec(specText);
124
+ const repoFacts = collectRepoFacts(cwd, { requirements: parsedSpec.requirements });
125
+ const repoFactsPath = join(scratchDir, "repo-facts.json");
103
126
  writeFileSync(repoFactsPath, `${JSON.stringify(repoFacts, null, 2)}\n`);
104
127
  const relativeRepoFactsPath = relative(cwd, repoFactsPath);
105
128
  logStage("repo-facts", { gitHead: repoFacts.gitHead });
@@ -128,7 +151,7 @@ export async function runPlanningPipeline(options) {
128
151
  const validated = validateContract(raw, contractPath);
129
152
  writeFileSync(contractPath, `${JSON.stringify(raw, null, 2)}\n`);
130
153
  await launch(contractPath, validated);
131
- const runDir = join(validated.cwd, ".runs", validated.id);
154
+ const runDir = runDirectory(validated.cwd, validated.id);
132
155
  const progress = await wait(runDir);
133
156
  const classification = classifyRunProgress(progress);
134
157
  if (classification !== "succeeded") {
@@ -144,7 +167,7 @@ export async function runPlanningPipeline(options) {
144
167
  let plan = validatePlanOutput(draft.output.plan);
145
168
  logStage("draft", { runId: draft.contract.id, nodeCount: plan.nodes.length });
146
169
 
147
- const workingPlanPath = join(plansDir, "plan.working.json");
170
+ const workingPlanPath = join(scratchDir, "plan.working.json");
148
171
  writeJsonAtomic(workingPlanPath, plan);
149
172
  const relativeWorkingPlanPath = relative(cwd, workingPlanPath);
150
173
 
@@ -167,7 +190,7 @@ export async function runPlanningPipeline(options) {
167
190
  ]);
168
191
  return { status: "contested", plansDir, planPath, findings, round };
169
192
  }
170
- const findingsPath = join(plansDir, `findings-round-${round}.json`);
193
+ const findingsPath = join(scratchDir, `findings-round-${round}.json`);
171
194
  writeJsonAtomic(findingsPath, findings);
172
195
  const revise = await runStage("revise", {
173
196
  specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, findingsPath: relative(cwd, findingsPath),
@@ -5,24 +5,53 @@
5
5
  * A planning stage's draft is authored against exactly this JSON instead of
6
6
  * the session reading the repository by hand.
7
7
  *
8
+ * When the caller passes the spec's parsed requirements, every `measure`
9
+ * command a requirement declares also runs here — read-only, before any node
10
+ * exists — and its output rides along as `requirementMeasurements`: the draft
11
+ * reasons from a fact the planner measured, not one it inferred from prose.
12
+ *
8
13
  * Sorting and the absence of any clock in the output itself (only inside an
9
14
  * injected measurer's own numbers) is what makes two calls at the same HEAD
10
15
  * byte-identical.
11
16
  */
17
+ import { spawnSync } from "node:child_process";
12
18
  import { existsSync, readFileSync } from "node:fs";
13
19
  import { join } from "node:path";
14
20
  import { timeVerificationCommands } from "../host/preflight.mjs";
21
+ import { NOTIFY_BIN_ENV } from "../notify/index.mjs";
15
22
  import { boundedGitSync, gitHead } from "../repo/worktree.mjs";
16
23
 
24
+ /** @typedef {import("./spec.mjs").SpecRequirement} SpecRequirement */
25
+ /** @typedef {{requirementId: string|null, command: string, output: string, exitCode: number|null, truncated: boolean}} RequirementMeasurement */
17
26
  /** @typedef {{argv: string[], measuredMs: number, eligible: boolean}} VerificationCandidate */
18
27
  /** @typedef {{path: string, covers: string|null}} TestFileEntry */
19
- /** @typedef {{formatVersion: number, gitHead: string|null, paths: string[], truncated: boolean, scripts: Record<string, string>, verificationCandidates: VerificationCandidate[], testFiles: TestFileEntry[]}} RepoFacts */
28
+ /** @typedef {{formatVersion: number, gitHead: string|null, paths: string[], truncated: boolean, scripts: Record<string, string>, verificationCandidates: VerificationCandidate[], testFiles: TestFileEntry[], requirementMeasurements: RequirementMeasurement[]}} RepoFacts */
20
29
  /** @typedef {{now?: () => number, run?: typeof import("node:child_process").spawnSync}} MeasureProbes */
21
30
 
22
31
  const FORMAT_VERSION = 1;
23
32
  const DEFAULT_MAX_PATHS = 2000;
24
33
  const ELIGIBLE_MS_CEILING = 600_000;
25
34
  const CANDIDATE_TIMEOUT_SEC = ELIGIBLE_MS_CEILING / 1_000;
35
+ const MEASURE_TIMEOUT_MS = 30_000;
36
+ const MEASURE_OUTPUT_CAP_BYTES = 4096;
37
+
38
+ /**
39
+ * The same named few `timeVerificationCommands` subtracts before spawning a
40
+ * measurement (SIDE_EFFECT_ENV_KEYS in src/host/preflight.mjs, which is
41
+ * module-private and could not be edited by the node that added this): a
42
+ * measure must not notify a human and must not be redirectable at a live,
43
+ * paid harness binary. A subtraction of a named few, not an allowlist — PATH,
44
+ * HOME and every ordinary variable still pass through unchanged.
45
+ */
46
+ const MEASURE_SIDE_EFFECT_ENV_KEYS = [
47
+ NOTIFY_BIN_ENV,
48
+ "FABERUN_CODEX_BIN",
49
+ "FABERUN_CLAUDE_BIN",
50
+ "FABERUN_AGY_BIN",
51
+ "FABERUN_DSH_BIN",
52
+ "FABERUN_ZCODE_BIN",
53
+ "FABERUN_EXEC_JSONL_BIN",
54
+ ];
26
55
 
27
56
  /**
28
57
  * Every path git tracks at HEAD, sorted. The bounded spawn is the same
@@ -124,9 +153,57 @@ function measureCandidates(cwd, commands, probes) {
124
153
  });
125
154
  }
126
155
 
156
+ /**
157
+ * Run every requirement's declared `measure` command against the repository
158
+ * as it stands right now and record what it reports. This is the planner's
159
+ * fact, not the node's proof: it has to be evaluable before any node exists,
160
+ * which is why it reuses the same SpecProof shape `proof` parses into.
161
+ *
162
+ * The command goes through the shell (`spawnSync` with `shell: true`), so a
163
+ * requirement's own pipe — `| wc -l`, `| grep -v …` — works exactly as a spec
164
+ * author would type it at a terminal. The bounding shape matches
165
+ * `timeVerificationCommands` (stripped env, injectable probes, a hard
166
+ * ceiling) but that function discards stdout by design, so it cannot be
167
+ * reused where the output is the point. A non-zero exit is recorded, never
168
+ * thrown: finding the pattern still present is exactly the fact a draft
169
+ * needs.
170
+ *
171
+ * @param {string} cwd
172
+ * @param {SpecRequirement[]} requirements
173
+ * @param {MeasureProbes} [probes]
174
+ * @returns {RequirementMeasurement[]}
175
+ */
176
+ export function measureRequirements(cwd, requirements, probes = {}) {
177
+ if (requirements.length === 0) return [];
178
+ const run = probes.run ?? spawnSync;
179
+ const env = { ...process.env };
180
+ for (const key of MEASURE_SIDE_EFFECT_ENV_KEYS) delete env[key];
181
+ /** @type {RequirementMeasurement[]} */
182
+ const measurements = [];
183
+ for (const requirement of requirements) {
184
+ // Only the `command` kind is wired: a `path` or `judgment` measure parses
185
+ // (parseProof already accepts both) but nothing runs it — left unmeasured
186
+ // rather than guessed at.
187
+ if (requirement.measure?.kind !== "command" || !requirement.measure.ref) continue;
188
+ const command = requirement.measure.ref;
189
+ const result = run(command, { shell: true, cwd, timeout: MEASURE_TIMEOUT_MS, encoding: "utf8", env });
190
+ const combined = `${result.stdout ?? ""}${result.stderr ?? ""}`;
191
+ const bytes = Buffer.from(combined, "utf8");
192
+ const truncated = bytes.length > MEASURE_OUTPUT_CAP_BYTES;
193
+ measurements.push({
194
+ requirementId: requirement.id,
195
+ command,
196
+ output: truncated ? bytes.subarray(0, MEASURE_OUTPUT_CAP_BYTES).toString("utf8") : combined,
197
+ exitCode: result.status,
198
+ truncated,
199
+ });
200
+ }
201
+ return measurements;
202
+ }
203
+
127
204
  /**
128
205
  * @param {string} cwd
129
- * @param {{measure?: MeasureProbes, maxPaths?: number}} [options]
206
+ * @param {{measure?: MeasureProbes, maxPaths?: number, requirements?: SpecRequirement[]}} [options]
130
207
  * @returns {RepoFacts}
131
208
  */
132
209
  export function collectRepoFacts(cwd, options = {}) {
@@ -144,5 +221,6 @@ export function collectRepoFacts(cwd, options = {}) {
144
221
  scripts,
145
222
  verificationCandidates: measureCandidates(cwd, commands, options.measure ?? {}),
146
223
  testFiles: testFileEntries(allPaths, pathSet),
224
+ requirementMeasurements: measureRequirements(cwd, options.requirements ?? [], options.measure ?? {}),
147
225
  };
148
226
  }
package/src/plan/spec.mjs CHANGED
@@ -9,7 +9,7 @@ import { git } from "../repo/worktree.mjs";
9
9
 
10
10
  /** @typedef {"command"|"path"|"judgment"} ProofKind */
11
11
  /** @typedef {{kind: ProofKind, ref?: string}} SpecProof */
12
- /** @typedef {{id: string|null, title: string, statement: string|null, proof: SpecProof|null, constraints: string|null, line: number}} SpecRequirement */
12
+ /** @typedef {{id: string|null, title: string, statement: string|null, proof: SpecProof|null, measure: SpecProof|null, constraints: string|null, line: number}} SpecRequirement */
13
13
  /** @typedef {Record<string, string>} SpecFrontMatter */
14
14
  /** @typedef {{heading: string, body: string, line: number}} SpecSection */
15
15
  /** @typedef {{frontMatter: SpecFrontMatter|null, sections: Map<string, SpecSection>, requirements: SpecRequirement[]}} ParsedSpec */
@@ -160,6 +160,7 @@ function extractRequirements(section) {
160
160
  title: idMatch ? idMatch[2].trim() : heading,
161
161
  statement: bullets.get("statement") ?? null,
162
162
  proof: parseProof(bullets.get("proof")),
163
+ measure: parseProof(bullets.get("measure")),
163
164
  constraints: bullets.get("constraints") ?? null,
164
165
  line: blockLine,
165
166
  });
@@ -15,10 +15,16 @@ import { execFileSync } from "node:child_process";
15
15
  import { join, resolve } from "node:path";
16
16
  import { lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
17
  import { tmpdir } from "node:os";
18
+ import { RUNS_DIR_NAME } from "../run/paths.mjs";
18
19
 
19
20
  /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
20
21
 
21
- const ALWAYS_UNOBSERVABLE_ROOTS = new Set([".runs", ".git"]);
22
+ // `RUNS_DIR_NAME` (the runner's own scratch tree) is never part of the
23
+ // closed-scope snapshot, so a packet that declares a write under it warns
24
+ // instead of refusing: the author may mean it, but no diff will ever exist to
25
+ // prove the write happened. `.git` is unobservable for the same reason as any
26
+ // path git ignores: there is nothing for `git status` to show.
27
+ const ALWAYS_UNOBSERVABLE_ROOTS = new Set([RUNS_DIR_NAME, ".git"]);
22
28
  /**
23
29
  * @param {ValidatedNode} node
24
30
  * @param {number} index
@@ -12,10 +12,10 @@ import {
12
12
  gitHead,
13
13
  removeWorktree,
14
14
  updateRefConditional,
15
- candidateWorktreePath,
16
15
  runRefName,
17
16
  worktreeCheckedOutAt,
18
17
  } from "./worktree.mjs";
18
+ import { candidateWorktreePath } from "../run/paths.mjs";
19
19
 
20
20
  const JOURNAL = "integration.jsonl";
21
21
  const TERMINAL = new Set(["accepted", "failed", "conflict"]);
@@ -49,6 +49,7 @@
49
49
  import { errorCode } from "../util.mjs";
50
50
  import { readdirSync, readFileSync } from "node:fs";
51
51
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
52
+ import { RUNS_DIR_NAME } from "../run/paths.mjs";
52
53
 
53
54
  /** @typedef {import("../contract/index.mjs").TaskPacket} TaskPacket */
54
55
  /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
@@ -62,7 +63,7 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
62
63
  */
63
64
  const SKIPPED_DIRECTORIES = new Set([
64
65
  ".git",
65
- ".runs",
66
+ RUNS_DIR_NAME,
66
67
  "node_modules",
67
68
  ".claude",
68
69
  ".codex",
@@ -23,6 +23,7 @@ import { dirname, join } from "node:path";
23
23
  import { discoverCampaigns } from "../campaign/index.mjs";
24
24
  import { runProgress } from "../engine/supervise.mjs";
25
25
  import { readInbox } from "../notify/index.mjs";
26
+ import { repositoryForRunsDir } from "../run/paths.mjs";
26
27
  import { SIGNAL_END, SIGNAL_START } from "./signal-block.mjs";
27
28
  import { HANDOFF_FILE } from "../campaign/layout.mjs";
28
29
 
@@ -158,9 +159,19 @@ function nodeText(node) {
158
159
  return `\`${node.id}:${node.status ?? "unknown"}${node.errorCode ? ` ${node.errorCode}` : ""}\``;
159
160
  }
160
161
 
161
- /** @param {string} value @returns {string} */
162
+ /**
163
+ * The managed block is a list of single lines, so a multi-line summary (the
164
+ * inbox now carries `renderRunProgress`'s full, multi-line message for a
165
+ * `node.terminal`/`run.terminal`/`attention` event) is collapsed to its first
166
+ * line before the character bound applies. The block stays a pointer; the
167
+ * whole message lives in the inbox, one read away.
168
+ *
169
+ * @param {string} value
170
+ * @returns {string}
171
+ */
162
172
  function boundedAttention(value) {
163
- return value.length <= ATTENTION_CHARS ? value : `${value.slice(0, ATTENTION_CHARS - 1)}…`;
173
+ const line = value.split("\n")[0];
174
+ return line.length <= ATTENTION_CHARS ? line : `${line.slice(0, ATTENTION_CHARS - 1)}…`;
164
175
  }
165
176
 
166
177
  /**
@@ -184,16 +195,22 @@ function boundLines(lines) {
184
195
  }
185
196
 
186
197
  /**
187
- * Rewrites the managed signal block at the bottom of <repo>/AGENTS.md from
188
- * the current .runs state. Leaves the file untouched when there is no
189
- * AGENTS.md, no active work, or nothing changed. Returns true when the file
190
- * was written.
198
+ * Rewrites the managed signal block at the bottom of the repository's
199
+ * AGENTS.md from the current runs state. Leaves the file untouched when there
200
+ * is no AGENTS.md, no active work, or nothing changed. Returns true when the
201
+ * file was written.
202
+ *
203
+ * The repository is named by the project registry, not derived as a sibling
204
+ * of `runsDir`: since the runs directory moved under the home, a directory
205
+ * climb would land inside `<home>/projects/<id>` and the block would silently
206
+ * stop being maintained. A legacy `<repo>/.runs` has no registry entry, so it
207
+ * keeps the parent-of answer, which is exact there.
191
208
  *
192
209
  * @param {string} runsDir
193
210
  * @returns {boolean}
194
211
  */
195
212
  export function syncAgentSignal(runsDir) {
196
- const agentsPath = join(dirname(runsDir), "AGENTS.md");
213
+ const agentsPath = join(repositoryForRunsDir(runsDir) ?? dirname(runsDir), "AGENTS.md");
197
214
  if (!existsSync(agentsPath)) return false;
198
215
  const current = readFileSync(agentsPath, "utf8");
199
216
  const block = renderAgentSignalBlock(runsDir);
@@ -122,6 +122,13 @@ function sourcePathspec(options = {}) {
122
122
  return [
123
123
  ".",
124
124
  ":(exclude).runs",
125
+ // The planning pipeline's own scratch relay (src/plan/pipeline.mjs's
126
+ // PLAN_SCRATCH_DIR_NAME) lands inside the target repo the same way
127
+ // .runs used to: untracked, and not every target repository's own
128
+ // .gitignore names it. Excluded here for the same reason .runs is —
129
+ // a dirty-tree refusal must not fire on state the pipeline itself
130
+ // wrote, only on an operator's own uncommitted work.
131
+ ":(exclude).faberun-plan",
125
132
  ":(exclude)AGENTS.md",
126
133
  ...(options.ignorePaths ?? []).map((path) => `:(exclude)${path}`),
127
134
  ...(options.ignoreRoots ?? []).map((path) => `:(exclude)${path}`),