portable-agent-layer 0.67.0 → 0.69.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.
@@ -6,8 +6,17 @@
6
6
  * tool the post-tool half ignores leaves a snapshot nothing ever claims.
7
7
  */
8
8
 
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { isAbsolute, resolve } from "node:path";
9
11
  import { normalizeToolUse } from "./agent";
10
- import type { LedgerOutcome } from "./ledger";
12
+ import {
13
+ claimPending,
14
+ type LedgerEntry,
15
+ type LedgerOutcome,
16
+ reapStalePending,
17
+ recordAction,
18
+ savePending,
19
+ } from "./ledger";
11
20
 
12
21
  /**
13
22
  * Edits and writes, which carry their own target in the call. A shell command's
@@ -16,12 +25,39 @@ import type { LedgerOutcome } from "./ledger";
16
25
  *
17
26
  * Reads and searches are excluded because they are queries, not actions, and a
18
27
  * ledger that logs them buries the changes among them.
28
+ *
29
+ * The four beyond edit and write are Copilot's own names for the same act — its
30
+ * runtime groups them as its file-editing tools, and it sends the command as
31
+ * the tool name rather than as an argument.
32
+ */
33
+ const LEDGERED_TOOLS = new Set([
34
+ "edit",
35
+ "write",
36
+ "create",
37
+ "insert",
38
+ "str_replace",
39
+ "str_replace_editor",
40
+ ]);
41
+
42
+ /**
43
+ * Copilot's CLI also writes through a patch, and one call of that edits however
44
+ * many files the patch names. Its targets live in the patch body rather than in
45
+ * an argument, so it is filtered separately and read by patchedTargets.
46
+ */
47
+ const PATCHING_TOOLS = new Set(["apply_patch", "applypatch"]);
48
+
49
+ /**
50
+ * The editor tool that changes a file also reads one, under a command argument.
51
+ * A read recorded as an action would put queries back in a log of changes.
19
52
  */
20
- const LEDGERED_TOOLS = new Set(["edit", "write"]);
53
+ const READING_COMMANDS = new Set(["view"]);
21
54
 
22
55
  /** Agents disagree on the spelling; the value is the same file either way. */
23
56
  const TARGET_KEYS = ["file_path", "filePath", "path"];
24
57
 
58
+ /** Every header in the V4A patch format that names a file the patch changes. */
59
+ const PATCHED_FILE_HEADER = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm;
60
+
25
61
  export interface LedgeredCall {
26
62
  toolUseId: string;
27
63
  tool: string;
@@ -32,14 +68,104 @@ export interface LedgeredCall {
32
68
  * The one reading of a payload both halves share. Asking the same question in
33
69
  * two places is how they drift apart, and a drift here is silent: the pre-tool
34
70
  * half parks a snapshot the post-tool half never comes to claim.
71
+ *
72
+ * A list because a patch call changes a set of files, not one.
35
73
  */
36
- export function ledgeredCall(payload: Record<string, unknown>): LedgeredCall | null {
74
+ export function ledgeredCalls(payload: Record<string, unknown>): LedgeredCall[] {
37
75
  const toolUse = normalizeToolUse(payload);
38
- const toolUseId = toolUseIdOf(payload);
39
- if (!toolUse || !toolUseId) return null;
76
+ if (!toolUse) return [];
77
+
78
+ const patched = patchedTargets(payload, toolUse.toolName);
79
+ if (patched.length > 0) return patchCalls(payload, toolUse.toolName, patched);
40
80
 
41
81
  const target = ledgeredTarget(toolUse.toolName, toolUse.toolInput);
42
- return target ? { toolUseId, tool: toolUse.toolName, target } : null;
82
+ if (!target) return [];
83
+
84
+ const toolUseId = pairingKeyOf(payload, toolUse.toolName, target);
85
+ return toolUseId ? [{ toolUseId, tool: toolUse.toolName, target }] : [];
86
+ }
87
+
88
+ /**
89
+ * A patch changes several files under one tool call, so the call's own id
90
+ * cannot key its snapshots — all of them would collide on it and only one would
91
+ * ever be claimed. Naming the target in the key is what keeps them apart.
92
+ */
93
+ function patchCalls(
94
+ payload: Record<string, unknown>,
95
+ tool: string,
96
+ targets: string[]
97
+ ): LedgeredCall[] {
98
+ const anchor = toolUseIdOf(payload) ?? sessionOf(payload);
99
+ if (!anchor) return [];
100
+ return targets.map((target) => ({
101
+ toolUseId: derivedPairingKey(anchor, tool, target),
102
+ tool,
103
+ target,
104
+ }));
105
+ }
106
+
107
+ /**
108
+ * The files a patch call changes, resolved against the directory the agent ran
109
+ * it in — a patch names them the way the agent typed them, which is relative.
110
+ */
111
+ function patchedTargets(payload: Record<string, unknown>, toolName: string): string[] {
112
+ if (!PATCHING_TOOLS.has(toolName.toLowerCase())) return [];
113
+
114
+ const patch = patchBodyOf(payload);
115
+ if (!patch) return [];
116
+
117
+ const base = typeof payload.cwd === "string" ? payload.cwd : process.cwd();
118
+ return Array.from(patch.matchAll(PATCHED_FILE_HEADER), (match) =>
119
+ absoluteFrom(base, match[1].trim())
120
+ );
121
+ }
122
+
123
+ /** Its arguments are the patch itself, rather than JSON naming a file. */
124
+ function patchBodyOf(payload: Record<string, unknown>): string | null {
125
+ const args = payload.toolArgs ?? payload.tool_input ?? payload.toolInput;
126
+ if (typeof args === "string") return args.length > 0 ? args : null;
127
+ return patchCommandOf(args);
128
+ }
129
+
130
+ /** Codex carries the patch under a command key instead of sending it as the arguments. */
131
+ function patchCommandOf(args: unknown): string | null {
132
+ if (typeof args !== "object" || args === null || Array.isArray(args)) return null;
133
+ const command = (args as Record<string, unknown>).command;
134
+ return typeof command === "string" && command.length > 0 ? command : null;
135
+ }
136
+
137
+ function absoluteFrom(base: string, path: string): string {
138
+ return isAbsolute(path) ? path : resolve(base, path);
139
+ }
140
+
141
+ /**
142
+ * Copilot's CLI publishes no id for a tool invocation, so its two halves are
143
+ * paired on what both do carry. Session, tool and target identify the call
144
+ * uniquely as long as it does not overlap another write to the same file in
145
+ * the same session, which sequential tool use cannot produce.
146
+ */
147
+ function derivedPairingKey(anchor: string, tool: string, target: string): string {
148
+ const digest = new Bun.CryptoHasher("sha256")
149
+ .update([anchor, tool, target].join(" "), "utf-8")
150
+ .digest("hex");
151
+ return `derived-${digest.slice(0, 32)}`;
152
+ }
153
+
154
+ function sessionOf(payload: Record<string, unknown>): string | null {
155
+ const session = payload.sessionId ?? payload.session_id;
156
+ return typeof session === "string" && session.length > 0 ? session : null;
157
+ }
158
+
159
+ function pairingKeyOf(
160
+ payload: Record<string, unknown>,
161
+ tool: string,
162
+ target: string
163
+ ): string | null {
164
+ const explicit = toolUseIdOf(payload);
165
+ if (explicit) return explicit;
166
+
167
+ const session = sessionOf(payload);
168
+ return session ? derivedPairingKey(session, tool, target) : null;
43
169
  }
44
170
 
45
171
  /**
@@ -50,16 +176,28 @@ export function ledgeredCall(payload: Record<string, unknown>): LedgeredCall | n
50
176
  * They carry the same fact under different keys, which is the whole reason this
51
177
  * mapping is written down in one place rather than read twice.
52
178
  */
53
- const UNAPPLIED_EVENTS: Record<string, { outcome: LedgerOutcome; reasonKey: string }> = {
54
- PostToolUseFailure: { outcome: "failed", reasonKey: "error" },
55
- PermissionDenied: { outcome: "denied", reasonKey: "reason" },
56
- };
179
+ const UNAPPLIED_EVENTS: Record<string, { outcome: LedgerOutcome; reasonKeys: string[] }> =
180
+ {
181
+ PostToolUseFailure: { outcome: "failed", reasonKeys: ["error"] },
182
+ PermissionDenied: { outcome: "denied", reasonKeys: ["reason"] },
183
+ postToolUseFailure: { outcome: "failed", reasonKeys: ["error_message", "error"] },
184
+ };
185
+
186
+ function deniedByFailureType(payload: Record<string, unknown>): boolean {
187
+ return payload.failure_type === "permission_denied";
188
+ }
57
189
 
58
190
  export interface UnappliedVerdict {
59
191
  outcome: LedgerOutcome;
60
192
  reason?: string;
61
193
  }
62
194
 
195
+ /** Copilot names the event nowhere in its payload, so its config says so on argv. */
196
+ function eventFromArgv(): string | undefined {
197
+ const flag = process.argv.find((a) => a.startsWith("--event="));
198
+ return flag?.slice("--event=".length) || undefined;
199
+ }
200
+
63
201
  /**
64
202
  * What became of a call, for the events that mean it did not land — or nothing
65
203
  * when the payload is some other event, so one hook can be registered on both
@@ -68,18 +206,43 @@ export interface UnappliedVerdict {
68
206
  export function unappliedVerdictOf(
69
207
  payload: Record<string, unknown>
70
208
  ): UnappliedVerdict | null {
71
- const event = payload.hook_event_name ?? payload.hookEventName;
209
+ const event = payload.hook_event_name ?? payload.hookEventName ?? eventFromArgv();
72
210
  if (typeof event !== "string") return null;
73
211
 
74
212
  const mapping = UNAPPLIED_EVENTS[event];
75
213
  if (!mapping) return null;
76
214
 
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 };
215
+ const outcome = deniedByFailureType(payload) ? "denied" : mapping.outcome;
216
+ const reason = mapping.reasonKeys
217
+ .map((key) => payload[key])
218
+ .find((value): value is string => typeof value === "string" && value.length > 0);
219
+ return reason ? { outcome, reason } : { outcome };
220
+ }
221
+
222
+ function contentsOf(path: string): string | null {
223
+ return existsSync(path) ? readFileSync(path, "utf-8") : null;
224
+ }
225
+
226
+ /** Park the target's current contents, the last moment they still exist. */
227
+ export function snapshotCall(call: LedgeredCall): void {
228
+ savePending({ ...call, before: contentsOf(call.target), ts: new Date().toISOString() });
229
+ }
230
+
231
+ /** Pair a parked before-state with the result, or nothing if none was parked. */
232
+ export function commitApplied(call: LedgeredCall): LedgerEntry | null {
233
+ const pending = claimPending(call.toolUseId);
234
+ if (!pending) return null;
235
+
236
+ const entry = recordAction({
237
+ tool: pending.tool,
238
+ target: pending.target,
239
+ outcome: "applied",
240
+ before: pending.before,
241
+ beforeState: pending.beforeState,
242
+ after: contentsOf(call.target),
243
+ });
244
+ reapStalePending();
245
+ return entry;
83
246
  }
84
247
 
85
248
  export function toolUseIdOf(payload: Record<string, unknown>): string | null {
@@ -90,6 +253,11 @@ export function toolUseIdOf(payload: Record<string, unknown>): string | null {
90
253
  return null;
91
254
  }
92
255
 
256
+ function readsRatherThanChanges(toolInput: Record<string, unknown>): boolean {
257
+ const command = toolInput.command;
258
+ return typeof command === "string" && READING_COMMANDS.has(command);
259
+ }
260
+
93
261
  /**
94
262
  * The absolute path this call will change, or nothing if the call is not one
95
263
  * the ledger records.
@@ -99,6 +267,7 @@ export function ledgeredTarget(
99
267
  toolInput: Record<string, unknown>
100
268
  ): string | null {
101
269
  if (!LEDGERED_TOOLS.has(toolName.toLowerCase())) return null;
270
+ if (readsRatherThanChanges(toolInput)) return null;
102
271
  for (const key of TARGET_KEYS) {
103
272
  const value = toolInput[key];
104
273
  if (typeof value === "string" && value.length > 0) return value;
@@ -35,6 +35,7 @@ import { calcPatch } from "fast-myers-diff";
35
35
  import { currentAttribution, type RecordAttribution } from "./actor";
36
36
  import { encodeAnchor } from "./anchor";
37
37
  import { ensureDir, paths } from "./paths";
38
+ import { isSensitivePath } from "./sensitive-path";
38
39
 
39
40
  /**
40
41
  * What became of the action.
@@ -82,6 +83,8 @@ export interface LedgerDelta {
82
83
  hunks: LedgerHunk[];
83
84
  /** Set when the change itself was too large to keep. Its absence means the hunks are complete. */
84
85
  truncated?: boolean;
86
+ /** Set when the target is one whose contents the ledger never keeps. */
87
+ redacted?: boolean;
85
88
  }
86
89
 
87
90
  export interface LedgerEntry extends RecordAttribution {
@@ -111,8 +114,10 @@ export interface RecordActionInput {
111
114
  /** Absolute path of the file the action targeted. */
112
115
  target: string;
113
116
  outcome: LedgerOutcome;
114
- /** Prior content; null for a file creation. */
117
+ /** Prior content; null for a file creation, and withheld for a sensitive target. */
115
118
  before: string | null;
119
+ /** Identity of the prior content, when the content itself was withheld. */
120
+ beforeState?: LedgerState;
116
121
  /** Resulting content; null when nothing landed. */
117
122
  after: string | null;
118
123
  reason?: string;
@@ -156,6 +161,13 @@ function toLines(content: string | null): string[] {
156
161
  * The change between two states, or nothing when there was no transition to
157
162
  * describe. An action that did not land has no delta — see LedgerEntry.delta.
158
163
  */
164
+ const WITHHELD: LedgerDelta = { hunks: [], redacted: true };
165
+
166
+ function deltaFor(input: RecordActionInput): LedgerDelta | undefined {
167
+ if (isSensitivePath(input.target)) return WITHHELD;
168
+ return deltaOf(input.before, input.after);
169
+ }
170
+
159
171
  function deltaOf(before: string | null, after: string | null): LedgerDelta | undefined {
160
172
  if (after === null) return undefined;
161
173
 
@@ -180,7 +192,7 @@ function deltaOf(before: string | null, after: string | null): LedgerDelta | und
180
192
  * side of the ledger — a stored change is only evidence if it can be replayed.
181
193
  */
182
194
  export function applyDelta(before: string | null, delta: LedgerDelta): string | null {
183
- if (delta.truncated) return null;
195
+ if (delta.truncated || delta.redacted) return null;
184
196
 
185
197
  const lines = toLines(before);
186
198
  const out: string[] = [];
@@ -230,7 +242,7 @@ function freeArchivePath(stamp: string): string {
230
242
  * being asked of it.
231
243
  */
232
244
  export function recordAction(input: RecordActionInput): LedgerEntry {
233
- const delta = deltaOf(input.before, input.after);
245
+ const delta = deltaFor(input);
234
246
  const entry: LedgerEntry = {
235
247
  id: generateId(),
236
248
  ts: new Date().toISOString(),
@@ -238,7 +250,7 @@ export function recordAction(input: RecordActionInput): LedgerEntry {
238
250
  tool: input.tool,
239
251
  target: encodeAnchor(input.target),
240
252
  outcome: input.outcome,
241
- before: stateOf(input.before),
253
+ before: input.beforeState ?? stateOf(input.before),
242
254
  after: stateOf(input.after),
243
255
  ...(delta ? { delta } : {}),
244
256
  ...(input.reason ? { reason: input.reason } : {}),
@@ -262,6 +274,7 @@ export interface PendingSnapshot {
262
274
  tool: string;
263
275
  target: string;
264
276
  before: string | null;
277
+ beforeState?: LedgerState;
265
278
  ts: string;
266
279
  }
267
280
 
@@ -281,8 +294,15 @@ function pendingPath(toolUseId: string): string {
281
294
  return resolve(pendingDir(), `${toolUseId.replace(/[^A-Za-z0-9_-]/g, "")}.json`);
282
295
  }
283
296
 
297
+ function withheldWhenSensitive(snapshot: PendingSnapshot): PendingSnapshot {
298
+ if (snapshot.before === null || !isSensitivePath(snapshot.target)) return snapshot;
299
+ const state = stateOf(snapshot.before);
300
+ return { ...snapshot, before: null, ...(state ? { beforeState: state } : {}) };
301
+ }
302
+
284
303
  export function savePending(snapshot: PendingSnapshot): void {
285
- writeFileSync(pendingPath(snapshot.toolUseId), JSON.stringify(snapshot), "utf-8");
304
+ const withheld = withheldWhenSensitive(snapshot);
305
+ writeFileSync(pendingPath(withheld.toolUseId), JSON.stringify(withheld), "utf-8");
286
306
  }
287
307
 
288
308
  /**
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Paths whose contents the ledger notes the change of but never keeps.
3
+ *
4
+ * The floor below is fixed in code rather than configured: a denylist a user can
5
+ * shrink is a suggestion, and settings may only add to this one. That direction
6
+ * also makes a malformed user pattern harmless — the worst it can do is redact
7
+ * something it did not need to.
8
+ */
9
+
10
+ import { raw } from "./settings";
11
+
12
+ const ENV_TEMPLATE_SUFFIXES = [".sample", ".example", ".template", ".dist", ".defaults"];
13
+
14
+ /** Matched whole: `id_*` would catch `id_generator.ts`, `credentials*` a test file. */
15
+ const SECRET_FILENAMES = new Set([
16
+ ".npmrc",
17
+ ".netrc",
18
+ "_netrc",
19
+ ".pgpass",
20
+ ".htpasswd",
21
+ ".envrc",
22
+ "credentials",
23
+ "id_rsa",
24
+ "id_dsa",
25
+ "id_ecdsa",
26
+ "id_ecdsa_sk",
27
+ "id_ed25519",
28
+ "id_ed25519_sk",
29
+ ]);
30
+
31
+ const SECRET_EXTENSIONS = [
32
+ ".pem",
33
+ ".key",
34
+ ".p12",
35
+ ".pfx",
36
+ ".keystore",
37
+ ".jks",
38
+ ".asc",
39
+ ".gpg",
40
+ ".kdbx",
41
+ ];
42
+
43
+ const SECRET_DIRECTORIES = new Set([
44
+ ".ssh",
45
+ ".gnupg",
46
+ ".aws",
47
+ ".docker",
48
+ ".kube",
49
+ ".gcloud",
50
+ ".azure",
51
+ ]);
52
+
53
+ const SECRET_DIRECTORY_PAIRS = [
54
+ [".config", "gh"],
55
+ [".config", "gcloud"],
56
+ [".local", "share/keyrings"],
57
+ ];
58
+
59
+ function segmentsOf(path: string): string[] {
60
+ return path.split(/[/\\]/).filter(Boolean);
61
+ }
62
+
63
+ function isLiveDotenv(name: string): boolean {
64
+ if (name !== ".env" && !name.startsWith(".env.")) return false;
65
+ return !ENV_TEMPLATE_SUFFIXES.some((suffix) => name.endsWith(suffix));
66
+ }
67
+
68
+ function isSecretFilename(name: string): boolean {
69
+ return SECRET_FILENAMES.has(name);
70
+ }
71
+
72
+ function hasSecretExtension(name: string): boolean {
73
+ return SECRET_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext));
74
+ }
75
+
76
+ function inSecretDirectory(dirs: string[]): boolean {
77
+ if (dirs.some((dir) => SECRET_DIRECTORIES.has(dir))) return true;
78
+ return SECRET_DIRECTORY_PAIRS.some(([parent, child]) =>
79
+ dirs.some(
80
+ (dir, i) =>
81
+ dir === parent &&
82
+ dirs
83
+ .slice(i + 1)
84
+ .join("/")
85
+ .startsWith(child)
86
+ )
87
+ );
88
+ }
89
+
90
+ function userPatterns(): string[] {
91
+ const configured = raw().ledger?.redactPaths;
92
+ if (!Array.isArray(configured)) return [];
93
+ return configured.filter(
94
+ (pattern) => typeof pattern === "string" && pattern.length > 0
95
+ );
96
+ }
97
+
98
+ function matchesUserPattern(path: string, name: string): boolean {
99
+ return userPatterns().some((pattern) => {
100
+ const glob = new Bun.Glob(pattern);
101
+ return glob.match(path) || glob.match(name);
102
+ });
103
+ }
104
+
105
+ /** Should this file's contents be withheld from the ledger? */
106
+ export function isSensitivePath(path: string): boolean {
107
+ const segments = segmentsOf(path);
108
+ const name = segments.at(-1) ?? "";
109
+ const dirs = segments.slice(0, -1);
110
+ return (
111
+ inSecretDirectory(dirs) ||
112
+ isLiveDotenv(name) ||
113
+ isSecretFilename(name) ||
114
+ hasSecretExtension(name) ||
115
+ matchesUserPattern(path, name)
116
+ );
117
+ }
@@ -25,6 +25,11 @@ export interface PalSettingsData {
25
25
  dynamicContext?: Record<string, boolean>;
26
26
  /** Git co-author attribution opt-in. `decided` gates the one-time prompt. */
27
27
  attribution?: { enabled?: boolean; decided?: boolean };
28
+ /**
29
+ * Action-ledger user extension. `redactPaths` adds to the built-in set of
30
+ * paths whose contents are never stored; it cannot shrink it.
31
+ */
32
+ ledger?: { redactPaths?: string[] };
28
33
  /** Contextual-steering user extension: personal rules + shipped rules to suppress by tag. */
29
34
  steering?: {
30
35
  disable?: string[];
@@ -472,7 +472,21 @@ export function unmergeCursorHooks(
472
472
 
473
473
  type CodexHookCommand = { type: string; command: string; timeout?: number };
474
474
  type CodexHookGroup = { matcher?: string; hooks: CodexHookCommand[] };
475
- type CodexHooks = { hooks?: Record<string, CodexHookGroup[]> };
475
+ type CodexHooks = {
476
+ hooks?: Record<string, CodexHookGroup[]>;
477
+ description?: string;
478
+ version?: unknown;
479
+ };
480
+
481
+ /**
482
+ * Codex parses hooks.json strictly and accepts only `description` and `hooks`.
483
+ * A stale `version` makes it reject the whole file — every PAL hook silently
484
+ * stops — and merging preserves what it finds, so it must be dropped by name.
485
+ */
486
+ function withoutRejectedFields(config: CodexHooks): CodexHooks {
487
+ const { version: _version, ...accepted } = config;
488
+ return accepted;
489
+ }
476
490
 
477
491
  /**
478
492
  * Normalize a PAL hook command for cross-path deduplication.
@@ -542,7 +556,7 @@ function stripPalHooks(
542
556
 
543
557
  /** Merge PAL hooks into an existing Codex hooks.json. Deduplicates by canonical command path. */
544
558
  export function mergeCodexHooks(existing: CodexHooks, template: CodexHooks): CodexHooks {
545
- const result: CodexHooks = { ...existing };
559
+ const result: CodexHooks = withoutRejectedFields(existing);
546
560
  if (!template.hooks) return result;
547
561
  result.hooks ??= {};
548
562
 
@@ -561,7 +575,7 @@ export function unmergeCodexHooks(
561
575
  existing: CodexHooks,
562
576
  template: CodexHooks
563
577
  ): CodexHooks {
564
- const result: CodexHooks = { ...existing };
578
+ const result: CodexHooks = withoutRejectedFields(existing);
565
579
  if (!template.hooks || !result.hooks) return result;
566
580
 
567
581
  stripPalHooks(result.hooks, collectPalCanonical(template));
@@ -106,6 +106,14 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
106
106
  const { isPalSpawnedInference } =
107
107
  await lib<typeof import("../../hooks/lib/spawn-guard")>("spawn-guard.ts");
108
108
 
109
+ const { commitApplied, ledgeredTarget, snapshotCall } =
110
+ await lib<typeof import("../../hooks/lib/ledger-hook")>("ledger-hook.ts");
111
+
112
+ const ledgeredOpencodeCall = (tool: string, callID: string, args: unknown) => {
113
+ const target = ledgeredTarget(tool, (args ?? {}) as Record<string, unknown>);
114
+ return target ? { toolUseId: callID, tool, target } : null;
115
+ };
116
+
109
117
  return {
110
118
  // --- Per-message: Inject dynamic system reminder ---
111
119
  "experimental.chat.system.transform": async (_input, output) => {
@@ -213,6 +221,20 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
213
221
  throw new Error(`PAL Security: ${fileReason}`);
214
222
  }
215
223
  }
224
+
225
+ const call = ledgeredOpencodeCall(toolName, _input.callID, output.args);
226
+ if (call) snapshotCall(call);
227
+ },
228
+
229
+ "tool.execute.after": async (
230
+ input: { tool: string; sessionID: string; callID: string; args: unknown },
231
+ _output: { title: string; output: string; metadata: unknown }
232
+ ) => {
233
+ const call = ledgeredOpencodeCall(input.tool, input.callID, input.args);
234
+ if (!call) return;
235
+
236
+ const entry = commitApplied(call);
237
+ if (entry) logDebug("opencode:ledger", `recorded ${entry.id} ${entry.target}`);
216
238
  },
217
239
 
218
240
  // --- Inject PAL_DIR into shell environment ---
@@ -392,11 +392,36 @@ interface Isc {
392
392
  status: IscStatus;
393
393
  }
394
394
 
395
+ /**
396
+ * An ISC is one markdown line, so a newline in its text would end the record
397
+ * and strand every paragraph after it as unparseable debris. Backslashes are
398
+ * escaped first so that decoding a literal "\n" in a regex cannot be mistaken
399
+ * for the separator.
400
+ */
401
+ function encodeIscText(text: string): string {
402
+ return text
403
+ .replaceAll("\\", "\\\\")
404
+ .replaceAll("\r\n", "\n")
405
+ .replaceAll("\r", "\n")
406
+ .replaceAll("\n", "\\n");
407
+ }
408
+
409
+ const ISC_UNESCAPE: Record<string, string> = { n: "\n", "\\": "\\" };
410
+
411
+ function decodeIscText(stored: string): string {
412
+ return stored.replaceAll(/\\(.)/g, (whole, ch) => ISC_UNESCAPE[ch] ?? whole);
413
+ }
414
+
395
415
  function parseIscs(criteria: string): Isc[] {
396
416
  const out: Isc[] = [];
397
417
  for (const line of criteria.split("\n")) {
398
418
  const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
399
- if (m) out.push({ id: Number(m[2]), text: m[3].trim(), status: statusFromBox(m[1]) });
419
+ if (m)
420
+ out.push({
421
+ id: Number(m[2]),
422
+ text: decodeIscText(m[3].trim()),
423
+ status: statusFromBox(m[1]),
424
+ });
400
425
  }
401
426
  return out;
402
427
  }
@@ -471,7 +496,7 @@ function cmdAddIsc(args: string[]): void {
471
496
  const p = requireProject(name);
472
497
  const current = p.criteria ?? "";
473
498
  const id = nextIscId(p);
474
- const newLine = `- [ ] ISC-${id}: ${title}`;
499
+ const newLine = `- [ ] ISC-${id}: ${encodeIscText(title)}`;
475
500
  p.criteria = current ? `${current.trimEnd()}\n${newLine}` : newLine;
476
501
  p.updated = now();
477
502
  writeProject(p);
@@ -628,7 +653,7 @@ function cmdEditIsc(args: string[]): void {
628
653
  .split("\n")
629
654
  .map((l) =>
630
655
  new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`, "i").test(l)
631
- ? `- ${box} ISC-${id}: ${text}`
656
+ ? `- ${box} ISC-${id}: ${encodeIscText(text)}`
632
657
  : l
633
658
  )
634
659
  .join("\n");