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.
@@ -0,0 +1,182 @@
1
+ // Optional LLM-narrated executive summary for the dashboard. Opt-in via
2
+ // /codecarto-dashboard --narrate. Runs the orchestrator's model as a
3
+ // one-shot in-memory AgentSession with no tools, reads recent closeouts +
4
+ // status + usage totals, and produces a 200-400 word Markdown summary.
5
+ //
6
+ // The summary is cached to .codecarto/.dashboard-narration.local.md with a
7
+ // YAML frontmatter recording when it was generated and the completed-phase
8
+ // count at that time. Subsequent deterministic re-renders consult this
9
+ // cache and surface a "<N> runs since" staleness note.
10
+ //
11
+ // Same one-shot pattern as agent-rewriter.ts:runRewriterOnce — different
12
+ // system prompt, different output target. Never throws.
13
+ import { readFile, readdir, rename, writeFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
16
+ import { computeTotals, NARRATION_CACHE_RELATIVE_PATH, loadUsage, pathExists, stringifySimpleYaml, } from "../../core/index.js";
17
+ // Per-closeout byte budget when stuffing the narrator's input. Three
18
+ // closeouts × 4 KB each ≈ 12 KB of prompt context, which is well under any
19
+ // reasonable model's input window.
20
+ const CLOSEOUT_BYTE_BUDGET = 4000;
21
+ const MAX_CLOSEOUTS = 3;
22
+ export async function narrateDashboard(ctx, state) {
23
+ const closeouts = await readRecentCloseouts(state.workspaceDir);
24
+ if (closeouts.length === 0) {
25
+ return { used: false, skipReason: "no closeouts to narrate from" };
26
+ }
27
+ const usage = await loadUsage(state.workspaceDir);
28
+ const prompt = buildNarratorPrompt({
29
+ projectName: state.status.project_name || "(unnamed project)",
30
+ pipeline: state.status.pipeline,
31
+ currentPhase: state.status.current_phase || "—",
32
+ completedCount: completedPhaseCount(state),
33
+ totalPhases: state.pipeline.phase_order.length,
34
+ usageSummary: summarizeUsage(usage),
35
+ closeouts,
36
+ });
37
+ let narration;
38
+ try {
39
+ narration = await runNarratorOnce(ctx, prompt);
40
+ }
41
+ catch (error) {
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ return { used: false, skipReason: `narrator session failed: ${message}` };
44
+ }
45
+ const trimmed = narration.trim();
46
+ if (!trimmed) {
47
+ return { used: false, skipReason: "narrator returned empty output" };
48
+ }
49
+ await writeNarrationCache(state.workspaceDir, trimmed, completedPhaseCount(state));
50
+ return { narration: trimmed, used: true };
51
+ }
52
+ async function readRecentCloseouts(workspaceDir) {
53
+ const dir = join(workspaceDir, "closeouts");
54
+ if (!(await pathExists(dir)))
55
+ return [];
56
+ let entries;
57
+ try {
58
+ entries = await readdir(dir);
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ const matched = entries
64
+ .map((name) => {
65
+ const m = /^(\d{4}-\d{2}-\d{2})-(.+)\.md$/.exec(name);
66
+ return m ? { date: m[1], phaseOrModule: m[2], fileName: name } : null;
67
+ })
68
+ .filter((x) => x !== null)
69
+ .sort((a, b) => (a.date < b.date ? 1 : -1))
70
+ .slice(0, MAX_CLOSEOUTS);
71
+ const out = [];
72
+ for (const entry of matched) {
73
+ try {
74
+ const raw = await readFile(join(dir, entry.fileName), "utf8");
75
+ const truncated = raw.length > CLOSEOUT_BYTE_BUDGET
76
+ ? `${raw.slice(0, CLOSEOUT_BYTE_BUDGET)}\n\n[…truncated for narration budget…]`
77
+ : raw;
78
+ out.push({ ...entry, content: truncated });
79
+ }
80
+ catch {
81
+ // skip unreadable closeouts
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ function buildNarratorPrompt(input) {
87
+ const closeoutBlocks = input.closeouts.map((c) => [
88
+ `=== CLOSEOUT ${c.date} ${c.phaseOrModule} ===`,
89
+ c.content,
90
+ `=== END CLOSEOUT ${c.date} ${c.phaseOrModule} ===`,
91
+ ].join("\n")).join("\n\n");
92
+ return [
93
+ `You are writing a 200-400 word executive summary of a CodeCartographer run for a human reader (project owner, reviewer, or new team member).`,
94
+ "",
95
+ "Constraints:",
96
+ "- Cite specific findings from the closeouts below; quote phase IDs and artifact names verbatim.",
97
+ "- Do not invent findings the closeouts do not state.",
98
+ "- Lead with what the run discovered, not what it did mechanically. Findings > activity.",
99
+ "- Surface any cross-cutting risks or open questions worth pulling forward.",
100
+ "- Output Markdown only, no commentary, no preface, no fenced code blocks.",
101
+ "- 200-400 words; tight prose, no bullet-list-soup.",
102
+ "",
103
+ "=== RUN METADATA ===",
104
+ `Project: ${input.projectName}`,
105
+ `Pipeline: ${input.pipeline}`,
106
+ `Progress: ${input.completedCount}/${input.totalPhases} phases complete`,
107
+ `Current phase: ${input.currentPhase}`,
108
+ `Usage: ${input.usageSummary}`,
109
+ "=== END RUN METADATA ===",
110
+ "",
111
+ closeoutBlocks,
112
+ ].join("\n");
113
+ }
114
+ function summarizeUsage(usage) {
115
+ if (usage.runs.length === 0)
116
+ return "no runs recorded";
117
+ const totals = computeTotals(usage);
118
+ const tokensTotal = totals.tokens.input + totals.tokens.output;
119
+ return `${totals.runs} runs · ${formatK(tokensTotal)} tokens · ${totals.tool_uses} tool uses`;
120
+ }
121
+ function formatK(n) {
122
+ if (n >= 1_000_000)
123
+ return `${(n / 1_000_000).toFixed(2)}M`;
124
+ if (n >= 1_000)
125
+ return `${(n / 1_000).toFixed(1)}k`;
126
+ return `${n}`;
127
+ }
128
+ function completedPhaseCount(state) {
129
+ return Object.values(state.status.phases).filter((p) => p.status === "complete").length;
130
+ }
131
+ async function writeNarrationCache(workspaceDir, content, phaseCount) {
132
+ const generatedAt = new Date().toISOString();
133
+ const frontmatter = stringifySimpleYaml({ generatedAt, phaseCountAtGeneration: phaseCount });
134
+ const body = `---\n${frontmatter}\n---\n${content}\n`;
135
+ const path = join(workspaceDir, NARRATION_CACHE_RELATIVE_PATH);
136
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
137
+ await writeFile(tempPath, body, "utf8");
138
+ await rename(tempPath, path);
139
+ }
140
+ async function runNarratorOnce(ctx, prompt) {
141
+ const cwd = ctx.cwd;
142
+ const agentDir = getAgentDir();
143
+ const loader = new DefaultResourceLoader({
144
+ cwd,
145
+ agentDir,
146
+ noExtensions: true,
147
+ noSkills: true,
148
+ noPromptTemplates: true,
149
+ noThemes: true,
150
+ noContextFiles: true,
151
+ });
152
+ await loader.reload();
153
+ const { session } = await createAgentSession({
154
+ cwd,
155
+ agentDir,
156
+ sessionManager: SessionManager.inMemory(cwd),
157
+ settingsManager: SettingsManager.create(cwd, agentDir),
158
+ modelRegistry: ctx.modelRegistry,
159
+ model: ctx.model,
160
+ tools: [],
161
+ resourceLoader: loader,
162
+ });
163
+ await session.prompt(prompt);
164
+ return getLastAssistantText(session);
165
+ }
166
+ function getLastAssistantText(session) {
167
+ for (let i = session.messages.length - 1; i >= 0; i--) {
168
+ const msg = session.messages[i];
169
+ if (msg.role !== "assistant")
170
+ continue;
171
+ const blocks = msg.content;
172
+ const parts = [];
173
+ for (const c of blocks) {
174
+ if (c.type === "text" && c.text)
175
+ parts.push(c.text);
176
+ }
177
+ const joined = parts.join("\n").trim();
178
+ if (joined)
179
+ return joined;
180
+ }
181
+ return "";
182
+ }
@@ -0,0 +1 @@
1
+ export declare function writeDashboard(cwd: string, packageVersion: string): Promise<void>;
@@ -0,0 +1,123 @@
1
+ // I/O wrapper that gathers all dashboard inputs and writes the rendered
2
+ // HTML to `.codecarto/dashboard.html`. Best-effort — failures are swallowed
3
+ // and never escalate to a phase error the user sees, mirroring the
4
+ // recordUsage discipline at extensions/codecarto/index.ts.
5
+ import { readdir, readFile, rename, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { DASHBOARD_RELATIVE_PATH, getWorkspaceState, loadUsage, NARRATION_CACHE_RELATIVE_PATH, parseSimpleYaml, pathExists, renderDashboard, } from "../../core/index.js";
8
+ const CLOSEOUT_FILENAME_RE = /^(\d{4}-\d{2}-\d{2})-(.+)\.md$/;
9
+ export async function writeDashboard(cwd, packageVersion) {
10
+ try {
11
+ const state = await getWorkspaceState(cwd);
12
+ if (!state)
13
+ return;
14
+ const workspaceDir = state.workspaceDir;
15
+ const [usage, closeouts, outputsPresent, narration] = await Promise.all([
16
+ loadUsage(workspaceDir),
17
+ listCloseouts(workspaceDir),
18
+ buildOutputsPresent(state.workspaceDir, state.pipeline),
19
+ loadNarration(workspaceDir),
20
+ ]);
21
+ const inputs = {
22
+ status: state.status,
23
+ pipeline: state.pipeline,
24
+ usage,
25
+ closeouts,
26
+ outputsPresent,
27
+ packageVersion,
28
+ generatedAt: new Date().toISOString(),
29
+ narration,
30
+ };
31
+ const html = renderDashboard(inputs);
32
+ const path = join(workspaceDir, DASHBOARD_RELATIVE_PATH);
33
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
34
+ await writeFile(tempPath, html, "utf8");
35
+ await rename(tempPath, path);
36
+ }
37
+ catch {
38
+ // Best-effort: a failed dashboard write must not surface as a phase
39
+ // error. The user's pipeline state is unaffected; the next state
40
+ // change will trigger another render attempt.
41
+ }
42
+ }
43
+ async function listCloseouts(workspaceDir) {
44
+ const dir = join(workspaceDir, "closeouts");
45
+ if (!(await pathExists(dir)))
46
+ return [];
47
+ let entries;
48
+ try {
49
+ entries = await readdir(dir);
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ const out = [];
55
+ for (const name of entries) {
56
+ const m = CLOSEOUT_FILENAME_RE.exec(name);
57
+ if (!m)
58
+ continue;
59
+ out.push({ date: m[1], phaseOrModule: m[2], fileName: name });
60
+ }
61
+ return out;
62
+ }
63
+ async function buildOutputsPresent(workspaceDir, pipeline) {
64
+ const out = new Map();
65
+ for (const phaseId of pipeline.phase_order) {
66
+ const phaseDef = pipeline.phases.find((p) => p.id === phaseId);
67
+ if (!phaseDef)
68
+ continue;
69
+ const entry = { secondary: [] };
70
+ if (phaseDef.primary_output) {
71
+ entry.primary = {
72
+ path: phaseDef.primary_output,
73
+ exists: await pathExists(join(workspaceDir, phaseDef.primary_output)),
74
+ };
75
+ }
76
+ for (const sec of phaseDef.secondary_outputs ?? []) {
77
+ entry.secondary.push({
78
+ path: sec.path,
79
+ exists: await pathExists(join(workspaceDir, sec.path)),
80
+ });
81
+ }
82
+ out.set(phaseId, entry);
83
+ }
84
+ return out;
85
+ }
86
+ async function loadNarration(workspaceDir) {
87
+ const path = join(workspaceDir, NARRATION_CACHE_RELATIVE_PATH);
88
+ if (!(await pathExists(path)))
89
+ return undefined;
90
+ try {
91
+ const raw = await readFile(path, "utf8");
92
+ const { frontmatter, body } = splitFrontmatter(raw);
93
+ if (!frontmatter)
94
+ return undefined;
95
+ const generatedAt = typeof frontmatter.generatedAt === "string" ? frontmatter.generatedAt : "";
96
+ const phaseCountAtGeneration = typeof frontmatter.phaseCountAtGeneration === "number" ? frontmatter.phaseCountAtGeneration : 0;
97
+ if (!generatedAt)
98
+ return undefined;
99
+ return { content: body.trim(), generatedAt, phaseCountAtGeneration };
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ function splitFrontmatter(raw) {
106
+ if (!raw.startsWith("---\n"))
107
+ return { frontmatter: null, body: raw };
108
+ const end = raw.indexOf("\n---\n", 4);
109
+ if (end === -1)
110
+ return { frontmatter: null, body: raw };
111
+ const yamlText = raw.slice(4, end);
112
+ const body = raw.slice(end + 5);
113
+ try {
114
+ const parsed = parseSimpleYaml(yamlText);
115
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
116
+ return { frontmatter: parsed, body };
117
+ }
118
+ }
119
+ catch {
120
+ // fall through
121
+ }
122
+ return { frontmatter: null, body };
123
+ }
@@ -1,36 +1,15 @@
1
1
  import { cp, mkdir, rm, writeFile } from "node:fs/promises";
2
2
  import { basename, join, resolve } from "node:path";
3
- import { runPhase } from "./agent-runner.js";
4
- import { rewritePhasePrompt } from "./agent-rewriter.js";
5
- import { clearPhase, finishPhase, getPhaseActivity, startPhase } from "./agent-state.js";
6
- import { buildPhaseSummary } from "./agent-summary.js";
7
- import { disposeAgentsWidget, getAgentsWidget } from "./agent-widget.js";
3
+ import { autoCompletePhase, buildAutoSummary, isPhaseRunning, runAuto, runSinglePhase } from "./auto-runner.js";
4
+ import { disposeAgentsWidget } from "./agent-widget.js";
5
+ import { parseDashboardFlags } from "./dashboard-flags.js";
6
+ import { narrateDashboard } from "./dashboard-narrator.js";
7
+ import { writeDashboard } from "./dashboard-writer.js";
8
8
  import { parseNextFlags } from "./next-flags.js";
9
- import { appendUsageRun, buildPhasePrompt, buildSkillPrompt, buildThreadLogEntry, buildValidationSummary, canonicalPath, closeoutFileName, computePerPhaseTotals, computeTotals, createEmptyStatus, dateOnly, DEFAULT_PIPELINE_PATH, ensureCloseoutStub, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isWithinPath, listSkillNames, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, normalizeStatus, packagedWorkspaceDir, pathExists, PIPELINE_ALIASES, resolvePhase, resolvePipelineChoice, stringifySimpleYaml, uniqueStrings, updateStatusAtomically, validatePhaseOutput, } from "../../core/index.js";
9
+ import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, canonicalPath, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isWithinPath, listSkillNames, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, PIPELINE_ALIASES, resolvePhase, resolvePipelineChoice, stringifySimpleYaml, validatePhaseOutput, } from "../../core/index.js";
10
10
  const STATUS_WIDGET_ID = "codecarto-widget";
11
11
  const STATUS_LINE_ID = "codecarto-status";
12
12
  const SAFE_TOOL_NAMES = ["read", "grep", "find", "ls", "edit", "write"];
13
- async function recordUsage(workspaceDir, phaseId, status, activity) {
14
- try {
15
- await appendUsageRun(workspaceDir, {
16
- timestamp: new Date().toISOString(),
17
- phase: phaseId,
18
- status,
19
- turn_count: activity.turnCount,
20
- tool_uses: activity.toolUses,
21
- duration_ms: (activity.completedAt ?? Date.now()) - activity.startedAt,
22
- tokens: {
23
- input: activity.lifetimeUsage.input,
24
- output: activity.lifetimeUsage.output,
25
- cache_write: activity.lifetimeUsage.cacheWrite,
26
- },
27
- });
28
- }
29
- catch {
30
- // Local usage logging is best-effort. A full disk or permission
31
- // failure shouldn't surface as a phase error to the user.
32
- }
33
- }
34
13
  function formatUsageTokens(count) {
35
14
  if (count >= 1_000_000)
36
15
  return `${(count / 1_000_000).toFixed(2)}M`;
@@ -203,6 +182,9 @@ export default function codeCartographerExtension(pi) {
203
182
  lastFeedbackLines = [`Initialized workspace with pipeline: ${getPipelineLabel(selectedPipelinePath)}`];
204
183
  ctx.ui.notify(`Initialized CodeCartographer (${getPipelineLabel(selectedPipelinePath)})`, "info");
205
184
  await ctx.reload();
185
+ // Render the initial dashboard (empty usage, all phases pending) so
186
+ // the user sees the file exist immediately after /codecarto-init.
187
+ void writeDashboard(ctx.cwd, PACKAGE_VERSION);
206
188
  return;
207
189
  },
208
190
  });
@@ -219,15 +201,19 @@ export default function codeCartographerExtension(pi) {
219
201
  },
220
202
  });
221
203
  pi.registerCommand("codecarto-next", {
222
- description: "Run the next eligible CodeCartographer phase as a sub-agent. Flags: --llm-steer / --no-llm-steer",
204
+ description: "Run the next eligible CodeCartographer phase as a sub-agent. Flags: --llm-steer / --no-llm-steer / --auto [--strict]",
223
205
  getArgumentCompletions: (prefix) => {
224
- const items = ["--llm-steer", "--no-llm-steer"]
206
+ const items = ["--llm-steer", "--no-llm-steer", "--auto", "--strict"]
225
207
  .filter((value) => value.startsWith(prefix))
226
208
  .map((value) => ({ value, label: value }));
227
209
  return items.length > 0 ? items : null;
228
210
  },
229
211
  handler: async (args, ctx) => {
230
212
  const flags = parseNextFlags(args);
213
+ if (flags.error) {
214
+ ctx.ui.notify(flags.error, "error");
215
+ return;
216
+ }
231
217
  if (flags.unknown.length > 0) {
232
218
  ctx.ui.notify(`Unknown /codecarto-next flag: ${flags.unknown.join(" ")}`, "error");
233
219
  return;
@@ -235,6 +221,24 @@ export default function codeCartographerExtension(pi) {
235
221
  const state = await ensureWorkspaceState(ctx);
236
222
  if (!state)
237
223
  return;
224
+ if (flags.auto) {
225
+ ctx.ui.notify(`Auto pipeline${flags.strict ? " (strict)" : ""} running…`, "info");
226
+ const result = await runAuto(ctx, pi, state, {
227
+ strict: flags.strict,
228
+ llmSteerOverride: flags.llmSteerOverride,
229
+ signal: ctx.signal,
230
+ });
231
+ const availableSkills = await listSkillNames(state.workspaceDir).catch(() => []);
232
+ pi.sendMessage({
233
+ customType: "codecarto-auto-summary",
234
+ content: buildAutoSummary(result, availableSkills),
235
+ display: true,
236
+ });
237
+ lastFeedbackLines = [`Auto pipeline ${result.outcome}: ${result.reason}`];
238
+ await refreshWorkspaceUi(ctx, lastFeedbackLines);
239
+ ctx.ui.notify(`Auto pipeline ${result.outcome}: ${result.phasesRun.length}/${result.totalPhases} phases.`, result.outcome === "complete" ? "info" : "warning");
240
+ return;
241
+ }
238
242
  const phase = getNextEligiblePhase(state);
239
243
  if (!phase) {
240
244
  lastFeedbackLines = ["All phases complete."];
@@ -244,108 +248,23 @@ export default function codeCartographerExtension(pi) {
244
248
  }
245
249
  // Reject re-entry: don't spawn a duplicate runner for a phase that's
246
250
  // already in flight from a previous /codecarto-next invocation.
247
- const existing = getPhaseActivity(phase.id);
248
- if (existing && existing.status === "running") {
251
+ if (isPhaseRunning(phase.id)) {
249
252
  ctx.ui.notify(`Phase ${phase.id} is already running.`, "warning");
250
253
  return;
251
254
  }
252
255
  const config = await loadCodecartoConfig(state.workspaceDir);
253
256
  const llmSteerEnabled = flags.llmSteerOverride ?? config.orchestrator.llm_steer_next_phase;
254
- let prompt = await buildPhasePrompt(state, phase, false);
255
- if (llmSteerEnabled) {
256
- ctx.ui.notify(`Customizing ${phase.id} prompt via LLM rewriter…`, "info");
257
- const rewrite = await rewritePhasePrompt({ ctx, state, originalPrompt: prompt, nextPhaseId: phase.id });
258
- if (rewrite.used) {
259
- prompt = rewrite.prompt;
260
- ctx.ui.notify(`LLM rewriter customized ${phase.id} seed prompt.`, "info");
261
- }
262
- else {
263
- ctx.ui.notify(`LLM rewriter skipped (${rewrite.skipReason}); using stock prompt.`, "warning");
264
- }
265
- }
266
- const activity = startPhase(phase.id);
267
257
  lastFeedbackLines = [`Running ${phase.id} phase as sub-agent`];
268
258
  setUiState(ctx, state, lastFeedbackLines);
269
- ctx.ui.notify(`CodeCartographer phase: ${phase.id} (sub-agent running)`, "info");
270
- // Attach the persistent "Agents" widget so the user can watch the
271
- // phase's tool/turn/token counts live above the editor while the
272
- // orchestrator's TUI stays responsive.
273
- getAgentsWidget().attach(ctx.ui);
274
- // Fire-and-forget: spawn the phase runner asynchronously so the
275
- // orchestrator's TUI stays responsive. The runner mutates the shared
276
- // agent-state map from event callbacks; M2 will read that map from a
277
- // persistent widget. For M1 we just notify on completion.
278
- void runPhase(ctx, prompt, {
279
- onSessionCreated: (session) => { activity.session = session; },
280
- onToolStart: (id, name) => { activity.activeTools.set(id, name); activity.toolUses++; },
281
- onToolEnd: (id) => { activity.activeTools.delete(id); },
282
- onTextDelta: (_delta, fullText) => { activity.responseText = fullText; },
283
- onTurnEnd: (turnCount) => { activity.turnCount = turnCount; },
284
- onMessageEnd: (usage) => {
285
- activity.lifetimeUsage.input += usage.input;
286
- activity.lifetimeUsage.output += usage.output;
287
- activity.lifetimeUsage.cacheWrite += usage.cacheWrite;
288
- },
289
- }, { sessionName: `CodeCartographer phase: ${phase.id}` })
290
- .then((result) => {
291
- const status = result.aborted ? "aborted" : "completed";
292
- finishPhase(phase.id, { status });
293
- if (ctx.hasUI) {
294
- ctx.ui.notify(result.aborted
295
- ? `Phase ${phase.id} aborted.`
296
- : `Phase ${phase.id} sub-agent finished (${result.toolUses} tool uses, ${result.turnCount} turns).`, result.aborted ? "warning" : "info");
297
- }
298
- // Inject a CustomMessageEntry into the orchestrator's session so
299
- // the user sees a closeout summary in the TUI scrollback and the
300
- // orchestrator's LLM picks up the phase result as context on the
301
- // next turn. display:true renders in the TUI; no triggerTurn so
302
- // the LLM doesn't auto-respond — the user remains in control.
303
- pi.sendMessage({
304
- customType: "codecarto-phase-summary",
305
- content: buildPhaseSummary({
306
- phaseId: phase.id,
307
- status: result.aborted ? "aborted" : "completed",
308
- turnCount: activity.turnCount,
309
- toolUses: activity.toolUses,
310
- tokens: activity.lifetimeUsage,
311
- durationMs: (activity.completedAt ?? Date.now()) - activity.startedAt,
312
- responseText: result.responseText,
313
- }),
314
- display: true,
315
- });
316
- void recordUsage(state.workspaceDir, phase.id, status, activity);
317
- })
318
- .catch((err) => {
319
- const message = err instanceof Error ? err.message : String(err);
320
- finishPhase(phase.id, { status: "error", error: message });
321
- if (ctx.hasUI) {
322
- ctx.ui.notify(`Phase ${phase.id} sub-agent failed: ${message}`, "error");
323
- }
324
- pi.sendMessage({
325
- customType: "codecarto-phase-summary",
326
- content: buildPhaseSummary({
327
- phaseId: phase.id,
328
- status: "error",
329
- turnCount: activity.turnCount,
330
- toolUses: activity.toolUses,
331
- tokens: activity.lifetimeUsage,
332
- durationMs: (activity.completedAt ?? Date.now()) - activity.startedAt,
333
- responseText: "",
334
- error: message,
335
- }),
336
- display: true,
337
- });
338
- void recordUsage(state.workspaceDir, phase.id, "error", activity);
339
- })
259
+ // Fire-and-forget: keep the TUI responsive while the sub-agent works.
260
+ // runSinglePhase handles all side effects (steering message, notify,
261
+ // phase summary, recordUsage, dashboard regen, clearPhase linger).
262
+ void runSinglePhase(ctx, pi, state, phase, { llmSteerEnabled, signal: ctx.signal })
340
263
  .finally(() => {
341
- // Sub-agent may have written findings, owner_notes, or carry-forward
342
- // items into status.yaml. Refresh the main status widget so the
343
- // "Open questions / Carry-forward / Next" lines reflect the new
344
- // state without waiting for the user to run /codecarto-status.
264
+ // Refresh the status widget after the phase resolves so the
265
+ // "Open questions / Carry-forward / Next" lines reflect any
266
+ // owner_notes the sub-agent wrote to status.yaml.
345
267
  void refreshWorkspaceUi(ctx);
346
- // Linger 30s in M1 so /codecarto-status can show that the phase ran;
347
- // M2's widget owns the proper "linger N turns" lifecycle.
348
- setTimeout(() => clearPhase(phase.id), 30_000);
349
268
  });
350
269
  },
351
270
  });
@@ -403,76 +322,7 @@ export default function codeCartographerExtension(pi) {
403
322
  ctx.ui.notify(`Cannot complete ${validation.phaseId}: ${validation.overall}`, "error");
404
323
  return;
405
324
  }
406
- const completionTimestamp = new Date().toISOString();
407
- const updatedState = await updateStatusAtomically(ctx.cwd, (lockedState) => {
408
- const phase = resolvePhase(lockedState, validation.phaseId);
409
- if (!phase?.primary_output) {
410
- throw new Error(`Phase ${validation.phaseId} is missing primary_output.`);
411
- }
412
- const nextStatus = normalizeStatus(lockedState.status, lockedState.pipeline, lockedState.status.pipeline, lockedState.cwd);
413
- const existingPhase = nextStatus.phases[validation.phaseId] ?? {
414
- status: "pending",
415
- owner_notes: [],
416
- outputs_present: [],
417
- open_questions: [],
418
- carry_forward: [],
419
- };
420
- const gapEntries = validation.rows
421
- .filter((row) => row.result.toUpperCase().includes("PARTIAL"))
422
- .map((row) => ({
423
- kind: "needs-maintainer-decision",
424
- description: row.criterion || "Partial validation gap",
425
- deferred_reason: row.evidence || "Marked PARTIAL by validation",
426
- }));
427
- const mergedOpenQuestions = [...existingPhase.open_questions];
428
- for (const candidate of gapEntries) {
429
- const dupe = mergedOpenQuestions.some((entry) => entry.description === candidate.description && entry.deferred_reason === candidate.deferred_reason);
430
- if (!dupe)
431
- mergedOpenQuestions.push(candidate);
432
- }
433
- nextStatus.phases[validation.phaseId] = {
434
- status: "complete",
435
- owner_notes: uniqueStrings([
436
- ...existingPhase.owner_notes,
437
- `Completed via /codecarto-complete on ${completionTimestamp}.`,
438
- `Primary output: .codecarto/${validation.primaryOutput}`,
439
- `Validation: ${validation.overall}`,
440
- ]).slice(-3),
441
- outputs_present: uniqueStrings([...existingPhase.outputs_present, validation.primaryOutput]),
442
- open_questions: mergedOpenQuestions,
443
- carry_forward: existingPhase.carry_forward ?? [],
444
- };
445
- nextStatus.last_updated = completionTimestamp;
446
- const updatedWorkspaceState = {
447
- ...lockedState,
448
- status: nextStatus,
449
- };
450
- const nextEligible = getNextEligiblePhase(updatedWorkspaceState);
451
- nextStatus.current_phase = nextEligible?.id ?? "complete";
452
- nextStatus.next_actions = nextEligible
453
- ? [
454
- `Begin ${nextEligible.id} phase by producing ${nextEligible.primary_output ?? `findings/${nextEligible.id}/`}`,
455
- ]
456
- : ["All phases complete. Review findings, open questions, and downstream implementation notes."];
457
- return {
458
- state: {
459
- ...updatedWorkspaceState,
460
- status: nextStatus,
461
- },
462
- threadLogEntry: buildThreadLogEntry(validation.phaseId, validation, completionTimestamp),
463
- };
464
- });
465
- let closeoutNotice;
466
- try {
467
- const created = await ensureCloseoutStub(updatedState.workspaceDir, validation.phaseId, completionTimestamp);
468
- if (created) {
469
- closeoutNotice = `Closeout stub: .codecarto/closeouts/${closeoutFileName(dateOnly(completionTimestamp), validation.phaseId)} (fill it in)`;
470
- }
471
- }
472
- catch (error) {
473
- const message = error instanceof Error ? error.message : String(error);
474
- closeoutNotice = `Closeout stub not created: ${message}`;
475
- }
325
+ const { updatedState, closeoutNotice } = await autoCompletePhase(ctx, validation);
476
326
  lastFeedbackLines = [
477
327
  `Completed phase: ${validation.phaseId}`,
478
328
  `Validation: ${validation.overall}`,
@@ -553,4 +403,37 @@ export default function codeCartographerExtension(pi) {
553
403
  ctx.ui.notify(`CodeCartographer usage: ${totals.runs} run${totals.runs === 1 ? "" : "s"}, ${formatUsageTokens(totals.tokens.input + totals.tokens.output)} tokens total`, "info");
554
404
  },
555
405
  });
406
+ pi.registerCommand("codecarto-dashboard", {
407
+ description: "Regenerate .codecarto/dashboard.html (use --narrate for an LLM executive summary)",
408
+ getArgumentCompletions: (prefix) => {
409
+ const items = ["--narrate"]
410
+ .filter((value) => value.startsWith(prefix))
411
+ .map((value) => ({ value, label: value }));
412
+ return items.length > 0 ? items : null;
413
+ },
414
+ handler: async (args, ctx) => {
415
+ const flags = parseDashboardFlags(args);
416
+ if (flags.unknown.length > 0) {
417
+ ctx.ui.notify(`Unknown /codecarto-dashboard flag: ${flags.unknown.join(" ")}`, "error");
418
+ return;
419
+ }
420
+ const state = await ensureWorkspaceState(ctx);
421
+ if (!state)
422
+ return;
423
+ if (flags.narrate) {
424
+ ctx.ui.notify(`Narrating dashboard via LLM…`, "info");
425
+ const result = await narrateDashboard(ctx, state);
426
+ if (result.used) {
427
+ ctx.ui.notify("Narration written to .codecarto/.dashboard-narration.local.md", "info");
428
+ }
429
+ else {
430
+ ctx.ui.notify(`LLM narration skipped (${result.skipReason}); rendering deterministic dashboard.`, "warning");
431
+ }
432
+ }
433
+ await writeDashboard(ctx.cwd, PACKAGE_VERSION);
434
+ lastFeedbackLines = ["Dashboard regenerated: .codecarto/dashboard.html"];
435
+ setUiState(ctx, state, lastFeedbackLines);
436
+ ctx.ui.notify("Dashboard regenerated: .codecarto/dashboard.html", "info");
437
+ },
438
+ });
556
439
  }
@@ -1,6 +1,10 @@
1
1
  export interface NextFlags {
2
2
  llmSteerOverride?: boolean;
3
+ auto: boolean;
4
+ strict: boolean;
3
5
  unknown: string[];
6
+ /** Set when --strict is passed without --auto. Caller surfaces as an error. */
7
+ error?: string;
4
8
  }
5
9
  export declare function parseNextFlags(args: string): NextFlags;
6
10
  export declare const KNOWN_NEXT_FLAGS: readonly string[];