@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,569 @@
1
+ /**
2
+ * init feature — `/ask-init`.
3
+ *
4
+ * One command, two jobs:
5
+ *
6
+ * 1. **Install** the agent-memory framework into a project (fresh), and
7
+ * 2. **Adopt / reconcile** it where documentation already exists — moving the
8
+ * old agent-only directories under `.agents/`, rewriting the path
9
+ * references, and handing the prose work to the model as a bounded work
10
+ * order.
11
+ *
12
+ * The split is deliberate: everything mechanical is code (deterministic,
13
+ * snapshot-first, idempotent), everything that needs judgement is a task. That
14
+ * keeps the command cheap, the diff reviewable, and "nothing was lost" checkable
15
+ * through the coverage table instead of trust.
16
+ *
17
+ * Guards, in order:
18
+ * - never in the operator's home directory or at the filesystem root;
19
+ * - never while the agent is streaming (a plan needs a stable tree);
20
+ * - never without `write`/`edit` in the active tool set (`/ask` mode would
21
+ * reject the model's own work);
22
+ * - never writes without an explicit decision (TUI panel; no UI ⇒ report only).
23
+ */
24
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { basename, dirname, join, resolve } from "node:path";
27
+ import type {
28
+ ExtensionAPI,
29
+ ExtensionCommandContext,
30
+ ExtensionContext,
31
+ } from "@earendil-works/pi-coding-agent";
32
+ import { Text } from "@earendil-works/pi-tui";
33
+ import { stateToToml } from "./apply.js";
34
+ import { applyPlan } from "./apply.js";
35
+ import { audit, countFindings, renderFindings } from "./audit.js";
36
+ import { detect } from "./detect.js";
37
+ import { buildWorkOrder } from "./dispatch.js";
38
+ import { buildPlan, iso, runStamp } from "./plan.js";
39
+ import { rootsBlock } from "./render.js";
40
+ import {
41
+ FRAMEWORK_DIR,
42
+ FRAMEWORK_VERSION,
43
+ REPORTS_DIR,
44
+ STATE_FILE,
45
+ type ProjectLanguage,
46
+ } from "./spec.js";
47
+ import { FRAMEWORK_AUDIT_TOOL } from "./tool.js";
48
+ import { confirmPlan, showReport } from "./ui.js";
49
+ import type { ApplyResult, Plan } from "./types.js";
50
+ import { registerAuditTool } from "./tool.js";
51
+
52
+ export interface InitOptions {
53
+ /** Test seam; defaults to `ctx.cwd`. */
54
+ readonly root?: string;
55
+ /** Test seam for pi-add-dir roots; defaults to the session branch entry. */
56
+ readonly addedRoots?: Array<{ label: string; absolute: string }>;
57
+ readonly now?: Date;
58
+ /** Test seam; defaults to the package version. */
59
+ readonly toolVersion?: string;
60
+ }
61
+
62
+ export interface ParsedInitArgs {
63
+ readonly sub: "run" | "plan" | "check" | "roots" | "help";
64
+ readonly dryRun: boolean;
65
+ readonly deep: boolean;
66
+ readonly yes: boolean;
67
+ readonly lang?: ProjectLanguage;
68
+ /** Unknown arguments are an error, never a silent full run. */
69
+ readonly error?: string;
70
+ }
71
+
72
+ const USAGE = [
73
+ "/ask-init detect → confirm → scaffold → hand content work to the model",
74
+ "/ask-init plan [--deep] show the plan only (writes nothing)",
75
+ "/ask-init check audit the installed framework (read-only)",
76
+ "/ask-init roots sync pi-add-dir roots into the framework state",
77
+ "/ask-init --dry-run same as plan",
78
+ "/ask-init --yes skip the confirmation panel",
79
+ "/ask-init --lang zh|en language for generated prose",
80
+ ].join("\n");
81
+
82
+ export function parseInitArgs(input: string): ParsedInitArgs {
83
+ const tokens = input.trim().split(/\s+/).filter((token) => token.length > 0);
84
+ let sub: ParsedInitArgs["sub"] = "run";
85
+ let dryRun = false;
86
+ let deep = false;
87
+ let yes = false;
88
+ let lang: ProjectLanguage | undefined;
89
+ const unknown: string[] = [];
90
+ for (let index = 0; index < tokens.length; index += 1) {
91
+ const token = tokens[index];
92
+ if (token === "plan" || token === "check" || token === "roots" || token === "help") {
93
+ sub = token;
94
+ continue;
95
+ }
96
+ if (token === "run") continue;
97
+ if (token === "--dry-run") {
98
+ dryRun = true;
99
+ continue;
100
+ }
101
+ if (token === "--deep") {
102
+ deep = true;
103
+ continue;
104
+ }
105
+ if (token === "--yes" || token === "-y") {
106
+ yes = true;
107
+ continue;
108
+ }
109
+ if (token === "--lang") {
110
+ const value = tokens[index + 1];
111
+ if (value === "zh" || value === "en") {
112
+ lang = value;
113
+ index += 1;
114
+ } else {
115
+ unknown.push(`${token} ${value ?? "(missing value)"}`);
116
+ index += 1;
117
+ }
118
+ continue;
119
+ }
120
+ if (token.startsWith("--lang=")) {
121
+ const value = token.slice("--lang=".length);
122
+ if (value === "zh" || value === "en") lang = value;
123
+ else unknown.push(token);
124
+ continue;
125
+ }
126
+ unknown.push(token);
127
+ }
128
+ if (dryRun && sub === "run") sub = "plan";
129
+ return {
130
+ sub,
131
+ dryRun,
132
+ deep,
133
+ yes,
134
+ lang,
135
+ error:
136
+ unknown.length > 0
137
+ ? `unknown argument(s): ${unknown.join(", ")} — nothing was done`
138
+ : undefined,
139
+ };
140
+ }
141
+
142
+ /* -------------------------------------------------------------------------- */
143
+ /* Session roots */
144
+ /* -------------------------------------------------------------------------- */
145
+
146
+ interface AddDirState {
147
+ dirs?: Array<{ label?: string; absolutePath?: string }>;
148
+ }
149
+
150
+ /** pi-add-dir records its directory list as a session custom entry. */
151
+ export function addedRootsFromBranch(ctx: ExtensionContext): Array<{
152
+ label: string;
153
+ absolute: string;
154
+ }> {
155
+ let entries: unknown[] = [];
156
+ try {
157
+ entries = ctx.sessionManager.getBranch() as unknown[];
158
+ } catch {
159
+ return [];
160
+ }
161
+ let dirs: AddDirState["dirs"] = [];
162
+ for (const entry of entries) {
163
+ const record = entry as {
164
+ type?: string;
165
+ customType?: string;
166
+ data?: AddDirState;
167
+ };
168
+ if (record.type !== "custom" || record.customType !== "add-dir:state") continue;
169
+ if (Array.isArray(record.data?.dirs)) dirs = record.data.dirs;
170
+ }
171
+ return (dirs ?? [])
172
+ .map((dir) => ({
173
+ label: typeof dir?.label === "string" && dir.label.length > 0 ? dir.label : basename(String(dir?.absolutePath ?? "")),
174
+ absolute: typeof dir?.absolutePath === "string" ? dir.absolutePath : "",
175
+ }))
176
+ .filter((dir) => dir.absolute.length > 0);
177
+ }
178
+
179
+ /* -------------------------------------------------------------------------- */
180
+ /* Helpers */
181
+ /* -------------------------------------------------------------------------- */
182
+
183
+ function packageVersion(): string {
184
+ try {
185
+ const raw = readFileSync(new URL("../../../package.json", import.meta.url), "utf8");
186
+ const parsed = JSON.parse(raw) as { version?: string };
187
+ return parsed.version ?? "0.0.0";
188
+ } catch {
189
+ return "0.0.0";
190
+ }
191
+ }
192
+
193
+ function guardRoot(root: string): string | undefined {
194
+ const absolute = resolve(root);
195
+ if (absolute === resolve("/") || absolute === resolve(homedir())) {
196
+ return "refusing to install a project framework at the filesystem root or in the home directory";
197
+ }
198
+ if (!existsSync(absolute)) return `directory does not exist: ${absolute}`;
199
+ return undefined;
200
+ }
201
+
202
+ function guardTools(pi: ExtensionAPI): string | undefined {
203
+ const active = new Set(pi.getActiveTools());
204
+ const missing = ["write", "edit"].filter((name) => !active.has(name));
205
+ if (missing.length === 0) return undefined;
206
+ return `${missing.join("/")} is not active in this session (ask mode?) — leave that mode first, then run /ask-init`;
207
+ }
208
+
209
+ function activateAuditTool(pi: ExtensionAPI): void {
210
+ const active = pi.getActiveTools();
211
+ if (active.includes(FRAMEWORK_AUDIT_TOOL)) return;
212
+ pi.setActiveTools([...active, FRAMEWORK_AUDIT_TOOL]);
213
+ }
214
+
215
+ function deactivateAuditTool(pi: ExtensionAPI): void {
216
+ const active = pi.getActiveTools();
217
+ if (!active.includes(FRAMEWORK_AUDIT_TOOL)) return;
218
+ pi.setActiveTools(active.filter((name) => name !== FRAMEWORK_AUDIT_TOOL));
219
+ }
220
+
221
+ function writeRuntimeReport(root: string, name: string, text: string): string | undefined {
222
+ if (!existsSync(join(root, FRAMEWORK_DIR))) return undefined;
223
+ const rel = `${REPORTS_DIR}/${name}`;
224
+ const absolute = join(root, rel);
225
+ mkdirSync(join(root, REPORTS_DIR), { recursive: true });
226
+ writeFileSync(absolute, text, "utf8");
227
+ return rel;
228
+ }
229
+
230
+ function planText(plan: Plan): string {
231
+ const lines = [
232
+ `/ask-init — ${plan.mode}`,
233
+ `root: ${plan.root}`,
234
+ `framework: v${plan.frameworkVersion} · language: ${plan.language}`,
235
+ "",
236
+ "## plan",
237
+ ...plan.summary.map((line) => `- ${line}`),
238
+ ];
239
+ if (plan.warnings.length > 0) {
240
+ lines.push("", "## warnings", ...plan.warnings.map((line) => `- ${line}`));
241
+ }
242
+ if (plan.actions.length > 0) {
243
+ lines.push("", "## mechanical actions");
244
+ for (const action of plan.actions) {
245
+ switch (action.kind) {
246
+ case "snapshot":
247
+ lines.push(`- snapshot ${action.paths.length} paths`);
248
+ break;
249
+ case "move-dir":
250
+ lines.push(`- move ${action.from}/ → ${action.to}/ (${action.files} files)`);
251
+ break;
252
+ case "move-file":
253
+ lines.push(`- move ${action.from} → ${action.to} (content untouched)`);
254
+ break;
255
+ case "rewrite-refs":
256
+ lines.push(
257
+ `- rewrite ${action.pairs.map((pair) => `${pair.from}→${pair.to}`).join(", ")} (${action.hits} refs in ${action.files} files)`,
258
+ );
259
+ break;
260
+ case "create-dirs":
261
+ lines.push(`- create dirs ${action.paths.join(", ")}`);
262
+ break;
263
+ case "create-file":
264
+ lines.push(`- create ${action.path} (${action.note})`);
265
+ break;
266
+ case "write-state":
267
+ lines.push("- write .agents/framework/state.toml");
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ if (plan.tasks.length > 0) {
273
+ lines.push("", "## model tasks");
274
+ for (const task of plan.tasks) lines.push(`- ${task.id}${task.targets ? ` [${task.targets.join(", ")}]` : ""}`);
275
+ }
276
+ return lines.join("\n");
277
+ }
278
+
279
+ function appliedText(applied: ApplyResult): string {
280
+ const lines = ["## applied"];
281
+ if (applied.snapshot) {
282
+ lines.push(`- snapshot: ${applied.snapshot.dir} (${applied.snapshot.files} files)`);
283
+ }
284
+ for (const move of applied.moved) {
285
+ lines.push(`- moved ${move.from}/ → ${move.to}/ (${move.files} files)`);
286
+ }
287
+ if (applied.rewrites) {
288
+ lines.push(
289
+ `- rewrote ${applied.rewrites.hits} references in ${applied.rewrites.files} files → ${applied.rewrites.report}`,
290
+ );
291
+ }
292
+ if (applied.created.length > 0) {
293
+ lines.push(`- created: ${applied.created.join(", ")}`);
294
+ }
295
+ if (applied.statePath) lines.push(`- state: ${applied.statePath}`);
296
+ if (applied.planPath) lines.push(`- work order: ${applied.planPath}`);
297
+ for (const skipped of applied.skipped) {
298
+ lines.push(`- skipped ${skipped.path} (${skipped.reason})`);
299
+ }
300
+ return lines.join("\n");
301
+ }
302
+
303
+ /* -------------------------------------------------------------------------- */
304
+ /* Command */
305
+ /* -------------------------------------------------------------------------- */
306
+
307
+ /**
308
+ * Write the roots observed in this session into the framework state, and show the
309
+ * table. Only the state file is touched: the AGENTS.md roots section is a model
310
+ * task (it needs prose and the change-impact context).
311
+ */
312
+ async function syncRoots(
313
+ ctx: ExtensionContext,
314
+ root: string,
315
+ facts: ReturnType<typeof detect>,
316
+ now: Date,
317
+ ): Promise<void> {
318
+ if (!facts.state) {
319
+ ctx.ui.notify("ask-init roots: no framework state yet — run /ask-init first.", "warning");
320
+ return;
321
+ }
322
+ const before = facts.state.roots.map((entry) => `${entry.label}|${entry.absolute}`).join("\n");
323
+ const after = facts.roots.map((entry) => `${entry.label}|${entry.absolute}`).join("\n");
324
+ if (before === after) {
325
+ ctx.ui.notify(`ask-init roots: up to date (${facts.roots.length} registered).`, "info");
326
+ } else {
327
+ // roots is a small write, but it still changes project state: keep the
328
+ // same snapshot-first rule as the full migration.
329
+ const backup = join(
330
+ root,
331
+ FRAMEWORK_DIR,
332
+ "snapshots",
333
+ `roots-${runStamp(iso(now))}`,
334
+ "files",
335
+ STATE_FILE,
336
+ );
337
+ mkdirSync(dirname(backup), { recursive: true });
338
+ copyFileSync(join(root, STATE_FILE), backup);
339
+ mkdirSync(join(root, FRAMEWORK_DIR), { recursive: true });
340
+ writeFileSync(
341
+ join(root, STATE_FILE),
342
+ stateToToml({ ...facts.state, roots: facts.roots, updated: iso(now) }),
343
+ "utf8",
344
+ );
345
+ ctx.ui.notify(`ask-init roots: ${facts.roots.length} roots written to ${STATE_FILE}.`, "info");
346
+ }
347
+ const table = rootsBlock(facts.roots, facts.language);
348
+ if (table.length > 0) await showReport(ctx, "ask-init roots", table);
349
+ if (facts.roots.length > 0 && facts.context.agents && !/Workspace roots|工作区根/.test(facts.context.agents.text)) {
350
+ ctx.ui.notify(
351
+ "ask-init roots: AGENTS.md has no roots section yet — /ask-init will add it (model task root-cards).",
352
+ "info",
353
+ );
354
+ }
355
+ }
356
+
357
+ async function reportAudit(
358
+ ctx: ExtensionContext,
359
+ root: string,
360
+ facts: ReturnType<typeof detect>,
361
+ now: Date,
362
+ ): Promise<void> {
363
+ const findings = audit(facts);
364
+ const text = renderFindings(findings, { root, frameworkVersion: FRAMEWORK_VERSION });
365
+ const rel = writeRuntimeReport(root, `audit-${runStamp(iso(now))}.md`, text);
366
+ const counts = countFindings(findings);
367
+ ctx.ui.notify(
368
+ `ask-init check: ${counts.errors} errors, ${counts.warnings} warnings, ${counts.infos} info${rel ? ` → ${rel}` : ""}`,
369
+ counts.errors > 0 ? "error" : counts.warnings > 0 ? "warning" : "info",
370
+ );
371
+ await showReport(ctx, "ask-init check", text, { path: rel });
372
+ }
373
+
374
+ /**
375
+ * Apply the deterministic half, record it, then hand the prose work to the
376
+ * model. Activation of the audit tool happens here and is asserted again in
377
+ * `before_agent_start`, because pi rebuilds the tool loadout for the new run.
378
+ */
379
+ async function applyAndDispatch(
380
+ pi: ExtensionAPI,
381
+ ctx: ExtensionContext,
382
+ plan: Plan,
383
+ runtime: { auditActive: boolean },
384
+ now: Date,
385
+ ): Promise<ApplyResult> {
386
+ const applied = applyPlan(plan, { now });
387
+ runtime.auditActive = true;
388
+ activateAuditTool(pi);
389
+
390
+ pi.appendEntry("ask-init:run", {
391
+ mode: plan.mode,
392
+ summary: [...plan.summary, ...applied.summary],
393
+ at: plan.createdAt,
394
+ });
395
+
396
+ ctx.ui.notify(
397
+ `ask-init: ${plan.mode} applied — ${applied.summary.slice(0, 2).join(" · ")}`,
398
+ "info",
399
+ );
400
+ await showReport(ctx, `ask-init ${plan.mode}`, appliedText(applied), {
401
+ path: applied.planPath,
402
+ });
403
+
404
+ pi.sendUserMessage(buildWorkOrder({ plan, applied, planPath: applied.planPath }), {
405
+ expandPromptTemplates: false,
406
+ });
407
+ ctx.ui.notify(
408
+ "ask-init: work order dispatched; run /reload after the run so the new AGENTS.md enters the prompt.",
409
+ "info",
410
+ );
411
+ return applied;
412
+ }
413
+
414
+ /**
415
+ * The command body, kept out of `registerInit` so registration stays a thin
416
+ * table of hooks and the dispatcher can be read on its own.
417
+ */
418
+ function createRunHandler(
419
+ pi: ExtensionAPI,
420
+ options: InitOptions,
421
+ runtime: { auditActive: boolean },
422
+ ) {
423
+ return async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
424
+ const parsed = parseInitArgs(args);
425
+ if (parsed.sub === "help") {
426
+ await showReport(ctx, "/ask-init", USAGE);
427
+ return;
428
+ }
429
+ if (parsed.error) {
430
+ ctx.ui.notify(`ask-init: ${parsed.error}`, "error");
431
+ await showReport(ctx, "/ask-init", USAGE);
432
+ return;
433
+ }
434
+
435
+ const root = options.root ?? ctx.cwd;
436
+ const rootError = guardRoot(root);
437
+ if (rootError) {
438
+ ctx.ui.notify(`ask-init: ${rootError}`, "error");
439
+ return;
440
+ }
441
+ if (!ctx.isIdle()) {
442
+ ctx.ui.notify("ask-init: the agent is still streaming — finish or abort first.", "warning");
443
+ return;
444
+ }
445
+
446
+ const toolVersion = options.toolVersion ?? packageVersion();
447
+ const now = options.now ?? new Date();
448
+ const addedRoots = options.addedRoots ?? addedRootsFromBranch(ctx);
449
+ const facts = detect(root, { addedRoots, lang: parsed.lang });
450
+
451
+ const doCheck = (): Promise<void> => reportAudit(ctx, root, facts, now);
452
+
453
+ if (parsed.sub === "check") {
454
+ await doCheck();
455
+ return;
456
+ }
457
+
458
+ if (parsed.sub === "roots") {
459
+ await syncRoots(ctx, root, facts, now);
460
+ return;
461
+ }
462
+
463
+ const plan = buildPlan(facts, {
464
+ deep: parsed.deep,
465
+ toolVersion,
466
+ now,
467
+ });
468
+
469
+ if (parsed.sub === "plan") {
470
+ await showReport(ctx, "ask-init plan", planText(plan));
471
+ if (plan.warnings.length > 0) {
472
+ ctx.ui.notify(`ask-init plan: ${plan.warnings.length} warning(s) — see the panel.`, "warning");
473
+ }
474
+ ctx.ui.notify("ask-init: report only — nothing was written.", "info");
475
+ return;
476
+ }
477
+
478
+ if (plan.mode === "unsupported") {
479
+ ctx.ui.notify(
480
+ plan.warnings[plan.warnings.length - 1] ??
481
+ "ask-init: this project's framework is newer than this tool.",
482
+ "error",
483
+ );
484
+ await showReport(ctx, "ask-init", planText(plan));
485
+ return;
486
+ }
487
+ if (plan.mode === "upgrade") {
488
+ // There is no v1 upgrade recipe. Reporting is safe; applying a scaffold
489
+ // over an older state file would be a guess, not a migration.
490
+ await showReport(ctx, "ask-init upgrade", planText(plan));
491
+ ctx.ui.notify(
492
+ "ask-init: no automatic upgrade recipe for this state version — nothing was written. Migrate by hand after reviewing .agents/memory/README.md.",
493
+ "warning",
494
+ );
495
+ return;
496
+ }
497
+ if (plan.mode === "audit-only") {
498
+ await doCheck();
499
+ return;
500
+ }
501
+
502
+ const toolError = guardTools(pi);
503
+ if (toolError) {
504
+ ctx.ui.notify(`ask-init: ${toolError}`, "error");
505
+ return;
506
+ }
507
+
508
+ const decision = parsed.yes ? "proceed" : await confirmPlan(ctx, plan);
509
+ if (decision === "cancel") {
510
+ ctx.ui.notify("ask-init: cancelled — nothing was written.", "info");
511
+ return;
512
+ }
513
+ if (decision === "dry-run") {
514
+ await showReport(ctx, "ask-init plan", planText(plan));
515
+ ctx.ui.notify("ask-init: report only — nothing was written.", "info");
516
+ return;
517
+ }
518
+
519
+ await applyAndDispatch(pi, ctx, plan, runtime, now);
520
+ };
521
+ }
522
+
523
+ export function registerInit(pi0: ExtensionAPI, options: InitOptions = {}): void {
524
+ const pi = pi0;
525
+ const runtime = { auditActive: false };
526
+
527
+ registerAuditTool(pi);
528
+
529
+ // ask mode replaces the active tool set at run start (loadout preparation
530
+ // runs after `session_start`), so the audit tool is asserted here as well as
531
+ // at dispatch time — the same lesson the scratchpad tool learned.
532
+ pi.on("before_agent_start", () => {
533
+ if (runtime.auditActive) activateAuditTool(pi);
534
+ });
535
+ pi.on("agent_end", () => {
536
+ if (!runtime.auditActive) return;
537
+ runtime.auditActive = false;
538
+ deactivateAuditTool(pi);
539
+ });
540
+
541
+ pi.registerEntryRenderer<{ mode: string; summary: string[]; at: string }>(
542
+ "ask-init:run",
543
+ (entry) => {
544
+ const data = entry.data as { mode?: string; summary?: string[]; at?: string } | undefined;
545
+ const lines = [
546
+ `ask-init · ${data?.mode ?? "run"} · ${data?.at ?? ""}`,
547
+ ...(data?.summary ?? []).map((line) => ` ${line}`),
548
+ ];
549
+ return new Text(lines.join("\n"));
550
+ },
551
+ );
552
+
553
+ pi.registerCommand("ask-init", {
554
+ description:
555
+ "Install or reconcile the agent memory framework (.agents/ + specs/) and migrate existing docs to it",
556
+ getArgumentCompletions: (prefix: string) =>
557
+ [
558
+ { value: "plan", label: "plan", description: "show the plan only (writes nothing)" },
559
+ { value: "check", label: "check", description: "audit the installed framework" },
560
+ { value: "roots", label: "roots", description: "sync pi-add-dir roots into state" },
561
+ { value: "--dry-run", label: "--dry-run", description: "same as plan" },
562
+ { value: "--deep", label: "--deep", description: "allow structural rewrites" },
563
+ { value: "--yes", label: "--yes", description: "skip the confirmation panel" },
564
+ { value: "--lang zh", label: "--lang zh", description: "Chinese prose" },
565
+ { value: "--lang en", label: "--lang en", description: "English prose" },
566
+ ].filter((item) => item.value.startsWith(prefix)),
567
+ handler: createRunHandler(pi, options, runtime),
568
+ });
569
+ }