diff --git a/scripts/audit/memory-invariants.mjs b/scripts/audit/memory-invariants.mjs index eea0881e..e31aa267 100644 --- a/scripts/audit/memory-invariants.mjs +++ b/scripts/audit/memory-invariants.mjs @@ -57,6 +57,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; @@ -381,6 +386,38 @@ 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. + // + // 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 + // 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`, + 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 c650d4ac..ce6c730c 100644 --- a/tests/audit/memory-invariants.test.ts +++ b/tests/audit/memory-invariants.test.ts @@ -627,12 +627,149 @@ 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 — 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 made global keeps being checked until its capture tag goes too', () => { + const { dir, dbPath } = freshGraph(); + try { + 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 }); + closeDatabase(); + 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(); + 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(); + }); + 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(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 }); + } + }); + + 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); } }); @@ -653,7 +790,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);