codecartographer-pi 0.6.1 → 0.9.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/.codecarto/GUIDE.md +2 -0
- package/README.md +275 -219
- package/assets/logo.svg +42 -0
- package/dist/core/dashboard.d.ts +42 -0
- package/dist/core/dashboard.js +637 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/library.d.ts +157 -0
- package/dist/core/library.js +675 -0
- package/dist/core/orchestrator-config.d.ts +38 -0
- package/dist/core/orchestrator-config.js +86 -19
- package/dist/core/pipeline.js +15 -3
- package/dist/core/prompts.d.ts +12 -1
- package/dist/core/prompts.js +15 -6
- package/dist/core/usage.js +15 -1
- package/dist/core/utils.d.ts +21 -0
- package/dist/core/utils.js +44 -1
- package/dist/core/workspace.d.ts +1 -0
- package/dist/core/workspace.js +13 -1
- package/dist/extensions/codecarto/auto-runner.d.ts +96 -0
- package/dist/extensions/codecarto/auto-runner.js +403 -0
- package/dist/extensions/codecarto/dashboard-flags.d.ts +6 -0
- package/dist/extensions/codecarto/dashboard-flags.js +17 -0
- package/dist/extensions/codecarto/dashboard-narrator.d.ts +8 -0
- package/dist/extensions/codecarto/dashboard-narrator.js +182 -0
- package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -0
- package/dist/extensions/codecarto/dashboard-writer.js +148 -0
- package/dist/extensions/codecarto/index.js +98 -208
- package/dist/extensions/codecarto/next-flags.d.ts +4 -0
- package/dist/extensions/codecarto/next-flags.js +19 -6
- package/dist/mcp-server/server.d.ts +21 -0
- package/dist/mcp-server/server.js +305 -3
- package/package.json +3 -2
|
@@ -0,0 +1,403 @@
|
|
|
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, { auto: options.auto === true });
|
|
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
|
+
sessionFile: result.sessionFile,
|
|
91
|
+
}),
|
|
92
|
+
display: true,
|
|
93
|
+
});
|
|
94
|
+
void recordUsage(state.workspaceDir, phase.id, status, activity, result.sessionFile);
|
|
95
|
+
void writeDashboard(ctx.cwd, PACKAGE_VERSION);
|
|
96
|
+
return {
|
|
97
|
+
status: result.aborted ? "aborted" : "completed",
|
|
98
|
+
activity,
|
|
99
|
+
responseText: result.responseText,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
104
|
+
finishPhase(phase.id, { status: "error", error: message });
|
|
105
|
+
if (ctx.hasUI) {
|
|
106
|
+
ctx.ui.notify(`Phase ${phase.id} sub-agent failed: ${message}`, "error");
|
|
107
|
+
}
|
|
108
|
+
pi.sendMessage({
|
|
109
|
+
customType: "codecarto-phase-summary",
|
|
110
|
+
content: buildPhaseSummary({
|
|
111
|
+
phaseId: phase.id,
|
|
112
|
+
status: "error",
|
|
113
|
+
turnCount: activity.turnCount,
|
|
114
|
+
toolUses: activity.toolUses,
|
|
115
|
+
tokens: activity.lifetimeUsage,
|
|
116
|
+
durationMs: (activity.completedAt ?? Date.now()) - activity.startedAt,
|
|
117
|
+
responseText: "",
|
|
118
|
+
error: message,
|
|
119
|
+
}),
|
|
120
|
+
display: true,
|
|
121
|
+
});
|
|
122
|
+
void recordUsage(state.workspaceDir, phase.id, "error", activity);
|
|
123
|
+
void writeDashboard(ctx.cwd, PACKAGE_VERSION);
|
|
124
|
+
return { status: "error", activity, error: message };
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
// Linger 30s so /codecarto-status can show that the phase ran.
|
|
128
|
+
setTimeout(() => clearPhase(phase.id), 30_000);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Re-entry guard: returns true if the phase is already running from a prior
|
|
133
|
+
* spawn (manual or auto). Callers (both /codecarto-next paths) should reject
|
|
134
|
+
* before invoking runSinglePhase.
|
|
135
|
+
*/
|
|
136
|
+
export function isPhaseRunning(phaseId) {
|
|
137
|
+
const existing = getPhaseActivity(phaseId);
|
|
138
|
+
return existing?.status === "running";
|
|
139
|
+
}
|
|
140
|
+
export async function autoCompletePhase(ctx, validation) {
|
|
141
|
+
const completionTimestamp = new Date().toISOString();
|
|
142
|
+
const updatedState = await updateStatusAtomically(ctx.cwd, (lockedState) => {
|
|
143
|
+
const phase = resolvePhase(lockedState, validation.phaseId);
|
|
144
|
+
if (!phase?.primary_output) {
|
|
145
|
+
throw new Error(`Phase ${validation.phaseId} is missing primary_output.`);
|
|
146
|
+
}
|
|
147
|
+
const nextStatus = normalizeStatus(lockedState.status, lockedState.pipeline, lockedState.status.pipeline, lockedState.cwd);
|
|
148
|
+
const existingPhase = nextStatus.phases[validation.phaseId] ?? {
|
|
149
|
+
status: "pending",
|
|
150
|
+
owner_notes: [],
|
|
151
|
+
outputs_present: [],
|
|
152
|
+
open_questions: [],
|
|
153
|
+
carry_forward: [],
|
|
154
|
+
};
|
|
155
|
+
const gapEntries = validation.rows
|
|
156
|
+
.filter((row) => row.result.toUpperCase().includes("PARTIAL"))
|
|
157
|
+
.map((row) => ({
|
|
158
|
+
kind: "needs-maintainer-decision",
|
|
159
|
+
description: row.criterion || "Partial validation gap",
|
|
160
|
+
deferred_reason: row.evidence || "Marked PARTIAL by validation",
|
|
161
|
+
}));
|
|
162
|
+
const mergedOpenQuestions = [...existingPhase.open_questions];
|
|
163
|
+
for (const candidate of gapEntries) {
|
|
164
|
+
const dupe = mergedOpenQuestions.some((entry) => entry.description === candidate.description && entry.deferred_reason === candidate.deferred_reason);
|
|
165
|
+
if (!dupe)
|
|
166
|
+
mergedOpenQuestions.push(candidate);
|
|
167
|
+
}
|
|
168
|
+
nextStatus.phases[validation.phaseId] = {
|
|
169
|
+
status: "complete",
|
|
170
|
+
owner_notes: uniqueStrings([
|
|
171
|
+
...existingPhase.owner_notes,
|
|
172
|
+
`Completed via /codecarto-complete on ${completionTimestamp}.`,
|
|
173
|
+
`Primary output: .codecarto/${validation.primaryOutput}`,
|
|
174
|
+
`Validation: ${validation.overall}`,
|
|
175
|
+
]).slice(-3),
|
|
176
|
+
outputs_present: uniqueStrings([...existingPhase.outputs_present, validation.primaryOutput]),
|
|
177
|
+
open_questions: mergedOpenQuestions,
|
|
178
|
+
carry_forward: existingPhase.carry_forward ?? [],
|
|
179
|
+
};
|
|
180
|
+
nextStatus.last_updated = completionTimestamp;
|
|
181
|
+
const updatedWorkspaceState = {
|
|
182
|
+
...lockedState,
|
|
183
|
+
status: nextStatus,
|
|
184
|
+
};
|
|
185
|
+
const nextEligible = getNextEligiblePhase(updatedWorkspaceState);
|
|
186
|
+
nextStatus.current_phase = nextEligible?.id ?? "complete";
|
|
187
|
+
nextStatus.next_actions = nextEligible
|
|
188
|
+
? [`Begin ${nextEligible.id} phase by producing ${nextEligible.primary_output ?? `findings/${nextEligible.id}/`}`]
|
|
189
|
+
: ["All phases complete. Review findings, open questions, and downstream implementation notes."];
|
|
190
|
+
return {
|
|
191
|
+
state: { ...updatedWorkspaceState, status: nextStatus },
|
|
192
|
+
threadLogEntry: buildThreadLogEntry(validation.phaseId, validation, completionTimestamp),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
let closeoutNotice;
|
|
196
|
+
try {
|
|
197
|
+
const created = await ensureCloseoutStub(updatedState.workspaceDir, validation.phaseId, completionTimestamp);
|
|
198
|
+
if (created) {
|
|
199
|
+
closeoutNotice = `Closeout stub: .codecarto/closeouts/${closeoutFileName(dateOnly(completionTimestamp), validation.phaseId)} (fill it in)`;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
204
|
+
closeoutNotice = `Closeout stub not created: ${message}`;
|
|
205
|
+
}
|
|
206
|
+
void writeDashboard(ctx.cwd, PACKAGE_VERSION);
|
|
207
|
+
return { updatedState, closeoutNotice };
|
|
208
|
+
}
|
|
209
|
+
export function decideAfterPhase(phaseStatus, phaseError, validation, strict) {
|
|
210
|
+
if (phaseStatus === "aborted")
|
|
211
|
+
return { action: "aborted" };
|
|
212
|
+
if (phaseStatus === "error") {
|
|
213
|
+
return { action: "stop", reason: phaseError ?? "Sub-agent errored.", error: phaseError };
|
|
214
|
+
}
|
|
215
|
+
if (!validation) {
|
|
216
|
+
return { action: "stop", reason: "Validation skipped (no result)." };
|
|
217
|
+
}
|
|
218
|
+
if (validation.overall === "FAIL" || validation.overall === "MISSING") {
|
|
219
|
+
return {
|
|
220
|
+
action: "stop",
|
|
221
|
+
reason: `Validation ${validation.overall} on ${validation.phaseId}.`,
|
|
222
|
+
validation: validation.overall,
|
|
223
|
+
validationSummary: buildValidationSummary(validation),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (validation.overall === "PASS WITH GAPS" && strict) {
|
|
227
|
+
return {
|
|
228
|
+
action: "stop",
|
|
229
|
+
reason: `PASS WITH GAPS on ${validation.phaseId} (strict mode).`,
|
|
230
|
+
validation: validation.overall,
|
|
231
|
+
validationSummary: buildValidationSummary(validation),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return { action: "continue" };
|
|
235
|
+
}
|
|
236
|
+
export async function runAuto(ctx, pi, initialState, options) {
|
|
237
|
+
const startedAt = Date.now();
|
|
238
|
+
const phasesRun = [];
|
|
239
|
+
const totalTokens = { input: 0, output: 0, cacheWrite: 0 };
|
|
240
|
+
const totalPhases = initialState.pipeline.phase_order.length;
|
|
241
|
+
const config = await loadCodecartoConfig(initialState.workspaceDir);
|
|
242
|
+
const llmSteerEnabled = options.llmSteerOverride ?? config.orchestrator.llm_steer_next_phase;
|
|
243
|
+
let state = initialState;
|
|
244
|
+
while (true) {
|
|
245
|
+
if (options.signal?.aborted) {
|
|
246
|
+
return finish({
|
|
247
|
+
outcome: "aborted",
|
|
248
|
+
reason: "User aborted the auto run.",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const phase = getNextEligiblePhase(state);
|
|
252
|
+
if (!phase) {
|
|
253
|
+
return finish({
|
|
254
|
+
outcome: "complete",
|
|
255
|
+
reason: "Pipeline complete.",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (isPhaseRunning(phase.id)) {
|
|
259
|
+
return finish({
|
|
260
|
+
outcome: "stopped",
|
|
261
|
+
reason: `Phase ${phase.id} is already running from a prior invocation.`,
|
|
262
|
+
stoppedAt: { phaseId: phase.id },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const phaseResult = await runSinglePhase(ctx, pi, state, phase, {
|
|
266
|
+
llmSteerEnabled,
|
|
267
|
+
signal: options.signal,
|
|
268
|
+
auto: true,
|
|
269
|
+
});
|
|
270
|
+
// Accumulate tokens whether the phase succeeded, was aborted, or errored.
|
|
271
|
+
totalTokens.input += phaseResult.activity.lifetimeUsage.input;
|
|
272
|
+
totalTokens.output += phaseResult.activity.lifetimeUsage.output;
|
|
273
|
+
totalTokens.cacheWrite += phaseResult.activity.lifetimeUsage.cacheWrite;
|
|
274
|
+
// Validate only when the phase actually completed. Aborts and errors
|
|
275
|
+
// short-circuit; decideAfterPhase handles all three outcomes.
|
|
276
|
+
let validation = null;
|
|
277
|
+
if (phaseResult.status === "completed") {
|
|
278
|
+
// State must be refreshed because the sub-agent may have written
|
|
279
|
+
// findings to disk that the validator reads.
|
|
280
|
+
const stateForValidation = (await getWorkspaceState(ctx.cwd)) ?? state;
|
|
281
|
+
validation = await validatePhaseOutput(stateForValidation, phase.id);
|
|
282
|
+
}
|
|
283
|
+
const decision = decideAfterPhase(phaseResult.status, phaseResult.error, validation, options.strict);
|
|
284
|
+
if (decision.action === "aborted") {
|
|
285
|
+
return finish({
|
|
286
|
+
outcome: "aborted",
|
|
287
|
+
reason: `Aborted during ${phase.id}.`,
|
|
288
|
+
stoppedAt: { phaseId: phase.id },
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (decision.action === "stop") {
|
|
292
|
+
return finish({
|
|
293
|
+
outcome: "stopped",
|
|
294
|
+
reason: decision.reason,
|
|
295
|
+
stoppedAt: { phaseId: phase.id, validation: decision.validation, error: decision.error },
|
|
296
|
+
validationSummary: decision.validationSummary,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
// decision.action === "continue" → auto-complete and loop.
|
|
300
|
+
// validation is guaranteed non-null on the continue branch.
|
|
301
|
+
try {
|
|
302
|
+
const { updatedState } = await autoCompletePhase(ctx, validation);
|
|
303
|
+
state = updatedState;
|
|
304
|
+
phasesRun.push(phase.id);
|
|
305
|
+
options.onPhaseAdvanced?.(state);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
309
|
+
return finish({
|
|
310
|
+
outcome: "stopped",
|
|
311
|
+
reason: `Auto-complete failed on ${phase.id}: ${message}`,
|
|
312
|
+
stoppedAt: { phaseId: phase.id, error: message },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function finish(partial) {
|
|
317
|
+
return {
|
|
318
|
+
...partial,
|
|
319
|
+
phasesRun,
|
|
320
|
+
totalPhases,
|
|
321
|
+
startedAt,
|
|
322
|
+
endedAt: Date.now(),
|
|
323
|
+
totalTokens,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// ----------------------------------------------------------------------------
|
|
328
|
+
// buildAutoSummary — the codecarto-auto-summary message body
|
|
329
|
+
// ----------------------------------------------------------------------------
|
|
330
|
+
export function buildAutoSummary(result, availableSkills = []) {
|
|
331
|
+
const totalTokens = result.totalTokens.input + result.totalTokens.output;
|
|
332
|
+
const wallTime = formatMillis(result.endedAt - result.startedAt);
|
|
333
|
+
const tokensStr = formatTokenCount(totalTokens);
|
|
334
|
+
const ranOf = `${result.phasesRun.length}/${result.totalPhases} phase${result.totalPhases === 1 ? "" : "s"}`;
|
|
335
|
+
const header = (() => {
|
|
336
|
+
switch (result.outcome) {
|
|
337
|
+
case "complete":
|
|
338
|
+
return `**Auto pipeline complete.**`;
|
|
339
|
+
case "stopped":
|
|
340
|
+
return `**Auto pipeline stopped at \`${result.stoppedAt?.phaseId ?? "?"}\`.**`;
|
|
341
|
+
case "aborted":
|
|
342
|
+
return `**Auto pipeline aborted${result.stoppedAt?.phaseId ? ` during \`${result.stoppedAt.phaseId}\`` : ""}.**`;
|
|
343
|
+
}
|
|
344
|
+
})();
|
|
345
|
+
const statsLine = `_⟳ ${ranOf} · ${tokensStr} tokens · ${wallTime}_`;
|
|
346
|
+
const lines = [header, "", statsLine];
|
|
347
|
+
if (result.outcome === "stopped" && result.validationSummary && result.validationSummary.length > 0) {
|
|
348
|
+
lines.push("", "```");
|
|
349
|
+
lines.push(...result.validationSummary);
|
|
350
|
+
lines.push("```");
|
|
351
|
+
}
|
|
352
|
+
if (result.outcome === "stopped" || result.outcome === "aborted") {
|
|
353
|
+
lines.push("", result.reason);
|
|
354
|
+
lines.push("", recoveryHint(result));
|
|
355
|
+
}
|
|
356
|
+
if (result.outcome === "complete") {
|
|
357
|
+
lines.push("", "Dashboard: `.codecarto/dashboard.html`");
|
|
358
|
+
if (availableSkills.length > 0) {
|
|
359
|
+
lines.push(`Next: try \`/codecarto-skill ${availableSkills[0]}\` (also available: ${availableSkills.slice(1).join(", ") || "none"}).`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return lines.join("\n");
|
|
363
|
+
}
|
|
364
|
+
function recoveryHint(result) {
|
|
365
|
+
if (result.outcome === "aborted") {
|
|
366
|
+
return "Run `/codecarto-next --auto` to resume.";
|
|
367
|
+
}
|
|
368
|
+
const v = result.stoppedAt?.validation;
|
|
369
|
+
if (v === "FAIL" || v === "MISSING") {
|
|
370
|
+
return `Fix the phase output, then \`/codecarto-next --auto\` to resume.`;
|
|
371
|
+
}
|
|
372
|
+
if (v === "PASS WITH GAPS") {
|
|
373
|
+
return `Review gaps via \`/codecarto-validate\`, then \`/codecarto-complete ${result.stoppedAt?.phaseId ?? "<phase>"}\`, then \`/codecarto-next --auto\`.`;
|
|
374
|
+
}
|
|
375
|
+
if (result.stoppedAt?.error) {
|
|
376
|
+
return `Re-run \`/codecarto-next\` (single-step) on \`${result.stoppedAt.phaseId}\` to retry once the issue is resolved.`;
|
|
377
|
+
}
|
|
378
|
+
return "Run `/codecarto-next --auto` to resume.";
|
|
379
|
+
}
|
|
380
|
+
// ----------------------------------------------------------------------------
|
|
381
|
+
// Helpers
|
|
382
|
+
// ----------------------------------------------------------------------------
|
|
383
|
+
async function recordUsage(workspaceDir, phaseId, status, activity, sessionFile) {
|
|
384
|
+
try {
|
|
385
|
+
await appendUsageRun(workspaceDir, {
|
|
386
|
+
timestamp: new Date().toISOString(),
|
|
387
|
+
phase: phaseId,
|
|
388
|
+
status,
|
|
389
|
+
turn_count: activity.turnCount,
|
|
390
|
+
tool_uses: activity.toolUses,
|
|
391
|
+
duration_ms: (activity.completedAt ?? Date.now()) - activity.startedAt,
|
|
392
|
+
tokens: {
|
|
393
|
+
input: activity.lifetimeUsage.input,
|
|
394
|
+
output: activity.lifetimeUsage.output,
|
|
395
|
+
cache_write: activity.lifetimeUsage.cacheWrite,
|
|
396
|
+
},
|
|
397
|
+
...(sessionFile ? { session_file: sessionFile } : {}),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// Best-effort, matches the original recordUsage discipline.
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -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>;
|
|
@@ -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>;
|