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.
- package/.codecarto/GUIDE.md +30 -15
- package/.codecarto/README.md +3 -0
- package/.codecarto/findings/goal-synthesis/README.md +3 -0
- package/.codecarto/findings/goal-synthesis-finalize/SKILL.md +30 -0
- package/.codecarto/findings/goal-synthesis-propose/SKILL.md +24 -0
- package/.codecarto/findings/spec-merge/README.md +3 -0
- package/.codecarto/findings/spec-merge/SKILL.md +23 -0
- package/.codecarto/findings/vision-capture/README.md +3 -0
- package/.codecarto/findings/vision-capture/SKILL.md +26 -0
- package/.codecarto/inputs/vision.md +11 -0
- package/.codecarto/templates/merged-spec.md +58 -0
- package/.codecarto/templates/phase-handoff.yaml +22 -0
- package/.codecarto/templates/project-plan.md +70 -0
- package/.codecarto/templates/proposal.md +43 -0
- package/.codecarto/templates/vision.md +63 -0
- package/.codecarto/workflow/pipeline-synthesis.yaml +105 -0
- package/.codecarto/workflow/status.yaml +2 -0
- package/README.md +52 -6
- package/dist/core/completion.d.ts +6 -0
- package/dist/core/completion.js +127 -0
- package/dist/core/dashboard.js +19 -2
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/pipeline.js +1 -0
- package/dist/core/prompts.d.ts +9 -3
- package/dist/core/prompts.js +37 -26
- package/dist/core/status.d.ts +7 -1
- package/dist/core/status.js +187 -2
- package/dist/core/synthesis.d.ts +31 -0
- package/dist/core/synthesis.js +140 -0
- package/dist/core/types.d.ts +29 -1
- package/dist/core/workspace.d.ts +3 -1
- package/dist/core/workspace.js +39 -3
- package/dist/core/yaml.js +24 -0
- package/dist/extensions/codecarto/auto-runner.d.ts +3 -1
- package/dist/extensions/codecarto/auto-runner.js +20 -68
- package/dist/extensions/codecarto/index.js +125 -11
- package/dist/mcp-server/server.js +24 -68
- package/package.json +3 -2
package/dist/core/status.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/core/workspace.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/workspace.js
CHANGED
|
@@ -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
|
-
|
|
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
|