Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
existing `threadManagement` capability, with the local writer kept as the
fallback for a sidecar too old to know the method.

- **Change ledger records provenance.** Each entry now carries `derivedFrom` —
the files the turn read before making that change — answering the question
after "what changed" and "how do I undo it": _what was it derived from_. That
is what you ask when a generated file is wrong and you need to know which
input to fix. Shown by `deepcode ledger show`. Built on the existing ledger
rather than a second store.

It is observed, not declared: only `Read` counts (`Grep`/`Glob` take a search
root and return many paths — calling the root an input claims a derivation the
turn did not make), a failed read is not an input, and the file being written
is excluded so an `Edit` does not look self-derived. Absent rather than empty
when there is nothing to say.

### 🔒 Security

- **A sub-agent did not inherit the file contract.** The `Task` delegation
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/src/ledger-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ async function show(
out.write(` actor : ${r.actor}${r.tool ? ` (${r.tool})` : ''}\n`);
if (r.intent) out.write(` intent : ${r.intent}\n`);
out.write(` paths : ${r.paths.length > 0 ? r.paths.join(', ') : '—'}\n`);
// Only shown when there is something to show. A "derived from: —" line on
// every Bash record would train people to stop reading the field.
if (r.derivedFrom && r.derivedFrom.length > 0) {
out.write(` from : ${r.derivedFrom.join(', ')}\n`);
}
out.write(` summary : ${r.summary}\n`);
if (r.rollbackHint) {
out.write(` rollback : ${r.rollbackHint.kind}`);
Expand Down
18 changes: 9 additions & 9 deletions docs/FLOATBOAT_ADOPTION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,15 +500,15 @@ File Contract 接入(PR 2)是唯一需要谨慎评审的一步。

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

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

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

Expand Down
33 changes: 33 additions & 0 deletions docs/change-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ digest wherever you like.
"intent": "fix the expired-token branch in auth.ts",
"paths": ["src/auth.ts"],
"summary": "edited src/auth.ts",
"derivedFrom": ["schema.json"],
"rollbackHint": { "kind": "snapshot", "ref": "7" },
}
```
Expand All @@ -64,6 +65,38 @@ records stay readable after the repo moves. `rollbackHint` points at the
snapshot or git checkpoint already taken for that specific call — and is
**absent when no checkpoint exists**, rather than guessing at one.

## Provenance — what a change came from

`derivedFrom` lists the files the turn **read** before making the change. It
answers the third question, after "what changed" and "how do I undo it": _what
was this derived from_ — which is what you ask when a generated file is wrong and
you need to know which input to fix, or when something turns up in a commit and
you need to know what the turn had open.

```bash
deepcode ledger show chg-lz4k2p-01
# paths : src/client.ts
# from : config/gen.yaml, schema.json
```

It is **observed, not declared**. These are the reads the turn actually
performed, so a tool that ignored its inputs shows nothing rather than a
plausible list. Consequences worth knowing:

- **Only `Read` counts.** `Grep` and `Glob` take a search _root_ and return many
paths; calling the root an input would claim a derivation the turn did not
make, and listing every hit would drown 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
would send someone to fix a file that was never opened.
- **The file being written is excluded.** `Edit` reads its own target by
construction, and including it would make every edit look self-derived.
- The field is **absent**, not empty, when there is nothing to say. A field that
is always present is a field that stops being read.

Provenance is a derivation record, not a dependency graph: it says what this one
turn read, not what the file transitively depends on.

## What is not recorded

- **Reads.** A ledger answering "what changed" has nothing to say about a read,
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,74 @@ describe('runAgent', () => {
};
}

const readTool: ToolHandler = {
name: 'Read',
definition: { name: 'Read', description: 'r', inputSchema: { type: 'object' } },
async execute() {
return { content: 'file contents' };
},
};

it('records what the write was derived from', async () => {
// Observed, not declared: the reads the turn actually performed. This is
// the question you ask when a generated file is wrong and you need to
// know which input to fix.
const ledger = recordingSink();
await runAgent({
provider: new MockProvider([
toolUse('reading', {
type: 'tool_use',
id: 'r-1',
name: 'Read',
input: { file_path: 'schema.json' },
}),
toolUse('writing', writeCall()),
endTurn('done'),
]),
tools: new ToolRegistry([readTool, writeTool]),
systemPrompt: '',
userMessage: 'regenerate the client',
model: 'deepseek-chat',
cwd,
ledger: ledger.sink,
});
const [, record] = ledger.entries[0]!;
expect(record.derivedFrom).toEqual(['schema.json']);
});

it('does not credit a read that failed', async () => {
// A read that errored gave the turn nothing, so claiming the output came
// from it would send someone to fix a file that was never opened.
const failingRead: ToolHandler = {
name: 'Read',
definition: { name: 'Read', description: 'r', inputSchema: { type: 'object' } },
async execute() {
return { content: 'ENOENT', isError: true };
},
};
const ledger = recordingSink();
await runAgent({
provider: new MockProvider([
toolUse('reading', {
type: 'tool_use',
id: 'r-1',
name: 'Read',
input: { file_path: 'missing.json' },
}),
toolUse('writing', writeCall()),
endTurn('done'),
]),
tools: new ToolRegistry([failingRead, writeTool]),
systemPrompt: '',
userMessage: 'regenerate',
model: 'deepseek-chat',
cwd,
ledger: ledger.sink,
});
const [, record] = ledger.entries[0]!;
expect(record.derivedFrom).toBeUndefined();
});

it('records a completed write, with the turn request as intent', async () => {
const ledger = recordingSink();
await runAgent({
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { PermissionRules } from './config/types.js';
import type { FileContract } from './config/file-contract.js';
import type { UnattendedApprovalPolicy } from './cron/index.js';
import type { LedgerSink } from './ledger/index.js';
import { buildToolCallRecord, ledgerKindForTool } from './ledger/record-tool-call.js';
import { buildToolCallRecord, ledgerKindForTool, readPathFor } from './ledger/record-tool-call.js';
import { dispatchToolCall, type DispatchVerdict } from './harness/tool-dispatcher.js';
import { TaskManager, type TaskRunner } from './tasks/manager.js';
import type { HookDispatcher } from './hooks/index.js';
Expand Down Expand Up @@ -285,6 +285,11 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
// modeSignal is mutable — EnterPlanMode / ExitPlanMode flip these; the agent
// loop owner reads them after the run to switch mode (default ⇄ plan).
const modeSignal: { exitPlanMode?: boolean; enterPlanMode?: boolean } = {};

// Inputs this run has read, in call order, deduped. Provenance for every
// change it goes on to make: the question "which input do I fix" is only
// answerable if somebody wrote down what was open at the time.
const readsThisTurn = new Set<string>();
const toolCtx: ToolContext = {
cwd: opts.cwd,
signal: opts.signal,
Expand Down Expand Up @@ -797,6 +802,15 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
}
}

// Reads observed so far in this run become the provenance of whatever
// this turn writes next. Recorded here, at the one place every completed
// tool call passes through, for the same reason the ledger append is
// here: a per-tool hook is a hook a new tool forgets.
if (!tr.isError) {
const readPath = readPathFor(toolUse.name, toolUse.input);
if (readPath) readsThisTurn.add(readPath);
}

// One write point for the whole loop. Per-tool writes are exactly the
// shape AGENTS.md rules out — a new mutating tool would silently go
// unrecorded if each site had to remember.
Expand All @@ -812,6 +826,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
threadId: opts.session?.id,
turnId: opts.session?.turnId,
snapshotSeq: preSeq,
readPaths: [...readsThisTurn],
});
// Never let bookkeeping fail a completed edit; FileLedger already
// swallows its own I/O errors, this covers a host-supplied sink.
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/ledger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ export interface LedgerRecord {
intent?: string;
/** Workspace-relative paths. Empty when the effect cannot be pinned to files. */
paths: string[];
/**
* Workspace-relative paths this turn read before making this change.
*
* The ledger already answers "what changed" and "how do I undo it". This
* answers "what was it derived from" — the question you ask when a generated
* file is wrong and you need to know which input to fix, or when a secret
* turns up somewhere and you need to know what the turn had open.
*
* Observed, not declared: these are the reads the turn actually performed, so
* a tool that ignored its inputs shows an empty list rather than a plausible
* one. Absent on records written before this existed, and on turns that read
* nothing.
*/
derivedFrom?: string[];
summary: string;
rollbackHint?: RollbackHint;
}
Expand Down
80 changes: 80 additions & 0 deletions packages/core/src/ledger/record-tool-call.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { buildToolCallRecord, readPathFor } from './record-tool-call.js';

// Provenance: what a change was derived from, so a wrong generated file leads
// back to the input that produced it.
describe('derivedFrom', () => {
const base = { cwd: '/work/repo', intent: 'regenerate the client' };

it('records the reads that preceded the write', () => {
const record = buildToolCallRecord({
...base,
tool: 'Write',
input: { file_path: '/work/repo/src/client.ts', content: '…' },
readPaths: ['/work/repo/schema.json', '/work/repo/config/gen.yaml'],
});
expect(record?.derivedFrom).toEqual(['config/gen.yaml', 'schema.json']);
});

it('is absent when the turn read nothing', () => {
// Not an empty array: a field that is always present is a field that stops
// being read.
const record = buildToolCallRecord({
...base,
tool: 'Write',
input: { file_path: 'a.ts', content: 'x' },
});
expect(record?.derivedFrom).toBeUndefined();
});

it('excludes the file being written', () => {
// Edit reads its own target by construction; listing it would make every
// edit look self-derived.
const record = buildToolCallRecord({
...base,
tool: 'Edit',
input: { file_path: '/work/repo/src/a.ts', old_string: 'x', new_string: 'y' },
readPaths: ['/work/repo/src/a.ts', '/work/repo/schema.json'],
});
expect(record?.derivedFrom).toEqual(['schema.json']);
});

it('normalizes to workspace-relative and dedupes', () => {
const record = buildToolCallRecord({
...base,
tool: 'Write',
input: { file_path: 'out.ts', content: 'x' },
readPaths: ['/work/repo/schema.json', 'schema.json', '/work/repo/./schema.json'],
});
expect(record?.derivedFrom).toEqual(['schema.json']);
});

it('attaches to Bash too — a command has inputs even without a declarable output', () => {
const record = buildToolCallRecord({
...base,
tool: 'Bash',
input: { command: 'make generate' },
readPaths: ['/work/repo/Makefile'],
});
expect(record?.derivedFrom).toEqual(['Makefile']);
});
});

describe('readPathFor', () => {
it('reports a Read', () => {
expect(readPathFor('Read', { file_path: '/a/b.ts' })).toBe('/a/b.ts');
});

it('ignores Grep and Glob', () => {
// They take a search *root* and return many paths. Calling the root an
// input claims a derivation the turn did not make; enumerating every hit
// drowns the real inputs in whatever the search swept up.
expect(readPathFor('Grep', { path: '/a' })).toBeUndefined();
expect(readPathFor('Glob', { path: '/a' })).toBeUndefined();
});

it('ignores writes and anything without a path', () => {
expect(readPathFor('Write', { file_path: '/a' })).toBeUndefined();
expect(readPathFor('Read', {})).toBeUndefined();
});
});
Loading
Loading