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.
- package/assets/skills/pal-analyze/SKILL.md +1 -1
- package/assets/skills/pal-reflect/SKILL.md +1 -1
- package/assets/skills/projects/SKILL.md +1 -1
- package/assets/skills/telos/SKILL.md +1 -1
- package/assets/templates/hooks.codex.json +18 -4
- package/assets/templates/hooks.copilot.json +31 -12
- package/assets/templates/hooks.cursor.json +23 -7
- package/assets/templates/settings.claude.json +12 -12
- package/package.json +1 -1
- package/src/cli/index.ts +7 -3
- package/src/hooks/LedgerCommit.ts +7 -21
- package/src/hooks/LedgerSnapshot.ts +7 -14
- package/src/hooks/LedgerUnapplied.ts +17 -15
- package/src/hooks/LoadContext.ts +4 -2
- package/src/hooks/lib/actor.ts +4 -4
- package/src/hooks/lib/agent.ts +31 -7
- package/src/hooks/lib/ledger-hook.ts +186 -17
- package/src/hooks/lib/ledger.ts +25 -5
- package/src/hooks/lib/sensitive-path.ts +117 -0
- package/src/hooks/lib/settings.ts +5 -0
- package/src/targets/lib.ts +17 -3
- package/src/targets/opencode/plugin.ts +22 -0
|
@@ -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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
8
|
-
"powershell": "
|
|
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": "
|
|
15
|
-
"powershell": "
|
|
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": "
|
|
22
|
-
"powershell": "
|
|
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": "
|
|
27
|
-
"powershell": "
|
|
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": "
|
|
32
|
-
"powershell": "
|
|
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": "
|
|
39
|
-
"powershell": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
19
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=cursor"
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
"type": "command",
|
|
23
|
-
"command": "
|
|
23
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts --agent=cursor"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"type": "command",
|
|
27
|
-
"command": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
185
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=claude"
|
|
186
186
|
}
|
|
187
187
|
]
|
|
188
188
|
}
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -469,7 +469,11 @@ function checkCodexHooksRegistered(): boolean {
|
|
|
469
469
|
}
|
|
470
470
|
|
|
471
471
|
function checkCopilotInstructionsPresent(): boolean {
|
|
472
|
-
|
|
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("
|
|
943
|
-
: warn("
|
|
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 {
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 {
|
|
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
|
|
24
|
-
if (
|
|
21
|
+
const calls = ledgeredCalls(input);
|
|
22
|
+
if (calls.length === 0) process.exit(0);
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 {
|
|
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
|
|
49
|
-
const verdict =
|
|
50
|
-
if (!
|
|
48
|
+
const calls = ledgeredCalls(input);
|
|
49
|
+
const verdict = calls.length > 0 && unappliedVerdictOf(input);
|
|
50
|
+
if (!verdict) process.exit(0);
|
|
51
51
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
}
|
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -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:
|
|
9
|
-
*
|
|
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",
|
package/src/hooks/lib/actor.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { resolve } from "node:path";
|
|
18
|
-
import { type AgentType,
|
|
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
|
|
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:
|
|
168
|
+
runtime: declaredAgent() ?? "unknown",
|
|
169
169
|
authority: currentAuthority(),
|
|
170
170
|
};
|
|
171
171
|
}
|
package/src/hooks/lib/agent.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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> =>
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
74
|
+
export function ledgeredCalls(payload: Record<string, unknown>): LedgeredCall[] {
|
|
37
75
|
const toolUse = normalizeToolUse(payload);
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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;
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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;
|
package/src/hooks/lib/ledger.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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[];
|
package/src/targets/lib.ts
CHANGED
|
@@ -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 = {
|
|
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 =
|
|
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 =
|
|
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 ---
|