codecartographer-pi 0.1.0 → 0.1.2

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.
@@ -0,0 +1,46 @@
1
+ // General-purpose helpers used by yaml/status/prompts and by wrapper-specific
2
+ // path-boundary enforcement (Pi tool interception, MCP cwd validation).
3
+ import { access } from "node:fs/promises";
4
+ import { constants } from "node:fs";
5
+ import { normalize, resolve } from "node:path";
6
+ import { realpath } from "node:fs/promises";
7
+ export function sleep(ms) {
8
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
9
+ }
10
+ export async function pathExists(path) {
11
+ try {
12
+ await access(path, constants.F_OK);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ export async function canonicalPath(path) {
20
+ try {
21
+ return await realpath(path);
22
+ }
23
+ catch {
24
+ return resolve(path);
25
+ }
26
+ }
27
+ export function normalizeForComparison(path) {
28
+ const normalized = normalize(path);
29
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
30
+ }
31
+ export function isWithinPath(path, root) {
32
+ const normalizedPath = normalizeForComparison(resolve(path));
33
+ const normalizedRoot = normalizeForComparison(resolve(root));
34
+ if (normalizedPath === normalizedRoot)
35
+ return true;
36
+ return normalizedPath.startsWith(`${normalizedRoot}${process.platform === "win32" ? "\\" : "/"}`);
37
+ }
38
+ export function isPlainObject(value) {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+ export function uniqueStrings(items) {
42
+ return [...new Set(items.filter(Boolean))];
43
+ }
44
+ export function dateOnly(timestamp) {
45
+ return timestamp.slice(0, 10);
46
+ }
@@ -0,0 +1,10 @@
1
+ import type { WorkspaceState } from "./types.ts";
2
+ export declare const packagedWorkspaceDir: string;
3
+ export declare function getWorkspaceState(cwd: string): Promise<WorkspaceState | null>;
4
+ export declare function updateStatusAtomically(cwd: string, updater: (state: WorkspaceState) => Promise<{
5
+ state: WorkspaceState;
6
+ threadLogEntry?: string;
7
+ }> | {
8
+ state: WorkspaceState;
9
+ threadLogEntry?: string;
10
+ }): Promise<WorkspaceState>;
@@ -0,0 +1,83 @@
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
+ import { existsSync } from "node:fs";
6
+ import { appendFile, rename, writeFile } from "node:fs/promises";
7
+ import { dirname, join, relative } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { acquireLock, normalizeStatus } from "./status.js";
10
+ import { pathExists } from "./utils.js";
11
+ import { loadYamlFile, stringifySimpleYaml } from "./yaml.js";
12
+ // Walk up from the current file to find the package root. Needed because the
13
+ // source lives at <root>/core/workspace.ts (one level below the package root)
14
+ // but compiles to <root>/dist/core/workspace.js (two levels below). A fixed
15
+ // `..` only works in one of those layouts, so resolve `package.json` instead.
16
+ function findPackageRoot(start) {
17
+ let dir = start;
18
+ while (true) {
19
+ if (existsSync(join(dir, "package.json")))
20
+ return dir;
21
+ const parent = dirname(dir);
22
+ if (parent === dir) {
23
+ throw new Error(`Could not locate package.json starting from ${start}`);
24
+ }
25
+ dir = parent;
26
+ }
27
+ }
28
+ const coreDir = dirname(fileURLToPath(import.meta.url));
29
+ const packageRoot = findPackageRoot(coreDir);
30
+ // Path to the packaged framework template directory. Wrappers copy this on
31
+ // /codecarto-init.
32
+ export const packagedWorkspaceDir = join(packageRoot, ".codecarto");
33
+ export async function getWorkspaceState(cwd) {
34
+ const workspaceDir = join(cwd, ".codecarto");
35
+ const statusPath = join(workspaceDir, "workflow", "status.yaml");
36
+ if (!(await pathExists(statusPath)))
37
+ return null;
38
+ const rawStatus = await loadYamlFile(statusPath);
39
+ const pipelineRelativePath = rawStatus.pipeline?.trim();
40
+ if (!pipelineRelativePath) {
41
+ throw new Error(`Missing pipeline in ${relative(cwd, statusPath) || statusPath}`);
42
+ }
43
+ const pipelinePath = join(workspaceDir, pipelineRelativePath);
44
+ if (!(await pathExists(pipelinePath))) {
45
+ throw new Error(`Active pipeline does not exist: ${relative(cwd, pipelinePath) || pipelinePath}`);
46
+ }
47
+ const pipeline = await loadYamlFile(pipelinePath);
48
+ const status = normalizeStatus(rawStatus, pipeline, pipelineRelativePath, cwd);
49
+ return {
50
+ cwd,
51
+ workspaceDir,
52
+ statusPath,
53
+ pipelinePath,
54
+ pipeline,
55
+ status,
56
+ };
57
+ }
58
+ export async function updateStatusAtomically(cwd, updater) {
59
+ const workspaceDir = join(cwd, ".codecarto");
60
+ const statusPath = join(workspaceDir, "workflow", "status.yaml");
61
+ const lockPath = `${statusPath}.lock`;
62
+ const lock = await acquireLock(lockPath);
63
+ try {
64
+ const currentState = await getWorkspaceState(cwd);
65
+ if (!currentState) {
66
+ throw new Error("CodeCartographer workspace not found. Run /codecarto-init first.");
67
+ }
68
+ const result = await updater(currentState);
69
+ const nextState = result.state;
70
+ const serialized = `${stringifySimpleYaml(nextState.status)}\n`;
71
+ const tempPath = `${statusPath}.${process.pid}.${Date.now()}.tmp`;
72
+ await writeFile(tempPath, serialized, "utf8");
73
+ await rename(tempPath, statusPath);
74
+ if (result.threadLogEntry) {
75
+ const threadLogPath = join(workspaceDir, "THREAD_LOG.md");
76
+ await appendFile(threadLogPath, result.threadLogEntry, "utf8");
77
+ }
78
+ return nextState;
79
+ }
80
+ finally {
81
+ await lock.release();
82
+ }
83
+ }
@@ -0,0 +1,6 @@
1
+ export declare function stripYamlComment(value: string): string;
2
+ export declare function parseYamlScalar(rawValue: string): unknown;
3
+ export declare function parseSimpleYaml(raw: string): unknown;
4
+ export declare function formatYamlScalar(value: unknown): string;
5
+ export declare function stringifySimpleYaml(value: unknown, indent?: number): string;
6
+ export declare function loadYamlFile<T>(path: string): Promise<T>;
@@ -0,0 +1,259 @@
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
+ import { readFile } from "node:fs/promises";
5
+ import { isPlainObject } from "./utils.js";
6
+ export function stripYamlComment(value) {
7
+ let inSingle = false;
8
+ let inDouble = false;
9
+ for (let i = 0; i < value.length; i++) {
10
+ const char = value[i];
11
+ if (char === "'" && !inDouble) {
12
+ inSingle = !inSingle;
13
+ continue;
14
+ }
15
+ if (char === '"' && !inSingle && value[i - 1] !== "\\") {
16
+ inDouble = !inDouble;
17
+ continue;
18
+ }
19
+ if (char === "#" && !inSingle && !inDouble) {
20
+ if (i === 0 || /\s/.test(value[i - 1] ?? "")) {
21
+ return value.slice(0, i).trimEnd();
22
+ }
23
+ }
24
+ }
25
+ return value.trimEnd();
26
+ }
27
+ function countIndent(line) {
28
+ let count = 0;
29
+ for (const char of line) {
30
+ if (char === " ")
31
+ count++;
32
+ else if (char === "\t")
33
+ count += 2;
34
+ else
35
+ break;
36
+ }
37
+ return count;
38
+ }
39
+ function isBlankOrComment(line) {
40
+ const trimmed = line.trim();
41
+ return trimmed === "" || trimmed.startsWith("#");
42
+ }
43
+ function findKeySeparator(text) {
44
+ let inSingle = false;
45
+ let inDouble = false;
46
+ for (let i = 0; i < text.length; i++) {
47
+ const char = text[i];
48
+ if (char === "'" && !inDouble) {
49
+ inSingle = !inSingle;
50
+ continue;
51
+ }
52
+ if (char === '"' && !inSingle && text[i - 1] !== "\\") {
53
+ inDouble = !inDouble;
54
+ continue;
55
+ }
56
+ if (char === ":" && !inSingle && !inDouble) {
57
+ return i;
58
+ }
59
+ }
60
+ return -1;
61
+ }
62
+ export function parseYamlScalar(rawValue) {
63
+ const trimmed = stripYamlComment(rawValue).trim();
64
+ if (trimmed === "")
65
+ return "";
66
+ if (trimmed === "[]")
67
+ return [];
68
+ if (trimmed === "{}")
69
+ return {};
70
+ if (trimmed === "null")
71
+ return null;
72
+ if (trimmed === "true")
73
+ return true;
74
+ if (trimmed === "false")
75
+ return false;
76
+ if (/^-?\d+$/.test(trimmed))
77
+ return Number.parseInt(trimmed, 10);
78
+ if (/^-?\d+\.\d+$/.test(trimmed))
79
+ return Number.parseFloat(trimmed);
80
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
81
+ try {
82
+ return JSON.parse(trimmed);
83
+ }
84
+ catch {
85
+ return trimmed.slice(1, -1);
86
+ }
87
+ }
88
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
89
+ return trimmed.slice(1, -1).replace(/''/g, "'");
90
+ }
91
+ return trimmed;
92
+ }
93
+ export function parseSimpleYaml(raw) {
94
+ const lines = raw.split(/\r?\n/);
95
+ let index = 0;
96
+ const skipBlank = () => {
97
+ while (index < lines.length && isBlankOrComment(lines[index] ?? ""))
98
+ index++;
99
+ };
100
+ const parseBlock = (indent) => {
101
+ skipBlank();
102
+ if (index >= lines.length)
103
+ return {};
104
+ const line = lines[index] ?? "";
105
+ const lineIndent = countIndent(line);
106
+ const trimmed = line.slice(lineIndent);
107
+ if (trimmed.startsWith("- ") || trimmed === "-") {
108
+ return parseSequence(indent);
109
+ }
110
+ return parseMapping(indent);
111
+ };
112
+ const parseMapping = (indent) => {
113
+ const result = {};
114
+ while (index < lines.length) {
115
+ skipBlank();
116
+ if (index >= lines.length)
117
+ break;
118
+ const line = lines[index] ?? "";
119
+ const lineIndent = countIndent(line);
120
+ if (lineIndent < indent)
121
+ break;
122
+ if (lineIndent > indent) {
123
+ throw new Error(`Invalid YAML indentation near: ${line.trim()}`);
124
+ }
125
+ const trimmed = line.slice(indent);
126
+ if (trimmed.startsWith("- ") || trimmed === "-")
127
+ break;
128
+ const separator = findKeySeparator(trimmed);
129
+ if (separator === -1) {
130
+ throw new Error(`Invalid YAML mapping entry: ${trimmed}`);
131
+ }
132
+ const key = trimmed.slice(0, separator).trim();
133
+ const rawValue = trimmed.slice(separator + 1).trim();
134
+ index++;
135
+ if (rawValue !== "") {
136
+ result[key] = parseYamlScalar(rawValue);
137
+ continue;
138
+ }
139
+ skipBlank();
140
+ if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
141
+ result[key] = parseBlock(countIndent(lines[index] ?? ""));
142
+ }
143
+ else {
144
+ result[key] = null;
145
+ }
146
+ }
147
+ return result;
148
+ };
149
+ const parseSequence = (indent) => {
150
+ const result = [];
151
+ while (index < lines.length) {
152
+ skipBlank();
153
+ if (index >= lines.length)
154
+ break;
155
+ const line = lines[index] ?? "";
156
+ const lineIndent = countIndent(line);
157
+ if (lineIndent < indent)
158
+ break;
159
+ const trimmed = line.slice(lineIndent);
160
+ if (lineIndent !== indent || (!trimmed.startsWith("- ") && trimmed !== "-"))
161
+ break;
162
+ const rawItem = trimmed === "-" ? "" : trimmed.slice(2).trim();
163
+ index++;
164
+ if (rawItem === "") {
165
+ skipBlank();
166
+ if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
167
+ result.push(parseBlock(countIndent(lines[index] ?? "")));
168
+ }
169
+ else {
170
+ result.push(null);
171
+ }
172
+ continue;
173
+ }
174
+ const separator = findKeySeparator(rawItem);
175
+ if (separator !== -1) {
176
+ const key = rawItem.slice(0, separator).trim();
177
+ const rawValue = rawItem.slice(separator + 1).trim();
178
+ const item = {};
179
+ item[key] = rawValue === "" ? null : parseYamlScalar(rawValue);
180
+ skipBlank();
181
+ if (rawValue === "" && index < lines.length && countIndent(lines[index] ?? "") > indent + 1) {
182
+ item[key] = parseBlock(countIndent(lines[index] ?? ""));
183
+ }
184
+ if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
185
+ const nested = parseMapping(indent + 2);
186
+ for (const [nestedKey, nestedValue] of Object.entries(nested))
187
+ item[nestedKey] = nestedValue;
188
+ }
189
+ result.push(item);
190
+ continue;
191
+ }
192
+ result.push(parseYamlScalar(rawItem));
193
+ }
194
+ return result;
195
+ };
196
+ skipBlank();
197
+ if (index >= lines.length)
198
+ return {};
199
+ return parseBlock(countIndent(lines[index] ?? ""));
200
+ }
201
+ export function formatYamlScalar(value) {
202
+ if (value === null)
203
+ return "null";
204
+ if (typeof value === "number" || typeof value === "boolean")
205
+ return String(value);
206
+ if (Array.isArray(value))
207
+ return value.length === 0 ? "[]" : JSON.stringify(value);
208
+ if (isPlainObject(value))
209
+ return Object.keys(value).length === 0 ? "{}" : JSON.stringify(value);
210
+ const stringValue = String(value);
211
+ if (stringValue === "")
212
+ return '""';
213
+ if (/^[A-Za-z0-9_./-]+$/.test(stringValue))
214
+ return stringValue;
215
+ return JSON.stringify(stringValue);
216
+ }
217
+ export function stringifySimpleYaml(value, indent = 0) {
218
+ const prefix = " ".repeat(indent);
219
+ if (Array.isArray(value)) {
220
+ if (value.length === 0)
221
+ return `${prefix}[]`;
222
+ return value
223
+ .map((item) => {
224
+ if (Array.isArray(item) || isPlainObject(item)) {
225
+ const isEmptyObject = isPlainObject(item) && Object.keys(item).length === 0;
226
+ if (Array.isArray(item) && item.length === 0)
227
+ return `${prefix}- []`;
228
+ if (isEmptyObject)
229
+ return `${prefix}- {}`;
230
+ return `${prefix}-\n${stringifySimpleYaml(item, indent + 2)}`;
231
+ }
232
+ return `${prefix}- ${formatYamlScalar(item)}`;
233
+ })
234
+ .join("\n");
235
+ }
236
+ if (isPlainObject(value)) {
237
+ const entries = Object.entries(value);
238
+ if (entries.length === 0)
239
+ return `${prefix}{}`;
240
+ return entries
241
+ .map(([key, entryValue]) => {
242
+ if (Array.isArray(entryValue) || isPlainObject(entryValue)) {
243
+ const isEmptyObject = isPlainObject(entryValue) && Object.keys(entryValue).length === 0;
244
+ if (Array.isArray(entryValue) && entryValue.length === 0)
245
+ return `${prefix}${key}: []`;
246
+ if (isEmptyObject)
247
+ return `${prefix}${key}: {}`;
248
+ return `${prefix}${key}:\n${stringifySimpleYaml(entryValue, indent + 2)}`;
249
+ }
250
+ return `${prefix}${key}: ${formatYamlScalar(entryValue)}`;
251
+ })
252
+ .join("\n");
253
+ }
254
+ return `${prefix}${formatYamlScalar(value)}`;
255
+ }
256
+ export async function loadYamlFile(path) {
257
+ const raw = await readFile(path, "utf8");
258
+ return (parseSimpleYaml(raw) ?? {});
259
+ }
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export default function codeCartographerExtension(pi: ExtensionAPI): void;