@andares/pi-toolkits 0.9.8 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -5
- package/README.zh-CN.md +102 -5
- package/package.json +8 -3
- package/src/features/init/analyze.ts +162 -0
- package/src/features/init/apply.test.ts +264 -0
- package/src/features/init/apply.ts +693 -0
- package/src/features/init/audit.test.ts +292 -0
- package/src/features/init/audit.ts +586 -0
- package/src/features/init/coverage.test.ts +93 -0
- package/src/features/init/coverage.ts +163 -0
- package/src/features/init/detect.test.ts +237 -0
- package/src/features/init/detect.ts +808 -0
- package/src/features/init/dispatch.ts +135 -0
- package/src/features/init/index.test.ts +331 -0
- package/src/features/init/index.ts +569 -0
- package/src/features/init/plan.test.ts +261 -0
- package/src/features/init/plan.ts +867 -0
- package/src/features/init/render.ts +131 -0
- package/src/features/init/spec.ts +429 -0
- package/src/features/init/templates/agents-skeleton.md +44 -0
- package/src/features/init/templates/memory-readme.md +99 -0
- package/src/features/init/templates/signpost.md +15 -0
- package/src/features/init/templates/state-gitignore +6 -0
- package/src/features/init/templates.test.ts +58 -0
- package/src/features/init/templates.ts +79 -0
- package/src/features/init/test-helpers.ts +89 -0
- package/src/features/init/tool.ts +72 -0
- package/src/features/init/types.ts +284 -0
- package/src/features/init/ui.ts +160 -0
- package/src/features/permission/audit.test.ts +159 -0
- package/src/features/permission/audit.ts +145 -0
- package/src/features/permission/index.test.ts +138 -3
- package/src/features/permission/index.ts +108 -12
- package/src/features/permission/policy.ts +39 -3
- package/src/index.ts +9 -2
- package/src/lib/approval-log.test.ts +289 -0
- package/src/lib/approval-log.ts +724 -0
- package/src/lib/gates.audit.test.ts +343 -0
- package/src/lib/gates.ts +291 -10
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init feature — read-only survey of a project.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a fact read from disk, with no judgement: the plan and the
|
|
5
|
+
* audit decide what those facts mean. Two rules shape the code:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Bounded** — the survey must never stall the command on a huge repo, so
|
|
8
|
+
* the walk is capped, ignored dirs are explicit, and oversized files are
|
|
9
|
+
* read for headings only.
|
|
10
|
+
* 2. **Reproducible** — the reference scan used for "N occurrences in M files"
|
|
11
|
+
* is the same predicate the rewrite uses. Counts and edits can therefore be
|
|
12
|
+
* reconciled instead of trusted.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import {
|
|
16
|
+
existsSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
statSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { join, posix, relative, sep } from "node:path";
|
|
22
|
+
import { parse as parseToml } from "smol-toml";
|
|
23
|
+
import {
|
|
24
|
+
AGENTS_DIR,
|
|
25
|
+
DEPRECATED_ROOT_DIRS,
|
|
26
|
+
FRAMEWORK_DIR,
|
|
27
|
+
FRAMEWORK_VERSION,
|
|
28
|
+
IMPLEMENTATIONS_DIR,
|
|
29
|
+
LEGACY_DOC_NAMES,
|
|
30
|
+
MEMORY_DIR,
|
|
31
|
+
OVERRIDE_FILE,
|
|
32
|
+
PROMPTS_DIR,
|
|
33
|
+
RETIRED_ROOT_DIRS,
|
|
34
|
+
SCAN,
|
|
35
|
+
SPECS_DIR,
|
|
36
|
+
STATE_FILE,
|
|
37
|
+
isMemoryFileName,
|
|
38
|
+
type ProjectLanguage,
|
|
39
|
+
} from "./spec.js";
|
|
40
|
+
import type {
|
|
41
|
+
DocInfo,
|
|
42
|
+
Facts,
|
|
43
|
+
FileInfo,
|
|
44
|
+
FrameworkState,
|
|
45
|
+
Heading,
|
|
46
|
+
ProjectFacts,
|
|
47
|
+
RefArea,
|
|
48
|
+
RefScan,
|
|
49
|
+
RootInfo,
|
|
50
|
+
} from "./types.js";
|
|
51
|
+
|
|
52
|
+
/* -------------------------------------------------------------------------- */
|
|
53
|
+
/* Small filesystem helpers */
|
|
54
|
+
/* -------------------------------------------------------------------------- */
|
|
55
|
+
|
|
56
|
+
function toPosix(path: string): string {
|
|
57
|
+
return sep === "/" ? path : path.split(sep).join("/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function statFile(root: string, rel: string): FileInfo | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const absolute = join(root, rel);
|
|
63
|
+
const stat = statSync(absolute);
|
|
64
|
+
if (!stat.isFile()) return undefined;
|
|
65
|
+
let lines = 0;
|
|
66
|
+
if (stat.size <= SCAN.maxFileBytes) {
|
|
67
|
+
const text = readFileSync(absolute, "utf8");
|
|
68
|
+
lines = countLines(text);
|
|
69
|
+
}
|
|
70
|
+
return { rel, bytes: stat.size, lines, mtimeMs: stat.mtimeMs };
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function countLines(text: string): number {
|
|
77
|
+
if (text.length === 0) return 0;
|
|
78
|
+
let lines = 1;
|
|
79
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
80
|
+
if (text.charCodeAt(i) === 10) lines += 1;
|
|
81
|
+
}
|
|
82
|
+
return lines;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readTextFile(root: string, rel: string): string | undefined {
|
|
86
|
+
try {
|
|
87
|
+
const absolute = join(root, rel);
|
|
88
|
+
const stat = statSync(absolute);
|
|
89
|
+
if (!stat.isFile()) return undefined;
|
|
90
|
+
const buffer = readFileSync(absolute);
|
|
91
|
+
// Binary check: a NUL in the first 8 KiB is enough to classify.
|
|
92
|
+
const probe = buffer.subarray(0, 8192);
|
|
93
|
+
if (probe.includes(0)) return undefined;
|
|
94
|
+
const limit = SCAN.maxFileBytes;
|
|
95
|
+
const text =
|
|
96
|
+
buffer.length > limit
|
|
97
|
+
? buffer.subarray(0, limit).toString("utf8")
|
|
98
|
+
: buffer.toString("utf8");
|
|
99
|
+
return text;
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Headings outside fenced code blocks, with 1-based line numbers. */
|
|
106
|
+
export function parseHeadings(text: string): Heading[] {
|
|
107
|
+
const headings: Heading[] = [];
|
|
108
|
+
let fence: string | undefined;
|
|
109
|
+
const lines = text.split("\n");
|
|
110
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
111
|
+
const line = lines[index];
|
|
112
|
+
const fenceMatch = /^\s*(```+|~~~+)/.exec(line);
|
|
113
|
+
if (fenceMatch) {
|
|
114
|
+
const marker = fenceMatch[1][0];
|
|
115
|
+
if (!fence) fence = marker;
|
|
116
|
+
else if (fence === marker) fence = undefined;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (fence) continue;
|
|
120
|
+
const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
121
|
+
if (match) {
|
|
122
|
+
headings.push({ level: match[1].length, text: match[2].trim(), line: index + 1 });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return headings;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readDoc(root: string, rel: string): DocInfo | undefined {
|
|
129
|
+
const text = readTextFile(root, rel);
|
|
130
|
+
if (text === undefined) return undefined;
|
|
131
|
+
const info = statFile(root, rel);
|
|
132
|
+
if (!info) return undefined;
|
|
133
|
+
return { ...info, text, headings: parseHeadings(text) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function listDir(root: string, rel: string): string[] {
|
|
137
|
+
try {
|
|
138
|
+
return readdirSync(join(root, rel)).sort();
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isDir(root: string, rel: string): boolean {
|
|
145
|
+
try {
|
|
146
|
+
return statSync(join(root, rel)).isDirectory();
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isFile(root: string, rel: string): boolean {
|
|
153
|
+
try {
|
|
154
|
+
return statSync(join(root, rel)).isFile();
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* -------------------------------------------------------------------------- */
|
|
161
|
+
/* Tree walk */
|
|
162
|
+
/* -------------------------------------------------------------------------- */
|
|
163
|
+
|
|
164
|
+
export interface WalkEntry {
|
|
165
|
+
readonly rel: string;
|
|
166
|
+
readonly bytes: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Walk `root` with the shared ignore list. Directories listed in `excludePrefix`
|
|
171
|
+
* are skipped entirely (the framework's own runtime dir must never be scanned:
|
|
172
|
+
* snapshots are copies, reports quote old paths).
|
|
173
|
+
*/
|
|
174
|
+
export function walk(
|
|
175
|
+
root: string,
|
|
176
|
+
options: { excludePrefix?: string[]; maxDepth?: number; maxFiles?: number } = {},
|
|
177
|
+
): WalkEntry[] {
|
|
178
|
+
const out: WalkEntry[] = [];
|
|
179
|
+
const maxDepth = options.maxDepth ?? SCAN.maxDepth;
|
|
180
|
+
const maxFiles = options.maxFiles ?? SCAN.maxFiles;
|
|
181
|
+
const excluded = options.excludePrefix ?? [];
|
|
182
|
+
|
|
183
|
+
const visit = (rel: string, depth: number): boolean => {
|
|
184
|
+
if (out.length >= maxFiles) return false;
|
|
185
|
+
let names: string[];
|
|
186
|
+
try {
|
|
187
|
+
names = readdirSync(join(root, rel), { withFileTypes: true })
|
|
188
|
+
.map((entry) => `${entry.isDirectory() ? "d:" : "f:"}${entry.name}`)
|
|
189
|
+
.sort();
|
|
190
|
+
} catch {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
for (const tagged of names) {
|
|
194
|
+
const kind = tagged.slice(0, 2);
|
|
195
|
+
const name = tagged.slice(2);
|
|
196
|
+
const child = rel === "" ? name : `${rel}/${name}`;
|
|
197
|
+
if (excluded.some((prefix) => child === prefix || child.startsWith(`${prefix}/`))) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (kind === "d:") {
|
|
201
|
+
if (SCAN.ignoreDirs.has(name)) continue;
|
|
202
|
+
if (depth >= maxDepth) continue;
|
|
203
|
+
if (!visit(child, depth + 1)) return false;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
let bytes = 0;
|
|
207
|
+
try {
|
|
208
|
+
bytes = statSync(join(root, child)).size;
|
|
209
|
+
} catch {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
out.push({ rel: child, bytes });
|
|
213
|
+
if (out.length >= maxFiles) return false;
|
|
214
|
+
}
|
|
215
|
+
return true;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
visit("", 0);
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function isTextExtension(rel: string): boolean {
|
|
223
|
+
const dot = rel.lastIndexOf(".");
|
|
224
|
+
if (dot < 0) return false;
|
|
225
|
+
return SCAN.refExtensions.has(rel.slice(dot).toLowerCase());
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* -------------------------------------------------------------------------- */
|
|
229
|
+
/* Reference scan */
|
|
230
|
+
/* -------------------------------------------------------------------------- */
|
|
231
|
+
|
|
232
|
+
const REF_NAMES = ["memory/", "implementations/"] as const;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* True when the occurrence at `index` is a *bare* reference: not already under
|
|
236
|
+
* `.agents/`, not a longer hyphenated name (`my-memory/`), and not the tail of
|
|
237
|
+
* somebody else's absolute path (`~/.pi/agent/memory/`).
|
|
238
|
+
*
|
|
239
|
+
* Relative prefixes are part of the reference, not a reason to skip it:
|
|
240
|
+
* `../memory/X.md` and `./memory/X.md` must be rewritten just like `memory/X.md`
|
|
241
|
+
* (the prefix is preserved, only the directory name changes).
|
|
242
|
+
*/
|
|
243
|
+
export function isBareRef(text: string, index: number, name: string): boolean {
|
|
244
|
+
if (text.startsWith(name, index) === false) return false;
|
|
245
|
+
const before = text.slice(Math.max(0, index - 32), index);
|
|
246
|
+
if (before.endsWith(".agents/")) return false;
|
|
247
|
+
const prev = index > 0 ? text[index - 1] : "";
|
|
248
|
+
if (prev === "/") {
|
|
249
|
+
const prefix = text.slice(0, index);
|
|
250
|
+
// Only `./` and one or more `../` count as a reference prefix. Anything
|
|
251
|
+
// else (`agent/memory/`, `/memory/`) is a different path altogether.
|
|
252
|
+
return /(?:\.\.\/)+$/.test(prefix) || /\.\/$/.test(prefix);
|
|
253
|
+
}
|
|
254
|
+
// `-` continues a compound name (`my-memory/`); letters, digits and `.` are
|
|
255
|
+
// the same story (`foo.memory/`).
|
|
256
|
+
if (prev && /[A-Za-z0-9_.-]/.test(prev)) return false;
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Count bare occurrences of `name` in `text`. */
|
|
261
|
+
export function countBareRefs(text: string, name: string): number {
|
|
262
|
+
let count = 0;
|
|
263
|
+
let index = text.indexOf(name);
|
|
264
|
+
while (index >= 0) {
|
|
265
|
+
if (isBareRef(text, index, name)) count += 1;
|
|
266
|
+
index = text.indexOf(name, index + name.length);
|
|
267
|
+
}
|
|
268
|
+
return count;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function areaOf(rel: string): string {
|
|
272
|
+
const slash = rel.indexOf("/");
|
|
273
|
+
return slash < 0 ? "(root)" : rel.slice(0, slash);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function scanRefs(root: string): RefScan {
|
|
277
|
+
const entries = walk(root, { excludePrefix: [FRAMEWORK_DIR] });
|
|
278
|
+
const byArea: Record<string, RefArea> = {};
|
|
279
|
+
const examples: RefScan["examples"] = [];
|
|
280
|
+
const paths: string[] = [];
|
|
281
|
+
let files = 0;
|
|
282
|
+
let hits = 0;
|
|
283
|
+
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
if (entry.bytes === 0 || entry.bytes > SCAN.maxFileBytes) continue;
|
|
286
|
+
if (!isTextExtension(entry.rel)) continue;
|
|
287
|
+
const text = readTextFile(root, entry.rel);
|
|
288
|
+
if (text === undefined) continue;
|
|
289
|
+
files += 1;
|
|
290
|
+
let fileHits = 0;
|
|
291
|
+
for (const name of REF_NAMES) fileHits += countBareRefs(text, name);
|
|
292
|
+
if (fileHits === 0) continue;
|
|
293
|
+
hits += fileHits;
|
|
294
|
+
paths.push(entry.rel);
|
|
295
|
+
const area = areaOf(entry.rel);
|
|
296
|
+
const bucket = (byArea[area] ??= { files: 0, hits: 0 });
|
|
297
|
+
bucket.files += 1;
|
|
298
|
+
bucket.hits += fileHits;
|
|
299
|
+
if (examples.length < 8) {
|
|
300
|
+
const line = lineOfFirstRef(text);
|
|
301
|
+
examples.push({
|
|
302
|
+
path: entry.rel,
|
|
303
|
+
line,
|
|
304
|
+
text: firstRefLine(text) ?? "",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { files, hits, byArea, examples, paths };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function lineOfFirstRef(text: string): number {
|
|
313
|
+
const lines = text.split("\n");
|
|
314
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
315
|
+
for (const name of REF_NAMES) {
|
|
316
|
+
let at = lines[index].indexOf(name);
|
|
317
|
+
while (at >= 0) {
|
|
318
|
+
if (isBareRef(lines[index], at, name)) return index + 1;
|
|
319
|
+
at = lines[index].indexOf(name, at + name.length);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function firstRefLine(text: string): string | undefined {
|
|
327
|
+
const lines = text.split("\n");
|
|
328
|
+
for (const line of lines) {
|
|
329
|
+
for (const name of REF_NAMES) {
|
|
330
|
+
let at = line.indexOf(name);
|
|
331
|
+
while (at >= 0) {
|
|
332
|
+
if (isBareRef(line, at, name)) return line.trim().slice(0, 160);
|
|
333
|
+
at = line.indexOf(name, at + name.length);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* -------------------------------------------------------------------------- */
|
|
341
|
+
/* Git */
|
|
342
|
+
/* -------------------------------------------------------------------------- */
|
|
343
|
+
|
|
344
|
+
function git(args: string[], root: string): string | undefined {
|
|
345
|
+
try {
|
|
346
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
347
|
+
encoding: "utf8",
|
|
348
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
349
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
350
|
+
});
|
|
351
|
+
} catch {
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function gitInfo(root: string): Pick<Facts, "isGit" | "gitClean" | "gitDirtyCount"> {
|
|
357
|
+
const inside = git(["rev-parse", "--is-inside-work-tree"], root);
|
|
358
|
+
if (inside?.trim() !== "true") return { isGit: false };
|
|
359
|
+
const status = git(["status", "--porcelain"], root);
|
|
360
|
+
if (status === undefined) return { isGit: true };
|
|
361
|
+
const lines = status.split("\n").filter((line) => line.length > 0);
|
|
362
|
+
return { isGit: true, gitClean: lines.length === 0, gitDirtyCount: lines.length };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* -------------------------------------------------------------------------- */
|
|
366
|
+
/* State */
|
|
367
|
+
/* -------------------------------------------------------------------------- */
|
|
368
|
+
|
|
369
|
+
export function loadState(root: string): { state?: FrameworkState; error?: string } {
|
|
370
|
+
if (!existsSync(join(root, STATE_FILE))) return {};
|
|
371
|
+
try {
|
|
372
|
+
const raw = readFileSync(join(root, STATE_FILE), "utf8");
|
|
373
|
+
return { state: parseState(raw) };
|
|
374
|
+
} catch (error) {
|
|
375
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Defensive reader for our own state file: unknown or missing fields fall back
|
|
381
|
+
* instead of throwing, so a partially hand-edited state file still produces a
|
|
382
|
+
* plan (and a warning) rather than a dead command.
|
|
383
|
+
*/
|
|
384
|
+
export function parseState(raw: string): FrameworkState {
|
|
385
|
+
const data = parseToml(raw) as Record<string, unknown>;
|
|
386
|
+
const version = Number(data.version ?? 0);
|
|
387
|
+
const tool = String(data.tool ?? "");
|
|
388
|
+
const created = String(data.created ?? "");
|
|
389
|
+
const updated = String(data.updated ?? created);
|
|
390
|
+
const profile = data.profile === "full" ? "full" : "core";
|
|
391
|
+
const modules = Array.isArray(data.modules) ? data.modules.map(String) : [];
|
|
392
|
+
const lang = data.lang === "zh" ? "zh" : "en";
|
|
393
|
+
const budgets = data.budgets as Record<string, unknown> | undefined;
|
|
394
|
+
const roots = Array.isArray(data.roots)
|
|
395
|
+
? (data.roots as Array<Record<string, unknown>>).map((entry) => ({
|
|
396
|
+
label: String(entry.label ?? ""),
|
|
397
|
+
path: String(entry.path ?? ""),
|
|
398
|
+
absolute: String(entry.absolute ?? ""),
|
|
399
|
+
role: entry.role === undefined ? undefined : String(entry.role),
|
|
400
|
+
agentsMd:
|
|
401
|
+
entry.agents_md === "scope-card" ||
|
|
402
|
+
entry.agents_md === "independent" ||
|
|
403
|
+
entry.agents_md === "none"
|
|
404
|
+
? (entry.agents_md as RootInfo["agentsMd"])
|
|
405
|
+
: "unknown",
|
|
406
|
+
source: "state" as const,
|
|
407
|
+
exists: true,
|
|
408
|
+
lastSeen: entry.last_seen === undefined ? undefined : String(entry.last_seen),
|
|
409
|
+
}))
|
|
410
|
+
: [];
|
|
411
|
+
const managed = Array.isArray(data.managed)
|
|
412
|
+
? (data.managed as Array<Record<string, unknown>>).map((entry) => ({
|
|
413
|
+
path: String(entry.path ?? ""),
|
|
414
|
+
sha256: String(entry.sha256 ?? ""),
|
|
415
|
+
template: String(entry.template ?? ""),
|
|
416
|
+
}))
|
|
417
|
+
: [];
|
|
418
|
+
const migrations = Array.isArray(data.migrations)
|
|
419
|
+
? (data.migrations as Array<Record<string, unknown>>).map((entry) => ({
|
|
420
|
+
at: String(entry.at ?? ""),
|
|
421
|
+
to: String(entry.to ?? ""),
|
|
422
|
+
notes: entry.notes === undefined ? undefined : String(entry.notes),
|
|
423
|
+
}))
|
|
424
|
+
: [];
|
|
425
|
+
return {
|
|
426
|
+
version: Number.isFinite(version) ? version : 0,
|
|
427
|
+
tool,
|
|
428
|
+
created,
|
|
429
|
+
updated,
|
|
430
|
+
profile,
|
|
431
|
+
modules,
|
|
432
|
+
lang,
|
|
433
|
+
budgets: budgets
|
|
434
|
+
? {
|
|
435
|
+
agentsMdSoftChars: numberOrUndefined(
|
|
436
|
+
budgets.agents_md_soft_chars ?? budgets.agentsMdSoftChars,
|
|
437
|
+
),
|
|
438
|
+
agentsMdHardChars: numberOrUndefined(
|
|
439
|
+
budgets.agents_md_hard_chars ?? budgets.agentsMdHardChars,
|
|
440
|
+
),
|
|
441
|
+
memoryFileChars: numberOrUndefined(
|
|
442
|
+
budgets.memory_file_chars ?? budgets.memoryFileChars,
|
|
443
|
+
),
|
|
444
|
+
}
|
|
445
|
+
: undefined,
|
|
446
|
+
roots,
|
|
447
|
+
managed,
|
|
448
|
+
migrations,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function numberOrUndefined(value: unknown): number | undefined {
|
|
453
|
+
if (value === undefined || value === null) return undefined;
|
|
454
|
+
const parsed = Number(value);
|
|
455
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* -------------------------------------------------------------------------- */
|
|
459
|
+
/* Language */
|
|
460
|
+
/* -------------------------------------------------------------------------- */
|
|
461
|
+
|
|
462
|
+
export function detectLanguage(
|
|
463
|
+
root: string,
|
|
464
|
+
fallback: ProjectLanguage = "en",
|
|
465
|
+
): ProjectLanguage {
|
|
466
|
+
const candidates = ["AGENTS.md", "README.md", "README.zh-CN.md", "readme.md"];
|
|
467
|
+
for (const candidate of candidates) {
|
|
468
|
+
const text = readTextFile(root, candidate);
|
|
469
|
+
if (!text) continue;
|
|
470
|
+
const sample = text.slice(0, 20_000);
|
|
471
|
+
let cjk = 0;
|
|
472
|
+
let visible = 0;
|
|
473
|
+
for (const char of sample) {
|
|
474
|
+
const code = char.codePointAt(0) ?? 0;
|
|
475
|
+
if (code <= 32) continue;
|
|
476
|
+
visible += 1;
|
|
477
|
+
if (code >= 0x4e00 && code <= 0x9fff) cjk += 1;
|
|
478
|
+
}
|
|
479
|
+
if (visible === 0) continue;
|
|
480
|
+
return cjk / visible >= 0.08 ? "zh" : "en";
|
|
481
|
+
}
|
|
482
|
+
return fallback;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/* -------------------------------------------------------------------------- */
|
|
486
|
+
/* Project facts */
|
|
487
|
+
/* -------------------------------------------------------------------------- */
|
|
488
|
+
|
|
489
|
+
const MANIFESTS = [
|
|
490
|
+
"package.json",
|
|
491
|
+
"Cargo.toml",
|
|
492
|
+
"pyproject.toml",
|
|
493
|
+
"go.mod",
|
|
494
|
+
"pom.xml",
|
|
495
|
+
"build.gradle",
|
|
496
|
+
"composer.json",
|
|
497
|
+
"Gemfile",
|
|
498
|
+
"mix.exs",
|
|
499
|
+
];
|
|
500
|
+
|
|
501
|
+
const LOCKFILES: Array<[string, string]> = [
|
|
502
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
503
|
+
["package-lock.json", "npm"],
|
|
504
|
+
["yarn.lock", "yarn"],
|
|
505
|
+
["bun.lockb", "bun"],
|
|
506
|
+
["Cargo.lock", "cargo"],
|
|
507
|
+
["uv.lock", "uv"],
|
|
508
|
+
["poetry.lock", "poetry"],
|
|
509
|
+
["Pipfile.lock", "pipenv"],
|
|
510
|
+
["go.sum", "go"],
|
|
511
|
+
];
|
|
512
|
+
|
|
513
|
+
function detectProject(root: string): ProjectFacts {
|
|
514
|
+
const manifests = MANIFESTS.filter((name) => isFile(root, name));
|
|
515
|
+
const lockfiles: string[] = [];
|
|
516
|
+
let pkgManager: string | undefined;
|
|
517
|
+
for (const [name, manager] of LOCKFILES) {
|
|
518
|
+
if (!isFile(root, name)) continue;
|
|
519
|
+
lockfiles.push(name);
|
|
520
|
+
pkgManager ??= manager;
|
|
521
|
+
}
|
|
522
|
+
const licenseName = listDir(root, "").find((name) =>
|
|
523
|
+
/^(LICENSE|LICENCE|COPYING)(\..*)?$/i.test(name),
|
|
524
|
+
);
|
|
525
|
+
const ci: string[] = [];
|
|
526
|
+
if (isDir(root, ".github/workflows")) {
|
|
527
|
+
for (const name of listDir(root, ".github/workflows")) {
|
|
528
|
+
if (/\.ya?ml$/i.test(name)) ci.push(`.github/workflows/${name}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (isFile(root, ".gitlab-ci.yml")) ci.push(".gitlab-ci.yml");
|
|
532
|
+
if (isFile(root, "azure-pipelines.yml")) ci.push("azure-pipelines.yml");
|
|
533
|
+
if (isFile(root, "Jenkinsfile")) ci.push("Jenkinsfile");
|
|
534
|
+
|
|
535
|
+
const scripts: Record<string, string> = {};
|
|
536
|
+
const pkg = readTextFile(root, "package.json");
|
|
537
|
+
if (pkg) {
|
|
538
|
+
try {
|
|
539
|
+
const parsed = JSON.parse(pkg) as {
|
|
540
|
+
scripts?: Record<string, string>;
|
|
541
|
+
packageManager?: string;
|
|
542
|
+
};
|
|
543
|
+
for (const [key, value] of Object.entries(parsed.scripts ?? {})) {
|
|
544
|
+
if (typeof value === "string") scripts[key] = value;
|
|
545
|
+
}
|
|
546
|
+
if (parsed.packageManager) {
|
|
547
|
+
pkgManager = parsed.packageManager.split("@")[0];
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
// A malformed manifest is a project problem, not an init failure.
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return { manifests, lockfiles, license: licenseName, ci, scripts, pkgManager };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/* -------------------------------------------------------------------------- */
|
|
558
|
+
/* Roots */
|
|
559
|
+
/* -------------------------------------------------------------------------- */
|
|
560
|
+
|
|
561
|
+
function classifyRootAgentsMd(root: string, absolute: string): RootInfo["agentsMd"] {
|
|
562
|
+
const rel = toPosix(relative(root, absolute));
|
|
563
|
+
const agentsRel = rel === "" ? "AGENTS.md" : posix.join(rel, "AGENTS.md");
|
|
564
|
+
if (!isFile(root, agentsRel)) return "none";
|
|
565
|
+
const text = readTextFile(root, agentsRel) ?? "";
|
|
566
|
+
const lines = text.split("\n").length;
|
|
567
|
+
// A secondary root that already carries the framework contract owns its own
|
|
568
|
+
// memory; we must not treat it as a scope card.
|
|
569
|
+
if (text.includes(".agents/memory") || text.includes("agent-framework")) {
|
|
570
|
+
return "independent";
|
|
571
|
+
}
|
|
572
|
+
return lines <= 40 ? "scope-card" : "independent";
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function mergeRoots(
|
|
576
|
+
root: string,
|
|
577
|
+
stateRoots: RootInfo[],
|
|
578
|
+
added: Array<{ label: string; absolute: string }>,
|
|
579
|
+
): RootInfo[] {
|
|
580
|
+
const byAbsolute = new Map<string, RootInfo>();
|
|
581
|
+
for (const entry of stateRoots) {
|
|
582
|
+
byAbsolute.set(entry.absolute, {
|
|
583
|
+
...entry,
|
|
584
|
+
exists: existsSync(entry.absolute),
|
|
585
|
+
agentsMd: classifyRootAgentsMd(root, entry.absolute),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
for (const entry of added) {
|
|
589
|
+
const rel = toPosix(relative(root, entry.absolute));
|
|
590
|
+
const inside = !rel.startsWith("..");
|
|
591
|
+
byAbsolute.set(entry.absolute, {
|
|
592
|
+
label: entry.label,
|
|
593
|
+
path: inside ? rel : entry.absolute,
|
|
594
|
+
absolute: entry.absolute,
|
|
595
|
+
agentsMd: classifyRootAgentsMd(root, entry.absolute),
|
|
596
|
+
source: "add-dir",
|
|
597
|
+
exists: existsSync(entry.absolute),
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
return [...byAbsolute.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/* -------------------------------------------------------------------------- */
|
|
604
|
+
/* The survey */
|
|
605
|
+
/* -------------------------------------------------------------------------- */
|
|
606
|
+
|
|
607
|
+
export interface DetectOptions {
|
|
608
|
+
/** Absolute paths added via pi-add-dir in this session. */
|
|
609
|
+
readonly addedRoots?: Array<{ label: string; absolute: string }>;
|
|
610
|
+
/** Language override (`--lang`). */
|
|
611
|
+
readonly lang?: ProjectLanguage;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function readDocs(root: string, rel: string, keep: (name: string) => boolean): DocInfo[] {
|
|
615
|
+
const docs: DocInfo[] = [];
|
|
616
|
+
for (const name of listDir(root, rel)) {
|
|
617
|
+
if (!name.endsWith(".md") || !keep(name)) continue;
|
|
618
|
+
const doc = readDoc(root, `${rel}/${name}`);
|
|
619
|
+
if (doc) docs.push(doc);
|
|
620
|
+
}
|
|
621
|
+
return docs;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function readFiles(root: string, rel: string): FileInfo[] {
|
|
625
|
+
const files: FileInfo[] = [];
|
|
626
|
+
for (const name of listDir(root, rel)) {
|
|
627
|
+
if (!name.endsWith(".md")) continue;
|
|
628
|
+
const info = statFile(root, `${rel}/${name}`);
|
|
629
|
+
if (info) files.push(info);
|
|
630
|
+
}
|
|
631
|
+
return files;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function countFilesUnder(root: string, rel: string, limit = 5000): number {
|
|
635
|
+
let count = 0;
|
|
636
|
+
const visit = (current: string): void => {
|
|
637
|
+
if (count >= limit) return;
|
|
638
|
+
for (const name of listDir(root, current)) {
|
|
639
|
+
if (count >= limit) return;
|
|
640
|
+
const child = `${current}/${name}`;
|
|
641
|
+
if (isDir(root, child)) {
|
|
642
|
+
if (SCAN.ignoreDirs.has(name)) continue;
|
|
643
|
+
visit(child);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
count += 1;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
visit(rel);
|
|
650
|
+
return count;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function readDeprecated(root: string): {
|
|
654
|
+
deprecated: Facts["deprecated"];
|
|
655
|
+
deprecatedFiles: Facts["deprecatedFiles"];
|
|
656
|
+
retiredTodos: boolean;
|
|
657
|
+
} {
|
|
658
|
+
const deprecated = {
|
|
659
|
+
memory: isDir(root, DEPRECATED_ROOT_DIRS[0]),
|
|
660
|
+
implementations: isDir(root, DEPRECATED_ROOT_DIRS[1]),
|
|
661
|
+
prompts: isDir(root, DEPRECATED_ROOT_DIRS[2]),
|
|
662
|
+
};
|
|
663
|
+
return {
|
|
664
|
+
deprecated,
|
|
665
|
+
deprecatedFiles: {
|
|
666
|
+
memory: deprecated.memory ? countFilesUnder(root, "memory") : 0,
|
|
667
|
+
implementations: deprecated.implementations ? countFilesUnder(root, "implementations") : 0,
|
|
668
|
+
prompts: deprecated.prompts ? countFilesUnder(root, "prompts") : 0,
|
|
669
|
+
},
|
|
670
|
+
retiredTodos: isDir(root, RETIRED_ROOT_DIRS[0]),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function readLegacyDocs(root: string): DocInfo[] {
|
|
675
|
+
const docs: DocInfo[] = [];
|
|
676
|
+
for (const name of [...LEGACY_DOC_NAMES, ".github/copilot-instructions.md", ".cursorrules", ".windsurfrules"]) {
|
|
677
|
+
if (!isFile(root, name)) continue;
|
|
678
|
+
const doc = readDoc(root, name);
|
|
679
|
+
if (doc) docs.push(doc);
|
|
680
|
+
}
|
|
681
|
+
if (isDir(root, ".cursor/rules")) {
|
|
682
|
+
docs.push(...readDocs(root, ".cursor/rules", () => true));
|
|
683
|
+
}
|
|
684
|
+
for (const rel of ["adr", "decisions", "docs/adr", "docs/decisions"]) {
|
|
685
|
+
if (!isDir(root, rel)) continue;
|
|
686
|
+
docs.push(...readDocs(root, rel, () => true));
|
|
687
|
+
}
|
|
688
|
+
return docs;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function readNestedAgents(root: string): FileInfo[] {
|
|
692
|
+
const files: FileInfo[] = [];
|
|
693
|
+
for (const entry of walk(root, { maxDepth: 3 })) {
|
|
694
|
+
if (entry.rel === "AGENTS.md") continue;
|
|
695
|
+
if (!/(^|\/)(AGENTS|CLAUDE)\.md$/.test(entry.rel)) continue;
|
|
696
|
+
const info = statFile(root, entry.rel);
|
|
697
|
+
if (info) files.push(info);
|
|
698
|
+
}
|
|
699
|
+
return files;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function readModulesPresent(root: string, memory: readonly DocInfo[]): string[] {
|
|
703
|
+
return MEMORY_MODULES.filter((module) => {
|
|
704
|
+
if (module === "function") {
|
|
705
|
+
return memory.some((doc) => doc.rel.startsWith(".agents/memory/FUNCTION-"));
|
|
706
|
+
}
|
|
707
|
+
return isFile(root, `${MEMORY_DIR}/${module.toUpperCase()}.md`);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function readEmptyRootFiles(root: string): string[] {
|
|
712
|
+
return listDir(root, "").filter((name) => {
|
|
713
|
+
const info = statFile(root, name);
|
|
714
|
+
return info !== undefined && info.bytes === 0;
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function contextWarnings(
|
|
719
|
+
context: Facts["context"],
|
|
720
|
+
state: FrameworkState | undefined,
|
|
721
|
+
stateError: string | undefined,
|
|
722
|
+
): string[] {
|
|
723
|
+
const warnings: string[] = [];
|
|
724
|
+
if (stateError) warnings.push(`state file unreadable: ${stateError}`);
|
|
725
|
+
if (state && state.version > FRAMEWORK_VERSION) {
|
|
726
|
+
warnings.push(
|
|
727
|
+
`state version ${state.version} is newer than this tool (${FRAMEWORK_VERSION}); refusing to plan a migration`,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
if (context.override) {
|
|
731
|
+
warnings.push(
|
|
732
|
+
`${OVERRIDE_FILE} exists: pi loads it instead of AGENTS.md in this directory, so the framework entry is invisible to the agent`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
if (!context.agents && context.claude) {
|
|
736
|
+
warnings.push("only CLAUDE.md exists; AGENTS.md is the pi entry and will be created");
|
|
737
|
+
}
|
|
738
|
+
return warnings;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export function detect(root: string, options: DetectOptions = {}): Facts {
|
|
742
|
+
const { state, error: stateError } = loadState(root);
|
|
743
|
+
const context = {
|
|
744
|
+
agents: readDoc(root, "AGENTS.md"),
|
|
745
|
+
claude: readDoc(root, "CLAUDE.md"),
|
|
746
|
+
override: readDoc(root, OVERRIDE_FILE),
|
|
747
|
+
piAgents: readDoc(root, ".pi/AGENTS.md"),
|
|
748
|
+
};
|
|
749
|
+
const dirs: Record<string, boolean> = {};
|
|
750
|
+
for (const rel of [
|
|
751
|
+
AGENTS_DIR,
|
|
752
|
+
MEMORY_DIR,
|
|
753
|
+
IMPLEMENTATIONS_DIR,
|
|
754
|
+
PROMPTS_DIR,
|
|
755
|
+
FRAMEWORK_DIR,
|
|
756
|
+
SPECS_DIR,
|
|
757
|
+
"design",
|
|
758
|
+
"docs",
|
|
759
|
+
"legal",
|
|
760
|
+
]) {
|
|
761
|
+
dirs[rel] = isDir(root, rel);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const memory = readDocs(
|
|
765
|
+
root,
|
|
766
|
+
MEMORY_DIR,
|
|
767
|
+
// PLAN.md is not a memory file in v1, but an older install may still
|
|
768
|
+
// have one here; it must stay visible so the plan can move it to specs/.
|
|
769
|
+
(name) => isMemoryFileName(name) || name === "PLAN.md",
|
|
770
|
+
);
|
|
771
|
+
const legacyMemory = readDocs(root, "memory", () => true);
|
|
772
|
+
const specs = readDocs(root, SPECS_DIR, () => true);
|
|
773
|
+
const implementations = [
|
|
774
|
+
...readFiles(root, IMPLEMENTATIONS_DIR),
|
|
775
|
+
...readFiles(root, "implementations"),
|
|
776
|
+
];
|
|
777
|
+
const todos = readDocs(root, "todos", () => true);
|
|
778
|
+
const { deprecated, deprecatedFiles, retiredTodos } = readDeprecated(root);
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
root,
|
|
782
|
+
...gitInfo(root),
|
|
783
|
+
state,
|
|
784
|
+
stateError,
|
|
785
|
+
context,
|
|
786
|
+
dirs,
|
|
787
|
+
deprecated,
|
|
788
|
+
deprecatedFiles,
|
|
789
|
+
retiredTodos,
|
|
790
|
+
rootEmptyFiles: readEmptyRootFiles(root),
|
|
791
|
+
memory,
|
|
792
|
+
legacyMemory,
|
|
793
|
+
specs,
|
|
794
|
+
implementations,
|
|
795
|
+
todos,
|
|
796
|
+
legacyDocs: readLegacyDocs(root),
|
|
797
|
+
nestedAgents: readNestedAgents(root),
|
|
798
|
+
modulesPresent: readModulesPresent(root, memory),
|
|
799
|
+
refs: scanRefs(root),
|
|
800
|
+
project: detectProject(root),
|
|
801
|
+
language: options.lang ?? state?.lang ?? detectLanguage(root),
|
|
802
|
+
roots: mergeRoots(root, state?.roots ?? [], options.addedRoots ?? []),
|
|
803
|
+
warnings: contextWarnings(context, state, stateError),
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Module ids recognised inside `.agents/memory/`. */
|
|
808
|
+
const MEMORY_MODULES = ["design", "terms", "review", "debugging", "function"] as const;
|