fullstackgtm 0.43.0 → 0.45.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/CHANGELOG.md +378 -1
- package/README.md +37 -14
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +80 -0
- package/dist/cli/auth.js +500 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +373 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +788 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +344 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +609 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +454 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +70 -0
- package/dist/cli/shared.js +331 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +121 -0
- package/dist/cli/ui.js +375 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +104 -4556
- package/dist/connector.d.ts +15 -0
- package/dist/connector.js +35 -0
- package/dist/connectors/hubspot.d.ts +3 -1
- package/dist/connectors/hubspot.js +10 -3
- package/dist/connectors/outboxChannel.d.ts +64 -0
- package/dist/connectors/outboxChannel.js +170 -0
- package/dist/connectors/prospectSources.d.ts +22 -0
- package/dist/connectors/prospectSources.js +43 -0
- package/dist/connectors/salesforce.d.ts +3 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/signalSources.d.ts +105 -0
- package/dist/connectors/signalSources.js +264 -0
- package/dist/connectors/theirstack.d.ts +84 -0
- package/dist/connectors/theirstack.js +125 -0
- package/dist/icp.d.ts +52 -4
- package/dist/icp.js +112 -35
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +3 -0
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/judgeEval.d.ts +7 -0
- package/dist/judgeEval.js +8 -1
- package/dist/marketClassify.d.ts +2 -0
- package/dist/marketClassify.js +7 -1
- package/dist/mcp-bin.js +2 -2
- package/dist/mcp.js +259 -166
- package/dist/runReport.d.ts +26 -0
- package/dist/runReport.js +15 -0
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +272 -5
- package/dist/signals.d.ts +54 -0
- package/dist/signals.js +64 -0
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/tam.d.ts +225 -0
- package/dist/tam.js +470 -0
- package/dist/types.d.ts +11 -0
- package/docs/api.md +91 -11
- package/docs/outbox-format.md +92 -0
- package/docs/recipes.md +37 -0
- package/docs/roadmap-to-1.0.md +31 -1
- package/docs/signal-spool-format.md +175 -0
- package/docs/tam.md +195 -0
- package/llms.txt +83 -11
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +4 -3
- package/src/audit.ts +27 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +549 -0
- package/src/cli/call.ts +398 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +885 -0
- package/src/cli/fix.ts +372 -0
- package/src/cli/help.ts +664 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +523 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +375 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +426 -0
- package/src/cli.ts +103 -5170
- package/src/connector.ts +46 -0
- package/src/connectors/hubspot.ts +14 -2
- package/src/connectors/outboxChannel.ts +202 -0
- package/src/connectors/prospectSources.ts +57 -0
- package/src/connectors/salesforce.ts +18 -2
- package/src/connectors/signalSources.ts +314 -0
- package/src/connectors/theirstack.ts +170 -0
- package/src/icp.ts +120 -34
- package/src/index.ts +32 -0
- package/src/init.ts +3 -0
- package/src/judge.ts +7 -0
- package/src/judgeEval.ts +8 -1
- package/src/marketClassify.ts +8 -1
- package/src/mcp-bin.ts +2 -2
- package/src/mcp.ts +130 -57
- package/src/runReport.ts +39 -0
- package/src/schedule.ts +330 -7
- package/src/signals.ts +90 -0
- package/src/spoolFiles.ts +116 -0
- package/src/tam.ts +654 -0
- package/src/types.ts +12 -0
package/src/cli/ui.ts
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// Terminal presentation layer — zero-dependency by design (this package ships
|
|
2
|
+
// with no runtime deps and holds CRM credentials; every avoided dependency is
|
|
3
|
+
// supply-chain surface we don't carry).
|
|
4
|
+
//
|
|
5
|
+
// The contract that keeps the agent surface safe:
|
|
6
|
+
// - stdout NEVER carries ANSI bytes unless stdout itself is an interactive
|
|
7
|
+
// TTY. Piped/redirected/--json output is byte-identical to the unstyled CLI.
|
|
8
|
+
// - Animation (spinners, progress, checklists) goes to stderr ONLY, and only
|
|
9
|
+
// when stderr is an interactive TTY. Non-TTY runs render nothing — no
|
|
10
|
+
// frame spam in CI logs or agent transcripts.
|
|
11
|
+
// - NO_COLOR (https://no-color.org), TERM=dumb, and CI disable styling;
|
|
12
|
+
// FORCE_COLOR (non-"0") re-enables it, matching the wider ecosystem.
|
|
13
|
+
|
|
14
|
+
type StreamLike = {
|
|
15
|
+
isTTY?: boolean;
|
|
16
|
+
columns?: number;
|
|
17
|
+
write(chunk: string): boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type UiEnv = Record<string, string | undefined>;
|
|
21
|
+
|
|
22
|
+
/** Should `stream` receive color/ANSI styling at all? */
|
|
23
|
+
export function colorEnabled(
|
|
24
|
+
stream: StreamLike | undefined = process.stdout,
|
|
25
|
+
env: UiEnv = process.env,
|
|
26
|
+
): boolean {
|
|
27
|
+
if (env.FORCE_COLOR === "0") return false;
|
|
28
|
+
if (env.FORCE_COLOR !== undefined) return true;
|
|
29
|
+
if (env.NO_COLOR !== undefined) return false;
|
|
30
|
+
if (env.TERM === "dumb") return false;
|
|
31
|
+
if (env.CI !== undefined) return false;
|
|
32
|
+
return Boolean(stream?.isTTY);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Should `stream` receive live single-line repaints (spinners, bars)?
|
|
37
|
+
* Stricter than color: FORCE_COLOR opts into color codes, not into cursor
|
|
38
|
+
* animation — repaint sequences in a captured log are never what anyone wants.
|
|
39
|
+
*/
|
|
40
|
+
export function animationEnabled(
|
|
41
|
+
stream: StreamLike | undefined = process.stderr,
|
|
42
|
+
env: UiEnv = process.env,
|
|
43
|
+
): boolean {
|
|
44
|
+
if (env.NO_COLOR !== undefined) return false;
|
|
45
|
+
if (env.TERM === "dumb") return false;
|
|
46
|
+
if (env.CI !== undefined) return false;
|
|
47
|
+
return Boolean(stream?.isTTY);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CODES = {
|
|
51
|
+
reset: "\u001b[0m",
|
|
52
|
+
bold: "\u001b[1m",
|
|
53
|
+
dim: "\u001b[2m",
|
|
54
|
+
red: "\u001b[31m",
|
|
55
|
+
green: "\u001b[32m",
|
|
56
|
+
yellow: "\u001b[33m",
|
|
57
|
+
blue: "\u001b[34m",
|
|
58
|
+
magenta: "\u001b[35m",
|
|
59
|
+
cyan: "\u001b[36m",
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
export type StyleName = keyof Omit<typeof CODES, "reset">;
|
|
63
|
+
|
|
64
|
+
export type Paint = Record<StyleName, (text: string) => string> & {
|
|
65
|
+
enabled: boolean;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Style functions for one stream: identity functions when styling is off, so
|
|
70
|
+
* call sites never branch. `paint(colorEnabled(process.stdout))`.
|
|
71
|
+
*/
|
|
72
|
+
export function paint(enabled: boolean): Paint {
|
|
73
|
+
const entry = (code: string) =>
|
|
74
|
+
enabled ? (text: string) => `${code}${text}${CODES.reset}` : (text: string) => text;
|
|
75
|
+
return {
|
|
76
|
+
enabled,
|
|
77
|
+
bold: entry(CODES.bold),
|
|
78
|
+
dim: entry(CODES.dim),
|
|
79
|
+
red: entry(CODES.red),
|
|
80
|
+
green: entry(CODES.green),
|
|
81
|
+
yellow: entry(CODES.yellow),
|
|
82
|
+
blue: entry(CODES.blue),
|
|
83
|
+
magenta: entry(CODES.magenta),
|
|
84
|
+
cyan: entry(CODES.cyan),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const GLYPH = {
|
|
89
|
+
ok: "✓",
|
|
90
|
+
warn: "!",
|
|
91
|
+
fail: "✗",
|
|
92
|
+
pending: "○",
|
|
93
|
+
bullet: "●",
|
|
94
|
+
} as const;
|
|
95
|
+
|
|
96
|
+
/** `✓` in green / `!` in yellow / `✗` in red — or the bare glyph unstyled. */
|
|
97
|
+
export function statusGlyph(state: "ok" | "warn" | "fail" | "pending", p: Paint): string {
|
|
98
|
+
if (state === "ok") return p.green(GLYPH.ok);
|
|
99
|
+
if (state === "warn") return p.yellow(GLYPH.warn);
|
|
100
|
+
if (state === "fail") return p.red(GLYPH.fail);
|
|
101
|
+
return p.dim(GLYPH.pending);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Color banding for 0–100 scores: ≥80 green, ≥60 yellow, else red. */
|
|
105
|
+
export function scoreColor(score: number, p: Paint): string {
|
|
106
|
+
const text = String(score);
|
|
107
|
+
if (score >= 80) return p.green(text);
|
|
108
|
+
if (score >= 60) return p.yellow(text);
|
|
109
|
+
return p.red(text);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
|
|
113
|
+
|
|
114
|
+
/** Min–max normalized sparkline: [42, 51, 68, 81] → "▁▃▅█". */
|
|
115
|
+
export function sparkline(values: number[]): string {
|
|
116
|
+
if (values.length === 0) return "";
|
|
117
|
+
const min = Math.min(...values);
|
|
118
|
+
const max = Math.max(...values);
|
|
119
|
+
const span = max - min;
|
|
120
|
+
return values
|
|
121
|
+
.map((value) => {
|
|
122
|
+
const fraction = span === 0 ? 0.5 : (value - min) / span;
|
|
123
|
+
return SPARKS[Math.min(SPARKS.length - 1, Math.floor(fraction * SPARKS.length))];
|
|
124
|
+
})
|
|
125
|
+
.join("");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `formatBar(0.67)` → "▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱" (clamped to [0,1]). */
|
|
129
|
+
export function formatBar(fraction: number, width = 20): string {
|
|
130
|
+
const clamped = Math.max(0, Math.min(1, fraction));
|
|
131
|
+
const filled = Math.round(clamped * width);
|
|
132
|
+
return "▰".repeat(filled) + "▱".repeat(width - filled);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 950 → "950ms"; 12_400 → "12s"; 84_000 → "1m 24s". */
|
|
136
|
+
export function formatDuration(ms: number): string {
|
|
137
|
+
if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
|
|
138
|
+
const seconds = Math.round(ms / 1000);
|
|
139
|
+
if (seconds < 60) return `${seconds}s`;
|
|
140
|
+
const minutes = Math.floor(seconds / 60);
|
|
141
|
+
const rest = seconds % 60;
|
|
142
|
+
return `${minutes}m ${String(rest).padStart(2, "0")}s`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 12345 → "12,345" (fixed en-US grouping — output must not vary by host locale). */
|
|
146
|
+
export function formatCount(value: number): string {
|
|
147
|
+
return value.toLocaleString("en-US");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Rounded-corner frame around pre-rendered lines. `width` is measured on the
|
|
152
|
+
* raw text, so style the CONTENT only after framing (or pass unstyled lines) —
|
|
153
|
+
* ANSI codes inside `lines` would inflate the measured width.
|
|
154
|
+
*/
|
|
155
|
+
export function box(lines: string[], p: Paint, title?: string): string[] {
|
|
156
|
+
const inner = Math.max(
|
|
157
|
+
...lines.map((line) => line.length),
|
|
158
|
+
title ? title.length + 2 : 0,
|
|
159
|
+
);
|
|
160
|
+
const top = title
|
|
161
|
+
? `╭─ ${title} ${"─".repeat(Math.max(0, inner - title.length - 1))}╮`
|
|
162
|
+
: `╭${"─".repeat(inner + 2)}╮`;
|
|
163
|
+
const bottom = `╰${"─".repeat(inner + 2)}╯`;
|
|
164
|
+
return [
|
|
165
|
+
p.dim(top),
|
|
166
|
+
...lines.map((line) => `${p.dim("│")} ${line.padEnd(inner)} ${p.dim("│")}`),
|
|
167
|
+
p.dim(bottom),
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Left-aligned columns with two-space gutters. Rows may be ragged; widths are
|
|
173
|
+
* measured on raw cell text, so pass unstyled cells and style whole lines, or
|
|
174
|
+
* style via the optional per-column painters (applied AFTER padding).
|
|
175
|
+
*/
|
|
176
|
+
export function table(
|
|
177
|
+
rows: string[][],
|
|
178
|
+
painters?: Array<((cell: string) => string) | null>,
|
|
179
|
+
): string[] {
|
|
180
|
+
const widths: number[] = [];
|
|
181
|
+
for (const row of rows) {
|
|
182
|
+
row.forEach((cell, index) => {
|
|
183
|
+
widths[index] = Math.max(widths[index] ?? 0, cell.length);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return rows.map((row) =>
|
|
187
|
+
row
|
|
188
|
+
.map((cell, index) => {
|
|
189
|
+
const padded = index === row.length - 1 ? cell : cell.padEnd(widths[index]);
|
|
190
|
+
const painterFn = painters?.[index];
|
|
191
|
+
return painterFn ? painterFn(padded) : padded;
|
|
192
|
+
})
|
|
193
|
+
.join(" ")
|
|
194
|
+
.trimEnd(),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
199
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
200
|
+
const CLEAR_LINE = "\r\u001b[2K";
|
|
201
|
+
|
|
202
|
+
export type StatusLine = {
|
|
203
|
+
/** Replace the line's text (next frame repaints it). */
|
|
204
|
+
set(text: string): void;
|
|
205
|
+
/** Stop animating; optionally leave a final static line (else clear). */
|
|
206
|
+
done(finalText?: string): void;
|
|
207
|
+
readonly active: boolean;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const NOOP_STATUS: StatusLine = { set() {}, done() {}, active: false };
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One live spinner line on stderr, repainted in place. Returns an inert no-op
|
|
214
|
+
* outside an interactive TTY, so call sites never branch. `done()` always
|
|
215
|
+
* clears the animation; pass `finalText` to leave a plain summary line behind.
|
|
216
|
+
*/
|
|
217
|
+
export function createStatusLine(
|
|
218
|
+
stream: StreamLike = process.stderr,
|
|
219
|
+
env: UiEnv = process.env,
|
|
220
|
+
): StatusLine {
|
|
221
|
+
if (!animationEnabled(stream, env)) return NOOP_STATUS;
|
|
222
|
+
let text = "";
|
|
223
|
+
let frame = 0;
|
|
224
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
225
|
+
|
|
226
|
+
const render = () => {
|
|
227
|
+
const columns = stream.columns ?? 80;
|
|
228
|
+
const line = `${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${text}`;
|
|
229
|
+
stream.write(CLEAR_LINE + (line.length >= columns ? `${line.slice(0, columns - 2)}…` : line));
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const status: StatusLine = {
|
|
233
|
+
set(next: string) {
|
|
234
|
+
text = next;
|
|
235
|
+
if (!timer) {
|
|
236
|
+
// The timer only advances the spinner frame; text changes repaint
|
|
237
|
+
// immediately so progress is never a tick behind the work.
|
|
238
|
+
timer = setInterval(() => {
|
|
239
|
+
frame += 1;
|
|
240
|
+
render();
|
|
241
|
+
}, SPINNER_INTERVAL_MS);
|
|
242
|
+
// Never hold the event loop open: a forgotten spinner must not stop
|
|
243
|
+
// the CLI from exiting.
|
|
244
|
+
timer.unref?.();
|
|
245
|
+
}
|
|
246
|
+
render();
|
|
247
|
+
},
|
|
248
|
+
done(finalText?: string) {
|
|
249
|
+
if (timer) clearInterval(timer);
|
|
250
|
+
timer = null;
|
|
251
|
+
stream.write(CLEAR_LINE);
|
|
252
|
+
if (finalText !== undefined) stream.write(`${finalText}\n`);
|
|
253
|
+
},
|
|
254
|
+
active: true,
|
|
255
|
+
};
|
|
256
|
+
return status;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A status line that re-renders itself every second with the elapsed time —
|
|
261
|
+
* for single long waits (an LLM call) where nothing else ticks. `label`
|
|
262
|
+
* receives the formatted elapsed time. Inert outside an interactive TTY.
|
|
263
|
+
*/
|
|
264
|
+
export function startElapsedStatus(
|
|
265
|
+
label: (elapsed: string) => string,
|
|
266
|
+
stream: StreamLike = process.stderr,
|
|
267
|
+
env: UiEnv = process.env,
|
|
268
|
+
): { done(finalText?: string): void; active: boolean } {
|
|
269
|
+
const status = createStatusLine(stream, env);
|
|
270
|
+
if (!status.active) return { done() {}, active: false };
|
|
271
|
+
const started = Date.now();
|
|
272
|
+
const refresh = () => status.set(label(formatDuration(Date.now() - started)));
|
|
273
|
+
refresh();
|
|
274
|
+
const timer = setInterval(refresh, 1000);
|
|
275
|
+
timer.unref?.();
|
|
276
|
+
return {
|
|
277
|
+
done(finalText?: string) {
|
|
278
|
+
clearInterval(timer);
|
|
279
|
+
status.done(finalText);
|
|
280
|
+
},
|
|
281
|
+
active: true,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export type Checklist = {
|
|
286
|
+
update(id: string, state: "pending" | "running" | "ok" | "warn" | "fail", note?: string): void;
|
|
287
|
+
/** Stop animating and erase the board (callers print their own summary). */
|
|
288
|
+
done(): void;
|
|
289
|
+
readonly active: boolean;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const NOOP_CHECKLIST: Checklist = { update() {}, done() {}, active: false };
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A multi-line live board — one line per item, each flipping ○ → ⠹ → ✓ as work
|
|
296
|
+
* progresses (the audit rule registry renders through this). Repaints with
|
|
297
|
+
* cursor-up; erases itself on done() so the verb's real output owns stdout.
|
|
298
|
+
*/
|
|
299
|
+
export function createChecklist(
|
|
300
|
+
items: Array<{ id: string; label: string }>,
|
|
301
|
+
stream: StreamLike = process.stderr,
|
|
302
|
+
env: UiEnv = process.env,
|
|
303
|
+
): Checklist {
|
|
304
|
+
if (!animationEnabled(stream, env) || items.length === 0) return NOOP_CHECKLIST;
|
|
305
|
+
const p = paint(true);
|
|
306
|
+
const states = new Map<string, { state: "pending" | "running" | "ok" | "warn" | "fail"; note?: string }>(
|
|
307
|
+
items.map((item) => [item.id, { state: "pending" as const }]),
|
|
308
|
+
);
|
|
309
|
+
let frame = 0;
|
|
310
|
+
let painted = 0;
|
|
311
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
312
|
+
const labelWidth = Math.max(...items.map((item) => item.label.length));
|
|
313
|
+
|
|
314
|
+
const render = () => {
|
|
315
|
+
const spin = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
|
|
316
|
+
const lines = items.map((item) => {
|
|
317
|
+
const entry = states.get(item.id)!;
|
|
318
|
+
const glyph =
|
|
319
|
+
entry.state === "running" ? p.cyan(spin) : statusGlyph(entry.state, p);
|
|
320
|
+
const note = entry.note ? ` ${p.dim(entry.note)}` : "";
|
|
321
|
+
const label = entry.state === "pending" ? p.dim(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
|
|
322
|
+
return ` ${glyph} ${label}${note}`;
|
|
323
|
+
});
|
|
324
|
+
const up = painted > 0 ? `\u001b[${painted}A` : "";
|
|
325
|
+
stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}\n`);
|
|
326
|
+
painted = lines.length;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const start = () => {
|
|
330
|
+
if (!timer) {
|
|
331
|
+
timer = setInterval(() => {
|
|
332
|
+
frame += 1;
|
|
333
|
+
render();
|
|
334
|
+
}, SPINNER_INTERVAL_MS);
|
|
335
|
+
timer.unref?.();
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
update(id, state, note) {
|
|
341
|
+
const entry = states.get(id);
|
|
342
|
+
if (!entry) return;
|
|
343
|
+
entry.state = state;
|
|
344
|
+
entry.note = note;
|
|
345
|
+
start();
|
|
346
|
+
render();
|
|
347
|
+
},
|
|
348
|
+
done() {
|
|
349
|
+
if (timer) clearInterval(timer);
|
|
350
|
+
timer = null;
|
|
351
|
+
if (painted > 0) {
|
|
352
|
+
// Erase the board: cursor up over every painted line, clear each.
|
|
353
|
+
stream.write(`\u001b[${painted}A${"\u001b[2K\n".repeat(painted)}\u001b[${painted}A`);
|
|
354
|
+
painted = 0;
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
active: true,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Truncate to `max` display characters, ending in "…" when cut. */
|
|
362
|
+
export function truncateToWidth(text: string, max: number): string {
|
|
363
|
+
if (max <= 0) return "";
|
|
364
|
+
if (text.length <= max) return text;
|
|
365
|
+
if (max === 1) return "…";
|
|
366
|
+
return `${text.slice(0, max - 1).trimEnd()}…`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** critical → red, warning → yellow, info → dim. */
|
|
370
|
+
export function severityWord(word: string, p: Paint): string {
|
|
371
|
+
if (word === "critical") return p.red(word);
|
|
372
|
+
if (word === "warning") return p.yellow(word);
|
|
373
|
+
return p.dim(word);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** needs_approval → yellow, approved/applied → green, rejected → red. */
|
|
377
|
+
export function planStatusWord(status: string, p: Paint): string {
|
|
378
|
+
if (status === "needs_approval") return p.yellow(status);
|
|
379
|
+
if (status === "approved" || status === "applied") return p.green(status);
|
|
380
|
+
if (status === "rejected") return p.red(status);
|
|
381
|
+
return status;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Interactive-terminal styling for the patch-plan markdown that `audit` and
|
|
386
|
+
* `plans show` print: headings bold, severities banded, and the core diff —
|
|
387
|
+
* current value red, proposed value green (placeholders yellow: they need a
|
|
388
|
+
* human before they can be written). Line-level and additive — with paint
|
|
389
|
+
* disabled the text passes through untouched, so piped output is unchanged.
|
|
390
|
+
* One deliberate text change in rich mode: finding headlines render
|
|
391
|
+
* terminal-bold instead of markdown-bold (the ** markers drop).
|
|
392
|
+
*/
|
|
393
|
+
export function stylizePlanMarkdown(text: string, p: Paint): string {
|
|
394
|
+
if (!p.enabled) return text;
|
|
395
|
+
return text
|
|
396
|
+
.split("\n")
|
|
397
|
+
.map((line) => {
|
|
398
|
+
if (line.startsWith("# ") || line.startsWith("## ")) return p.bold(line);
|
|
399
|
+
// Rule-summary table severity cell: "| stale-deal | 50 | warning |"
|
|
400
|
+
const cell = line.match(/^(\| .+ \| \d+ \| )(critical|warning|info)( \|)$/);
|
|
401
|
+
if (cell) return `${cell[1]}${severityWord(cell[2], p)}${cell[3]}`;
|
|
402
|
+
// Finding headline: "- **Deal has no valid owner** (warning, planned)"
|
|
403
|
+
const headline = line.match(/^- \*\*(.+)\*\* \((critical|warning|info)(, .+)$/);
|
|
404
|
+
if (headline) return `- ${p.bold(headline[1])} (${severityWord(headline[2], p)}${headline[3]}`;
|
|
405
|
+
// The diff at the heart of the plan: what is → what would be written.
|
|
406
|
+
const current = line.match(/^(\s*- Current CRM value: )(.*)$/);
|
|
407
|
+
if (current) return `${current[1]}${p.red(current[2])}`;
|
|
408
|
+
const proposed = line.match(/^(\s*- Proposed value: )(.*)$/);
|
|
409
|
+
if (proposed) {
|
|
410
|
+
const value = proposed[2];
|
|
411
|
+
return `${proposed[1]}${value.startsWith("requires_human") ? p.yellow(value) : p.green(value)}`;
|
|
412
|
+
}
|
|
413
|
+
if (line.startsWith("Status: ")) {
|
|
414
|
+
return `Status: ${planStatusWord(line.slice("Status: ".length), p)}`;
|
|
415
|
+
}
|
|
416
|
+
if (line.startsWith("Dry run: ")) return p.dim(line);
|
|
417
|
+
return line;
|
|
418
|
+
})
|
|
419
|
+
.join("\n");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Strip ANSI escape sequences (test helper + safety net for width math). */
|
|
423
|
+
export function stripAnsi(text: string): string {
|
|
424
|
+
// eslint-disable-next-line no-control-regex
|
|
425
|
+
return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
|
|
426
|
+
}
|