codecartographer-pi 0.1.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 (75) hide show
  1. package/.codecarto/BACKLOG.md +192 -0
  2. package/.codecarto/CHANGELOG-2026-05-02-feedback-pass.md +118 -0
  3. package/.codecarto/CONTRIBUTING.md +56 -0
  4. package/.codecarto/GUIDE.md +298 -0
  5. package/.codecarto/LICENSE +21 -0
  6. package/.codecarto/NEW_THREAD_BLURB.md +47 -0
  7. package/.codecarto/README.md +39 -0
  8. package/.codecarto/THREAD_LOG.md +39 -0
  9. package/.codecarto/closeouts/2026-05-02-framework-feedback-pass.md +111 -0
  10. package/.codecarto/findings/architecture/README.md +3 -0
  11. package/.codecarto/findings/architecture/SKILL.md +102 -0
  12. package/.codecarto/findings/build-and-deploy/.gitkeep +0 -0
  13. package/.codecarto/findings/config-model/.gitkeep +0 -0
  14. package/.codecarto/findings/contracts/README.md +3 -0
  15. package/.codecarto/findings/contracts/SKILL.md +89 -0
  16. package/.codecarto/findings/defect-scan/README.md +18 -0
  17. package/.codecarto/findings/defect-scan/SKILL.md +87 -0
  18. package/.codecarto/findings/defect-scan/passes/01-logic-and-correctness.md +50 -0
  19. package/.codecarto/findings/defect-scan/passes/02-error-handling.md +55 -0
  20. package/.codecarto/findings/defect-scan/passes/03-concurrency-and-resources.md +54 -0
  21. package/.codecarto/findings/defect-scan/passes/04-security-and-trust.md +62 -0
  22. package/.codecarto/findings/defect-scan/passes/05-api-contract-violations.md +57 -0
  23. package/.codecarto/findings/defect-scan/passes/06-config-and-environment.md +58 -0
  24. package/.codecarto/findings/defect-scan-mechanical/README.md +17 -0
  25. package/.codecarto/findings/defect-scan-mechanical/SKILL.md +60 -0
  26. package/.codecarto/findings/defect-scan-semantic/README.md +17 -0
  27. package/.codecarto/findings/defect-scan-semantic/SKILL.md +54 -0
  28. package/.codecarto/findings/porting/README.md +3 -0
  29. package/.codecarto/findings/porting/SKILL.md +52 -0
  30. package/.codecarto/findings/protocols/README.md +3 -0
  31. package/.codecarto/findings/protocols/SKILL.md +87 -0
  32. package/.codecarto/findings/public-surfaces/README.md +3 -0
  33. package/.codecarto/findings/reimplementation-spec/README.md +3 -0
  34. package/.codecarto/findings/reimplementation-spec/SKILL.md +66 -0
  35. package/.codecarto/findings/runtime-lifecycle/README.md +3 -0
  36. package/.codecarto/findings/state-and-storage/README.md +3 -0
  37. package/.codecarto/scratch/.gitkeep +0 -0
  38. package/.codecarto/skills/spec-delta-application/SKILL.md +102 -0
  39. package/.codecarto/templates/architecture-map.md +143 -0
  40. package/.codecarto/templates/behavioral-contracts.md +134 -0
  41. package/.codecarto/templates/closeout-template.md +85 -0
  42. package/.codecarto/templates/conventions-template.md +65 -0
  43. package/.codecarto/templates/decisions-template.md +82 -0
  44. package/.codecarto/templates/defect-fix-tracker.md +77 -0
  45. package/.codecarto/templates/defect-report.md +116 -0
  46. package/.codecarto/templates/deltas-applied.md +71 -0
  47. package/.codecarto/templates/mechanical-defects.md +104 -0
  48. package/.codecarto/templates/protocols-and-state.md +126 -0
  49. package/.codecarto/templates/reimplementation-spec-opinionated.md +183 -0
  50. package/.codecarto/templates/reimplementation-spec.md +148 -0
  51. package/.codecarto/templates/reverse-engineering-bundle.md +141 -0
  52. package/.codecarto/templates/semantic-defects.md +109 -0
  53. package/.codecarto/templates/thread-log-entry-template.md +27 -0
  54. package/.codecarto/workflow/VALIDATE.md +81 -0
  55. package/.codecarto/workflow/pipeline-architecture-only.yaml +38 -0
  56. package/.codecarto/workflow/pipeline-defect-scan.yaml +61 -0
  57. package/.codecarto/workflow/pipeline-full-with-audit.yaml +188 -0
  58. package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +227 -0
  59. package/.codecarto/workflow/pipeline-lite.yaml +100 -0
  60. package/.codecarto/workflow/pipeline.yaml +163 -0
  61. package/.codecarto/workflow/status.yaml +64 -0
  62. package/LICENSE +21 -0
  63. package/README.md +356 -0
  64. package/core/index.ts +11 -0
  65. package/core/pipeline.ts +175 -0
  66. package/core/prompts.ts +183 -0
  67. package/core/status.ts +155 -0
  68. package/core/types.ts +96 -0
  69. package/core/utils.ts +52 -0
  70. package/core/workspace.ts +81 -0
  71. package/core/yaml.ts +256 -0
  72. package/extensions/codecarto/index.ts +446 -0
  73. package/mcp-server/bin.mjs +7 -0
  74. package/mcp-server/server.ts +497 -0
  75. package/package.json +52 -0
package/core/yaml.ts ADDED
@@ -0,0 +1,256 @@
1
+ // Hand-rolled YAML parser/serializer good enough for the .codecarto workflow
2
+ // files (mappings, sequences, scalars, nested maps, strings/numbers/booleans).
3
+ // Round-trips structured carry_forward/open_questions entries.
4
+
5
+ import { readFile } from "node:fs/promises";
6
+ import { isPlainObject } from "./utils.ts";
7
+
8
+ export function stripYamlComment(value: string): string {
9
+ let inSingle = false;
10
+ let inDouble = false;
11
+
12
+ for (let i = 0; i < value.length; i++) {
13
+ const char = value[i];
14
+ if (char === "'" && !inDouble) {
15
+ inSingle = !inSingle;
16
+ continue;
17
+ }
18
+ if (char === '"' && !inSingle && value[i - 1] !== "\\") {
19
+ inDouble = !inDouble;
20
+ continue;
21
+ }
22
+ if (char === "#" && !inSingle && !inDouble) {
23
+ if (i === 0 || /\s/.test(value[i - 1] ?? "")) {
24
+ return value.slice(0, i).trimEnd();
25
+ }
26
+ }
27
+ }
28
+
29
+ return value.trimEnd();
30
+ }
31
+
32
+ function countIndent(line: string): number {
33
+ let count = 0;
34
+ for (const char of line) {
35
+ if (char === " ") count++;
36
+ else if (char === "\t") count += 2;
37
+ else break;
38
+ }
39
+ return count;
40
+ }
41
+
42
+ function isBlankOrComment(line: string): boolean {
43
+ const trimmed = line.trim();
44
+ return trimmed === "" || trimmed.startsWith("#");
45
+ }
46
+
47
+ function findKeySeparator(text: string): number {
48
+ let inSingle = false;
49
+ let inDouble = false;
50
+
51
+ for (let i = 0; i < text.length; i++) {
52
+ const char = text[i];
53
+ if (char === "'" && !inDouble) {
54
+ inSingle = !inSingle;
55
+ continue;
56
+ }
57
+ if (char === '"' && !inSingle && text[i - 1] !== "\\") {
58
+ inDouble = !inDouble;
59
+ continue;
60
+ }
61
+ if (char === ":" && !inSingle && !inDouble) {
62
+ return i;
63
+ }
64
+ }
65
+
66
+ return -1;
67
+ }
68
+
69
+ export function parseYamlScalar(rawValue: string): unknown {
70
+ const trimmed = stripYamlComment(rawValue).trim();
71
+ if (trimmed === "") return "";
72
+ if (trimmed === "[]") return [];
73
+ if (trimmed === "{}") return {};
74
+ if (trimmed === "null") return null;
75
+ if (trimmed === "true") return true;
76
+ if (trimmed === "false") return false;
77
+ if (/^-?\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
78
+ if (/^-?\d+\.\d+$/.test(trimmed)) return Number.parseFloat(trimmed);
79
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
80
+ try {
81
+ return JSON.parse(trimmed);
82
+ } catch {
83
+ return trimmed.slice(1, -1);
84
+ }
85
+ }
86
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
87
+ return trimmed.slice(1, -1).replace(/''/g, "'");
88
+ }
89
+ return trimmed;
90
+ }
91
+
92
+ export function parseSimpleYaml(raw: string): unknown {
93
+ const lines = raw.split(/\r?\n/);
94
+ let index = 0;
95
+
96
+ const skipBlank = (): void => {
97
+ while (index < lines.length && isBlankOrComment(lines[index] ?? "")) index++;
98
+ };
99
+
100
+ const parseBlock = (indent: number): unknown => {
101
+ skipBlank();
102
+ if (index >= lines.length) return {};
103
+ const line = lines[index] ?? "";
104
+ const lineIndent = countIndent(line);
105
+ const trimmed = line.slice(lineIndent);
106
+ if (trimmed.startsWith("- ") || trimmed === "-") {
107
+ return parseSequence(indent);
108
+ }
109
+ return parseMapping(indent);
110
+ };
111
+
112
+ const parseMapping = (indent: number): Record<string, unknown> => {
113
+ const result: Record<string, unknown> = {};
114
+
115
+ while (index < lines.length) {
116
+ skipBlank();
117
+ if (index >= lines.length) break;
118
+ const line = lines[index] ?? "";
119
+ const lineIndent = countIndent(line);
120
+ if (lineIndent < indent) break;
121
+ if (lineIndent > indent) {
122
+ throw new Error(`Invalid YAML indentation near: ${line.trim()}`);
123
+ }
124
+
125
+ const trimmed = line.slice(indent);
126
+ if (trimmed.startsWith("- ") || trimmed === "-") break;
127
+
128
+ const separator = findKeySeparator(trimmed);
129
+ if (separator === -1) {
130
+ throw new Error(`Invalid YAML mapping entry: ${trimmed}`);
131
+ }
132
+
133
+ const key = trimmed.slice(0, separator).trim();
134
+ const rawValue = trimmed.slice(separator + 1).trim();
135
+ index++;
136
+
137
+ if (rawValue !== "") {
138
+ result[key] = parseYamlScalar(rawValue);
139
+ continue;
140
+ }
141
+
142
+ skipBlank();
143
+ if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
144
+ result[key] = parseBlock(countIndent(lines[index] ?? ""));
145
+ } else {
146
+ result[key] = null;
147
+ }
148
+ }
149
+
150
+ return result;
151
+ };
152
+
153
+ const parseSequence = (indent: number): unknown[] => {
154
+ const result: unknown[] = [];
155
+
156
+ while (index < lines.length) {
157
+ skipBlank();
158
+ if (index >= lines.length) break;
159
+ const line = lines[index] ?? "";
160
+ const lineIndent = countIndent(line);
161
+ if (lineIndent < indent) break;
162
+ const trimmed = line.slice(lineIndent);
163
+ if (lineIndent !== indent || (!trimmed.startsWith("- ") && trimmed !== "-")) break;
164
+
165
+ const rawItem = trimmed === "-" ? "" : trimmed.slice(2).trim();
166
+ index++;
167
+
168
+ if (rawItem === "") {
169
+ skipBlank();
170
+ if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
171
+ result.push(parseBlock(countIndent(lines[index] ?? "")));
172
+ } else {
173
+ result.push(null);
174
+ }
175
+ continue;
176
+ }
177
+
178
+ const separator = findKeySeparator(rawItem);
179
+ if (separator !== -1) {
180
+ const key = rawItem.slice(0, separator).trim();
181
+ const rawValue = rawItem.slice(separator + 1).trim();
182
+ const item: Record<string, unknown> = {};
183
+ item[key] = rawValue === "" ? null : parseYamlScalar(rawValue);
184
+
185
+ skipBlank();
186
+ if (rawValue === "" && index < lines.length && countIndent(lines[index] ?? "") > indent + 1) {
187
+ item[key] = parseBlock(countIndent(lines[index] ?? ""));
188
+ }
189
+ if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
190
+ const nested = parseMapping(indent + 2);
191
+ for (const [nestedKey, nestedValue] of Object.entries(nested)) item[nestedKey] = nestedValue;
192
+ }
193
+ result.push(item);
194
+ continue;
195
+ }
196
+
197
+ result.push(parseYamlScalar(rawItem));
198
+ }
199
+
200
+ return result;
201
+ };
202
+
203
+ skipBlank();
204
+ if (index >= lines.length) return {};
205
+ return parseBlock(countIndent(lines[index] ?? ""));
206
+ }
207
+
208
+ export function formatYamlScalar(value: unknown): string {
209
+ if (value === null) return "null";
210
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
211
+ if (Array.isArray(value)) return value.length === 0 ? "[]" : JSON.stringify(value);
212
+ if (isPlainObject(value)) return Object.keys(value).length === 0 ? "{}" : JSON.stringify(value);
213
+ const stringValue = String(value);
214
+ if (stringValue === "") return '""';
215
+ if (/^[A-Za-z0-9_./-]+$/.test(stringValue)) return stringValue;
216
+ return JSON.stringify(stringValue);
217
+ }
218
+
219
+ export function stringifySimpleYaml(value: unknown, indent: number = 0): string {
220
+ const prefix = " ".repeat(indent);
221
+ if (Array.isArray(value)) {
222
+ if (value.length === 0) return `${prefix}[]`;
223
+ return value
224
+ .map((item) => {
225
+ if (Array.isArray(item) || isPlainObject(item)) {
226
+ const isEmptyObject = isPlainObject(item) && Object.keys(item).length === 0;
227
+ if (Array.isArray(item) && item.length === 0) return `${prefix}- []`;
228
+ if (isEmptyObject) return `${prefix}- {}`;
229
+ return `${prefix}-\n${stringifySimpleYaml(item, indent + 2)}`;
230
+ }
231
+ return `${prefix}- ${formatYamlScalar(item)}`;
232
+ })
233
+ .join("\n");
234
+ }
235
+ if (isPlainObject(value)) {
236
+ const entries = Object.entries(value);
237
+ if (entries.length === 0) return `${prefix}{}`;
238
+ return entries
239
+ .map(([key, entryValue]) => {
240
+ if (Array.isArray(entryValue) || isPlainObject(entryValue)) {
241
+ const isEmptyObject = isPlainObject(entryValue) && Object.keys(entryValue).length === 0;
242
+ if (Array.isArray(entryValue) && entryValue.length === 0) return `${prefix}${key}: []`;
243
+ if (isEmptyObject) return `${prefix}${key}: {}`;
244
+ return `${prefix}${key}:\n${stringifySimpleYaml(entryValue, indent + 2)}`;
245
+ }
246
+ return `${prefix}${key}: ${formatYamlScalar(entryValue)}`;
247
+ })
248
+ .join("\n");
249
+ }
250
+ return `${prefix}${formatYamlScalar(value)}`;
251
+ }
252
+
253
+ export async function loadYamlFile<T>(path: string): Promise<T> {
254
+ const raw = await readFile(path, "utf8");
255
+ return (parseSimpleYaml(raw) ?? {}) as T;
256
+ }
@@ -0,0 +1,446 @@
1
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { basename, join, resolve } from "node:path";
3
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+
5
+ import {
6
+ buildPhasePrompt,
7
+ buildSkillPrompt,
8
+ buildThreadLogEntry,
9
+ buildValidationSummary,
10
+ canonicalPath,
11
+ closeoutFileName,
12
+ createEmptyStatus,
13
+ dateOnly,
14
+ DEFAULT_PIPELINE_PATH,
15
+ ensureCloseoutStub,
16
+ getNextEligiblePhase,
17
+ getPipelineLabel,
18
+ getWorkspaceState,
19
+ isWithinPath,
20
+ listSkillNames,
21
+ loadYamlFile,
22
+ normalizeForComparison,
23
+ normalizeStatus,
24
+ type OpenQuestionEntry,
25
+ packagedWorkspaceDir,
26
+ pathExists,
27
+ PIPELINE_ALIASES,
28
+ type PipelineFile,
29
+ resolvePhase,
30
+ resolvePipelineChoice,
31
+ type StatusFile,
32
+ stringifySimpleYaml,
33
+ uniqueStrings,
34
+ updateStatusAtomically,
35
+ validatePhaseOutput,
36
+ type WorkspaceState,
37
+ } from "../../core/index.ts";
38
+
39
+ const STATUS_WIDGET_ID = "codecarto-widget";
40
+ const STATUS_LINE_ID = "codecarto-status";
41
+ const SAFE_TOOL_NAMES = ["read", "grep", "find", "ls", "edit", "write"];
42
+
43
+ function buildStatusLines(state: WorkspaceState, extraLines: string[] = []): string[] {
44
+ const nextPhase = getNextEligiblePhase(state);
45
+ const currentPhase = nextPhase?.id ?? state.status.current_phase ?? "complete";
46
+ const pipelineLabel = getPipelineLabel(state.status.pipeline);
47
+ const completedCount = state.pipeline.phase_order.filter((phaseId) => state.status.phases[phaseId]?.status === "complete").length;
48
+ const currentOpenQuestions = currentPhase === "complete" ? 0 : state.status.phases[currentPhase]?.open_questions.length ?? 0;
49
+ const totalCarryForward = Object.values(state.status.phases).reduce((sum, phase) => sum + (phase.carry_forward?.length ?? 0), 0);
50
+ const nextAction = state.status.next_actions[0] ?? (nextPhase ? `Next: ${nextPhase.id}` : "All phases complete.");
51
+
52
+ const lines = [
53
+ "CodeCartographer",
54
+ `Phase: ${currentPhase}`,
55
+ `Pipeline: ${pipelineLabel}`,
56
+ `Progress: ${completedCount}/${state.pipeline.phase_order.length} complete`,
57
+ `Open questions: ${currentOpenQuestions}`,
58
+ `Carry-forward: ${totalCarryForward}`,
59
+ `Next: ${nextAction}`,
60
+ ];
61
+
62
+ if (extraLines.length > 0) {
63
+ lines.push("", ...extraLines);
64
+ }
65
+
66
+ return lines;
67
+ }
68
+
69
+ function setUiState(ctx: ExtensionContext | ExtensionCommandContext, state: WorkspaceState | null, extraLines: string[] = []): void {
70
+ if (!ctx.hasUI) return;
71
+ if (!state) {
72
+ ctx.ui.setStatus(STATUS_LINE_ID, undefined);
73
+ ctx.ui.setWidget(STATUS_WIDGET_ID, undefined);
74
+ return;
75
+ }
76
+
77
+ const theme = ctx.ui.theme;
78
+ const currentPhase = getNextEligiblePhase(state)?.id ?? state.status.current_phase ?? "complete";
79
+ ctx.ui.setStatus(STATUS_LINE_ID, `${theme.fg("accent", "CC")} ${theme.fg("dim", currentPhase)}`);
80
+ ctx.ui.setWidget(STATUS_WIDGET_ID, buildStatusLines(state, extraLines));
81
+ }
82
+
83
+ export default function codeCartographerExtension(pi: ExtensionAPI) {
84
+ let lastFeedbackLines: string[] = [];
85
+
86
+ const readWorkspaceState = async (ctx: ExtensionContext | ExtensionCommandContext, notifyOnError: boolean = true): Promise<WorkspaceState | null> => {
87
+ try {
88
+ return await getWorkspaceState(ctx.cwd);
89
+ } catch (error) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ lastFeedbackLines = [message];
92
+ setUiState(ctx, null);
93
+ if (notifyOnError && ctx.hasUI) ctx.ui.notify(message, "error");
94
+ return null;
95
+ }
96
+ };
97
+
98
+ const refreshWorkspaceUi = async (ctx: ExtensionContext | ExtensionCommandContext, extraLines?: string[]): Promise<WorkspaceState | null> => {
99
+ const state = await readWorkspaceState(ctx, false);
100
+ setUiState(ctx, state, extraLines ?? lastFeedbackLines);
101
+ if (state) {
102
+ const phaseId = getNextEligiblePhase(state)?.id ?? state.status.current_phase;
103
+ if (phaseId) pi.setSessionName(`CodeCartographer: ${phaseId}`);
104
+ }
105
+ return state;
106
+ };
107
+
108
+ const ensureWorkspaceState = async (ctx: ExtensionCommandContext): Promise<WorkspaceState | null> => {
109
+ const state = await readWorkspaceState(ctx);
110
+ if (state) return state;
111
+ const hasWorkspace = await pathExists(join(ctx.cwd, ".codecarto", "workflow", "status.yaml"));
112
+ if (!hasWorkspace) ctx.ui.notify("No .codecarto/ workspace found. Run /codecarto-init first.", "warning");
113
+ return null;
114
+ };
115
+
116
+ pi.on("session_start", async (_event, ctx) => {
117
+ const state = await refreshWorkspaceUi(ctx);
118
+ if (!state) return;
119
+ pi.setActiveTools(SAFE_TOOL_NAMES);
120
+ });
121
+
122
+ pi.on("agent_end", async (_event, ctx) => {
123
+ await refreshWorkspaceUi(ctx);
124
+ });
125
+
126
+ pi.on("tool_call", async (event, ctx) => {
127
+ const workspaceDir = join(ctx.cwd, ".codecarto");
128
+ if (!(await pathExists(workspaceDir))) return undefined;
129
+
130
+ if (event.toolName === "bash") {
131
+ if (ctx.hasUI) ctx.ui.notify("Blocked bash in CodeCartographer mode", "warning");
132
+ return { block: true, reason: "CodeCartographer mode disables bash to keep source analysis read-only." };
133
+ }
134
+
135
+ if (event.toolName === "edit" || event.toolName === "write") {
136
+ const inputPath = typeof event.input.path === "string" ? event.input.path : "";
137
+ const strippedPath = inputPath.startsWith("@") ? inputPath.slice(1) : inputPath;
138
+ const targetPath = await canonicalPath(resolve(ctx.cwd, strippedPath));
139
+ const allowedRoot = await canonicalPath(workspaceDir);
140
+ if (!isWithinPath(targetPath, allowedRoot)) {
141
+ if (ctx.hasUI) {
142
+ ctx.ui.notify(`Blocked ${event.toolName} outside .codecarto/: ${inputPath}`, "warning");
143
+ }
144
+ return { block: true, reason: `CodeCartographer mode only allows ${event.toolName} within .codecarto/` };
145
+ }
146
+ }
147
+
148
+ return undefined;
149
+ });
150
+
151
+ pi.registerCommand("codecarto-init", {
152
+ description: "Initialize .codecarto/ in the current repository",
153
+ getArgumentCompletions: (prefix) => {
154
+ const items = Object.keys(PIPELINE_ALIASES)
155
+ .filter((value) => value.startsWith(prefix))
156
+ .map((value) => ({ value, label: value }));
157
+ return items.length > 0 ? items : null;
158
+ },
159
+ handler: async (args, ctx) => {
160
+ const trimmedArgs = args.trim();
161
+ const pipelineChoice = resolvePipelineChoice(trimmedArgs);
162
+ if (trimmedArgs && !pipelineChoice) {
163
+ ctx.ui.notify(`Unknown pipeline: ${trimmedArgs}`, "error");
164
+ return;
165
+ }
166
+ const targetWorkspaceDir = join(ctx.cwd, ".codecarto");
167
+ const sourceWorkspaceDir = packagedWorkspaceDir;
168
+
169
+ if (!(await pathExists(sourceWorkspaceDir))) {
170
+ ctx.ui.notify("Packaged .codecarto assets are missing.", "error");
171
+ return;
172
+ }
173
+
174
+ const targetExists = await pathExists(targetWorkspaceDir);
175
+ if (targetExists) {
176
+ const sameWorkspace = normalizeForComparison(await canonicalPath(targetWorkspaceDir)) === normalizeForComparison(await canonicalPath(sourceWorkspaceDir));
177
+ if (!sameWorkspace) {
178
+ const overwrite = await ctx.ui.confirm(
179
+ "CodeCartographer already exists",
180
+ "A .codecarto/ directory already exists in this repository. Overwrite it?",
181
+ );
182
+ if (!overwrite) return;
183
+ await rm(targetWorkspaceDir, { recursive: true, force: true });
184
+ }
185
+ }
186
+
187
+ if (!(await pathExists(targetWorkspaceDir))) {
188
+ await mkdir(ctx.cwd, { recursive: true });
189
+ await cp(sourceWorkspaceDir, targetWorkspaceDir, { recursive: true });
190
+ }
191
+
192
+ const rawStatusPath = join(targetWorkspaceDir, "workflow", "status.yaml");
193
+ const rawStatus = (await loadYamlFile<StatusFile>(rawStatusPath)) ?? {};
194
+ const selectedPipelinePath = pipelineChoice ?? rawStatus.pipeline?.trim() ?? DEFAULT_PIPELINE_PATH;
195
+ const resolvedPipelinePath = join(targetWorkspaceDir, selectedPipelinePath);
196
+
197
+ if (!(await pathExists(resolvedPipelinePath))) {
198
+ ctx.ui.notify(`Pipeline not found: ${selectedPipelinePath}`, "error");
199
+ return;
200
+ }
201
+
202
+ const pipeline = await loadYamlFile<PipelineFile>(resolvedPipelinePath);
203
+ const normalizedStatus = createEmptyStatus(basename(ctx.cwd), selectedPipelinePath, pipeline);
204
+ normalizedStatus.last_updated = new Date().toISOString();
205
+ await writeFile(rawStatusPath, `${stringifySimpleYaml(normalizedStatus)}\n`, "utf8");
206
+
207
+ lastFeedbackLines = [`Initialized workspace with pipeline: ${getPipelineLabel(selectedPipelinePath)}`];
208
+ ctx.ui.notify(`Initialized CodeCartographer (${getPipelineLabel(selectedPipelinePath)})`, "info");
209
+ await ctx.reload();
210
+ return;
211
+ },
212
+ });
213
+
214
+ pi.registerCommand("codecarto-status", {
215
+ description: "Show the current CodeCartographer phase and progress",
216
+ handler: async (_args, ctx) => {
217
+ const state = await ensureWorkspaceState(ctx);
218
+ if (!state) return;
219
+
220
+ const nextPhase = getNextEligiblePhase(state)?.id ?? "complete";
221
+ lastFeedbackLines = [`Current phase: ${nextPhase}`, `Pipeline: ${getPipelineLabel(state.status.pipeline)}`];
222
+ setUiState(ctx, state, lastFeedbackLines);
223
+ ctx.ui.notify(`CodeCartographer phase: ${nextPhase}`, "info");
224
+ },
225
+ });
226
+
227
+ pi.registerCommand("codecarto-next", {
228
+ description: "Queue the next eligible CodeCartographer phase prompt",
229
+ handler: async (_args, ctx) => {
230
+ const state = await ensureWorkspaceState(ctx);
231
+ if (!state) return;
232
+
233
+ const phase = getNextEligiblePhase(state);
234
+ if (!phase) {
235
+ lastFeedbackLines = ["All phases complete."];
236
+ setUiState(ctx, state, lastFeedbackLines);
237
+ ctx.ui.notify("All CodeCartographer phases are complete.", "info");
238
+ return;
239
+ }
240
+
241
+ const prompt = await buildPhasePrompt(state, phase, false);
242
+ if (ctx.isIdle()) {
243
+ pi.sendUserMessage(prompt);
244
+ } else {
245
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
246
+ }
247
+
248
+ lastFeedbackLines = [`Queued phase prompt for ${phase.id}`];
249
+ setUiState(ctx, state, lastFeedbackLines);
250
+ ctx.ui.notify(`Queued CodeCartographer phase: ${phase.id}`, "info");
251
+ },
252
+ });
253
+
254
+ pi.registerCommand("codecarto-phase", {
255
+ description: "Queue a specific CodeCartographer phase prompt: /codecarto-phase <phase>",
256
+ handler: async (args, ctx) => {
257
+ const phaseId = args.trim();
258
+ if (!phaseId) {
259
+ ctx.ui.notify("Usage: /codecarto-phase <phase>", "warning");
260
+ return;
261
+ }
262
+
263
+ const state = await ensureWorkspaceState(ctx);
264
+ if (!state) return;
265
+
266
+ const phase = resolvePhase(state, phaseId);
267
+ if (!phase) {
268
+ ctx.ui.notify(`Unknown phase: ${phaseId}`, "error");
269
+ return;
270
+ }
271
+
272
+ const prompt = await buildPhasePrompt(state, phase, true);
273
+ if (ctx.isIdle()) {
274
+ pi.sendUserMessage(prompt);
275
+ } else {
276
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
277
+ }
278
+
279
+ lastFeedbackLines = [`Queued explicit phase prompt for ${phase.id}`];
280
+ setUiState(ctx, state, lastFeedbackLines);
281
+ ctx.ui.notify(`Queued CodeCartographer phase: ${phase.id}`, "info");
282
+ },
283
+ });
284
+
285
+ pi.registerCommand("codecarto-validate", {
286
+ description: "Validate a phase output: /codecarto-validate [phase]",
287
+ handler: async (args, ctx) => {
288
+ const state = await ensureWorkspaceState(ctx);
289
+ if (!state) return;
290
+
291
+ const validation = await validatePhaseOutput(state, args.trim() || undefined);
292
+ lastFeedbackLines = buildValidationSummary(validation);
293
+ setUiState(ctx, state, lastFeedbackLines);
294
+
295
+ const level = validation.overall === "FAIL" || validation.overall === "MISSING" ? "error" : validation.overall === "PASS WITH GAPS" ? "warning" : "info";
296
+ ctx.ui.notify(`Validation ${validation.phaseId}: ${validation.overall}`, level);
297
+ },
298
+ });
299
+
300
+ pi.registerCommand("codecarto-complete", {
301
+ description: "Mark a phase complete after validation passes: /codecarto-complete [phase]",
302
+ handler: async (args, ctx) => {
303
+ const currentState = await ensureWorkspaceState(ctx);
304
+ if (!currentState) return;
305
+
306
+ const validation = await validatePhaseOutput(currentState, args.trim() || undefined);
307
+ if (validation.overall === "FAIL" || validation.overall === "MISSING") {
308
+ lastFeedbackLines = buildValidationSummary(validation);
309
+ setUiState(ctx, currentState, lastFeedbackLines);
310
+ ctx.ui.notify(`Cannot complete ${validation.phaseId}: ${validation.overall}`, "error");
311
+ return;
312
+ }
313
+
314
+ const completionTimestamp = new Date().toISOString();
315
+ const updatedState = await updateStatusAtomically(ctx.cwd, (lockedState) => {
316
+ const phase = resolvePhase(lockedState, validation.phaseId);
317
+ if (!phase?.primary_output) {
318
+ throw new Error(`Phase ${validation.phaseId} is missing primary_output.`);
319
+ }
320
+
321
+ const nextStatus = normalizeStatus(lockedState.status, lockedState.pipeline, lockedState.status.pipeline, lockedState.cwd);
322
+ const existingPhase = nextStatus.phases[validation.phaseId] ?? {
323
+ status: "pending",
324
+ owner_notes: [],
325
+ outputs_present: [],
326
+ open_questions: [],
327
+ carry_forward: [],
328
+ };
329
+
330
+ const gapEntries: OpenQuestionEntry[] = validation.rows
331
+ .filter((row) => row.result.toUpperCase().includes("PARTIAL"))
332
+ .map((row) => ({
333
+ kind: "needs-maintainer-decision",
334
+ description: row.criterion || "Partial validation gap",
335
+ deferred_reason: row.evidence || "Marked PARTIAL by validation",
336
+ }));
337
+
338
+ const mergedOpenQuestions: OpenQuestionEntry[] = [...existingPhase.open_questions];
339
+ for (const candidate of gapEntries) {
340
+ const dupe = mergedOpenQuestions.some((entry) => entry.description === candidate.description && entry.deferred_reason === candidate.deferred_reason);
341
+ if (!dupe) mergedOpenQuestions.push(candidate);
342
+ }
343
+
344
+ nextStatus.phases[validation.phaseId] = {
345
+ status: "complete",
346
+ owner_notes: uniqueStrings([
347
+ ...existingPhase.owner_notes,
348
+ `Completed via /codecarto-complete on ${completionTimestamp}.`,
349
+ `Primary output: .codecarto/${validation.primaryOutput}`,
350
+ `Validation: ${validation.overall}`,
351
+ ]).slice(-3),
352
+ outputs_present: uniqueStrings([...existingPhase.outputs_present, validation.primaryOutput]),
353
+ open_questions: mergedOpenQuestions,
354
+ carry_forward: existingPhase.carry_forward ?? [],
355
+ };
356
+
357
+ nextStatus.last_updated = completionTimestamp;
358
+ const updatedWorkspaceState: WorkspaceState = {
359
+ ...lockedState,
360
+ status: nextStatus,
361
+ };
362
+
363
+ const nextEligible = getNextEligiblePhase(updatedWorkspaceState);
364
+ nextStatus.current_phase = nextEligible?.id ?? "complete";
365
+ nextStatus.next_actions = nextEligible
366
+ ? [
367
+ `Begin ${nextEligible.id} phase by producing ${nextEligible.primary_output ?? `findings/${nextEligible.id}/`}`,
368
+ ]
369
+ : ["All phases complete. Review findings, open questions, and downstream implementation notes."];
370
+
371
+ return {
372
+ state: {
373
+ ...updatedWorkspaceState,
374
+ status: nextStatus,
375
+ },
376
+ threadLogEntry: buildThreadLogEntry(validation.phaseId, validation, completionTimestamp),
377
+ };
378
+ });
379
+
380
+ let closeoutNotice: string | undefined;
381
+ try {
382
+ const created = await ensureCloseoutStub(updatedState.workspaceDir, validation.phaseId, completionTimestamp);
383
+ if (created) {
384
+ closeoutNotice = `Closeout stub: .codecarto/closeouts/${closeoutFileName(dateOnly(completionTimestamp), validation.phaseId)} (fill it in)`;
385
+ }
386
+ } catch (error) {
387
+ const message = error instanceof Error ? error.message : String(error);
388
+ closeoutNotice = `Closeout stub not created: ${message}`;
389
+ }
390
+
391
+ lastFeedbackLines = [
392
+ `Completed phase: ${validation.phaseId}`,
393
+ `Validation: ${validation.overall}`,
394
+ `Next phase: ${updatedState.status.current_phase}`,
395
+ ];
396
+ if (closeoutNotice) lastFeedbackLines.push(closeoutNotice);
397
+ setUiState(ctx, updatedState, lastFeedbackLines);
398
+ ctx.ui.notify(`Marked ${validation.phaseId} complete`, validation.overall === "PASS WITH GAPS" ? "warning" : "info");
399
+ if (closeoutNotice) ctx.ui.notify(closeoutNotice, "info");
400
+ },
401
+ });
402
+
403
+ pi.registerCommand("codecarto-skill", {
404
+ description: "Run a post-pipeline skill (after all phases are complete): /codecarto-skill <name>",
405
+ handler: async (args, ctx) => {
406
+ const skillName = args.trim();
407
+ if (!skillName) {
408
+ const available = await listSkillNames(join(ctx.cwd, ".codecarto"));
409
+ const hint = available.length > 0 ? ` (available: ${available.join(", ")})` : "";
410
+ ctx.ui.notify(`Usage: /codecarto-skill <name>${hint}`, "warning");
411
+ return;
412
+ }
413
+
414
+ const state = await ensureWorkspaceState(ctx);
415
+ if (!state) return;
416
+
417
+ const nextPhase = getNextEligiblePhase(state);
418
+ if (nextPhase) {
419
+ ctx.ui.notify(
420
+ `Cannot run skill: pipeline is not complete (next phase: ${nextPhase.id}). Finish the pipeline before running post-pipeline skills.`,
421
+ "error",
422
+ );
423
+ return;
424
+ }
425
+
426
+ const skillFile = join(state.workspaceDir, "skills", skillName, "SKILL.md");
427
+ if (!(await pathExists(skillFile))) {
428
+ const available = await listSkillNames(state.workspaceDir);
429
+ const hint = available.length > 0 ? ` (available: ${available.join(", ")})` : " (no skills installed)";
430
+ ctx.ui.notify(`Unknown skill: ${skillName}${hint}`, "error");
431
+ return;
432
+ }
433
+
434
+ const prompt = await buildSkillPrompt(state, skillName);
435
+ if (ctx.isIdle()) {
436
+ pi.sendUserMessage(prompt);
437
+ } else {
438
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
439
+ }
440
+
441
+ lastFeedbackLines = [`Queued post-pipeline skill: ${skillName}`];
442
+ setUiState(ctx, state, lastFeedbackLines);
443
+ ctx.ui.notify(`Queued CodeCartographer skill: ${skillName}`, "info");
444
+ },
445
+ });
446
+ }