portable-agent-layer 0.66.1 → 0.67.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.
@@ -132,6 +132,48 @@
132
132
  "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
133
133
  }
134
134
  ]
135
+ },
136
+ {
137
+ "matcher": "Write|Edit",
138
+ "hooks": [
139
+ {
140
+ "type": "command",
141
+ "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerSnapshot.ts"
142
+ }
143
+ ]
144
+ }
145
+ ],
146
+ "PostToolUse": [
147
+ {
148
+ "matcher": "Write|Edit",
149
+ "hooks": [
150
+ {
151
+ "type": "command",
152
+ "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts"
153
+ }
154
+ ]
155
+ }
156
+ ],
157
+ "PostToolUseFailure": [
158
+ {
159
+ "matcher": "Write|Edit",
160
+ "hooks": [
161
+ {
162
+ "type": "command",
163
+ "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts"
164
+ }
165
+ ]
166
+ }
167
+ ],
168
+ "PermissionDenied": [
169
+ {
170
+ "matcher": "Write|Edit",
171
+ "hooks": [
172
+ {
173
+ "type": "command",
174
+ "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts"
175
+ }
176
+ ]
135
177
  }
136
178
  ],
137
179
  "Stop": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.66.1",
3
+ "version": "0.67.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,7 @@
42
42
  "lint": "biome lint",
43
43
  "lint-write": "biome lint --write",
44
44
  "check": "biome check",
45
- "check-write": "biome check --write",
45
+ "check-write": "bun .agents/scripts/check-write.ts",
46
46
  "knip": "knip-bun",
47
47
  "klint": "klint",
48
48
  "jscpd": "jscpd --noTips",
@@ -96,6 +96,7 @@
96
96
  "dependencies": {
97
97
  "@clack/prompts": "^1.4.0",
98
98
  "adm-zip": "^0.5.17",
99
+ "fast-myers-diff": "^3.2.0",
99
100
  "marked": "18.0.4",
100
101
  "pdf-lib": "1.17.1",
101
102
  "playwright": "^1.60.0",
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Hook: PostToolUse — pairs the parked before-state with the result and writes
3
+ * the ledger entry.
4
+ *
5
+ * Only a call that actually landed reaches this event, so every entry written
6
+ * here is an applied one. A denied or failed call fires the snapshot half and
7
+ * never this one, leaving its snapshot unclaimed — which is why claiming also
8
+ * reaps the ones that were abandoned.
9
+ *
10
+ * Silent and fail-open, for the same reason as its other half.
11
+ */
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { claimPending, reapStalePending, recordAction } from "./lib/ledger";
15
+ import { ledgeredCall } from "./lib/ledger-hook";
16
+ import { logDebug } from "./lib/log";
17
+ import { readStdinJSON } from "./lib/stdin";
18
+
19
+ try {
20
+ const input = await readStdinJSON<Record<string, unknown>>();
21
+ if (!input) process.exit(0);
22
+
23
+ const call = ledgeredCall(input);
24
+ if (!call) process.exit(0);
25
+
26
+ // No snapshot means no before-state, and an entry claiming one it never had
27
+ // would be worse than the missing entry.
28
+ const pending = claimPending(call.toolUseId);
29
+ if (!pending) process.exit(0);
30
+
31
+ const entry = recordAction({
32
+ tool: pending.tool,
33
+ target: pending.target,
34
+ outcome: "applied",
35
+ before: pending.before,
36
+ after: existsSync(call.target) ? readFileSync(call.target, "utf-8") : null,
37
+ });
38
+
39
+ reapStalePending();
40
+ logDebug("LedgerCommit", `recorded ${entry.id} ${entry.tool} ${entry.target}`);
41
+ } catch {
42
+ process.exit(0);
43
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Hook: PreToolUse — parks the target file's current contents before an edit.
3
+ *
4
+ * This half exists because the other half cannot do its job alone. A post-tool
5
+ * event fires once the file has already been rewritten, so the prior contents
6
+ * are gone and "what changed" is unanswerable from it. Reading the file here,
7
+ * before the tool runs, is the only moment the before-state still exists.
8
+ *
9
+ * Silent and fail-open: stdout is the agent's protocol channel, and a ledger
10
+ * that could block an edit would be a worse thing than a ledger with a gap.
11
+ */
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { savePending } from "./lib/ledger";
15
+ import { ledgeredCall } from "./lib/ledger-hook";
16
+ import { logDebug } from "./lib/log";
17
+ import { readStdinJSON } from "./lib/stdin";
18
+
19
+ try {
20
+ const input = await readStdinJSON<Record<string, unknown>>();
21
+ if (!input) process.exit(0);
22
+
23
+ const call = ledgeredCall(input);
24
+ if (!call) process.exit(0);
25
+
26
+ savePending({
27
+ ...call,
28
+ // Absent rather than empty: a file that does not exist yet is a creation,
29
+ // which is a different event from a write over an empty file.
30
+ before: existsSync(call.target) ? readFileSync(call.target, "utf-8") : null,
31
+ ts: new Date().toISOString(),
32
+ });
33
+
34
+ logDebug("LedgerSnapshot", `captured ${call.tool} ${call.toolUseId}`);
35
+ } catch {
36
+ process.exit(0);
37
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Hook: PostToolUseFailure and PermissionDenied — records an edit that was
3
+ * attempted and did not land.
4
+ *
5
+ * A log that keeps only what succeeded cannot answer what was attempted, and
6
+ * that is usually the question being asked of it. So the two endings that are
7
+ * not success are recorded too, and kept apart: `failed` is the tool's own
8
+ * attempt breaking, `denied` is something refusing to let it run.
9
+ *
10
+ * One hook serves both events because they differ only in which key carries the
11
+ * reason — a difference `unappliedVerdictOf` owns, so registering this on a
12
+ * third such event later is a line in that table rather than a new file.
13
+ *
14
+ * Not everything that fails to land reaches here. A manual denial at the
15
+ * permission dialog, a `deny` rule, and a PreToolUse hook's own block all fire
16
+ * PreToolUse and nothing after it; a schema rejection fires no hook at all.
17
+ * Those attempts leave a snapshot no half ever claims, which is what the reaper
18
+ * in ledger.ts is for.
19
+ *
20
+ * Silent and fail-open, for the same reason as the other halves.
21
+ */
22
+
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import {
25
+ claimPending,
26
+ type PendingSnapshot,
27
+ reapStalePending,
28
+ recordAction,
29
+ } from "./lib/ledger";
30
+ import { ledgeredCall, unappliedVerdictOf } from "./lib/ledger-hook";
31
+ import { logDebug } from "./lib/log";
32
+ import { readStdinJSON } from "./lib/stdin";
33
+
34
+ /**
35
+ * The snapshot is the trustworthy source, but its absence is recoverable here
36
+ * in a way it never is after a successful edit: nothing landed, so whatever is
37
+ * on disk now is still the before-state.
38
+ */
39
+ function beforeState(pending: PendingSnapshot | null, target: string): string | null {
40
+ if (pending) return pending.before;
41
+ return existsSync(target) ? readFileSync(target, "utf-8") : null;
42
+ }
43
+
44
+ try {
45
+ const input = await readStdinJSON<Record<string, unknown>>();
46
+ if (!input) process.exit(0);
47
+
48
+ const call = ledgeredCall(input);
49
+ const verdict = call && unappliedVerdictOf(input);
50
+ if (!call || !verdict) process.exit(0);
51
+
52
+ const entry = recordAction({
53
+ tool: call.tool,
54
+ target: call.target,
55
+ outcome: verdict.outcome,
56
+ before: beforeState(claimPending(call.toolUseId), call.target),
57
+ // Nothing landed. That is what this event means, and it is the difference
58
+ // between this entry and an applied one.
59
+ after: null,
60
+ reason: verdict.reason,
61
+ });
62
+
63
+ reapStalePending();
64
+ logDebug("LedgerUnapplied", `recorded ${entry.id} ${entry.outcome} ${entry.target}`);
65
+ } catch {
66
+ process.exit(0);
67
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * What the two ledger hooks agree on: which tool calls are worth recording, and
3
+ * how to find the file and the call id in an agent's payload.
4
+ *
5
+ * Both halves must answer these identically — a pre-tool half that snapshots a
6
+ * tool the post-tool half ignores leaves a snapshot nothing ever claims.
7
+ */
8
+
9
+ import { normalizeToolUse } from "./agent";
10
+ import type { LedgerOutcome } from "./ledger";
11
+
12
+ /**
13
+ * Edits and writes, which carry their own target in the call. A shell command's
14
+ * effect is not derivable from its arguments, so recording one honestly needs a
15
+ * different mechanism than reading the path out of the payload.
16
+ *
17
+ * Reads and searches are excluded because they are queries, not actions, and a
18
+ * ledger that logs them buries the changes among them.
19
+ */
20
+ const LEDGERED_TOOLS = new Set(["edit", "write"]);
21
+
22
+ /** Agents disagree on the spelling; the value is the same file either way. */
23
+ const TARGET_KEYS = ["file_path", "filePath", "path"];
24
+
25
+ export interface LedgeredCall {
26
+ toolUseId: string;
27
+ tool: string;
28
+ target: string;
29
+ }
30
+
31
+ /**
32
+ * The one reading of a payload both halves share. Asking the same question in
33
+ * two places is how they drift apart, and a drift here is silent: the pre-tool
34
+ * half parks a snapshot the post-tool half never comes to claim.
35
+ */
36
+ export function ledgeredCall(payload: Record<string, unknown>): LedgeredCall | null {
37
+ const toolUse = normalizeToolUse(payload);
38
+ const toolUseId = toolUseIdOf(payload);
39
+ if (!toolUse || !toolUseId) return null;
40
+
41
+ const target = ledgeredTarget(toolUse.toolName, toolUse.toolInput);
42
+ return target ? { toolUseId, tool: toolUse.toolName, target } : null;
43
+ }
44
+
45
+ /**
46
+ * How a call that did not land reports itself. Two events, because the runtime
47
+ * treats the two endings as different things and so does the ledger: a tool
48
+ * that ran and errored is not a call something refused to run.
49
+ *
50
+ * They carry the same fact under different keys, which is the whole reason this
51
+ * mapping is written down in one place rather than read twice.
52
+ */
53
+ const UNAPPLIED_EVENTS: Record<string, { outcome: LedgerOutcome; reasonKey: string }> = {
54
+ PostToolUseFailure: { outcome: "failed", reasonKey: "error" },
55
+ PermissionDenied: { outcome: "denied", reasonKey: "reason" },
56
+ };
57
+
58
+ export interface UnappliedVerdict {
59
+ outcome: LedgerOutcome;
60
+ reason?: string;
61
+ }
62
+
63
+ /**
64
+ * What became of a call, for the events that mean it did not land — or nothing
65
+ * when the payload is some other event, so one hook can be registered on both
66
+ * without having to be told which one it is being run for.
67
+ */
68
+ export function unappliedVerdictOf(
69
+ payload: Record<string, unknown>
70
+ ): UnappliedVerdict | null {
71
+ const event = payload.hook_event_name ?? payload.hookEventName;
72
+ if (typeof event !== "string") return null;
73
+
74
+ const mapping = UNAPPLIED_EVENTS[event];
75
+ if (!mapping) return null;
76
+
77
+ const reason = payload[mapping.reasonKey];
78
+ // A reason the runtime did not send is left absent rather than invented: an
79
+ // entry that states a cause it does not have is worse than one that admits none.
80
+ return typeof reason === "string" && reason.length > 0
81
+ ? { outcome: mapping.outcome, reason }
82
+ : { outcome: mapping.outcome };
83
+ }
84
+
85
+ export function toolUseIdOf(payload: Record<string, unknown>): string | null {
86
+ for (const key of ["tool_use_id", "toolUseId", "tool_call_id"]) {
87
+ const value = payload[key];
88
+ if (typeof value === "string" && value.length > 0) return value;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * The absolute path this call will change, or nothing if the call is not one
95
+ * the ledger records.
96
+ */
97
+ export function ledgeredTarget(
98
+ toolName: string,
99
+ toolInput: Record<string, unknown>
100
+ ): string | null {
101
+ if (!LEDGERED_TOOLS.has(toolName.toLowerCase())) return null;
102
+ for (const key of TARGET_KEYS) {
103
+ const value = toolInput[key];
104
+ if (typeof value === "string" && value.length > 0) return value;
105
+ }
106
+ return null;
107
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Action ledger — an append-only record of what an agent changed, and under
3
+ * whose authority.
4
+ *
5
+ * A transcript says a tool was called. It does not say what the file looked
6
+ * like before, so it cannot answer "what changed" after the fact — the prior
7
+ * contents are gone by the time anything downstream reads it. The ledger is
8
+ * therefore written at the moment of the change, from both sides of it.
9
+ *
10
+ * Scope is deliberately narrow: edits and writes, which carry their own
11
+ * before/after in the call. A shell command's effect is not derivable from its
12
+ * arguments, so recording one honestly would need a different mechanism than
13
+ * this file — see the AGENTS.md rule steering file changes onto the edit tools.
14
+ *
15
+ * Reads and searches are excluded on purpose. They are queries, not actions,
16
+ * and a ledger that logs them buries the changes among them.
17
+ *
18
+ * This module is silent. It runs inside hooks, where stdout is the agent's
19
+ * protocol channel, so it returns what it wrote and leaves reporting to the
20
+ * caller.
21
+ */
22
+
23
+ import {
24
+ appendFileSync,
25
+ existsSync,
26
+ readdirSync,
27
+ readFileSync,
28
+ renameSync,
29
+ statSync,
30
+ unlinkSync,
31
+ writeFileSync,
32
+ } from "node:fs";
33
+ import { resolve } from "node:path";
34
+ import { calcPatch } from "fast-myers-diff";
35
+ import { currentAttribution, type RecordAttribution } from "./actor";
36
+ import { encodeAnchor } from "./anchor";
37
+ import { ensureDir, paths } from "./paths";
38
+
39
+ /**
40
+ * What became of the action.
41
+ *
42
+ * `failed` and `denied` are kept apart because they answer different questions.
43
+ * A failure is the agent's own attempt not working — a bad path, a stale match,
44
+ * a permission on disk. A denial is a human refusing it. Collapsing them would
45
+ * lose the only signal in the record that says where the boundary was drawn,
46
+ * and "what did I try that was refused" is a question worth being able to ask
47
+ * separately from "what did I try that broke".
48
+ */
49
+ export type LedgerOutcome = "applied" | "failed" | "denied";
50
+
51
+ /**
52
+ * One side of a change, identified rather than reproduced. The hash ties the
53
+ * entry to a real file — apply the delta to something matching `before.hash`
54
+ * and you must land on `after.hash` — and the byte count says how big that file
55
+ * was without keeping it.
56
+ */
57
+ export interface LedgerState {
58
+ hash: string;
59
+ bytes: number;
60
+ }
61
+
62
+ /**
63
+ * One contiguous replacement. `at` and `remove` index the before-state and are
64
+ * not shifted by earlier hunks in the same delta, which is what the diff
65
+ * produces and what applying them in order with a running offset expects.
66
+ */
67
+ export interface LedgerHunk {
68
+ at: number;
69
+ remove: number;
70
+ insert: string[];
71
+ }
72
+
73
+ /**
74
+ * What changed, at line granularity.
75
+ *
76
+ * Storing this rather than both whole files is what lets the record scale with
77
+ * the size of the change instead of the size of the file. Under the old shape a
78
+ * four-line edit to a large file kept two hashes and nothing else, so the
79
+ * entries that said least were the ones about the biggest files.
80
+ */
81
+ export interface LedgerDelta {
82
+ hunks: LedgerHunk[];
83
+ /** Set when the change itself was too large to keep. Its absence means the hunks are complete. */
84
+ truncated?: boolean;
85
+ }
86
+
87
+ export interface LedgerEntry extends RecordAttribution {
88
+ id: string;
89
+ ts: string;
90
+ /** The tool that made the change — Edit or Write today. */
91
+ tool: string;
92
+ /** Project-anchored path, so the entry survives a different mount or machine. */
93
+ target: string;
94
+ outcome: LedgerOutcome;
95
+ /** Null when nothing was there before: a file creation has no prior state. */
96
+ before: LedgerState | null;
97
+ /** Null when nothing landed, which is what a failed or denied action means. */
98
+ after: LedgerState | null;
99
+ /**
100
+ * The change from one side to the other. Absent when nothing landed: an
101
+ * action that was refused did not empty the file, and a delta saying it did
102
+ * would be the ledger stating something that never happened.
103
+ */
104
+ delta?: LedgerDelta;
105
+ /** Why the action did not land. Absent on an applied one. */
106
+ reason?: string;
107
+ }
108
+
109
+ export interface RecordActionInput {
110
+ tool: string;
111
+ /** Absolute path of the file the action targeted. */
112
+ target: string;
113
+ outcome: LedgerOutcome;
114
+ /** Prior content; null for a file creation. */
115
+ before: string | null;
116
+ /** Resulting content; null when nothing landed. */
117
+ after: string | null;
118
+ reason?: string;
119
+ }
120
+
121
+ /**
122
+ * A change larger than this is recorded as having happened without being kept.
123
+ * It caps the delta rather than the files, so what fits is decided by how much
124
+ * an action changed, not by how big the thing it changed happened to be.
125
+ */
126
+ const MAX_DELTA_BYTES = 4096;
127
+
128
+ /** Size at which the active file is rotated aside. */
129
+ const MAX_LEDGER_BYTES = 4 * 1024 * 1024;
130
+
131
+ const ACTIVE = "actions.jsonl";
132
+
133
+ export function ledgerPath(): string {
134
+ return resolve(paths.ledger(), ACTIVE);
135
+ }
136
+
137
+ function hash(content: string): string {
138
+ return new Bun.CryptoHasher("sha256").update(content, "utf-8").digest("hex");
139
+ }
140
+
141
+ function stateOf(content: string | null): LedgerState | null {
142
+ if (content === null) return null;
143
+ return { hash: hash(content), bytes: Buffer.byteLength(content, "utf-8") };
144
+ }
145
+
146
+ /**
147
+ * Splitting on newlines keeps the trailing one: "a\n" becomes ["a", ""], and
148
+ * joining puts it back. A file and its line list round-trip exactly, which is
149
+ * what makes a reconstructed after-state hash-identical to the real one.
150
+ */
151
+ function toLines(content: string | null): string[] {
152
+ return content === null ? [] : content.split("\n");
153
+ }
154
+
155
+ /**
156
+ * The change between two states, or nothing when there was no transition to
157
+ * describe. An action that did not land has no delta — see LedgerEntry.delta.
158
+ */
159
+ function deltaOf(before: string | null, after: string | null): LedgerDelta | undefined {
160
+ if (after === null) return undefined;
161
+
162
+ const hunks: LedgerHunk[] = [];
163
+ for (const [at, end, insert] of calcPatch(toLines(before), toLines(after))) {
164
+ hunks.push({ at, remove: end - at, insert: [...insert] });
165
+ }
166
+ if (hunks.length === 0) return undefined;
167
+
168
+ const delta: LedgerDelta = { hunks };
169
+ return Buffer.byteLength(JSON.stringify(delta), "utf-8") > MAX_DELTA_BYTES
170
+ ? { hunks: [], truncated: true }
171
+ : delta;
172
+ }
173
+
174
+ /**
175
+ * Rebuild the after-state from the before-state and the delta, or nothing when
176
+ * the delta was too large to keep.
177
+ *
178
+ * This is what makes an entry checkable rather than merely plausible: the hash
179
+ * of what this returns must equal the entry's `after.hash`. It is also the read
180
+ * side of the ledger — a stored change is only evidence if it can be replayed.
181
+ */
182
+ export function applyDelta(before: string | null, delta: LedgerDelta): string | null {
183
+ if (delta.truncated) return null;
184
+
185
+ const lines = toLines(before);
186
+ const out: string[] = [];
187
+ let cursor = 0;
188
+ for (const hunk of delta.hunks) {
189
+ out.push(...lines.slice(cursor, hunk.at), ...hunk.insert);
190
+ cursor = hunk.at + hunk.remove;
191
+ }
192
+ out.push(...lines.slice(cursor));
193
+ return out.join("\n");
194
+ }
195
+
196
+ function generateId(): string {
197
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
198
+ }
199
+
200
+ /**
201
+ * Move the active file aside once it crosses the size cap, so reads stay cheap
202
+ * without discarding anything. A count-based trim would delete the oldest
203
+ * entries first, which in an audit record is the evidence most worth keeping.
204
+ */
205
+ function rotateIfFull(file: string): void {
206
+ if (!existsSync(file) || statSync(file).size < MAX_LEDGER_BYTES) return;
207
+ renameSync(file, freeArchivePath(new Date().toISOString().replace(/[:.]/g, "-")));
208
+ }
209
+
210
+ /**
211
+ * A name no archive already holds. Two rotations within the same millisecond
212
+ * agree on a stamp, and renaming onto a taken name destroys that archive
213
+ * without a trace — the one failure mode an append-only record cannot have.
214
+ */
215
+ function freeArchivePath(stamp: string): string {
216
+ const nth = (n: number) => {
217
+ const suffix = n ? `-${n}` : "";
218
+ return resolve(paths.ledger(), `actions-${stamp}${suffix}.jsonl`);
219
+ };
220
+ let n = 0;
221
+ while (existsSync(nth(n))) n++;
222
+ return nth(n);
223
+ }
224
+
225
+ /**
226
+ * Append one action to the ledger and return the entry as written.
227
+ *
228
+ * An action that did not land is recorded like any other: a log that keeps only
229
+ * what succeeded cannot answer what was attempted, which is usually the question
230
+ * being asked of it.
231
+ */
232
+ export function recordAction(input: RecordActionInput): LedgerEntry {
233
+ const delta = deltaOf(input.before, input.after);
234
+ const entry: LedgerEntry = {
235
+ id: generateId(),
236
+ ts: new Date().toISOString(),
237
+ ...currentAttribution(),
238
+ tool: input.tool,
239
+ target: encodeAnchor(input.target),
240
+ outcome: input.outcome,
241
+ before: stateOf(input.before),
242
+ after: stateOf(input.after),
243
+ ...(delta ? { delta } : {}),
244
+ ...(input.reason ? { reason: input.reason } : {}),
245
+ };
246
+
247
+ const file = ledgerPath();
248
+ rotateIfFull(file);
249
+ appendFileSync(file, `${JSON.stringify(entry)}\n`, "utf-8");
250
+ return entry;
251
+ }
252
+
253
+ /**
254
+ * The before-state, held between the two halves of one tool call.
255
+ *
256
+ * A post-tool event alone cannot produce it: by the time the tool has run, the
257
+ * prior contents are gone. So the pre-tool half reads the file and parks it
258
+ * here, and the post-tool half claims it back and pairs it with the result.
259
+ */
260
+ export interface PendingSnapshot {
261
+ toolUseId: string;
262
+ tool: string;
263
+ target: string;
264
+ before: string | null;
265
+ ts: string;
266
+ }
267
+
268
+ /** How long a snapshot waits for a second half that may never come. */
269
+ const PENDING_TTL_MS = 60 * 60 * 1000;
270
+
271
+ function pendingDir(): string {
272
+ return ensureDir(resolve(paths.ledger(), "pending"));
273
+ }
274
+
275
+ /**
276
+ * A tool-call id is an identifier from another system, and it lands here as a
277
+ * filename — so it is reduced to characters that cannot climb out of the
278
+ * directory rather than trusted to be well-formed.
279
+ */
280
+ function pendingPath(toolUseId: string): string {
281
+ return resolve(pendingDir(), `${toolUseId.replace(/[^A-Za-z0-9_-]/g, "")}.json`);
282
+ }
283
+
284
+ export function savePending(snapshot: PendingSnapshot): void {
285
+ writeFileSync(pendingPath(snapshot.toolUseId), JSON.stringify(snapshot), "utf-8");
286
+ }
287
+
288
+ /**
289
+ * Take the snapshot for this tool call, removing it in the same step. Claiming
290
+ * is one-shot on purpose: a snapshot that stayed put after being read could be
291
+ * paired with a second result and record a change that never happened.
292
+ */
293
+ export function claimPending(toolUseId: string): PendingSnapshot | null {
294
+ const file = pendingPath(toolUseId);
295
+ if (!existsSync(file)) return null;
296
+ try {
297
+ const snapshot = JSON.parse(readFileSync(file, "utf-8")) as PendingSnapshot;
298
+ unlinkSync(file);
299
+ return snapshot;
300
+ } catch {
301
+ unlinkSync(file);
302
+ return null;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Drop snapshots nothing ever claimed.
308
+ *
309
+ * Applying, failing and being denied by auto mode all fire a second half that
310
+ * claims the snapshot. What is left here is the endings that fire nothing after
311
+ * the pre-tool half — a manual denial at the permission dialog, a deny rule, a
312
+ * pre-tool hook's own block — plus anything interrupted mid-call.
313
+ *
314
+ * They are dropped rather than recorded because the ledger would have to invent
315
+ * which of those it was. The attempt is real and currently goes unrecorded; see
316
+ * the manual-denial gap in the project's ISCs.
317
+ */
318
+ export function reapStalePending(now: number = Date.now()): number {
319
+ const dir = pendingDir();
320
+ let reaped = 0;
321
+ for (const name of readdirSync(dir)) {
322
+ const file = resolve(dir, name);
323
+ if (now - statSync(file).mtimeMs < PENDING_TTL_MS) continue;
324
+ unlinkSync(file);
325
+ reaped++;
326
+ }
327
+ return reaped;
328
+ }
@@ -54,6 +54,7 @@ export const paths = {
54
54
  reflectionsFile: () =>
55
55
  home("memory", "learning", "reflections", "algorithm-reflections.jsonl"),
56
56
  retrievalIndex: () => home("memory", "learning", ".retrieval-index.json"),
57
+ ledger: () => ensureDir(home("memory", "ledger")),
57
58
  progress: () => ensureDir(home("memory", "state", "progress")),
58
59
  projectHistory: () => ensureDir(home("memory", "projects")),
59
60
  sessionLearning: () => ensureDir(home("memory", "learning", "session")),
@@ -23,11 +23,27 @@ import { declaredTriggers } from "../hooks/lib/skill-triggers";
23
23
 
24
24
  // --- Colored logging ---
25
25
 
26
+ function runningUnderTest(): boolean {
27
+ return process.env.PAL_TEST_SANDBOX === "1";
28
+ }
29
+
26
30
  export const log = {
27
31
  info: (msg: string) => console.log(`\x1b[34m[pal]\x1b[0m ${msg}`),
28
32
  success: (msg: string) => console.log(`\x1b[32m[pal]\x1b[0m ${msg}`),
29
33
  warn: (msg: string) => console.log(`\x1b[33m[pal]\x1b[0m ${msg}`),
30
34
  error: (msg: string) => console.error(`\x1b[31m[pal]\x1b[0m ${msg}`),
35
+
36
+ /**
37
+ * Per-item narration from inside a loop, where the caller already reports the
38
+ * total. Silent under the test runner: the suite drives these installers by
39
+ * the hundred against temp directories, so the lines name files that were
40
+ * never on this machine — and they are the only output no caller asserts on,
41
+ * precisely because the summary is what carries the result.
42
+ */
43
+ detail: (msg: string) => {
44
+ if (runningUnderTest()) return;
45
+ console.log(`\x1b[34m[pal]\x1b[0m ${msg}`);
46
+ },
31
47
  };
32
48
 
33
49
  // --- JSON helpers ---
@@ -784,7 +800,7 @@ export function copySkills(claudeSkillsDir: string): number {
784
800
  let count = 0;
785
801
 
786
802
  for (const name of pruneStaleSkillLinks(claudeSkillsDir)) {
787
- log.info(`Removed stale skill link: ${name}`);
803
+ log.detail(`Removed stale skill link: ${name}`);
788
804
  }
789
805
 
790
806
  for (const name of readdirSync(skillsDir)) {
@@ -994,7 +1010,7 @@ export function removeSkills(claudeSkillsDir: string): string[] {
994
1010
  }
995
1011
  }
996
1012
  removed.push(name);
997
- log.info(`Removed skill: ${name}`);
1013
+ log.detail(`Removed skill: ${name}`);
998
1014
  }
999
1015
 
1000
1016
  // Remove ~/.agents/skills/ → ~/.pal/skills/ symlink
@@ -1031,7 +1047,7 @@ export function removeAgents(): string[] {
1031
1047
  unlinkSync(dst);
1032
1048
  const name = file.replace(/\.md$/, "");
1033
1049
  removed.push(name);
1034
- log.info(`Removed agent: ${name}`);
1050
+ log.detail(`Removed agent: ${name}`);
1035
1051
  }
1036
1052
  }
1037
1053
  return removed;
@@ -1141,7 +1157,7 @@ function uninstallAgents(targetDir: string, label: string): string[] {
1141
1157
  if (existsSync(dst)) {
1142
1158
  unlinkSync(dst);
1143
1159
  removed.push(file.replace(/\.md$/, ""));
1144
- log.info(`Removed ${label} agent: ${file.replace(/\.md$/, "")}`);
1160
+ log.detail(`Removed ${label} agent: ${file.replace(/\.md$/, "")}`);
1145
1161
  }
1146
1162
  }
1147
1163
  return removed;