first-tree 0.0.2 → 0.0.3
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/README.md +73 -39
- package/dist/cli.js +27 -13
- package/dist/help-xEI-s9iN.js +25 -0
- package/dist/init-DtOjj0wc.js +253 -0
- package/dist/installer-rcZpGLnM.js +47 -0
- package/dist/onboarding-6Fr5Gkrk.js +2 -0
- package/dist/onboarding-B9zPGvvG.js +10 -0
- package/dist/repo-BTJG8BU1.js +187 -0
- package/dist/upgrade-COGgI7Rj.js +96 -0
- package/dist/{verify-CSRIkuoM.js → verify-CxN6JiV9.js} +53 -24
- package/package.json +33 -10
- package/skills/first-tree/SKILL.md +109 -0
- package/skills/first-tree/agents/openai.yaml +4 -0
- package/skills/first-tree/assets/framework/VERSION +1 -0
- package/skills/first-tree/assets/framework/examples/claude-code/README.md +14 -0
- package/skills/first-tree/assets/framework/examples/claude-code/settings.json +14 -0
- package/skills/first-tree/assets/framework/helpers/generate-codeowners.ts +224 -0
- package/skills/first-tree/assets/framework/helpers/inject-tree-context.sh +15 -0
- package/skills/first-tree/assets/framework/helpers/run-review.ts +179 -0
- package/skills/first-tree/assets/framework/manifest.json +11 -0
- package/skills/first-tree/assets/framework/prompts/pr-review.md +38 -0
- package/skills/first-tree/assets/framework/templates/agent.md.template +48 -0
- package/skills/first-tree/assets/framework/templates/member-node.md.template +18 -0
- package/skills/first-tree/assets/framework/templates/members-domain.md.template +45 -0
- package/skills/first-tree/assets/framework/templates/root-node.md.template +38 -0
- package/skills/first-tree/assets/framework/workflows/codeowners.yml +31 -0
- package/skills/first-tree/assets/framework/workflows/pr-review.yml +146 -0
- package/skills/first-tree/assets/framework/workflows/validate.yml +19 -0
- package/skills/first-tree/engine/commands/help.ts +32 -0
- package/skills/first-tree/engine/commands/init.ts +1 -0
- package/skills/first-tree/engine/commands/upgrade.ts +1 -0
- package/skills/first-tree/engine/commands/verify.ts +1 -0
- package/skills/first-tree/engine/init.ts +145 -0
- package/skills/first-tree/engine/onboarding.ts +10 -0
- package/skills/first-tree/engine/repo.ts +184 -0
- package/skills/first-tree/engine/rules/agent-instructions.ts +37 -0
- package/skills/first-tree/engine/rules/agent-integration.ts +19 -0
- package/skills/first-tree/engine/rules/ci-validation.ts +72 -0
- package/skills/first-tree/engine/rules/framework.ts +13 -0
- package/skills/first-tree/engine/rules/index.ts +41 -0
- package/skills/first-tree/engine/rules/members.ts +21 -0
- package/skills/first-tree/engine/rules/populate-tree.ts +36 -0
- package/skills/first-tree/engine/rules/root-node.ts +41 -0
- package/skills/first-tree/engine/runtime/adapters.ts +22 -0
- package/skills/first-tree/engine/runtime/asset-loader.ts +134 -0
- package/skills/first-tree/engine/runtime/installer.ts +82 -0
- package/skills/first-tree/engine/runtime/upgrader.ts +23 -0
- package/skills/first-tree/engine/upgrade.ts +176 -0
- package/skills/first-tree/engine/validators/members.ts +215 -0
- package/skills/first-tree/engine/validators/nodes.ts +514 -0
- package/skills/first-tree/engine/verify.ts +97 -0
- package/skills/first-tree/references/about.md +36 -0
- package/skills/first-tree/references/maintainer-architecture.md +59 -0
- package/skills/first-tree/references/maintainer-build-and-distribution.md +56 -0
- package/skills/first-tree/references/maintainer-testing.md +58 -0
- package/skills/first-tree/references/maintainer-thin-cli.md +38 -0
- package/skills/first-tree/references/onboarding.md +162 -0
- package/skills/first-tree/references/ownership-and-naming.md +94 -0
- package/skills/first-tree/references/principles.md +113 -0
- package/skills/first-tree/references/source-map.md +94 -0
- package/skills/first-tree/references/upgrade-contract.md +85 -0
- package/skills/first-tree/scripts/check-skill-sync.sh +133 -0
- package/skills/first-tree/scripts/quick_validate.py +95 -0
- package/skills/first-tree/scripts/run-local-cli.sh +35 -0
- package/skills/first-tree/tests/asset-loader.test.ts +75 -0
- package/skills/first-tree/tests/generate-codeowners.test.ts +94 -0
- package/skills/first-tree/tests/helpers.ts +149 -0
- package/skills/first-tree/tests/init.test.ts +153 -0
- package/skills/first-tree/tests/repo.test.ts +362 -0
- package/skills/first-tree/tests/rules.test.ts +394 -0
- package/skills/first-tree/tests/run-review.test.ts +155 -0
- package/skills/first-tree/tests/skill-artifacts.test.ts +307 -0
- package/skills/first-tree/tests/thin-cli.test.ts +59 -0
- package/skills/first-tree/tests/upgrade.test.ts +89 -0
- package/skills/first-tree/tests/validate-members.test.ts +224 -0
- package/skills/first-tree/tests/validate-nodes.test.ts +198 -0
- package/skills/first-tree/tests/verify.test.ts +142 -0
- package/dist/init-CE_944sb.js +0 -283
- package/dist/repo-BByc3VvM.js +0 -111
- package/dist/upgrade-Chr7z0CY.js +0 -82
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, posix } from "node:path";
|
|
3
|
+
|
|
4
|
+
const FRONTMATTER_RE = /^---\s*\n(.*?)\n---/s;
|
|
5
|
+
const OWNERS_RE = /^owners:\s*\[([^\]]*)\]/m;
|
|
6
|
+
const SOFT_LINKS_INLINE_RE = /^soft_links:\s*\[([^\]]*)\]/m;
|
|
7
|
+
const SOFT_LINKS_BLOCK_RE = /^soft_links:\s*\n((?:\s+-\s+.+\n?)+)/m;
|
|
8
|
+
const TITLE_RE = /^title:\s*['"]?(.+?)['"]?\s*$/m;
|
|
9
|
+
const GITHUB_USER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/;
|
|
10
|
+
const MD_LINK_RE = /\[.*?\]\(([^)]+\.md)\)/g;
|
|
11
|
+
const DOMAIN_LINK_RE = /\[(\w[\w-]*)\/?\]\((\w[\w-]*)\/NODE\.md\)/g;
|
|
12
|
+
|
|
13
|
+
const SKIP = new Set(["node_modules", "__pycache__"]);
|
|
14
|
+
const SKIP_FILES = new Set(["AGENT.md", "CLAUDE.md"]);
|
|
15
|
+
const MIN_BODY_LENGTH = 20;
|
|
16
|
+
|
|
17
|
+
export class Findings {
|
|
18
|
+
errors: string[] = [];
|
|
19
|
+
warnings: string[] = [];
|
|
20
|
+
infos: string[] = [];
|
|
21
|
+
|
|
22
|
+
error(msg: string): void {
|
|
23
|
+
this.errors.push(msg);
|
|
24
|
+
}
|
|
25
|
+
warning(msg: string): void {
|
|
26
|
+
this.warnings.push(msg);
|
|
27
|
+
}
|
|
28
|
+
info(msg: string): void {
|
|
29
|
+
this.infos.push(msg);
|
|
30
|
+
}
|
|
31
|
+
hasErrors(): boolean {
|
|
32
|
+
return this.errors.length > 0;
|
|
33
|
+
}
|
|
34
|
+
printReport(totalFiles: number): void {
|
|
35
|
+
const all: [string, string][] = [
|
|
36
|
+
...this.errors.map((e): [string, string] => ["error", e]),
|
|
37
|
+
...this.warnings.map((w): [string, string] => ["warning", w]),
|
|
38
|
+
...this.infos.map((i): [string, string] => ["info", i]),
|
|
39
|
+
];
|
|
40
|
+
if (all.length > 0) {
|
|
41
|
+
const counts: string[] = [];
|
|
42
|
+
if (this.errors.length) counts.push(`${this.errors.length} error(s)`);
|
|
43
|
+
if (this.warnings.length) counts.push(`${this.warnings.length} warning(s)`);
|
|
44
|
+
if (this.infos.length) counts.push(`${this.infos.length} info(s)`);
|
|
45
|
+
console.log(`Found ${counts.join(", ")}:\n`);
|
|
46
|
+
const icons: Record<string, string> = {
|
|
47
|
+
error: "\u2717",
|
|
48
|
+
warning: "\u26a0",
|
|
49
|
+
info: "\u2139",
|
|
50
|
+
};
|
|
51
|
+
for (const [severity, msg] of all) {
|
|
52
|
+
console.log(` ${icons[severity]} [${severity}] ${msg}`);
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
console.log(`All ${totalFiles} node(s) passed validation.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// -- Utilities --
|
|
61
|
+
|
|
62
|
+
let treeRoot = "";
|
|
63
|
+
const textCache = new Map<string, string | null>();
|
|
64
|
+
|
|
65
|
+
export function setTreeRoot(root: string): void {
|
|
66
|
+
treeRoot = root;
|
|
67
|
+
textCache.clear();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getTreeRoot(): string {
|
|
71
|
+
return treeRoot;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rel(path: string): string {
|
|
75
|
+
return relative(treeRoot, path);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function shouldSkip(path: string): boolean {
|
|
79
|
+
const parts = relative(treeRoot, path).split("/");
|
|
80
|
+
return parts.some((part) => SKIP.has(part) || part.startsWith("."));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readText(path: string): string | null {
|
|
84
|
+
if (!textCache.has(path)) {
|
|
85
|
+
try {
|
|
86
|
+
textCache.set(path, readFileSync(path, "utf-8"));
|
|
87
|
+
} catch {
|
|
88
|
+
textCache.set(path, null);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return textCache.get(path)!;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseFrontmatter(path: string): string | null {
|
|
95
|
+
const text = readText(path);
|
|
96
|
+
if (text === null) return null;
|
|
97
|
+
const m = text.match(FRONTMATTER_RE);
|
|
98
|
+
return m ? m[1] : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function parseBody(path: string): string | null {
|
|
102
|
+
const text = readText(path);
|
|
103
|
+
if (text === null) return null;
|
|
104
|
+
const m = text.match(FRONTMATTER_RE);
|
|
105
|
+
if (m) return text.slice(m[0].length);
|
|
106
|
+
return text;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function parseSoftLinks(fm: string): string[] | null {
|
|
110
|
+
// Inline format
|
|
111
|
+
let m = fm.match(SOFT_LINKS_INLINE_RE);
|
|
112
|
+
if (m) {
|
|
113
|
+
const raw = m[1].trim();
|
|
114
|
+
if (!raw) return [];
|
|
115
|
+
return raw
|
|
116
|
+
.split(",")
|
|
117
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
|
118
|
+
.filter(Boolean);
|
|
119
|
+
}
|
|
120
|
+
// Block format
|
|
121
|
+
m = fm.match(SOFT_LINKS_BLOCK_RE);
|
|
122
|
+
if (m) {
|
|
123
|
+
return m[1]
|
|
124
|
+
.trim()
|
|
125
|
+
.split("\n")
|
|
126
|
+
.map((line) =>
|
|
127
|
+
line
|
|
128
|
+
.trim()
|
|
129
|
+
.replace(/^-\s*/, "")
|
|
130
|
+
.trim()
|
|
131
|
+
.replace(/^['"]|['"]$/g, ""),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveSoftLink(link: string): boolean {
|
|
138
|
+
const clean = link.replace(/^\/+/, "");
|
|
139
|
+
const target = join(treeRoot, clean);
|
|
140
|
+
|
|
141
|
+
// Direct .md file
|
|
142
|
+
try {
|
|
143
|
+
if (statSync(target).isFile() && target.endsWith(".md")) return true;
|
|
144
|
+
} catch {
|
|
145
|
+
// not found
|
|
146
|
+
}
|
|
147
|
+
// Directory with NODE.md
|
|
148
|
+
try {
|
|
149
|
+
if (statSync(target).isDirectory() && existsSync(join(target, "NODE.md")))
|
|
150
|
+
return true;
|
|
151
|
+
} catch {
|
|
152
|
+
// not found
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeSoftLink(link: string): string {
|
|
158
|
+
const clean = link.replace(/^\/+/, "");
|
|
159
|
+
const target = join(treeRoot, clean);
|
|
160
|
+
try {
|
|
161
|
+
if (statSync(target).isDirectory()) return join(target, "NODE.md");
|
|
162
|
+
} catch {
|
|
163
|
+
// not a directory
|
|
164
|
+
}
|
|
165
|
+
return target;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function collectMdFiles(): string[] {
|
|
169
|
+
const files: string[] = [];
|
|
170
|
+
function walk(dir: string): void {
|
|
171
|
+
let entries: string[];
|
|
172
|
+
try {
|
|
173
|
+
entries = readdirSync(dir).sort();
|
|
174
|
+
} catch {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
const full = join(dir, entry);
|
|
179
|
+
if (shouldSkip(full)) continue;
|
|
180
|
+
try {
|
|
181
|
+
const stat = statSync(full);
|
|
182
|
+
if (stat.isDirectory()) {
|
|
183
|
+
walk(full);
|
|
184
|
+
} else if (
|
|
185
|
+
stat.isFile() &&
|
|
186
|
+
entry.endsWith(".md") &&
|
|
187
|
+
!SKIP_FILES.has(entry)
|
|
188
|
+
) {
|
|
189
|
+
files.push(full);
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// skip
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
walk(treeRoot);
|
|
197
|
+
return files;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// -- Validation checks --
|
|
201
|
+
|
|
202
|
+
export function validateOwners(
|
|
203
|
+
fm: string,
|
|
204
|
+
path: string,
|
|
205
|
+
findings: Findings,
|
|
206
|
+
): void {
|
|
207
|
+
const m = fm.match(OWNERS_RE);
|
|
208
|
+
if (!m) {
|
|
209
|
+
findings.error(`${rel(path)}: missing 'owners' field in frontmatter`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const raw = m[1].trim();
|
|
213
|
+
if (!raw) return; // owners: [] is valid (inheritance)
|
|
214
|
+
|
|
215
|
+
const owners = raw
|
|
216
|
+
.split(",")
|
|
217
|
+
.map((o) => o.trim())
|
|
218
|
+
.filter(Boolean);
|
|
219
|
+
if (owners.length === 0) {
|
|
220
|
+
findings.error(`${rel(path)}: owners list contains only whitespace entries`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (owners.length === 1 && owners[0] === "*") return; // owners: [*] valid
|
|
224
|
+
|
|
225
|
+
for (const owner of owners) {
|
|
226
|
+
if (owner === "*") {
|
|
227
|
+
findings.error(
|
|
228
|
+
`${rel(path)}: wildcard '*' must be the sole entry, not mixed with usernames`,
|
|
229
|
+
);
|
|
230
|
+
} else if (!GITHUB_USER_RE.test(owner)) {
|
|
231
|
+
findings.error(`${rel(path)}: invalid owner '${owner}'`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function validateSoftLinks(
|
|
237
|
+
fm: string,
|
|
238
|
+
path: string,
|
|
239
|
+
findings: Findings,
|
|
240
|
+
): void {
|
|
241
|
+
const links = parseSoftLinks(fm);
|
|
242
|
+
if (links === null) return;
|
|
243
|
+
for (const link of links) {
|
|
244
|
+
if (!link) {
|
|
245
|
+
findings.error(`${rel(path)}: empty soft_link entry`);
|
|
246
|
+
} else if (!resolveSoftLink(link)) {
|
|
247
|
+
findings.error(
|
|
248
|
+
`${rel(path)}: soft_link '${link}' does not resolve to an existing node`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function validateFolders(findings: Findings): void {
|
|
255
|
+
function walk(dir: string): void {
|
|
256
|
+
let entries: string[];
|
|
257
|
+
try {
|
|
258
|
+
entries = readdirSync(dir).sort();
|
|
259
|
+
} catch {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
const full = join(dir, entry);
|
|
264
|
+
if (shouldSkip(full)) continue;
|
|
265
|
+
try {
|
|
266
|
+
if (!statSync(full).isDirectory()) continue;
|
|
267
|
+
} catch {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (!existsSync(join(full, "NODE.md"))) {
|
|
271
|
+
findings.error(`${rel(full)}/: missing NODE.md`);
|
|
272
|
+
}
|
|
273
|
+
walk(full);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
walk(treeRoot);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function validateDirectoryListing(findings: Findings): void {
|
|
280
|
+
function walk(dir: string): void {
|
|
281
|
+
let entries: string[];
|
|
282
|
+
try {
|
|
283
|
+
entries = readdirSync(dir).sort();
|
|
284
|
+
} catch {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
const full = join(dir, entry);
|
|
289
|
+
if (shouldSkip(full)) continue;
|
|
290
|
+
try {
|
|
291
|
+
if (!statSync(full).isDirectory()) continue;
|
|
292
|
+
} catch {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const nodeMd = join(full, "NODE.md");
|
|
296
|
+
if (!existsSync(nodeMd)) {
|
|
297
|
+
walk(full);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const body = parseBody(nodeMd);
|
|
301
|
+
if (body !== null) {
|
|
302
|
+
const actualLeaves = new Set<string>();
|
|
303
|
+
for (const f of readdirSync(full).sort()) {
|
|
304
|
+
const fp = join(full, f);
|
|
305
|
+
try {
|
|
306
|
+
if (statSync(fp).isFile() && f.endsWith(".md") && f !== "NODE.md") {
|
|
307
|
+
actualLeaves.add(f);
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// skip
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const referenced = new Set<string>();
|
|
314
|
+
let linkMatch: RegExpExecArray | null;
|
|
315
|
+
const linkRe = /\[.*?\]\(([^)]+\.md)\)/g;
|
|
316
|
+
while ((linkMatch = linkRe.exec(body)) !== null) {
|
|
317
|
+
const ref = linkMatch[1];
|
|
318
|
+
if (ref.startsWith("http") || ref.startsWith("/")) continue;
|
|
319
|
+
if (!ref.includes("/")) referenced.add(ref);
|
|
320
|
+
}
|
|
321
|
+
for (const orphan of [...actualLeaves].filter((f) => !referenced.has(f)).sort()) {
|
|
322
|
+
findings.warning(
|
|
323
|
+
`${rel(nodeMd)}: leaf file '${orphan}' exists but is not mentioned in NODE.md`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
for (const ref of [...referenced].filter((f) => !actualLeaves.has(f)).sort()) {
|
|
327
|
+
if (!existsSync(join(full, ref))) {
|
|
328
|
+
findings.warning(
|
|
329
|
+
`${rel(nodeMd)}: references '${ref}' but the file does not exist`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
walk(full);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
walk(treeRoot);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function validateRootDomainSync(findings: Findings): void {
|
|
341
|
+
const nodeMd = join(treeRoot, "NODE.md");
|
|
342
|
+
const body = parseBody(nodeMd);
|
|
343
|
+
if (body === null) return;
|
|
344
|
+
|
|
345
|
+
// Strip HTML comments
|
|
346
|
+
const bodyNoComments = body.replace(/<!--.*?-->/gs, "");
|
|
347
|
+
|
|
348
|
+
const listedDomains = new Set<string>();
|
|
349
|
+
let dm: RegExpExecArray | null;
|
|
350
|
+
const domainRe = /\[(\w[\w-]*)\/?\]\((\w[\w-]*)\/NODE\.md\)/g;
|
|
351
|
+
while ((dm = domainRe.exec(bodyNoComments)) !== null) {
|
|
352
|
+
listedDomains.add(dm[2]);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const actualDomains = new Set<string>();
|
|
356
|
+
for (const child of readdirSync(treeRoot).sort()) {
|
|
357
|
+
const full = join(treeRoot, child);
|
|
358
|
+
try {
|
|
359
|
+
if (!statSync(full).isDirectory()) continue;
|
|
360
|
+
} catch {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (child.startsWith(".") || SKIP.has(child)) continue;
|
|
364
|
+
if (existsSync(join(full, "NODE.md"))) actualDomains.add(child);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const missing of [...actualDomains].filter((d) => !listedDomains.has(d)).sort()) {
|
|
368
|
+
findings.error(
|
|
369
|
+
`NODE.md: domain directory '${missing}/' exists but is not listed in root NODE.md`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
for (const extra of [...listedDomains].filter((d) => !actualDomains.has(d)).sort()) {
|
|
373
|
+
findings.error(
|
|
374
|
+
`NODE.md: lists domain '${extra}/' but the directory does not exist or has no NODE.md`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function validateSoftLinkReciprocity(
|
|
380
|
+
files: string[],
|
|
381
|
+
findings: Findings,
|
|
382
|
+
): void {
|
|
383
|
+
const allLinks: [string, string][] = [];
|
|
384
|
+
|
|
385
|
+
for (const path of files) {
|
|
386
|
+
const fm = parseFrontmatter(path);
|
|
387
|
+
if (fm === null) continue;
|
|
388
|
+
const links = parseSoftLinks(fm);
|
|
389
|
+
if (!links) continue;
|
|
390
|
+
for (const link of links) {
|
|
391
|
+
if (!link) continue;
|
|
392
|
+
const target = normalizeSoftLink(link);
|
|
393
|
+
allLinks.push([path, target]);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
for (const [source, target] of allLinks) {
|
|
398
|
+
if (!existsSync(target)) continue;
|
|
399
|
+
|
|
400
|
+
let hasBackLink = false;
|
|
401
|
+
const targetFm = parseFrontmatter(target);
|
|
402
|
+
if (targetFm) {
|
|
403
|
+
const targetLinks = parseSoftLinks(targetFm);
|
|
404
|
+
if (targetLinks) {
|
|
405
|
+
for (const tl of targetLinks) {
|
|
406
|
+
if (!tl) continue;
|
|
407
|
+
const resolved = normalizeSoftLink(tl);
|
|
408
|
+
if (
|
|
409
|
+
resolved === source ||
|
|
410
|
+
resolved === join(source, "..", "NODE.md")
|
|
411
|
+
) {
|
|
412
|
+
hasBackLink = true;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!hasBackLink) {
|
|
420
|
+
const targetBody = parseBody(target);
|
|
421
|
+
if (targetBody) {
|
|
422
|
+
const sourceRel = rel(source);
|
|
423
|
+
const linkRe = /\[.*?\]\(([^)]+\.md)\)/g;
|
|
424
|
+
let lm: RegExpExecArray | null;
|
|
425
|
+
while ((lm = linkRe.exec(targetBody)) !== null) {
|
|
426
|
+
const ref = lm[1];
|
|
427
|
+
if (ref.startsWith("http") || ref.startsWith("/")) continue;
|
|
428
|
+
if (sourceRel.endsWith(ref) || ref === sourceRel) {
|
|
429
|
+
hasBackLink = true;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!hasBackLink) {
|
|
437
|
+
findings.info(
|
|
438
|
+
`${rel(source)}: soft_link to '${rel(target)}' is one-way (target has no reference back)`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function validateEmptyNodes(
|
|
445
|
+
files: string[],
|
|
446
|
+
findings: Findings,
|
|
447
|
+
): void {
|
|
448
|
+
for (const path of files) {
|
|
449
|
+
const text = readText(path);
|
|
450
|
+
if (text === null) continue;
|
|
451
|
+
const m = text.match(FRONTMATTER_RE);
|
|
452
|
+
if (!m) continue;
|
|
453
|
+
const body = text.slice(m[0].length);
|
|
454
|
+
const stripped = body.replace(/\s+/g, "");
|
|
455
|
+
if (stripped.length < MIN_BODY_LENGTH) {
|
|
456
|
+
findings.warning(`${rel(path)}: node has little or no body content`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function validateTitleMismatch(
|
|
462
|
+
files: string[],
|
|
463
|
+
findings: Findings,
|
|
464
|
+
): void {
|
|
465
|
+
for (const path of files) {
|
|
466
|
+
const text = readText(path);
|
|
467
|
+
if (text === null) continue;
|
|
468
|
+
const fmMatch = text.match(FRONTMATTER_RE);
|
|
469
|
+
if (!fmMatch) continue;
|
|
470
|
+
|
|
471
|
+
const fm = fmMatch[1];
|
|
472
|
+
const titleMatch = fm.match(TITLE_RE);
|
|
473
|
+
if (!titleMatch) continue;
|
|
474
|
+
const fmTitle = titleMatch[1].trim();
|
|
475
|
+
|
|
476
|
+
const body = text.slice(fmMatch[0].length);
|
|
477
|
+
const headingMatch = body.match(/^#\s+(.+)$/m);
|
|
478
|
+
if (!headingMatch) continue;
|
|
479
|
+
const bodyHeading = headingMatch[1].trim();
|
|
480
|
+
|
|
481
|
+
if (fmTitle !== bodyHeading) {
|
|
482
|
+
findings.warning(
|
|
483
|
+
`${rel(path)}: frontmatter title '${fmTitle}' differs from first heading '${bodyHeading}'`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function runValidateNodes(root: string): { exitCode: number; findings: Findings } {
|
|
490
|
+
setTreeRoot(root);
|
|
491
|
+
const files = collectMdFiles();
|
|
492
|
+
const findings = new Findings();
|
|
493
|
+
|
|
494
|
+
validateFolders(findings);
|
|
495
|
+
|
|
496
|
+
for (const path of files) {
|
|
497
|
+
const fm = parseFrontmatter(path);
|
|
498
|
+
if (fm === null) {
|
|
499
|
+
findings.error(`${rel(path)}: no frontmatter found`);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
validateOwners(fm, path, findings);
|
|
503
|
+
validateSoftLinks(fm, path, findings);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
validateDirectoryListing(findings);
|
|
507
|
+
validateRootDomainSync(findings);
|
|
508
|
+
validateSoftLinkReciprocity(files, findings);
|
|
509
|
+
validateEmptyNodes(files, findings);
|
|
510
|
+
validateTitleMismatch(files, findings);
|
|
511
|
+
|
|
512
|
+
findings.printReport(files.length);
|
|
513
|
+
return { exitCode: findings.hasErrors() ? 1 : 0, findings };
|
|
514
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Repo } from "#skill/engine/repo.js";
|
|
2
|
+
import { runValidateMembers } from "#skill/engine/validators/members.js";
|
|
3
|
+
import { runValidateNodes } from "#skill/engine/validators/nodes.js";
|
|
4
|
+
|
|
5
|
+
const UNCHECKED_RE = /^- \[ \] (.+)$/gm;
|
|
6
|
+
|
|
7
|
+
export function check(label: string, passed: boolean): boolean {
|
|
8
|
+
const icon = passed ? "\u2713" : "\u2717";
|
|
9
|
+
const status = passed ? "PASS" : "FAIL";
|
|
10
|
+
console.log(` ${icon} [${status}] ${label}`);
|
|
11
|
+
return passed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function checkProgress(repo: Repo): string[] {
|
|
15
|
+
const progressPath = repo.progressPath();
|
|
16
|
+
const text = progressPath === null ? null : repo.readFile(progressPath);
|
|
17
|
+
if (text === null) return [];
|
|
18
|
+
const matches: string[] = [];
|
|
19
|
+
let m: RegExpExecArray | null;
|
|
20
|
+
UNCHECKED_RE.lastIndex = 0;
|
|
21
|
+
while ((m = UNCHECKED_RE.exec(text)) !== null) {
|
|
22
|
+
matches.push(m[1]);
|
|
23
|
+
}
|
|
24
|
+
return matches;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ValidateNodesResult {
|
|
28
|
+
exitCode: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type NodeValidator = (root: string) => ValidateNodesResult;
|
|
32
|
+
|
|
33
|
+
function defaultNodeValidator(root: string): ValidateNodesResult {
|
|
34
|
+
const { exitCode } = runValidateNodes(root);
|
|
35
|
+
return { exitCode };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function runVerify(repo?: Repo, nodeValidator?: NodeValidator): number {
|
|
39
|
+
const r = repo ?? new Repo();
|
|
40
|
+
const validate = nodeValidator ?? defaultNodeValidator;
|
|
41
|
+
let allPassed = true;
|
|
42
|
+
const progressPath = r.progressPath() ?? r.preferredProgressPath();
|
|
43
|
+
const frameworkVersionPath = r.frameworkVersionPath();
|
|
44
|
+
|
|
45
|
+
console.log("Context Tree Verification\n");
|
|
46
|
+
|
|
47
|
+
// Progress file check
|
|
48
|
+
const unchecked = checkProgress(r);
|
|
49
|
+
if (unchecked.length > 0) {
|
|
50
|
+
console.log(` Unchecked items in ${progressPath}:\n`);
|
|
51
|
+
for (const item of unchecked) {
|
|
52
|
+
console.log(` - [ ] ${item}`);
|
|
53
|
+
}
|
|
54
|
+
console.log();
|
|
55
|
+
console.log(
|
|
56
|
+
` Verify each step above and check it off in ${progressPath} before running verify again.\n`,
|
|
57
|
+
);
|
|
58
|
+
allPassed = false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Deterministic checks
|
|
62
|
+
console.log(" Checks:\n");
|
|
63
|
+
|
|
64
|
+
// 1. Framework exists
|
|
65
|
+
allPassed = check(`${frameworkVersionPath} exists`, r.hasFramework()) && allPassed;
|
|
66
|
+
|
|
67
|
+
// 2. Root NODE.md has valid frontmatter
|
|
68
|
+
const fm = r.frontmatter("NODE.md");
|
|
69
|
+
const hasValidNode =
|
|
70
|
+
fm !== null && fm.title !== undefined && fm.owners !== undefined;
|
|
71
|
+
allPassed = check(
|
|
72
|
+
"Root NODE.md has valid frontmatter (title, owners)",
|
|
73
|
+
hasValidNode,
|
|
74
|
+
) && allPassed;
|
|
75
|
+
|
|
76
|
+
// 3. AGENT.md exists with framework markers
|
|
77
|
+
allPassed = check(
|
|
78
|
+
"AGENT.md exists with framework markers",
|
|
79
|
+
r.hasAgentMdMarkers(),
|
|
80
|
+
) && allPassed;
|
|
81
|
+
|
|
82
|
+
// 4. Node validation
|
|
83
|
+
const { exitCode } = validate(r.root);
|
|
84
|
+
allPassed = check("Node validation passes", exitCode === 0) && allPassed;
|
|
85
|
+
|
|
86
|
+
// 5. Member validation
|
|
87
|
+
const members = runValidateMembers(r.root);
|
|
88
|
+
allPassed = check("Member validation passes", members.exitCode === 0) && allPassed;
|
|
89
|
+
|
|
90
|
+
console.log();
|
|
91
|
+
if (allPassed) {
|
|
92
|
+
console.log("All checks passed.");
|
|
93
|
+
} else {
|
|
94
|
+
console.log("Some checks failed. See above for details.");
|
|
95
|
+
}
|
|
96
|
+
return allPassed ? 0 : 1;
|
|
97
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "About Context Tree"
|
|
3
|
+
owners: []
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# About Context Tree
|
|
7
|
+
|
|
8
|
+
**context-tree.ai** — The living source of truth for your organization.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## The Problem
|
|
13
|
+
|
|
14
|
+
Organizations generate decisions constantly — in PRs, meetings, Slack threads, documents. But none of these systems stay current. PRs get merged, issues get closed, documents decay. The knowledge that produced them scatters and disappears.
|
|
15
|
+
|
|
16
|
+
When an agent — or a new teammate — needs to understand *why* something was built a certain way, there's nowhere to look. The information existed once, but no system kept it alive.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## The Idea
|
|
21
|
+
|
|
22
|
+
Context Tree is the **living source of truth** for an organization — a tree-structured knowledge base that agents and humans build and maintain together.
|
|
23
|
+
|
|
24
|
+
Every node represents a domain, decision, or design. Every node has an **owner**. When a decision is made, it is written to the tree. When things change, the tree updates. The tree is never a snapshot — it's the current state.
|
|
25
|
+
|
|
26
|
+
The result is an organization where:
|
|
27
|
+
|
|
28
|
+
- Every agent and every human reads from the same, always-current source
|
|
29
|
+
- Decisions are traceable — the *what*, the *why*, and who owns it
|
|
30
|
+
- Knowledge compounds over time instead of evaporating
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Who It's For
|
|
35
|
+
|
|
36
|
+
Agent-centric teams — founders, engineers, and product builders who work alongside AI agents every day and want their organizational knowledge to grow with them, not against them.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Maintainer Architecture
|
|
2
|
+
|
|
3
|
+
This reference explains how to maintain the `first-tree` source repo itself.
|
|
4
|
+
|
|
5
|
+
## What This Repo Ships
|
|
6
|
+
|
|
7
|
+
- One canonical skill: `skills/first-tree/`
|
|
8
|
+
- One thin CLI package: the `context-tree` command distributed by the `first-tree`
|
|
9
|
+
npm package
|
|
10
|
+
- The published package carries that canonical skill directly; normal install
|
|
11
|
+
and upgrade flows should not depend on cloning this source repo
|
|
12
|
+
|
|
13
|
+
This repo is not a user context tree. User decision content lives in the repos
|
|
14
|
+
that install the framework.
|
|
15
|
+
|
|
16
|
+
## Canonical Layers
|
|
17
|
+
|
|
18
|
+
1. `SKILL.md` defines when to use the skill and the maintainer workflow.
|
|
19
|
+
2. `references/` stores the knowledge an agent needs to maintain the framework
|
|
20
|
+
and the thin CLI without reading repo-local prose.
|
|
21
|
+
3. `assets/framework/` stores the runtime payload that gets installed into user
|
|
22
|
+
repos.
|
|
23
|
+
4. `engine/` stores the canonical framework and CLI behavior.
|
|
24
|
+
5. `tests/` store the canonical skill validation surface.
|
|
25
|
+
6. The root repo may also keep maintainer-only developer tooling such as
|
|
26
|
+
`evals/` when that tooling should not ship with the skill.
|
|
27
|
+
7. The root CLI/package files are implementation shell code. They should call
|
|
28
|
+
into the skill-owned engine and validation surface, not become a second
|
|
29
|
+
source of framework knowledge.
|
|
30
|
+
|
|
31
|
+
## Non-Negotiables
|
|
32
|
+
|
|
33
|
+
- Treat `skills/first-tree/` as the only canonical source.
|
|
34
|
+
- If a maintainer needs information to safely change behavior, move that
|
|
35
|
+
information into `references/`; do not leave it only in root `README.md`,
|
|
36
|
+
`AGENT.md`, CI comments, or PR descriptions.
|
|
37
|
+
- Keep runtime assets generic. They are copied into every user tree.
|
|
38
|
+
- Keep the CLI thin. Command semantics, upgrade rules, layout contracts, and
|
|
39
|
+
maintainer guidance should belong to the skill.
|
|
40
|
+
- Keep the user tree decision-focused. Execution detail stays in source systems.
|
|
41
|
+
|
|
42
|
+
## Change Discipline
|
|
43
|
+
|
|
44
|
+
- Path or layout changes: update `references/upgrade-contract.md`, task text,
|
|
45
|
+
validators, and tests together.
|
|
46
|
+
- Shipped payload changes: update `assets/framework/`, the maintainer references
|
|
47
|
+
that describe the contract, and the validation surface together.
|
|
48
|
+
- Thin shell changes: update the relevant maintainer reference before or during
|
|
49
|
+
the code change so the skill remains self-sufficient.
|
|
50
|
+
|
|
51
|
+
## End-State Target
|
|
52
|
+
|
|
53
|
+
- skill owns knowledge, runtime payload, framework engine, and the canonical
|
|
54
|
+
framework test surface
|
|
55
|
+
- root owns only the light CLI/bootstrap/build shell plus maintainer-only
|
|
56
|
+
developer tooling such as `evals/`
|
|
57
|
+
|
|
58
|
+
When deciding where a new file should live, bias toward the skill unless the
|
|
59
|
+
file is purely package-tooling shell code.
|