@andares/pi-toolkits 0.10.0 → 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 +83 -0
- package/README.zh-CN.md +67 -0
- package/package.json +7 -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/index.ts +3 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init feature — the deterministic half of a run.
|
|
3
|
+
*
|
|
4
|
+
* Guarantees, in the order they are enforced:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Nothing is touched before it is snapshotted.** A copy of every path the
|
|
7
|
+
* plan may write lives under `.agents/framework/snapshots/<stamp>/` with a
|
|
8
|
+
* sha256 manifest and the `git status` of the moment.
|
|
9
|
+
* 2. **Writes stay inside the repository.** Every path is resolved and checked;
|
|
10
|
+
* added roots are never written here (they go through the permission gate as
|
|
11
|
+
* ordinary tool calls in the model stage).
|
|
12
|
+
* 3. **Existing files are never clobbered.** A create skips an existing path; a
|
|
13
|
+
* move refuses a non-empty destination and turns it into a manual task.
|
|
14
|
+
* 4. **Counts are reconciled.** The rewrite uses the same bare-reference
|
|
15
|
+
* predicate as the survey, so the reported number matches the plan's number.
|
|
16
|
+
*/
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import {
|
|
20
|
+
copyFileSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
readdirSync,
|
|
25
|
+
renameSync,
|
|
26
|
+
statSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from "node:fs";
|
|
29
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
30
|
+
import { stringify as stringifyToml } from "smol-toml";
|
|
31
|
+
import {
|
|
32
|
+
CONTRACT_FILE,
|
|
33
|
+
FRAMEWORK_DIR,
|
|
34
|
+
FRAMEWORK_VERSION,
|
|
35
|
+
IMPLEMENTATIONS_DIR,
|
|
36
|
+
MEMORY_DIR,
|
|
37
|
+
PLAN_FILE,
|
|
38
|
+
REPORTS_DIR,
|
|
39
|
+
SIGNPOST_FILE,
|
|
40
|
+
SPECS_DIR,
|
|
41
|
+
STATE_FILE,
|
|
42
|
+
SCAN,
|
|
43
|
+
STATE_GITIGNORE,
|
|
44
|
+
} from "./spec.js";
|
|
45
|
+
import { isBareRef, readDoc, walk } from "./detect.js";
|
|
46
|
+
import { renderTemplate } from "./templates.js";
|
|
47
|
+
import { buildCoverage, renderCoverage } from "./coverage.js";
|
|
48
|
+
import { serializeWorkOrder } from "./dispatch.js";
|
|
49
|
+
import { runStamp } from "./plan.js";
|
|
50
|
+
import type {
|
|
51
|
+
Action,
|
|
52
|
+
ApplyResult,
|
|
53
|
+
CreateFileAction,
|
|
54
|
+
DocInfo,
|
|
55
|
+
FrameworkState,
|
|
56
|
+
Plan,
|
|
57
|
+
RewriteAction,
|
|
58
|
+
} from "./types.js";
|
|
59
|
+
|
|
60
|
+
export interface ApplyOptions {
|
|
61
|
+
readonly dryRun?: boolean;
|
|
62
|
+
/** Test seam: pretend the clock, so paths are predictable. */
|
|
63
|
+
readonly now?: Date;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* -------------------------------------------------------------------------- */
|
|
67
|
+
/* Paths and hashing */
|
|
68
|
+
/* -------------------------------------------------------------------------- */
|
|
69
|
+
|
|
70
|
+
function inside(root: string, rel: string): string {
|
|
71
|
+
const absolute = resolve(root, rel);
|
|
72
|
+
const base = resolve(root);
|
|
73
|
+
if (absolute !== base && !absolute.startsWith(`${base}${sep}`)) {
|
|
74
|
+
throw new Error(`refusing to write outside the repository: ${rel}`);
|
|
75
|
+
}
|
|
76
|
+
return absolute;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sha256File(absolute: string): string {
|
|
80
|
+
return createHash("sha256").update(readFileSync(absolute)).digest("hex");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function mkdirFor(absolute: string): void {
|
|
84
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* -------------------------------------------------------------------------- */
|
|
88
|
+
/* Snapshot */
|
|
89
|
+
/* -------------------------------------------------------------------------- */
|
|
90
|
+
|
|
91
|
+
interface SnapshotResult {
|
|
92
|
+
readonly dir: string;
|
|
93
|
+
readonly files: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function snapshot(root: string, paths: string[], stamp: string): SnapshotResult {
|
|
97
|
+
const target = join(FRAMEWORK_DIR, "snapshots", stamp);
|
|
98
|
+
const absoluteTarget = inside(root, target);
|
|
99
|
+
mkdirSync(absoluteTarget, { recursive: true });
|
|
100
|
+
|
|
101
|
+
const manifest: Array<Record<string, unknown>> = [];
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
let files = 0;
|
|
104
|
+
for (const rel of paths) {
|
|
105
|
+
if (seen.has(rel)) continue;
|
|
106
|
+
seen.add(rel);
|
|
107
|
+
const source = inside(root, rel);
|
|
108
|
+
if (!existsSync(source)) continue;
|
|
109
|
+
const stat = statSync(source);
|
|
110
|
+
if (stat.isDirectory()) {
|
|
111
|
+
const entries = walk(root, { excludePrefix: [FRAMEWORK_DIR] }).filter(
|
|
112
|
+
(entry) => entry.rel === rel || entry.rel.startsWith(`${rel}/`),
|
|
113
|
+
);
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (seen.has(entry.rel)) continue;
|
|
116
|
+
seen.add(entry.rel);
|
|
117
|
+
const from = inside(root, entry.rel);
|
|
118
|
+
const to = join(absoluteTarget, "files", entry.rel);
|
|
119
|
+
mkdirFor(to);
|
|
120
|
+
copyFileSync(from, to);
|
|
121
|
+
manifest.push({ path: entry.rel, bytes: statSync(from).size, sha256: sha256File(from) });
|
|
122
|
+
files += 1;
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
const to = join(absoluteTarget, "files", rel);
|
|
126
|
+
mkdirFor(to);
|
|
127
|
+
copyFileSync(source, to);
|
|
128
|
+
manifest.push({ path: rel, bytes: stat.size, sha256: sha256File(source) });
|
|
129
|
+
files += 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let gitStatus = "";
|
|
134
|
+
try {
|
|
135
|
+
gitStatus = execFileSync("git", ["-C", root, "status", "--porcelain"], {
|
|
136
|
+
encoding: "utf8",
|
|
137
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
138
|
+
});
|
|
139
|
+
} catch {
|
|
140
|
+
// Not a git repository: the manifest stands on its own.
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
writeFileSync(
|
|
144
|
+
join(absoluteTarget, "manifest.json"),
|
|
145
|
+
`${JSON.stringify(
|
|
146
|
+
{
|
|
147
|
+
created: new Date().toISOString(),
|
|
148
|
+
framework: FRAMEWORK_VERSION,
|
|
149
|
+
root,
|
|
150
|
+
gitStatus,
|
|
151
|
+
files: manifest,
|
|
152
|
+
},
|
|
153
|
+
null,
|
|
154
|
+
2,
|
|
155
|
+
)}\n`,
|
|
156
|
+
"utf8",
|
|
157
|
+
);
|
|
158
|
+
return { dir: target, files };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* -------------------------------------------------------------------------- */
|
|
162
|
+
/* Moves */
|
|
163
|
+
/* -------------------------------------------------------------------------- */
|
|
164
|
+
|
|
165
|
+
interface MoveResult {
|
|
166
|
+
readonly ok: boolean;
|
|
167
|
+
readonly files: number;
|
|
168
|
+
readonly reason?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function moveDir(root: string, from: string, to: string): MoveResult {
|
|
172
|
+
const source = inside(root, from);
|
|
173
|
+
const destination = inside(root, to);
|
|
174
|
+
if (!existsSync(source)) return { ok: false, files: 0, reason: "source missing" };
|
|
175
|
+
if (existsSync(destination)) return { ok: false, files: 0, reason: "destination exists" };
|
|
176
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
177
|
+
|
|
178
|
+
const count = (): number => {
|
|
179
|
+
const stat = statSync(destination);
|
|
180
|
+
if (!stat.isDirectory()) return 1;
|
|
181
|
+
let files = 0;
|
|
182
|
+
const visit = (current: string): void => {
|
|
183
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
184
|
+
const child = join(current, entry.name);
|
|
185
|
+
if (entry.isDirectory()) visit(child);
|
|
186
|
+
else files += 1;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
visit(destination);
|
|
190
|
+
return files;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
execFileSync("git", ["-C", root, "mv", from, to], {
|
|
195
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
196
|
+
});
|
|
197
|
+
return { ok: true, files: count() };
|
|
198
|
+
} catch {
|
|
199
|
+
try {
|
|
200
|
+
renameSync(source, destination);
|
|
201
|
+
return { ok: true, files: count() };
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
files: 0,
|
|
206
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* -------------------------------------------------------------------------- */
|
|
213
|
+
/* Reference rewrite */
|
|
214
|
+
/* -------------------------------------------------------------------------- */
|
|
215
|
+
|
|
216
|
+
export interface RewriteStat {
|
|
217
|
+
readonly path: string;
|
|
218
|
+
readonly hits: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface RewriteResult {
|
|
222
|
+
readonly files: number;
|
|
223
|
+
readonly hits: number;
|
|
224
|
+
readonly stats: RewriteStat[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Apply the ordered pairs to one text. The first pair that matches at a given
|
|
229
|
+
* position wins, so `memory/PLAN.md → specs/PLAN.md` runs before the broader
|
|
230
|
+
* `memory/ → .agents/memory/`.
|
|
231
|
+
*/
|
|
232
|
+
export function rewriteText(
|
|
233
|
+
text: string,
|
|
234
|
+
pairs: ReadonlyArray<{ from: string; to: string }>,
|
|
235
|
+
): { text: string; hits: number } {
|
|
236
|
+
if (pairs.length === 0) return { text, hits: 0 };
|
|
237
|
+
let out = "";
|
|
238
|
+
let index = 0;
|
|
239
|
+
let hits = 0;
|
|
240
|
+
while (index < text.length) {
|
|
241
|
+
let best: { pair: { from: string; to: string }; at: number } | undefined;
|
|
242
|
+
for (const pair of pairs) {
|
|
243
|
+
const at = text.indexOf(pair.from, index);
|
|
244
|
+
if (at < 0) continue;
|
|
245
|
+
if (
|
|
246
|
+
!best ||
|
|
247
|
+
at < best.at ||
|
|
248
|
+
(at === best.at && pair.from.length > best.pair.from.length)
|
|
249
|
+
) {
|
|
250
|
+
best = { pair, at };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!best) {
|
|
254
|
+
out += text.slice(index);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
out += text.slice(index, best.at);
|
|
258
|
+
const { pair, at } = best;
|
|
259
|
+
const length = pair.from.length;
|
|
260
|
+
if (isBareRef(text, at, pair.from)) {
|
|
261
|
+
out += pair.to;
|
|
262
|
+
hits += 1;
|
|
263
|
+
} else {
|
|
264
|
+
out += text.slice(at, at + length);
|
|
265
|
+
}
|
|
266
|
+
index = at + length;
|
|
267
|
+
}
|
|
268
|
+
return { text: out, hits };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function rewriteRefs(root: string, action: RewriteAction): RewriteResult {
|
|
272
|
+
const entries = walk(root, { excludePrefix: [FRAMEWORK_DIR] });
|
|
273
|
+
let files = 0;
|
|
274
|
+
let hits = 0;
|
|
275
|
+
const stats: RewriteStat[] = [];
|
|
276
|
+
for (const entry of entries) {
|
|
277
|
+
if (entry.bytes === 0 || entry.bytes > SCAN.maxFileBytes) continue;
|
|
278
|
+
const absolute = inside(root, entry.rel);
|
|
279
|
+
const buffer = readFileSync(absolute);
|
|
280
|
+
if (buffer.subarray(0, 8192).includes(0)) continue;
|
|
281
|
+
const text = buffer.toString("utf8");
|
|
282
|
+
// Never "rewrite" a file that is not valid UTF-8: decoding would replace
|
|
283
|
+
// bytes and writing it back would silently corrupt the document.
|
|
284
|
+
if (!buffer.equals(Buffer.from(text, "utf8"))) continue;
|
|
285
|
+
const result = rewriteText(text, action.pairs);
|
|
286
|
+
if (result.hits === 0) continue;
|
|
287
|
+
writeFileSync(absolute, result.text, "utf8");
|
|
288
|
+
files += 1;
|
|
289
|
+
hits += result.hits;
|
|
290
|
+
stats.push({ path: entry.rel, hits: result.hits });
|
|
291
|
+
}
|
|
292
|
+
return { files, hits, stats };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function renderRewriteReport(
|
|
296
|
+
result: RewriteResult,
|
|
297
|
+
action: RewriteAction,
|
|
298
|
+
createdAt: string,
|
|
299
|
+
root: string,
|
|
300
|
+
): string {
|
|
301
|
+
const byArea = new Map<string, { files: number; hits: number }>();
|
|
302
|
+
for (const entry of result.stats) {
|
|
303
|
+
const slash = entry.path.indexOf("/");
|
|
304
|
+
const area = slash < 0 ? entry.path : entry.path.slice(0, slash);
|
|
305
|
+
const bucket = byArea.get(area) ?? { files: 0, hits: 0 };
|
|
306
|
+
bucket.files += 1;
|
|
307
|
+
bucket.hits += entry.hits;
|
|
308
|
+
byArea.set(area, bucket);
|
|
309
|
+
}
|
|
310
|
+
const rows = [...byArea.entries()]
|
|
311
|
+
.sort((a, b) => b[1].hits - a[1].hits)
|
|
312
|
+
.map(([area, bucket]) => `| \`${area}\` | ${bucket.files} | ${bucket.hits} |`);
|
|
313
|
+
const files = result.stats
|
|
314
|
+
.slice(0, 300)
|
|
315
|
+
.map((entry) => `| \`${entry.path}\` | ${entry.hits} |`);
|
|
316
|
+
if (result.stats.length > 300) files.push(`| … ${result.stats.length - 300} more | |`);
|
|
317
|
+
return [
|
|
318
|
+
"# Path rewrite",
|
|
319
|
+
"",
|
|
320
|
+
`- created: ${createdAt}`,
|
|
321
|
+
`- root: ${root}`,
|
|
322
|
+
`- pairs: ${action.pairs.map((pair) => `${pair.from} → ${pair.to}`).join(", ")}`,
|
|
323
|
+
`- planned: ${action.hits} occurrences in ${action.files} files`,
|
|
324
|
+
`- done: ${result.hits} occurrences in ${result.files} files`,
|
|
325
|
+
"",
|
|
326
|
+
"## By area",
|
|
327
|
+
"",
|
|
328
|
+
"| Area | Files | Occurrences |",
|
|
329
|
+
"| --- | --- | --- |",
|
|
330
|
+
...rows,
|
|
331
|
+
"",
|
|
332
|
+
"## Files",
|
|
333
|
+
"",
|
|
334
|
+
"| File | Occurrences |",
|
|
335
|
+
"| --- | --- |",
|
|
336
|
+
...files,
|
|
337
|
+
"",
|
|
338
|
+
].join("\n");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* -------------------------------------------------------------------------- */
|
|
342
|
+
/* Creates */
|
|
343
|
+
/* -------------------------------------------------------------------------- */
|
|
344
|
+
|
|
345
|
+
function createFile(
|
|
346
|
+
root: string,
|
|
347
|
+
action: CreateFileAction,
|
|
348
|
+
language: Plan["language"],
|
|
349
|
+
): string | undefined {
|
|
350
|
+
const absolute = inside(root, action.path);
|
|
351
|
+
if (existsSync(absolute)) return undefined;
|
|
352
|
+
mkdirFor(absolute);
|
|
353
|
+
const body = renderTemplate(action.template, action.tokens, language);
|
|
354
|
+
writeFileSync(absolute, body, "utf8");
|
|
355
|
+
return body;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* -------------------------------------------------------------------------- */
|
|
359
|
+
/* State */
|
|
360
|
+
/* -------------------------------------------------------------------------- */
|
|
361
|
+
|
|
362
|
+
function compact<T extends Record<string, unknown>>(value: T): T {
|
|
363
|
+
for (const key of Object.keys(value)) {
|
|
364
|
+
if (value[key] === undefined) delete value[key];
|
|
365
|
+
}
|
|
366
|
+
return value;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function stateToToml(state: FrameworkState): string {
|
|
370
|
+
const doc = compact({
|
|
371
|
+
version: state.version,
|
|
372
|
+
tool: state.tool,
|
|
373
|
+
created: state.created,
|
|
374
|
+
updated: state.updated,
|
|
375
|
+
profile: state.profile,
|
|
376
|
+
modules: state.modules,
|
|
377
|
+
lang: state.lang,
|
|
378
|
+
budgets: state.budgets
|
|
379
|
+
? compact({
|
|
380
|
+
agents_md_soft_chars: state.budgets.agentsMdSoftChars,
|
|
381
|
+
agents_md_hard_chars: state.budgets.agentsMdHardChars,
|
|
382
|
+
memory_file_chars: state.budgets.memoryFileChars,
|
|
383
|
+
})
|
|
384
|
+
: undefined,
|
|
385
|
+
roots: state.roots.map((root) =>
|
|
386
|
+
compact({
|
|
387
|
+
label: root.label,
|
|
388
|
+
path: root.path,
|
|
389
|
+
absolute: root.absolute,
|
|
390
|
+
role: root.role,
|
|
391
|
+
agents_md: root.agentsMd,
|
|
392
|
+
source: root.source,
|
|
393
|
+
last_seen: root.lastSeen,
|
|
394
|
+
}),
|
|
395
|
+
),
|
|
396
|
+
managed: state.managed.map((entry) => ({
|
|
397
|
+
path: entry.path,
|
|
398
|
+
sha256: entry.sha256,
|
|
399
|
+
template: entry.template,
|
|
400
|
+
})),
|
|
401
|
+
migrations: state.migrations.map((entry) =>
|
|
402
|
+
compact({ at: entry.at, to: entry.to, notes: entry.notes }),
|
|
403
|
+
),
|
|
404
|
+
});
|
|
405
|
+
return `${stringifyToml(doc)}\n`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/* -------------------------------------------------------------------------- */
|
|
409
|
+
/* Coverage */
|
|
410
|
+
/* -------------------------------------------------------------------------- */
|
|
411
|
+
|
|
412
|
+
function listDocs(root: string, rel: string): DocInfo[] {
|
|
413
|
+
const docs: DocInfo[] = [];
|
|
414
|
+
let names: string[] = [];
|
|
415
|
+
try {
|
|
416
|
+
names = readdirSync(join(root, rel)).sort();
|
|
417
|
+
} catch {
|
|
418
|
+
return docs;
|
|
419
|
+
}
|
|
420
|
+
for (const name of names) {
|
|
421
|
+
if (!name.endsWith(".md")) continue;
|
|
422
|
+
const doc = readDoc(root, `${rel}/${name}`);
|
|
423
|
+
if (doc) docs.push(doc);
|
|
424
|
+
}
|
|
425
|
+
return docs;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function writeCoverage(
|
|
429
|
+
root: string,
|
|
430
|
+
plan: Plan,
|
|
431
|
+
reportRel: string,
|
|
432
|
+
createdAt: string,
|
|
433
|
+
): void {
|
|
434
|
+
const sources: DocInfo[] = [];
|
|
435
|
+
const agents = readDoc(root, "AGENTS.md");
|
|
436
|
+
if (agents) sources.push(agents);
|
|
437
|
+
// The plan file is compared from its pre-move snapshot: after a `move-file`
|
|
438
|
+
// its content lives at specs/PLAN.md and is already a target, so counting it
|
|
439
|
+
// as a source too would report it as “accounted for” by itself.
|
|
440
|
+
sources.push(
|
|
441
|
+
...plan.facts.legacyMemory.filter((doc) => doc.rel === "memory/PLAN.md"),
|
|
442
|
+
);
|
|
443
|
+
sources.push(...plan.facts.todos);
|
|
444
|
+
sources.push(
|
|
445
|
+
...plan.facts.legacyDocs.filter((doc) => doc.rel !== "AGENTS.md" && doc.rel !== PLAN_FILE),
|
|
446
|
+
);
|
|
447
|
+
if (sources.length === 0) return;
|
|
448
|
+
|
|
449
|
+
const targets = [
|
|
450
|
+
...listDocs(root, MEMORY_DIR),
|
|
451
|
+
...listDocs(root, SPECS_DIR),
|
|
452
|
+
...listDocs(root, IMPLEMENTATIONS_DIR),
|
|
453
|
+
];
|
|
454
|
+
const report = buildCoverage(sources, targets);
|
|
455
|
+
const body = renderCoverage(report, {
|
|
456
|
+
createdAt,
|
|
457
|
+
sources: sources.map((doc) => doc.rel),
|
|
458
|
+
targets: [MEMORY_DIR, SPECS_DIR, IMPLEMENTATIONS_DIR],
|
|
459
|
+
});
|
|
460
|
+
const absolute = inside(root, reportRel);
|
|
461
|
+
if (existsSync(absolute)) return;
|
|
462
|
+
mkdirFor(absolute);
|
|
463
|
+
writeFileSync(absolute, body, "utf8");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* -------------------------------------------------------------------------- */
|
|
467
|
+
/* Apply */
|
|
468
|
+
/* -------------------------------------------------------------------------- */
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
/* -------------------------------------------------------------------------- */
|
|
472
|
+
/* Action handlers (one concern each) */
|
|
473
|
+
/* -------------------------------------------------------------------------- */
|
|
474
|
+
|
|
475
|
+
interface ApplyContext {
|
|
476
|
+
readonly root: string;
|
|
477
|
+
readonly stamp: string;
|
|
478
|
+
readonly createdAt: string;
|
|
479
|
+
readonly language: Plan["language"];
|
|
480
|
+
readonly dryRun: boolean;
|
|
481
|
+
readonly moved: ApplyResult["moved"];
|
|
482
|
+
readonly created: string[];
|
|
483
|
+
readonly skipped: ApplyResult["skipped"];
|
|
484
|
+
rewrites?: ApplyResult["rewrites"];
|
|
485
|
+
snapshot?: ApplyResult["snapshot"];
|
|
486
|
+
statePath?: string;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function applySnapshot(ctx: ApplyContext, action: Action & { kind: "snapshot" }): void {
|
|
490
|
+
const dir = `${FRAMEWORK_DIR}/snapshots/${ctx.stamp}`;
|
|
491
|
+
if (ctx.dryRun) {
|
|
492
|
+
ctx.snapshot = { dir, files: action.files };
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
ctx.snapshot = snapshot(ctx.root, action.paths, ctx.stamp);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function applyCreateDirs(ctx: ApplyContext, action: Action & { kind: "create-dirs" }): void {
|
|
499
|
+
if (ctx.dryRun) return;
|
|
500
|
+
for (const rel of action.paths) mkdirSync(inside(ctx.root, rel), { recursive: true });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function applyMoveDir(ctx: ApplyContext, action: Action & { kind: "move-dir" }): void {
|
|
504
|
+
if (ctx.dryRun) {
|
|
505
|
+
ctx.moved.push({ from: action.from, to: action.to, files: action.files });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const result = moveDir(ctx.root, action.from, action.to);
|
|
509
|
+
if (result.ok) ctx.moved.push({ from: action.from, to: action.to, files: result.files });
|
|
510
|
+
else ctx.skipped.push({ path: `${action.from}/`, reason: result.reason ?? "move failed" });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function applyMoveFile(ctx: ApplyContext, action: Action & { kind: "move-file" }): void {
|
|
514
|
+
if (ctx.dryRun) {
|
|
515
|
+
ctx.moved.push({ from: action.from, to: action.to, files: 1 });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const source = inside(ctx.root, action.from);
|
|
519
|
+
const destination = inside(ctx.root, action.to);
|
|
520
|
+
if (!existsSync(source)) {
|
|
521
|
+
ctx.skipped.push({ path: action.from, reason: "source missing" });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (existsSync(destination)) {
|
|
525
|
+
ctx.skipped.push({ path: action.from, reason: "destination exists" });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
mkdirFor(destination);
|
|
529
|
+
try {
|
|
530
|
+
execFileSync("git", ["-C", ctx.root, "mv", action.from, action.to], {
|
|
531
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
532
|
+
});
|
|
533
|
+
} catch {
|
|
534
|
+
renameSync(source, destination);
|
|
535
|
+
}
|
|
536
|
+
ctx.moved.push({ from: action.from, to: action.to, files: 1 });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function applyRewrite(ctx: ApplyContext, action: Action & { kind: "rewrite-refs" }): void {
|
|
540
|
+
const report = `${REPORTS_DIR}/rewrite-${ctx.stamp}.md`;
|
|
541
|
+
if (ctx.dryRun) {
|
|
542
|
+
ctx.rewrites = { files: action.files, hits: action.hits, report };
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const result = rewriteRefs(ctx.root, action);
|
|
546
|
+
ctx.rewrites = { files: result.files, hits: result.hits, report };
|
|
547
|
+
const absolute = inside(ctx.root, report);
|
|
548
|
+
mkdirFor(absolute);
|
|
549
|
+
writeFileSync(
|
|
550
|
+
absolute,
|
|
551
|
+
renderRewriteReport(result, action, ctx.createdAt, ctx.root),
|
|
552
|
+
"utf8",
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function applyCreateFile(ctx: ApplyContext, action: Action & { kind: "create-file" }): void {
|
|
557
|
+
if (ctx.dryRun) {
|
|
558
|
+
ctx.created.push(action.path);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const body = createFile(ctx.root, action, ctx.language);
|
|
562
|
+
if (body === undefined) ctx.skipped.push({ path: action.path, reason: "already exists" });
|
|
563
|
+
else ctx.created.push(action.path);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function applyWriteState(ctx: ApplyContext, action: Action & { kind: "write-state" }): void {
|
|
567
|
+
if (ctx.dryRun) {
|
|
568
|
+
ctx.statePath = STATE_FILE;
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
ctx.statePath = writeState(ctx.root, action.state);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function runAction(ctx: ApplyContext, action: Action): void {
|
|
575
|
+
switch (action.kind) {
|
|
576
|
+
case "snapshot":
|
|
577
|
+
return applySnapshot(ctx, action);
|
|
578
|
+
case "create-dirs":
|
|
579
|
+
return applyCreateDirs(ctx, action);
|
|
580
|
+
case "move-dir":
|
|
581
|
+
return applyMoveDir(ctx, action);
|
|
582
|
+
case "move-file":
|
|
583
|
+
return applyMoveFile(ctx, action);
|
|
584
|
+
case "rewrite-refs":
|
|
585
|
+
return applyRewrite(ctx, action);
|
|
586
|
+
case "create-file":
|
|
587
|
+
return applyCreateFile(ctx, action);
|
|
588
|
+
case "write-state":
|
|
589
|
+
return applyWriteState(ctx, action);
|
|
590
|
+
default: {
|
|
591
|
+
const exhaustive: never = action;
|
|
592
|
+
throw new Error(`unknown action: ${JSON.stringify(exhaustive)}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/* -------------------------------------------------------------------------- */
|
|
598
|
+
/* Apply */
|
|
599
|
+
/* -------------------------------------------------------------------------- */
|
|
600
|
+
|
|
601
|
+
export function applyPlan(plan: Plan, options: ApplyOptions = {}): ApplyResult {
|
|
602
|
+
const stamp = runStamp(plan.createdAt);
|
|
603
|
+
const ctx: ApplyContext = {
|
|
604
|
+
root: plan.root,
|
|
605
|
+
stamp,
|
|
606
|
+
createdAt: plan.createdAt,
|
|
607
|
+
language: plan.language,
|
|
608
|
+
dryRun: options.dryRun === true,
|
|
609
|
+
moved: [],
|
|
610
|
+
created: [],
|
|
611
|
+
skipped: [],
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
for (const action of plan.actions) runAction(ctx, action);
|
|
615
|
+
|
|
616
|
+
const coverageWritten =
|
|
617
|
+
!ctx.dryRun && plan.artifacts.coverage
|
|
618
|
+
? writeCoverageFile(plan, plan.artifacts.coverage)
|
|
619
|
+
: undefined;
|
|
620
|
+
const planPath = !ctx.dryRun && plan.artifacts.plan ? writeWorkOrder(plan, ctx) : undefined;
|
|
621
|
+
|
|
622
|
+
const summary: string[] = [];
|
|
623
|
+
if (ctx.snapshot) summary.push(`snapshot: ${ctx.snapshot.dir} (${ctx.snapshot.files} files)`);
|
|
624
|
+
for (const move of ctx.moved) summary.push(`moved ${move.from}/ → ${move.to}/ (${move.files})`);
|
|
625
|
+
if (ctx.rewrites) {
|
|
626
|
+
summary.push(`rewrote ${ctx.rewrites.hits} references in ${ctx.rewrites.files} files`);
|
|
627
|
+
}
|
|
628
|
+
if (ctx.created.length > 0) summary.push(`created ${ctx.created.length} files`);
|
|
629
|
+
if (coverageWritten) summary.push(`coverage worklist: ${coverageWritten}`);
|
|
630
|
+
if (planPath) summary.push(`work order: ${planPath}`);
|
|
631
|
+
if (ctx.skipped.length > 0) summary.push(`skipped ${ctx.skipped.length} (see report)`);
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
snapshot: ctx.snapshot,
|
|
635
|
+
moved: ctx.moved,
|
|
636
|
+
rewrites: ctx.rewrites,
|
|
637
|
+
created: ctx.created,
|
|
638
|
+
skipped: ctx.skipped,
|
|
639
|
+
statePath: ctx.statePath,
|
|
640
|
+
planPath,
|
|
641
|
+
summary,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function writeCoverageFile(plan: Plan, rel: string): string {
|
|
646
|
+
writeCoverage(plan.root, plan, rel, plan.createdAt);
|
|
647
|
+
return rel;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function writeWorkOrder(plan: Plan, ctx: ApplyContext): string | undefined {
|
|
651
|
+
if (!plan.artifacts.plan) return undefined;
|
|
652
|
+
const absolute = inside(ctx.root, plan.artifacts.plan);
|
|
653
|
+
mkdirFor(absolute);
|
|
654
|
+
writeFileSync(
|
|
655
|
+
absolute,
|
|
656
|
+
serializeWorkOrder(plan, {
|
|
657
|
+
moved: ctx.moved,
|
|
658
|
+
created: ctx.created,
|
|
659
|
+
skipped: ctx.skipped,
|
|
660
|
+
rewrites: ctx.rewrites,
|
|
661
|
+
snapshot: ctx.snapshot,
|
|
662
|
+
summary: [],
|
|
663
|
+
}),
|
|
664
|
+
"utf8",
|
|
665
|
+
);
|
|
666
|
+
return plan.artifacts.plan;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Write state, refreshing the hashes of files this framework manages. */
|
|
670
|
+
function writeState(root: string, state: FrameworkState): string {
|
|
671
|
+
const managedPaths = [CONTRACT_FILE, SIGNPOST_FILE, STATE_GITIGNORE];
|
|
672
|
+
const previous = new Map(state.managed.map((entry) => [entry.path, entry]));
|
|
673
|
+
const managed: FrameworkState["managed"] = [];
|
|
674
|
+
for (const rel of managedPaths) {
|
|
675
|
+
const absolute = inside(root, rel);
|
|
676
|
+
if (!existsSync(absolute)) continue;
|
|
677
|
+
const template =
|
|
678
|
+
rel === CONTRACT_FILE
|
|
679
|
+
? "memory-readme"
|
|
680
|
+
: rel === SIGNPOST_FILE
|
|
681
|
+
? "signpost"
|
|
682
|
+
: "state-gitignore";
|
|
683
|
+
managed.push({
|
|
684
|
+
path: rel,
|
|
685
|
+
sha256: sha256File(absolute),
|
|
686
|
+
template: previous.get(rel)?.template ?? template,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
const absolute = inside(root, STATE_FILE);
|
|
690
|
+
mkdirFor(absolute);
|
|
691
|
+
writeFileSync(absolute, stateToToml({ ...state, managed }), "utf8");
|
|
692
|
+
return STATE_FILE;
|
|
693
|
+
}
|