From 46c450776b6058baa54466c4a29f1599225009cd Mon Sep 17 00:00:00 2001 From: Felipe Sauer <120697114+felipesauer@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:37:25 -0400 Subject: [PATCH 1/3] The run says what was written in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every read that reported a run reported the container: an id, a goal, and two durations. `lastFactAt` says WHEN the last fact landed and can never say WHAT landed, so a session that recorded a decision and one that recorded a passing note reported identically. The run projection now folds `wrote` off the same pass and the same envelope slot — one `{kind, count}` entry per sort of fact pinned to the run, commonest first, ties broken by the kind's own spelling so the order is total. It is always present: `[]` is the answer for a run that wrote nothing, which is a different claim from the absent `lastFactAt` beside it. A count per kind rather than a list of names, and that is a declared divergence from the opening context's convention: those lists are over entities, which a record holds without limit, so they are cut and the cut declares itself; this one is over the event catalog, a closed union, so it is bounded whatever the run did and needs no cut. It reaches the reader: `resume` and `status` through `lastRunPhrase`, `focus` through the same `wroteSuffix`, both `--json` payloads, and the MCP run contract every read that lists a run appends. `mnema usage` is left alone — it answers what a session COST, joined to the host's transcripts, and what a run wrote is the record's. --- packages/code/src/cli.golden.test.ts | 16 +++ packages/code/src/cli.reads.golden.txt | 94 ++++++++++++++-- packages/code/src/mcp/server.ts | 15 ++- packages/code/src/presentation/runs.ts | 70 +++++++++++- packages/code/src/wiring/focus.ts | 13 ++- packages/code/tests/cli-e2e.test.ts | 67 +++++++++++ packages/code/tests/mcp-e2e.test.ts | 35 ++++++ ...e-line-a-reading-words-is-one-line.test.ts | 8 +- packages/copilot/README.md | 1 + packages/copilot/src/context/bootstrap.ts | 5 +- packages/copilot/src/context/focus.test.ts | 28 +++++ packages/copilot/src/context/focus.ts | 20 ++++ packages/copilot/tests/readme-example.test.ts | 7 ++ packages/core/src/db/schema.ts | 12 +- packages/core/src/index.ts | 2 +- packages/core/src/projections/cache.test.ts | 2 + packages/core/src/projections/fed-by.ts | 7 +- .../core/src/projections/run-store.test.ts | 25 ++++- packages/core/src/projections/run-store.ts | 30 ++++- packages/core/src/projections/run.test.ts | 106 +++++++++++++++++- packages/core/src/projections/run.ts | 88 ++++++++++++++- 21 files changed, 614 insertions(+), 37 deletions(-) diff --git a/packages/code/src/cli.golden.test.ts b/packages/code/src/cli.golden.test.ts index b5905920..25a57b0f 100644 --- a/packages/code/src/cli.golden.test.ts +++ b/packages/code/src/cli.golden.test.ts @@ -738,6 +738,22 @@ beforeAll(async () => { 'the record is set up', ); delete process.env.MNEMA_RUN; + + // ── The run that DID something, read while it is still the latest one. + // + // Every other reading of a run in this transcript lands on the second one, which is + // opened below and stays empty — so without this pair the file would pin `wrote` in + // its empty case only, and a clause that never shows a value is a clause a change + // could empty without moving a byte here. Read HERE, between the two runs, because + // `resume` answers with the latest and the second one is a line away. + // + // Nothing volatile crosses: the run has ENDED, so it carries no age and no idleness + // — which is also the case the phrase treats differently, `wrote` riding an ended run + // where the durations do not. + section('reads', 'the run that did something'); + await mnema('reads', 'resume', '--actor', anchor); + await mnema('reads', 'resume', '--actor', anchor, '--json'); + const second = await mnema( 'writes', 'run', diff --git a/packages/code/src/cli.reads.golden.txt b/packages/code/src/cli.reads.golden.txt index c3c98f4b..3f5c68bf 100644 --- a/packages/code/src/cli.reads.golden.txt +++ b/packages/code/src/cli.reads.golden.txt @@ -289,6 +289,73 @@ $ mnema verify | census [key-without-tail] public : committed public key has no tail on disk — the tail may have been dropped (a botched merge), never written (an empty tail is not versioned), or removed | private: no record here — nothing has been written to this tree on this machine, so there is nothing to rule on +### the run that did something +$ mnema resume --actor +| last run (ended) — set the record up · wrote 8 task.transitioned, 5 decision.transitioned, 5 knowledge.linked, 3 decision.recorded, 3 skill.transitioned, 2 memory.captured, 1 handoff.recorded, 1 observation.recorded, 1 skill.created, 1 task.created +| 0 run(s) still open +$ mnema resume --actor --json +| { +| "actor": "", +| "lastRun": { +| "id": "", +| "agent": "agent-alpha", +| "who": "", +| "open": false, +| "startedAt": "", +| "wrote": [ +| { +| "kind": "task.transitioned", +| "count": 8 +| }, +| { +| "kind": "decision.transitioned", +| "count": 5 +| }, +| { +| "kind": "knowledge.linked", +| "count": 5 +| }, +| { +| "kind": "decision.recorded", +| "count": 3 +| }, +| { +| "kind": "skill.transitioned", +| "count": 3 +| }, +| { +| "kind": "memory.captured", +| "count": 2 +| }, +| { +| "kind": "handoff.recorded", +| "count": 1 +| }, +| { +| "kind": "observation.recorded", +| "count": 1 +| }, +| { +| "kind": "skill.created", +| "count": 1 +| }, +| { +| "kind": "task.created", +| "count": 1 +| } +| ], +| "goal": "set the record up", +| "outcome": "the record is set up", +| "endedAt": "", +| "lastFactAt": "", +| "thisSession": false +| }, +| "focus": { +| "actor": "", +| "openRuns": [] +| } +| } + ### a populated project $ mnema search | 13 record(s): @@ -1217,7 +1284,7 @@ $ mnema antipatterns --json | } $ mnema status --actor | — where things stand. -| last run (open) — read the record back · open · nothing recorded in it +| last run (open) — read the record back · open · nothing recorded in it · wrote nothing | 1 run(s) still open | | 3 live task(s): @@ -1242,7 +1309,8 @@ $ mnema status --actor --json | "agent": "agent-beta", | "who": "", | "open": true, -| "startedAt": "", +| "startedAt": "", +| "wrote": [], | "goal": "read the record back", | "thisSession": false, | "ageSeconds": @@ -1255,7 +1323,8 @@ $ mnema status --actor --json | "agent": "agent-beta", | "who": "", | "open": true, -| "startedAt": "", +| "startedAt": "", +| "wrote": [], | "goal": "read the record back", | "thisSession": false, | "ageSeconds": @@ -1312,10 +1381,10 @@ $ mnema status --actor --json | } $ mnema focus --actor | — 1 open run(s): -| agent-beta — read the record back · open · nothing recorded in it +| agent-beta — read the record back · open · nothing recorded in it · wrote nothing $ mnema focus --actor | — 1 open run(s): -| agent-beta — read the record back · open · nothing recorded in it +| agent-beta — read the record back · open · nothing recorded in it · wrote nothing $ mnema focus --actor --json | { | "actor": "", @@ -1325,7 +1394,8 @@ $ mnema focus --actor --json | "agent": "agent-beta", | "who": "", | "open": true, -| "startedAt": "", +| "startedAt": "", +| "wrote": [], | "goal": "read the record back", | "thisSession": false, | "ageSeconds": @@ -1333,7 +1403,7 @@ $ mnema focus --actor --json | ] | } $ mnema resume --actor -| last run (open) — read the record back · open · nothing recorded in it +| last run (open) — read the record back · open · nothing recorded in it · wrote nothing | 1 run(s) still open $ mnema resume --actor --json | { @@ -1343,7 +1413,8 @@ $ mnema resume --actor --json | "agent": "agent-beta", | "who": "", | "open": true, -| "startedAt": "", +| "startedAt": "", +| "wrote": [], | "goal": "read the record back", | "thisSession": false, | "ageSeconds": @@ -1356,7 +1427,8 @@ $ mnema resume --actor --json | "agent": "agent-beta", | "who": "", | "open": true, -| "startedAt": "", +| "startedAt": "", +| "wrote": [], | "goal": "read the record back", | "thisSession": false, | "ageSeconds": @@ -1491,8 +1563,8 @@ $ mnema switch | edit-asks-a-person on the pause before a file is written where the record asks that a person look first — the rules go on arriving, and nothing stops $ mnema usage | 2 run(s): -| agent-beta → open no transcript -| agent-alpha no transcript +| agent-beta → open no transcript +| agent-alpha no transcript | | Read from /home/.claude/projects — 0 host session(s) there record work in this project. | These counts come from Claude Code's own transcripts on this machine, not from the record: nothing here is signed, `mnema verify` does not cover it, and the host deletes a transcript on a retention it decides. Which host session belongs to which run is this reading inferring it from two clocks, not a fact the record states. Nothing was written to produce it. diff --git a/packages/code/src/mcp/server.ts b/packages/code/src/mcp/server.ts index e11dd58c..13cbda25 100644 --- a/packages/code/src/mcp/server.ts +++ b/packages/code/src/mcp/server.ts @@ -243,6 +243,13 @@ const DECISION_VERDICTS = andListed(actionsRequiring('decision', 'note')); * missing MEANS something (a run that recorded nothing at all), and a reader left to * infer that from silence would infer whatever it already believed. * + * `wrote` is said here too, though it is not one of the asker-relative fields the rest + * of this text is about: it is on EVERY run reported, open or ended, and an agent that + * does not know a field exists does not read it. It is the one thing here that says + * what a session DID rather than how it stands, which is what "where was I" actually + * asks — and the empty array is spelled out for the same reason the absent + * `idleSeconds` is, except that here the emptiness is the answer rather than a gap. + * * And it says what none of this proves. An open run is not evidence of a live * session: nothing in the record says a process is running, so a run left behind by * a session that was killed is indistinguishable from one an agent is idle inside. @@ -257,7 +264,13 @@ const OPEN_RUN_CONTRACT = 'it started) and, when anything has been recorded in it, `idleSeconds` (since its ' + 'last recorded fact); NO `idleSeconds` means the run has recorded nothing at all. ' + "Both compare this machine's clock with the writer's, so a run written on another " + - 'machine reports whatever those two clocks differ by. None of this says a ' + + 'machine reports whatever those two clocks differ by. EVERY run reported — open or ' + + 'ended — also carries `wrote`: what was written IN it, as one `{kind, count}` entry ' + + 'per kind of fact recorded there, commonest first. An EMPTY array means the run ' + + 'recorded nothing (it is never absent, so an empty one is an answer and not a gap), ' + + 'and the run’s own start and end are not counted — only the work. The `kind` is the ' + + 'catalog’s own, so `search` takes it verbatim; the facts themselves are not listed, ' + + 'and `audit_timeline` on an entity is what serves those. None of this says a ' + 'run is dead — nothing in the record speaks about a process — so an old idle run ' + 'may be abandoned or may be a session waiting; closing one is `mnema run end `.'; diff --git a/packages/code/src/presentation/runs.ts b/packages/code/src/presentation/runs.ts index e196499f..0a62343c 100644 --- a/packages/code/src/presentation/runs.ts +++ b/packages/code/src/presentation/runs.ts @@ -3,13 +3,14 @@ * * `focus` lists the open runs, `resume` names the last one and `status` says where the * actor left off as one half of a wider answer, and all three need the same things said - * the same way: how long a run has been open, how long since it recorded anything, what - * the LAST one was, how many are still open, and what a run IS for the reader who has - * none. Three readings wording that separately is three wordings, and the second one to - * change would be the one nobody noticed. + * the same way: how long a run has been open, how long since it recorded anything, WHAT + * was written in it, what the LAST one was, how many are still open, and what a run IS + * for the reader who has none. Three readings wording that separately is three wordings, + * and the second one to change would be the one nobody noticed. */ import type { Resume } from '@mnema/copilot'; +import type { WrittenInRun } from '@mnema/core'; import { oneLine } from '../one-line.js'; import { fact } from './detail.js'; import type { Line } from './line.js'; @@ -58,6 +59,13 @@ export const NO_RUNS_HINT: readonly Line[] = [ * the rule `wiring/on-one-line.ts` states and the reason the three halves are joined * rather than nested: a fragment carrying its own punctuation, collapsed, loses the * space it opens with, and `for a — g` becomes `for a— g`. + * + * WHAT WAS WRITTEN RIDES IT WHETHER THE RUN IS OPEN OR ENDED, which is the one part of + * this phrase that is NOT under the age's rule. An age beside an ended run would read + * as time still passing; a tally beside one reads as what that session did, and the + * reader of an ENDED run is exactly the person asking "where was I" — the question this + * phrase exists for. Asserted in `cli-e2e.test.ts` — "`resume` says what was written in + * the run, ended or open". */ export function lastRunPhrase(run: { readonly id: string; @@ -65,11 +73,13 @@ export function lastRunPhrase(run: { readonly goal?: string; readonly ageSeconds?: number; readonly idleSeconds?: number; + readonly wrote: readonly WrittenInRun[]; }): string { return ( `last run ${run.id} (${run.open ? 'open' : 'ended'})` + (run.goal === undefined ? '' : ` — ${oneLine(run.goal)}`) + - (run.open ? runAgeSuffix(run) : '') + (run.open ? runAgeSuffix(run) : '') + + wroteSuffix(run) ); } @@ -129,6 +139,56 @@ export function runAgeSuffix(run: { return ` · ${age} · ${idle}`; } +/** + * WHAT was written in a run — the tally per kind, commonest first, appended to the + * run's OWN line. + * + * The order and the entries are the projection's (`core`'s `run.ts`); this only words + * them. It is the answer to the complaint that put this clause here: every reading of a + * run reported the CONTAINER — an id, a goal, two durations — and none of them said + * what was put in it, so a session that recorded a decision and one that recorded + * nothing but a memory printed the same line. + * + * IT IS UNCUT, and that is the projection's ceiling kept rather than a second decision + * made here. The entries are over the event catalog, a closed union, so the clause is + * bounded by the number of kinds however long the session ran — which is why there is + * no `+N more` and no total beside it, the shape the four lists of `status` need + * because a record holds entities without limit. + * + * WHAT THAT BOUND COSTS, measured rather than assumed: the golden transcript pins one + * run that every write of the fixture was pinned to — 10 kinds, 30 facts — and its + * clause is 10 entries and about 210 characters (`cli.reads.golden.txt`, "the run that + * did something"). That is the worst shape a real record produces, and it wraps on a + * narrow terminal rather than truncating. The trade is deliberate: a cut here would be + * a second observable decision to document and to keep in step with the projection's + * order, in exchange for a line that is already the LONGEST one this file can word. + * + * "wrote nothing" IS SAID, and it is said even beside {@link runAgeSuffix}'s own + * "nothing recorded in it", which for an OPEN run is the same fact from the other axis. + * The two are left to agree rather than one being dropped: dropping this half when the + * other happens to be present would make the clause conditional on the run being open, + * which is the rule written in two places — and the case that needs it most is the + * ENDED run, where `runAgeSuffix` does not run at all and silence would be the only + * answer a reader got. + * + * THE SITE THIS CLAUSE DELIBERATELY DOES NOT REACH is `mnema usage`, which is the + * other reading in this package that lists runs (`presentation/usage.ts`). It was found + * by asking who reads `listRuns`, not by anybody's list, and it is left alone because + * it answers a different question: what a session COST, joined to the host's + * transcripts, in a fixed-width table whose closing statement says the numbers are not + * the record's. What a run wrote is the record's, and putting it in that table would + * mix the two halves the report exists to keep apart. + * + * Nothing here goes through `oneLine`: every value on this clause is the record's own + * — a kind is one of the catalog's literals and a count is a number — so there is no + * text an actor typed for a newline to hide in. That is the classification + * `tests/the-line-a-reading-words-is-one-line.test.ts` holds for this line. + */ +export function wroteSuffix(run: { readonly wrote: readonly WrittenInRun[] }): string { + if (run.wrote.length === 0) return ' · wrote nothing'; + return ` · wrote ${run.wrote.map((w) => `${w.count} ${w.kind}`).join(', ')}`; +} + /** * A duration in seconds as the two largest units that matter — `3d 4h`, `2h 14m`, * `41m 3s`, `9s`. Two and not three: the third digit never changes a decision, and diff --git a/packages/code/src/wiring/focus.ts b/packages/code/src/wiring/focus.ts index 210cb69d..465becdb 100644 --- a/packages/code/src/wiring/focus.ts +++ b/packages/code/src/wiring/focus.ts @@ -7,7 +7,7 @@ import type { Command } from 'commander'; import { asId, itemLine } from '../presentation/items.js'; -import { NO_RUNS_HINT, runAgeSuffix } from '../presentation/runs.js'; +import { NO_RUNS_HINT, runAgeSuffix, wroteSuffix } from '../presentation/runs.js'; import { here } from './context.js'; import { writeLines } from './io.js'; import { onOneLine } from './on-one-line.js'; @@ -58,6 +58,14 @@ export function registerFocus(program: Command, wiring: Wiring): Declared { // stays the rule a reader counts by. `thisSession` is NOT printed: a read // opens no run, so it is false in every line here, and a constant is noise // rather than honesty (`--json` carries it, being the faithful object). + // + // And WHAT each one wrote, from the same module, because this reading needs + // it for the reason the durations were added: what tells two leftover runs + // apart is what happened inside them, and until this clause a session that + // recorded a decision and one that recorded nothing at all differed only by + // a number of seconds. The clause is `wroteSuffix`'s and not this file's — + // `resume` and `status` print the same words through `lastRunPhrase`, and a + // second wording here is the drift that module exists to prevent. io.out( render( itemLine([ @@ -70,7 +78,8 @@ export function registerFocus(program: Command, wiring: Wiring): Declared { // and a collapse would eat the space this one opens with. onOneLine`${run.agent}` + (run.goal !== undefined ? onOneLine` — ${run.goal}` : '') + - runAgeSuffix(run), + runAgeSuffix(run) + + wroteSuffix(run), ]), ), ); diff --git a/packages/code/tests/cli-e2e.test.ts b/packages/code/tests/cli-e2e.test.ts index e7f201c3..732a0fbe 100644 --- a/packages/code/tests/cli-e2e.test.ts +++ b/packages/code/tests/cli-e2e.test.ts @@ -2522,6 +2522,73 @@ describe('mnema CLI — run (the session), end to end', () => { expect(s.err.join('\n')).toContain('Run `mnema init`'); }); + it('`resume` says what was written in the run, ended or open', async () => { + // The complaint this closes: every reading of a run reported the CONTAINER. Before + // the clause, a session that recorded three facts of two kinds and one that + // recorded nothing printed lines that differed only by a duration. + // + // The ENDED run is the case that matters most and the one `runAgeSuffix` cannot + // reach: it prints no age and no idleness, so without this clause a finished + // session — which is exactly what "where was I" lands on — said nothing at all + // about what happened in it. + const anchor = await initHere(); + const worked = await startRun('claude-code', 'a session that did things'); + process.env.MNEMA_RUN = worked.id; + await run(['task', 'one job'], capture().io); + await run(['task', 'another job'], capture().io); + await run(['memory', 'something worth keeping', '--scope', 'public'], capture().io); + delete process.env.MNEMA_RUN; + + // While it is OPEN: the clause rides beside the durations, not instead of them. + const open = capture(); + await run(['resume', '--actor', anchor], open.io); + expect(open.failed()).toBe(false); + // Two kinds per task, and that is the record being literal rather than a surprise: + // a task is BORN as a `task.created` and a `task.transitioned` into its initial + // state, so a session that opened two tasks wrote four facts. The two 2s also make + // the tie-break part of the claim — equal counts are ordered by the kind's own + // spelling, which is why `task.created` leads `task.transitioned`. + expect(open.out.join('\n')).toContain( + '· wrote 2 task.created, 2 task.transitioned, 1 memory.captured', + ); + expect(open.out.join('\n')).toMatch(/· open \d+[dhms]/); + + // And once it has ENDED, where the durations stop and this clause does not. + await run(['run', 'end', worked.id, '--which', 'claude-code'], capture().io); + const ended = capture(); + await run(['resume', '--actor', anchor], ended.io); + const line = ended.out.join('\n'); + expect(line).toContain('(ended)'); + expect(line).not.toMatch(/· open \d+[dhms]/); + expect(line).toContain('· wrote 2 task.created, 2 task.transitioned, 1 memory.captured'); + + // The VALUE, off `--json`, so the words above are not the only thing asserted: + // commonest kind first, and the count with it. + const j = capture(); + await run(['resume', '--actor', anchor, '--json'], j.io); + const parsed = JSON.parse(j.out.join('\n')) as { + lastRun: { wrote: Array<{ kind: string; count: number }> }; + }; + expect(parsed.lastRun.wrote).toEqual([ + { kind: 'task.created', count: 2 }, + { kind: 'task.transitioned', count: 2 }, + { kind: 'memory.captured', count: 1 }, + ]); + }); + + it('`resume` says a run wrote NOTHING rather than leaving the clause off', async () => { + // Distinguishable from "I do not know", which is what silence would claim. A run + // that opened and closed with no write in it is a real state — a session whose + // first write did not land — and the reading has to say so. + const anchor = await initHere(); + const empty = await startRun('claude-code', 'a session that did nothing'); + await run(['run', 'end', empty.id, '--which', 'claude-code'], capture().io); + + const c = capture(); + await run(['resume', '--actor', anchor], c.io); + expect(c.out.join('\n')).toContain('· wrote nothing'); + }); + it('`focus` says how long each open run has been open and how long since it recorded', async () => { // What makes a list of leftover runs readable. Two runs, one with a fact pinned to // it and one with none, and the difference is stated rather than left to a blank: diff --git a/packages/code/tests/mcp-e2e.test.ts b/packages/code/tests/mcp-e2e.test.ts index 0ce12141..1ba3da90 100644 --- a/packages/code/tests/mcp-e2e.test.ts +++ b/packages/code/tests/mcp-e2e.test.ts @@ -2564,6 +2564,41 @@ describe('MCP server — end to end over a real client', () => { await client.close(); }); + it('resume tells an agent WHAT was written in its run, over the real transport', async () => { + // The elo, asserted where it can actually break: the field is on the projection and + // the tool hands back a serialized object, so a case that called the derivation + // directly would stay green with nothing reaching the wire. This one reads the TEXT + // the server sent and parses that. + const project = makeProject('proj'); + const { server } = buildMcpServer({ env, log: () => {} }); + const client = await connectClient(server, [pathToFileURL(project).href]); + + // Three writes of two kinds through the server, so the tally is a tally and not a + // one-entry list that any grouping would produce by accident. + await client.callTool({ name: 'create_task', arguments: { title: 'one job' } }); + await client.callTool({ name: 'create_task', arguments: { title: 'another job' } }); + await client.callTool({ + name: 'capture_memory', + arguments: { content: 'worth keeping', scope: 'public' }, + }); + + const resumeRes = await client.callTool({ name: 'resume' }); + const resume = JSON.parse(textOf(resumeRes)) as { + lastRun: { wrote: { kind: string; count: number }[] } | null; + }; + // A task is born as a pair (`task.created` + the transition into its initial + // state), so two tasks are four facts across two kinds — and the two equal counts + // are what put the tie-break in the claim: equal counts order by the kind's own + // spelling. + expect(resume.lastRun?.wrote).toEqual([ + { kind: 'task.created', count: 2 }, + { kind: 'task.transitioned', count: 2 }, + { kind: 'memory.captured', count: 1 }, + ]); + + await client.close(); + }); + it('focus / resume / next_actions read the session context over the real transport', async () => { const project = makeProject('proj'); const { server } = buildMcpServer({ env, log: () => {} }); diff --git a/packages/code/tests/the-line-a-reading-words-is-one-line.test.ts b/packages/code/tests/the-line-a-reading-words-is-one-line.test.ts index 027acca2..e0af3ba4 100644 --- a/packages/code/tests/the-line-a-reading-words-is-one-line.test.ts +++ b/packages/code/tests/the-line-a-reading-words-is-one-line.test.ts @@ -772,6 +772,10 @@ const CLASSIFIED: Readonly> = verdict: 'composed', why: 'the idle clause composed above, on the same terms', }, + "runs.ts « · wrote {}» run.wrote.map((w) => `${w.count} ${w.kind}`).join(', ') #1": { + verdict: 'minted', + why: 'what the run wrote — each entry a count and one of the catalog’s own kind literals, neither of them anything an actor typed', + }, 'runs.ts «{}{}» count #1': { verdict: 'minted', why: 'how many of a unit the duration holds — a number', @@ -1294,7 +1298,7 @@ describe('every value this layer puts on a line is classified', () => { expect(FOUND.machinery).toContain('line.ts'); expect(FOUND.machinery).toContain('width.ts'); expect(FOUND.builders.length).toBeGreaterThan(10); - expect(FOUND.sites.length).toBe(200); + expect(FOUND.sites.length).toBe(201); }); it('reads the verdict off the source rather than believing the table', () => { @@ -1316,7 +1320,7 @@ describe('every value this layer puts on a line is classified', () => { const verdicts = Object.values(CLASSIFIED).map((said) => said.verdict); const count = (verdict: Verdict): number => verdicts.filter((said) => said === verdict).length; expect(count('collapsed')).toBe(47); - expect(count('minted')).toBe(116); + expect(count('minted')).toBe(117); expect(count('composed')).toBe(37); expect(FOUND.sites.filter((site) => /\boneLine\b/.test(site.expression))).toHaveLength(47); }); diff --git a/packages/copilot/README.md b/packages/copilot/README.md index c1a8db30..426da643 100644 --- a/packages/copilot/README.md +++ b/packages/copilot/README.md @@ -159,6 +159,7 @@ const opening = bootstrap([cache], { }); const lastGoal = opening.resume.lastRun?.goal; // "ship the parser" const openFor = opening.resume.lastRun?.ageSeconds; // how long it has been open +const didWrite = opening.resume.lastRun?.wrote; // WHAT went in: [{ kind, count }, …] const firstJob = opening.work[0]; // the freshest live task — a NAME const more = opening.workTotal > opening.work.length; // was the list cut? const patterns = opening.skills.map((s) => s.name); // names only — one line each diff --git a/packages/copilot/src/context/bootstrap.ts b/packages/copilot/src/context/bootstrap.ts index 6a31b632..09f73a61 100644 --- a/packages/copilot/src/context/bootstrap.ts +++ b/packages/copilot/src/context/bootstrap.ts @@ -180,7 +180,10 @@ * one per list, because a slice written twice is a rule written twice. * * What still makes the rest lean is the filtering: - * - the actor's focus comes from `resume`, already scoped to the actor; + * - the actor's focus comes from `resume`, already scoped to the actor — and the run + * it names now carries WHAT WAS WRITTEN IN IT (`core`'s `RunProjection.wrote`), + * which is the same NAMES-not-bodies economy read off a session instead of a list: + * a tally per kind, bounded by the catalog, never the facts themselves; * - the work list carries ONLY live tasks — those a reader still has something to * do about, which is `advancing` or `stalled` (see {@link liveWork}) — most * recently touched first, so the freshest work leads and the cut falls on the diff --git a/packages/copilot/src/context/focus.test.ts b/packages/copilot/src/context/focus.test.ts index e952eca7..99a4b710 100644 --- a/packages/copilot/src/context/focus.test.ts +++ b/packages/copilot/src/context/focus.test.ts @@ -3,7 +3,9 @@ import { afterEach, describe, expect, it } from 'vitest'; import { asking, type Bench, + birthSkill, capture, + consultSkill, endRun, makeBench, startRun, @@ -353,6 +355,32 @@ describe('what a reported run says about its age and its idleness', () => { } }); + it('carries what the run wrote, as the projection folded it', () => { + // The case the one below cannot make: a run with NOTHING in it has `wrote: []` on + // both sides, so a comparison over it would hold with the field dropped entirely. + // Here the run recorded three facts of two kinds, and the reported run has to say + // so — the complaint this answers is that a reported run described the container. + bench = makeBench(); + startRun(bench, 'run-1', { agent: 'claude', goal: 'the goal' }); + birthSkill(bench, 'skill-1', 'Small PRs', 'adopted'); + capture(bench, 'mem-1', 'one', 'run-1'); + capture(bench, 'mem-2', 'two', 'run-1'); + consultSkill(bench, 'skill-1', { run: 'run-1' }); + const cache = bench.cache(); + try { + const [reported] = focus([cache], asking(bench.who)).openRuns; + expect(reported?.wrote).toEqual([ + { kind: 'memory.captured', count: 2 }, + { kind: 'skill.consulted', count: 1 }, + ]); + // And it is the PROJECTION's answer, passed through: this module derives the + // three asker-relative fields and must not recompute this one. + expect(reported?.wrote).toEqual(cache.getRun('run-1')?.wrote); + } finally { + cache.close(); + } + }); + it('carries the projection through untouched — the added fields only add', () => { bench = makeBench(); startRun(bench, 'run-1', { agent: 'claude', goal: 'the goal' }); diff --git a/packages/copilot/src/context/focus.ts b/packages/copilot/src/context/focus.ts index a839d132..65f3eb48 100644 --- a/packages/copilot/src/context/focus.ts +++ b/packages/copilot/src/context/focus.ts @@ -42,6 +42,19 @@ * it. The order is a property of the CONTENT (`startedAt`, then id), so adding a * tree to the list never reshuffles what an asker sees. * + * WHAT A REPORTED RUN NOW SAYS THAT IT DID NOT, and the sentence it falsified was + * this module's shape rather than its prose. A measurement over a real record put the + * complaint as one line — every read that reports a run reports the CONTAINER, never + * what was put in it — and it was true of the whole of {@link ReportedRun}: `id`, + * `agent`, `who`, `goal`, `outcome`, `open`, `startedAt`, `endedAt`, `lastFactAt`, and + * the three fields added here. `lastFactAt` says WHEN the last fact landed and nothing + * can be inferred from it about WHAT landed, so a session that recorded a decision and + * one that recorded a passing note reported identically. The fold answers it now + * (`core`'s `RunProjection.wrote`), so every run reported through here carries the + * tally of what was written in it — this file adds nothing to it, because it is a fact + * of the record and not of the asker's position, which is the line the three fields + * below are on the other side of. + * * WHAT IT DELIBERATELY DOES NOT DO: decide whether an open run is ABANDONED. The * record holds no fact about a process — no pid, no boot id — so "old" and "idle" * are what an asker can be told, and "dead" is not. A live agent sitting idle is @@ -98,6 +111,13 @@ export interface ActorScope extends AskerContext { * record to produce them. That is deliberate: the defect they answer is a session's * life cycle, and a permanent field on a signed, append-only event is the wrong * place for a detail whose useful life is minutes. + * + * WHAT IS NOT ADDED HERE is `wrote` — what the run recorded, by kind. It arrives on + * the projection this extends and is passed through untouched, because it is a + * property of the RECORD: two askers reading one run read the same tally, whereas the + * three fields below are answers only this asker's position can give. The same test + * that proves the three are attached proves this one is not recomputed + * (`focus.test.ts` — "carries what the run wrote, as the projection folded it"). */ export interface ReportedRun extends RunProjection { /** diff --git a/packages/copilot/tests/readme-example.test.ts b/packages/copilot/tests/readme-example.test.ts index 24776c1a..ad077582 100644 --- a/packages/copilot/tests/readme-example.test.ts +++ b/packages/copilot/tests/readme-example.test.ts @@ -17,6 +17,7 @@ import { birthDecision, birthSkill, birthTask, + consultSkill, makeBench, moveDecision, moveTask, @@ -34,6 +35,10 @@ describe('README example', () => { moveTask(bench, task, 'DRAFT', 'READY', 'submit'); moveTask(bench, task, 'READY', 'IN_PROGRESS', 'start'); birthSkill(bench, 'skill-3', 'Small PRs', 'adopted'); + // Two facts PINNED to the run, so `wrote` below is a tally and not an empty + // array: an example whose every field is the empty case documents nothing. + consultSkill(bench, 'skill-3', { run: 'run-42' }); + consultSkill(bench, 'skill-3', { run: 'run-42' }); birthDecision(bench, 'dec-5', 'Hand-rolled arithmetic'); moveDecision(bench, 'dec-5', 'proposed', 'accepted', 'accept'); const cache = bench.cache(); @@ -52,6 +57,7 @@ describe('README example', () => { }); const lastGoal = opening.resume.lastRun?.goal; // "ship the parser" const openFor = opening.resume.lastRun?.ageSeconds; // how long it has been open + const didWrite = opening.resume.lastRun?.wrote; // WHAT went in: [{ kind, count }, …] const firstJob = opening.work[0]; // the freshest live task — a NAME const more = opening.workTotal > opening.work.length; // was the list cut? const patterns = opening.skills.map((s) => s.name); // names only @@ -95,6 +101,7 @@ describe('README example', () => { expect(lastGoal).toBe('ship the parser'); expect(openFor).toBeTypeOf('number'); + expect(didWrite).toEqual([{ kind: 'skill.consulted', count: 2 }]); expect(firstJob?.id).toBe('task-7'); // One task in the record, so nothing was cut — the example's own claim. expect(more).toBe(false); diff --git a/packages/core/src/db/schema.ts b/packages/core/src/db/schema.ts index 2481ec56..e51661e0 100644 --- a/packages/core/src/db/schema.ts +++ b/packages/core/src/db/schema.ts @@ -77,7 +77,17 @@ CREATE TABLE IF NOT EXISTS runs ( started_at TEXT NOT NULL, ended_at TEXT, -- 'at' of the most recent event PINNED to this run; NULL when it has none. - last_fact_at TEXT + last_fact_at TEXT, + -- WHAT was written in the run: the tally per event kind, JSON-encoded as the + -- ordered array the projection produces ([] when the run wrote nothing, never + -- NULL — absence would say "unknown", and the fold always knows). + -- + -- JSON in a column rather than a table of (run, kind, count) rows, and the + -- reason is the query nobody makes: this tally is only ever read BESIDE its run, + -- never filtered or grouped by kind, so a relational shape would charge every + -- read of a run for a join that serves no reader. The refs table sets the + -- precedent for a JSON column here, and it is read the same way when it must be. + wrote TEXT NOT NULL ) STRICT; CREATE INDEX IF NOT EXISTS idx_runs_open ON runs (open); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f0edd091..d2d2ed8f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -129,7 +129,7 @@ export { tallyAuthorship, walkReferences, } from './projections/reference-store.js'; -export { projectRuns, type RunProjection } from './projections/run.js'; +export { projectRuns, type RunProjection, type WrittenInRun } from './projections/run.js'; export { getRun, listOpenRuns, listRuns } from './projections/run-store.js'; export { compareSearchHits, diff --git a/packages/core/src/projections/cache.test.ts b/packages/core/src/projections/cache.test.ts index f306a8b1..b3c5bae2 100644 --- a/packages/core/src/projections/cache.test.ts +++ b/packages/core/src/projections/cache.test.ts @@ -203,6 +203,8 @@ describe('ProjectionCache — runs', () => { goal: 'ship', open: true, startedAt: at(0), + // Through the cache too, and empty because this run holds only its own birth. + wrote: [], }); expect(cache.listOpenRuns().map((r) => r.id)).toEqual(['r-1']); diff --git a/packages/core/src/projections/fed-by.ts b/packages/core/src/projections/fed-by.ts index 3bde9476..646648c2 100644 --- a/packages/core/src/projections/fed-by.ts +++ b/packages/core/src/projections/fed-by.ts @@ -20,10 +20,11 @@ * is FALSE, and this is where it is false: `channel.asked` and * `channel.served` are read by `audit_accountability`, `audit_refs` and * `audit_timeline` through this index. - * - `runs` reads EVERY kind too, and by one field rather than by a `kind` test: + * - `runs` reads EVERY kind too, and by the ENVELOPE rather than by a `kind` test: * `lastFactAt` is the latest `at` of any event whose envelope PINS it to the - * run (`run.ts`). A fold that reads the envelope reads every kind, whatever - * its `kind` switch says. + * run, and `wrote` tallies those same events by kind (`run.ts`). A fold that + * reads the envelope reads every kind, whatever its `kind` switch says — and + * the second field made that literal, since the tally's own keys ARE the kinds. * * So no kind feeds nothing, and a design that had skipped work for one would have * served a stale index and a stale idleness. What is left is still worth the diff --git a/packages/core/src/projections/run-store.test.ts b/packages/core/src/projections/run-store.test.ts index 9b90391b..978e7b54 100644 --- a/packages/core/src/projections/run-store.test.ts +++ b/packages/core/src/projections/run-store.test.ts @@ -24,7 +24,7 @@ * sides from drifting together. */ -import { type CatalogEvent, memoryCaptured, runEnded, runStarted } from '@mnema/chain'; +import { type CatalogEvent, memoryCaptured, runEnded, runStarted, taskCreated } from '@mnema/chain'; import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ensureSchema } from '../db/schema.js'; @@ -115,6 +115,29 @@ describe('run-store — what goes in comes back out', () => { expect(getRun(db, 'idle')).not.toHaveProperty('lastFactAt'); }); + it('carries WHAT was written across the boundary, tally and order alike', () => { + // A round-trip against the fold would pass with `[]` on both sides, which is the + // shape that proves nothing. So the VALUE is asserted here, whole: the counts, and + // the order the fold decided, read back out of the column. + const events: CatalogEvent[] = [ + runStarted(env('busy', 0), { agent: 'claude' }), + memoryCaptured(inRun('m-1', 1, 'busy'), { content: 'one' }), + taskCreated(inRun('t-1', 2, 'busy'), { title: 'a job' }), + memoryCaptured(inRun('m-2', 3, 'busy'), { content: 'two' }), + runStarted(env('idle', 4), { agent: 'claude' }), + ]; + materializeRuns(db, fold(events)); + + expect(getRun(db, 'busy')?.wrote).toEqual([ + { kind: 'memory.captured', count: 2 }, + { kind: 'task.created', count: 1 }, + ]); + // And the empty tally comes back EMPTY rather than absent: the column is NOT NULL, + // so a run that wrote nothing crosses as `[]` and stays an answer on the far side. + expect(getRun(db, 'idle')).toHaveProperty('wrote'); + expect(getRun(db, 'idle')?.wrote).toEqual([]); + }); + it('stores `open` as the integer a STRICT table can hold, and agrees with itself', () => { const events: CatalogEvent[] = [ runStarted(env('a-closed', 0), { agent: 'claude' }), diff --git a/packages/core/src/projections/run-store.ts b/packages/core/src/projections/run-store.ts index 67615435..e7876f1d 100644 --- a/packages/core/src/projections/run-store.ts +++ b/packages/core/src/projections/run-store.ts @@ -2,14 +2,21 @@ * Persisting and querying the run projection in SQLite. * * The pure fold ({@link projectRuns}) produces run state; this module writes it - * to the `runs` table and reads it back. Two shape mismatches are handled at + * to the `runs` table and reads it back. Three shape mismatches are handled at * this boundary: the projection's optional fields (goal, outcome, endedAt, - * lastFactAt) are bound as SQL NULL when absent, and the `open` boolean is stored - * as 0/1 because a STRICT table has no boolean type. + * lastFactAt) are bound as SQL NULL when absent, the `open` boolean is stored + * as 0/1 because a STRICT table has no boolean type, and `wrote` — an array, which + * no column type holds — crosses as JSON. + * + * `wrote` is NOT an optional handled by NULL, and that asymmetry is the projection's + * own: an empty tally is the ANSWER for a run that wrote nothing, so it crosses as + * `[]` and comes back as `[]`. Binding it as NULL would turn a fact the fold knows + * into an absence a reader has to guess at, which is the distinction the field exists + * to make. */ import type { SqliteDatabase } from '../db/sqlite.js'; -import type { RunProjection } from './run.js'; +import type { RunProjection, WrittenInRun } from './run.js'; /** The `runs` row shape as stored. */ interface RunRow { @@ -22,6 +29,7 @@ interface RunRow { readonly started_at: string; readonly ended_at: string | null; readonly last_fact_at: string | null; + readonly wrote: string; } /** The bound-parameter shape: every column present, optionals as null. */ @@ -35,6 +43,7 @@ interface RunParams { readonly startedAt: string; readonly endedAt: string | null; readonly lastFactAt: string | null; + readonly wrote: string; } /** @@ -44,8 +53,8 @@ interface RunParams { */ export function materializeRuns(db: SqliteDatabase, runs: Iterable): void { const insert = db.prepare( - `INSERT INTO runs (id, agent, who, goal, outcome, open, started_at, ended_at, last_fact_at) - VALUES (@id, @agent, @who, @goal, @outcome, @open, @startedAt, @endedAt, @lastFactAt)`, + `INSERT INTO runs (id, agent, who, goal, outcome, open, started_at, ended_at, last_fact_at, wrote) + VALUES (@id, @agent, @who, @goal, @outcome, @open, @startedAt, @endedAt, @lastFactAt, @wrote)`, ); for (const run of runs) { insert.run(toParams(run)); @@ -82,6 +91,10 @@ function toParams(run: RunProjection): RunParams { startedAt: run.startedAt, endedAt: run.endedAt ?? null, lastFactAt: run.lastFactAt ?? null, + // The array as written by the fold, order included: the fold's order is total + // (see `orderedWrites`), so the text is a function of the events alone and two + // caches over one chain hold the same bytes here. + wrote: JSON.stringify(run.wrote), }; } @@ -92,6 +105,11 @@ function toProjection(row: RunRow): RunProjection { who: row.who, open: row.open === 1, startedAt: row.started_at, + // Not defended against malformed text: this column is written by `toParams` in + // this same file and by nothing else, and the cache is dropped and replayed + // rather than migrated — so a row this cannot parse is a bug in the pair above, + // not input a reader should be quietly recovering from. + wrote: JSON.parse(row.wrote) as readonly WrittenInRun[], }; if (row.goal !== null) projection.goal = row.goal; if (row.outcome !== null) projection.outcome = row.outcome; diff --git a/packages/core/src/projections/run.test.ts b/packages/core/src/projections/run.test.ts index ef5a58e8..bad16098 100644 --- a/packages/core/src/projections/run.test.ts +++ b/packages/core/src/projections/run.test.ts @@ -1,4 +1,11 @@ -import { type CatalogEvent, memoryCaptured, runEnded, runStarted } from '@mnema/chain'; +import { + type CatalogEvent, + memoryCaptured, + observationRecorded, + runEnded, + runStarted, + taskCreated, +} from '@mnema/chain'; import { describe, expect, it } from 'vitest'; import { projectRuns } from './run.js'; @@ -22,6 +29,10 @@ describe('projectRuns — the reader rule', () => { goal: 'ship the thing', open: true, startedAt: at(0), + // Present and EMPTY on a run that has written nothing — the whole point of the + // field being non-optional. This is an exact-shape assertion, so it is also what + // fails if the fold ever stops attaching it. + wrote: [], }); }); @@ -143,3 +154,96 @@ describe('projectRuns — when the run last did something', () => { expect(projectRuns(events).has('r-elsewhere')).toBe(false); }); }); + +describe('projectRuns — WHAT was written in the run', () => { + it('tallies the facts pinned to it, per kind, and says how many of each', () => { + // The complaint this answers, in one line: the reads reported the CONTAINER. A run + // that recorded three different sorts of fact and one that recorded a single note + // reported identically, because `lastFactAt` says WHEN and can never say WHAT. + const events: CatalogEvent[] = [ + runStarted(env('r-1', 0), { agent: 'claude' }), + memoryCaptured(inRun('m-1', 1, 'r-1'), { content: 'first' }), + taskCreated(inRun('t-1', 2, 'r-1'), { title: 'a job' }), + memoryCaptured(inRun('m-2', 3, 'r-1'), { content: 'second' }), + observationRecorded(inRun('o-1', 4, 'r-1'), { about: 't-1', topic: 'why', text: 'a note' }), + memoryCaptured(inRun('m-3', 5, 'r-1'), { content: 'third' }), + ]; + // The VALUE, whole: commonest kind first, and the two that tie broken by the kind's + // own spelling. Asserting the array entire is what makes the order part of the + // claim rather than something a reader may or may not get. + expect(projectRuns(events).get('r-1')?.wrote).toEqual([ + { kind: 'memory.captured', count: 3 }, + { kind: 'observation.recorded', count: 1 }, + { kind: 'task.created', count: 1 }, + ]); + }); + + it('answers EMPTY for a run that wrote nothing — never absent', () => { + // Distinguishable from "I do not know", which is what an absent field claims. The + // fold saw the whole stream, so it knows the answer is none, and a zero-length list + // is how it says so. `lastFactAt` beside it IS absent in this same case, and the + // pair of assertions is the difference stated rather than described. + const events = [runStarted(env('r-1', 0), { agent: 'claude' })]; + const run = projectRuns(events).get('r-1'); + expect(run).toHaveProperty('wrote'); + expect(run?.wrote).toEqual([]); + expect(run).not.toHaveProperty('lastFactAt'); + }); + + it('does not count the run’s OWN birth or end as something written in it', () => { + // Neither carries a `run` — their subject IS the run — so a session's own + // bookkeeping is not work it did. Same rule `lastFactAt` follows, off the same slot. + const events: CatalogEvent[] = [ + runStarted(env('r-1', 0), { agent: 'claude', goal: 'g' }), + runEnded(env('r-1', 5), { outcome: 'o' }), + ]; + expect(projectRuns(events).get('r-1')?.wrote).toEqual([]); + }); + + it('keeps each run’s own tally apart', () => { + const events: CatalogEvent[] = [ + runStarted(env('r-1', 0), { agent: 'claude' }), + runStarted(env('r-2', 1), { agent: 'cursor' }), + memoryCaptured(inRun('m-1', 2, 'r-1'), { content: 'in one' }), + taskCreated(inRun('t-1', 3, 'r-2'), { title: 'in the other' }), + taskCreated(inRun('t-2', 4, 'r-2'), { title: 'in the other again' }), + ]; + const runs = projectRuns(events); + expect(runs.get('r-1')?.wrote).toEqual([{ kind: 'memory.captured', count: 1 }]); + expect(runs.get('r-2')?.wrote).toEqual([{ kind: 'task.created', count: 2 }]); + }); + + it('orders the tally the same way whatever order the kinds arrive in', () => { + // The order is stored and compared (`run-store.ts` round-trips the array, and the + // incremental fold is asserted byte-identical to a full replay), so it has to be a + // function of the counts and the names alone. Two streams with the SAME tally and + // the opposite arrival order must fold to the same array. + const forward: CatalogEvent[] = [ + runStarted(env('r-1', 0), { agent: 'claude' }), + taskCreated(inRun('t-1', 1, 'r-1'), { title: 'one' }), + memoryCaptured(inRun('m-1', 2, 'r-1'), { content: 'two' }), + ]; + const backward: CatalogEvent[] = [ + runStarted(env('r-1', 0), { agent: 'claude' }), + memoryCaptured(inRun('m-1', 1, 'r-1'), { content: 'two' }), + taskCreated(inRun('t-1', 2, 'r-1'), { title: 'one' }), + ]; + // NOT vacuous: both really did record one of each, so the tie-break is the only + // thing deciding the order here. + expect(projectRuns(forward).get('r-1')?.wrote).toEqual([ + { kind: 'memory.captured', count: 1 }, + { kind: 'task.created', count: 1 }, + ]); + expect(projectRuns(backward).get('r-1')?.wrote).toEqual(projectRuns(forward).get('r-1')?.wrote); + }); + + it('tallies a fact pinned to a run this stream never opened — and drops the run', () => { + // The rule the module already states, met by the new field: an accumulator is + // created for any `run` seen on an envelope, and one with no birth is not projected. + // The tally must not resurrect it. + const events: CatalogEvent[] = [ + memoryCaptured(inRun('m-1', 1, 'elsewhere'), { content: 'another tree opened it' }), + ]; + expect(projectRuns(events).has('elsewhere')).toBe(false); + }); +}); diff --git a/packages/core/src/projections/run.ts b/packages/core/src/projections/run.ts index e365fc7c..74eaf737 100644 --- a/packages/core/src/projections/run.ts +++ b/packages/core/src/projections/run.ts @@ -29,6 +29,24 @@ * its run on the first write, so a run with no fact pinned to it is one whose first * write did not land. * + * AND WHAT WAS WRITTEN IN IT ({@link RunProjection.wrote}), off the SAME pass and the + * same envelope slot. The two fields are one question asked along its two axes — an + * instant says WHEN the last fact landed and can never say WHAT landed — and the + * measurement that asked for the second one put it plainly: every read that reports a + * run reported the CONTAINER, never what was put in it. A reader shown `last recorded + * 12s ago` learns that a session is alive and nothing whatever about what it did. + * + * It is a COUNT PER KIND and not a list of the facts, and that is a divergence from + * the naming convention the opening context states for its own lists (names and ids, + * cut, with the total beside the cut — see `copilot`'s `bootstrap.ts`). The reason is + * the ceiling: those lists are over entities, which a record holds without limit, so + * they are cut and the cut declares itself; this one is over the event CATALOG, which + * is a closed union, so its length is bounded by the number of kinds whatever the run + * did. A bounded answer needs no cut, and a cut it does not need would be a second + * observable decision to document for nothing. What a caller loses is the id of each + * fact — which is the entity's own history, and `audit_timeline` answers it per + * entity, which is the question anyone actually asks next. + * * SIGNATURE CAVEAT (shared by every projection). A projection reflects the facts * as written; it does not itself attest that they are signature-covered. The * fields it reads — `who` above all — carry only the assurance of the chain @@ -39,7 +57,21 @@ * proof grade; the read model is the queryable state, not the attestation. */ -import type { CatalogEvent } from '@mnema/chain'; +import type { CatalogEvent, EventKind } from '@mnema/chain'; + +/** + * One sort of fact written in a run, and how many of it there were. + * + * The `kind` is the catalog's own discriminator, not a second vocabulary: it is what + * `search` filters by and what a tool description already names, so a reader that + * meets `decision.recorded` here can spell it at the next read without translating. + */ +export interface WrittenInRun { + /** The event kind, as the catalog spells it. */ + readonly kind: EventKind; + /** How many facts of that kind were pinned to the run. At least 1 — a zero is no row. */ + readonly count: number; +} /** Current projected state of one run. */ export interface RunProjection { @@ -73,6 +105,35 @@ export interface RunProjection { * reader has and what it must be told (see the surfaces that report idleness). */ readonly lastFactAt?: string; + /** + * WHAT was written in this run: one entry per kind of fact pinned to it, with how + * many of that kind there were. + * + * ALWAYS PRESENT, and EMPTY is the answer for a run that wrote nothing — never + * absent. That is the one deliberate difference from {@link RunProjection.lastFactAt} + * beside it, which is absent in exactly the same case: an absent field is read as + * "this reader does not know", and the two claims are not the same claim. Here the + * fold DOES know — it saw every event of the stream — so it says so with a list + * whose length is zero. + * + * Read off the same envelope slot as `lastFactAt` (`run: `), so it counts + * the WORK done in the session and not the session's own bookkeeping: neither + * `run.started` nor `run.ended` carries a `run`, so a run that only opened and + * closed reports `[]`. + * + * Ordered by `count` DESCENDING, ties broken by `kind` ascending — a total order, + * so the same events always fold to the same array and a caller may compare two + * projections byte for byte. Commonest first because a reader scanning a session + * asks what it mostly DID; the tie-break is the kind's own spelling because nothing + * about two equal counts ranks one above the other, and a tie left unbroken would + * hand the order to whichever kind the stream happened to reach first. + * + * Not cut, and it needs no total beside it: the entries are over the event catalog, + * which is a closed union, so the length is bounded by the number of kinds however + * long the run ran (see the module doc for why the opening context's cut convention + * does not carry here). + */ + readonly wrote: readonly WrittenInRun[]; } /** Mutable accumulator; existence comes from `started`, closure from `ended`. */ @@ -84,6 +145,8 @@ interface RunAccumulator { outcome?: string; endedAt?: string; lastFactAt?: string; + /** How many facts of each kind were pinned to the run; ordered on the way out. */ + wrote: Map; } /** @@ -118,6 +181,10 @@ export function projectRuns(events: readonly CatalogEvent[]): Map = { -readonly [K in keyof T]: T[K] }; function getOrInit(acc: Map, id: string): RunAccumulator { let entry = acc.get(id); if (entry === undefined) { - entry = {}; + entry = { wrote: new Map() }; acc.set(id, entry); } return entry; } + +/** + * The tally as the array a caller reads: commonest kind first, ties by the kind's own + * spelling. + * + * The tie-break is what makes this a TOTAL order rather than nearly one, and that + * matters beyond neatness: the array is stored and compared (`run-store.ts` round-trips + * it, and `advance.test.ts` asserts an incremental fold writes the same bytes as a full + * replay), so an order that depended on which kind the stream reached first would make + * two equal records disagree. + */ +function orderedWrites(tally: Map): readonly WrittenInRun[] { + return [...tally] + .map(([kind, count]) => ({ kind, count })) + .sort((a, b) => b.count - a.count || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0)); +} From db8188934ec79a7238cfc41ecfd9c7010e05074a Mon Sep 17 00:00:00 2001 From: Felipe Sauer <120697114+felipesauer@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:49:29 -0400 Subject: [PATCH 2/3] Hold the two sites the mutation battery found blind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The battery lit every guard of the fold and the store, and left two rows at zero. `focus` prints the clause and nothing held it: the only fixture that reached that line was a run that had written nothing, so handing the wording function an empty tally changed no byte anywhere. The case now runs two open runs — one that recorded work, one that recorded none — and reads both lines. The MCP contract sentence was the other: an agent learns a field exists from the tool description and nothing pinned it, so falsifying it left the suite green. The new case reads `tools/list` and states the rule as an implication — a tool that describes the asker-relative fields of a run is one whose answer carries a run, so it must describe `wrote` too. A fifth tool appending the same contract is covered the day it is written. --- packages/code/tests/cli-e2e.test.ts | 32 +++++++++++++++++++++++++++ packages/code/tests/mcp-e2e.test.ts | 34 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/packages/code/tests/cli-e2e.test.ts b/packages/code/tests/cli-e2e.test.ts index 732a0fbe..d95946ea 100644 --- a/packages/code/tests/cli-e2e.test.ts +++ b/packages/code/tests/cli-e2e.test.ts @@ -2589,6 +2589,38 @@ describe('mnema CLI — run (the session), end to end', () => { expect(c.out.join('\n')).toContain('· wrote nothing'); }); + it('`focus` says what each open run has written, not only how long it has been open', async () => { + // The hole a mutation battery found: `focus` prints the clause and NOTHING held it. + // Handing the wording function a run that wrote nothing left every case green, + // because the only fixture that reached this line was a run that had written + // nothing anyway — a guard blind for want of a value, not for want of a case. + // + // Two runs, one that recorded work and one that recorded none, so the line tells + // them apart on the axis this clause exists for. + const anchor = await initHere(); + const worked = await startRun('claude-code', 'with work in it'); + process.env.MNEMA_RUN = worked.id; + // A TASK and not a memory: routing sends a memory to the private tree while the run + // stays in the committed one, and a projection is per tree — so the pair would show + // a run that wrote nothing while something was written. (The same limit the + // idleness case below is built around.) + await run(['task', 'a job done in that session'], capture().io); + delete process.env.MNEMA_RUN; + const empty = await startRun('other-agent', 'with nothing in it'); + + const f = capture(); + await run(['focus', '--actor', anchor], f.io); + expect(f.failed()).toBe(false); + const lines = f.out.join('\n').split('\n'); + const forWorked = lines.find((l) => l.includes(worked.id)) ?? ''; + const forEmpty = lines.find((l) => l.includes(empty.id)) ?? ''; + // A task is born as a pair — the creation and the move into its initial state. + expect(forWorked).toContain('· wrote 1 task.created, 1 task.transitioned'); + expect(forEmpty).toContain('· wrote nothing'); + // Still ONE line per run: the clause rides the run's own line, like the durations. + expect(lines).toHaveLength(3); + }); + it('`focus` says how long each open run has been open and how long since it recorded', async () => { // What makes a list of leftover runs readable. Two runs, one with a fact pinned to // it and one with none, and the difference is stated rather than left to a blank: diff --git a/packages/code/tests/mcp-e2e.test.ts b/packages/code/tests/mcp-e2e.test.ts index 1ba3da90..7c639ebe 100644 --- a/packages/code/tests/mcp-e2e.test.ts +++ b/packages/code/tests/mcp-e2e.test.ts @@ -2599,6 +2599,40 @@ describe('MCP server — end to end over a real client', () => { await client.close(); }); + it('every tool that describes a reported run describes what it WROTE', async () => { + // The prose an agent reads is the only place it learns a field exists, and a + // mutation battery found nothing holding it: falsifying the sentence left the whole + // suite green. This is that hole closed, and it is written over `tools/list` — what + // the server actually serves — rather than over the constant in the source. + // + // The rule is stated as an implication rather than a list of four tool names: any + // tool that describes the asker-relative fields of a run is a tool whose answer + // carries a run, so a fifth one appending the same contract is covered the day it + // is written, and a contract split in two is caught rather than half-kept. + const project = makeProject('proj'); + const { server } = buildMcpServer({ env, log: () => {} }); + const client = await connectClient(server, [pathToFileURL(project).href]); + + const { tools } = await client.listTools(); + const describingARun = tools.filter((t) => (t.description ?? '').includes('`thisSession`')); + // NOT vacuous: there really are tools of this shape, and if the phrase this filter + // keys on is reworded the count goes to zero and this line says so. + expect(describingARun.map((t) => t.name).sort()).toEqual([ + 'bootstrap', + 'focus', + 'guard', + 'resume', + ]); + for (const tool of describingARun) { + expect(tool.description, `${tool.name} must say what a run wrote`).toContain('`wrote`'); + expect(tool.description, `${tool.name} must say what an EMPTY tally means`).toContain( + 'An EMPTY array means the run', + ); + } + + await client.close(); + }); + it('focus / resume / next_actions read the session context over the real transport', async () => { const project = makeProject('proj'); const { server } = buildMcpServer({ env, log: () => {} }); From 23f596a2d508fb94c6b8ebc8ef5784c4081bcc36 Mon Sep 17 00:00:00 2001 From: Felipe Sauer <120697114+felipesauer@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:52:36 -0400 Subject: [PATCH 3/3] The contract's claim is asserted, not just the field's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool description that names `wrote` while saying it is on NO run reported is worse than one that omits it, and the guard could not tell the two apart: it asserted that the field is mentioned. That mutation was the only row of the battery still reading zero once the instrument was fixed. The claim is asserted now — the scope, the name, and what an empty tally means — and the battery carries one row per claim, because a guard over one of three is not a guard over the other two. --- packages/code/tests/mcp-e2e.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/code/tests/mcp-e2e.test.ts b/packages/code/tests/mcp-e2e.test.ts index 7c639ebe..7bfce137 100644 --- a/packages/code/tests/mcp-e2e.test.ts +++ b/packages/code/tests/mcp-e2e.test.ts @@ -2623,8 +2623,16 @@ describe('MCP server — end to end over a real client', () => { 'guard', 'resume', ]); + // THREE THINGS, not one, and the third is here because a mutation showed the first + // two were not enough: falsifying the SCOPE of the sentence — "EVERY run reported" + // turned into "NO run reported" — left both of the others intact and the suite + // green. A description that mentions a field while lying about when it is there is + // worse than one that omits it, so the claim itself is asserted, not just the name. for (const tool of describingARun) { expect(tool.description, `${tool.name} must say what a run wrote`).toContain('`wrote`'); + expect(tool.description, `${tool.name} must say it is on EVERY run reported`).toContain( + 'EVERY run reported — open or ', + ); expect(tool.description, `${tool.name} must say what an EMPTY tally means`).toContain( 'An EMPTY array means the run', );