portable-agent-layer 0.67.0 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- 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 +16 -3
- package/src/cli/ledger.ts +329 -0
- 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 +91 -11
- 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
- package/src/tools/agent/project.ts +28 -3
- package/src/tools/ledger/query.ts +300 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pal cli ledger — query the action ledger.
|
|
3
|
+
*
|
|
4
|
+
* Thin presentation layer over src/tools/ledger/query.ts. Owns formatting and
|
|
5
|
+
* argv parsing only; every question about the records themselves is answered
|
|
6
|
+
* there.
|
|
7
|
+
*
|
|
8
|
+
* Subcommands:
|
|
9
|
+
* log [filters] Matching actions, oldest first
|
|
10
|
+
* show <id> One action in full, with its change and current standing
|
|
11
|
+
* stats [filters] Counts by outcome, runtime, actor, tool and target
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { parseArgs } from "node:util";
|
|
15
|
+
import type { LedgerEntry } from "../hooks/lib/ledger";
|
|
16
|
+
import {
|
|
17
|
+
type ChainVerdict,
|
|
18
|
+
chainVerdict,
|
|
19
|
+
changeShape,
|
|
20
|
+
findEntry,
|
|
21
|
+
type LedgerFilter,
|
|
22
|
+
ledgerFiles,
|
|
23
|
+
locate,
|
|
24
|
+
parseSince,
|
|
25
|
+
queryLedger,
|
|
26
|
+
type Standing,
|
|
27
|
+
standing,
|
|
28
|
+
summarize,
|
|
29
|
+
} from "../tools/ledger/query";
|
|
30
|
+
|
|
31
|
+
const FILTER_OPTIONS = {
|
|
32
|
+
project: { type: "string" },
|
|
33
|
+
since: { type: "string" },
|
|
34
|
+
until: { type: "string" },
|
|
35
|
+
actor: { type: "string" },
|
|
36
|
+
machine: { type: "string" },
|
|
37
|
+
runtime: { type: "string" },
|
|
38
|
+
outcome: { type: "string" },
|
|
39
|
+
tool: { type: "string" },
|
|
40
|
+
target: { type: "string" },
|
|
41
|
+
limit: { type: "string" },
|
|
42
|
+
json: { type: "boolean" },
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
export async function runLedger(args: string[]): Promise<number> {
|
|
46
|
+
const [sub, ...rest] = args;
|
|
47
|
+
switch (sub) {
|
|
48
|
+
case "log":
|
|
49
|
+
return cmdLog(rest);
|
|
50
|
+
case "show":
|
|
51
|
+
return cmdShow(rest);
|
|
52
|
+
case "stats":
|
|
53
|
+
return cmdStats(rest);
|
|
54
|
+
case undefined:
|
|
55
|
+
case "help":
|
|
56
|
+
case "--help":
|
|
57
|
+
case "-h":
|
|
58
|
+
showHelp();
|
|
59
|
+
return 0;
|
|
60
|
+
default:
|
|
61
|
+
console.error(`Unknown subcommand: ${sub}\n`);
|
|
62
|
+
showHelp();
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function showHelp(): void {
|
|
68
|
+
console.log(`
|
|
69
|
+
Usage:
|
|
70
|
+
pal cli ledger <subcommand> [filters]
|
|
71
|
+
|
|
72
|
+
Subcommands:
|
|
73
|
+
log [filters] Matching actions, oldest first
|
|
74
|
+
show <id> One action in full: change, target, standing
|
|
75
|
+
stats [filters] Counts by outcome, runtime, actor, tool, target
|
|
76
|
+
|
|
77
|
+
Filters:
|
|
78
|
+
--project <slug> Actions against a registered project
|
|
79
|
+
--since <7d|2026-09-01> A duration back from now, or a date
|
|
80
|
+
--until <date> Upper bound on the timestamp
|
|
81
|
+
--actor <id> Who caused it
|
|
82
|
+
--machine <id> Which install wrote it
|
|
83
|
+
--runtime <agent> claude, cursor, codex, copilot, opencode
|
|
84
|
+
--outcome <applied|failed|denied>
|
|
85
|
+
--tool <Edit|Write>
|
|
86
|
+
--target <substring> Match anywhere in the recorded path
|
|
87
|
+
--limit <n> Keep the newest n matches
|
|
88
|
+
--json Machine-readable output
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
pal cli ledger log --project portable-agent-layer --since 7d
|
|
92
|
+
pal cli ledger log --target memory/ --outcome applied
|
|
93
|
+
pal cli ledger stats --since 24h
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A filter that silently ignored an unparseable window would answer the wrong question. */
|
|
98
|
+
function buildFilter(values: Record<string, unknown>): LedgerFilter | string {
|
|
99
|
+
const filter: LedgerFilter = {};
|
|
100
|
+
for (const key of [
|
|
101
|
+
"project",
|
|
102
|
+
"actor",
|
|
103
|
+
"machine",
|
|
104
|
+
"runtime",
|
|
105
|
+
"outcome",
|
|
106
|
+
"tool",
|
|
107
|
+
"target",
|
|
108
|
+
] as const) {
|
|
109
|
+
const value = values[key];
|
|
110
|
+
if (typeof value === "string") filter[key] = value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const key of ["since", "until"] as const) {
|
|
114
|
+
const spec = values[key];
|
|
115
|
+
if (typeof spec !== "string") continue;
|
|
116
|
+
const at = parseSince(spec);
|
|
117
|
+
if (!at) return `Unrecognised --${key}: ${spec} (use 7d, 24h, or a date)`;
|
|
118
|
+
filter[key] = at;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof values.limit === "string") {
|
|
122
|
+
const limit = Number(values.limit);
|
|
123
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
124
|
+
return `--limit must be a positive integer`;
|
|
125
|
+
filter.limit = limit;
|
|
126
|
+
}
|
|
127
|
+
return filter;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseFilters(args: string[]): { filter: LedgerFilter; json: boolean } | string {
|
|
131
|
+
try {
|
|
132
|
+
const { values } = parseArgs({
|
|
133
|
+
args,
|
|
134
|
+
options: FILTER_OPTIONS,
|
|
135
|
+
allowPositionals: true,
|
|
136
|
+
});
|
|
137
|
+
const filter = buildFilter(values);
|
|
138
|
+
return typeof filter === "string" ? filter : { filter, json: values.json === true };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return error instanceof Error ? error.message : String(error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function shortId(id: string): string {
|
|
145
|
+
return id.slice(0, 11).padEnd(11);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function changedLines(entry: LedgerEntry): string {
|
|
149
|
+
const shape = changeShape(entry);
|
|
150
|
+
switch (shape.kind) {
|
|
151
|
+
case "redacted":
|
|
152
|
+
return "withheld";
|
|
153
|
+
case "truncated":
|
|
154
|
+
return "too large";
|
|
155
|
+
case "none":
|
|
156
|
+
return "no change";
|
|
157
|
+
default: {
|
|
158
|
+
const added = shape.delta.hunks.reduce((n, h) => n + h.insert.length, 0);
|
|
159
|
+
const removed = shape.delta.hunks.reduce((n, h) => n + h.remove, 0);
|
|
160
|
+
return `+${added} -${removed}`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cmdLog(args: string[]): number {
|
|
166
|
+
const parsed = parseFilters(args);
|
|
167
|
+
if (typeof parsed === "string") return fail(parsed);
|
|
168
|
+
|
|
169
|
+
const entries = queryLedger(parsed.filter);
|
|
170
|
+
if (parsed.json) {
|
|
171
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (entries.length === 0) {
|
|
176
|
+
console.log("No actions match.");
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
console.log(
|
|
182
|
+
`${entry.ts} ${shortId(entry.id)} ${entry.outcome.padEnd(7)} ${entry.runtime.padEnd(8)} ${entry.tool.padEnd(5)} ${changedLines(entry).padEnd(9)} ${entry.target}`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
console.log(
|
|
186
|
+
`\n${entries.length} action(s) across ${ledgerFiles().length} ledger file(s).`
|
|
187
|
+
);
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function describeStanding(verdict: Standing): string {
|
|
192
|
+
switch (verdict.state) {
|
|
193
|
+
case "in-place":
|
|
194
|
+
return "still in place on disk";
|
|
195
|
+
case "reverted":
|
|
196
|
+
return verdict.replays
|
|
197
|
+
? "reverted since — the stored change replays cleanly onto the file as it stands"
|
|
198
|
+
: "reverted since, but the stored change no longer replays";
|
|
199
|
+
case "superseded":
|
|
200
|
+
return `superseded — the file has changed again since (now ${verdict.hash.slice(0, 12)})`;
|
|
201
|
+
case "missing":
|
|
202
|
+
return "the target no longer exists";
|
|
203
|
+
default:
|
|
204
|
+
return verdict.why;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function describeChain(verdict: ChainVerdict): string {
|
|
209
|
+
switch (verdict.state) {
|
|
210
|
+
case "latest":
|
|
211
|
+
return "the newest recorded action on this target";
|
|
212
|
+
case "undone":
|
|
213
|
+
return `undone by ${verdict.by} at ${verdict.at}, which put the file back as this one found it`;
|
|
214
|
+
default:
|
|
215
|
+
return `changed again by ${verdict.by} at ${verdict.at}`;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const UNKEPT_CHANGE: Record<string, string> = {
|
|
220
|
+
redacted: "contents withheld — the target is one the ledger never keeps",
|
|
221
|
+
truncated: "the change was too large to keep; its size and hashes remain",
|
|
222
|
+
none: "no change was recorded, which is what a refused action looks like",
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
function printChange(entry: LedgerEntry): void {
|
|
226
|
+
const shape = changeShape(entry);
|
|
227
|
+
if (shape.kind !== "hunks") {
|
|
228
|
+
console.log(` ${UNKEPT_CHANGE[shape.kind]}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const hunk of shape.delta.hunks) {
|
|
232
|
+
console.log(` @@ line ${hunk.at + 1}, -${hunk.remove} +${hunk.insert.length}`);
|
|
233
|
+
for (const line of hunk.insert) console.log(` + ${line}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function cmdShow(args: string[]): number {
|
|
238
|
+
const [id, ...rest] = args;
|
|
239
|
+
if (!id) return fail("Usage: pal cli ledger show <id>");
|
|
240
|
+
|
|
241
|
+
const entry = findEntry(id);
|
|
242
|
+
if (!entry) return fail(`No action with id ${id}`);
|
|
243
|
+
|
|
244
|
+
if (rest.includes("--json")) {
|
|
245
|
+
console.log(
|
|
246
|
+
JSON.stringify(
|
|
247
|
+
{
|
|
248
|
+
...entry,
|
|
249
|
+
resolved: locate(entry),
|
|
250
|
+
standing: standing(entry),
|
|
251
|
+
chain: chainVerdict(entry),
|
|
252
|
+
},
|
|
253
|
+
null,
|
|
254
|
+
2
|
|
255
|
+
)
|
|
256
|
+
);
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log(`
|
|
261
|
+
${entry.id} ${entry.ts}
|
|
262
|
+
${entry.tool} → ${outcomeLine(entry)}
|
|
263
|
+
target ${entry.target}
|
|
264
|
+
on disk ${diskLine(entry)}
|
|
265
|
+
runtime ${entry.runtime}, authority ${entry.authority}
|
|
266
|
+
actor ${entry.actor}
|
|
267
|
+
machine ${entry.machine}
|
|
268
|
+
size ${sizeOf(entry.before, "created")} → ${sizeOf(entry.after, "nothing landed")}
|
|
269
|
+
standing ${describeStanding(standing(entry))}
|
|
270
|
+
in ledger ${describeChain(chainVerdict(entry))}
|
|
271
|
+
|
|
272
|
+
change`);
|
|
273
|
+
printChange(entry);
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function outcomeLine(entry: LedgerEntry): string {
|
|
278
|
+
return entry.reason ? `${entry.outcome} (${entry.reason})` : entry.outcome;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function diskLine(entry: LedgerEntry): string {
|
|
282
|
+
const found = locate(entry);
|
|
283
|
+
if (found.path) return found.path;
|
|
284
|
+
return `unresolvable: project ${found.unresolvable} is not registered here`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function sizeOf(state: LedgerEntry["before"], absent: string): string {
|
|
288
|
+
return state ? `${state.bytes}b` : absent;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function printTally(label: string, counts: Record<string, number>): void {
|
|
292
|
+
const rows = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
|
293
|
+
if (rows.length === 0) return;
|
|
294
|
+
console.log(` ${label}`);
|
|
295
|
+
for (const [key, count] of rows)
|
|
296
|
+
console.log(` ${String(count).padStart(6)} ${key}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function cmdStats(args: string[]): number {
|
|
300
|
+
const parsed = parseFilters(args);
|
|
301
|
+
if (typeof parsed === "string") return fail(parsed);
|
|
302
|
+
|
|
303
|
+
const stats = summarize(queryLedger(parsed.filter));
|
|
304
|
+
if (parsed.json) {
|
|
305
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
console.log(
|
|
310
|
+
`\n ${stats.total} action(s) across ${ledgerFiles().length} ledger file(s)`
|
|
311
|
+
);
|
|
312
|
+
if (stats.span) console.log(` ${stats.span.first} → ${stats.span.last}\n`);
|
|
313
|
+
printTally("outcome", stats.byOutcome);
|
|
314
|
+
printTally("runtime", stats.byRuntime);
|
|
315
|
+
printTally("tool", stats.byTool);
|
|
316
|
+
printTally("actor", stats.byActor);
|
|
317
|
+
if (stats.topTargets.length > 0) {
|
|
318
|
+
console.log(" most-changed targets");
|
|
319
|
+
for (const { target, count } of stats.topTargets) {
|
|
320
|
+
console.log(` ${String(count).padStart(6)} ${target}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function fail(message: string): number {
|
|
327
|
+
console.error(message);
|
|
328
|
+
return 1;
|
|
329
|
+
}
|
|
@@ -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
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* one-shot subscription-backed inference. These helpers identify which agent
|
|
7
7
|
* is currently running PAL so downstream code can dispatch accordingly.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
9
|
+
* Detection reads PAL_AGENT first (set in-process by
|
|
10
|
+
* `src/targets/opencode/plugin.ts`), then the host's own environment, and only
|
|
11
|
+
* then the `--agent=` flag the install templates in `assets/templates/*` put
|
|
12
|
+
* on the hook command line. See declaredAgent for why that order.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
export type AgentType = "claude" | "cursor" | "codex" | "copilot" | "opencode" | "vscode";
|
|
@@ -42,13 +43,75 @@ function agentFromArgv(): AgentType | undefined {
|
|
|
42
43
|
return value && KNOWN_AGENTS.has(value as AgentType) ? (value as AgentType) : undefined;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* cursor-agent's own session env. CURSOR_VERSION is kept because it costs
|
|
48
|
+
* nothing and appears on no other surface, but it is not the primary signal —
|
|
49
|
+
* it is absent from the session env that hook children inherit.
|
|
50
|
+
*/
|
|
51
|
+
function inCursorAgent(): boolean {
|
|
52
|
+
return Boolean(
|
|
53
|
+
process.env.CURSOR_AGENT ??
|
|
54
|
+
process.env.CURSOR_VERSION ??
|
|
55
|
+
(process.env.CURSOR_INVOKED_AS === "cursor-agent" || undefined)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function inCodex(): boolean {
|
|
60
|
+
return Boolean(process.env.CODEX_CLI_VERSION ?? process.env.OPENAI_CODEX);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Set by every Claude Code host — cli, claude-vscode, claude-desktop — and by
|
|
65
|
+
* none of the others. The Cursor extension carries CURSOR_SPAWN_CHAIN and
|
|
66
|
+
* friends but no CURSOR_AGENT, so it lands here rather than on cursor.
|
|
67
|
+
*/
|
|
68
|
+
function inClaudeCode(): boolean {
|
|
69
|
+
return Boolean(process.env.CLAUDE_CODE_ENTRYPOINT);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Which host is running this process, read from what the host itself exported.
|
|
74
|
+
*
|
|
75
|
+
* cursor-agent is tested first on purpose: it emulates Claude Code closely
|
|
76
|
+
* enough to inject CLAUDE_PROJECT_DIR and CLAUDE_CODE_AUTO_COMPACT_WINDOW, so
|
|
77
|
+
* a CLAUDE_* variable is evidence of Claude Code only once Cursor is ruled out.
|
|
78
|
+
*/
|
|
79
|
+
function agentFromRuntimeEnv(): AgentType | undefined {
|
|
80
|
+
if (inCursorAgent()) return "cursor";
|
|
81
|
+
if (inCodex()) return "codex";
|
|
82
|
+
if (inClaudeCode()) return "claude";
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Cursor and VS Code both load ~/.claude/settings.json alongside their own
|
|
88
|
+
* config, so a `--agent=claude` flag names a file three hosts share and cannot
|
|
89
|
+
* by itself say which one is running. Every other flag comes from a config only
|
|
90
|
+
* its own agent reads.
|
|
91
|
+
*/
|
|
92
|
+
const SHARED_CONFIG_AGENTS: ReadonlySet<AgentType> = new Set(["claude", "vscode"]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The agent something actually said was running, or undefined if nothing did.
|
|
96
|
+
*
|
|
97
|
+
* Host evidence outranks the flag only for the shared config, because one
|
|
98
|
+
* cursor-agent edit runs both ~/.cursor/hooks.json and ~/.claude/settings.json
|
|
99
|
+
* and the winner of that race used to decide the recorded runtime. It must not
|
|
100
|
+
* outrank an unambiguous flag: a Copilot or Codex session started from a Claude
|
|
101
|
+
* Code terminal inherits CLAUDE_CODE_ENTRYPOINT, and ambient inheritance is
|
|
102
|
+
* weaker evidence than an agent's own registration.
|
|
103
|
+
*/
|
|
104
|
+
export function declaredAgent(): AgentType | undefined {
|
|
105
|
+
const explicit = agentFromEnv();
|
|
106
|
+
if (explicit) return explicit;
|
|
107
|
+
const flag = agentFromArgv();
|
|
108
|
+
if (flag && !SHARED_CONFIG_AGENTS.has(flag)) return flag;
|
|
109
|
+
return agentFromRuntimeEnv() ?? flag;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Which agent's conventions to follow. Assumes "claude" when undeclared. */
|
|
46
113
|
export function getActiveAgent(): AgentType {
|
|
47
|
-
|
|
48
|
-
if (declared) return declared;
|
|
49
|
-
if (process.env.CURSOR_VERSION) return "cursor";
|
|
50
|
-
if (process.env.CODEX_CLI_VERSION ?? process.env.OPENAI_CODEX) return "codex";
|
|
51
|
-
return "claude";
|
|
114
|
+
return declaredAgent() ?? "claude";
|
|
52
115
|
}
|
|
53
116
|
|
|
54
117
|
export const isClaude = () => getActiveAgent() === "claude";
|
|
@@ -71,10 +134,27 @@ function firstString(...values: unknown[]): string | undefined {
|
|
|
71
134
|
|
|
72
135
|
function firstObject(...values: unknown[]): Record<string, unknown> | undefined {
|
|
73
136
|
return values.find(
|
|
74
|
-
(v): v is Record<string, unknown> =>
|
|
137
|
+
(v): v is Record<string, unknown> =>
|
|
138
|
+
typeof v === "object" && v !== null && !Array.isArray(v)
|
|
75
139
|
);
|
|
76
140
|
}
|
|
77
141
|
|
|
142
|
+
/** Copilot's CLI sends toolArgs as JSON text where the others send an object. */
|
|
143
|
+
function parsedObject(value: unknown): Record<string, unknown> | undefined {
|
|
144
|
+
if (typeof value !== "string") return undefined;
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(value);
|
|
147
|
+
return firstObject(parsed);
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toolInputOf(payload: Record<string, unknown>): Record<string, unknown> {
|
|
154
|
+
const candidates = [payload.tool_input, payload.toolArgs, payload.toolInput];
|
|
155
|
+
return firstObject(...candidates) ?? candidates.map(parsedObject).find(Boolean) ?? {};
|
|
156
|
+
}
|
|
157
|
+
|
|
78
158
|
/**
|
|
79
159
|
* Normalize a preToolUse payload across agents.
|
|
80
160
|
*
|
|
@@ -90,7 +170,7 @@ export function normalizeToolUse(raw: unknown): ToolUseRequest | null {
|
|
|
90
170
|
if (!toolName) return null;
|
|
91
171
|
return {
|
|
92
172
|
toolName,
|
|
93
|
-
toolInput:
|
|
173
|
+
toolInput: toolInputOf(payload),
|
|
94
174
|
hookEventName: firstString(payload.hook_event_name, payload.hookEventName),
|
|
95
175
|
};
|
|
96
176
|
}
|