@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.
Files changed (39) hide show
  1. package/README.md +117 -5
  2. package/README.zh-CN.md +102 -5
  3. package/package.json +8 -3
  4. package/src/features/init/analyze.ts +162 -0
  5. package/src/features/init/apply.test.ts +264 -0
  6. package/src/features/init/apply.ts +693 -0
  7. package/src/features/init/audit.test.ts +292 -0
  8. package/src/features/init/audit.ts +586 -0
  9. package/src/features/init/coverage.test.ts +93 -0
  10. package/src/features/init/coverage.ts +163 -0
  11. package/src/features/init/detect.test.ts +237 -0
  12. package/src/features/init/detect.ts +808 -0
  13. package/src/features/init/dispatch.ts +135 -0
  14. package/src/features/init/index.test.ts +331 -0
  15. package/src/features/init/index.ts +569 -0
  16. package/src/features/init/plan.test.ts +261 -0
  17. package/src/features/init/plan.ts +867 -0
  18. package/src/features/init/render.ts +131 -0
  19. package/src/features/init/spec.ts +429 -0
  20. package/src/features/init/templates/agents-skeleton.md +44 -0
  21. package/src/features/init/templates/memory-readme.md +99 -0
  22. package/src/features/init/templates/signpost.md +15 -0
  23. package/src/features/init/templates/state-gitignore +6 -0
  24. package/src/features/init/templates.test.ts +58 -0
  25. package/src/features/init/templates.ts +79 -0
  26. package/src/features/init/test-helpers.ts +89 -0
  27. package/src/features/init/tool.ts +72 -0
  28. package/src/features/init/types.ts +284 -0
  29. package/src/features/init/ui.ts +160 -0
  30. package/src/features/permission/audit.test.ts +159 -0
  31. package/src/features/permission/audit.ts +145 -0
  32. package/src/features/permission/index.test.ts +138 -3
  33. package/src/features/permission/index.ts +108 -12
  34. package/src/features/permission/policy.ts +39 -3
  35. package/src/index.ts +9 -2
  36. package/src/lib/approval-log.test.ts +289 -0
  37. package/src/lib/approval-log.ts +724 -0
  38. package/src/lib/gates.audit.test.ts +343 -0
  39. package/src/lib/gates.ts +291 -10
package/src/index.ts CHANGED
@@ -12,31 +12,38 @@
12
12
  * - compact — dedicated compact model for /compact + auto-compact summaries
13
13
  * - autostart — replay project-declared slash commands at session start (/add-dir persistence)
14
14
  * - permission — session-scoped boundary permission checks (/ask-perm)
15
+ * - init — agent memory framework install / adoption (/ask-init)
15
16
  *
16
17
  * `ask` and `permission` are independent gates on one bus: each answers its own
17
18
  * question and the strictest answer wins, so they compose rather than replace
18
19
  * each other. The bus is installed once, after every gate is registered.
19
20
  */
20
21
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import { createApprovalLog } from "./lib/approval-log.js";
21
23
  import { createGateHub } from "./lib/gates.js";
22
24
  import { registerAsk } from "./features/ask/index.js";
23
25
  import { registerAutostart } from "./features/autostart/index.js";
24
26
  import { registerCompact } from "./features/compact/index.js";
25
27
  import { registerFavorites } from "./features/favorites/index.js";
28
+ import { registerInit } from "./features/init/index.js";
26
29
  import { registerPermission } from "./features/permission/index.js";
27
30
  import { registerReview } from "./features/review/index.js";
28
31
  import { registerStash } from "./features/stash/index.js";
29
32
 
30
33
  export default function piToolkits(pi: ExtensionAPI): void {
31
- const gates = createGateHub();
34
+ // One rolling audit sink for every gate. Creation is lazy and side-effect
35
+ // free: the directory is only touched when the first record is written.
36
+ const audit = createApprovalLog();
37
+ const gates = createGateHub({ audit });
32
38
 
33
39
  registerAsk(pi, gates);
34
40
  // Permission registers its session_start reset before autostart, so a
35
41
  // project-declared `/ask-perm ...` runs against a freshly reset mode.
36
- registerPermission(pi, gates);
42
+ registerPermission(pi, gates, { audit });
37
43
  registerAutostart(pi);
38
44
  registerCompact(pi);
39
45
  registerFavorites(pi);
46
+ registerInit(pi);
40
47
  registerReview(pi);
41
48
  registerStash(pi);
42
49
 
@@ -0,0 +1,289 @@
1
+ import {
2
+ existsSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { describe, expect, it } from "vitest";
12
+ import {
13
+ clipText,
14
+ createApprovalLog,
15
+ sanitizeToolInput,
16
+ } from "./approval-log.js";
17
+
18
+ function tempDir(prefix = "pi-approval-"): string {
19
+ return mkdtempSync(join(tmpdir(), prefix));
20
+ }
21
+
22
+ function logFiles(dir: string): string[] {
23
+ return readdirSync(dir)
24
+ .filter((name) => /^audit-\d{6,}\.jsonl$/.test(name))
25
+ .sort();
26
+ }
27
+
28
+ function linesOf(dir: string, name: string): string[] {
29
+ return readFileSync(join(dir, name), "utf8")
30
+ .split("\n")
31
+ .filter((line) => line !== "");
32
+ }
33
+
34
+ describe("approval log", () => {
35
+ it("writes one JSON object per line and creates the directory lazily", () => {
36
+ const root = tempDir();
37
+ const dir = join(root, "nested", "log");
38
+ const log = createApprovalLog({
39
+ dir,
40
+ mode: "interesting",
41
+ now: () => new Date("2026-01-02T03:04:05.000Z"),
42
+ });
43
+
44
+ expect(existsSync(dir)).toBe(false);
45
+ log.append({
46
+ event: "block",
47
+ tool: "bash",
48
+ verdict: { action: "deny", layer: "secret" },
49
+ });
50
+ expect(logFiles(dir)).toEqual(["audit-000001.jsonl"]);
51
+
52
+ const lines = linesOf(dir, "audit-000001.jsonl");
53
+ expect(lines).toHaveLength(1);
54
+ const record = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
55
+ expect(record).toMatchObject({
56
+ v: 1,
57
+ ts: "2026-01-02T03:04:05.000Z",
58
+ event: "block",
59
+ tool: "bash",
60
+ verdict: { action: "deny", layer: "secret" },
61
+ });
62
+ if (process.platform !== "win32") {
63
+ expect(statSync(join(dir, "audit-000001.jsonl")).mode & 0o777).toBe(
64
+ 0o600,
65
+ );
66
+ }
67
+ });
68
+
69
+ it("rotates at the record cap and removes the oldest file", () => {
70
+ const dir = tempDir();
71
+ const log = createApprovalLog({
72
+ dir,
73
+ mode: "interesting",
74
+ maxRecords: 3,
75
+ maxFiles: 2,
76
+ });
77
+ for (let i = 1; i <= 7; i++) log.append({ event: "ask", n: i });
78
+
79
+ expect(logFiles(dir)).toEqual(["audit-000002.jsonl", "audit-000003.jsonl"]);
80
+ expect(linesOf(dir, "audit-000002.jsonl")).toHaveLength(3);
81
+ expect(linesOf(dir, "audit-000003.jsonl")).toHaveLength(1);
82
+ expect(
83
+ linesOf(dir, "audit-000003.jsonl")
84
+ .map((line) => (JSON.parse(line) as { n: number }).n)
85
+ .join(","),
86
+ ).toBe("7");
87
+ });
88
+
89
+ it("rotates at the byte cap before the record cap", () => {
90
+ const dir = tempDir();
91
+ const log = createApprovalLog({
92
+ dir,
93
+ mode: "interesting",
94
+ maxFileBytes: 300,
95
+ maxRecords: 1000,
96
+ });
97
+ for (let i = 1; i <= 3; i++) {
98
+ log.append({ event: "ask", n: i, payload: "x".repeat(200) });
99
+ }
100
+ const files = logFiles(dir);
101
+ expect(files).toHaveLength(2);
102
+ expect(linesOf(dir, files[0] ?? "")).toHaveLength(2);
103
+ expect(linesOf(dir, files[1] ?? "")).toHaveLength(1);
104
+ });
105
+
106
+ it("terminates a crash-truncated tail before appending", () => {
107
+ const dir = tempDir();
108
+ writeFileSync(
109
+ join(dir, "audit-000001.jsonl"),
110
+ '{"v":1,"event":"block"}\n{"v":1,"event":"as',
111
+ );
112
+ const log = createApprovalLog({ dir, mode: "interesting" });
113
+ log.append({ event: "ask", n: 2 });
114
+
115
+ const lines = linesOf(dir, "audit-000001.jsonl");
116
+ expect(lines).toHaveLength(3);
117
+ expect(() => JSON.parse(lines[0] ?? "")).not.toThrow();
118
+ expect(() => JSON.parse(lines[1] ?? "")).toThrow();
119
+ expect(JSON.parse(lines[2] ?? "") as Record<string, unknown>).toMatchObject({
120
+ event: "ask",
121
+ n: 2,
122
+ });
123
+ });
124
+
125
+ it("redacts credential-shaped strings anywhere in the record", () => {
126
+ const dir = tempDir();
127
+ const log = createApprovalLog({ dir, mode: "interesting" });
128
+ log.append({
129
+ event: "ask",
130
+ input: {
131
+ command:
132
+ "curl -H 'Authorization: Bearer abcdefgh12345678' https://user:pass@example.com/x?token=supersecretvalue",
133
+ },
134
+ units: [{ raw: "export TOKEN=anothersecretvalue" }],
135
+ reason: "password=hunter2",
136
+ });
137
+
138
+ const line = readFileSync(join(dir, "audit-000001.jsonl"), "utf8");
139
+ expect(line).toContain('"redacted":true');
140
+ expect(line).not.toContain("abcdefgh12345678");
141
+ expect(line).not.toContain("supersecretvalue");
142
+ expect(line).not.toContain("anothersecretvalue");
143
+ expect(line).not.toContain("hunter2");
144
+ expect(line).toContain("[redacted]");
145
+ });
146
+
147
+ it("drops write/edit bodies but keeps their size", () => {
148
+ expect(sanitizeToolInput("write", { path: "/tmp/a", content: "hello" }))
149
+ .toEqual({ path: "/tmp/a", contentBytes: 5 });
150
+ expect(
151
+ sanitizeToolInput("edit", {
152
+ path: "/tmp/a",
153
+ oldText: "abc",
154
+ newText: "abcdef",
155
+ }),
156
+ ).toEqual({ path: "/tmp/a", contentBytes: 6, oldTextBytes: 3 });
157
+ });
158
+
159
+ it("clips overlong text with a visible marker", () => {
160
+ const clipped = clipText("x".repeat(5000), 100);
161
+ expect(clipped).toContain("[+4900 chars]");
162
+ expect(clipped.length).toBeLessThan(5000);
163
+ });
164
+
165
+ it("caps a pathological record without losing the verdict", () => {
166
+ const dir = tempDir();
167
+ const log = createApprovalLog({
168
+ dir,
169
+ mode: "interesting",
170
+ maxRecordBytes: 600,
171
+ });
172
+ log.append({
173
+ event: "ask",
174
+ verdict: { action: "ask", layer: "mode", target: "/tmp/x" },
175
+ verdictCode: 1,
176
+ units: Array.from({ length: 40 }, (_, index) => ({
177
+ raw: `${index}:${"y".repeat(300)}`,
178
+ })),
179
+ input: { command: "z".repeat(5000) },
180
+ });
181
+
182
+ const line = readFileSync(join(dir, "audit-000001.jsonl"), "utf8").trim();
183
+ const record = JSON.parse(line) as Record<string, unknown>;
184
+ expect(record.event).toBe("ask");
185
+ expect(record.verdict).toMatchObject({ action: "ask", layer: "mode" });
186
+ expect(record.units === undefined || record.truncated === true).toBe(true);
187
+ expect(line.length).toBeLessThan(2000);
188
+ });
189
+
190
+ it("skips one unserializable record instead of disabling the log", () => {
191
+ const dir = tempDir();
192
+ const warnings: string[] = [];
193
+ const log = createApprovalLog({
194
+ dir,
195
+ mode: "interesting",
196
+ warn: (message) => warnings.push(message),
197
+ });
198
+ // A BigInt inside the envelope cannot be stringified even after every
199
+ // optional field is dropped. It must cost one record, not the process.
200
+ log.append({ event: "ask", verdict: { action: 1n as unknown as number } });
201
+ expect(log.active).toBe(true);
202
+ log.append({ event: "block", n: 2 });
203
+
204
+ const lines = linesOf(dir, "audit-000001.jsonl");
205
+ expect(lines).toHaveLength(1);
206
+ expect(JSON.parse(lines[0] ?? "{}") as Record<string, unknown>).toMatchObject({
207
+ event: "block",
208
+ n: 2,
209
+ });
210
+ expect(warnings).toHaveLength(1);
211
+ });
212
+
213
+ it("keeps a record whose envelope survives the dropped detail", () => {
214
+ const dir = tempDir();
215
+ const log = createApprovalLog({ dir, mode: "interesting" });
216
+ // The BigInt sits in a field the last-resort envelope drops, so the
217
+ // decision itself is still recorded (with `truncated`).
218
+ log.append({ event: "ask", extra: 1n as unknown as number, verdict: "ask" });
219
+
220
+ const record = JSON.parse(
221
+ linesOf(dir, "audit-000001.jsonl")[0] ?? "{}",
222
+ ) as Record<string, unknown>;
223
+ expect(record.event).toBe("ask");
224
+ expect(record.truncated).toBe(true);
225
+ });
226
+
227
+ it("fails open and warns once when the directory is unusable", () => {
228
+ const root = tempDir();
229
+ const file = join(root, "not-a-dir");
230
+ writeFileSync(file, "");
231
+ const warnings: string[] = [];
232
+ const log = createApprovalLog({
233
+ dir: join(file, "log"),
234
+ mode: "interesting",
235
+ warn: (message) => warnings.push(message),
236
+ });
237
+
238
+ expect(() => log.append({ event: "ask" })).not.toThrow();
239
+ expect(log.active).toBe(false);
240
+ expect(warnings).toHaveLength(1);
241
+ log.append({ event: "ask" });
242
+ expect(warnings).toHaveLength(1);
243
+ });
244
+
245
+ it("writes nothing when the mode is off", () => {
246
+ const root = tempDir();
247
+ const dir = join(root, "off");
248
+ const log = createApprovalLog({ dir, mode: "off" });
249
+ log.append({ event: "ask" });
250
+ expect(log.active).toBe(false);
251
+ expect(existsSync(dir)).toBe(false);
252
+ });
253
+
254
+ it("keeps two writers within the ring without corrupting lines", () => {
255
+ const dir = tempDir();
256
+ const first = createApprovalLog({
257
+ dir,
258
+ mode: "interesting",
259
+ maxRecords: 3,
260
+ maxFiles: 2,
261
+ });
262
+ const second = createApprovalLog({
263
+ dir,
264
+ mode: "interesting",
265
+ maxRecords: 3,
266
+ maxFiles: 2,
267
+ });
268
+ for (let i = 0; i < 10; i++) {
269
+ (i % 2 === 0 ? first : second).append({ event: "ask", n: i });
270
+ }
271
+
272
+ const files = logFiles(dir);
273
+ expect(files.length).toBeLessThanOrEqual(2);
274
+ let total = 0;
275
+ for (const name of files) {
276
+ const lines = linesOf(dir, name);
277
+ for (const line of lines) {
278
+ expect(() => JSON.parse(line)).not.toThrow();
279
+ }
280
+ // Each writer checks the count before appending, so two writers can
281
+ // overshoot the cap by at most one record — not by 2x, which is what a
282
+ // per-process counter would do.
283
+ expect(lines.length).toBeLessThanOrEqual(4);
284
+ total += lines.length;
285
+ }
286
+ expect(total).toBeGreaterThan(0);
287
+ expect(total).toBeLessThanOrEqual(10);
288
+ });
289
+ });