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.
- package/README.md +6 -0
- package/integrations/claude-code/statusline.sh +31 -2
- package/package.json +2 -2
- package/skills/faberun/references/spec-format.md +9 -0
- package/src/campaign/chain.mjs +2 -1
- package/src/campaign/index.mjs +102 -1
- package/src/campaign/metrics.mjs +2 -2
- package/src/cli/brand.mjs +2 -0
- package/src/cli/campaign-contract.mjs +55 -0
- package/src/cli/campaign.mjs +48 -13
- package/src/cli/init.mjs +8 -2
- package/src/cli/launch.mjs +2 -1
- package/src/cli/plan.mjs +3 -2
- package/src/cli/project.mjs +150 -0
- package/src/cli/skills.mjs +2 -3
- package/src/cli/update.mjs +7 -10
- package/src/cli.mjs +30 -4
- package/src/contract/index.mjs +44 -0
- package/src/contract/task-packet.mjs +12 -3
- package/src/engine/cancel.mjs +13 -0
- package/src/engine/dispatch.mjs +20 -2
- package/src/engine/lifecycle.mjs +38 -61
- package/src/engine/live-preflight.mjs +2 -1
- package/src/engine/notify-queue.mjs +9 -1
- package/src/engine/pricing-seed.json +543 -0
- package/src/engine/pricing-seed.mjs +54 -0
- package/src/engine/process.mjs +8 -1
- package/src/engine/result-file.mjs +2 -1
- package/src/engine/resume.mjs +6 -2
- package/src/engine/run-identity.mjs +9 -2
- package/src/engine/scheduler.mjs +40 -6
- package/src/engine/settle-judge.mjs +141 -0
- package/src/engine/settle.mjs +1 -1
- package/src/harnesses/protocol.mjs +46 -10
- package/src/harnesses/replay/bin.mjs +10 -1
- package/src/host/home.mjs +10 -3
- package/src/host/platform.mjs +111 -0
- package/src/host/preflight.mjs +43 -21
- package/src/host/projects.mjs +155 -0
- package/src/notify/index.mjs +88 -10
- package/src/plan/pipeline.mjs +32 -9
- package/src/plan/repo-facts.mjs +80 -2
- package/src/plan/spec.mjs +2 -1
- package/src/repo/declared-paths.mjs +7 -1
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +2 -1
- package/src/repo/signal.mjs +24 -7
- package/src/repo/source-identity.mjs +7 -0
- package/src/repo/workspace.mjs +12 -2
- package/src/repo/worktree.mjs +89 -22
- package/src/report/next.mjs +66 -6
- package/src/report/progress.mjs +724 -0
- package/src/report/render.mjs +3 -3
- package/src/run/migrate.mjs +278 -0
- package/src/run/paths.mjs +189 -0
- package/src/seat/index.mjs +2 -1
- package/src/web/app.css +170 -0
- package/src/web/app.mjs +690 -0
- package/src/web/index.html +38 -283
- 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
|
+
}
|
package/src/notify/index.mjs
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
5
|
-
* event as JSON on stdin, and
|
|
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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
|
|
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
|
package/src/plan/pipeline.mjs
CHANGED
|
@@ -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 =
|
|
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,
|
|
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
|
-
|
|
102
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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),
|
package/src/plan/repo-facts.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/repo/integrate.mjs
CHANGED
|
@@ -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
|
-
|
|
66
|
+
RUNS_DIR_NAME,
|
|
66
67
|
"node_modules",
|
|
67
68
|
".claude",
|
|
68
69
|
".codex",
|
package/src/repo/signal.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
188
|
-
* the current
|
|
189
|
-
* AGENTS.md, no active work, or nothing changed. Returns true when the
|
|
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}`),
|