portable-agent-layer 0.67.0 → 0.68.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.
@@ -2,7 +2,7 @@
2
2
  name: pal-analyze
3
3
  license: MIT
4
4
  description: "Run learning analysis — surface rating trends, recurring failure patterns, and graduation candidates. Use when learning analysis is due, or when the user asks about performance patterns, low ratings, or what to improve."
5
- argument-hint: [optional: --actionable for AI-generated recommendations]
5
+ argument-hint: "[optional: --actionable for AI-generated recommendations]"
6
6
  metadata:
7
7
  source: portable-agent-layer
8
8
  triggers:
@@ -2,7 +2,7 @@
2
2
  name: pal-reflect
3
3
  license: MIT
4
4
  description: "Run relationship reflect — promote recurring behavioral observations into tracked opinions. Use when relationship reflect is due, or when the user asks to review what patterns have been observed."
5
- argument-hint: [optional: --dry-run to preview without writing]
5
+ argument-hint: "[optional: --dry-run to preview without writing]"
6
6
  metadata:
7
7
  source: portable-agent-layer
8
8
  triggers:
@@ -2,7 +2,7 @@
2
2
  name: projects
3
3
  license: MIT
4
4
  description: "Project context management. PROACTIVE — use when the user references a project (by name or as \"this repo\", \"current work\"), asks to add/update/complete a project, says \"store under a named project\", \"track this\", \"what am I working on\", \"my projects\", \"my priorities\". Also triggered by ISC/ISA terminology — ISC stands for Ideal State Criteria, a project's verifiable done-conditions stored as `ISC-N:` lines in the Criteria section of its ISA.md spec. Phrases like \"ISC\", \"open/opening the ISC\", \"open ISCs\" (unfinished criteria), \"create/creating a ticket for a project\", \"new ticket\", or \"project ticket\" all map here: use `list-isc` to read them, `add-isc` to create one, `complete-isc`/`reopen-isc` to change status."
5
- argument-hint: [list | create | resume | add-next | add-blocker | add-decision | add-handoff | update-section | criteria | isa-init | complete | archive | pause]
5
+ argument-hint: "[list | create | resume | add-next | add-blocker | add-decision | add-handoff | update-section | criteria | isa-init | complete | archive | pause]"
6
6
  metadata:
7
7
  source: portable-agent-layer
8
8
  triggers:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: telos
3
3
  description: "Personal context management. Use when discussing goals, beliefs, challenges, identity, updating telos, life context, changing a goal, what do I believe, current obstacles, mission, or strategies."
4
- argument-hint: [area to view or update]
4
+ argument-hint: "[area to view or update]"
5
5
  metadata:
6
6
  source: portable-agent-layer
7
7
  derived-from: https://github.com/danielmiessler/LifeOS
@@ -5,7 +5,7 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": "PAL_AGENT=codex bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts"
8
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts --agent=codex"
9
9
  }
10
10
  ]
11
11
  }
@@ -15,7 +15,7 @@
15
15
  "hooks": [
16
16
  {
17
17
  "type": "command",
18
- "command": "PAL_AGENT=codex bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts"
18
+ "command": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts --agent=codex"
19
19
  }
20
20
  ]
21
21
  }
@@ -25,7 +25,21 @@
25
25
  "hooks": [
26
26
  {
27
27
  "type": "command",
28
- "command": "PAL_AGENT=codex bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
28
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=codex"
29
+ },
30
+ {
31
+ "type": "command",
32
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerSnapshot.ts --agent=codex"
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ "PostToolUse": [
38
+ {
39
+ "hooks": [
40
+ {
41
+ "type": "command",
42
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts --agent=codex"
29
43
  }
30
44
  ]
31
45
  }
@@ -35,7 +49,7 @@
35
49
  "hooks": [
36
50
  {
37
51
  "type": "command",
38
- "command": "PAL_AGENT=codex bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts"
52
+ "command": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=codex"
39
53
  }
40
54
  ]
41
55
  }
@@ -4,39 +4,58 @@
4
4
  "sessionStart": [
5
5
  {
6
6
  "type": "command",
7
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts",
8
- "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts"
7
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts --agent=copilot",
8
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts --agent=copilot"
9
9
  }
10
10
  ],
11
11
  "userPromptSubmitted": [
12
12
  {
13
13
  "type": "command",
14
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts",
15
- "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts"
14
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts --agent=copilot",
15
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts --agent=copilot"
16
16
  }
17
17
  ],
18
18
  "preToolUse": [
19
19
  {
20
20
  "type": "command",
21
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts",
22
- "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
21
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=copilot",
22
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=copilot"
23
23
  },
24
24
  {
25
25
  "type": "command",
26
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts",
27
- "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
26
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts --agent=copilot",
27
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts --agent=copilot"
28
28
  },
29
29
  {
30
30
  "type": "command",
31
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts",
32
- "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
31
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts --agent=copilot",
32
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts --agent=copilot"
33
+ },
34
+ {
35
+ "type": "command",
36
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/LedgerSnapshot.ts --agent=copilot",
37
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/LedgerSnapshot.ts --agent=copilot"
38
+ }
39
+ ],
40
+ "postToolUse": [
41
+ {
42
+ "type": "command",
43
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts --agent=copilot",
44
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts --agent=copilot"
45
+ }
46
+ ],
47
+ "postToolUseFailure": [
48
+ {
49
+ "type": "command",
50
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts --agent=copilot --event=postToolUseFailure",
51
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts --agent=copilot --event=postToolUseFailure"
33
52
  }
34
53
  ],
35
54
  "agentStop": [
36
55
  {
37
56
  "type": "command",
38
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts",
39
- "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts"
57
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=copilot",
58
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=copilot"
40
59
  }
41
60
  ]
42
61
  }
@@ -4,39 +4,55 @@
4
4
  "sessionStart": [
5
5
  {
6
6
  "type": "command",
7
- "command": "PAL_AGENT=cursor bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts"
7
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts --agent=cursor"
8
8
  }
9
9
  ],
10
10
  "beforeSubmitPrompt": [
11
11
  {
12
12
  "type": "command",
13
- "command": "PAL_AGENT=cursor bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts"
13
+ "command": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts --agent=cursor"
14
14
  }
15
15
  ],
16
16
  "preToolUse": [
17
17
  {
18
18
  "type": "command",
19
- "command": "PAL_AGENT=cursor bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
19
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=cursor"
20
20
  },
21
21
  {
22
22
  "type": "command",
23
- "command": "PAL_AGENT=cursor bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
23
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts --agent=cursor"
24
24
  },
25
25
  {
26
26
  "type": "command",
27
- "command": "PAL_AGENT=cursor bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
27
+ "command": "bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts --agent=cursor"
28
+ },
29
+ {
30
+ "type": "command",
31
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerSnapshot.ts --agent=cursor"
32
+ }
33
+ ],
34
+ "postToolUse": [
35
+ {
36
+ "type": "command",
37
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts --agent=cursor"
38
+ }
39
+ ],
40
+ "postToolUseFailure": [
41
+ {
42
+ "type": "command",
43
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts --agent=cursor"
28
44
  }
29
45
  ],
30
46
  "beforeShellExecution": [
31
47
  {
32
48
  "type": "command",
33
- "command": "PAL_AGENT=cursor bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
49
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=cursor"
34
50
  }
35
51
  ],
36
52
  "stop": [
37
53
  {
38
54
  "type": "command",
39
- "command": "PAL_AGENT=cursor bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts"
55
+ "command": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=cursor"
40
56
  }
41
57
  ]
42
58
  }
@@ -69,7 +69,7 @@
69
69
  "hooks": [
70
70
  {
71
71
  "type": "command",
72
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts"
72
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts --agent=claude"
73
73
  }
74
74
  ]
75
75
  },
@@ -78,7 +78,7 @@
78
78
  "hooks": [
79
79
  {
80
80
  "type": "command",
81
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/CompactRecover.ts"
81
+ "command": "bun run {{PKG_ROOT}}/src/hooks/CompactRecover.ts --agent=claude"
82
82
  }
83
83
  ]
84
84
  }
@@ -89,7 +89,7 @@
89
89
  "hooks": [
90
90
  {
91
91
  "type": "command",
92
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/PreCompactPersist.ts"
92
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PreCompactPersist.ts --agent=claude"
93
93
  }
94
94
  ]
95
95
  }
@@ -100,7 +100,7 @@
100
100
  "hooks": [
101
101
  {
102
102
  "type": "command",
103
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts"
103
+ "command": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts --agent=claude"
104
104
  }
105
105
  ]
106
106
  }
@@ -111,7 +111,7 @@
111
111
  "hooks": [
112
112
  {
113
113
  "type": "command",
114
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
114
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=claude"
115
115
  }
116
116
  ]
117
117
  },
@@ -120,7 +120,7 @@
120
120
  "hooks": [
121
121
  {
122
122
  "type": "command",
123
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
123
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts --agent=claude"
124
124
  }
125
125
  ]
126
126
  },
@@ -129,7 +129,7 @@
129
129
  "hooks": [
130
130
  {
131
131
  "type": "command",
132
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
132
+ "command": "bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts --agent=claude"
133
133
  }
134
134
  ]
135
135
  },
@@ -138,7 +138,7 @@
138
138
  "hooks": [
139
139
  {
140
140
  "type": "command",
141
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerSnapshot.ts"
141
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerSnapshot.ts --agent=claude"
142
142
  }
143
143
  ]
144
144
  }
@@ -149,7 +149,7 @@
149
149
  "hooks": [
150
150
  {
151
151
  "type": "command",
152
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts"
152
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts --agent=claude"
153
153
  }
154
154
  ]
155
155
  }
@@ -160,7 +160,7 @@
160
160
  "hooks": [
161
161
  {
162
162
  "type": "command",
163
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts"
163
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts --agent=claude"
164
164
  }
165
165
  ]
166
166
  }
@@ -171,7 +171,7 @@
171
171
  "hooks": [
172
172
  {
173
173
  "type": "command",
174
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts"
174
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts --agent=claude"
175
175
  }
176
176
  ]
177
177
  }
@@ -182,7 +182,7 @@
182
182
  "hooks": [
183
183
  {
184
184
  "type": "command",
185
- "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts"
185
+ "command": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=claude"
186
186
  }
187
187
  ]
188
188
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.67.0",
3
+ "version": "0.68.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -469,7 +469,11 @@ function checkCodexHooksRegistered(): boolean {
469
469
  }
470
470
 
471
471
  function checkCopilotInstructionsPresent(): boolean {
472
- return existsSync(resolve(platform.copilotDir(), "copilot-instructions.md"));
472
+ const instructionsDir = resolve(platform.copilotDir(), "instructions");
473
+ if (!existsSync(instructionsDir)) return false;
474
+ return readdirSync(instructionsDir).some(
475
+ (f) => f.startsWith("pal-") && f.endsWith(".instructions.md")
476
+ );
473
477
  }
474
478
 
475
479
  // ── Install integrity (Tier 2 doctor checks) ──
@@ -939,8 +943,8 @@ function doctor(silent = false): DoctorResult {
939
943
  ? ok("Copilot hooks registered")
940
944
  : fail("Copilot hooks — not registered (run 'pal cli install --copilot')");
941
945
  checkCopilotInstructionsPresent()
942
- ? ok("copilot-instructions.md present")
943
- : warn("copilot-instructions.md missing (run 'pal cli install --copilot')");
946
+ ? ok("Copilot instructions present")
947
+ : warn("Copilot instructions missing (written at first session stop)");
944
948
  }
945
949
  if (codex.available) {
946
950
  checkCodexHooksRegistered()
@@ -10,9 +10,7 @@
10
10
  * Silent and fail-open, for the same reason as its other half.
11
11
  */
12
12
 
13
- import { existsSync, readFileSync } from "node:fs";
14
- import { claimPending, reapStalePending, recordAction } from "./lib/ledger";
15
- import { ledgeredCall } from "./lib/ledger-hook";
13
+ import { commitApplied, ledgeredCalls } from "./lib/ledger-hook";
16
14
  import { logDebug } from "./lib/log";
17
15
  import { readStdinJSON } from "./lib/stdin";
18
16
 
@@ -20,24 +18,12 @@ try {
20
18
  const input = await readStdinJSON<Record<string, unknown>>();
21
19
  if (!input) process.exit(0);
22
20
 
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}`);
21
+ for (const call of ledgeredCalls(input)) {
22
+ const entry = commitApplied(call);
23
+ if (entry) {
24
+ logDebug("LedgerCommit", `recorded ${entry.id} ${entry.tool} ${entry.target}`);
25
+ }
26
+ }
41
27
  } catch {
42
28
  process.exit(0);
43
29
  }
@@ -10,9 +10,7 @@
10
10
  * that could block an edit would be a worse thing than a ledger with a gap.
11
11
  */
12
12
 
13
- import { existsSync, readFileSync } from "node:fs";
14
- import { savePending } from "./lib/ledger";
15
- import { ledgeredCall } from "./lib/ledger-hook";
13
+ import { ledgeredCalls, snapshotCall } from "./lib/ledger-hook";
16
14
  import { logDebug } from "./lib/log";
17
15
  import { readStdinJSON } from "./lib/stdin";
18
16
 
@@ -20,18 +18,13 @@ try {
20
18
  const input = await readStdinJSON<Record<string, unknown>>();
21
19
  if (!input) process.exit(0);
22
20
 
23
- const call = ledgeredCall(input);
24
- if (!call) process.exit(0);
21
+ const calls = ledgeredCalls(input);
22
+ if (calls.length === 0) process.exit(0);
25
23
 
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}`);
24
+ for (const call of calls) {
25
+ snapshotCall(call);
26
+ logDebug("LedgerSnapshot", `captured ${call.tool} ${call.toolUseId}`);
27
+ }
35
28
  } catch {
36
29
  process.exit(0);
37
30
  }
@@ -27,7 +27,7 @@ import {
27
27
  reapStalePending,
28
28
  recordAction,
29
29
  } from "./lib/ledger";
30
- import { ledgeredCall, unappliedVerdictOf } from "./lib/ledger-hook";
30
+ import { ledgeredCalls, unappliedVerdictOf } from "./lib/ledger-hook";
31
31
  import { logDebug } from "./lib/log";
32
32
  import { readStdinJSON } from "./lib/stdin";
33
33
 
@@ -45,23 +45,25 @@ try {
45
45
  const input = await readStdinJSON<Record<string, unknown>>();
46
46
  if (!input) process.exit(0);
47
47
 
48
- const call = ledgeredCall(input);
49
- const verdict = call && unappliedVerdictOf(input);
50
- if (!call || !verdict) process.exit(0);
48
+ const calls = ledgeredCalls(input);
49
+ const verdict = calls.length > 0 && unappliedVerdictOf(input);
50
+ if (!verdict) process.exit(0);
51
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
- });
52
+ for (const call of calls) {
53
+ const entry = recordAction({
54
+ tool: call.tool,
55
+ target: call.target,
56
+ outcome: verdict.outcome,
57
+ before: beforeState(claimPending(call.toolUseId), call.target),
58
+ // Nothing landed. That is what this event means, and it is the difference
59
+ // between this entry and an applied one.
60
+ after: null,
61
+ reason: verdict.reason,
62
+ });
63
+ logDebug("LedgerUnapplied", `recorded ${entry.id} ${entry.outcome} ${entry.target}`);
64
+ }
62
65
 
63
66
  reapStalePending();
64
- logDebug("LedgerUnapplied", `recorded ${entry.id} ${entry.outcome} ${entry.target}`);
65
67
  } catch {
66
68
  process.exit(0);
67
69
  }
@@ -5,8 +5,9 @@
5
5
  * This hook injects dynamic context only: wisdom principles, relationship notes,
6
6
  * learning digest, signal trends, failure patterns, active work state.
7
7
  *
8
- * Copilot: sessionStart output is ignored by the runtime. Instead, we write the merged
9
- * context directly to ~/.copilot/copilot-instructions.md so it is picked up on load.
8
+ * Copilot: the CLI reads additionalContext from this hook's stdout, while
9
+ * ~/.copilot/instructions/ is read only by the VS Code extension. The merged
10
+ * context goes to both so either surface picks it up.
10
11
  */
11
12
 
12
13
  import { mkdirSync, writeFileSync } from "node:fs";
@@ -63,6 +64,7 @@ try {
63
64
  `---\napplyTo: "**"\n---\n\n${context}`,
64
65
  "utf-8"
65
66
  );
67
+ process.stdout.write(JSON.stringify({ additionalContext: context }));
66
68
  }
67
69
  logDebug(
68
70
  "LoadContext",
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { resolve } from "node:path";
18
- import { type AgentType, getActiveAgent } from "./agent";
18
+ import { type AgentType, declaredAgent } from "./agent";
19
19
  import {
20
20
  type IdentityBase,
21
21
  loadIdentity,
@@ -50,8 +50,8 @@ export interface RecordAttribution {
50
50
  machine: string;
51
51
  /** Which person caused it. */
52
52
  actor: string;
53
- /** Which agent the actor was driving claude, codex, cursor, copilot, opencode. */
54
- runtime: AgentType;
53
+ /** Which agent the actor was driving, or "unknown" when none declared itself. */
54
+ runtime: AgentType | "unknown";
55
55
  /** Whether a human turn was behind the call. */
56
56
  authority: Authority;
57
57
  }
@@ -165,7 +165,7 @@ export function currentAttribution(): RecordAttribution {
165
165
  return {
166
166
  machine: loadMachine().id,
167
167
  actor: loadActor().id,
168
- runtime: getActiveAgent(),
168
+ runtime: declaredAgent() ?? "unknown",
169
169
  authority: currentAuthority(),
170
170
  };
171
171
  }
@@ -42,13 +42,20 @@ function agentFromArgv(): AgentType | undefined {
42
42
  return value && KNOWN_AGENTS.has(value as AgentType) ? (value as AgentType) : undefined;
43
43
  }
44
44
 
45
- /** Detect which agent is currently running PAL. Defaults to "claude". */
46
- export function getActiveAgent(): AgentType {
47
- const declared = agentFromArgv() ?? agentFromEnv();
48
- if (declared) return declared;
45
+ function agentFromRuntimeEnv(): AgentType | undefined {
49
46
  if (process.env.CURSOR_VERSION) return "cursor";
50
47
  if (process.env.CODEX_CLI_VERSION ?? process.env.OPENAI_CODEX) return "codex";
51
- return "claude";
48
+ return undefined;
49
+ }
50
+
51
+ /** The agent something actually said was running, or undefined if nothing did. */
52
+ export function declaredAgent(): AgentType | undefined {
53
+ return agentFromArgv() ?? agentFromEnv() ?? agentFromRuntimeEnv();
54
+ }
55
+
56
+ /** Which agent's conventions to follow. Assumes "claude" when undeclared. */
57
+ export function getActiveAgent(): AgentType {
58
+ return declaredAgent() ?? "claude";
52
59
  }
53
60
 
54
61
  export const isClaude = () => getActiveAgent() === "claude";
@@ -71,10 +78,27 @@ function firstString(...values: unknown[]): string | undefined {
71
78
 
72
79
  function firstObject(...values: unknown[]): Record<string, unknown> | undefined {
73
80
  return values.find(
74
- (v): v is Record<string, unknown> => typeof v === "object" && v !== null
81
+ (v): v is Record<string, unknown> =>
82
+ typeof v === "object" && v !== null && !Array.isArray(v)
75
83
  );
76
84
  }
77
85
 
86
+ /** Copilot's CLI sends toolArgs as JSON text where the others send an object. */
87
+ function parsedObject(value: unknown): Record<string, unknown> | undefined {
88
+ if (typeof value !== "string") return undefined;
89
+ try {
90
+ const parsed = JSON.parse(value);
91
+ return firstObject(parsed);
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ }
96
+
97
+ function toolInputOf(payload: Record<string, unknown>): Record<string, unknown> {
98
+ const candidates = [payload.tool_input, payload.toolArgs, payload.toolInput];
99
+ return firstObject(...candidates) ?? candidates.map(parsedObject).find(Boolean) ?? {};
100
+ }
101
+
78
102
  /**
79
103
  * Normalize a preToolUse payload across agents.
80
104
  *
@@ -90,7 +114,7 @@ export function normalizeToolUse(raw: unknown): ToolUseRequest | null {
90
114
  if (!toolName) return null;
91
115
  return {
92
116
  toolName,
93
- toolInput: firstObject(payload.tool_input, payload.toolArgs, payload.toolInput) ?? {},
117
+ toolInput: toolInputOf(payload),
94
118
  hookEventName: firstString(payload.hook_event_name, payload.hookEventName),
95
119
  };
96
120
  }
@@ -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 ---