@gr8ful/spf 0.6.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/README.md +122 -27
- package/assets/prompts/refiner/system.md +11 -1
- package/assets/prompts/refiner/user.md +9 -3
- package/assets/skill/references/config.md +51 -13
- package/assets/templates/ts.spf.config.yaml +6 -2
- package/dist/chains/context.d.ts +26 -0
- package/dist/chains/simple_sdlc.js +9 -0
- package/dist/chains/steps.d.ts +0 -27
- package/dist/chains/steps.js +21 -2
- package/dist/cli/ask.d.ts +13 -0
- package/dist/cli/ask.js +15 -1
- package/dist/cli/commands/doctor.js +47 -9
- package/dist/cli/commands/fanout.js +49 -5
- package/dist/cli/commands/init.js +11 -3
- 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 +30 -2
- package/dist/cli/commands/sessions.d.ts +1 -1
- package/dist/cli/commands/sessions.js +11 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +93 -13
- package/dist/cli/index.js +4 -4
- package/dist/cli/interview.js +9 -5
- 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/data_types.d.ts +108 -5
- package/dist/core/data_types.js +50 -5
- package/dist/core/fanout.d.ts +9 -0
- package/dist/core/fanout.js +6 -2
- package/dist/core/gates.js +24 -1
- package/dist/core/issues/github_provider.d.ts +39 -5
- package/dist/core/issues/github_provider.js +103 -4
- package/dist/core/issues/jira_provider.d.ts +79 -12
- package/dist/core/issues/jira_provider.js +97 -2
- package/dist/core/issues/provider.d.ts +73 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- 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/dist/core/watch.d.ts +86 -3
- package/dist/core/watch.js +353 -29
- package/package.json +6 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type CheckIcon = "ok" | "fail" | "warn" | "info";
|
|
2
|
+
export interface CheckLine {
|
|
3
|
+
icon: CheckIcon;
|
|
4
|
+
name: string;
|
|
5
|
+
detail: string;
|
|
6
|
+
}
|
|
7
|
+
/** `spf doctor`'s check list — same one line per check as the plain-text path, just with the icon colored instead of a bare glyph. */
|
|
8
|
+
export declare function renderChecklist(lines: CheckLine[], footer: {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
message: string;
|
|
11
|
+
}): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* A column-aligned table from pre-formatted string cells — callers still own
|
|
14
|
+
* field selection/truncation (same as the `.padEnd()` code this replaces);
|
|
15
|
+
* this only owns column width and alignment, via Ink's own box layout
|
|
16
|
+
* instead of hand-computed pad strings. `rowColor(i)` colors an entire row
|
|
17
|
+
* (e.g. red for a failed phase) — `undefined` leaves the terminal default.
|
|
18
|
+
*/
|
|
19
|
+
export declare function renderTable(rows: string[][], opts?: {
|
|
20
|
+
rowColor?: (rowIndex: number) => string | undefined;
|
|
21
|
+
}): Promise<void>;
|
|
22
|
+
export interface ChainListEntry {
|
|
23
|
+
name: string;
|
|
24
|
+
phases: string;
|
|
25
|
+
describe: string;
|
|
26
|
+
agentsLine: string;
|
|
27
|
+
repoLabel?: string;
|
|
28
|
+
}
|
|
29
|
+
/** `spf list` — one styled block per chain (bold name, dimmed description) instead of the plain-text path's hand-padded columns. */
|
|
30
|
+
export declare function renderChainList(entries: ChainListEntry[], footer: string[]): Promise<void>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { render, Box, Text } from "ink";
|
|
3
|
+
/**
|
|
4
|
+
* Mounts, waits for the frame to actually flush, then unmounts — the frame
|
|
5
|
+
* it just wrote stays in scrollback exactly like `console.log` output
|
|
6
|
+
* would.
|
|
7
|
+
*
|
|
8
|
+
* A long, line-wrapping tree (doctor's ~25-line checklist reliably does
|
|
9
|
+
* this) makes Ink emit an initial paint, then a corrective full
|
|
10
|
+
* `clear-screen + clear-scrollback + home` repaint once it recalculates the
|
|
11
|
+
* content's true height — the raw byte stream this writes really does
|
|
12
|
+
* contain the whole tree twice, which reads as a bug if you inspect it
|
|
13
|
+
* with `grep`/`wc` the way a naive capture would. It isn't one: replayed
|
|
14
|
+
* through an actual terminal emulator (verified with `pyte`, not just
|
|
15
|
+
* eyeballed), the erase sequence takes effect exactly as a real terminal
|
|
16
|
+
* would apply it, and only the corrected repaint remains on screen —
|
|
17
|
+
* confirmed for both `render()`'s default mode and `incrementalRendering:
|
|
18
|
+
* true` (neither changes the outcome; this file uses the default).
|
|
19
|
+
* `waitUntilRenderFlush()` before `unmount()` is still worth keeping: it's
|
|
20
|
+
* the correct way to let that settle before tearing the instance down,
|
|
21
|
+
* even though skipping it turned out not to be the duplicate's actual
|
|
22
|
+
* cause.
|
|
23
|
+
*/
|
|
24
|
+
async function paint(node) {
|
|
25
|
+
// `interactive: true` overrides Ink's own `stdout.isTTY`/`is-in-ci`
|
|
26
|
+
// auto-detection — every caller here already reached this file only
|
|
27
|
+
// after its own `isInteractive()` check passed (which itself checks
|
|
28
|
+
// `!process.env["CI"]`), so Ink's redundant CI detection can only ever
|
|
29
|
+
// disagree by mistake. See `ink_asker.tsx`'s identical `render()` call
|
|
30
|
+
// for where disagreeing actually broke something (a CI-run test hung).
|
|
31
|
+
const app = render(node, { patchConsole: false, interactive: true });
|
|
32
|
+
await app.waitUntilRenderFlush();
|
|
33
|
+
app.unmount();
|
|
34
|
+
}
|
|
35
|
+
const ICON_GLYPH = { ok: "✓", fail: "✗", warn: "⚠", info: "ℹ" };
|
|
36
|
+
const ICON_COLOR = { ok: "green", fail: "red", warn: "yellow", info: "cyan" };
|
|
37
|
+
/** `spf doctor`'s check list — same one line per check as the plain-text path, just with the icon colored instead of a bare glyph. */
|
|
38
|
+
export async function renderChecklist(lines, footer) {
|
|
39
|
+
await paint(_jsxs(Box, { flexDirection: "column", children: [lines.map((line, i) => (_jsxs(Text, { children: [_jsx(Text, { color: ICON_COLOR[line.icon], children: ICON_GLYPH[line.icon] }), " ", line.name, ": ", line.detail] }, i))), _jsx(Text, { children: " " }), _jsx(Text, { color: footer.ok ? "green" : "red", children: footer.message })] }));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A column-aligned table from pre-formatted string cells — callers still own
|
|
43
|
+
* field selection/truncation (same as the `.padEnd()` code this replaces);
|
|
44
|
+
* this only owns column width and alignment, via Ink's own box layout
|
|
45
|
+
* instead of hand-computed pad strings. `rowColor(i)` colors an entire row
|
|
46
|
+
* (e.g. red for a failed phase) — `undefined` leaves the terminal default.
|
|
47
|
+
*/
|
|
48
|
+
export async function renderTable(rows, opts) {
|
|
49
|
+
if (rows.length === 0)
|
|
50
|
+
return;
|
|
51
|
+
const colCount = Math.max(...rows.map((r) => r.length));
|
|
52
|
+
const widths = Array.from({ length: colCount }, (_, c) => Math.max(...rows.map((r) => (r[c] ?? "").length)));
|
|
53
|
+
await paint(_jsx(Box, { flexDirection: "column", children: rows.map((row, i) => (_jsx(Box, { children: row.map((cell, c) => {
|
|
54
|
+
const isLast = c === row.length - 1;
|
|
55
|
+
return (_jsx(Box, { width: isLast ? undefined : widths[c], marginRight: isLast ? 0 : 2, children: _jsx(Text, { color: opts?.rowColor?.(i), children: cell }) }, c));
|
|
56
|
+
}) }, i))) }));
|
|
57
|
+
}
|
|
58
|
+
/** `spf list` — one styled block per chain (bold name, dimmed description) instead of the plain-text path's hand-padded columns. */
|
|
59
|
+
export async function renderChainList(entries, footer) {
|
|
60
|
+
await paint(_jsxs(Box, { flexDirection: "column", children: [entries.map((entry, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: "cyan", children: entry.name }), " ", entry.phases] }), _jsxs(Text, { dimColor: true, children: [" ", entry.describe] }), _jsxs(Text, { dimColor: true, children: [" ", entry.agentsLine] }), entry.repoLabel ? _jsxs(Text, { dimColor: true, children: [" (repo: ", entry.repoLabel, ")"] }) : null] }, i))), footer.map((line, i) => (_jsx(Text, { children: line }, i)))] }));
|
|
61
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RunObserver } from "../../core/console.ts";
|
|
2
|
+
export interface RunDashboard {
|
|
3
|
+
sink: (line: string) => void;
|
|
4
|
+
observer: RunObserver;
|
|
5
|
+
/** Detaches the live phase/spend slot and stops the elapsed-time ticker, then tears the Ink instance down entirely — call around a nested prompt (the sign-off gate) that needs the terminal to itself. Awaits the pending frame flush first: unmounting before Ink's initial commit for a fresh/updated tree has actually flushed can drop the whole accumulated `<Static>` history instead of leaving it in scrollback (confirmed empirically — the exact failure mode `reports.tsx`'s `paint()` guards against, just worse here: nothing printed at all instead of printing twice). */
|
|
6
|
+
pause(): Promise<void>;
|
|
7
|
+
/** Re-attaches after `pause()`. */
|
|
8
|
+
resume(): void;
|
|
9
|
+
/** Tears the Ink instance down. Call exactly once, when the run finishes (success or error) — same "call this in both the try and the catch" discipline `asker.close()` already follows in `commands/init.ts`. Same flush-before-unmount reasoning as `pause()`. */
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export declare function mountRunDashboard(opts: {
|
|
13
|
+
maxCost?: number;
|
|
14
|
+
maxTokens?: number;
|
|
15
|
+
}): RunDashboard;
|
|
@@ -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
|
+
}
|
package/dist/core/console.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/console.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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}`)}`);
|