faberun 0.6.0 → 0.8.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.
Binary file
@@ -0,0 +1,320 @@
1
+ /**
2
+ * The spec format (skills/faberun/references/spec-format.md): parsing and
3
+ * deterministic validation of the document a spec author hands the planner.
4
+ * Separate from `contract/` because a spec is pre-planning input, never an
5
+ * authored contract, and from `engine/` because nothing here dispatches,
6
+ * schedules, or reaches a provider — this module invokes no model.
7
+ */
8
+ import { git } from "../repo/worktree.mjs";
9
+
10
+ /** @typedef {"command"|"path"|"judgment"} ProofKind */
11
+ /** @typedef {{kind: ProofKind, ref?: string}} SpecProof */
12
+ /** @typedef {{id: string|null, title: string, statement: string|null, proof: SpecProof|null, constraints: string|null, line: number}} SpecRequirement */
13
+ /** @typedef {Record<string, string>} SpecFrontMatter */
14
+ /** @typedef {{heading: string, body: string, line: number}} SpecSection */
15
+ /** @typedef {{frontMatter: SpecFrontMatter|null, sections: Map<string, SpecSection>, requirements: SpecRequirement[]}} ParsedSpec */
16
+ /** @typedef {{rule: string, severity: "advisory"|"blocking", message: string, line: number}} SpecFinding */
17
+ /** @typedef {{class: "structured"|"legacy", ok: boolean, findings: SpecFinding[]}} SpecValidation */
18
+
19
+ /**
20
+ * Section headings the format recognizes, in the language the reference
21
+ * proposal actually writes them (skills/faberun/references/spec-format.md):
22
+ * the section's role is what a rule checks, never the language of the
23
+ * heading text.
24
+ */
25
+ const SECTION_ALIASES = new Map([
26
+ ["intenção", "intent"],
27
+ ["intencao", "intent"],
28
+ ["requisitos", "requirements"],
29
+ ["não-objetivos", "non-goals"],
30
+ ["nao-objetivos", "non-goals"],
31
+ ["restrições", "constraints"],
32
+ ["restricoes", "constraints"],
33
+ ["critério de sucesso", "success criteria"],
34
+ ["criterio de sucesso", "success criteria"],
35
+ ["riscos", "risks"],
36
+ ]);
37
+
38
+ /** @param {string} raw @returns {string} */
39
+ function normalizeHeading(raw) {
40
+ const key = raw.trim().toLowerCase();
41
+ return SECTION_ALIASES.get(key) ?? key;
42
+ }
43
+
44
+ /**
45
+ * @param {string[]} lines
46
+ * @returns {{data: SpecFrontMatter, end: number}|null}
47
+ */
48
+ function extractFrontMatter(lines) {
49
+ if (lines[0]?.trim() !== "---") return null;
50
+ let end = -1;
51
+ for (let i = 1; i < lines.length; i += 1) {
52
+ if (lines[i].trim() === "---") { end = i; break; }
53
+ }
54
+ if (end === -1) return null;
55
+ /** @type {SpecFrontMatter} */
56
+ const data = {};
57
+ for (let i = 1; i < end; i += 1) {
58
+ const match = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/u.exec(lines[i]);
59
+ if (!match) continue;
60
+ data[match[1]] = unquote(match[2].trim());
61
+ }
62
+ return { data, end };
63
+ }
64
+
65
+ /** @param {string} value @returns {string} */
66
+ function unquote(value) {
67
+ return value.length >= 2 && value.startsWith("\"") && value.endsWith("\"") ? value.slice(1, -1) : value;
68
+ }
69
+
70
+ /**
71
+ * Every level-2 (`## `) section from `startIndex` to the end of the document.
72
+ * A level-3 (`### `) heading, which a requirement block owns, is left inside
73
+ * its parent section's body.
74
+ *
75
+ * @param {string[]} lines
76
+ * @param {number} startIndex
77
+ * @returns {Map<string, SpecSection>}
78
+ */
79
+ function extractSections(lines, startIndex) {
80
+ /** @type {Map<string, SpecSection>} */
81
+ const sections = new Map();
82
+ let i = startIndex;
83
+ while (i < lines.length) {
84
+ const match = /^##\s+(.+?)\s*$/u.exec(lines[i]);
85
+ if (!match) { i += 1; continue; }
86
+ const heading = match[1];
87
+ const bodyStart = i + 1;
88
+ let end = bodyStart;
89
+ while (end < lines.length && !/^##\s+/u.test(lines[end])) end += 1;
90
+ sections.set(normalizeHeading(heading), { heading, body: lines.slice(bodyStart, end).join("\n"), line: bodyStart + 1 });
91
+ i = end;
92
+ }
93
+ return sections;
94
+ }
95
+
96
+ /**
97
+ * A `- **key:** value` bullet, and any following non-blank, non-bullet line as
98
+ * its wrapped continuation.
99
+ *
100
+ * @param {string[]} lines
101
+ * @returns {Map<string, string>}
102
+ */
103
+ function parseBullets(lines) {
104
+ /** @type {Map<string, string>} */
105
+ const bullets = new Map();
106
+ let currentKey = null;
107
+ for (const line of lines) {
108
+ const match = /^-\s+\*\*([a-zA-Z-]+):\*\*\s?(.*)$/u.exec(line);
109
+ if (match) {
110
+ currentKey = match[1].toLowerCase();
111
+ bullets.set(currentKey, match[2].trim());
112
+ continue;
113
+ }
114
+ const trimmed = line.trim();
115
+ if (!trimmed) { currentKey = null; continue; }
116
+ if (currentKey && !trimmed.startsWith("-")) bullets.set(currentKey, `${bullets.get(currentKey)} ${trimmed}`.trim());
117
+ }
118
+ return bullets;
119
+ }
120
+
121
+ /**
122
+ * `command: <shell command>`, `path: <repo-relative path>`, or
123
+ * `judgment: true`, optionally wrapped in one pair of backticks (the shape
124
+ * the reference proposal writes).
125
+ *
126
+ * @param {string|undefined} raw
127
+ * @returns {SpecProof|null}
128
+ */
129
+ function parseProof(raw) {
130
+ if (!raw) return null;
131
+ const unwrapped = /^`(.*)`$/u.exec(raw.trim());
132
+ const value = unwrapped ? unwrapped[1] : raw.trim();
133
+ const match = /^(command|path|judgment):\s*(.*)$/u.exec(value);
134
+ if (!match) return null;
135
+ const kind = /** @type {ProofKind} */ (match[1]);
136
+ return kind === "judgment" ? { kind } : { kind, ref: match[2].trim() };
137
+ }
138
+
139
+ /**
140
+ * @param {SpecSection|undefined} section
141
+ * @returns {SpecRequirement[]}
142
+ */
143
+ function extractRequirements(section) {
144
+ if (!section) return [];
145
+ const lines = section.body.split("\n");
146
+ /** @type {SpecRequirement[]} */
147
+ const requirements = [];
148
+ let i = 0;
149
+ while (i < lines.length) {
150
+ const match = /^###\s+(.+?)\s*$/u.exec(lines[i]);
151
+ if (!match) { i += 1; continue; }
152
+ const heading = match[1];
153
+ const blockLine = section.line + i + 1;
154
+ let end = i + 1;
155
+ while (end < lines.length && !/^###\s+/u.test(lines[end])) end += 1;
156
+ const bullets = parseBullets(lines.slice(i + 1, end));
157
+ const idMatch = /^(R\d+)\.\s*(.*)$/u.exec(heading);
158
+ requirements.push({
159
+ id: idMatch ? idMatch[1] : null,
160
+ title: idMatch ? idMatch[2].trim() : heading,
161
+ statement: bullets.get("statement") ?? null,
162
+ proof: parseProof(bullets.get("proof")),
163
+ constraints: bullets.get("constraints") ?? null,
164
+ line: blockLine,
165
+ });
166
+ i = end;
167
+ }
168
+ return requirements;
169
+ }
170
+
171
+ /**
172
+ * Parse a spec document into its front matter, sections and requirements.
173
+ * Pure text processing: no file I/O, no git, no model.
174
+ *
175
+ * @param {string} text
176
+ * @returns {ParsedSpec}
177
+ */
178
+ export function parseSpec(text) {
179
+ const lines = text.split("\n");
180
+ const frontMatter = extractFrontMatter(lines);
181
+ const sections = extractSections(lines, frontMatter ? frontMatter.end + 1 : 0);
182
+ const requirements = extractRequirements(sections.get("requirements"));
183
+ return { frontMatter: frontMatter?.data ?? null, sections, requirements };
184
+ }
185
+
186
+ /**
187
+ * @param {string} body
188
+ * @returns {string[]}
189
+ */
190
+ function tableRows(body) {
191
+ return body.split("\n").filter((line) => line.trim().startsWith("|"));
192
+ }
193
+
194
+ /** @param {string} row @returns {string[]} */
195
+ function splitRow(row) {
196
+ return row.trim().replace(/^\|/u, "").replace(/\|$/u, "").split("|").map((cell) => cell.trim());
197
+ }
198
+
199
+ /**
200
+ * A Success criteria table with no Baseline column at all, or a data row
201
+ * whose Baseline cell is empty or a bare dash.
202
+ *
203
+ * @param {SpecSection} section
204
+ * @returns {SpecFinding[]}
205
+ */
206
+ function baselineColumnFindings(section) {
207
+ const rows = tableRows(section.body);
208
+ if (rows.length < 2) return [];
209
+ const header = splitRow(rows[0]);
210
+ const baselineIndex = header.findIndex((cell) => /baseline/iu.test(cell));
211
+ if (baselineIndex === -1) {
212
+ return [{ rule: "success-criteria-missing-baseline", severity: "advisory", message: "Success criteria table has no Baseline column", line: section.line }];
213
+ }
214
+ /** @type {SpecFinding[]} */
215
+ const findings = [];
216
+ for (let i = 2; i < rows.length; i += 1) {
217
+ const value = splitRow(rows[i])[baselineIndex]?.trim();
218
+ if (!value || value === "-" || value === "—") {
219
+ findings.push({ rule: "success-criteria-missing-baseline", severity: "advisory", message: `Success criteria row ${i - 1} has no Baseline value`, line: section.line + i });
220
+ }
221
+ }
222
+ return findings;
223
+ }
224
+
225
+ /**
226
+ * `git@host:owner/repo.git` and `https://host/owner/repo.git` both reduce to
227
+ * the same lowercase `owner/repo` suffix for comparison.
228
+ *
229
+ * @param {string} url
230
+ * @returns {string}
231
+ */
232
+ function normalizeRemoteUrl(url) {
233
+ return url.trim().replace(/\.git$/u, "").replace(/^git@([^:]+):/u, "https://$1/").toLowerCase();
234
+ }
235
+
236
+ /**
237
+ * Whether `ref` names a commit that actually exists in `cwd`. `git rev-parse
238
+ * <ref>` alone is not enough: given a 40-hex string it echoes the string back
239
+ * unverified even when no such object exists, so this peels it as `^{commit}`
240
+ * instead, which fails for an absent or non-commit object.
241
+ *
242
+ * @param {string} cwd
243
+ * @param {string} ref
244
+ * @returns {boolean}
245
+ */
246
+ function resolvesToCommit(cwd, ref) {
247
+ try {
248
+ git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
249
+ return true;
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Whether `target` (an `owner/repo` slug) names the repository this `cwd`'s
257
+ * `origin` remote points at. Checked against the remote name only, never a
258
+ * network call.
259
+ *
260
+ * @param {string} cwd
261
+ * @param {string} target
262
+ * @returns {boolean}
263
+ */
264
+ function targetMatchesOrigin(cwd, target) {
265
+ let url;
266
+ try {
267
+ url = git(cwd, ["remote", "get-url", "origin"]);
268
+ } catch {
269
+ return false;
270
+ }
271
+ return normalizeRemoteUrl(url).endsWith(`/${target.toLowerCase()}`);
272
+ }
273
+
274
+ /**
275
+ * Validate a spec's traceability rules: no model call, ever
276
+ * (skills/faberun/references/spec-format.md). A document without front matter
277
+ * is classified `legacy` and accepted outright, exempt from every rule below.
278
+ *
279
+ * Advisory by default — every violation is recorded and `ok` stays `true` —
280
+ * and blocking under `strict`, where any violation makes `ok` `false`.
281
+ *
282
+ * @param {string} text
283
+ * @param {{cwd?: string, strict?: boolean}} [options]
284
+ * @returns {SpecValidation}
285
+ */
286
+ export function validateSpec(text, options = {}) {
287
+ const parsed = parseSpec(text);
288
+ if (!parsed.frontMatter) {
289
+ return {
290
+ class: "legacy",
291
+ ok: true,
292
+ findings: [{ rule: "legacy-document", severity: "advisory", message: "no front matter: accepted as a legacy-class document, not scored against the structured rules", line: 1 }],
293
+ };
294
+ }
295
+ const cwd = options.cwd ?? process.cwd();
296
+ const strict = options.strict === true;
297
+ /** @type {SpecFinding[]} */
298
+ const findings = [];
299
+ if (!parsed.sections.has("non-goals")) {
300
+ findings.push({ rule: "missing-non-goals", severity: "advisory", message: "spec has no Non-goals section", line: 1 });
301
+ }
302
+ for (const requirement of parsed.requirements) {
303
+ if (!requirement.id) {
304
+ findings.push({ rule: "requirement-missing-id", severity: "advisory", message: `requirement "${requirement.title}" has no stable R<n> id`, line: requirement.line });
305
+ }
306
+ if (!requirement.proof) {
307
+ findings.push({ rule: "requirement-missing-proof", severity: "advisory", message: `requirement ${requirement.id ?? requirement.title} has no proof`, line: requirement.line });
308
+ }
309
+ }
310
+ const successCriteria = parsed.sections.get("success criteria");
311
+ if (successCriteria) findings.push(...baselineColumnFindings(successCriteria));
312
+ if (typeof parsed.frontMatter.baseline === "string" && !resolvesToCommit(cwd, parsed.frontMatter.baseline)) {
313
+ findings.push({ rule: "baseline-unresolved", severity: "advisory", message: `baseline "${parsed.frontMatter.baseline}" does not resolve to a commit`, line: 1 });
314
+ }
315
+ if (typeof parsed.frontMatter.target === "string" && !targetMatchesOrigin(cwd, parsed.frontMatter.target)) {
316
+ findings.push({ rule: "target-unresolved", severity: "advisory", message: `target "${parsed.frontMatter.target}" does not match the origin remote`, line: 1 });
317
+ }
318
+ const graded = findings.map((finding) => (strict ? { ...finding, severity: /** @type {const} */ ("blocking") } : finding));
319
+ return { class: "structured", ok: !graded.some((finding) => finding.severity === "blocking"), findings: graded };
320
+ }
@@ -90,7 +90,7 @@ export function renderFinalReport(runDir, contract, states) {
90
90
  const widths = [3, 24, 9, 7, 7, 28, 10, 10, 10, 12, 64];
91
91
  /** @param {unknown[]} cells */
92
92
  const row = (cells) => cells.map((cell, index) => fit(String(cell ?? ""), widths[index])).join(" ");
93
- const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 };
93
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, declaredReadBytes: 0 };
94
94
  let totalCostUsd = null;
95
95
  const lines = [
96
96
  `# run ${basename(runDir)}`,
@@ -106,6 +106,7 @@ export function renderFinalReport(runDir, contract, states) {
106
106
  totals.inputTokens += usage.inputTokens ?? 0;
107
107
  totals.outputTokens += usage.outputTokens ?? 0;
108
108
  totals.cacheReadInputTokens += usage.cacheReadInputTokens ?? 0;
109
+ if (typeof node.declaredReadBytes === "number") totals.declaredReadBytes += node.declaredReadBytes;
109
110
  if (typeof node.costUsd === "number" && Number.isFinite(node.costUsd)) totalCostUsd = (totalCostUsd ?? 0) + node.costUsd;
110
111
  const runtime = node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : "-";
111
112
  const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
@@ -129,7 +130,7 @@ export function renderFinalReport(runDir, contract, states) {
129
130
  ]));
130
131
  }
131
132
  const roles = roleCosts(nodes);
132
- lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${compactCost(totalCostUsd)}`);
133
+ lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${compactCost(totalCostUsd)} · read ${compactTokens(totals.declaredReadBytes)}`);
133
134
  return `${lines.join("\n")}\n`;
134
135
  }
135
136
  /**
@@ -26,7 +26,7 @@ const POINTER_ATTENTION_CHARS = 80;
26
26
  /** @typedef {{costUsd: number|null, costProvenance: CostProvenance, inputTokens: number, outputTokens: number, cacheReadInputTokens: number, pricedInvocations: number, unpricedInvocations: number}} RoleUsage */
27
27
  /** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
28
28
  /** @typedef {{index: number, total: number, argv: string}} VerificationProgress */
29
- /** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null}} StatusPayloadNode */
29
+ /** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
30
30
  /** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
31
31
 
32
32
  /** The glyph each terminal state prints in a status table. */
@@ -92,7 +92,8 @@ export function renderStatus(runDir) {
92
92
  node.note ?? "-",
93
93
  ]));
94
94
  }
95
- lines.push("```", "", "## Cost", "", `in ${compactTokens(usage.inputTokens)} · out ${compactTokens(usage.outputTokens)} · cache ${compactTokens(usage.cacheReadInputTokens)} · worker ${formatRole(payload.roles.worker)} · judge ${formatRole(payload.roles.judge)} · cost ${compactCost(usage.costUsd)}`);
95
+ const readBytes = payload.nodes.reduce((total, node) => total + (node.declaredReadBytes ?? 0), 0);
96
+ lines.push("```", "", "## Cost", "", `in ${compactTokens(usage.inputTokens)} · out ${compactTokens(usage.outputTokens)} · cache ${compactTokens(usage.cacheReadInputTokens)} · worker ${formatRole(payload.roles.worker)} · judge ${formatRole(payload.roles.judge)} · cost ${compactCost(usage.costUsd)} · read ${compactTokens(readBytes)}`);
96
97
  return `${lines.join("\n")}\n`;
97
98
  }
98
99
 
@@ -232,6 +233,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
232
233
  errorCode: node.error?.code ?? null,
233
234
  blockedBy: node.blockedBy ?? [],
234
235
  verificationProgress: progress,
236
+ declaredReadBytes: typeof node.declaredReadBytes === "number" ? node.declaredReadBytes : null,
235
237
  };
236
238
  }),
237
239
  };
@@ -407,13 +409,14 @@ export function renderReportJson(runDir) {
407
409
  const { contract, nodes } = loadRun(runDir);
408
410
  const counts = new Map();
409
411
  for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
410
- /** @type {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null, costStatus: string, workerCostUsd: number|null, judgeCostUsd: number|null}} */
411
- const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null, costStatus: "ambiguous", workerCostUsd: null, judgeCostUsd: null };
412
+ /** @type {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null, costStatus: string, workerCostUsd: number|null, judgeCostUsd: number|null, declaredReadBytes: number}} */
413
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null, costStatus: "ambiguous", workerCostUsd: null, judgeCostUsd: null, declaredReadBytes: 0 };
412
414
  const costs = nodes.map(costProjection);
413
415
  const listed = nodes.map((node, index) => {
414
416
  const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
415
417
  for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (["inputTokens", "outputTokens", "cacheReadInputTokens"])) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
416
418
  const cost = costs[index];
419
+ totals.declaredReadBytes += typeof node.declaredReadBytes === "number" ? node.declaredReadBytes : 0;
417
420
  return {
418
421
  id: node.id,
419
422
  status: node.status,
@@ -427,6 +430,7 @@ export function renderReportJson(runDir) {
427
430
  costStatus: cost.status,
428
431
  continuation: continuationMode(node),
429
432
  note: nodeNote(node),
433
+ declaredReadBytes: typeof node.declaredReadBytes === "number" ? node.declaredReadBytes : null,
430
434
  };
431
435
  });
432
436
  const aggregateCost = aggregateCostProjection(costs);