codecartographer-pi 0.1.3 → 0.6.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.
@@ -1,9 +1,52 @@
1
1
  import { cp, mkdir, rm, writeFile } from "node:fs/promises";
2
2
  import { basename, join, resolve } from "node:path";
3
- import { buildPhasePrompt, buildSkillPrompt, buildThreadLogEntry, buildValidationSummary, canonicalPath, closeoutFileName, createEmptyStatus, dateOnly, DEFAULT_PIPELINE_PATH, ensureCloseoutStub, getNextEligiblePhase, getPipelineLabel, getWorkspaceState, isWithinPath, listSkillNames, loadOrchestratorState, loadYamlFile, normalizeForComparison, normalizeStatus, packagedWorkspaceDir, pathExists, PIPELINE_ALIASES, resolvePhase, resolvePipelineChoice, stringifySimpleYaml, uniqueStrings, updateStatusAtomically, validatePhaseOutput, writeOrchestratorState, } from "../../core/index.js";
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";
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";
4
10
  const STATUS_WIDGET_ID = "codecarto-widget";
5
11
  const STATUS_LINE_ID = "codecarto-status";
6
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
+ function formatUsageTokens(count) {
35
+ if (count >= 1_000_000)
36
+ return `${(count / 1_000_000).toFixed(2)}M`;
37
+ if (count >= 1_000)
38
+ return `${(count / 1_000).toFixed(1)}k`;
39
+ return `${count}`;
40
+ }
41
+ function formatUsageDuration(ms) {
42
+ if (ms < 1000)
43
+ return `${ms}ms`;
44
+ if (ms < 60_000)
45
+ return `${(ms / 1000).toFixed(1)}s`;
46
+ const minutes = Math.floor(ms / 60_000);
47
+ const seconds = Math.floor((ms % 60_000) / 1000);
48
+ return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
49
+ }
7
50
  function buildStatusLines(state, extraLines = []) {
8
51
  const nextPhase = getNextEligiblePhase(state);
9
52
  const currentPhase = nextPhase?.id ?? state.status.current_phase ?? "complete";
@@ -79,6 +122,11 @@ export default function codeCartographerExtension(pi) {
79
122
  return;
80
123
  pi.setActiveTools(SAFE_TOOL_NAMES);
81
124
  });
125
+ pi.on("session_shutdown", async () => {
126
+ // Tear down the persistent agents widget so we don't leak the timer
127
+ // or render against a torn-down UI context after a session swap.
128
+ disposeAgentsWidget();
129
+ });
82
130
  pi.on("agent_end", async (_event, ctx) => {
83
131
  await refreshWorkspaceUi(ctx);
84
132
  });
@@ -153,21 +201,6 @@ export default function codeCartographerExtension(pi) {
153
201
  normalizedStatus.last_updated = new Date().toISOString();
154
202
  await writeFile(rawStatusPath, `${stringifySimpleYaml(normalizedStatus)}\n`, "utf8");
155
203
  lastFeedbackLines = [`Initialized workspace with pipeline: ${getPipelineLabel(selectedPipelinePath)}`];
156
- // Claim the current Pi session as the orchestrator for this workspace.
157
- // /codecarto-next will then spawn each phase as a child session, keeping
158
- // the orchestrator's context window clean. The pointer is gitignored
159
- // (workflow/.orchestrator.local.yaml) so it's machine-local. If we have
160
- // no session file (rare; Pi running headless), skip silently — handlers
161
- // fall back to in-place phase prompts.
162
- const orchestratorSessionFile = ctx.sessionManager.getSessionFile();
163
- const orchestratorSessionId = ctx.sessionManager.getSessionId();
164
- if (orchestratorSessionFile && orchestratorSessionId) {
165
- await writeOrchestratorState(ctx.cwd, {
166
- sessionFile: orchestratorSessionFile,
167
- sessionId: orchestratorSessionId,
168
- });
169
- lastFeedbackLines.push(`Claimed this session as the orchestrator (${orchestratorSessionId.slice(0, 8)}…).`);
170
- }
171
204
  ctx.ui.notify(`Initialized CodeCartographer (${getPipelineLabel(selectedPipelinePath)})`, "info");
172
205
  await ctx.reload();
173
206
  return;
@@ -186,8 +219,19 @@ export default function codeCartographerExtension(pi) {
186
219
  },
187
220
  });
188
221
  pi.registerCommand("codecarto-next", {
189
- description: "Queue the next eligible CodeCartographer phase prompt",
190
- handler: async (_args, ctx) => {
222
+ description: "Run the next eligible CodeCartographer phase as a sub-agent. Flags: --llm-steer / --no-llm-steer",
223
+ getArgumentCompletions: (prefix) => {
224
+ const items = ["--llm-steer", "--no-llm-steer"]
225
+ .filter((value) => value.startsWith(prefix))
226
+ .map((value) => ({ value, label: value }));
227
+ return items.length > 0 ? items : null;
228
+ },
229
+ handler: async (args, ctx) => {
230
+ const flags = parseNextFlags(args);
231
+ if (flags.unknown.length > 0) {
232
+ ctx.ui.notify(`Unknown /codecarto-next flag: ${flags.unknown.join(" ")}`, "error");
233
+ return;
234
+ }
191
235
  const state = await ensureWorkspaceState(ctx);
192
236
  if (!state)
193
237
  return;
@@ -198,70 +242,111 @@ export default function codeCartographerExtension(pi) {
198
242
  ctx.ui.notify("All CodeCartographer phases are complete.", "info");
199
243
  return;
200
244
  }
201
- const prompt = await buildPhasePrompt(state, phase, false);
202
- const orchestrator = await loadOrchestratorState(ctx.cwd);
203
- const currentSessionFile = ctx.sessionManager.getSessionFile();
204
- // Legacy path: workspace has no orchestrator pointer (created by 0.1.2
205
- // or earlier, or Pi has no session file). Queue the prompt in-place
206
- // exactly as we did before sub-agent mode existed.
207
- if (!orchestrator || !currentSessionFile) {
208
- if (ctx.isIdle())
209
- pi.sendUserMessage(prompt);
210
- else
211
- pi.sendUserMessage(prompt, { deliverAs: "followUp" });
212
- lastFeedbackLines = [`Queued phase prompt for ${phase.id} (in-place; run /codecarto-init to enable sub-agent mode)`];
213
- setUiState(ctx, state, lastFeedbackLines);
214
- ctx.ui.notify(`Queued CodeCartographer phase: ${phase.id}`, "info");
245
+ // Reject re-entry: don't spawn a duplicate runner for a phase that's
246
+ // already in flight from a previous /codecarto-next invocation.
247
+ const existing = getPhaseActivity(phase.id);
248
+ if (existing && existing.status === "running") {
249
+ ctx.ui.notify(`Phase ${phase.id} is already running.`, "warning");
215
250
  return;
216
251
  }
217
- // Orchestrator path: we're the parent. Spawn a child session for the
218
- // phase so its tool calls and reasoning land in an isolated context.
219
- if (currentSessionFile === orchestrator.sessionFile) {
220
- const result = await ctx.newSession({
221
- parentSession: orchestrator.sessionFile,
222
- withSession: async (childCtx) => {
223
- childCtx.sendUserMessage(prompt);
224
- },
225
- });
226
- if (result.cancelled) {
227
- ctx.ui.notify("Sub-agent spawn cancelled.", "warning");
228
- return;
252
+ const config = await loadCodecartoConfig(state.workspaceDir);
253
+ 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");
229
264
  }
230
- lastFeedbackLines = [`Spawned ${phase.id} phase in a sub-agent`];
231
- setUiState(ctx, state, lastFeedbackLines);
232
- ctx.ui.notify(`CodeCartographer phase: ${phase.id} (sub-agent)`, "info");
233
- return;
234
265
  }
235
- // Phase-child path: we're inside a phase sub-agent. Switch the TUI back
236
- // to the orchestrator and chain the next phase atomically. The fresh
237
- // state read inside withSession reflects any closeouts the child wrote.
238
- const switchResult = await ctx.switchSession(orchestrator.sessionFile, {
239
- withSession: async (orchestratorCtx) => {
240
- const freshState = await getWorkspaceState(orchestratorCtx.cwd);
241
- if (!freshState) {
242
- orchestratorCtx.ui.notify("Workspace state not found after switch.", "error");
243
- return;
244
- }
245
- const nextPhase = getNextEligiblePhase(freshState);
246
- if (!nextPhase) {
247
- orchestratorCtx.ui.notify("All CodeCartographer phases are complete.", "info");
248
- return;
249
- }
250
- const nextPrompt = await buildPhasePrompt(freshState, nextPhase, false);
251
- await orchestratorCtx.newSession({
252
- parentSession: orchestrator.sessionFile,
253
- withSession: async (nextChildCtx) => {
254
- nextChildCtx.sendUserMessage(nextPrompt);
255
- },
256
- });
266
+ const activity = startPhase(phase.id);
267
+ lastFeedbackLines = [`Running ${phase.id} phase as sub-agent`];
268
+ 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;
257
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
+ })
340
+ .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.
345
+ 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);
258
349
  });
259
- if (switchResult.cancelled) {
260
- ctx.ui.notify("Switch back to orchestrator cancelled.", "warning");
261
- return;
262
- }
263
- lastFeedbackLines = ["Returned to orchestrator and queued next phase as a sub-agent"];
264
- setUiState(ctx, state, lastFeedbackLines);
265
350
  },
266
351
  });
267
352
  pi.registerCommand("codecarto-phase", {
@@ -438,4 +523,34 @@ export default function codeCartographerExtension(pi) {
438
523
  ctx.ui.notify(`Queued CodeCartographer skill: ${skillName}`, "info");
439
524
  },
440
525
  });
526
+ pi.registerCommand("codecarto-usage", {
527
+ description: "Show cumulative + per-phase token usage from local phase runs",
528
+ handler: async (_args, ctx) => {
529
+ const state = await ensureWorkspaceState(ctx);
530
+ if (!state)
531
+ return;
532
+ const usage = await loadUsage(state.workspaceDir);
533
+ if (usage.runs.length === 0) {
534
+ lastFeedbackLines = ["No phase runs recorded yet."];
535
+ setUiState(ctx, state, lastFeedbackLines);
536
+ ctx.ui.notify("No phase runs recorded yet.", "info");
537
+ return;
538
+ }
539
+ const totals = computeTotals(usage);
540
+ const perPhase = computePerPhaseTotals(usage);
541
+ const lines = [];
542
+ lines.push(`Total runs: ${totals.runs}`);
543
+ lines.push(`Total tokens: ${formatUsageTokens(totals.tokens.input)} in · ${formatUsageTokens(totals.tokens.output)} out · ${formatUsageTokens(totals.tokens.cache_write)} cache-write`);
544
+ lines.push(`Total duration: ${formatUsageDuration(totals.duration_ms)} · ${totals.tool_uses} tool uses`);
545
+ lines.push("");
546
+ lines.push("Per-phase totals:");
547
+ for (const [phaseId, t] of perPhase) {
548
+ const tokensTotal = t.tokens.input + t.tokens.output;
549
+ lines.push(` ${phaseId}: ${t.runs} run${t.runs === 1 ? "" : "s"} · ${formatUsageTokens(tokensTotal)} tokens · ${t.tool_uses} tool uses · ${formatUsageDuration(t.duration_ms)}`);
550
+ }
551
+ lastFeedbackLines = lines;
552
+ setUiState(ctx, state, lastFeedbackLines);
553
+ ctx.ui.notify(`CodeCartographer usage: ${totals.runs} run${totals.runs === 1 ? "" : "s"}, ${formatUsageTokens(totals.tokens.input + totals.tokens.output)} tokens total`, "info");
554
+ },
555
+ });
441
556
  }
@@ -0,0 +1,6 @@
1
+ export interface NextFlags {
2
+ llmSteerOverride?: boolean;
3
+ unknown: string[];
4
+ }
5
+ export declare function parseNextFlags(args: string): NextFlags;
6
+ export declare const KNOWN_NEXT_FLAGS: readonly string[];
@@ -0,0 +1,19 @@
1
+ // Flag parser for /codecarto-next. The user invokes the slash command with
2
+ // an optional "--llm-steer" or "--no-llm-steer" override. We parse and
3
+ // validate; index.ts decides how to merge the override with workspace
4
+ // config to compute the effective "should we run the LLM rewriter?" bit.
5
+ const KNOWN = new Set(["--llm-steer", "--no-llm-steer"]);
6
+ export function parseNextFlags(args) {
7
+ const tokens = args.trim().split(/\s+/).filter((t) => t.length > 0);
8
+ const result = { unknown: [] };
9
+ for (const t of tokens) {
10
+ if (t === "--llm-steer")
11
+ result.llmSteerOverride = true;
12
+ else if (t === "--no-llm-steer")
13
+ result.llmSteerOverride = false;
14
+ else
15
+ result.unknown.push(t);
16
+ }
17
+ return result;
18
+ }
19
+ export const KNOWN_NEXT_FLAGS = [...KNOWN];
@@ -367,7 +367,7 @@ const HANDLERS = {
367
367
  };
368
368
  // ---------- server bootstrap ----------
369
369
  export function buildServer() {
370
- const server = new Server({ name: "codecartographer", version: "0.1.3" }, { capabilities: { tools: {} } });
370
+ const server = new Server({ name: "codecartographer", version: "0.2.0" }, { capabilities: { tools: {} } });
371
371
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
372
372
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
373
373
  const handler = HANDLERS[request.params.name];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.1.3",
3
+ "version": "0.6.0",
4
4
  "description": "CodeCartographer packaged for Pi as an extension-driven workflow wrapper.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -42,7 +42,7 @@
42
42
  "@modelcontextprotocol/sdk": "^1.29.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@earendil-works/pi-coding-agent": "^0.74.0",
45
+ "@earendil-works/pi-coding-agent": "~0.74.0",
46
46
  "@sinclair/typebox": "*"
47
47
  },
48
48
  "pi": {
@@ -1,6 +0,0 @@
1
- export interface OrchestratorState {
2
- sessionFile: string;
3
- sessionId: string;
4
- }
5
- export declare function loadOrchestratorState(cwd: string): Promise<OrchestratorState | null>;
6
- export declare function writeOrchestratorState(cwd: string, state: OrchestratorState): Promise<void>;
@@ -1,36 +0,0 @@
1
- // Per-machine pointer to the Pi session that runs as the CodeCartographer
2
- // orchestrator. Stored in `.codecarto/workflow/.orchestrator.local.yaml`
3
- // (gitignored) so committed `status.yaml` doesn't leak machine-specific
4
- // session file paths to collaborators.
5
- //
6
- // Only the Pi extension writes/reads this — the MCP path has no session
7
- // concept. When the file is missing or malformed, `/codecarto-next` falls
8
- // back to in-place phase prompts (legacy 0.1.0–0.1.2 behavior).
9
- import { mkdir, writeFile } from "node:fs/promises";
10
- import { dirname, join } from "node:path";
11
- import { pathExists } from "./utils.js";
12
- import { loadYamlFile, stringifySimpleYaml } from "./yaml.js";
13
- const ORCHESTRATOR_STATE_RELATIVE = "workflow/.orchestrator.local.yaml";
14
- function orchestratorStatePath(cwd) {
15
- return join(cwd, ".codecarto", ORCHESTRATOR_STATE_RELATIVE);
16
- }
17
- export async function loadOrchestratorState(cwd) {
18
- const path = orchestratorStatePath(cwd);
19
- if (!(await pathExists(path)))
20
- return null;
21
- let data;
22
- try {
23
- data = await loadYamlFile(path);
24
- }
25
- catch {
26
- return null;
27
- }
28
- if (!data?.sessionFile || !data?.sessionId)
29
- return null;
30
- return { sessionFile: data.sessionFile, sessionId: data.sessionId };
31
- }
32
- export async function writeOrchestratorState(cwd, state) {
33
- const path = orchestratorStatePath(cwd);
34
- await mkdir(dirname(path), { recursive: true });
35
- await writeFile(path, `${stringifySimpleYaml(state)}\n`, "utf8");
36
- }