@gr8ful/spf 0.5.1 → 0.7.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 +168 -29
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +42 -6
- package/assets/prompts/refiner/user.md +46 -8
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +192 -5
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +12 -2
- 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 +8 -17
- package/dist/chains/steps.js +102 -14
- package/dist/cli/commands/doctor.js +112 -3
- 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 +23 -1
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +95 -33
- package/dist/cli/index.js +13 -1
- package/dist/cli/interview.js +9 -5
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +340 -5
- package/dist/core/data_types.js +185 -5
- 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 +48 -2
- 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 +46 -6
- package/dist/core/issues/github_provider.js +120 -5
- package/dist/core/issues/jira_provider.d.ts +83 -12
- package/dist/core/issues/jira_provider.js +110 -4
- package/dist/core/issues/provider.d.ts +124 -19
- package/dist/core/issues/provider.js +24 -7
- 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/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- 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 +174 -11
- package/dist/core/watch.js +531 -33
- 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/paths.test.d.ts +0 -1
- package/dist/test/paths.test.js +0 -68
- 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
|
@@ -16,6 +16,7 @@ import { isRepoAt, makeGit } from "../../core/git_helper.js";
|
|
|
16
16
|
import { GitHubProvider } from "../../core/issues/github_provider.js";
|
|
17
17
|
import { JiraProvider } from "../../core/issues/jira_provider.js";
|
|
18
18
|
import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
|
|
19
|
+
import { isAuthoringProvider } from "../../core/issues/provider.js";
|
|
19
20
|
import { createWatchState, tick } from "../../core/watch.js";
|
|
20
21
|
import { findChain, resolveRequiredAgents, runChain as runChainDef } from "../../chains/index.js";
|
|
21
22
|
import { ReviewOutput } from "../../core/data_types.js";
|
|
@@ -107,7 +108,7 @@ function resolveIssueProvider(cfg) {
|
|
|
107
108
|
console.error('JIRA_EMAIL and JIRA_API_TOKEN must both be set — spf watch needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens). See README.md\'s "spf watch" section.');
|
|
108
109
|
return null;
|
|
109
110
|
}
|
|
110
|
-
return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token);
|
|
111
|
+
return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types);
|
|
111
112
|
}
|
|
112
113
|
console.error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} is not supported`);
|
|
113
114
|
return null;
|
|
@@ -177,6 +178,14 @@ function releaseLock(lockPath) {
|
|
|
177
178
|
* machine needs, with sensible colors/descriptions. Doesn't touch git or
|
|
178
179
|
* run anything, so it skips watchCommand's repo/chain checks entirely —
|
|
179
180
|
* you can seed labels before ever wiring up a worktree-capable checkout.
|
|
181
|
+
*
|
|
182
|
+
* On Jira, with `watch.refine.enabled`, this also runs a READ-ONLY check of
|
|
183
|
+
* `watch.jira.issue_types` against the real project — the same check
|
|
184
|
+
* `watchCommand`'s own startup gate runs, exposed here too so a bad mapping
|
|
185
|
+
* can be caught (and fixed) before ever starting the daemon, not just at
|
|
186
|
+
* startup time. Labels themselves stay a pure report on Jira either way
|
|
187
|
+
* (Jira labels are freeform strings with no color/description registry to
|
|
188
|
+
* seed — see `jira_provider.ts`'s module comment).
|
|
180
189
|
*/
|
|
181
190
|
export async function watchInitCommand(argv) {
|
|
182
191
|
const { options } = parseCli(argv, ["cwd", "config"], []);
|
|
@@ -193,6 +202,15 @@ export async function watchInitCommand(argv) {
|
|
|
193
202
|
console.log(` ~ ${name} (updated color/description)`);
|
|
194
203
|
for (const name of result.unchanged)
|
|
195
204
|
console.log(` = ${name} (already correct)`);
|
|
205
|
+
if (cfg.watch.refine.enabled && cfg.watch.issue_provider === "jira" && provider instanceof JiraProvider) {
|
|
206
|
+
const checks = await provider.validateIssueTypes();
|
|
207
|
+
console.log(`\nvalidating watch.jira.issue_types against ${cfg.watch.jira.project_key}:`);
|
|
208
|
+
for (const c of checks) {
|
|
209
|
+
console.log(` ${c.exists ? "✓" : "✗"} ${c.kind} → ${c.jiraType}${c.exists ? "" : ` — no issue type named "${c.jiraType}" in ${cfg.watch.jira.project_key}`}`);
|
|
210
|
+
}
|
|
211
|
+
if (checks.some((c) => !c.exists))
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
196
214
|
return 0;
|
|
197
215
|
}
|
|
198
216
|
export async function watchCommand(argv) {
|
|
@@ -215,19 +233,37 @@ export async function watchCommand(argv) {
|
|
|
215
233
|
return 1;
|
|
216
234
|
}
|
|
217
235
|
if (cfg.watch.refine.enabled) {
|
|
218
|
-
if (cfg.watch.issue_provider !== "github") {
|
|
219
|
-
// Fail loudly at startup, not silently every tick:
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
236
|
+
if (cfg.watch.issue_provider !== "github" && cfg.watch.issue_provider !== "jira") {
|
|
237
|
+
// Fail loudly at startup, not silently every tick: neither provider
|
|
238
|
+
// implements IssueAuthoringProvider besides these two — a refine lane
|
|
239
|
+
// that can never publish would otherwise just claim every spec-ready
|
|
240
|
+
// spec and block it, forever.
|
|
223
241
|
console.error(`watch.refine.enabled is true but watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — ` +
|
|
224
|
-
`the refine lane needs "github"
|
|
242
|
+
`the refine lane needs "github" or "jira"`);
|
|
225
243
|
return 1;
|
|
226
244
|
}
|
|
227
245
|
if (!findChain(cfg.watch.refine.chain)) {
|
|
228
246
|
console.error(`watch.refine.chain ${JSON.stringify(cfg.watch.refine.chain)} is not a registered chain — run \`spf list\` to see every chain`);
|
|
229
247
|
return 1;
|
|
230
248
|
}
|
|
249
|
+
// Jira's issue-type mapping is user-configured (watch.jira.issue_types)
|
|
250
|
+
// and project-specific — validated here, at startup, for the same
|
|
251
|
+
// reason findChain() is: a bad mapping should stop the daemon before
|
|
252
|
+
// it starts, not fail silently every tick once the first spec-ready
|
|
253
|
+
// spec tries to publish. Silent on success, matching this function's
|
|
254
|
+
// other startup checks; loud (and the full per-kind report) on failure.
|
|
255
|
+
if (cfg.watch.issue_provider === "jira" && provider instanceof JiraProvider) {
|
|
256
|
+
const checks = await provider.validateIssueTypes();
|
|
257
|
+
const mismatches = checks.filter((c) => !c.exists);
|
|
258
|
+
if (mismatches.length > 0) {
|
|
259
|
+
console.error(`watch.jira.issue_types has ${mismatches.length} mismatch(es) against ${cfg.watch.jira.project_key}:`);
|
|
260
|
+
for (const c of checks) {
|
|
261
|
+
console.error(` ${c.exists ? "✓" : "✗"} ${c.kind} → ${c.jiraType}${c.exists ? "" : ` — no issue type named "${c.jiraType}" in ${cfg.watch.jira.project_key}`}`);
|
|
262
|
+
}
|
|
263
|
+
console.error(`Fix watch.jira.issue_types, or the project's issue types, before running spf watch unattended. Run \`spf watch init\` any time to re-check.`);
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
231
267
|
}
|
|
232
268
|
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
233
269
|
const lockPath = path.join(dataPaths.data_dir, "watch.lock");
|
|
@@ -352,17 +388,19 @@ export async function watchCommand(argv) {
|
|
|
352
388
|
unattended: true,
|
|
353
389
|
chain_source: chainDef.source,
|
|
354
390
|
};
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
// an
|
|
361
|
-
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
//
|
|
365
|
-
|
|
391
|
+
// `opts.chainOptions` is `watch.chain_options` (see WatchConfigSchema's
|
|
392
|
+
// doc comment), threaded down from `deps.chainOptions` by
|
|
393
|
+
// `core/watch.ts`'s `runIssue` — the same options-map shape an
|
|
394
|
+
// interactive `spf <chain> --suite <name>` builds in
|
|
395
|
+
// `cli/commands/run.ts`'s `dispatchChain`. This closes the KNOWN
|
|
396
|
+
// LIMITATION from PR #20: an unattended watch dispatch used to call
|
|
397
|
+
// `runChainDef` with no options at all, so nothing --suite-shaped could
|
|
398
|
+
// ever reach it.
|
|
399
|
+
const code = await runChainDef(chainDef, ctx, opts.chainOptions);
|
|
400
|
+
// Resolved with the SAME options `runChainDef` just ran the chain with,
|
|
401
|
+
// so a `chain_options` override that swaps a "reviewer" step in or out
|
|
402
|
+
// is reflected here too, not just each chain's static/YAML default.
|
|
403
|
+
const reviewRequired = resolveRequiredAgents(chainDef, opts.chainOptions).includes("reviewer");
|
|
366
404
|
if (code === 0) {
|
|
367
405
|
return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: reviewSummaryFor(opts.cwd, opts.adwId) };
|
|
368
406
|
}
|
|
@@ -371,11 +409,15 @@ export async function watchCommand(argv) {
|
|
|
371
409
|
};
|
|
372
410
|
/**
|
|
373
411
|
* Same shape as `runChain`, for the refine lane — with one extra step on
|
|
374
|
-
* success: a chain's return value is just an exit code, so
|
|
375
|
-
*
|
|
376
|
-
* through the side channel it
|
|
377
|
-
*
|
|
378
|
-
* `
|
|
412
|
+
* success: a chain's return value is just an exit code, so what
|
|
413
|
+
* `steps.publishIssues()` actually did — published a tree, OR escalated a
|
|
414
|
+
* question set instead — has to come back through the side channel it
|
|
415
|
+
* wrote (`refine_publish.json` or `refine_questions.json`, under the SAME
|
|
416
|
+
* symlinked session dir `linkDataDir` already wires up), not through
|
|
417
|
+
* `runChainDef`'s return value. The two are read independently and both
|
|
418
|
+
* default to `[]`: `gates.refinementWellFormed` guarantees a chain never
|
|
419
|
+
* writes both files in the same run, so exactly one of `created`/
|
|
420
|
+
* `questions` is ever non-empty on an accepted run.
|
|
379
421
|
*/
|
|
380
422
|
const runRefine = async (opts) => {
|
|
381
423
|
const chainDef = findChain(cfg.watch.refine.chain); // checked above
|
|
@@ -386,29 +428,47 @@ export async function watchCommand(argv) {
|
|
|
386
428
|
cwd: opts.cwd,
|
|
387
429
|
chain_name: chainDef.name,
|
|
388
430
|
issue_id: opts.issueId,
|
|
389
|
-
// Same reasoning as runChain's ctx above — an unattended dispatch
|
|
390
|
-
// with no --suite-shaped options reaching it either (same KNOWN
|
|
391
|
-
// LIMITATION).
|
|
431
|
+
// Same reasoning as runChain's ctx above — an unattended dispatch.
|
|
392
432
|
unattended: true,
|
|
393
433
|
chain_source: chainDef.source,
|
|
394
434
|
};
|
|
395
|
-
|
|
435
|
+
// Same `watch.chain_options` threading as runChain above — see its
|
|
436
|
+
// comment for the KNOWN LIMITATION this fixes.
|
|
437
|
+
const code = await runChainDef(chainDef, ctx, opts.chainOptions);
|
|
396
438
|
if (code !== 0) {
|
|
397
439
|
const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
398
|
-
return { accepted: false, adwId: opts.adwId, detail, created: [] };
|
|
440
|
+
return { accepted: false, adwId: opts.adwId, detail, created: [], questions: [] };
|
|
399
441
|
}
|
|
442
|
+
const wtAnchor = paths.resolveAnchor(opts.cwd);
|
|
443
|
+
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
444
|
+
const handoffDir = path.join(wtDataPaths.data_dir, "sessions", opts.adwId, "context_handoff");
|
|
400
445
|
let created = [];
|
|
401
446
|
try {
|
|
402
|
-
|
|
403
|
-
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
404
|
-
const summaryPath = path.join(wtDataPaths.data_dir, "sessions", opts.adwId, "context_handoff", "refine_publish.json");
|
|
405
|
-
created = JSON.parse(readFileSync(summaryPath, "utf-8"));
|
|
447
|
+
created = JSON.parse(readFileSync(path.join(handoffDir, "refine_publish.json"), "utf-8"));
|
|
406
448
|
}
|
|
407
449
|
catch {
|
|
408
450
|
// best-effort — an empty list still lets runSpec finish cleanly, just with no per-issue summary
|
|
409
451
|
}
|
|
410
|
-
|
|
452
|
+
let questions = [];
|
|
453
|
+
try {
|
|
454
|
+
questions = JSON.parse(readFileSync(path.join(handoffDir, "refine_questions.json"), "utf-8"));
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// best-effort, same as above — no questions file means this run wasn't an escalation
|
|
458
|
+
}
|
|
459
|
+
return { accepted: true, adwId: opts.adwId, detail: "", created, questions };
|
|
411
460
|
};
|
|
461
|
+
// `IssueAuthoringProvider`'s read-back half — `isAuthoringProvider()` is a
|
|
462
|
+
// structural check (see `provider.ts`), so both GitHub and Jira are
|
|
463
|
+
// recognized here without an `instanceof` chain that would need editing
|
|
464
|
+
// for every future authoring-capable provider. `null` on any tracker that
|
|
465
|
+
// ISN'T authoring-capable makes `rollUp` a logged no-op there rather than
|
|
466
|
+
// a startup failure the way `watch.refine.enabled` without authoring
|
|
467
|
+
// support is above: the build lane functions fine without container
|
|
468
|
+
// roll-up, unlike refine, which cannot function without authoring at all.
|
|
469
|
+
// Not a second `resolveAuthoringProvider()` call: that helper's own config
|
|
470
|
+
// validation already ran to produce `provider` itself.
|
|
471
|
+
const authoringProvider = isAuthoringProvider(provider) ? provider : null;
|
|
412
472
|
const deps = {
|
|
413
473
|
provider,
|
|
414
474
|
codeHost,
|
|
@@ -418,6 +478,7 @@ export async function watchCommand(argv) {
|
|
|
418
478
|
chain: cfg.watch.chain,
|
|
419
479
|
baseBranch: cfg.watch.base_branch,
|
|
420
480
|
concurrency: cfg.watch.concurrency,
|
|
481
|
+
chainOptions: cfg.watch.chain_options,
|
|
421
482
|
refineEnabled: cfg.watch.refine.enabled,
|
|
422
483
|
refineConcurrency: cfg.watch.refine.concurrency,
|
|
423
484
|
refineChain: cfg.watch.refine.chain,
|
|
@@ -426,6 +487,7 @@ export async function watchCommand(argv) {
|
|
|
426
487
|
linkDataDir,
|
|
427
488
|
dryRun: Boolean(flags["dry-run"]),
|
|
428
489
|
runChain,
|
|
490
|
+
listChildren: authoringProvider ? (parent) => authoringProvider.listChildren(parent) : undefined,
|
|
429
491
|
log: (message) => console.log(message),
|
|
430
492
|
notify: (event) => notifier?.send(event),
|
|
431
493
|
};
|
package/dist/cli/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import * as paths from "../core/paths.js";
|
|
|
13
13
|
import { findChain, registerRepoChains, repoChainProblems } from "../chains/index.js";
|
|
14
14
|
import { loadRepoChains } from "../chains/repo_chains.js";
|
|
15
15
|
import { dispatchChain, usageFor } from "./commands/run.js";
|
|
16
|
+
import { estimateCommand } from "./commands/estimate.js";
|
|
16
17
|
import { listCommand } from "./commands/list.js";
|
|
17
18
|
import { initCommand } from "./commands/init.js";
|
|
18
19
|
import { installSkillCommand } from "./commands/install-skill.js";
|
|
@@ -25,11 +26,15 @@ import { eventsCommand } from "./commands/events.js";
|
|
|
25
26
|
import { abortCommand } from "./commands/abort.js";
|
|
26
27
|
import { uiCommand } from "./commands/ui.js";
|
|
27
28
|
import { watchCommand, watchInitCommand } from "./commands/watch.js";
|
|
29
|
+
import { fanoutCommand } from "./commands/fanout.js";
|
|
28
30
|
import { versionCommand } from "./commands/version.js";
|
|
29
31
|
const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
|
|
30
32
|
|
|
31
33
|
spf list the chain registry — names, phases, what each needs
|
|
32
34
|
spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
|
|
35
|
+
spf fanout <chain> "<prompt>" [--n 3] best-of-N: N isolated attempts, one deterministic winner branch — YOU merge it, spf never does
|
|
36
|
+
spf fanout --clean <base-adw-id> remove leftover worktrees/branches from a killed or discarded fanout run
|
|
37
|
+
spf estimate <chain> "<prompt>" [--n N] read-only: planned model routing + a token/cost projection from real trace history — starts nothing, exits 3 if there's no history yet
|
|
33
38
|
spf init [--force] [--yes] [--template <name>] [--no-skills] interview to seed .spf/spf.config.yaml + .env, and install the Claude Code skill unless --no-skills (--yes/--template skip the interview, not the skill install)
|
|
34
39
|
spf install-skill [--user] [--force] (re)install the Claude Code skill by hand — spf init already does this
|
|
35
40
|
spf migrate [--apply] [--force] move an old stamped adws/ tree onto .spf/ (dry run by default)
|
|
@@ -44,7 +49,8 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
|
|
|
44
49
|
spf abort <adw_id> signal a run's process to stop
|
|
45
50
|
spf version print the installed version
|
|
46
51
|
|
|
47
|
-
Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>]
|
|
52
|
+
Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>] [--priority p0|p1|p2|p3]
|
|
53
|
+
Run budget: set defaults.max_run_cost (USD) and/or defaults.max_run_tokens in spf.config.yaml to stop the NEXT agent call once a run has already spent this much — checked before each call, never after, so a single call is never capped and a one-agent-dispatch chain (scout/prompt/build) can never trip it; absent (the default) = unbounded.
|
|
48
54
|
Run \`spf list\` to see every chain and what it needs.`;
|
|
49
55
|
/** A raw scan for `--cwd`, ahead of any command-specific argv parsing — every command that takes it means the same thing by it. */
|
|
50
56
|
function findCwdFlag(argv) {
|
|
@@ -138,6 +144,12 @@ export async function main() {
|
|
|
138
144
|
process.exitCode = await dispatchChain(chain, chainArgs);
|
|
139
145
|
return;
|
|
140
146
|
}
|
|
147
|
+
case "fanout":
|
|
148
|
+
process.exitCode = await fanoutCommand(rest);
|
|
149
|
+
return;
|
|
150
|
+
case "estimate":
|
|
151
|
+
process.exitCode = await estimateCommand(rest);
|
|
152
|
+
return;
|
|
141
153
|
case "list":
|
|
142
154
|
process.exitCode = listCommand();
|
|
143
155
|
return;
|
package/dist/cli/interview.js
CHANGED
|
@@ -286,16 +286,20 @@ export async function runInterview(asker, ctx) {
|
|
|
286
286
|
watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
|
|
287
287
|
if (issueRepo)
|
|
288
288
|
watch.issue_repo = issueRepo;
|
|
289
|
-
// Issue authoring (create + link a hierarchy) is
|
|
290
|
-
// GitHubProvider
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
if (issueProvider === "github") {
|
|
289
|
+
// Issue authoring (create + link a hierarchy) is implemented on both
|
|
290
|
+
// GitHubProvider and JiraProvider — see jira_provider.ts's module
|
|
291
|
+
// comment for how Jira's version works (native issue types + the
|
|
292
|
+
// `parent` field).
|
|
293
|
+
if (issueProvider === "github" || issueProvider === "jira") {
|
|
294
294
|
const enableRefine = await asker.confirm(`Also enable the refine lane (decompose a "${labelPrefix}:spec-ready" product spec into a feature/story-or-bug tree)?`, false);
|
|
295
295
|
if (enableRefine) {
|
|
296
296
|
const refineChainChoices = allChains().map((c) => ({ value: c.name, label: c.name, hint: c.source ? `${c.describe} (repo)` : c.describe }));
|
|
297
297
|
const refineChain = await asker.select("Chain to run per spec", refineChainChoices, "refine");
|
|
298
298
|
watch.refine = { enabled: true, chain: refineChain };
|
|
299
|
+
if (issueProvider === "jira") {
|
|
300
|
+
asker.note('Jira issue types default to epic/feature -> "Epic", story -> "Story", bug -> "Bug", task -> "Task" — ' +
|
|
301
|
+
"customize per-kind in watch.jira.issue_types if your project renames any of them, then run `spf watch init` to validate.");
|
|
302
|
+
}
|
|
299
303
|
}
|
|
300
304
|
}
|
|
301
305
|
if (issueProvider === "jira") {
|
package/dist/core/agents.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* with a correction — context intact, bounded retries. Agent proposes, code
|
|
8
8
|
* disposes.
|
|
9
9
|
*/
|
|
10
|
+
import { type TierResolution } from "./tiering.ts";
|
|
10
11
|
import { GateReport, makeEventRecord, type AgentCall, type AgentConfig, type EnvelopeBase, type Phase, type SFConfig } from "./data_types.ts";
|
|
11
12
|
/**
|
|
12
13
|
* `undefined` (no `env_allowlist` configured — the default) means "don't
|
|
@@ -18,6 +19,51 @@ import { GateReport, makeEventRecord, type AgentCall, type AgentConfig, type Env
|
|
|
18
19
|
export declare function agentEnv(agent: AgentConfig): Record<string, string> | undefined;
|
|
19
20
|
export declare class GateFailure extends Error {
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* A run that has reached (or passed) one of `defaults`' budget ceilings.
|
|
24
|
+
*
|
|
25
|
+
* Its own class, like `GateFailure`, so a caller can tell "this run was cut
|
|
26
|
+
* off on purpose" from "the agent broke" — thrown from `send()` below, which
|
|
27
|
+
* puts it inside `run.phase()`'s try/catch, so the phase records `fail` and
|
|
28
|
+
* the run exits non-zero. That is the intended outcome: fail CLOSED. A budget
|
|
29
|
+
* that logged a warning and kept spending would not be a budget.
|
|
30
|
+
*/
|
|
31
|
+
export declare class BudgetExceeded extends Error {
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `$0.412` — three decimals, with a trailing zero trimmed so a round ceiling
|
|
35
|
+
* reads as the operator wrote it (`$0.40`, not `$0.400`). Cents are not
|
|
36
|
+
* enough precision here: a single cheap call is often sub-cent, and a budget
|
|
37
|
+
* message that says `$0.00 of max_run_cost $0.40` tells nobody anything.
|
|
38
|
+
*/
|
|
39
|
+
export declare function formatUsd(value: number): string;
|
|
40
|
+
/**
|
|
41
|
+
* The accumulating totals a budget check reads. Structurally a subset of
|
|
42
|
+
* `Run` (`core/runner.ts`) — `run.tokens`/`run.cost` are incremented by
|
|
43
|
+
* `run.addUsage()` after every send — so the real `Run` satisfies it with no
|
|
44
|
+
* adapter, and a test can pass a plain object with fake usage.
|
|
45
|
+
*/
|
|
46
|
+
export interface RunBudgetState {
|
|
47
|
+
cfg: SFConfig;
|
|
48
|
+
tokens: number;
|
|
49
|
+
cost: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Throw if this run has spent its budget. Called BEFORE every agent
|
|
53
|
+
* dispatch, never after.
|
|
54
|
+
*
|
|
55
|
+
* WHY BEFORE, AND WHY `>=`: the cost of the call about to happen is
|
|
56
|
+
* unknowable until it finishes, so a ceiling can only ever be enforced as
|
|
57
|
+
* "no further calls" — checked after the fact it would be a report, not a
|
|
58
|
+
* cap. `>=` follows from that: a run that has already spent exactly its
|
|
59
|
+
* ceiling has nothing left to spend, and letting one more (unbounded) call
|
|
60
|
+
* through would make the ceiling soft by exactly one call — which, on a
|
|
61
|
+
* chain whose last phase is the expensive one, is the whole overrun.
|
|
62
|
+
*
|
|
63
|
+
* Both ceilings absent (the default) returns immediately: zero behavior
|
|
64
|
+
* change, no arithmetic, nothing to get wrong.
|
|
65
|
+
*/
|
|
66
|
+
export declare function assertRunBudget(run: RunBudgetState): void;
|
|
21
67
|
/**
|
|
22
68
|
* Load and merge every existing path in `configPaths`, in order — later
|
|
23
69
|
* paths override earlier ones. Built-in defaults first, an optional `.spf/`
|
|
@@ -35,6 +81,9 @@ export declare function validate(cfg: SFConfig, required: string[], requiredSuit
|
|
|
35
81
|
interface RunForAgents {
|
|
36
82
|
cfg: SFConfig;
|
|
37
83
|
adw_id: string;
|
|
84
|
+
/** Run-total tokens/cost so far, mirrored by `addUsage` below — read by `assertRunBudget` before every send. */
|
|
85
|
+
tokens: number;
|
|
86
|
+
cost: number;
|
|
38
87
|
repo_root: string;
|
|
39
88
|
spf_dir: string | null;
|
|
40
89
|
data_dir: string;
|
|
@@ -45,6 +94,13 @@ interface RunForAgents {
|
|
|
45
94
|
model: string;
|
|
46
95
|
coding_agent: string;
|
|
47
96
|
}>;
|
|
97
|
+
/**
|
|
98
|
+
* Set once by `startRun` (`src/chains/steps.ts`), null until then. Optional
|
|
99
|
+
* here — a structural interface must not force every present and future
|
|
100
|
+
* test fake to carry it — and `effectiveAgent`'s own `?? base.model`
|
|
101
|
+
* fallback already handles absence. See `core/tiering.ts`.
|
|
102
|
+
*/
|
|
103
|
+
tiering?: TierResolution | null;
|
|
48
104
|
tracer: {
|
|
49
105
|
event: (record: ReturnType<typeof makeEventRecord>) => string;
|
|
50
106
|
processStart: (adwId: string, kind: string, name: string, pid: number, command: string) => void;
|
package/dist/core/agents.js
CHANGED
|
@@ -16,6 +16,7 @@ import * as agentFlue from "./agent_flue.js";
|
|
|
16
16
|
import * as paths from "./paths.js";
|
|
17
17
|
import * as permissions from "./permissions.js";
|
|
18
18
|
import * as prompts from "./prompts.js";
|
|
19
|
+
import { effectiveAgent } from "./tiering.js";
|
|
19
20
|
import { GateReport, UsageBreakdown, makeEventRecord, SFConfigSchema, } from "./data_types.js";
|
|
20
21
|
import { newId, operatorEnv } from "./utils.js";
|
|
21
22
|
const JSON_FIX_ATTEMPTS = 2; // continue-with-correction attempts for malformed JSON
|
|
@@ -45,6 +46,55 @@ export function agentEnv(agent) {
|
|
|
45
46
|
}
|
|
46
47
|
export class GateFailure extends Error {
|
|
47
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* A run that has reached (or passed) one of `defaults`' budget ceilings.
|
|
51
|
+
*
|
|
52
|
+
* Its own class, like `GateFailure`, so a caller can tell "this run was cut
|
|
53
|
+
* off on purpose" from "the agent broke" — thrown from `send()` below, which
|
|
54
|
+
* puts it inside `run.phase()`'s try/catch, so the phase records `fail` and
|
|
55
|
+
* the run exits non-zero. That is the intended outcome: fail CLOSED. A budget
|
|
56
|
+
* that logged a warning and kept spending would not be a budget.
|
|
57
|
+
*/
|
|
58
|
+
export class BudgetExceeded extends Error {
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* `$0.412` — three decimals, with a trailing zero trimmed so a round ceiling
|
|
62
|
+
* reads as the operator wrote it (`$0.40`, not `$0.400`). Cents are not
|
|
63
|
+
* enough precision here: a single cheap call is often sub-cent, and a budget
|
|
64
|
+
* message that says `$0.00 of max_run_cost $0.40` tells nobody anything.
|
|
65
|
+
*/
|
|
66
|
+
export function formatUsd(value) {
|
|
67
|
+
const fixed = value.toFixed(3);
|
|
68
|
+
return `$${fixed.endsWith("0") ? fixed.slice(0, -1) : fixed}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Throw if this run has spent its budget. Called BEFORE every agent
|
|
72
|
+
* dispatch, never after.
|
|
73
|
+
*
|
|
74
|
+
* WHY BEFORE, AND WHY `>=`: the cost of the call about to happen is
|
|
75
|
+
* unknowable until it finishes, so a ceiling can only ever be enforced as
|
|
76
|
+
* "no further calls" — checked after the fact it would be a report, not a
|
|
77
|
+
* cap. `>=` follows from that: a run that has already spent exactly its
|
|
78
|
+
* ceiling has nothing left to spend, and letting one more (unbounded) call
|
|
79
|
+
* through would make the ceiling soft by exactly one call — which, on a
|
|
80
|
+
* chain whose last phase is the expensive one, is the whole overrun.
|
|
81
|
+
*
|
|
82
|
+
* Both ceilings absent (the default) returns immediately: zero behavior
|
|
83
|
+
* change, no arithmetic, nothing to get wrong.
|
|
84
|
+
*/
|
|
85
|
+
export function assertRunBudget(run) {
|
|
86
|
+
const { max_run_cost: maxCost, max_run_tokens: maxTokens } = run.cfg.defaults;
|
|
87
|
+
if (maxCost === undefined && maxTokens === undefined)
|
|
88
|
+
return;
|
|
89
|
+
if (maxCost !== undefined && run.cost >= maxCost) {
|
|
90
|
+
throw new BudgetExceeded(`run budget exceeded: ${formatUsd(run.cost)} of max_run_cost ${formatUsd(maxCost)} — ` +
|
|
91
|
+
`raise defaults.max_run_cost or split the work`);
|
|
92
|
+
}
|
|
93
|
+
if (maxTokens !== undefined && run.tokens >= maxTokens) {
|
|
94
|
+
throw new BudgetExceeded(`run budget exceeded: ${run.tokens.toLocaleString("en-US")} tokens of max_run_tokens ` +
|
|
95
|
+
`${maxTokens.toLocaleString("en-US")} — raise defaults.max_run_tokens or split the work`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
48
98
|
/**
|
|
49
99
|
* A ValiError's own `.message` is only its FIRST issue — fine for a quick
|
|
50
100
|
* console line, not for something a human has to act on or a model has to
|
|
@@ -99,6 +149,15 @@ function mergeRawConfig(base, override) {
|
|
|
99
149
|
defaults: { ...(base.defaults || {}), ...(override.defaults || {}) },
|
|
100
150
|
observability: { ...(base.observability || {}), ...(override.observability || {}) },
|
|
101
151
|
quality: { ...(base.quality || {}), ...(override.quality || {}) },
|
|
152
|
+
// `watch` is spread WHOLE — no per-sub-key enumeration inside it — so
|
|
153
|
+
// any WatchConfigSchema field, present or future (jira, refine,
|
|
154
|
+
// chain_options, ...), already passes through this line untouched.
|
|
155
|
+
// The silent-drop trap this function's own doc comment warns about is
|
|
156
|
+
// about the TOP-LEVEL keys named here, not watch's own fields; the
|
|
157
|
+
// actual boundary for one of those is WatchConfigSchema itself (an
|
|
158
|
+
// unknown key there is stripped at `v.parse`, same failure mode, just a
|
|
159
|
+
// different gate) — see `chain_options`'s merge-survival test in
|
|
160
|
+
// `data_types.test.ts` for the check that actually matters here.
|
|
102
161
|
watch: { ...(base.watch || {}), ...(override.watch || {}) },
|
|
103
162
|
// channels is a whole-array replace on override, same as quality.checks —
|
|
104
163
|
// you don't want an override's channels appended to the built-in's.
|
|
@@ -106,6 +165,15 @@ function mergeRawConfig(base, override) {
|
|
|
106
165
|
// review.require_human_signoff / review.signoff_timeout_seconds — see
|
|
107
166
|
// data_types.ts's ReviewConfigSchema doc comment for why this key exists.
|
|
108
167
|
review: { ...(base.review || {}), ...(override.review || {}) },
|
|
168
|
+
// tiering.enabled / .tiers / .roles — key-by-key at this level, so a repo
|
|
169
|
+
// that only flips `enabled` keeps the base's tiers/roles. `tiers` is a
|
|
170
|
+
// whole-LIST replace on override (like quality.checks and
|
|
171
|
+
// notifications.channels): a repo that declares its own ladder replaces
|
|
172
|
+
// the packaged one wholesale rather than getting an unordered splice of
|
|
173
|
+
// both. `roles` is a whole-OBJECT replace for the same reason — a
|
|
174
|
+
// half-merged role map would route some agents by the base's ladder and
|
|
175
|
+
// some by the override's. Pinned by src/test/data_types.test.ts.
|
|
176
|
+
tiering: { ...(base.tiering || {}), ...(override.tiering || {}) },
|
|
109
177
|
agents: mergeAgentLists(base.agents || [], override.agents || []),
|
|
110
178
|
};
|
|
111
179
|
}
|
|
@@ -128,6 +196,15 @@ export function loadConfig(configPaths) {
|
|
|
128
196
|
raw = mergeRawConfig(raw, parsed);
|
|
129
197
|
}
|
|
130
198
|
const defaults = raw.defaults || {};
|
|
199
|
+
// THE BACK-FILL LIST IS PER-AGENT SETTINGS ONLY. Every key here is copied
|
|
200
|
+
// DOWN onto each agent that didn't set it, so only fields that mean
|
|
201
|
+
// something about ONE agent belong. Run-scoped `defaults` keys must stay
|
|
202
|
+
// out: `data_dir`, `protected_files`, and `max_run_cost`/`max_run_tokens`
|
|
203
|
+
// are properties of the RUN, and a per-agent copy of a run budget would
|
|
204
|
+
// read as "each agent may spend this much" — a different, unenforced
|
|
205
|
+
// feature. (AgentConfigSchema is a non-strict v.object, so a stray copy
|
|
206
|
+
// would be silently STRIPPED at parse rather than rejected: the mistake
|
|
207
|
+
// would look like it worked. See ConfigDefaultsSchema's own note.)
|
|
131
208
|
for (const agent of raw.agents || []) {
|
|
132
209
|
for (const key of ["coding_agent", "model", "thinking", "color", "tools", "writes", "env_allowlist"]) {
|
|
133
210
|
if (key in defaults && !(key in agent))
|
|
@@ -216,13 +293,81 @@ export function validate(cfg, required, requiredSuites = [], cwd) {
|
|
|
216
293
|
}
|
|
217
294
|
}
|
|
218
295
|
}
|
|
296
|
+
// Tiering (SPF #14) — every check below lives inside this ONE guard. A
|
|
297
|
+
// disabled ladder is not a config error, it is a config that is off: none
|
|
298
|
+
// of this fires for `enabled: false`, no matter what `tiers`/`roles` say —
|
|
299
|
+
// the same reason the packaged ladder + roles map surviving key-by-key
|
|
300
|
+
// merge into a repo that never opted in must not fail every chain.
|
|
301
|
+
if (cfg.tiering.enabled) {
|
|
302
|
+
// cfg-global: every declared rung must be well-formed for its OWN
|
|
303
|
+
// declared backend — the same branch this function already runs for
|
|
304
|
+
// agent.model, just keyed off the tier's coding_agent instead.
|
|
305
|
+
for (const tier of cfg.tiering.tiers) {
|
|
306
|
+
if (tier.coding_agent === "flue") {
|
|
307
|
+
try {
|
|
308
|
+
agentFlue.resolveModel(tier.model);
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
problems.push(`tiering.tiers[${JSON.stringify(tier.name)}]: ${error.message}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
else if (!tier.model.trim()) {
|
|
315
|
+
problems.push(`tiering.tiers[${JSON.stringify(tier.name)}]: model is empty`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// cfg-global: `enabled: true` with nothing to route is a silent no-op —
|
|
319
|
+
// the same reason an unconfigured quality.suites entry above is an
|
|
320
|
+
// error rather than a quiet pass-through.
|
|
321
|
+
if (cfg.tiering.tiers.length === 0) {
|
|
322
|
+
problems.push("tiering.enabled is true but tiering.tiers is empty — declare at least one rung or set tiering.enabled: false");
|
|
323
|
+
}
|
|
324
|
+
if (Object.keys(cfg.tiering.roles).length === 0) {
|
|
325
|
+
problems.push("tiering.enabled is true but tiering.roles is empty — name at least one role or set tiering.enabled: false");
|
|
326
|
+
}
|
|
327
|
+
// Scoped to `required` — same precedent as the per-agent loop above
|
|
328
|
+
// (`:256`/`:277-288`): a `roles` key naming a role no phase in this run
|
|
329
|
+
// will dispatch is neither routed nor validated. This is what lets the
|
|
330
|
+
// packaged six-name `roles` map survive key-by-key merge into a repo
|
|
331
|
+
// with a pruned/renamed roster without failing every chain.
|
|
332
|
+
for (const name of required) {
|
|
333
|
+
const tierName = cfg.tiering.roles[name];
|
|
334
|
+
if (tierName === undefined)
|
|
335
|
+
continue; // precedence: not named in roles -> untouched, nothing to check
|
|
336
|
+
const tier = cfg.tiering.tiers.find((t) => t.name === tierName);
|
|
337
|
+
if (!tier) {
|
|
338
|
+
problems.push(`tiering.roles.${name} names tier ${JSON.stringify(tierName)}, which is not declared in tiering.tiers`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const agent = cfg.agents.find((a) => a.name === name);
|
|
342
|
+
if (!agent)
|
|
343
|
+
continue; // already reported above by the per-agent loop's own resolve() failure
|
|
344
|
+
// Rule T — the backend-compatibility rule (§4.4A). A tier changes an
|
|
345
|
+
// agent's `model` and NOTHING else, so a role is routable by a tier
|
|
346
|
+
// only when their backends agree; a mismatch is a named error, never
|
|
347
|
+
// a silent skip, because a config that says "route this role by tier"
|
|
348
|
+
// and then quietly does not is the failure mode this codebase already
|
|
349
|
+
// refuses for suites.
|
|
350
|
+
if (agent.coding_agent !== tier.coding_agent) {
|
|
351
|
+
problems.push(`agent ${JSON.stringify(name)} (coding_agent: ${agent.coding_agent}) is routed by tiering.roles to tier ${JSON.stringify(tier.name)} ` +
|
|
352
|
+
`(coding_agent: ${tier.coding_agent}) — a tier's model only speaks its own backend's vocabulary; declare a ` +
|
|
353
|
+
`${agent.coding_agent} rung for ${JSON.stringify(name)} or remove it from tiering.roles`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
219
357
|
if (problems.length > 0) {
|
|
220
358
|
throw new Error("config validation failed:\n- " + problems.join("\n- "));
|
|
221
359
|
}
|
|
222
360
|
}
|
|
223
361
|
/** One agent call: render prompts -> pi run -> typed parse -> gates -> envelope. */
|
|
224
362
|
export async function execute(run, phase, call) {
|
|
225
|
-
|
|
363
|
+
// The single dispatch-site change tiering makes: one effective AgentConfig,
|
|
364
|
+
// `model` and ONLY `model` possibly overridden (rule T — `coding_agent` is
|
|
365
|
+
// never touched here). Every downstream reader below (`agent_start`'s
|
|
366
|
+
// payload, `run.console.agentStarted`, the actual `AgentRequest`,
|
|
367
|
+
// `agentSessionId`'s reuse comparison, the traced `agent_sessions.model`
|
|
368
|
+
// column, `run.saveAgentMap`) becomes consistent for free. See
|
|
369
|
+
// `core/tiering.ts`'s `effectiveAgent`.
|
|
370
|
+
const agent = effectiveAgent(run, resolve(run.cfg, phase.params.owner));
|
|
226
371
|
const agentDir = path.join(run.session_dir, agent.name);
|
|
227
372
|
mkdirSync(agentDir, { recursive: true });
|
|
228
373
|
const variables = {
|
|
@@ -258,6 +403,12 @@ export async function execute(run, phase, call) {
|
|
|
258
403
|
let latest = null;
|
|
259
404
|
const spent = new UsageBreakdown();
|
|
260
405
|
async function send(promptText) {
|
|
406
|
+
// The ONE dispatch point for this phase — the first prompt, every
|
|
407
|
+
// JSON-repair retry, and every gate correction all funnel through here,
|
|
408
|
+
// so checking the run's ceilings here is what makes them a cap on the
|
|
409
|
+
// WHOLE run rather than on the first call of each phase. See
|
|
410
|
+
// `assertRunBudget` for why it is checked before, not after.
|
|
411
|
+
assertRunBudget(run);
|
|
261
412
|
const request = {
|
|
262
413
|
prompt: promptText,
|
|
263
414
|
system_prompt: systemText,
|