faberun 0.12.0 → 0.12.1

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.12.0",
3
+ "version": "0.12.1",
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": {
@@ -46,7 +46,11 @@ export function assertObject(value, label) {
46
46
  * @returns {asserts value is string}
47
47
  */
48
48
  export function requireId(value, label) {
49
- if (typeof value !== "string" || !/^[A-Za-z0-9._-]+$/u.test(value)) {
49
+ // Absent and malformed refuse with different messages: observed 2026-09-20,
50
+ // a missing id answered with the character-class message and sent the reader
51
+ // hunting for an illegal character that was not there.
52
+ if (typeof value !== "string" || !value) throw new TypeError(`${label} is required`);
53
+ if (!/^[A-Za-z0-9._-]+$/u.test(value)) {
50
54
  throw new TypeError(`${label} must contain only letters, numbers, dot, underscore, or dash`);
51
55
  }
52
56
  if (value === "." || value === "..") throw new TypeError(`${label} must not be "." or ".."`);
@@ -155,50 +155,107 @@ export async function runPlanningPipeline(options) {
155
155
  const progress = await wait(runDir);
156
156
  const classification = classifyRunProgress(progress);
157
157
  if (classification !== "succeeded") {
158
+ logStage(kind, { runId: validated.id, failed: classification });
158
159
  throw new Error(`planning stage ${kind} did not succeed: run ${validated.id} ${classification}`);
159
160
  }
160
161
  const result = readWorkerResultFile(runDir, kind);
161
162
  const output = result ? discoveryOutput(result) : null;
162
- if (!output) throw new Error(`planning stage ${kind}: run ${validated.id} recorded no discovery output`);
163
+ if (!output) {
164
+ logStage(kind, { runId: validated.id, failed: "no_discovery_output" });
165
+ throw new Error(`planning stage ${kind}: run ${validated.id} recorded no discovery output`);
166
+ }
163
167
  return { contract: validated, output };
164
168
  };
165
169
 
166
170
  const draft = await runStage("draft", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath });
167
- let plan = validatePlanOutput(draft.output.plan);
168
- logStage("draft", { runId: draft.contract.id, nodeCount: plan.nodes.length });
171
+ /** @type {PlanOutput|null} */
172
+ let plan = null;
173
+ /** @type {PlanFindingOutput[]} */
174
+ let findings = [];
175
+ try {
176
+ plan = validatePlanOutput(draft.output.plan);
177
+ } catch (error) {
178
+ findings = [invalidPlanFinding("draft", error)];
179
+ }
180
+ logStage("draft", plan === null
181
+ ? { runId: draft.contract.id, invalid: findings[0].text }
182
+ : { runId: draft.contract.id, nodeCount: plan.nodes.length });
169
183
 
170
184
  const workingPlanPath = join(scratchDir, "plan.working.json");
171
- writeJsonAtomic(workingPlanPath, plan);
172
185
  const relativeWorkingPlanPath = relative(cwd, workingPlanPath);
173
186
 
174
- /** @type {PlanFindingOutput[]} */
175
- let findings = [];
187
+ /**
188
+ * End the pipeline the way an unresolvable plan already ends: the contested
189
+ * result, the outstanding findings that forced it, and an open question on
190
+ * the campaign journal. Every no-valid-plan exit funnels through here.
191
+ *
192
+ * @param {number} round
193
+ * @returns {Promise<ContestedPipelineResult>}
194
+ */
195
+ const contest = async (round) => {
196
+ const criticalFindings = findings.filter((finding) => finding.severity === "critical");
197
+ const planPath = join(plansDir, "plan.json");
198
+ writeJsonAtomic(planPath, { formatVersion: 1, status: "contested", rounds: round, findings });
199
+ logStage("contested", { round, criticalCount: criticalFindings.length });
200
+ await campaignCli([
201
+ "note", campaignId, "--cwd", cwd, "--session-id", PLANNER_SESSION_ID,
202
+ "--kind", "open-question", "--question-id", `plan-${phase}-contested`,
203
+ "--text", `Plan for phase ${phase} is contested after ${round} review round(s): ${criticalFindings.map((finding) => finding.text).join("; ")}`,
204
+ ]);
205
+ return { status: "contested", plansDir, planPath, findings, round };
206
+ };
207
+
176
208
  for (let round = 1; round <= reviewRounds; round += 1) {
177
- const review = await runStage("review", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, planPath: relativeWorkingPlanPath });
178
- findings = validateFindings(review.output.findings);
209
+ if (plan) {
210
+ // The reviewer grades a structurally valid plan; an invalid one skips
211
+ // review and reaches revise through the validator's finding instead.
212
+ writeJsonAtomic(workingPlanPath, plan);
213
+ const review = await runStage("review", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, planPath: relativeWorkingPlanPath });
214
+ /** @type {PlanFindingOutput|null} */
215
+ let invalidFindings = null;
216
+ try {
217
+ findings = validateFindings(review.output.findings);
218
+ } catch (error) {
219
+ // The review said nothing usable about the plan, so the plan cannot be
220
+ // treated as clean: the malformed-output finding is critical and
221
+ // drives the same revise-or-contest path a real critical finding does,
222
+ // with the still-outstanding findings riding along.
223
+ invalidFindings = invalidPlanFinding(`review-r${round}`, error);
224
+ findings = [...findings, invalidFindings];
225
+ }
226
+ logStage("review", {
227
+ round,
228
+ runId: review.contract.id,
229
+ findingsCount: findings.length,
230
+ criticalCount: findings.filter((finding) => finding.severity === "critical").length,
231
+ ...(invalidFindings === null ? {} : { invalid: invalidFindings.text }),
232
+ });
233
+ }
179
234
  const criticalFindings = findings.filter((finding) => finding.severity === "critical");
180
- logStage("review", { round, runId: review.contract.id, findingsCount: findings.length, criticalCount: criticalFindings.length });
181
235
  if (criticalFindings.length === 0) break;
182
- if (round === reviewRounds) {
183
- const planPath = join(plansDir, "plan.json");
184
- writeJsonAtomic(planPath, { formatVersion: 1, status: "contested", rounds: round, findings });
185
- logStage("contested", { round, criticalCount: criticalFindings.length });
186
- await campaignCli([
187
- "note", campaignId, "--cwd", cwd, "--session-id", PLANNER_SESSION_ID,
188
- "--kind", "open-question", "--question-id", `plan-${phase}-contested`,
189
- "--text", `Plan for phase ${phase} is contested after ${round} review round(s): ${criticalFindings.map((finding) => finding.text).join("; ")}`,
190
- ]);
191
- return { status: "contested", plansDir, planPath, findings, round };
192
- }
236
+ if (round === reviewRounds) return await contest(round);
193
237
  const findingsPath = join(scratchDir, `findings-round-${round}.json`);
194
238
  writeJsonAtomic(findingsPath, findings);
195
239
  const revise = await runStage("revise", {
196
240
  specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, findingsPath: relative(cwd, findingsPath),
197
241
  });
198
- plan = validatePlanOutput(revise.output.plan);
199
- writeJsonAtomic(workingPlanPath, plan);
200
- logStage("revise", { round, runId: revise.contract.id });
242
+ /** @type {PlanFindingOutput|null} */
243
+ let invalid = null;
244
+ try {
245
+ plan = validatePlanOutput(revise.output.plan);
246
+ } catch (error) {
247
+ // The revise output was rejected wholesale, so the round's review
248
+ // findings are still outstanding and ride along to the next round.
249
+ invalid = invalidPlanFinding(`revise-r${round}`, error);
250
+ plan = null;
251
+ findings = [...findings, invalid];
252
+ }
253
+ logStage("revise", { round, runId: revise.contract.id, ...(invalid === null ? {} : { invalid: invalid.text }) });
201
254
  }
255
+ // Reached only when no review round is configured (reviewRounds <= 0) and
256
+ // the draft never validated: no revise exists to reach, so contested is the
257
+ // end rather than a silent crash at sizing.
258
+ if (plan === null) return await contest(0);
202
259
 
203
260
  const sizing = applySizingRules(
204
261
  { nodes: plan.nodes.map(toSizingNode), justification: plan.justification },
@@ -294,6 +351,23 @@ function repoRelativePath(cwd, path, label) {
294
351
  return relativePath;
295
352
  }
296
353
 
354
+ /**
355
+ * A validatePlanOutput or validateFindings rejection, shaped as the finding a
356
+ * review round already carries to revise, so a structurally invalid plan — or
357
+ * a review whose findings are not findings — reaches the stage that can act on
358
+ * it instead of killing the pipeline between the run finishing and its stage
359
+ * line. `nodeId` is "plan" because the validator's message names a path into
360
+ * the plan, not one of its nodes.
361
+ *
362
+ * @param {string} label
363
+ * @param {unknown} error
364
+ * @returns {PlanFindingOutput}
365
+ */
366
+ function invalidPlanFinding(label, error) {
367
+ const text = error instanceof Error ? error.message : String(error);
368
+ return { id: `plan-shape-${label}`, severity: "critical", nodeId: "plan", text };
369
+ }
370
+
297
371
  /**
298
372
  * Every declared runtime treated as available. Live discovery (probing a
299
373
  * harness for real exhaustion) is a separate concern this pipeline does not
@@ -62,7 +62,15 @@ const REQUIRED_INPUTS = Object.freeze({
62
62
  "spec-review": ["specPath"],
63
63
  });
64
64
 
65
- const PLAN_OUTPUT_SHAPE = "{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, definitionOfDone, verification}], justification?}";
65
+ // The nested shapes are spelled from DefinitionOfDoneItem
66
+ // (contract/definition-of-done.mjs) and VerificationCommand
67
+ // (contract/verification.mjs), never from prose: observed 2026-09-20, bare
68
+ // field names made a worker guess — a DoD item with no id, a `command` string
69
+ // where argv belongs — and the guess failed validatePlanOutput only after the
70
+ // run had already succeeded. The id charset is requireId's
71
+ // (contract/assert.mjs) verbatim, because an id that is present but invalid
72
+ // fails that same validator just as late.
73
+ const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}]}], justification?}; every id in it (node and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
66
74
  const FINDINGS_SHAPE = "[{id, severity, nodeId, text}]";
67
75
 
68
76
  /** @type {Record<PlanningKind, string>} */