faberun 0.30.0 → 0.31.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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/campaign/campaign-brief.mjs +17 -6
  3. package/src/campaign/chain.mjs +124 -8
  4. package/src/campaign/ledger.mjs +19 -7
  5. package/src/campaign/metrics-command.mjs +68 -9
  6. package/src/campaign/metrics.mjs +75 -45
  7. package/src/campaign/record.mjs +8 -1
  8. package/src/campaign/reserve.mjs +340 -0
  9. package/src/campaign/spend.mjs +229 -0
  10. package/src/campaign/unpark.mjs +54 -1
  11. package/src/campaign/watch.mjs +314 -37
  12. package/src/contract/judge-envelope.mjs +17 -2
  13. package/src/contract/scope-findings.mjs +3 -1
  14. package/src/contract/task-packet.mjs +40 -5
  15. package/src/contract/verification.mjs +67 -1
  16. package/src/engine/aux-admission.mjs +124 -0
  17. package/src/engine/capacity.mjs +78 -0
  18. package/src/engine/dispatch.mjs +116 -23
  19. package/src/engine/failure-cause.mjs +59 -0
  20. package/src/engine/lifecycle.mjs +3 -2
  21. package/src/engine/phase-session.mjs +56 -10
  22. package/src/engine/prompts.mjs +57 -2
  23. package/src/engine/reserve-admission.mjs +99 -0
  24. package/src/engine/resume.mjs +3 -1
  25. package/src/engine/review.mjs +21 -0
  26. package/src/engine/run-command.mjs +166 -11
  27. package/src/engine/scheduler.mjs +71 -88
  28. package/src/engine/settle.mjs +15 -8
  29. package/src/engine/settlement-overlap.mjs +143 -0
  30. package/src/engine/verify.mjs +131 -7
  31. package/src/harnesses/session-metrics.mjs +112 -20
  32. package/src/notify/index.mjs +86 -10
  33. package/src/plan/freeze.mjs +7 -2
  34. package/src/plan/human-step.mjs +36 -0
  35. package/src/plan/pipeline.mjs +53 -22
  36. package/src/plan/preflight.mjs +81 -5
  37. package/src/plan/progress.mjs +101 -1
  38. package/src/plan/repo-facts.mjs +403 -49
  39. package/src/plan/resolve.mjs +7 -0
  40. package/src/plan/revise-context.mjs +108 -0
  41. package/src/plan/rounds.mjs +180 -45
  42. package/src/plan/sizing.mjs +33 -1
  43. package/src/plan/template.mjs +134 -22
  44. package/src/repo/integrate.mjs +47 -5
  45. package/src/report/campaign-brief-estimate.mjs +47 -1
  46. package/src/report/campaign-brief.mjs +27 -0
  47. package/src/report/locale.mjs +20 -0
  48. package/src/report/metrics-report.mjs +38 -0
  49. package/src/report/progress.mjs +194 -10
  50. package/src/run/usage-windows.mjs +99 -4
  51. package/src/run/usage.mjs +69 -7
  52. package/src/web/api.mjs +179 -24
  53. package/src/web/app.mjs +27 -2
@@ -1,17 +1,93 @@
1
1
  /**
2
- * The ask `faberun plan` makes before its first stage.
2
+ * What `faberun plan` decides before it spends anything: the refusals it
3
+ * returns before the first stage is dispatched -- which runtimes a planning run
4
+ * will spend, and what counts as one of them not answering -- and the
5
+ * deterministic checks a plan passes before any semantic review grades it.
3
6
  *
4
- * It lives beside the pipeline rather than inside it because asking is its own
5
- * concern -- which runtimes a planning run will spend, and what counts as one
6
- * of them not answering -- and because `pipeline.mjs` sits 45 lines from this
7
- * tree's 800-line ceiling.
7
+ * Both halves live beside the pipeline rather than inside it because asking
8
+ * and checking are their own concerns, and because `pipeline.mjs` sits close
9
+ * to this tree's 800-line ceiling.
8
10
  */
11
+ import { join } from "node:path";
9
12
  import { preflightRuntimes } from "../engine/live-preflight.mjs";
10
13
  import { liveSilenceCause } from "../engine/live-silence.mjs";
11
14
  import { harnessCapabilities } from "../harnesses/index.mjs";
15
+ import { validateContract } from "../contract/index.mjs";
12
16
  import { assertRuntimeExecutesCommands, validateRuntime } from "../contract/runtime.mjs";
13
17
  import { effectiveProvider } from "../contract/provider.mjs";
14
18
  import { SAME_VENDOR_REVIEW_MODE, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
19
+ import { assertTimeoutsCoverMeasured, raiseTimeoutsToMeasured } from "./freeze.mjs";
20
+ import { assertFilteredProofsNameTheirTest, declareDirectoryGuards } from "./proof-scope.mjs";
21
+ import { checkPlanProofs } from "./proof-check.mjs";
22
+
23
+ /** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
24
+ /** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
25
+ /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
26
+ /** @typedef {import("./pipeline.mjs").AssembledPlan} AssembledPlan */
27
+
28
+ /**
29
+ * What the deterministic checks need: the measured repository facts the freeze
30
+ * rules read, the cwd and plansDir the contract is assembled against, and the
31
+ * two closures that render the plan the way `freezePlan` will. They are
32
+ * supplied rather than imported because they are the pipeline's own assembly
33
+ * (`pipeline.mjs` imports this module), and taking them as data is what lets
34
+ * `rounds.mjs` — and a test — run the checks with fakes.
35
+ *
36
+ * @typedef {{repoFacts: import("./repo-facts.mjs").RepoFacts, cwd: string, plansDir: string, assembleFrozenNodes: (plan: PlanOutput) => AssembledPlan, frozenContractRaw: (assembly: AssembledPlan) => JsonObject}} PlanCheckContext
37
+ */
38
+
39
+ /**
40
+ * The two mechanically repairable freeze rules, applied, then the freeze
41
+ * contract itself validated the way `freeze.mjs` and `proof-scope.mjs`
42
+ * validate it: the rules are imported, never restated, so this cannot drift
43
+ * from the freeze it stands in front of.
44
+ *
45
+ * It does not throw. A plan the checks refuse is a diagnostic for the repair
46
+ * loop, not a refusal: the refusal is the pre-dispatch half of this module,
47
+ * and a thrown plan check would spend a round's budget twice — once on the
48
+ * revise that answers a finding, and once on the error that killed it.
49
+ *
50
+ * The failure is returned beside the repaired plan rather than instead of it,
51
+ * so the caller can hand the plan on to a review (or to the freeze) while
52
+ * still recording what the freeze would have said.
53
+ *
54
+ * @param {PlanOutput} plan
55
+ * @param {PlanCheckContext} ctx
56
+ * @returns {{plan: PlanOutput, raised: string[], declared: string[], failure: unknown|null}}
57
+ */
58
+ export function freezePreflight(plan, ctx) {
59
+ const timeouts = raiseTimeoutsToMeasured(plan, ctx.repoFacts);
60
+ const guards = declareDirectoryGuards(timeouts.plan, ctx.repoFacts, ctx.cwd);
61
+ const candidate = guards.plan;
62
+ const repaired = { plan: candidate, raised: timeouts.raised, declared: guards.declared };
63
+ try {
64
+ const contract = validateContract(ctx.frozenContractRaw(ctx.assembleFrozenNodes(candidate)), join(ctx.plansDir, "contract.json"));
65
+ assertFilteredProofsNameTheirTest(contract);
66
+ assertTimeoutsCoverMeasured(contract, ctx.repoFacts);
67
+ return { ...repaired, failure: null };
68
+ } catch (error) {
69
+ return { ...repaired, failure: error };
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The complete deterministic check set a plan passes before any semantic
75
+ * review begins: `freezePreflight` plus the plan-wide proof scan, whose two
76
+ * shapes (a name filter that selects no test, a bare grep proving an absence)
77
+ * no reviewer decides better than a local read. The pipeline runs this once on
78
+ * the draft and forwards `findings` into the round loop's own repair budget —
79
+ * the rounds `--review-rounds` already pays for — while the loop runs
80
+ * `freezePreflight` on every candidate it grades, where the repair it drives
81
+ * is the answer to whatever the check refused.
82
+ *
83
+ * @param {PlanOutput} plan
84
+ * @param {PlanCheckContext} ctx
85
+ * @returns {{plan: PlanOutput, raised: string[], declared: string[], findings: PlanFindingOutput[], failure: unknown|null}}
86
+ */
87
+ export function checkPlanBeforeReview(plan, ctx) {
88
+ const checks = freezePreflight(plan, ctx);
89
+ return { ...checks, findings: checkPlanProofs(checks.plan, ctx.repoFacts, ctx.cwd) };
90
+ }
15
91
 
16
92
  /**
17
93
  * The runtimes a planning run will spend, asked once before its first stage.
@@ -19,12 +19,24 @@
19
19
  * also why `plan --resolve` writes none: it is never detached, so a death
20
20
  * there is visible where it happens and a record would only add a second
21
21
  * account of something the operator already watched.
22
+ *
23
+ * The module also holds the record of what a dead attempt finished:
24
+ * `writePlanDraftRecord` persists the validated draft plan beside the phase's
25
+ * plan artifacts, and `resumablePlanDraft` reads it back for a resumed
26
+ * attempt, because the liveness record says that the attempt died but not
27
+ * what its death had already paid for. `startDraftPlan` decides, for one
28
+ * attempt, between adopting that draft and dispatching the draft stage —
29
+ * dispatching stays the pipeline's verb (it is passed in), but whether to
30
+ * dispatch at all is this module's question. Same boundary, same reader —
31
+ * all of it answers a later launch's one question, what did the last attempt
32
+ * leave behind.
22
33
  */
23
- import { rmSync } from "node:fs";
34
+ import { existsSync, rmSync } from "node:fs";
24
35
  import { join } from "node:path";
25
36
  import { pidAlive, processStartToken, sameProcessStartToken } from "../run/lock.mjs";
26
37
  import { campaignTree } from "../run/paths.mjs";
27
38
  import { readJson, writeJsonAtomic } from "../run/store.mjs";
39
+ import { validatePlanOutput } from "./template.mjs";
28
40
 
29
41
  /** @typedef {{pid?: number, processStartToken?: string|null, at?: string, stage?: string, [key: string]: unknown}} PlanProgressRecord */
30
42
 
@@ -99,3 +111,91 @@ export function abandonedPlanProgress(cwd, campaignId, phase) {
99
111
  export function clearPlanProgress(cwd, campaignId, phase) {
100
112
  rmSync(planProgressPath(cwd, campaignId, phase), { force: true });
101
113
  }
114
+
115
+ /** The file beside a phase's plan artifacts holding the draft a resumed attempt can adopt. */
116
+ const PLAN_DRAFT_FILE = "draft.json";
117
+
118
+ /**
119
+ * The validated draft a later attempt can adopt without paying for it again.
120
+ *
121
+ * @typedef {{runId: string, at: string, specDigest: string, plan: import("./template.mjs").PlanOutput}} PlanDraftRecord
122
+ */
123
+
124
+ /**
125
+ * Persist the validated draft plan where a later launch of the same phase
126
+ * finds it: beside the phase's durable plan artifacts, never in the disposable
127
+ * scratch tree, for the same reason the progress record is there — a record a
128
+ * later launch has to find cannot live where the next `plan` may wipe. The
129
+ * record is written once, when the draft validates, and never removed: a
130
+ * frozen or contested phase writes `plan.json` beside it, and the presence of
131
+ * that file is what stops a later attempt from adopting anything.
132
+ *
133
+ * @param {string} plansDir
134
+ * @param {PlanDraftRecord} record
135
+ * @returns {void}
136
+ */
137
+ export function writePlanDraftRecord(plansDir, record) {
138
+ writeJsonAtomic(join(plansDir, PLAN_DRAFT_FILE), record);
139
+ }
140
+
141
+ /**
142
+ * The dead attempt's draft, when adopting it costs no paid call, or null when
143
+ * there is nothing to adopt. Two records must agree before anything is
144
+ * adopted: `abandoned` — the progress record above, sampled by the caller
145
+ * before its own first liveness write, which would otherwise overwrite the
146
+ * evidence of the death it is about to resume from — and the draft record
147
+ * `writePlanDraftRecord` wrote when the dead attempt validated its draft. The
148
+ * plan in it is exactly the paid work a resumed attempt would otherwise
149
+ * duplicate. A phase that ever finished, frozen or contested, has a
150
+ * `plan.json` beside the record: that file, not the record's absence, is
151
+ * what makes a later deliberate re-plan draft fresh. A record from a
152
+ * different spec (the operator edited the spec between attempts) or one
153
+ * whose plan no longer validates is refused the same way.
154
+ *
155
+ * @param {{abandoned: PlanProgressRecord|null, plansDir: string, specDigest: string}} options
156
+ * @returns {{runId: string, plan: import("./template.mjs").PlanOutput}|null}
157
+ */
158
+ export function resumablePlanDraft({ abandoned, plansDir, specDigest }) {
159
+ if (abandoned === null) return null;
160
+ if (existsSync(join(plansDir, "plan.json"))) return null;
161
+ let record;
162
+ try {
163
+ record = /** @type {PlanDraftRecord|unknown} */ (readJson(join(plansDir, PLAN_DRAFT_FILE)));
164
+ } catch {
165
+ // An absent record is the ordinary case: a draft still running, or a
166
+ // death before the draft validated, leaves nothing to adopt.
167
+ return null;
168
+ }
169
+ if (!record || typeof record !== "object" || Array.isArray(record)) return null;
170
+ const draft = /** @type {PlanDraftRecord} */ (record);
171
+ if (draft.specDigest !== specDigest || typeof draft.runId !== "string") return null;
172
+ try {
173
+ return { runId: draft.runId, plan: validatePlanOutput(draft.plan) };
174
+ } catch {
175
+ // The record's plan no longer validates: adopting it would spend the
176
+ // round budget repairing an unknown shape, so the caller drafts fresh.
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The draft one attempt starts from: the dead attempt's validated plan when
183
+ * adopting it costs no paid call, or a fresh dispatch of the draft stage.
184
+ * The adoption is announced as its own stage line — a resumed attempt that
185
+ * dispatched nothing must still leave the same record a drafting one does.
186
+ *
187
+ * @param {{abandoned: PlanProgressRecord|null, plansDir: string, specDigest: string,
188
+ * draftStageInput: Record<string, unknown>,
189
+ * runStage: (kind: import("./template.mjs").PlanningKind, input: Record<string, unknown>) => Promise<{contract: {id: string}, output: Record<string, unknown>}>,
190
+ * logStage: (stage: string, detail: Record<string, unknown>) => void}} options
191
+ * @returns {Promise<{runId: string, planOutput: unknown, resumed: boolean}>}
192
+ */
193
+ export async function startDraftPlan({ abandoned, plansDir, specDigest, draftStageInput, runStage, logStage }) {
194
+ const resumed = resumablePlanDraft({ abandoned, plansDir, specDigest });
195
+ if (resumed !== null) {
196
+ logStage("draft-resumed", { runId: resumed.runId, nodeCount: resumed.plan.nodes.length });
197
+ return { runId: resumed.runId, planOutput: resumed.plan, resumed: true };
198
+ }
199
+ const stage = await runStage("draft", draftStageInput);
200
+ return { runId: stage.contract.id, planOutput: stage.output.plan, resumed: false };
201
+ }