codecartographer-pi 0.10.0 → 0.11.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.
Files changed (39) hide show
  1. package/.codecarto/GUIDE.md +10 -3
  2. package/.codecarto/findings/porting/SKILL.md +7 -0
  3. package/.codecarto/findings/reimplementation-spec/SKILL.md +10 -0
  4. package/.codecarto/templates/architecture-map.md +9 -0
  5. package/.codecarto/templates/behavioral-contracts.md +9 -0
  6. package/.codecarto/templates/defect-report.md +9 -0
  7. package/.codecarto/templates/mechanical-defects.md +9 -0
  8. package/.codecarto/templates/phase-checkpoint.md +41 -0
  9. package/.codecarto/templates/protocols-and-state.md +9 -0
  10. package/.codecarto/templates/reimplementation-spec-opinionated.md +10 -0
  11. package/.codecarto/templates/reimplementation-spec.md +10 -0
  12. package/.codecarto/templates/reverse-engineering-bundle.md +29 -3
  13. package/.codecarto/templates/semantic-defects.md +9 -0
  14. package/.codecarto/workflow/pipeline-architecture-only.yaml +1 -0
  15. package/.codecarto/workflow/pipeline-defect-scan.yaml +2 -0
  16. package/.codecarto/workflow/pipeline-full-with-audit.yaml +8 -3
  17. package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +9 -5
  18. package/.codecarto/workflow/pipeline-lite.yaml +3 -0
  19. package/.codecarto/workflow/pipeline.yaml +7 -3
  20. package/README.md +38 -2
  21. package/dist/core/dashboard.js +18 -5
  22. package/dist/core/prompts.js +6 -0
  23. package/dist/core/usage.d.ts +11 -0
  24. package/dist/core/usage.js +64 -46
  25. package/dist/extensions/codecarto/agent-rewriter.js +0 -1
  26. package/dist/extensions/codecarto/agent-runner.d.ts +19 -0
  27. package/dist/extensions/codecarto/agent-runner.js +68 -6
  28. package/dist/extensions/codecarto/agent-state.d.ts +3 -0
  29. package/dist/extensions/codecarto/agent-state.js +2 -0
  30. package/dist/extensions/codecarto/agent-summary.d.ts +5 -0
  31. package/dist/extensions/codecarto/agent-summary.js +9 -0
  32. package/dist/extensions/codecarto/agent-widget.js +6 -0
  33. package/dist/extensions/codecarto/auto-runner.js +13 -1
  34. package/dist/extensions/codecarto/dashboard-narrator.js +0 -1
  35. package/dist/extensions/codecarto/index.d.ts +1 -1
  36. package/dist/extensions/codecarto/index.js +28 -1
  37. package/dist/extensions/codecarto/phase-compaction.d.ts +11 -0
  38. package/dist/extensions/codecarto/phase-compaction.js +115 -0
  39. package/package.json +2 -2
package/README.md CHANGED
@@ -114,6 +114,39 @@ For multi-session work, every new session reads `.codecarto/GUIDE.md` (or the li
114
114
 
115
115
  ---
116
116
 
117
+ ## Progressive distillation and context resilience
118
+
119
+ CodeCartographer is a progressive, evidence-tagged distillation of a codebase. It does not ask one context window to retain the entire investigation. Instead, each phase turns a large body of source evidence into a smaller, more task-specific artifact that the next phase can read:
120
+
121
+ ```text
122
+ source code
123
+ → architecture map
124
+ → behavioral contracts + protocols + defect findings
125
+ → porting bundle
126
+ → reimplementation spec
127
+ ```
128
+
129
+ This is deliberate distillation, not incidental chat summarization. Each artifact follows a template, preserves evidence levels and known unknowns, and must pass validation before it becomes an input to downstream phases.
130
+
131
+ ### What happens when conversation context is compacted?
132
+
133
+ The filesystem, not the conversation, is the durable memory of a run:
134
+
135
+ - Each phase gets a fresh context window. In the Pi extension it runs as an isolated phase sub-agent; MCP and drop-in hosts should use the same one-session-per-phase pattern.
136
+ - Completed findings live under `.codecarto/findings/`. Later phases re-read the specific upstream artifacts declared by the active pipeline instead of relying on conversational recall.
137
+ - `workflow/status.yaml` records progress, `open_questions`, and `carry_forward` items routed to later phases. `CONVENTIONS.md`, `DECISIONS.md`, closeouts, and `THREAD_LOG.md` preserve cross-session knowledge and handoffs.
138
+ - Pi phase transcripts are file-backed and remain available through `/resume`, `/tree`, and `/export`, even when the active model context has been compacted.
139
+ - For isolated Pi phase sessions, compaction uses a phase-aware continuation summary that explicitly preserves evidence, files inspected, output progress, open questions, and validation gaps. The resulting summary is also checkpointed atomically at `.codecarto/scratch/checkpoints/<phase>.md`.
140
+ - Pi records successful, failed, and aborted compactions plus their trigger (`threshold`, `overflow`, or `manual`) in local usage data and exposes the totals in the widget, `/codecarto-usage`, completion summaries, and dashboard.
141
+
142
+ As a result, compaction—or even replacement—of the orchestrator session does not erase pipeline progress. A new session can reconstruct the relevant state from disk and continue.
143
+
144
+ The remaining limit is **within a single oversized phase**. Even Pi's phase-aware summary is still a lossy distillation, and MCP/drop-in compaction remains entirely host-controlled. Phase instructions therefore prioritize targeted reads, durable checkpoints, and explicit coverage accounting; if full coverage will not fit, the phase records `PARTIAL` validation and places unresolved work in `open_questions` or `carry_forward`. Cross-phase context loss is largely designed out; intra-phase context pressure is observed and bounded rather than hidden.
145
+
146
+ The porting bundle is the final intentional compression boundary. It carries a source index, load-bearing invariants, defect dispositions, and deep-read triggers. `reimplementation-spec` reads that bundle by default and opens lower-level reports only for a named gap, conflict, missing acceptance detail, or defect rationale.
147
+
148
+ ---
149
+
117
150
  ## Phases produce these artifacts
118
151
 
119
152
  | Artifact | Description |
@@ -164,7 +197,7 @@ Every state change re-renders `.codecarto/dashboard.html` — a self-contained s
164
197
 
165
198
  - Pipeline progress strip with per-phase status badges
166
199
  - Per-phase cards with output links, open questions, carry-forward routing, owner notes, last-run usage
167
- - Aggregate token usage panel + per-phase breakdown
200
+ - Aggregate token and compaction telemetry + per-phase breakdown
168
201
  - Activity timeline with session-file links
169
202
  - Open questions roll-up grouped by source phase
170
203
  - Closeouts list (reverse-chronological) with relative-path links
@@ -191,11 +224,13 @@ Beyond the slash commands, the Pi extension layers on:
191
224
 
192
225
  **File-backed phase sessions.** Phase transcripts persist to the same Pi session directory the orchestrator uses, so `/resume`, `/tree`, and `/export` browse them as first-class sessions. Each appears as `CodeCartographer phase: <id>` with lineage back to the orchestrator's session.
193
226
 
227
+ **Phase-aware compaction and checkpoints.** Only isolated sessions named `CodeCartographer phase: <id>` receive the specialized compaction prompt. It preserves the phase goal, evidence, inspected files, output progress, open questions, and validation gaps, then writes the resulting summary to `.codecarto/scratch/checkpoints/<phase>.md`. Orchestrator and unrelated Pi sessions retain normal host compaction.
228
+
194
229
  **Phase-completion summary in the orchestrator transcript.** When a phase finishes, a Markdown closeout block is appended to the orchestrator's session via `pi.sendMessage(...)`. Visible in the TUI scrollback; available to the orchestrator's LLM as context on your next message. No auto-trigger — you stay in control.
195
230
 
196
231
  **Opt-in LLM-steered seed prompts.** Set `orchestrator.llm_steer_next_phase: true` in `.codecarto/workflow/config.yaml` (or pass `--llm-steer` per invocation), and the orchestrator's LLM rewrites the next phase's seed prompt to highlight relevant prior findings. Off by default — extra orchestrator-side tokens, opt-in. The rewritten prompt is injected into the orchestrator transcript so you can audit what the rewriter chose to emphasize.
197
232
 
198
- **Per-phase usage tracking.** Each phase run is appended to `.codecarto/workflow/.usage.local.yaml`. `/codecarto-usage` reports cumulative + per-phase totals.
233
+ **Per-phase usage tracking.** Each phase run is appended to `.codecarto/workflow/.usage.local.yaml`. `/codecarto-usage` reports cumulative + per-phase token, runtime, tool-use, and compaction totals, including threshold/overflow/manual triggers and successful/failed/aborted outcomes.
199
234
 
200
235
  **Tool interception.** `bash` is blocked outright; `edit` and `write` are confined to `.codecarto/`. Same rules apply to phase sub-agents.
201
236
 
@@ -204,6 +239,7 @@ Beyond the slash commands, the Pi extension layers on:
204
239
  | Command | Purpose |
205
240
  |---|---|
206
241
  | `/codecarto-init [variant]` | Copy `.codecarto/` into the current repository, select pipeline variant |
242
+ | `/codecarto-open` | Activate an existing `.codecarto/` workspace in a new Pi session without resetting durable state |
207
243
  | `/codecarto-status` | Current phase, progress, open questions |
208
244
  | `/codecarto-next [--auto [--strict]] [--llm-steer \| --no-llm-steer]` | Spawn the next eligible phase as a sub-agent. `--auto` walks the full pipeline end-to-end (auto-validate + auto-complete + advance); `--strict` flips the `PASS WITH GAPS` rule from "advance" to "pause". |
209
245
  | `/codecarto-phase <id>` | Force a specific phase, even out of pipeline order |
@@ -369,7 +369,8 @@ function renderPhaseLastRun(run) {
369
369
  const tokensTotal = formatRunTokens(run);
370
370
  const sessionLink = run.session_file ? renderSafeLink(run.session_file, "transcript") : undefined;
371
371
  const session = sessionLink ? `<dt>Session</dt><dd>${sessionLink}</dd>` : "";
372
- return [`<div class="cc-phase-section">`, `<h3>Last run</h3>`, `<dl class="cc-run-meta">`, `<dt>Timestamp</dt><dd>${escapeHtml(run.timestamp)}</dd>`, `<dt>Status</dt><dd>${escapeHtml(run.status)}</dd>`, `<dt>Turns</dt><dd>${run.turn_count}</dd>`, `<dt>Tool uses</dt><dd>${run.tool_uses}</dd>`, `<dt>Tokens</dt><dd>${escapeHtml(tokensTotal)}</dd>`, `<dt>Duration</dt><dd>${escapeHtml(formatMillis(run.duration_ms))}</dd>`, session, `</dl>`, `</div>`].join("");
372
+ const compactions = run.compactions ? `<dt>Compactions</dt><dd>${formatCompactionCounts(run.compactions)}</dd>` : "";
373
+ return [`<div class="cc-phase-section">`, `<h3>Last run</h3>`, `<dl class="cc-run-meta">`, `<dt>Timestamp</dt><dd>${escapeHtml(run.timestamp)}</dd>`, `<dt>Status</dt><dd>${escapeHtml(run.status)}</dd>`, `<dt>Turns</dt><dd>${run.turn_count}</dd>`, `<dt>Tool uses</dt><dd>${run.tool_uses}</dd>`, `<dt>Tokens</dt><dd>${escapeHtml(tokensTotal)}</dd>`, `<dt>Duration</dt><dd>${escapeHtml(formatMillis(run.duration_ms))}</dd>`, compactions, session, `</dl>`, `</div>`].join("");
373
374
  }
374
375
  function renderUsagePanel(inputs) {
375
376
  const { usage, pipeline, status } = inputs;
@@ -382,10 +383,11 @@ function renderUsagePanel(inputs) {
382
383
  const t = perPhase.get(phaseId);
383
384
  if (!t) {
384
385
  const complete = status.phases[phaseId]?.status === "complete";
385
- return `<tr class="${complete ? "cc-usage-missing" : ""}"><td><a href="#${phaseAnchor(phaseId)}">${escapeHtml(phaseId)}</a></td><td>0</td><td>—</td><td>—</td><td>—</td><td>${complete ? "usage not recorded" : "not run"}</td></tr>`;
386
+ return `<tr class="${complete ? "cc-usage-missing" : ""}"><td><a href="#${phaseAnchor(phaseId)}">${escapeHtml(phaseId)}</a></td><td>0</td><td>—</td><td>—</td><td>—</td><td>—</td><td>${complete ? "usage not recorded" : "not run"}</td></tr>`;
386
387
  }
387
388
  const tokensTotal = t.tokens.input + t.tokens.output;
388
- return `<tr><td><a href="#${phaseAnchor(phaseId)}">${escapeHtml(phaseId)}</a></td><td>${t.runs}</td><td>${escapeHtml(tokenAccounting ? formatTokenCount(tokensTotal) : "unavailable")}</td><td>${renderUsageBar(t.tool_uses, maxTools, String(t.tool_uses))}</td><td>${renderUsageBar(t.duration_ms, maxDuration, formatMillis(t.duration_ms))}</td><td>${usagePhaseNote(phaseId, status)}</td></tr>`;
389
+ const compactions = t.compaction_runs > 0 ? formatCompactionTriplet(t.compactions) : "unavailable";
390
+ return `<tr><td><a href="#${phaseAnchor(phaseId)}">${escapeHtml(phaseId)}</a></td><td>${t.runs}</td><td>${escapeHtml(tokenAccounting ? formatTokenCount(tokensTotal) : "unavailable")}</td><td>${renderUsageBar(t.tool_uses, maxTools, String(t.tool_uses))}</td><td>${renderUsageBar(t.duration_ms, maxDuration, formatMillis(t.duration_ms))}</td><td>${compactions}</td><td>${usagePhaseNote(phaseId, status)}</td></tr>`;
389
391
  }).join("");
390
392
  return [
391
393
  `<section class="cc-card cc-usage" id="usage" aria-label="Token usage" data-section data-search-text="usage tokens tool duration">`,
@@ -393,7 +395,7 @@ function renderUsagePanel(inputs) {
393
395
  usage.runs.length === 0 ? `<p class="cc-empty">No phase runs recorded yet.</p>` : `<dl class="cc-usage-totals">${renderUsageTotalsList(totals, tokenAccounting)}</dl>`,
394
396
  renderUsageInsights(perPhase, status),
395
397
  `<table class="cc-usage-table">`,
396
- `<thead><tr><th>Phase</th><th>Runs</th><th>Tokens</th><th>Tools</th><th>Duration</th><th>State</th></tr></thead>`,
398
+ `<thead><tr><th>Phase</th><th>Runs</th><th>Tokens</th><th>Tools</th><th>Duration</th><th>Compactions</th><th>State</th></tr></thead>`,
397
399
  `<tbody>${rows}</tbody>`,
398
400
  `</table>`,
399
401
  `</section>`,
@@ -405,7 +407,18 @@ function renderUsageTotalsList(totals, tokenAccounting) {
405
407
  ? `${escapeHtml(formatTokenCount(totals.tokens.input))} / ${escapeHtml(formatTokenCount(totals.tokens.output))} / ${escapeHtml(formatTokenCount(totals.tokens.cache_write))}`
406
408
  : `<span class="cc-muted">unavailable — host did not report token counts</span>`;
407
409
  const tokenTotal = tokenAccounting ? escapeHtml(formatTokenCount(tokensTotal)) : `<span class="cc-muted">unavailable</span>`;
408
- return [`<dt>Total runs</dt><dd>${totals.runs}</dd>`, `<dt>Tokens (in / out / cache)</dt><dd>${tokenDetail}</dd>`, `<dt>Total tokens</dt><dd>${tokenTotal}</dd>`, `<dt>Tool uses</dt><dd>${totals.tool_uses}</dd>`, `<dt>Total duration</dt><dd>${escapeHtml(formatMillis(totals.duration_ms))}</dd>`].join("");
410
+ const compactionCounts = totals.compaction_runs > 0 ? formatCompactionCounts(totals.compactions) : `<span class="cc-muted">unavailable host did not report compaction events</span>`;
411
+ const compactionReasons = totals.compaction_runs > 0 ? `<dt>Reasons</dt><dd>${formatCompactionReasons(totals.compactions)}</dd>` : "";
412
+ return [`<dt>Total runs</dt><dd>${totals.runs}</dd>`, `<dt>Tokens (in / out / cache)</dt><dd>${tokenDetail}</dd>`, `<dt>Total tokens</dt><dd>${tokenTotal}</dd>`, `<dt>Tool uses</dt><dd>${totals.tool_uses}</dd>`, `<dt>Total duration</dt><dd>${escapeHtml(formatMillis(totals.duration_ms))}</dd>`, `<dt>Compactions</dt><dd>${compactionCounts}</dd>`, compactionReasons].join("");
413
+ }
414
+ function formatCompactionCounts(value) {
415
+ return `${value.successful} successful · ${value.failed} failed · ${value.aborted} aborted`;
416
+ }
417
+ function formatCompactionReasons(value) {
418
+ return `threshold ${value.reasons.threshold} · overflow ${value.reasons.overflow} · manual ${value.reasons.manual}`;
419
+ }
420
+ function formatCompactionTriplet(value) {
421
+ return `${value.successful} / ${value.failed} / ${value.aborted}`;
409
422
  }
410
423
  function renderUsageInsights(perPhase, status) {
411
424
  if (perPhase.size === 0)
@@ -48,6 +48,10 @@ export async function buildPhasePrompt(state, phase, forced, options = {}) {
48
48
  for (const path of phaseReads) {
49
49
  lines.push(`- .codecarto/${path}`);
50
50
  }
51
+ const checkpointRelativePath = `scratch/checkpoints/${phase.id}.md`;
52
+ if (await pathExists(join(state.workspaceDir, checkpointRelativePath))) {
53
+ lines.push(`- .codecarto/${checkpointRelativePath} (resume from durable in-phase progress after compaction or interruption)`);
54
+ }
51
55
  const conventionsPath = join(state.workspaceDir, "CONVENTIONS.md");
52
56
  if (await pathExists(conventionsPath)) {
53
57
  lines.push("- .codecarto/CONVENTIONS.md (cross-cutting patterns the orchestrator has promoted)");
@@ -85,6 +89,8 @@ export async function buildPhasePrompt(state, phase, forced, options = {}) {
85
89
  lines.push("- Do not modify source files outside .codecarto/.");
86
90
  lines.push("- Follow the active pipeline and validation protocol.");
87
91
  lines.push("- Update findings under .codecarto/findings/ for this phase.");
92
+ lines.push(`- For long phases, checkpoint resumable progress at .codecarto/scratch/checkpoints/${phase.id}.md; Pi writes this automatically after phase compaction.`);
93
+ lines.push("- Include a Coverage and limits section that names inspected scope, skipped scope, evidence basis, and blind spots; route material gaps through PARTIAL validation and open_questions/carry_forward.");
88
94
  lines.push("- Distinguish open_questions (genuinely unknown) from carry_forward (routed to a specific later phase) when updating workflow/status.yaml — see GUIDE.md \"Open Questions vs Carry-Forward\".");
89
95
  if (forced) {
90
96
  lines.push("- The user explicitly requested this phase even if it is not the next eligible phase.");
@@ -1,10 +1,18 @@
1
1
  export declare const USAGE_RELATIVE_PATH = "workflow/.usage.local.yaml";
2
2
  export type UsageRunStatus = "completed" | "aborted" | "error";
3
+ export type CompactionReason = "threshold" | "overflow" | "manual";
3
4
  export interface UsageTokens {
4
5
  input: number;
5
6
  output: number;
6
7
  cache_write: number;
7
8
  }
9
+ export interface CompactionTelemetry {
10
+ successful: number;
11
+ failed: number;
12
+ aborted: number;
13
+ reasons: Record<CompactionReason, number>;
14
+ }
15
+ export declare function emptyCompactionTelemetry(): CompactionTelemetry;
8
16
  export interface UsageRun {
9
17
  timestamp: string;
10
18
  phase: string;
@@ -14,6 +22,7 @@ export interface UsageRun {
14
22
  duration_ms: number;
15
23
  tokens: UsageTokens;
16
24
  session_file?: string;
25
+ compactions?: CompactionTelemetry;
17
26
  }
18
27
  export interface UsageFile {
19
28
  version: number;
@@ -21,9 +30,11 @@ export interface UsageFile {
21
30
  }
22
31
  export interface UsageTotals {
23
32
  runs: number;
33
+ compaction_runs: number;
24
34
  tokens: UsageTokens;
25
35
  tool_uses: number;
26
36
  duration_ms: number;
37
+ compactions: CompactionTelemetry;
27
38
  }
28
39
  export declare function loadUsage(workspaceDir: string): Promise<UsageFile>;
29
40
  export declare function appendUsageRun(workspaceDir: string, run: UsageRun): Promise<void>;
@@ -13,6 +13,9 @@ import { pathExists } from "./utils.js";
13
13
  import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
14
14
  export const USAGE_RELATIVE_PATH = "workflow/.usage.local.yaml";
15
15
  const SCHEMA_VERSION = 1;
16
+ export function emptyCompactionTelemetry() {
17
+ return { successful: 0, failed: 0, aborted: 0, reasons: { threshold: 0, overflow: 0, manual: 0 } };
18
+ }
16
19
  export async function loadUsage(workspaceDir) {
17
20
  const path = join(workspaceDir, USAGE_RELATIVE_PATH);
18
21
  if (!(await pathExists(path)))
@@ -23,8 +26,6 @@ export async function loadUsage(workspaceDir) {
23
26
  return normalize(parsed);
24
27
  }
25
28
  catch {
26
- // Malformed file: treat as empty rather than blocking the user. They
27
- // can fix or delete the file; corrupt local state shouldn't stop work.
28
29
  return emptyUsage();
29
30
  }
30
31
  }
@@ -33,45 +34,56 @@ export async function appendUsageRun(workspaceDir, run) {
33
34
  current.runs.push(run);
34
35
  const path = join(workspaceDir, USAGE_RELATIVE_PATH);
35
36
  const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
36
- const serialized = `${stringifySimpleYaml(current)}\n`;
37
- await writeFile(tempPath, serialized, "utf8");
37
+ await writeFile(tempPath, `${stringifySimpleYaml(current)}\n`, "utf8");
38
38
  await rename(tempPath, path);
39
39
  }
40
40
  export function computeTotals(file) {
41
- const totals = {
42
- runs: file.runs.length,
43
- tokens: { input: 0, output: 0, cache_write: 0 },
44
- tool_uses: 0,
45
- duration_ms: 0,
46
- };
47
- for (const r of file.runs) {
48
- totals.tokens.input += r.tokens?.input ?? 0;
49
- totals.tokens.output += r.tokens?.output ?? 0;
50
- totals.tokens.cache_write += r.tokens?.cache_write ?? 0;
51
- totals.tool_uses += r.tool_uses ?? 0;
52
- totals.duration_ms += r.duration_ms ?? 0;
53
- }
41
+ const totals = emptyTotals();
42
+ totals.runs = file.runs.length;
43
+ for (const run of file.runs)
44
+ addRun(totals, run);
54
45
  return totals;
55
46
  }
56
47
  export function computePerPhaseTotals(file) {
57
48
  const byPhase = new Map();
58
- for (const r of file.runs) {
59
- const t = byPhase.get(r.phase) ?? {
60
- runs: 0,
61
- tokens: { input: 0, output: 0, cache_write: 0 },
62
- tool_uses: 0,
63
- duration_ms: 0,
64
- };
65
- t.runs += 1;
66
- t.tokens.input += r.tokens?.input ?? 0;
67
- t.tokens.output += r.tokens?.output ?? 0;
68
- t.tokens.cache_write += r.tokens?.cache_write ?? 0;
69
- t.tool_uses += r.tool_uses ?? 0;
70
- t.duration_ms += r.duration_ms ?? 0;
71
- byPhase.set(r.phase, t);
49
+ for (const run of file.runs) {
50
+ const totals = byPhase.get(run.phase) ?? emptyTotals();
51
+ totals.runs += 1;
52
+ addRun(totals, run);
53
+ byPhase.set(run.phase, totals);
72
54
  }
73
55
  return byPhase;
74
56
  }
57
+ function emptyTotals() {
58
+ return {
59
+ runs: 0,
60
+ compaction_runs: 0,
61
+ tokens: { input: 0, output: 0, cache_write: 0 },
62
+ tool_uses: 0,
63
+ duration_ms: 0,
64
+ compactions: emptyCompactionTelemetry(),
65
+ };
66
+ }
67
+ function addRun(totals, run) {
68
+ totals.tokens.input += run.tokens?.input ?? 0;
69
+ totals.tokens.output += run.tokens?.output ?? 0;
70
+ totals.tokens.cache_write += run.tokens?.cache_write ?? 0;
71
+ totals.tool_uses += run.tool_uses ?? 0;
72
+ totals.duration_ms += run.duration_ms ?? 0;
73
+ if (run.compactions)
74
+ totals.compaction_runs += 1;
75
+ addCompactions(totals.compactions, run.compactions);
76
+ }
77
+ function addCompactions(target, source) {
78
+ if (!source)
79
+ return;
80
+ target.successful += source.successful;
81
+ target.failed += source.failed;
82
+ target.aborted += source.aborted;
83
+ target.reasons.threshold += source.reasons.threshold;
84
+ target.reasons.overflow += source.reasons.overflow;
85
+ target.reasons.manual += source.reasons.manual;
86
+ }
75
87
  function emptyUsage() {
76
88
  return { version: SCHEMA_VERSION, runs: [] };
77
89
  }
@@ -79,29 +91,35 @@ function normalize(raw) {
79
91
  if (!raw || typeof raw !== "object")
80
92
  return emptyUsage();
81
93
  const runs = Array.isArray(raw.runs) ? raw.runs.filter(isUsageRun) : [];
82
- return {
83
- version: typeof raw.version === "number" ? raw.version : SCHEMA_VERSION,
84
- runs,
85
- };
94
+ return { version: typeof raw.version === "number" ? raw.version : SCHEMA_VERSION, runs };
86
95
  }
87
96
  function isUsageRun(x) {
88
97
  if (!x || typeof x !== "object")
89
98
  return false;
90
- const r = x;
91
- return (typeof r.timestamp === "string" &&
92
- typeof r.phase === "string" &&
93
- (r.status === "completed" || r.status === "aborted" || r.status === "error") &&
94
- isFiniteNumber(r.turn_count) &&
95
- isFiniteNumber(r.tool_uses) &&
96
- isFiniteNumber(r.duration_ms) &&
97
- isUsageTokens(r.tokens) &&
98
- (r.session_file === undefined || typeof r.session_file === "string"));
99
+ const run = x;
100
+ return (typeof run.timestamp === "string" &&
101
+ typeof run.phase === "string" &&
102
+ (run.status === "completed" || run.status === "aborted" || run.status === "error") &&
103
+ isFiniteNumber(run.turn_count) &&
104
+ isFiniteNumber(run.tool_uses) &&
105
+ isFiniteNumber(run.duration_ms) &&
106
+ isUsageTokens(run.tokens) &&
107
+ (run.session_file === undefined || typeof run.session_file === "string") &&
108
+ (run.compactions === undefined || isCompactionTelemetry(run.compactions)));
99
109
  }
100
110
  function isUsageTokens(x) {
101
111
  if (!x || typeof x !== "object")
102
112
  return false;
103
- const t = x;
104
- return isFiniteNumber(t.input) && isFiniteNumber(t.output) && isFiniteNumber(t.cache_write);
113
+ const tokens = x;
114
+ return isFiniteNumber(tokens.input) && isFiniteNumber(tokens.output) && isFiniteNumber(tokens.cache_write);
115
+ }
116
+ function isCompactionTelemetry(x) {
117
+ if (!x || typeof x !== "object")
118
+ return false;
119
+ const telemetry = x;
120
+ const reasons = telemetry.reasons;
121
+ return isFiniteNumber(telemetry.successful) && isFiniteNumber(telemetry.failed) && isFiniteNumber(telemetry.aborted) &&
122
+ Boolean(reasons) && isFiniteNumber(reasons?.threshold) && isFiniteNumber(reasons?.overflow) && isFiniteNumber(reasons?.manual);
105
123
  }
106
124
  function isFiniteNumber(x) {
107
125
  return typeof x === "number" && Number.isFinite(x);
@@ -145,7 +145,6 @@ async function runRewriterOnce(ctx, prompt) {
145
145
  agentDir,
146
146
  sessionManager: SessionManager.inMemory(cwd),
147
147
  settingsManager: SettingsManager.create(cwd, agentDir),
148
- modelRegistry: ctx.modelRegistry,
149
148
  model: ctx.model,
150
149
  tools: [],
151
150
  resourceLoader: loader,
@@ -1,4 +1,15 @@
1
1
  import { type AgentSession, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ export declare function needsPhaseContinuation(messages: ReadonlyArray<{
3
+ role: string;
4
+ stopReason?: string;
5
+ }>): boolean;
6
+ export declare function shouldContinuePhase(messages: ReadonlyArray<{
7
+ role: string;
8
+ stopReason?: string;
9
+ }>, primaryOutputPresent: boolean): boolean;
10
+ export declare function primaryOutputExists(cwd: string, primaryOutput: string): Promise<boolean>;
11
+ export declare function buildPhaseContinuationPrompt(compacted: boolean): string;
12
+ export declare function waitForCompaction(compactionCompleted: Promise<boolean>, timeoutMs?: number): Promise<boolean>;
2
13
  export interface PhaseRunCallbacks {
3
14
  onSessionCreated?: (session: AgentSession) => void;
4
15
  onToolStart?: (toolCallId: string, toolName: string) => void;
@@ -10,12 +21,20 @@ export interface PhaseRunCallbacks {
10
21
  output: number;
11
22
  cacheWrite: number;
12
23
  }) => void;
24
+ onCompactionEnd?: (event: {
25
+ reason: "manual" | "threshold" | "overflow";
26
+ successful: boolean;
27
+ aborted: boolean;
28
+ }) => void;
13
29
  }
14
30
  export interface PhaseRunOptions {
15
31
  /** Display name written via appendSessionInfo so the session shows up in
16
32
  * /resume's picker as e.g. "CodeCartographer phase: blueprint". Pi reads
17
33
  * it via SessionManager.getSessionName(). */
18
34
  sessionName?: string;
35
+ /** Primary output relative to `.codecarto/`; used to detect provider runs
36
+ * that stop normally before writing their required artifact. */
37
+ primaryOutput?: string;
19
38
  }
20
39
  export interface PhaseRunResult {
21
40
  session: AgentSession;
@@ -9,11 +9,51 @@
9
9
  // system prompt, no parent-context inheritance, no turn-limit grace logic.
10
10
  // Codecarto phases are bounded by their phase prompt and validation gate;
11
11
  // they don't need the full subagent-framework machinery.
12
+ import { access } from "node:fs/promises";
13
+ import { join, resolve } from "node:path";
12
14
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
15
+ import { canonicalPath, isWithinPath } from "../../core/index.js";
16
+ import { phaseCompactionExtension } from "./phase-compaction.js";
13
17
  // Tools available to the phase sub-agent. Matches the codecarto interception
14
18
  // allowlist (SAFE_TOOL_NAMES in extensions/codecarto/index.ts), minus bash.
15
19
  // Phases analyze source code and write findings; they don't need a shell.
16
20
  const PHASE_TOOL_NAMES = ["read", "edit", "write", "grep", "find", "ls"];
21
+ const COMPACTION_SETTLE_TIMEOUT_MS = 30_000;
22
+ export function needsPhaseContinuation(messages) {
23
+ const last = messages.at(-1);
24
+ if (!last)
25
+ return false;
26
+ return last.role === "toolResult" || (last.role === "assistant" && last.stopReason === "toolUse");
27
+ }
28
+ export function shouldContinuePhase(messages, primaryOutputPresent) {
29
+ return !primaryOutputPresent || needsPhaseContinuation(messages);
30
+ }
31
+ export async function primaryOutputExists(cwd, primaryOutput) {
32
+ const workspaceRoot = await canonicalPath(join(cwd, ".codecarto"));
33
+ const candidate = await canonicalPath(resolve(workspaceRoot, primaryOutput));
34
+ if (!isWithinPath(candidate, workspaceRoot))
35
+ return false;
36
+ return access(candidate).then(() => true, () => false);
37
+ }
38
+ export function buildPhaseContinuationPrompt(compacted) {
39
+ const recovery = compacted
40
+ ? "Continue the current CodeCartographer phase from the compacted context and durable checkpoint."
41
+ : "The previous phase run stopped before finalizing its required output. Continue from the current session context.";
42
+ return `${recovery} Finish the declared primary output, validation block, status updates, and closeout before ending.`;
43
+ }
44
+ export async function waitForCompaction(compactionCompleted, timeoutMs = COMPACTION_SETTLE_TIMEOUT_MS) {
45
+ let timer;
46
+ try {
47
+ return await Promise.race([
48
+ compactionCompleted,
49
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
50
+ ]);
51
+ }
52
+ finally {
53
+ if (timer)
54
+ clearTimeout(timer);
55
+ }
56
+ }
17
57
  /**
18
58
  * Run one CodeCartographer phase as an isolated AgentSession. Awaiting this
19
59
  * function blocks until the phase completes (or aborts via signal). The
@@ -30,17 +70,19 @@ const PHASE_TOOL_NAMES = ["read", "edit", "write", "grep", "find", "ls"];
30
70
  export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal) {
31
71
  const cwd = ctx.cwd;
32
72
  const agentDir = getAgentDir();
33
- // Resource loader: load Pi extensions and skills (so codecarto's own tool
34
- // interception applies to the child) but skip prompt templates, themes,
35
- // and project context files they'd just bloat the system prompt.
73
+ // Resource loader: isolate the child from global extensions/skills and load
74
+ // only CodeCartographer's inline phase guards and compaction hooks. This
75
+ // avoids duplicate registration when CodeCartographer is globally installed
76
+ // while preserving the same safety when it was loaded explicitly with -e.
36
77
  const loader = new DefaultResourceLoader({
37
78
  cwd,
38
79
  agentDir,
39
- noExtensions: false,
40
- noSkills: false,
80
+ noExtensions: true,
81
+ noSkills: true,
41
82
  noPromptTemplates: true,
42
83
  noThemes: true,
43
84
  noContextFiles: true,
85
+ extensionFactories: [phaseCompactionExtension],
44
86
  });
45
87
  await loader.reload();
46
88
  // File-backed session in the same directory the orchestrator's TUI uses.
@@ -64,7 +106,6 @@ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal
64
106
  agentDir,
65
107
  sessionManager,
66
108
  settingsManager: SettingsManager.create(cwd, agentDir),
67
- modelRegistry: ctx.modelRegistry,
68
109
  model: ctx.model,
69
110
  tools: PHASE_TOOL_NAMES,
70
111
  resourceLoader: loader,
@@ -75,6 +116,8 @@ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal
75
116
  let turnCount = 0;
76
117
  let currentMessageText = "";
77
118
  let aborted = false;
119
+ let resolveCompaction;
120
+ const compactionCompleted = new Promise((resolve) => { resolveCompaction = resolve; });
78
121
  const unsubscribe = session.subscribe((event) => {
79
122
  switch (event.type) {
80
123
  case "tool_execution_start": {
@@ -117,6 +160,16 @@ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal
117
160
  }
118
161
  break;
119
162
  }
163
+ case "compaction_end": {
164
+ const compactEvent = event;
165
+ callbacks.onCompactionEnd?.({
166
+ reason: compactEvent.reason,
167
+ successful: compactEvent.result !== undefined && compactEvent.result !== null && !compactEvent.aborted && !compactEvent.errorMessage,
168
+ aborted: compactEvent.aborted,
169
+ });
170
+ resolveCompaction?.(true);
171
+ break;
172
+ }
120
173
  }
121
174
  });
122
175
  let abortCleanup = () => { };
@@ -130,6 +183,15 @@ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal
130
183
  }
131
184
  try {
132
185
  await session.prompt(prompt);
186
+ let primaryOutputPresent = true;
187
+ if (options.primaryOutput) {
188
+ primaryOutputPresent = await primaryOutputExists(cwd, options.primaryOutput);
189
+ }
190
+ if (!aborted && shouldContinuePhase(session.messages, primaryOutputPresent)) {
191
+ const compacted = await waitForCompaction(compactionCompleted);
192
+ if (!aborted)
193
+ await session.prompt(buildPhaseContinuationPrompt(compacted));
194
+ }
133
195
  }
134
196
  finally {
135
197
  unsubscribe();
@@ -1,4 +1,5 @@
1
1
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ import { type CompactionTelemetry } from "../../core/usage.ts";
2
3
  export type PhaseStatus = "running" | "completed" | "error" | "aborted";
3
4
  export interface PhaseActivity {
4
5
  phaseId: string;
@@ -17,6 +18,8 @@ export interface PhaseActivity {
17
18
  output: number;
18
19
  cacheWrite: number;
19
20
  };
21
+ /** Compaction outcomes observed during this phase session. */
22
+ compactions: CompactionTelemetry;
20
23
  session?: AgentSession;
21
24
  error?: string;
22
25
  }
@@ -2,6 +2,7 @@
2
2
  // it from session-event callbacks; the agents widget (M2) reads it on each
3
3
  // render. Module-scoped Map so different command handlers can hand work to
4
4
  // the runner and the widget sees the same state without explicit plumbing.
5
+ import { emptyCompactionTelemetry } from "../../core/usage.js";
5
6
  const phaseActivity = new Map();
6
7
  export function getPhaseActivity(phaseId) {
7
8
  return phaseActivity.get(phaseId);
@@ -22,6 +23,7 @@ export function startPhase(phaseId) {
22
23
  activeTools: new Map(),
23
24
  responseText: "",
24
25
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
26
+ compactions: emptyCompactionTelemetry(),
25
27
  };
26
28
  phaseActivity.set(phaseId, activity);
27
29
  return activity;
@@ -9,6 +9,11 @@ export interface PhaseSummaryInput {
9
9
  output: number;
10
10
  cacheWrite: number;
11
11
  };
12
+ compactions?: {
13
+ successful: number;
14
+ failed: number;
15
+ aborted: number;
16
+ };
12
17
  durationMs: number;
13
18
  responseText: string;
14
19
  sessionFile?: string;
@@ -39,6 +39,15 @@ function formatStats(input) {
39
39
  const totalTokens = input.tokens.input + input.tokens.output;
40
40
  if (totalTokens > 0)
41
41
  parts.push(formatTokens(totalTokens));
42
+ const compact = input.compactions;
43
+ if (compact && compact.successful + compact.failed + compact.aborted > 0) {
44
+ const details = [`${compact.successful} compaction${compact.successful === 1 ? "" : "s"}`];
45
+ if (compact.failed > 0)
46
+ details.push(`${compact.failed} failed`);
47
+ if (compact.aborted > 0)
48
+ details.push(`${compact.aborted} aborted`);
49
+ parts.push(details.join(", "));
50
+ }
42
51
  if (input.durationMs > 0)
43
52
  parts.push(formatDuration(input.durationMs));
44
53
  return parts.length > 0 ? `_${parts.join(" · ")}_` : "_(no activity recorded)_";
@@ -186,6 +186,9 @@ function formatRunningStats(a) {
186
186
  const tokens = a.lifetimeUsage.input + a.lifetimeUsage.output;
187
187
  if (tokens > 0)
188
188
  parts.push(formatTokens(tokens));
189
+ const compactions = a.compactions.successful + a.compactions.failed + a.compactions.aborted;
190
+ if (compactions > 0)
191
+ parts.push(`${compactions} compact`);
189
192
  parts.push(formatDuration(Date.now() - a.startedAt));
190
193
  return parts.join(" · ");
191
194
  }
@@ -198,6 +201,9 @@ function formatFinishedStats(a) {
198
201
  const tokens = a.lifetimeUsage.input + a.lifetimeUsage.output;
199
202
  if (tokens > 0)
200
203
  parts.push(formatTokens(tokens));
204
+ const compactions = a.compactions.successful + a.compactions.failed + a.compactions.aborted;
205
+ if (compactions > 0)
206
+ parts.push(`${compactions} compact`);
201
207
  const dur = a.completedAt ? a.completedAt - a.startedAt : 0;
202
208
  if (dur > 0)
203
209
  parts.push(formatDuration(dur));
@@ -69,7 +69,16 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
69
69
  activity.lifetimeUsage.output += usage.output;
70
70
  activity.lifetimeUsage.cacheWrite += usage.cacheWrite;
71
71
  },
72
- }, { sessionName: `CodeCartographer phase: ${phase.id}` }, options.signal);
72
+ onCompactionEnd: (event) => {
73
+ activity.compactions.reasons[event.reason]++;
74
+ if (event.aborted)
75
+ activity.compactions.aborted++;
76
+ else if (event.successful)
77
+ activity.compactions.successful++;
78
+ else
79
+ activity.compactions.failed++;
80
+ },
81
+ }, { sessionName: `CodeCartographer phase: ${phase.id}`, primaryOutput: phase.primary_output }, options.signal);
73
82
  const status = result.aborted ? "aborted" : "completed";
74
83
  finishPhase(phase.id, { status });
75
84
  if (ctx.hasUI) {
@@ -85,6 +94,7 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
85
94
  turnCount: activity.turnCount,
86
95
  toolUses: activity.toolUses,
87
96
  tokens: activity.lifetimeUsage,
97
+ compactions: activity.compactions,
88
98
  durationMs: (activity.completedAt ?? Date.now()) - activity.startedAt,
89
99
  responseText: result.responseText,
90
100
  sessionFile: result.sessionFile,
@@ -113,6 +123,7 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
113
123
  turnCount: activity.turnCount,
114
124
  toolUses: activity.toolUses,
115
125
  tokens: activity.lifetimeUsage,
126
+ compactions: activity.compactions,
116
127
  durationMs: (activity.completedAt ?? Date.now()) - activity.startedAt,
117
128
  responseText: "",
118
129
  error: message,
@@ -394,6 +405,7 @@ async function recordUsage(workspaceDir, phaseId, status, activity, sessionFile)
394
405
  output: activity.lifetimeUsage.output,
395
406
  cache_write: activity.lifetimeUsage.cacheWrite,
396
407
  },
408
+ compactions: activity.compactions,
397
409
  ...(sessionFile ? { session_file: sessionFile } : {}),
398
410
  });
399
411
  }