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.
- package/.codecarto/BACKLOG.md +192 -0
- package/.codecarto/CHANGELOG-2026-05-02-feedback-pass.md +118 -0
- package/.codecarto/CONTRIBUTING.md +56 -0
- package/.codecarto/GUIDE.md +298 -0
- package/.codecarto/LICENSE +21 -0
- package/.codecarto/NEW_THREAD_BLURB.md +47 -0
- package/.codecarto/README.md +39 -0
- package/.codecarto/THREAD_LOG.md +39 -0
- package/.codecarto/closeouts/2026-05-02-framework-feedback-pass.md +111 -0
- package/.codecarto/findings/architecture/README.md +3 -0
- package/.codecarto/findings/architecture/SKILL.md +102 -0
- package/.codecarto/findings/build-and-deploy/.gitkeep +0 -0
- package/.codecarto/findings/config-model/.gitkeep +0 -0
- package/.codecarto/findings/contracts/README.md +3 -0
- package/.codecarto/findings/contracts/SKILL.md +89 -0
- package/.codecarto/findings/defect-scan/README.md +18 -0
- package/.codecarto/findings/defect-scan/SKILL.md +87 -0
- package/.codecarto/findings/defect-scan/passes/01-logic-and-correctness.md +50 -0
- package/.codecarto/findings/defect-scan/passes/02-error-handling.md +55 -0
- package/.codecarto/findings/defect-scan/passes/03-concurrency-and-resources.md +54 -0
- package/.codecarto/findings/defect-scan/passes/04-security-and-trust.md +62 -0
- package/.codecarto/findings/defect-scan/passes/05-api-contract-violations.md +57 -0
- package/.codecarto/findings/defect-scan/passes/06-config-and-environment.md +58 -0
- package/.codecarto/findings/defect-scan-mechanical/README.md +17 -0
- package/.codecarto/findings/defect-scan-mechanical/SKILL.md +60 -0
- package/.codecarto/findings/defect-scan-semantic/README.md +17 -0
- package/.codecarto/findings/defect-scan-semantic/SKILL.md +54 -0
- package/.codecarto/findings/porting/README.md +3 -0
- package/.codecarto/findings/porting/SKILL.md +52 -0
- package/.codecarto/findings/protocols/README.md +3 -0
- package/.codecarto/findings/protocols/SKILL.md +87 -0
- package/.codecarto/findings/public-surfaces/README.md +3 -0
- package/.codecarto/findings/reimplementation-spec/README.md +3 -0
- package/.codecarto/findings/reimplementation-spec/SKILL.md +66 -0
- package/.codecarto/findings/runtime-lifecycle/README.md +3 -0
- package/.codecarto/findings/state-and-storage/README.md +3 -0
- package/.codecarto/scratch/.gitkeep +0 -0
- package/.codecarto/skills/spec-delta-application/SKILL.md +102 -0
- package/.codecarto/templates/architecture-map.md +143 -0
- package/.codecarto/templates/behavioral-contracts.md +134 -0
- package/.codecarto/templates/closeout-template.md +85 -0
- package/.codecarto/templates/conventions-template.md +65 -0
- package/.codecarto/templates/decisions-template.md +82 -0
- package/.codecarto/templates/defect-fix-tracker.md +77 -0
- package/.codecarto/templates/defect-report.md +116 -0
- package/.codecarto/templates/deltas-applied.md +71 -0
- package/.codecarto/templates/mechanical-defects.md +104 -0
- package/.codecarto/templates/protocols-and-state.md +126 -0
- package/.codecarto/templates/reimplementation-spec-opinionated.md +183 -0
- package/.codecarto/templates/reimplementation-spec.md +148 -0
- package/.codecarto/templates/reverse-engineering-bundle.md +141 -0
- package/.codecarto/templates/semantic-defects.md +109 -0
- package/.codecarto/templates/thread-log-entry-template.md +27 -0
- package/.codecarto/workflow/VALIDATE.md +81 -0
- package/.codecarto/workflow/pipeline-architecture-only.yaml +38 -0
- package/.codecarto/workflow/pipeline-defect-scan.yaml +61 -0
- package/.codecarto/workflow/pipeline-full-with-audit.yaml +188 -0
- package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +227 -0
- package/.codecarto/workflow/pipeline-lite.yaml +100 -0
- package/.codecarto/workflow/pipeline.yaml +163 -0
- package/.codecarto/workflow/status.yaml +64 -0
- package/LICENSE +21 -0
- package/README.md +356 -0
- package/core/index.ts +11 -0
- package/core/pipeline.ts +175 -0
- package/core/prompts.ts +183 -0
- package/core/status.ts +155 -0
- package/core/types.ts +96 -0
- package/core/utils.ts +52 -0
- package/core/workspace.ts +81 -0
- package/core/yaml.ts +256 -0
- package/extensions/codecarto/index.ts +446 -0
- package/mcp-server/bin.mjs +7 -0
- package/mcp-server/server.ts +497 -0
- package/package.json +52 -0
package/core/prompts.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Prompt builders + closeout/thread-log helpers. The phase prompt is the
|
|
2
|
+
// single biggest fidelity surface — both Pi and the MCP server emit
|
|
3
|
+
// byte-identical text by importing buildPhasePrompt from here.
|
|
4
|
+
|
|
5
|
+
import { copyFile, mkdir, readdir } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type {
|
|
8
|
+
CarryForwardEntry,
|
|
9
|
+
OpenQuestionEntry,
|
|
10
|
+
PipelinePhase,
|
|
11
|
+
ValidationResult,
|
|
12
|
+
WorkspaceState,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
import { dateOnly, pathExists } from "./utils.ts";
|
|
15
|
+
|
|
16
|
+
export function describeEntry(entry: OpenQuestionEntry | CarryForwardEntry): string {
|
|
17
|
+
const parts: string[] = [];
|
|
18
|
+
if (entry.id) parts.push(entry.id);
|
|
19
|
+
if (entry.kind) parts.push(`(${entry.kind})`);
|
|
20
|
+
if (entry.description) parts.push(entry.description);
|
|
21
|
+
else if (entry.deferred_reason) parts.push(entry.deferred_reason);
|
|
22
|
+
return parts.join(" ").trim() || "(unlabeled entry)";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function collectRoutedCarryForward(state: WorkspaceState, targetPhaseId: string): CarryForwardEntry[] {
|
|
26
|
+
const routed: CarryForwardEntry[] = [];
|
|
27
|
+
for (const phase of Object.values(state.status.phases)) {
|
|
28
|
+
for (const entry of phase.carry_forward ?? []) {
|
|
29
|
+
if (entry.target_phase === targetPhaseId) routed.push(entry);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return routed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function buildPhasePrompt(state: WorkspaceState, phase: PipelinePhase, forced: boolean): Promise<string> {
|
|
36
|
+
const lines = [
|
|
37
|
+
`Read .codecarto/GUIDE.md and continue the CodeCartographer workflow for the phase \`${phase.id}\`.`,
|
|
38
|
+
`Work on this phase only. The analyzed source code is the repository outside .codecarto/.`,
|
|
39
|
+
"",
|
|
40
|
+
"Required reads before analysis:",
|
|
41
|
+
"- .codecarto/GUIDE.md",
|
|
42
|
+
"- .codecarto/workflow/status.yaml",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const primaryOutput = phase.primary_output ? `.codecarto/${phase.primary_output}` : undefined;
|
|
46
|
+
if (primaryOutput) {
|
|
47
|
+
lines.push(`- ${primaryOutput} if it already exists (continue instead of duplicating work)`);
|
|
48
|
+
}
|
|
49
|
+
if (phase.skill_path) lines.push(`- .codecarto/${phase.skill_path}`);
|
|
50
|
+
if (phase.output_template) lines.push(`- .codecarto/${phase.output_template}`);
|
|
51
|
+
|
|
52
|
+
const staticReads = new Set(["GUIDE.md", "workflow/status.yaml"]);
|
|
53
|
+
const phaseReads = (phase.required_reads ?? []).filter((path) => path && !staticReads.has(path));
|
|
54
|
+
for (const path of phaseReads) {
|
|
55
|
+
lines.push(`- .codecarto/${path}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const conventionsPath = join(state.workspaceDir, "CONVENTIONS.md");
|
|
59
|
+
if (await pathExists(conventionsPath)) {
|
|
60
|
+
lines.push("- .codecarto/CONVENTIONS.md (cross-cutting patterns the orchestrator has promoted)");
|
|
61
|
+
}
|
|
62
|
+
const decisionsPath = join(state.workspaceDir, "DECISIONS.md");
|
|
63
|
+
if (await pathExists(decisionsPath)) {
|
|
64
|
+
lines.push("- .codecarto/DECISIONS.md (numbered project decisions; new entries are appended in your closeout)");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const routed = collectRoutedCarryForward(state, phase.id);
|
|
68
|
+
if (routed.length > 0) {
|
|
69
|
+
lines.push("", `Items routed to \`${phase.id}\` for closure (carry_forward from earlier phases):`);
|
|
70
|
+
for (const entry of routed) {
|
|
71
|
+
lines.push(`- ${describeEntry(entry)}`);
|
|
72
|
+
}
|
|
73
|
+
lines.push("Close each item by editing your phase output to address it, then remove the entry from the source phase's carry_forward in workflow/status.yaml.");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (phase.id === "reimplementation-spec") {
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push("Strategic Alignment Hook (run BEFORE producing the spec):");
|
|
79
|
+
lines.push("- Confirm with the user whether this spec should be language-agnostic or opinionated:");
|
|
80
|
+
lines.push(" - language-agnostic → use templates/reimplementation-spec.md (default).");
|
|
81
|
+
lines.push(" - opinionated (target stack locked) → use templates/reimplementation-spec-opinionated.md.");
|
|
82
|
+
lines.push("- Record the chosen variant in the spec front-matter and in your validation block.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
lines.push("", "Rules:");
|
|
86
|
+
lines.push("- Do not modify source files outside .codecarto/.");
|
|
87
|
+
lines.push("- Follow the active pipeline and validation protocol.");
|
|
88
|
+
lines.push("- Update findings under .codecarto/findings/ for this phase.");
|
|
89
|
+
lines.push("- Distinguish open_questions (genuinely unknown) from carry_forward (routed to a specific later phase) when updating workflow/status.yaml — see GUIDE.md \"Open Questions vs Carry-Forward\".");
|
|
90
|
+
|
|
91
|
+
if (forced) {
|
|
92
|
+
lines.push("- The user explicitly requested this phase even if it is not the next eligible phase.");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (phase.depends_on && phase.depends_on.length > 0) {
|
|
96
|
+
const unmet = phase.depends_on.filter((dependencyId) => state.status.phases[dependencyId]?.status !== "complete");
|
|
97
|
+
if (unmet.length > 0) {
|
|
98
|
+
lines.push(`- Warning: dependencies not complete yet: ${unmet.join(", ")}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (phase.handoff_requirements && phase.handoff_requirements.length > 0) {
|
|
103
|
+
lines.push("", "Handoff requirements (from the active pipeline):");
|
|
104
|
+
for (const requirement of phase.handoff_requirements) {
|
|
105
|
+
lines.push(`- ${requirement}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (primaryOutput) {
|
|
110
|
+
lines.push("", `Primary output target: ${primaryOutput}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function closeoutFileName(date: string, phaseOrModule: string): string {
|
|
117
|
+
return `${date}-${phaseOrModule}.md`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function buildThreadLogEntry(phaseOrModule: string, validation: ValidationResult, timestamp: string): string {
|
|
121
|
+
const date = dateOnly(timestamp);
|
|
122
|
+
const file = closeoutFileName(date, phaseOrModule);
|
|
123
|
+
return `- ${date} — ${phaseOrModule} — Validation: ${validation.overall} — [closeout](closeouts/${file})\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function ensureCloseoutStub(workspaceDir: string, phaseOrModule: string, timestamp: string): Promise<string | null> {
|
|
127
|
+
const date = dateOnly(timestamp);
|
|
128
|
+
const closeoutsDir = join(workspaceDir, "closeouts");
|
|
129
|
+
const closeoutPath = join(closeoutsDir, closeoutFileName(date, phaseOrModule));
|
|
130
|
+
if (await pathExists(closeoutPath)) return null;
|
|
131
|
+
const templatePath = join(workspaceDir, "templates", "closeout-template.md");
|
|
132
|
+
if (!(await pathExists(templatePath))) return null;
|
|
133
|
+
await mkdir(closeoutsDir, { recursive: true });
|
|
134
|
+
await copyFile(templatePath, closeoutPath);
|
|
135
|
+
return closeoutPath;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function listSkillNames(workspaceDir: string): Promise<string[]> {
|
|
139
|
+
const skillsDir = join(workspaceDir, "skills");
|
|
140
|
+
if (!(await pathExists(skillsDir))) return [];
|
|
141
|
+
try {
|
|
142
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
143
|
+
const names: string[] = [];
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
if (!entry.isDirectory()) continue;
|
|
146
|
+
const skillFile = join(skillsDir, entry.name, "SKILL.md");
|
|
147
|
+
if (await pathExists(skillFile)) names.push(entry.name);
|
|
148
|
+
}
|
|
149
|
+
return names.sort();
|
|
150
|
+
} catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function buildSkillPrompt(state: WorkspaceState, skillName: string): Promise<string> {
|
|
156
|
+
const lines = [
|
|
157
|
+
`Read .codecarto/GUIDE.md and run the post-pipeline skill \`${skillName}\`.`,
|
|
158
|
+
"This is post-pipeline work. The pipeline is `complete`. Do not change `current_phase` or `phase_order` in workflow/status.yaml.",
|
|
159
|
+
"",
|
|
160
|
+
"Required reads before starting:",
|
|
161
|
+
"- .codecarto/GUIDE.md",
|
|
162
|
+
"- .codecarto/workflow/status.yaml",
|
|
163
|
+
`- .codecarto/skills/${skillName}/SKILL.md`,
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const conventionsPath = join(state.workspaceDir, "CONVENTIONS.md");
|
|
167
|
+
if (await pathExists(conventionsPath)) {
|
|
168
|
+
lines.push("- .codecarto/CONVENTIONS.md (cross-cutting patterns the orchestrator has promoted)");
|
|
169
|
+
}
|
|
170
|
+
const decisionsPath = join(state.workspaceDir, "DECISIONS.md");
|
|
171
|
+
if (await pathExists(decisionsPath)) {
|
|
172
|
+
lines.push("- .codecarto/DECISIONS.md (numbered project decisions; new entries are appended in your closeout)");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
lines.push("", "Rules:");
|
|
176
|
+
lines.push("- Do not modify source files outside .codecarto/.");
|
|
177
|
+
lines.push("- Follow the SKILL.md instructions exactly; the skill enforces its own discipline (see GUIDE.md).");
|
|
178
|
+
lines.push("- Update only the artifacts the skill calls for. Do NOT touch phase status entries.");
|
|
179
|
+
lines.push("- On completion, write a closeout at .codecarto/closeouts/<YYYY-MM-DD>-<skill-or-module>.md and append a one-line index entry to THREAD_LOG.md.");
|
|
180
|
+
lines.push("- If your work resolves entries in any phase's open_questions or carry_forward, remove only those resolved entries.");
|
|
181
|
+
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
package/core/status.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Status normalization, atomic writes, and file-lock primitives. Pure
|
|
2
|
+
// framework logic shared by every wrapper.
|
|
3
|
+
|
|
4
|
+
import { open, rm, stat } from "node:fs/promises";
|
|
5
|
+
import { basename } from "node:path";
|
|
6
|
+
import type {
|
|
7
|
+
CarryForwardEntry,
|
|
8
|
+
NormalizedStatus,
|
|
9
|
+
OpenQuestionEntry,
|
|
10
|
+
PipelineFile,
|
|
11
|
+
PipelinePhase,
|
|
12
|
+
StatusFile,
|
|
13
|
+
StatusPhase,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
import { sleep } from "./utils.ts";
|
|
16
|
+
|
|
17
|
+
export const LOCK_RETRY_MS = 125;
|
|
18
|
+
export const LOCK_TIMEOUT_MS = 5000;
|
|
19
|
+
export const STALE_LOCK_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
export function ensureArray(value: unknown): string[] {
|
|
22
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function coerceEntry(value: unknown, allowTargetPhase: boolean): OpenQuestionEntry | CarryForwardEntry | null {
|
|
26
|
+
if (typeof value === "string") {
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
if (!trimmed) return null;
|
|
29
|
+
return { description: trimmed };
|
|
30
|
+
}
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
32
|
+
const raw = value as Record<string, unknown>;
|
|
33
|
+
const entry: CarryForwardEntry = {};
|
|
34
|
+
if (typeof raw.id === "string" && raw.id.trim()) entry.id = raw.id.trim();
|
|
35
|
+
if (typeof raw.kind === "string" && raw.kind.trim()) entry.kind = raw.kind.trim();
|
|
36
|
+
if (typeof raw.description === "string" && raw.description.trim()) entry.description = raw.description.trim();
|
|
37
|
+
if (typeof raw.deferred_reason === "string" && raw.deferred_reason.trim()) entry.deferred_reason = raw.deferred_reason.trim();
|
|
38
|
+
if (allowTargetPhase && typeof raw.target_phase === "string" && raw.target_phase.trim()) entry.target_phase = raw.target_phase.trim();
|
|
39
|
+
return Object.keys(entry).length > 0 ? entry : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ensureEntryArray<T extends OpenQuestionEntry>(value: unknown, allowTargetPhase: boolean = false): T[] {
|
|
43
|
+
if (!Array.isArray(value)) return [];
|
|
44
|
+
const result: T[] = [];
|
|
45
|
+
for (const item of value) {
|
|
46
|
+
const coerced = coerceEntry(item, allowTargetPhase);
|
|
47
|
+
if (coerced) result.push(coerced as T);
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ensurePhaseRecord(value: unknown): Record<string, StatusPhase> {
|
|
53
|
+
if (!value || typeof value !== "object") return {};
|
|
54
|
+
const record = value as Record<string, unknown>;
|
|
55
|
+
const result: Record<string, StatusPhase> = {};
|
|
56
|
+
for (const [phaseId, phaseValue] of Object.entries(record)) {
|
|
57
|
+
const phase = (phaseValue ?? {}) as Partial<StatusPhase>;
|
|
58
|
+
result[phaseId] = {
|
|
59
|
+
status: typeof phase.status === "string" ? phase.status : "pending",
|
|
60
|
+
owner_notes: ensureArray(phase.owner_notes),
|
|
61
|
+
outputs_present: ensureArray(phase.outputs_present),
|
|
62
|
+
open_questions: ensureEntryArray<OpenQuestionEntry>(phase.open_questions, false),
|
|
63
|
+
carry_forward: ensureEntryArray<CarryForwardEntry>(phase.carry_forward, true),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createEmptyStatus(projectName: string, pipelinePath: string, pipeline: PipelineFile): NormalizedStatus {
|
|
70
|
+
const phases: Record<string, StatusPhase> = {};
|
|
71
|
+
for (const phaseId of pipeline.phase_order) {
|
|
72
|
+
phases[phaseId] = {
|
|
73
|
+
status: "pending",
|
|
74
|
+
owner_notes: [],
|
|
75
|
+
outputs_present: [],
|
|
76
|
+
open_questions: [],
|
|
77
|
+
carry_forward: [],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const firstPhase = pipeline.phase_order[0] ?? "complete";
|
|
82
|
+
const phaseMap = new Map<string, PipelinePhase>(pipeline.phases.map((phase) => [phase.id, phase]));
|
|
83
|
+
const firstPhaseConfig = phaseMap.get(firstPhase);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
project_name: projectName,
|
|
87
|
+
pipeline: pipelinePath,
|
|
88
|
+
current_phase: firstPhase,
|
|
89
|
+
last_updated: "",
|
|
90
|
+
phases,
|
|
91
|
+
next_actions: firstPhaseConfig?.primary_output
|
|
92
|
+
? [`Begin ${firstPhase} phase by producing ${firstPhaseConfig.primary_output}`]
|
|
93
|
+
: ["Begin the first pending phase."],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function normalizeStatus(status: StatusFile, pipeline: PipelineFile, pipelinePath: string, cwd: string): NormalizedStatus {
|
|
98
|
+
const phases = ensurePhaseRecord(status.phases);
|
|
99
|
+
for (const phaseId of pipeline.phase_order) {
|
|
100
|
+
if (!phases[phaseId]) {
|
|
101
|
+
phases[phaseId] = {
|
|
102
|
+
status: "pending",
|
|
103
|
+
owner_notes: [],
|
|
104
|
+
outputs_present: [],
|
|
105
|
+
open_questions: [],
|
|
106
|
+
carry_forward: [],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
project_name: status.project_name?.trim() || basename(cwd),
|
|
113
|
+
pipeline: status.pipeline?.trim() || pipelinePath,
|
|
114
|
+
current_phase: status.current_phase?.trim() || pipeline.phase_order[0] || "complete",
|
|
115
|
+
last_updated: status.last_updated?.trim() || "",
|
|
116
|
+
phases,
|
|
117
|
+
next_actions: ensureArray(status.next_actions),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function acquireLock(lockPath: string): Promise<{ release: () => Promise<void> }> {
|
|
122
|
+
const startedAt = Date.now();
|
|
123
|
+
|
|
124
|
+
while (true) {
|
|
125
|
+
try {
|
|
126
|
+
const handle = await open(lockPath, "wx");
|
|
127
|
+
await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`, "utf8");
|
|
128
|
+
await handle.close();
|
|
129
|
+
return {
|
|
130
|
+
release: async () => {
|
|
131
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const nodeError = error as NodeJS.ErrnoException;
|
|
136
|
+
if (nodeError.code !== "EEXIST") throw error;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const lockStat = await stat(lockPath);
|
|
140
|
+
if (Date.now() - lockStat.mtimeMs > STALE_LOCK_MS) {
|
|
141
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (Date.now() - startedAt > LOCK_TIMEOUT_MS) {
|
|
149
|
+
throw new Error(`Timed out waiting for lock: ${lockPath}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await sleep(LOCK_RETRY_MS);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
package/core/types.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Shared schema types for the CodeCartographer framework.
|
|
2
|
+
// Both the Pi extension and the MCP server import from here so the schema
|
|
3
|
+
// has a single source of truth.
|
|
4
|
+
|
|
5
|
+
export type PhaseStatusValue = "pending" | "complete" | "partial" | "in-progress";
|
|
6
|
+
|
|
7
|
+
export const OPEN_QUESTION_KINDS = [
|
|
8
|
+
"needs-runtime-test",
|
|
9
|
+
"needs-maintainer-decision",
|
|
10
|
+
"needs-spec-ruling",
|
|
11
|
+
"defer-to-phase",
|
|
12
|
+
"needs-fixture-capture",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type EntryKind = (typeof OPEN_QUESTION_KINDS)[number] | string;
|
|
16
|
+
|
|
17
|
+
export type OpenQuestionEntry = {
|
|
18
|
+
id?: string;
|
|
19
|
+
kind?: EntryKind;
|
|
20
|
+
description?: string;
|
|
21
|
+
deferred_reason?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type CarryForwardEntry = OpenQuestionEntry & {
|
|
25
|
+
target_phase?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type StatusPhase = {
|
|
29
|
+
status: PhaseStatusValue | string;
|
|
30
|
+
owner_notes: string[];
|
|
31
|
+
outputs_present: string[];
|
|
32
|
+
open_questions: OpenQuestionEntry[];
|
|
33
|
+
carry_forward: CarryForwardEntry[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type StatusFile = {
|
|
37
|
+
project_name?: string;
|
|
38
|
+
pipeline?: string;
|
|
39
|
+
current_phase?: string;
|
|
40
|
+
last_updated?: string;
|
|
41
|
+
phases?: Record<string, StatusPhase>;
|
|
42
|
+
next_actions?: string[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type SecondaryOutput = {
|
|
46
|
+
path: string;
|
|
47
|
+
mode?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type PipelinePhase = {
|
|
51
|
+
id: string;
|
|
52
|
+
purpose?: string;
|
|
53
|
+
skill_path?: string;
|
|
54
|
+
output_template?: string;
|
|
55
|
+
depends_on?: string[];
|
|
56
|
+
primary_output?: string;
|
|
57
|
+
secondary_outputs?: SecondaryOutput[];
|
|
58
|
+
required_reads?: string[];
|
|
59
|
+
completion_criteria?: string[];
|
|
60
|
+
handoff_requirements?: string[];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type PipelineFile = {
|
|
64
|
+
workflow_name?: string;
|
|
65
|
+
workflow_version?: number;
|
|
66
|
+
workflow_goal?: string;
|
|
67
|
+
source_location?: string;
|
|
68
|
+
validation_protocol?: string;
|
|
69
|
+
phase_order: string[];
|
|
70
|
+
phases: PipelinePhase[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type NormalizedStatus = Required<Pick<StatusFile, "project_name" | "pipeline" | "current_phase" | "last_updated" | "phases" | "next_actions">>;
|
|
74
|
+
|
|
75
|
+
export type WorkspaceState = {
|
|
76
|
+
cwd: string;
|
|
77
|
+
workspaceDir: string;
|
|
78
|
+
statusPath: string;
|
|
79
|
+
pipelinePath: string;
|
|
80
|
+
status: NormalizedStatus;
|
|
81
|
+
pipeline: PipelineFile;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type ValidationOverall = "PASS" | "PASS WITH GAPS" | "FAIL" | "MISSING";
|
|
85
|
+
|
|
86
|
+
export type ValidationResult = {
|
|
87
|
+
phaseId: string;
|
|
88
|
+
primaryOutput: string;
|
|
89
|
+
outputPath: string;
|
|
90
|
+
exists: boolean;
|
|
91
|
+
hasValidationBlock: boolean;
|
|
92
|
+
overall: ValidationOverall;
|
|
93
|
+
rows: Array<{ criterion: string; result: string; evidence: string }>;
|
|
94
|
+
gaps: string[];
|
|
95
|
+
errors: string[];
|
|
96
|
+
};
|
package/core/utils.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// General-purpose helpers used by yaml/status/prompts and by wrapper-specific
|
|
2
|
+
// path-boundary enforcement (Pi tool interception, MCP cwd validation).
|
|
3
|
+
|
|
4
|
+
import { access } from "node:fs/promises";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import { normalize, resolve } from "node:path";
|
|
7
|
+
import { realpath } from "node:fs/promises";
|
|
8
|
+
|
|
9
|
+
export function sleep(ms: number): Promise<void> {
|
|
10
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function pathExists(path: string): Promise<boolean> {
|
|
14
|
+
try {
|
|
15
|
+
await access(path, constants.F_OK);
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function canonicalPath(path: string): Promise<string> {
|
|
23
|
+
try {
|
|
24
|
+
return await realpath(path);
|
|
25
|
+
} catch {
|
|
26
|
+
return resolve(path);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeForComparison(path: string): string {
|
|
31
|
+
const normalized = normalize(path);
|
|
32
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isWithinPath(path: string, root: string): boolean {
|
|
36
|
+
const normalizedPath = normalizeForComparison(resolve(path));
|
|
37
|
+
const normalizedRoot = normalizeForComparison(resolve(root));
|
|
38
|
+
if (normalizedPath === normalizedRoot) return true;
|
|
39
|
+
return normalizedPath.startsWith(`${normalizedRoot}${process.platform === "win32" ? "\\" : "/"}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function uniqueStrings(items: string[]): string[] {
|
|
47
|
+
return [...new Set(items.filter(Boolean))];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function dateOnly(timestamp: string): string {
|
|
51
|
+
return timestamp.slice(0, 10);
|
|
52
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Workspace bootstrap: resolves the packaged .codecarto/ template directory
|
|
2
|
+
// (so the MCP server and Pi can both copy from it on /codecarto-init), loads
|
|
3
|
+
// + normalizes the per-project workspace state from disk, and provides the
|
|
4
|
+
// atomic status-update primitive used by /codecarto-complete.
|
|
5
|
+
|
|
6
|
+
import { appendFile, rename, writeFile } from "node:fs/promises";
|
|
7
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { acquireLock, normalizeStatus } from "./status.ts";
|
|
10
|
+
import type { PipelineFile, StatusFile, WorkspaceState } from "./types.ts";
|
|
11
|
+
import { pathExists } from "./utils.ts";
|
|
12
|
+
import { loadYamlFile, stringifySimpleYaml } from "./yaml.ts";
|
|
13
|
+
|
|
14
|
+
const coreDir = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const packageRoot = resolve(coreDir, "..");
|
|
16
|
+
|
|
17
|
+
// Path to the packaged framework template directory. Wrappers copy this on
|
|
18
|
+
// /codecarto-init.
|
|
19
|
+
export const packagedWorkspaceDir = join(packageRoot, ".codecarto");
|
|
20
|
+
|
|
21
|
+
export async function getWorkspaceState(cwd: string): Promise<WorkspaceState | null> {
|
|
22
|
+
const workspaceDir = join(cwd, ".codecarto");
|
|
23
|
+
const statusPath = join(workspaceDir, "workflow", "status.yaml");
|
|
24
|
+
if (!(await pathExists(statusPath))) return null;
|
|
25
|
+
|
|
26
|
+
const rawStatus = await loadYamlFile<StatusFile>(statusPath);
|
|
27
|
+
const pipelineRelativePath = rawStatus.pipeline?.trim();
|
|
28
|
+
if (!pipelineRelativePath) {
|
|
29
|
+
throw new Error(`Missing pipeline in ${relative(cwd, statusPath) || statusPath}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pipelinePath = join(workspaceDir, pipelineRelativePath);
|
|
33
|
+
if (!(await pathExists(pipelinePath))) {
|
|
34
|
+
throw new Error(`Active pipeline does not exist: ${relative(cwd, pipelinePath) || pipelinePath}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pipeline = await loadYamlFile<PipelineFile>(pipelinePath);
|
|
38
|
+
const status = normalizeStatus(rawStatus, pipeline, pipelineRelativePath, cwd);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
cwd,
|
|
42
|
+
workspaceDir,
|
|
43
|
+
statusPath,
|
|
44
|
+
pipelinePath,
|
|
45
|
+
pipeline,
|
|
46
|
+
status,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function updateStatusAtomically(
|
|
51
|
+
cwd: string,
|
|
52
|
+
updater: (state: WorkspaceState) => Promise<{ state: WorkspaceState; threadLogEntry?: string }> | { state: WorkspaceState; threadLogEntry?: string },
|
|
53
|
+
): Promise<WorkspaceState> {
|
|
54
|
+
const workspaceDir = join(cwd, ".codecarto");
|
|
55
|
+
const statusPath = join(workspaceDir, "workflow", "status.yaml");
|
|
56
|
+
const lockPath = `${statusPath}.lock`;
|
|
57
|
+
const lock = await acquireLock(lockPath);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const currentState = await getWorkspaceState(cwd);
|
|
61
|
+
if (!currentState) {
|
|
62
|
+
throw new Error("CodeCartographer workspace not found. Run /codecarto-init first.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = await updater(currentState);
|
|
66
|
+
const nextState = result.state;
|
|
67
|
+
const serialized = `${stringifySimpleYaml(nextState.status)}\n`;
|
|
68
|
+
const tempPath = `${statusPath}.${process.pid}.${Date.now()}.tmp`;
|
|
69
|
+
await writeFile(tempPath, serialized, "utf8");
|
|
70
|
+
await rename(tempPath, statusPath);
|
|
71
|
+
|
|
72
|
+
if (result.threadLogEntry) {
|
|
73
|
+
const threadLogPath = join(workspaceDir, "THREAD_LOG.md");
|
|
74
|
+
await appendFile(threadLogPath, result.threadLogEntry, "utf8");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return nextState;
|
|
78
|
+
} finally {
|
|
79
|
+
await lock.release();
|
|
80
|
+
}
|
|
81
|
+
}
|