faberun 0.7.0 → 0.9.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/package.json +1 -1
- package/skills/faberun/SKILL.md +1 -0
- package/skills/faberun/references/spec-format.md +87 -0
- package/src/campaign/index.mjs +46 -3
- package/src/campaign/journal.mjs +48 -0
- package/src/cli/brand.mjs +3 -0
- package/src/cli/campaign.mjs +16 -2
- package/src/cli/manual.mjs +3 -1
- package/src/cli/plan.mjs +142 -0
- package/src/cli/spec.mjs +119 -0
- package/src/cli.mjs +17 -0
- package/src/contract/task-packet.mjs +1 -1
- package/src/contract/worker-result.mjs +40 -8
- package/src/engine/lifecycle.mjs +11 -0
- package/src/engine/result-file.mjs +18 -4
- package/src/harnesses/protocol.mjs +91 -11
- package/src/plan/freeze.mjs +101 -0
- package/src/plan/pipeline.mjs +371 -0
- package/src/plan/repo-facts.mjs +148 -0
- package/src/plan/routing.mjs +200 -0
- package/src/plan/sizing.mjs +0 -0
- package/src/plan/spec.mjs +320 -0
- package/src/plan/template.mjs +278 -0
- package/src/seat/allowance.mjs +177 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/skills/faberun/SKILL.md
CHANGED
|
@@ -10,6 +10,7 @@ Read [rules](references/rules.md) first.
|
|
|
10
10
|
|
|
11
11
|
| Action | Read |
|
|
12
12
|
| --- | --- |
|
|
13
|
+
| Write or validate a spec | [spec-format](references/spec-format.md) |
|
|
13
14
|
| Author a contract (fallback) | [contract](references/contract.md), [engineering](references/engineering.md) |
|
|
14
15
|
| Launch and resume | [workflow](references/workflow.md), [operations](references/operations.md) |
|
|
15
16
|
| Dispatch a node | [handoffs](references/handoffs.md) |
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Spec format reference
|
|
2
|
+
|
|
3
|
+
Format version `1`. A spec is the free-form input the operator hands the
|
|
4
|
+
planner; this is the structured shape it validates against
|
|
5
|
+
(`faberun spec validate`, no model invoked). A document without the front
|
|
6
|
+
matter below is classified `legacy` and accepted, not rejected — the
|
|
7
|
+
validator says so explicitly, so old campaign records keep working.
|
|
8
|
+
|
|
9
|
+
## Front matter
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
---
|
|
13
|
+
id: kebab-case-campaign-id
|
|
14
|
+
title: "Human-readable title"
|
|
15
|
+
version: 1.1.0
|
|
16
|
+
status: draft
|
|
17
|
+
date: 2026-09-17
|
|
18
|
+
owner: Author Name
|
|
19
|
+
target: org/repo
|
|
20
|
+
baseline: <git sha the spec was measured against>
|
|
21
|
+
---
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`id`, `title`, `version`, `status`, `date`, `owner`, `target`, `baseline` are
|
|
25
|
+
required. `derived_from` and `followed_by` are optional cross-references to
|
|
26
|
+
other spec ids (a prior spec this one revises, or the campaign meant to
|
|
27
|
+
follow it).
|
|
28
|
+
|
|
29
|
+
## Sections
|
|
30
|
+
|
|
31
|
+
Mandatory, in order: **Intent**, **Requirements**, **Non-goals**. The
|
|
32
|
+
reference proposal
|
|
33
|
+
(`docs/campaigns/spec-format-and-planning-stages/spec/PROPOSAL.md`) writes
|
|
34
|
+
these as `Intenção`, `Requisitos`, `Não-objetivos` — the section role is what
|
|
35
|
+
matters, not the language of the heading text.
|
|
36
|
+
|
|
37
|
+
- **Intent** — prose: why this work, what problem, what it unblocks.
|
|
38
|
+
- **Requirements** — one `### R<n>. <title>` block per requirement (see
|
|
39
|
+
below).
|
|
40
|
+
- **Non-goals** — a bullet list of what this spec explicitly excludes, so a
|
|
41
|
+
planner never infers scope from silence.
|
|
42
|
+
|
|
43
|
+
Optional sections, any subset, any order after Non-goals:
|
|
44
|
+
|
|
45
|
+
- **Constraints** — bullets binding every requirement at once (e.g. "no node
|
|
46
|
+
runs the full suite").
|
|
47
|
+
- **Success criteria** — a table with at least a `Baseline` column, so
|
|
48
|
+
validation can catch a metric nobody measured before claiming a delta.
|
|
49
|
+
- **Risks** — a table of risk / impact / mitigation.
|
|
50
|
+
|
|
51
|
+
## Requirement shape
|
|
52
|
+
|
|
53
|
+
```markdown
|
|
54
|
+
### R7. Repo facts are deterministic and carry measured duration
|
|
55
|
+
|
|
56
|
+
- **statement:** the target repo inventory is generated without invoking a
|
|
57
|
+
model, is identical across two runs at the same HEAD, and every candidate
|
|
58
|
+
verification command carries a duration measured by
|
|
59
|
+
`preflight --time-verification`.
|
|
60
|
+
- **proof:** command: node --test --test-name-pattern="repo facts"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`R<n>` is a stable id — never renumbered once referenced elsewhere (a
|
|
64
|
+
comparative arm, a follow-up spec). `statement` is the testable claim.
|
|
65
|
+
`proof` is exactly one of:
|
|
66
|
+
|
|
67
|
+
- `command: <shell command>` — re-run it, exit zero proves the requirement.
|
|
68
|
+
- `path: <repo-relative path>` — the file or directory must exist.
|
|
69
|
+
- `judgment: true` — no deterministic check; a reviewer decides.
|
|
70
|
+
|
|
71
|
+
A requirement may add its own `- **constraints:** ...` line for a rule
|
|
72
|
+
scoped to it alone, distinct from the spec-wide Constraints section.
|
|
73
|
+
|
|
74
|
+
## What `faberun spec validate` checks
|
|
75
|
+
|
|
76
|
+
Deterministic, no model call. Rejects:
|
|
77
|
+
|
|
78
|
+
- a requirement without a stable id, or without a `proof` line;
|
|
79
|
+
- a spec with no Non-goals section;
|
|
80
|
+
- a Success criteria table row with no Baseline value;
|
|
81
|
+
- a `target` or `baseline` that does not resolve to a real commit.
|
|
82
|
+
|
|
83
|
+
These are **advisory** by default — recorded as findings, spec still
|
|
84
|
+
validates — and become **blocking** under `--strict-traceability`, which
|
|
85
|
+
fails validation on any of the above. A `legacy`-class document (no front
|
|
86
|
+
matter) is exempt from every check above; it is accepted and labeled, never
|
|
87
|
+
scored against these rules.
|
package/src/campaign/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
copyFileSync,
|
|
2
3
|
existsSync,
|
|
3
4
|
mkdirSync,
|
|
4
5
|
readFileSync,
|
|
@@ -9,7 +10,7 @@ import { join, resolve } from "node:path";
|
|
|
9
10
|
import { writeJsonAtomic } from "../run/store.mjs";
|
|
10
11
|
import { requireId, requirePacketHash, requireString, requireTimestamp } from "../contract/assert.mjs";
|
|
11
12
|
import { promoteRun } from "../repo/integrate.mjs";
|
|
12
|
-
import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
|
|
13
|
+
import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, JOURNAL_FILE, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
|
|
13
14
|
import { readCampaign } from "./record.mjs";
|
|
14
15
|
import { appendJournal, normalizeText, readJournalForDedupe } from "./journal.mjs";
|
|
15
16
|
import { readProjectionState } from "./projection.mjs";
|
|
@@ -122,7 +123,7 @@ export function resolveCampaign(runsDir, campaignId) {
|
|
|
122
123
|
/**
|
|
123
124
|
* @param {string} campaignPath
|
|
124
125
|
* @param {{at?: string, eventId?: string}} options
|
|
125
|
-
* @returns {{path: string, campaign: Campaign}}
|
|
126
|
+
* @returns {{path: string, campaign: Campaign, ledgerFiles: string[]}}
|
|
126
127
|
*/
|
|
127
128
|
export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID() } = {}) {
|
|
128
129
|
requireTimestamp(at, "at");
|
|
@@ -131,10 +132,52 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
|
|
|
131
132
|
if (!readJournalForDedupe(campaignPath).some((entry) => entry.type === "retrospective")) {
|
|
132
133
|
throw new Error(`campaign ${campaign.id} has no recorded retrospective; record one with note --kind retrospective before close`);
|
|
133
134
|
}
|
|
135
|
+
const repoRoot = resolve(campaignPath, "..", "..", "..");
|
|
136
|
+
const ledgerFiles = preserveCampaignLedger(campaignPath, repoRoot);
|
|
134
137
|
const closed = /** @type {Campaign} */ ({ ...campaign, status: "closed", closedAt: at, updatedAt: at });
|
|
135
138
|
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), closed);
|
|
136
139
|
appendJournal(campaignPath, { type: "campaign.closed", at, eventId });
|
|
137
|
-
return { path: campaignPath, campaign: closed };
|
|
140
|
+
return { path: campaignPath, campaign: closed, ledgerFiles };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Copy a campaign's journal, record and each linked run's usage into
|
|
145
|
+
* `<repoRoot>/docs/campaigns/<id>/ledger/` so the comparative arm of the
|
|
146
|
+
* planner has a session-side baseline even after `.runs/` (gitignored) is
|
|
147
|
+
* pruned. Nothing in this tree redacts token counts, costs or operator notes
|
|
148
|
+
* before this point, so the copy is verbatim; the pre-commit secret scan is
|
|
149
|
+
* the guard against anything that should not land in git.
|
|
150
|
+
*
|
|
151
|
+
* Idempotent: re-running it (a second `close` on an already-closed campaign
|
|
152
|
+
* cannot reach this, but a direct call can) overwrites the same destination
|
|
153
|
+
* files rather than duplicating them. A linked run without a `usage.jsonl`
|
|
154
|
+
* (never launched, or pruned) is skipped rather than thrown.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} campaignPath
|
|
157
|
+
* @param {string} repoRoot
|
|
158
|
+
* @returns {string[]}
|
|
159
|
+
*/
|
|
160
|
+
export function preserveCampaignLedger(campaignPath, repoRoot) {
|
|
161
|
+
const campaign = readCampaign(campaignPath);
|
|
162
|
+
const runsDir = resolve(campaignPath, "..", "..");
|
|
163
|
+
const ledgerDir = join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
|
|
164
|
+
mkdirSync(ledgerDir, { recursive: true });
|
|
165
|
+
const written = [];
|
|
166
|
+
for (const name of [JOURNAL_FILE, CAMPAIGN_FILE]) {
|
|
167
|
+
const source = join(campaignPath, name);
|
|
168
|
+
if (!existsSync(source)) continue;
|
|
169
|
+
const destination = join(ledgerDir, name);
|
|
170
|
+
copyFileSync(source, destination);
|
|
171
|
+
written.push(destination);
|
|
172
|
+
}
|
|
173
|
+
for (const runId of campaign.linkedRunIds) {
|
|
174
|
+
const source = join(runsDir, runId, "usage.jsonl");
|
|
175
|
+
if (!existsSync(source)) continue;
|
|
176
|
+
const destination = join(ledgerDir, `${runId}.usage.jsonl`);
|
|
177
|
+
copyFileSync(source, destination);
|
|
178
|
+
written.push(destination);
|
|
179
|
+
}
|
|
180
|
+
return written;
|
|
138
181
|
}
|
|
139
182
|
|
|
140
183
|
/**
|
package/src/campaign/journal.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { JOURNAL_FILE, JOURNAL_TEXT_BYTES, JOURNAL_WATCH_CURSOR_DIR, JOURNAL_WAT
|
|
|
11
11
|
import { boundedText, collapseLines } from "../util.mjs";
|
|
12
12
|
import { campaignIdOf } from "./record.mjs";
|
|
13
13
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
14
15
|
import { dirname, isAbsolute, join } from "node:path";
|
|
15
16
|
import { requireText, requireTimestamp } from "../contract/assert.mjs";
|
|
16
17
|
import { writeJsonAtomic } from "../run/store.mjs";
|
|
@@ -34,6 +35,7 @@ const JOURNAL_TYPES = new Set([
|
|
|
34
35
|
"question.resolved",
|
|
35
36
|
"retrospective",
|
|
36
37
|
"liveness",
|
|
38
|
+
"seat.allowance",
|
|
37
39
|
]);
|
|
38
40
|
const SESSION_REQUIRED_TYPES = new Set([
|
|
39
41
|
"session.attached",
|
|
@@ -63,7 +65,9 @@ const ENTRY_SHAPES = {
|
|
|
63
65
|
"question.resolved": ["at", "type", "eventId", "sessionId", "questionId", "text"],
|
|
64
66
|
retrospective: ["at", "type", "eventId", "sessionId", "text"],
|
|
65
67
|
liveness: ["at", "type", "eventId", "campaignId", "runId", "nodeId", "phase", "checkpointsDone", "checkpointsTotal", "runtime", "state", "lastProgressAt", "attention"],
|
|
68
|
+
"seat.allowance": ["at", "type", "eventId", "sample", "harness", "remaining", "limit", "resetsAt", "delta", "window"],
|
|
66
69
|
};
|
|
70
|
+
const SEAT_ALLOWANCE_SAMPLES = new Set(["start", "freeze"]);
|
|
67
71
|
/**
|
|
68
72
|
* Fields a liveness fact carried before the budget ceiling was removed. A
|
|
69
73
|
* historical journal (like the live campaign's own) still has lines shaped
|
|
@@ -272,6 +276,7 @@ export function validateJournalEntry(entry) {
|
|
|
272
276
|
// rejects an unexpected key; no deeper shape validation is needed for a type
|
|
273
277
|
// nothing produces.
|
|
274
278
|
if (type === "liveness") return;
|
|
279
|
+
if (type === "seat.allowance") return validateSeatAllowanceEntry(record);
|
|
275
280
|
if (type === "session.attached") return validateSessionEntry(record);
|
|
276
281
|
if (type === "run.registered") {
|
|
277
282
|
requireText(record.runId, "entry.runId");
|
|
@@ -293,6 +298,49 @@ export function validateJournalEntry(entry) {
|
|
|
293
298
|
}
|
|
294
299
|
if (type === "outcome" && record.runId !== undefined) requireText(record.runId, "entry.runId");
|
|
295
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* @param {JsonObject} entry
|
|
303
|
+
*/
|
|
304
|
+
function validateSeatAllowanceEntry(entry) {
|
|
305
|
+
if (!SEAT_ALLOWANCE_SAMPLES.has(/** @type {string} */ (entry.sample))) {
|
|
306
|
+
throw new TypeError("seat.allowance sample must be start or freeze");
|
|
307
|
+
}
|
|
308
|
+
if (entry.harness !== null && typeof entry.harness !== "string") {
|
|
309
|
+
throw new TypeError("seat.allowance harness must be null or a string");
|
|
310
|
+
}
|
|
311
|
+
for (const field of ["remaining", "limit", "delta"]) {
|
|
312
|
+
if (entry[field] !== null && typeof entry[field] !== "number") {
|
|
313
|
+
throw new TypeError(`seat.allowance ${field} must be null or a number`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (entry.resetsAt !== null && typeof entry.resetsAt !== "string") {
|
|
317
|
+
throw new TypeError("seat.allowance resetsAt must be null or a string");
|
|
318
|
+
}
|
|
319
|
+
// Optional: a historical entry, and every call site not yet updated to
|
|
320
|
+
// sample it, carries no window at all.
|
|
321
|
+
if (entry.window !== undefined && entry.window !== null && typeof entry.window !== "string") {
|
|
322
|
+
throw new TypeError("seat.allowance window must be null or a string");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The one writer of `seat.allowance`: `campaign init` calls it for the
|
|
327
|
+
* `start` sample, `plan freeze` for the `freeze` sample. Kept as a single
|
|
328
|
+
* function, rather than two call sites building the literal themselves, so
|
|
329
|
+
* the field-ownership ratchet does not grow by one for every field this event
|
|
330
|
+
* carries.
|
|
331
|
+
*
|
|
332
|
+
* @param {string} campaignPath
|
|
333
|
+
* @param {{sample: "start"|"freeze", harness: string|null, remaining: number|null, limit: number|null, resetsAt: string|null, delta: number|null, window?: string|null}} allowance
|
|
334
|
+
* @returns {{entry: JournalEntry, deduplicated: boolean}}
|
|
335
|
+
*/
|
|
336
|
+
export function appendSeatAllowanceEvent(campaignPath, allowance) {
|
|
337
|
+
return appendJournal(campaignPath, {
|
|
338
|
+
type: "seat.allowance",
|
|
339
|
+
eventId: randomUUID(),
|
|
340
|
+
at: new Date().toISOString(),
|
|
341
|
+
...allowance,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
296
344
|
/**
|
|
297
345
|
* @param {JsonObject} entry
|
|
298
346
|
*/
|
package/src/cli/brand.mjs
CHANGED
|
@@ -195,6 +195,9 @@ export function renderUsage() {
|
|
|
195
195
|
"next [--cwd <dir>] [--json]",
|
|
196
196
|
"bulk-read --question <text> --paths <a,b,c> [--json]",
|
|
197
197
|
"contract validate <contract.json>",
|
|
198
|
+
"spec validate <file> [--strict-traceability] [--json]",
|
|
199
|
+
"spec scaffold <path> [--id <id>]",
|
|
200
|
+
"plan <spec.md> --campaign <id> [--phase <phase>] [--review-rounds <n>] [--approve-below standard|high|none] [--runtime-defaults worker=<id>,judge=<id>] [--detach] [--json]",
|
|
198
201
|
"metrics <campaign-id> [--cwd <dir>] [--json]",
|
|
199
202
|
"campaign <init|watch|attach|note|resolve|close|supervise|show|list|sync|ack> ...",
|
|
200
203
|
"seat <start|attach|status|stop> [<campaign-id>] [--cwd <dir>] ...",
|
package/src/cli/campaign.mjs
CHANGED
|
@@ -12,12 +12,14 @@ import {
|
|
|
12
12
|
} from "../campaign/index.mjs";
|
|
13
13
|
import { lockStale, pidAlive, processStartToken, readLock } from "../run/lock.mjs";
|
|
14
14
|
import { syncAgentSignal } from "../repo/signal.mjs";
|
|
15
|
-
import { acknowledgeJournalEvent, appendJournal, readJournal, watchJournal } from "../campaign/journal.mjs";
|
|
15
|
+
import { acknowledgeJournalEvent, appendJournal, appendSeatAllowanceEvent, readJournal, watchJournal } from "../campaign/journal.mjs";
|
|
16
16
|
import { driveCampaignChain } from "../campaign/chain.mjs";
|
|
17
17
|
import { unparkCampaign } from "../campaign/unpark.mjs";
|
|
18
18
|
import { readCampaign } from "../campaign/record.mjs";
|
|
19
19
|
import { notifyQueueFor } from "../engine/notify-queue.mjs";
|
|
20
20
|
import { appendInbox, readInbox, wakeCapabilityNotice } from "../notify/index.mjs";
|
|
21
|
+
import { allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
|
|
22
|
+
import { detectOperatorHarness } from "../seat/harnesses.mjs";
|
|
21
23
|
import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
|
|
22
24
|
import { errorCode, readJsonTolerant } from "../util.mjs";
|
|
23
25
|
|
|
@@ -326,12 +328,23 @@ function watchLockStale(occupant) {
|
|
|
326
328
|
* @param {string} campaignId
|
|
327
329
|
* @param {CliValues} values
|
|
328
330
|
*/
|
|
329
|
-
function init(campaignId, values) {
|
|
331
|
+
async function init(campaignId, values) {
|
|
330
332
|
const cwd = resolve(values.cwd ?? ".");
|
|
331
333
|
const runsDir = join(cwd, ".runs");
|
|
332
334
|
const goal = textValue(values.goal, "--goal");
|
|
333
335
|
const contracts = contractManifest(values.contract);
|
|
334
336
|
const created = initializeCampaign(runsDir, { campaignId, goal, contracts, landBranch: values.landBranch });
|
|
337
|
+
// The operator's own seat: whichever harness this CLI is running inside
|
|
338
|
+
// (env-marker detection, see seat/harnesses.mjs), the only harness whose
|
|
339
|
+
// allowance is meaningful at a point before any node runtime exists.
|
|
340
|
+
const harness = detectOperatorHarness();
|
|
341
|
+
const allowance = await sampleAllowance({ harness });
|
|
342
|
+
appendSeatAllowanceEvent(created.path, {
|
|
343
|
+
sample: "start",
|
|
344
|
+
harness,
|
|
345
|
+
delta: null,
|
|
346
|
+
...allowanceEventFields(allowance),
|
|
347
|
+
});
|
|
335
348
|
renderHandoff(created.path, runsDir);
|
|
336
349
|
process.stdout.write(`[campaign] ${campaignId} initialized · ${created.path} · landBranch ${created.campaign.landBranch} · ${created.campaign.contracts.length} contract(s)\n`);
|
|
337
350
|
if (syncAgentSignal(runsDir)) process.stdout.write(`[campaign] AGENTS.md signal updated\n`);
|
|
@@ -447,6 +460,7 @@ function close(campaignId, values) {
|
|
|
447
460
|
const closed = closeCampaign(path, { eventId: values.eventId ?? randomUUID() });
|
|
448
461
|
renderHandoff(path, runsDir);
|
|
449
462
|
process.stdout.write(`[campaign] ${closed.campaign.id} closed\n`);
|
|
463
|
+
process.stdout.write(`[campaign] ledger · docs/campaigns/${closed.campaign.id}/ledger · ${closed.ledgerFiles.length} files\n`);
|
|
450
464
|
if (syncAgentSignal(runsDir)) process.stdout.write(`[campaign] AGENTS.md signal updated\n`);
|
|
451
465
|
}
|
|
452
466
|
|
package/src/cli/manual.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import CAMPAIGN_OPERATIONS from "./campaign.mjs";
|
|
|
14
14
|
import SEAT_OPERATIONS from "./seat.mjs";
|
|
15
15
|
import CONTRACT_OPERATIONS from "./contract.mjs";
|
|
16
16
|
import SKILLS_OPERATIONS from "./skills.mjs";
|
|
17
|
+
import SPEC_OPERATIONS from "./spec.mjs";
|
|
17
18
|
|
|
18
19
|
/** @typedef {{type: "string"|"boolean", multiple?: boolean}} FlagSpec */
|
|
19
20
|
/** @typedef {{flags?: Record<string, FlagSpec>, operations?: Record<string, Record<string, FlagSpec>>}} VerbSurface */
|
|
@@ -22,7 +23,7 @@ import SKILLS_OPERATIONS from "./skills.mjs";
|
|
|
22
23
|
const MANUAL_PATH = fileURLToPath(new URL("../../docs/COMMANDS.md", import.meta.url));
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
|
-
* `campaign`, `seat`, `contract` and `
|
|
26
|
+
* `campaign`, `seat`, `contract`, `skills` and `spec` are dispatched before
|
|
26
27
|
* `COMMAND_OPTIONS` is ever consulted (`cli.mjs` routes them by `argv[0]`), so
|
|
27
28
|
* they carry no flags of their own — only the operations their own module
|
|
28
29
|
* declares. Their top-level `## faberun <verb>` section is therefore never
|
|
@@ -35,6 +36,7 @@ const CONTAINER_OPERATIONS = {
|
|
|
35
36
|
seat: SEAT_OPERATIONS,
|
|
36
37
|
contract: CONTRACT_OPERATIONS,
|
|
37
38
|
skills: SKILLS_OPERATIONS,
|
|
39
|
+
spec: SPEC_OPERATIONS,
|
|
38
40
|
};
|
|
39
41
|
|
|
40
42
|
/**
|
package/src/cli/plan.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `plan` argv: run the planning pipeline as successive ordinary runs (draft,
|
|
3
|
+
* review, revise up to a round budget) and freeze the result, or park it
|
|
4
|
+
* contested. This file only owns the wire — `src/plan/pipeline.mjs` owns the
|
|
5
|
+
* sequencing and every decision the pipeline makes.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
|
|
10
|
+
import { classifyRunProgress } from "../campaign/chain.mjs";
|
|
11
|
+
import { runProgress } from "../engine/supervise.mjs";
|
|
12
|
+
import { DISCOVERY_RUNTIME_DEFINITIONS } from "../engine/runtime-discovery.mjs";
|
|
13
|
+
import { validateRuntime } from "../contract/runtime.mjs";
|
|
14
|
+
import { delay } from "../util.mjs";
|
|
15
|
+
import { runPlanningPipeline } from "../plan/pipeline.mjs";
|
|
16
|
+
|
|
17
|
+
/** How often a foreground `plan` polls a launched stage's run directory. */
|
|
18
|
+
const DEFAULT_POLL_MS = 1_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `--runtime-defaults worker=<id>,judge=<id>`, either key optional, comma
|
|
22
|
+
* separated. Absent entirely, the pipeline falls through to plain
|
|
23
|
+
* availability discovery for every node.
|
|
24
|
+
*
|
|
25
|
+
* @param {string|undefined} value
|
|
26
|
+
* @returns {{worker?: string, judge?: string}}
|
|
27
|
+
*/
|
|
28
|
+
export function parseRuntimeDefaults(value) {
|
|
29
|
+
/** @type {{worker?: string, judge?: string}} */
|
|
30
|
+
const result = {};
|
|
31
|
+
if (value === undefined) return result;
|
|
32
|
+
for (const pair of value.split(",")) {
|
|
33
|
+
const eq = pair.indexOf("=");
|
|
34
|
+
if (eq < 0) throw new Error(`--runtime-defaults entries must be worker=<id> or judge=<id>: ${pair}`);
|
|
35
|
+
const role = pair.slice(0, eq).trim();
|
|
36
|
+
const id = pair.slice(eq + 1).trim();
|
|
37
|
+
if (role !== "worker" && role !== "judge") throw new Error(`--runtime-defaults role must be worker or judge: ${role}`);
|
|
38
|
+
if (!id) throw new Error(`--runtime-defaults ${role} needs a runtime id`);
|
|
39
|
+
result[role] = id;
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {unknown} value
|
|
46
|
+
* @returns {number}
|
|
47
|
+
*/
|
|
48
|
+
function reviewRoundsOf(value) {
|
|
49
|
+
if (value === undefined) return 2;
|
|
50
|
+
const rounds = Number(value);
|
|
51
|
+
if (!Number.isInteger(rounds) || rounds < 1) throw new Error(`--review-rounds must be a positive integer: ${String(value)}`);
|
|
52
|
+
return rounds;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A `--runtimes <path>` catalogue: a JSON object in the same shape a
|
|
57
|
+
* contract's own `runtimes` field takes, validated entry-by-entry with the
|
|
58
|
+
* same validator `validateContract` uses, so a malformed catalogue is
|
|
59
|
+
* rejected before any planning stage launches rather than surfacing as an
|
|
60
|
+
* opaque failure deep inside the pipeline.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} path
|
|
63
|
+
* @returns {Record<string, import("../contract/index.mjs").ValidatedRuntime>}
|
|
64
|
+
*/
|
|
65
|
+
export function loadRuntimesCatalogue(path) {
|
|
66
|
+
const resolved = resolve(path);
|
|
67
|
+
/** @type {unknown} */
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = JSON.parse(readFileSync(resolved, "utf8"));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new Error(`--runtimes ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
73
|
+
}
|
|
74
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`--runtimes ${path} must be a JSON object`);
|
|
75
|
+
/** @type {Record<string, import("../contract/index.mjs").ValidatedRuntime>} */
|
|
76
|
+
const runtimes = {};
|
|
77
|
+
for (const [id, runtime] of Object.entries(raw)) runtimes[id] = validateRuntime(id, runtime);
|
|
78
|
+
return runtimes;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} target
|
|
83
|
+
* @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, runtimes?: string, detach?: boolean, json?: boolean}} values
|
|
84
|
+
* @returns {Promise<void>}
|
|
85
|
+
*/
|
|
86
|
+
export async function planCli(target, values) {
|
|
87
|
+
const specPath = resolve(target);
|
|
88
|
+
if (typeof values.campaign !== "string" || !values.campaign) throw new Error("plan requires --campaign <id>");
|
|
89
|
+
const campaignId = values.campaign;
|
|
90
|
+
const phase = typeof values.phase === "string" && values.phase ? values.phase : "default";
|
|
91
|
+
const reviewRounds = reviewRoundsOf(values["review-rounds"]);
|
|
92
|
+
const approveBelow = /** @type {"standard"|"high"|"none"|undefined} */ (values["approve-below"]);
|
|
93
|
+
const runtimeDefaults = parseRuntimeDefaults(values["runtime-defaults"]);
|
|
94
|
+
const runtimes = typeof values.runtimes === "string" && values.runtimes
|
|
95
|
+
? loadRuntimesCatalogue(values.runtimes)
|
|
96
|
+
: DISCOVERY_RUNTIME_DEFINITIONS;
|
|
97
|
+
|
|
98
|
+
if (values.detach === true) {
|
|
99
|
+
const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
|
|
100
|
+
if (approveBelow !== undefined) argv.push("--approve-below", approveBelow);
|
|
101
|
+
if (values["runtime-defaults"] !== undefined) argv.push("--runtime-defaults", values["runtime-defaults"]);
|
|
102
|
+
if (typeof values.runtimes === "string" && values.runtimes) argv.push("--runtimes", resolve(values.runtimes));
|
|
103
|
+
const child = detachArgv(argv);
|
|
104
|
+
if (child.pid === undefined) throw new Error("detached plan has no pid");
|
|
105
|
+
process.stdout.write(`[plan] detached · pid ${child.pid} · ${specPath}\n`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = await runPlanningPipeline({
|
|
110
|
+
specPath,
|
|
111
|
+
campaignId,
|
|
112
|
+
phase,
|
|
113
|
+
reviewRounds,
|
|
114
|
+
approveBelow,
|
|
115
|
+
runtimeDefaults,
|
|
116
|
+
runtimes,
|
|
117
|
+
launch: async (contractPath, contract) => {
|
|
118
|
+
const child = detachSelf("run", contractPath);
|
|
119
|
+
if (child.pid === undefined) throw new Error("detached planning run has no pid");
|
|
120
|
+
await waitForBootstrap(join(contract.cwd, ".runs", contract.id), child.pid, child);
|
|
121
|
+
},
|
|
122
|
+
wait: async (runDir) => {
|
|
123
|
+
for (;;) {
|
|
124
|
+
const progress = runProgress(runDir);
|
|
125
|
+
const classification = classifyRunProgress(progress);
|
|
126
|
+
if (classification !== "unfinished" && classification !== "waiting") return progress;
|
|
127
|
+
await delay(DEFAULT_POLL_MS);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (values.json === true) {
|
|
133
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (result.status === "contested") {
|
|
137
|
+
process.stdout.write(`[plan] ${campaignId} phase ${phase} contested after ${result.round} round(s) · ${result.findings.length} finding(s) · ${result.planPath}\n`);
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
process.stdout.write(`[plan] ${campaignId} phase ${phase} frozen · approved ${result.approved} · ${result.contractPath}\n`);
|
|
142
|
+
}
|
package/src/cli/spec.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec` argv: validate and scaffold a spec document. Both operations are
|
|
3
|
+
* deterministic — `src/plan/spec.mjs` invokes no model — so this file only
|
|
4
|
+
* owns the wire, the same split every other verb module in this directory
|
|
5
|
+
* uses.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { parseArgs as parseFlags } from "node:util";
|
|
10
|
+
import { validateSpec } from "../plan/spec.mjs";
|
|
11
|
+
|
|
12
|
+
/** @typedef {import("../plan/spec.mjs").SpecValidation} SpecValidation */
|
|
13
|
+
|
|
14
|
+
/** Flags are scoped to the operation that declares them; all others are rejected. */
|
|
15
|
+
/** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
|
|
16
|
+
const OPERATION_OPTIONS = {
|
|
17
|
+
validate: { "strict-traceability": { type: "boolean" }, json: { type: "boolean" } },
|
|
18
|
+
scaffold: { id: { type: "string" } },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const SCAFFOLD_TEMPLATE = `---
|
|
22
|
+
id: <id>
|
|
23
|
+
title: "<title>"
|
|
24
|
+
version: 1.0.0
|
|
25
|
+
status: draft
|
|
26
|
+
date: <yyyy-mm-dd>
|
|
27
|
+
owner: <owner>
|
|
28
|
+
target: <org/repo>
|
|
29
|
+
baseline: <git sha>
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
# <title>
|
|
33
|
+
|
|
34
|
+
## Intent
|
|
35
|
+
|
|
36
|
+
<Why this work, what problem, what it unblocks.>
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
### R1. <title>
|
|
41
|
+
|
|
42
|
+
- **statement:** <the testable claim>
|
|
43
|
+
- **proof:** \`command: <shell command>\`
|
|
44
|
+
|
|
45
|
+
## Non-goals
|
|
46
|
+
|
|
47
|
+
- <what this spec explicitly excludes>
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string[]} args
|
|
52
|
+
* @returns {void}
|
|
53
|
+
*/
|
|
54
|
+
export function specCli(args) {
|
|
55
|
+
const operation = args[0];
|
|
56
|
+
if (!operation || !Object.hasOwn(OPERATION_OPTIONS, operation)) return usage();
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = parseFlags({ args: args.slice(1), options: OPERATION_OPTIONS[operation], allowPositionals: true, strict: true });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
62
|
+
return usage();
|
|
63
|
+
}
|
|
64
|
+
const target = parsed.positionals[0];
|
|
65
|
+
if (!target || parsed.positionals.length > 1) return usage();
|
|
66
|
+
const values = /** @type {{"strict-traceability"?: boolean, json?: boolean, id?: string}} */ (parsed.values);
|
|
67
|
+
if (operation === "validate") {
|
|
68
|
+
validateSpecFile(resolve(target), { strict: values["strict-traceability"] === true, json: values.json === true });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
scaffoldSpec(resolve(target), typeof values.id === "string" ? values.id : undefined);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate a spec file and print its class, its overall verdict, and one
|
|
81
|
+
* line per finding. Exits `1` when the verdict is not `ok`.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} path
|
|
84
|
+
* @param {{strict: boolean, json: boolean}} options
|
|
85
|
+
* @returns {SpecValidation}
|
|
86
|
+
*/
|
|
87
|
+
export function validateSpecFile(path, { strict, json }) {
|
|
88
|
+
const result = validateSpec(readFileSync(path, "utf8"), { cwd: process.cwd(), strict });
|
|
89
|
+
if (json) {
|
|
90
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
91
|
+
} else {
|
|
92
|
+
process.stdout.write(`${result.class} · ${result.class === "legacy" ? "accepted" : result.ok ? "ok" : "not ok"}\n`);
|
|
93
|
+
for (const finding of result.findings) process.stdout.write(`[${finding.severity}] ${finding.rule}: ${finding.message}\n`);
|
|
94
|
+
}
|
|
95
|
+
if (!result.ok) process.exitCode = 1;
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write an empty document in the spec format at `path`. Refuses to overwrite
|
|
101
|
+
* an existing file.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} path
|
|
104
|
+
* @param {string} [id]
|
|
105
|
+
* @returns {void}
|
|
106
|
+
*/
|
|
107
|
+
export function scaffoldSpec(path, id) {
|
|
108
|
+
if (existsSync(path)) throw new Error(`refusing to overwrite an existing file: ${path}`);
|
|
109
|
+
writeFileSync(path, id ? SCAFFOLD_TEMPLATE.replace("<id>", id) : SCAFFOLD_TEMPLATE);
|
|
110
|
+
process.stdout.write(`scaffolded ${path}\n`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @returns {void} */
|
|
114
|
+
function usage() {
|
|
115
|
+
process.stderr.write("usage: faberun spec <validate|scaffold> <path> [--strict-traceability] [--json] [--id <value>]\n");
|
|
116
|
+
process.exitCode = 2;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default OPERATION_OPTIONS;
|
package/src/cli.mjs
CHANGED
|
@@ -31,6 +31,8 @@ import { setupCommand } from "./cli/setup.mjs";
|
|
|
31
31
|
import { skillsCli } from "./cli/skills.mjs";
|
|
32
32
|
import { updateCommand } from "./cli/update.mjs";
|
|
33
33
|
import { contractCli, validateContractFile } from "./cli/contract.mjs";
|
|
34
|
+
import { specCli } from "./cli/spec.mjs";
|
|
35
|
+
import { planCli } from "./cli/plan.mjs";
|
|
34
36
|
import { METRICS_OPTIONS, renderCampaignMetrics } from "./campaign/metrics.mjs";
|
|
35
37
|
import { runContract } from "./engine/scheduler.mjs";
|
|
36
38
|
import { resumeRun } from "./engine/resume.mjs";
|
|
@@ -105,6 +107,16 @@ export const COMMAND_OPTIONS = {
|
|
|
105
107
|
setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, "no-skill": { type: "boolean" }, json: { type: "boolean" } },
|
|
106
108
|
init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
|
|
107
109
|
metrics: METRICS_OPTIONS,
|
|
110
|
+
plan: {
|
|
111
|
+
campaign: { type: "string" },
|
|
112
|
+
phase: { type: "string" },
|
|
113
|
+
"review-rounds": { type: "string" },
|
|
114
|
+
"approve-below": { type: "string" },
|
|
115
|
+
"runtime-defaults": { type: "string" },
|
|
116
|
+
runtimes: { type: "string" },
|
|
117
|
+
detach: { type: "boolean" },
|
|
118
|
+
json: { type: "boolean" },
|
|
119
|
+
},
|
|
108
120
|
};
|
|
109
121
|
|
|
110
122
|
/**
|
|
@@ -209,6 +221,7 @@ async function main(argv) {
|
|
|
209
221
|
if (argv[0] === "seat") { seatCli(argv.slice(1)); return; }
|
|
210
222
|
if (argv[0] === "skills") { skillsCli(argv.slice(1)); return; }
|
|
211
223
|
if (argv[0] === "contract") { contractCli(argv.slice(1)); return; }
|
|
224
|
+
if (argv[0] === "spec") { specCli(argv.slice(1)); return; }
|
|
212
225
|
const parsed = parseCli(argv);
|
|
213
226
|
if (!parsed) { usage(); return; }
|
|
214
227
|
const { command, values } = parsed;
|
|
@@ -419,6 +432,10 @@ async function main(argv) {
|
|
|
419
432
|
}
|
|
420
433
|
if (command === "metrics") { process.stdout.write(renderCampaignMetrics(target, values)); return; }
|
|
421
434
|
if (command === "findings") { process.stdout.write(renderFindings(resolve(target))); return; }
|
|
435
|
+
if (command === "plan") {
|
|
436
|
+
await planCli(target, /** @type {Parameters<typeof planCli>[1]} */ (values));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
422
439
|
if (command === "validate") { validateContractFile(resolve(target)); return; }
|
|
423
440
|
usage();
|
|
424
441
|
}
|
|
@@ -432,7 +432,7 @@ function renderDiscoveryPrompt(packet, nodeId) {
|
|
|
432
432
|
...bulletOrNone(packet.nonGoals),
|
|
433
433
|
"",
|
|
434
434
|
"## Required output",
|
|
435
|
-
'Return exactly one worker-result JSON object, with no markdown or prose. Set status to "done", missingContext to [], and artifacts to an array containing exactly one JSON-stringified execution task packet with every required taskPacket field. The packet readFiles and writeFiles must be non-empty and scoped to this repository.',
|
|
435
|
+
'Return exactly one worker-result JSON object, with no markdown or prose. Set status to "done", missingContext to [], and artifacts to an array containing exactly one JSON-stringified execution task packet with every required taskPacket field. The packet readFiles and writeFiles must be non-empty and scoped to this repository. Put structured findings meant to inform that packet in `output` (a JSON object, at most 65536 bytes); prose belongs in `summary`.',
|
|
436
436
|
"",
|
|
437
437
|
"## Verification",
|
|
438
438
|
VERIFICATION_PARAGRAPH,
|