@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
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Gate-bus audit wiring: every ask settles into exactly one record, a block is
3
+ * recorded with the feature's structured payload, a pending dialog that never
4
+ * runs is still recorded, and session counters flush as one summary line.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import type {
8
+ ExtensionAPI,
9
+ ExtensionContext,
10
+ ToolCallEvent,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import type { ApprovalLog, ApprovalRecord } from "./approval-log.js";
13
+ import {
14
+ createGateHub,
15
+ type AskDecision,
16
+ type AskRequest,
17
+ type ToolGate,
18
+ } from "./gates.js";
19
+
20
+ function fakeAudit(mode: ApprovalLog["mode"] = "interesting"): {
21
+ records: ApprovalRecord[];
22
+ log: ApprovalLog;
23
+ } {
24
+ const records: ApprovalRecord[] = [];
25
+ return {
26
+ records,
27
+ log: {
28
+ dir: "/tmp/approval-log-test",
29
+ mode,
30
+ active: true,
31
+ append: (record) => records.push(record),
32
+ },
33
+ };
34
+ }
35
+
36
+ function ctxFor(session: string): ExtensionContext {
37
+ return {
38
+ cwd: "/proj",
39
+ model: { provider: "test", id: "model" },
40
+ sessionManager: { getSessionId: () => session },
41
+ } as unknown as ExtensionContext;
42
+ }
43
+
44
+ function bashEvent(command = "rm -rf /tmp/x"): ToolCallEvent {
45
+ return {
46
+ type: "tool_call",
47
+ toolCallId: "call-1",
48
+ toolName: "bash",
49
+ input: { command },
50
+ } as unknown as ToolCallEvent;
51
+ }
52
+
53
+ function askRequest(
54
+ decide: AskDecision | undefined,
55
+ settle?: AskRequest["settle"],
56
+ ): AskRequest {
57
+ return {
58
+ title: "Permission required",
59
+ details: ["Command: rm -rf /tmp/x"],
60
+ root: "/tmp",
61
+ audit: {
62
+ prompt: ["Permission required", "Command: rm -rf /tmp/x"],
63
+ verdict: { action: "ask", layer: "mode", target: "/tmp/x" },
64
+ },
65
+ present: async () => decide,
66
+ apply: () => {},
67
+ denyReason: (choice) => `denied:${choice}`,
68
+ settle,
69
+ };
70
+ }
71
+
72
+ function gate(
73
+ name: string,
74
+ order: number,
75
+ verdict: ReturnType<ToolGate["evaluate"]>,
76
+ ): ToolGate {
77
+ return { name, order, evaluate: () => verdict };
78
+ }
79
+
80
+ describe("gate audit", () => {
81
+ it("records an allowed ask with prompt, answer and grant", async () => {
82
+ const { records, log } = fakeAudit();
83
+ const hub = createGateHub({ audit: log });
84
+ hub.register(
85
+ gate("permission", 10, {
86
+ kind: "ask",
87
+ request: askRequest("allow-session", (choice, meta) => ({
88
+ grant: {
89
+ kind: choice === "allow-session" ? "allow" : "deny",
90
+ scope: "root",
91
+ root: "/tmp",
92
+ msSeen: meta.ms,
93
+ },
94
+ })),
95
+ }),
96
+ );
97
+
98
+ await expect(hub.handle(bashEvent(), ctxFor("s1"))).resolves.toBeUndefined();
99
+ expect(records).toHaveLength(1);
100
+ expect(records[0]).toMatchObject({
101
+ event: "ask",
102
+ gate: "permission",
103
+ session: "s1",
104
+ tool: "bash",
105
+ toolCall: "call-1",
106
+ input: { command: "rm -rf /tmp/x" },
107
+ verdict: { action: "ask", layer: "mode", target: "/tmp/x" },
108
+ answer: { choice: "allow-session" },
109
+ outcome: "allowed",
110
+ grant: { kind: "allow", scope: "root", root: "/tmp" },
111
+ });
112
+ });
113
+
114
+ it("records a denial answer", async () => {
115
+ const { records, log } = fakeAudit();
116
+ const hub = createGateHub({ audit: log });
117
+ hub.register(
118
+ gate("permission", 10, {
119
+ kind: "ask",
120
+ request: askRequest("deny-session"),
121
+ }),
122
+ );
123
+
124
+ await expect(hub.handle(bashEvent(), ctxFor("s1"))).resolves.toEqual({
125
+ reason: "denied:deny-session",
126
+ });
127
+ expect(records[0]).toMatchObject({
128
+ event: "ask",
129
+ answer: { choice: "deny-session" },
130
+ outcome: "blocked",
131
+ });
132
+ });
133
+
134
+ it("records a cancelled dialog as a denial with a null answer", async () => {
135
+ const { records, log } = fakeAudit();
136
+ const hub = createGateHub({ audit: log });
137
+ hub.register(
138
+ gate("permission", 10, {
139
+ kind: "ask",
140
+ request: askRequest(undefined),
141
+ }),
142
+ );
143
+
144
+ const result = await hub.handle(bashEvent(), ctxFor("s1"));
145
+ expect(result?.reason).toContain("was not answered");
146
+ expect(records[0]).toMatchObject({
147
+ event: "ask",
148
+ answer: null,
149
+ outcome: "cancelled",
150
+ });
151
+ });
152
+
153
+ it("records a pending ask that a later block prevented from being shown", async () => {
154
+ const { records, log } = fakeAudit();
155
+ const hub = createGateHub({ audit: log });
156
+ hub.register(
157
+ gate("permission", 10, {
158
+ kind: "ask",
159
+ request: askRequest("allow"),
160
+ }),
161
+ );
162
+ hub.register(gate("ask", 20, { kind: "block", reason: "ask mode" }));
163
+
164
+ await expect(hub.handle(bashEvent(), ctxFor("s1"))).resolves.toEqual({
165
+ reason: "ask mode",
166
+ });
167
+ const ask = records.find((record) => record.event === "ask");
168
+ const block = records.find((record) => record.event === "block");
169
+ expect(ask).toMatchObject({ outcome: "not-presented", answer: null });
170
+ expect(block).toMatchObject({
171
+ event: "block",
172
+ gate: "ask",
173
+ reason: "ask mode",
174
+ });
175
+ });
176
+
177
+ it("records a gate failure as a block", async () => {
178
+ const { records, log } = fakeAudit();
179
+ const hub = createGateHub({ audit: log });
180
+ hub.register({
181
+ name: "broken",
182
+ order: 10,
183
+ evaluate: () => {
184
+ throw new Error("boom");
185
+ },
186
+ });
187
+
188
+ const result = await hub.handle(bashEvent(), ctxFor("s1"));
189
+ expect(result?.reason).toContain("boom");
190
+ expect(records[0]).toMatchObject({
191
+ event: "block",
192
+ gate: "broken",
193
+ verdict: { action: "block", layer: "gate-error" },
194
+ });
195
+ });
196
+
197
+ it("merges a block payload attached by the feature", async () => {
198
+ const { records, log } = fakeAudit();
199
+ const hub = createGateHub({ audit: log });
200
+ hub.register(
201
+ gate("permission", 10, {
202
+ kind: "block",
203
+ reason: "credentials — denied by the fixed safety table",
204
+ audit: {
205
+ units: [{ raw: "cat ~/.ssh/id_rsa", opaque: false }],
206
+ verdict: { action: "deny", layer: "secret" },
207
+ modes: { inRead: "allow" },
208
+ },
209
+ }),
210
+ );
211
+
212
+ await hub.handle(bashEvent("cat ~/.ssh/id_rsa"), ctxFor("s1"));
213
+ expect(records[0]).toMatchObject({
214
+ event: "block",
215
+ gate: "permission",
216
+ units: [{ raw: "cat ~/.ssh/id_rsa", opaque: false }],
217
+ verdict: { action: "deny", layer: "secret" },
218
+ modes: { inRead: "allow" },
219
+ });
220
+ });
221
+
222
+ it("keeps per-session counters apart and flushes them on shutdown", async () => {
223
+ const { records, log } = fakeAudit();
224
+ const hub = createGateHub({ audit: log });
225
+ hub.register(gate("noop", 10, undefined));
226
+ const handlers = new Map<string, (...args: unknown[]) => unknown>();
227
+ hub.install({
228
+ on(event: string, handler: (...args: unknown[]) => unknown) {
229
+ handlers.set(event, handler);
230
+ },
231
+ } as unknown as ExtensionAPI);
232
+
233
+ // Two sessions interleaved in one process (parent + child) must not flush
234
+ // each other's partial counters; each summary carries only its own numbers.
235
+ await hub.handle(bashEvent(), ctxFor("s1"));
236
+ await hub.handle(bashEvent(), ctxFor("s2"));
237
+ await hub.handle(bashEvent(), ctxFor("s1"));
238
+ await hub.handle(bashEvent(), ctxFor("s2"));
239
+ expect(
240
+ records.filter((record) => record.event === "session"),
241
+ ).toHaveLength(0);
242
+
243
+ handlers.get("session_shutdown")?.(
244
+ { type: "session_shutdown", reason: "quit" },
245
+ ctxFor("s2"),
246
+ );
247
+ // Only the session being torn down is flushed; the other one keeps its
248
+ // counters for its own shutdown instead of being cut in half.
249
+ const summaries = records.filter((record) => record.event === "session");
250
+ expect(summaries).toHaveLength(1);
251
+ expect(summaries[0]).toMatchObject({
252
+ event: "session",
253
+ session: "s2",
254
+ reason: "quit",
255
+ counts: { calls: 2 },
256
+ });
257
+
258
+ handlers.get("session_shutdown")?.(
259
+ { type: "session_shutdown", reason: "quit" },
260
+ ctxFor("s1"),
261
+ );
262
+ const bySession = new Map(
263
+ records
264
+ .filter((record) => record.event === "session")
265
+ .map((record) => [record.session, record]),
266
+ );
267
+ expect(bySession.get("s1")).toMatchObject({
268
+ event: "session",
269
+ session: "s1",
270
+ counts: { calls: 2 },
271
+ });
272
+ });
273
+
274
+ it("records the queue wait separately from the answer time", async () => {
275
+ const { records, log } = fakeAudit();
276
+ const hub = createGateHub({ audit: log });
277
+ const base = askRequest("allow");
278
+ hub.register(
279
+ gate("permission", 10, {
280
+ kind: "ask",
281
+ request: {
282
+ ...base,
283
+ present: async () => {
284
+ await new Promise((resolve) => setTimeout(resolve, 30));
285
+ return "allow";
286
+ },
287
+ },
288
+ }),
289
+ );
290
+
291
+ // Two calls from one assistant message: the second waits for the first.
292
+ await Promise.all([
293
+ hub.handle(bashEvent("a"), ctxFor("s1")),
294
+ hub.handle(bashEvent("b"), ctxFor("s1")),
295
+ ]);
296
+ const asks = records.filter((record) => record.event === "ask");
297
+ expect(asks).toHaveLength(2);
298
+ const second = asks[1]?.answer as { ms: number; queueMs: number };
299
+ expect(second.queueMs).toBeGreaterThanOrEqual(25);
300
+ expect(second.ms).toBeGreaterThanOrEqual(second.queueMs);
301
+ });
302
+
303
+ it("flushes the current session on shutdown", async () => {
304
+ const { records, log } = fakeAudit();
305
+ const hub = createGateHub({ audit: log });
306
+ hub.register(gate("noop", 10, undefined));
307
+ const handlers = new Map<string, (...args: unknown[]) => unknown>();
308
+ hub.install({
309
+ on(event: string, handler: (...args: unknown[]) => unknown) {
310
+ handlers.set(event, handler);
311
+ },
312
+ } as unknown as ExtensionAPI);
313
+
314
+ await hub.handle(bashEvent(), ctxFor("s1"));
315
+ handlers.get("session_shutdown")?.(
316
+ { type: "session_shutdown", reason: "quit" },
317
+ ctxFor("s1"),
318
+ );
319
+
320
+ expect(
321
+ records.find((record) => record.event === "session"),
322
+ ).toMatchObject({
323
+ event: "session",
324
+ session: "s1",
325
+ reason: "quit",
326
+ counts: { calls: 1 },
327
+ });
328
+ });
329
+
330
+ it("writes nothing when the audit mode is off", async () => {
331
+ const { records, log } = fakeAudit("off");
332
+ const hub = createGateHub({ audit: log });
333
+ hub.register(
334
+ gate("permission", 10, {
335
+ kind: "ask",
336
+ request: askRequest("allow"),
337
+ }),
338
+ );
339
+
340
+ await hub.handle(bashEvent(), ctxFor("s1"));
341
+ expect(records).toHaveLength(0);
342
+ });
343
+ });