faberun 0.19.3 → 0.21.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 +2 -1
- package/src/campaign/brief-cli.mjs +174 -0
- package/src/campaign/brief-text.mjs +96 -0
- package/src/campaign/campaign-brief.mjs +772 -0
- package/src/campaign/projection.mjs +34 -0
- package/src/cli/campaign.mjs +22 -2
- package/src/engine/dispatch.mjs +8 -1
- package/src/engine/process.mjs +36 -8
- package/src/engine/transcript.mjs +43 -1
- package/src/harnesses/index.mjs +1 -1
- package/src/harnesses/zcode/index.mjs +49 -4
- package/src/plan/freeze.mjs +135 -34
- package/src/plan/pipeline-shape.mjs +100 -0
- package/src/plan/pipeline.mjs +66 -115
- package/src/plan/template.mjs +88 -25
- package/src/report/campaign-brief-estimate.mjs +450 -0
- package/src/report/campaign-brief-html.mjs +439 -0
- package/src/report/campaign-brief.mjs +409 -0
- package/src/report/mdhtml-release.json +30 -0
- package/src/run/usage.mjs +265 -0
- package/src/web/campaign-brief-server.mjs +401 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|
|
@@ -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
|
+
}
|