@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
@@ -0,0 +1,131 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A live TTY view for `spf <chain>` / `spf run <chain>` — the phase-by-phase
4
+ * transcript `core/console.ts` already prints, in a `<Static>` history
5
+ * exactly as it always read, plus one live line below it: the currently
6
+ * running phase, an elapsed timer, and spend so far (against a ceiling,
7
+ * when `defaults.max_run_cost`/`max_run_tokens` configure one).
8
+ *
9
+ * Reached only through a dynamic `import()` from `cli/commands/run.ts`, and
10
+ * only when `isInteractive()` — a CI log or a piped `spf <chain>` never
11
+ * imports this file, and `core/console.ts`'s default `sink`
12
+ * (`console.log`) and `observer` (`null`) reproduce today's plain-line
13
+ * behavior exactly. `RunObserver`'s hooks are deliberately the only signal
14
+ * this reads — never a re-parse of the printed lines themselves (see its
15
+ * doc comment in `core/console.ts`).
16
+ *
17
+ * One persistent Ink instance for the whole run, same reasoning as
18
+ * `ink_asker.tsx`: nothing here calls `useInput`, so raw mode never
19
+ * engages and there's no per-line mount/unmount race to worry about — but
20
+ * `simple_sdlc.ts`'s human sign-off prompt DOES call `useInput` (through
21
+ * `createInkAsker()`/`createAsker()`) mid-run, and two live Ink instances
22
+ * cannot share one stdout. `pause()`/`resume()` exist for exactly that
23
+ * handoff — `cli/commands/run.ts` calls `pause()` before the chain can
24
+ * reach a sign-off prompt... except it can't know when that will happen
25
+ * either, so instead this dashboard is paused/resumed by the same
26
+ * `unattended`/interactive gate `decideSignoff` already uses: see
27
+ * `run.ts`'s comment at its mount site.
28
+ */
29
+ import { useEffect, useRef, useState } from "react";
30
+ import { render, Static, Box, Text } from "ink";
31
+ function formatUsd(n) {
32
+ return `$${n.toFixed(4)}`;
33
+ }
34
+ function Elapsed({ sinceMs }) {
35
+ const [now, setNow] = useState(() => Date.now());
36
+ useEffect(() => {
37
+ const id = setInterval(() => setNow(Date.now()), 1000);
38
+ return () => clearInterval(id);
39
+ }, []);
40
+ const seconds = Math.max(0, (now - sinceMs) / 1000);
41
+ return _jsxs(Text, { dimColor: true, children: [seconds.toFixed(0), "s"] });
42
+ }
43
+ function DashboardRoot(props) {
44
+ const [history, setHistory] = useState([]);
45
+ const [live, setLive] = useState(props.initialLive);
46
+ const [usage, setUsage] = useState(props.initialUsage);
47
+ const keyRef = useRef(0); // see ink_asker.tsx's identical comment — a ref, not state, so two pushes in one tick never collide
48
+ props.handleRef.current = {
49
+ pushHistory(text) {
50
+ const key = keyRef.current++;
51
+ setHistory((h) => [...h, { key, text }]);
52
+ },
53
+ setLivePhase: setLive,
54
+ setUsage: (tokens, cost) => setUsage({ tokens, cost }),
55
+ };
56
+ const overCost = props.maxCost !== undefined && usage.cost >= props.maxCost * 0.8;
57
+ const overTokens = props.maxTokens !== undefined && usage.tokens >= props.maxTokens * 0.8;
58
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (line) => _jsx(Text, { children: line.text }, line.key) }), live ? (_jsxs(Box, { children: [_jsx(Text, { color: "magenta", children: "\u25B8 running: " }), _jsx(Text, { bold: true, children: live.name }), _jsxs(Text, { dimColor: true, children: [" (", live.kind, " \u00B7 ", live.owner, ") "] }), _jsx(Elapsed, { sinceMs: live.startedAtMs })] })) : null, _jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["spend: ", usage.tokens.toLocaleString(), " tokens \u00B7 ", formatUsd(usage.cost), props.maxCost !== undefined ? ` / ${formatUsd(props.maxCost)}` : "", props.maxTokens !== undefined ? ` (of ${props.maxTokens.toLocaleString()} tokens)` : ""] }), overCost || overTokens ? _jsx(Text, { color: "yellow", children: " \u2014 approaching ceiling" }) : null] })] }));
59
+ }
60
+ export function mountRunDashboard(opts) {
61
+ const handleRef = { current: null };
62
+ let currentPhase = null;
63
+ let currentUsage = { tokens: 0, cost: 0 };
64
+ // `undefined` while paused: Ink refuses a second `render()` on the same
65
+ // stdout while a prior instance is still live (the same restriction
66
+ // `ink_asker.tsx`'s confirm-timeout comment names), and the sign-off
67
+ // prompt's own Ink instance needs the terminal to itself. `pause()`
68
+ // fully unmounts rather than just hiding state; `resume()` mounts a
69
+ // fresh instance seeded with whatever `currentPhase`/`currentUsage`
70
+ // were at the moment of the handoff, so the live line picks up exactly
71
+ // where it left off instead of resetting to "nothing running".
72
+ let app;
73
+ function mount() {
74
+ app = render(_jsx(DashboardRoot, { maxCost: opts.maxCost, maxTokens: opts.maxTokens, handleRef: handleRef, initialLive: currentPhase, initialUsage: currentUsage }),
75
+ // `interactive: true` overrides Ink's own CI auto-detection — see
76
+ // `ink_asker.tsx`'s identical `render()` call for why: the caller
77
+ // (`commands/run.ts`) only reaches this file after its own
78
+ // `isInteractive()` check has already passed.
79
+ { patchConsole: false, interactive: true });
80
+ }
81
+ mount();
82
+ const sink = (line) => {
83
+ // Never silently dropped, even while paused: falls back to the exact
84
+ // default `Console` would use on its own (`console.log`) rather than
85
+ // lose the printed half of "one narrative, two destinations" during
86
+ // the handoff window.
87
+ if (!app) {
88
+ console.log(line);
89
+ return;
90
+ }
91
+ handleRef.current?.pushHistory(line);
92
+ };
93
+ const observer = {
94
+ onPhaseStart(phase) {
95
+ currentPhase = { name: phase.params.name, kind: phase.params.kind, owner: phase.params.owner, startedAtMs: Date.now() };
96
+ // `currentPhase`/`currentUsage` above are updated regardless of `app`
97
+ // so a `resume()` after this always seeds the fresh mount correctly,
98
+ // even if the phase/usage change itself happened while paused.
99
+ if (app)
100
+ handleRef.current?.setLivePhase(currentPhase);
101
+ },
102
+ onPhaseEnd() {
103
+ currentPhase = null;
104
+ if (app)
105
+ handleRef.current?.setLivePhase(null);
106
+ },
107
+ onUsage(tokens, cost) {
108
+ currentUsage = { tokens, cost };
109
+ if (app)
110
+ handleRef.current?.setUsage(tokens, cost);
111
+ },
112
+ };
113
+ async function unmount() {
114
+ if (!app)
115
+ return;
116
+ const current = app;
117
+ await current.waitUntilRenderFlush();
118
+ current.unmount();
119
+ app = undefined;
120
+ }
121
+ return {
122
+ sink,
123
+ observer,
124
+ pause: unmount,
125
+ resume() {
126
+ if (!app)
127
+ mount();
128
+ },
129
+ close: unmount,
130
+ };
131
+ }
@@ -0,0 +1,22 @@
1
+ import type { NotifyEvent } from "../../core/notify/channel.ts";
2
+ export interface WatchDashboard {
3
+ log: (message: string) => void;
4
+ /** Mirrors a one-line summary into the same log history — delivery to the real `Notifier` is unaffected; call this alongside it, never instead of it. */
5
+ mirrorNotify: (event: NotifyEvent) => void;
6
+ setCounts: (inflight: number, refining: number) => void;
7
+ /** `null` while a tick is running or the daemon is draining — clears the countdown instead of showing a stale or negative one. */
8
+ setNextPollAt: (deadlineMs: number | null) => void;
9
+ /** Tears the Ink instance down. Call once, in the same `finally` `commands/watch.ts` already releases its lockfile in. */
10
+ close(): Promise<void>;
11
+ /** Synchronous, no flush-wait — for `stop()`'s second-Ctrl-C `process.exit(130)` path, same reasoning as `fanout_dashboard.tsx`'s `unmountNow()`. */
12
+ unmountNow(): void;
13
+ }
14
+ export declare function mountWatchDashboard(opts: {
15
+ repo: string;
16
+ labelPrefix: string;
17
+ chain: string;
18
+ concurrency: number;
19
+ refineChain?: string;
20
+ refineConcurrency?: number;
21
+ dryRun: boolean;
22
+ }): WatchDashboard;
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A live status line for `spf watch` — the exact same log lines
4
+ * `WatchDeps.log()` already prints (the daemon banner, each tick's
5
+ * claim/finish lines), in a `<Static>` history, plus one live line below
6
+ * it: how many issues are in flight, how many are being refined, and a
7
+ * countdown to the next poll.
8
+ *
9
+ * `core/watch.ts` is untouched — this only ever swaps `cli/commands/
10
+ * watch.ts`'s own `WatchDeps.log` callback and reads `WatchRunState`'s
11
+ * public `.size` after each `tick()` returns, exactly the seam the plan
12
+ * called for ("no core changes"). `notify` keeps going to the real
13
+ * `Notifier` unchanged (Slack/Teams/webhook delivery has nothing to do
14
+ * with terminal rendering); this only ALSO mirrors a one-line summary of
15
+ * each event into the same log history, for visibility.
16
+ *
17
+ * One persistent Ink instance for the whole `spf watch` invocation — same
18
+ * shape as `run_dashboard.tsx`/`fanout_dashboard.tsx`. `spf watch`'s own
19
+ * SIGINT handling exits immediately on a second Ctrl-C (`process.exit(130)`
20
+ * in `commands/watch.ts`'s `stop()`), so `unmountNow()` exists here for the
21
+ * same best-effort, no-flush-wait reason `fanout_dashboard.tsx`'s does.
22
+ */
23
+ import { useEffect, useRef, useState } from "react";
24
+ import { render, Static, Box, Text } from "ink";
25
+ function Countdown({ deadlineMs }) {
26
+ const [now, setNow] = useState(() => Date.now());
27
+ useEffect(() => {
28
+ const id = setInterval(() => setNow(Date.now()), 1000);
29
+ return () => clearInterval(id);
30
+ }, []);
31
+ const remaining = Math.max(0, Math.round((deadlineMs - now) / 1000));
32
+ return _jsxs(Text, { dimColor: true, children: ["next poll in ", remaining, "s"] });
33
+ }
34
+ function WatchRoot(props) {
35
+ const [log, setLog] = useState([]);
36
+ const [counts, setCounts] = useState({ inflight: 0, refining: 0 });
37
+ const [nextPollAt, setNextPollAt] = useState(null);
38
+ const keyRef = useRef(0); // a ref, not state — see ink_asker.tsx's identical comment on why two pushes in one tick must not collide
39
+ props.handleRef.current = {
40
+ pushLog(text) {
41
+ const key = keyRef.current++;
42
+ setLog((prev) => [...prev, { key, text }]);
43
+ },
44
+ setCounts: (inflight, refining) => setCounts({ inflight, refining }),
45
+ setNextPollAt,
46
+ };
47
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: log, children: (line) => _jsx(Text, { children: line.text }, line.key) }), _jsx(Box, { children: _jsxs(Text, { children: [props.repo, " ", _jsxs(Text, { dimColor: true, children: ["label \"", props.labelPrefix, ":*\" \u00B7 chain \"", props.chain, "\" \u00B7 concurrency ", props.concurrency] }), props.refineChain ? (_jsxs(Text, { dimColor: true, children: [" ", "\u00B7 refine \"", props.refineChain, "\" concurrency ", props.refineConcurrency] })) : null, props.dryRun ? _jsx(Text, { color: "yellow", children: " (dry run)" }) : null] }) }), _jsxs(Box, { children: [_jsxs(Text, { children: [_jsxs(Text, { color: counts.inflight > 0 ? "cyan" : undefined, children: [counts.inflight, " in flight"] }), counts.refining > 0 ? _jsxs(Text, { color: "magenta", children: [", ", counts.refining, " refining"] }) : null, " · "] }), nextPollAt !== null ? _jsx(Countdown, { deadlineMs: nextPollAt }) : _jsx(Text, { dimColor: true, children: "ticking\u2026" })] })] }));
48
+ }
49
+ export function mountWatchDashboard(opts) {
50
+ const handleRef = { current: null };
51
+ const app = render(_jsx(WatchRoot, { repo: opts.repo, labelPrefix: opts.labelPrefix, chain: opts.chain, concurrency: opts.concurrency, refineChain: opts.refineChain, refineConcurrency: opts.refineConcurrency, dryRun: opts.dryRun, handleRef: handleRef }),
52
+ // `interactive: true` overrides Ink's own CI auto-detection — see
53
+ // `ink_asker.tsx`'s identical `render()` call for why: the caller
54
+ // (`commands/watch.ts`) only reaches this file after its own
55
+ // `isInteractive()` check has already passed.
56
+ { patchConsole: false, interactive: true });
57
+ return {
58
+ log(message) {
59
+ handleRef.current?.pushLog(message);
60
+ },
61
+ mirrorNotify(event) {
62
+ handleRef.current?.pushLog(`[notify] ${event.title}${event.detail ? ` — ${event.detail}` : ""}`);
63
+ },
64
+ setCounts(inflight, refining) {
65
+ handleRef.current?.setCounts(inflight, refining);
66
+ },
67
+ setNextPollAt(deadlineMs) {
68
+ handleRef.current?.setNextPollAt(deadlineMs);
69
+ },
70
+ async close() {
71
+ await app.waitUntilRenderFlush();
72
+ app.unmount();
73
+ },
74
+ unmountNow() {
75
+ app.unmount();
76
+ },
77
+ };
78
+ }
@@ -15,6 +15,21 @@ export declare function paint(style: string, text: string): string;
15
15
  interface Tracer {
16
16
  event(record: EventRecord): string;
17
17
  }
18
+ /**
19
+ * A run-scoped hook for a live TTY view (`cli/ui/run_dashboard.tsx`) to
20
+ * track phase state and spend without re-parsing `Console`'s formatted
21
+ * lines — deliberately narrow (phase transitions + usage, not every method
22
+ * below) since that's the whole surface a "which phase is running, how
23
+ * long, how much so far" dashboard needs. Every hook is optional and every
24
+ * `Console` call site that doesn't pass one behaves exactly as before this
25
+ * existed.
26
+ */
27
+ export interface RunObserver {
28
+ onPhaseStart?(phase: Phase): void;
29
+ onPhaseEnd?(phase: Phase, seconds: number): void;
30
+ onUsage?(tokens: number, cost: number): void;
31
+ onSessionEnd?(ok: boolean): void;
32
+ }
18
33
  /** Bound to one run's tracer. Reachable as `run.console` everywhere. */
19
34
  export declare class Console {
20
35
  private tracer;
@@ -23,6 +38,17 @@ export declare class Console {
23
38
  private notifier;
24
39
  /** The CLI chain name (`"plan-build-test"`), for a notification's title — see session.ts. */
25
40
  private chainName;
41
+ /**
42
+ * Where a printed line actually goes — defaults to `console.log`, exactly
43
+ * the prior behavior. `cli/commands/run.ts` is the only caller that ever
44
+ * passes something else: on a real TTY, a sink that feeds a live Ink
45
+ * dashboard's scrollback instead of writing straight to stdout. The
46
+ * tracer side of `emit()` below is UNCHANGED either way — this only
47
+ * repoints the print half of "one narrative, two destinations".
48
+ */
49
+ private sink;
50
+ /** See `RunObserver`'s own doc comment. `null` outside an interactive `cli/commands/run.ts` dispatch. */
51
+ private observer;
26
52
  private phaseId;
27
53
  private phaseName;
28
54
  private results;
@@ -31,7 +57,18 @@ export declare class Console {
31
57
  /** `null` when notifications are off — every call site below guards with `?.`. */
32
58
  notifier?: Notifier | null,
33
59
  /** The CLI chain name (`"plan-build-test"`), for a notification's title — see session.ts. */
34
- chainName?: string);
60
+ chainName?: string,
61
+ /**
62
+ * Where a printed line actually goes — defaults to `console.log`, exactly
63
+ * the prior behavior. `cli/commands/run.ts` is the only caller that ever
64
+ * passes something else: on a real TTY, a sink that feeds a live Ink
65
+ * dashboard's scrollback instead of writing straight to stdout. The
66
+ * tracer side of `emit()` below is UNCHANGED either way — this only
67
+ * repoints the print half of "one narrative, two destinations".
68
+ */
69
+ sink?: (line: string) => void,
70
+ /** See `RunObserver`'s own doc comment. `null` outside an interactive `cli/commands/run.ts` dispatch. */
71
+ observer?: RunObserver | null);
35
72
  private emit;
36
73
  sessionStarted(adwId: string, engineer: string): void;
37
74
  sessionFinished(ok: boolean, tokens: number, cost: number, dbPath: string): void;
@@ -39,6 +76,8 @@ export declare class Console {
39
76
  phaseEnded(phase: Phase, seconds: number): void;
40
77
  /** Free-form detail inside the current phase — what `ph.log()` recorded. */
41
78
  note(message: string): void;
79
+ /** `Run.addUsage()`'s only hook into `Console` — the running total lives on `Run`, not here, so this just forwards it to the observer. No line prints for this on its own; the totals already show up in `sessionFinished`'s panel. */
80
+ notifyUsage(tokens: number, cost: number): void;
42
81
  agentStarted(name: string, model: string, sessionId: string): void;
43
82
  agentFinished(name: string, tokens: number, cost: number): void;
44
83
  retry(name: string, attempt: number, limit: number, reason: string): void;
@@ -47,6 +47,8 @@ export class Console {
47
47
  adwId;
48
48
  notifier;
49
49
  chainName;
50
+ sink;
51
+ observer;
50
52
  phaseId = ""; // current lane — log events attach to it
51
53
  phaseName = "";
52
54
  results = []; // phase statuses, for the summary
@@ -55,15 +57,28 @@ export class Console {
55
57
  /** `null` when notifications are off — every call site below guards with `?.`. */
56
58
  notifier = null,
57
59
  /** The CLI chain name (`"plan-build-test"`), for a notification's title — see session.ts. */
58
- chainName = "adw") {
60
+ chainName = "adw",
61
+ /**
62
+ * Where a printed line actually goes — defaults to `console.log`, exactly
63
+ * the prior behavior. `cli/commands/run.ts` is the only caller that ever
64
+ * passes something else: on a real TTY, a sink that feeds a live Ink
65
+ * dashboard's scrollback instead of writing straight to stdout. The
66
+ * tracer side of `emit()` below is UNCHANGED either way — this only
67
+ * repoints the print half of "one narrative, two destinations".
68
+ */
69
+ sink = console.log,
70
+ /** See `RunObserver`'s own doc comment. `null` outside an interactive `cli/commands/run.ts` dispatch. */
71
+ observer = null) {
59
72
  this.tracer = tracer;
60
73
  this.adwId = adwId;
61
74
  this.notifier = notifier;
62
75
  this.chainName = chainName;
76
+ this.sink = sink;
77
+ this.observer = observer;
63
78
  }
64
79
  // ── the one helper: print AND trace, always together ────────────────────
65
80
  emit(line, level = "info") {
66
- console.log(line);
81
+ this.sink(line);
67
82
  this.tracer.event(makeEventRecord({
68
83
  adw_id: this.adwId,
69
84
  phase_id: this.phaseId,
@@ -101,7 +116,8 @@ export class Console {
101
116
  ` ${paint("dim", "next")} ${paint("bold", `just phases ${this.adwId}`)}`,
102
117
  ];
103
118
  const rendered = panel(rows, "ADW complete", ok ? "green" : "red");
104
- console.log(rendered);
119
+ this.sink(rendered);
120
+ this.observer?.onSessionEnd?.(ok);
105
121
  const plain = `session ${this.adwId} ${ok ? "success" : "fail"} · ${passed}/${this.results.length} phases · ${tokens.toLocaleString()} tokens · $${cost.toFixed(4)}`;
106
122
  this.tracer.event(makeEventRecord({
107
123
  adw_id: this.adwId,
@@ -133,10 +149,12 @@ export class Console {
133
149
  if (p.description)
134
150
  line += ` ${paint("dim", clip(p.description))}`;
135
151
  this.emit(line);
152
+ this.observer?.onPhaseStart?.(phase);
136
153
  }
137
154
  phaseEnded(phase, seconds) {
138
155
  const ok = phase.status === "success";
139
156
  this.results.push(phase.status);
157
+ this.observer?.onPhaseEnd?.(phase, seconds);
140
158
  let line = ` ${ok ? paint("green", "✓") : paint("red", "✗")} ${phase.params.name} ${paint("dim", `${seconds.toFixed(1)}s`)}`;
141
159
  if (!ok && phase.error)
142
160
  line += ` ${paint("red", clip(phase.error))}`;
@@ -161,6 +179,10 @@ export class Console {
161
179
  note(message) {
162
180
  this.emit(` ${paint("dim", `· ${clip(message)}`)}`);
163
181
  }
182
+ /** `Run.addUsage()`'s only hook into `Console` — the running total lives on `Run`, not here, so this just forwards it to the observer. No line prints for this on its own; the totals already show up in `sessionFinished`'s panel. */
183
+ notifyUsage(tokens, cost) {
184
+ this.observer?.onUsage?.(tokens, cost);
185
+ }
164
186
  // ── agents ──────────────────────────────────────────────────────────────
165
187
  agentStarted(name, model, sessionId) {
166
188
  this.emit(` ${paint("magenta", "▸")} ${name} ${paint("dim", model)} ${paint("dim", `session ${sessionId}`)}`);
@@ -214,6 +214,15 @@ export interface FanoutDeps {
214
214
  * ABORT GRANULARITY note.
215
215
  */
216
216
  firstSuccess?: boolean;
217
+ /**
218
+ * Fires once per attempt the moment it settles (success, fail, error, or
219
+ * skipped) — the same record that lands in the final `attempts` array, just
220
+ * as it happens instead of only once every worker has finished. Purely
221
+ * observational: nothing here changes selection or cleanup. `cli/commands/
222
+ * fanout.ts` uses it to update a live results table row by row on a TTY;
223
+ * omitted (the default) everywhere else, including every test.
224
+ */
225
+ onAttempt?: (attempt: FanoutAttempt) => void;
217
226
  }
218
227
  /**
219
228
  * Run `n` attempts of one prompt, select deterministically, clean up the
@@ -284,10 +284,14 @@ export async function runBestOf(deps) {
284
284
  return;
285
285
  if (decided) {
286
286
  deps.log(`fanout: attempt ${index} skipped — an earlier attempt already succeeded`);
287
- attempts.push(skipped(index));
287
+ const attempt = skipped(index);
288
+ attempts.push(attempt);
289
+ deps.onAttempt?.(attempt);
288
290
  continue;
289
291
  }
290
- attempts.push(await runOne(index));
292
+ const attempt = await runOne(index);
293
+ attempts.push(attempt);
294
+ deps.onAttempt?.(attempt);
291
295
  }
292
296
  };
293
297
  const workers = Array.from({ length: Math.max(1, Math.min(deps.concurrency, deps.n)) }, () => worker());
@@ -12,7 +12,22 @@ const STATES = [
12
12
  "continue-refinement",
13
13
  "spec-in-progress",
14
14
  ];
15
- const MARKER_RE = /<!--\s*spf-watch:\s*(\{.*?\})\s*-->/s;
15
+ /**
16
+ * GREEDY capture, not lazy: `WatchMarker.feedback` nests its own object
17
+ * (`{rounds, asked_at}`), so a lazy `\{.*?\}` stops at the FIRST `}` it
18
+ * finds — which is `feedback`'s own closing brace, not the marker's outer
19
+ * one — capturing an unbalanced, unparseable fragment the instant a spec
20
+ * escalates even once. `JSON.parse` then throws, is silently caught, and
21
+ * `readMarker` returns `null` forever after that point: the round counter
22
+ * can never advance past 1, a fresh marker comment gets posted every tick
23
+ * instead of the existing one being edited in place, and — worst of all —
24
+ * `buildSpecPrompt` never receives a valid `asked_at`, so a human's answer
25
+ * is never recognized as one, just dumped into undifferentiated "earlier
26
+ * discussion." A greedy match backtracks from the END of the string to find
27
+ * the LAST `}` — the marker's own outer brace — which is exactly right
28
+ * here since nothing meaningful follows it but the closing `-->`.
29
+ */
30
+ const MARKER_RE = /<!--\s*spf-watch:\s*(\{.*\})\s*-->/s;
16
31
  /** `listByLabel`'s pagination bound — see its own doc comment. */
17
32
  const MAX_LIST_PAGES = 5;
18
33
  /** The refine lane's leaf/container taxonomy — see `data_types.ts`'s `RefinedIssueSchema.kind`. Not a `WatchState`: these never appear on the left of a `transition()` call, so `transition()` never strips them. */
@@ -11,7 +11,19 @@ const STATES = [
11
11
  "continue-refinement",
12
12
  "spec-in-progress",
13
13
  ];
14
- const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*?\})/s;
14
+ /**
15
+ * GREEDY capture, not lazy — same fix and same reasoning as
16
+ * `github_provider.ts`'s own `MARKER_RE`: `WatchMarker.feedback` nests its
17
+ * own object, so a lazy `\{.*?\}` stops at `feedback`'s own closing brace
18
+ * instead of the marker's outer one, producing unparseable JSON the moment
19
+ * a spec escalates even once — `readMarker` then returns `null` forever,
20
+ * `writeMarker` can never find the existing marker to edit in place (a new
21
+ * one gets posted every tick instead), and `buildSpecPrompt` never sees a
22
+ * valid `asked_at`, so a human's answer never gets flagged as one. Greedy
23
+ * backtracks from the end of the comment body to the true last `}` — safe
24
+ * here because nothing follows the JSON in this format at all.
25
+ */
26
+ const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*\})/s;
15
27
  function toAdf(text) {
16
28
  return {
17
29
  type: "doc",
@@ -9,7 +9,7 @@
9
9
  * parsed envelope + green gates, enforced inside ph.call).
10
10
  */
11
11
  import { type GitHandle } from "./git_helper.ts";
12
- import { Console } from "./console.ts";
12
+ import { Console, type RunObserver } from "./console.ts";
13
13
  import { Tracer } from "./tracer.ts";
14
14
  import { type AgentCall, type EnvelopeBase, type Phase, type PhaseParams, type SFConfig } from "./data_types.ts";
15
15
  import type { TierResolution } from "./tiering.ts";
@@ -38,6 +38,10 @@ export interface RunInit {
38
38
  chainName?: string;
39
39
  /** `null`/omitted when notifications are off (the default) or no channel resolved. */
40
40
  notifier?: Notifier | null;
41
+ /** Where a printed line goes — see `Console`'s own constructor doc. Omitted everywhere except an interactive `cli/commands/run.ts` dispatch. */
42
+ sink?: (line: string) => void;
43
+ /** See `RunObserver`'s doc comment (`core/console.ts`). `null`/omitted outside an interactive dispatch. */
44
+ observer?: RunObserver | null;
41
45
  }
42
46
  export declare class Run {
43
47
  cfg: SFConfig;
@@ -78,7 +78,7 @@ export class Run {
78
78
  this.adw_id = init.adwId;
79
79
  this.tracer = init.tracer;
80
80
  this.notify = init.notifier ?? null;
81
- this.console = new Console(init.tracer, init.adwId, this.notify, init.chainName || "adw");
81
+ this.console = new Console(init.tracer, init.adwId, this.notify, init.chainName || "adw", init.sink, init.observer);
82
82
  this.engineer = init.engineer;
83
83
  this.seq = init.tracer.maxPhaseSeq(init.adwId);
84
84
  this.repo_root = init.repoRoot;
@@ -100,6 +100,7 @@ export class Run {
100
100
  this.tokens += tokens;
101
101
  this.cost += cost;
102
102
  this.tracer.sessionAddUsage(this.adw_id, tokens, cost);
103
+ this.console.notifyUsage(this.tokens, this.cost);
103
104
  }
104
105
  // ── the phase primitive ─────────────────────────────────────────────────
105
106
  async phase(params, fn) {
@@ -6,6 +6,7 @@
6
6
  * minted and printed so the next ADW can pick it up.
7
7
  */
8
8
  import { Run } from "./runner.ts";
9
+ import type { RunObserver } from "./console.ts";
9
10
  import type { SFConfig } from "./data_types.ts";
10
11
  /**
11
12
  * The symmetric teardown for `finalizeWhenKilled()` above: drop `adwId` from
@@ -43,4 +44,9 @@ export declare function activeRunIdsForTest(): string[];
43
44
  * longer a `process.argv[1]` basename that means anything. Direct callers
44
45
  * that have no chain of their own fall back to `"adw"`.
45
46
  */
46
- export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: string, chainName?: string): Run;
47
+ export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: string, chainName?: string,
48
+ /** See `RunObserver`'s doc comment (`core/console.ts`). Omitted for every caller except an interactive `cli/commands/run.ts` dispatch — a `spf watch` per-issue run, `spf fanout`'s per-attempt runs, and every test all continue to build a plain, unobserved `Console`. */
49
+ renderHooks?: {
50
+ sink?: (line: string) => void;
51
+ observer?: RunObserver | null;
52
+ }): Run;
@@ -138,7 +138,9 @@ export function activeRunIdsForTest() {
138
138
  * longer a `process.argv[1]` basename that means anything. Direct callers
139
139
  * that have no chain of their own fall back to `"adw"`.
140
140
  */
141
- export function ensure(cfg, adwId, cwd, chainName) {
141
+ export function ensure(cfg, adwId, cwd, chainName,
142
+ /** See `RunObserver`'s doc comment (`core/console.ts`). Omitted for every caller except an interactive `cli/commands/run.ts` dispatch — a `spf watch` per-issue run, `spf fanout`'s per-attempt runs, and every test all continue to build a plain, unobserved `Console`. */
143
+ renderHooks) {
142
144
  const id = adwId || newId(8);
143
145
  const anchor = paths.resolveAnchor(cwd);
144
146
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
@@ -161,6 +163,8 @@ export function ensure(cfg, adwId, cwd, chainName) {
161
163
  dataDir: dataPaths.data_dir,
162
164
  chainName: chainName || "adw",
163
165
  notifier: resolveNotifier(cfg),
166
+ sink: renderHooks?.sink,
167
+ observer: renderHooks?.observer,
164
168
  });
165
169
  const scriptPath = process.argv[1] || "adw";
166
170
  tracer.sessionStart(id, run.engineer, chainName || "adw");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,8 +44,11 @@
44
44
  "@earendil-works/pi-ai": "0.83.0",
45
45
  "@flue/runtime": "2.0.3",
46
46
  "@hono/node-server": "^2.1.1",
47
+ "@inkjs/ui": "^2.0.0",
47
48
  "@valibot/to-json-schema": "^1.7.1",
48
49
  "hono": "^4.13.3",
50
+ "ink": "^7.1.1",
51
+ "react": "^19.2.8",
49
52
  "valibot": "^1.4.2",
50
53
  "yaml": "^2.5.1"
51
54
  },
@@ -54,6 +57,8 @@
54
57
  },
55
58
  "devDependencies": {
56
59
  "@types/node": "^22.10.0",
60
+ "@types/react": "^19.2.18",
61
+ "ink-testing-library": "^4.0.0",
57
62
  "lefthook": "^2.1.10",
58
63
  "typescript": "^7.0.2"
59
64
  }