@gr8ful/spf 0.7.0 → 0.8.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/dist/chains/context.d.ts +26 -0
- package/dist/chains/simple_sdlc.js +9 -0
- package/dist/chains/steps.js +1 -1
- package/dist/cli/ask.d.ts +13 -0
- package/dist/cli/ask.js +15 -1
- package/dist/cli/commands/doctor.js +43 -7
- package/dist/cli/commands/fanout.js +49 -5
- package/dist/cli/commands/init.js +7 -2
- package/dist/cli/commands/list.d.ts +1 -1
- package/dist/cli/commands/list.js +31 -12
- package/dist/cli/commands/phases.d.ts +1 -1
- package/dist/cli/commands/phases.js +18 -4
- package/dist/cli/commands/run.js +21 -0
- package/dist/cli/commands/sessions.d.ts +1 -1
- package/dist/cli/commands/sessions.js +11 -3
- package/dist/cli/commands/watch.js +38 -6
- package/dist/cli/index.js +3 -3
- package/dist/cli/ui/fanout_dashboard.d.ts +22 -0
- package/dist/cli/ui/fanout_dashboard.js +102 -0
- package/dist/cli/ui/ink_asker.d.ts +13 -0
- package/dist/cli/ui/ink_asker.js +247 -0
- package/dist/cli/ui/reports.d.ts +30 -0
- package/dist/cli/ui/reports.js +61 -0
- package/dist/cli/ui/run_dashboard.d.ts +15 -0
- package/dist/cli/ui/run_dashboard.js +131 -0
- package/dist/cli/ui/watch_dashboard.d.ts +22 -0
- package/dist/cli/ui/watch_dashboard.js +78 -0
- package/dist/core/console.d.ts +40 -1
- package/dist/core/console.js +25 -3
- package/dist/core/fanout.d.ts +9 -0
- package/dist/core/fanout.js +6 -2
- package/dist/core/issues/github_provider.js +16 -1
- package/dist/core/issues/jira_provider.js +13 -1
- package/dist/core/runner.d.ts +5 -1
- package/dist/core/runner.js +2 -1
- package/dist/core/session.d.ts +7 -1
- package/dist/core/session.js +5 -1
- package/package.json +6 -1
package/dist/chains/context.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RunObserver } from "../core/console.ts";
|
|
1
2
|
/**
|
|
2
3
|
* What every chain's `main()` receives. The CLI (or, later, the chain
|
|
3
4
|
* registry) resolves every path here — a chain never has its own opinion
|
|
@@ -57,4 +58,29 @@ export interface ChainContext {
|
|
|
57
58
|
* edited by the time anyone reads the run back).
|
|
58
59
|
*/
|
|
59
60
|
chain_source?: string;
|
|
61
|
+
/**
|
|
62
|
+
* A live TTY view's hook into this run's `Console` — see `RunObserver`'s
|
|
63
|
+
* doc comment (`core/console.ts`). Optional and omitted by every
|
|
64
|
+
* construction site except an interactive `cli/commands/run.ts`
|
|
65
|
+
* dispatch: `spf watch`'s per-issue runs, `spf fanout`'s per-attempt
|
|
66
|
+
* runs, the `refine` chain, and every test all continue to build a
|
|
67
|
+
* plain, unobserved `Console` exactly as before this field existed.
|
|
68
|
+
*/
|
|
69
|
+
render_hooks?: {
|
|
70
|
+
sink?: (line: string) => void;
|
|
71
|
+
observer?: RunObserver | null;
|
|
72
|
+
/**
|
|
73
|
+
* The live dashboard's own handoff pair — call `pause()` before a
|
|
74
|
+
* chain shows its own competing prompt (today, only `simple_sdlc.ts`'s
|
|
75
|
+
* human sign-off gate) and `resume()` once it's done, symmetrically,
|
|
76
|
+
* in a `finally`. Ink refuses a second `render()` on the same stdout
|
|
77
|
+
* while a prior instance is still live, so this isn't optional
|
|
78
|
+
* politeness — skipping `pause()` before mounting another Ink surface
|
|
79
|
+
* (or even a plain `readline` prompt sharing the same stdin/stdout,
|
|
80
|
+
* given the dashboard's own live-updating timer keeps writing to it)
|
|
81
|
+
* corrupts both.
|
|
82
|
+
*/
|
|
83
|
+
pause?: () => void | Promise<void>;
|
|
84
|
+
resume?: () => void;
|
|
85
|
+
};
|
|
60
86
|
}
|
|
@@ -217,6 +217,13 @@ export async function main(ctx) {
|
|
|
217
217
|
const canPrompt = isInteractive() && !ctx.unattended; // amendment: never infer this from TTY state alone
|
|
218
218
|
identity = committerIdentity(run.repo_root);
|
|
219
219
|
const asker = canPrompt ? createAsker() : null;
|
|
220
|
+
// A live run dashboard (cli/ui/run_dashboard.tsx) keeps writing to the
|
|
221
|
+
// same stdout/stdin on its own timer even though it never reads input
|
|
222
|
+
// itself — that alone is enough to corrupt this prompt's rendering if
|
|
223
|
+
// both are live at once. `pause()`/`resume()` are no-ops when no
|
|
224
|
+
// dashboard is mounted (every non-interactive dispatch, and any test).
|
|
225
|
+
if (canPrompt)
|
|
226
|
+
await ctx.render_hooks?.pause?.();
|
|
220
227
|
try {
|
|
221
228
|
const outcome = await run.phase(makePhaseParams({
|
|
222
229
|
name: "signoff",
|
|
@@ -238,6 +245,8 @@ export async function main(ctx) {
|
|
|
238
245
|
}
|
|
239
246
|
finally {
|
|
240
247
|
asker?.close();
|
|
248
|
+
if (canPrompt)
|
|
249
|
+
ctx.render_hooks?.resume?.();
|
|
241
250
|
}
|
|
242
251
|
}
|
|
243
252
|
if (verified) {
|
package/dist/chains/steps.js
CHANGED
|
@@ -73,7 +73,7 @@ function makeStep(fn, meta = {}) {
|
|
|
73
73
|
export async function startRun(ctx, requiredAgents, requiredSuites) {
|
|
74
74
|
const cfg = agentsCfg.loadConfig(ctx.config_paths);
|
|
75
75
|
agentsCfg.validate(cfg, requiredAgents, requiredSuites, ctx.cwd);
|
|
76
|
-
const run = session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name);
|
|
76
|
+
const run = session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name, ctx.render_hooks);
|
|
77
77
|
// Provenance, once per run, before any phase opens: a repo-local chain
|
|
78
78
|
// (.spf/chains/*.yaml) records the file it came from. `chain_name` alone
|
|
79
79
|
// stops being enough to reconstruct a run the moment a target repo can
|
package/dist/cli/ask.d.ts
CHANGED
|
@@ -33,8 +33,21 @@ export interface Asker {
|
|
|
33
33
|
}
|
|
34
34
|
/** `stdin.isTTY` is what actually matters (the interview reads it) — `stdout.isTTY` alone, this repo's only prior TTY check (`src/ui/server/serve.ts:88`), would let a piped-in `spf init` hang waiting on input that will never arrive. */
|
|
35
35
|
export declare function isInteractive(): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Whether `cli/ui/ink_asker.tsx`'s Ink-backed `Asker` can run instead of this
|
|
38
|
+
* file's plain readline one. Ink hard-requires raw-mode stdin — confirmed by
|
|
39
|
+
* testing: rendering an Ink app against a non-raw-mode-capable stdin throws
|
|
40
|
+
* before anything is drawn. `isInteractive()` already implies raw mode is
|
|
41
|
+
* available in every real case (a TTY stdin always exposes `setRawMode`);
|
|
42
|
+
* the extra `typeof` check is cheap insurance against whatever exotic
|
|
43
|
+
* terminal doesn't, so a caller falls back to `createAsker()` instead of
|
|
44
|
+
* crashing.
|
|
45
|
+
*/
|
|
46
|
+
export declare function inkAvailable(): boolean;
|
|
36
47
|
/** Thrown when the user interrupts (Ctrl-C) or stdin closes (EOF) mid-interview. `initCommand` catches this and exits 130, writing nothing. */
|
|
37
48
|
export declare class InterviewAborted extends Error {
|
|
38
49
|
constructor();
|
|
39
50
|
}
|
|
40
51
|
export declare function createAsker(): Asker;
|
|
52
|
+
/** Exported for `cli/ui/ink_asker.tsx`'s secret prompt, which renders the same "keep current" line and must mask it identically. */
|
|
53
|
+
export declare function maskForPrompt(value: string): string;
|
package/dist/cli/ask.js
CHANGED
|
@@ -16,6 +16,19 @@ import { paint } from "../core/console.js";
|
|
|
16
16
|
export function isInteractive() {
|
|
17
17
|
return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env["CI"];
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether `cli/ui/ink_asker.tsx`'s Ink-backed `Asker` can run instead of this
|
|
21
|
+
* file's plain readline one. Ink hard-requires raw-mode stdin — confirmed by
|
|
22
|
+
* testing: rendering an Ink app against a non-raw-mode-capable stdin throws
|
|
23
|
+
* before anything is drawn. `isInteractive()` already implies raw mode is
|
|
24
|
+
* available in every real case (a TTY stdin always exposes `setRawMode`);
|
|
25
|
+
* the extra `typeof` check is cheap insurance against whatever exotic
|
|
26
|
+
* terminal doesn't, so a caller falls back to `createAsker()` instead of
|
|
27
|
+
* crashing.
|
|
28
|
+
*/
|
|
29
|
+
export function inkAvailable() {
|
|
30
|
+
return isInteractive() && typeof process.stdin.setRawMode === "function";
|
|
31
|
+
}
|
|
19
32
|
/** Thrown when the user interrupts (Ctrl-C) or stdin closes (EOF) mid-interview. `initCommand` catches this and exits 130, writing nothing. */
|
|
20
33
|
export class InterviewAborted extends Error {
|
|
21
34
|
constructor() {
|
|
@@ -148,7 +161,8 @@ export function createAsker() {
|
|
|
148
161
|
},
|
|
149
162
|
};
|
|
150
163
|
}
|
|
151
|
-
|
|
164
|
+
/** Exported for `cli/ui/ink_asker.tsx`'s secret prompt, which renders the same "keep current" line and must mask it identically. */
|
|
165
|
+
export function maskForPrompt(value) {
|
|
152
166
|
if (value.length <= 4)
|
|
153
167
|
return "•".repeat(value.length);
|
|
154
168
|
return `${"•".repeat(Math.max(0, value.length - 4))}${value.slice(-4)}`;
|
|
@@ -22,6 +22,30 @@ import { PROVIDER_ENV_KEYS } from "../../core/providers.js";
|
|
|
22
22
|
import { probeServedOllamaTags, resolveTiering } from "../../core/tiering.js";
|
|
23
23
|
import { isRepoAt } from "../../core/git_helper.js";
|
|
24
24
|
import { allChains, findChain, repoChainProblems, resolveRequiredAgents, resolveRequiredSuites } from "../../chains/index.js";
|
|
25
|
+
import { isInteractive } from "../ask.js";
|
|
26
|
+
import { paint as paintPlain } from "../../core/console.js";
|
|
27
|
+
/**
|
|
28
|
+
* A transient "checking X..." status line around a network probe — real
|
|
29
|
+
* work only starts inside `run()`; this doesn't touch the promise's timing.
|
|
30
|
+
* TTY-only: `\r` + erase-to-end-of-line only makes sense against a real
|
|
31
|
+
* terminal, and a non-TTY/CI log should show nothing extra, matching
|
|
32
|
+
* doctor's output exactly as it always has when piped. Not Ink-based on
|
|
33
|
+
* purpose — this is a plain transient ANSI line, not a component tree, and
|
|
34
|
+
* doctor's checks accumulate into one report printed at the very end
|
|
35
|
+
* regardless, so there's no persistent live region for a probe status to
|
|
36
|
+
* join.
|
|
37
|
+
*/
|
|
38
|
+
async function withProbeStatus(label, run) {
|
|
39
|
+
if (!isInteractive())
|
|
40
|
+
return run();
|
|
41
|
+
process.stdout.write(paintPlain("dim", ` checking ${label}...`));
|
|
42
|
+
try {
|
|
43
|
+
return await run();
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
process.stdout.write("\r\x1b[2K");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
25
49
|
/**
|
|
26
50
|
* `severity` is a display-only axis, orthogonal to `ok`/`report.ok`: an
|
|
27
51
|
* "info"/"warn" check still reports `ok: true` (it never fails `spf doctor`
|
|
@@ -174,7 +198,7 @@ export async function doctorCommand(argv) {
|
|
|
174
198
|
}
|
|
175
199
|
catch (error) {
|
|
176
200
|
check(report, "config parses", false, error.message);
|
|
177
|
-
return finish(report, flags["json"]);
|
|
201
|
+
return await finish(report, flags["json"]);
|
|
178
202
|
}
|
|
179
203
|
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
180
204
|
check(report, "data_dir", true, dataPaths.data_dir);
|
|
@@ -254,7 +278,7 @@ export async function doctorCommand(argv) {
|
|
|
254
278
|
const probeBase = doubledPath ? trimmed.replace(/\/v1$/, "") : trimmed;
|
|
255
279
|
const claudeCodeAgent = cfg.agents.find((a) => a.coding_agent === "claude_code");
|
|
256
280
|
const probeModel = claudeCodeAgent?.model || "claude-sonnet-5";
|
|
257
|
-
const result = await probeAnthropicMessages(probeBase, probeModel);
|
|
281
|
+
const result = await withProbeStatus("ANTHROPIC_BASE_URL reachability", () => probeAnthropicMessages(probeBase, probeModel));
|
|
258
282
|
check(report, "ANTHROPIC_BASE_URL reachability", true, (result.ok
|
|
259
283
|
? `${doubledPath ? `dropping the trailing "/v1" makes it ` : ""}reachable: POST ${probeBase}/v1/messages -> HTTP ${result.status}${result.note ? ` (${result.note})` : ""}`
|
|
260
284
|
: `unreachable: POST ${probeBase}/v1/messages -> ${result.error}`) +
|
|
@@ -288,7 +312,7 @@ export async function doctorCommand(argv) {
|
|
|
288
312
|
// latency either way — strictly cheaper for a pure reachability check,
|
|
289
313
|
// and there's no live-server dependency in this choice: doctor's probe
|
|
290
314
|
// itself tolerates either endpoint being down (see `probeGet`).
|
|
291
|
-
const result = await probeGet(`${ollamaBase}/models`);
|
|
315
|
+
const result = await withProbeStatus("OLLAMA_BASE_URL reachability", () => probeGet(`${ollamaBase}/models`));
|
|
292
316
|
check(report, "OLLAMA_BASE_URL reachability", true, // informational/warning only — see the ANTHROPIC_BASE_URL check above for why
|
|
293
317
|
result.ok ? `reachable: GET ${ollamaBase}/models -> HTTP ${result.status}` : `unreachable: GET ${ollamaBase}/models -> ${result.error}`, result.ok ? "info" : "warn");
|
|
294
318
|
}
|
|
@@ -379,7 +403,7 @@ export async function doctorCommand(argv) {
|
|
|
379
403
|
// chain+prompt is `spf estimate`'s job, not doctor's. `required` is the
|
|
380
404
|
// WHOLE roster, matching the "roster + suites validate" call above —
|
|
381
405
|
// every routed role doctor's job covers, not just what one chain needs.
|
|
382
|
-
const servedOllamaTags = flags["no-probe"] ? null : await probeServedOllamaTags(cfg);
|
|
406
|
+
const servedOllamaTags = flags["no-probe"] ? null : await withProbeStatus("served Ollama tags", () => probeServedOllamaTags(cfg));
|
|
383
407
|
const resolution = resolveTiering({
|
|
384
408
|
cfg,
|
|
385
409
|
chainName: "",
|
|
@@ -559,7 +583,9 @@ export async function doctorCommand(argv) {
|
|
|
559
583
|
`${cfg.observability.otel.headers ? `, headers: ${Object.keys(cfg.observability.otel.headers).join(", ")}` : ""})` +
|
|
560
584
|
(insecure ? " — WARNING: plain http to a non-loopback host sends this telemetry in cleartext" : ""), insecure ? "warn" : "info");
|
|
561
585
|
if (!flags["no-probe"]) {
|
|
562
|
-
|
|
586
|
+
// Captured into a local: TS narrowing from the outer `if (cfg.observability.otel)` doesn't survive into this closure.
|
|
587
|
+
const otelHeaders = cfg.observability.otel.headers;
|
|
588
|
+
const result = await withProbeStatus("observability.otel reachability", () => probeOtel(url, otelHeaders));
|
|
563
589
|
check(report, "observability.otel reachability", true, // informational/warning only — same rule as the base-URL probes above
|
|
564
590
|
result.ok
|
|
565
591
|
? `reachable: POST ${endpointLabel(url)} (empty batch) -> HTTP ${result.status}${result.status >= 400 ? " — reachable but rejecting; check the path (/v1/traces) and headers" : ""}`
|
|
@@ -579,16 +605,26 @@ export async function doctorCommand(argv) {
|
|
|
579
605
|
}
|
|
580
606
|
return finish(report, flags["json"]);
|
|
581
607
|
}
|
|
582
|
-
function finish(report, json) {
|
|
608
|
+
async function finish(report, json) {
|
|
583
609
|
if (json) {
|
|
584
610
|
console.log(JSON.stringify(report, null, 2));
|
|
611
|
+
return report.ok ? 0 : 1;
|
|
612
|
+
}
|
|
613
|
+
const footer = { ok: report.ok, message: report.ok ? "spf doctor: clean" : "spf doctor: problems found above" };
|
|
614
|
+
if (isInteractive()) {
|
|
615
|
+
const { renderChecklist } = await import("../ui/reports.js");
|
|
616
|
+
await renderChecklist(report.checks.map((c) => ({
|
|
617
|
+
icon: c.severity === "warn" ? "warn" : c.severity === "info" ? "info" : c.ok ? "ok" : "fail",
|
|
618
|
+
name: c.name,
|
|
619
|
+
detail: c.detail,
|
|
620
|
+
})), footer);
|
|
585
621
|
}
|
|
586
622
|
else {
|
|
587
623
|
for (const c of report.checks) {
|
|
588
624
|
const icon = c.severity === "warn" ? "⚠" : c.severity === "info" ? "ℹ" : c.ok ? "✓" : "✗";
|
|
589
625
|
console.log(`${icon} ${c.name}: ${c.detail}`);
|
|
590
626
|
}
|
|
591
|
-
console.log(
|
|
627
|
+
console.log(`\n${footer.message}`);
|
|
592
628
|
}
|
|
593
629
|
return report.ok ? 0 : 1;
|
|
594
630
|
}
|
|
@@ -31,6 +31,7 @@ import { ABORTED_EXIT, attemptAdwId, runBestOf, ZERO_METRICS, } from "../../core
|
|
|
31
31
|
import { findChain, runChain as runChainDef } from "../../chains/index.js";
|
|
32
32
|
import { SfDb } from "../../ui/server/db.js";
|
|
33
33
|
import { newId, parseCli, resolvePrompt } from "../../core/utils.js";
|
|
34
|
+
import { isInteractive } from "../ask.js";
|
|
34
35
|
/**
|
|
35
36
|
* A ceiling on `--n`, not a recommendation. Each attempt is a full chain run
|
|
36
37
|
* with its own agents, so `--n` multiplies spend directly — and the machine
|
|
@@ -62,9 +63,30 @@ function formatGates(attempt) {
|
|
|
62
63
|
/**
|
|
63
64
|
* The result table. Fixed column widths over a `console.table`: every other
|
|
64
65
|
* `spf` listing (`sessions`, `phases`) prints plain padded columns, and this
|
|
65
|
-
* output is read in a terminal beside them.
|
|
66
|
+
* output is read in a terminal beside them. On a TTY, routes through the
|
|
67
|
+
* same `renderTable()` (`cli/ui/reports.tsx`) those two now use instead —
|
|
68
|
+
* `fanout_cli.test.ts` runs with no TTY, so it keeps exercising this exact
|
|
69
|
+
* plain-text path unchanged.
|
|
66
70
|
*/
|
|
67
|
-
function printTable(attempts) {
|
|
71
|
+
async function printTable(attempts) {
|
|
72
|
+
const rows = attempts.map((a) => [
|
|
73
|
+
String(a.index),
|
|
74
|
+
a.adw_id,
|
|
75
|
+
a.branch,
|
|
76
|
+
a.status,
|
|
77
|
+
formatGates(a),
|
|
78
|
+
agents.formatUsd(a.cost),
|
|
79
|
+
a.status === "skipped" ? "-" : formatDuration(a.wall_ms),
|
|
80
|
+
]);
|
|
81
|
+
if (isInteractive()) {
|
|
82
|
+
const { renderTable } = await import("../ui/reports.js");
|
|
83
|
+
await renderTable([["#", "adw_id", "branch", "status", "gates", "cost", "time"], ...rows], { rowColor: (i) => (i > 0 && attempts[i - 1].status === "fail" ? "red" : undefined) });
|
|
84
|
+
for (const a of attempts) {
|
|
85
|
+
if (a.error)
|
|
86
|
+
console.log(` error: ${a.error}`);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
68
90
|
const widths = {
|
|
69
91
|
adw: Math.max(6, ...attempts.map((a) => a.adw_id.length)),
|
|
70
92
|
branch: Math.max(6, ...attempts.map((a) => a.branch.length)),
|
|
@@ -343,11 +365,26 @@ export async function fanoutCommand(argv) {
|
|
|
343
365
|
// this reliably buys, in the case it can't prevent, is telling the
|
|
344
366
|
// operator the one thing the trace can't: which base adw_id to hand to
|
|
345
367
|
// `--clean` afterwards.
|
|
368
|
+
// Mounted only on a real TTY — a live results table that updates row by
|
|
369
|
+
// row as each attempt settles, instead of the plain-text path's one-shot
|
|
370
|
+
// table at the very end. `dashboard` (not just its presence) is read by
|
|
371
|
+
// `onSignal` below, best-effort-unmounted before `process.exit()`: Ink's
|
|
372
|
+
// usual cleanup (restoring cursor visibility) never gets a chance to run
|
|
373
|
+
// on an abrupt exit otherwise, and this command's own SIGINT handler —
|
|
374
|
+
// unlike `spf watch`'s two-stage drain — already exits immediately by
|
|
375
|
+
// design (see its comment), so there's no "wait for it to finish" point
|
|
376
|
+
// to hook a graceful teardown into instead.
|
|
377
|
+
let dashboard;
|
|
378
|
+
if (isInteractive()) {
|
|
379
|
+
const { mountFanoutDashboard } = await import("../ui/fanout_dashboard.js");
|
|
380
|
+
dashboard = mountFanoutDashboard({ n, chainName: chain.name, baseBranch, baseAdwId });
|
|
381
|
+
}
|
|
346
382
|
let interrupted = false;
|
|
347
383
|
const onSignal = (signal) => {
|
|
348
384
|
if (interrupted)
|
|
349
385
|
return;
|
|
350
386
|
interrupted = true;
|
|
387
|
+
dashboard?.unmountNow(); // best-effort cursor-visibility restore — see FanoutDashboard.unmountNow's own comment for why this isn't the awaited close()
|
|
351
388
|
console.error(`\n[spf] fanout: ${signal} received — attempts already past their last checkpoint run to completion. ` +
|
|
352
389
|
`Once everything has settled, clean up anything left under this base id with:\n` +
|
|
353
390
|
` spf fanout --clean ${baseAdwId}\n`);
|
|
@@ -370,10 +407,17 @@ export async function fanoutCommand(argv) {
|
|
|
370
407
|
runAttempt,
|
|
371
408
|
readMetrics,
|
|
372
409
|
firstSuccess: flags["first-success"],
|
|
373
|
-
log: (message) => console.log(`[spf] ${message}`),
|
|
410
|
+
log: (message) => (dashboard ? dashboard.log(message) : console.log(`[spf] ${message}`)),
|
|
411
|
+
onAttempt: dashboard?.onAttempt,
|
|
374
412
|
});
|
|
375
|
-
|
|
376
|
-
|
|
413
|
+
await dashboard?.close();
|
|
414
|
+
// A dashboard already printed the header and kept the table live and
|
|
415
|
+
// in scrollback throughout the run — printing it again here would just
|
|
416
|
+
// duplicate what's already there. Only the non-TTY path needs both.
|
|
417
|
+
if (!dashboard) {
|
|
418
|
+
console.log(`\nfanout ${chain.name} — ${n} attempt(s), base ${baseBranch}`);
|
|
419
|
+
await printTable(result.attempts);
|
|
420
|
+
}
|
|
377
421
|
if (!result.winner) {
|
|
378
422
|
console.error(`\nno winner: ${result.basis}`);
|
|
379
423
|
console.error(`Any attempt whose chain got far enough to open a session has its trace in the shared db — ` +
|
|
@@ -27,7 +27,7 @@ import * as agents from "../../core/agents.js";
|
|
|
27
27
|
import { ensureGitignore } from "../gitignore.js";
|
|
28
28
|
import { parseCli } from "../../core/utils.js";
|
|
29
29
|
import { paint } from "../../core/console.js";
|
|
30
|
-
import { createAsker, isInteractive, InterviewAborted } from "../ask.js";
|
|
30
|
+
import { createAsker, inkAvailable, isInteractive, InterviewAborted } from "../ask.js";
|
|
31
31
|
import { gatherContext, runInterview } from "../interview.js";
|
|
32
32
|
import { readEnvFile, upsertEnvFile, writeEnvExample } from "../env_file.js";
|
|
33
33
|
import { installSkillCommand } from "./install-skill.js";
|
|
@@ -255,7 +255,12 @@ export async function initCommand(argv) {
|
|
|
255
255
|
installSkill();
|
|
256
256
|
}
|
|
257
257
|
else {
|
|
258
|
-
|
|
258
|
+
// Ink needs raw-mode stdin to drive arrow-key selects and inline
|
|
259
|
+
// validation; `inkAvailable()` is `isInteractive()` plus one extra
|
|
260
|
+
// guard for the (practically never, but cheap to check) case where a
|
|
261
|
+
// TTY-reporting stdin still doesn't expose `setRawMode`. Either way
|
|
262
|
+
// falls back to the original readline asker, never to a hang.
|
|
263
|
+
const asker = inkAvailable() ? (await import("../ui/ink_asker.js")).createInkAsker() : createAsker();
|
|
259
264
|
try {
|
|
260
265
|
if (existsSync(configPath) && !flags["force"]) {
|
|
261
266
|
const overwrite = await asker.confirm(`${configPath} already exists — overwrite it?`, false);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function listCommand(): number
|
|
1
|
+
export declare function listCommand(): Promise<number>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { allChains, repoChainProblems } from "../../chains/index.js";
|
|
3
|
+
import { isInteractive } from "../ask.js";
|
|
3
4
|
/**
|
|
4
5
|
* A repo-defined chain's `source` is an absolute path into `.spf/chains/` by
|
|
5
6
|
* construction (that's the one place `loadRepoChains` ever looks) — trim it
|
|
@@ -12,28 +13,46 @@ function repoChainLabel(source) {
|
|
|
12
13
|
const idx = source.lastIndexOf(marker);
|
|
13
14
|
return idx === -1 ? source : source.slice(idx);
|
|
14
15
|
}
|
|
15
|
-
export function listCommand() {
|
|
16
|
+
export async function listCommand() {
|
|
16
17
|
// Built-ins first, then repo chains — same order `allChains()` guarantees,
|
|
17
18
|
// so this listing and `findChain`'s resolution order never disagree about
|
|
18
19
|
// which chain wins on a name collision.
|
|
19
20
|
const chains = allChains();
|
|
20
21
|
const width = Math.max(...chains.map((c) => c.name.length));
|
|
21
|
-
|
|
22
|
+
const entries = chains.map((chain) => {
|
|
22
23
|
const agents = typeof chain.requiredAgents === "function" ? "(--agent picks who)" : chain.requiredAgents.join(", ") || "(none)";
|
|
23
24
|
const suites = typeof chain.requiredSuites === "function" ? chain.requiredSuites({}) : chain.requiredSuites;
|
|
24
25
|
const suiteNote = typeof chain.requiredSuites === "function" ? " (--suite overrides)" : "";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
return {
|
|
27
|
+
name: chain.name,
|
|
28
|
+
phases: chain.phases,
|
|
29
|
+
describe: chain.describe,
|
|
30
|
+
agentsLine: `agents: ${agents}${suites.length ? ` · quality suites: ${suites.join(", ")}${suiteNote}` : ""}`,
|
|
31
|
+
// `source` is undefined for every built-in — only a chain loaded from a
|
|
32
|
+
// `.spf/chains/*.yaml` file carries one (see chains/repo_chains.ts).
|
|
33
|
+
repoLabel: chain.source ? repoChainLabel(chain.source) : undefined,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
const usageLines = [
|
|
37
|
+
`spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>] (spf run <name> ... works identically)`,
|
|
38
|
+
`spf watch polls a tracker and runs one of these chains per issue — spf doctor shows the current config.`,
|
|
39
|
+
];
|
|
40
|
+
if (isInteractive()) {
|
|
41
|
+
const { renderChainList } = await import("../ui/reports.js");
|
|
42
|
+
await renderChainList(entries, usageLines);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
console.log(`${entry.name.padEnd(width)} ${entry.phases}`);
|
|
47
|
+
console.log(`${"".padEnd(width)} ${entry.describe}`);
|
|
48
|
+
console.log(`${"".padEnd(width)} ${entry.agentsLine}`);
|
|
49
|
+
if (entry.repoLabel)
|
|
50
|
+
console.log(`${"".padEnd(width)} (repo: ${entry.repoLabel})`);
|
|
51
|
+
console.log();
|
|
32
52
|
}
|
|
33
|
-
|
|
53
|
+
for (const line of usageLines)
|
|
54
|
+
console.log(line);
|
|
34
55
|
}
|
|
35
|
-
console.log(`spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>] (spf run <name> ... works identically)`);
|
|
36
|
-
console.log(`spf watch polls a tracker and runs one of these chains per issue — spf doctor shows the current config.`);
|
|
37
56
|
// Every malformed `.spf/chains/*.yaml` file (bad YAML, a schema/params
|
|
38
57
|
// mismatch, a name naming a step factory that doesn't exist) becomes a
|
|
39
58
|
// problem here instead of a chain in the list above — loadRepoChains()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function phasesCommand(argv: string[]): number
|
|
1
|
+
export declare function phasesCommand(argv: string[]): Promise<number>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseCli } from "../../core/utils.js";
|
|
2
2
|
import { openTrace } from "./trace.js";
|
|
3
|
-
|
|
3
|
+
import { isInteractive } from "../ask.js";
|
|
4
|
+
export async function phasesCommand(argv) {
|
|
4
5
|
const { positionals, options, flags } = parseCli(argv, ["cwd", "config"], ["json"]);
|
|
5
6
|
if (positionals.length < 1) {
|
|
6
7
|
console.error("usage: spf phases <adw_id> [--cwd <dir>] [--config <path>] [--json]");
|
|
@@ -17,9 +18,22 @@ export function phasesCommand(argv) {
|
|
|
17
18
|
console.log(`no phases recorded for ${adwId}`);
|
|
18
19
|
return 1;
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const table = rows.map((p) => [
|
|
22
|
+
String(p.seq).padStart(2, "0"),
|
|
23
|
+
p.status === "success" ? "✓" : p.status === "fail" ? "✗" : "…",
|
|
24
|
+
p.name ?? "",
|
|
25
|
+
p.kind ?? "",
|
|
26
|
+
p.owner ?? "",
|
|
27
|
+
p.error ?? "",
|
|
28
|
+
]);
|
|
29
|
+
if (isInteractive()) {
|
|
30
|
+
const { renderTable } = await import("../ui/reports.js");
|
|
31
|
+
await renderTable(table, { rowColor: (i) => (rows[i].status === "fail" ? "red" : undefined) });
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
for (const [seq, marker, name, kind, owner, error] of table) {
|
|
35
|
+
console.log(`${seq} ${marker} ${name.padEnd(20)} ${kind.padEnd(9)} ${owner.padEnd(12)} ${error}`);
|
|
36
|
+
}
|
|
23
37
|
}
|
|
24
38
|
return 0;
|
|
25
39
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Shared by both `spf <chain> "..."` and `spf run <chain> "..."` — same dispatch. */
|
|
2
2
|
import * as paths from "../../core/paths.js";
|
|
3
|
+
import * as agents from "../../core/agents.js";
|
|
3
4
|
import { parseCli, resolvePrompt } from "../../core/utils.js";
|
|
4
5
|
import { runChain } from "../../chains/index.js";
|
|
5
6
|
import { isInteractive } from "../ask.js";
|
|
@@ -59,5 +60,25 @@ export async function dispatchChain(chain, argv) {
|
|
|
59
60
|
// rejecting anything but p0|p1|p2|p3 — so this stays a plain passthrough.
|
|
60
61
|
if (options["priority"] !== undefined)
|
|
61
62
|
chainOptions["priority"] = options["priority"];
|
|
63
|
+
// A live dashboard only ever makes sense for the one dispatch a human is
|
|
64
|
+
// actually watching — `spf watch`'s per-issue runs and `spf fanout`'s
|
|
65
|
+
// per-attempt runs go through `runChain`/`startRun` directly, never this
|
|
66
|
+
// function, so they're untouched. Loaded here (not left to `startRun`'s
|
|
67
|
+
// own `loadConfig` call moments later) purely to read the ceilings the
|
|
68
|
+
// dashboard displays — a second, cheap parse of the same small YAML, not
|
|
69
|
+
// a second source of truth: `startRun` still owns the real, validated
|
|
70
|
+
// config the run actually executes against.
|
|
71
|
+
if (isInteractive()) {
|
|
72
|
+
const { mountRunDashboard } = await import("../ui/run_dashboard.js");
|
|
73
|
+
const cfg = agents.loadConfig(ctx.config_paths);
|
|
74
|
+
const dashboard = mountRunDashboard({ maxCost: cfg.defaults.max_run_cost, maxTokens: cfg.defaults.max_run_tokens });
|
|
75
|
+
ctx.render_hooks = { sink: dashboard.sink, observer: dashboard.observer, pause: dashboard.pause, resume: dashboard.resume };
|
|
76
|
+
try {
|
|
77
|
+
return await runChain(chain, ctx, chainOptions);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await dashboard.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
62
83
|
return runChain(chain, ctx, chainOptions);
|
|
63
84
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function sessionsCommand(argv: string[]): number
|
|
1
|
+
export declare function sessionsCommand(argv: string[]): Promise<number>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseCli } from "../../core/utils.js";
|
|
2
2
|
import { openTrace } from "./trace.js";
|
|
3
|
-
|
|
3
|
+
import { isInteractive } from "../ask.js";
|
|
4
|
+
export async function sessionsCommand(argv) {
|
|
4
5
|
const { options, flags } = parseCli(argv, ["cwd", "config", "limit"], ["json"]);
|
|
5
6
|
const { db } = openTrace(options);
|
|
6
7
|
const limit = options["limit"] ? Number.parseInt(options["limit"], 10) : 20;
|
|
@@ -13,8 +14,15 @@ export function sessionsCommand(argv) {
|
|
|
13
14
|
console.log("no sessions yet");
|
|
14
15
|
return 0;
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
const table = rows.map((s) => [s.adw_id, s.status ?? "", s.started_at ?? "", (s.request ?? "").slice(0, 60)]);
|
|
18
|
+
if (isInteractive()) {
|
|
19
|
+
const { renderTable } = await import("../ui/reports.js");
|
|
20
|
+
await renderTable(table);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
for (const [adwId, status, startedAt, request] of table) {
|
|
24
|
+
console.log(`${adwId} ${status.padEnd(8)} ${startedAt} ${request}`);
|
|
25
|
+
}
|
|
18
26
|
}
|
|
19
27
|
return 0;
|
|
20
28
|
}
|
|
@@ -22,6 +22,7 @@ import { findChain, resolveRequiredAgents, runChain as runChainDef } from "../..
|
|
|
22
22
|
import { ReviewOutput } from "../../core/data_types.js";
|
|
23
23
|
import { SfDb } from "../../ui/server/db.js";
|
|
24
24
|
import { parseCli } from "../../core/utils.js";
|
|
25
|
+
import { isInteractive } from "../ask.js";
|
|
25
26
|
/** Kept well under Slack's own 2900-char slice on `detail` (see `slack_channel.ts`) — a reviewer can emit a lot of findings, but the PR body/notification only needs enough to tell a human whether to look closer. */
|
|
26
27
|
const MAX_REVIEW_DIGEST_CHARS = 1200;
|
|
27
28
|
/**
|
|
@@ -469,6 +470,23 @@ export async function watchCommand(argv) {
|
|
|
469
470
|
// Not a second `resolveAuthoringProvider()` call: that helper's own config
|
|
470
471
|
// validation already ran to produce `provider` itself.
|
|
471
472
|
const authoringProvider = isAuthoringProvider(provider) ? provider : null;
|
|
473
|
+
// Only for the one dispatch a human is actually watching a terminal for —
|
|
474
|
+
// `core/watch.ts` itself is untouched; this only ever swaps `deps.log`
|
|
475
|
+
// below and reads `state.inflight`/`state.refining` sizes after each
|
|
476
|
+
// `tick()`, exactly the seam the plan called for.
|
|
477
|
+
let dashboard;
|
|
478
|
+
if (isInteractive()) {
|
|
479
|
+
const { mountWatchDashboard } = await import("../ui/watch_dashboard.js");
|
|
480
|
+
dashboard = mountWatchDashboard({
|
|
481
|
+
repo: `${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo}`,
|
|
482
|
+
labelPrefix: cfg.watch.label_prefix,
|
|
483
|
+
chain: cfg.watch.chain,
|
|
484
|
+
concurrency: cfg.watch.concurrency,
|
|
485
|
+
refineChain: cfg.watch.refine.enabled ? cfg.watch.refine.chain : undefined,
|
|
486
|
+
refineConcurrency: cfg.watch.refine.enabled ? cfg.watch.refine.concurrency : undefined,
|
|
487
|
+
dryRun: Boolean(flags["dry-run"]),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
472
490
|
const deps = {
|
|
473
491
|
provider,
|
|
474
492
|
codeHost,
|
|
@@ -488,8 +506,11 @@ export async function watchCommand(argv) {
|
|
|
488
506
|
dryRun: Boolean(flags["dry-run"]),
|
|
489
507
|
runChain,
|
|
490
508
|
listChildren: authoringProvider ? (parent) => authoringProvider.listChildren(parent) : undefined,
|
|
491
|
-
log: (message) => console.log(message),
|
|
492
|
-
notify: (event) =>
|
|
509
|
+
log: (message) => (dashboard ? dashboard.log(message) : console.log(message)),
|
|
510
|
+
notify: (event) => {
|
|
511
|
+
notifier?.send(event); // unaffected either way — see mountWatchDashboard's own doc comment
|
|
512
|
+
dashboard?.mirrorNotify(event);
|
|
513
|
+
},
|
|
493
514
|
};
|
|
494
515
|
const state = createWatchState();
|
|
495
516
|
let stopping = false;
|
|
@@ -513,6 +534,7 @@ export async function watchCommand(argv) {
|
|
|
513
534
|
interrupt?.();
|
|
514
535
|
sigints++;
|
|
515
536
|
if (sigints >= 2) {
|
|
537
|
+
dashboard?.unmountNow(); // best-effort cursor-visibility restore before the abrupt exit — see WatchDashboard.unmountNow's own comment
|
|
516
538
|
console.error("\n[spf] watch second interrupt — exiting immediately, without draining");
|
|
517
539
|
releaseLock(lockPath);
|
|
518
540
|
process.exit(130);
|
|
@@ -523,9 +545,15 @@ export async function watchCommand(argv) {
|
|
|
523
545
|
};
|
|
524
546
|
process.on("SIGINT", stop);
|
|
525
547
|
process.on("SIGTERM", stop);
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
548
|
+
// The dashboard already renders this exact information as its own live
|
|
549
|
+
// header (repo/label/chain/concurrency/dry-run) — printing it again here
|
|
550
|
+
// would both duplicate it and interleave a raw console.log with an
|
|
551
|
+
// active Ink region.
|
|
552
|
+
if (!dashboard) {
|
|
553
|
+
console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}` +
|
|
554
|
+
(cfg.watch.refine.enabled ? ` refine "${cfg.watch.refine.chain}" concurrency ${cfg.watch.refine.concurrency}` : "") +
|
|
555
|
+
(flags["dry-run"] ? " (dry run)" : ""));
|
|
556
|
+
}
|
|
529
557
|
deps.notify({
|
|
530
558
|
kind: "watch_started",
|
|
531
559
|
level: "info",
|
|
@@ -538,15 +566,18 @@ export async function watchCommand(argv) {
|
|
|
538
566
|
});
|
|
539
567
|
try {
|
|
540
568
|
for (;;) {
|
|
569
|
+
dashboard?.setNextPollAt(null); // clears any stale countdown while a tick is actually running
|
|
541
570
|
await tick(deps, state);
|
|
571
|
+
dashboard?.setCounts(state.inflight.size, state.refining.size);
|
|
542
572
|
if (flags["once"] || stopping)
|
|
543
573
|
break;
|
|
574
|
+
dashboard?.setNextPollAt(Date.now() + cfg.watch.poll_ms);
|
|
544
575
|
await interruptibleSleep(cfg.watch.poll_ms);
|
|
545
576
|
if (stopping)
|
|
546
577
|
break;
|
|
547
578
|
}
|
|
548
579
|
while (state.inflight.size > 0) {
|
|
549
|
-
|
|
580
|
+
deps.log(`[spf] watch draining ${state.inflight.size} in-flight issue(s)...`); // deps.log already routes to the dashboard when one is mounted, console.log otherwise
|
|
550
581
|
await interruptibleSleep(1000);
|
|
551
582
|
if (stopping && sigints >= 2)
|
|
552
583
|
break; // stop() itself already exits on the 2nd signal; this is belt-and-suspenders
|
|
@@ -559,5 +590,6 @@ export async function watchCommand(argv) {
|
|
|
559
590
|
process.off("SIGINT", stop);
|
|
560
591
|
process.off("SIGTERM", stop);
|
|
561
592
|
releaseLock(lockPath);
|
|
593
|
+
await dashboard?.close();
|
|
562
594
|
}
|
|
563
595
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -151,7 +151,7 @@ export async function main() {
|
|
|
151
151
|
process.exitCode = await estimateCommand(rest);
|
|
152
152
|
return;
|
|
153
153
|
case "list":
|
|
154
|
-
process.exitCode = listCommand();
|
|
154
|
+
process.exitCode = await listCommand();
|
|
155
155
|
return;
|
|
156
156
|
case "init":
|
|
157
157
|
process.exitCode = await initCommand(rest);
|
|
@@ -177,10 +177,10 @@ export async function main() {
|
|
|
177
177
|
return;
|
|
178
178
|
}
|
|
179
179
|
case "sessions":
|
|
180
|
-
process.exitCode = sessionsCommand(rest);
|
|
180
|
+
process.exitCode = await sessionsCommand(rest);
|
|
181
181
|
return;
|
|
182
182
|
case "phases":
|
|
183
|
-
process.exitCode = phasesCommand(rest);
|
|
183
|
+
process.exitCode = await phasesCommand(rest);
|
|
184
184
|
return;
|
|
185
185
|
case "events":
|
|
186
186
|
process.exitCode = await eventsCommand(rest);
|