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
package/src/plan/pipeline.mjs
CHANGED
|
@@ -30,7 +30,8 @@ import { collectRepoFacts } from "./repo-facts.mjs";
|
|
|
30
30
|
import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validateFindings, validatePlanOutput } from "./template.mjs";
|
|
31
31
|
import { MIN_WRITE_FILES, applySizingRules, provenParallelism } from "./sizing.mjs";
|
|
32
32
|
import { resolveRuntimes } from "./routing.mjs";
|
|
33
|
-
import { freezePlan } from "./freeze.mjs";
|
|
33
|
+
import { assertTimeoutsCoverMeasured, contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
|
|
34
|
+
import { availabilityOf, fileLineCount, highestOf, modelOf, toContractNode, toSizingNode } from "./pipeline-shape.mjs";
|
|
34
35
|
import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
35
36
|
|
|
36
37
|
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
@@ -39,8 +40,9 @@ import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
|
39
40
|
/** @typedef {{sharedVerification?: VerificationCommand[], finalVerification?: VerificationCommand[]}} VerificationSuites */
|
|
40
41
|
/** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
|
|
41
42
|
/** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
|
|
43
|
+
/** @typedef {import("./template.mjs").PlanPhase} PlanPhase */
|
|
42
44
|
/** @typedef {import("./sizing.mjs").PlanNode & {objective: string}} SizedPlanNode */
|
|
43
|
-
/** @typedef {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[]}} AssembledPlan */
|
|
45
|
+
/** @typedef {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[], phases?: PlanPhase[]}} AssembledPlan */
|
|
44
46
|
/** @typedef {"standard"|"high"|"none"} ApproveBelow */
|
|
45
47
|
/** @typedef {(contractPath: string, contract: ValidatedContract) => Promise<void>|void} LaunchFn */
|
|
46
48
|
/** @typedef {(runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, cwd: string) => Promise<import("../harnesses/index.mjs").ProbeResult[]>} AskFn */
|
|
@@ -113,6 +115,9 @@ export async function runPlanningPipeline(options) {
|
|
|
113
115
|
|
|
114
116
|
const relativeSpecPath = repoRelativePath(cwd, specPath, "specPath");
|
|
115
117
|
const specText = readFileSync(resolve(cwd, relativeSpecPath), "utf8");
|
|
118
|
+
// Pinned now, from the same bytes every planning stage reads, so the frozen
|
|
119
|
+
// record names the exact structured spec it was planned from.
|
|
120
|
+
const specDigest = contentDigest(specText);
|
|
116
121
|
const specValidation = validateSpec(specText, { cwd, strict: true });
|
|
117
122
|
if (specValidation.class === "structured" && !specValidation.ok) {
|
|
118
123
|
const detail = specValidation.findings.map((finding) => `${finding.rule}: ${finding.message}`).join("; ");
|
|
@@ -276,6 +281,11 @@ export async function runPlanningPipeline(options) {
|
|
|
276
281
|
sizing,
|
|
277
282
|
routing,
|
|
278
283
|
nodes: sizing.plan.nodes.map((node) => toContractNode(/** @type {SizedPlanNode} */ (node), phase, routing.assignments[node.id])),
|
|
284
|
+
// The declarations a reviewer saw, remapped onto the nodes sizing
|
|
285
|
+
// actually produced: a merge removes a node id, and a declaration that
|
|
286
|
+
// named it must now name the node that absorbed it or freeze would see
|
|
287
|
+
// an unknown assignment.
|
|
288
|
+
phases: carryPhaseDeclarations(currentPlan.phases, sizing.transformations),
|
|
279
289
|
};
|
|
280
290
|
};
|
|
281
291
|
|
|
@@ -354,7 +364,7 @@ export async function runPlanningPipeline(options) {
|
|
|
354
364
|
/** @type {PlanFindingOutput|null} */
|
|
355
365
|
let freezeFailure = null;
|
|
356
366
|
try {
|
|
357
|
-
validateContract(frozenContractRaw(assembleFrozenNodes(plan)), join(plansDir, "contract.json"));
|
|
367
|
+
assertTimeoutsCoverMeasured(validateContract(frozenContractRaw(assembleFrozenNodes(plan)), join(plansDir, "contract.json")), repoFacts);
|
|
358
368
|
} catch (error) {
|
|
359
369
|
freezeFailure = invalidPlanFinding(`freeze-r${round}`, error);
|
|
360
370
|
findings = [...findings, freezeFailure];
|
|
@@ -443,6 +453,11 @@ export async function runPlanningPipeline(options) {
|
|
|
443
453
|
highestRiskTier = highestOf(assembled.sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
|
|
444
454
|
frozen = freezePlan(frozenContractRaw(assembled), {
|
|
445
455
|
outDir: plansDir,
|
|
456
|
+
phases: assembled.phases,
|
|
457
|
+
// The pipeline's own pinned spec bytes: a wrong or missing digest is the
|
|
458
|
+
// first thing the Campaign Brief refuses on, never a summary.
|
|
459
|
+
spec: { path: relativeSpecPath, digest: specDigest },
|
|
460
|
+
facts: repoFacts,
|
|
446
461
|
provenance: {
|
|
447
462
|
targetGitHead: repoFacts.gitHead,
|
|
448
463
|
planner: { runtimeId: runtimeDefaults.worker ?? "", model: modelOf(runtimes, runtimeDefaults.worker) },
|
|
@@ -490,7 +505,10 @@ export async function runPlanningPipeline(options) {
|
|
|
490
505
|
|
|
491
506
|
const approved = approveBelow === "high" ? true : approveBelow === "none" ? false : highestRiskTier !== "high";
|
|
492
507
|
const planPath = join(plansDir, "plan.json");
|
|
493
|
-
|
|
508
|
+
// The final bytes — status and approval included — are written first; the
|
|
509
|
+
// sidecar then covers exactly those bytes, and nothing rewrites the plan
|
|
510
|
+
// afterward. The written record is the plan.json the brief will read.
|
|
511
|
+
writeFrozenPlan(plansDir, /** @type {import("./freeze.mjs").FrozenPlan} */ (frozen), { status: "frozen", approved });
|
|
494
512
|
if (!approved) {
|
|
495
513
|
await campaignCli([
|
|
496
514
|
"note", campaignId, "--cwd", cwd, "--session-id", PLANNER_SESSION_ID,
|
|
@@ -666,124 +684,58 @@ export function droppedWriteFindings(previousPlan, revisedPlan) {
|
|
|
666
684
|
}
|
|
667
685
|
|
|
668
686
|
/**
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
687
|
+
* The phase declarations a reviewer saw, remapped onto the nodes sizing
|
|
688
|
+
* actually produced. `applySizingRules` is the only stage that changes the
|
|
689
|
+
* node set, and it only ever folds one node into another — a declaration that
|
|
690
|
+
* named the folded-away node must name the node that absorbed it, or freeze
|
|
691
|
+
* would refuse the record as an unknown assignment. A two-entry transformation
|
|
692
|
+
* is a merge (`[child, parent]`); every one-entry transformation edits a node
|
|
693
|
+
* in place. Resolution follows a chain, so a node folded into another that was
|
|
694
|
+
* itself folded lands on the final owner, and duplicates collapse so a
|
|
695
|
+
* declaration never lists the same node twice. A declaration in the legacy
|
|
696
|
+
* shape (no `nodeIds`) is returned untouched.
|
|
673
697
|
*
|
|
674
|
-
* @param {
|
|
675
|
-
* @
|
|
698
|
+
* @param {PlanPhase[]|undefined} phases
|
|
699
|
+
* @param {import("./sizing.mjs").SizingTransformation[]|undefined} transformations
|
|
700
|
+
* @returns {PlanPhase[]|undefined}
|
|
676
701
|
*/
|
|
677
|
-
function
|
|
678
|
-
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
*/
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
* A draft or revise output node (flat `readFiles`/`writeFiles`/`verification`)
|
|
701
|
-
* turned into the shape `applySizingRules` merges and splits: those fields move
|
|
702
|
-
* under `taskPacket`, alongside `sizing.mjs`'s own `writeFiles`/`verification`
|
|
703
|
-
* expectations, while `objective` rides along as a passthrough field a merge
|
|
704
|
-
* never touches.
|
|
705
|
-
*
|
|
706
|
-
* @param {import("./template.mjs").PlanOutputNode} node
|
|
707
|
-
* @returns {SizedPlanNode}
|
|
708
|
-
*/
|
|
709
|
-
function toSizingNode(node) {
|
|
710
|
-
return /** @type {SizedPlanNode} */ ({
|
|
711
|
-
id: node.id,
|
|
712
|
-
dependsOn: node.dependsOn,
|
|
713
|
-
taskKind: node.taskKind,
|
|
714
|
-
riskTier: node.riskTier,
|
|
715
|
-
objective: node.objective,
|
|
716
|
-
expectedTurns: node.expectedTurns,
|
|
717
|
-
definitionOfDone: node.definitionOfDone,
|
|
718
|
-
taskPacket: {
|
|
719
|
-
readFiles: node.readFiles,
|
|
720
|
-
writeFiles: node.writeFiles,
|
|
721
|
-
scopeAcknowledged: node.scopeAcknowledged,
|
|
722
|
-
verification: node.verification,
|
|
723
|
-
},
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* A sized plan node's classification and shape, turned into the contract node
|
|
729
|
-
* `freezePlan` validates. `riskTier: "low"` gets no gate; `standard` an
|
|
730
|
-
* advisory one; `high` a blocking one, which `validateGate` requires `major`
|
|
731
|
-
* in `failOn` for.
|
|
732
|
-
*
|
|
733
|
-
* @param {SizedPlanNode} node
|
|
734
|
-
* @param {string} phase
|
|
735
|
-
* @param {{worker: string|null, judge: string|null}|undefined} assignment
|
|
736
|
-
* @returns {JsonObject}
|
|
737
|
-
*/
|
|
738
|
-
function toContractNode(node, phase, assignment) {
|
|
739
|
-
const riskTier = /** @type {string} */ (node.riskTier);
|
|
740
|
-
const gate = riskTier === "low"
|
|
741
|
-
? false
|
|
742
|
-
: {
|
|
743
|
-
review: riskTier === "high" ? "blocking" : "advisory",
|
|
744
|
-
failOn: riskTier === "high" ? ["major", "critical"] : ["critical"],
|
|
745
|
-
...(assignment?.judge ? { runtime: assignment.judge } : {}),
|
|
746
|
-
};
|
|
747
|
-
return {
|
|
748
|
-
id: node.id,
|
|
749
|
-
type: node.taskKind,
|
|
750
|
-
phase,
|
|
751
|
-
dependsOn: node.dependsOn ?? [],
|
|
752
|
-
...(assignment?.worker ? { runtime: assignment.worker } : {}),
|
|
753
|
-
taskPacket: {
|
|
754
|
-
mode: "execution",
|
|
755
|
-
objective: node.objective,
|
|
756
|
-
instructions: [node.objective],
|
|
757
|
-
readFiles: node.taskPacket.readFiles ?? [],
|
|
758
|
-
writeFiles: node.taskPacket.writeFiles ?? [],
|
|
759
|
-
// Carried, never computed here: the drafter decided which importers it
|
|
760
|
-
// will not change, and scope closure exists to force that decision on a
|
|
761
|
-
// person rather than answer it for them (src/repo/scope-closure.mjs).
|
|
762
|
-
scopeAcknowledged: node.taskPacket.scopeAcknowledged ?? [],
|
|
763
|
-
symbols: [],
|
|
764
|
-
decisions: [],
|
|
765
|
-
nonGoals: [],
|
|
766
|
-
verification: node.taskPacket.verification,
|
|
767
|
-
},
|
|
768
|
-
definitionOfDone: node.definitionOfDone ?? [],
|
|
769
|
-
gate,
|
|
702
|
+
export function carryPhaseDeclarations(phases, transformations) {
|
|
703
|
+
if (!phases || phases.length === 0) return phases;
|
|
704
|
+
/** @type {Map<string, string>} */
|
|
705
|
+
const parentOf = new Map();
|
|
706
|
+
for (const transformation of transformations ?? []) {
|
|
707
|
+
const nodes = transformation?.nodes;
|
|
708
|
+
if (Array.isArray(nodes) && nodes.length === 2 && typeof nodes[0] === "string" && typeof nodes[1] === "string") {
|
|
709
|
+
parentOf.set(nodes[0], nodes[1]);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (parentOf.size === 0) return phases;
|
|
713
|
+
/** @param {string} id @returns {string} */
|
|
714
|
+
const resolve = (id) => {
|
|
715
|
+
let current = id;
|
|
716
|
+
const seen = new Set();
|
|
717
|
+
while (parentOf.has(current) && !seen.has(current)) {
|
|
718
|
+
seen.add(current);
|
|
719
|
+
current = /** @type {string} */ (parentOf.get(current));
|
|
720
|
+
}
|
|
721
|
+
return current;
|
|
770
722
|
};
|
|
723
|
+
return phases.map((phase) => {
|
|
724
|
+
if (phase.nodeIds === undefined) return phase;
|
|
725
|
+
return { ...phase, nodeIds: [...new Set(phase.nodeIds.map(resolve))] };
|
|
726
|
+
});
|
|
771
727
|
}
|
|
772
728
|
|
|
773
729
|
/**
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
730
|
+
* Write the pipeline's final frozen plan record and the `plan.json.sha256`
|
|
731
|
+
* sidecar over those exact bytes. This is the last write to plan.json: after
|
|
732
|
+
* it returns, the sidecar and the plan agree, and neither may be rewritten.
|
|
777
733
|
*
|
|
778
|
-
* @param {string}
|
|
779
|
-
* @
|
|
734
|
+
* @param {string} outDir
|
|
735
|
+
* @param {import("./freeze.mjs").FrozenPlan} frozen
|
|
736
|
+
* @param {{status: "frozen", approved: boolean}} outcome
|
|
737
|
+
* @returns {import("./freeze.mjs").FrozenPlan}
|
|
780
738
|
*/
|
|
781
|
-
function
|
|
782
|
-
|
|
783
|
-
const text = readFileSync(path, "utf8");
|
|
784
|
-
if (text === "") return 0;
|
|
785
|
-
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
786
|
-
} catch {
|
|
787
|
-
return null;
|
|
788
|
-
}
|
|
739
|
+
export function writeFrozenPlan(outDir, frozen, outcome) {
|
|
740
|
+
return writeFrozenPlanRecord(outDir, { ...frozen, ...outcome });
|
|
789
741
|
}
|
package/src/plan/template.mjs
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
* artefact under review, never the author's packet, transcript or summary.
|
|
11
11
|
*
|
|
12
12
|
* The plan output validator also owns requirement traceability: a plan
|
|
13
|
-
* declares, per phase, the requirement ids it satisfies
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* declares, per phase, the requirement ids it satisfies, the node ids it
|
|
14
|
+
* assigns, and its one-sentence deliverable. A planned node belongs to exactly
|
|
15
|
+
* one declaration, so a missing, duplicate, or unknown node assignment is a
|
|
16
|
+
* refusal. A legacy declaration that names no requirements and no nodes is
|
|
17
|
+
* still reported as a finding, never a silent pass. freeze.mjs imports the
|
|
18
|
+
* same phase check for the frozen plan record.
|
|
17
19
|
*/
|
|
18
20
|
import { assertObject, positiveInteger, rejectUnknown, requireId, requireString, requireStringArray } from "../contract/assert.mjs";
|
|
19
21
|
import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../contract/index.mjs";
|
|
@@ -26,7 +28,7 @@ import { validateVerificationCommands } from "../contract/verification.mjs";
|
|
|
26
28
|
/** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, cataloguePath?: string, packageMode?: import("./sizing.mjs").PackageMode, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
|
|
27
29
|
/** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], scopeAcknowledged: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[], expectedTurns?: number}} PlanOutputNode */
|
|
28
30
|
/** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
|
|
29
|
-
/** @typedef {{id: string, requirementIds: string[], deliverable: string}} PlanPhase */
|
|
31
|
+
/** @typedef {{id: string, requirementIds: string[], nodeIds?: string[], deliverable: string}} PlanPhase */
|
|
30
32
|
/** @typedef {{id: string, severity: "critical"|"major"|"minor", nodeId: string, text: string}} PlanFindingOutput */
|
|
31
33
|
|
|
32
34
|
/** The taskKind catalogue a draft or revise classifies against. */
|
|
@@ -109,7 +111,7 @@ const REQUIRED_INPUTS = Object.freeze({
|
|
|
109
111
|
// run had already succeeded. The id charset is requireId's
|
|
110
112
|
// (contract/assert.mjs) verbatim, because an id that is present but invalid
|
|
111
113
|
// fails that same validator just as late.
|
|
112
|
-
const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds
|
|
114
|
+
const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds: [string], nodeIds: [string], deliverable}], justification?}; every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
|
|
113
115
|
/**
|
|
114
116
|
* The size guidance every draft and revise carries. measured 2026-09-20 over
|
|
115
117
|
* stored runs: median 49 provider requests per worker turn; cost per turn
|
|
@@ -165,7 +167,7 @@ const OBJECTIVES = Object.freeze({
|
|
|
165
167
|
const INSTRUCTIONS = Object.freeze({
|
|
166
168
|
draft: [
|
|
167
169
|
`Consult the ${TASK_KIND_CATALOGUE_FILE} in readFiles before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
|
|
168
|
-
"Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies and the deliverable it produces in one sentence.
|
|
170
|
+
"Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies, the planned node ids it assigns, and the deliverable it produces in one sentence. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.",
|
|
169
171
|
...SCOPE_CLOSURE_RULE,
|
|
170
172
|
`Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
|
|
171
173
|
"Never name a runtime, harness, model, or vendor anywhere in output.plan. taskKind and riskTier are the only classification a draft makes; a routing table assigns a runtime afterward, from those two fields alone.",
|
|
@@ -173,7 +175,7 @@ const INSTRUCTIONS = Object.freeze({
|
|
|
173
175
|
],
|
|
174
176
|
revise: [
|
|
175
177
|
"Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
|
|
176
|
-
"Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies and the deliverable it produces in one sentence.
|
|
178
|
+
"Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies, the planned node ids it assigns, and the deliverable it produces in one sentence. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.",
|
|
177
179
|
...SCOPE_CLOSURE_RULE,
|
|
178
180
|
`Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
|
|
179
181
|
"Never name a runtime, harness, model, or vendor anywhere in output.plan.",
|
|
@@ -344,7 +346,7 @@ export function validatePlanOutput(plan) {
|
|
|
344
346
|
});
|
|
345
347
|
});
|
|
346
348
|
if (record.justification !== undefined) requireString(record.justification, "plan.justification");
|
|
347
|
-
const phases = validatePlanPhases(record.phases);
|
|
349
|
+
const phases = validatePlanPhases(record.phases, nodes.map((node) => node.id));
|
|
348
350
|
const findings = (phases ?? [])
|
|
349
351
|
.filter((phase) => phase.requirementIds.length === 0)
|
|
350
352
|
.map((phase) => /** @type {PlanFindingOutput} */ ({
|
|
@@ -364,41 +366,102 @@ export function validatePlanOutput(plan) {
|
|
|
364
366
|
};
|
|
365
367
|
}
|
|
366
368
|
|
|
367
|
-
// The fields a phase declaration carries beyond its id: requirementIds|deliverable —
|
|
368
|
-
// which spec requirements the phase satisfies,
|
|
369
|
-
|
|
369
|
+
// The fields a phase declaration carries beyond its id: requirementIds|nodeIds|deliverable —
|
|
370
|
+
// which spec requirements the phase satisfies, which planned nodes it assigns, and the one
|
|
371
|
+
// sentence naming its result.
|
|
372
|
+
const PLAN_PHASE_FIELDS = new Set(["id", "requirementIds", "nodeIds", "deliverable"]);
|
|
370
373
|
|
|
371
374
|
/**
|
|
372
|
-
* Validate a plan's `phases` — the per-phase requirement declarations
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
375
|
+
* Validate a plan's `phases` — the per-phase requirement declarations. The
|
|
376
|
+
* current shape `{id, requirementIds, nodeIds, deliverable}` assigns every
|
|
377
|
+
* planned node to exactly one declaration: `plannedNodeIds` is the plan's own
|
|
378
|
+
* node-id list, and a node no declaration names, a node two declarations both
|
|
379
|
+
* name, or a declared node absent from the plan is refused. A declaration in
|
|
380
|
+
* the older `{id, requirementIds, deliverable}` shape (no `nodeIds` at all)
|
|
381
|
+
* stays legal so a plan frozen before node assignment existed still reads; in
|
|
382
|
+
* that shape an empty `requirementIds` is the caller's finding to report
|
|
383
|
+
* (validatePlanOutput does), not a refusal. Mixing the two shapes is refused
|
|
384
|
+
* because it would leave some nodes silently unattributed. Shared with
|
|
385
|
+
* freeze.mjs, which holds the same declarations on the frozen plan record.
|
|
379
386
|
*
|
|
380
387
|
* @param {unknown} phases
|
|
388
|
+
* @param {string[]|undefined} [plannedNodeIds] the plan's node ids, when the caller has them
|
|
381
389
|
* @returns {PlanPhase[]|undefined} the normalized declarations, or undefined when none were given
|
|
382
390
|
*/
|
|
383
|
-
export function validatePlanPhases(phases) {
|
|
391
|
+
export function validatePlanPhases(phases, plannedNodeIds) {
|
|
384
392
|
if (phases === undefined) return undefined;
|
|
385
393
|
if (!Array.isArray(phases)) throw new TypeError("plan.phases must be an array of phase declarations");
|
|
386
394
|
if (phases.length === 0) return undefined;
|
|
387
|
-
|
|
395
|
+
|
|
396
|
+
/** @type {Set<string>} */
|
|
397
|
+
const ids = new Set();
|
|
398
|
+
const declarations = phases.map((phase, index) => {
|
|
388
399
|
const label = `plan.phases[${index}]`;
|
|
389
400
|
assertObject(phase, label);
|
|
390
401
|
const record = /** @type {Record<string, unknown>} */ (phase);
|
|
391
402
|
rejectUnknown(record, PLAN_PHASE_FIELDS, label);
|
|
392
403
|
requireId(record.id, `${label}.id`);
|
|
404
|
+
const id = /** @type {string} */ (record.id);
|
|
405
|
+
if (ids.has(id)) throw new TypeError(`plan.phases has duplicate phase id ${id}`);
|
|
406
|
+
ids.add(id);
|
|
393
407
|
requireString(record.deliverable, `${label}.deliverable`);
|
|
394
|
-
const requirementIds = record.requirementIds ?? [];
|
|
408
|
+
const requirementIds = /** @type {string[]} */ (record.requirementIds ?? []);
|
|
395
409
|
requireStringArray(requirementIds, `${label}.requirementIds`);
|
|
396
|
-
|
|
397
|
-
|
|
410
|
+
/** @type {string[]|undefined} */
|
|
411
|
+
let nodeIds;
|
|
412
|
+
if (record.nodeIds !== undefined) {
|
|
413
|
+
requireStringArray(record.nodeIds, `${label}.nodeIds`);
|
|
414
|
+
nodeIds = /** @type {string[]} */ (record.nodeIds);
|
|
415
|
+
if (nodeIds.length === 0) throw new TypeError(`${label}.nodeIds must name at least one planned node`);
|
|
416
|
+
if (requirementIds.length === 0) throw new TypeError(`${label}.requirementIds must name at least one requirement when the declaration assigns nodes`);
|
|
417
|
+
}
|
|
418
|
+
return /** @type {PlanPhase} */ ({
|
|
419
|
+
id,
|
|
398
420
|
requirementIds: /** @type {string[]} */ (requirementIds),
|
|
421
|
+
...(nodeIds === undefined ? {} : { nodeIds }),
|
|
399
422
|
deliverable: /** @type {string} */ (record.deliverable),
|
|
400
|
-
};
|
|
423
|
+
});
|
|
401
424
|
});
|
|
425
|
+
|
|
426
|
+
const assigned = declarations.filter((declaration) => declaration.nodeIds !== undefined);
|
|
427
|
+
if (assigned.length > 0 && assigned.length !== declarations.length) {
|
|
428
|
+
throw new TypeError("plan.phases must assign nodeIds on every declaration or on none: mixing the two leaves the nodes named by the other declarations unattributed");
|
|
429
|
+
}
|
|
430
|
+
if (assigned.length > 0 && plannedNodeIds !== undefined) {
|
|
431
|
+
validateNodeAssignments(assigned, plannedNodeIds);
|
|
432
|
+
}
|
|
433
|
+
return declarations;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Refuse a node assignment that does not cover the plan exactly once. Called
|
|
438
|
+
* only for the nodeIds shape, where every declaration already names at least
|
|
439
|
+
* one node and at least one requirement.
|
|
440
|
+
*
|
|
441
|
+
* @param {PlanPhase[]} declarations
|
|
442
|
+
* @param {string[]} plannedNodeIds
|
|
443
|
+
* @returns {void}
|
|
444
|
+
*/
|
|
445
|
+
function validateNodeAssignments(declarations, plannedNodeIds) {
|
|
446
|
+
const planned = new Set(plannedNodeIds);
|
|
447
|
+
/** @type {Map<string, string>} */
|
|
448
|
+
const owner = new Map();
|
|
449
|
+
for (const declaration of declarations) {
|
|
450
|
+
for (const nodeId of declaration.nodeIds ?? []) {
|
|
451
|
+
if (!planned.has(nodeId)) {
|
|
452
|
+
throw new TypeError(`plan.phases: phase ${declaration.id} assigns unknown node ${nodeId}, which is not one of the plan's nodes`);
|
|
453
|
+
}
|
|
454
|
+
const previous = owner.get(nodeId);
|
|
455
|
+
if (previous !== undefined) {
|
|
456
|
+
throw new TypeError(`plan.phases assigns node ${nodeId} to both ${previous} and ${declaration.id}; every planned node belongs to exactly one phase`);
|
|
457
|
+
}
|
|
458
|
+
owner.set(nodeId, declaration.id);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const missing = [...planned].filter((nodeId) => !owner.has(nodeId));
|
|
462
|
+
if (missing.length > 0) {
|
|
463
|
+
throw new TypeError(`plan.phases leaves planned node(s) assigned to no phase: ${missing.join(", ")}`);
|
|
464
|
+
}
|
|
402
465
|
}
|
|
403
466
|
|
|
404
467
|
const FINDING_FIELDS = new Set(["id", "severity", "nodeId", "text"]);
|
|
@@ -17,6 +17,7 @@ import { join, resolve } from "node:path";
|
|
|
17
17
|
import { lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
import { RUNS_DIR_NAME } from "../run/paths.mjs";
|
|
20
|
+
import { isIgnoreSource } from "./workspace.mjs";
|
|
20
21
|
|
|
21
22
|
/** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
|
|
22
23
|
|
|
@@ -49,6 +50,21 @@ export function unsnapshottedWriteWarnings(node, index, cwd) {
|
|
|
49
50
|
`nodes[${index}] (${node.id}): ${path.includes("/") ? `${kind} under ${root}/` : `${kind} ${path}`} are outside the workspace snapshot, so the closed-scope gate cannot observe them`,
|
|
50
51
|
);
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* `writes_ignore_source`: a declared write the workspace snapshot fingerprints
|
|
55
|
+
* as an ignore source. The node fails with `snapshot_ignore_changed` the
|
|
56
|
+
* moment the worker changes it, and two campaigns each lost a node learning
|
|
57
|
+
* that (RM-051), so the author hears it before dispatch.
|
|
58
|
+
*
|
|
59
|
+
* @param {ValidatedNode} node
|
|
60
|
+
* @param {number} index
|
|
61
|
+
* @returns {string[]}
|
|
62
|
+
*/
|
|
63
|
+
export function ignoreSourceWriteWarnings(node, index) {
|
|
64
|
+
const sources = (node.taskPacket.writeFiles ?? []).filter(isIgnoreSource);
|
|
65
|
+
if (!sources.length) return [];
|
|
66
|
+
return [`nodes[${index}] (${node.id}): writes_ignore_source: writeFiles ${sources.join(", ")} ${sources.length === 1 ? "is an ignore source" : "are ignore sources"} the workspace snapshot fingerprints; a worker that changes one fails the node with snapshot_ignore_changed, so make that edit outside the run`];
|
|
67
|
+
}
|
|
52
68
|
/**
|
|
53
69
|
* @param {string|undefined} cwd
|
|
54
70
|
* @param {string} declaredPath
|
package/src/repo/workspace.mjs
CHANGED
|
@@ -53,7 +53,7 @@ export function captureWorkspaceSnapshot(cwd, expectedIgnoreSources) {
|
|
|
53
53
|
const root = realpathSync(cwd);
|
|
54
54
|
const ignoreSources = captureIgnoreSources(root);
|
|
55
55
|
if (expectedIgnoreSources !== undefined && !sameSnapshotEntries(expectedIgnoreSources, ignoreSources)) {
|
|
56
|
-
throw
|
|
56
|
+
throw ignoreSourcesChanged(expectedIgnoreSources, ignoreSources);
|
|
57
57
|
}
|
|
58
58
|
/** @type {SnapshotEntry[]} */
|
|
59
59
|
const entries = [];
|
|
@@ -123,7 +123,7 @@ export function compareWorkspaceSnapshot(before, cwd, scope = {}) {
|
|
|
123
123
|
});
|
|
124
124
|
const after = captureWorkspaceSnapshot(cwd, before.ignoreSources);
|
|
125
125
|
if (!sameSnapshotEntries(before.ignoreSources, after.ignoreSources)) {
|
|
126
|
-
throw
|
|
126
|
+
throw ignoreSourcesChanged(before.ignoreSources, after.ignoreSources);
|
|
127
127
|
}
|
|
128
128
|
const prior = new Map(before.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
|
|
129
129
|
const current = new Map(after.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
|
|
@@ -262,6 +262,45 @@ export function validateWorkspaceScopeBoundary(cwd, boundary, declared = {}) {
|
|
|
262
262
|
rootOrigins,
|
|
263
263
|
};
|
|
264
264
|
}
|
|
265
|
+
/** Directories `captureIgnoreSources` skips at any depth. */
|
|
266
|
+
const SKIPPED_SOURCE_DIRS = new Set([".git", RUNS_DIR_NAME, "node_modules", ".venv", "venv"]);
|
|
267
|
+
|
|
268
|
+
/** The ignore sources `captureIgnoreSources` always fingerprints at the root. */
|
|
269
|
+
const ROOT_IGNORE_SOURCES = [".faberunignore", ".gitignore", ".git/config"];
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Whether a workspace-relative path is one `captureIgnoreSources` fingerprints,
|
|
273
|
+
* so a worker that changes it fails its node with `snapshot_ignore_changed`.
|
|
274
|
+
* A `.gitignore` counts at any depth outside the directories the walk skips.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} path
|
|
277
|
+
* @returns {boolean}
|
|
278
|
+
*/
|
|
279
|
+
export function isIgnoreSource(path) {
|
|
280
|
+
const normalized = path.replaceAll("\\", "/").replace(/^\.\//u, "");
|
|
281
|
+
if (ROOT_IGNORE_SOURCES.includes(normalized) || normalized === ".git/info/exclude" || normalized === ".git") return true;
|
|
282
|
+
const segments = normalized.split("/");
|
|
283
|
+
// The same directories `captureIgnoreSources` never walks.
|
|
284
|
+
if (segments.some((segment) => SKIPPED_SOURCE_DIRS.has(segment)) || [".claude", ".codex"].includes(segments[0])) return false;
|
|
285
|
+
return basename(normalized) === ".gitignore";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The `snapshot_ignore_changed` failure, naming every source that moved.
|
|
290
|
+
* Measured on `rec-audit-remediation`: the message named none, and the
|
|
291
|
+
* operator had to diff the worktree to learn it was one `.gitignore` line.
|
|
292
|
+
*
|
|
293
|
+
* @param {SnapshotEntry[]} before
|
|
294
|
+
* @param {SnapshotEntry[]} after
|
|
295
|
+
* @returns {Error}
|
|
296
|
+
*/
|
|
297
|
+
function ignoreSourcesChanged(before, after) {
|
|
298
|
+
const prior = new Map(before.map((entry) => [entry.path, JSON.stringify(entry)]));
|
|
299
|
+
const current = new Map(after.map((entry) => [entry.path, JSON.stringify(entry)]));
|
|
300
|
+
const changed = [...new Set([...prior.keys(), ...current.keys()])].filter((path) => prior.get(path) !== current.get(path)).sort();
|
|
301
|
+
return fail("snapshot_ignore_changed", `workspace ignore sources changed during worker execution: ${changed.join(", ")}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
265
304
|
/**
|
|
266
305
|
* Snapshot the files Git can use to hide workspace changes. These entries are
|
|
267
306
|
* kept separate from the relevant-file entry cap. A worker cannot replace
|
|
@@ -273,7 +312,7 @@ export function validateWorkspaceScopeBoundary(cwd, boundary, declared = {}) {
|
|
|
273
312
|
*/
|
|
274
313
|
function captureIgnoreSources(root) {
|
|
275
314
|
/** @type {Set<string>} */
|
|
276
|
-
const paths = new Set(
|
|
315
|
+
const paths = new Set(ROOT_IGNORE_SOURCES);
|
|
277
316
|
/** @type {Map<string, string>} */
|
|
278
317
|
const gitPaths = new Map();
|
|
279
318
|
/** @param {string} name @param {string} logical @returns {string|null} */
|
package/src/repo/worktree.mjs
CHANGED
|
@@ -322,10 +322,27 @@ function gitTracks(repo, read) {
|
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
/**
|
|
325
|
-
*
|
|
325
|
+
* Every untracked, unignored path in a worktree, outside the runner's own
|
|
326
|
+
* `.runs` tree and the linked `node_modules`: the same exclusions the seal
|
|
327
|
+
* applies, so the two agree on what "left in the worktree" means.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} path
|
|
330
|
+
* @returns {string[]}
|
|
331
|
+
*/
|
|
332
|
+
export function untrackedPaths(path) {
|
|
333
|
+
const listed = git(path, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".", `:(exclude)${RUNS_DIR_NAME}`, ":(exclude)node_modules"]);
|
|
334
|
+
return listed.split("\0").filter((entry) => entry.startsWith("?? ")).map((entry) => entry.slice(3)).sort();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* `exclude` names paths the attempt holds but must not seal: what the
|
|
339
|
+
* controller's own verification left behind (`verificationArtifacts`). They
|
|
340
|
+
* stay on disk and out of the commit.
|
|
341
|
+
*
|
|
342
|
+
* @param {{repo: string, path: string, baseSha: string|null, runId: string, nodeId: string, attempt: number, exclude?: string[]}} args
|
|
326
343
|
* @returns {SealedAttempt}
|
|
327
344
|
*/
|
|
328
|
-
export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
|
|
345
|
+
export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt, exclude = [] }) {
|
|
329
346
|
// The attempt-local `.runs` result sidecar must never enter the attempt
|
|
330
347
|
// commit. Naming it through an exclude pathspec makes `git add` exit 1 with
|
|
331
348
|
// advice.addIgnoredFile as soon as the sidecar exists in a repository that
|
|
@@ -345,6 +362,11 @@ export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
|
|
|
345
362
|
// node_modules is linked into the worktree as a symlink, which `node_modules/`
|
|
346
363
|
// in .gitignore does not match; never let the link into the attempt commit.
|
|
347
364
|
runGit(["-C", path, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", RUNS_DIR_NAME, "node_modules"]);
|
|
365
|
+
if (exclude.length) runGit(["-C", path, "rm", "-q", "--cached", "--ignore-unmatch", "--", ...exclude.map((item) => `:(literal)${item}`)]);
|
|
366
|
+
}
|
|
367
|
+
// A worktree whose only change was an excluded artifact stages nothing, and
|
|
368
|
+
// an empty commit exits 1; the attempt's head is then its seal.
|
|
369
|
+
if (dirty && !stagedNothing(path)) {
|
|
348
370
|
runGit([
|
|
349
371
|
"-C", path,
|
|
350
372
|
"-c", "user.email=runner@example.test",
|
|
@@ -371,6 +393,16 @@ export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
|
|
|
371
393
|
return { sha, empty };
|
|
372
394
|
}
|
|
373
395
|
|
|
396
|
+
/** @param {string} path @returns {boolean} */
|
|
397
|
+
function stagedNothing(path) {
|
|
398
|
+
try {
|
|
399
|
+
runGit(["-C", path, "diff", "--cached", "--quiet"]);
|
|
400
|
+
return true;
|
|
401
|
+
} catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
374
406
|
/** @param {string} repo @param {string} base @param {string} head @returns {boolean} */
|
|
375
407
|
export function gitDiffEmpty(repo, base, head) {
|
|
376
408
|
try {
|