@gr8ful/spf 0.9.2 → 0.10.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/README.md +56 -0
- package/assets/defaults/spf.config.yaml +75 -0
- package/assets/skill/references/config.md +98 -4
- package/dist/chains/index.d.ts +2 -0
- package/dist/chains/index.js +4 -0
- package/dist/cli/commands/doctor.js +339 -2
- package/dist/cli/commands/fanout.d.ts +7 -14
- package/dist/cli/commands/fanout.js +45 -39
- package/dist/cli/commands/loop.d.ts +2 -0
- package/dist/cli/commands/loop.js +198 -0
- package/dist/cli/commands/run.js +14 -4
- package/dist/cli/commands/watch.d.ts +29 -1
- package/dist/cli/commands/watch.js +219 -64
- package/dist/cli/index.js +14 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +25 -2
- package/dist/core/agent_flue.js +14 -5
- package/dist/core/agents.d.ts +61 -1
- package/dist/core/agents.js +363 -6
- package/dist/core/data_types.d.ts +316 -0
- package/dist/core/data_types.js +143 -0
- package/dist/core/loop.d.ts +230 -0
- package/dist/core/loop.js +290 -0
- package/dist/core/quality.d.ts +1 -2
- package/dist/core/sandbox.d.ts +236 -0
- package/dist/core/sandbox.js +655 -0
- package/dist/core/sandbox_cloudflare.d.ts +137 -0
- package/dist/core/sandbox_cloudflare.js +505 -0
- package/dist/core/sandbox_opensandbox.d.ts +59 -0
- package/dist/core/sandbox_opensandbox.js +484 -0
- package/dist/core/sandbox_sdk_types.d.ts +171 -0
- package/dist/core/sandbox_sdk_types.js +20 -0
- package/dist/core/watch.d.ts +56 -0
- package/dist/core/watch.js +354 -51
- package/dist/core/worktree_data.d.ts +1 -0
- package/dist/core/worktree_data.js +37 -0
- 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
|
+
}
|
package/dist/core/quality.d.ts
CHANGED
|
@@ -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 {};
|