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,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
|
+
}
|