From f419f11870919ba3a5099ba156d8479172d87bc8 Mon Sep 17 00:00:00 2001 From: githubscum <65476409+githubscum@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:41:01 -0600 Subject: [PATCH 1/6] KNOWN-LIMITS 76: MCP rows, Claude chapters and since-view stop reading a refused write as touched MCP session rows gain touchedRefused, omitted when no item has an outcome. A chapter gains refused when it has one, settled per chapter and left out of the chapters/1 digest so receipts already on the chain keep verifying. The since-view's touchedCount is distinct paths, not a per-receipt sum. Found doing it, recorded, not fixed: since-view turns, toolCalls and failures sum across cumulative receipts. Test s3 pins the wrong number as a tripwire. Co-Authored-By: Claude Opus 5.5 --- KNOWN-LIMITS.md | 47 ++++-- src/mcp/server.js | 16 +- src/views/chapters.js | 42 ++++- src/views/since.js | 4 +- test/known-limits-76-readers.test.js | 231 +++++++++++++++++++++++++++ test/mcp.test.js | 2 +- 6 files changed, 323 insertions(+), 19 deletions(-) create mode 100644 test/known-limits-76-readers.test.js diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index 9129759..453086e 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -1,8 +1,8 @@ @@ -3058,10 +3058,12 @@ unmerged. ## 76. A refused write was listed as touched, so an attempt read as a change -**Status: PARTLY CLOSED 2026-10-19, branch `lotor-lane/touched-outcome`. -Closed for the parser, the since-view, the text receipt and the live view. -Still OPEN for retcon's charter reconciliation (core) and three smaller -readers; see "Still open" below.** The parser +**Status: PARTLY CLOSED 2026-10-19, branch `lotor-lane/touched-outcome`; +narrowed 2026-11-04, branch `lotor-lane/l76-readers`. Closed for the parser, +the since-view, the text receipt, the live view, MCP `query_receipts` and the +chapters view on Claude Code transcripts. Still OPEN for retcon's charter +reconciliation (core), Codex chapters, and a count this work found; see "Still +open" below.** The parser now records an Edit/Write attempt and settles it against the tool_result that answers it. A touched entry from an attempt carries `outcome`: `ok` when the harness answered with a success, `error` when it answered with an error @@ -3087,13 +3089,32 @@ this change carry no `outcome` and render exactly as they did. items whose `outcome` is `'error'`, keeping ok-is-sticky across receipts), and `bin/` is wholesale core, so it waits for a signing sitting. `reconcile.js` itself cannot fix it: it never sees the outcome. -- **MCP `query_receipts` rows** (`src/mcp/server.js`, `summarizeEntry`) - report `touchedCount: p.touched.length`, refused attempts included. -- **The chapters view** (`src/views/chapters.js`) reads Edit/Write paths - straight from the transcript's tool_use and never joins the tool_result. -- **`touchedCount` in the since-view** is still summed per receipt, so with - subsessions "N files touched" overcounts in the same way `touchedRefused` - did. That predates this limit and is left for its own change. +- **Codex chapters.** `chaptersFromCodexRollout` does not settle `apply_patch` + attempts against their output, so a refused patch still reads as a file. +- **`turns`, `toolCalls` and `failures` in the since-view are summed across + receipts, and the receipts are cumulative.** Found 2026-11-04 fixing + `touchedCount`, which had the same fault. Every subsession receipt re-parses + the whole transcript (`src/ingest/index.js`), so a session with three + SessionEnd firings reports each of those counts about three times over. A + comment at the sum says accumulating prevents under-reporting; that holds + only if a receipt counted just its own stretch, which it does not. Not fixed + here: taking the largest value would be right for one transcript and wrong + for a session continued in a new one, and that case was not measured. + `test/known-limits-76-readers.test.js` s3 pins the wrong number as a tripwire. + +**What the 2026-11-04 narrowing added.** MCP session rows now carry +`touchedRefused`, omitted when no item on the receipt has an `outcome`, because +then the number is unrecorded rather than zero. `touchedCount` still counts +attempts. In the chapters view a chapter gains a `refused` list when it has a +refused path, settled per chapter: a path refused in one chapter and landed in +a later one is refused in the first and not in the second. The since-view's +`touchedCount` is now distinct paths per session. **What to doubt in this part.** +`refused` is left out of the chapters/1 digest on purpose, because digests +already on the chain were taken without it, so the marks are bound only through +the transcript hash and a reader checking the chapter digest alone learns +nothing about them. Distinct paths in the since-view compare strings as the +receipts wrote them, and a receipt from before limit 75's fix can still list one +file under two spellings. Found 2026-10-19. Seq 36203 is a gate denial: a session's write to one of its own hook files was refused. Seq 36204 is that session's receipt, and it lists diff --git a/src/mcp/server.js b/src/mcp/server.js index e8aef0e..886ab4f 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -101,6 +101,19 @@ function asWritten(p, key) { return Object.hasOwn(p, key) ? { [key]: p[key] } : {}; } +/** + * KNOWN-LIMITS 76. `touchedCount` counts attempts. This says how many of them + * the harness answered with an error. A receipt written before 2026-10-19 + * carries no `outcome` on any item, and for that receipt the number is + * unrecorded rather than zero, so the field is omitted. Absent is not zero. + */ +function touchedRefusedField(touched) { + if (!Array.isArray(touched)) return {}; + const items = touched.filter(t => t && typeof t === 'object'); + if (!items.some(t => Object.hasOwn(t, 'outcome'))) return {}; + return { touchedRefused: items.filter(t => t.outcome === 'error').length }; +} + /** * The chain holds several kinds of entry and they do not share a shape. * @@ -156,7 +169,8 @@ function summarizeEntry(entry) { ...(typeof c.failures === 'number' ? { failures: c.failures } : {}), ...(typeof c.transcriptEntries === 'number' ? { transcriptEntries: c.transcriptEntries } : {}), - ...(Array.isArray(p.touched) ? { touchedCount: p.touched.length } : {}) + ...(Array.isArray(p.touched) ? { touchedCount: p.touched.length } : {}), + ...touchedRefusedField(p.touched) }; } diff --git a/src/views/chapters.js b/src/views/chapters.js index fdef644..1872698 100644 --- a/src/views/chapters.js +++ b/src/views/chapters.js @@ -144,6 +144,7 @@ function makeBook() { const c = ensure(ts); if (p && !c.touched.includes(p)) c.touched.push(p); touch(ts); + return c; }, fail(ts) { const c = ensure(ts); c.failures++; touch(ts); } }; @@ -197,12 +198,31 @@ const FILE_TOOLS = { Edit: 'file_path', Write: 'file_path', NotebookEdit: 'noteb */ export function chaptersFromClaudeTranscript(jsonlText) { const book = makeBook(); + // Limit 76. A file tool's tool_use is an attempt, and the tool_result that + // answers it says how it went. attempts: tool_use id -> { chapter, path }. + // settled: chapter -> Map(path -> 'ok' | 'error'). `ok` is sticky within a + // chapter, the same rule the parser applies: one landed write is a write. + const attempts = new Map(); + const settled = new Map(); + const settle = (id, isError) => { + const a = attempts.get(id); + if (!a) return; + if (!settled.has(a.chapter)) settled.set(a.chapter, new Map()); + const m = settled.get(a.chapter); + if (!isError) m.set(a.path, 'ok'); + else if (m.get(a.path) !== 'ok') m.set(a.path, 'error'); + }; for (const e of parseLines(jsonlText)) { const msg = e?.message; if (!msg) continue; const ts = CLAUDE_TS(e); if (msg.role === 'user') { + if (Array.isArray(msg.content)) { + for (const item of msg.content) { + if (item?.type === 'tool_result') settle(item.tool_use_id, item.is_error === true); + } + } if (e.isSidechain !== true) { const text = claudeUserText(e); if (text !== null) { book.open(ts, makeTitle(text)); continue; } @@ -223,10 +243,19 @@ export function chaptersFromClaudeTranscript(jsonlText) { book.call(ts, item.name); const key = FILE_TOOLS[item.name]; const p = key ? item.input?.[key] : undefined; - if (typeof p === 'string' && p) book.path(ts, p); + if (typeof p === 'string' && p) { + const chapter = book.path(ts, p); + if (item.id) attempts.set(item.id, { chapter, path: p }); + } } } } + // `refused` appears only on a chapter that has one, so a chapter with no + // refused write keeps exactly the shape it has always had. + for (const [chapter, m] of settled) { + const refused = chapter.touched.filter(p => m.get(p) === 'error'); + if (refused.length) chapter.refused = refused; + } return book.chapters; } @@ -355,10 +384,15 @@ function canonicalize(value) { * title removed, keys sorted at every depth, arrays in order. Titles are * operator words and never enter the chain; everything else in a chapter * is counts, timestamps and paths, which `touched` already puts there. + * + * `refused` is removed too. chapters/1 digests are already on the chain and + * were taken without it, so including it would make every old receipt with a + * refused write read DOES NOT match. The marks are bound only through the + * transcript hash, not through this digest. */ export function chaptersCanonical(chapters) { const rows = (Array.isArray(chapters) ? chapters : []).map(c => { - const { title, ...rest } = c; + const { title, refused, ...rest } = c; return canonicalize(rest); }); return JSON.stringify(rows); @@ -536,6 +570,7 @@ export function chaptersReport(entries, opts = {}) { sessions: out, caveats: [ 'Intent is not recorded. A chapter shows which tools ran and which files were named, never why.', + 'A file count includes attempts. A refused file is one whose every answered write in that chapter came back as an error; Claude Code transcripts only, Codex rollouts are not settled.', 'A chapter title is the operator\'s own words, quoted. It is not a claim about what was done.', 'Chapters come from transcripts on disk, not from the chain. The chain witnesses the session receipt, not this list.', 'Codex sessions are not on the chain at all. Their chapters are unattested.', @@ -597,8 +632,9 @@ export function renderChapters(report) { } else { for (const c of s.chapters) { const files = c.touched.length; + const refused = c.refused?.length ?? 0; const title = c.title === null ? '(no prompt recorded)' : `"${c.title}"`; - const tail = `(${c.toolCalls} tool call${c.toolCalls === 1 ? '' : 's'}, ${files} file${files === 1 ? '' : 's'}${c.failures ? `, ${c.failures} failed` : ''})`; + const tail = `(${c.toolCalls} tool call${c.toolCalls === 1 ? '' : 's'}, ${files} file${files === 1 ? '' : 's'}${refused ? `, ${refused} refused` : ''}${c.failures ? `, ${c.failures} failed` : ''})`; L.push(` #${String(c.index).padEnd(3)} ${hhmm(c.startedAt)} ${title} ${tail}`); } } diff --git a/src/views/since.js b/src/views/since.js index 4848e02..3f7440d 100644 --- a/src/views/since.js +++ b/src/views/since.js @@ -133,7 +133,6 @@ export function sinceReport(entries, opts = {}) { s.toolCalls = (s.toolCalls ?? 0) + (c.toolCalls ?? 0); s.failures = (s.failures ?? 0) + (c.failures ?? 0); if (Array.isArray(p.touched)) { - s.touchedCount = (s.touchedCount ?? 0) + p.touched.length; // Keep the paths, not just the count. A count narrows the search to // a handful of sessions; a path answers the question outright, which // is the difference between "go and look" and "here it is". This is @@ -153,6 +152,9 @@ export function sinceReport(entries, opts = {}) { s.refusedPaths.splice(s.refusedPaths.indexOf(pth), 1); } } + // DISTINCT paths for the session, for the same reason as below: a + // per-receipt sum counted one file once per SessionEnd firing. + s.touchedCount = s.touched.length; // DISTINCT refused paths for the session, not a per-receipt sum. // Every subsession receipt re-parses the whole transcript // (src/ingest), so one refused write in a session with three diff --git a/test/known-limits-76-readers.test.js b/test/known-limits-76-readers.test.js new file mode 100644 index 0000000..2ad6ac1 --- /dev/null +++ b/test/known-limits-76-readers.test.js @@ -0,0 +1,231 @@ +/** + * KNOWN-LIMITS 76, narrowed 2026-11-04, branch `lotor-lane/l76-readers`: + * three readers still reported a refused write as a plain touched file. + * + * WHAT THIS HOLDS + * Chapters: src/views/chapters.js settles each file attempt against the + * tool_result that answers it, per chapter; a chapter whose every answered + * write to a path came back as an error carries `refused: [paths]`. + * MCP: `query_receipts` session rows carry `touchedRefused`, omitted when + * no item on the receipt has an `outcome` (absent is not zero). + * Since-view: `touchedCount` is distinct paths per session, not a sum. + * s3 is a deliberate tripwire: it pins a known wrong number so a fix has + * to come and update the limit beside it. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + chaptersFromClaudeTranscript, + chaptersBinding, + renderChapters +} from '../src/views/chapters.js'; +import { sinceReport } from '../src/views/since.js'; +import { createStore } from '../src/store/index.js'; + +// Fixture helpers, copied verbatim from .lane/fixture.mjs (the work order +// forbids importing from .lane: the copy is the durable thing). +const u = (ts, content, extra = {}) => JSON.stringify({ type: 'user', message: { role: 'user', content }, timestamp: ts, ...extra }); +const a = (ts, id, name, input) => JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, timestamp: ts }); +const r = (ts, id, isError) => u(ts, [{ type: 'tool_result', tool_use_id: id, content: isError ? 'denied' : 'ok', is_error: isError }]); +const T = s => `2026-11-04T10:00:${String(s).padStart(2, '0')}.000Z`; +const TRANSCRIPT = [ + u(T(0), 'Fix the hook'), + a(T(1), 'tu1', 'Write', { file_path: '/w/refused.js', content: 'x' }), + r(T(2), 'tu1', true), + a(T(3), 'tu2', 'Write', { file_path: '/w/landed.js', content: 'x' }), + r(T(4), 'tu2', false), + a(T(5), 'tu3', 'Edit', { file_path: '/w/retry.js', old_string: 'a', new_string: 'b' }), + r(T(6), 'tu3', true), + a(T(7), 'tu4', 'Edit', { file_path: '/w/retry.js', old_string: 'a', new_string: 'b' }), + r(T(8), 'tu4', false), + u(T(9), 'Second prompt'), + a(T(10), 'tu5', 'Write', { file_path: '/w/refused.js', content: 'x' }), + r(T(11), 'tu5', false), + a(T(12), 'tu6', 'Write', { file_path: '/w/unanswered.js', content: 'x' }), + a(T(13), 'tu7', 'Write', { file_path: '/w/late.js', content: 'x' }), + u(T(14), 'Third prompt'), + r(T(15), 'tu7', true) +].join('\n') + '\n'; + +const chapters = chaptersFromClaudeTranscript(TRANSCRIPT); + +describe('KNOWN-LIMITS 76 narrowing: the chapters view settles attempts per chapter', () => { + it('c1. chapter 1 lists retry.js as touched but not refused: it failed then landed', () => { + assert.deepEqual(chapters[0].touched, ['/w/refused.js', '/w/landed.js', '/w/retry.js']); + assert.deepEqual(chapters[0].refused, ['/w/refused.js'], + 'retry.js failed once then landed in the same chapter; one landed write is a write'); + }); + + it('c2. chapter 2: late.js is refused there, refused.js landed there, unanswered.js stays out', () => { + assert.deepEqual(chapters[1].refused, ['/w/late.js'], + 'the answer to tu7 arrived after chapter 3 opened, but the attempt was chapter 2\'s'); + assert.ok(!chapters[1].refused.includes('/w/refused.js'), + 'refused.js landed in chapter 2 (tu5 answered ok), so it refuses nothing here'); + assert.ok(!chapters[1].refused.includes('/w/unanswered.js'), + 'an attempt with no answer stays out: unanswered is not refused'); + }); + + it('c3. chapter 3 has no own refused property', () => { + assert.ok(!Object.hasOwn(chapters[2], 'refused'), + '"refused appears only on a chapter that has one"; chapter 3 only received a late answer'); + }); + + it('c4. the chapters/1 digest is unchanged by this narrowing', () => { + // Computed on main (22ae903) before this change, using the same fixture: + // sha256(chaptersCanonical(chaptersFromClaudeTranscript(TRANSCRIPT))) + // It must never move. chapters/1 digests are already on the chain, bound + // at signing sitting against the pre-refused canonical text, so any change to what + // chaptersCanonical includes makes every stored binding read DOES NOT + // match. That is why `refused` is excluded from the canonical form, and + // why this pins it. + assert.strictEqual(chaptersBinding(chapters).digest, + 'b8c53cab506db1ac645937ed57744861a46e90894e013f810fa039462fd24e5c'); + }); + + it('c5. failures are unchanged: [2, 0, 1]', () => { + assert.deepEqual(chapters.map(c => c.failures), [2, 0, 1], + 'the settle pass must not change book.fail behaviour'); + }); + + it('c6. renderChapters shows "1 refused" for chapter 1 and no refused word where there is none', () => { + const report = { + window: { from: 0, to: 0, entryCount: 0 }, + sessions: [{ + runtime: 'claude-code', sessionId: 's', witnessed: false, + chapters, caveats: [] + }], + caveats: [] + }; + const out = renderChapters(report); + const lines = out.split('\n'); + const l1 = lines.find(l => l.trim().startsWith('#1 ')); + const l3 = lines.find(l => l.trim().startsWith('#3 ')); + assert.ok(l1 && l1.includes(', 1 refused,'), + `chapter 1's line must carry the refused count, got: ${l1}`); + assert.ok(l3 && !l3.includes('refused'), + `a chapter without refused must print no refused word, got: ${l3}`); + }); +}); + +// Point the shared home at an isolated temp dir BEFORE importing the server, +// so the server's singleton store lands there and never touches the real +// ~/.lotor. Same approach as test/mcp.test.js; dynamic import because ESM +// static imports are hoisted above this assignment. +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lotor-l76-readers-')); +process.env.LOTOR_HOME = TEST_HOME; + +const { handleQueryReceipts } = await import('../src/mcp/server.js'); + +describe('KNOWN-LIMITS 76 narrowing: query_receipts session rows carry touchedRefused', () => { + it('m1. two touched items, one outcome error: touchedCount 2, touchedRefused 1', () => { + const store = createStore(TEST_HOME); + store.appendReceipt({ + session: { id: 'l76-m1', model: 'test-model' }, + ran: [], failed: [], cost: {}, + counts: { turns: 1, toolCalls: 2, failures: 1 }, + touched: [ + { path: '/a', via: 'write', outcome: 'error' }, + { path: '/b', via: 'write', outcome: 'ok' } + ] + }); + const row = handleQueryReceipts({ sessionId: 'l76-m1' }).receipts[0]; + assert.strictEqual(row.touchedCount, 2); + assert.strictEqual(row.touchedRefused, 1); + }); + + it('m2. all outcomes ok: touchedRefused is present and reads 0', () => { + const store = createStore(TEST_HOME); + store.appendReceipt({ + session: { id: 'l76-m2', model: 'test-model' }, + ran: [], failed: [], cost: {}, + counts: { turns: 1, toolCalls: 2, failures: 0 }, + touched: [ + { path: '/a', via: 'write', outcome: 'ok' }, + { path: '/b', via: 'write', outcome: 'ok' } + ] + }); + const row = handleQueryReceipts({ sessionId: 'l76-m2' }).receipts[0]; + assert.ok(Object.hasOwn(row, 'touchedRefused'), + 'zero refused is a receipt that said so; only no-outcome receipts omit the key'); + assert.strictEqual(row.touchedRefused, 0); + }); + + it('m3. touched with no outcome key anywhere: no touchedRefused key on the row', () => { + const store = createStore(TEST_HOME); + store.appendReceipt({ + session: { id: 'l76-m3', model: 'test-model' }, + ran: [], failed: [], cost: {}, + counts: { turns: 1, toolCalls: 1, failures: 0 }, + touched: [{ path: '/a', via: 'write' }] + }); + const row = handleQueryReceipts({ sessionId: 'l76-m3' }).receipts[0]; + assert.strictEqual(row.touchedCount, 1); + assert.ok(!Object.hasOwn(row, 'touchedRefused'), + 'a receipt written before 2026-10-19 records nothing about outcomes; absent is not zero'); + }); +}); + +describe('KNOWN-LIMITS 76 narrowing: the since-view counts touched paths once per session', () => { + const at = (seq, ts, payload) => ({ seq, timestamp: ts, hash: 'h', payload }); + + const receipt = (id, sub, paths) => ({ + session: { id, model: 'test-model', subsession: sub }, + counts: { turns: 1, toolCalls: 1, failures: 0 }, + touched: paths.map(p => ({ path: p, via: 'write', outcome: 'ok' })) + }); + + const open = id => at(0, 1000, { type: 'session-open', sessionId: id, source: 'startup', cwd: '/w' }); + + it('s1. two receipts listing the same two paths count 2, not 4', () => { + const entries = [ + open('l76-s1'), + at(1, 2000, receipt('l76-s1', 0, ['/a.js', '/b.js'])), + at(2, 3000, receipt('l76-s1', 1, ['/a.js', '/b.js'])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.strictEqual(s.touchedCount, 2, + 'every subsession receipt repeats the whole transcript\'s touched list; the count is distinct paths'); + }); + + it('s2. a second receipt adding one new path counts 3', () => { + const entries = [ + open('l76-s2'), + at(1, 2000, receipt('l76-s2', 0, ['/a.js', '/b.js'])), + at(2, 3000, receipt('l76-s2', 1, ['/a.js', '/b.js', '/c.js'])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.strictEqual(s.touchedCount, 3); + }); + + it('s3. tripwire: turns, toolCalls and failures still sum across cumulative receipts (limit 76, still open)', () => { + // This asserts a known wrong number ON PURPOSE. Every subsession receipt + // re-parses the whole transcript (src/ingest), so a session with two + // SessionEnd firings reports each of these counts about twice over, and + // the since-view still sums them. Summing is wrong for cumulative + // receipts; the honest fix needs a decision this change did not make. + // A fix must come and update this test together with KNOWN-LIMITS 76's + // "Still open" list; if this test starts failing instead, the number + // moved and the limit must be revisited, not the test deleted. + const entries = [ + open('l76-s3'), + at(1, 2000, { + session: { id: 'l76-s3', model: 'test-model', subsession: 0 }, + counts: { turns: 5, toolCalls: 4, failures: 1 }, + touched: [{ path: '/a.js', via: 'write', outcome: 'ok' }] + }), + at(2, 3000, { + session: { id: 'l76-s3', model: 'test-model', subsession: 1 }, + counts: { turns: 5, toolCalls: 4, failures: 1 }, + touched: [{ path: '/a.js', via: 'write', outcome: 'ok' }] + }) + ]; + const s = sinceReport(entries).sessions[0]; + assert.strictEqual(s.turns, 10, 'WRONG ON PURPOSE: 5 turns happened, 10 are reported'); + assert.strictEqual(s.toolCalls, 8, 'WRONG ON PURPOSE: 4 tool calls happened, 8 are reported'); + assert.strictEqual(s.failures, 2, 'WRONG ON PURPOSE: 1 failure happened, 2 are reported'); + }); +}); \ No newline at end of file diff --git a/test/mcp.test.js b/test/mcp.test.js index edd12ac..6834dcc 100644 --- a/test/mcp.test.js +++ b/test/mcp.test.js @@ -151,7 +151,7 @@ describe('MCP tool handlers', () => { 'seq', 'timestamp', 'hash', 'type', // session receipts 'sessionId', 'model', 'subsession', - 'turns', 'toolCalls', 'failures', 'transcriptEntries', 'touchedCount', + 'turns', 'toolCalls', 'failures', 'transcriptEntries', 'touchedCount', 'touchedRefused', // session-open 'source', 'cwd', // gated-action From 3672679e22001abfb63e6cb917f52b839276a68a Mon Sep 17 00:00:00 2001 From: githubscum <65476409+githubscum@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:34:29 -0600 Subject: [PATCH 2/6] Re-stamp the limits pin after the source commit The stamp in f419f11 was taken before that commit existed, so it named main's last source commit and the committed-pin tripwire read diverged on every CI job. Stamp only; the body digest is unchanged. Co-Authored-By: Claude Opus 5.5 --- KNOWN-LIMITS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index 453086e..efbcbca 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -1,7 +1,7 @@ From 295dabc232800e8b42e5b1621486cf3e15ba7c4f Mon Sep 17 00:00:00 2001 From: githubscum <65476409+githubscum@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:36:21 -0600 Subject: [PATCH 5/6] KNOWN-LIMITS 76: since view keeps a landed write landed across receipts A path answered ok by one receipt and error by a later one read as refused, against the parser's rule that one landed write is a write. ok now holds across receipts, and only ok: unknown then error still reads as refused. Tests s5 to s9 pin the orders. Only s6 fails on the old source; the others are tripwires. Run against synthetic receipts; whether this order occurs on a real chain is not known. Co-Authored-By: Claude Opus 5.5 --- KNOWN-LIMITS.md | 10 +++++ src/views/since.js | 14 ++++++- test/known-limits-76-readers.test.js | 61 ++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index 17a5b05..e1b33d6 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -3128,6 +3128,16 @@ is a superset" held for the counts and not for that one list. The window is unchanged: a receipt inside the window still reports work done before the window opened. +**Amended 2026-11-05, the since view against receipts that disagree.** The view +takes `touched` as a union, so a path a later receipt forgets stays listed; the +shrink above costs that view nothing. It did get one order wrong: a path answered +`ok` by one receipt and `error` by a later one read as refused, against the +parser's rule that one landed write is a write. `ok` now holds across receipts, +and only `ok`: `unknown` then `error` still reads as refused. These orders were +run against synthetic receipts. No real session was found showing landed then +refused, and the 39-to-32 session is still not identified or explained, so +whether this order occurs on a real chain is not known. + Found 2026-10-19. Seq 36203 is a gate denial: a session's write to one of its own hook files was refused. Seq 36204 is that session's receipt, and it lists the hook file under `touched`. The file was unchanged on disk. The reader of diff --git a/src/views/since.js b/src/views/since.js index 3b3bc28..720f6c4 100644 --- a/src/views/since.js +++ b/src/views/since.js @@ -98,6 +98,10 @@ export function sinceReport(entries, opts = {}) { return sessions.get(id); }; + // sessionId -> paths some receipt answered with outcome 'ok'. Kept out of the + // session record so it does not reach the report. + const landedBySession = new Map(); + const denials = []; const warns = []; const egress = []; @@ -143,11 +147,19 @@ export function sinceReport(entries, opts = {}) { for (const t of p.touched) { const pth = typeof t === 'string' ? t : t?.path; if (pth && !s.touched.includes(pth)) s.touched.push(pth); + // 'ok' is sticky across receipts, the same rule the parser keeps + // inside one transcript. Only 'ok': an 'unknown' attempt has no + // answer yet, and a later receipt may rightly answer it 'error'. + if (pth && typeof t === 'object' && t?.outcome === 'ok') { + if (!landedBySession.has(id)) landedBySession.set(id, new Set()); + landedBySession.get(id).add(pth); + } // The receipt's own word (limit 76): outcome 'error' means the // harness returned an error for that attempt. Old receipts carry // no outcome key and refuse nothing a reader can see. if (pth && typeof t === 'object' && t?.outcome === 'error') { - if (!s.refusedPaths.includes(pth)) s.refusedPaths.push(pth); + const landed = landedBySession.get(id)?.has(pth) === true; + if (!landed && !s.refusedPaths.includes(pth)) s.refusedPaths.push(pth); } else if (pth && s.refusedPaths.includes(pth)) { // A later receipt answering the same path with a landed write // (any outcome other than 'error', including none) un-refuses diff --git a/test/known-limits-76-readers.test.js b/test/known-limits-76-readers.test.js index 1d80586..82ea3e7 100644 --- a/test/known-limits-76-readers.test.js +++ b/test/known-limits-76-readers.test.js @@ -246,4 +246,65 @@ describe('KNOWN-LIMITS 76 narrowing: the since-view counts touched paths once pe assert.strictEqual(s.toolCalls, 6); assert.strictEqual(s.failures, 2); }); + + // Receipts that disagree about a path. Inside one transcript the parser keeps + // 'ok' sticky; these pin the same answer across receipts, whatever the order. + const said = (id, sub, items) => ({ + session: { id, model: 'test-model', subsession: sub }, + counts: { turns: 1, toolCalls: 1, failures: 0 }, + touched: items.map(([p, outcome]) => ({ path: p, via: 'write', outcome })) + }); + + it('s5. a path a later receipt forgets stays touched: the view is a union', () => { + const entries = [ + open('l76-s5'), + at(1, 2000, said('l76-s5', 0, [['/a.js', 'ok'], ['/b.js', 'ok']])), + at(2, 3000, said('l76-s5', 1, [['/a.js', 'ok']])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.deepStrictEqual(s.touched, ['/a.js', '/b.js']); + assert.strictEqual(s.touchedCount, 2); + }); + + it('s6. landed then refused reads as landed: one landed write is a write', () => { + const entries = [ + open('l76-s6'), + at(1, 2000, said('l76-s6', 0, [['/a.js', 'ok']])), + at(2, 3000, said('l76-s6', 1, [['/a.js', 'error']])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.deepStrictEqual(s.refusedPaths, []); + assert.strictEqual(s.touchedRefused, 0); + }); + + it('s7. refused then landed reads as landed', () => { + const entries = [ + open('l76-s7'), + at(1, 2000, said('l76-s7', 0, [['/a.js', 'error']])), + at(2, 3000, said('l76-s7', 1, [['/a.js', 'ok']])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.deepStrictEqual(s.refusedPaths, []); + assert.strictEqual(s.touchedRefused, 0); + }); + + it('s8. unknown then refused stays refused: an unanswered attempt never landed', () => { + const entries = [ + open('l76-s8'), + at(1, 2000, said('l76-s8', 0, [['/a.js', 'unknown']])), + at(2, 3000, said('l76-s8', 1, [['/a.js', 'error']])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.deepStrictEqual(s.refusedPaths, ['/a.js']); + assert.strictEqual(s.touchedRefused, 1); + }); + + it('s9. the landed bookkeeping does not reach the session record', () => { + const entries = [ + open('l76-s9'), + at(1, 2000, said('l76-s9', 0, [['/a.js', 'ok']])) + ]; + const s = sinceReport(entries).sessions[0]; + assert.strictEqual(Object.keys(s).some(k => /landed/i.test(k)), false); + }); }); \ No newline at end of file From 8f407c668b2d3ef61c89e58bf398fa91c50a3b67 Mon Sep 17 00:00:00 2001 From: githubscum <65476409+githubscum@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:36:33 -0600 Subject: [PATCH 6/6] Re-stamp the limits pin after the since-view order commit Co-Authored-By: Claude Opus 5.5 --- KNOWN-LIMITS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index e1b33d6..7af8503 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -1,8 +1,8 @@