From c8b6eacc53f979fc788e7402acf6a73b1f98bf4b Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:56:09 +0800 Subject: [PATCH 1/3] test(audit): check that captured memories keep their project tag A new memory invariant, `captured-memories-keep-a-project`: every memory a hook captured (tagged `source:auto-capture`) and every task state must still carry a `project:` tag, because every writer of those adds one. A memory of that kind without one lost it afterwards, which is what `kg rename-project --from X --to X --apply` did to every memory of project X (#519). It is keyed on the writers' own constants, not on names. It cannot see a memory a person wrote without a project, a commit captured before the tag existed, or a wrong but present project. Two graph-repair fixtures now carry the project tag every real writer adds. Refs #519 --- scripts/audit/memory-invariants.mjs | 31 ++++++++++++++++++ tests/audit/memory-invariants.test.ts | 45 +++++++++++++++++++++++++-- tests/storage/graph-repairs.test.ts | 6 ++-- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/scripts/audit/memory-invariants.mjs b/scripts/audit/memory-invariants.mjs index 57ebb459..3a47e95e 100644 --- a/scripts/audit/memory-invariants.mjs +++ b/scripts/audit/memory-invariants.mjs @@ -55,6 +55,11 @@ import { join } from 'node:path'; // removeJunkFileTags) and this invariant provably the same function, and the // same for the Bash write shapes #240's invariant below needs. import { bashEditedPaths, isPathShapedFileName } from '../hooks/_generated/bash-edited-paths.js'; +// The writers' own constants: the tag every hook capture carries, and the task +// state's type. The #519 invariant below is keyed on WHO wrote a memory, and +// these are how the writers mark it. +import { AUTO_CAPTURE_TAG } from '../hooks/_shared.js'; +import { TASK_STATE_TYPE } from '../hooks/_generated/task-state.js'; const MAX_ROWS = 8; @@ -370,6 +375,32 @@ const INVARIANTS = [ rows: (_db, rows) => rows.filter((r) => !isPathShapedFileName(r.tag.slice('file:'.length))), row: (r) => `${r.name} ${r.tag}`, }, + { + id: 'captured-memories-keep-a-project', + refs: '#519', + says: 'every memory a hook captured, and every task state, still carries a project tag', + // WHO wrote it, not what it is called: every hook writer that stamps + // AUTO_CAPTURE_TAG (post-commit, session-summary, pre-compact, the Stop + // handoff, and core's extractor) adds `project:` in the same tags + // array, and task-state-store always tags its project. So one of these + // with no project tag was not written that way; something took the tag + // away. #519: `kg rename-project --from X --to X --apply` removed + // `project:X` from every memory that had it. + // + // What this cannot see: a memory a person wrote without a project is + // legitimate and is not checked, so the same rename's damage to those is + // invisible here; so is a commit captured before the auto-capture tag + // existed. It also cannot tell a WRONG project from the right one. + // Measured on the maintainer's graph before adding it: 2,091 such + // memories, none without a project tag. + sql: ` + SELECT e.name AS name, e.type AS type FROM entities e + WHERE (e.type = '${TASK_STATE_TYPE}' + OR EXISTS (SELECT 1 FROM tags a WHERE a.entity_id = e.id AND a.tag = '${AUTO_CAPTURE_TAG}')) + AND NOT EXISTS (SELECT 1 FROM tags t WHERE t.entity_id = e.id AND t.tag LIKE 'project:%') + ORDER BY e.id LIMIT ${MAX_ROWS + 1}`, + row: (r) => `${r.name} type=${r.type}`, + }, { id: 'agent-message-scope-ids-are-not-filesystem-paths', refs: 'message identity', diff --git a/tests/audit/memory-invariants.test.ts b/tests/audit/memory-invariants.test.ts index 0df9d9a6..445d00cb 100644 --- a/tests/audit/memory-invariants.test.ts +++ b/tests/audit/memory-invariants.test.ts @@ -626,12 +626,53 @@ describe('memory-invariants: read-only detector over a real graph', () => { } }); + it('#519 — flags a captured memory and a task state that lost their project tag', () => { + const { dir, dbPath } = freshGraph(); + try { + withRawDb(dbPath, (db) => { + const commit = insertEntity(db, 'commit-abc1234', 'commit'); + db.prepare('INSERT INTO tags (entity_id, tag) VALUES (?, ?)').run(commit, 'source:auto-capture'); + insertEntity(db, 'task-state:acme', 'task-state'); + }); + const r = run(dbPath); + expect(r.status, r.stdout).toBe(1); + expect(r.stdout).toContain('FAIL captured-memories-keep-a-project'); + expect(r.stdout).toContain('commit-abc1234 type=commit'); + expect(r.stdout).toContain('task-state:acme type=task-state'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('#519 — a person\'s own memory, a global one or a commit from before the capture tag need no project', () => { + const { dir, dbPath } = freshGraph(); + try { + withRawDb(dbPath, (db) => { + insertEntity(db, 'decision-no-project', 'decision'); + insertEntity(db, 'global-rule', 'directive', { namespace: 'global' }); + insertEntity(db, 'commit-legacy01', 'commit'); + const tagged = insertEntity(db, 'commit-def5678', 'commit'); + for (const tag of ['source:auto-capture', 'project:acme']) { + db.prepare('INSERT INTO tags (entity_id, tag) VALUES (?, ?)').run(tagged, tag); + } + const state = insertEntity(db, 'task-state:acme', 'task-state'); + db.prepare('INSERT INTO tags (entity_id, tag) VALUES (?, ?)').run(state, 'project:acme'); + }); + const r = run(dbPath); + expect(r.stdout).toContain('ok captured-memories-keep-a-project'); + expect(r.stdout).not.toContain('FAIL captured-memories-keep-a-project'); + expect(r.status, r.stdout).toBe(0); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it('#495 — flags a file: tag that is a shell variable, a flag, or a sed/regex fragment', () => { const { dir, dbPath } = freshGraph(); try { withRawDb(dbPath, (db) => { const id = insertEntity(db, 'session-junk-files', 'session-insight'); - for (const tag of ['file:$f', 'file:-E', 'file:s#^source', 'file:auth.ts', 'source:auto-capture']) { + for (const tag of ['file:$f', 'file:-E', 'file:s#^source', 'file:auth.ts', 'source:auto-capture', 'project:acme']) { db.prepare('INSERT INTO tags (entity_id, tag) VALUES (?, ?)').run(id, tag); } }); @@ -652,7 +693,7 @@ describe('memory-invariants: read-only detector over a real graph', () => { try { withRawDb(dbPath, (db) => { const id = insertEntity(db, 'session-clean-files', 'session-insight'); - for (const tag of ['file:auth.ts', 'file:auth', 'file:README.md', 'file:{{cookiecutter.slug}}.py', 'source:auto-capture']) { + for (const tag of ['file:auth.ts', 'file:auth', 'file:README.md', 'file:{{cookiecutter.slug}}.py', 'source:auto-capture', 'project:acme']) { db.prepare('INSERT INTO tags (entity_id, tag) VALUES (?, ?)').run(id, tag); } // A person's own glob tag is not auto-capture debris. diff --git a/tests/storage/graph-repairs.test.ts b/tests/storage/graph-repairs.test.ts index 07ec7d3f..68468296 100644 --- a/tests/storage/graph-repairs.test.ts +++ b/tests/storage/graph-repairs.test.ts @@ -158,7 +158,7 @@ describe('#240 — duplicate observations are removed once, on ANY entity', () = ins.run(other, 'same'); ins.run(other, 'same'); // A history log: the duplicate is unreachable (no reader selects // observations.created_at) so it is repaired like any other. - const task = insertEntity(db, 'task-state:proj', 'task-state'); + const task = insertEntity(db, 'task-state:proj', 'task-state', ['project:proj']); ins.run(task, 'next cleared'); ins.run(task, 'done: shipped'); ins.run(task, 'next cleared'); }); expect(runInvariants().status, 'fixture must reproduce the defect before repair').toBe(1); @@ -807,12 +807,12 @@ describe('#495 — removeJunkFileTags: a file: tag that is not path-shaped is de it('removes a shell-variable and a flag tag, keeps a real one, and the invariant goes green', () => { seed((db) => { insertEntity(db, 'session-junk-abc-files', 'session-insight', [ - 'file:$F', 'file:-E', 'file:s#^source', 'file:auth.ts', 'file:auth', 'source:auto-capture', + 'file:$F', 'file:-E', 'file:s#^source', 'file:auth.ts', 'file:auth', 'source:auto-capture', 'project:proj', ]); }); const db = repaired(); - expect(tagsOf(db, 'session-junk-abc-files')).toEqual(['file:auth', 'file:auth.ts', 'source:auto-capture']); + expect(tagsOf(db, 'session-junk-abc-files')).toEqual(['file:auth', 'file:auth.ts', 'project:proj', 'source:auto-capture']); closeDatabase(); const inv = runInvariants(); expect(inv.status, inv.stdout).toBe(0); From 5fa315ae5d41a51b213e56737ce1121ea0aa0d5c Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:32:25 +0800 Subject: [PATCH 2/3] test(audit): a captured memory deliberately made global needs no project `captured-memories-keep-a-project` flagged a captured memory that a person had deliberately turned into a global one (`remember` with `replace`, `namespace: 'global'` and no project tag), including after export and import into another graph. The global namespace is now excluded: a rename never changes a memory's namespace, so #519's damage is still caught. A captured session summary, not only a commit, is now tested as a violation too. Refs #519 --- scripts/audit/memory-invariants.mjs | 7 ++++ tests/audit/memory-invariants.test.ts | 47 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/scripts/audit/memory-invariants.mjs b/scripts/audit/memory-invariants.mjs index 3a47e95e..5c3dbf64 100644 --- a/scripts/audit/memory-invariants.mjs +++ b/scripts/audit/memory-invariants.mjs @@ -387,6 +387,12 @@ const INVARIANTS = [ // away. #519: `kg rename-project --from X --to X --apply` removed // `project:X` from every memory that had it. // + // A captured memory a person deliberately made global (`remember` with + // `replace`, `namespace: 'global'` and tags without a project) keeps its + // capture tag truthfully and has no project on purpose, so the global + // namespace is excluded. A rename never changes a memory's namespace, so + // #519's damage (personal memories stripped of their project) is still seen. + // // What this cannot see: a memory a person wrote without a project is // legitimate and is not checked, so the same rename's damage to those is // invisible here; so is a commit captured before the auto-capture tag @@ -397,6 +403,7 @@ const INVARIANTS = [ SELECT e.name AS name, e.type AS type FROM entities e WHERE (e.type = '${TASK_STATE_TYPE}' OR EXISTS (SELECT 1 FROM tags a WHERE a.entity_id = e.id AND a.tag = '${AUTO_CAPTURE_TAG}')) + AND e.namespace != 'global' AND NOT EXISTS (SELECT 1 FROM tags t WHERE t.entity_id = e.id AND t.tag LIKE 'project:%') ORDER BY e.id LIMIT ${MAX_ROWS + 1}`, row: (r) => `${r.name} type=${r.type}`, diff --git a/tests/audit/memory-invariants.test.ts b/tests/audit/memory-invariants.test.ts index 445d00cb..e5ef5c10 100644 --- a/tests/audit/memory-invariants.test.ts +++ b/tests/audit/memory-invariants.test.ts @@ -7,6 +7,7 @@ import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { openDatabase, closeDatabase } from '../../src/db.js'; import { KnowledgeGraph } from '../../src/knowledge-graph.js'; +import { remember, exportMemories, importMemories } from '../../src/core/operations.js'; import { lessonSlug } from '../../src/core/lesson-slug.js'; import { AGENT_MESSAGE_SCOPE_COLUMNS, isFilesystemPathScopeId } from '../../src/core/agent-scope-id.js'; @@ -644,6 +645,52 @@ describe('memory-invariants: read-only detector over a real graph', () => { } }); + it('#519 — flags a captured session summary, not only a commit, that lost its project', () => { + const { dir, dbPath } = freshGraph(); + try { + withRawDb(dbPath, (db) => { + const summary = insertEntity(db, 'session-xyz-summary', 'session-insight'); + for (const tag of ['source:auto-capture', 'session:xyz']) { + db.prepare('INSERT INTO tags (entity_id, tag) VALUES (?, ?)').run(summary, tag); + } + }); + const r = run(dbPath); + expect(r.status, r.stdout).toBe(1); + expect(r.stdout).toContain('session-xyz-summary type=session-insight'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('#519 — a captured memory deliberately made global, then exported and imported, is not flagged', async () => { + const home = freshGraph(); + const target = freshGraph(); + try { + openDatabase(home.dbPath); + remember({ name: 'session-portable-summary', type: 'session-insight', observations: ['kept'], tags: ['source:auto-capture', 'project:acme'] }); + remember({ name: 'session-portable-summary', type: 'session-insight', observations: ['kept'], tags: ['source:auto-capture', 'session:portable'], namespace: 'global', replace: true }); + const bundle = exportMemories({}); + closeDatabase(); + openDatabase(target.dbPath); + importMemories({ data: bundle, merge_strategy: 'skip' }); + closeDatabase(); + for (const g of [home, target]) { + const r = run(g.dbPath); + expect(r.stdout).toContain('ok captured-memories-keep-a-project'); + expect(r.status, r.stdout).toBe(0); + } + // The same memory left in the personal namespace without its project is flagged. + withRawDb(target.dbPath, (db) => { + db.prepare("UPDATE entities SET namespace = 'personal' WHERE name = 'session-portable-summary'").run(); + }); + expect(run(target.dbPath).status).toBe(1); + } finally { + closeDatabase(); + fs.rmSync(home.dir, { recursive: true, force: true }); + fs.rmSync(target.dir, { recursive: true, force: true }); + } + }); + it('#519 — a person\'s own memory, a global one or a commit from before the capture tag need no project', () => { const { dir, dbPath } = freshGraph(); try { From e3280e3b6090e3cb3241ae2ff0a6b7cf126667a3 Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:16:13 +0800 Subject: [PATCH 3/3] test(audit): report every captured memory that lost its project tag The data cannot tell a deliberate removal from damage: a replace record describes an earlier write, not the later change that took the tag away. So `npm run audit:memory` now reports every memory a hook captured, and every task state, that has no project tag. To make a captured memory global on purpose, replace it without the capture tag too; it is then a person's own memory and is not checked. Refs #519 --- scripts/audit/memory-invariants.mjs | 13 ++-- tests/audit/memory-invariants.test.ts | 88 +++++++++++++++++++++------ 2 files changed, 75 insertions(+), 26 deletions(-) diff --git a/scripts/audit/memory-invariants.mjs b/scripts/audit/memory-invariants.mjs index 5c3dbf64..8d18809c 100644 --- a/scripts/audit/memory-invariants.mjs +++ b/scripts/audit/memory-invariants.mjs @@ -387,11 +387,11 @@ const INVARIANTS = [ // away. #519: `kg rename-project --from X --to X --apply` removed // `project:X` from every memory that had it. // - // A captured memory a person deliberately made global (`remember` with - // `replace`, `namespace: 'global'` and tags without a project) keeps its - // capture tag truthfully and has no project on purpose, so the global - // namespace is excluded. A rename never changes a memory's namespace, so - // #519's damage (personal memories stripped of their project) is still seen. + // The data cannot tell a deliberate removal from damage: a replace record + // describes an earlier write, not the later change that took the tag + // away. So every such memory is reported. To make a captured memory + // global on purpose, replace it without the capture tag too; it is then + // the person's own memory and is not checked. // // What this cannot see: a memory a person wrote without a project is // legitimate and is not checked, so the same rename's damage to those is @@ -403,9 +403,8 @@ const INVARIANTS = [ SELECT e.name AS name, e.type AS type FROM entities e WHERE (e.type = '${TASK_STATE_TYPE}' OR EXISTS (SELECT 1 FROM tags a WHERE a.entity_id = e.id AND a.tag = '${AUTO_CAPTURE_TAG}')) - AND e.namespace != 'global' AND NOT EXISTS (SELECT 1 FROM tags t WHERE t.entity_id = e.id AND t.tag LIKE 'project:%') - ORDER BY e.id LIMIT ${MAX_ROWS + 1}`, + ORDER BY e.id`, row: (r) => `${r.name} type=${r.type}`, }, { diff --git a/tests/audit/memory-invariants.test.ts b/tests/audit/memory-invariants.test.ts index e5ef5c10..3f719833 100644 --- a/tests/audit/memory-invariants.test.ts +++ b/tests/audit/memory-invariants.test.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { openDatabase, closeDatabase } from '../../src/db.js'; import { KnowledgeGraph } from '../../src/knowledge-graph.js'; -import { remember, exportMemories, importMemories } from '../../src/core/operations.js'; +import { remember } from '../../src/core/operations.js'; import { lessonSlug } from '../../src/core/lesson-slug.js'; import { AGENT_MESSAGE_SCOPE_COLUMNS, isFilesystemPathScopeId } from '../../src/core/agent-scope-id.js'; @@ -662,32 +662,82 @@ describe('memory-invariants: read-only detector over a real graph', () => { } }); - it('#519 — a captured memory deliberately made global, then exported and imported, is not flagged', async () => { - const home = freshGraph(); - const target = freshGraph(); + it('#519 — a captured memory made global keeps being checked until its capture tag goes too', () => { + const { dir, dbPath } = freshGraph(); try { - openDatabase(home.dbPath); + openDatabase(dbPath); remember({ name: 'session-portable-summary', type: 'session-insight', observations: ['kept'], tags: ['source:auto-capture', 'project:acme'] }); remember({ name: 'session-portable-summary', type: 'session-insight', observations: ['kept'], tags: ['source:auto-capture', 'session:portable'], namespace: 'global', replace: true }); - const bundle = exportMemories({}); closeDatabase(); - openDatabase(target.dbPath); - importMemories({ data: bundle, merge_strategy: 'skip' }); + const flagged = run(dbPath); + expect(flagged.status, flagged.stdout).toBe(1); + expect(flagged.stdout).toContain('session-portable-summary type=session-insight'); + + openDatabase(dbPath); + remember({ name: 'session-portable-summary', type: 'session-insight', observations: ['kept'], tags: ['session:portable'], namespace: 'global', replace: true }); closeDatabase(); - for (const g of [home, target]) { - const r = run(g.dbPath); - expect(r.stdout).toContain('ok captured-memories-keep-a-project'); - expect(r.status, r.stdout).toBe(0); - } - // The same memory left in the personal namespace without its project is flagged. - withRawDb(target.dbPath, (db) => { - db.prepare("UPDATE entities SET namespace = 'personal' WHERE name = 'session-portable-summary'").run(); + const own = run(dbPath); + expect(own.stdout).toContain('ok captured-memories-keep-a-project'); + expect(own.status, own.stdout).toBe(0); + } finally { + closeDatabase(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('#519 — a project lost after a replace that kept it is flagged, history or not', () => { + const { dir, dbPath } = freshGraph(); + try { + openDatabase(dbPath); + remember({ name: 'session-kept-summary', type: 'session-insight', observations: ['first'], tags: ['source:auto-capture', 'project:acme'] }); + remember({ name: 'session-kept-summary', type: 'session-insight', observations: ['corrected'], replace: true }); + closeDatabase(); + expect(run(dbPath).status).toBe(0); + // #519's damage, done after the replace: the replace record still shows project:acme. + withRawDb(dbPath, (db) => { + db.prepare("DELETE FROM tags WHERE tag = 'project:acme' AND entity_id = (SELECT id FROM entities WHERE name = 'session-kept-summary')").run(); }); - expect(run(target.dbPath).status).toBe(1); + const r = run(dbPath); + expect(r.status, r.stdout).toBe(1); + expect(r.stdout).toContain('session-kept-summary type=session-insight'); } finally { closeDatabase(); - fs.rmSync(home.dir, { recursive: true, force: true }); - fs.rmSync(target.dir, { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('#519 — a global captured memory and a global task state that lost their project in a rename are flagged', () => { + const { dir, dbPath } = freshGraph(); + try { + withRawDb(dbPath, (db) => { + const captured = insertEntity(db, 'commit-global01', 'commit', { namespace: 'global' }); + db.prepare('INSERT INTO tags (entity_id, tag) VALUES (?, ?)').run(captured, 'source:auto-capture'); + insertEntity(db, 'task-state:shared', 'task-state', { namespace: 'global' }); + }); + const r = run(dbPath); + expect(r.status, r.stdout).toBe(1); + expect(r.stdout).toContain('commit-global01 type=commit'); + expect(r.stdout).toContain('task-state:shared type=task-state'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('#519 — a replace history whose newest version had no project does not excuse a missing project', () => { + const { dir, dbPath } = freshGraph(); + try { + withRawDb(dbPath, (db) => { + const history = JSON.stringify({ replaced_history: [ + { replaced_at: '2026-09-01T00:00:00.000Z', title: null, observations: ['old'], tags: ['source:auto-capture'] }, + ] }); + const id = insertEntity(db, 'session-noproj-summary', 'session-insight', { metadata: history }); + db.prepare('INSERT INTO tags (entity_id, tag) VALUES (?, ?)').run(id, 'source:auto-capture'); + }); + const r = run(dbPath); + expect(r.status, r.stdout).toBe(1); + expect(r.stdout).toContain('session-noproj-summary type=session-insight'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); } });