faberun 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/integrations/claude-code/statusline.sh +31 -2
- package/package.json +2 -2
- package/skills/faberun/references/spec-format.md +9 -0
- package/src/campaign/chain.mjs +2 -1
- package/src/campaign/index.mjs +102 -1
- package/src/campaign/metrics.mjs +2 -2
- package/src/cli/brand.mjs +2 -0
- package/src/cli/campaign-contract.mjs +55 -0
- package/src/cli/campaign.mjs +48 -13
- package/src/cli/init.mjs +8 -2
- package/src/cli/launch.mjs +2 -1
- package/src/cli/plan.mjs +3 -2
- package/src/cli/project.mjs +150 -0
- package/src/cli/skills.mjs +2 -3
- package/src/cli/update.mjs +7 -10
- package/src/cli.mjs +30 -4
- package/src/contract/index.mjs +44 -0
- package/src/engine/cancel.mjs +13 -0
- package/src/engine/dispatch.mjs +3 -2
- package/src/engine/lifecycle.mjs +7 -1
- package/src/engine/live-preflight.mjs +2 -1
- package/src/engine/notify-queue.mjs +9 -1
- package/src/engine/pricing-seed.json +543 -0
- package/src/engine/pricing-seed.mjs +54 -0
- package/src/engine/process.mjs +8 -1
- package/src/engine/result-file.mjs +2 -1
- package/src/engine/resume.mjs +6 -2
- package/src/engine/run-identity.mjs +9 -2
- package/src/engine/scheduler.mjs +40 -6
- package/src/engine/settle.mjs +1 -1
- package/src/harnesses/replay/bin.mjs +10 -1
- package/src/host/home.mjs +10 -3
- package/src/host/platform.mjs +111 -0
- package/src/host/preflight.mjs +24 -21
- package/src/host/projects.mjs +155 -0
- package/src/notify/index.mjs +88 -10
- package/src/plan/pipeline.mjs +32 -9
- package/src/plan/repo-facts.mjs +80 -2
- package/src/plan/spec.mjs +2 -1
- package/src/repo/declared-paths.mjs +7 -1
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +2 -1
- package/src/repo/signal.mjs +24 -7
- package/src/repo/source-identity.mjs +7 -0
- package/src/repo/workspace.mjs +12 -2
- package/src/repo/worktree.mjs +89 -22
- package/src/report/next.mjs +66 -6
- package/src/report/progress.mjs +724 -0
- package/src/report/render.mjs +3 -3
- package/src/run/migrate.mjs +278 -0
- package/src/run/paths.mjs +189 -0
- package/src/seat/index.mjs +2 -1
- package/src/web/app.css +170 -0
- package/src/web/app.mjs +690 -0
- package/src/web/index.html +38 -283
- package/src/web/server.mjs +197 -182
package/README.md
CHANGED
|
@@ -178,6 +178,12 @@ The installer resolves the newest release, checks the requirements and runs
|
|
|
178
178
|
curl -fsSL https://raw.githubusercontent.com/feliperun/faberun/main/install.sh | sh
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
+
On Windows, in PowerShell:
|
|
182
|
+
|
|
183
|
+
```powershell
|
|
184
|
+
irm https://raw.githubusercontent.com/feliperun/faberun/main/install.ps1 | iex
|
|
185
|
+
```
|
|
186
|
+
|
|
181
187
|
`faberun setup` runs at the end of the installer and registers the `faberun`
|
|
182
188
|
skill for the harnesses it finds (Claude Code, Codex and the shared
|
|
183
189
|
`~/.agents/skills`, plus any measured convention); `faberun skills register`
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
2
|
# Claude Code statusLine renderer for faberun ambient liveness. Reads
|
|
3
|
-
# the session JSON on stdin,
|
|
4
|
-
#
|
|
3
|
+
# the session JSON on stdin, resolves that repo's runs pointer (rewritten
|
|
4
|
+
# every controller tick), and prints one line:
|
|
5
5
|
# <run-id> · <state> · <node> <elapsed> · $<usd> · needs you: <n>
|
|
6
6
|
# elapsedSec, costUsd and needsYou are precomputed by the controller, so this
|
|
7
7
|
# never touches a clock or a node process, only formats. No pointer, an
|
|
8
8
|
# unreadable file, or one over the 1 KiB cap prints an empty line, exit 0.
|
|
9
9
|
# jq is used when present; otherwise sed/grep pull the flat top-level fields.
|
|
10
10
|
#
|
|
11
|
+
# This file is a hand-maintained seam: the repository's source-shape guard
|
|
12
|
+
# walks .mjs files only, so no test fails when this script and the resolver
|
|
13
|
+
# drift -- test/integrations/statusline.test.mjs is the only pin here.
|
|
14
|
+
#
|
|
11
15
|
# Allowance guard. The same session JSON carries rate_limits.five_hour
|
|
12
16
|
# (used_percentage plus a reset instant); the script otherwise reads stdin only
|
|
13
17
|
# for cwd. At ALLOWANCE_WARN_PCT or above, the line appends the warning and the
|
|
@@ -27,7 +31,32 @@ ALLOWANCE_WARN_PCT=85
|
|
|
27
31
|
|
|
28
32
|
session=$(cat)
|
|
29
33
|
repo=$(printf '%s' "$session" | sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p;s/.*"current_dir":"\([^"]*\)".*/\1/p' | head -n 1)
|
|
34
|
+
# The pointer follows the state (R2): the project registry under the faberun
|
|
35
|
+
# home maps the repository's resolved path to an opaque id, and the pointer
|
|
36
|
+
# sits in that project's runs directory. A repository whose runs never moved
|
|
37
|
+
# still answers in-tree -- R7 keeps the reading side dual-layout until
|
|
38
|
+
# `faberun migrate` runs -- so the legacy path stays the fallback, and the
|
|
39
|
+
# home side wins when both exist, exactly like the resolver. Two fixed paths:
|
|
40
|
+
# no glob, no newest-by-mtime. That is the half of R6 already true and to
|
|
41
|
+
# keep; the one machine-wide pointer is a later phase, not this lookup.
|
|
42
|
+
home=${FABERUN_HOME:-$HOME/.faberun}
|
|
43
|
+
index="$home/projects/index.json"
|
|
44
|
+
id=
|
|
45
|
+
if [ -n "$repo" ] && [ -f "$index" ]; then
|
|
46
|
+
if command -v jq >/dev/null 2>&1; then
|
|
47
|
+
id=$(jq -r --arg p "$repo" '.[$p] // empty' "$index" 2>/dev/null) || id=
|
|
48
|
+
else
|
|
49
|
+
# The index is pretty-printed, one `"path": "id"` pair per line, so a
|
|
50
|
+
# fixed-string grep for the quoted key picks exactly that one line and
|
|
51
|
+
# the id follows its colon. Fixed-string on purpose: a repo path is
|
|
52
|
+
# data, not a regex.
|
|
53
|
+
id=$(grep -F "\"$repo\"" "$index" 2>/dev/null | sed -n 's/^.*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
|
54
|
+
fi
|
|
55
|
+
fi
|
|
30
56
|
pointer="$repo/.runs/status.json"
|
|
57
|
+
if [ -n "$id" ] && [ -d "$home/projects/$id/runs" ]; then
|
|
58
|
+
pointer="$home/projects/$id/runs/status.json"
|
|
59
|
+
fi
|
|
31
60
|
|
|
32
61
|
# The nested five_hour.used_percentage. jq when present; otherwise flatten the
|
|
33
62
|
# session JSON and pull the field out of the five_hour object with sed. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"node": ">=22"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
|
-
"check": "
|
|
27
|
+
"check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
|
|
28
28
|
"typecheck": "tsc",
|
|
29
29
|
"test": "node --test test/*.test.mjs test/*/*.test.mjs",
|
|
30
30
|
"docs": "node src/cli/manual.mjs --write",
|
|
@@ -71,6 +71,15 @@ comparative arm, a follow-up spec). `statement` is the testable claim.
|
|
|
71
71
|
A requirement may add its own `- **constraints:** ...` line for a rule
|
|
72
72
|
scoped to it alone, distinct from the spec-wide Constraints section.
|
|
73
73
|
|
|
74
|
+
A requirement may also declare a measurement with
|
|
75
|
+
`- **measure:** command: <shell command>` — a read-only check the planner
|
|
76
|
+
runs against the repository *before* drafting anything, folded into the repo
|
|
77
|
+
facts the draft stage reads. The distinction from `proof` is timing: `proof`
|
|
78
|
+
is what the finished node satisfies, while `measure` is checkable before any
|
|
79
|
+
node exists — a grep, a `wc -l`, a small pipeline, run through the shell
|
|
80
|
+
exactly as written here, pipes included. Only the `command` kind is wired;
|
|
81
|
+
`path` and `judgment` measures parse but run nothing.
|
|
82
|
+
|
|
74
83
|
## What `faberun spec validate` checks
|
|
75
84
|
|
|
76
85
|
Deterministic, no model call. Rejects:
|
package/src/campaign/chain.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import { HEARTBEAT_INTERVAL_MS, createHeartbeat, groupAlive, heartbeatBreach, re
|
|
|
41
41
|
import { pidAlive, processStartToken } from "../run/lock.mjs";
|
|
42
42
|
import { delay, errorCode, errorMessage } from "../util.mjs";
|
|
43
43
|
import { writeJsonAtomic } from "../run/store.mjs";
|
|
44
|
+
import { runDirectory } from "../run/paths.mjs";
|
|
44
45
|
|
|
45
46
|
/** @typedef {import("../contract/index.mjs").ControllerIdentity} ControllerIdentity */
|
|
46
47
|
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
@@ -333,7 +334,7 @@ function runIdentityFor(contractPath) {
|
|
|
333
334
|
const raw = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(contractPath, "utf8")));
|
|
334
335
|
const cwd = resolve(dirname(contractPath), typeof raw.cwd === "string" ? raw.cwd : ".");
|
|
335
336
|
const id = String(raw.id);
|
|
336
|
-
return { id, cwd, runDir:
|
|
337
|
+
return { id, cwd, runDir: runDirectory(cwd, id) };
|
|
337
338
|
}
|
|
338
339
|
|
|
339
340
|
/**
|
package/src/campaign/index.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
10
10
|
import { writeJsonAtomic } from "../run/store.mjs";
|
|
11
|
+
import { projectIdForRunsDir, repositoryForRunsDir } from "../run/paths.mjs";
|
|
11
12
|
import { requireId, requirePacketHash, requireString, requireTimestamp } from "../contract/assert.mjs";
|
|
12
13
|
import { promoteRun } from "../repo/integrate.mjs";
|
|
13
14
|
import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, JOURNAL_FILE, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
|
|
@@ -132,7 +133,7 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
|
|
|
132
133
|
if (!readJournalForDedupe(campaignPath).some((entry) => entry.type === "retrospective")) {
|
|
133
134
|
throw new Error(`campaign ${campaign.id} has no recorded retrospective; record one with note --kind retrospective before close`);
|
|
134
135
|
}
|
|
135
|
-
const repoRoot =
|
|
136
|
+
const repoRoot = campaignRepoRoot(campaignPath);
|
|
136
137
|
const ledgerFiles = preserveCampaignLedger(campaignPath, repoRoot);
|
|
137
138
|
const closed = /** @type {Campaign} */ ({ ...campaign, status: "closed", closedAt: at, updatedAt: at });
|
|
138
139
|
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), closed);
|
|
@@ -140,6 +141,28 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
|
|
|
140
141
|
return { path: campaignPath, campaign: closed, ledgerFiles };
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The repository a campaign's ledger is preserved into. Under the home layout
|
|
146
|
+
* the campaign path carries the project id at the resolver's fixed position,
|
|
147
|
+
* so the repository is looked up live in the registry rather than derived by
|
|
148
|
+
* climbing: a project reassociated after the campaign was created preserves
|
|
149
|
+
* at its current repository, which a path cached anywhere would not. A
|
|
150
|
+
* campaign under a legacy `<repo>/.runs` keeps the old three-directory climb,
|
|
151
|
+
* which is exact there because the campaign path ends `<repo>/.runs/campaigns/<id>`.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} campaignPath
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
function campaignRepoRoot(campaignPath) {
|
|
157
|
+
const runsDir = resolve(campaignPath, "..", "..");
|
|
158
|
+
const repository = repositoryForRunsDir(runsDir);
|
|
159
|
+
if (repository) return repository;
|
|
160
|
+
if (projectIdForRunsDir(runsDir)) {
|
|
161
|
+
throw new Error(`no project record for ${campaignPath}; the registry cannot name the repository its ledger belongs to`);
|
|
162
|
+
}
|
|
163
|
+
return resolve(campaignPath, "..", "..", "..");
|
|
164
|
+
}
|
|
165
|
+
|
|
143
166
|
/**
|
|
144
167
|
* Copy a campaign's journal, record and each linked run's usage into
|
|
145
168
|
* `<repoRoot>/docs/campaigns/<id>/ledger/` so the comparative arm of the
|
|
@@ -237,6 +260,84 @@ export function assertContractManifestIntact(entry) {
|
|
|
237
260
|
}
|
|
238
261
|
}
|
|
239
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Append a contract to an active campaign's manifest, digesting its authored
|
|
265
|
+
* bytes exactly as `campaign init --contract` does. This is what an operator
|
|
266
|
+
* used to do by hand-editing `campaign.json` and recomputing
|
|
267
|
+
* `authoredContractDigest` themselves -- a step that parks the campaign for
|
|
268
|
+
* good on a typo, since `assertContractManifestIntact` refuses a digest that
|
|
269
|
+
* does not match at launch.
|
|
270
|
+
*
|
|
271
|
+
* Idempotent by path and bytes: adding the same contract path a second time,
|
|
272
|
+
* with the file unchanged since, finds its digest already recorded and
|
|
273
|
+
* returns the campaign untouched. A second call after the file changed
|
|
274
|
+
* updates the recorded digest in place rather than duplicating the entry.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} campaignPath
|
|
277
|
+
* @param {string} contractPath
|
|
278
|
+
* @param {{at?: string}} [options]
|
|
279
|
+
* @returns {{campaign: Campaign, added: boolean}}
|
|
280
|
+
*/
|
|
281
|
+
export function addContractToCampaign(campaignPath, contractPath, { at = new Date().toISOString() } = {}) {
|
|
282
|
+
requireTimestamp(at, "at");
|
|
283
|
+
requireString(contractPath, "contractPath");
|
|
284
|
+
if (!existsSync(contractPath)) throw new Error(`contract not found: ${contractPath}`);
|
|
285
|
+
const campaign = readCampaign(campaignPath);
|
|
286
|
+
if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
|
|
287
|
+
const digest = authoredContractDigest(contractPath);
|
|
288
|
+
const existingIndex = campaign.contracts.findIndex((entry) => entry.path === contractPath);
|
|
289
|
+
if (existingIndex !== -1 && campaign.contracts[existingIndex].digest === digest) {
|
|
290
|
+
return { campaign, added: false };
|
|
291
|
+
}
|
|
292
|
+
const entry = { path: contractPath, digest };
|
|
293
|
+
const contracts = existingIndex === -1
|
|
294
|
+
? [...campaign.contracts, entry]
|
|
295
|
+
: campaign.contracts.map((existing, index) => (index === existingIndex ? entry : existing));
|
|
296
|
+
const updated = /** @type {Campaign} */ ({ ...campaign, contracts, updatedAt: at });
|
|
297
|
+
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
|
|
298
|
+
return { campaign: updated, added: true };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Replace a manifest entry -- matched by its current, recorded path -- with a
|
|
303
|
+
* freshly authored contract. Re-authoring a phase after a blocked or failed
|
|
304
|
+
* node is the normal case in this repository, not the exception, so this is
|
|
305
|
+
* the command form of the hand edit an operator otherwise repeats every time.
|
|
306
|
+
*
|
|
307
|
+
* When the campaign's `attention` names the contract being replaced (its
|
|
308
|
+
* `contractPath` matches `oldPath`), the attention is cleared in the same
|
|
309
|
+
* write, so a stale park cannot go on refusing `campaign supervise` once the
|
|
310
|
+
* contract it named is gone. This does not go through `unparkCampaign`
|
|
311
|
+
* (`./unpark.mjs`, which owns the `campaign.unparked` journal event): that
|
|
312
|
+
* module already imports `chain.mjs`, which imports this one, and importing
|
|
313
|
+
* it back here would close that cycle.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} campaignPath
|
|
316
|
+
* @param {string} oldPath
|
|
317
|
+
* @param {string} newPath
|
|
318
|
+
* @param {{at?: string}} [options]
|
|
319
|
+
* @returns {{campaign: Campaign, replaced: CampaignContract, clearedAttention: CampaignAttention|null}}
|
|
320
|
+
*/
|
|
321
|
+
export function replaceContractInCampaign(campaignPath, oldPath, newPath, { at = new Date().toISOString() } = {}) {
|
|
322
|
+
requireTimestamp(at, "at");
|
|
323
|
+
requireString(oldPath, "oldPath");
|
|
324
|
+
requireString(newPath, "newPath");
|
|
325
|
+
if (!existsSync(newPath)) throw new Error(`contract not found: ${newPath}`);
|
|
326
|
+
const campaign = readCampaign(campaignPath);
|
|
327
|
+
if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
|
|
328
|
+
const index = campaign.contracts.findIndex((entry) => entry.path === oldPath);
|
|
329
|
+
if (index === -1) throw new Error(`no contract at ${oldPath} in campaign ${campaign.id}`);
|
|
330
|
+
const replaced = { path: newPath, digest: authoredContractDigest(newPath) };
|
|
331
|
+
const contracts = campaign.contracts.map((entry, position) => (position === index ? replaced : entry));
|
|
332
|
+
const attention = campaign.attention;
|
|
333
|
+
const clearAttention = attention !== undefined && attention.contractPath === oldPath;
|
|
334
|
+
/** @type {Campaign} */
|
|
335
|
+
const updated = { ...campaign, contracts, updatedAt: at };
|
|
336
|
+
if (clearAttention) delete updated.attention;
|
|
337
|
+
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
|
|
338
|
+
return { campaign: updated, replaced, clearedAttention: clearAttention ? /** @type {CampaignAttention} */ (attention) : null };
|
|
339
|
+
}
|
|
340
|
+
|
|
240
341
|
/**
|
|
241
342
|
* Persist one promotion in the campaign record. Idempotent by run id and the
|
|
242
343
|
* sha it landed: a re-invocation after a crash between the branch move and
|
package/src/campaign/metrics.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { renderMetricsJson, renderMetricsReport } from "../report/metrics-report
|
|
|
37
37
|
import { campaignDir } from "./layout.mjs";
|
|
38
38
|
import { readCampaign } from "./record.mjs";
|
|
39
39
|
import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
|
|
40
|
+
import { runsRoot } from "../run/paths.mjs";
|
|
40
41
|
|
|
41
42
|
/** Node statuses that are not terminal: everything else settles a logical node. */
|
|
42
43
|
const OPEN_STATUSES = new Set(["pending", "running"]);
|
|
@@ -401,7 +402,6 @@ function orderOf(entry) {
|
|
|
401
402
|
/** @type {import("node:util").ParseArgsOptionsConfig} */
|
|
402
403
|
export const METRICS_OPTIONS = { cwd: { type: "string" }, json: { type: "boolean" } };
|
|
403
404
|
|
|
404
|
-
const RUNS_DIR_NAME = ".runs";
|
|
405
405
|
const RUN_EVENTS_FILE = "events.jsonl";
|
|
406
406
|
const USAGE_LOG_FILE = "usage.jsonl";
|
|
407
407
|
const NOTIFY_LOG_FILE = "notify.jsonl";
|
|
@@ -488,7 +488,7 @@ function readRunNodes(runDir) {
|
|
|
488
488
|
* @returns {string}
|
|
489
489
|
*/
|
|
490
490
|
export function renderCampaignMetrics(campaignId, values = {}) {
|
|
491
|
-
const runsDir =
|
|
491
|
+
const runsDir = runsRoot(resolve(typeof values.cwd === "string" && values.cwd !== "" ? values.cwd : process.cwd()));
|
|
492
492
|
const sources = readMetricsSources(campaignDir(runsDir, campaignId), { runsDir });
|
|
493
493
|
const metrics = projectMetrics(sources);
|
|
494
494
|
return values.json === true ? renderMetricsJson(sources, metrics) : renderMetricsReport(sources, metrics);
|
package/src/cli/brand.mjs
CHANGED
|
@@ -191,6 +191,8 @@ export function renderUsage() {
|
|
|
191
191
|
"setup [--yes] [--no-skill] [--harnesses <a,b>] [--worker <id>] [--judge <id>] [--json]",
|
|
192
192
|
"init [--cwd <dir>] [--yes] [--no-skill] [--agentkit] [--greenfield|--stable] [--json]",
|
|
193
193
|
"update [--check] [--json]",
|
|
194
|
+
"project <new-path> [--from <old-path>]",
|
|
195
|
+
"migrate [--cwd <dir>]",
|
|
194
196
|
"models [--probe] [--json]",
|
|
195
197
|
"next [--cwd <dir>] [--json]",
|
|
196
198
|
"bulk-read --question <text> --paths <a,b,c> [--json]",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `campaign add-contract` and `campaign replace-contract`: give an active
|
|
3
|
+
* campaign's contract manifest a command, instead of the hand edit of
|
|
4
|
+
* `campaign.json` an operator otherwise repeats every time a phase is
|
|
5
|
+
* re-authored. `campaign.mjs` dispatches into this module; its
|
|
6
|
+
* `OPERATION_OPTIONS` entries live there so the command surface stays one
|
|
7
|
+
* table (`cli/campaign.mjs`'s own header explains why).
|
|
8
|
+
*/
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { addContractToCampaign, renderHandoff, replaceContractInCampaign, resolveCampaign } from "../campaign/index.mjs";
|
|
11
|
+
import { runsRoot } from "../run/paths.mjs";
|
|
12
|
+
|
|
13
|
+
/** @typedef {{cwd?: string, path?: string, replace?: string, eventId?: string}} CampaignContractValues */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} campaignId
|
|
17
|
+
* @param {CampaignContractValues} values
|
|
18
|
+
*/
|
|
19
|
+
export function addContract(campaignId, values) {
|
|
20
|
+
const cwd = resolve(values.cwd ?? ".");
|
|
21
|
+
const runsDir = runsRoot(cwd);
|
|
22
|
+
const { path } = resolveCampaign(runsDir, campaignId);
|
|
23
|
+
const contractPath = requiredPath(values.path, "--path");
|
|
24
|
+
const { campaign, added } = addContractToCampaign(path, contractPath);
|
|
25
|
+
renderHandoff(path, runsDir);
|
|
26
|
+
process.stdout.write(added
|
|
27
|
+
? `[campaign] contract added · ${contractPath} · ${campaign.contracts.length} contract(s)\n`
|
|
28
|
+
: `[campaign] contract already recorded · ${contractPath}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} campaignId
|
|
33
|
+
* @param {CampaignContractValues} values
|
|
34
|
+
*/
|
|
35
|
+
export function replaceContract(campaignId, values) {
|
|
36
|
+
const cwd = resolve(values.cwd ?? ".");
|
|
37
|
+
const runsDir = runsRoot(cwd);
|
|
38
|
+
const { path } = resolveCampaign(runsDir, campaignId);
|
|
39
|
+
const contractPath = requiredPath(values.path, "--path");
|
|
40
|
+
const oldPath = requiredPath(values.replace, "--replace");
|
|
41
|
+
const { clearedAttention } = replaceContractInCampaign(path, oldPath, contractPath);
|
|
42
|
+
renderHandoff(path, runsDir);
|
|
43
|
+
process.stdout.write(`[campaign] contract replaced · ${oldPath} -> ${contractPath}\n`);
|
|
44
|
+
if (clearedAttention) process.stdout.write(`[campaign] attention cleared · ${clearedAttention.code}\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {unknown} value
|
|
49
|
+
* @param {string} label
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function requiredPath(value, label) {
|
|
53
|
+
if (typeof value !== "string" || !value.trim()) throw new TypeError(`${label} requires a value`);
|
|
54
|
+
return resolve(value);
|
|
55
|
+
}
|
package/src/cli/campaign.mjs
CHANGED
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
renderHandoff,
|
|
11
11
|
resolveCampaign,
|
|
12
12
|
} from "../campaign/index.mjs";
|
|
13
|
+
import { addContract, replaceContract } from "./campaign-contract.mjs";
|
|
13
14
|
import { lockStale, pidAlive, processStartToken, readLock } from "../run/lock.mjs";
|
|
15
|
+
import { runsRoot } from "../run/paths.mjs";
|
|
14
16
|
import { syncAgentSignal } from "../repo/signal.mjs";
|
|
15
17
|
import { acknowledgeJournalEvent, appendJournal, appendSeatAllowanceEvent, readJournal, watchJournal } from "../campaign/journal.mjs";
|
|
16
18
|
import { driveCampaignChain } from "../campaign/chain.mjs";
|
|
@@ -85,12 +87,14 @@ const OPERATION_OPTIONS = {
|
|
|
85
87
|
close: { cwd: { type: "string" }, "event-id": { type: "string" } },
|
|
86
88
|
supervise: { cwd: { type: "string" }, "allow-main": { type: "boolean" } },
|
|
87
89
|
unpark: { cwd: { type: "string" }, force: { type: "boolean" }, "event-id": { type: "string" } },
|
|
90
|
+
"add-contract": { cwd: { type: "string" }, path: { type: "string" } },
|
|
91
|
+
"replace-contract": { cwd: { type: "string" }, path: { type: "string" }, replace: { type: "string" } },
|
|
88
92
|
show: { cwd: { type: "string" } },
|
|
89
93
|
sync: { cwd: { type: "string" }, "session-id": { type: "string" } },
|
|
90
94
|
ack: { cwd: { type: "string" }, "session-id": { type: "string" }, "event-id": { type: "string" } },
|
|
91
95
|
};
|
|
92
96
|
|
|
93
|
-
/** @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, force?: boolean}} CliValues */
|
|
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, force?: boolean, path?: string, replace?: string}} CliValues */
|
|
94
98
|
/** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
|
|
95
99
|
|
|
96
100
|
/**
|
|
@@ -118,6 +122,8 @@ export async function campaignCli(args) {
|
|
|
118
122
|
if (operation === "show") return show(campaignId, values);
|
|
119
123
|
if (operation === "sync") return sync(campaignId, values);
|
|
120
124
|
if (operation === "ack") return ack(campaignId, values);
|
|
125
|
+
if (operation === "add-contract") return addContract(campaignId, values);
|
|
126
|
+
if (operation === "replace-contract") return replaceContract(campaignId, values);
|
|
121
127
|
return usage();
|
|
122
128
|
}
|
|
123
129
|
|
|
@@ -330,7 +336,7 @@ function watchLockStale(occupant) {
|
|
|
330
336
|
*/
|
|
331
337
|
async function init(campaignId, values) {
|
|
332
338
|
const cwd = resolve(values.cwd ?? ".");
|
|
333
|
-
const runsDir =
|
|
339
|
+
const runsDir = runsRoot(cwd);
|
|
334
340
|
const goal = textValue(values.goal, "--goal");
|
|
335
341
|
const contracts = contractManifest(values.contract);
|
|
336
342
|
const created = initializeCampaign(runsDir, { campaignId, goal, contracts, landBranch: values.landBranch });
|
|
@@ -476,7 +482,7 @@ function close(campaignId, values) {
|
|
|
476
482
|
*/
|
|
477
483
|
async function supervise(campaignId, values) {
|
|
478
484
|
const cwd = resolve(values.cwd ?? ".");
|
|
479
|
-
const runsDir =
|
|
485
|
+
const runsDir = runsRoot(cwd);
|
|
480
486
|
const { path } = resolveCampaign(runsDir, campaignId);
|
|
481
487
|
const outcome = await driveCampaignChain(path, {
|
|
482
488
|
repo: cwd,
|
|
@@ -502,13 +508,42 @@ async function supervise(campaignId, values) {
|
|
|
502
508
|
* @param {CliValues} values
|
|
503
509
|
*/
|
|
504
510
|
function unpark(campaignId, values) {
|
|
505
|
-
const { path, runsDir } = selectCampaign(campaignId, values);
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
511
|
+
const { path, runsDir, campaign } = selectCampaign(campaignId, values);
|
|
512
|
+
try {
|
|
513
|
+
const result = unparkCampaign(path, {
|
|
514
|
+
runsDir,
|
|
515
|
+
force: values.force === true,
|
|
516
|
+
eventId: values.eventId ?? randomUUID(),
|
|
517
|
+
});
|
|
518
|
+
process.stdout.write(`[campaign] ${result.campaign.id} unparked · ${result.cleared.code} cleared\n`);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
throw explainStillParked(error, campaign.attention);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* `unparkCampaign`'s still-parked refusal names the run and its state but not
|
|
526
|
+
* the node holding the park, and tells the operator to pass `--force` without
|
|
527
|
+
* saying what that flag will and will not do -- an operator told only the
|
|
528
|
+
* flag types the flag. The campaign's own `attention` record already carries
|
|
529
|
+
* the node, its status, and the resume command the chain wrote when it
|
|
530
|
+
* parked (see `chain.mjs`'s `park`), so recompose the message from that
|
|
531
|
+
* instead of changing what the still-parked check itself reports. Any other
|
|
532
|
+
* refusal (closed campaign, not parked) is passed through unchanged.
|
|
533
|
+
*
|
|
534
|
+
* @param {unknown} error
|
|
535
|
+
* @param {Campaign["attention"]} attention
|
|
536
|
+
* @returns {unknown}
|
|
537
|
+
*/
|
|
538
|
+
function explainStillParked(error, attention) {
|
|
539
|
+
if (!(error instanceof Error) || !attention) return error;
|
|
540
|
+
const match = /^run (\S+) is still (parked|canceled); resume it first or pass --force$/u.exec(error.message);
|
|
541
|
+
if (!match) return error;
|
|
542
|
+
const [, runId, state] = match;
|
|
543
|
+
const node = attention.node ? ` node ${attention.node}${attention.status ? ` (${attention.status})` : ""}` : "";
|
|
544
|
+
return new Error(
|
|
545
|
+
`run ${runId}${node} is still ${state}. --force clears only the campaign's parked attention so \`supervise campaign\` can drive the chain again; it does not resume, cancel, or otherwise change run ${runId}, which stays ${state} until you act on it directly. Resume the run, or pass --force to move on without resuming it.`,
|
|
546
|
+
);
|
|
512
547
|
}
|
|
513
548
|
|
|
514
549
|
/**
|
|
@@ -634,7 +669,7 @@ function sessionCursorId(sessionId) {
|
|
|
634
669
|
*/
|
|
635
670
|
function listCampaigns(values) {
|
|
636
671
|
const cwd = resolve(values.cwd ?? ".");
|
|
637
|
-
const runsDir =
|
|
672
|
+
const runsDir = runsRoot(cwd);
|
|
638
673
|
const { campaigns, corrupt } = discoverCampaigns(runsDir);
|
|
639
674
|
if (!campaigns.length && !corrupt.length) {
|
|
640
675
|
process.stdout.write("[campaign] none\n");
|
|
@@ -658,7 +693,7 @@ function listCampaigns(values) {
|
|
|
658
693
|
*/
|
|
659
694
|
function selectCampaign(campaignId, values) {
|
|
660
695
|
const cwd = resolve(values.cwd ?? ".");
|
|
661
|
-
const runsDir =
|
|
696
|
+
const runsDir = runsRoot(cwd);
|
|
662
697
|
return { ...resolveCampaign(runsDir, campaignId), runsDir };
|
|
663
698
|
}
|
|
664
699
|
|
|
@@ -738,7 +773,7 @@ function positiveIntervalMs(value) {
|
|
|
738
773
|
|
|
739
774
|
function usage() {
|
|
740
775
|
process.stderr.write(
|
|
741
|
-
"usage: faberun campaign <init|watch|attach|note|resolve|close|supervise|unpark|show|list|sync|ack> <campaign-id> [--cwd <dir>] ...\n",
|
|
776
|
+
"usage: faberun campaign <init|watch|attach|note|resolve|close|supervise|unpark|show|list|sync|ack|add-contract|replace-contract> <campaign-id> [--cwd <dir>] ...\n",
|
|
742
777
|
);
|
|
743
778
|
process.exitCode = 2;
|
|
744
779
|
}
|
package/src/cli/init.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
import { colorLevel, statusToken } from "./brand.mjs";
|
|
20
20
|
import { installSkills } from "./skills.mjs";
|
|
21
21
|
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
22
|
+
import { RUNS_DIR_NAME } from "../run/paths.mjs";
|
|
22
23
|
|
|
23
24
|
/** @typedef {(text: string) => void} Writer */
|
|
24
25
|
/** @typedef {(question: string) => Promise<string>} Asker */
|
|
@@ -152,6 +153,11 @@ function isGitWorkTree(cwd) {
|
|
|
152
153
|
* exists, creating the file when missing. A file that does not end in a newline
|
|
153
154
|
* gets one before the appended line, so the result is always a whole line.
|
|
154
155
|
*
|
|
156
|
+
* Run state itself lives under the faberun home now, but this line is not
|
|
157
|
+
* obsolete: every attempt worktree is still a git working tree, and R3 keeps
|
|
158
|
+
* the worker's result sidecar inside it, under `.runs/`, where the scope diff
|
|
159
|
+
* must not see it. Do not remove this as cleanup.
|
|
160
|
+
*
|
|
155
161
|
* @param {string} cwd
|
|
156
162
|
* @returns {string} the `.gitignore` path written or confirmed
|
|
157
163
|
*/
|
|
@@ -160,11 +166,11 @@ function ensureRunsIgnored(cwd) {
|
|
|
160
166
|
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
161
167
|
const ignored = existing.split(/\r?\n/u).some((line) => {
|
|
162
168
|
const trimmed = line.trim();
|
|
163
|
-
return trimmed ===
|
|
169
|
+
return trimmed === `${RUNS_DIR_NAME}/` || trimmed === RUNS_DIR_NAME;
|
|
164
170
|
});
|
|
165
171
|
if (!ignored) {
|
|
166
172
|
const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
167
|
-
writeFileSync(path, `${existing}${separator}
|
|
173
|
+
writeFileSync(path, `${existing}${separator}${RUNS_DIR_NAME}/\n`);
|
|
168
174
|
}
|
|
169
175
|
return path;
|
|
170
176
|
}
|
package/src/cli/launch.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { randomUUID } from "node:crypto";
|
|
|
23
23
|
import { readRunNodes } from "../engine/scheduler.mjs";
|
|
24
24
|
import { spawn } from "node:child_process";
|
|
25
25
|
import { validateContract } from "../contract/index.mjs";
|
|
26
|
+
import { runDirectory } from "../run/paths.mjs";
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The file a detached child is spawned as. It must be the CLI and not this
|
|
@@ -182,7 +183,7 @@ export function bootstrapRunDir(command, target) {
|
|
|
182
183
|
if (!target) return null;
|
|
183
184
|
const path = resolve(target);
|
|
184
185
|
const contract = validateContract(JSON.parse(readFileSync(path, "utf8")), path);
|
|
185
|
-
return
|
|
186
|
+
return runDirectory(contract.cwd, contract.id);
|
|
186
187
|
}
|
|
187
188
|
if (["resume", "cancel"].includes(command)) {
|
|
188
189
|
if (!target) return null;
|
package/src/cli/plan.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* sequencing and every decision the pipeline makes.
|
|
6
6
|
*/
|
|
7
7
|
import { readFileSync } from "node:fs";
|
|
8
|
-
import {
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
9
|
import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
|
|
10
10
|
import { classifyRunProgress } from "../campaign/chain.mjs";
|
|
11
11
|
import { runProgress } from "../engine/supervise.mjs";
|
|
@@ -13,6 +13,7 @@ import { DISCOVERY_RUNTIME_DEFINITIONS } from "../engine/runtime-discovery.mjs";
|
|
|
13
13
|
import { validateRuntime } from "../contract/runtime.mjs";
|
|
14
14
|
import { delay } from "../util.mjs";
|
|
15
15
|
import { runPlanningPipeline } from "../plan/pipeline.mjs";
|
|
16
|
+
import { runDirectory } from "../run/paths.mjs";
|
|
16
17
|
|
|
17
18
|
/** How often a foreground `plan` polls a launched stage's run directory. */
|
|
18
19
|
const DEFAULT_POLL_MS = 1_000;
|
|
@@ -117,7 +118,7 @@ export async function planCli(target, values) {
|
|
|
117
118
|
launch: async (contractPath, contract) => {
|
|
118
119
|
const child = detachSelf("run", contractPath);
|
|
119
120
|
if (child.pid === undefined) throw new Error("detached planning run has no pid");
|
|
120
|
-
await waitForBootstrap(
|
|
121
|
+
await waitForBootstrap(runDirectory(contract.cwd, contract.id), child.pid, child);
|
|
121
122
|
},
|
|
122
123
|
wait: async (runDir) => {
|
|
123
124
|
for (;;) {
|