@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,264 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { applyPlan, rewriteText, stateToToml } from "./apply.js";
|
|
6
|
+
import { parseState } from "./detect.js";
|
|
7
|
+
import { detect } from "./detect.js";
|
|
8
|
+
import { buildPlan } from "./plan.js";
|
|
9
|
+
import { installedProject, project, remove } from "./test-helpers.js";
|
|
10
|
+
import type { Plan } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const ORIGINAL_PLAN = "# 计划\n\n## 路线\n\n- M1 未完成\n";
|
|
13
|
+
const LEGACY: Record<string, string> = {
|
|
14
|
+
"AGENTS.md": [
|
|
15
|
+
"# demo — 项目指南",
|
|
16
|
+
"",
|
|
17
|
+
"细节记忆放在 `memory/`。",
|
|
18
|
+
"",
|
|
19
|
+
"## 8. 问题清单",
|
|
20
|
+
"",
|
|
21
|
+
"已迁至 `memory/PLAN.md` §5。",
|
|
22
|
+
"",
|
|
23
|
+
].join("\n"),
|
|
24
|
+
"memory/PLAN.md": ORIGINAL_PLAN,
|
|
25
|
+
"memory/FINISHED.md": "# 已完成\n\n## 摘要\n\n- M0:见 `implementations/2026-01-01_a.md`\n",
|
|
26
|
+
"memory/ARCHITECTURE.md": "# 架构\n\n## 分层\n\n- 见 `memory/DESIGN.md`\n",
|
|
27
|
+
"memory/DESIGN.md": "# 视觉\n",
|
|
28
|
+
"implementations/2026-01-01_a.md": "# a\n\n见 `memory/ARCHITECTURE.md`。\n",
|
|
29
|
+
"specs/a.md": "# a\n\n证据:`implementations/2026-01-01_a.md`。\n",
|
|
30
|
+
"todos/2026-01-02-x.md": "# 原始需求\n\n做点事。\n",
|
|
31
|
+
"src/a.ts": "// 见 `memory/DESIGN.md`\n",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function planFor(root: string): Plan {
|
|
35
|
+
return buildPlan(detect(root), {
|
|
36
|
+
toolVersion: "test",
|
|
37
|
+
now: new Date("2026-10-26T10:00:00+08:00"),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("rewriteText", () => {
|
|
42
|
+
const pairs = [
|
|
43
|
+
{ from: "memory/PLAN.md", to: "specs/PLAN.md" },
|
|
44
|
+
{ from: "memory/", to: ".agents/memory/" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
it("applies the longest prefix first", () => {
|
|
48
|
+
const result = rewriteText("see `memory/PLAN.md` and `memory/X.md`", pairs);
|
|
49
|
+
expect(result.text).toBe("see `specs/PLAN.md` and `.agents/memory/X.md`");
|
|
50
|
+
expect(result.hits).toBe(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("leaves already-migrated paths and path tails alone", () => {
|
|
54
|
+
const result = rewriteText("`.agents/memory/X.md` ~/.pi/agent/memory/y", pairs);
|
|
55
|
+
expect(result.text).toBe("`.agents/memory/X.md` ~/.pi/agent/memory/y");
|
|
56
|
+
expect(result.hits).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("applyPlan", () => {
|
|
61
|
+
it("moves the legacy layout, rewrites references and keeps the originals", () => {
|
|
62
|
+
const root = project(LEGACY);
|
|
63
|
+
try {
|
|
64
|
+
const plan = planFor(root);
|
|
65
|
+
const expectedHits = plan.facts.refs.hits;
|
|
66
|
+
const applied = applyPlan(plan, { now: new Date("2026-10-26T10:00:00+08:00") });
|
|
67
|
+
|
|
68
|
+
expect(existsSync(join(root, "memory"))).toBe(false);
|
|
69
|
+
expect(existsSync(join(root, "implementations"))).toBe(false);
|
|
70
|
+
expect(existsSync(join(root, ".agents/memory/FINISHED.md"))).toBe(true);
|
|
71
|
+
expect(existsSync(join(root, ".agents/implementations/2026-01-01_a.md"))).toBe(true);
|
|
72
|
+
|
|
73
|
+
// The plan file moves byte-identically; the model shapes it afterwards.
|
|
74
|
+
expect(readFileSync(join(root, "specs/PLAN.md"), "utf8")).toBe(ORIGINAL_PLAN);
|
|
75
|
+
|
|
76
|
+
// References are rewritten in prose and code alike.
|
|
77
|
+
expect(readFileSync(join(root, "src/a.ts"), "utf8")).toContain(".agents/memory/DESIGN.md");
|
|
78
|
+
expect(readFileSync(join(root, "specs/a.md"), "utf8")).toContain(
|
|
79
|
+
".agents/implementations/2026-01-01_a.md",
|
|
80
|
+
);
|
|
81
|
+
const agents = readFileSync(join(root, "AGENTS.md"), "utf8");
|
|
82
|
+
expect(agents).toContain(".agents/memory/");
|
|
83
|
+
expect(agents).toContain("specs/PLAN.md");
|
|
84
|
+
|
|
85
|
+
// Snapshot keeps the pre-move bytes (and therefore the old paths).
|
|
86
|
+
expect(applied.snapshot?.dir).toMatch(/^\.agents\/framework\/snapshots\//);
|
|
87
|
+
const snapshotFile = join(root, applied.snapshot?.dir ?? "", "files/src/a.ts");
|
|
88
|
+
expect(readFileSync(snapshotFile, "utf8")).toContain("memory/DESIGN.md");
|
|
89
|
+
expect(existsSync(join(root, applied.snapshot?.dir ?? "", "manifest.json"))).toBe(true);
|
|
90
|
+
|
|
91
|
+
// Counts reconcile with the survey.
|
|
92
|
+
expect(applied.rewrites?.hits).toBe(expectedHits);
|
|
93
|
+
|
|
94
|
+
// State, work order and coverage worklist exist and are readable.
|
|
95
|
+
expect(applied.statePath).toBe(".agents/framework/state.toml");
|
|
96
|
+
const state = parseState(readFileSync(join(root, ".agents/framework/state.toml"), "utf8"));
|
|
97
|
+
expect(state.version).toBe(1);
|
|
98
|
+
expect(state.migrations.length).toBeGreaterThan(0);
|
|
99
|
+
expect(state.lang).toBe("zh");
|
|
100
|
+
expect(applied.planPath).toMatch(/^\.agents\/framework\/plans\/plan-.*\.json$/);
|
|
101
|
+
const order = JSON.parse(readFileSync(join(root, applied.planPath ?? ""), "utf8")) as {
|
|
102
|
+
tasks: Array<{ id: string }>;
|
|
103
|
+
};
|
|
104
|
+
expect(order.tasks.map((task) => task.id)).toContain("triage-todos");
|
|
105
|
+
expect(applied.created).toContain(".agents/memory/README.md");
|
|
106
|
+
|
|
107
|
+
expect(plan.artifacts.coverage).toMatch(/^\.agents\/framework\/reports\/coverage-.*\.md$/);
|
|
108
|
+
const coverage = readFileSync(join(root, plan.artifacts.coverage ?? ""), "utf8");
|
|
109
|
+
expect(coverage).toContain("OPEN");
|
|
110
|
+
expect(coverage).toContain("AGENTS.md");
|
|
111
|
+
} finally {
|
|
112
|
+
remove(root);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("is safe to run twice", () => {
|
|
117
|
+
const root = project(LEGACY);
|
|
118
|
+
try {
|
|
119
|
+
const plan = planFor(root);
|
|
120
|
+
applyPlan(plan, { now: new Date("2026-10-26T10:00:00+08:00") });
|
|
121
|
+
const second = applyPlan(plan, { now: new Date("2026-10-26T10:05:00+08:00") });
|
|
122
|
+
expect(second.moved).toHaveLength(0);
|
|
123
|
+
expect(second.rewrites?.hits).toBe(0);
|
|
124
|
+
expect(second.skipped.length).toBeGreaterThan(0);
|
|
125
|
+
expect(existsSync(join(root, ".agents/memory/FINISHED.md"))).toBe(true);
|
|
126
|
+
expect(existsSync(join(root, "memory"))).toBe(false);
|
|
127
|
+
} finally {
|
|
128
|
+
remove(root);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("writes nothing on a dry run", () => {
|
|
133
|
+
const root = project(LEGACY);
|
|
134
|
+
try {
|
|
135
|
+
const plan = planFor(root);
|
|
136
|
+
applyPlan(plan, {
|
|
137
|
+
dryRun: true,
|
|
138
|
+
now: new Date("2026-10-26T10:00:00+08:00"),
|
|
139
|
+
});
|
|
140
|
+
expect(existsSync(join(root, "memory"))).toBe(true);
|
|
141
|
+
expect(existsSync(join(root, ".agents"))).toBe(false);
|
|
142
|
+
} finally {
|
|
143
|
+
remove(root);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("does not merge two copies of a directory on its own", () => {
|
|
148
|
+
const root = installedProject({
|
|
149
|
+
"memory/FINISHED.md": "# old finished\n",
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
const plan = planFor(root);
|
|
153
|
+
expect(plan.mode).toBe("reconcile");
|
|
154
|
+
applyPlan(plan, { now: new Date("2026-10-26T10:00:00+08:00") });
|
|
155
|
+
expect(existsSync(join(root, "memory/FINISHED.md"))).toBe(true);
|
|
156
|
+
expect(readFileSync(join(root, ".agents/memory/FINISHED.md"), "utf8")).toContain("Summary");
|
|
157
|
+
} finally {
|
|
158
|
+
remove(root);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("round-trips state through TOML", () => {
|
|
163
|
+
const root = installedProject();
|
|
164
|
+
try {
|
|
165
|
+
const plan = planFor(root);
|
|
166
|
+
const state = {
|
|
167
|
+
version: 1,
|
|
168
|
+
tool: "9.9.9",
|
|
169
|
+
created: "2026-01-01T00:00:00+08:00",
|
|
170
|
+
updated: "2026-01-02T00:00:00+08:00",
|
|
171
|
+
profile: "core" as const,
|
|
172
|
+
modules: ["design"],
|
|
173
|
+
lang: "zh" as const,
|
|
174
|
+
roots: [
|
|
175
|
+
{
|
|
176
|
+
label: "api",
|
|
177
|
+
path: "../api",
|
|
178
|
+
absolute: "/tmp/api",
|
|
179
|
+
role: "backend",
|
|
180
|
+
agentsMd: "scope-card" as const,
|
|
181
|
+
source: "add-dir" as const,
|
|
182
|
+
exists: true,
|
|
183
|
+
lastSeen: "2026-01-02T00:00:00+08:00",
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
managed: [],
|
|
187
|
+
migrations: [{ at: "2026-01-02T00:00:00+08:00", to: "1", notes: "adopt" }],
|
|
188
|
+
};
|
|
189
|
+
const parsed = parseState(stateToToml(state));
|
|
190
|
+
expect(parsed.roots[0]).toMatchObject({ label: "api", agentsMd: "scope-card" });
|
|
191
|
+
expect(parsed.migrations[0].notes).toBe("adopt");
|
|
192
|
+
expect(parsed.budgets).toBeUndefined();
|
|
193
|
+
void plan;
|
|
194
|
+
} finally {
|
|
195
|
+
remove(root);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
describe("review regressions", () => {
|
|
202
|
+
it("preserves relative prefixes while rewriting", () => {
|
|
203
|
+
const result = rewriteText("see `../memory/A.md` and `./implementations/B.md`", [
|
|
204
|
+
{ from: "memory/", to: ".agents/memory/" },
|
|
205
|
+
{ from: "implementations/", to: ".agents/implementations/" },
|
|
206
|
+
]);
|
|
207
|
+
expect(result.text).toBe(
|
|
208
|
+
"see `../.agents/memory/A.md` and `./.agents/implementations/B.md`",
|
|
209
|
+
);
|
|
210
|
+
expect(result.hits).toBe(2);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("records managed hashes after the reference rewrite", () => {
|
|
214
|
+
const root = project(LEGACY);
|
|
215
|
+
try {
|
|
216
|
+
applyPlan(planFor(root), { now: new Date("2026-10-26T10:00:00+08:00") });
|
|
217
|
+
const state = parseState(readFileSync(join(root, ".agents/framework/state.toml"), "utf8"));
|
|
218
|
+
for (const entry of state.managed) {
|
|
219
|
+
const digest = createHash("sha256")
|
|
220
|
+
.update(readFileSync(join(root, entry.path)))
|
|
221
|
+
.digest("hex");
|
|
222
|
+
expect(entry.sha256, entry.path).toBe(digest);
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
remove(root);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("moves a root-level PLAN.md into the plan home", () => {
|
|
230
|
+
const root = project({
|
|
231
|
+
"AGENTS.md": "# demo\n\nSee `PLAN.md`.\n",
|
|
232
|
+
"PLAN.md": "# Plan\n\n## Route\n\n- open item\n",
|
|
233
|
+
});
|
|
234
|
+
try {
|
|
235
|
+
const plan = planFor(root);
|
|
236
|
+
const move = plan.actions.find((action) => action.kind === "move-file");
|
|
237
|
+
expect(move && move.kind === "move-file" ? move.from : "").toBe("PLAN.md");
|
|
238
|
+
expect(plan.tasks.some((task) => task.id === "edit-plan")).toBe(true);
|
|
239
|
+
const applied = applyPlan(plan, { now: new Date("2026-10-26T10:00:00+08:00") });
|
|
240
|
+
expect(existsSync(join(root, "PLAN.md"))).toBe(false);
|
|
241
|
+
expect(readFileSync(join(root, "specs/PLAN.md"), "utf8")).toContain("open item");
|
|
242
|
+
// The moved source must be recoverable, not just the destination.
|
|
243
|
+
expect(
|
|
244
|
+
existsSync(join(root, applied.snapshot?.dir ?? "", "files/PLAN.md")),
|
|
245
|
+
).toBe(true);
|
|
246
|
+
} finally {
|
|
247
|
+
remove(root);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
it("does not corrupt non-UTF-8 files during reference rewriting", () => {
|
|
254
|
+
const root = project(LEGACY);
|
|
255
|
+
try {
|
|
256
|
+
const latin = Buffer.from("// see `memory/DESIGN.md` — caf\xe9\n", "latin1");
|
|
257
|
+
writeFileSync(join(root, "src/latin1.ts"), latin);
|
|
258
|
+
applyPlan(planFor(root), { now: new Date("2026-10-26T10:00:00+08:00") });
|
|
259
|
+
expect(readFileSync(join(root, "src/latin1.ts"))).toEqual(latin);
|
|
260
|
+
expect(readFileSync(join(root, "src/a.ts"), "utf8")).toContain(".agents/memory/DESIGN.md");
|
|
261
|
+
} finally {
|
|
262
|
+
remove(root);
|
|
263
|
+
}
|
|
264
|
+
});
|