codecartographer-pi 0.11.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 (39) hide show
  1. package/.codecarto/GUIDE.md +30 -15
  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/spec-merge/README.md +3 -0
  7. package/.codecarto/findings/spec-merge/SKILL.md +23 -0
  8. package/.codecarto/findings/vision-capture/README.md +3 -0
  9. package/.codecarto/findings/vision-capture/SKILL.md +26 -0
  10. package/.codecarto/inputs/vision.md +11 -0
  11. package/.codecarto/templates/merged-spec.md +58 -0
  12. package/.codecarto/templates/phase-handoff.yaml +22 -0
  13. package/.codecarto/templates/project-plan.md +70 -0
  14. package/.codecarto/templates/proposal.md +43 -0
  15. package/.codecarto/templates/vision.md +63 -0
  16. package/.codecarto/workflow/pipeline-synthesis.yaml +105 -0
  17. package/.codecarto/workflow/status.yaml +2 -0
  18. package/README.md +52 -6
  19. package/dist/core/completion.d.ts +6 -0
  20. package/dist/core/completion.js +127 -0
  21. package/dist/core/dashboard.js +19 -2
  22. package/dist/core/index.d.ts +2 -0
  23. package/dist/core/index.js +2 -0
  24. package/dist/core/pipeline.js +1 -0
  25. package/dist/core/prompts.d.ts +9 -3
  26. package/dist/core/prompts.js +37 -26
  27. package/dist/core/status.d.ts +7 -1
  28. package/dist/core/status.js +187 -2
  29. package/dist/core/synthesis.d.ts +31 -0
  30. package/dist/core/synthesis.js +140 -0
  31. package/dist/core/types.d.ts +29 -1
  32. package/dist/core/workspace.d.ts +3 -1
  33. package/dist/core/workspace.js +39 -3
  34. package/dist/core/yaml.js +24 -0
  35. package/dist/extensions/codecarto/auto-runner.d.ts +3 -1
  36. package/dist/extensions/codecarto/auto-runner.js +20 -68
  37. package/dist/extensions/codecarto/index.js +125 -11
  38. package/dist/mcp-server/server.js +24 -68
  39. package/package.json +3 -2
@@ -1,11 +1,17 @@
1
1
  // Status normalization, atomic writes, and file-lock primitives. Pure
2
2
  // framework logic shared by every wrapper.
3
3
  import { open, rm, stat } from "node:fs/promises";
4
- import { basename } from "node:path";
5
- import { sleep } from "./utils.js";
4
+ import { basename, join } from "node:path";
5
+ import { pathExists, sleep } from "./utils.js";
6
+ import { loadYamlFile } from "./yaml.js";
6
7
  export const LOCK_RETRY_MS = 125;
7
8
  export const LOCK_TIMEOUT_MS = 5000;
8
9
  export const STALE_LOCK_MS = 60_000;
10
+ export function assertSafePhaseId(phaseId) {
11
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(phaseId)) {
12
+ throw new Error(`Invalid phase id: ${phaseId}`);
13
+ }
14
+ }
9
15
  export function ensureArray(value) {
10
16
  return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
11
17
  }
@@ -43,6 +49,39 @@ export function ensureEntryArray(value, allowTargetPhase = false) {
43
49
  }
44
50
  return result;
45
51
  }
52
+ export function autoAssignIds(entries, prefix, phaseId) {
53
+ const existingIds = new Set(entries.map((e) => e.id).filter(Boolean));
54
+ let counter = 0;
55
+ for (const entry of entries) {
56
+ if (!entry.id || !entry.id.trim()) {
57
+ counter++;
58
+ let candidate = `${prefix}-${phaseId}-${counter}`;
59
+ while (existingIds.has(candidate)) {
60
+ counter++;
61
+ candidate = `${prefix}-${phaseId}-${counter}`;
62
+ }
63
+ existingIds.add(candidate);
64
+ entry.id = candidate;
65
+ }
66
+ }
67
+ }
68
+ export function ensurePostPipelineArray(value) {
69
+ if (!Array.isArray(value))
70
+ return [];
71
+ const result = [];
72
+ for (const item of value) {
73
+ const base = coerceEntry(item, false);
74
+ if (!base)
75
+ continue;
76
+ const raw = typeof item === "object" && item && !Array.isArray(item) ? item : {};
77
+ result.push({
78
+ ...base,
79
+ source_phase: typeof raw.source_phase === "string" && raw.source_phase.trim() ? raw.source_phase.trim() : undefined,
80
+ status: raw.status === "resolved" ? "resolved" : "pending",
81
+ });
82
+ }
83
+ return result;
84
+ }
46
85
  export function ensurePhaseRecord(value) {
47
86
  if (!value || typeof value !== "object")
48
87
  return {};
@@ -79,13 +118,18 @@ export function createEmptyStatus(projectName, pipelinePath, pipeline) {
79
118
  pipeline: pipelinePath,
80
119
  current_phase: firstPhase,
81
120
  last_updated: "",
121
+ schema_version: 1,
82
122
  phases,
83
123
  next_actions: firstPhaseConfig?.primary_output
84
124
  ? [`Begin ${firstPhase} phase by producing ${firstPhaseConfig.primary_output}`]
85
125
  : ["Begin the first pending phase."],
126
+ post_pipeline: [],
86
127
  };
87
128
  }
88
129
  export function normalizeStatus(status, pipeline, pipelinePath, cwd) {
130
+ if (typeof status.schema_version === "number" && status.schema_version > 1) {
131
+ throw new Error(`Unsupported status schema_version ${status.schema_version}. Supported: 1.`);
132
+ }
89
133
  const phases = ensurePhaseRecord(status.phases);
90
134
  for (const phaseId of pipeline.phase_order) {
91
135
  if (!phases[phaseId]) {
@@ -103,10 +147,151 @@ export function normalizeStatus(status, pipeline, pipelinePath, cwd) {
103
147
  pipeline: status.pipeline?.trim() || pipelinePath,
104
148
  current_phase: status.current_phase?.trim() || pipeline.phase_order[0] || "complete",
105
149
  last_updated: status.last_updated?.trim() || "",
150
+ schema_version: typeof status.schema_version === "number" ? status.schema_version : 1,
106
151
  phases,
107
152
  next_actions: ensureArray(status.next_actions),
153
+ post_pipeline: ensurePostPipelineArray(status.post_pipeline),
154
+ };
155
+ }
156
+ export function parseHandoff(value) {
157
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
158
+ throw new Error("Invalid handoff: expected object");
159
+ }
160
+ const raw = value;
161
+ if (typeof raw.phase_id !== "string" || !raw.phase_id.trim()) {
162
+ throw new Error("Invalid handoff: phase_id is required");
163
+ }
164
+ const schemaVersion = typeof raw.schema_version === "number" ? raw.schema_version : 1;
165
+ // Reject unsupported future versions (anything > current version 1)
166
+ if (schemaVersion > 1) {
167
+ throw new Error(`Invalid handoff: unsupported schema_version ${schemaVersion}. Supported: 1.`);
168
+ }
169
+ for (const field of ["owner_notes", "open_questions", "carry_forward", "carry_forward_closures", "open_question_closures", "post_pipeline", "decisions"]) {
170
+ if (raw[field] !== undefined && !Array.isArray(raw[field])) {
171
+ throw new Error(`Invalid handoff: ${field} must be an array`);
172
+ }
173
+ }
174
+ const openQuestions = ensureEntryArray(raw.open_questions, false);
175
+ const carryForward = ensureEntryArray(raw.carry_forward, true);
176
+ autoAssignIds(openQuestions, "oq", raw.phase_id.trim());
177
+ autoAssignIds(carryForward, "cf", raw.phase_id.trim());
178
+ return {
179
+ phase_id: raw.phase_id.trim(),
180
+ timestamp: typeof raw.timestamp === "string" ? raw.timestamp.trim() : undefined,
181
+ owner_notes: ensureArray(raw.owner_notes),
182
+ open_questions: openQuestions,
183
+ carry_forward: carryForward,
184
+ carry_forward_closures: ensureArray(raw.carry_forward_closures),
185
+ open_question_closures: ensureArray(raw.open_question_closures),
186
+ post_pipeline: ensurePostPipelineArray(raw.post_pipeline),
187
+ decisions: ensureArray(raw.decisions),
188
+ closeout_content: typeof raw.closeout_content === "string" ? raw.closeout_content : "",
189
+ closeout_summary: typeof raw.closeout_summary === "string" ? raw.closeout_summary : "",
190
+ schema_version: schemaVersion,
108
191
  };
109
192
  }
193
+ export async function loadHandoffFile(phaseId, workspaceDir) {
194
+ assertSafePhaseId(phaseId);
195
+ const handoffPath = join(workspaceDir, "scratch", "handoffs", `${phaseId}.yaml`);
196
+ if (!(await pathExists(handoffPath)))
197
+ return null;
198
+ const raw = await loadYamlFile(handoffPath);
199
+ return parseHandoff(raw);
200
+ }
201
+ export function applyHandoff(status, handoff) {
202
+ const phase = status.phases[handoff.phase_id];
203
+ if (!phase)
204
+ return status;
205
+ phase.owner_notes = ensureArray([
206
+ ...phase.owner_notes,
207
+ ...handoff.owner_notes,
208
+ ]);
209
+ // Merge open_questions: deduplicate by id across ALL phases, not just this one
210
+ // First, collect existing questions with the same id from other phases
211
+ const oqMap = new Map();
212
+ for (const [pid, ph] of Object.entries(status.phases)) {
213
+ for (const entry of ph.open_questions ?? []) {
214
+ const key = entry.id || entry.description || "";
215
+ if (key)
216
+ oqMap.set(key, { entry, phase: pid });
217
+ }
218
+ }
219
+ // Remove existing entries with matching ids from their original phases
220
+ for (const entry of handoff.open_questions) {
221
+ const key = entry.id || entry.description || "";
222
+ if (key && oqMap.has(key)) {
223
+ const existing = oqMap.get(key);
224
+ if (existing.phase !== handoff.phase_id) {
225
+ status.phases[existing.phase].open_questions = status.phases[existing.phase].open_questions.filter((e) => (e.id || e.description || "") !== key);
226
+ }
227
+ }
228
+ }
229
+ // Now merge into the current phase: overwrite by id or append new
230
+ const localOqMap = new Map();
231
+ for (const entry of phase.open_questions) {
232
+ const key = entry.id || entry.description || "";
233
+ if (key)
234
+ localOqMap.set(key, entry);
235
+ }
236
+ for (const entry of handoff.open_questions) {
237
+ const key = entry.id || entry.description || "";
238
+ if (key)
239
+ localOqMap.set(key, entry);
240
+ else
241
+ phase.open_questions.push(entry);
242
+ }
243
+ phase.open_questions = [...localOqMap.values()];
244
+ // Merge carry_forward: overwrite by id or append new
245
+ const cfMap = new Map();
246
+ for (const entry of phase.carry_forward) {
247
+ const key = entry.id || entry.description || "";
248
+ if (key)
249
+ cfMap.set(key, entry);
250
+ }
251
+ for (const entry of handoff.carry_forward) {
252
+ const key = entry.id || entry.description || "";
253
+ if (key)
254
+ cfMap.set(key, entry);
255
+ else
256
+ phase.carry_forward.push(entry);
257
+ }
258
+ phase.carry_forward = [...cfMap.values()];
259
+ // Apply closures: remove carry_forward entries from ALL phases by id
260
+ for (const closureId of handoff.carry_forward_closures) {
261
+ if (!closureId)
262
+ continue;
263
+ for (const ph of Object.values(status.phases)) {
264
+ ph.carry_forward = ph.carry_forward.filter((entry) => entry.id !== closureId);
265
+ }
266
+ }
267
+ // Apply open_question_closures: remove resolved questions from ALL phases by id
268
+ for (const closureId of handoff.open_question_closures) {
269
+ if (!closureId)
270
+ continue;
271
+ for (const ph of Object.values(status.phases)) {
272
+ ph.open_questions = ph.open_questions.filter((entry) => entry.id !== closureId);
273
+ }
274
+ }
275
+ const postPipeline = new Map();
276
+ const legacyPostPipeline = [];
277
+ for (const entry of status.post_pipeline) {
278
+ if (entry.id)
279
+ postPipeline.set(entry.id, entry);
280
+ else
281
+ legacyPostPipeline.push(entry);
282
+ }
283
+ for (const entry of handoff.post_pipeline) {
284
+ const normalized = {
285
+ ...entry,
286
+ source_phase: entry.source_phase ?? handoff.phase_id,
287
+ status: entry.status ?? "pending",
288
+ };
289
+ if (normalized.id)
290
+ postPipeline.set(normalized.id, normalized);
291
+ }
292
+ status.post_pipeline = [...legacyPostPipeline, ...postPipeline.values()];
293
+ return status;
294
+ }
110
295
  export async function acquireLock(lockPath) {
111
296
  const startedAt = Date.now();
112
297
  while (true) {
@@ -0,0 +1,31 @@
1
+ import type { PipelinePhase, WorkspaceState } from "./types.ts";
2
+ export declare const SYNTHESIS_PROPOSAL_PATH = "findings/goal-synthesis/proposal.md";
3
+ export declare const SYNTHESIS_VISION_INPUT_PATH = "inputs/vision.md";
4
+ export type SynthesisLibraryEntry = {
5
+ ref: string;
6
+ version: number;
7
+ versions: number[];
8
+ headline: string;
9
+ tags: string[];
10
+ specPath: string;
11
+ };
12
+ export type ConfirmedProposalSelection = {
13
+ ref: string;
14
+ version: number;
15
+ specPath?: string;
16
+ };
17
+ export type PhasePreflightResult = {
18
+ libraryPath?: string;
19
+ libraryName?: string;
20
+ libraryEntries: SynthesisLibraryEntry[];
21
+ confirmedEntries: string[];
22
+ confirmedSelections: ConfirmedProposalSelection[];
23
+ };
24
+ export declare class PhasePreflightError extends Error {
25
+ readonly phaseId: string;
26
+ constructor(phaseId: string, message: string);
27
+ }
28
+ export declare function parseConfirmedProposalEntries(markdown: string): string[];
29
+ export declare function hasMeaningfulVisionContent(markdown: string): boolean;
30
+ export declare function parseConfirmedProposalSelections(markdown: string): ConfirmedProposalSelection[];
31
+ export declare function runPhasePreflight(state: WorkspaceState, phase: PipelinePhase): Promise<PhasePreflightResult>;
@@ -0,0 +1,140 @@
1
+ // Runtime guards and library context for the forward synthesis pipeline.
2
+ // These checks live in core so Pi and MCP refuse the same invalid transition
3
+ // before an LLM receives a phase prompt.
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { discoverLibrary, listEntries } from "./library.js";
7
+ import { loadCodecartoConfig } from "./orchestrator-config.js";
8
+ import { pathExists } from "./utils.js";
9
+ export const SYNTHESIS_PROPOSAL_PATH = "findings/goal-synthesis/proposal.md";
10
+ export const SYNTHESIS_VISION_INPUT_PATH = "inputs/vision.md";
11
+ export class PhasePreflightError extends Error {
12
+ phaseId;
13
+ constructor(phaseId, message) {
14
+ super(`Cannot start ${phaseId}: ${message}`);
15
+ this.phaseId = phaseId;
16
+ this.name = "PhasePreflightError";
17
+ }
18
+ }
19
+ export function parseConfirmedProposalEntries(markdown) {
20
+ return parseConfirmedProposalSelections(markdown).map((selection) => selection.ref);
21
+ }
22
+ export function hasMeaningfulVisionContent(markdown) {
23
+ let inHtmlComment = false;
24
+ for (const line of markdown.split(/\r?\n/)) {
25
+ const visibleSegments = [];
26
+ let cursor = 0;
27
+ while (cursor < line.length) {
28
+ if (inHtmlComment) {
29
+ const commentEnd = line.indexOf("-->", cursor);
30
+ if (commentEnd === -1) {
31
+ cursor = line.length;
32
+ continue;
33
+ }
34
+ inHtmlComment = false;
35
+ cursor = commentEnd + 3;
36
+ continue;
37
+ }
38
+ const commentStart = line.indexOf("<!--", cursor);
39
+ if (commentStart === -1) {
40
+ visibleSegments.push(line.slice(cursor));
41
+ break;
42
+ }
43
+ visibleSegments.push(line.slice(cursor, commentStart));
44
+ inHtmlComment = true;
45
+ cursor = commentStart + 4;
46
+ }
47
+ // Keep source segments separated so removing a comment cannot manufacture
48
+ // a new multi-character token from the characters on either side.
49
+ const visible = visibleSegments.join(" ").trim();
50
+ if (!visible || /^#+(?:\s|$)/.test(visible))
51
+ continue;
52
+ if (/[\p{L}\p{N}]/u.test(visible))
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+ export function parseConfirmedProposalSelections(markdown) {
58
+ const confirmed = [];
59
+ for (const rawLine of markdown.split(/\r?\n/)) {
60
+ const match = rawLine.match(/^\|\s*\[[xX]\]\s*\|\s*`?([^|`]+)`?\s*\|\s*`?v?(\d+)`?\s*\|/i);
61
+ const ref = match?.[1]?.trim();
62
+ const version = match?.[2] ? Number.parseInt(match[2], 10) : Number.NaN;
63
+ if (ref && Number.isInteger(version) && !confirmed.some((selection) => selection.ref === ref)) {
64
+ confirmed.push({ ref, version });
65
+ }
66
+ }
67
+ return confirmed;
68
+ }
69
+ export async function runPhasePreflight(state, phase) {
70
+ const checks = new Set(phase.preflight ?? []);
71
+ // Confirmed proposal rows are meaningful only when they can be resolved
72
+ // against the configured versioned library. Keep the stronger check
73
+ // self-contained instead of relying on every pipeline to co-declare its
74
+ // implementation dependency.
75
+ if (checks.has("requires-confirmed-proposal"))
76
+ checks.add("requires-library");
77
+ const result = { libraryEntries: [], confirmedEntries: [], confirmedSelections: [] };
78
+ if (checks.has("requires-vision-input")) {
79
+ const visionPath = join(state.workspaceDir, SYNTHESIS_VISION_INPUT_PATH);
80
+ if (!(await pathExists(visionPath))) {
81
+ throw new PhasePreflightError(phase.id, `the vision brief is missing at .codecarto/${SYNTHESIS_VISION_INPUT_PATH}. Create it before starting synthesis.`);
82
+ }
83
+ const rawVision = await readFile(visionPath, "utf8");
84
+ if (!hasMeaningfulVisionContent(rawVision)) {
85
+ throw new PhasePreflightError(phase.id, `the vision brief at .codecarto/${SYNTHESIS_VISION_INPUT_PATH} is still empty. Describe the audience, problem, and desired outcome, then retry.`);
86
+ }
87
+ }
88
+ if (checks.has("requires-library")) {
89
+ const config = await loadCodecartoConfig(state.workspaceDir);
90
+ if (!config.library.path) {
91
+ throw new PhasePreflightError(phase.id, "no library.path is configured. Set it in ~/.codecarto/config.yaml or .codecarto/workflow/config.yaml.");
92
+ }
93
+ const marker = await discoverLibrary(config.library.path);
94
+ if (!marker) {
95
+ throw new PhasePreflightError(phase.id, `no CodeCartographer library was found at ${config.library.path} (missing .codecarto-library).`);
96
+ }
97
+ const entries = await listEntries(config.library.path);
98
+ if (entries.length === 0) {
99
+ throw new PhasePreflightError(phase.id, `the configured library at ${config.library.path} has no entries. Publish at least one reimplementation spec first.`);
100
+ }
101
+ result.libraryPath = config.library.path;
102
+ result.libraryName = marker.name;
103
+ result.libraryEntries = entries.map((entry) => describeLibraryEntry(config.library.path, entry));
104
+ }
105
+ if (checks.has("requires-confirmed-proposal")) {
106
+ const proposalPath = join(state.workspaceDir, SYNTHESIS_PROPOSAL_PATH);
107
+ if (!(await pathExists(proposalPath))) {
108
+ throw new PhasePreflightError(phase.id, `the proposal is missing at .codecarto/${SYNTHESIS_PROPOSAL_PATH}. Run goal-synthesis-propose first.`);
109
+ }
110
+ result.confirmedSelections = parseConfirmedProposalSelections(await readFile(proposalPath, "utf8"));
111
+ result.confirmedEntries = result.confirmedSelections.map((selection) => selection.ref);
112
+ if (result.confirmedSelections.length === 0) {
113
+ throw new PhasePreflightError(phase.id, `no library entries are confirmed in .codecarto/${SYNTHESIS_PROPOSAL_PATH}. Change at least one [ ] checkbox to [x], then retry.`);
114
+ }
115
+ const available = new Map(result.libraryEntries.map((entry) => [entry.ref, entry]));
116
+ for (const selection of result.confirmedSelections) {
117
+ const entry = available.get(selection.ref);
118
+ if (!entry || !entry.versions.includes(selection.version)) {
119
+ throw new PhasePreflightError(phase.id, `confirmed selection ${selection.ref}@v${selection.version} is not present in the configured library. Re-run the proposal phase or correct the checked row.`);
120
+ }
121
+ selection.specPath = specPathForVersion(result.libraryPath, selection.ref, selection.version);
122
+ }
123
+ }
124
+ return result;
125
+ }
126
+ function describeLibraryEntry(libraryPath, entry) {
127
+ const ref = entry.namespace ? `${entry.namespace}/${entry.slug}` : entry.slug;
128
+ const version = entry.latest_version;
129
+ return {
130
+ ref,
131
+ version,
132
+ versions: [...entry.versions],
133
+ headline: entry.headline,
134
+ tags: [...entry.tags],
135
+ specPath: join(libraryPath, "entries", ...(entry.namespace ? [entry.namespace] : []), entry.slug, `v${version}`, "reimplementation-spec.md"),
136
+ };
137
+ }
138
+ function specPathForVersion(libraryPath, ref, version) {
139
+ return join(libraryPath, "entries", ...ref.split("/"), `v${version}`, "reimplementation-spec.md");
140
+ }
@@ -10,6 +10,10 @@ export type OpenQuestionEntry = {
10
10
  export type CarryForwardEntry = OpenQuestionEntry & {
11
11
  target_phase?: string;
12
12
  };
13
+ export type PostPipelineEntry = OpenQuestionEntry & {
14
+ source_phase?: string;
15
+ status?: "pending" | "resolved";
16
+ };
13
17
  export type StatusPhase = {
14
18
  status: PhaseStatusValue | string;
15
19
  owner_notes: string[];
@@ -22,8 +26,10 @@ export type StatusFile = {
22
26
  pipeline?: string;
23
27
  current_phase?: string;
24
28
  last_updated?: string;
29
+ schema_version?: number;
25
30
  phases?: Record<string, StatusPhase>;
26
31
  next_actions?: string[];
32
+ post_pipeline?: PostPipelineEntry[];
27
33
  };
28
34
  export type SecondaryOutput = {
29
35
  path: string;
@@ -40,6 +46,7 @@ export type PipelinePhase = {
40
46
  required_reads?: string[];
41
47
  completion_criteria?: string[];
42
48
  handoff_requirements?: string[];
49
+ preflight?: Array<"requires-vision-input" | "requires-library" | "requires-confirmed-proposal">;
43
50
  };
44
51
  export type PipelineFile = {
45
52
  workflow_name?: string;
@@ -50,7 +57,9 @@ export type PipelineFile = {
50
57
  phase_order: string[];
51
58
  phases: PipelinePhase[];
52
59
  };
53
- export type NormalizedStatus = Required<Pick<StatusFile, "project_name" | "pipeline" | "current_phase" | "last_updated" | "phases" | "next_actions">>;
60
+ export type NormalizedStatus = Required<Pick<StatusFile, "project_name" | "pipeline" | "current_phase" | "last_updated" | "phases" | "next_actions" | "post_pipeline">> & {
61
+ schema_version: number;
62
+ };
54
63
  export type WorkspaceState = {
55
64
  cwd: string;
56
65
  workspaceDir: string;
@@ -75,3 +84,22 @@ export type ValidationResult = {
75
84
  gaps: string[];
76
85
  errors: string[];
77
86
  };
87
+ export type PhaseHandoff = {
88
+ phase_id: string;
89
+ /**
90
+ * Deprecated: model-provided timestamps are ignored. The framework uses
91
+ * the host clock for all canonical writes (status.yaml, THREAD_LOG,
92
+ * closeout). Kept in the type for backward-compatible parse only.
93
+ */
94
+ timestamp?: string;
95
+ owner_notes: string[];
96
+ open_questions: OpenQuestionEntry[];
97
+ carry_forward: CarryForwardEntry[];
98
+ carry_forward_closures: string[];
99
+ open_question_closures: string[];
100
+ post_pipeline: PostPipelineEntry[];
101
+ decisions: string[];
102
+ closeout_content: string;
103
+ closeout_summary: string;
104
+ schema_version?: number;
105
+ };
@@ -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;
@@ -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