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 +2 -1
- package/skills/faberun/references/operations.md +7 -7
- 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/chain.mjs +30 -1
- package/src/campaign/projection.mjs +34 -0
- package/src/cli/campaign.mjs +22 -2
- package/src/contract/index.mjs +5 -3
- package/src/contract/runtime.mjs +22 -1
- package/src/contract/scope-findings.mjs +12 -0
- package/src/contract/snapshot.mjs +16 -5
- package/src/engine/dispatch.mjs +6 -4
- package/src/engine/process.mjs +1 -0
- package/src/engine/result-file.mjs +13 -1
- package/src/engine/settle.mjs +1 -0
- package/src/engine/verify.mjs +37 -2
- package/src/harnesses/codex/index.mjs +1 -0
- package/src/harnesses/index.mjs +18 -1
- package/src/plan/freeze.mjs +240 -35
- package/src/plan/pipeline-shape.mjs +100 -0
- package/src/plan/pipeline.mjs +68 -116
- package/src/plan/template.mjs +88 -25
- package/src/repo/declared-paths.mjs +16 -0
- package/src/repo/workspace.mjs +42 -3
- package/src/repo/worktree.mjs +34 -2
- 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/final.mjs +5 -4
- package/src/report/mdhtml-release.json +30 -0
- package/src/report/render.mjs +4 -3
- package/src/run/usage.mjs +265 -0
- package/src/web/campaign-brief-server.mjs +401 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Campaign Brief HTML owns the optional mdhtml boundary. Markdown remains the
|
|
3
|
+
* durable source; this module either replaces the portable copy atomically or
|
|
4
|
+
* removes it and reports a stable rendering failure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { homedir, tmpdir } from "node:os";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
|
+
import { renderCampaignBriefMarkdown } from "./campaign-brief.mjs";
|
|
23
|
+
|
|
24
|
+
/** @typedef {import("../campaign/campaign-brief.mjs").BriefModel} BriefModel */
|
|
25
|
+
|
|
26
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const ROOT = resolve(HERE, "..", "..");
|
|
28
|
+
const MANIFEST_PATH = join(HERE, "mdhtml-release.json");
|
|
29
|
+
const THEME_FILE = "faberun-brief.theme.css";
|
|
30
|
+
|
|
31
|
+
/** @typedef {{file: string, sha256: string}} MdhtmlAsset */
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} MdhtmlRelease
|
|
34
|
+
* @property {string} schema
|
|
35
|
+
* @property {string} repo
|
|
36
|
+
* @property {string} tag
|
|
37
|
+
* @property {string} version
|
|
38
|
+
* @property {number} major
|
|
39
|
+
* @property {string} minimumVersion
|
|
40
|
+
* @property {Record<string, MdhtmlAsset>} assets
|
|
41
|
+
*/
|
|
42
|
+
/** @typedef {{major: number, minor: number, patch: number, raw: string}} MdhtmlVersion */
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {object} RenderCampaignBriefHtmlOptions
|
|
45
|
+
* @property {string} outputPath absolute destination ending in `.md.html`.
|
|
46
|
+
* @property {string} [title]
|
|
47
|
+
* @property {string} [mdhtmlBin]
|
|
48
|
+
* @property {string} [cwd]
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {object} RenderCampaignBriefHtmlResult
|
|
52
|
+
* @property {string} html
|
|
53
|
+
* @property {string} outputPath
|
|
54
|
+
* @property {string} version
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** A stable error surface for callers and CLI reporting. */
|
|
58
|
+
export class CampaignBriefRenderError extends Error {
|
|
59
|
+
/** @param {string} code @param {string} message @param {{cause?: unknown}} [options] */
|
|
60
|
+
constructor(code, message, options = {}) {
|
|
61
|
+
super(message, options);
|
|
62
|
+
this.name = "CampaignBriefRenderError";
|
|
63
|
+
this.code = code;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @returns {MdhtmlRelease} */
|
|
68
|
+
function releaseManifest() {
|
|
69
|
+
const parsed = /** @type {MdhtmlRelease} */ (JSON.parse(readFileSync(MANIFEST_PATH, "utf8")));
|
|
70
|
+
if (
|
|
71
|
+
parsed.schema !== "faberun/mdhtml-release/1" ||
|
|
72
|
+
parsed.repo !== "feliperun/md.html" ||
|
|
73
|
+
parsed.tag !== `v${parsed.version}` ||
|
|
74
|
+
parsed.version !== "1.1.3" ||
|
|
75
|
+
parsed.major !== 1 ||
|
|
76
|
+
parsed.minimumVersion !== "1.1.3" ||
|
|
77
|
+
!parsed.assets ||
|
|
78
|
+
Object.values(parsed.assets).some((asset) => !/^[0-9a-f]{64}$/u.test(asset.sha256))
|
|
79
|
+
) {
|
|
80
|
+
throw new CampaignBriefRenderError("MDHTML_MANIFEST_INVALID", `invalid mdhtml release manifest at ${MANIFEST_PATH}`);
|
|
81
|
+
}
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {string} output @returns {MdhtmlVersion | null} */
|
|
86
|
+
function parseVersion(output) {
|
|
87
|
+
const match = /(?:^|\s)(\d+)\.(\d+)\.(\d+)(?:\s|$)/u.exec(output);
|
|
88
|
+
if (!match) return null;
|
|
89
|
+
return {
|
|
90
|
+
major: Number(match[1]),
|
|
91
|
+
minor: Number(match[2]),
|
|
92
|
+
patch: Number(match[3]),
|
|
93
|
+
raw: `${match[1]}.${match[2]}.${match[3]}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {MdhtmlVersion | null} version @param {MdhtmlRelease} release */
|
|
98
|
+
function isCompatible(version, release) {
|
|
99
|
+
const minimum = parseVersion(release.minimumVersion);
|
|
100
|
+
if (!version || !minimum || version.major !== release.major || version.major !== minimum.major) return false;
|
|
101
|
+
if (version.minor !== minimum.minor) return version.minor > minimum.minor;
|
|
102
|
+
return version.patch >= minimum.patch;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @returns {string} */
|
|
106
|
+
function assetKey() {
|
|
107
|
+
if (process.platform === "darwin" && ["arm64", "x64"].includes(process.arch)) return `darwin-${process.arch}`;
|
|
108
|
+
if (process.platform === "win32" && process.arch === "x64") return "windows-x64";
|
|
109
|
+
if (process.platform === "linux" && process.arch === "x64") {
|
|
110
|
+
return existsSync("/etc/alpine-release") ? "linux-x64-musl" : "linux-x64-gnu";
|
|
111
|
+
}
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @returns {string} */
|
|
116
|
+
function binaryName() {
|
|
117
|
+
return process.platform === "win32" ? "mdhtml.exe" : "mdhtml";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @returns {string} */
|
|
121
|
+
function cacheRoot() {
|
|
122
|
+
return process.env.FABERUN_MDHTML_CACHE ?? join(homedir(), ".cache", "faberun", "mdhtml");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @returns {string} */
|
|
126
|
+
function provisionedBinary() {
|
|
127
|
+
return join(cacheRoot(), releaseManifest().version, binaryName());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Download and install exactly the release described by the committed manifest. */
|
|
131
|
+
async function provisionMdhtml() {
|
|
132
|
+
const release = releaseManifest();
|
|
133
|
+
const key = assetKey();
|
|
134
|
+
const asset = release.assets[key];
|
|
135
|
+
if (!asset) {
|
|
136
|
+
throw new CampaignBriefRenderError(
|
|
137
|
+
"MDHTML_PROVISION_FAILED",
|
|
138
|
+
`no ${release.repo} ${release.tag} asset for ${process.platform}/${process.arch}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const versionDir = join(cacheRoot(), release.version);
|
|
142
|
+
const binary = join(versionDir, binaryName());
|
|
143
|
+
const stamp = join(versionDir, ".verified-sha256");
|
|
144
|
+
if (existsSync(binary) && existsSync(stamp) && readFileSync(stamp, "utf8").trim() === asset.sha256) return binary;
|
|
145
|
+
|
|
146
|
+
const url = `https://github.com/${release.repo}/releases/download/${release.tag}/${asset.file}`;
|
|
147
|
+
let bytes;
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(url);
|
|
150
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
151
|
+
bytes = Buffer.from(await response.arrayBuffer());
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw new CampaignBriefRenderError(
|
|
154
|
+
"MDHTML_PROVISION_FAILED",
|
|
155
|
+
`could not download ${url}: ${error instanceof Error ? error.message : String(error)}`,
|
|
156
|
+
{ cause: error },
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
160
|
+
if (digest !== asset.sha256) {
|
|
161
|
+
throw new CampaignBriefRenderError(
|
|
162
|
+
"MDHTML_PROVISION_FAILED",
|
|
163
|
+
`checksum mismatch for ${asset.file}: expected ${asset.sha256}, got ${digest}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
mkdirSync(versionDir, { recursive: true });
|
|
168
|
+
const archive = join(versionDir, asset.file);
|
|
169
|
+
writeFileSync(archive, bytes);
|
|
170
|
+
const extracted = spawnSync("tar", ["-xzf", archive, "-C", versionDir], { encoding: "utf8" });
|
|
171
|
+
rmSync(archive, { force: true });
|
|
172
|
+
if (extracted.error || extracted.status !== 0 || !existsSync(binary)) {
|
|
173
|
+
rmSync(binary, { force: true });
|
|
174
|
+
rmSync(stamp, { force: true });
|
|
175
|
+
throw new CampaignBriefRenderError(
|
|
176
|
+
"MDHTML_PROVISION_FAILED",
|
|
177
|
+
`could not extract ${asset.file}: ${extracted.error?.message ?? firstLine(extracted.stderr)}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
chmodSync(binary, 0o755);
|
|
181
|
+
writeFileSync(stamp, `${asset.sha256}\n`, "utf8");
|
|
182
|
+
return binary;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Render through an installed mdhtml 1.x release. The destination is removed
|
|
187
|
+
* before any renderer probe so an absent binary cannot leave a stale copy.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} markdown
|
|
190
|
+
* @param {RenderCampaignBriefHtmlOptions} options
|
|
191
|
+
* @returns {RenderCampaignBriefHtmlResult}
|
|
192
|
+
*/
|
|
193
|
+
export function renderCampaignBriefHtml(markdown, options) {
|
|
194
|
+
const outputPath = options.outputPath;
|
|
195
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
196
|
+
rmSync(outputPath, { force: true });
|
|
197
|
+
const stage = mkdtempSync(join(dirname(outputPath), ".campaign-brief-render-"));
|
|
198
|
+
try {
|
|
199
|
+
const source = prepareSource(markdown, options.title);
|
|
200
|
+
const sourcePath = join(stage, "campaign-brief.md");
|
|
201
|
+
const htmlPath = join(stage, "campaign-brief.md.html");
|
|
202
|
+
writeFileSync(sourcePath, source, "utf8");
|
|
203
|
+
writeFileSync(join(stage, THEME_FILE), themeCss(), "utf8");
|
|
204
|
+
|
|
205
|
+
const bin = resolveBinary(options.mdhtmlBin);
|
|
206
|
+
const version = compatibleVersion(bin, options.cwd);
|
|
207
|
+
runMdhtml(bin, ["build", sourcePath, "-o", htmlPath], options.cwd, "MDHTML_BUILD_FAILED");
|
|
208
|
+
const checked = runMdhtml(bin, ["check", htmlPath], options.cwd, "MDHTML_CHECK_FAILED");
|
|
209
|
+
assertCheck(checked.stdout);
|
|
210
|
+
|
|
211
|
+
const html = readFileSync(htmlPath, "utf8");
|
|
212
|
+
assertTheme(html);
|
|
213
|
+
assertOffline(html);
|
|
214
|
+
const extractedPath = join(stage, "extracted.md");
|
|
215
|
+
runMdhtml(bin, ["extract", htmlPath, "-o", extractedPath], options.cwd, "MDHTML_SOURCE_MISMATCH");
|
|
216
|
+
if (readFileSync(extractedPath, "utf8") !== source) {
|
|
217
|
+
throw new CampaignBriefRenderError("MDHTML_SOURCE_MISMATCH", "the rendered copy does not carry the source brief");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
renameSync(htmlPath, outputPath);
|
|
221
|
+
return { html, outputPath, version: version.raw };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
rmSync(outputPath, { force: true });
|
|
224
|
+
if (error instanceof CampaignBriefRenderError) throw error;
|
|
225
|
+
throw new CampaignBriefRenderError("MDHTML_FAILED", error instanceof Error ? error.message : String(error), { cause: error });
|
|
226
|
+
} finally {
|
|
227
|
+
rmSync(stage, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @param {string | undefined} explicit */
|
|
232
|
+
function resolveBinary(explicit) {
|
|
233
|
+
if (explicit) return explicit;
|
|
234
|
+
if (process.env.FABERUN_MDHTML_BIN) return process.env.FABERUN_MDHTML_BIN;
|
|
235
|
+
const provisioned = provisionedBinary();
|
|
236
|
+
return existsSync(provisioned) ? provisioned : "mdhtml";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @param {string} bin @param {string | undefined} cwd @returns {MdhtmlVersion} */
|
|
240
|
+
function compatibleVersion(bin, cwd) {
|
|
241
|
+
const result = run(bin, ["--version"], cwd);
|
|
242
|
+
if (result.error) {
|
|
243
|
+
throw new CampaignBriefRenderError(
|
|
244
|
+
"MDHTML_UNAVAILABLE",
|
|
245
|
+
`mdhtml is not available at ${bin}: ${result.error.message}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const version = parseVersion(result.stdout);
|
|
249
|
+
const release = releaseManifest();
|
|
250
|
+
if (result.status !== 0 || !isCompatible(version, release)) {
|
|
251
|
+
throw new CampaignBriefRenderError(
|
|
252
|
+
"MDHTML_INCOMPATIBLE",
|
|
253
|
+
`mdhtml version ${version?.raw ?? "unknown"} is outside major ${release.major} at or above ${release.minimumVersion}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return /** @type {MdhtmlVersion} */ (version);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @param {string} bin @param {string[]} args @param {string | undefined} cwd @param {string} code */
|
|
260
|
+
function runMdhtml(bin, args, cwd, code) {
|
|
261
|
+
const result = run(bin, args, cwd);
|
|
262
|
+
if (result.error) {
|
|
263
|
+
const errno = /** @type {NodeJS.ErrnoException} */ (result.error).code;
|
|
264
|
+
const failure = errno === "ENOENT" ? "MDHTML_UNAVAILABLE" : code;
|
|
265
|
+
throw new CampaignBriefRenderError(failure, `mdhtml ${args[0]} failed: ${result.error.message}`);
|
|
266
|
+
}
|
|
267
|
+
if (result.status !== 0) {
|
|
268
|
+
throw new CampaignBriefRenderError(code, `mdhtml ${args[0]} exited with ${result.status}: ${firstLine(result.stderr)}`);
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** @param {string} command @param {string[]} args @param {string | undefined} cwd */
|
|
274
|
+
function run(command, args, cwd) {
|
|
275
|
+
return spawnSync(command, args, {
|
|
276
|
+
cwd,
|
|
277
|
+
encoding: "utf8",
|
|
278
|
+
env: { ...process.env },
|
|
279
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
280
|
+
shell: process.platform === "win32" && /\.(cmd|bat)$/iu.test(command),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @param {string} output */
|
|
285
|
+
function assertCheck(output) {
|
|
286
|
+
const portable = /portable:\s*(true|false)/iu.exec(output);
|
|
287
|
+
const requests = /requests:\s*(\d+)/iu.exec(output);
|
|
288
|
+
const diagnostic = /\b(?:E|W)-[A-Z]+-\d+\b/u.exec(output);
|
|
289
|
+
if (portable?.[1].toLowerCase() === "false" || (requests && Number(requests[1]) !== 0) || diagnostic) {
|
|
290
|
+
throw new CampaignBriefRenderError("MDHTML_CHECK_FAILED", `mdhtml check rejected portability: ${firstLine(output)}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** @param {string} html */
|
|
295
|
+
function assertTheme(html) {
|
|
296
|
+
const lower = html.toLowerCase();
|
|
297
|
+
for (const marker of ["#f4e9d8", "#1f1f1f", "#b5522a", "#93aa70", "ui-sans-serif", "ui-monospace"]) {
|
|
298
|
+
if (!lower.includes(marker)) {
|
|
299
|
+
throw new CampaignBriefRenderError("MDHTML_THEME_MISMATCH", `the rendered copy is missing ${marker}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** @param {string} html */
|
|
305
|
+
function assertOffline(html) {
|
|
306
|
+
const externalSubresource = /<(?:script|img|iframe|link)\b[^>]*(?:src|href)=["']https?:\/\//iu;
|
|
307
|
+
const externalCss = /url\(\s*["']?https?:\/\//iu;
|
|
308
|
+
if (externalSubresource.test(html) || externalCss.test(html)) {
|
|
309
|
+
throw new CampaignBriefRenderError("MDHTML_OFFLINE_VIOLATION", "the rendered copy references a network resource");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** @param {string} markdown @param {string | undefined} title */
|
|
314
|
+
function prepareSource(markdown, title) {
|
|
315
|
+
const body = stripFrontMatter(markdown);
|
|
316
|
+
const resolvedTitle = title ?? /^#\s+(.+)$/mu.exec(body)?.[1].trim() ?? "Campaign brief";
|
|
317
|
+
return [
|
|
318
|
+
"---",
|
|
319
|
+
`title: ${JSON.stringify(resolvedTitle)}`,
|
|
320
|
+
`theme: ${THEME_FILE}`,
|
|
321
|
+
"fonts: system",
|
|
322
|
+
"toc: { depth: 2, position: side }",
|
|
323
|
+
"---",
|
|
324
|
+
"",
|
|
325
|
+
body,
|
|
326
|
+
].join("\n");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** @param {string} markdown */
|
|
330
|
+
function stripFrontMatter(markdown) {
|
|
331
|
+
if (!markdown.startsWith("---\n")) return markdown;
|
|
332
|
+
const end = markdown.indexOf("\n---\n", 4);
|
|
333
|
+
return end === -1 ? markdown : markdown.slice(end + 5);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** @returns {string} */
|
|
337
|
+
function themeCss() {
|
|
338
|
+
return `/* Faberun Campaign Brief theme; tokens are defined by DESIGN.md. */
|
|
339
|
+
:root, :root[data-mdhtml-theme="light"], :root[data-mdhtml-theme="system"] {
|
|
340
|
+
--md-bg: #F4E9D8; --md-surface: #F4E9D8; --md-text: #1F1F1F;
|
|
341
|
+
--md-muted: #556B3F; --md-border: #D97B4F; --md-accent: #B5522A; --md-focus: #B5522A;
|
|
342
|
+
--md-font-body: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
343
|
+
--md-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
344
|
+
--md-radius: 8px; --md-shadow: none;
|
|
345
|
+
}
|
|
346
|
+
:root[data-mdhtml-theme="dark"] {
|
|
347
|
+
--md-bg: #1F1F1F; --md-surface: #1F1F1F; --md-text: #F4E9D8;
|
|
348
|
+
--md-muted: #93AA70; --md-border: #E0875F; --md-accent: #E0875F; --md-focus: #ECAB86;
|
|
349
|
+
}
|
|
350
|
+
@media (prefers-color-scheme: dark) {
|
|
351
|
+
:root[data-mdhtml-theme="system"] {
|
|
352
|
+
--md-bg: #1F1F1F; --md-surface: #1F1F1F; --md-text: #F4E9D8;
|
|
353
|
+
--md-muted: #93AA70; --md-border: #E0875F; --md-accent: #E0875F; --md-focus: #ECAB86;
|
|
354
|
+
}
|
|
355
|
+
}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @param {string} text */
|
|
359
|
+
function firstLine(text) {
|
|
360
|
+
return text.trim().split("\n")[0] ?? "";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** A representative real brief for the development/CI renderer proof. */
|
|
364
|
+
function exampleMarkdown() {
|
|
365
|
+
/** @type {BriefModel} */
|
|
366
|
+
const model = {
|
|
367
|
+
identity: {
|
|
368
|
+
campaign: "campaign-brief", specBaseline: "d9eae18", specDigest: "a".repeat(64),
|
|
369
|
+
specPath: "docs/campaigns/campaign-brief/spec/SPEC.md", targetGitHead: "abc123",
|
|
370
|
+
planPath: ".runs/campaigns/campaign-brief/plans/P1/plan.json", planDigest: "b".repeat(64),
|
|
371
|
+
contractDigest: "c".repeat(64), journalCursor: 7, usageSampleCutoff: "2026-09-01T00:00:00.000Z",
|
|
372
|
+
},
|
|
373
|
+
opening: {
|
|
374
|
+
intent: "Decide whether a frozen plan is worth executing.", expectedOutcome: "Markdown plus portable HTML.",
|
|
375
|
+
successCriteria: [{ measure: "Approval artefact", target: "Markdown and HTML", evidence: "R6" }],
|
|
376
|
+
humanFacts: ["Review the brief before execution."], calculatedFacts: ["R6 is covered."], gaps: [],
|
|
377
|
+
},
|
|
378
|
+
coverage: {
|
|
379
|
+
rows: [{ requirementId: "R6", title: "Portable HTML", declaredNodeIds: ["render"],
|
|
380
|
+
nodes: [{ id: "render", proof: "npm run check:campaign-brief-render" }], state: "covered", reasons: [] }],
|
|
381
|
+
unknownIds: [], unknownDeclared: [], unknownStamped: [], gaps: [],
|
|
382
|
+
specPath: "docs/campaigns/campaign-brief/spec/SPEC.md", planPath: ".runs/campaigns/campaign-brief/plans/P1/plan.json",
|
|
383
|
+
covered: 1, uncovered: 0, outside: 0, traceabilityMissing: 0, total: 1,
|
|
384
|
+
},
|
|
385
|
+
graph: {
|
|
386
|
+
nodes: [{ id: "render", runtimeId: "codex", model: "gpt-5", dependsOn: [], requirementIds: ["R6"] }],
|
|
387
|
+
edges: [], independent: ["render"], blocking: [], maxParallel: 1, maxConcurrent: { codex: 1 },
|
|
388
|
+
effectiveConcurrency: 1, dispatchableTogether: ["render"], dispatchNote: "one node", gaps: [],
|
|
389
|
+
},
|
|
390
|
+
decisions: {
|
|
391
|
+
human: ["Review before execution."], delegated: [], journal: [],
|
|
392
|
+
risks: [{ risk: "Styled HTML hides a gap", impact: "gap missed", mitigation: "retain semantic labels" }],
|
|
393
|
+
evals: ["A failed renderer removes stale HTML."], gaps: [],
|
|
394
|
+
},
|
|
395
|
+
estimate: {
|
|
396
|
+
cost: { status: "range", min: 1, max: 2, samples: 5, reason: null, sourceRuns: ["run-a"], method: null, provenance: "priced usage" },
|
|
397
|
+
duration: { status: "range", min: 10, max: 20, samples: 5, reason: null, sourceRuns: ["run-a"], method: null, provenance: "elapsed times" },
|
|
398
|
+
runtimes: ["codex"], models: ["gpt-5"], effectiveConcurrency: 1, nodeCount: 1, workerCount: 1,
|
|
399
|
+
sampleCutoff: "2026-09-01T00:00:00.000Z", method: ["comparable samples"], assumptions: ["advisory"], gaps: [],
|
|
400
|
+
},
|
|
401
|
+
decisionState: "ready for human review",
|
|
402
|
+
};
|
|
403
|
+
return renderCampaignBriefMarkdown(model);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Provision, run controlled tests, then build/check a real example. */
|
|
407
|
+
async function main() {
|
|
408
|
+
const binary = await provisionMdhtml();
|
|
409
|
+
const tests = run(process.execPath, ["--test", join(ROOT, "test", "report", "campaign-brief-render.test.mjs")], ROOT);
|
|
410
|
+
if (tests.status !== 0) {
|
|
411
|
+
throw new CampaignBriefRenderError(
|
|
412
|
+
"CAMPAIGN_BRIEF_RENDER_CHECK_FAILED",
|
|
413
|
+
`renderer fixture tests failed: ${firstLine(tests.stderr) || firstLine(tests.stdout)}`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const dir = mkdtempSync(join(tmpdir(), "campaign-brief-render-example-"));
|
|
417
|
+
try {
|
|
418
|
+
const outputPath = join(dir, "campaign-brief.md.html");
|
|
419
|
+
const result = renderCampaignBriefHtml(exampleMarkdown(), { outputPath, mdhtmlBin: binary });
|
|
420
|
+
const fromDisk = readFileSync(pathToFileURL(outputPath), "utf8");
|
|
421
|
+
for (const fact of ["ready for human review", "R6", "covered"]) {
|
|
422
|
+
if (!fromDisk.includes(fact)) {
|
|
423
|
+
throw new CampaignBriefRenderError("MDHTML_SOURCE_MISMATCH", `rendered example is missing ${fact}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (fromDisk !== result.html) throw new CampaignBriefRenderError("MDHTML_FAILED", "file URL bytes differ from the render result");
|
|
427
|
+
process.stdout.write(`[ok] mdhtml ${result.version} · fixture tests, build, check and offline file open passed\n`);
|
|
428
|
+
} finally {
|
|
429
|
+
rmSync(dir, { recursive: true, force: true });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
434
|
+
main().catch((error) => {
|
|
435
|
+
const code = error instanceof CampaignBriefRenderError ? error.code : "MDHTML_FAILED";
|
|
436
|
+
process.stderr.write(`[fail] ${code} · ${error instanceof Error ? error.message : String(error)}\n`);
|
|
437
|
+
process.exitCode = 1;
|
|
438
|
+
});
|
|
439
|
+
}
|