codecartographer-pi 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.
@@ -10,6 +10,8 @@ export interface RewritePhasePromptResult {
10
10
  prompt: string;
11
11
  used: boolean;
12
12
  skipReason?: string;
13
+ /** ID of the previous phase whose closeout the rewriter read, when used. */
14
+ prevPhaseId?: string;
13
15
  }
14
16
  /**
15
17
  * Run the rewriter and return the customized seed prompt. On any failure
@@ -18,3 +20,14 @@ export interface RewritePhasePromptResult {
18
20
  * throws. The caller decides what to surface to the user.
19
21
  */
20
22
  export declare function rewritePhasePrompt(input: RewritePhasePromptInput): Promise<RewritePhasePromptResult>;
23
+ /**
24
+ * Build the markdown block injected into the orchestrator's session
25
+ * (via pi.sendMessage with customType "codecarto-steering") whenever the
26
+ * rewriter produces a customized seed. Lets the user audit what the
27
+ * rewriter chose to emphasize before the phase sub-agent starts.
28
+ */
29
+ export declare function buildSteeringMessage(input: {
30
+ nextPhaseId: string;
31
+ prevPhaseId?: string;
32
+ rewrittenPrompt: string;
33
+ }): string;
@@ -48,7 +48,27 @@ export async function rewritePhasePrompt(input) {
48
48
  if (!trimmed) {
49
49
  return { prompt: originalPrompt, used: false, skipReason: "rewriter returned empty output" };
50
50
  }
51
- return { prompt: trimmed, used: true };
51
+ return { prompt: trimmed, used: true, prevPhaseId };
52
+ }
53
+ /**
54
+ * Build the markdown block injected into the orchestrator's session
55
+ * (via pi.sendMessage with customType "codecarto-steering") whenever the
56
+ * rewriter produces a customized seed. Lets the user audit what the
57
+ * rewriter chose to emphasize before the phase sub-agent starts.
58
+ */
59
+ export function buildSteeringMessage(input) {
60
+ const provenance = input.prevPhaseId
61
+ ? `customized by the orchestrator's LLM from \`${input.prevPhaseId}\`'s closeout`
62
+ : "customized by the orchestrator's LLM";
63
+ return [
64
+ `**Steering: \`${input.nextPhaseId}\` seed prompt**`,
65
+ "",
66
+ `_${provenance}. The phase sub-agent will receive the prompt below as its first user message._`,
67
+ "",
68
+ "---",
69
+ "",
70
+ input.rewrittenPrompt,
71
+ ].join("\n");
52
72
  }
53
73
  function findPreviousPhaseId(state, nextPhaseId) {
54
74
  const order = state.pipeline.phase_order;
@@ -0,0 +1,80 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type PhaseActivity } from "./agent-state.ts";
3
+ import { type PipelinePhase, type ValidationOverall, type ValidationResult, type WorkspaceState } from "../../core/index.ts";
4
+ export interface RunSinglePhaseOptions {
5
+ llmSteerEnabled: boolean;
6
+ signal?: AbortSignal;
7
+ }
8
+ export interface SinglePhaseResult {
9
+ status: "completed" | "aborted" | "error";
10
+ activity: PhaseActivity;
11
+ error?: string;
12
+ responseText?: string;
13
+ }
14
+ /**
15
+ * Run one phase end to end: optional LLM-steered rewrite, spawn the sub-agent,
16
+ * wait for it, then emit the side effects the historical /codecarto-next chain
17
+ * fired (notify, phase-summary sendMessage, recordUsage, writeDashboard). The
18
+ * .finally linger-timeout for clearPhase fires here so both callers (one-shot
19
+ * + auto loop) get the same lifecycle.
20
+ *
21
+ * Pre-conditions: caller verified the phase isn't already running (via
22
+ * getPhaseActivity) and attached the agents widget if it wanted live progress.
23
+ */
24
+ export declare function runSinglePhase(ctx: ExtensionContext, pi: ExtensionAPI, state: WorkspaceState, phase: PipelinePhase, options: RunSinglePhaseOptions): Promise<SinglePhaseResult>;
25
+ /**
26
+ * Re-entry guard: returns true if the phase is already running from a prior
27
+ * spawn (manual or auto). Callers (both /codecarto-next paths) should reject
28
+ * before invoking runSinglePhase.
29
+ */
30
+ export declare function isPhaseRunning(phaseId: string): boolean;
31
+ export interface AutoCompleteResult {
32
+ updatedState: WorkspaceState;
33
+ closeoutNotice?: string;
34
+ }
35
+ export declare function autoCompletePhase(ctx: ExtensionContext, validation: ValidationResult): Promise<AutoCompleteResult>;
36
+ export type AutoOutcome = "complete" | "stopped" | "aborted";
37
+ export interface AutoRunOptions {
38
+ strict: boolean;
39
+ llmSteerOverride?: boolean;
40
+ signal?: AbortSignal;
41
+ }
42
+ export interface AutoRunResult {
43
+ outcome: AutoOutcome;
44
+ reason: string;
45
+ phasesRun: string[];
46
+ totalPhases: number;
47
+ startedAt: number;
48
+ endedAt: number;
49
+ totalTokens: {
50
+ input: number;
51
+ output: number;
52
+ cacheWrite: number;
53
+ };
54
+ stoppedAt?: {
55
+ phaseId: string;
56
+ validation?: ValidationOverall;
57
+ error?: string;
58
+ };
59
+ validationSummary?: string[];
60
+ }
61
+ /**
62
+ * Per-iteration decision: given the outcome of a sub-agent run and (if it
63
+ * completed) its validation result, what should the auto loop do next?
64
+ * Pure function — no I/O, no module state — so the decision matrix is
65
+ * unit-testable without mocking the SDK.
66
+ */
67
+ export type AutoDecision = {
68
+ action: "continue";
69
+ } | {
70
+ action: "stop";
71
+ reason: string;
72
+ validation?: ValidationOverall;
73
+ error?: string;
74
+ validationSummary?: string[];
75
+ } | {
76
+ action: "aborted";
77
+ };
78
+ export declare function decideAfterPhase(phaseStatus: SinglePhaseResult["status"], phaseError: string | undefined, validation: ValidationResult | null, strict: boolean): AutoDecision;
79
+ export declare function runAuto(ctx: ExtensionContext, pi: ExtensionAPI, initialState: WorkspaceState, options: AutoRunOptions): Promise<AutoRunResult>;
80
+ export declare function buildAutoSummary(result: AutoRunResult, availableSkills?: string[]): string;
@@ -0,0 +1,399 @@
1
+ // End-to-end auto runner for /codecarto-next --auto plus the two helpers
2
+ // shared between the one-shot path and the auto loop.
3
+ //
4
+ // runSinglePhase encapsulates everything the historical inline /codecarto-next
5
+ // did between getNextEligiblePhase and the .finally setTimeout. The one-shot
6
+ // handler still consumes it as a fire-and-forget Promise (void runSinglePhase
7
+ // keeps the TUI responsive while the phase runs). The auto loop awaits the
8
+ // same Promise, validates the output, and decides whether to advance.
9
+ //
10
+ // autoCompletePhase mirrors /codecarto-complete's updateStatusAtomically
11
+ // block — the gap → open_questions extraction, owner-notes append, THREAD_LOG
12
+ // write, closeout-stub creation, dashboard regen. UI notifications stay in
13
+ // the calling handler.
14
+ import { runPhase } from "./agent-runner.js";
15
+ import { clearPhase, finishPhase, getPhaseActivity, startPhase } from "./agent-state.js";
16
+ import { buildSteeringMessage, rewritePhasePrompt } from "./agent-rewriter.js";
17
+ import { buildPhaseSummary } from "./agent-summary.js";
18
+ import { getAgentsWidget } from "./agent-widget.js";
19
+ import { writeDashboard } from "./dashboard-writer.js";
20
+ import { appendUsageRun, buildPhasePrompt, buildThreadLogEntry, buildValidationSummary, closeoutFileName, dateOnly, ensureCloseoutStub, formatMillis, formatTokenCount, getNextEligiblePhase, getWorkspaceState, loadCodecartoConfig, normalizeStatus, PACKAGE_VERSION, resolvePhase, uniqueStrings, updateStatusAtomically, validatePhaseOutput, } from "../../core/index.js";
21
+ /**
22
+ * Run one phase end to end: optional LLM-steered rewrite, spawn the sub-agent,
23
+ * wait for it, then emit the side effects the historical /codecarto-next chain
24
+ * fired (notify, phase-summary sendMessage, recordUsage, writeDashboard). The
25
+ * .finally linger-timeout for clearPhase fires here so both callers (one-shot
26
+ * + auto loop) get the same lifecycle.
27
+ *
28
+ * Pre-conditions: caller verified the phase isn't already running (via
29
+ * getPhaseActivity) and attached the agents widget if it wanted live progress.
30
+ */
31
+ export async function runSinglePhase(ctx, pi, state, phase, options) {
32
+ let prompt = await buildPhasePrompt(state, phase, false);
33
+ if (options.llmSteerEnabled) {
34
+ if (ctx.hasUI)
35
+ ctx.ui.notify(`Customizing ${phase.id} prompt via LLM rewriter…`, "info");
36
+ const rewrite = await rewritePhasePrompt({ ctx, state, originalPrompt: prompt, nextPhaseId: phase.id });
37
+ if (rewrite.used) {
38
+ prompt = rewrite.prompt;
39
+ if (ctx.hasUI)
40
+ ctx.ui.notify(`LLM rewriter customized ${phase.id} seed prompt.`, "info");
41
+ pi.sendMessage({
42
+ customType: "codecarto-steering",
43
+ content: buildSteeringMessage({
44
+ nextPhaseId: phase.id,
45
+ prevPhaseId: rewrite.prevPhaseId,
46
+ rewrittenPrompt: rewrite.prompt,
47
+ }),
48
+ display: true,
49
+ });
50
+ }
51
+ else if (ctx.hasUI) {
52
+ ctx.ui.notify(`LLM rewriter skipped (${rewrite.skipReason}); using stock prompt.`, "warning");
53
+ }
54
+ }
55
+ const activity = startPhase(phase.id);
56
+ if (ctx.hasUI) {
57
+ ctx.ui.notify(`CodeCartographer phase: ${phase.id} (sub-agent running)`, "info");
58
+ getAgentsWidget().attach(ctx.ui);
59
+ }
60
+ try {
61
+ const result = await runPhase(ctx, prompt, {
62
+ onSessionCreated: (session) => { activity.session = session; },
63
+ onToolStart: (id, name) => { activity.activeTools.set(id, name); activity.toolUses++; },
64
+ onToolEnd: (id) => { activity.activeTools.delete(id); },
65
+ onTextDelta: (_delta, fullText) => { activity.responseText = fullText; },
66
+ onTurnEnd: (turnCount) => { activity.turnCount = turnCount; },
67
+ onMessageEnd: (usage) => {
68
+ activity.lifetimeUsage.input += usage.input;
69
+ activity.lifetimeUsage.output += usage.output;
70
+ activity.lifetimeUsage.cacheWrite += usage.cacheWrite;
71
+ },
72
+ }, { sessionName: `CodeCartographer phase: ${phase.id}` }, options.signal);
73
+ const status = result.aborted ? "aborted" : "completed";
74
+ finishPhase(phase.id, { status });
75
+ if (ctx.hasUI) {
76
+ ctx.ui.notify(result.aborted
77
+ ? `Phase ${phase.id} aborted.`
78
+ : `Phase ${phase.id} sub-agent finished (${result.toolUses} tool uses, ${result.turnCount} turns).`, result.aborted ? "warning" : "info");
79
+ }
80
+ pi.sendMessage({
81
+ customType: "codecarto-phase-summary",
82
+ content: buildPhaseSummary({
83
+ phaseId: phase.id,
84
+ status: result.aborted ? "aborted" : "completed",
85
+ turnCount: activity.turnCount,
86
+ toolUses: activity.toolUses,
87
+ tokens: activity.lifetimeUsage,
88
+ durationMs: (activity.completedAt ?? Date.now()) - activity.startedAt,
89
+ responseText: result.responseText,
90
+ }),
91
+ display: true,
92
+ });
93
+ void recordUsage(state.workspaceDir, phase.id, status, activity);
94
+ void writeDashboard(ctx.cwd, PACKAGE_VERSION);
95
+ return {
96
+ status: result.aborted ? "aborted" : "completed",
97
+ activity,
98
+ responseText: result.responseText,
99
+ };
100
+ }
101
+ catch (err) {
102
+ const message = err instanceof Error ? err.message : String(err);
103
+ finishPhase(phase.id, { status: "error", error: message });
104
+ if (ctx.hasUI) {
105
+ ctx.ui.notify(`Phase ${phase.id} sub-agent failed: ${message}`, "error");
106
+ }
107
+ pi.sendMessage({
108
+ customType: "codecarto-phase-summary",
109
+ content: buildPhaseSummary({
110
+ phaseId: phase.id,
111
+ status: "error",
112
+ turnCount: activity.turnCount,
113
+ toolUses: activity.toolUses,
114
+ tokens: activity.lifetimeUsage,
115
+ durationMs: (activity.completedAt ?? Date.now()) - activity.startedAt,
116
+ responseText: "",
117
+ error: message,
118
+ }),
119
+ display: true,
120
+ });
121
+ void recordUsage(state.workspaceDir, phase.id, "error", activity);
122
+ void writeDashboard(ctx.cwd, PACKAGE_VERSION);
123
+ return { status: "error", activity, error: message };
124
+ }
125
+ finally {
126
+ // Linger 30s so /codecarto-status can show that the phase ran.
127
+ setTimeout(() => clearPhase(phase.id), 30_000);
128
+ }
129
+ }
130
+ /**
131
+ * Re-entry guard: returns true if the phase is already running from a prior
132
+ * spawn (manual or auto). Callers (both /codecarto-next paths) should reject
133
+ * before invoking runSinglePhase.
134
+ */
135
+ export function isPhaseRunning(phaseId) {
136
+ const existing = getPhaseActivity(phaseId);
137
+ return existing?.status === "running";
138
+ }
139
+ export async function autoCompletePhase(ctx, validation) {
140
+ const completionTimestamp = new Date().toISOString();
141
+ const updatedState = await updateStatusAtomically(ctx.cwd, (lockedState) => {
142
+ const phase = resolvePhase(lockedState, validation.phaseId);
143
+ if (!phase?.primary_output) {
144
+ throw new Error(`Phase ${validation.phaseId} is missing primary_output.`);
145
+ }
146
+ const nextStatus = normalizeStatus(lockedState.status, lockedState.pipeline, lockedState.status.pipeline, lockedState.cwd);
147
+ const existingPhase = nextStatus.phases[validation.phaseId] ?? {
148
+ status: "pending",
149
+ owner_notes: [],
150
+ outputs_present: [],
151
+ open_questions: [],
152
+ carry_forward: [],
153
+ };
154
+ const gapEntries = validation.rows
155
+ .filter((row) => row.result.toUpperCase().includes("PARTIAL"))
156
+ .map((row) => ({
157
+ kind: "needs-maintainer-decision",
158
+ description: row.criterion || "Partial validation gap",
159
+ deferred_reason: row.evidence || "Marked PARTIAL by validation",
160
+ }));
161
+ const mergedOpenQuestions = [...existingPhase.open_questions];
162
+ for (const candidate of gapEntries) {
163
+ const dupe = mergedOpenQuestions.some((entry) => entry.description === candidate.description && entry.deferred_reason === candidate.deferred_reason);
164
+ if (!dupe)
165
+ mergedOpenQuestions.push(candidate);
166
+ }
167
+ nextStatus.phases[validation.phaseId] = {
168
+ status: "complete",
169
+ owner_notes: uniqueStrings([
170
+ ...existingPhase.owner_notes,
171
+ `Completed via /codecarto-complete on ${completionTimestamp}.`,
172
+ `Primary output: .codecarto/${validation.primaryOutput}`,
173
+ `Validation: ${validation.overall}`,
174
+ ]).slice(-3),
175
+ outputs_present: uniqueStrings([...existingPhase.outputs_present, validation.primaryOutput]),
176
+ open_questions: mergedOpenQuestions,
177
+ carry_forward: existingPhase.carry_forward ?? [],
178
+ };
179
+ nextStatus.last_updated = completionTimestamp;
180
+ const updatedWorkspaceState = {
181
+ ...lockedState,
182
+ status: nextStatus,
183
+ };
184
+ const nextEligible = getNextEligiblePhase(updatedWorkspaceState);
185
+ nextStatus.current_phase = nextEligible?.id ?? "complete";
186
+ nextStatus.next_actions = nextEligible
187
+ ? [`Begin ${nextEligible.id} phase by producing ${nextEligible.primary_output ?? `findings/${nextEligible.id}/`}`]
188
+ : ["All phases complete. Review findings, open questions, and downstream implementation notes."];
189
+ return {
190
+ state: { ...updatedWorkspaceState, status: nextStatus },
191
+ threadLogEntry: buildThreadLogEntry(validation.phaseId, validation, completionTimestamp),
192
+ };
193
+ });
194
+ let closeoutNotice;
195
+ try {
196
+ const created = await ensureCloseoutStub(updatedState.workspaceDir, validation.phaseId, completionTimestamp);
197
+ if (created) {
198
+ closeoutNotice = `Closeout stub: .codecarto/closeouts/${closeoutFileName(dateOnly(completionTimestamp), validation.phaseId)} (fill it in)`;
199
+ }
200
+ }
201
+ catch (error) {
202
+ const message = error instanceof Error ? error.message : String(error);
203
+ closeoutNotice = `Closeout stub not created: ${message}`;
204
+ }
205
+ void writeDashboard(ctx.cwd, PACKAGE_VERSION);
206
+ return { updatedState, closeoutNotice };
207
+ }
208
+ export function decideAfterPhase(phaseStatus, phaseError, validation, strict) {
209
+ if (phaseStatus === "aborted")
210
+ return { action: "aborted" };
211
+ if (phaseStatus === "error") {
212
+ return { action: "stop", reason: phaseError ?? "Sub-agent errored.", error: phaseError };
213
+ }
214
+ if (!validation) {
215
+ return { action: "stop", reason: "Validation skipped (no result)." };
216
+ }
217
+ if (validation.overall === "FAIL" || validation.overall === "MISSING") {
218
+ return {
219
+ action: "stop",
220
+ reason: `Validation ${validation.overall} on ${validation.phaseId}.`,
221
+ validation: validation.overall,
222
+ validationSummary: buildValidationSummary(validation),
223
+ };
224
+ }
225
+ if (validation.overall === "PASS WITH GAPS" && strict) {
226
+ return {
227
+ action: "stop",
228
+ reason: `PASS WITH GAPS on ${validation.phaseId} (strict mode).`,
229
+ validation: validation.overall,
230
+ validationSummary: buildValidationSummary(validation),
231
+ };
232
+ }
233
+ return { action: "continue" };
234
+ }
235
+ export async function runAuto(ctx, pi, initialState, options) {
236
+ const startedAt = Date.now();
237
+ const phasesRun = [];
238
+ const totalTokens = { input: 0, output: 0, cacheWrite: 0 };
239
+ const totalPhases = initialState.pipeline.phase_order.length;
240
+ const config = await loadCodecartoConfig(initialState.workspaceDir);
241
+ const llmSteerEnabled = options.llmSteerOverride ?? config.orchestrator.llm_steer_next_phase;
242
+ let state = initialState;
243
+ while (true) {
244
+ if (options.signal?.aborted) {
245
+ return finish({
246
+ outcome: "aborted",
247
+ reason: "User aborted the auto run.",
248
+ });
249
+ }
250
+ const phase = getNextEligiblePhase(state);
251
+ if (!phase) {
252
+ return finish({
253
+ outcome: "complete",
254
+ reason: "Pipeline complete.",
255
+ });
256
+ }
257
+ if (isPhaseRunning(phase.id)) {
258
+ return finish({
259
+ outcome: "stopped",
260
+ reason: `Phase ${phase.id} is already running from a prior invocation.`,
261
+ stoppedAt: { phaseId: phase.id },
262
+ });
263
+ }
264
+ const phaseResult = await runSinglePhase(ctx, pi, state, phase, {
265
+ llmSteerEnabled,
266
+ signal: options.signal,
267
+ });
268
+ // Accumulate tokens whether the phase succeeded, was aborted, or errored.
269
+ totalTokens.input += phaseResult.activity.lifetimeUsage.input;
270
+ totalTokens.output += phaseResult.activity.lifetimeUsage.output;
271
+ totalTokens.cacheWrite += phaseResult.activity.lifetimeUsage.cacheWrite;
272
+ // Validate only when the phase actually completed. Aborts and errors
273
+ // short-circuit; decideAfterPhase handles all three outcomes.
274
+ let validation = null;
275
+ if (phaseResult.status === "completed") {
276
+ // State must be refreshed because the sub-agent may have written
277
+ // findings to disk that the validator reads.
278
+ const stateForValidation = (await getWorkspaceState(ctx.cwd)) ?? state;
279
+ validation = await validatePhaseOutput(stateForValidation, phase.id);
280
+ }
281
+ const decision = decideAfterPhase(phaseResult.status, phaseResult.error, validation, options.strict);
282
+ if (decision.action === "aborted") {
283
+ return finish({
284
+ outcome: "aborted",
285
+ reason: `Aborted during ${phase.id}.`,
286
+ stoppedAt: { phaseId: phase.id },
287
+ });
288
+ }
289
+ if (decision.action === "stop") {
290
+ return finish({
291
+ outcome: "stopped",
292
+ reason: decision.reason,
293
+ stoppedAt: { phaseId: phase.id, validation: decision.validation, error: decision.error },
294
+ validationSummary: decision.validationSummary,
295
+ });
296
+ }
297
+ // decision.action === "continue" → auto-complete and loop.
298
+ // validation is guaranteed non-null on the continue branch.
299
+ try {
300
+ const { updatedState } = await autoCompletePhase(ctx, validation);
301
+ state = updatedState;
302
+ phasesRun.push(phase.id);
303
+ }
304
+ catch (err) {
305
+ const message = err instanceof Error ? err.message : String(err);
306
+ return finish({
307
+ outcome: "stopped",
308
+ reason: `Auto-complete failed on ${phase.id}: ${message}`,
309
+ stoppedAt: { phaseId: phase.id, error: message },
310
+ });
311
+ }
312
+ }
313
+ function finish(partial) {
314
+ return {
315
+ ...partial,
316
+ phasesRun,
317
+ totalPhases,
318
+ startedAt,
319
+ endedAt: Date.now(),
320
+ totalTokens,
321
+ };
322
+ }
323
+ }
324
+ // ----------------------------------------------------------------------------
325
+ // buildAutoSummary — the codecarto-auto-summary message body
326
+ // ----------------------------------------------------------------------------
327
+ export function buildAutoSummary(result, availableSkills = []) {
328
+ const totalTokens = result.totalTokens.input + result.totalTokens.output;
329
+ const wallTime = formatMillis(result.endedAt - result.startedAt);
330
+ const tokensStr = formatTokenCount(totalTokens);
331
+ const ranOf = `${result.phasesRun.length}/${result.totalPhases} phase${result.totalPhases === 1 ? "" : "s"}`;
332
+ const header = (() => {
333
+ switch (result.outcome) {
334
+ case "complete":
335
+ return `**Auto pipeline complete.**`;
336
+ case "stopped":
337
+ return `**Auto pipeline stopped at \`${result.stoppedAt?.phaseId ?? "?"}\`.**`;
338
+ case "aborted":
339
+ return `**Auto pipeline aborted${result.stoppedAt?.phaseId ? ` during \`${result.stoppedAt.phaseId}\`` : ""}.**`;
340
+ }
341
+ })();
342
+ const statsLine = `_⟳ ${ranOf} · ${tokensStr} tokens · ${wallTime}_`;
343
+ const lines = [header, "", statsLine];
344
+ if (result.outcome === "stopped" && result.validationSummary && result.validationSummary.length > 0) {
345
+ lines.push("", "```");
346
+ lines.push(...result.validationSummary);
347
+ lines.push("```");
348
+ }
349
+ if (result.outcome === "stopped" || result.outcome === "aborted") {
350
+ lines.push("", result.reason);
351
+ lines.push("", recoveryHint(result));
352
+ }
353
+ if (result.outcome === "complete") {
354
+ lines.push("", "Dashboard: `.codecarto/dashboard.html`");
355
+ if (availableSkills.length > 0) {
356
+ lines.push(`Next: try \`/codecarto-skill ${availableSkills[0]}\` (also available: ${availableSkills.slice(1).join(", ") || "none"}).`);
357
+ }
358
+ }
359
+ return lines.join("\n");
360
+ }
361
+ function recoveryHint(result) {
362
+ if (result.outcome === "aborted") {
363
+ return "Run `/codecarto-next --auto` to resume.";
364
+ }
365
+ const v = result.stoppedAt?.validation;
366
+ if (v === "FAIL" || v === "MISSING") {
367
+ return `Fix the phase output, then \`/codecarto-next --auto\` to resume.`;
368
+ }
369
+ if (v === "PASS WITH GAPS") {
370
+ return `Review gaps via \`/codecarto-validate\`, then \`/codecarto-complete ${result.stoppedAt?.phaseId ?? "<phase>"}\`, then \`/codecarto-next --auto\`.`;
371
+ }
372
+ if (result.stoppedAt?.error) {
373
+ return `Re-run \`/codecarto-next\` (single-step) on \`${result.stoppedAt.phaseId}\` to retry once the issue is resolved.`;
374
+ }
375
+ return "Run `/codecarto-next --auto` to resume.";
376
+ }
377
+ // ----------------------------------------------------------------------------
378
+ // Helpers
379
+ // ----------------------------------------------------------------------------
380
+ async function recordUsage(workspaceDir, phaseId, status, activity) {
381
+ try {
382
+ await appendUsageRun(workspaceDir, {
383
+ timestamp: new Date().toISOString(),
384
+ phase: phaseId,
385
+ status,
386
+ turn_count: activity.turnCount,
387
+ tool_uses: activity.toolUses,
388
+ duration_ms: (activity.completedAt ?? Date.now()) - activity.startedAt,
389
+ tokens: {
390
+ input: activity.lifetimeUsage.input,
391
+ output: activity.lifetimeUsage.output,
392
+ cache_write: activity.lifetimeUsage.cacheWrite,
393
+ },
394
+ });
395
+ }
396
+ catch {
397
+ // Best-effort, matches the original recordUsage discipline.
398
+ }
399
+ }
@@ -0,0 +1,6 @@
1
+ export interface DashboardFlags {
2
+ narrate: boolean;
3
+ unknown: string[];
4
+ }
5
+ export declare function parseDashboardFlags(args: string): DashboardFlags;
6
+ export declare const KNOWN_DASHBOARD_FLAGS: readonly string[];
@@ -0,0 +1,17 @@
1
+ // Flag parser for /codecarto-dashboard. The user invokes the slash command
2
+ // with an optional "--narrate" flag to trigger the opt-in LLM narrator.
3
+ // Same shape and discipline as next-flags.ts so future flags slot in
4
+ // without refactoring.
5
+ const KNOWN = new Set(["--narrate"]);
6
+ export function parseDashboardFlags(args) {
7
+ const tokens = args.trim().split(/\s+/).filter((t) => t.length > 0);
8
+ const result = { narrate: false, unknown: [] };
9
+ for (const t of tokens) {
10
+ if (t === "--narrate")
11
+ result.narrate = true;
12
+ else
13
+ result.unknown.push(t);
14
+ }
15
+ return result;
16
+ }
17
+ export const KNOWN_DASHBOARD_FLAGS = [...KNOWN];
@@ -0,0 +1,8 @@
1
+ import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type WorkspaceState } from "../../core/index.ts";
3
+ export interface NarrateDashboardResult {
4
+ narration?: string;
5
+ used: boolean;
6
+ skipReason?: string;
7
+ }
8
+ export declare function narrateDashboard(ctx: ExtensionContext, state: WorkspaceState): Promise<NarrateDashboardResult>;