Skip to content

Commit d66df0c

Browse files
oratisclaude
andcommitted
feat(ledger): record what a change was derived from
The ledger answers "what changed" and "how do I undo it". It could not answer the third question people actually ask: what was this derived from — which is what you want when a generated file is wrong and you need to know which input to fix, or when something turns up in a diff and you need to know what the turn had open. `derivedFrom` on each record: the files the turn read before making that change. Built on the existing ledger rather than a second store, because a provenance log kept separately from the change log is two things to keep in step and one place for them to disagree. Observed, not declared. These are reads that actually happened, so a tool that ignored its inputs shows nothing rather than a plausible list. Four consequences of that, each of which is a deliberate narrowing: - Only `Read` counts. Grep and Glob take a search *root* and return many paths; calling the root an input claims a derivation the turn did not make, and listing every hit drowns the real inputs in whatever the search swept up. Narrow and true beats wide and approximate. - A failed read is not an input. It gave the turn nothing, and crediting it sends someone to fix a file that was never opened. - The file being written is excluded. Edit reads its own target by construction, so including it would make every edit look self-derived. - Absent, not empty, when there is nothing to say. A field that is always present is a field that stops being read. Collected at the one place every completed tool call passes through — the same site the ledger append already lives at, for the same reason: a per-tool hook is a hook a new tool forgets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d901879 commit d66df0c

9 files changed

Lines changed: 271 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
existing `threadManagement` capability, with the local writer kept as the
3030
fallback for a sidecar too old to know the method.
3131

32+
- **Change ledger records provenance.** Each entry now carries `derivedFrom`
33+
the files the turn read before making that change — answering the question
34+
after "what changed" and "how do I undo it": _what was it derived from_. That
35+
is what you ask when a generated file is wrong and you need to know which
36+
input to fix. Shown by `deepcode ledger show`. Built on the existing ledger
37+
rather than a second store.
38+
39+
It is observed, not declared: only `Read` counts (`Grep`/`Glob` take a search
40+
root and return many paths — calling the root an input claims a derivation the
41+
turn did not make), a failed read is not an input, and the file being written
42+
is excluded so an `Edit` does not look self-derived. Absent rather than empty
43+
when there is nothing to say.
44+
3245
### 🔒 Security
3346

3447
- **A sub-agent did not inherit the file contract.** The `Task` delegation

apps/cli/src/ledger-cmd.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ async function show(
140140
out.write(` actor : ${r.actor}${r.tool ? ` (${r.tool})` : ''}\n`);
141141
if (r.intent) out.write(` intent : ${r.intent}\n`);
142142
out.write(` paths : ${r.paths.length > 0 ? r.paths.join(', ') : '—'}\n`);
143+
// Only shown when there is something to show. A "derived from: —" line on
144+
// every Bash record would train people to stop reading the field.
145+
if (r.derivedFrom && r.derivedFrom.length > 0) {
146+
out.write(` from : ${r.derivedFrom.join(', ')}\n`);
147+
}
143148
out.write(` summary : ${r.summary}\n`);
144149
if (r.rollbackHint) {
145150
out.write(` rollback : ${r.rollbackHint.kind}`);

docs/FLOATBOAT_ADOPTION_PLAN.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -500,15 +500,15 @@ File Contract 接入(PR 2)是唯一需要谨慎评审的一步。
500500

501501
写下与计划不符的地方,比宣称"照计划完成"有用。
502502

503-
|| 计划 | 实际 |
504-
| ---------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
505-
| PR 0 的问题陈述 | 称无人值守可能"静默放行" | **计划写错了**`ask` 路径本来就 fail-closed(`runHeadless``approval: async () => false`)。真正缺的是"停下来"的能力和可见性,PR #237 按事实重写了范围 |
506-
| 权限档位的钳制 | 放在 PR 0 | 推迟到 PR 7。没有 opt-in 的钳制只是破坏,等 `TriggerProfile` 落地才安全 |
507-
| 契约 `deny``bypassPermissions` | 计划未明确 | 实施时决定 **`deny` 不可被 `bypassPermissions` 豁免**。它是关于路径的常驻声明,不是逐次提示;否则契约最强的一句话也最容易被关掉 |
508-
| 四客户端一致性测试 | 计划要求 4 个客户端逐字段相等 | 实际只有 CLI 与 app-server **独立解析**策略;VS Code / LSP 是协议瘦客户端,逐字节消费 server 的答复,构造上即相等。测试断言前两者,并在文档里说明后两者的理由 —— 不宣称验证了 4 条独立路径 |
509-
| Grep/Glob 结果过滤 | 列为 PR 1 的已知缺口 | 仍未做。契约对 Grep/Glob 只裁决搜索根,命中结果里混入 deny 路径的内容需要工具输出层二次过滤 |
510-
| 制品 `provenance` | 列在 PR 8(P2) | 未做 |
511-
| 触发源抽象(ICS / watch) | 列在 PR 8(P2) | 未做。`cron` 仍只有时间源 |
503+
|| 计划 | 实际 |
504+
| ---------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
505+
| PR 0 的问题陈述 | 称无人值守可能"静默放行" | **计划写错了**`ask` 路径本来就 fail-closed(`runHeadless``approval: async () => false`)。真正缺的是"停下来"的能力和可见性,PR #237 按事实重写了范围 |
506+
| 权限档位的钳制 | 放在 PR 0 | 推迟到 PR 7。没有 opt-in 的钳制只是破坏,等 `TriggerProfile` 落地才安全 |
507+
| 契约 `deny``bypassPermissions` | 计划未明确 | 实施时决定 **`deny` 不可被 `bypassPermissions` 豁免**。它是关于路径的常驻声明,不是逐次提示;否则契约最强的一句话也最容易被关掉 |
508+
| 四客户端一致性测试 | 计划要求 4 个客户端逐字段相等 | 实际只有 CLI 与 app-server **独立解析**策略;VS Code / LSP 是协议瘦客户端,逐字节消费 server 的答复,构造上即相等。测试断言前两者,并在文档里说明后两者的理由 —— 不宣称验证了 4 条独立路径 |
509+
| Grep/Glob 结果过滤 | 列为 PR 1 的已知缺口 | 仍未做。契约对 Grep/Glob 只裁决搜索根,命中结果里混入 deny 路径的内容需要工具输出层二次过滤 |
510+
| 制品 `provenance` | 列在 PR 8(P2) | 已做。建在 change ledger 上而不是第二套存储:每条变更记录带 `derivedFrom`(本轮在写之前读过的文件)。**观察得来而非声明**——只算 `Read`,失败的读不算,被写的文件本身不算。见 [`change-ledger.md`](change-ledger.md) |
511+
| 触发源抽象(ICS / watch) | 列在 PR 8(P2) | 未做。`cron` 仍只有时间源 |
512512

513513
**未解假设的最终结论**(§7 提的四个):
514514

docs/change-ledger.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ digest wherever you like.
5555
"intent": "fix the expired-token branch in auth.ts",
5656
"paths": ["src/auth.ts"],
5757
"summary": "edited src/auth.ts",
58+
"derivedFrom": ["schema.json"],
5859
"rollbackHint": { "kind": "snapshot", "ref": "7" },
5960
}
6061
```
@@ -64,6 +65,38 @@ records stay readable after the repo moves. `rollbackHint` points at the
6465
snapshot or git checkpoint already taken for that specific call — and is
6566
**absent when no checkpoint exists**, rather than guessing at one.
6667

68+
## Provenance — what a change came from
69+
70+
`derivedFrom` lists the files the turn **read** before making the change. It
71+
answers the third question, after "what changed" and "how do I undo it": _what
72+
was this derived from_ — which is what you ask when a generated file is wrong and
73+
you need to know which input to fix, or when something turns up in a commit and
74+
you need to know what the turn had open.
75+
76+
```bash
77+
deepcode ledger show chg-lz4k2p-01
78+
# paths : src/client.ts
79+
# from : config/gen.yaml, schema.json
80+
```
81+
82+
It is **observed, not declared**. These are the reads the turn actually
83+
performed, so a tool that ignored its inputs shows nothing rather than a
84+
plausible list. Consequences worth knowing:
85+
86+
- **Only `Read` counts.** `Grep` and `Glob` take a search _root_ and return many
87+
paths; calling the root an input would claim a derivation the turn did not
88+
make, and listing every hit would drown the real inputs in whatever the search
89+
swept up. Narrow and true beats wide and approximate.
90+
- **A failed read is not an input.** It gave the turn nothing, and crediting it
91+
would send someone to fix a file that was never opened.
92+
- **The file being written is excluded.** `Edit` reads its own target by
93+
construction, and including it would make every edit look self-derived.
94+
- The field is **absent**, not empty, when there is nothing to say. A field that
95+
is always present is a field that stops being read.
96+
97+
Provenance is a derivation record, not a dependency graph: it says what this one
98+
turn read, not what the file transitively depends on.
99+
67100
## What is not recorded
68101

69102
- **Reads.** A ledger answering "what changed" has nothing to say about a read,

packages/core/src/agent.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,74 @@ describe('runAgent', () => {
10231023
};
10241024
}
10251025

1026+
const readTool: ToolHandler = {
1027+
name: 'Read',
1028+
definition: { name: 'Read', description: 'r', inputSchema: { type: 'object' } },
1029+
async execute() {
1030+
return { content: 'file contents' };
1031+
},
1032+
};
1033+
1034+
it('records what the write was derived from', async () => {
1035+
// Observed, not declared: the reads the turn actually performed. This is
1036+
// the question you ask when a generated file is wrong and you need to
1037+
// know which input to fix.
1038+
const ledger = recordingSink();
1039+
await runAgent({
1040+
provider: new MockProvider([
1041+
toolUse('reading', {
1042+
type: 'tool_use',
1043+
id: 'r-1',
1044+
name: 'Read',
1045+
input: { file_path: 'schema.json' },
1046+
}),
1047+
toolUse('writing', writeCall()),
1048+
endTurn('done'),
1049+
]),
1050+
tools: new ToolRegistry([readTool, writeTool]),
1051+
systemPrompt: '',
1052+
userMessage: 'regenerate the client',
1053+
model: 'deepseek-chat',
1054+
cwd,
1055+
ledger: ledger.sink,
1056+
});
1057+
const [, record] = ledger.entries[0]!;
1058+
expect(record.derivedFrom).toEqual(['schema.json']);
1059+
});
1060+
1061+
it('does not credit a read that failed', async () => {
1062+
// A read that errored gave the turn nothing, so claiming the output came
1063+
// from it would send someone to fix a file that was never opened.
1064+
const failingRead: ToolHandler = {
1065+
name: 'Read',
1066+
definition: { name: 'Read', description: 'r', inputSchema: { type: 'object' } },
1067+
async execute() {
1068+
return { content: 'ENOENT', isError: true };
1069+
},
1070+
};
1071+
const ledger = recordingSink();
1072+
await runAgent({
1073+
provider: new MockProvider([
1074+
toolUse('reading', {
1075+
type: 'tool_use',
1076+
id: 'r-1',
1077+
name: 'Read',
1078+
input: { file_path: 'missing.json' },
1079+
}),
1080+
toolUse('writing', writeCall()),
1081+
endTurn('done'),
1082+
]),
1083+
tools: new ToolRegistry([failingRead, writeTool]),
1084+
systemPrompt: '',
1085+
userMessage: 'regenerate',
1086+
model: 'deepseek-chat',
1087+
cwd,
1088+
ledger: ledger.sink,
1089+
});
1090+
const [, record] = ledger.entries[0]!;
1091+
expect(record.derivedFrom).toBeUndefined();
1092+
});
1093+
10261094
it('records a completed write, with the turn request as intent', async () => {
10271095
const ledger = recordingSink();
10281096
await runAgent({

packages/core/src/agent.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { PermissionRules } from './config/types.js';
66
import type { FileContract } from './config/file-contract.js';
77
import type { UnattendedApprovalPolicy } from './cron/index.js';
88
import type { LedgerSink } from './ledger/index.js';
9-
import { buildToolCallRecord, ledgerKindForTool } from './ledger/record-tool-call.js';
9+
import { buildToolCallRecord, ledgerKindForTool, readPathFor } from './ledger/record-tool-call.js';
1010
import { dispatchToolCall, type DispatchVerdict } from './harness/tool-dispatcher.js';
1111
import { TaskManager, type TaskRunner } from './tasks/manager.js';
1212
import type { HookDispatcher } from './hooks/index.js';
@@ -285,6 +285,11 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
285285
// modeSignal is mutable — EnterPlanMode / ExitPlanMode flip these; the agent
286286
// loop owner reads them after the run to switch mode (default ⇄ plan).
287287
const modeSignal: { exitPlanMode?: boolean; enterPlanMode?: boolean } = {};
288+
289+
// Inputs this run has read, in call order, deduped. Provenance for every
290+
// change it goes on to make: the question "which input do I fix" is only
291+
// answerable if somebody wrote down what was open at the time.
292+
const readsThisTurn = new Set<string>();
288293
const toolCtx: ToolContext = {
289294
cwd: opts.cwd,
290295
signal: opts.signal,
@@ -797,6 +802,15 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
797802
}
798803
}
799804

805+
// Reads observed so far in this run become the provenance of whatever
806+
// this turn writes next. Recorded here, at the one place every completed
807+
// tool call passes through, for the same reason the ledger append is
808+
// here: a per-tool hook is a hook a new tool forgets.
809+
if (!tr.isError) {
810+
const readPath = readPathFor(toolUse.name, toolUse.input);
811+
if (readPath) readsThisTurn.add(readPath);
812+
}
813+
800814
// One write point for the whole loop. Per-tool writes are exactly the
801815
// shape AGENTS.md rules out — a new mutating tool would silently go
802816
// unrecorded if each site had to remember.
@@ -812,6 +826,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
812826
threadId: opts.session?.id,
813827
turnId: opts.session?.turnId,
814828
snapshotSeq: preSeq,
829+
readPaths: [...readsThisTurn],
815830
});
816831
// Never let bookkeeping fail a completed edit; FileLedger already
817832
// swallows its own I/O errors, this covers a host-supplied sink.

packages/core/src/ledger/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ export interface LedgerRecord {
5151
intent?: string;
5252
/** Workspace-relative paths. Empty when the effect cannot be pinned to files. */
5353
paths: string[];
54+
/**
55+
* Workspace-relative paths this turn read before making this change.
56+
*
57+
* The ledger already answers "what changed" and "how do I undo it". This
58+
* answers "what was it derived from" — the question you ask when a generated
59+
* file is wrong and you need to know which input to fix, or when a secret
60+
* turns up somewhere and you need to know what the turn had open.
61+
*
62+
* Observed, not declared: these are the reads the turn actually performed, so
63+
* a tool that ignored its inputs shows an empty list rather than a plausible
64+
* one. Absent on records written before this existed, and on turns that read
65+
* nothing.
66+
*/
67+
derivedFrom?: string[];
5468
summary: string;
5569
rollbackHint?: RollbackHint;
5670
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { buildToolCallRecord, readPathFor } from './record-tool-call.js';
3+
4+
// Provenance: what a change was derived from, so a wrong generated file leads
5+
// back to the input that produced it.
6+
describe('derivedFrom', () => {
7+
const base = { cwd: '/work/repo', intent: 'regenerate the client' };
8+
9+
it('records the reads that preceded the write', () => {
10+
const record = buildToolCallRecord({
11+
...base,
12+
tool: 'Write',
13+
input: { file_path: '/work/repo/src/client.ts', content: '…' },
14+
readPaths: ['/work/repo/schema.json', '/work/repo/config/gen.yaml'],
15+
});
16+
expect(record?.derivedFrom).toEqual(['config/gen.yaml', 'schema.json']);
17+
});
18+
19+
it('is absent when the turn read nothing', () => {
20+
// Not an empty array: a field that is always present is a field that stops
21+
// being read.
22+
const record = buildToolCallRecord({
23+
...base,
24+
tool: 'Write',
25+
input: { file_path: 'a.ts', content: 'x' },
26+
});
27+
expect(record?.derivedFrom).toBeUndefined();
28+
});
29+
30+
it('excludes the file being written', () => {
31+
// Edit reads its own target by construction; listing it would make every
32+
// edit look self-derived.
33+
const record = buildToolCallRecord({
34+
...base,
35+
tool: 'Edit',
36+
input: { file_path: '/work/repo/src/a.ts', old_string: 'x', new_string: 'y' },
37+
readPaths: ['/work/repo/src/a.ts', '/work/repo/schema.json'],
38+
});
39+
expect(record?.derivedFrom).toEqual(['schema.json']);
40+
});
41+
42+
it('normalizes to workspace-relative and dedupes', () => {
43+
const record = buildToolCallRecord({
44+
...base,
45+
tool: 'Write',
46+
input: { file_path: 'out.ts', content: 'x' },
47+
readPaths: ['/work/repo/schema.json', 'schema.json', '/work/repo/./schema.json'],
48+
});
49+
expect(record?.derivedFrom).toEqual(['schema.json']);
50+
});
51+
52+
it('attaches to Bash too — a command has inputs even without a declarable output', () => {
53+
const record = buildToolCallRecord({
54+
...base,
55+
tool: 'Bash',
56+
input: { command: 'make generate' },
57+
readPaths: ['/work/repo/Makefile'],
58+
});
59+
expect(record?.derivedFrom).toEqual(['Makefile']);
60+
});
61+
});
62+
63+
describe('readPathFor', () => {
64+
it('reports a Read', () => {
65+
expect(readPathFor('Read', { file_path: '/a/b.ts' })).toBe('/a/b.ts');
66+
});
67+
68+
it('ignores Grep and Glob', () => {
69+
// They take a search *root* and return many paths. Calling the root an
70+
// input claims a derivation the turn did not make; enumerating every hit
71+
// drowns the real inputs in whatever the search swept up.
72+
expect(readPathFor('Grep', { path: '/a' })).toBeUndefined();
73+
expect(readPathFor('Glob', { path: '/a' })).toBeUndefined();
74+
});
75+
76+
it('ignores writes and anything without a path', () => {
77+
expect(readPathFor('Write', { file_path: '/a' })).toBeUndefined();
78+
expect(readPathFor('Read', {})).toBeUndefined();
79+
});
80+
});

0 commit comments

Comments
 (0)