@first-tree-ai/context-tree 0.1.1 → 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.
- package/.agents/plugins/marketplace.json +19 -0
- package/.claude-plugin/marketplace.json +21 -0
- package/.claude-plugin/plugin.json +12 -0
- package/.codex-plugin/plugin.json +28 -0
- package/README.md +152 -79
- package/dist/cli/index.mjs +25841 -6
- package/dist/index.d.mts +11 -2
- package/dist/index.mjs +1087 -2
- package/dist/{schemas-DyQ0V9Z3.mjs → schemas-BWM6Q6iz.mjs} +75 -6
- package/dist/{schemas-BZkU14CI.d.mts → schemas-C4bs-FkC.d.mts} +115 -3
- package/dist/schemas.d.mts +2 -2
- package/dist/schemas.mjs +2 -2
- package/docs/specification.md +58 -23
- package/examples/basic/NODE.md +0 -2
- package/hooks/hooks.json +26 -0
- package/hooks/session-start.mjs +64 -0
- package/package.json +10 -4
- package/plugin.json +14 -0
- package/policy/context-tree-policy.md +2 -0
- package/skills/context-tree-init/SKILL.md +11 -10
- package/skills/context-tree-init/scripts/context-tree.mjs +41 -0
- package/skills/context-tree-link/SKILL.md +45 -0
- package/skills/context-tree-link/agents/openai.yaml +4 -0
- package/skills/context-tree-link/scripts/context-tree.mjs +41 -0
- package/skills/context-tree-read/SKILL.md +27 -25
- package/skills/context-tree-read/agents/openai.yaml +1 -1
- package/skills/context-tree-read/scripts/context-tree.mjs +41 -0
- package/skills/context-tree-write/SKILL.md +53 -35
- package/skills/context-tree-write/agents/openai.yaml +1 -1
- package/skills/context-tree-write/scripts/context-tree.mjs +41 -0
- package/templates/AGENTS.md +66 -0
- package/dist/src-DJZoQVCF.mjs +0 -619
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,1087 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { A as isRecord, C as githubRepositoryIdentitySchema, S as credentialFreeRepositoryUrlSchema, f as contextTreeLinkSchema, i as VALIDATION_CODES, k as parseMarkdownFrontmatter, t as CLI_ERROR_CODES, w as parseContextTreeRootNode } from "./schemas-BWM6Q6iz.mjs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, parse, posix, relative, resolve } from "node:path";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
//#region src/core/internal/filesystem.ts
|
|
10
|
+
function readUtf8File(path) {
|
|
11
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path));
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/core/internal/github-repository.ts
|
|
15
|
+
function parseGitHubRepositoryIdentity(repository) {
|
|
16
|
+
githubRepositoryIdentitySchema.parse(repository);
|
|
17
|
+
return repository.split("/")[1] ?? "";
|
|
18
|
+
}
|
|
19
|
+
function repositoryIdentityFromGitHubUrl(repositoryUrl) {
|
|
20
|
+
try {
|
|
21
|
+
credentialFreeRepositoryUrlSchema.parse(repositoryUrl);
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error("Context Tree origin must be a safe credential-free github.com repository URL.");
|
|
24
|
+
}
|
|
25
|
+
let host;
|
|
26
|
+
let path;
|
|
27
|
+
const scp = /^(?:git@)?([^:]+):(.+)$/u.exec(repositoryUrl);
|
|
28
|
+
if (scp !== null && !repositoryUrl.includes("://")) {
|
|
29
|
+
host = scp[1] ?? "";
|
|
30
|
+
path = scp[2] ?? "";
|
|
31
|
+
} else {
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = new URL(repositoryUrl);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error("Context Tree origin must be a safe credential-free github.com repository URL.");
|
|
37
|
+
}
|
|
38
|
+
if (parsed.password || (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.username) throw new Error("Context Tree origin must be a safe credential-free github.com repository URL.");
|
|
39
|
+
host = parsed.hostname;
|
|
40
|
+
path = parsed.pathname;
|
|
41
|
+
}
|
|
42
|
+
if (host.toLowerCase() !== "github.com") throw new Error("Context Tree origin must use github.com.");
|
|
43
|
+
const identity = path.replace(/^\/+|\/+$/gu, "").replace(/\.git$/iu, "");
|
|
44
|
+
try {
|
|
45
|
+
parseGitHubRepositoryIdentity(identity);
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error("Context Tree origin must identify a safe GitHub OWNER/REPO repository.");
|
|
48
|
+
}
|
|
49
|
+
return identity;
|
|
50
|
+
}
|
|
51
|
+
function canonicalGitHubRepositoryUrl(repository) {
|
|
52
|
+
parseGitHubRepositoryIdentity(repository);
|
|
53
|
+
return `https://github.com/${repository}.git`;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/core/path.ts
|
|
57
|
+
function isPathInside(root, target) {
|
|
58
|
+
const path = relative(root, target);
|
|
59
|
+
return path === "" || !path.startsWith("..") && !isAbsolute(path);
|
|
60
|
+
}
|
|
61
|
+
function resolveTreeRoot(path) {
|
|
62
|
+
const absolute = resolve(path);
|
|
63
|
+
const entry = lstatSync(absolute);
|
|
64
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Context Tree root must be a real directory: ${path}`);
|
|
65
|
+
return realpathSync(absolute);
|
|
66
|
+
}
|
|
67
|
+
function toPosixPath(path) {
|
|
68
|
+
return path.replace(/\\/gu, "/");
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/core/internal/content-class.ts
|
|
72
|
+
const GENERATED_DIRECTORY_NAMES = new Set([
|
|
73
|
+
"node_modules",
|
|
74
|
+
"__pycache__",
|
|
75
|
+
"dist",
|
|
76
|
+
"build",
|
|
77
|
+
".next",
|
|
78
|
+
".turbo"
|
|
79
|
+
]);
|
|
80
|
+
const REPO_INFRA_MARKDOWN_FILES = new Set(["AGENTS.md", "CLAUDE.md"]);
|
|
81
|
+
const MANAGED_SYMLINK_PATHS = new Set(["WHITEPAPER.md"]);
|
|
82
|
+
function toTreeRelativePosixPath(treeRoot, targetPath) {
|
|
83
|
+
return relative(treeRoot, targetPath).replace(/\\/gu, "/");
|
|
84
|
+
}
|
|
85
|
+
function classifyContextContent(relativePath) {
|
|
86
|
+
const parts = relativePath.replace(/\\/gu, "/").replace(/^\.\//u, "").split("/").filter((part) => part.length > 0);
|
|
87
|
+
if (parts.some((part) => part.startsWith(".") || GENERATED_DIRECTORY_NAMES.has(part)) || parts[0] === "scripts" || REPO_INFRA_MARKDOWN_FILES.has(parts.at(-1) ?? "")) return "repo-infra";
|
|
88
|
+
if (parts[0] === "members") return "member";
|
|
89
|
+
return "normal";
|
|
90
|
+
}
|
|
91
|
+
function emptyContentClassCounts() {
|
|
92
|
+
return {
|
|
93
|
+
normal: 0,
|
|
94
|
+
member: 0,
|
|
95
|
+
"repo-infra": 0
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function readDirectoryEntries(path) {
|
|
99
|
+
try {
|
|
100
|
+
return readdirSync(path, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function canonicalTarget$1(realTreeRoot, path) {
|
|
106
|
+
try {
|
|
107
|
+
const realTarget = realpathSync(path);
|
|
108
|
+
if (!isPathInside(realTreeRoot, realTarget)) return { kind: "escaped" };
|
|
109
|
+
return {
|
|
110
|
+
kind: "resolved",
|
|
111
|
+
relativePath: toTreeRelativePosixPath(realTreeRoot, realTarget)
|
|
112
|
+
};
|
|
113
|
+
} catch {
|
|
114
|
+
return { kind: "unresolved" };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function inspectMarkdownSymlink(realTreeRoot, absolutePath, contentClass) {
|
|
118
|
+
let targetStat;
|
|
119
|
+
try {
|
|
120
|
+
targetStat = statSync(absolutePath);
|
|
121
|
+
} catch {
|
|
122
|
+
return { kind: "unresolved" };
|
|
123
|
+
}
|
|
124
|
+
const target = canonicalTarget$1(realTreeRoot, absolutePath);
|
|
125
|
+
if (target.kind !== "resolved") return target;
|
|
126
|
+
if (!targetStat.isFile()) return { kind: "unsupported" };
|
|
127
|
+
if (classifyContextContent(target.relativePath) !== contentClass) return {
|
|
128
|
+
kind: "content-class-mismatch",
|
|
129
|
+
canonicalRelativePath: target.relativePath
|
|
130
|
+
};
|
|
131
|
+
return { kind: "regular" };
|
|
132
|
+
}
|
|
133
|
+
function collectContextMarkdownContent(treeRoot) {
|
|
134
|
+
const directories = [];
|
|
135
|
+
const directorySymlinks = [];
|
|
136
|
+
const files = [];
|
|
137
|
+
const realTreeRoot = realpathSync(treeRoot);
|
|
138
|
+
function walk(directoryPath) {
|
|
139
|
+
for (const entry of readDirectoryEntries(directoryPath)) {
|
|
140
|
+
const absolutePath = join(directoryPath, entry.name);
|
|
141
|
+
const relativePath = toTreeRelativePosixPath(treeRoot, absolutePath);
|
|
142
|
+
const contentClass = classifyContextContent(relativePath);
|
|
143
|
+
if (entry.isDirectory()) {
|
|
144
|
+
if (contentClass !== "repo-infra") {
|
|
145
|
+
directories.push(relativePath);
|
|
146
|
+
walk(absolutePath);
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const symbolicLink = entry.isSymbolicLink();
|
|
151
|
+
if (symbolicLink) try {
|
|
152
|
+
if (statSync(absolutePath).isDirectory()) {
|
|
153
|
+
if (contentClass !== "repo-infra" || entry.name.endsWith(".md")) {
|
|
154
|
+
const target = canonicalTarget$1(realTreeRoot, absolutePath);
|
|
155
|
+
directorySymlinks.push({
|
|
156
|
+
escaped: target.kind === "escaped",
|
|
157
|
+
relativePath
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
if (MANAGED_SYMLINK_PATHS.has(relativePath)) continue;
|
|
164
|
+
if (entry.name.endsWith(".md")) files.push({
|
|
165
|
+
absolutePath,
|
|
166
|
+
contentClass,
|
|
167
|
+
inspection: { kind: "unresolved" },
|
|
168
|
+
relativePath
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!entry.isFile() && !symbolicLink || !entry.name.endsWith(".md")) continue;
|
|
173
|
+
if (symbolicLink && MANAGED_SYMLINK_PATHS.has(relativePath)) continue;
|
|
174
|
+
files.push({
|
|
175
|
+
absolutePath,
|
|
176
|
+
contentClass,
|
|
177
|
+
inspection: symbolicLink ? inspectMarkdownSymlink(realTreeRoot, absolutePath, contentClass) : { kind: "regular" },
|
|
178
|
+
relativePath
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
walk(treeRoot);
|
|
183
|
+
return {
|
|
184
|
+
directories,
|
|
185
|
+
directorySymlinks,
|
|
186
|
+
files
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/core/internal/context-document.ts
|
|
191
|
+
function readContextDocument(path) {
|
|
192
|
+
try {
|
|
193
|
+
return parseMarkdownFrontmatter(readUtf8File(path));
|
|
194
|
+
} catch (error) {
|
|
195
|
+
return {
|
|
196
|
+
body: "",
|
|
197
|
+
data: null,
|
|
198
|
+
error: error instanceof Error ? error.message : String(error),
|
|
199
|
+
frontmatter: "invalid"
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function readNonEmptyStringField(data, key) {
|
|
204
|
+
if (!(key in data)) return {
|
|
205
|
+
present: false,
|
|
206
|
+
valid: false
|
|
207
|
+
};
|
|
208
|
+
const value = data[key];
|
|
209
|
+
if (typeof value !== "string" || value.trim().length === 0) return {
|
|
210
|
+
present: true,
|
|
211
|
+
valid: false
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
present: true,
|
|
215
|
+
valid: true,
|
|
216
|
+
value: value.trim()
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function readNonEmptyStringArrayField(data, key) {
|
|
220
|
+
if (!(key in data)) return {
|
|
221
|
+
present: false,
|
|
222
|
+
valid: false
|
|
223
|
+
};
|
|
224
|
+
const value = data[key];
|
|
225
|
+
if (!Array.isArray(value) || value.length === 0) return {
|
|
226
|
+
present: true,
|
|
227
|
+
valid: false
|
|
228
|
+
};
|
|
229
|
+
const items = [];
|
|
230
|
+
for (const item of value) {
|
|
231
|
+
if (typeof item !== "string" || item.trim().length === 0) return {
|
|
232
|
+
present: true,
|
|
233
|
+
valid: false
|
|
234
|
+
};
|
|
235
|
+
items.push(item.trim());
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
present: true,
|
|
239
|
+
valid: true,
|
|
240
|
+
value: items
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function readNodeDocument(path) {
|
|
244
|
+
const document = readContextDocument(path);
|
|
245
|
+
if (document.frontmatter !== "valid") return null;
|
|
246
|
+
const title = readNonEmptyStringField(document.data, "title");
|
|
247
|
+
const description = readNonEmptyStringField(document.data, "description");
|
|
248
|
+
if (!title.valid || description.present && !description.valid) return null;
|
|
249
|
+
return {
|
|
250
|
+
body: document.body,
|
|
251
|
+
frontmatter: document.data,
|
|
252
|
+
title: title.value,
|
|
253
|
+
...description.valid ? { description: description.value } : {}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/core/internal/context-links.ts
|
|
258
|
+
function stripQueryAndFragment(target) {
|
|
259
|
+
const indexes = [target.indexOf("?"), target.indexOf("#")].filter((index) => index >= 0);
|
|
260
|
+
const end = indexes.length === 0 ? target.length : Math.min(...indexes);
|
|
261
|
+
return target.slice(0, end);
|
|
262
|
+
}
|
|
263
|
+
function decodeTarget(target) {
|
|
264
|
+
try {
|
|
265
|
+
return decodeURIComponent(target);
|
|
266
|
+
} catch {
|
|
267
|
+
return target;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function isWindowsAbsoluteTarget(target) {
|
|
271
|
+
return /^[a-z]:[\\/]/iu.test(target) || /^\\/u.test(target);
|
|
272
|
+
}
|
|
273
|
+
function isTreeLocalTarget(target) {
|
|
274
|
+
const trimmed = target.trim();
|
|
275
|
+
if (isWindowsAbsoluteTarget(decodeTarget(stripQueryAndFragment(trimmed)))) return true;
|
|
276
|
+
return trimmed.length > 0 && !trimmed.startsWith("#") && !trimmed.startsWith("//") && !/^[a-z][a-z\d+.-]*:/iu.test(trimmed);
|
|
277
|
+
}
|
|
278
|
+
function targetExists(path, softLink) {
|
|
279
|
+
try {
|
|
280
|
+
const stat = statSync(path);
|
|
281
|
+
if (stat.isFile()) return !softLink || path.endsWith(".md");
|
|
282
|
+
return stat.isDirectory() && (!softLink || existsSync(resolve(path, "NODE.md")));
|
|
283
|
+
} catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function resolveLocalTreeTarget(options) {
|
|
288
|
+
if (!isTreeLocalTarget(options.target)) return null;
|
|
289
|
+
const decodedTarget = decodeTarget(stripQueryAndFragment(options.target.trim()));
|
|
290
|
+
const withoutSuffix = decodedTarget.replace(/\\/gu, "/");
|
|
291
|
+
if (withoutSuffix.length === 0) return null;
|
|
292
|
+
if (isWindowsAbsoluteTarget(decodedTarget)) return "escaped-missing";
|
|
293
|
+
const sourceDirectory = posix.dirname(options.sourcePath);
|
|
294
|
+
const relativePath = posix.normalize(options.softLink || withoutSuffix.startsWith("/") ? withoutSuffix.replace(/^\/+/, "") : posix.join(sourceDirectory, withoutSuffix));
|
|
295
|
+
const absoluteRoot = resolve(options.treeRoot);
|
|
296
|
+
const absoluteTarget = resolve(absoluteRoot, relativePath);
|
|
297
|
+
if (!isPathInside(absoluteRoot, absoluteTarget)) return "escaped-missing";
|
|
298
|
+
if (!targetExists(absoluteTarget, options.softLink)) return "missing";
|
|
299
|
+
try {
|
|
300
|
+
if (!isPathInside(realpathSync(absoluteRoot), realpathSync(absoluteTarget))) return "escaped-existing";
|
|
301
|
+
} catch {
|
|
302
|
+
return "missing";
|
|
303
|
+
}
|
|
304
|
+
return "valid";
|
|
305
|
+
}
|
|
306
|
+
function readMarkdownLinkTargets(markdown) {
|
|
307
|
+
const root = fromMarkdown(markdown);
|
|
308
|
+
const targets = [];
|
|
309
|
+
function visit(node) {
|
|
310
|
+
if (!isRecord(node)) return;
|
|
311
|
+
if ((node.type === "link" || node.type === "image" || node.type === "definition") && typeof node.url === "string") targets.push(node.url);
|
|
312
|
+
if (Array.isArray(node.children)) for (const child of node.children) visit(child);
|
|
313
|
+
}
|
|
314
|
+
visit(root);
|
|
315
|
+
return targets;
|
|
316
|
+
}
|
|
317
|
+
//#endregion
|
|
318
|
+
//#region src/core/internal/validate-nodes.ts
|
|
319
|
+
function addFinding(findings, code, path, message, target) {
|
|
320
|
+
findings.push({
|
|
321
|
+
code,
|
|
322
|
+
message,
|
|
323
|
+
path,
|
|
324
|
+
...target === void 0 ? {} : { target }
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function validateRequiredNodeMetadata(document, path, findings) {
|
|
328
|
+
if (document.frontmatter === "missing") {
|
|
329
|
+
addFinding(findings, VALIDATION_CODES.frontmatterMissing, path, "missing frontmatter");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (document.frontmatter === "invalid") {
|
|
333
|
+
addFinding(findings, VALIDATION_CODES.frontmatterParse, path, `frontmatter could not be parsed: ${document.error}`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const title = readNonEmptyStringField(document.data, "title");
|
|
337
|
+
if (!title.present) addFinding(findings, VALIDATION_CODES.titleMissing, path, "missing 'title' field in frontmatter");
|
|
338
|
+
else if (!title.valid) addFinding(findings, VALIDATION_CODES.titleInvalid, path, "'title' must be a non-empty string");
|
|
339
|
+
const description = readNonEmptyStringField(document.data, "description");
|
|
340
|
+
if (description.present && !description.valid) addFinding(findings, VALIDATION_CODES.descriptionInvalid, path, "'description' must be a non-empty string when present");
|
|
341
|
+
}
|
|
342
|
+
function validateRootOnlyFields(document, path, findings) {
|
|
343
|
+
if (path === "NODE.md" || document.frontmatter !== "valid") return;
|
|
344
|
+
const fields = ["schemaVersion"].filter((field) => field in document.data);
|
|
345
|
+
if (fields.length > 0) addFinding(findings, VALIDATION_CODES.rootOnlyFields, path, `root-only frontmatter field${fields.length === 1 ? "" : "s"} must appear only in root NODE.md: ${fields.join(", ")}`);
|
|
346
|
+
}
|
|
347
|
+
function readSoftLinks(document, path, findings) {
|
|
348
|
+
if (document.frontmatter !== "valid") return [];
|
|
349
|
+
const softLinks = readNonEmptyStringArrayField(document.data, "soft_links");
|
|
350
|
+
if (!softLinks.present) return [];
|
|
351
|
+
if (!softLinks.valid) {
|
|
352
|
+
addFinding(findings, VALIDATION_CODES.softLinksInvalid, path, "'soft_links' must be a non-empty string array when present");
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
return softLinks.value;
|
|
356
|
+
}
|
|
357
|
+
function validateSoftLinks(options) {
|
|
358
|
+
for (const target of readSoftLinks(options.document, options.path, options.findings)) {
|
|
359
|
+
const resolved = resolveLocalTreeTarget({
|
|
360
|
+
sourcePath: options.path,
|
|
361
|
+
target,
|
|
362
|
+
treeRoot: options.treeRoot,
|
|
363
|
+
softLink: true
|
|
364
|
+
});
|
|
365
|
+
if (resolved === null || resolved === "missing" || resolved === "escaped-missing") addFinding(options.findings, VALIDATION_CODES.softLinkBroken, options.path, "broken soft_links target", target);
|
|
366
|
+
if (resolved === null) continue;
|
|
367
|
+
if (resolved === "escaped-existing" || resolved === "escaped-missing") addFinding(options.findings, VALIDATION_CODES.softLinkPathEscape, options.path, "soft_links target resolves outside the Context Tree root", target);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function validateMarkdownLinks(document, path, treeRoot, findings) {
|
|
371
|
+
for (const target of readMarkdownLinkTargets(document.body)) {
|
|
372
|
+
const resolved = resolveLocalTreeTarget({
|
|
373
|
+
sourcePath: path,
|
|
374
|
+
target,
|
|
375
|
+
treeRoot,
|
|
376
|
+
softLink: false
|
|
377
|
+
});
|
|
378
|
+
if (resolved === null) continue;
|
|
379
|
+
if (resolved === "escaped-existing" || resolved === "escaped-missing") addFinding(findings, VALIDATION_CODES.markdownPathEscape, path, "Markdown link resolves outside the Context Tree root", target);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function collectNodeValidationFindings(treeRoot) {
|
|
383
|
+
const findings = [];
|
|
384
|
+
const scannedByContentClass = emptyContentClassCounts();
|
|
385
|
+
const content = collectContextMarkdownContent(treeRoot);
|
|
386
|
+
for (const directory of content.directories) {
|
|
387
|
+
const nodePath = `${directory}/NODE.md`;
|
|
388
|
+
let hasRegularNode = false;
|
|
389
|
+
try {
|
|
390
|
+
const entry = lstatSync(join(treeRoot, nodePath));
|
|
391
|
+
hasRegularNode = entry.isFile() && !entry.isSymbolicLink();
|
|
392
|
+
} catch {}
|
|
393
|
+
if (!hasRegularNode) addFinding(findings, VALIDATION_CODES.directoryNodeMissing, directory, "Context Tree directory is missing NODE.md");
|
|
394
|
+
}
|
|
395
|
+
for (const directory of content.directorySymlinks) addFinding(findings, directory.escaped ? VALIDATION_CODES.directorySymlinkPathEscape : VALIDATION_CODES.directorySymlinkUnsupported, directory.relativePath, directory.escaped ? "directory symlink resolves outside the Context Tree root" : "Context Tree domain directories must not be symlinks");
|
|
396
|
+
for (const file of content.files) {
|
|
397
|
+
scannedByContentClass[file.contentClass] += 1;
|
|
398
|
+
if (file.inspection.kind === "unresolved") {
|
|
399
|
+
addFinding(findings, VALIDATION_CODES.markdownFileSymlinkBroken, file.relativePath, "Markdown file symlink target cannot be resolved");
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (file.inspection.kind === "escaped") {
|
|
403
|
+
addFinding(findings, VALIDATION_CODES.markdownFilePathEscape, file.relativePath, "Markdown file resolves outside the Context Tree root");
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (file.inspection.kind === "unsupported") {
|
|
407
|
+
addFinding(findings, VALIDATION_CODES.markdownFileSymlinkUnsupported, file.relativePath, "Markdown file symlink must resolve to a regular file");
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (file.inspection.kind === "content-class-mismatch") {
|
|
411
|
+
const canonicalContentClass = classifyContextContent(file.inspection.canonicalRelativePath);
|
|
412
|
+
addFinding(findings, VALIDATION_CODES.markdownFileContentClassMismatch, file.relativePath, `Markdown file symlink crosses content-class boundary from ${file.contentClass} to ${canonicalContentClass}`, file.inspection.canonicalRelativePath);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (file.contentClass === "repo-infra") continue;
|
|
416
|
+
const document = readContextDocument(file.absolutePath);
|
|
417
|
+
validateRootOnlyFields(document, file.relativePath, findings);
|
|
418
|
+
if (file.relativePath !== "NODE.md" || document.frontmatter === "valid") validateRequiredNodeMetadata(document, file.relativePath, findings);
|
|
419
|
+
validateSoftLinks({
|
|
420
|
+
document,
|
|
421
|
+
findings,
|
|
422
|
+
path: file.relativePath,
|
|
423
|
+
treeRoot
|
|
424
|
+
});
|
|
425
|
+
validateMarkdownLinks(document, file.relativePath, treeRoot, findings);
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
findings,
|
|
429
|
+
scannedByContentClass
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/core/verify.ts
|
|
434
|
+
function rootNodeFindings(root) {
|
|
435
|
+
const path = join(root, "NODE.md");
|
|
436
|
+
if (!existsSync(path)) return [{
|
|
437
|
+
code: VALIDATION_CODES.rootMissing,
|
|
438
|
+
message: "root NODE.md is missing",
|
|
439
|
+
path: "NODE.md"
|
|
440
|
+
}];
|
|
441
|
+
try {
|
|
442
|
+
const entry = lstatSync(path);
|
|
443
|
+
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("Root NODE.md must be a regular file and must not be a symlink.");
|
|
444
|
+
parseContextTreeRootNode(readUtf8File(path));
|
|
445
|
+
return [];
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return [{
|
|
448
|
+
code: VALIDATION_CODES.rootNodeInvalid,
|
|
449
|
+
message: error instanceof Error ? error.message : String(error),
|
|
450
|
+
path: "NODE.md"
|
|
451
|
+
}];
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function deduplicate(findings) {
|
|
455
|
+
const seen = /* @__PURE__ */ new Set();
|
|
456
|
+
return findings.filter((finding) => {
|
|
457
|
+
const key = `${finding.code}\0${finding.path}\0${finding.target ?? ""}`;
|
|
458
|
+
if (seen.has(key)) return false;
|
|
459
|
+
seen.add(key);
|
|
460
|
+
return true;
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
function verifyTree(treePath) {
|
|
464
|
+
const root = resolveTreeRoot(treePath);
|
|
465
|
+
const nodeResult = collectNodeValidationFindings(root);
|
|
466
|
+
const findings = deduplicate([...rootNodeFindings(root), ...nodeResult.findings]);
|
|
467
|
+
return {
|
|
468
|
+
findings,
|
|
469
|
+
ok: findings.length === 0,
|
|
470
|
+
root,
|
|
471
|
+
scannedByContentClass: nodeResult.scannedByContentClass,
|
|
472
|
+
schemaVersion: 1
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/core/links.ts
|
|
477
|
+
const contextTreeLinksFileSchema = z.object({
|
|
478
|
+
links: z.array(contextTreeLinkSchema),
|
|
479
|
+
schemaVersion: z.literal(1)
|
|
480
|
+
}).strict();
|
|
481
|
+
var LinkError = class extends Error {
|
|
482
|
+
code;
|
|
483
|
+
constructor(code, message) {
|
|
484
|
+
super(message);
|
|
485
|
+
this.name = "LinkError";
|
|
486
|
+
this.code = code;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
function git$1(path, args) {
|
|
490
|
+
const result = spawnSync("git", [
|
|
491
|
+
"-C",
|
|
492
|
+
path,
|
|
493
|
+
...args
|
|
494
|
+
], {
|
|
495
|
+
encoding: "utf8",
|
|
496
|
+
stdio: [
|
|
497
|
+
"ignore",
|
|
498
|
+
"pipe",
|
|
499
|
+
"ignore"
|
|
500
|
+
]
|
|
501
|
+
});
|
|
502
|
+
if (result.error !== void 0 || result.status !== 0) return void 0;
|
|
503
|
+
return result.stdout.replace(/\r?\n$/u, "");
|
|
504
|
+
}
|
|
505
|
+
function requireGit$1(path, args, message, allowEmpty = false) {
|
|
506
|
+
const value = git$1(path, args);
|
|
507
|
+
if (value === void 0 || !allowEmpty && value.length === 0) throw new Error(message);
|
|
508
|
+
return value;
|
|
509
|
+
}
|
|
510
|
+
function normalizeRepositoryUrl(repositoryUrl) {
|
|
511
|
+
try {
|
|
512
|
+
credentialFreeRepositoryUrlSchema.parse(repositoryUrl);
|
|
513
|
+
} catch {
|
|
514
|
+
throw new Error("Git origin must be a canonical credential-free repository URL.");
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
return canonicalGitHubRepositoryUrl(repositoryIdentityFromGitHubUrl(repositoryUrl).toLowerCase());
|
|
518
|
+
} catch {}
|
|
519
|
+
const scp = /^(?:([^@]+)@)?([^:]+):(.+)$/u.exec(repositoryUrl);
|
|
520
|
+
if (scp !== null && !repositoryUrl.includes("://")) return `${scp[1] === void 0 ? "" : `${scp[1].toLowerCase()}@`}${(scp[2] ?? "").toLowerCase()}:${(scp[3] ?? "").replace(/\/+$/gu, "").replace(/\.git$/iu, "")}.git`;
|
|
521
|
+
const parsed = new URL(repositoryUrl);
|
|
522
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
523
|
+
parsed.pathname = `${parsed.pathname.replace(/\/+$/gu, "").replace(/\.git$/iu, "")}.git`;
|
|
524
|
+
return parsed.toString();
|
|
525
|
+
}
|
|
526
|
+
function realDirectory(path) {
|
|
527
|
+
const absolute = resolve(path);
|
|
528
|
+
if (!lstatSync(absolute).isDirectory()) throw new Error("Project path must be a directory.");
|
|
529
|
+
return realpathSync(absolute);
|
|
530
|
+
}
|
|
531
|
+
function identifyProject(projectPath) {
|
|
532
|
+
const path = realDirectory(projectPath);
|
|
533
|
+
const gitRoot = git$1(path, ["rev-parse", "--show-toplevel"]);
|
|
534
|
+
if (gitRoot === void 0) return {
|
|
535
|
+
kind: "directory",
|
|
536
|
+
path
|
|
537
|
+
};
|
|
538
|
+
return {
|
|
539
|
+
kind: "git",
|
|
540
|
+
origin: normalizeRepositoryUrl(requireGit$1(realpathSync(gitRoot), [
|
|
541
|
+
"remote",
|
|
542
|
+
"get-url",
|
|
543
|
+
"origin"
|
|
544
|
+
], "Git project must have an origin remote."))
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function linksPath() {
|
|
548
|
+
return join(homedir(), ".context-tree", "connections.json");
|
|
549
|
+
}
|
|
550
|
+
function emptyLinks() {
|
|
551
|
+
return {
|
|
552
|
+
links: [],
|
|
553
|
+
schemaVersion: 1
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function loadLinks(required) {
|
|
557
|
+
const path = linksPath();
|
|
558
|
+
if (!existsSync(path)) {
|
|
559
|
+
if (required) throw new LinkError(CLI_ERROR_CODES.noLink, "No Context Tree link exists for this project.");
|
|
560
|
+
return emptyLinks();
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
const entry = lstatSync(path);
|
|
564
|
+
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("not a regular file");
|
|
565
|
+
return contextTreeLinksFileSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
566
|
+
} catch {
|
|
567
|
+
throw new LinkError(CLI_ERROR_CODES.corruptLink, "Context Tree links are corrupt; run link after repairing or removing the internal links file.");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function saveLinks(value) {
|
|
571
|
+
const path = linksPath();
|
|
572
|
+
const directory = dirname(path);
|
|
573
|
+
mkdirSync(directory, {
|
|
574
|
+
recursive: true,
|
|
575
|
+
mode: 448
|
|
576
|
+
});
|
|
577
|
+
const directoryEntry = lstatSync(directory);
|
|
578
|
+
if (directoryEntry.isSymbolicLink() || !directoryEntry.isDirectory()) throw new Error("Context Tree links directory must be a real directory.");
|
|
579
|
+
const temporary = join(directory, `.links-${process.pid}-${Date.now()}.tmp`);
|
|
580
|
+
writeFileSync(temporary, `${JSON.stringify(contextTreeLinksFileSchema.parse(value), null, 2)}\n`, {
|
|
581
|
+
encoding: "utf8",
|
|
582
|
+
flag: "wx",
|
|
583
|
+
mode: 384
|
|
584
|
+
});
|
|
585
|
+
renameSync(temporary, path);
|
|
586
|
+
chmodSync(path, 384);
|
|
587
|
+
}
|
|
588
|
+
function exactCheckoutRoot(treePath) {
|
|
589
|
+
const absolute = resolve(treePath);
|
|
590
|
+
const entry = lstatSync(absolute);
|
|
591
|
+
const root = realpathSync(absolute);
|
|
592
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || absolute !== root) throw new Error("Context Tree checkout path must be a real directory with no symlink component.");
|
|
593
|
+
if (realpathSync(requireGit$1(root, ["rev-parse", "--show-toplevel"], "Context Tree checkout must be a Git repository.")) !== root) throw new Error("Context Tree checkout must be the real Git root.");
|
|
594
|
+
return root;
|
|
595
|
+
}
|
|
596
|
+
function checkoutRepository(root) {
|
|
597
|
+
return repositoryIdentityFromGitHubUrl(requireGit$1(root, [
|
|
598
|
+
"remote",
|
|
599
|
+
"get-url",
|
|
600
|
+
"origin"
|
|
601
|
+
], "Context Tree checkout must have an origin remote."));
|
|
602
|
+
}
|
|
603
|
+
function requireCheckoutClean(root, mode) {
|
|
604
|
+
const status = requireGit$1(root, [
|
|
605
|
+
"status",
|
|
606
|
+
"--porcelain",
|
|
607
|
+
"--untracked-files=all"
|
|
608
|
+
], "Failed to inspect Context Tree cleanliness.", true);
|
|
609
|
+
if (status.length === 0) return;
|
|
610
|
+
if (mode === "scaffold") {
|
|
611
|
+
const lines = status.split("\n").sort();
|
|
612
|
+
if (git$1(root, [
|
|
613
|
+
"rev-parse",
|
|
614
|
+
"--verify",
|
|
615
|
+
"HEAD"
|
|
616
|
+
]) === void 0 && JSON.stringify(lines) === JSON.stringify([
|
|
617
|
+
"?? .github/workflows/validate-context-tree.yml",
|
|
618
|
+
"?? AGENTS.md",
|
|
619
|
+
"?? CLAUDE.md",
|
|
620
|
+
"?? NODE.md"
|
|
621
|
+
])) return;
|
|
622
|
+
}
|
|
623
|
+
throw new Error("Context Tree checkout must be clean.");
|
|
624
|
+
}
|
|
625
|
+
function parseRootNode(root) {
|
|
626
|
+
const path = join(root, "NODE.md");
|
|
627
|
+
const entry = lstatSync(path);
|
|
628
|
+
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("Context Tree root NODE.md must be a regular file.");
|
|
629
|
+
return parseContextTreeRootNode(readUtf8File(path));
|
|
630
|
+
}
|
|
631
|
+
function verifyCheckout(treePath, mode) {
|
|
632
|
+
const root = exactCheckoutRoot(treePath);
|
|
633
|
+
requireCheckoutClean(root, mode);
|
|
634
|
+
const repository = checkoutRepository(root);
|
|
635
|
+
if (!verifyTree(root).ok) throw new Error("Context Tree checkout is invalid; run context-tree verify.");
|
|
636
|
+
return {
|
|
637
|
+
path: root,
|
|
638
|
+
repository
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function sameProject(left, right) {
|
|
642
|
+
return left.kind === "git" && right.kind === "git" ? left.origin === right.origin : left.kind === "directory" && right.kind === "directory" && left.path === right.path;
|
|
643
|
+
}
|
|
644
|
+
function projectMatches(candidate, current) {
|
|
645
|
+
if (candidate.kind === "git" && current.kind === "git") return candidate.origin === current.origin;
|
|
646
|
+
if (candidate.kind === "directory" && current.kind === "directory") return isPathInside(candidate.path, current.path);
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
function liveStoredCheckout(link) {
|
|
650
|
+
try {
|
|
651
|
+
const path = exactCheckoutRoot(link.tree.path);
|
|
652
|
+
return {
|
|
653
|
+
path,
|
|
654
|
+
repository: checkoutRepository(path)
|
|
655
|
+
};
|
|
656
|
+
} catch {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function linkWithMode(projectPath, treePath, mode) {
|
|
661
|
+
const project = identifyProject(projectPath);
|
|
662
|
+
const tree = verifyCheckout(treePath, mode);
|
|
663
|
+
const stored = loadLinks(false);
|
|
664
|
+
const existing = stored.links.filter((link) => sameProject(link.project, project));
|
|
665
|
+
if (existing.length > 1) throw new LinkError(CLI_ERROR_CODES.ambiguousLink, "Multiple Context Tree links match this project.");
|
|
666
|
+
const previous = existing[0];
|
|
667
|
+
if (previous !== void 0) {
|
|
668
|
+
if (previous.tree.repository.toLowerCase() !== tree.repository.toLowerCase()) throw new Error("A project cannot link to a different Context Tree repository.");
|
|
669
|
+
if (previous.tree.path !== tree.path) {
|
|
670
|
+
const live = liveStoredCheckout(previous);
|
|
671
|
+
if (live !== void 0 && live.repository.toLowerCase() === previous.tree.repository.toLowerCase()) throw new Error("The existing Context Tree checkout is still live; replacement is allowed only when it is stale.");
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const link = {
|
|
675
|
+
project,
|
|
676
|
+
tree
|
|
677
|
+
};
|
|
678
|
+
saveLinks({
|
|
679
|
+
links: [...stored.links.filter((candidate) => !sameProject(candidate.project, project)), link],
|
|
680
|
+
schemaVersion: 1
|
|
681
|
+
});
|
|
682
|
+
return {
|
|
683
|
+
link,
|
|
684
|
+
schemaVersion: 1
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
function linkProject(projectPath, treePath) {
|
|
688
|
+
return linkWithMode(projectPath, treePath, "link");
|
|
689
|
+
}
|
|
690
|
+
function resolveLink(projectPath) {
|
|
691
|
+
const project = identifyProject(projectPath);
|
|
692
|
+
const matches = loadLinks(true).links.filter((link) => projectMatches(link.project, project));
|
|
693
|
+
if (matches.length === 0) throw new LinkError(CLI_ERROR_CODES.noLink, "No Context Tree link exists for this project.");
|
|
694
|
+
if (matches.length > 1) throw new LinkError(CLI_ERROR_CODES.ambiguousLink, "Multiple Context Tree links match this project.");
|
|
695
|
+
const link = matches[0];
|
|
696
|
+
if (link === void 0) throw new Error("Link lookup failed.");
|
|
697
|
+
try {
|
|
698
|
+
const root = exactCheckoutRoot(link.tree.path);
|
|
699
|
+
requireCheckoutClean(root, "link");
|
|
700
|
+
const repository = checkoutRepository(root);
|
|
701
|
+
if (repository.toLowerCase() !== link.tree.repository.toLowerCase()) throw new Error("The linked path now contains a different Context Tree repository.");
|
|
702
|
+
parseRootNode(root);
|
|
703
|
+
return {
|
|
704
|
+
link: {
|
|
705
|
+
project: link.project,
|
|
706
|
+
tree: {
|
|
707
|
+
path: root,
|
|
708
|
+
repository
|
|
709
|
+
}
|
|
710
|
+
},
|
|
711
|
+
schemaVersion: 1
|
|
712
|
+
};
|
|
713
|
+
} catch (error) {
|
|
714
|
+
const message = error instanceof Error && error.message === "The linked path now contains a different Context Tree repository." ? error.message : "The linked Context Tree checkout is no longer a valid clean candidate; run link when its stored path is stale.";
|
|
715
|
+
throw new LinkError(CLI_ERROR_CODES.staleLink, message);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
//#endregion
|
|
719
|
+
//#region src/core/live.ts
|
|
720
|
+
function git(root, args) {
|
|
721
|
+
const result = spawnSync("git", [
|
|
722
|
+
"-C",
|
|
723
|
+
root,
|
|
724
|
+
...args
|
|
725
|
+
], {
|
|
726
|
+
encoding: "utf8",
|
|
727
|
+
stdio: [
|
|
728
|
+
"ignore",
|
|
729
|
+
"pipe",
|
|
730
|
+
"ignore"
|
|
731
|
+
]
|
|
732
|
+
});
|
|
733
|
+
if (result.error !== void 0 || result.status !== 0) throw new Error("A Git operation failed while preparing the Context Tree.");
|
|
734
|
+
return typeof result.stdout === "string" ? result.stdout : "";
|
|
735
|
+
}
|
|
736
|
+
function requireGit(root, args, allowEmpty = false) {
|
|
737
|
+
const output = git(root, args).trim();
|
|
738
|
+
if (output.length === 0 && !allowEmpty) throw new Error("Unexpected empty Git output.");
|
|
739
|
+
return output;
|
|
740
|
+
}
|
|
741
|
+
function discoverDefaultBranch(root) {
|
|
742
|
+
const refs = git(root, [
|
|
743
|
+
"ls-remote",
|
|
744
|
+
"--symref",
|
|
745
|
+
"origin",
|
|
746
|
+
"HEAD"
|
|
747
|
+
]).split("\n").map((line) => /^ref: refs\/heads\/([^\s\t]+)\tHEAD$/u.exec(line)?.[1]).filter((value) => value !== void 0 && value.length > 0);
|
|
748
|
+
if (refs.length !== 1) throw new Error("The Context Tree origin must report exactly one live default branch.");
|
|
749
|
+
return refs[0] ?? "";
|
|
750
|
+
}
|
|
751
|
+
function refreshProject(projectPath) {
|
|
752
|
+
const result = resolveLink(projectPath);
|
|
753
|
+
const root = result.link.tree.path;
|
|
754
|
+
const defaultBranch = discoverDefaultBranch(root);
|
|
755
|
+
if (requireGit(root, [
|
|
756
|
+
"symbolic-ref",
|
|
757
|
+
"--short",
|
|
758
|
+
"HEAD"
|
|
759
|
+
]) !== defaultBranch) throw new Error(`The Context Tree checkout must be on the live default branch "${defaultBranch}".`);
|
|
760
|
+
const before = requireGit(root, ["rev-parse", "HEAD"]);
|
|
761
|
+
requireGit(root, [
|
|
762
|
+
"pull",
|
|
763
|
+
"--ff-only",
|
|
764
|
+
"origin",
|
|
765
|
+
defaultBranch
|
|
766
|
+
]);
|
|
767
|
+
const after = requireGit(root, ["rev-parse", "HEAD"]);
|
|
768
|
+
return {
|
|
769
|
+
link: {
|
|
770
|
+
...result.link,
|
|
771
|
+
tree: {
|
|
772
|
+
...result.link.tree,
|
|
773
|
+
path: realpathSync(root)
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
defaultBranch,
|
|
777
|
+
refreshed: before !== after,
|
|
778
|
+
schemaVersion: 1,
|
|
779
|
+
sha: after
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function createIsolatedWorktree(treePath, baseSha) {
|
|
783
|
+
const destination = mkdtempSync(join(tmpdir(), "context-tree-stage-"));
|
|
784
|
+
const taskBranch = `context-tree/write/${basename(destination)}`;
|
|
785
|
+
git(treePath, [
|
|
786
|
+
"worktree",
|
|
787
|
+
"add",
|
|
788
|
+
"-b",
|
|
789
|
+
taskBranch,
|
|
790
|
+
destination,
|
|
791
|
+
baseSha
|
|
792
|
+
]);
|
|
793
|
+
return {
|
|
794
|
+
taskBranch,
|
|
795
|
+
worktreePath: realpathSync(destination)
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
function stageContextWrite(projectPath) {
|
|
799
|
+
const result = resolveLink(projectPath);
|
|
800
|
+
const treePath = result.link.tree.path;
|
|
801
|
+
const ownerRepository = result.link.tree.repository;
|
|
802
|
+
const defaultBranch = discoverDefaultBranch(treePath);
|
|
803
|
+
if (requireGit(treePath, [
|
|
804
|
+
"symbolic-ref",
|
|
805
|
+
"--short",
|
|
806
|
+
"HEAD"
|
|
807
|
+
]) !== defaultBranch) throw new Error(`The Context Tree checkout must be on the live default branch "${defaultBranch}".`);
|
|
808
|
+
requireGit(treePath, [
|
|
809
|
+
"fetch",
|
|
810
|
+
"origin",
|
|
811
|
+
defaultBranch
|
|
812
|
+
], true);
|
|
813
|
+
const baseSha = requireGit(treePath, ["rev-parse", `origin/${defaultBranch}`]);
|
|
814
|
+
const { taskBranch, worktreePath } = createIsolatedWorktree(treePath, baseSha);
|
|
815
|
+
return {
|
|
816
|
+
baseSha,
|
|
817
|
+
link: {
|
|
818
|
+
project: result.link.project,
|
|
819
|
+
tree: {
|
|
820
|
+
path: treePath,
|
|
821
|
+
repository: ownerRepository
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
defaultBranch,
|
|
825
|
+
schemaVersion: 1,
|
|
826
|
+
taskBranch,
|
|
827
|
+
worktreePath
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
const DIFF_STATUS = {
|
|
831
|
+
A: "added",
|
|
832
|
+
D: "deleted",
|
|
833
|
+
M: "modified",
|
|
834
|
+
R: "renamed"
|
|
835
|
+
};
|
|
836
|
+
function changedFiles(root, base) {
|
|
837
|
+
const output = git(root, [
|
|
838
|
+
"diff",
|
|
839
|
+
"--name-status",
|
|
840
|
+
base
|
|
841
|
+
]);
|
|
842
|
+
const files = [];
|
|
843
|
+
for (const line of output.split("\n")) {
|
|
844
|
+
const match = /^([ADMR])\s+(.+)$/u.exec(line);
|
|
845
|
+
if (match === null) continue;
|
|
846
|
+
const status = DIFF_STATUS[match[1]];
|
|
847
|
+
if (status === void 0) continue;
|
|
848
|
+
files.push({
|
|
849
|
+
path: match[2] ?? "",
|
|
850
|
+
status
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
const porcelain = git(root, [
|
|
854
|
+
"status",
|
|
855
|
+
"--porcelain",
|
|
856
|
+
"--untracked-files=all"
|
|
857
|
+
]);
|
|
858
|
+
for (const line of porcelain.split("\n")) {
|
|
859
|
+
const path = /^\?\?\s+(.+)$/u.exec(line)?.[1];
|
|
860
|
+
if (path !== void 0 && path.length > 0) files.push({
|
|
861
|
+
path,
|
|
862
|
+
status: "added"
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
return files;
|
|
866
|
+
}
|
|
867
|
+
function inspectContextTreeDiff(treePath, base) {
|
|
868
|
+
const root = realpathSync(treePath);
|
|
869
|
+
const reference = base ?? "HEAD";
|
|
870
|
+
return {
|
|
871
|
+
base: reference,
|
|
872
|
+
files: changedFiles(root, reference),
|
|
873
|
+
patch: git(root, ["diff", reference]),
|
|
874
|
+
schemaVersion: 1,
|
|
875
|
+
treePath: root
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
//#endregion
|
|
879
|
+
//#region src/core/internal/packaged-resource.ts
|
|
880
|
+
const PACKAGE_NAME = "@first-tree-ai/context-tree";
|
|
881
|
+
function isPackageRoot(path) {
|
|
882
|
+
const manifestPath = join(path, "package.json");
|
|
883
|
+
if (!existsSync(manifestPath)) return false;
|
|
884
|
+
try {
|
|
885
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
886
|
+
return isRecord(manifest) && manifest.name === PACKAGE_NAME;
|
|
887
|
+
} catch {
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
function resolvePackagedResource(...segments) {
|
|
892
|
+
let candidate = dirname(fileURLToPath(import.meta.url));
|
|
893
|
+
const filesystemRoot = parse(candidate).root;
|
|
894
|
+
while (true) {
|
|
895
|
+
if (isPackageRoot(candidate)) {
|
|
896
|
+
const resource = resolve(candidate, ...segments);
|
|
897
|
+
if (existsSync(resource)) return resource;
|
|
898
|
+
throw new Error(`Packaged resource is missing: ${segments.join("/")}`);
|
|
899
|
+
}
|
|
900
|
+
if (candidate === filesystemRoot) break;
|
|
901
|
+
candidate = dirname(candidate);
|
|
902
|
+
}
|
|
903
|
+
throw new Error(`Package root is missing while resolving: ${segments.join("/")}`);
|
|
904
|
+
}
|
|
905
|
+
function readPackageManifest() {
|
|
906
|
+
const parsed = JSON.parse(readFileSync(resolvePackagedResource("package.json"), "utf8"));
|
|
907
|
+
if (!isRecord(parsed)) throw new Error("Package metadata is invalid.");
|
|
908
|
+
return parsed;
|
|
909
|
+
}
|
|
910
|
+
function readPackageVersion() {
|
|
911
|
+
const manifest = readPackageManifest();
|
|
912
|
+
if (typeof manifest.version !== "string") throw new Error("Package version is missing or invalid.");
|
|
913
|
+
return manifest.version;
|
|
914
|
+
}
|
|
915
|
+
//#endregion
|
|
916
|
+
//#region src/core/policy.ts
|
|
917
|
+
function readContextTreePolicy() {
|
|
918
|
+
return {
|
|
919
|
+
content: readFileSync(resolvePackagedResource("policy", "context-tree-policy.md"), "utf8"),
|
|
920
|
+
schemaVersion: 1
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
//#endregion
|
|
924
|
+
//#region src/core/read.ts
|
|
925
|
+
function normalizeTreeTarget(value) {
|
|
926
|
+
if (!value || value === ".") return "";
|
|
927
|
+
const normalized = posix.normalize(toPosixPath(value).replace(/^\.\//u, ""));
|
|
928
|
+
if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/")) throw new Error(`Read target is outside the Context Tree: ${value}`);
|
|
929
|
+
return normalized.replace(/\/$/u, "");
|
|
930
|
+
}
|
|
931
|
+
function canonicalTarget(root, path) {
|
|
932
|
+
const requested = normalizeTreeTarget(path);
|
|
933
|
+
const semanticPath = requested === "NODE.md" ? "" : requested.endsWith("/NODE.md") ? dirname(requested) : requested;
|
|
934
|
+
if (classifyContextContent(semanticPath) === "repo-infra") throw new Error(`Read target is repository infrastructure: ${requested || "."}`);
|
|
935
|
+
const absolutePath = resolve(root, semanticPath);
|
|
936
|
+
if (!isPathInside(root, absolutePath)) throw new Error("Read target escapes the Context Tree root.");
|
|
937
|
+
const entry = lstatSync(absolutePath);
|
|
938
|
+
if (entry.isSymbolicLink() || !entry.isDirectory() && !entry.isFile()) throw new Error(`Read target must be a real file or directory: ${requested || "."}`);
|
|
939
|
+
if (realpathSync(absolutePath) !== absolutePath) throw new Error(`Read target must not traverse a symlink: ${requested || "."}`);
|
|
940
|
+
const relativePath = toPosixPath(relative(root, absolutePath));
|
|
941
|
+
if (entry.isFile() && !absolutePath.endsWith(".md")) throw new Error(`Read target must be a Markdown file or indexed directory: ${requested || "."}`);
|
|
942
|
+
return {
|
|
943
|
+
absolutePath,
|
|
944
|
+
kind: entry.isDirectory() ? "directory" : "file",
|
|
945
|
+
relativePath
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
function readNode(path, relativePath, kind) {
|
|
949
|
+
const documentPath = kind === "directory" ? join(path, "NODE.md") : path;
|
|
950
|
+
const entry = lstatSync(documentPath);
|
|
951
|
+
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error(`Context Tree document must be a regular file: ${relativePath || "NODE.md"}`);
|
|
952
|
+
const document = readNodeDocument(documentPath);
|
|
953
|
+
if (document === null) throw new Error(`Context Tree document has invalid or missing metadata: ${relativePath || "."}`);
|
|
954
|
+
return {
|
|
955
|
+
body: document.body,
|
|
956
|
+
contentClass: classifyContextContent(relativePath),
|
|
957
|
+
frontmatter: document.frontmatter,
|
|
958
|
+
kind,
|
|
959
|
+
path: relativePath || "."
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
function childSummary(root, parentPath, name) {
|
|
963
|
+
const absolutePath = join(parentPath, name);
|
|
964
|
+
const relativePath = toPosixPath(relative(root, absolutePath));
|
|
965
|
+
const contentClass = classifyContextContent(relativePath);
|
|
966
|
+
if (contentClass === "repo-infra") return null;
|
|
967
|
+
const entry = lstatSync(absolutePath);
|
|
968
|
+
if (entry.isSymbolicLink()) return null;
|
|
969
|
+
const kind = entry.isDirectory() ? "directory" : entry.isFile() && name.endsWith(".md") && name !== "NODE.md" ? "file" : null;
|
|
970
|
+
if (kind === null) return null;
|
|
971
|
+
const document = readNodeDocument(kind === "directory" ? join(absolutePath, "NODE.md") : absolutePath);
|
|
972
|
+
if (document === null) throw new Error(`Context Tree child has invalid or missing metadata: ${relativePath}`);
|
|
973
|
+
return {
|
|
974
|
+
contentClass,
|
|
975
|
+
...document.description === void 0 ? {} : { description: document.description },
|
|
976
|
+
kind,
|
|
977
|
+
path: relativePath,
|
|
978
|
+
title: document.title
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
function readTree(treePath, path) {
|
|
982
|
+
const root = resolveTreeRoot(treePath);
|
|
983
|
+
const target = canonicalTarget(root, path);
|
|
984
|
+
const node = readNode(target.absolutePath, target.relativePath, target.kind);
|
|
985
|
+
return {
|
|
986
|
+
children: target.kind === "file" ? [] : readdirSync(target.absolutePath).map((name) => childSummary(root, target.absolutePath, name)).filter((child) => child !== null).sort((left, right) => left.path.localeCompare(right.path)),
|
|
987
|
+
node,
|
|
988
|
+
root,
|
|
989
|
+
schemaVersion: 1,
|
|
990
|
+
target: target.relativePath || "."
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
//#endregion
|
|
994
|
+
//#region src/core/scaffold.ts
|
|
995
|
+
function template(name, values) {
|
|
996
|
+
let result = readFileSync(resolvePackagedResource("templates", name), "utf8");
|
|
997
|
+
for (const [key, value] of Object.entries(values)) result = result.replaceAll(`{{${key}}}`, value);
|
|
998
|
+
return result;
|
|
999
|
+
}
|
|
1000
|
+
function initializeGitRepository(root, repository) {
|
|
1001
|
+
const initialized = spawnSync("git", [
|
|
1002
|
+
"init",
|
|
1003
|
+
"--quiet",
|
|
1004
|
+
root
|
|
1005
|
+
], { stdio: "ignore" });
|
|
1006
|
+
if (initialized.error !== void 0 || initialized.status !== 0) throw new Error("Failed to initialize Git repository.");
|
|
1007
|
+
const branch = spawnSync("git", [
|
|
1008
|
+
"-C",
|
|
1009
|
+
root,
|
|
1010
|
+
"symbolic-ref",
|
|
1011
|
+
"--short",
|
|
1012
|
+
"HEAD"
|
|
1013
|
+
], {
|
|
1014
|
+
encoding: "utf8",
|
|
1015
|
+
stdio: [
|
|
1016
|
+
"ignore",
|
|
1017
|
+
"pipe",
|
|
1018
|
+
"ignore"
|
|
1019
|
+
]
|
|
1020
|
+
});
|
|
1021
|
+
const name = branch.stdout.replace(/\r?\n$/u, "");
|
|
1022
|
+
if (branch.error !== void 0 || branch.status !== 0 || name.length === 0) throw new Error("Failed to resolve the initial Git branch during repository initialization.");
|
|
1023
|
+
const remote = spawnSync("git", [
|
|
1024
|
+
"-C",
|
|
1025
|
+
root,
|
|
1026
|
+
"remote",
|
|
1027
|
+
"add",
|
|
1028
|
+
"origin",
|
|
1029
|
+
canonicalGitHubRepositoryUrl(repository)
|
|
1030
|
+
], { stdio: "ignore" });
|
|
1031
|
+
if (remote.error !== void 0 || remote.status !== 0) throw new Error("Failed to configure the credential-free Context Tree origin.");
|
|
1032
|
+
return name;
|
|
1033
|
+
}
|
|
1034
|
+
function scaffoldTree(options) {
|
|
1035
|
+
const title = parseGitHubRepositoryIdentity(options.repository);
|
|
1036
|
+
const root = resolve(options.path);
|
|
1037
|
+
const destination = lstatSync(root, { throwIfNoEntry: false });
|
|
1038
|
+
if (destination !== void 0) {
|
|
1039
|
+
if (destination.isSymbolicLink() || !destination.isDirectory()) throw new Error(`Refusing to scaffold into a symlink or non-directory destination: ${root}`);
|
|
1040
|
+
if (readdirSync(root).length > 0) throw new Error(`Refusing to scaffold into a non-empty directory: ${root}`);
|
|
1041
|
+
}
|
|
1042
|
+
const initialBranch = initializeGitRepository(root, options.repository);
|
|
1043
|
+
const values = {
|
|
1044
|
+
branchJson: JSON.stringify(initialBranch),
|
|
1045
|
+
packageVersion: readPackageVersion(),
|
|
1046
|
+
title,
|
|
1047
|
+
titleJson: JSON.stringify(title)
|
|
1048
|
+
};
|
|
1049
|
+
const regularFiles = [
|
|
1050
|
+
["NODE.md", "root-node.md"],
|
|
1051
|
+
["AGENTS.md", "AGENTS.md"],
|
|
1052
|
+
[".github/workflows/validate-context-tree.yml", "validate-context-tree.yml"]
|
|
1053
|
+
];
|
|
1054
|
+
const files = [
|
|
1055
|
+
"NODE.md",
|
|
1056
|
+
"AGENTS.md",
|
|
1057
|
+
"CLAUDE.md",
|
|
1058
|
+
".github/workflows/validate-context-tree.yml"
|
|
1059
|
+
];
|
|
1060
|
+
for (const [relativePath, source] of regularFiles.slice(0, 2)) {
|
|
1061
|
+
const path = join(root, relativePath);
|
|
1062
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1063
|
+
writeFileSync(path, template(source, values), {
|
|
1064
|
+
encoding: "utf8",
|
|
1065
|
+
flag: "wx",
|
|
1066
|
+
mode: 420
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
symlinkSync("AGENTS.md", join(root, "CLAUDE.md"), "file");
|
|
1070
|
+
for (const [relativePath, source] of regularFiles.slice(2)) {
|
|
1071
|
+
const path = join(root, relativePath);
|
|
1072
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1073
|
+
writeFileSync(path, template(source, values), {
|
|
1074
|
+
encoding: "utf8",
|
|
1075
|
+
flag: "wx",
|
|
1076
|
+
mode: 420
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
files,
|
|
1081
|
+
root,
|
|
1082
|
+
schemaVersion: 1,
|
|
1083
|
+
verification: verifyTree(root)
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
//#endregion
|
|
1087
|
+
export { inspectContextTreeDiff, linkProject, readContextTreePolicy, readTree, refreshProject, resolveLink, scaffoldTree, stageContextWrite, verifyTree };
|