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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions scripts/audit/memory-invariants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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:<name>` 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',
Expand Down
141 changes: 139 additions & 2 deletions tests/audit/memory-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions tests/storage/graph-repairs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading