portable-agent-layer 0.68.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/package.json +1 -1
- package/src/cli/index.ts +9 -0
- package/src/cli/ledger.ts +329 -0
- package/src/hooks/lib/agent.ts +63 -7
- package/src/tools/agent/project.ts +28 -3
- package/src/tools/ledger/query.ts +300 -0
package/README.md
CHANGED
|
@@ -88,6 +88,7 @@ pal cli status # check your setup
|
|
|
88
88
|
| `pal cli actor [label <name>]` | Show or rename the actor — who caused a record. Travels with an export, so a shared memory can tell two people apart |
|
|
89
89
|
| `pal cli machine [label <name>]` | Show or rename this install — where a record was written. Never leaves the machine |
|
|
90
90
|
| `pal cli knowledge` | Query & manage the knowledge store (search, graph, stats, hubs, find, show, add, ls, ingest) |
|
|
91
|
+
| `pal cli ledger` | Query the action ledger — `log`, `show <id>`, `stats`, filtered by `--project`, `--since`, `--actor`, `--machine`, `--runtime`, `--outcome`, `--tool`, `--target` |
|
|
91
92
|
| `pal cli skill link <name>` | Link a personal `~/.pal/skills/<name>/` into every installed agent so it is discoverable |
|
|
92
93
|
| `pal cli skill doctor <name>` | Evaluate a skill against the authoring best practices (folder/file-name match, name, description, body length, point-of-view, reference depth) |
|
|
93
94
|
| `pal cli subagent link <name>` | Install a personal `~/.pal/agents/<name>.md` (merged multi-platform definition) into every installed agent, split per platform |
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* status Show current PAL configuration
|
|
17
17
|
* doctor Check prerequisites and system health
|
|
18
18
|
* usage Summarize token usage and cost
|
|
19
|
+
* ledger <sub> [filters] Query the action ledger (log · show · stats)
|
|
19
20
|
* skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
|
|
20
21
|
* skill doctor <name|--all> Evaluate one skill, or every installed skill, against the authoring best practices
|
|
21
22
|
* subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
|
|
@@ -232,6 +233,12 @@ async function runCli(command: string | undefined, args: string[]) {
|
|
|
232
233
|
if (code !== 0) process.exit(code);
|
|
233
234
|
break;
|
|
234
235
|
}
|
|
236
|
+
case "ledger": {
|
|
237
|
+
const { runLedger } = await import("./ledger");
|
|
238
|
+
const code = await runLedger(args);
|
|
239
|
+
if (code !== 0) process.exit(code);
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
235
242
|
case "subagent": {
|
|
236
243
|
const { runSubagent } = await import("./subagent");
|
|
237
244
|
const code = await runSubagent(args);
|
|
@@ -299,6 +306,8 @@ function showHelp() {
|
|
|
299
306
|
pal cli machine [label <name>] Show or rename this install (where it was written)
|
|
300
307
|
pal cli knowledge <sub> [args] Query & manage the knowledge store
|
|
301
308
|
(search · graph · stats · hubs · find · show · add · ls)
|
|
309
|
+
pal cli ledger <sub> [filters] Query the action ledger (log · show · stats)
|
|
310
|
+
e.g. ledger log --project X --since 7d
|
|
302
311
|
pal cli skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
|
|
303
312
|
pal cli skill doctor <name|--all> Evaluate one skill, or every installed skill
|
|
304
313
|
pal cli skill author-model Print the flagship model that authors skills for the active agent
|
|
@@ -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
|
+
}
|
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,15 +43,70 @@ function agentFromArgv(): AgentType | undefined {
|
|
|
42
43
|
return value && KNOWN_AGENTS.has(value as AgentType) ? (value as AgentType) : undefined;
|
|
43
44
|
}
|
|
44
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
|
+
*/
|
|
45
79
|
function agentFromRuntimeEnv(): AgentType | undefined {
|
|
46
|
-
if (
|
|
47
|
-
if (
|
|
80
|
+
if (inCursorAgent()) return "cursor";
|
|
81
|
+
if (inCodex()) return "codex";
|
|
82
|
+
if (inClaudeCode()) return "claude";
|
|
48
83
|
return undefined;
|
|
49
84
|
}
|
|
50
85
|
|
|
51
|
-
/**
|
|
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
|
+
*/
|
|
52
104
|
export function declaredAgent(): AgentType | undefined {
|
|
53
|
-
|
|
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;
|
|
54
110
|
}
|
|
55
111
|
|
|
56
112
|
/** Which agent's conventions to follow. Assumes "claude" when undeclared. */
|
|
@@ -392,11 +392,36 @@ interface Isc {
|
|
|
392
392
|
status: IscStatus;
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
+
/**
|
|
396
|
+
* An ISC is one markdown line, so a newline in its text would end the record
|
|
397
|
+
* and strand every paragraph after it as unparseable debris. Backslashes are
|
|
398
|
+
* escaped first so that decoding a literal "\n" in a regex cannot be mistaken
|
|
399
|
+
* for the separator.
|
|
400
|
+
*/
|
|
401
|
+
function encodeIscText(text: string): string {
|
|
402
|
+
return text
|
|
403
|
+
.replaceAll("\\", "\\\\")
|
|
404
|
+
.replaceAll("\r\n", "\n")
|
|
405
|
+
.replaceAll("\r", "\n")
|
|
406
|
+
.replaceAll("\n", "\\n");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const ISC_UNESCAPE: Record<string, string> = { n: "\n", "\\": "\\" };
|
|
410
|
+
|
|
411
|
+
function decodeIscText(stored: string): string {
|
|
412
|
+
return stored.replaceAll(/\\(.)/g, (whole, ch) => ISC_UNESCAPE[ch] ?? whole);
|
|
413
|
+
}
|
|
414
|
+
|
|
395
415
|
function parseIscs(criteria: string): Isc[] {
|
|
396
416
|
const out: Isc[] = [];
|
|
397
417
|
for (const line of criteria.split("\n")) {
|
|
398
418
|
const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
|
|
399
|
-
if (m)
|
|
419
|
+
if (m)
|
|
420
|
+
out.push({
|
|
421
|
+
id: Number(m[2]),
|
|
422
|
+
text: decodeIscText(m[3].trim()),
|
|
423
|
+
status: statusFromBox(m[1]),
|
|
424
|
+
});
|
|
400
425
|
}
|
|
401
426
|
return out;
|
|
402
427
|
}
|
|
@@ -471,7 +496,7 @@ function cmdAddIsc(args: string[]): void {
|
|
|
471
496
|
const p = requireProject(name);
|
|
472
497
|
const current = p.criteria ?? "";
|
|
473
498
|
const id = nextIscId(p);
|
|
474
|
-
const newLine = `- [ ] ISC-${id}: ${title}`;
|
|
499
|
+
const newLine = `- [ ] ISC-${id}: ${encodeIscText(title)}`;
|
|
475
500
|
p.criteria = current ? `${current.trimEnd()}\n${newLine}` : newLine;
|
|
476
501
|
p.updated = now();
|
|
477
502
|
writeProject(p);
|
|
@@ -628,7 +653,7 @@ function cmdEditIsc(args: string[]): void {
|
|
|
628
653
|
.split("\n")
|
|
629
654
|
.map((l) =>
|
|
630
655
|
new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`, "i").test(l)
|
|
631
|
-
? `- ${box} ISC-${id}: ${text}`
|
|
656
|
+
? `- ${box} ISC-${id}: ${encodeIscText(text)}`
|
|
632
657
|
: l
|
|
633
658
|
)
|
|
634
659
|
.join("\n");
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read side of the action ledger — typed queries over what was recorded.
|
|
3
|
+
*
|
|
4
|
+
* The write side stores enough to answer questions, but only in the shape that
|
|
5
|
+
* was cheap to write: one JSON object per line, targets held as project
|
|
6
|
+
* anchors, changes held as line deltas. Reading it back with a grep gets the
|
|
7
|
+
* lines and loses the meaning — a slug is not a path, and a delta is not a
|
|
8
|
+
* diff until something replays it.
|
|
9
|
+
*
|
|
10
|
+
* Every query here spans the archives as well as the active file. Rotation
|
|
11
|
+
* exists so history survives; a reader that opened only the live file would
|
|
12
|
+
* quietly answer "what changed" with "what changed recently".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { resolveAnchor } from "../../hooks/lib/anchor";
|
|
18
|
+
import {
|
|
19
|
+
applyDelta,
|
|
20
|
+
type LedgerDelta,
|
|
21
|
+
type LedgerEntry,
|
|
22
|
+
ledgerPath,
|
|
23
|
+
} from "../../hooks/lib/ledger";
|
|
24
|
+
import { paths } from "../../hooks/lib/paths";
|
|
25
|
+
|
|
26
|
+
const ARCHIVE_RE = /^actions-.*\.jsonl$/;
|
|
27
|
+
|
|
28
|
+
const ANCHOR_SLUG_RE = /^\{proj:([a-z0-9_-]+)\}/;
|
|
29
|
+
|
|
30
|
+
export interface LedgerFilter {
|
|
31
|
+
project?: string;
|
|
32
|
+
since?: Date;
|
|
33
|
+
until?: Date;
|
|
34
|
+
actor?: string;
|
|
35
|
+
machine?: string;
|
|
36
|
+
runtime?: string;
|
|
37
|
+
outcome?: string;
|
|
38
|
+
tool?: string;
|
|
39
|
+
target?: string;
|
|
40
|
+
limit?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Archives first, then the active file, so the result reads oldest to newest
|
|
45
|
+
* the way the underlying appends do. Names carry an ISO stamp, which sorts
|
|
46
|
+
* lexicographically into chronological order.
|
|
47
|
+
*/
|
|
48
|
+
export function ledgerFiles(): string[] {
|
|
49
|
+
const dir = paths.ledger();
|
|
50
|
+
const archives = readdirSync(dir)
|
|
51
|
+
.filter((name) => ARCHIVE_RE.test(name))
|
|
52
|
+
.sort()
|
|
53
|
+
.map((name) => resolve(dir, name));
|
|
54
|
+
const active = ledgerPath();
|
|
55
|
+
return existsSync(active) ? [...archives, active] : archives;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function entriesInFile(file: string): LedgerEntry[] {
|
|
59
|
+
if (!existsSync(file)) return [];
|
|
60
|
+
const entries: LedgerEntry[] = [];
|
|
61
|
+
for (const line of readFileSync(file, "utf-8").split("\n")) {
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
try {
|
|
64
|
+
entries.push(JSON.parse(line) as LedgerEntry);
|
|
65
|
+
} catch {
|
|
66
|
+
/* a partially written line is not evidence; skip it */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return entries;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function readLedger(): LedgerEntry[] {
|
|
73
|
+
return ledgerFiles().flatMap(entriesInFile);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function anchorSlugOf(target: string): string | null {
|
|
77
|
+
return ANCHOR_SLUG_RE.exec(target)?.[1] ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* An entry names its project two ways depending on when it was written, and a
|
|
82
|
+
* query has to accept both. An anchored target carries the slug outright. A
|
|
83
|
+
* plain one predates anchoring or fell outside every registered project, and
|
|
84
|
+
* only means this project if it resolves under its root on this machine.
|
|
85
|
+
*/
|
|
86
|
+
function inProject(entry: LedgerEntry, slug: string): boolean {
|
|
87
|
+
const anchored = anchorSlugOf(entry.target);
|
|
88
|
+
if (anchored) return anchored === slug;
|
|
89
|
+
|
|
90
|
+
const root = resolveAnchor(`{proj:${slug}}`);
|
|
91
|
+
if (root.state !== "anchored") return false;
|
|
92
|
+
return resolve(entry.target).startsWith(resolve(root.path));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function matchesFilter(entry: LedgerEntry, filter: LedgerFilter): boolean {
|
|
96
|
+
const at = new Date(entry.ts).getTime();
|
|
97
|
+
if (filter.since && at < filter.since.getTime()) return false;
|
|
98
|
+
if (filter.until && at > filter.until.getTime()) return false;
|
|
99
|
+
if (filter.project && !inProject(entry, filter.project)) return false;
|
|
100
|
+
if (filter.actor && entry.actor !== filter.actor) return false;
|
|
101
|
+
if (filter.machine && entry.machine !== filter.machine) return false;
|
|
102
|
+
if (filter.runtime && entry.runtime !== filter.runtime) return false;
|
|
103
|
+
if (filter.outcome && entry.outcome !== filter.outcome) return false;
|
|
104
|
+
if (filter.tool && entry.tool.toLowerCase() !== filter.tool.toLowerCase()) return false;
|
|
105
|
+
if (filter.target && !entry.target.includes(filter.target)) return false;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Matching entries oldest first, capped from the newest end — a limit that
|
|
111
|
+
* dropped the newest would answer "the last N changes" with the first ones.
|
|
112
|
+
*/
|
|
113
|
+
export function queryLedger(filter: LedgerFilter = {}): LedgerEntry[] {
|
|
114
|
+
const matched = readLedger().filter((entry) => matchesFilter(entry, filter));
|
|
115
|
+
return filter.limit === undefined ? matched : matched.slice(-filter.limit);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function findEntry(id: string): LedgerEntry | null {
|
|
119
|
+
return readLedger().find((entry) => entry.id === id) ?? null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Where the entry's target lives on this machine, or the reason it cannot be
|
|
124
|
+
* placed — a project this install has never registered resolves to nothing,
|
|
125
|
+
* and saying so is more useful than printing a slug as if it were a path.
|
|
126
|
+
*/
|
|
127
|
+
export function locate(entry: LedgerEntry): { path?: string; unresolvable?: string } {
|
|
128
|
+
const resolved = resolveAnchor(entry.target);
|
|
129
|
+
return resolved.state === "unresolvable"
|
|
130
|
+
? { unresolvable: resolved.slug }
|
|
131
|
+
: { path: resolved.path };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type ChangeShape =
|
|
135
|
+
| { kind: "hunks"; delta: LedgerDelta }
|
|
136
|
+
| { kind: "redacted" }
|
|
137
|
+
| { kind: "truncated" }
|
|
138
|
+
| { kind: "none" };
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What the entry can say about its own change. The three empty cases are kept
|
|
142
|
+
* apart because they mean different things: contents deliberately withheld, a
|
|
143
|
+
* change too large to keep, and an action that never landed at all.
|
|
144
|
+
*/
|
|
145
|
+
export function changeShape(entry: LedgerEntry): ChangeShape {
|
|
146
|
+
if (!entry.delta) return { kind: "none" };
|
|
147
|
+
if (entry.delta.redacted) return { kind: "redacted" };
|
|
148
|
+
if (entry.delta.truncated) return { kind: "truncated" };
|
|
149
|
+
return { kind: "hunks", delta: entry.delta };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether the change this entry recorded is still the state of the file.
|
|
154
|
+
*
|
|
155
|
+
* The comparison is against the hashes the entry already carries, not a replay
|
|
156
|
+
* from a stored before-image — the ledger keeps the change, not the prior file,
|
|
157
|
+
* so there is nothing to replay from unless the file happens to be sitting at
|
|
158
|
+
* its before-state again. `reverted` is exactly that case, and there the delta
|
|
159
|
+
* can be run forward for real, which is why it reports whether it did.
|
|
160
|
+
*/
|
|
161
|
+
export type Standing =
|
|
162
|
+
| { state: "in-place" }
|
|
163
|
+
| { state: "reverted"; replays: boolean }
|
|
164
|
+
| { state: "superseded"; hash: string }
|
|
165
|
+
| { state: "missing" }
|
|
166
|
+
| { state: "unknown"; why: string };
|
|
167
|
+
|
|
168
|
+
function hashOf(content: string): string {
|
|
169
|
+
return new Bun.CryptoHasher("sha256").update(content, "utf-8").digest("hex");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function replaysToAfter(entry: LedgerEntry, onDisk: string): boolean {
|
|
173
|
+
if (!entry.delta || !entry.after) return false;
|
|
174
|
+
const rebuilt = applyDelta(onDisk, entry.delta);
|
|
175
|
+
return rebuilt !== null && hashOf(rebuilt) === entry.after.hash;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function standing(entry: LedgerEntry): Standing {
|
|
179
|
+
if (!entry.after) return { state: "unknown", why: "the action never landed" };
|
|
180
|
+
|
|
181
|
+
const found = locate(entry);
|
|
182
|
+
if (!found.path)
|
|
183
|
+
return { state: "unknown", why: `unknown project ${found.unresolvable}` };
|
|
184
|
+
if (!existsSync(found.path)) return { state: "missing" };
|
|
185
|
+
|
|
186
|
+
const onDisk = readFileSync(found.path, "utf-8");
|
|
187
|
+
const hash = hashOf(onDisk);
|
|
188
|
+
if (hash === entry.after.hash) return { state: "in-place" };
|
|
189
|
+
if (entry.before && hash === entry.before.hash)
|
|
190
|
+
return { state: "reverted", replays: replaysToAfter(entry, onDisk) };
|
|
191
|
+
return { state: "superseded", hash };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* What the record itself says became of a change, as opposed to what the disk
|
|
196
|
+
* says now.
|
|
197
|
+
*
|
|
198
|
+
* `standing` can only describe the present, so any entry that is not the newest
|
|
199
|
+
* for its target reads as superseded — true, and nearly content-free, since it
|
|
200
|
+
* says only that something happened afterwards. The ledger already holds the
|
|
201
|
+
* whole per-target chain, and that answers the question worth asking: whether a
|
|
202
|
+
* later action put the file back the way this one found it, and which action
|
|
203
|
+
* that was. It stays true no matter how many edits come after.
|
|
204
|
+
*/
|
|
205
|
+
export type ChainVerdict =
|
|
206
|
+
| { state: "latest" }
|
|
207
|
+
| { state: "undone"; by: string; at: string }
|
|
208
|
+
| { state: "followed"; by: string; at: string };
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Deliberately takes no entry list. A chain computed over a filtered query
|
|
212
|
+
* would report `latest` for an entry the filter merely hid the successor of,
|
|
213
|
+
* so there is no parameter here through which that mistake can be made.
|
|
214
|
+
*/
|
|
215
|
+
export function chainVerdict(entry: LedgerEntry): ChainVerdict {
|
|
216
|
+
const all = readLedger();
|
|
217
|
+
const position = all.findIndex((candidate) => candidate.id === entry.id);
|
|
218
|
+
const later = all
|
|
219
|
+
.slice(position + 1)
|
|
220
|
+
.filter((candidate) => candidate.target === entry.target);
|
|
221
|
+
if (later.length === 0) return { state: "latest" };
|
|
222
|
+
|
|
223
|
+
const undo = undoingEntry(entry, later);
|
|
224
|
+
const next = undo ?? later[0];
|
|
225
|
+
return { state: undo ? "undone" : "followed", by: next.id, at: next.ts };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The first later action that left the file as this one found it. An action
|
|
230
|
+
* that never landed changed nothing to undo, and one that created the file is
|
|
231
|
+
* undone by a deletion, which this ledger does not record.
|
|
232
|
+
*/
|
|
233
|
+
function undoingEntry(entry: LedgerEntry, later: LedgerEntry[]): LedgerEntry | undefined {
|
|
234
|
+
if (!entry.before || !entry.after) return undefined;
|
|
235
|
+
return later.find((candidate) => candidate.after?.hash === entry.before?.hash);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface LedgerStats {
|
|
239
|
+
total: number;
|
|
240
|
+
span: { first: string; last: string } | null;
|
|
241
|
+
byOutcome: Record<string, number>;
|
|
242
|
+
byRuntime: Record<string, number>;
|
|
243
|
+
byActor: Record<string, number>;
|
|
244
|
+
byTool: Record<string, number>;
|
|
245
|
+
topTargets: { target: string; count: number }[];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function tally<T>(items: T[], key: (item: T) => string): Record<string, number> {
|
|
249
|
+
const counts: Record<string, number> = {};
|
|
250
|
+
for (const item of items) {
|
|
251
|
+
const k = key(item);
|
|
252
|
+
counts[k] = (counts[k] ?? 0) + 1;
|
|
253
|
+
}
|
|
254
|
+
return counts;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function rank(
|
|
258
|
+
counts: Record<string, number>,
|
|
259
|
+
top: number
|
|
260
|
+
): { target: string; count: number }[] {
|
|
261
|
+
return Object.entries(counts)
|
|
262
|
+
.map(([target, count]) => ({ target, count }))
|
|
263
|
+
.sort((a, b) => b.count - a.count || a.target.localeCompare(b.target))
|
|
264
|
+
.slice(0, top);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function summarize(entries: LedgerEntry[], topTargets = 10): LedgerStats {
|
|
268
|
+
const first = entries.at(0);
|
|
269
|
+
const last = entries.at(-1);
|
|
270
|
+
return {
|
|
271
|
+
total: entries.length,
|
|
272
|
+
span: first && last ? { first: first.ts, last: last.ts } : null,
|
|
273
|
+
byOutcome: tally(entries, (e) => e.outcome),
|
|
274
|
+
byRuntime: tally(entries, (e) => e.runtime),
|
|
275
|
+
byActor: tally(entries, (e) => e.actor),
|
|
276
|
+
byTool: tally(entries, (e) => e.tool),
|
|
277
|
+
topTargets: rank(
|
|
278
|
+
tally(entries, (e) => e.target),
|
|
279
|
+
topTargets
|
|
280
|
+
),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A window expressed the way someone asks for one: a duration back from now
|
|
286
|
+
* ("7d", "24h") or a calendar date. Nothing else is guessed at — an
|
|
287
|
+
* unparseable spec is reported rather than silently treated as no filter,
|
|
288
|
+
* which would answer a narrow question with the whole ledger.
|
|
289
|
+
*/
|
|
290
|
+
export function parseSince(spec: string, now: Date = new Date()): Date | null {
|
|
291
|
+
const duration = /^(\d+)([smhdw])$/.exec(spec.trim());
|
|
292
|
+
if (duration) {
|
|
293
|
+
const unit = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 }[duration[2]];
|
|
294
|
+
if (!unit) return null;
|
|
295
|
+
return new Date(now.getTime() - Number(duration[1]) * unit);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const at = new Date(spec);
|
|
299
|
+
return Number.isNaN(at.getTime()) ? null : at;
|
|
300
|
+
}
|