faberun 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/package.json +1 -1
- package/skills/faberun/SKILL.md +3 -3
- package/skills/faberun/references/contract.md +9 -9
- package/skills/faberun/references/local-env.md +11 -0
- package/skills/faberun/references/operations.md +26 -33
- package/src/campaign/brief.mjs +41 -9
- package/src/campaign/campaign-brief.mjs +18 -3
- package/src/campaign/index.mjs +22 -9
- package/src/campaign/ledger.mjs +72 -4
- package/src/campaign/metrics.mjs +29 -2
- package/src/campaign/record.mjs +34 -0
- package/src/cli/campaign.mjs +35 -12
- package/src/cli/contract.mjs +11 -1
- package/src/cli/launch.mjs +31 -4
- package/src/cli/manual.mjs +24 -8
- package/src/cli/plan.mjs +56 -5
- package/src/cli/setup.mjs +5 -0
- package/src/cli/skills.mjs +18 -7
- package/src/cli/uninstall.mjs +542 -0
- package/src/cli/walkthrough.mjs +462 -0
- package/src/cli.mjs +72 -8
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +164 -0
- package/src/contract/index.mjs +8 -5
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +20 -6
- package/src/contract/scope-findings.mjs +120 -0
- package/src/contract/snapshot.mjs +19 -5
- package/src/contract/task-packet.mjs +49 -1
- package/src/contract/worker-result.mjs +37 -11
- package/src/engine/assignment.mjs +45 -0
- package/src/engine/detach.mjs +18 -0
- package/src/engine/gate.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -9
- package/src/engine/live-preflight.mjs +117 -18
- package/src/engine/prompts.mjs +58 -4
- package/src/engine/resume.mjs +119 -12
- package/src/engine/retry.mjs +11 -11
- package/src/engine/run-command.mjs +80 -4
- package/src/engine/run-identity.mjs +27 -14
- package/src/engine/runtime-discovery.mjs +15 -9
- package/src/engine/scheduler.mjs +8 -1
- package/src/engine/worker-env.mjs +179 -0
- package/src/harnesses/agy/index.mjs +11 -0
- package/src/harnesses/catalogue.mjs +29 -0
- package/src/harnesses/claude/index.mjs +98 -1
- package/src/harnesses/codex/index.mjs +15 -0
- package/src/harnesses/dsh/index.mjs +14 -0
- package/src/harnesses/exec-jsonl/index.mjs +12 -0
- package/src/harnesses/fx/index.mjs +13 -0
- package/src/harnesses/index.mjs +21 -10
- package/src/harnesses/replay/index.mjs +12 -0
- package/src/harnesses/zcode/index.mjs +19 -0
- package/src/host/config.mjs +8 -2
- package/src/host/environment-report.mjs +85 -0
- package/src/host/home.mjs +27 -0
- package/src/host/install-registry.mjs +511 -0
- package/src/host/preflight.mjs +32 -9
- package/src/host/uninstall-roots.mjs +196 -0
- package/src/plan/freeze.mjs +2 -0
- package/src/plan/pipeline.mjs +56 -4
- package/src/plan/progress.mjs +101 -0
- package/src/plan/proof-check.mjs +27 -0
- package/src/plan/proof-scope.mjs +1 -1
- package/src/plan/repo-facts.mjs +129 -14
- package/src/plan/rounds.mjs +27 -0
- package/src/plan/spec.mjs +82 -6
- package/src/plan/template.mjs +9 -0
- package/src/repo/declared-paths.mjs +74 -6
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +59 -0
- package/src/repo/signal-block.mjs +26 -0
- package/src/repo/signal.mjs +9 -24
- package/src/repo/workspace.mjs +189 -14
- package/src/repo/worktree-prune.mjs +13 -5
- package/src/repo/worktree.mjs +54 -0
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/message.mjs +37 -6
- package/src/report/progress.mjs +13 -2
- package/src/run/paths.mjs +14 -1
- package/src/run/usage.mjs +66 -1
- package/src/util.mjs +18 -2
package/src/campaign/metrics.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/campaign/record.mjs
CHANGED
|
@@ -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) {
|
package/src/cli/campaign.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
277
|
+
sessionId: values.sessionId ?? lastAttachedSessionId(path),
|
|
277
278
|
text: textValue(values.text, "--text"),
|
|
278
279
|
};
|
|
279
|
-
if (kind === "decision") entry.decisionId =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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`);
|
package/src/cli/contract.mjs
CHANGED
|
@@ -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
|
|
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))
|
package/src/cli/launch.mjs
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
import { SETTLED } from "../engine/prompts.mjs";
|
|
15
15
|
import { bootstrapAckPath, bootstrapAttemptPath, bootstrapPath, cleanupBootstrapAttempts, readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
16
16
|
import { bootstrapFailureMatchesChild, bootstrapMatchesChild, processStartToken, readLock, sameProcessStartToken, validBootstrapNonce } from "../run/lock.mjs";
|
|
17
|
-
import { cleanupBootstrapNonce } from "../engine/detach.mjs";
|
|
17
|
+
import { cleanupBootstrapNonce, discardNodeLessRunDirectory } from "../engine/detach.mjs";
|
|
18
18
|
import { delay, errorCode } from "../util.mjs";
|
|
19
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { join, resolve } from "node:path";
|
|
22
22
|
import { randomUUID } from "node:crypto";
|
|
@@ -112,6 +112,7 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bo
|
|
|
112
112
|
const childIdentity = bootstrapMatchesChild(bootstrap, pid, nonce, expectedProcessStartToken);
|
|
113
113
|
if (bootstrap.status === "failed" && bootstrapFailureMatchesChild(bootstrap, pid, nonce, expectedProcessStartToken)) {
|
|
114
114
|
cleanupBootstrapAttempts(runDir);
|
|
115
|
+
discardNodeLessRunDirectory(runDir);
|
|
115
116
|
throw new Error(`detached bootstrap failed: ${bootstrap.error}`);
|
|
116
117
|
}
|
|
117
118
|
const lock = readLock(runDir);
|
|
@@ -139,6 +140,7 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bo
|
|
|
139
140
|
const failure = /** @type {BootstrapRecord} */ (readJson(bootstrapAttemptPath(runDir, nonce)));
|
|
140
141
|
if (failure.status === "failed" && bootstrapFailureMatchesChild(failure, pid, nonce, expectedProcessStartToken)) {
|
|
141
142
|
cleanupBootstrapAttempts(runDir);
|
|
143
|
+
discardNodeLessRunDirectory(runDir);
|
|
142
144
|
throw new Error(`detached bootstrap failed: ${failure.error}`);
|
|
143
145
|
}
|
|
144
146
|
} catch (error) {
|
|
@@ -150,6 +152,7 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bo
|
|
|
150
152
|
}
|
|
151
153
|
cleanupBootstrapNonce(runDir, nonce);
|
|
152
154
|
cleanupBootstrapAttempts(runDir);
|
|
155
|
+
discardNodeLessRunDirectory(runDir);
|
|
153
156
|
throw new Error(`detached bootstrap failed before readiness for pid ${pid}`);
|
|
154
157
|
}
|
|
155
158
|
await delay(50);
|
|
@@ -229,10 +232,34 @@ export function bootstrapRunDir(command, target) {
|
|
|
229
232
|
*/
|
|
230
233
|
export function writeBootstrapFailure(command, target, error) {
|
|
231
234
|
const runDir = bootstrapRunDir(command, target);
|
|
232
|
-
if (!runDir
|
|
235
|
+
if (!runDir) return;
|
|
233
236
|
const nonce = validBootstrapNonce(process.env.FABERUN_BOOTSTRAP_NONCE) ? process.env.FABERUN_BOOTSTRAP_NONCE : null;
|
|
234
237
|
const failure = { status: "failed", pid: process.pid, processStartToken: processStartToken(process.pid), runDir, nonce, at: new Date().toISOString(), error: error.message };
|
|
235
|
-
|
|
238
|
+
const detachedChild = nonce !== null && !process.argv.includes("--detach");
|
|
239
|
+
if (!existsSync(runDir)) {
|
|
240
|
+
// A refusal that happens before the run directory exists — a dirty tree, a
|
|
241
|
+
// base ref that does not resolve — is the one the launcher cannot
|
|
242
|
+
// otherwise learn: the child's stdio is discarded, so all it could say was
|
|
243
|
+
// "failed before readiness for pid N". Measured 2026-09-27 (AP10): a whole
|
|
244
|
+
// `plan` stage died that way over two uncommitted paths. Only a detached
|
|
245
|
+
// child creates the directory, and only to carry this record;
|
|
246
|
+
// `waitForBootstrap` removes a node-less one as it reads it.
|
|
247
|
+
if (!detachedChild) return;
|
|
248
|
+
try {
|
|
249
|
+
mkdirSync(runDir, { recursive: true });
|
|
250
|
+
} catch {
|
|
251
|
+
// The refusal being reported matters more than the directory that would
|
|
252
|
+
// have carried it; without one the launcher falls back to its own message.
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// A `run` that names an existing run directory is refused before it writes
|
|
257
|
+
// anything, and its failure must not be stamped into that run's bootstrap
|
|
258
|
+
// record. The detached child is the exception: it wrote contract.json and
|
|
259
|
+
// then died before readiness, and the launcher has no other channel to
|
|
260
|
+
// learn the controller's error, so it must record that error before it
|
|
261
|
+
// exits.
|
|
262
|
+
if (command === "run" && !detachedChild && existsSync(join(runDir, "contract.json"))) return;
|
|
236
263
|
/** @type {BootstrapRecord|null} */
|
|
237
264
|
let current = null;
|
|
238
265
|
try {
|
package/src/cli/manual.mjs
CHANGED
|
@@ -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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
348
|
-
|
|
354
|
+
let failed = false;
|
|
355
|
+
if (next !== current) {
|
|
356
|
+
process.stderr.write(`docs/COMMANDS.md is out of date; run \`npm run docs\`.\n${diffSummary(current, next)}\n`);
|
|
357
|
+
failed = true;
|
|
358
|
+
}
|
|
359
|
+
const walkthrough = checkGettingStarted();
|
|
360
|
+
if (!walkthrough.ok) {
|
|
361
|
+
process.stderr.write(renderWalkthroughFailure(walkthrough));
|
|
362
|
+
failed = true;
|
|
363
|
+
}
|
|
364
|
+
if (failed) process.exitCode = 1;
|
|
349
365
|
}
|
|
350
366
|
|
|
351
367
|
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) main(process.argv.slice(2));
|
package/src/cli/plan.mjs
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* sequencing and every decision the pipeline makes.
|
|
6
6
|
*/
|
|
7
7
|
import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
|
|
8
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
10
10
|
import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
|
|
11
11
|
import { classifyRunProgress } from "../campaign/chain.mjs";
|
|
12
12
|
import { runProgress } from "../engine/supervise.mjs";
|
|
@@ -17,6 +17,7 @@ import { colorLevel, statusToken } from "./brand.mjs";
|
|
|
17
17
|
import { delay } from "../util.mjs";
|
|
18
18
|
import { runPlanningPipeline } from "../plan/pipeline.mjs";
|
|
19
19
|
import { parseAnswerFlags, resolvePlanningPipeline } from "../plan/resolve.mjs";
|
|
20
|
+
import { abandonedPlanProgress } from "../plan/progress.mjs";
|
|
20
21
|
import { resolveReviewerList } from "../plan/reviewer.mjs";
|
|
21
22
|
import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
22
23
|
import { readCampaign } from "../campaign/record.mjs";
|
|
@@ -112,6 +113,46 @@ export function loadRuntimesCatalogue(path) {
|
|
|
112
113
|
return runtimes;
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The campaign's own checked-in planning inputs live beside the spec it
|
|
118
|
+
* already keeps in the repository: `docs/campaigns/<id>/plan-inputs/`. A file
|
|
119
|
+
* that exists there is used as if it had been passed on the command line,
|
|
120
|
+
* because that is what it is for; an absent one changes nothing.
|
|
121
|
+
*
|
|
122
|
+
* Measured 2026-09-27 (AP19): a campaign carried
|
|
123
|
+
* `plan-inputs/runtimes.json` with a raised output ceiling, `plan` fell back
|
|
124
|
+
* to the 8192 default without a word, and the operator found out only after
|
|
125
|
+
* burning rounds on a truncated draft.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} cwd @param {string} campaignId @param {string} file
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function campaignInputPath(cwd, campaignId, file) {
|
|
131
|
+
return join(cwd, "docs", "campaigns", campaignId, "plan-inputs", file);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The catalogue a `plan` reads runtimes from, and the file it came from when
|
|
136
|
+
* that file is the campaign's own. `--runtimes` wins over the checked-in one:
|
|
137
|
+
* an operator passing a path is overriding, not asking.
|
|
138
|
+
*
|
|
139
|
+
* `source` is returned rather than printed here so a caller decides the
|
|
140
|
+
* wording; `planCli` says it out loud because silence is the whole defect. The
|
|
141
|
+
* path is resolved from the launch cwd, never from the plan's own target, so
|
|
142
|
+
* the same command from a subdirectory reads the same file the campaign does.
|
|
143
|
+
*
|
|
144
|
+
* @param {{cwd: string, campaignId: string, runtimes?: unknown}} options
|
|
145
|
+
* @returns {{runtimes: Record<string, import("../contract/index.mjs").ValidatedRuntime>, source: string|null}}
|
|
146
|
+
*/
|
|
147
|
+
export function resolvePlanRuntimes({ cwd, campaignId, runtimes }) {
|
|
148
|
+
if (typeof runtimes === "string" && runtimes) return { runtimes: loadRuntimesCatalogue(runtimes), source: null };
|
|
149
|
+
const campaignRuntimes = campaignInputPath(cwd, campaignId, "runtimes.json");
|
|
150
|
+
if (!existsSync(campaignRuntimes)) {
|
|
151
|
+
return { runtimes: /** @type {Record<string, import("../contract/index.mjs").ValidatedRuntime>} */ (DISCOVERY_RUNTIME_DEFINITIONS), source: null };
|
|
152
|
+
}
|
|
153
|
+
return { runtimes: loadRuntimesCatalogue(campaignRuntimes), source: campaignRuntimes };
|
|
154
|
+
}
|
|
155
|
+
|
|
115
156
|
/**
|
|
116
157
|
* A `--verification <path>` catalogue: a JSON object carrying either or both
|
|
117
158
|
* of the contract's own suite keys, `sharedVerification` and
|
|
@@ -160,9 +201,10 @@ export async function planCli(target, values) {
|
|
|
160
201
|
// R19: the planner's own reviewer list, never `runtimeDefaults.judge` --
|
|
161
202
|
// `--reviewers` wins over the machine's own `config.reviewers` default.
|
|
162
203
|
const reviewers = resolveReviewerList({ reviewers: parseReviewerList(values.reviewers) }, readUserConfig(process.env));
|
|
163
|
-
const runtimes =
|
|
164
|
-
|
|
165
|
-
|
|
204
|
+
const { runtimes, source: runtimesSource } = resolvePlanRuntimes({ cwd: process.cwd(), campaignId, runtimes: values.runtimes });
|
|
205
|
+
// Said out loud because silence is the whole defect: the default and the
|
|
206
|
+
// campaign's own catalogue look identical from the outside.
|
|
207
|
+
if (runtimesSource) process.stdout.write(`[plan] runtimes · ${relative(process.cwd(), runtimesSource)}\n`);
|
|
166
208
|
const verification = typeof values.verification === "string" && values.verification
|
|
167
209
|
? loadVerificationSuites(values.verification)
|
|
168
210
|
: {};
|
|
@@ -171,6 +213,15 @@ export async function planCli(target, values) {
|
|
|
171
213
|
? undefined
|
|
172
214
|
: validateJudgeIndependence(values["judge-independence"], "--judge-independence");
|
|
173
215
|
|
|
216
|
+
// A record with no live process behind it is the only trace a planning run
|
|
217
|
+
// that died without a word leaves. Measured 2026-09-26 (AP4): `--detach` pid
|
|
218
|
+
// 64270 vanished during repo-facts and the phase directory stayed empty, so
|
|
219
|
+
// the next launch is the one place the operator can still be told.
|
|
220
|
+
const abandoned = abandonedPlanProgress(process.cwd(), campaignId, phase);
|
|
221
|
+
if (abandoned) {
|
|
222
|
+
process.stderr.write(`[plan] a previous planning process (pid ${String(abandoned.pid ?? "?")}) stopped during ${String(abandoned.stage ?? "?")} at ${String(abandoned.at ?? "?")} and never reached the end\n`);
|
|
223
|
+
}
|
|
224
|
+
|
|
174
225
|
if (values.detach === true) {
|
|
175
226
|
const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
|
|
176
227
|
if (approveBelow !== undefined) argv.push("--approve-below", approveBelow);
|
package/src/cli/setup.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { boundedGitSync } from "../repo/worktree.mjs";
|
|
|
31
31
|
import { colorLevel, renderBanner, statusToken } from "./brand.mjs";
|
|
32
32
|
import { packageVersion } from "../host/package.mjs";
|
|
33
33
|
import { configPath, faberunHome } from "../host/home.mjs";
|
|
34
|
+
import { recordConfigInstall } from "../host/install-registry.mjs";
|
|
34
35
|
import { readUserConfig, writeUserConfig } from "../host/config.mjs";
|
|
35
36
|
import { discoverSkillTargets, registerSkills } from "./skills.mjs";
|
|
36
37
|
|
|
@@ -181,6 +182,10 @@ export async function setupCommand(options = {}) {
|
|
|
181
182
|
updatedAt: new Date().toISOString(),
|
|
182
183
|
};
|
|
183
184
|
writeUserConfig(env, config);
|
|
185
|
+
// The config lives under FABERUN_HOME, which removal deletes wholesale, but
|
|
186
|
+
// it is still an out-of-repository write; recording it keeps the ledger a
|
|
187
|
+
// complete account of what setup left behind.
|
|
188
|
+
recordConfigInstall(env, configPath(faberunHome(env)));
|
|
184
189
|
if (!json) stdout(`${statusToken("ok", level)} config · ${configPath(faberunHome(env))}\n`);
|
|
185
190
|
|
|
186
191
|
// The offer comes after the config is durable, so a machine that answers
|
package/src/cli/skills.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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) =>
|
|
78
|
-
codex: { binary: "codex", optional: false, dir: (home) =>
|
|
79
|
-
zcode: { binary: "zcode", optional: false, dir: (home) =>
|
|
80
|
-
agy: { binary: "agy", optional: false, dir: (home) =>
|
|
81
|
-
dsh: { binary: "dsh", optional: false, dir:
|
|
82
|
-
agents: { binary: null, optional: true, dir: (home) =>
|
|
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") {
|