@gr8ful/spf 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/chains/context.d.ts +26 -0
  2. package/dist/chains/simple_sdlc.js +9 -0
  3. package/dist/chains/steps.d.ts +17 -0
  4. package/dist/chains/steps.js +24 -2
  5. package/dist/cli/ask.d.ts +13 -0
  6. package/dist/cli/ask.js +15 -1
  7. package/dist/cli/commands/doctor.js +43 -7
  8. package/dist/cli/commands/fanout.js +49 -5
  9. package/dist/cli/commands/init.js +7 -2
  10. package/dist/cli/commands/list.d.ts +1 -1
  11. package/dist/cli/commands/list.js +31 -12
  12. package/dist/cli/commands/phases.d.ts +1 -1
  13. package/dist/cli/commands/phases.js +18 -4
  14. package/dist/cli/commands/run.js +21 -0
  15. package/dist/cli/commands/sessions.d.ts +1 -1
  16. package/dist/cli/commands/sessions.js +11 -3
  17. package/dist/cli/commands/watch.js +50 -6
  18. package/dist/cli/index.js +3 -3
  19. package/dist/cli/ui/fanout_dashboard.d.ts +22 -0
  20. package/dist/cli/ui/fanout_dashboard.js +102 -0
  21. package/dist/cli/ui/ink_asker.d.ts +13 -0
  22. package/dist/cli/ui/ink_asker.js +247 -0
  23. package/dist/cli/ui/reports.d.ts +30 -0
  24. package/dist/cli/ui/reports.js +61 -0
  25. package/dist/cli/ui/run_dashboard.d.ts +15 -0
  26. package/dist/cli/ui/run_dashboard.js +131 -0
  27. package/dist/cli/ui/watch_dashboard.d.ts +22 -0
  28. package/dist/cli/ui/watch_dashboard.js +78 -0
  29. package/dist/core/console.d.ts +40 -1
  30. package/dist/core/console.js +25 -3
  31. package/dist/core/fanout.d.ts +9 -0
  32. package/dist/core/fanout.js +6 -2
  33. package/dist/core/issues/github_provider.js +16 -1
  34. package/dist/core/issues/jira_provider.js +13 -1
  35. package/dist/core/runner.d.ts +5 -1
  36. package/dist/core/runner.js +2 -1
  37. package/dist/core/session.d.ts +7 -1
  38. package/dist/core/session.js +5 -1
  39. package/package.json +6 -1
@@ -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) {
@@ -316,6 +316,23 @@ export declare function refine(opts?: {
316
316
  retries?: number;
317
317
  extraGates?: string[];
318
318
  }): Step;
319
+ /**
320
+ * `cli/commands/watch.ts`'s `runRefine()` reads BOTH `refine_publish.json`
321
+ * and `refine_questions.json` back, unconditionally, after this chain
322
+ * exits — it has no other way to learn what THIS run did, since a chain's
323
+ * return value is just an exit code. A resumed spec (`continue-refinement`)
324
+ * reruns this entire chain from `request` on, into the SAME deterministic
325
+ * `context_handoff_dir` a PRIOR round already wrote into. Without clearing
326
+ * the file this run is NOT about to write, a stale `refine_questions.json`
327
+ * from an earlier escalation round survives a LATER round's successful
328
+ * publish — `runRefine` then reports those old questions as if raised
329
+ * again THIS round, so `runSpec` escalates a second time even though real
330
+ * issues were already created on the tracker seconds earlier. Called
331
+ * before EITHER branch writes, so exactly one of the two files reflects
332
+ * this run when the phase returns, never a leftover from a previous one.
333
+ * `force: true` — a first-ever run has neither file yet, which is fine.
334
+ */
335
+ export declare function clearStaleRefineOutputFiles(contextHandoffDir: string): void;
319
336
  export declare function publishIssues(opts?: {
320
337
  description?: string;
321
338
  }): Step;
@@ -40,7 +40,7 @@
40
40
  * chain is a load-time problem the loader can report against a file and a
41
41
  * line, never a phase that blows up ten minutes into an unattended run.
42
42
  */
43
- import { writeFileSync } from "node:fs";
43
+ import { rmSync, writeFileSync } from "node:fs";
44
44
  import path from "node:path";
45
45
  import * as changesLib from "../core/changes.js";
46
46
  import * as gates from "../core/gates.js";
@@ -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
@@ -723,6 +723,27 @@ function parsePriorityOption(raw) {
723
723
  }
724
724
  return raw;
725
725
  }
726
+ /**
727
+ * `cli/commands/watch.ts`'s `runRefine()` reads BOTH `refine_publish.json`
728
+ * and `refine_questions.json` back, unconditionally, after this chain
729
+ * exits — it has no other way to learn what THIS run did, since a chain's
730
+ * return value is just an exit code. A resumed spec (`continue-refinement`)
731
+ * reruns this entire chain from `request` on, into the SAME deterministic
732
+ * `context_handoff_dir` a PRIOR round already wrote into. Without clearing
733
+ * the file this run is NOT about to write, a stale `refine_questions.json`
734
+ * from an earlier escalation round survives a LATER round's successful
735
+ * publish — `runRefine` then reports those old questions as if raised
736
+ * again THIS round, so `runSpec` escalates a second time even though real
737
+ * issues were already created on the tracker seconds earlier. Called
738
+ * before EITHER branch writes, so exactly one of the two files reflects
739
+ * this run when the phase returns, never a leftover from a previous one.
740
+ * `force: true` — a first-ever run has neither file yet, which is fine.
741
+ */
742
+ export function clearStaleRefineOutputFiles(contextHandoffDir) {
743
+ for (const name of ["refine_questions.json", "refine_publish.json"]) {
744
+ rmSync(path.join(contextHandoffDir, name), { force: true });
745
+ }
746
+ }
726
747
  export function publishIssues(opts = {}) {
727
748
  preflightDescription("publish", opts.description);
728
749
  const fn = async (run, state) => {
@@ -738,6 +759,7 @@ export function publishIssues(opts = {}) {
738
759
  owner: "tracker",
739
760
  description: opts.description ?? "Create the feature/story tree on the tracker, in dependency order, and link each to its parent",
740
761
  }), async (ph) => {
762
+ clearStaleRefineOutputFiles(run.context_handoff_dir);
741
763
  if (questions.length > 0) {
742
764
  writeFileSync(path.join(run.context_handoff_dir, "refine_questions.json"), JSON.stringify(questions, null, 2));
743
765
  ph.log({ escalated: questions.length });
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
- function maskForPrompt(value) {
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
- const result = await probeOtel(url, cfg.observability.otel.headers);
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(report.ok ? "\nspf doctor: clean" : "\nspf doctor: problems found above");
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
- console.log(`\nfanout ${chain.name} — ${n} attempt(s), base ${baseBranch}`);
376
- printTable(result.attempts);
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
- const asker = createAsker();
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
- for (const chain of chains) {
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
- console.log(`${chain.name.padEnd(width)} ${chain.phases}`);
26
- console.log(`${"".padEnd(width)} ${chain.describe}`);
27
- console.log(`${"".padEnd(width)} agents: ${agents}${suites.length ? ` · quality suites: ${suites.join(", ")}${suiteNote}` : ""}`);
28
- // `source` is undefined for every built-in — only a chain loaded from a
29
- // `.spf/chains/*.yaml` file carries one (see chains/repo_chains.ts).
30
- if (chain.source) {
31
- console.log(`${"".padEnd(width)} (repo: ${repoChainLabel(chain.source)})`);
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
- console.log();
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
- export function phasesCommand(argv) {
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
- for (const p of rows) {
21
- const marker = p.status === "success" ? "✓" : p.status === "fail" ? "✗" : "…";
22
- console.log(`${String(p.seq).padStart(2, "0")} ${marker} ${(p.name ?? "").padEnd(20)} ${(p.kind ?? "").padEnd(9)} ${(p.owner ?? "").padEnd(12)} ${p.error ?? ""}`);
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
  }
@@ -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
- export function sessionsCommand(argv) {
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
- for (const s of rows) {
17
- console.log(`${s.adw_id} ${(s.status ?? "").padEnd(8)} ${s.started_at} ${(s.request ?? "").slice(0, 60)}`);
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
  }