faberun 0.20.0 → 0.22.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.20.0",
3
+ "version": "0.22.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": {
@@ -26,6 +26,7 @@
26
26
  "scripts": {
27
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
+ "check:campaign-brief-render": "node src/report/campaign-brief-html.mjs",
29
30
  "test": "node --test --import ./test/scoped-home.mjs --import ./test/git-env.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
30
31
  "docs": "node src/cli/manual.mjs --write",
31
32
  "docs:check": "node src/cli/manual.mjs --check",
@@ -45,22 +45,22 @@ attempt's sealed sha, the same continuation rule as any other retry.
45
45
  One controller drives a run, holding `<run-dir>/controller.lock`: `{pid,
46
46
  processStartToken, startedAt, hostname}`. Acquisition is an exclusive create
47
47
  with no TTL. A contender treats the lock as stale only once it can prove the
48
- holder dead — the pid is gone, or its process start token no longer matches
49
- (pid recycled); anything less is `controller_active` and it exits untouched.
50
- Takeover renames the lock aside, re-checks the captured record is stale, then
51
- installs its own; a capture that turns out live is handed back. Worker/judge/
48
+ holder dead — pid gone, or its start token no longer matches (pid recycled);
49
+ otherwise it exits `controller_active`, untouched. Takeover renames the lock
50
+ aside and re-checks it is stale before installing its own. Worker/judge/
52
51
  verification children run detached in their own process group, so before
53
52
  dispatching new work `resume`'s recovery pass terminates (`SIGTERM` then
54
53
  `SIGKILL`, same as `cancel`) every invocation recorded for a `running` node —
55
54
  unless it is still inside its deadline, when it is adopted and its result read.
56
- `cancel <run-dir>` signals a live controller first, so its own takeover never
57
- waits on an expiry.
55
+ `cancel` signals a live controller first, so its takeover never waits.
58
56
 
59
57
  `supervise <run-dir> [--detach] [--interval <sec>]` is the watchdog above that.
60
58
  It holds no lock and writes no state: every interval (default 30s) it launches
61
59
  `resume --detach` when a node is unfinished and no controller is live, exits 0
62
60
  once all are terminal, and stops after three failed launches. An empty run
63
- directory is never resumed it has not proved it needs to be.
61
+ directory is never resumed. A detached controller outlives its launcher,
62
+ not the session scope (cgroup) holding it: run long work under `tmux`,
63
+ `systemd-run` or the seat.
64
64
 
65
65
  ## Runtime discovery
66
66
 
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `faberun campaign brief generate`: the R7 sharing surface. The campaign CLI
3
+ * parses argv and dispatches here; every artifact path, the artifact write
4
+ * order, the stale-HTML cleanup and the renderer failure report live in this
5
+ * module.
6
+ *
7
+ * Generation is deliberately the operator's explicit act. `generate` verifies
8
+ * the pinned plan, contract and spec through `buildBriefModel`, writes the
9
+ * Markdown source beside `plan.json`, then asks the optional external renderer
10
+ * for the portable sibling copy. A successful render and check replaces the
11
+ * HTML atomically; an absent or failing renderer leaves the Markdown usable,
12
+ * removes any prior HTML for that phase, and exits with the renderer's named
13
+ * error. Nothing here writes the spec, the plan, the contract, the
14
+ * `operator-brief.md` continuity capsule or an external service.
15
+ *
16
+ * The usage cutoff is read from recorded evidence -- the newest completed
17
+ * execution node in this project's pool -- not the wall clock, so regenerating
18
+ * from the same snapshots produces the same bytes.
19
+ */
20
+ import { readFileSync, rmSync } from "node:fs";
21
+ import { join, resolve } from "node:path";
22
+ import { buildBriefModel } from "./campaign-brief.mjs";
23
+ import { resolveCampaign } from "./index.mjs";
24
+ import { readProjectionState } from "./projection.mjs";
25
+ import { renderCampaignBriefMarkdown } from "../report/campaign-brief.mjs";
26
+ import { CampaignBriefRenderError, renderCampaignBriefHtml } from "../report/campaign-brief-html.mjs";
27
+ import { estimateBriefExpense } from "../report/campaign-brief-estimate.mjs";
28
+ import { collectCompletedExecutionNodes } from "../run/usage.mjs";
29
+ import { runsRoot } from "../run/paths.mjs";
30
+ import { writeTextAtomic } from "../run/store.mjs";
31
+
32
+ const MARKDOWN_FILE = "campaign-brief.md";
33
+ const HTML_FILE = "campaign-brief.md.html";
34
+ // A cutoff far enough ahead that the wide pool carries every recorded node;
35
+ // the pool that feeds the estimate is then filtered to the real 90-day window
36
+ // around the newest recorded completion, not this sentinel.
37
+ const WIDE_WINDOW_DAYS = 36_500;
38
+ const WIDE_CUTOFF = "9999-12-31T23:59:59.999Z";
39
+
40
+ /** @typedef {import("./campaign-brief.mjs").BriefModel} BriefModel */
41
+ /** @typedef {import("../report/campaign-brief-html.mjs").RenderCampaignBriefHtmlOptions} RenderCampaignBriefHtmlOptions */
42
+ /** @typedef {import("../report/campaign-brief-html.mjs").RenderCampaignBriefHtmlResult} RenderCampaignBriefHtmlResult */
43
+ /** @typedef {import("../run/usage.mjs").CompletedExecutionPool} CompletedExecutionPool */
44
+
45
+ /**
46
+ * @typedef {object} BriefGenerateOptions
47
+ * @property {string} campaignId
48
+ * @property {unknown} phase
49
+ * @property {string} [cwd]
50
+ * @property {string} [runsDir] resolved runs root; defaults to `runsRoot(cwd)`.
51
+ * @property {string} [mdhtmlBin]
52
+ * @property {(markdown: string, options: RenderCampaignBriefHtmlOptions) => RenderCampaignBriefHtmlResult} [renderHtml] injectable renderer for tests.
53
+ * @property {(line: string) => void} [stdout]
54
+ * @property {(line: string) => void} [stderr]
55
+ */
56
+
57
+ /**
58
+ * @typedef {object} BriefGenerateResult
59
+ * @property {BriefModel} model
60
+ * @property {string} markdownPath absolute
61
+ * @property {string|null} htmlPath absolute when the render succeeded, else null
62
+ * @property {string|null} code the renderer's named error, or null on success
63
+ */
64
+
65
+ /**
66
+ * Generate the Campaign Brief for one frozen phase plan. The Markdown is
67
+ * always written before the renderer is asked, and the HTML only survives a
68
+ * successful build, check, audit and source round-trip. A renderer failure is
69
+ * reported on stderr with its stable code, the Markdown path is printed, and
70
+ * any prior HTML for the phase is removed.
71
+ *
72
+ * @param {BriefGenerateOptions} options
73
+ * @returns {BriefGenerateResult}
74
+ */
75
+ export function generateCampaignBrief(options) {
76
+ const campaignId = options.campaignId;
77
+ const cwd = resolve(options.cwd ?? ".");
78
+ const runsDir = options.runsDir ?? runsRoot(cwd);
79
+ const { path: campaignPath, campaign } = resolveCampaign(runsDir, campaignId);
80
+ const phase = requirePhase(options.phase);
81
+ const planDir = join(campaignPath, "plans", phase);
82
+ const markdownPath = join(planDir, MARKDOWN_FILE);
83
+ const htmlPath = join(planDir, HTML_FILE);
84
+ const stdout = options.stdout ?? ((line) => process.stdout.write(`${line}\n`));
85
+ const stderr = options.stderr ?? ((line) => process.stderr.write(`${line}\n`));
86
+
87
+ const { state, cursor: journalCursor } = readProjectionState(campaignPath, campaign);
88
+ const pool = collectCompletedExecutionNodes({ runsRoot: runsDir, cutoff: WIDE_CUTOFF, windowDays: WIDE_WINDOW_DAYS });
89
+ const usageSampleCutoff = newestCompletedAt(pool);
90
+ const contract = readContractTolerant(join(planDir, "contract.json"));
91
+ const estimate = contract === null ? undefined : estimateBriefExpense({ contract, cutoff: usageSampleCutoff, pool });
92
+ const model = buildBriefModel({
93
+ campaignId,
94
+ planPath: join(planDir, "plan.json"),
95
+ cwd,
96
+ projection: state,
97
+ journalCursor,
98
+ usageSampleCutoff,
99
+ estimate,
100
+ });
101
+
102
+ const markdown = renderCampaignBriefMarkdown(model);
103
+ writeTextAtomic(markdownPath, markdown);
104
+
105
+ const renderHtml = options.renderHtml ?? renderCampaignBriefHtml;
106
+ try {
107
+ renderHtml(markdown, { outputPath: htmlPath, title: `Campaign brief — ${campaignId}`, mdhtmlBin: options.mdhtmlBin, cwd });
108
+ } catch (error) {
109
+ const code = error instanceof CampaignBriefRenderError ? error.code : "CAMPAIGN_BRIEF_RENDER_FAILED";
110
+ rmSync(htmlPath, { force: true });
111
+ stderr(`[fail] ${code} · ${error instanceof Error ? error.message : String(error)}`);
112
+ stdout(`[brief] markdown · ${markdownPath}`);
113
+ process.exitCode = 1;
114
+ return { model, markdownPath, htmlPath: null, code };
115
+ }
116
+ stdout(`[brief] markdown · ${markdownPath}`);
117
+ stdout(`[brief] html · ${htmlPath}`);
118
+ return { model, markdownPath, htmlPath, code: null };
119
+ }
120
+
121
+ /**
122
+ * `--phase` names one directory under `<campaignDir>/plans/`; anything with a
123
+ * path separator or a dot segment would escape it.
124
+ *
125
+ * @param {unknown} value
126
+ * @returns {string}
127
+ */
128
+ function requirePhase(value) {
129
+ if (typeof value !== "string" || !value.trim()) {
130
+ throw new TypeError("campaign brief generate requires --phase <phase>");
131
+ }
132
+ if (value === "." || value === ".." || value.includes("/") || value.includes("\\")) {
133
+ throw new TypeError("--phase must be a single path segment");
134
+ }
135
+ return value;
136
+ }
137
+
138
+ /**
139
+ * The newest completion in the wide pool, as the recorded usage cutoff. A pool
140
+ * with no readable completion has no cutoff, which the estimate reports as
141
+ * `insufficient data` rather than inventing one.
142
+ *
143
+ * @param {CompletedExecutionPool} pool
144
+ * @returns {string|null}
145
+ */
146
+ function newestCompletedAt(pool) {
147
+ let newest = null;
148
+ for (const node of pool.nodes) {
149
+ if (typeof node.completedAt !== "string") continue;
150
+ const completedMs = Date.parse(node.completedAt);
151
+ if (!Number.isFinite(completedMs)) continue;
152
+ if (newest === null || completedMs > newest) newest = completedMs;
153
+ }
154
+ return newest === null ? null : new Date(newest).toISOString();
155
+ }
156
+
157
+ /**
158
+ * Read the sibling contract the plan froze, tolerating anything unreadable:
159
+ * `buildBriefModel` owns the refusal and names the exact missing or mismatched
160
+ * input, so a malformed contract here simply supplies no estimate and lets the
161
+ * model raise its typed error.
162
+ *
163
+ * @param {string} path
164
+ * @returns {Record<string, any>|null}
165
+ */
166
+ function readContractTolerant(path) {
167
+ let value;
168
+ try {
169
+ value = JSON.parse(readFileSync(path, "utf8"));
170
+ } catch {
171
+ return null;
172
+ }
173
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
174
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Campaign Brief text parsing and list normalization. Separate from the model
3
+ * builder because these small Markdown readers are a distinct boundary from
4
+ * the frozen-plan facts they shape into the brief.
5
+ */
6
+
7
+ /** @typedef {{measure: string, target: string, evidence: string}} BriefSuccessCriterion */
8
+ /** @typedef {{risk: string, impact: string, mitigation: string}} BriefRisk */
9
+
10
+ /**
11
+ * @param {string} body
12
+ * @returns {string[]}
13
+ */
14
+ export function bulletLines(body) {
15
+ return body
16
+ .split("\n")
17
+ .map((line) => line.trim())
18
+ .filter((line) => line.startsWith("- "))
19
+ .map((line) => line.slice(2).trim())
20
+ .filter((line) => line.length > 0);
21
+ }
22
+
23
+ /**
24
+ * @param {string} body
25
+ * @returns {BriefSuccessCriterion[]}
26
+ */
27
+ export function successCriteriaRows(body) {
28
+ const rows = body.split("\n").filter((line) => line.trim().startsWith("|"));
29
+ if (rows.length < 3) return [];
30
+ const header = tableCells(rows[0]).map((cell) => cell.toLowerCase());
31
+ const measureIndex = header.findIndex((cell) => cell.includes("measure"));
32
+ const targetIndex = header.findIndex((cell) => cell.includes("target"));
33
+ const evidenceIndex = header.findIndex((cell) => cell.includes("evidence"));
34
+ return rows.slice(2).map((row) => {
35
+ const cells = tableCells(row);
36
+ return {
37
+ measure: measureIndex >= 0 ? cells[measureIndex] ?? "" : "",
38
+ target: targetIndex >= 0 ? cells[targetIndex] ?? "" : "",
39
+ evidence: evidenceIndex >= 0 ? cells[evidenceIndex] ?? "" : "",
40
+ };
41
+ }).filter((criterion) => criterion.measure.length > 0 || criterion.target.length > 0);
42
+ }
43
+
44
+ /**
45
+ * @param {string} body
46
+ * @returns {BriefRisk[]}
47
+ */
48
+ export function riskRows(body) {
49
+ const rows = body.split("\n").filter((line) => line.trim().startsWith("|"));
50
+ if (rows.length < 3) return [];
51
+ const header = tableCells(rows[0]).map((cell) => cell.toLowerCase());
52
+ const riskIndex = header.findIndex((cell) => cell.includes("risk"));
53
+ const impactIndex = header.findIndex((cell) => cell.includes("impact"));
54
+ const mitigationIndex = header.findIndex((cell) => cell.includes("mitigation"));
55
+ return rows.slice(2).map((row) => {
56
+ const cells = tableCells(row);
57
+ return {
58
+ risk: riskIndex >= 0 ? cells[riskIndex] ?? "" : "",
59
+ impact: impactIndex >= 0 ? cells[impactIndex] ?? "" : "",
60
+ mitigation: mitigationIndex >= 0 ? cells[mitigationIndex] ?? "" : "",
61
+ };
62
+ }).filter((risk) => risk.risk.length > 0);
63
+ }
64
+
65
+ /** @param {string} row @returns {string[]} */
66
+ function tableCells(row) {
67
+ const trimmed = row.trim();
68
+ const withoutLeading = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
69
+ const withoutTrailing = withoutLeading.endsWith("|") ? withoutLeading.slice(0, -1) : withoutLeading;
70
+ return withoutTrailing.split("|").map((value) => value.trim());
71
+ }
72
+
73
+ /** @param {string} text @returns {string|null} */
74
+ export function firstSentence(text) {
75
+ const collapsed = text.replace(/\s+/gu, " ").trim();
76
+ if (!collapsed) return null;
77
+ const match = /^(.*?[.!?])(?:\s|$)/u.exec(collapsed);
78
+ return (match ? match[1] : collapsed).trim();
79
+ }
80
+
81
+ /** @param {unknown} value @returns {any[]} */
82
+ export function asArray(value) {
83
+ return Array.isArray(value) ? value : [];
84
+ }
85
+
86
+ /** @template T @param {T[]} values @returns {T[]} */
87
+ export function unique(values) {
88
+ const seen = new Set();
89
+ const result = [];
90
+ for (const value of values) {
91
+ if (seen.has(value)) continue;
92
+ seen.add(value);
93
+ result.push(value);
94
+ }
95
+ return result;
96
+ }