codecartographer-pi 0.10.0 → 0.12.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 (71) hide show
  1. package/.codecarto/GUIDE.md +40 -18
  2. package/.codecarto/README.md +3 -0
  3. package/.codecarto/findings/goal-synthesis/README.md +3 -0
  4. package/.codecarto/findings/goal-synthesis-finalize/SKILL.md +30 -0
  5. package/.codecarto/findings/goal-synthesis-propose/SKILL.md +24 -0
  6. package/.codecarto/findings/porting/SKILL.md +7 -0
  7. package/.codecarto/findings/reimplementation-spec/SKILL.md +10 -0
  8. package/.codecarto/findings/spec-merge/README.md +3 -0
  9. package/.codecarto/findings/spec-merge/SKILL.md +23 -0
  10. package/.codecarto/findings/vision-capture/README.md +3 -0
  11. package/.codecarto/findings/vision-capture/SKILL.md +26 -0
  12. package/.codecarto/inputs/vision.md +11 -0
  13. package/.codecarto/templates/architecture-map.md +9 -0
  14. package/.codecarto/templates/behavioral-contracts.md +9 -0
  15. package/.codecarto/templates/defect-report.md +9 -0
  16. package/.codecarto/templates/mechanical-defects.md +9 -0
  17. package/.codecarto/templates/merged-spec.md +58 -0
  18. package/.codecarto/templates/phase-checkpoint.md +41 -0
  19. package/.codecarto/templates/phase-handoff.yaml +22 -0
  20. package/.codecarto/templates/project-plan.md +70 -0
  21. package/.codecarto/templates/proposal.md +43 -0
  22. package/.codecarto/templates/protocols-and-state.md +9 -0
  23. package/.codecarto/templates/reimplementation-spec-opinionated.md +10 -0
  24. package/.codecarto/templates/reimplementation-spec.md +10 -0
  25. package/.codecarto/templates/reverse-engineering-bundle.md +29 -3
  26. package/.codecarto/templates/semantic-defects.md +9 -0
  27. package/.codecarto/templates/vision.md +63 -0
  28. package/.codecarto/workflow/pipeline-architecture-only.yaml +1 -0
  29. package/.codecarto/workflow/pipeline-defect-scan.yaml +2 -0
  30. package/.codecarto/workflow/pipeline-full-with-audit.yaml +8 -3
  31. package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +9 -5
  32. package/.codecarto/workflow/pipeline-lite.yaml +3 -0
  33. package/.codecarto/workflow/pipeline-synthesis.yaml +105 -0
  34. package/.codecarto/workflow/pipeline.yaml +7 -3
  35. package/.codecarto/workflow/status.yaml +2 -0
  36. package/README.md +89 -7
  37. package/dist/core/completion.d.ts +6 -0
  38. package/dist/core/completion.js +127 -0
  39. package/dist/core/dashboard.js +37 -7
  40. package/dist/core/index.d.ts +2 -0
  41. package/dist/core/index.js +2 -0
  42. package/dist/core/pipeline.js +1 -0
  43. package/dist/core/prompts.d.ts +9 -3
  44. package/dist/core/prompts.js +43 -26
  45. package/dist/core/status.d.ts +7 -1
  46. package/dist/core/status.js +187 -2
  47. package/dist/core/synthesis.d.ts +31 -0
  48. package/dist/core/synthesis.js +140 -0
  49. package/dist/core/types.d.ts +29 -1
  50. package/dist/core/usage.d.ts +11 -0
  51. package/dist/core/usage.js +64 -46
  52. package/dist/core/workspace.d.ts +3 -1
  53. package/dist/core/workspace.js +39 -3
  54. package/dist/core/yaml.js +24 -0
  55. package/dist/extensions/codecarto/agent-rewriter.js +0 -1
  56. package/dist/extensions/codecarto/agent-runner.d.ts +19 -0
  57. package/dist/extensions/codecarto/agent-runner.js +68 -6
  58. package/dist/extensions/codecarto/agent-state.d.ts +3 -0
  59. package/dist/extensions/codecarto/agent-state.js +2 -0
  60. package/dist/extensions/codecarto/agent-summary.d.ts +5 -0
  61. package/dist/extensions/codecarto/agent-summary.js +9 -0
  62. package/dist/extensions/codecarto/agent-widget.js +6 -0
  63. package/dist/extensions/codecarto/auto-runner.d.ts +3 -1
  64. package/dist/extensions/codecarto/auto-runner.js +33 -69
  65. package/dist/extensions/codecarto/dashboard-narrator.js +0 -1
  66. package/dist/extensions/codecarto/index.d.ts +1 -1
  67. package/dist/extensions/codecarto/index.js +153 -12
  68. package/dist/extensions/codecarto/phase-compaction.d.ts +11 -0
  69. package/dist/extensions/codecarto/phase-compaction.js +115 -0
  70. package/dist/mcp-server/server.js +24 -68
  71. package/package.json +4 -3
@@ -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);
@@ -1,11 +1,13 @@
1
- import type { WorkspaceState } from "./types.ts";
1
+ import type { PhaseHandoff, WorkspaceState } from "./types.ts";
2
2
  export declare const packagedWorkspaceDir: string;
3
3
  export declare const PACKAGE_VERSION: string;
4
4
  export declare function getWorkspaceState(cwd: string): Promise<WorkspaceState | null>;
5
5
  export declare function updateStatusAtomically(cwd: string, updater: (state: WorkspaceState) => Promise<{
6
6
  state: WorkspaceState;
7
+ handoff?: PhaseHandoff;
7
8
  threadLogEntry?: string;
8
9
  }> | {
9
10
  state: WorkspaceState;
11
+ handoff?: PhaseHandoff;
10
12
  threadLogEntry?: string;
11
13
  }): Promise<WorkspaceState>;
@@ -3,10 +3,10 @@
3
3
  // + normalizes the per-project workspace state from disk, and provides the
4
4
  // atomic status-update primitive used by /codecarto-complete.
5
5
  import { existsSync, readFileSync } from "node:fs";
6
- import { appendFile, rename, writeFile } from "node:fs/promises";
6
+ import { appendFile, readFile, rename, writeFile } from "node:fs/promises";
7
7
  import { dirname, join, relative } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { acquireLock, normalizeStatus } from "./status.js";
9
+ import { acquireLock, applyHandoff, normalizeStatus, parseHandoff } from "./status.js";
10
10
  import { pathExists } from "./utils.js";
11
11
  import { loadYamlFile, stringifySimpleYaml } from "./yaml.js";
12
12
  // Walk up from the current file to find the package root. Needed because the
@@ -42,6 +42,24 @@ export const PACKAGE_VERSION = (() => {
42
42
  return "0.0.0";
43
43
  }
44
44
  })();
45
+ function assertCanonicalStatus(status) {
46
+ if (status.schema_version !== 1) {
47
+ throw new Error(`Cannot write unsupported status schema_version ${String(status.schema_version)}.`);
48
+ }
49
+ if (!Array.isArray(status.post_pipeline)) {
50
+ throw new Error("Cannot write status: post_pipeline must be an array.");
51
+ }
52
+ if (!status.phases || typeof status.phases !== "object" || Array.isArray(status.phases)) {
53
+ throw new Error("Cannot write status: phases must be a mapping.");
54
+ }
55
+ for (const [phaseId, phase] of Object.entries(status.phases)) {
56
+ for (const field of ["owner_notes", "outputs_present", "open_questions", "carry_forward"]) {
57
+ if (!Array.isArray(phase?.[field])) {
58
+ throw new Error(`Cannot write status: phases.${phaseId}.${field} must be an array.`);
59
+ }
60
+ }
61
+ }
62
+ }
45
63
  export async function getWorkspaceState(cwd) {
46
64
  const workspaceDir = join(cwd, ".codecarto");
47
65
  const statusPath = join(workspaceDir, "workflow", "status.yaml");
@@ -79,13 +97,31 @@ export async function updateStatusAtomically(cwd, updater) {
79
97
  }
80
98
  const result = await updater(currentState);
81
99
  const nextState = result.state;
100
+ // Apply handoff if provided
101
+ if (result.handoff) {
102
+ const handoff = parseHandoff(result.handoff);
103
+ applyHandoff(nextState.status, handoff);
104
+ }
105
+ assertCanonicalStatus(nextState.status);
82
106
  const serialized = `${stringifySimpleYaml(nextState.status)}\n`;
83
107
  const tempPath = `${statusPath}.${process.pid}.${Date.now()}.tmp`;
84
108
  await writeFile(tempPath, serialized, "utf8");
85
109
  await rename(tempPath, statusPath);
86
110
  if (result.threadLogEntry) {
87
111
  const threadLogPath = join(workspaceDir, "THREAD_LOG.md");
88
- await appendFile(threadLogPath, result.threadLogEntry, "utf8");
112
+ let currentLog = "";
113
+ try {
114
+ currentLog = await readFile(threadLogPath, "utf8");
115
+ }
116
+ catch {
117
+ // File may not exist yet
118
+ }
119
+ const logEntries = currentLog.split(/\r?\n/).filter((line) => line.trim().startsWith("- "));
120
+ const normalizedEntry = result.threadLogEntry.trim();
121
+ const isDuplicate = logEntries.some((line) => line.trim() === normalizedEntry);
122
+ if (!isDuplicate) {
123
+ await appendFile(threadLogPath, result.threadLogEntry, "utf8");
124
+ }
89
125
  }
90
126
  return nextState;
91
127
  }
package/dist/core/yaml.js CHANGED
@@ -132,6 +132,30 @@ export function parseSimpleYaml(raw) {
132
132
  const key = trimmed.slice(0, separator).trim();
133
133
  const rawValue = trimmed.slice(separator + 1).trim();
134
134
  index++;
135
+ if (key in result) {
136
+ throw new Error(`Duplicate YAML key: ${key} near line: ${line.trim()}`);
137
+ }
138
+ if (rawValue === "|" || rawValue === "|-") {
139
+ const blockLines = [];
140
+ let contentIndent = null;
141
+ while (index < lines.length) {
142
+ const blockLine = lines[index] ?? "";
143
+ if (blockLine.trim() === "") {
144
+ blockLines.push("");
145
+ index++;
146
+ continue;
147
+ }
148
+ const blockIndent = countIndent(blockLine);
149
+ if (blockIndent <= indent)
150
+ break;
151
+ contentIndent ??= blockIndent;
152
+ blockLines.push(blockLine.slice(Math.min(contentIndent, blockIndent)));
153
+ index++;
154
+ }
155
+ const content = blockLines.join("\n").replace(/\n+$/, "");
156
+ result[key] = rawValue === "|" ? `${content}\n` : content;
157
+ continue;
158
+ }
135
159
  if (rawValue !== "") {
136
160
  result[key] = parseYamlScalar(rawValue);
137
161
  continue;
@@ -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));
@@ -1,9 +1,11 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type PhaseActivity } from "./agent-state.ts";
3
- import { type PipelinePhase, type ValidationOverall, type ValidationResult, type WorkspaceState } from "../../core/index.ts";
3
+ import { type PhasePreflightResult, type PipelinePhase, type ValidationOverall, type ValidationResult, type WorkspaceState } from "../../core/index.ts";
4
4
  export interface RunSinglePhaseOptions {
5
5
  llmSteerEnabled: boolean;
6
6
  signal?: AbortSignal;
7
+ /** Preflight validated by the caller so prompt construction does not repeat it. */
8
+ preflight?: PhasePreflightResult;
7
9
  /**
8
10
  * True when this phase is being driven by `/codecarto-next --auto`. The flag
9
11
  * propagates into `buildPhasePrompt` so interactive hooks (notably the