@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.
@@ -0,0 +1,867 @@
1
+ /**
2
+ * init feature — turn facts into a plan.
3
+ *
4
+ * The plan is split into two halves on purpose:
5
+ *
6
+ * - **actions** are deterministic and executed by this extension (snapshot,
7
+ * move, reference rewrite, scaffold, state). They are safe to run twice and
8
+ * never rewrite prose.
9
+ * - **tasks** are judgement calls handed to the model in a work order. They
10
+ * are explicit, bounded, and each one carries its own acceptance criteria.
11
+ *
12
+ * The default is the *conservative pass*: install the framework, fix mechanical
13
+ * drift (tombstones, duplicate sections, appendix order, retired `todos/`) and
14
+ * produce a coverage table. Splitting an over-budget AGENTS.md is recorded as
15
+ * debt, not attempted in the same pass.
16
+ */
17
+ import { existsSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import {
20
+ AGENTS_FILE,
21
+ BUDGETS,
22
+ CLAUDE_FILE,
23
+ CONTRACT_FILE,
24
+ FRAMEWORK_DIR,
25
+ FRAMEWORK_VERSION,
26
+ IMPLEMENTATIONS_DIR,
27
+ MEMORY_DIR,
28
+ MEMORY_FILES,
29
+ PLANS_DIR,
30
+ PLAN_FILE,
31
+ PROMPTS_DIR,
32
+ REPORTS_DIR,
33
+ ROOT_MODULES,
34
+ SIGNPOST_FILE,
35
+ SNAPSHOTS_DIR,
36
+ SPECS_DIR,
37
+ STATE_FILE,
38
+ STATE_GITIGNORE,
39
+ type ProjectLanguage,
40
+ } from "./spec.js";
41
+ import { duplicateHeadings, largestSections, sectionsAfterAppendix, tombstones } from "./analyze.js";
42
+ import { countBareRefs } from "./detect.js";
43
+ import { indexBlock, rootsBlock } from "./render.js";
44
+ import type {
45
+ Action,
46
+ CreateFileAction,
47
+ DocInfo,
48
+ Facts,
49
+ FrameworkState,
50
+ Plan,
51
+ PlanMode,
52
+ RootInfo,
53
+ Task,
54
+ } from "./types.js";
55
+
56
+ export interface PlanOptions {
57
+ /** `--deep`: allow structural rewrites (off by default). */
58
+ readonly deep?: boolean;
59
+ readonly toolVersion: string;
60
+ readonly now?: Date;
61
+ }
62
+
63
+ export function iso(now: Date): string {
64
+ const offset = -now.getTimezoneOffset();
65
+ const sign = offset >= 0 ? "+" : "-";
66
+ const pad = (value: number): string => String(Math.trunc(Math.abs(value))).padStart(2, "0");
67
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(
68
+ now.getHours(),
69
+ )}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${sign}${pad(offset / 60)}:${pad(
70
+ offset % 60,
71
+ )}`;
72
+ }
73
+
74
+ /** File-name-safe stamp for one run's plans and reports. */
75
+ export function runStamp(createdAt: string): string {
76
+ return createdAt.replace(/[:+]/g, "-");
77
+ }
78
+
79
+ function projectName(root: string): string {
80
+ const name = root.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "project";
81
+ return name;
82
+ }
83
+
84
+ /* -------------------------------------------------------------------------- */
85
+ /* Language tokens */
86
+ /* -------------------------------------------------------------------------- */
87
+
88
+ /** Flat token map handed to templates; every value is a string. */
89
+ type Tokens = Record<string, string>;
90
+
91
+ export const CONTRACT_LINES: Record<ProjectLanguage, string> = {
92
+ zh: [
93
+ "> 记忆契约(agent-framework v1):一个事实一个家;不猜事实;进度只在 todo 工具。",
94
+ "> 计划在 `specs/`(PLAN.md + 单元方案),记忆在 `.agents/memory/`,证据在 `.agents/implementations/`。",
95
+ "> 结构审计:`/ask-init check`;写记忆前先读 `.agents/memory/README.md`。",
96
+ ].join("\n"),
97
+ en: [
98
+ "> Memory contract (agent-framework v1): one fact, one home; never invent facts; progress lives in the todo tool.",
99
+ "> Plans in `specs/` (PLAN.md + unit specs), memory in `.agents/memory/`, evidence in `.agents/implementations/`.",
100
+ "> Audit with `/ask-init check`; read `.agents/memory/README.md` before writing memory.",
101
+ ].join("\n"),
102
+ };
103
+
104
+ const HEADINGS: Record<ProjectLanguage, Record<string, string>> = {
105
+ zh: {
106
+ what: "这是什么",
107
+ rules: "硬规则(不可违反)",
108
+ commands: "命令",
109
+ layout: "目录与分层",
110
+ index: "计划与记忆索引",
111
+ impact: "变更影响索引",
112
+ roots: "工作区根(多仓库)",
113
+ terms: "术语",
114
+ maintenance: "框架维护",
115
+ },
116
+ en: {
117
+ what: "What this is",
118
+ rules: "Hard rules (do not violate)",
119
+ commands: "Commands",
120
+ layout: "Layout",
121
+ index: "Plan and memory index",
122
+ impact: "Change impact index",
123
+ roots: "Workspace roots",
124
+ terms: "Terms",
125
+ maintenance: "Framework maintenance",
126
+ },
127
+ };
128
+
129
+ const DOC_TITLE: Record<ProjectLanguage, string> = {
130
+ zh: "项目指南(AGENTS.md)",
131
+ en: "project guide (AGENTS.md)",
132
+ };
133
+
134
+ function buildTokens(facts: Facts, now: Date, created: ReadonlySet<string>): Tokens {
135
+ const language = facts.language;
136
+ const headings = HEADINGS[language];
137
+ return {
138
+ project: projectName(facts.root),
139
+ frameworkVersion: String(FRAMEWORK_VERSION),
140
+ date: iso(now).slice(0, 10),
141
+ contract: CONTRACT_LINES[language],
142
+ docTitle: DOC_TITLE[language],
143
+ softBudget: String(BUDGETS.agentsMdSoftChars),
144
+ hardBudget: String(BUDGETS.agentsMdHardChars),
145
+ memoryBudget: String(BUDGETS.memoryFileChars),
146
+ implementationsBudget: String(BUDGETS.implementationsFiles),
147
+ statePath: STATE_FILE,
148
+ contractPath: CONTRACT_FILE,
149
+ implementationsDir: IMPLEMENTATIONS_DIR,
150
+ ...Object.fromEntries(
151
+ Object.entries(headings).map(([key, value]) => [`h_${key}`, value]),
152
+ ),
153
+ index: indexBlock(facts, language, created),
154
+ roots: rootsBlock(facts.roots, language),
155
+ };
156
+ }
157
+
158
+ /* -------------------------------------------------------------------------- */
159
+ /* Mode */
160
+ /* -------------------------------------------------------------------------- */
161
+
162
+ function frameworkInstalled(facts: Facts): boolean {
163
+ return (
164
+ facts.dirs[MEMORY_DIR] &&
165
+ facts.dirs[IMPLEMENTATIONS_DIR] &&
166
+ (facts.dirs[FRAMEWORK_DIR] || existsSync(join(facts.root, STATE_FILE)))
167
+ );
168
+ }
169
+
170
+ function selectMode(facts: Facts): PlanMode {
171
+ if (facts.state && facts.state.version > FRAMEWORK_VERSION) return "unsupported";
172
+ if (facts.state) {
173
+ if (facts.state.version < FRAMEWORK_VERSION) return "upgrade";
174
+ const layoutOk =
175
+ frameworkInstalled(facts) &&
176
+ !facts.deprecated.memory &&
177
+ !facts.deprecated.implementations &&
178
+ !facts.retiredTodos &&
179
+ // A PLAN.md left in memory is pre-v1 layout, not an installed plan home.
180
+ !existsSync(join(facts.root, MEMORY_DIR, "PLAN.md")) &&
181
+ existsSync(join(facts.root, CONTRACT_FILE));
182
+ return layoutOk ? "audit-only" : "reconcile";
183
+ }
184
+ const anyContent =
185
+ Boolean(facts.context.agents || facts.context.claude) ||
186
+ facts.deprecated.memory ||
187
+ facts.deprecated.implementations ||
188
+ facts.deprecated.prompts ||
189
+ facts.retiredTodos ||
190
+ facts.memory.length > 0 ||
191
+ facts.legacyMemory.length > 0 ||
192
+ facts.specs.length > 0 ||
193
+ facts.implementations.length > 0;
194
+ return anyContent ? "adopt" : "fresh";
195
+ }
196
+
197
+ /* -------------------------------------------------------------------------- */
198
+ /* State */
199
+ /* -------------------------------------------------------------------------- */
200
+
201
+ function buildState(
202
+ facts: Facts,
203
+ now: Date,
204
+ toolVersion: string,
205
+ mode: PlanMode,
206
+ ): FrameworkState {
207
+ const previous = facts.state;
208
+ const modules = [
209
+ ...ROOT_MODULES.filter((module) => facts.dirs[module.dir]).map((module) => module.dir),
210
+ ...facts.modulesPresent,
211
+ ];
212
+ return {
213
+ version: FRAMEWORK_VERSION,
214
+ tool: toolVersion,
215
+ created: previous?.created ?? iso(now),
216
+ updated: iso(now),
217
+ profile: "core",
218
+ modules: [...new Set(modules)],
219
+ lang: facts.language,
220
+ budgets: previous?.budgets,
221
+ roots: facts.roots.length > 0 ? facts.roots : (previous?.roots ?? []),
222
+ managed: previous?.managed ?? [],
223
+ migrations: [
224
+ ...(previous?.migrations ?? []),
225
+ { at: iso(now), to: String(FRAMEWORK_VERSION), notes: mode },
226
+ ],
227
+ };
228
+ }
229
+
230
+ /* -------------------------------------------------------------------------- */
231
+ /* Tasks */
232
+ /* -------------------------------------------------------------------------- */
233
+
234
+ function surveyTask(facts: Facts): Task {
235
+ const read = [
236
+ ...facts.project.manifests,
237
+ ...facts.project.lockfiles,
238
+ facts.project.license ?? "",
239
+ ...facts.project.ci.slice(0, 4),
240
+ facts.context.agents ? AGENTS_FILE : "",
241
+ facts.context.claude ? CLAUDE_FILE : "",
242
+ ]
243
+ .filter((value) => value.length > 0)
244
+ .slice(0, 14);
245
+ return {
246
+ id: "survey",
247
+ kind: "survey",
248
+ detail: [
249
+ "Read the project and record only evidence; anything you cannot verify is `TBD`.",
250
+ `Read: ${read.join(", ") || "(no manifests found — walk the tree)"}`,
251
+ `Also: directory tree (depth 2-3, skip build dirs), \`git log --oneline -20\`, README.`,
252
+ `Language for everything you write: ${facts.language}.`,
253
+ ].join("\n"),
254
+ acceptance: [
255
+ "every command in AGENTS.md was read from a manifest/CI/script, not guessed",
256
+ "positions and hard rules cite a file instead of a hunch",
257
+ ],
258
+ };
259
+ }
260
+
261
+ function editAgentsTask(facts: Facts, deep: boolean): Task | undefined {
262
+ const doc = facts.context.agents;
263
+ if (!doc) return undefined;
264
+ const text = doc.text;
265
+ const steps: string[] = [];
266
+ if (!/agent-framework|\.agents\/memory/.test(text)) {
267
+ steps.push("Add the 3-line memory contract near the top (`.agents/memory/README.md` has the wording).");
268
+ }
269
+ if (!text.includes(PLAN_FILE) || !text.includes(MEMORY_DIR)) {
270
+ steps.push(
271
+ `Merge the plan/memory index into AGENTS.md: \`${PLAN_FILE}\` + \`${SPECS_DIR}/<unit>.md\`, then one row per file in \`${MEMORY_DIR}/\`.`,
272
+ );
273
+ }
274
+ const hasLegacyPaths =
275
+ countBareRefs(text, "memory/") > 0 || countBareRefs(text, "implementations/") > 0;
276
+ if (!text.includes(IMPLEMENTATIONS_DIR) || hasLegacyPaths) {
277
+ steps.push(
278
+ `Update every path in the layout/records sections to the framework paths (\`${IMPLEMENTATIONS_DIR}/\`, \`${MEMORY_DIR}/\`).`,
279
+ );
280
+ }
281
+ const dead = tombstones(doc);
282
+ if (dead.length > 0) {
283
+ steps.push(
284
+ `Remove tombstone sections whose content already lives elsewhere: ${dead
285
+ .map((heading) => `§${heading.text}`)
286
+ .join(", ")}.`,
287
+ );
288
+ }
289
+ const dupes = duplicateHeadings([
290
+ doc,
291
+ ...facts.memory,
292
+ ...facts.legacyMemory,
293
+ ]).slice(0, 4);
294
+ if (dupes.length > 0) {
295
+ steps.push(
296
+ `Collapse duplicated sections to a pointer (keep the owning file, delete the copy after checking no fact is unique): ${dupes
297
+ .map(
298
+ (group) =>
299
+ `"${group.occurrences[0].heading}" (${group.occurrences
300
+ .map((occurrence) => occurrence.path)
301
+ .join(" / ")})`,
302
+ )
303
+ .join("; ")}.`,
304
+ );
305
+ }
306
+ const after = sectionsAfterAppendix(doc);
307
+ if (after.length > 0) {
308
+ steps.push(
309
+ `Move appendices to the end (currently followed by: ${after
310
+ .map((heading) => `§${heading.text}`)
311
+ .join(", ")}).`,
312
+ );
313
+ }
314
+ if (facts.roots.length > 0 && !/Workspace roots|工作区根/.test(text)) {
315
+ steps.push("Add the workspace-roots table (memory stays in the main repo; roots get scope cards).");
316
+ }
317
+ if (deep) {
318
+ steps.push(
319
+ "Deep pass: split over-budget sections into their owning memory files, updating the index in the same edit.",
320
+ );
321
+ }
322
+ if (steps.length === 0) return undefined;
323
+ return {
324
+ id: "edit-agents",
325
+ kind: "edit-agents",
326
+ targets: [AGENTS_FILE],
327
+ detail: steps.map((step, index) => `${index + 1}. ${step}`).join("\n"),
328
+ acceptance: [
329
+ "no fact was dropped: every removed sentence is either still present verbatim in the destination or listed in the coverage table",
330
+ `AGENTS.md stays under ${BUDGETS.agentsMdHardChars} characters (soft: ${BUDGETS.agentsMdSoftChars})`,
331
+ ],
332
+ };
333
+ }
334
+
335
+ function budgetTask(facts: Facts): Task | undefined {
336
+ const doc = facts.context.agents;
337
+ if (!doc) return undefined;
338
+ if (doc.bytes <= BUDGETS.agentsMdHardChars) return undefined;
339
+ const largest = largestSections(doc, 4);
340
+ return {
341
+ id: "budget-debt",
342
+ kind: "budget-debt",
343
+ detail: [
344
+ `AGENTS.md is ${doc.bytes} characters (soft ${BUDGETS.agentsMdSoftChars}, hard ${BUDGETS.agentsMdHardChars}).`,
345
+ "This pass does not rewrite prose: record the overflow in `specs/PLAN.md` as debt.",
346
+ "Largest sections (candidates to move into their owning memory file, leaving a pointer):",
347
+ ...largest.map(({ heading, chars }) => ` - §${heading.text} — ${chars} chars`),
348
+ "Suggested homes: glossary → `.agents/memory/TERMS.md`, component inventory → `.agents/memory/ARCHITECTURE.md`, visual rules → `.agents/memory/DESIGN.md`.",
349
+ ].join("\n"),
350
+ acceptance: ["the debt is written into specs/PLAN.md with the measured number", "no section was moved in this pass"],
351
+ };
352
+ }
353
+
354
+ /** The plan document that should move into an empty `specs/PLAN.md`. */
355
+ function planMoveSource(facts: Facts): DocInfo | undefined {
356
+ if (existsSync(join(facts.root, PLAN_FILE))) return undefined;
357
+ // Prefer the root-level plan: it has to leave the root anyway. A second
358
+ // source is merged by the model instead of being chosen for it.
359
+ return (
360
+ facts.legacyDocs.find((doc) => doc.rel === "PLAN.md") ??
361
+ facts.legacyMemory.find((doc) => doc.rel === "memory/PLAN.md") ??
362
+ facts.memory.find((doc) => doc.rel === `${MEMORY_DIR}/PLAN.md`)
363
+ );
364
+ }
365
+
366
+ function mergePlanTask(facts: Facts): Task | undefined {
367
+ const moved = planMoveSource(facts);
368
+ const sources = [
369
+ facts.legacyDocs.find((doc) => doc.rel === "PLAN.md"),
370
+ facts.legacyMemory.find((doc) => doc.rel === "memory/PLAN.md"),
371
+ facts.memory.find((doc) => doc.rel === `${MEMORY_DIR}/PLAN.md`),
372
+ ].filter((doc): doc is DocInfo => doc !== undefined && doc !== moved);
373
+ if (!moved && sources.length === 0) return undefined;
374
+
375
+ const detail: string[] = [];
376
+ if (moved) {
377
+ detail.push(
378
+ `\`${moved.rel}\` was moved to \`${PLAN_FILE}\` verbatim (content untouched); shape it now.`,
379
+ );
380
+ }
381
+ for (const source of sources) {
382
+ // The legacy memory directory is moved before the model runs, so the
383
+ // instruction must point at the post-move path, not the vanished one.
384
+ const current = source.rel.startsWith("memory/")
385
+ ? `${MEMORY_DIR}/${source.rel.slice("memory/".length)}`
386
+ : source.rel;
387
+ detail.push(`Merge \`${current}\` into \`${PLAN_FILE}\`; remove the source afterwards.`);
388
+ }
389
+ detail.push(
390
+ "Keep: route/milestones still open, open questions, and decisions that still bind.",
391
+ "Move decision records to `.agents/memory/ARCHITECTURE.md` (leave a pointer).",
392
+ "Move closed work to `.agents/memory/FINISHED.md` as summary + evidence pointer.",
393
+ "Progress lists stay out of both files (todo tool owns progress).",
394
+ );
395
+ return {
396
+ id: moved ? "edit-plan" : "merge-plan",
397
+ kind: "merge-plan",
398
+ targets: [
399
+ ...(moved ? [moved.rel] : []),
400
+ ...sources.map((source) =>
401
+ source.rel.startsWith("memory/")
402
+ ? `${MEMORY_DIR}/${source.rel.slice("memory/".length)}`
403
+ : source.rel,
404
+ ),
405
+ PLAN_FILE,
406
+ ],
407
+ detail: detail.join("\n"),
408
+ acceptance: [
409
+ `\`${PLAN_FILE}\` contains every still-open item from every source`,
410
+ "no completed items and no progress table remain in the plan",
411
+ "nothing was deleted without a coverage-table row",
412
+ ],
413
+ };
414
+ }
415
+
416
+ function triageTodosTask(facts: Facts): Task | undefined {
417
+ if (!facts.retiredTodos) return undefined;
418
+ const specs = facts.specs.map((doc) => doc.rel.replace(`${SPECS_DIR}/`, "").replace(/\.md$/, ""));
419
+ const byTodo = facts.todos.map((todo) => {
420
+ const slug = todo.rel.replace(/^todos\//, "").replace(/^\d{4}-\d{2}-\d{2}-/, "").replace(/\.md$/, "");
421
+ const tokens = slug.split("-").filter((token) => token.length >= 4);
422
+ const match = specs.find(
423
+ (name) =>
424
+ name.includes(slug) ||
425
+ slug.includes(name) ||
426
+ tokens.some((token) => name.includes(token)),
427
+ );
428
+ return `- ${todo.rel} → ${match ? `${SPECS_DIR}/${match}.md` : "new spec draft"}`;
429
+ });
430
+ return {
431
+ id: "triage-todos",
432
+ kind: "triage-todos",
433
+ targets: [SPECS_DIR],
434
+ detail: [
435
+ "`todos/` is retired. Each requirement becomes a spec:",
436
+ ...byTodo,
437
+ "For an existing spec, append the verbatim wording under `## Requirement (verbatim)`.",
438
+ "For a new one, create `specs/<unit>.md` from the spec template and keep the wording verbatim.",
439
+ "Only then delete `todos/` (the snapshot still has the originals).",
440
+ ].join("\n"),
441
+ acceptance: [
442
+ "every todos/ file has a destination spec with its original wording",
443
+ "`todos/` no longer exists",
444
+ ],
445
+ };
446
+ }
447
+
448
+ function rootCardsTask(facts: Facts): Task | undefined {
449
+ const missing = facts.roots.filter((root) => root.agentsMd === "none");
450
+ if (missing.length === 0) return undefined;
451
+ return {
452
+ id: "root-cards",
453
+ kind: "root-cards",
454
+ targets: missing.map((root) => join(root.absolute, "AGENTS.md")),
455
+ detail: [
456
+ "Create a scope card (≤30 lines) in each root that has none:",
457
+ ...missing.map((root) => `- ${root.label}: \`${root.absolute}/AGENTS.md\``),
458
+ "Content: what lives here, its commands, and which main-repo memory file describes it.",
459
+ "Never copy the framework rules into a root: pi-add-dir injects each root AGENTS.md verbatim into every request.",
460
+ "Writes outside the main repository are ordinary tool calls and therefore subject to the permission gate.",
461
+ ].join("\n"),
462
+ acceptance: ["each scope card is ≤30 lines", "no rule is duplicated from the main AGENTS.md"],
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Non-plan legacy documents need an explicit destination too. Without this task
468
+ * they would appear in the coverage table but nobody would be asked to move
469
+ * their content — the table would be a checklist without an owner.
470
+ */
471
+ function legacyDocsTask(facts: Facts): Task | undefined {
472
+ const docs = facts.legacyDocs.filter((doc) => doc.rel !== "PLAN.md");
473
+ if (docs.length === 0) return undefined;
474
+ const destinationFor = (rel: string): string => {
475
+ if (/^(TODO|TASKS|ROADMAP)\.md$/i.test(rel)) return PLAN_FILE;
476
+ if (/^(adr|decisions|docs\/adr|docs\/decisions)\//.test(rel)) {
477
+ return `${MEMORY_DIR}/ARCHITECTURE.md (decision records)`;
478
+ }
479
+ return `${MEMORY_DIR}/ARCHITECTURE.md or docs/ (whichever owns the content)`;
480
+ };
481
+ return {
482
+ id: "merge-legacy-docs",
483
+ kind: "merge-legacy",
484
+ targets: docs.map((doc) => doc.rel),
485
+ detail: [
486
+ "Migrate these legacy documents instead of leaving them beside the framework:",
487
+ ...docs.map((doc) => `- ${doc.rel} → ${destinationFor(doc.rel)}`),
488
+ "Keep decisions as decisions, open work as open work, and prose as documentation; do not flatten everything into one file.",
489
+ "Only remove a source after its coverage rows say where each section went.",
490
+ ].join("\n"),
491
+ acceptance: [
492
+ "every listed document has a destination or a written reason to keep it",
493
+ "no open item is lost and no decision is silently dropped",
494
+ ],
495
+ };
496
+ }
497
+
498
+ function coverageTask(facts: Facts, now: Date): Task | undefined {
499
+ const sources = [
500
+ ...(facts.context.agents ? [AGENTS_FILE] : []),
501
+ ...facts.legacyMemory.map((doc) => doc.rel),
502
+ ...facts.legacyDocs.map((doc) => doc.rel),
503
+ ...facts.todos.map((doc) => doc.rel),
504
+ ];
505
+ if (sources.length === 0) return undefined;
506
+ const report = `${REPORTS_DIR}/coverage-${runStamp(iso(now))}.md`;
507
+ return {
508
+ id: "coverage",
509
+ kind: "coverage",
510
+ targets: [report],
511
+ detail: [
512
+ "Fill the coverage table: one row per source section (`##`/`###`) with destination or reason.",
513
+ `sources: ${sources.join(", ")}`,
514
+ `report: ${report} (table columns: source#heading | destination | reason)`,
515
+ `Then run the framework_audit tool; it reports unaccounted sections and budget/duplication findings.`,
516
+ ].join("\n"),
517
+ acceptance: [
518
+ "0 unaccounted source sections",
519
+ "every 'dropped' row explains why the fact is no longer needed",
520
+ ],
521
+ };
522
+ }
523
+
524
+ /* -------------------------------------------------------------------------- */
525
+ /* Build */
526
+ /* -------------------------------------------------------------------------- */
527
+
528
+ export function buildPlan(facts: Facts, options: PlanOptions): Plan {
529
+ const now = options.now ?? new Date();
530
+ const language = facts.language;
531
+ const deep = options.deep === true;
532
+ const mode = selectMode(facts);
533
+ const warnings = [...facts.warnings];
534
+ if (facts.isGit && facts.gitClean === false) {
535
+ warnings.push(
536
+ `worktree is not clean (${facts.gitDirtyCount} entries): commit or stash first so the migration diff stays reviewable`,
537
+ );
538
+ }
539
+
540
+ if (mode === "audit-only") {
541
+ return {
542
+ mode,
543
+ root: facts.root,
544
+ language,
545
+ frameworkVersion: FRAMEWORK_VERSION,
546
+ toolVersion: options.toolVersion,
547
+ createdAt: iso(now),
548
+ actions: [],
549
+ tasks: [],
550
+ artifacts: {},
551
+ summary: [
552
+ `framework v${FRAMEWORK_VERSION} is installed and matches the layout`,
553
+ "nothing to migrate — run an audit instead",
554
+ ],
555
+ warnings,
556
+ facts,
557
+ };
558
+ }
559
+
560
+ const actions: Action[] = [];
561
+ const tasks: Task[] = [];
562
+ const summary: string[] = [];
563
+
564
+ /* Snapshot every path this plan may touch. */
565
+ const snapshotPaths = [
566
+ AGENTS_FILE,
567
+ CLAUDE_FILE,
568
+ facts.deprecated.memory ? "memory" : "",
569
+ facts.deprecated.implementations ? "implementations" : "",
570
+ facts.deprecated.prompts ? "prompts" : "",
571
+ facts.retiredTodos ? "todos" : "",
572
+ PLAN_FILE,
573
+ STATE_FILE,
574
+ ...facts.legacyDocs.map((doc) => doc.rel),
575
+ ...facts.memory.map((doc) => doc.rel),
576
+ // The rewrite can touch any file that mentions a legacy path, so the
577
+ // snapshot has to cover exactly those files: that is what makes the
578
+ // "nothing is written before it is snapshotted" guarantee true.
579
+ ...facts.refs.paths,
580
+ ]
581
+ .filter((value, index, all) => value.length > 0 && all.indexOf(value) === index)
582
+ .filter((rel) => existsSync(join(facts.root, rel)));
583
+ if (snapshotPaths.length > 0) {
584
+ actions.push({
585
+ kind: "snapshot",
586
+ paths: snapshotPaths,
587
+ files: 0,
588
+ });
589
+ }
590
+
591
+ /* Moves first: a destination must not be pre-created by the scaffold, or the
592
+ * move sees a non-empty target and refuses. A conflict is never auto-merged:
593
+ * it becomes a model task. */
594
+ const moves: Array<{ from: string; to: string; files: number }> = [];
595
+ const conflicts: string[] = [];
596
+ if (facts.deprecated.memory) {
597
+ if (facts.dirs[MEMORY_DIR]) conflicts.push("memory/ and .agents/memory/ both exist");
598
+ else
599
+ moves.push({
600
+ from: "memory",
601
+ to: MEMORY_DIR,
602
+ files: facts.deprecatedFiles.memory,
603
+ });
604
+ }
605
+ if (facts.deprecated.implementations) {
606
+ if (facts.dirs[IMPLEMENTATIONS_DIR])
607
+ conflicts.push("implementations/ and .agents/implementations/ both exist");
608
+ else
609
+ moves.push({
610
+ from: "implementations",
611
+ to: IMPLEMENTATIONS_DIR,
612
+ files: facts.deprecatedFiles.implementations,
613
+ });
614
+ }
615
+ if (facts.deprecated.prompts) {
616
+ if (facts.dirs[PROMPTS_DIR]) conflicts.push("prompts/ and .agents/prompts/ both exist");
617
+ else moves.push({ from: "prompts", to: PROMPTS_DIR, files: facts.deprecatedFiles.prompts });
618
+ }
619
+ // The plan file changes home too. Moving it as a file keeps the content
620
+ // byte-identical; the model only shapes it afterwards. This must happen
621
+ // before the directory move (the source would be gone).
622
+ const planMove = planMoveSource(facts);
623
+ if (planMove) actions.push({ kind: "move-file", from: planMove.rel, to: PLAN_FILE });
624
+
625
+ for (const move of moves) {
626
+ actions.push({
627
+ kind: "move-dir",
628
+ from: move.from,
629
+ to: move.to,
630
+ files: move.files,
631
+ trackedOnly: facts.isGit,
632
+ });
633
+ }
634
+
635
+ /* Parent directories only. Move destinations are created by the move itself,
636
+ * otherwise a pre-created destination would look like a conflict. */
637
+ const dirs = [".agents", FRAMEWORK_DIR, SPECS_DIR].filter(
638
+ (rel) => !existsSync(join(facts.root, rel)),
639
+ );
640
+ if (!moves.some((move) => move.to === MEMORY_DIR)) dirs.push(MEMORY_DIR);
641
+ if (!moves.some((move) => move.to === IMPLEMENTATIONS_DIR)) dirs.push(IMPLEMENTATIONS_DIR);
642
+ const missingDirs = dirs.filter(
643
+ (rel, index) => dirs.indexOf(rel) === index && !existsSync(join(facts.root, rel)),
644
+ );
645
+ if (missingDirs.length > 0) actions.push({ kind: "create-dirs", paths: missingDirs });
646
+ for (const conflict of conflicts) warnings.push(`conflict: ${conflict} — merge by hand (task merge-legacy)`);
647
+ if (conflicts.length > 0) {
648
+ tasks.push({
649
+ id: "merge-legacy",
650
+ kind: "merge-legacy",
651
+ detail: [
652
+ "Two locations for the same framework directory exist. Merge deliberately:",
653
+ ...conflicts.map((conflict) => `- ${conflict}`),
654
+ "Keep the canonical `.agents/` copy as the destination; carry over anything unique; leave no duplicate fact.",
655
+ ].join("\n"),
656
+ acceptance: ["the legacy directory is gone", "the coverage table lists every merged file"],
657
+ });
658
+ }
659
+
660
+ /* Reference rewrite pairs, longest first. */
661
+ const pairs: Array<{ from: string; to: string }> = [];
662
+ if (facts.legacyMemory.some((doc) => doc.rel === "memory/PLAN.md")) {
663
+ pairs.push({ from: "memory/PLAN.md", to: PLAN_FILE });
664
+ }
665
+ if (facts.memory.some((doc) => doc.rel === `${MEMORY_DIR}/PLAN.md`)) {
666
+ pairs.push({ from: `${MEMORY_DIR}/PLAN.md`, to: PLAN_FILE });
667
+ }
668
+ for (const move of moves) pairs.push({ from: `${move.from}/`, to: `${move.to}/` });
669
+
670
+ /* Scaffold. `created` feeds the index block, so it is built first. */
671
+ const creates: CreateFileAction[] = [];
672
+ const created = new Set<string>();
673
+ const planWillExist =
674
+ existsSync(join(facts.root, PLAN_FILE)) ||
675
+ facts.memory.some((doc) => doc.rel === `${MEMORY_DIR}/PLAN.md`) ||
676
+ facts.legacyMemory.some((doc) => doc.rel === "memory/PLAN.md") ||
677
+ facts.legacyDocs.some((doc) => doc.rel === "PLAN.md");
678
+ const add = (
679
+ path: string,
680
+ template: string,
681
+ tokens: Record<string, string>,
682
+ note: string,
683
+ ): void => {
684
+ if (existsSync(join(facts.root, path))) return;
685
+ creates.push({ kind: "create-file", path, template, tokens, note });
686
+ created.add(path);
687
+ };
688
+
689
+ if (!planWillExist) add(PLAN_FILE, "plan", {}, "plan home");
690
+ add(
691
+ SIGNPOST_FILE,
692
+ "signpost",
693
+ { frameworkVersion: String(FRAMEWORK_VERSION) },
694
+ "signpost for the hidden agent directory",
695
+ );
696
+ add(CONTRACT_FILE, "memory-readme", { frameworkVersion: String(FRAMEWORK_VERSION) }, "framework contract");
697
+ add(STATE_GITIGNORE, "state-gitignore", {}, "ignore runtime state");
698
+ for (const spec of MEMORY_FILES) {
699
+ if (!spec.core) continue;
700
+ const dest = `${MEMORY_DIR}/${spec.file}`;
701
+ const legacy = `memory/${spec.file}`;
702
+ if (existsSync(join(facts.root, dest)) || existsSync(join(facts.root, legacy))) continue;
703
+ add(dest, `seed-${spec.role}`, {}, `${spec.owns}`);
704
+ }
705
+ if (!facts.context.agents) {
706
+ // Fresh (or CLAUDE-only): create the entry skeleton. For an existing
707
+ // CLAUDE.md the task list imports its unique content afterwards.
708
+ add(AGENTS_FILE, "agents-skeleton", {}, "framework entry");
709
+ }
710
+
711
+ const tokens = buildTokens(facts, now, created);
712
+ for (const action of creates) {
713
+ actions.push({
714
+ ...action,
715
+ tokens: { ...tokens, ...action.tokens } as Record<string, string>,
716
+ });
717
+ }
718
+
719
+ /* Rewrite last: it also normalises the files this run just created (the
720
+ * contract and the signpost quote canonical paths), so a second run finds
721
+ * nothing left to rewrite. */
722
+ if (pairs.length > 0 && facts.refs.hits > 0) {
723
+ actions.push({
724
+ kind: "rewrite-refs",
725
+ pairs,
726
+ files: facts.refs.paths.length,
727
+ hits: facts.refs.hits,
728
+ });
729
+ }
730
+
731
+ /* Tasks. */
732
+ if (mode !== "upgrade") tasks.push(surveyTask(facts));
733
+ const edit = editAgentsTask(facts, deep);
734
+ if (edit) tasks.push(edit);
735
+ else if (!facts.context.agents) {
736
+ tasks.push({
737
+ id: "fill-agents",
738
+ kind: "fill-file",
739
+ targets: [AGENTS_FILE],
740
+ detail: [
741
+ "Fill the AGENTS.md skeleton from evidence: positioning, hard rules, commands, layout, change-impact index, terms.",
742
+ "Replace every `TODO(ask-init)` marker; leave nothing invented — use `TBD` when the repo does not say.",
743
+ ].join("\n"),
744
+ acceptance: [
745
+ "no TODO(ask-init) marker remains",
746
+ "the memory index points at files that exist",
747
+ ],
748
+ });
749
+ }
750
+ if (facts.context.claude && facts.context.agents) {
751
+ const agentsLines = new Set(
752
+ facts.context.agents.text
753
+ .split("\n")
754
+ .map((line) => line.trim())
755
+ .filter((line) => line.length > 20),
756
+ );
757
+ const claudeLines = facts.context.claude.text
758
+ .split("\n")
759
+ .map((line) => line.trim())
760
+ .filter((line) => line.length > 20);
761
+ const shared = claudeLines.filter((line) => agentsLines.has(line)).length;
762
+ const overlap = claudeLines.length > 0 ? shared / claudeLines.length : 0;
763
+ if (overlap >= 0.6) {
764
+ tasks.push({
765
+ id: "dedupe-claude",
766
+ kind: "merge-legacy",
767
+ targets: [CLAUDE_FILE, AGENTS_FILE],
768
+ detail: [
769
+ `${CLAUDE_FILE} duplicates ${Math.round(overlap * 100)}% of AGENTS.md.`,
770
+ "pi loads AGENTS.md and ignores CLAUDE.md in the same directory; other tools read CLAUDE.md.",
771
+ "Keep AGENTS.md canonical and reduce CLAUDE.md to a short pointer, unless it carries rules that are specific to another tool.",
772
+ ].join("\n"),
773
+ acceptance: [
774
+ "no rule exists only in CLAUDE.md",
775
+ "CLAUDE.md no longer duplicates AGENTS.md",
776
+ ],
777
+ });
778
+ }
779
+ }
780
+ if (facts.context.claude && !facts.context.agents) {
781
+ tasks.push({
782
+ id: "merge-claude",
783
+ kind: "merge-legacy",
784
+ targets: [CLAUDE_FILE, AGENTS_FILE],
785
+ detail: [
786
+ "`CLAUDE.md` exists but `AGENTS.md` is the pi entry.",
787
+ "Carry every unique rule into AGENTS.md (or the memory file that owns it), then reduce CLAUDE.md to a short pointer so the two cannot drift.",
788
+ ].join("\n"),
789
+ acceptance: ["no rule exists only in CLAUDE.md", "CLAUDE.md no longer duplicates AGENTS.md"],
790
+ });
791
+ }
792
+ const mergePlan = mergePlanTask(facts);
793
+ if (mergePlan) tasks.push(mergePlan);
794
+ const legacyDocs = legacyDocsTask(facts);
795
+ if (legacyDocs) tasks.push(legacyDocs);
796
+ const triage = triageTodosTask(facts);
797
+ if (triage) tasks.push(triage);
798
+ const roots = rootCardsTask(facts);
799
+ if (roots) tasks.push(roots);
800
+ const budget = budgetTask(facts);
801
+ if (budget) tasks.push(budget);
802
+ const coverage = coverageTask(facts, now);
803
+ if (coverage) tasks.push(coverage);
804
+
805
+ /* State last: it records what this run created and moved. */
806
+ actions.push({ kind: "write-state", state: buildState(facts, now, options.toolVersion, mode) });
807
+
808
+ /* Summary for the confirmation panel. */
809
+ const modeLine: Record<PlanMode, string> = {
810
+ fresh: "fresh project: install framework v1",
811
+ adopt: "existing project: adopt framework v1 (conservative pass)",
812
+ reconcile: "framework present but layout drifted: reconcile",
813
+ upgrade: `framework ${facts.state?.version ?? 0} → ${FRAMEWORK_VERSION}: upgrade`,
814
+ "audit-only": "already installed: audit only",
815
+ unsupported: "framework is newer than this tool: refusing",
816
+ };
817
+ summary.push(modeLine[mode]);
818
+ for (const move of moves) {
819
+ summary.push(`move ${move.from}/ → ${move.to}/ (${move.files} files)`);
820
+ }
821
+ if (planMove) summary.push(`move ${planMove.rel} → ${PLAN_FILE} (content untouched)`);
822
+ const rewrite = actions.find((action) => action.kind === "rewrite-refs");
823
+ if (rewrite && rewrite.kind === "rewrite-refs") {
824
+ summary.push(
825
+ `rewrite ${rewrite.hits} path references in ${rewrite.files} files (dry-run counts)`,
826
+ );
827
+ }
828
+ if (creates.length > 0) {
829
+ summary.push(`create ${creates.length} files (contract, signpost, seeds, state)`);
830
+ }
831
+ if (tasks.length > 0) {
832
+ summary.push(`hand ${tasks.length} tasks to the model: ${tasks.map((task) => task.id).join(", ")}`);
833
+ }
834
+ if (snapshotPaths.length > 0) {
835
+ summary.push(`snapshot ${snapshotPaths.length} paths before writing`);
836
+ }
837
+ if (mode === "upgrade") {
838
+ summary.push(
839
+ "no upgrade recipe exists for this version pair: review `.agents/memory/README.md` and migrate by hand",
840
+ );
841
+ }
842
+
843
+ return {
844
+ mode,
845
+ root: facts.root,
846
+ language,
847
+ frameworkVersion: FRAMEWORK_VERSION,
848
+ toolVersion: options.toolVersion,
849
+ createdAt: iso(now),
850
+ actions,
851
+ tasks,
852
+ artifacts: {
853
+ plan: `${PLANS_DIR}/plan-${runStamp(iso(now))}.json`,
854
+ coverage: coverage ? coverage.targets?.[0] : undefined,
855
+ rewrite: pairs.length > 0 ? `${REPORTS_DIR}/rewrite-${runStamp(iso(now))}.md` : undefined,
856
+ snapshot: snapshotPaths.length > 0 ? `${SNAPSHOTS_DIR}/${runStamp(iso(now))}` : undefined,
857
+ },
858
+ summary,
859
+ warnings,
860
+ facts,
861
+ };
862
+ }
863
+
864
+ /** Roots that are registered but missing on disk (used by the plan and audit). */
865
+ export function staleRoots(facts: Facts): RootInfo[] {
866
+ return facts.roots.filter((root) => !root.exists);
867
+ }