portable-agent-layer 0.68.0 → 0.70.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.
@@ -0,0 +1,195 @@
1
+ /**
2
+ * pal cli server — start, stop and inspect the local ledger page.
3
+ *
4
+ * The server itself is src/tools/ledger/server.ts, run detached so it
5
+ * outlives the shell that started it. This file only owns the lifecycle:
6
+ * spawning, waiting for it to answer, remembering its pid, and killing it.
7
+ */
8
+
9
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { parseArgs } from "node:util";
12
+ import { spawnDetachedInference } from "../hooks/lib/detached-inference";
13
+ import { paths } from "../hooks/lib/paths";
14
+ import { DEFAULT_PORT, LOOPBACK, type ServerStatus } from "../tools/ledger/server";
15
+
16
+ interface ServerState {
17
+ pid: number;
18
+ port: number;
19
+ startedAt: string;
20
+ }
21
+
22
+ const SERVER_SCRIPT = resolve(import.meta.dir, "..", "tools", "ledger", "server.ts");
23
+ const STARTUP_TIMEOUT_MS = 3000;
24
+ const PROBE_TIMEOUT_MS = 500;
25
+
26
+ export async function runServer(args: string[]): Promise<number> {
27
+ const [sub, ...rest] = args;
28
+ switch (sub) {
29
+ case "start":
30
+ return cmdStart(rest);
31
+ case "stop":
32
+ return cmdStop();
33
+ case "status":
34
+ return cmdStatus();
35
+ case undefined:
36
+ case "help":
37
+ case "--help":
38
+ case "-h":
39
+ showHelp();
40
+ return 0;
41
+ default:
42
+ console.error(`Unknown subcommand: ${sub}\n`);
43
+ showHelp();
44
+ return 1;
45
+ }
46
+ }
47
+
48
+ function showHelp(): void {
49
+ console.log(`
50
+ Usage:
51
+ pal cli server <subcommand>
52
+
53
+ Subcommands:
54
+ start [--port <n>] Start the ledger page in the background (default port ${DEFAULT_PORT})
55
+ stop Stop it
56
+ status Show whether it is running, and where
57
+
58
+ The page listens on ${LOOPBACK} only.
59
+ `);
60
+ }
61
+
62
+ function url(port: number): string {
63
+ return `http://${LOOPBACK}:${port}/`;
64
+ }
65
+
66
+ function readState(): ServerState | null {
67
+ const file = paths.serverState();
68
+ if (!existsSync(file)) return null;
69
+ try {
70
+ return JSON.parse(readFileSync(file, "utf-8")) as ServerState;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function writeState(state: ServerState): void {
77
+ writeFileSync(paths.serverState(), JSON.stringify(state, null, 2), "utf-8");
78
+ }
79
+
80
+ function clearState(): void {
81
+ const file = paths.serverState();
82
+ if (existsSync(file)) unlinkSync(file);
83
+ }
84
+
85
+ function alive(pid: number): boolean {
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ async function probe(port: number): Promise<ServerStatus | null> {
95
+ try {
96
+ const res = await fetch(`${url(port)}api/status`, {
97
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
98
+ });
99
+ return res.ok ? ((await res.json()) as ServerStatus) : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ async function waitUntilAnswering(port: number): Promise<ServerStatus | null> {
106
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
107
+ while (Date.now() < deadline) {
108
+ const status = await probe(port);
109
+ if (status) return status;
110
+ await Bun.sleep(100);
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function parsePort(args: string[]): number | string {
116
+ const { values } = parseArgs({ args, options: { port: { type: "string" } } });
117
+ if (values.port === undefined) return DEFAULT_PORT;
118
+ const port = Number(values.port);
119
+ return Number.isInteger(port) && port > 0 && port < 65536
120
+ ? port
121
+ : `--port must be a port number, got ${values.port}`;
122
+ }
123
+
124
+ async function cmdStart(args: string[]): Promise<number> {
125
+ const port = parsePort(args);
126
+ if (typeof port === "string") return fail(port);
127
+
128
+ const running = await runningServer();
129
+ if (running) {
130
+ console.log(`Already running at ${url(running.port)} (pid ${running.pid})`);
131
+ return 0;
132
+ }
133
+
134
+ spawnDetachedInference(SERVER_SCRIPT, [`--port=${port}`], "ledger-server");
135
+ const status = await waitUntilAnswering(port);
136
+ if (!status)
137
+ return fail(
138
+ `The ledger page did not answer on port ${port} within ${STARTUP_TIMEOUT_MS / 1000}s. Is the port free?`
139
+ );
140
+
141
+ writeState({ pid: status.pid, port, startedAt: status.startedAt });
142
+ console.log(url(port));
143
+ return 0;
144
+ }
145
+
146
+ /** The state file is a claim; the process answering on that port is the fact. */
147
+ async function runningServer(): Promise<ServerState | null> {
148
+ const state = readState();
149
+ if (!state || !alive(state.pid)) return null;
150
+ return (await probe(state.port)) ? state : null;
151
+ }
152
+
153
+ async function cmdStop(): Promise<number> {
154
+ const state = readState();
155
+ if (!state) {
156
+ console.log("Not running.");
157
+ return 0;
158
+ }
159
+ if (alive(state.pid)) {
160
+ process.kill(state.pid);
161
+ console.log(`Stopped pid ${state.pid}.`);
162
+ } else {
163
+ console.log(`Pid ${state.pid} was already gone; cleared the stale record.`);
164
+ }
165
+ clearState();
166
+ return 0;
167
+ }
168
+
169
+ async function cmdStatus(): Promise<number> {
170
+ const state = readState();
171
+ if (!state) {
172
+ console.log("Not running.");
173
+ return 1;
174
+ }
175
+ const status = alive(state.pid) ? await probe(state.port) : null;
176
+ if (!status) {
177
+ console.log(
178
+ `Not running (stale record for pid ${state.pid}; run \`pal cli server stop\`).`
179
+ );
180
+ return 1;
181
+ }
182
+ console.log(`
183
+ ${url(status.port)}
184
+ pid ${status.pid}
185
+ started ${status.startedAt}
186
+ ledger ${status.ledgerFiles} file(s)
187
+ machine ${status.machine}
188
+ `);
189
+ return 0;
190
+ }
191
+
192
+ function fail(message: string): number {
193
+ console.error(message);
194
+ return 1;
195
+ }
@@ -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
- * Primary signal: PAL_AGENT env var, set by every install template/plugin in
10
- * `assets/templates/*` and `src/targets/opencode/plugin.ts`. IDE-provided env
11
- * vars are used as secondary fallbacks for environments that forward them.
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 (process.env.CURSOR_VERSION) return "cursor";
47
- if (process.env.CODEX_CLI_VERSION ?? process.env.OPENAI_CODEX) return "codex";
80
+ if (inCursorAgent()) return "cursor";
81
+ if (inCodex()) return "codex";
82
+ if (inClaudeCode()) return "claude";
48
83
  return undefined;
49
84
  }
50
85
 
51
- /** The agent something actually said was running, or undefined if nothing did. */
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
- return agentFromArgv() ?? agentFromEnv() ?? agentFromRuntimeEnv();
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. */
@@ -62,6 +62,7 @@ export const paths = {
62
62
  work: () => ensureDir(home("memory", "work")),
63
63
  backups: () => ensureDir(home("backups")),
64
64
  debug: () => ensureDir(home("debug")),
65
+ serverState: () => home("server.json"),
65
66
  } as const;
66
67
 
67
68
  // Platform directories (env override or cross-platform defaults)
@@ -87,6 +88,7 @@ export const assets = {
87
88
  copilotHooksTemplate: () => pkg("assets", "templates", "hooks.copilot.json"),
88
89
  codexHooksTemplate: () => pkg("assets", "templates", "hooks.codex.json"),
89
90
  codexRulesTemplate: () => pkg("assets", "templates", "rules.codex.rules"),
91
+ ledgerPageTemplate: () => pkg("assets", "templates", "ledger-page.html"),
90
92
  statuslineScriptBash: () => pkg("assets", "statusline.sh"),
91
93
  statuslineScriptPs1: () => pkg("assets", "statusline.ps1"),
92
94
  agentTools: () => pkg("src", "tools", "agent"),
@@ -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) out.push({ id: Number(m[2]), text: m[3].trim(), status: statusFromBox(m[1]) });
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,318 @@
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
+ /** The change as a reader would want it summarised: a line count, or why there is none. */
162
+ export function changedLines(entry: LedgerEntry): string {
163
+ const shape = changeShape(entry);
164
+ switch (shape.kind) {
165
+ case "redacted":
166
+ return "withheld";
167
+ case "truncated":
168
+ return "too large";
169
+ case "none":
170
+ return "no change";
171
+ default: {
172
+ const added = shape.delta.hunks.reduce((n, h) => n + h.insert.length, 0);
173
+ const removed = shape.delta.hunks.reduce((n, h) => n + h.remove, 0);
174
+ return `+${added} -${removed}`;
175
+ }
176
+ }
177
+ }
178
+
179
+ export type Standing =
180
+ | { state: "in-place" }
181
+ | { state: "reverted"; replays: boolean }
182
+ | { state: "superseded"; hash: string }
183
+ | { state: "missing" }
184
+ | { state: "unknown"; why: string };
185
+
186
+ function hashOf(content: string): string {
187
+ return new Bun.CryptoHasher("sha256").update(content, "utf-8").digest("hex");
188
+ }
189
+
190
+ function replaysToAfter(entry: LedgerEntry, onDisk: string): boolean {
191
+ if (!entry.delta || !entry.after) return false;
192
+ const rebuilt = applyDelta(onDisk, entry.delta);
193
+ return rebuilt !== null && hashOf(rebuilt) === entry.after.hash;
194
+ }
195
+
196
+ export function standing(entry: LedgerEntry): Standing {
197
+ if (!entry.after) return { state: "unknown", why: "the action never landed" };
198
+
199
+ const found = locate(entry);
200
+ if (!found.path)
201
+ return { state: "unknown", why: `unknown project ${found.unresolvable}` };
202
+ if (!existsSync(found.path)) return { state: "missing" };
203
+
204
+ const onDisk = readFileSync(found.path, "utf-8");
205
+ const hash = hashOf(onDisk);
206
+ if (hash === entry.after.hash) return { state: "in-place" };
207
+ if (entry.before && hash === entry.before.hash)
208
+ return { state: "reverted", replays: replaysToAfter(entry, onDisk) };
209
+ return { state: "superseded", hash };
210
+ }
211
+
212
+ /**
213
+ * What the record itself says became of a change, as opposed to what the disk
214
+ * says now.
215
+ *
216
+ * `standing` can only describe the present, so any entry that is not the newest
217
+ * for its target reads as superseded — true, and nearly content-free, since it
218
+ * says only that something happened afterwards. The ledger already holds the
219
+ * whole per-target chain, and that answers the question worth asking: whether a
220
+ * later action put the file back the way this one found it, and which action
221
+ * that was. It stays true no matter how many edits come after.
222
+ */
223
+ export type ChainVerdict =
224
+ | { state: "latest" }
225
+ | { state: "undone"; by: string; at: string }
226
+ | { state: "followed"; by: string; at: string };
227
+
228
+ /**
229
+ * Deliberately takes no entry list. A chain computed over a filtered query
230
+ * would report `latest` for an entry the filter merely hid the successor of,
231
+ * so there is no parameter here through which that mistake can be made.
232
+ */
233
+ export function chainVerdict(entry: LedgerEntry): ChainVerdict {
234
+ const all = readLedger();
235
+ const position = all.findIndex((candidate) => candidate.id === entry.id);
236
+ const later = all
237
+ .slice(position + 1)
238
+ .filter((candidate) => candidate.target === entry.target);
239
+ if (later.length === 0) return { state: "latest" };
240
+
241
+ const undo = undoingEntry(entry, later);
242
+ const next = undo ?? later[0];
243
+ return { state: undo ? "undone" : "followed", by: next.id, at: next.ts };
244
+ }
245
+
246
+ /**
247
+ * The first later action that left the file as this one found it. An action
248
+ * that never landed changed nothing to undo, and one that created the file is
249
+ * undone by a deletion, which this ledger does not record.
250
+ */
251
+ function undoingEntry(entry: LedgerEntry, later: LedgerEntry[]): LedgerEntry | undefined {
252
+ if (!entry.before || !entry.after) return undefined;
253
+ return later.find((candidate) => candidate.after?.hash === entry.before?.hash);
254
+ }
255
+
256
+ export interface LedgerStats {
257
+ total: number;
258
+ span: { first: string; last: string } | null;
259
+ byOutcome: Record<string, number>;
260
+ byRuntime: Record<string, number>;
261
+ byActor: Record<string, number>;
262
+ byTool: Record<string, number>;
263
+ topTargets: { target: string; count: number }[];
264
+ }
265
+
266
+ function tally<T>(items: T[], key: (item: T) => string): Record<string, number> {
267
+ const counts: Record<string, number> = {};
268
+ for (const item of items) {
269
+ const k = key(item);
270
+ counts[k] = (counts[k] ?? 0) + 1;
271
+ }
272
+ return counts;
273
+ }
274
+
275
+ function rank(
276
+ counts: Record<string, number>,
277
+ top: number
278
+ ): { target: string; count: number }[] {
279
+ return Object.entries(counts)
280
+ .map(([target, count]) => ({ target, count }))
281
+ .sort((a, b) => b.count - a.count || a.target.localeCompare(b.target))
282
+ .slice(0, top);
283
+ }
284
+
285
+ export function summarize(entries: LedgerEntry[], topTargets = 10): LedgerStats {
286
+ const first = entries.at(0);
287
+ const last = entries.at(-1);
288
+ return {
289
+ total: entries.length,
290
+ span: first && last ? { first: first.ts, last: last.ts } : null,
291
+ byOutcome: tally(entries, (e) => e.outcome),
292
+ byRuntime: tally(entries, (e) => e.runtime),
293
+ byActor: tally(entries, (e) => e.actor),
294
+ byTool: tally(entries, (e) => e.tool),
295
+ topTargets: rank(
296
+ tally(entries, (e) => e.target),
297
+ topTargets
298
+ ),
299
+ };
300
+ }
301
+
302
+ /**
303
+ * A window expressed the way someone asks for one: a duration back from now
304
+ * ("7d", "24h") or a calendar date. Nothing else is guessed at — an
305
+ * unparseable spec is reported rather than silently treated as no filter,
306
+ * which would answer a narrow question with the whole ledger.
307
+ */
308
+ export function parseSince(spec: string, now: Date = new Date()): Date | null {
309
+ const duration = /^(\d+)([smhdw])$/.exec(spec.trim());
310
+ if (duration) {
311
+ const unit = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 }[duration[2]];
312
+ if (!unit) return null;
313
+ return new Date(now.getTime() - Number(duration[1]) * unit);
314
+ }
315
+
316
+ const at = new Date(spec);
317
+ return Number.isNaN(at.getTime()) ? null : at;
318
+ }