faberun 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +12 -2
- package/src/cli/manual.mjs +24 -8
- 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 +67 -7
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +109 -0
- package/src/contract/index.mjs +7 -4
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +5 -1
- 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 +10 -0
- 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 +114 -18
- package/src/engine/prompts.mjs +41 -3
- 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/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/proof-check.mjs +15 -0
- package/src/plan/repo-facts.mjs +95 -11
- package/src/plan/rounds.mjs +27 -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/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,7 +14,7 @@
|
|
|
14
14
|
import { SETTLED } from "../engine/prompts.mjs";
|
|
15
15
|
import { bootstrapAckPath, bootstrapAttemptPath, bootstrapPath, cleanupBootstrapAttempts, readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
16
16
|
import { bootstrapFailureMatchesChild, bootstrapMatchesChild, processStartToken, readLock, sameProcessStartToken, validBootstrapNonce } from "../run/lock.mjs";
|
|
17
|
-
import { cleanupBootstrapNonce } from "../engine/detach.mjs";
|
|
17
|
+
import { cleanupBootstrapNonce, discardNodeLessRunDirectory } from "../engine/detach.mjs";
|
|
18
18
|
import { delay, errorCode } from "../util.mjs";
|
|
19
19
|
import { existsSync, readFileSync } from "node:fs";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
@@ -112,6 +112,7 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bo
|
|
|
112
112
|
const childIdentity = bootstrapMatchesChild(bootstrap, pid, nonce, expectedProcessStartToken);
|
|
113
113
|
if (bootstrap.status === "failed" && bootstrapFailureMatchesChild(bootstrap, pid, nonce, expectedProcessStartToken)) {
|
|
114
114
|
cleanupBootstrapAttempts(runDir);
|
|
115
|
+
discardNodeLessRunDirectory(runDir);
|
|
115
116
|
throw new Error(`detached bootstrap failed: ${bootstrap.error}`);
|
|
116
117
|
}
|
|
117
118
|
const lock = readLock(runDir);
|
|
@@ -139,6 +140,7 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bo
|
|
|
139
140
|
const failure = /** @type {BootstrapRecord} */ (readJson(bootstrapAttemptPath(runDir, nonce)));
|
|
140
141
|
if (failure.status === "failed" && bootstrapFailureMatchesChild(failure, pid, nonce, expectedProcessStartToken)) {
|
|
141
142
|
cleanupBootstrapAttempts(runDir);
|
|
143
|
+
discardNodeLessRunDirectory(runDir);
|
|
142
144
|
throw new Error(`detached bootstrap failed: ${failure.error}`);
|
|
143
145
|
}
|
|
144
146
|
} catch (error) {
|
|
@@ -150,6 +152,7 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bo
|
|
|
150
152
|
}
|
|
151
153
|
cleanupBootstrapNonce(runDir, nonce);
|
|
152
154
|
cleanupBootstrapAttempts(runDir);
|
|
155
|
+
discardNodeLessRunDirectory(runDir);
|
|
153
156
|
throw new Error(`detached bootstrap failed before readiness for pid ${pid}`);
|
|
154
157
|
}
|
|
155
158
|
await delay(50);
|
|
@@ -232,7 +235,14 @@ export function writeBootstrapFailure(command, target, error) {
|
|
|
232
235
|
if (!runDir || !existsSync(runDir)) return;
|
|
233
236
|
const nonce = validBootstrapNonce(process.env.FABERUN_BOOTSTRAP_NONCE) ? process.env.FABERUN_BOOTSTRAP_NONCE : null;
|
|
234
237
|
const failure = { status: "failed", pid: process.pid, processStartToken: processStartToken(process.pid), runDir, nonce, at: new Date().toISOString(), error: error.message };
|
|
235
|
-
|
|
238
|
+
// A `run` that names an existing run directory is refused before it writes
|
|
239
|
+
// anything, and its failure must not be stamped into that run's bootstrap
|
|
240
|
+
// record. The detached child is the exception: it wrote contract.json and
|
|
241
|
+
// then died before readiness, and the launcher has no other channel to
|
|
242
|
+
// learn the controller's error, so it must record that error before it
|
|
243
|
+
// exits.
|
|
244
|
+
const detachedChild = nonce !== null && !process.argv.includes("--detach");
|
|
245
|
+
if (command === "run" && !detachedChild && existsSync(join(runDir, "contract.json"))) return;
|
|
236
246
|
/** @type {BootstrapRecord|null} */
|
|
237
247
|
let current = null;
|
|
238
248
|
try {
|
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/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") {
|