portable-agent-layer 0.66.1 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/templates/settings.claude.json +42 -0
- package/package.json +3 -2
- package/src/hooks/LedgerCommit.ts +43 -0
- package/src/hooks/LedgerSnapshot.ts +37 -0
- package/src/hooks/LedgerUnapplied.ts +67 -0
- package/src/hooks/lib/ledger-hook.ts +107 -0
- package/src/hooks/lib/ledger.ts +328 -0
- package/src/hooks/lib/paths.ts +1 -0
- package/src/targets/lib.ts +20 -4
|
@@ -132,6 +132,48 @@
|
|
|
132
132
|
"command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
|
|
133
133
|
}
|
|
134
134
|
]
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"matcher": "Write|Edit",
|
|
138
|
+
"hooks": [
|
|
139
|
+
{
|
|
140
|
+
"type": "command",
|
|
141
|
+
"command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerSnapshot.ts"
|
|
142
|
+
}
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
"PostToolUse": [
|
|
147
|
+
{
|
|
148
|
+
"matcher": "Write|Edit",
|
|
149
|
+
"hooks": [
|
|
150
|
+
{
|
|
151
|
+
"type": "command",
|
|
152
|
+
"command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts"
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
],
|
|
157
|
+
"PostToolUseFailure": [
|
|
158
|
+
{
|
|
159
|
+
"matcher": "Write|Edit",
|
|
160
|
+
"hooks": [
|
|
161
|
+
{
|
|
162
|
+
"type": "command",
|
|
163
|
+
"command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts"
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
],
|
|
168
|
+
"PermissionDenied": [
|
|
169
|
+
{
|
|
170
|
+
"matcher": "Write|Edit",
|
|
171
|
+
"hooks": [
|
|
172
|
+
{
|
|
173
|
+
"type": "command",
|
|
174
|
+
"command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts"
|
|
175
|
+
}
|
|
176
|
+
]
|
|
135
177
|
}
|
|
136
178
|
],
|
|
137
179
|
"Stop": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"lint": "biome lint",
|
|
43
43
|
"lint-write": "biome lint --write",
|
|
44
44
|
"check": "biome check",
|
|
45
|
-
"check-write": "
|
|
45
|
+
"check-write": "bun .agents/scripts/check-write.ts",
|
|
46
46
|
"knip": "knip-bun",
|
|
47
47
|
"klint": "klint",
|
|
48
48
|
"jscpd": "jscpd --noTips",
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
"dependencies": {
|
|
97
97
|
"@clack/prompts": "^1.4.0",
|
|
98
98
|
"adm-zip": "^0.5.17",
|
|
99
|
+
"fast-myers-diff": "^3.2.0",
|
|
99
100
|
"marked": "18.0.4",
|
|
100
101
|
"pdf-lib": "1.17.1",
|
|
101
102
|
"playwright": "^1.60.0",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook: PostToolUse — pairs the parked before-state with the result and writes
|
|
3
|
+
* the ledger entry.
|
|
4
|
+
*
|
|
5
|
+
* Only a call that actually landed reaches this event, so every entry written
|
|
6
|
+
* here is an applied one. A denied or failed call fires the snapshot half and
|
|
7
|
+
* never this one, leaving its snapshot unclaimed — which is why claiming also
|
|
8
|
+
* reaps the ones that were abandoned.
|
|
9
|
+
*
|
|
10
|
+
* Silent and fail-open, for the same reason as its other half.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { claimPending, reapStalePending, recordAction } from "./lib/ledger";
|
|
15
|
+
import { ledgeredCall } from "./lib/ledger-hook";
|
|
16
|
+
import { logDebug } from "./lib/log";
|
|
17
|
+
import { readStdinJSON } from "./lib/stdin";
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const input = await readStdinJSON<Record<string, unknown>>();
|
|
21
|
+
if (!input) process.exit(0);
|
|
22
|
+
|
|
23
|
+
const call = ledgeredCall(input);
|
|
24
|
+
if (!call) process.exit(0);
|
|
25
|
+
|
|
26
|
+
// No snapshot means no before-state, and an entry claiming one it never had
|
|
27
|
+
// would be worse than the missing entry.
|
|
28
|
+
const pending = claimPending(call.toolUseId);
|
|
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}`);
|
|
41
|
+
} catch {
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook: PreToolUse — parks the target file's current contents before an edit.
|
|
3
|
+
*
|
|
4
|
+
* This half exists because the other half cannot do its job alone. A post-tool
|
|
5
|
+
* event fires once the file has already been rewritten, so the prior contents
|
|
6
|
+
* are gone and "what changed" is unanswerable from it. Reading the file here,
|
|
7
|
+
* before the tool runs, is the only moment the before-state still exists.
|
|
8
|
+
*
|
|
9
|
+
* Silent and fail-open: stdout is the agent's protocol channel, and a ledger
|
|
10
|
+
* that could block an edit would be a worse thing than a ledger with a gap.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { savePending } from "./lib/ledger";
|
|
15
|
+
import { ledgeredCall } from "./lib/ledger-hook";
|
|
16
|
+
import { logDebug } from "./lib/log";
|
|
17
|
+
import { readStdinJSON } from "./lib/stdin";
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const input = await readStdinJSON<Record<string, unknown>>();
|
|
21
|
+
if (!input) process.exit(0);
|
|
22
|
+
|
|
23
|
+
const call = ledgeredCall(input);
|
|
24
|
+
if (!call) process.exit(0);
|
|
25
|
+
|
|
26
|
+
savePending({
|
|
27
|
+
...call,
|
|
28
|
+
// Absent rather than empty: a file that does not exist yet is a creation,
|
|
29
|
+
// which is a different event from a write over an empty file.
|
|
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}`);
|
|
35
|
+
} catch {
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook: PostToolUseFailure and PermissionDenied — records an edit that was
|
|
3
|
+
* attempted and did not land.
|
|
4
|
+
*
|
|
5
|
+
* A log that keeps only what succeeded cannot answer what was attempted, and
|
|
6
|
+
* that is usually the question being asked of it. So the two endings that are
|
|
7
|
+
* not success are recorded too, and kept apart: `failed` is the tool's own
|
|
8
|
+
* attempt breaking, `denied` is something refusing to let it run.
|
|
9
|
+
*
|
|
10
|
+
* One hook serves both events because they differ only in which key carries the
|
|
11
|
+
* reason — a difference `unappliedVerdictOf` owns, so registering this on a
|
|
12
|
+
* third such event later is a line in that table rather than a new file.
|
|
13
|
+
*
|
|
14
|
+
* Not everything that fails to land reaches here. A manual denial at the
|
|
15
|
+
* permission dialog, a `deny` rule, and a PreToolUse hook's own block all fire
|
|
16
|
+
* PreToolUse and nothing after it; a schema rejection fires no hook at all.
|
|
17
|
+
* Those attempts leave a snapshot no half ever claims, which is what the reaper
|
|
18
|
+
* in ledger.ts is for.
|
|
19
|
+
*
|
|
20
|
+
* Silent and fail-open, for the same reason as the other halves.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
24
|
+
import {
|
|
25
|
+
claimPending,
|
|
26
|
+
type PendingSnapshot,
|
|
27
|
+
reapStalePending,
|
|
28
|
+
recordAction,
|
|
29
|
+
} from "./lib/ledger";
|
|
30
|
+
import { ledgeredCall, unappliedVerdictOf } from "./lib/ledger-hook";
|
|
31
|
+
import { logDebug } from "./lib/log";
|
|
32
|
+
import { readStdinJSON } from "./lib/stdin";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The snapshot is the trustworthy source, but its absence is recoverable here
|
|
36
|
+
* in a way it never is after a successful edit: nothing landed, so whatever is
|
|
37
|
+
* on disk now is still the before-state.
|
|
38
|
+
*/
|
|
39
|
+
function beforeState(pending: PendingSnapshot | null, target: string): string | null {
|
|
40
|
+
if (pending) return pending.before;
|
|
41
|
+
return existsSync(target) ? readFileSync(target, "utf-8") : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const input = await readStdinJSON<Record<string, unknown>>();
|
|
46
|
+
if (!input) process.exit(0);
|
|
47
|
+
|
|
48
|
+
const call = ledgeredCall(input);
|
|
49
|
+
const verdict = call && unappliedVerdictOf(input);
|
|
50
|
+
if (!call || !verdict) process.exit(0);
|
|
51
|
+
|
|
52
|
+
const entry = recordAction({
|
|
53
|
+
tool: call.tool,
|
|
54
|
+
target: call.target,
|
|
55
|
+
outcome: verdict.outcome,
|
|
56
|
+
before: beforeState(claimPending(call.toolUseId), call.target),
|
|
57
|
+
// Nothing landed. That is what this event means, and it is the difference
|
|
58
|
+
// between this entry and an applied one.
|
|
59
|
+
after: null,
|
|
60
|
+
reason: verdict.reason,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
reapStalePending();
|
|
64
|
+
logDebug("LedgerUnapplied", `recorded ${entry.id} ${entry.outcome} ${entry.target}`);
|
|
65
|
+
} catch {
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the two ledger hooks agree on: which tool calls are worth recording, and
|
|
3
|
+
* how to find the file and the call id in an agent's payload.
|
|
4
|
+
*
|
|
5
|
+
* Both halves must answer these identically — a pre-tool half that snapshots a
|
|
6
|
+
* tool the post-tool half ignores leaves a snapshot nothing ever claims.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { normalizeToolUse } from "./agent";
|
|
10
|
+
import type { LedgerOutcome } from "./ledger";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Edits and writes, which carry their own target in the call. A shell command's
|
|
14
|
+
* effect is not derivable from its arguments, so recording one honestly needs a
|
|
15
|
+
* different mechanism than reading the path out of the payload.
|
|
16
|
+
*
|
|
17
|
+
* Reads and searches are excluded because they are queries, not actions, and a
|
|
18
|
+
* ledger that logs them buries the changes among them.
|
|
19
|
+
*/
|
|
20
|
+
const LEDGERED_TOOLS = new Set(["edit", "write"]);
|
|
21
|
+
|
|
22
|
+
/** Agents disagree on the spelling; the value is the same file either way. */
|
|
23
|
+
const TARGET_KEYS = ["file_path", "filePath", "path"];
|
|
24
|
+
|
|
25
|
+
export interface LedgeredCall {
|
|
26
|
+
toolUseId: string;
|
|
27
|
+
tool: string;
|
|
28
|
+
target: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one reading of a payload both halves share. Asking the same question in
|
|
33
|
+
* two places is how they drift apart, and a drift here is silent: the pre-tool
|
|
34
|
+
* half parks a snapshot the post-tool half never comes to claim.
|
|
35
|
+
*/
|
|
36
|
+
export function ledgeredCall(payload: Record<string, unknown>): LedgeredCall | null {
|
|
37
|
+
const toolUse = normalizeToolUse(payload);
|
|
38
|
+
const toolUseId = toolUseIdOf(payload);
|
|
39
|
+
if (!toolUse || !toolUseId) return null;
|
|
40
|
+
|
|
41
|
+
const target = ledgeredTarget(toolUse.toolName, toolUse.toolInput);
|
|
42
|
+
return target ? { toolUseId, tool: toolUse.toolName, target } : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How a call that did not land reports itself. Two events, because the runtime
|
|
47
|
+
* treats the two endings as different things and so does the ledger: a tool
|
|
48
|
+
* that ran and errored is not a call something refused to run.
|
|
49
|
+
*
|
|
50
|
+
* They carry the same fact under different keys, which is the whole reason this
|
|
51
|
+
* mapping is written down in one place rather than read twice.
|
|
52
|
+
*/
|
|
53
|
+
const UNAPPLIED_EVENTS: Record<string, { outcome: LedgerOutcome; reasonKey: string }> = {
|
|
54
|
+
PostToolUseFailure: { outcome: "failed", reasonKey: "error" },
|
|
55
|
+
PermissionDenied: { outcome: "denied", reasonKey: "reason" },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export interface UnappliedVerdict {
|
|
59
|
+
outcome: LedgerOutcome;
|
|
60
|
+
reason?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What became of a call, for the events that mean it did not land — or nothing
|
|
65
|
+
* when the payload is some other event, so one hook can be registered on both
|
|
66
|
+
* without having to be told which one it is being run for.
|
|
67
|
+
*/
|
|
68
|
+
export function unappliedVerdictOf(
|
|
69
|
+
payload: Record<string, unknown>
|
|
70
|
+
): UnappliedVerdict | null {
|
|
71
|
+
const event = payload.hook_event_name ?? payload.hookEventName;
|
|
72
|
+
if (typeof event !== "string") return null;
|
|
73
|
+
|
|
74
|
+
const mapping = UNAPPLIED_EVENTS[event];
|
|
75
|
+
if (!mapping) return null;
|
|
76
|
+
|
|
77
|
+
const reason = payload[mapping.reasonKey];
|
|
78
|
+
// A reason the runtime did not send is left absent rather than invented: an
|
|
79
|
+
// entry that states a cause it does not have is worse than one that admits none.
|
|
80
|
+
return typeof reason === "string" && reason.length > 0
|
|
81
|
+
? { outcome: mapping.outcome, reason }
|
|
82
|
+
: { outcome: mapping.outcome };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function toolUseIdOf(payload: Record<string, unknown>): string | null {
|
|
86
|
+
for (const key of ["tool_use_id", "toolUseId", "tool_call_id"]) {
|
|
87
|
+
const value = payload[key];
|
|
88
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The absolute path this call will change, or nothing if the call is not one
|
|
95
|
+
* the ledger records.
|
|
96
|
+
*/
|
|
97
|
+
export function ledgeredTarget(
|
|
98
|
+
toolName: string,
|
|
99
|
+
toolInput: Record<string, unknown>
|
|
100
|
+
): string | null {
|
|
101
|
+
if (!LEDGERED_TOOLS.has(toolName.toLowerCase())) return null;
|
|
102
|
+
for (const key of TARGET_KEYS) {
|
|
103
|
+
const value = toolInput[key];
|
|
104
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action ledger — an append-only record of what an agent changed, and under
|
|
3
|
+
* whose authority.
|
|
4
|
+
*
|
|
5
|
+
* A transcript says a tool was called. It does not say what the file looked
|
|
6
|
+
* like before, so it cannot answer "what changed" after the fact — the prior
|
|
7
|
+
* contents are gone by the time anything downstream reads it. The ledger is
|
|
8
|
+
* therefore written at the moment of the change, from both sides of it.
|
|
9
|
+
*
|
|
10
|
+
* Scope is deliberately narrow: edits and writes, which carry their own
|
|
11
|
+
* before/after in the call. A shell command's effect is not derivable from its
|
|
12
|
+
* arguments, so recording one honestly would need a different mechanism than
|
|
13
|
+
* this file — see the AGENTS.md rule steering file changes onto the edit tools.
|
|
14
|
+
*
|
|
15
|
+
* Reads and searches are excluded on purpose. They are queries, not actions,
|
|
16
|
+
* and a ledger that logs them buries the changes among them.
|
|
17
|
+
*
|
|
18
|
+
* This module is silent. It runs inside hooks, where stdout is the agent's
|
|
19
|
+
* protocol channel, so it returns what it wrote and leaves reporting to the
|
|
20
|
+
* caller.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
appendFileSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
statSync,
|
|
30
|
+
unlinkSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
} from "node:fs";
|
|
33
|
+
import { resolve } from "node:path";
|
|
34
|
+
import { calcPatch } from "fast-myers-diff";
|
|
35
|
+
import { currentAttribution, type RecordAttribution } from "./actor";
|
|
36
|
+
import { encodeAnchor } from "./anchor";
|
|
37
|
+
import { ensureDir, paths } from "./paths";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What became of the action.
|
|
41
|
+
*
|
|
42
|
+
* `failed` and `denied` are kept apart because they answer different questions.
|
|
43
|
+
* A failure is the agent's own attempt not working — a bad path, a stale match,
|
|
44
|
+
* a permission on disk. A denial is a human refusing it. Collapsing them would
|
|
45
|
+
* lose the only signal in the record that says where the boundary was drawn,
|
|
46
|
+
* and "what did I try that was refused" is a question worth being able to ask
|
|
47
|
+
* separately from "what did I try that broke".
|
|
48
|
+
*/
|
|
49
|
+
export type LedgerOutcome = "applied" | "failed" | "denied";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One side of a change, identified rather than reproduced. The hash ties the
|
|
53
|
+
* entry to a real file — apply the delta to something matching `before.hash`
|
|
54
|
+
* and you must land on `after.hash` — and the byte count says how big that file
|
|
55
|
+
* was without keeping it.
|
|
56
|
+
*/
|
|
57
|
+
export interface LedgerState {
|
|
58
|
+
hash: string;
|
|
59
|
+
bytes: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One contiguous replacement. `at` and `remove` index the before-state and are
|
|
64
|
+
* not shifted by earlier hunks in the same delta, which is what the diff
|
|
65
|
+
* produces and what applying them in order with a running offset expects.
|
|
66
|
+
*/
|
|
67
|
+
export interface LedgerHunk {
|
|
68
|
+
at: number;
|
|
69
|
+
remove: number;
|
|
70
|
+
insert: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What changed, at line granularity.
|
|
75
|
+
*
|
|
76
|
+
* Storing this rather than both whole files is what lets the record scale with
|
|
77
|
+
* the size of the change instead of the size of the file. Under the old shape a
|
|
78
|
+
* four-line edit to a large file kept two hashes and nothing else, so the
|
|
79
|
+
* entries that said least were the ones about the biggest files.
|
|
80
|
+
*/
|
|
81
|
+
export interface LedgerDelta {
|
|
82
|
+
hunks: LedgerHunk[];
|
|
83
|
+
/** Set when the change itself was too large to keep. Its absence means the hunks are complete. */
|
|
84
|
+
truncated?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface LedgerEntry extends RecordAttribution {
|
|
88
|
+
id: string;
|
|
89
|
+
ts: string;
|
|
90
|
+
/** The tool that made the change — Edit or Write today. */
|
|
91
|
+
tool: string;
|
|
92
|
+
/** Project-anchored path, so the entry survives a different mount or machine. */
|
|
93
|
+
target: string;
|
|
94
|
+
outcome: LedgerOutcome;
|
|
95
|
+
/** Null when nothing was there before: a file creation has no prior state. */
|
|
96
|
+
before: LedgerState | null;
|
|
97
|
+
/** Null when nothing landed, which is what a failed or denied action means. */
|
|
98
|
+
after: LedgerState | null;
|
|
99
|
+
/**
|
|
100
|
+
* The change from one side to the other. Absent when nothing landed: an
|
|
101
|
+
* action that was refused did not empty the file, and a delta saying it did
|
|
102
|
+
* would be the ledger stating something that never happened.
|
|
103
|
+
*/
|
|
104
|
+
delta?: LedgerDelta;
|
|
105
|
+
/** Why the action did not land. Absent on an applied one. */
|
|
106
|
+
reason?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface RecordActionInput {
|
|
110
|
+
tool: string;
|
|
111
|
+
/** Absolute path of the file the action targeted. */
|
|
112
|
+
target: string;
|
|
113
|
+
outcome: LedgerOutcome;
|
|
114
|
+
/** Prior content; null for a file creation. */
|
|
115
|
+
before: string | null;
|
|
116
|
+
/** Resulting content; null when nothing landed. */
|
|
117
|
+
after: string | null;
|
|
118
|
+
reason?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A change larger than this is recorded as having happened without being kept.
|
|
123
|
+
* It caps the delta rather than the files, so what fits is decided by how much
|
|
124
|
+
* an action changed, not by how big the thing it changed happened to be.
|
|
125
|
+
*/
|
|
126
|
+
const MAX_DELTA_BYTES = 4096;
|
|
127
|
+
|
|
128
|
+
/** Size at which the active file is rotated aside. */
|
|
129
|
+
const MAX_LEDGER_BYTES = 4 * 1024 * 1024;
|
|
130
|
+
|
|
131
|
+
const ACTIVE = "actions.jsonl";
|
|
132
|
+
|
|
133
|
+
export function ledgerPath(): string {
|
|
134
|
+
return resolve(paths.ledger(), ACTIVE);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hash(content: string): string {
|
|
138
|
+
return new Bun.CryptoHasher("sha256").update(content, "utf-8").digest("hex");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function stateOf(content: string | null): LedgerState | null {
|
|
142
|
+
if (content === null) return null;
|
|
143
|
+
return { hash: hash(content), bytes: Buffer.byteLength(content, "utf-8") };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Splitting on newlines keeps the trailing one: "a\n" becomes ["a", ""], and
|
|
148
|
+
* joining puts it back. A file and its line list round-trip exactly, which is
|
|
149
|
+
* what makes a reconstructed after-state hash-identical to the real one.
|
|
150
|
+
*/
|
|
151
|
+
function toLines(content: string | null): string[] {
|
|
152
|
+
return content === null ? [] : content.split("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The change between two states, or nothing when there was no transition to
|
|
157
|
+
* describe. An action that did not land has no delta — see LedgerEntry.delta.
|
|
158
|
+
*/
|
|
159
|
+
function deltaOf(before: string | null, after: string | null): LedgerDelta | undefined {
|
|
160
|
+
if (after === null) return undefined;
|
|
161
|
+
|
|
162
|
+
const hunks: LedgerHunk[] = [];
|
|
163
|
+
for (const [at, end, insert] of calcPatch(toLines(before), toLines(after))) {
|
|
164
|
+
hunks.push({ at, remove: end - at, insert: [...insert] });
|
|
165
|
+
}
|
|
166
|
+
if (hunks.length === 0) return undefined;
|
|
167
|
+
|
|
168
|
+
const delta: LedgerDelta = { hunks };
|
|
169
|
+
return Buffer.byteLength(JSON.stringify(delta), "utf-8") > MAX_DELTA_BYTES
|
|
170
|
+
? { hunks: [], truncated: true }
|
|
171
|
+
: delta;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Rebuild the after-state from the before-state and the delta, or nothing when
|
|
176
|
+
* the delta was too large to keep.
|
|
177
|
+
*
|
|
178
|
+
* This is what makes an entry checkable rather than merely plausible: the hash
|
|
179
|
+
* of what this returns must equal the entry's `after.hash`. It is also the read
|
|
180
|
+
* side of the ledger — a stored change is only evidence if it can be replayed.
|
|
181
|
+
*/
|
|
182
|
+
export function applyDelta(before: string | null, delta: LedgerDelta): string | null {
|
|
183
|
+
if (delta.truncated) return null;
|
|
184
|
+
|
|
185
|
+
const lines = toLines(before);
|
|
186
|
+
const out: string[] = [];
|
|
187
|
+
let cursor = 0;
|
|
188
|
+
for (const hunk of delta.hunks) {
|
|
189
|
+
out.push(...lines.slice(cursor, hunk.at), ...hunk.insert);
|
|
190
|
+
cursor = hunk.at + hunk.remove;
|
|
191
|
+
}
|
|
192
|
+
out.push(...lines.slice(cursor));
|
|
193
|
+
return out.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function generateId(): string {
|
|
197
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Move the active file aside once it crosses the size cap, so reads stay cheap
|
|
202
|
+
* without discarding anything. A count-based trim would delete the oldest
|
|
203
|
+
* entries first, which in an audit record is the evidence most worth keeping.
|
|
204
|
+
*/
|
|
205
|
+
function rotateIfFull(file: string): void {
|
|
206
|
+
if (!existsSync(file) || statSync(file).size < MAX_LEDGER_BYTES) return;
|
|
207
|
+
renameSync(file, freeArchivePath(new Date().toISOString().replace(/[:.]/g, "-")));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A name no archive already holds. Two rotations within the same millisecond
|
|
212
|
+
* agree on a stamp, and renaming onto a taken name destroys that archive
|
|
213
|
+
* without a trace — the one failure mode an append-only record cannot have.
|
|
214
|
+
*/
|
|
215
|
+
function freeArchivePath(stamp: string): string {
|
|
216
|
+
const nth = (n: number) => {
|
|
217
|
+
const suffix = n ? `-${n}` : "";
|
|
218
|
+
return resolve(paths.ledger(), `actions-${stamp}${suffix}.jsonl`);
|
|
219
|
+
};
|
|
220
|
+
let n = 0;
|
|
221
|
+
while (existsSync(nth(n))) n++;
|
|
222
|
+
return nth(n);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Append one action to the ledger and return the entry as written.
|
|
227
|
+
*
|
|
228
|
+
* An action that did not land is recorded like any other: a log that keeps only
|
|
229
|
+
* what succeeded cannot answer what was attempted, which is usually the question
|
|
230
|
+
* being asked of it.
|
|
231
|
+
*/
|
|
232
|
+
export function recordAction(input: RecordActionInput): LedgerEntry {
|
|
233
|
+
const delta = deltaOf(input.before, input.after);
|
|
234
|
+
const entry: LedgerEntry = {
|
|
235
|
+
id: generateId(),
|
|
236
|
+
ts: new Date().toISOString(),
|
|
237
|
+
...currentAttribution(),
|
|
238
|
+
tool: input.tool,
|
|
239
|
+
target: encodeAnchor(input.target),
|
|
240
|
+
outcome: input.outcome,
|
|
241
|
+
before: stateOf(input.before),
|
|
242
|
+
after: stateOf(input.after),
|
|
243
|
+
...(delta ? { delta } : {}),
|
|
244
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const file = ledgerPath();
|
|
248
|
+
rotateIfFull(file);
|
|
249
|
+
appendFileSync(file, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
250
|
+
return entry;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The before-state, held between the two halves of one tool call.
|
|
255
|
+
*
|
|
256
|
+
* A post-tool event alone cannot produce it: by the time the tool has run, the
|
|
257
|
+
* prior contents are gone. So the pre-tool half reads the file and parks it
|
|
258
|
+
* here, and the post-tool half claims it back and pairs it with the result.
|
|
259
|
+
*/
|
|
260
|
+
export interface PendingSnapshot {
|
|
261
|
+
toolUseId: string;
|
|
262
|
+
tool: string;
|
|
263
|
+
target: string;
|
|
264
|
+
before: string | null;
|
|
265
|
+
ts: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** How long a snapshot waits for a second half that may never come. */
|
|
269
|
+
const PENDING_TTL_MS = 60 * 60 * 1000;
|
|
270
|
+
|
|
271
|
+
function pendingDir(): string {
|
|
272
|
+
return ensureDir(resolve(paths.ledger(), "pending"));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A tool-call id is an identifier from another system, and it lands here as a
|
|
277
|
+
* filename — so it is reduced to characters that cannot climb out of the
|
|
278
|
+
* directory rather than trusted to be well-formed.
|
|
279
|
+
*/
|
|
280
|
+
function pendingPath(toolUseId: string): string {
|
|
281
|
+
return resolve(pendingDir(), `${toolUseId.replace(/[^A-Za-z0-9_-]/g, "")}.json`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function savePending(snapshot: PendingSnapshot): void {
|
|
285
|
+
writeFileSync(pendingPath(snapshot.toolUseId), JSON.stringify(snapshot), "utf-8");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Take the snapshot for this tool call, removing it in the same step. Claiming
|
|
290
|
+
* is one-shot on purpose: a snapshot that stayed put after being read could be
|
|
291
|
+
* paired with a second result and record a change that never happened.
|
|
292
|
+
*/
|
|
293
|
+
export function claimPending(toolUseId: string): PendingSnapshot | null {
|
|
294
|
+
const file = pendingPath(toolUseId);
|
|
295
|
+
if (!existsSync(file)) return null;
|
|
296
|
+
try {
|
|
297
|
+
const snapshot = JSON.parse(readFileSync(file, "utf-8")) as PendingSnapshot;
|
|
298
|
+
unlinkSync(file);
|
|
299
|
+
return snapshot;
|
|
300
|
+
} catch {
|
|
301
|
+
unlinkSync(file);
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Drop snapshots nothing ever claimed.
|
|
308
|
+
*
|
|
309
|
+
* Applying, failing and being denied by auto mode all fire a second half that
|
|
310
|
+
* claims the snapshot. What is left here is the endings that fire nothing after
|
|
311
|
+
* the pre-tool half — a manual denial at the permission dialog, a deny rule, a
|
|
312
|
+
* pre-tool hook's own block — plus anything interrupted mid-call.
|
|
313
|
+
*
|
|
314
|
+
* They are dropped rather than recorded because the ledger would have to invent
|
|
315
|
+
* which of those it was. The attempt is real and currently goes unrecorded; see
|
|
316
|
+
* the manual-denial gap in the project's ISCs.
|
|
317
|
+
*/
|
|
318
|
+
export function reapStalePending(now: number = Date.now()): number {
|
|
319
|
+
const dir = pendingDir();
|
|
320
|
+
let reaped = 0;
|
|
321
|
+
for (const name of readdirSync(dir)) {
|
|
322
|
+
const file = resolve(dir, name);
|
|
323
|
+
if (now - statSync(file).mtimeMs < PENDING_TTL_MS) continue;
|
|
324
|
+
unlinkSync(file);
|
|
325
|
+
reaped++;
|
|
326
|
+
}
|
|
327
|
+
return reaped;
|
|
328
|
+
}
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -54,6 +54,7 @@ export const paths = {
|
|
|
54
54
|
reflectionsFile: () =>
|
|
55
55
|
home("memory", "learning", "reflections", "algorithm-reflections.jsonl"),
|
|
56
56
|
retrievalIndex: () => home("memory", "learning", ".retrieval-index.json"),
|
|
57
|
+
ledger: () => ensureDir(home("memory", "ledger")),
|
|
57
58
|
progress: () => ensureDir(home("memory", "state", "progress")),
|
|
58
59
|
projectHistory: () => ensureDir(home("memory", "projects")),
|
|
59
60
|
sessionLearning: () => ensureDir(home("memory", "learning", "session")),
|
package/src/targets/lib.ts
CHANGED
|
@@ -23,11 +23,27 @@ import { declaredTriggers } from "../hooks/lib/skill-triggers";
|
|
|
23
23
|
|
|
24
24
|
// --- Colored logging ---
|
|
25
25
|
|
|
26
|
+
function runningUnderTest(): boolean {
|
|
27
|
+
return process.env.PAL_TEST_SANDBOX === "1";
|
|
28
|
+
}
|
|
29
|
+
|
|
26
30
|
export const log = {
|
|
27
31
|
info: (msg: string) => console.log(`\x1b[34m[pal]\x1b[0m ${msg}`),
|
|
28
32
|
success: (msg: string) => console.log(`\x1b[32m[pal]\x1b[0m ${msg}`),
|
|
29
33
|
warn: (msg: string) => console.log(`\x1b[33m[pal]\x1b[0m ${msg}`),
|
|
30
34
|
error: (msg: string) => console.error(`\x1b[31m[pal]\x1b[0m ${msg}`),
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-item narration from inside a loop, where the caller already reports the
|
|
38
|
+
* total. Silent under the test runner: the suite drives these installers by
|
|
39
|
+
* the hundred against temp directories, so the lines name files that were
|
|
40
|
+
* never on this machine — and they are the only output no caller asserts on,
|
|
41
|
+
* precisely because the summary is what carries the result.
|
|
42
|
+
*/
|
|
43
|
+
detail: (msg: string) => {
|
|
44
|
+
if (runningUnderTest()) return;
|
|
45
|
+
console.log(`\x1b[34m[pal]\x1b[0m ${msg}`);
|
|
46
|
+
},
|
|
31
47
|
};
|
|
32
48
|
|
|
33
49
|
// --- JSON helpers ---
|
|
@@ -784,7 +800,7 @@ export function copySkills(claudeSkillsDir: string): number {
|
|
|
784
800
|
let count = 0;
|
|
785
801
|
|
|
786
802
|
for (const name of pruneStaleSkillLinks(claudeSkillsDir)) {
|
|
787
|
-
log.
|
|
803
|
+
log.detail(`Removed stale skill link: ${name}`);
|
|
788
804
|
}
|
|
789
805
|
|
|
790
806
|
for (const name of readdirSync(skillsDir)) {
|
|
@@ -994,7 +1010,7 @@ export function removeSkills(claudeSkillsDir: string): string[] {
|
|
|
994
1010
|
}
|
|
995
1011
|
}
|
|
996
1012
|
removed.push(name);
|
|
997
|
-
log.
|
|
1013
|
+
log.detail(`Removed skill: ${name}`);
|
|
998
1014
|
}
|
|
999
1015
|
|
|
1000
1016
|
// Remove ~/.agents/skills/ → ~/.pal/skills/ symlink
|
|
@@ -1031,7 +1047,7 @@ export function removeAgents(): string[] {
|
|
|
1031
1047
|
unlinkSync(dst);
|
|
1032
1048
|
const name = file.replace(/\.md$/, "");
|
|
1033
1049
|
removed.push(name);
|
|
1034
|
-
log.
|
|
1050
|
+
log.detail(`Removed agent: ${name}`);
|
|
1035
1051
|
}
|
|
1036
1052
|
}
|
|
1037
1053
|
return removed;
|
|
@@ -1141,7 +1157,7 @@ function uninstallAgents(targetDir: string, label: string): string[] {
|
|
|
1141
1157
|
if (existsSync(dst)) {
|
|
1142
1158
|
unlinkSync(dst);
|
|
1143
1159
|
removed.push(file.replace(/\.md$/, ""));
|
|
1144
|
-
log.
|
|
1160
|
+
log.detail(`Removed ${label} agent: ${file.replace(/\.md$/, "")}`);
|
|
1145
1161
|
}
|
|
1146
1162
|
}
|
|
1147
1163
|
return removed;
|