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 +1 -1
- package/src/contract/assert.mjs +5 -1
- package/src/plan/pipeline.mjs +97 -23
- package/src/plan/template.mjs +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.12.
|
|
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": {
|
package/src/contract/assert.mjs
CHANGED
|
@@ -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
|
-
|
|
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 ".."`);
|
package/src/plan/pipeline.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
/**
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
package/src/plan/template.mjs
CHANGED
|
@@ -62,7 +62,15 @@ const REQUIRED_INPUTS = Object.freeze({
|
|
|
62
62
|
"spec-review": ["specPath"],
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
|
|
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>} */
|