@gr8ful/spf 0.9.2 → 0.10.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.
Files changed (37) hide show
  1. package/README.md +56 -0
  2. package/assets/defaults/spf.config.yaml +75 -0
  3. package/assets/skill/references/config.md +98 -4
  4. package/dist/chains/index.d.ts +2 -0
  5. package/dist/chains/index.js +4 -0
  6. package/dist/cli/commands/doctor.js +339 -2
  7. package/dist/cli/commands/fanout.d.ts +7 -14
  8. package/dist/cli/commands/fanout.js +45 -39
  9. package/dist/cli/commands/loop.d.ts +2 -0
  10. package/dist/cli/commands/loop.js +198 -0
  11. package/dist/cli/commands/run.js +14 -4
  12. package/dist/cli/commands/watch.d.ts +29 -1
  13. package/dist/cli/commands/watch.js +219 -64
  14. package/dist/cli/index.js +14 -0
  15. package/dist/core/agent_cc.d.ts +11 -0
  16. package/dist/core/agent_cc.js +25 -2
  17. package/dist/core/agent_flue.js +14 -5
  18. package/dist/core/agents.d.ts +61 -1
  19. package/dist/core/agents.js +363 -6
  20. package/dist/core/data_types.d.ts +316 -0
  21. package/dist/core/data_types.js +143 -0
  22. package/dist/core/loop.d.ts +230 -0
  23. package/dist/core/loop.js +290 -0
  24. package/dist/core/quality.d.ts +1 -2
  25. package/dist/core/sandbox.d.ts +236 -0
  26. package/dist/core/sandbox.js +655 -0
  27. package/dist/core/sandbox_cloudflare.d.ts +137 -0
  28. package/dist/core/sandbox_cloudflare.js +505 -0
  29. package/dist/core/sandbox_opensandbox.d.ts +59 -0
  30. package/dist/core/sandbox_opensandbox.js +484 -0
  31. package/dist/core/sandbox_sdk_types.d.ts +171 -0
  32. package/dist/core/sandbox_sdk_types.js +20 -0
  33. package/dist/core/watch.d.ts +56 -0
  34. package/dist/core/watch.js +358 -52
  35. package/dist/core/worktree_data.d.ts +1 -0
  36. package/dist/core/worktree_data.js +37 -0
  37. package/package.json +1 -1
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `spf loop` — run a whole chain repeatedly, toward one goal, until a
3
+ * TESTABLE STOP passes or a breaker trips. A different verb from `spf run`:
4
+ * an inner `fixLoop`/`reviseLoop` (`chains/steps.ts`) retries WITHIN one
5
+ * chain run at one known check; this retries WHOLE CHAIN RUNS at a goal the
6
+ * chain itself has no opinion about (an a11y score, an external rating, a
7
+ * suite passing on iteration N instead of iteration 1).
8
+ *
9
+ * SCRIPT STOPS ONLY, V1. A stop condition NAMES a `quality.suites` suite —
10
+ * it is resolved and run through `quality.runSuite`'s exact machinery
11
+ * (argv, no shell, per-check timeout, tail-captured evidence), never a raw
12
+ * command string. `gates.testsPass` was considered and rejected: it is a
13
+ * synchronous, unbounded `spawnSync` deliberately kept off `GATE_ALLOWLIST`
14
+ * (see `chains/steps.ts`'s GATE_ALLOWLIST comment) for exactly the reason
15
+ * it would be wrong here too — a hung check with no timeout, and (for any
16
+ * future tracker-sourced stop) a shell string is a command-injection
17
+ * surface. Naming a suite is data, not code, the same discipline that
18
+ * makes `.spf/chains/*.yaml` safe.
19
+ *
20
+ * FRESH adw_id EVERY ITERATION, NEVER NULL. `runChain`'s `finally` block
21
+ * (`otel.releaseOtelExporter`, `session.finalize`) is a documented no-op
22
+ * when `adw_id` is null, because a one-shot CLI process exits right after —
23
+ * an assumption this long-lived driver would violate by leaking a Tracer
24
+ * sqlite handle and growing `session.ts`'s `ACTIVE` map every iteration.
25
+ * Naming follows `core/fanout.ts`'s own scheme (`attemptAdwId`,
26
+ * `${base}-${i}`) so `spf estimate`'s existing fanout-sibling collapse
27
+ * (`cli/commands/estimate.ts`'s `fanoutBaseOf`) recognizes loop iterations
28
+ * as siblings for free — which is also why the base id must look like
29
+ * `newId(8)`'s actual output (8 lowercase hex), never a human label
30
+ * directly; see `resolveGoalId` below.
31
+ *
32
+ * EVERY ITERATION IS TRY/CAUGHT. `runSteps` (`chains/steps.ts`) has no
33
+ * try/catch of its own — a budget overrun (`agents.assertRunBudget`), a
34
+ * converged no-op iteration (`changes()` throwing "nothing changed"), a
35
+ * gate failure, or a permission breach all throw UNCAUGHT out of
36
+ * `runChain`. Those are exactly the outcomes a multi-attempt loop hits
37
+ * routinely, so the driver must not die on the first one — it records a
38
+ * distinct `"errored"` outcome and keeps going, same as `fixLoop` treats
39
+ * "still not accepted" as ordinary state rather than a thrown error.
40
+ *
41
+ * THE LEDGER IS GOAL-SCOPED, NEVER SESSION-SCOPED. `Run.context_handoff_dir`
42
+ * (`core/runner.ts`) is `<data_dir>/sessions/<adw_id>/context_handoff` — with
43
+ * a fresh adw_id every iteration, that directory is a brand-new empty
44
+ * folder every single pass. The ledger this module owns lives at
45
+ * `<data_dir>/loop/<goal_id>/ledger.json` instead: outside every session
46
+ * dir, so it is the one thing that actually persists across iterations
47
+ * (breakers 2 and 3 below have nothing to read back otherwise) AND across
48
+ * process restarts — which is what makes `cron + spf loop --max 1` a real
49
+ * scheduled loop with working breakers, not just a bare retry.
50
+ *
51
+ * FOUR BREAKERS, NONE OF THEM THE STOP CHECK ITSELF. The tested goal is
52
+ * exactly the signal a loop can spin forever chasing marginal gains against,
53
+ * so every breaker below is driver-owned, external to the check:
54
+ * 1. --max (required, no default) — an outer iteration is a whole chain
55
+ * run; a silent default is a cost surprise `fixLoop`'s own `max?:
56
+ * number` (defaulted to 3) doesn't have to worry about because ITS
57
+ * cost is one suite run, not one chain.
58
+ * 2. --max-cost / --max-tokens — a cumulative ceiling across every
59
+ * iteration, read back from the ledger and checked BEFORE dispatching
60
+ * the next one. Same honest caveat `core/fanout.ts`'s own budget note
61
+ * states for its N-multiplier: checked between iterations, never
62
+ * inside one, so a single expensive iteration can still overshoot by
63
+ * up to its own spend — say so in `--help`, don't imply a hard cap.
64
+ * 3. --stuck-after N (default 3) — N consecutive iterations with an
65
+ * unchanged commit sha. A score-based stuck check was considered and
66
+ * rejected for v1: nothing here judges a numeric score (that is the
67
+ * deferred prompt-stop path), and a diff/commit-sha check is always
68
+ * available regardless of what the suite measures.
69
+ * 4. --min-interval-ms — mandatory whenever a suite's checks hit an
70
+ * external service with no documented quota (isitagentready.com
71
+ * throttled after roughly one rapid request in testing). Applied
72
+ * between iterations, not inside `quality.runSuite`'s own per-check
73
+ * timeout, which is a different ceiling (one check hanging) from this
74
+ * one (calling the external service too often).
75
+ *
76
+ * BEST-ATTEMPT RETENTION. Each iteration commits onto whatever branch the
77
+ * operator is on (`chains/steps.ts`'s `commit()` — this module never
78
+ * touches branches or worktrees, unlike `core/fanout.ts`'s isolated-attempt
79
+ * model). Without recording a commit sha per attempt, an exhausted or
80
+ * stuck loop leaves whichever tree the LAST iteration happened to produce
81
+ * checked out — possibly worse than an earlier one. The ledger's
82
+ * `best_attempt` field and the printed "best: attempt N (sha ...)" line are
83
+ * what let the operator recover deliberately instead of by accident.
84
+ */
85
+ /** Script-only in v1 — see this module's doc comment for why a prompt/judge stop is deferred. */
86
+ export interface StopCondition {
87
+ kind: "script";
88
+ /** A `quality.suites` name, resolved through `quality.resolveSuite`/`runSuite` — never a raw shell string. */
89
+ suite: string;
90
+ }
91
+ export interface StopVerdict {
92
+ passed: boolean;
93
+ /** Verbatim from `QualityResult.failures` — enough for the next iteration's prompt without opening an artifact. */
94
+ failures: string[];
95
+ artifacts: string[];
96
+ }
97
+ export type AttemptOutcome = "passed" | "not-accepted" | "errored";
98
+ export type TerminalReason = "passed" | "exhausted" | "stuck";
99
+ export interface LedgerAttempt {
100
+ index: number;
101
+ adw_id: string;
102
+ outcome: AttemptOutcome;
103
+ /** `null` for an "errored" attempt whose exit code never got a chance to matter. */
104
+ exit_code: number | null;
105
+ /** The thrown error's message, only set when outcome === "errored". */
106
+ error: string | null;
107
+ /** HEAD's short sha after a successful (exit 0) iteration — unchanged from the previous attempt's if this iteration committed nothing new, which is exactly what `isStuck` below looks for. `null` only for an errored or non-accepted (nonzero exit) attempt, where nothing was checked out to read. */
108
+ commit_sha: string | null;
109
+ tokens: number;
110
+ cost: number;
111
+ failures: string[];
112
+ started_at: string;
113
+ ended_at: string;
114
+ }
115
+ export interface Ledger {
116
+ goal_id: string;
117
+ /** Minted ONCE, on the ledger's first write, and reused on every resume — never regenerated per process — so a killed-and-resumed or cron-repeated loop keeps deriving iteration adw_ids from the same base and `estimate.ts`'s sibling collapse still sees one family instead of a new one per process invocation. */
118
+ base_adw_id: string;
119
+ chain: string;
120
+ goal: string;
121
+ stop: StopCondition;
122
+ max: number;
123
+ attempts: LedgerAttempt[];
124
+ /** Set once the loop reaches a terminal state — absent while still in progress (a crash mid-loop leaves it absent, which is the honest state). */
125
+ reason?: TerminalReason;
126
+ /** The index of the best attempt seen so far — see `pickBestAttempt` below. `null` until at least one attempt commits something. */
127
+ best_attempt_index: number | null;
128
+ }
129
+ /** `<data_dir>/loop/<goal_id>/` — goal-scoped, outside every session dir. See this module's doc comment for why. */
130
+ export declare function ledgerDir(dataDir: string, goalId: string): string;
131
+ export declare function loadLedger(dataDir: string, goalId: string): Ledger | null;
132
+ /**
133
+ * `baseAdwId` is ALWAYS a fresh `newId(8)` — 8 lowercase hex, matching what
134
+ * `estimate.ts`'s `fanoutBaseOf` regex (`/^([0-9a-f]{8})-\d+$/`) expects, so
135
+ * this loop's iterations collapse as fanout-shaped siblings in `spf
136
+ * estimate`'s sample exactly the way `spf fanout`'s do. `goalId` is the
137
+ * human-facing label (`--goal-id`, or the same fresh id if none was given)
138
+ * used for the ledger's own directory name and printed to the operator —
139
+ * deliberately NOT required to look like `newId(8)`'s output, since
140
+ * `issue-41` or `a11y-loop` are exactly what a human wants to type and read
141
+ * back, and nothing downstream derives an adw_id from `goalId` itself.
142
+ */
143
+ export declare function newLedger(input: {
144
+ goalId: string;
145
+ chain: string;
146
+ goal: string;
147
+ stop: StopCondition;
148
+ max: number;
149
+ }): Ledger;
150
+ /** `--goal-id`, or a freshly minted one — human-facing only, see `newLedger`'s doc comment for why it need not be shaped like `newId(8)`'s output. */
151
+ export declare function resolveGoalId(explicit: string | undefined): string;
152
+ export interface CumulativeBudget {
153
+ maxCost?: number;
154
+ maxTokens?: number;
155
+ }
156
+ export declare function cumulativeSpend(attempts: LedgerAttempt[]): {
157
+ cost: number;
158
+ tokens: number;
159
+ };
160
+ /** Whether the goal-scoped ceiling is already exhausted going into the NEXT iteration — a stronger, ledger-wide check than any single iteration's own budget. */
161
+ export declare function overCumulativeBudget(budget: CumulativeBudget, spend: {
162
+ cost: number;
163
+ tokens: number;
164
+ }): boolean;
165
+ /**
166
+ * N consecutive attempts with the SAME commit sha (including all-null, an
167
+ * unbroken run of no-op iterations) — a diff-based check, not a score-based
168
+ * one, because nothing in v1 judges a numeric score and a commit sha is
169
+ * always available regardless of what the suite measures. `stuckAfter`
170
+ * attempts of history are enough to trip; fewer never can.
171
+ */
172
+ export declare function isStuck(attempts: LedgerAttempt[], stuckAfter: number): boolean;
173
+ /** The best attempt so far: the latest one that both committed something and passed the most recent stop check it ran — falling back to "committed something" when none has passed yet. Pure, so it is testable with no ledger I/O. */
174
+ export declare function pickBestAttempt(attempts: LedgerAttempt[]): number | null;
175
+ export interface RunIteration {
176
+ index: number;
177
+ adw_id: string;
178
+ /** The prompt for this iteration — the goal, plus (from iteration 2 on) a correction block built from the previous verdict, in the SAME shape `gates.ts`'s violation-correction text already uses (see `agents.ts:587-591` and `gates.ts`'s own doc comment) — the format builders already respond well to, one level up. */
179
+ prompt: string;
180
+ }
181
+ export interface IterationResult {
182
+ /** `null` when the iteration threw before `runChain` returned anything. */
183
+ exit_code: number | null;
184
+ /** Set only when the iteration threw. */
185
+ error: string | null;
186
+ /** `null` when nothing was committed this iteration (a no-op, or a throw before any commit). */
187
+ commit_sha: string | null;
188
+ tokens: number;
189
+ cost: number;
190
+ /** `null` when the iteration errored or exited non-zero — the stop check only ever runs against a chain that accepted its own work, same as `fixLoop` only re-verifies after a phase that didn't already throw. */
191
+ stop_verdict: StopVerdict | null;
192
+ }
193
+ export interface LoopDeps {
194
+ chainName: string;
195
+ goal: string;
196
+ stop: StopCondition;
197
+ max: number;
198
+ budget: CumulativeBudget;
199
+ stuckAfter: number;
200
+ minIntervalMs: number;
201
+ dataDir: string;
202
+ goalId: string;
203
+ /**
204
+ * Run one iteration end-to-end: build ctx, call `runChain`, and — only if
205
+ * it exited 0 — evaluate `deps.stop` (through `quality.runSuite`) against
206
+ * the same `Run`, then read back tokens/cost by `adw_id` and the HEAD
207
+ * commit sha if one changed. Never throws by contract: a thrown
208
+ * `runChain` call must be caught by the implementation and folded into
209
+ * `IterationResult.error`, the same way `fanout.ts`'s `runAttempt` catches
210
+ * around its own `runChainDef` call.
211
+ */
212
+ runIteration: (iteration: RunIteration) => Promise<IterationResult>;
213
+ log: (message: string) => void;
214
+ /** Real `Promise`-based sleep, injected so a test can fake it — `--min-interval-ms` between iterations. */
215
+ sleep: (ms: number) => Promise<void>;
216
+ }
217
+ export interface LoopResult {
218
+ ledger: Ledger;
219
+ /** 0 (passed), 1 (exhausted/stuck) — printed exit code semantics live in the CLI layer, this is just pass/fail. */
220
+ exitCode: number;
221
+ }
222
+ /**
223
+ * Run iterations until the stop passes, `max` is exhausted, or the loop is
224
+ * stuck. Never throws — every iteration is caught by `deps.runIteration`'s
225
+ * own contract, and the ledger is written after every iteration so a killed
226
+ * process leaves a readable partial history.
227
+ */
228
+ export declare function runLoop(deps: LoopDeps): Promise<LoopResult>;
229
+ /** A human-readable summary line for the CLI's final print — shared so a test can pin its exact wording. */
230
+ export declare function summarize(result: LoopResult): string;
@@ -0,0 +1,290 @@
1
+ /**
2
+ * `spf loop` — run a whole chain repeatedly, toward one goal, until a
3
+ * TESTABLE STOP passes or a breaker trips. A different verb from `spf run`:
4
+ * an inner `fixLoop`/`reviseLoop` (`chains/steps.ts`) retries WITHIN one
5
+ * chain run at one known check; this retries WHOLE CHAIN RUNS at a goal the
6
+ * chain itself has no opinion about (an a11y score, an external rating, a
7
+ * suite passing on iteration N instead of iteration 1).
8
+ *
9
+ * SCRIPT STOPS ONLY, V1. A stop condition NAMES a `quality.suites` suite —
10
+ * it is resolved and run through `quality.runSuite`'s exact machinery
11
+ * (argv, no shell, per-check timeout, tail-captured evidence), never a raw
12
+ * command string. `gates.testsPass` was considered and rejected: it is a
13
+ * synchronous, unbounded `spawnSync` deliberately kept off `GATE_ALLOWLIST`
14
+ * (see `chains/steps.ts`'s GATE_ALLOWLIST comment) for exactly the reason
15
+ * it would be wrong here too — a hung check with no timeout, and (for any
16
+ * future tracker-sourced stop) a shell string is a command-injection
17
+ * surface. Naming a suite is data, not code, the same discipline that
18
+ * makes `.spf/chains/*.yaml` safe.
19
+ *
20
+ * FRESH adw_id EVERY ITERATION, NEVER NULL. `runChain`'s `finally` block
21
+ * (`otel.releaseOtelExporter`, `session.finalize`) is a documented no-op
22
+ * when `adw_id` is null, because a one-shot CLI process exits right after —
23
+ * an assumption this long-lived driver would violate by leaking a Tracer
24
+ * sqlite handle and growing `session.ts`'s `ACTIVE` map every iteration.
25
+ * Naming follows `core/fanout.ts`'s own scheme (`attemptAdwId`,
26
+ * `${base}-${i}`) so `spf estimate`'s existing fanout-sibling collapse
27
+ * (`cli/commands/estimate.ts`'s `fanoutBaseOf`) recognizes loop iterations
28
+ * as siblings for free — which is also why the base id must look like
29
+ * `newId(8)`'s actual output (8 lowercase hex), never a human label
30
+ * directly; see `resolveGoalId` below.
31
+ *
32
+ * EVERY ITERATION IS TRY/CAUGHT. `runSteps` (`chains/steps.ts`) has no
33
+ * try/catch of its own — a budget overrun (`agents.assertRunBudget`), a
34
+ * converged no-op iteration (`changes()` throwing "nothing changed"), a
35
+ * gate failure, or a permission breach all throw UNCAUGHT out of
36
+ * `runChain`. Those are exactly the outcomes a multi-attempt loop hits
37
+ * routinely, so the driver must not die on the first one — it records a
38
+ * distinct `"errored"` outcome and keeps going, same as `fixLoop` treats
39
+ * "still not accepted" as ordinary state rather than a thrown error.
40
+ *
41
+ * THE LEDGER IS GOAL-SCOPED, NEVER SESSION-SCOPED. `Run.context_handoff_dir`
42
+ * (`core/runner.ts`) is `<data_dir>/sessions/<adw_id>/context_handoff` — with
43
+ * a fresh adw_id every iteration, that directory is a brand-new empty
44
+ * folder every single pass. The ledger this module owns lives at
45
+ * `<data_dir>/loop/<goal_id>/ledger.json` instead: outside every session
46
+ * dir, so it is the one thing that actually persists across iterations
47
+ * (breakers 2 and 3 below have nothing to read back otherwise) AND across
48
+ * process restarts — which is what makes `cron + spf loop --max 1` a real
49
+ * scheduled loop with working breakers, not just a bare retry.
50
+ *
51
+ * FOUR BREAKERS, NONE OF THEM THE STOP CHECK ITSELF. The tested goal is
52
+ * exactly the signal a loop can spin forever chasing marginal gains against,
53
+ * so every breaker below is driver-owned, external to the check:
54
+ * 1. --max (required, no default) — an outer iteration is a whole chain
55
+ * run; a silent default is a cost surprise `fixLoop`'s own `max?:
56
+ * number` (defaulted to 3) doesn't have to worry about because ITS
57
+ * cost is one suite run, not one chain.
58
+ * 2. --max-cost / --max-tokens — a cumulative ceiling across every
59
+ * iteration, read back from the ledger and checked BEFORE dispatching
60
+ * the next one. Same honest caveat `core/fanout.ts`'s own budget note
61
+ * states for its N-multiplier: checked between iterations, never
62
+ * inside one, so a single expensive iteration can still overshoot by
63
+ * up to its own spend — say so in `--help`, don't imply a hard cap.
64
+ * 3. --stuck-after N (default 3) — N consecutive iterations with an
65
+ * unchanged commit sha. A score-based stuck check was considered and
66
+ * rejected for v1: nothing here judges a numeric score (that is the
67
+ * deferred prompt-stop path), and a diff/commit-sha check is always
68
+ * available regardless of what the suite measures.
69
+ * 4. --min-interval-ms — mandatory whenever a suite's checks hit an
70
+ * external service with no documented quota (isitagentready.com
71
+ * throttled after roughly one rapid request in testing). Applied
72
+ * between iterations, not inside `quality.runSuite`'s own per-check
73
+ * timeout, which is a different ceiling (one check hanging) from this
74
+ * one (calling the external service too often).
75
+ *
76
+ * BEST-ATTEMPT RETENTION. Each iteration commits onto whatever branch the
77
+ * operator is on (`chains/steps.ts`'s `commit()` — this module never
78
+ * touches branches or worktrees, unlike `core/fanout.ts`'s isolated-attempt
79
+ * model). Without recording a commit sha per attempt, an exhausted or
80
+ * stuck loop leaves whichever tree the LAST iteration happened to produce
81
+ * checked out — possibly worse than an earlier one. The ledger's
82
+ * `best_attempt` field and the printed "best: attempt N (sha ...)" line are
83
+ * what let the operator recover deliberately instead of by accident.
84
+ */
85
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
86
+ import path from "node:path";
87
+ import { formatUsd } from "./agents.js";
88
+ import { attemptAdwId } from "./fanout.js";
89
+ import { newId } from "./utils.js";
90
+ /** `<data_dir>/loop/<goal_id>/` — goal-scoped, outside every session dir. See this module's doc comment for why. */
91
+ export function ledgerDir(dataDir, goalId) {
92
+ return path.join(dataDir, "loop", goalId);
93
+ }
94
+ function ledgerPath(dataDir, goalId) {
95
+ return path.join(ledgerDir(dataDir, goalId), "ledger.json");
96
+ }
97
+ export function loadLedger(dataDir, goalId) {
98
+ const p = ledgerPath(dataDir, goalId);
99
+ if (!existsSync(p))
100
+ return null;
101
+ return JSON.parse(readFileSync(p, "utf-8"));
102
+ }
103
+ function saveLedger(dataDir, ledger) {
104
+ const dir = ledgerDir(dataDir, ledger.goal_id);
105
+ mkdirSync(dir, { recursive: true });
106
+ writeFileSync(ledgerPath(dataDir, ledger.goal_id), JSON.stringify(ledger, null, 2));
107
+ }
108
+ /**
109
+ * `baseAdwId` is ALWAYS a fresh `newId(8)` — 8 lowercase hex, matching what
110
+ * `estimate.ts`'s `fanoutBaseOf` regex (`/^([0-9a-f]{8})-\d+$/`) expects, so
111
+ * this loop's iterations collapse as fanout-shaped siblings in `spf
112
+ * estimate`'s sample exactly the way `spf fanout`'s do. `goalId` is the
113
+ * human-facing label (`--goal-id`, or the same fresh id if none was given)
114
+ * used for the ledger's own directory name and printed to the operator —
115
+ * deliberately NOT required to look like `newId(8)`'s output, since
116
+ * `issue-41` or `a11y-loop` are exactly what a human wants to type and read
117
+ * back, and nothing downstream derives an adw_id from `goalId` itself.
118
+ */
119
+ export function newLedger(input) {
120
+ return {
121
+ goal_id: input.goalId,
122
+ base_adw_id: newId(8),
123
+ chain: input.chain,
124
+ goal: input.goal,
125
+ stop: input.stop,
126
+ max: input.max,
127
+ attempts: [],
128
+ best_attempt_index: null,
129
+ };
130
+ }
131
+ /** `--goal-id`, or a freshly minted one — human-facing only, see `newLedger`'s doc comment for why it need not be shaped like `newId(8)`'s output. */
132
+ export function resolveGoalId(explicit) {
133
+ return explicit ?? newId(8);
134
+ }
135
+ export function cumulativeSpend(attempts) {
136
+ return attempts.reduce((acc, a) => ({ cost: acc.cost + a.cost, tokens: acc.tokens + a.tokens }), { cost: 0, tokens: 0 });
137
+ }
138
+ /** Whether the goal-scoped ceiling is already exhausted going into the NEXT iteration — a stronger, ledger-wide check than any single iteration's own budget. */
139
+ export function overCumulativeBudget(budget, spend) {
140
+ if (budget.maxCost !== undefined && spend.cost >= budget.maxCost)
141
+ return true;
142
+ if (budget.maxTokens !== undefined && spend.tokens >= budget.maxTokens)
143
+ return true;
144
+ return false;
145
+ }
146
+ // ── stuck detection ────────────────────────────────────────────────────────
147
+ /**
148
+ * N consecutive attempts with the SAME commit sha (including all-null, an
149
+ * unbroken run of no-op iterations) — a diff-based check, not a score-based
150
+ * one, because nothing in v1 judges a numeric score and a commit sha is
151
+ * always available regardless of what the suite measures. `stuckAfter`
152
+ * attempts of history are enough to trip; fewer never can.
153
+ */
154
+ export function isStuck(attempts, stuckAfter) {
155
+ if (attempts.length < stuckAfter)
156
+ return false;
157
+ const tail = attempts.slice(-stuckAfter);
158
+ const first = tail[0].commit_sha;
159
+ return tail.every((a) => a.commit_sha === first);
160
+ }
161
+ /** The best attempt so far: the latest one that both committed something and passed the most recent stop check it ran — falling back to "committed something" when none has passed yet. Pure, so it is testable with no ledger I/O. */
162
+ export function pickBestAttempt(attempts) {
163
+ let best = null;
164
+ for (let i = 0; i < attempts.length; i++) {
165
+ const a = attempts[i];
166
+ if (a.commit_sha === null)
167
+ continue;
168
+ if (best === null) {
169
+ best = i;
170
+ continue;
171
+ }
172
+ const current = attempts[best];
173
+ // A later commit that passed beats an earlier one that didn't; among
174
+ // two passing (or two non-passing) commits, the later one wins — it is
175
+ // the more-refined attempt, all else equal.
176
+ const currentPassed = current.outcome === "passed";
177
+ const aPassed = a.outcome === "passed";
178
+ if (aPassed && !currentPassed)
179
+ best = i;
180
+ else if (aPassed === currentPassed)
181
+ best = i;
182
+ }
183
+ return best;
184
+ }
185
+ function buildPrompt(goal, previous) {
186
+ if (!previous || previous.passed)
187
+ return goal;
188
+ const lines = [
189
+ goal,
190
+ "",
191
+ "Your previous attempt did not meet the goal. The stop check reported:",
192
+ ...previous.failures.map((f) => `- ${f}`),
193
+ "",
194
+ "Fix these problems, then commit your changes.",
195
+ ];
196
+ return lines.join("\n");
197
+ }
198
+ /**
199
+ * Run iterations until the stop passes, `max` is exhausted, or the loop is
200
+ * stuck. Never throws — every iteration is caught by `deps.runIteration`'s
201
+ * own contract, and the ledger is written after every iteration so a killed
202
+ * process leaves a readable partial history.
203
+ */
204
+ export async function runLoop(deps) {
205
+ const existing = loadLedger(deps.dataDir, deps.goalId);
206
+ const ledger = existing ?? newLedger({ goalId: deps.goalId, chain: deps.chainName, goal: deps.goal, stop: deps.stop, max: deps.max });
207
+ if (existing) {
208
+ deps.log(`loop: resuming goal ${deps.goalId} — ${existing.attempts.length} attempt(s) already recorded`);
209
+ }
210
+ let lastVerdict = ledger.attempts.length > 0 ? { passed: false, failures: ledger.attempts[ledger.attempts.length - 1].failures, artifacts: [] } : null;
211
+ for (let i = ledger.attempts.length + 1; i <= deps.max; i++) {
212
+ if (overCumulativeBudget(deps.budget, cumulativeSpend(ledger.attempts))) {
213
+ deps.log(`loop: goal ${deps.goalId} — cumulative budget exhausted before iteration ${i}`);
214
+ break;
215
+ }
216
+ const adwId = attemptAdwId(ledger.base_adw_id, i);
217
+ const startedAt = new Date().toISOString();
218
+ deps.log(`loop: goal ${deps.goalId} — iteration ${i}/${deps.max} (${adwId})`);
219
+ const result = await deps.runIteration({
220
+ index: i,
221
+ adw_id: adwId,
222
+ prompt: buildPrompt(deps.goal, lastVerdict),
223
+ });
224
+ let outcome;
225
+ let failures = [];
226
+ if (result.error) {
227
+ outcome = "errored";
228
+ deps.log(`loop: iteration ${i} (${adwId}) errored: ${result.error}`);
229
+ }
230
+ else if (result.exit_code !== 0) {
231
+ outcome = "not-accepted";
232
+ failures = [`chain did not accept its own work (exit ${result.exit_code})`];
233
+ }
234
+ else {
235
+ const verdict = result.stop_verdict ?? { passed: false, failures: ["stop check produced no verdict"], artifacts: [] };
236
+ lastVerdict = verdict;
237
+ outcome = verdict.passed ? "passed" : "not-accepted";
238
+ failures = verdict.failures;
239
+ }
240
+ const attempt = {
241
+ index: i,
242
+ adw_id: adwId,
243
+ outcome,
244
+ exit_code: result.exit_code,
245
+ error: result.error,
246
+ commit_sha: result.commit_sha,
247
+ tokens: result.tokens,
248
+ cost: result.cost,
249
+ failures,
250
+ started_at: startedAt,
251
+ ended_at: new Date().toISOString(),
252
+ };
253
+ ledger.attempts.push(attempt);
254
+ ledger.best_attempt_index = pickBestAttempt(ledger.attempts);
255
+ saveLedger(deps.dataDir, ledger);
256
+ if (outcome === "passed") {
257
+ ledger.reason = "passed";
258
+ saveLedger(deps.dataDir, ledger);
259
+ return { ledger, exitCode: 0 };
260
+ }
261
+ if (isStuck(ledger.attempts, deps.stuckAfter)) {
262
+ deps.log(`loop: goal ${deps.goalId} — no progress in ${deps.stuckAfter} consecutive attempt(s), stopping`);
263
+ ledger.reason = "stuck";
264
+ saveLedger(deps.dataDir, ledger);
265
+ return { ledger, exitCode: 1 };
266
+ }
267
+ if (i < deps.max && deps.minIntervalMs > 0)
268
+ await deps.sleep(deps.minIntervalMs);
269
+ }
270
+ ledger.reason = ledger.reason ?? "exhausted";
271
+ saveLedger(deps.dataDir, ledger);
272
+ return { ledger, exitCode: 1 };
273
+ }
274
+ /** A human-readable summary line for the CLI's final print — shared so a test can pin its exact wording. */
275
+ export function summarize(result) {
276
+ const { ledger } = result;
277
+ const last = ledger.attempts[ledger.attempts.length - 1];
278
+ const spend = cumulativeSpend(ledger.attempts);
279
+ const lines = [
280
+ `loop ${ledger.goal_id}: ${ledger.reason} after ${ledger.attempts.length}/${ledger.max} attempt(s) — ${formatUsd(spend.cost)}, ${spend.tokens.toLocaleString("en-US")} tokens`,
281
+ ];
282
+ if (ledger.best_attempt_index !== null) {
283
+ const best = ledger.attempts[ledger.best_attempt_index];
284
+ lines.push(`best: attempt ${best.index} (${best.adw_id}${best.commit_sha ? `, sha ${best.commit_sha}` : ""})`);
285
+ }
286
+ if (last && last.outcome !== "passed" && last.failures.length > 0) {
287
+ lines.push("last failure(s):", ...last.failures.map((f) => ` ${f}`));
288
+ }
289
+ return lines.join("\n");
290
+ }
@@ -14,7 +14,7 @@
14
14
  import type { EventRecord, Phase, QualityCheckSpec, QualityConfig, QualityResult, VerifyOutputT } from "./data_types.ts";
15
15
  export declare class QualityNotConfigured extends Error {
16
16
  }
17
- interface RunLike {
17
+ export interface RunLike {
18
18
  cfg: {
19
19
  quality: QualityConfig;
20
20
  };
@@ -71,4 +71,3 @@ export declare function record(ph: {
71
71
  * script is the only thing that knows the difference.
72
72
  */
73
73
  export declare function asEnvelope(result: QualityResult, what: string): VerifyOutputT;
74
- export {};