faberun 0.8.0 → 0.10.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 +193 -31
- package/package.json +1 -1
- package/src/campaign/chain.mjs +19 -0
- package/src/campaign/journal.mjs +48 -0
- package/src/cli/brand.mjs +1 -0
- package/src/cli/campaign.mjs +15 -2
- package/src/cli/plan.mjs +142 -0
- package/src/cli.mjs +19 -3
- package/src/contract/final-verification.mjs +41 -10
- 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/engine/scheduler.mjs +199 -26
- package/src/engine/settle.mjs +1 -1
- package/src/engine/verify.mjs +57 -4
- package/src/harnesses/protocol.mjs +91 -11
- package/src/plan/freeze.mjs +7 -3
- package/src/plan/pipeline.mjs +371 -0
- package/src/plan/template.mjs +278 -0
- package/src/repo/integrate.mjs +6 -1
- package/src/report/render.mjs +58 -58
- package/src/seat/allowance.mjs +177 -0
- package/src/web/index.html +3 -3
- package/src/web/server.mjs +2 -1
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
<h1 align="center">faberun</h1>
|
|
4
4
|
|
|
5
|
-
<p align="center">From intent to running software.</p>
|
|
5
|
+
<p align="center"><strong>From intent to running software.</strong></p>
|
|
6
|
+
|
|
7
|
+
<p align="center">Graph engineering for coding agents.</p>
|
|
6
8
|
|
|
7
9
|
<p align="center">
|
|
8
10
|
<a href="https://github.com/feliperun/faberun/actions/workflows/ci.yml"><img src="https://github.com/feliperun/faberun/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
|
|
@@ -10,21 +12,162 @@
|
|
|
10
12
|
<a href="https://www.npmjs.com/package/faberun"><img src="https://img.shields.io/npm/v/faberun" alt="npm version"></a>
|
|
11
13
|
</p>
|
|
12
14
|
|
|
13
|
-
Faberun is a
|
|
14
|
-
|
|
15
|
-
dependencies,
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
Faberun is a graph-engineering runtime for building software with coding agents.
|
|
16
|
+
It sits above the models and the harnesses and turns an implementation plan into
|
|
17
|
+
a durable graph of tasks, dependencies, workers, proofs, reviews, retries and
|
|
18
|
+
integration gates.
|
|
19
|
+
|
|
20
|
+
Harnesses are execution environments: Claude Code, Codex, the Gemini, DeepSeek
|
|
21
|
+
and GLM shells, and a generic `exec-jsonl` door for whatever comes next. Models
|
|
22
|
+
are the engines inside them. Faberun owns the graph, the state and the
|
|
23
|
+
definition of done around both.
|
|
24
|
+
|
|
25
|
+
The goal is not to make a group of agents talk to each other. It is to use the
|
|
26
|
+
right model for each kind of work, keep the process independent of any single
|
|
27
|
+
model or harness, and require evidence before software counts as done.
|
|
28
|
+
|
|
29
|
+
## Why Faberun
|
|
30
|
+
|
|
31
|
+
Coding agents are already very good at changing repositories. The remaining
|
|
32
|
+
problem is everything around that execution.
|
|
33
|
+
|
|
34
|
+
A frontier model earns its cost when it reasons about architecture, challenges a
|
|
35
|
+
design, writes a specification or reviews a difficult decision. The same model
|
|
36
|
+
is usually unnecessary for a small, well-defined implementation task a much
|
|
37
|
+
cheaper one performs well. Without an orchestration layer, though, the model
|
|
38
|
+
driving the current session becomes the model doing everything.
|
|
39
|
+
|
|
40
|
+
Faberun separates those concerns. One campaign can use a frontier model to
|
|
41
|
+
reason about the problem and author a plan, a second strong model to attack that
|
|
42
|
+
plan, lower-cost models to execute well-defined implementation nodes, a model
|
|
43
|
+
from another vendor to judge the work that genuinely needs judgment, and plain
|
|
44
|
+
deterministic commands wherever no model is needed at all. The expensive
|
|
45
|
+
intelligence goes where it matters, and the rest becomes an execution problem.
|
|
46
|
+
|
|
47
|
+
It also takes the campaign out of the lifetime of one chat. The work has durable
|
|
48
|
+
state on disk, so Claude Code can start a campaign and Codex, DeepSeek, GLM or
|
|
49
|
+
any other adapted harness can continue it later without reconstructing the
|
|
50
|
+
process from a conversation history.
|
|
51
|
+
|
|
52
|
+
## Graph engineering
|
|
18
53
|
|
|
19
|
-
Faberun
|
|
20
|
-
|
|
21
|
-
models are engines; Faberun sits above them. It keeps the intent, coordinates
|
|
22
|
-
the work, tracks what was actually completed, validates the result and decides
|
|
23
|
-
what should happen next.
|
|
54
|
+
Faberun treats development as an executable graph rather than a long
|
|
55
|
+
conversation.
|
|
24
56
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
```text
|
|
58
|
+
intent
|
|
59
|
+
└─ campaign
|
|
60
|
+
└─ contract
|
|
61
|
+
├─ node A ── proof ── review ──┐
|
|
62
|
+
├─ node B ── proof ── review ──┼── integration ── promotion
|
|
63
|
+
└─ node C ── proof ── review ──┘
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A campaign carries the durable intent. A contract turns part of that intent into
|
|
67
|
+
a schema-versioned DAG. Each node carries a closed packet: what the worker may
|
|
68
|
+
read, what it may change, what done means, and how that claim has to be proven.
|
|
69
|
+
The graph decides what can run, what must wait, what may retry, what needs a
|
|
70
|
+
human and what is allowed to advance. The models are replaceable participants
|
|
71
|
+
inside it. The graph remains.
|
|
72
|
+
|
|
73
|
+
## What is different
|
|
74
|
+
|
|
75
|
+
### Use the right model for the job
|
|
76
|
+
|
|
77
|
+
A runtime is one harness running one model. Workers, judges and planning
|
|
78
|
+
sessions need not share a provider or a capability tier, so high-cost reasoning
|
|
79
|
+
and low-cost execution live in the same campaign instead of one model owning the
|
|
80
|
+
whole process. Changing providers is a routing decision, not a rewrite of the
|
|
81
|
+
workflow.
|
|
82
|
+
|
|
83
|
+
### Proof before opinion
|
|
84
|
+
|
|
85
|
+
A worker saying "done" is not evidence that it is. Every definition-of-done item
|
|
86
|
+
declares how it can be proven: a command that must succeed, a path that must
|
|
87
|
+
exist, or — only when the criterion cannot be mechanised — the judgment of a
|
|
88
|
+
judge. Mechanical verification always runs first, and a node whose proofs are
|
|
89
|
+
all mechanical settles without spending a token on review. A gate may also
|
|
90
|
+
declare `skipWhen`, so a change that is green and small enough skips the judge
|
|
91
|
+
by policy rather than by accident.
|
|
92
|
+
|
|
93
|
+
### Independent review
|
|
94
|
+
|
|
95
|
+
When judgment is required, the worker does not grade itself. Validation refuses
|
|
96
|
+
a gated node whose worker and judge resolve to the same vendor. The judge reads
|
|
97
|
+
the recorded result and its evidence, never the reasoning that produced it, and
|
|
98
|
+
never re-runs the work. The point is not to make two agents agree; it is to keep
|
|
99
|
+
implementation and acceptance as separate responsibilities.
|
|
100
|
+
|
|
101
|
+
### Isolated execution
|
|
102
|
+
|
|
103
|
+
Each attempt runs in its own git worktree, so independent nodes run at the same
|
|
104
|
+
time without sharing a mutable working tree, and accepted work reaches the
|
|
105
|
+
repository through controlled refs instead of being copied into the operator's
|
|
106
|
+
checkout. The operator keeps working while the campaign runs.
|
|
107
|
+
|
|
108
|
+
### Durable campaigns
|
|
109
|
+
|
|
110
|
+
A run is not tied to the terminal that launched it. The controller runs
|
|
111
|
+
detached, state is persisted under `.runs/`, `supervise` resumes a controller
|
|
112
|
+
that died, and a campaign carries its intent across many runs and sessions. If a
|
|
113
|
+
provider goes down or a harness session ends, the work does not have to be
|
|
114
|
+
re-authored.
|
|
115
|
+
|
|
116
|
+
### Safe integration
|
|
117
|
+
|
|
118
|
+
Passing a worker's own checks is not the end of the process. An accepted attempt
|
|
119
|
+
is sealed, integrated onto the run ref and verified again in the candidate
|
|
120
|
+
state, and the phase-terminal node runs the contract's final verification. If
|
|
121
|
+
combining individually valid changes breaks the system, the campaign does not
|
|
122
|
+
promote them.
|
|
123
|
+
|
|
124
|
+
### Measurable model economics
|
|
125
|
+
|
|
126
|
+
Every invocation records its usage, routing and outcome, and a closed campaign
|
|
127
|
+
keeps that ledger in its own record under `docs/campaigns/<id>/ledger/`. It makes
|
|
128
|
+
questions like these measurable instead of anecdotal: which model handles this
|
|
129
|
+
class of task reliably, where a frontier model is actually worth its price,
|
|
130
|
+
which nodes close mechanically with no judge at all, how many revisions a worker
|
|
131
|
+
needs before acceptance, what one closed checkpoint costs, and how often a
|
|
132
|
+
fallback saves a campaign. The objective is not to spend less. It is to know
|
|
133
|
+
where expensive intelligence creates value.
|
|
134
|
+
|
|
135
|
+
## Campaigns, not synthetic teams
|
|
136
|
+
|
|
137
|
+
Faberun does not model an agent process as a human organisation. It needs no "AI
|
|
138
|
+
architect", "AI developer", "AI QA engineer" and "AI product manager" merely
|
|
139
|
+
because human teams carry those titles: models are general enough that those
|
|
140
|
+
boundaries are mostly artificial. The roles it does define are the ones that
|
|
141
|
+
stay useful for agents.
|
|
142
|
+
|
|
143
|
+
| role | responsibility |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| worker | produces a result inside its packet. |
|
|
146
|
+
| judge | decides independently whether a result that needs judgment satisfies its contract. |
|
|
147
|
+
| controller | advances the graph deterministically. |
|
|
148
|
+
| operator | supplies the intent and intervenes when a decision genuinely needs a human. |
|
|
149
|
+
|
|
150
|
+
Everything else belongs in the contract.
|
|
151
|
+
|
|
152
|
+
## From spec to contract
|
|
153
|
+
|
|
154
|
+
Planning is part of the product, and it is adversarial on purpose. `faberun spec
|
|
155
|
+
validate` checks a specification's front matter, its `R<n>` requirements and the
|
|
156
|
+
proof each one declares, and invokes no model to do it. `faberun plan` then runs
|
|
157
|
+
the debate in budgeted, isolated runs: an author drafts, a reviewer from another
|
|
158
|
+
vendor attacks the draft holding only the spec, the repository facts and the
|
|
159
|
+
plan under review, and a revision round follows while a critical finding
|
|
160
|
+
remains. A converged draft is sized, routed and frozen into a contract. Freezing
|
|
161
|
+
never launches, and a plan that never converges ends `contested`, with its open
|
|
162
|
+
findings recorded as a campaign question instead of a contract.
|
|
163
|
+
|
|
164
|
+
None of it is mandatory. The traceability rules are advisory unless you ask for
|
|
165
|
+
`--strict-traceability`, a document with no front matter validates as `legacy`
|
|
166
|
+
rather than being rejected, and you can skip planning altogether and author the
|
|
167
|
+
contract by hand. What Faberun needs is the executable part of the plan:
|
|
168
|
+
objective, nodes, dependencies, read and write scope, definitions of done,
|
|
169
|
+
verification, runtime policy and integration behaviour. Your specification
|
|
170
|
+
process stays yours.
|
|
28
171
|
|
|
29
172
|
## Install
|
|
30
173
|
|
|
@@ -92,29 +235,35 @@ The operator writes the intent into a campaign and an authored contract, a
|
|
|
92
235
|
schema-versioned DAG whose nodes each carry a closed task packet. The controller
|
|
93
236
|
schedules every dependency-ready node and dispatches its packet to a worker
|
|
94
237
|
inside an attempt worktree, where the worker sees only the files the packet
|
|
95
|
-
names.
|
|
96
|
-
|
|
97
|
-
A passing attempt is sealed and integrated onto the run ref, a campaign promotes
|
|
98
|
-
each run onto its landing branch, and the orchestrator lands that branch.
|
|
99
|
-
Campaigns, handoffs and the `supervise` watchdog carry the work across sessions,
|
|
100
|
-
so an interrupted run is continued in place instead of being re-authored.
|
|
238
|
+
names. Faberun then judges the result in stages, and a free slot dispatches the
|
|
239
|
+
next node while another one is still being verified.
|
|
101
240
|
|
|
102
241
|
```text
|
|
103
|
-
|
|
104
|
-
└─
|
|
105
|
-
└─
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
242
|
+
packet
|
|
243
|
+
└─ worker attempt in its own worktree
|
|
244
|
+
└─ mechanical proof ── failure ─→ retry · route · attention
|
|
245
|
+
└─ judge, when required ── rejection ─→ revision
|
|
246
|
+
└─ seal ─→ integration ref ─→ candidate verification
|
|
247
|
+
└─ promotion onto the campaign's landing branch
|
|
109
248
|
```
|
|
110
249
|
|
|
111
|
-
|
|
112
|
-
|
|
250
|
+
A judge reviews recorded evidence rather than re-running arbitrary work. A
|
|
251
|
+
passing attempt is sealed and integrated onto the run ref, a campaign promotes
|
|
252
|
+
each finished run onto its landing branch, and the orchestrator lands that
|
|
253
|
+
branch. Campaigns, handoffs and the `supervise` watchdog carry the work across
|
|
254
|
+
sessions, so an interrupted run is continued in place instead of being
|
|
255
|
+
re-authored.
|
|
113
256
|
|
|
114
|
-
|
|
257
|
+
The vocabulary is in [Concepts](docs/CONCEPTS.md), and the layers, process model
|
|
258
|
+
and gates are in [Architecture](docs/ARCHITECTURE.md).
|
|
115
259
|
|
|
116
|
-
|
|
117
|
-
|
|
260
|
+
## Harnesses and models
|
|
261
|
+
|
|
262
|
+
Faberun keeps three things apart. A **model** is the reasoning engine, a
|
|
263
|
+
**harness** is the environment that gives a model access to code and tools, and
|
|
264
|
+
a **runtime** is one configured harness-and-model pair Faberun can dispatch. Any
|
|
265
|
+
runtime can act as a worker or as a judge, according to the contract and the
|
|
266
|
+
routing policy.
|
|
118
267
|
|
|
119
268
|
| Harness | Default vendor | Example model |
|
|
120
269
|
| --- | --- | --- |
|
|
@@ -126,6 +275,19 @@ judge of another vendor reviews what it produced.
|
|
|
126
275
|
| `exec-jsonl` | declared per runtime | the model its command names |
|
|
127
276
|
| `replay` | declared per runtime | the recorded model |
|
|
128
277
|
|
|
278
|
+
Harness and model support are adapter concerns. The orchestration above them
|
|
279
|
+
depends on no single vendor.
|
|
280
|
+
|
|
281
|
+
## Philosophy
|
|
282
|
+
|
|
283
|
+
Software should be built, not merely generated. Generating plausible code is
|
|
284
|
+
becoming cheap; the valuable part is the system around the generation —
|
|
285
|
+
preserving intent, decomposing work, choosing the appropriate intelligence,
|
|
286
|
+
isolating effects, proving outcomes, recovering from failure, and recording why a
|
|
287
|
+
change was accepted. A craftsman does not depend on one hammer, and Faberun does
|
|
288
|
+
not depend on one model. Models change, harnesses change, providers change. The
|
|
289
|
+
intent, the graph, the evidence and the finished work remain.
|
|
290
|
+
|
|
129
291
|
## Documentation
|
|
130
292
|
|
|
131
293
|
- [Documentation map](docs/README.md): every document and the question it answers.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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/src/campaign/chain.mjs
CHANGED
|
@@ -290,6 +290,25 @@ export function validateContractAgainstRef(raw, contractPath, context = {}) {
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Validate a contract for a launch, the one path the CLI's `run` command and
|
|
295
|
+
* the scheduler's own re-validation both go through: against `baseRef` when
|
|
296
|
+
* one is given, the checkout otherwise. `repo` defaults to the contract's own
|
|
297
|
+
* `cwd` when the caller does not know a better one (the campaign chain always
|
|
298
|
+
* passes its own), since a contract's `cwd` is always inside the repo it
|
|
299
|
+
* names.
|
|
300
|
+
*
|
|
301
|
+
* @param {Record<string, unknown>} raw
|
|
302
|
+
* @param {string} contractPath
|
|
303
|
+
* @param {{repo?: string, baseRef?: string}} [context]
|
|
304
|
+
* @returns {ValidatedContract}
|
|
305
|
+
*/
|
|
306
|
+
export function validateContractForLaunch(raw, contractPath, context = {}) {
|
|
307
|
+
if (!context.baseRef) return validateContract(raw, contractPath);
|
|
308
|
+
const repo = context.repo ?? resolve(dirname(contractPath), typeof raw.cwd === "string" ? raw.cwd : ".");
|
|
309
|
+
return validateContractAgainstRef(raw, contractPath, { repo, baseRef: context.baseRef });
|
|
310
|
+
}
|
|
311
|
+
|
|
293
312
|
/**
|
|
294
313
|
* Reduce a run's progress to the one decision the chain makes. An in-flight run
|
|
295
314
|
* is `unfinished` even though `reduceRunOutcome` already names its running nodes
|
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
|
@@ -197,6 +197,7 @@ export function renderUsage() {
|
|
|
197
197
|
"contract validate <contract.json>",
|
|
198
198
|
"spec validate <file> [--strict-traceability] [--json]",
|
|
199
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]",
|
|
200
201
|
"metrics <campaign-id> [--cwd <dir>] [--json]",
|
|
201
202
|
"campaign <init|watch|attach|note|resolve|close|supervise|show|list|sync|ack> ...",
|
|
202
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`);
|
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.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
validBootstrapNonce,
|
|
25
25
|
} from "./run/lock.mjs";
|
|
26
26
|
import { renderRunHandoff } from "./campaign/index.mjs";
|
|
27
|
+
import { validateContractForLaunch } from "./campaign/chain.mjs";
|
|
27
28
|
import { campaignCli } from "./cli/campaign.mjs";
|
|
28
29
|
import { seatCli } from "./cli/seat.mjs";
|
|
29
30
|
import { initCommand } from "./cli/init.mjs";
|
|
@@ -32,6 +33,7 @@ import { skillsCli } from "./cli/skills.mjs";
|
|
|
32
33
|
import { updateCommand } from "./cli/update.mjs";
|
|
33
34
|
import { contractCli, validateContractFile } from "./cli/contract.mjs";
|
|
34
35
|
import { specCli } from "./cli/spec.mjs";
|
|
36
|
+
import { planCli } from "./cli/plan.mjs";
|
|
35
37
|
import { METRICS_OPTIONS, renderCampaignMetrics } from "./campaign/metrics.mjs";
|
|
36
38
|
import { runContract } from "./engine/scheduler.mjs";
|
|
37
39
|
import { resumeRun } from "./engine/resume.mjs";
|
|
@@ -106,6 +108,16 @@ export const COMMAND_OPTIONS = {
|
|
|
106
108
|
setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, "no-skill": { type: "boolean" }, json: { type: "boolean" } },
|
|
107
109
|
init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
|
|
108
110
|
metrics: METRICS_OPTIONS,
|
|
111
|
+
plan: {
|
|
112
|
+
campaign: { type: "string" },
|
|
113
|
+
phase: { type: "string" },
|
|
114
|
+
"review-rounds": { type: "string" },
|
|
115
|
+
"approve-below": { type: "string" },
|
|
116
|
+
"runtime-defaults": { type: "string" },
|
|
117
|
+
runtimes: { type: "string" },
|
|
118
|
+
detach: { type: "boolean" },
|
|
119
|
+
json: { type: "boolean" },
|
|
120
|
+
},
|
|
109
121
|
};
|
|
110
122
|
|
|
111
123
|
/**
|
|
@@ -280,9 +292,9 @@ async function main(argv) {
|
|
|
280
292
|
if (command === "run") {
|
|
281
293
|
warnIfNoTransport();
|
|
282
294
|
const absolute = resolve(target);
|
|
283
|
-
const contract = validateContract(JSON.parse(readFileSync(absolute, "utf8")), absolute);
|
|
284
|
-
const runDir = join(contract.cwd, ".runs", contract.id);
|
|
285
295
|
const baseRef = typeof values["base-ref"] === "string" && values["base-ref"] ? values["base-ref"] : undefined;
|
|
296
|
+
const contract = validateContractForLaunch(JSON.parse(readFileSync(absolute, "utf8")), absolute, { baseRef });
|
|
297
|
+
const runDir = join(contract.cwd, ".runs", contract.id);
|
|
286
298
|
setLaunchBaseRef(baseRef);
|
|
287
299
|
// The base is what every worktree is cut from; a dirty tree only blocks
|
|
288
300
|
// when the cwd HEAD *is* that base. A `--base-ref` elsewhere leaves the
|
|
@@ -303,7 +315,7 @@ async function main(argv) {
|
|
|
303
315
|
return;
|
|
304
316
|
}
|
|
305
317
|
for (const warning of [...contract.warnings, ...reusedDoneWarnings(contract)]) process.stdout.write(`${advisoryToken()} ${warning}\n`);
|
|
306
|
-
const result = await runContract(target, { detachedBootstrap: hasDetachedBootstrapNonce() });
|
|
318
|
+
const result = await runContract(target, { detachedBootstrap: hasDetachedBootstrapNonce(), baseRef });
|
|
307
319
|
if (!result.ok) process.exitCode = 1;
|
|
308
320
|
return;
|
|
309
321
|
}
|
|
@@ -421,6 +433,10 @@ async function main(argv) {
|
|
|
421
433
|
}
|
|
422
434
|
if (command === "metrics") { process.stdout.write(renderCampaignMetrics(target, values)); return; }
|
|
423
435
|
if (command === "findings") { process.stdout.write(renderFindings(resolve(target))); return; }
|
|
436
|
+
if (command === "plan") {
|
|
437
|
+
await planCli(target, /** @type {Parameters<typeof planCli>[1]} */ (values));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
424
440
|
if (command === "validate") { validateContractFile(resolve(target)); return; }
|
|
425
441
|
usage();
|
|
426
442
|
}
|