@gr8ful/spf 0.5.0 → 0.6.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 +61 -17
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +32 -6
- package/assets/prompts/refiner/user.md +37 -5
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +153 -4
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +6 -0
- package/dist/chains/index.d.ts +11 -0
- package/dist/chains/index.js +38 -3
- package/dist/chains/repo_chains.js +1 -0
- package/dist/chains/simple_sdlc.js +1 -1
- package/dist/chains/steps.d.ts +27 -9
- package/dist/chains/steps.js +82 -13
- package/dist/cli/commands/doctor.js +108 -1
- package/dist/cli/commands/estimate.d.ts +82 -0
- package/dist/cli/commands/estimate.js +317 -0
- package/dist/cli/commands/fanout.d.ts +40 -0
- package/dist/cli/commands/fanout.js +401 -0
- package/dist/cli/commands/init.js +19 -0
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.js +51 -26
- package/dist/cli/index.js +12 -0
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +232 -0
- package/dist/core/data_types.js +135 -0
- package/dist/core/fanout.d.ts +229 -0
- package/dist/core/fanout.js +313 -0
- package/dist/core/gates.d.ts +8 -0
- package/dist/core/gates.js +24 -1
- package/dist/core/git_helper.d.ts +98 -0
- package/dist/core/git_helper.js +127 -0
- package/dist/core/issues/github_provider.d.ts +7 -1
- package/dist/core/issues/github_provider.js +33 -2
- package/dist/core/issues/jira_provider.d.ts +5 -1
- package/dist/core/issues/jira_provider.js +26 -3
- package/dist/core/issues/provider.d.ts +53 -2
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +16 -0
- package/dist/core/notify/notifier.js +36 -0
- package/dist/core/otel.d.ts +64 -9
- package/dist/core/otel.js +78 -14
- package/dist/core/paths.d.ts +3 -0
- package/dist/core/paths.js +48 -1
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +7 -0
- package/dist/core/session.d.ts +25 -0
- package/dist/core/session.js +97 -28
- package/dist/core/tiering.d.ts +145 -0
- package/dist/core/tiering.js +235 -0
- package/dist/core/tracer.d.ts +10 -0
- package/dist/core/tracer.js +12 -0
- package/dist/core/watch.d.ts +89 -9
- package/dist/core/watch.js +193 -19
- package/dist/ui/server/db.d.ts +39 -0
- package/dist/ui/server/db.js +61 -0
- package/package.json +2 -1
- package/dist/test/agent_cc.test.d.ts +0 -1
- package/dist/test/agent_cc.test.js +0 -95
- package/dist/test/agent_flue.test.d.ts +0 -1
- package/dist/test/agent_flue.test.js +0 -83
- package/dist/test/chains.test.d.ts +0 -12
- package/dist/test/chains.test.js +0 -92
- package/dist/test/data_types.test.d.ts +0 -10
- package/dist/test/data_types.test.js +0 -220
- package/dist/test/env_file.test.d.ts +0 -1
- package/dist/test/env_file.test.js +0 -74
- package/dist/test/fake_asker.d.ts +0 -23
- package/dist/test/fake_asker.js +0 -30
- package/dist/test/git_helper.test.d.ts +0 -1
- package/dist/test/git_helper.test.js +0 -59
- package/dist/test/hermetic_git.d.ts +0 -1
- package/dist/test/hermetic_git.js +0 -22
- package/dist/test/init_command.test.d.ts +0 -14
- package/dist/test/init_command.test.js +0 -136
- package/dist/test/interview.test.d.ts +0 -15
- package/dist/test/interview.test.js +0 -425
- package/dist/test/notify.test.d.ts +0 -1
- package/dist/test/notify.test.js +0 -174
- package/dist/test/ollama_provider.test.d.ts +0 -1
- package/dist/test/ollama_provider.test.js +0 -103
- package/dist/test/otel.test.d.ts +0 -26
- package/dist/test/otel.test.js +0 -512
- package/dist/test/refine.test.d.ts +0 -1
- package/dist/test/refine.test.js +0 -189
- package/dist/test/repo_chains.test.d.ts +0 -21
- package/dist/test/repo_chains.test.js +0 -416
- package/dist/test/signoff.test.d.ts +0 -1
- package/dist/test/signoff.test.js +0 -329
- package/dist/test/ui_server.test.d.ts +0 -7
- package/dist/test/ui_server.test.js +0 -120
- package/dist/test/watch.test.d.ts +0 -1
- package/dist/test/watch.test.js +0 -687
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spf estimate <chain> "<prompt>"` — a read-only projection of what a chain
|
|
3
|
+
* WOULD cost and WHICH models it would dispatch on, built only from real
|
|
4
|
+
* trace rows already on disk. Never mints an `adw_id`, never opens a
|
|
5
|
+
* session, never writes a row (design doc §5).
|
|
6
|
+
*
|
|
7
|
+
* Two questions, one command:
|
|
8
|
+
* - "what will this run on?" — the SAME `resolveTiering` (`core/tiering.ts`)
|
|
9
|
+
* a real run calls from `startRun`, so the printed routing cannot drift
|
|
10
|
+
* from what dispatch does.
|
|
11
|
+
* - "what will this cost?" — projected from real `sessions`/`phases`/
|
|
12
|
+
* `agent_end` rows for this exact chain name (never a joined session's),
|
|
13
|
+
* reported in TOKENS (never a fabricated per-token price table — see
|
|
14
|
+
* §5.3 for why cost is `$0.00` for exactly the rosters this feature is
|
|
15
|
+
* for) with an honest refusal — exit 3, no number — when there is
|
|
16
|
+
* nothing to project from.
|
|
17
|
+
*/
|
|
18
|
+
import * as agents from "../../core/agents.js";
|
|
19
|
+
import { parseCli, resolvePrompt } from "../../core/utils.js";
|
|
20
|
+
import { findChain, resolveRequiredAgents, resolveRequiredSuites } from "../../chains/index.js";
|
|
21
|
+
import { probeServedOllamaTags, resolveTiering } from "../../core/tiering.js";
|
|
22
|
+
import { openTraceIfExists } from "./trace.js";
|
|
23
|
+
const KNOWN_OPTIONS = ["config", "cwd", "agent", "n"];
|
|
24
|
+
const KNOWN_FLAGS = ["json", "no-probe"];
|
|
25
|
+
const SAMPLE_CAP = 20;
|
|
26
|
+
export function usage() {
|
|
27
|
+
return 'usage: spf estimate <chain> "<prompt or path/to/prompt.md>" [--n N] [--json] [--config <path>] [--cwd <dir>] [--no-probe] [--agent <name>]';
|
|
28
|
+
}
|
|
29
|
+
// ── fanout-sibling recognition (design doc §5.2) ────────────────────────────
|
|
30
|
+
/**
|
|
31
|
+
* The base an attempt was derived from, per `fanout.ts`'s own naming scheme
|
|
32
|
+
* (`attemptAdwId`: `${baseAdwId}-${index}`, `fanout.ts:129-131`) — a
|
|
33
|
+
* heuristic, not a foreign key (there is no column recording it), which is
|
|
34
|
+
* why the design doc calls the base "recoverable from the suffix scheme"
|
|
35
|
+
* rather than "stored".
|
|
36
|
+
*
|
|
37
|
+
* The base itself is always `newId(8)` (`fanout.ts:277`) — 8 lowercase hex
|
|
38
|
+
* characters, never containing `-` — so the match requires exactly that
|
|
39
|
+
* shape rather than "any dash then digits". A bare `/^(.+)-\d+$/` is far too
|
|
40
|
+
* loose: `spf watch` mints `adw_id`s like `issue-41`/`issue-42`/`issue-43`
|
|
41
|
+
* (`watch.ts:357,469`, `Issue.id` a decimal string), and every one of those
|
|
42
|
+
* ends in `-<digits>` despite never having gone through `spf fanout` — the
|
|
43
|
+
* loose regex collapsed 41/42/43 into one shared "issue" base. Requiring the
|
|
44
|
+
* base to look like `newId(8)`'s actual output means `issue-41` doesn't
|
|
45
|
+
* match at all (`issue` isn't 8 hex chars) and is correctly treated as its
|
|
46
|
+
* own independent run.
|
|
47
|
+
*/
|
|
48
|
+
function fanoutBaseOf(adwId) {
|
|
49
|
+
const match = /^([0-9a-f]{8})-\d+$/.exec(adwId);
|
|
50
|
+
return match ? match[1] : adwId;
|
|
51
|
+
}
|
|
52
|
+
/** At most one attempt per fanout base, most-recent kept (input is already recency-ordered). Does not cap; the caller decides how many of the result to keep. */
|
|
53
|
+
function collapseFanoutSiblings(sessions) {
|
|
54
|
+
const seenBases = new Set();
|
|
55
|
+
const deduped = [];
|
|
56
|
+
let collapsed = 0;
|
|
57
|
+
for (const session of sessions) {
|
|
58
|
+
const base = fanoutBaseOf(session.adw_id);
|
|
59
|
+
if (seenBases.has(base)) {
|
|
60
|
+
collapsed++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
seenBases.add(base);
|
|
64
|
+
deduped.push(session);
|
|
65
|
+
}
|
|
66
|
+
return { deduped, collapsed };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Two tiers, stopping at the first that qualifies, and an honest refusal.
|
|
70
|
+
* `sessions` is already most-recent-first (db.ts's own ORDER BY).
|
|
71
|
+
*/
|
|
72
|
+
export function selectSample(sessions, joinedExcluded) {
|
|
73
|
+
const successes = sessions.filter((s) => s.status === "success");
|
|
74
|
+
const { deduped: successDeduped, collapsed: successCollapsed } = collapseFanoutSiblings(successes);
|
|
75
|
+
if (successDeduped.length >= 3) {
|
|
76
|
+
return { sessions: successDeduped.slice(0, SAMPLE_CAP), status: "success", fanoutCollapsed: successCollapsed, joinedExcluded };
|
|
77
|
+
}
|
|
78
|
+
const { deduped: anyDeduped, collapsed: anyCollapsed } = collapseFanoutSiblings(sessions);
|
|
79
|
+
if (anyDeduped.length >= 1) {
|
|
80
|
+
return { sessions: anyDeduped.slice(0, SAMPLE_CAP), status: "any", fanoutCollapsed: anyCollapsed, joinedExcluded };
|
|
81
|
+
}
|
|
82
|
+
return { sessions: [], status: "none", fanoutCollapsed: 0, joinedExcluded };
|
|
83
|
+
}
|
|
84
|
+
// ── projection (design doc §5.3) ────────────────────────────────────────────
|
|
85
|
+
/** p50, no interpolation — a 3-sample percentile is false precision (design doc §5.3). Even-length arrays average the two middle values, the ordinary definition. */
|
|
86
|
+
export function median(values) {
|
|
87
|
+
if (values.length === 0)
|
|
88
|
+
return 0;
|
|
89
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
90
|
+
const mid = Math.floor(sorted.length / 2);
|
|
91
|
+
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
92
|
+
}
|
|
93
|
+
/** Strip a loop factory's trailing `_<digits>` (`steps.ts:572-579,597-600,670,683`) — `fix_1`/`fix_2` -> `fix`, `test_1` -> `test`. Reads the recorded name; never predicts it. */
|
|
94
|
+
export function normalizePhaseName(name) {
|
|
95
|
+
return name.replace(/_\d+$/, "");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Per run: sum every iteration of a normalized phase into one observation.
|
|
99
|
+
* Across runs: median (p50) and min/max of those per-run sums. A phase
|
|
100
|
+
* absent from a given run (a fix loop that never triggered) counts as a
|
|
101
|
+
* `0` observation for that run — not a skip — so its median reflects how
|
|
102
|
+
* often the phase actually ran, not just its size when it did.
|
|
103
|
+
*/
|
|
104
|
+
export function aggregatePhases(sessions) {
|
|
105
|
+
const perRunSums = sessions.map(() => new Map());
|
|
106
|
+
const firstSeq = new Map();
|
|
107
|
+
sessions.forEach((session, i) => {
|
|
108
|
+
for (const phase of session.phases) {
|
|
109
|
+
const name = normalizePhaseName(phase.name);
|
|
110
|
+
perRunSums[i].set(name, (perRunSums[i].get(name) ?? 0) + phase.tokens);
|
|
111
|
+
if (!firstSeq.has(name) || phase.seq < firstSeq.get(name))
|
|
112
|
+
firstSeq.set(name, phase.seq);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
const names = [...firstSeq.keys()].sort((a, b) => firstSeq.get(a) - firstSeq.get(b));
|
|
116
|
+
return names.map((name) => {
|
|
117
|
+
const perRun = perRunSums.map((m) => m.get(name) ?? 0);
|
|
118
|
+
return { name, p50: median(perRun), min: Math.min(...perRun), max: Math.max(...perRun) };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** The HEADLINE: median of sampled runs' `sessions.total_tokens` — NOT the sum of the per-phase medians (those are different numbers; median of sums != sum of medians). */
|
|
122
|
+
export function projectRunTotals(sessions) {
|
|
123
|
+
if (sessions.length === 0)
|
|
124
|
+
return null;
|
|
125
|
+
const totals = sessions.map((s) => s.total_tokens);
|
|
126
|
+
return { p50: median(totals), min: Math.min(...totals), max: Math.max(...totals) };
|
|
127
|
+
}
|
|
128
|
+
/** Where a p50 run would be cut off by `max_run_tokens`, walking the phase breakdown cumulatively in the order phases first appeared. `undefined` means unset; `null` means set but never reached on the p50 path. */
|
|
129
|
+
export function findCutoffPhase(phases, maxRunTokens) {
|
|
130
|
+
if (maxRunTokens === undefined)
|
|
131
|
+
return undefined;
|
|
132
|
+
let cumulative = 0;
|
|
133
|
+
for (const phase of phases) {
|
|
134
|
+
cumulative += phase.p50;
|
|
135
|
+
if (cumulative >= maxRunTokens)
|
|
136
|
+
return phase.name;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Compares each sampled agent's HISTORICAL dispatch model against the
|
|
142
|
+
* resolution's EFFECTIVE model — `res.routing[agent]?.effective ?? agent's
|
|
143
|
+
* own configured model` — never against `roles`/`tiers` in isolation. That
|
|
144
|
+
* is what makes the warning fire correctly whether or not tiering is
|
|
145
|
+
* enabled: with tiering off, "effective" is just the agent's own `model:`,
|
|
146
|
+
* so an operator who edited the roster directly between runs (no tiering
|
|
147
|
+
* involved) still gets an accurate warning.
|
|
148
|
+
*/
|
|
149
|
+
export function detectModelDrift(historicalModels, currentModel) {
|
|
150
|
+
const warnings = [];
|
|
151
|
+
for (const [agent, historical] of historicalModels) {
|
|
152
|
+
const current = currentModel(agent);
|
|
153
|
+
if (current !== undefined && current !== historical)
|
|
154
|
+
warnings.push({ agent, historical, current });
|
|
155
|
+
}
|
|
156
|
+
return warnings;
|
|
157
|
+
}
|
|
158
|
+
export async function estimateCommand(argv) {
|
|
159
|
+
const { positionals, options, flags } = parseCli(argv, KNOWN_OPTIONS, KNOWN_FLAGS);
|
|
160
|
+
if (positionals.length < 2) {
|
|
161
|
+
console.error(usage());
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
const [chainName, promptArg] = positionals;
|
|
165
|
+
const chain = findChain(chainName);
|
|
166
|
+
if (!chain) {
|
|
167
|
+
console.error(`unknown chain: ${chainName} — run \`spf list\` to see every chain`);
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
const n = options["n"] !== undefined ? Number.parseInt(options["n"], 10) : 1;
|
|
171
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
172
|
+
console.error(`--n must be a positive integer, got ${JSON.stringify(options["n"])}`);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
const prompt = resolvePrompt(promptArg);
|
|
176
|
+
const trace = openTraceIfExists(options);
|
|
177
|
+
const cfg = trace.cfg;
|
|
178
|
+
// Same shape as run.ts:54-56 — conditional insertion, never `{agent: flags["agent"] ?? ""}`.
|
|
179
|
+
// `flags` never carries a value-taking option, and an empty-string sentinel
|
|
180
|
+
// would defeat steps.ts:492's own `options["agent"] ?? "builder"` default.
|
|
181
|
+
const chainOptions = {};
|
|
182
|
+
if (options["agent"] !== undefined)
|
|
183
|
+
chainOptions["agent"] = options["agent"];
|
|
184
|
+
const required = resolveRequiredAgents(chain, chainOptions);
|
|
185
|
+
const requiredSuites = resolveRequiredSuites(chain, chainOptions);
|
|
186
|
+
const servedOllamaTags = flags["no-probe"] ? null : await probeServedOllamaTags(cfg);
|
|
187
|
+
const res = resolveTiering({ cfg, chainName: chain.name, prompt, servedOllamaTags, required });
|
|
188
|
+
// Same detector `validate()` itself runs at every real `startRun` — never
|
|
189
|
+
// thrown here, only reported: `estimate` gates nothing (exit 3 is reserved
|
|
190
|
+
// for "no history"), it just tells the operator a real run would fail.
|
|
191
|
+
let validateError = null;
|
|
192
|
+
try {
|
|
193
|
+
agents.validate(cfg, required, requiredSuites, trace.anchor.cwd);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
validateError = error instanceof Error ? error.message : String(error);
|
|
197
|
+
}
|
|
198
|
+
const history = trace.db ? trace.db.chainPhaseHistory(chain.name) : { sessions: [], joinedExcluded: 0 };
|
|
199
|
+
const sample = selectSample(history.sessions, history.joinedExcluded);
|
|
200
|
+
const coldStart = sample.sessions.length === 0;
|
|
201
|
+
const phases = aggregatePhases(sample.sessions);
|
|
202
|
+
const projectedRaw = projectRunTotals(sample.sessions);
|
|
203
|
+
const projected = projectedRaw && n > 1 ? { p50: projectedRaw.p50 * n, min: projectedRaw.min * n, max: projectedRaw.max * n } : projectedRaw;
|
|
204
|
+
const observedCost = sample.sessions.length > 0 ? median(sample.sessions.map((s) => s.total_cost)) * n : 0;
|
|
205
|
+
const historicalModels = new Map();
|
|
206
|
+
if (trace.db) {
|
|
207
|
+
for (const session of sample.sessions) {
|
|
208
|
+
for (const row of trace.db.agentSessions(session.adw_id)) {
|
|
209
|
+
if (row.model !== null && !historicalModels.has(row.agent))
|
|
210
|
+
historicalModels.set(row.agent, row.model);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const currentModel = (agent) => res.routing[agent]?.effective ?? cfg.agents.find((a) => a.name === agent)?.model;
|
|
215
|
+
const drift = detectModelDrift(historicalModels, currentModel);
|
|
216
|
+
const maxTokens = cfg.defaults.max_run_tokens;
|
|
217
|
+
const maxCost = cfg.defaults.max_run_cost;
|
|
218
|
+
const cutoffPhase = findCutoffPhase(phases, maxTokens);
|
|
219
|
+
const report = {
|
|
220
|
+
chain: chain.name,
|
|
221
|
+
risk: res.risk,
|
|
222
|
+
signals: res.signals,
|
|
223
|
+
routing: res.routing,
|
|
224
|
+
notes: res.notes,
|
|
225
|
+
sample: { n: sample.sessions.length, status: sample.status, joined_excluded: sample.joinedExcluded, fanout_collapsed: sample.fanoutCollapsed },
|
|
226
|
+
phases,
|
|
227
|
+
projected,
|
|
228
|
+
observed_cost: observedCost,
|
|
229
|
+
drift,
|
|
230
|
+
validate_error: validateError,
|
|
231
|
+
ceilings: { max_run_tokens: maxTokens, max_run_cost: maxCost, cutoff_phase: cutoffPhase },
|
|
232
|
+
fanout_n: n,
|
|
233
|
+
};
|
|
234
|
+
if (flags["json"]) {
|
|
235
|
+
console.log(JSON.stringify(report, null, 2));
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
printText(report, coldStart);
|
|
239
|
+
}
|
|
240
|
+
return coldStart ? 3 : 0;
|
|
241
|
+
}
|
|
242
|
+
function fmt(n) {
|
|
243
|
+
return Math.round(n).toLocaleString("en-US");
|
|
244
|
+
}
|
|
245
|
+
function printText(report, coldStart) {
|
|
246
|
+
const lines = [];
|
|
247
|
+
lines.push(`chain ${report.chain}`);
|
|
248
|
+
lines.push(`tier ${report.risk} (chain ${report.signals.chain} = ${report.signals.chain_weight}, ` +
|
|
249
|
+
`prompt ${report.signals.prompt_words}w = ${report.signals.prompt_weight}, sum ${report.signals.sum})`);
|
|
250
|
+
const routingEntries = Object.entries(report.routing);
|
|
251
|
+
if (routingEntries.length === 0) {
|
|
252
|
+
lines.push("routing (tiering.enabled is false, or nothing in this chain's roster is routed by tiering.roles)");
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
for (const [agent, route] of routingEntries) {
|
|
256
|
+
const same = route.configured === route.effective;
|
|
257
|
+
lines.push(`routing ${agent} -> ${route.tier} tier (${route.effective}) ${same ? "[= configured]" : `[was ${route.configured}]`}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const note of report.notes)
|
|
261
|
+
lines.push(`note ${note}`);
|
|
262
|
+
if (report.validate_error) {
|
|
263
|
+
lines.push(`note a real run of this config would fail agents.validate() at start: ${report.validate_error}`);
|
|
264
|
+
}
|
|
265
|
+
if (coldStart) {
|
|
266
|
+
lines.push("");
|
|
267
|
+
lines.push(`sample no trace history for chain "${report.chain}" yet — run \`spf ${report.chain} "..."\` once; ` +
|
|
268
|
+
"this projects from real trace rows only");
|
|
269
|
+
if (report.sample.joined_excluded > 0) {
|
|
270
|
+
lines.push(` (${report.sample.joined_excluded} joined session(s) named this chain but cannot be attributed to it alone — excluded)`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const label = report.sample.status === "success" ? "successful" : "run(s), including failed/partial";
|
|
275
|
+
lines.push("");
|
|
276
|
+
lines.push(`sample ${report.sample.n} ${label} of "${report.chain}"`);
|
|
277
|
+
const extras = [];
|
|
278
|
+
if (report.sample.joined_excluded > 0)
|
|
279
|
+
extras.push(`${report.sample.joined_excluded} joined session(s) excluded`);
|
|
280
|
+
if (report.sample.fanout_collapsed > 0)
|
|
281
|
+
extras.push(`${report.sample.fanout_collapsed} fanout sibling(s) collapsed`);
|
|
282
|
+
if (report.sample.status === "any")
|
|
283
|
+
extras.push("a truncated run under-reports later phases");
|
|
284
|
+
if (extras.length > 0)
|
|
285
|
+
lines.push(` (${extras.join("; ")})`);
|
|
286
|
+
if (report.phases.length > 0) {
|
|
287
|
+
lines.push("");
|
|
288
|
+
lines.push("phase p50 tokens range");
|
|
289
|
+
for (const phase of report.phases) {
|
|
290
|
+
lines.push(`${phase.name.padEnd(20)} ${fmt(phase.p50).padStart(12)} ${fmt(phase.min)} - ${fmt(phase.max)}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (report.projected) {
|
|
294
|
+
lines.push("");
|
|
295
|
+
lines.push(`projected ${fmt(report.projected.p50).padStart(12)} ${fmt(report.projected.min)} - ${fmt(report.projected.max)} ` +
|
|
296
|
+
"(median of RUN TOTALS, not the sum of the per-phase p50 column)");
|
|
297
|
+
if (report.fanout_n > 1) {
|
|
298
|
+
lines.push(` x${report.fanout_n} (spf fanout) already applied above`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
lines.push(`observed cost $${report.observed_cost.toFixed(3)} (cost is reported as observed in the sample, never modelled — $0.00 for a local roster)`);
|
|
302
|
+
for (const w of report.drift) {
|
|
303
|
+
lines.push(`drift ${w.agent}: sample ran ${w.historical}, current tiering picks ${w.current} — ` +
|
|
304
|
+
"token counts are not comparable across tokenizers; treat this projection as an order of magnitude, never rescaled");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
lines.push("");
|
|
308
|
+
if (report.ceilings.max_run_tokens !== undefined) {
|
|
309
|
+
lines.push(`max_run_tokens ${fmt(report.ceilings.max_run_tokens)}` +
|
|
310
|
+
(report.ceilings.cutoff_phase ? ` -> would stop at "${report.ceilings.cutoff_phase}" on a p50 run` : ""));
|
|
311
|
+
lines.push(' (checked BEFORE each call: a run can overshoot by one call, and a one-dispatch chain can never trip it)');
|
|
312
|
+
}
|
|
313
|
+
if (report.ceilings.max_run_cost !== undefined) {
|
|
314
|
+
lines.push(`max_run_cost $${report.ceilings.max_run_cost.toFixed(3)}`);
|
|
315
|
+
}
|
|
316
|
+
console.log(lines.join("\n"));
|
|
317
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire `<worktreePath>/.spf/data` to the main repo's `dataDir` — same
|
|
3
|
+
* purpose as `cli/commands/watch.ts`'s own `linkDataDir`: without it an
|
|
4
|
+
* attempt's chain resolves its session/trace data relative to `cwd` (its
|
|
5
|
+
* worktree) and writes into a fresh `.spf/data` that dies with the
|
|
6
|
+
* worktree — invisible in `spf ui`, and gone before `readMetrics` could even
|
|
7
|
+
* read the gate rows that DECIDE the winner. Symlinking it to the main
|
|
8
|
+
* repo's data dir is what makes the shared WAL SQLite the one place all N
|
|
9
|
+
* attempts report to (`tracer.ts`'s WAL + `busy_timeout=5000` is exactly
|
|
10
|
+
* what concurrent writers through this symlink already rely on under `spf
|
|
11
|
+
* watch`). Idempotent. A plain function of its two inputs (not a closure
|
|
12
|
+
* over `dataPaths`) so it can be exercised directly in a test, against a
|
|
13
|
+
* real filesystem and a real git worktree, without a real chain or agent.
|
|
14
|
+
*
|
|
15
|
+
* `mkdirSync(dataDir, ...)` BEFORE the symlink is not optional: a fresh
|
|
16
|
+
* clone with `.spf/` committed (`.spf/data` gitignored), or a repo straight
|
|
17
|
+
* out of `spf init` (which seeds `.spf/` and `.spf/chains/` but never
|
|
18
|
+
* `.spf/data` — nothing has traced a run there yet) has no `.spf/data` to
|
|
19
|
+
* point at. Without this, the symlink is dangling and every attempt's `Run`
|
|
20
|
+
* constructor fails its own `ensureDir` on first use — `spf fanout` would be
|
|
21
|
+
* broken on a repo's first ever run.
|
|
22
|
+
*/
|
|
23
|
+
export declare function linkFanoutDataDir(worktreePath: string, dataDir: string): void;
|
|
24
|
+
/**
|
|
25
|
+
* Keep the `.spf/data` symlink invisible to `git status`/`git add -A` in the
|
|
26
|
+
* attempt's own worktree. `.spf/data/` — a trailing-slash DIRECTORY pattern,
|
|
27
|
+
* the shape this repo's own gitignore uses — does NOT match a SYMLINK of
|
|
28
|
+
* the same name, so without this, `git status` shows `?? .spf/data` and a
|
|
29
|
+
* committing chain's `git_helper.commitAll` (`git add -A`) stages it.
|
|
30
|
+
* Following the printed `git merge <winner-branch>` instruction then
|
|
31
|
+
* fast-forwards the symlink straight into the MAIN repo, replacing its real
|
|
32
|
+
* `.spf/data` directory with a symlink pointing back at itself — destroying
|
|
33
|
+
* the trace db (`ls .spf/data` becomes `ELOOP`). `info/exclude` is
|
|
34
|
+
* per-worktree, local-only, and idempotent to append to — the opposite of
|
|
35
|
+
* adding a pattern to a tracked `.gitignore`, which would ship this
|
|
36
|
+
* workaround into every clone for a symlink only `spf fanout` itself ever
|
|
37
|
+
* creates.
|
|
38
|
+
*/
|
|
39
|
+
export declare function excludeSpfDataFromGit(worktreePath: string): void;
|
|
40
|
+
export declare function fanoutCommand(argv: string[]): Promise<number>;
|