diff --git a/.gitignore b/.gitignore index b145a787..add3122e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ packages/agents/content/skills/kb-edit/kb-edit.mjs packages/agents/content/skills/kb-retrieve/kb-retrieve.mjs packages/agents/content/skills/kb-retrieve-events/kb-retrieve-events.mjs packages/agents/content/skills/kb-update-events/kb-update-events.mjs +packages/agents/content/skills/migrate-feedback-memories/migrate-feedback-memories.mjs packages/agents/content/skills/update-jira-ticket/update-jira-ticket.mjs # Credentials diff --git a/packages/agents/content/skills/migrate-feedback-memories/SKILL.md b/packages/agents/content/skills/migrate-feedback-memories/SKILL.md new file mode 100644 index 00000000..b01c9976 --- /dev/null +++ b/packages/agents/content/skills/migrate-feedback-memories/SKILL.md @@ -0,0 +1,112 @@ +--- +name: migrate-feedback-memories +description: Route this machine's per-project feedback memories to their proper home — capture the propagating ones as capture-feedback candidates, delete the redundant, retain the genuinely local — via a bundled enumerator with a confirm-by-default batch flow and an --auto escape hatch +user-invocable: true +--- + +# Migrate feedback memories + +Route every `feedback`-type agent memory on this machine to its proper home. A bundled helper does the mechanical work — it enumerates feedback memories across every project store, and executes deletions with `MEMORY.md` reconciliation. You do the judgment work — classify each memory, dedup capture candidates against the knowledge base, and compose each capture. + +The three destinations: + +- **Capture** — a generalizable lesson that should propagate is recorded as a `capture-feedback`-style candidate event in the `codeassembly` KB, and the source memory is then removed from its store; a capture migrates the memory out, it does not copy it. A later distillation pass codifies the event into shared guidance. +- **Retain** — a genuinely local, non-propagating fact (a project-specific deadline or quirk) stays a memory, untouched. +- **Delete** — a memory already captured (including one migrated from another machine) or otherwise redundant is removed. + +The split is deliberate: the helper is narrow and mechanical (it never classifies); the routing is wide and judgment-driven. + +**Announce at start:** "Using migrate-feedback-memories to route this machine's feedback memories." + +## Arguments + +| Argument | Description | Required | +| -------- | -------------------------------------------------------------------------------------- | -------- | +| `--auto` | Skip the batch-review confirmation and execute the inferred routing. Dedup still runs. | No | + +The `--auto` flag is consumed by you, not the helper; it controls whether you present the routing plan before executing. + +## Runtime dependencies + +- **`node` ≥ 24** — the bundled helper inherits the Node version floor of `@codeassembly/kb`. + +## Modes + +- **Default mode**: enumerate, classify, dedup, present the routing plan, and execute only after confirmation. +- **Auto mode (`--auto`)**: enumerate, classify, dedup, and execute silently, with no confirmation. + +## Process + +### 1. Enumerate + +Run the helper's `enumerate` subcommand — it is read-only: + +```bash +node {harness_home_dir}/skills/migrate-feedback-memories/migrate-feedback-memories.mjs enumerate +``` + +It prints `{ ok, machine, projectsRoot, memories, skipped }`. Each entry in `memories` carries `path`, `store`, `machine`, `slug`, `name`, `description`, `originSessionId`, `body`, and `memoryIndexPath`. `skipped` lists memory files that have a frontmatter fence but unparseable YAML — read and route each one by hand (they are usually feedback memories whose `name:` value needs quoting). + +### 2. Classify + +Decide one destination per memory: + +- **Capture** when the lesson generalizes beyond its origin project — a behavior, correction, or convention that should propagate. This is the default for behavioral feedback. +- **Retain** when the fact is genuinely local and non-propagating (a project-specific deadline, a one-off quirk). +- **Delete** when the memory is redundant with shared guidance or a prior capture. The redirect memory `feedback-capture-feedback-in-kb-not-memory` is such a case — its guidance now lives in `shared/AGENTS.md`, so it is a delete like any other, with no carve-out. + +### 3. Dedup capture candidates + +For each capture candidate, invoke the {skill:kb-retrieve-events} skill on the memory's topic to check whether an equivalent event already exists in the `codeassembly` KB (captured on an earlier run or from another machine). When an equivalent exists, reclassify the memory to **delete** — do not re-capture. This is what makes a re-run, and a second machine's run, converge rather than duplicate. + +### 4. Present the routing plan (default mode) + +Show every memory with its destination, and for each capture the proposed `--tags`, `--skill`, and `--impact`. Present it as one batch for review — per-item confirmation is impractical at this scale. Wait for approval or adjustments. In auto mode, skip this step. + +### 5. Execute + +On approval, run all captures first, then a single deletion pass: + +1. **Capture** — for each memory routed to capture, compose the arguments and body per the {skill:capture-event} contract and pipe the body to its bundled helper directly (a batch this size cannot afford a per-item skill invocation): + + ```bash + cat <<'EOF' | node {harness_home_dir}/skills/capture-event/capture-event.mjs \ + --summary "" \ + --store codeassembly \ + --harness {harness_id} \ + --tags feedback \ + [--skill ] [--impact ] + + + Origin: project , machine , session . + EOF + ``` + + Only when `capture-event` returns `ok: true`, add that memory's source `path` to the deletion batch — a capture migrates the memory out of its store, so its source is removed once the event has landed. When a capture fails, leave the source in place and surface the failure; never delete a memory whose capture did not land. + +2. **Delete** — pipe every deletion path, newline-separated, to the helper's `delete` subcommand in a single call. The batch is the union of the memories routed to delete-as-redundant and the sources of successful captures, so each store's `MEMORY.md` is reconciled once: + + ```bash + printf '%s\n' "" "" … | node {harness_home_dir}/skills/migrate-feedback-memories/migrate-feedback-memories.mjs delete + ``` + + It removes each file and reconciles its `MEMORY.md`, printing a per-path outcome (`deleted`, `indexUpdated`, and a `note` for any already-absent file or unmatched index line). + +3. **Retain** — no action. + +### Composing a capture + +- `--store codeassembly` — the agent-guidance KB. Route to a different store only when a memory is specific to another registered project's KB. +- `--tags feedback` always; add `,mistake` (i.e. `--tags feedback,mistake`) when the memory recorded a _misapplied_ existing rule. +- `--skill ` when the lesson targets a specific skill. +- `--impact ` — rate on the merits of the memory's content: how much acting on the lesson would improve future behavior. Omit only on a genuine toss-up. +- `--harness {harness_id}` — keep verbatim; the installer injects the value. +- **Provenance in the body** — `capture-event` auto-fills `cwd`, `repo`, and `session` from _this_ migration run, not the memory's origin, so record the origin project (`store`), machine, and `originSessionId` in the body. + +### 6. Report + +Summarize the counts — captured, deleted, retained, skipped — with the ids and paths of the captures, and call out any skipped memories that still need manual handling. + +## Completion + +Every feedback memory on the machine is routed — captured then removed, deleted as redundant, or retained locally; each affected `MEMORY.md` reflects its post-migration store; and every capture carries origin provenance in its body. After a full run, a store holds only retained-local memories, so a re-run is a no-op. diff --git a/packages/agents/scripts/bundle-skill-helpers.ts b/packages/agents/scripts/bundle-skill-helpers.ts index b8dd4673..5318128c 100644 --- a/packages/agents/scripts/bundle-skill-helpers.ts +++ b/packages/agents/scripts/bundle-skill-helpers.ts @@ -104,6 +104,11 @@ export const targets: BundleTarget[] = [ outFile: 'content/skills/kb-update-events/kb-update-events.mjs', smokeTest: makeKbUpdateEventsSmokeTest(), }, + { + entry: 'src/migrate-feedback-memories/cli.ts', + outFile: 'content/skills/migrate-feedback-memories/migrate-feedback-memories.mjs', + smokeTest: makeMigrateFeedbackMemoriesSmokeTest(), + }, ]; /** @@ -527,6 +532,69 @@ function assertCompositionViolationFinding(result: unknown): void { } } +/** + * Stands up an isolated home holding one nested-schema feedback memory under a project store, then returns a + * `SmokeTestInvocation` that runs `enumerate` against it. `HOME` points the projects-root walk at the fixture and an + * empty `CLAUDE_CONFIG_DIR` neutralizes any ambient value, so the enumeration never touches the developer's real + * `~/.claude`. Exercises the full projects-root resolution → store walk → frontmatter parse → feedback filter pipeline. + */ +function makeMigrateFeedbackMemoriesSmokeTest(): SmokeTestInvocation { + const home = mkdtempSync(path.join(tmpdir(), 'migrate-feedback-memories-home-')); + const memoryDir = path.join(home, '.claude', 'projects', '-store-smoke', 'memory'); + mkdirSync(memoryDir, { recursive: true }); + writeFileSync( + path.join(memoryDir, 'feedback-smoke-example.md'), + [ + '---', + 'name: feedback-smoke-example', + 'description: a smoke-test feedback memory', + 'metadata:', + ' node_type: memory', + ' type: feedback', + ' originSessionId: smoke-session', + '---', + '', + 'Smoke body.', + '', + ].join('\n'), + 'utf8', + ); + writeFileSync( + path.join(memoryDir, 'MEMORY.md'), + '# Memory\n\n## Feedback\n\n- [x](feedback-smoke-example.md): x\n', + 'utf8', + ); + + return { + args: ['enumerate'], + env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: '' }, + assertResult: assertMigrateFeedbackMemoriesSmokeResult, + }; +} + +/** + * Assert the migrate-feedback-memories smoke enumerated exactly the seeded feedback memory, reading its slug and the + * origin session id from the nested `metadata` schema. + */ +function assertMigrateFeedbackMemoriesSmokeResult(result: unknown): void { + if (!isRecord(result)) { + throw new TypeError('expected object result from migrate-feedback-memories'); + } + if (result.ok !== true) { + throw new Error(`expected ok: true, got ${JSON.stringify(result)}`); + } + if (!Array.isArray(result.memories) || result.memories.length !== 1) { + throw new Error(`expected exactly one enumerated memory, got ${JSON.stringify(result.memories)}`); + } + const memory: unknown = result.memories[0]; + if (!isRecord(memory) || memory.slug !== 'feedback-smoke-example') { + throw new Error(`expected the seeded feedback memory, got ${JSON.stringify(memory)}`); + } + if (memory.originSessionId !== 'smoke-session') { + throw new Error(`expected originSessionId from nested metadata, got ${JSON.stringify(memory.originSessionId)}`); + } +} + // A CommonJS dependency (`yaml`) reaches Node built-ins via bare `require('process')` calls. // esbuild's ESM output otherwise has no `require`, so this banner restores a real one via `createRequire`. const requireShim = diff --git a/packages/agents/src/migrate-feedback-memories/__tests__/cli.test.ts b/packages/agents/src/migrate-feedback-memories/__tests__/cli.test.ts new file mode 100644 index 00000000..861958b5 --- /dev/null +++ b/packages/agents/src/migrate-feedback-memories/__tests__/cli.test.ts @@ -0,0 +1,105 @@ +import { access, mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; + +import { describe, expect, it } from 'vitest'; + +import { runMigrate } from '../cli.ts'; + +const MACHINE = 'test-host'; + +const FEEDBACK = `--- +name: feedback-example +description: an example feedback memory +metadata: + type: feedback + originSessionId: sess-1 +--- + +Body. +`; + +function bodyStream(body: string): Readable { + return Readable.from([Buffer.from(body, 'utf8')]); +} + +/** Builds a fixture home with one feedback memory under `/.claude/projects//memory/`. */ +async function makeHomeWithMemory(): Promise<{ home: string; memoryPath: string }> { + const home = await mkdtemp(join(tmpdir(), 'migrate-cli-home-')); + const memoryDir = join(home, '.claude', 'projects', '-store-a', 'memory'); + await mkdir(memoryDir, { recursive: true }); + const memoryPath = join(memoryDir, 'feedback-example.md'); + await writeFile(memoryPath, FEEDBACK, 'utf8'); + await writeFile(join(memoryDir, 'MEMORY.md'), '# Memory\n\n## Feedback\n\n- [x](feedback-example.md): x\n', 'utf8'); + return { home, memoryPath }; +} + +describe(runMigrate, () => { + it('enumerates feedback memories under the resolved projects root', async () => { + const { home } = await makeHomeWithMemory(); + + const result = await runMigrate({ + argv: ['enumerate'], + stdin: bodyStream(''), + env: {}, + home, + machine: MACHINE, + }); + + expect(result.ok).toBe(true); + if (!result.ok || !('memories' in result)) return; + expect(result.memories.map((memory) => memory.slug)).toEqual(['feedback-example']); + expect(result.machine).toBe(MACHINE); + }); + + it('deletes the paths piped on stdin and reconciles the index', async () => { + const { home, memoryPath } = await makeHomeWithMemory(); + + const result = await runMigrate({ + argv: ['delete'], + stdin: bodyStream(`${memoryPath}\n`), + env: {}, + home, + }); + + expect(result.ok).toBe(true); + if (!result.ok || !('results' in result)) return; + expect(result.results[0]).toMatchObject({ deleted: true, indexUpdated: true }); + await expect(access(memoryPath)).rejects.toThrow(); + }); + + it('treats empty stdin for delete as a clean no-op', async () => { + const result = await runMigrate({ argv: ['delete'], stdin: bodyStream(' \n'), env: {} }); + + expect(result.ok).toBe(true); + if (!result.ok || !('results' in result)) return; + expect(result.results).toEqual([]); + }); + + it('returns invalid-args for an unknown subcommand', async () => { + const result = await runMigrate({ argv: ['frobnicate'], stdin: bodyStream(''), env: {} }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('invalid-args'); + expect(result.message).toContain('frobnicate'); + }); + + it('returns invalid-args when no subcommand is given', async () => { + const result = await runMigrate({ argv: [], stdin: bodyStream(''), env: {} }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('invalid-args'); + expect(result.message).toContain('subcommand is required'); + }); + + it('returns invalid-args when enumerate is given a stray argument', async () => { + const result = await runMigrate({ argv: ['enumerate', 'extra'], stdin: bodyStream(''), env: {} }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('invalid-args'); + }); +}); diff --git a/packages/agents/src/migrate-feedback-memories/__tests__/delete-memory.test.ts b/packages/agents/src/migrate-feedback-memories/__tests__/delete-memory.test.ts new file mode 100644 index 00000000..fe59512e --- /dev/null +++ b/packages/agents/src/migrate-feedback-memories/__tests__/delete-memory.test.ts @@ -0,0 +1,104 @@ +import { access, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { deleteMemories } from '../delete-memory.ts'; + +const INDEX_TWO = `# Memory + +## Feedback + +- [A](feedback-a.md): a +- [B](feedback-b.md): b +`; + +/** Creates a temp store memory directory and returns its absolute path. */ +async function makeMemoryDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'migrate-del-')); + const memory = join(dir, 'memory'); + await mkdir(memory, { recursive: true }); + return memory; +} + +/** True when a path exists on disk. */ +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +describe(deleteMemories, () => { + it('deletes the file and removes its MEMORY.md line', async () => { + const memory = await makeMemoryDir(); + await writeFile(join(memory, 'feedback-a.md'), 'body a', 'utf8'); + await writeFile(join(memory, 'feedback-b.md'), 'body b', 'utf8'); + await writeFile(join(memory, 'MEMORY.md'), INDEX_TWO, 'utf8'); + + const result = await deleteMemories({ paths: [join(memory, 'feedback-a.md')] }); + + expect(result.results[0]).toMatchObject({ deleted: true, indexUpdated: true }); + expect(await exists(join(memory, 'feedback-a.md'))).toBe(false); + const index = await readFile(join(memory, 'MEMORY.md'), 'utf8'); + expect(index).not.toContain('](feedback-a.md)'); + expect(index).toContain('](feedback-b.md)'); + }); + + it('reconciles each store index once for a cross-store batch', async () => { + const storeA = await makeMemoryDir(); + const storeB = await makeMemoryDir(); + await writeFile(join(storeA, 'feedback-a.md'), 'a', 'utf8'); + await writeFile(join(storeA, 'MEMORY.md'), '# Memory\n\n## Feedback\n\n- [A](feedback-a.md): a\n', 'utf8'); + await writeFile(join(storeB, 'feedback-c.md'), 'c', 'utf8'); + await writeFile(join(storeB, 'MEMORY.md'), '# Memory\n\n## Feedback\n\n- [C](feedback-c.md): c\n', 'utf8'); + + const result = await deleteMemories({ + paths: [join(storeA, 'feedback-a.md'), join(storeB, 'feedback-c.md')], + }); + + expect(result.results.every((outcome) => outcome.deleted && outcome.indexUpdated)).toBe(true); + expect(await readFile(join(storeA, 'MEMORY.md'), 'utf8')).not.toContain('](feedback-a.md)'); + expect(await readFile(join(storeB, 'MEMORY.md'), 'utf8')).not.toContain('](feedback-c.md)'); + }); + + it('reports an already-absent file without failing the batch', async () => { + const memory = await makeMemoryDir(); + await writeFile(join(memory, 'MEMORY.md'), '# Memory\n', 'utf8'); + + const result = await deleteMemories({ paths: [join(memory, 'gone.md')] }); + + expect(result.ok).toBe(true); + expect(result.results[0]).toMatchObject({ deleted: false, indexUpdated: false }); + expect(result.results[0]?.note).toContain('already absent'); + }); + + it('deletes an orphan memory that has no MEMORY.md line', async () => { + const memory = await makeMemoryDir(); + await writeFile(join(memory, 'feedback-orphan.md'), 'orphan', 'utf8'); + await writeFile( + join(memory, 'MEMORY.md'), + '# Memory\n\n## Feedback\n\n- [Other](feedback-other.md): other\n', + 'utf8', + ); + + const result = await deleteMemories({ paths: [join(memory, 'feedback-orphan.md')] }); + + expect(result.results[0]).toMatchObject({ deleted: true, indexUpdated: false }); + expect(result.results[0]?.note).toContain('no MEMORY.md line matched'); + expect(await exists(join(memory, 'feedback-orphan.md'))).toBe(false); + }); + + it('deletes the file even when the store has no MEMORY.md', async () => { + const memory = await makeMemoryDir(); + await writeFile(join(memory, 'feedback-a.md'), 'a', 'utf8'); + + const result = await deleteMemories({ paths: [join(memory, 'feedback-a.md')] }); + + expect(result.results[0]).toMatchObject({ deleted: true, indexUpdated: false }); + expect(await exists(join(memory, 'feedback-a.md'))).toBe(false); + }); +}); diff --git a/packages/agents/src/migrate-feedback-memories/__tests__/enumerate.test.ts b/packages/agents/src/migrate-feedback-memories/__tests__/enumerate.test.ts new file mode 100644 index 00000000..850de7fa --- /dev/null +++ b/packages/agents/src/migrate-feedback-memories/__tests__/enumerate.test.ts @@ -0,0 +1,203 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { enumerateFeedbackMemories } from '../enumerate.ts'; + +const MACHINE = 'test-host'; + +/** A feedback memory in the current nested schema, carrying `metadata.type` and `metadata.originSessionId`. */ +const NESTED_FEEDBACK = `--- +name: feedback-nested-example +description: "A nested-schema feedback memory" +metadata: + node_type: memory + type: feedback + originSessionId: sess-nested +--- + +Nested schema body. +`; + +/** A feedback memory in the legacy schema: a top-level \`type\` and a top-level \`originSessionId\`. */ +const LEGACY_FEEDBACK = `--- +name: Run nmr from monorepo root +description: legacy top-level schema +type: feedback +originSessionId: sess-legacy +--- + +Legacy schema body. +`; + +/** A legacy feedback memory with no session id, matching the devtools-afg store shape. */ +const LEGACY_NO_SESSION = `--- +name: Atlaskit xcss requires static literals +description: no session id present +type: feedback +--- + +Body without a session id. +`; + +/** A non-feedback memory (nested \`user\` type) that enumeration must skip. */ +const USER_MEMORY = `--- +name: user-preference +description: not feedback +metadata: + type: user +--- + +A user memory. +`; + +/** A feedback memory whose frontmatter fence wraps invalid YAML: a quoted scalar followed by bare text. */ +const MALFORMED_FEEDBACK = `--- +name: "Implement directly" means no skill wrapper +description: malformed frontmatter +type: feedback +--- + +Body of a malformed memory. +`; + +async function makeProjectsRoot(): Promise { + return mkdtemp(join(tmpdir(), 'migrate-enum-')); +} + +/** Writes a memory file into `//memory/`, creating the store's memory directory. */ +async function writeMemory(root: string, store: string, filename: string, content: string): Promise { + const dir = join(root, store, 'memory'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, filename), content, 'utf8'); +} + +describe(enumerateFeedbackMemories, () => { + it('enumerates both frontmatter schemas and both filename conventions', async () => { + const root = await makeProjectsRoot(); + await writeMemory(root, '-store-a', 'feedback-nested-example.md', NESTED_FEEDBACK); + await writeMemory(root, '-store-a', 'run-nmr-from-root.md', LEGACY_FEEDBACK); + + const result = await enumerateFeedbackMemories({ projectsRoot: root, machine: MACHINE }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.memories.map((memory) => memory.slug)).toEqual(['feedback-nested-example', 'run-nmr-from-root']); + expect(result.memories.every((memory) => memory.store === '-store-a')).toBe(true); + expect(result.memories.every((memory) => memory.machine === MACHINE)).toBe(true); + }); + + it('reads name, description, body, and index path for a discovered memory', async () => { + const root = await makeProjectsRoot(); + await writeMemory(root, '-store-a', 'feedback-nested-example.md', NESTED_FEEDBACK); + + const result = await enumerateFeedbackMemories({ projectsRoot: root, machine: MACHINE }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const [memory] = result.memories; + expect(memory?.name).toBe('feedback-nested-example'); + expect(memory?.description).toBe('A nested-schema feedback memory'); + expect(memory?.body).toContain('Nested schema body.'); + expect(memory?.memoryIndexPath).toBe(join(root, '-store-a', 'memory', 'MEMORY.md')); + }); + + it('extracts originSessionId from either schema and leaves it null when absent', async () => { + const root = await makeProjectsRoot(); + await writeMemory(root, '-store-a', 'feedback-nested-example.md', NESTED_FEEDBACK); + await writeMemory(root, '-store-a', 'run-nmr-from-root.md', LEGACY_FEEDBACK); + await writeMemory(root, '-store-b', 'atlaskit-xcss.md', LEGACY_NO_SESSION); + + const result = await enumerateFeedbackMemories({ projectsRoot: root, machine: MACHINE }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const sessions = Object.fromEntries(result.memories.map((memory) => [memory.slug, memory.originSessionId])); + expect(sessions['feedback-nested-example']).toBe('sess-nested'); + expect(sessions['run-nmr-from-root']).toBe('sess-legacy'); + expect(sessions['atlaskit-xcss']).toBeNull(); + }); + + it('excludes non-feedback memories and the MEMORY.md index', async () => { + const root = await makeProjectsRoot(); + await writeMemory(root, '-store-a', 'feedback-nested-example.md', NESTED_FEEDBACK); + await writeMemory(root, '-store-a', 'user-preference.md', USER_MEMORY); + await writeMemory(root, '-store-a', 'MEMORY.md', '# Memory\n\n## Feedback\n\n- [x](feedback-nested-example.md)\n'); + + const result = await enumerateFeedbackMemories({ projectsRoot: root, machine: MACHINE }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.memories.map((memory) => memory.slug)).toEqual(['feedback-nested-example']); + }); + + it('reports a malformed memory (bad YAML in a real fence) in skipped rather than dropping it', async () => { + const root = await makeProjectsRoot(); + await writeMemory(root, '-store-a', 'feedback-nested-example.md', NESTED_FEEDBACK); + await writeMemory(root, '-store-a', 'feedback-malformed.md', MALFORMED_FEEDBACK); + + const result = await enumerateFeedbackMemories({ projectsRoot: root, machine: MACHINE }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.memories.map((memory) => memory.slug)).toEqual(['feedback-nested-example']); + expect(result.skipped.map((entry) => entry.path)).toEqual([ + join(root, '-store-a', 'memory', 'feedback-malformed.md'), + ]); + }); + + it('omits a fence-less file from both memories and skipped, since a feedback memory always has frontmatter', async () => { + const root = await makeProjectsRoot(); + await writeMemory(root, '-store-a', 'feedback-nested-example.md', NESTED_FEEDBACK); + await writeMemory(root, '-store-a', 'interaction-style.md', '# Interaction style\n\nProse with no frontmatter.\n'); + + const result = await enumerateFeedbackMemories({ projectsRoot: root, machine: MACHINE }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.memories.map((memory) => memory.slug)).toEqual(['feedback-nested-example']); + expect(result.skipped).toEqual([]); + }); + + it('skips a store with no memory directory and returns an empty list when nothing matches', async () => { + const root = await makeProjectsRoot(); + await mkdir(join(root, '-store-empty'), { recursive: true }); + await writeMemory(root, '-store-b', 'user-preference.md', USER_MEMORY); + + const result = await enumerateFeedbackMemories({ projectsRoot: root, machine: MACHINE }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.memories).toEqual([]); + expect(result.skipped).toEqual([]); + }); + + it('orders memories by store then slug for stable output', async () => { + const root = await makeProjectsRoot(); + await writeMemory(root, '-store-b', 'zebra.md', LEGACY_NO_SESSION); + await writeMemory(root, '-store-a', 'run-nmr-from-root.md', LEGACY_FEEDBACK); + await writeMemory(root, '-store-a', 'feedback-nested-example.md', NESTED_FEEDBACK); + + const result = await enumerateFeedbackMemories({ projectsRoot: root, machine: MACHINE }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.memories.map((memory) => `${memory.store}/${memory.slug}`)).toEqual([ + '-store-a/feedback-nested-example', + '-store-a/run-nmr-from-root', + '-store-b/zebra', + ]); + }); + + it('fails with no-projects-root when the projects root is absent', async () => { + const root = join(await makeProjectsRoot(), 'does-not-exist'); + + const result = await enumerateFeedbackMemories({ projectsRoot: root, machine: MACHINE }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe('no-projects-root'); + }); +}); diff --git a/packages/agents/src/migrate-feedback-memories/__tests__/reconcile-memory-index.test.ts b/packages/agents/src/migrate-feedback-memories/__tests__/reconcile-memory-index.test.ts new file mode 100644 index 00000000..a9498d6b --- /dev/null +++ b/packages/agents/src/migrate-feedback-memories/__tests__/reconcile-memory-index.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { removeMemoryIndexEntry } from '../reconcile-memory-index.ts'; + +const INDEX = `# Memory + +## User preferences + +- [Alpha](alpha.md): first preference +- [Beta](beta.md): second preference + +## Feedback + +- [Only one](feedback-only.md): the sole feedback entry + +## Project + +- [Proj](proj.md): a project note +`; + +describe(removeMemoryIndexEntry, () => { + it('removes the matching line and leaves the others intact', () => { + const { content, removed } = removeMemoryIndexEntry(INDEX, 'beta.md'); + + expect(removed).toBe(true); + expect(content).not.toContain('](beta.md)'); + expect(content).toContain('](alpha.md)'); + expect(content).toContain('](proj.md)'); + }); + + it('matches on the link basename, not the human title', () => { + const { content, removed } = removeMemoryIndexEntry(INDEX, 'feedback-only.md'); + + expect(removed).toBe(true); + expect(content).not.toContain('Only one'); + }); + + it('drops a section header left empty after its last entry is removed', () => { + const { content } = removeMemoryIndexEntry(INDEX, 'feedback-only.md'); + + expect(content).not.toContain('## Feedback'); + expect(content).toContain('## User preferences'); + expect(content).toContain('## Project'); + }); + + it('keeps the header when a sibling entry survives', () => { + const { content } = removeMemoryIndexEntry(INDEX, 'alpha.md'); + + expect(content).toContain('## User preferences'); + expect(content).toContain('](beta.md)'); + }); + + it('reports removed false and leaves content unchanged when nothing matches', () => { + const { content, removed } = removeMemoryIndexEntry(INDEX, 'absent.md'); + + expect(removed).toBe(false); + expect(content).toBe(INDEX); + }); + + it('does not remove an entry whose basename is a substring of the target', () => { + const index = `# Memory + +## Feedback + +- [Short](a.md): short one +- [Long](xa.md): long one +`; + + const { content, removed } = removeMemoryIndexEntry(index, 'a.md'); + + expect(removed).toBe(true); + expect(content).not.toContain('](a.md)'); + expect(content).toContain('](xa.md)'); + }); +}); diff --git a/packages/agents/src/migrate-feedback-memories/cli.ts b/packages/agents/src/migrate-feedback-memories/cli.ts new file mode 100644 index 00000000..9cae174f --- /dev/null +++ b/packages/agents/src/migrate-feedback-memories/cli.ts @@ -0,0 +1,114 @@ +/* eslint n/no-process-exit: off */ +/* eslint unicorn/no-process-exit: off */ +import { realpathSync } from 'node:fs'; +import process from 'node:process'; +import type { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; + +import { readAll } from '../lib/stream-helpers.ts'; +import { deleteMemories } from './delete-memory.ts'; +import { enumerateFeedbackMemories } from './enumerate.ts'; +import { resolveProjectsRoot } from './resolve-projects-root.ts'; +import type { MigrateFailure, MigrateResult } from './types.ts'; + +/** Executes the helper from `process.argv` and writes the JSON result to stdout. */ +async function main(): Promise { + try { + const result = await runMigrate({ + argv: process.argv.slice(2), + stdin: process.stdin, + env: process.env, + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + // The helper's contract is exit 0 with a structured `{ ok: false, ... }` for recoverable failures. Unexpected + // throws (permission denied, out-of-disk) take the catch arm below. + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`migrate-feedback-memories: ${message}\n`); + process.exit(1); + } +} + +if (isEntryPoint()) { + await main(); +} + +/** + * Runs the helper end to end, dispatching on the subcommand. `enumerate` (read-only) resolves the machine's projects + * root and lists every feedback memory with provenance. `delete` reads newline-separated memory paths from stdin, + * removes each file, and reconciles the affected `MEMORY.md` indexes. A missing or unknown subcommand, or an + * unexpected argument, is a recoverable `invalid-args` result. System failures propagate to the caller's try/catch. + * + * @internal - Exported to allow testing. + */ +export async function runMigrate(input: { + argv: readonly string[]; + stdin: Readable; + env?: NodeJS.ProcessEnv; + home?: string; + machine?: string; +}): Promise { + const [subcommand, ...rest] = input.argv; + + if (subcommand === 'enumerate') { + if (rest.length > 0) { + return invalidArgs(`enumerate takes no arguments; got: ${rest.join(' ')}`); + } + const projectsRoot = resolveProjectsRoot({ + ...(input.home !== undefined && { home: input.home }), + ...(input.env !== undefined && { env: input.env }), + }); + return enumerateFeedbackMemories({ + projectsRoot, + ...(input.machine !== undefined && { machine: input.machine }), + }); + } + + if (subcommand === 'delete') { + if (rest.length > 0) { + return invalidArgs(`delete takes memory paths on stdin, not arguments; got: ${rest.join(' ')}`); + } + const paths = parsePaths(await readAll(input.stdin)); + return deleteMemories({ paths }); + } + + return invalidArgs( + subcommand === undefined ? 'a subcommand is required: enumerate or delete' : `unknown subcommand: ${subcommand}`, + ); +} + +// region | Helpers + +/** Splits newline-separated stdin into a list of non-empty, trimmed memory paths. */ +function parsePaths(stdinText: string): string[] { + return stdinText + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +/** Builds a recoverable `invalid-args` failure with the given message. */ +function invalidArgs(message: string): MigrateFailure { + return { ok: false, error: 'invalid-args', message }; +} + +/** + * Returns true when this module is the process entry point. Both sides are resolved through `realpathSync`, so a + * symlinked invocation path still matches. On a `realpathSync` failure the function emits a warning and returns `false`, + * matching the degrade-with-warning pattern used by `kb-add` and `capture-event`. + */ +function isEntryPoint(): boolean { + const entry = process.argv[1]; + if (entry === undefined) { + return false; + } + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`migrate-feedback-memories: warning: could not determine entry point: ${message}\n`); + return false; + } +} + +// endregion | Helpers diff --git a/packages/agents/src/migrate-feedback-memories/delete-memory.ts b/packages/agents/src/migrate-feedback-memories/delete-memory.ts new file mode 100644 index 00000000..fb2ea8fe --- /dev/null +++ b/packages/agents/src/migrate-feedback-memories/delete-memory.ts @@ -0,0 +1,114 @@ +import { readFile, unlink, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import { isEnoent } from '../lib/type-guards.ts'; +import { removeMemoryIndexEntry } from './reconcile-memory-index.ts'; +import type { DeleteOutcome, DeleteSuccess } from './types.ts'; + +/** + * Deletes a batch of memory files and reconciles each affected `MEMORY.md`. Files are removed first, then the index of + * each store is read, stripped of every deleted memory's line, and rewritten once — so a store with many deletions is + * touched a single time. An already-absent file and a store with no `MEMORY.md` line are non-fatal, reported per path. + * A missing filesystem entry is tolerated; other I/O errors propagate as system failures. + */ +export async function deleteMemories(input: { paths: readonly string[] }): Promise { + const deleted = await deleteFiles(input.paths); + const indexUpdated = await reconcileIndexes(input.paths); + + const results: DeleteOutcome[] = input.paths.map((path) => { + const fileGone = deleted.get(path) ?? false; + const indexHit = indexUpdated.get(path) ?? false; + const note = noteFor(fileGone, indexHit); + return { + path, + deleted: fileGone, + indexUpdated: indexHit, + ...(note !== undefined && { note }), + }; + }); + + return { ok: true, results }; +} + +// region | Helpers + +/** Removes each file, mapping its path to whether a file was actually unlinked (false when already absent). */ +async function deleteFiles(paths: readonly string[]): Promise> { + const deleted = new Map(); + for (const path of paths) { + try { + await unlink(path); + deleted.set(path, true); + } catch (error) { + if (isEnoent(error)) { + deleted.set(path, false); + continue; + } + throw error; + } + } + return deleted; +} + +/** + * Reconciles each store's `MEMORY.md` once, mapping every path to whether its index line was found and removed. Paths + * are grouped by their sibling index so a store is read and rewritten a single time regardless of how many of its + * memories are in the batch. An absent index leaves every grouped path at `false`. + */ +async function reconcileIndexes(paths: readonly string[]): Promise> { + const indexUpdated = new Map(); + for (const [indexPath, groupedPaths] of groupByIndex(paths)) { + let content: string; + try { + content = await readFile(indexPath, 'utf8'); + } catch (error) { + if (isEnoent(error)) { + for (const path of groupedPaths) { + indexUpdated.set(path, false); + } + continue; + } + throw error; + } + + let changed = false; + for (const path of groupedPaths) { + const result = removeMemoryIndexEntry(content, basename(path)); + content = result.content; + indexUpdated.set(path, result.removed); + changed = changed || result.removed; + } + if (changed) { + await writeFile(indexPath, content, 'utf8'); + } + } + return indexUpdated; +} + +/** Groups paths by their sibling `MEMORY.md` index path, preserving each group's input order. */ +function groupByIndex(paths: readonly string[]): Map { + const groups = new Map(); + for (const path of paths) { + const indexPath = join(dirname(path), 'MEMORY.md'); + const group = groups.get(indexPath) ?? []; + group.push(path); + groups.set(indexPath, group); + } + return groups; +} + +/** Builds the per-path note describing any non-fatal condition, or `undefined` when the delete was clean. */ +function noteFor(fileGone: boolean, indexHit: boolean): string | undefined { + if (!fileGone && !indexHit) { + return 'file already absent and no MEMORY.md line matched'; + } + if (!fileGone) { + return 'file already absent'; + } + if (!indexHit) { + return 'no MEMORY.md line matched'; + } + return undefined; +} + +// endregion | Helpers diff --git a/packages/agents/src/migrate-feedback-memories/enumerate.ts b/packages/agents/src/migrate-feedback-memories/enumerate.ts new file mode 100644 index 00000000..92613015 --- /dev/null +++ b/packages/agents/src/migrate-feedback-memories/enumerate.ts @@ -0,0 +1,138 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { hostname } from 'node:os'; +import { basename, join } from 'node:path'; + +import { readNoteContent } from '@codeassembly/kb/note-io'; + +import { isMissingFile, isRecord } from '../lib/type-guards.ts'; +import type { EnumerateResult, FeedbackMemory, SkippedMemory } from './types.ts'; + +/** + * Walks every `//memory/` directory and returns each memory whose effective type is `feedback`. + * Membership is decided by parsed frontmatter — `metadata.type` when nested, else a top-level `type` — never by filename + * or a single-schema regex, so both the legacy and current memory schemas are enumerated. A file that cannot be read as + * a note is reported in `skipped` rather than dropped. An absent projects root is the one categorical failure; an absent + * per-store `memory/` directory is simply skipped. + */ +export async function enumerateFeedbackMemories(input: { + projectsRoot: string; + machine?: string; +}): Promise { + const machine = input.machine ?? hostname(); + + let stores: string[]; + try { + const entries = await readdir(input.projectsRoot, { withFileTypes: true }); + stores = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); + } catch (error) { + if (isMissingFile(error)) { + return { ok: false, error: 'no-projects-root', message: `no projects root at ${input.projectsRoot}` }; + } + throw error; + } + stores = stores.toSorted(); + + const memories: FeedbackMemory[] = []; + const skipped: SkippedMemory[] = []; + + for (const store of stores) { + const memoryDir = join(input.projectsRoot, store, 'memory'); + const files = await listMemoryFiles(memoryDir); + const memoryIndexPath = join(memoryDir, 'MEMORY.md'); + + for (const file of files) { + const path = join(memoryDir, file); + const record = await readMemory({ path, store, machine, memoryIndexPath }); + if (record.kind === 'feedback') { + memories.push(record.memory); + } else if (record.kind === 'unreadable') { + skipped.push({ path, reason: record.reason }); + } + // record.kind === 'other' — a non-feedback memory — is intentionally omitted. + } + } + + return { ok: true, machine, projectsRoot: input.projectsRoot, memories, skipped }; +} + +// region | Helpers + +/** Lists the `.md` memory files in a store, excluding the `MEMORY.md` index; an absent directory yields no files. */ +async function listMemoryFiles(memoryDir: string): Promise { + let entries: string[]; + try { + entries = await readdir(memoryDir); + } catch (error) { + if (isMissingFile(error)) { + return []; + } + throw error; + } + return entries.filter((name) => name.endsWith('.md') && name !== 'MEMORY.md').toSorted(); +} + +/** Reads one memory file and classifies it as a feedback memory, some other memory, or an unreadable note. */ +async function readMemory(input: { + path: string; + store: string; + machine: string; + memoryIndexPath: string; +}): Promise<{ kind: 'feedback'; memory: FeedbackMemory } | { kind: 'other' } | { kind: 'unreadable'; reason: string }> { + const content = await readFile(input.path, 'utf8'); + const note = readNoteContent(content); + if (note.error !== undefined) { + // Surface a malformed memory (a frontmatter fence with unparseable YAML) so it is routed by hand rather than + // dropped; a file with no frontmatter fence is not a memory at all — and a feedback memory always has frontmatter, + // so omitting it never hides one. + return hasFrontmatterFence(content) ? { kind: 'unreadable', reason: note.error } : { kind: 'other' }; + } + if (effectiveType(note.fields) !== 'feedback') { + return { kind: 'other' }; + } + return { + kind: 'feedback', + memory: { + path: input.path, + store: input.store, + machine: input.machine, + slug: basename(input.path, '.md'), + name: stringField(note.fields, 'name'), + description: stringField(note.fields, 'description'), + originSessionId: originSessionId(note.fields), + body: note.body, + memoryIndexPath: input.memoryIndexPath, + }, + }; +} + +/** True when content opens with a `---` frontmatter fence and carries a closing fence — the shape every memory has. */ +function hasFrontmatterFence(content: string): boolean { + const lines = content.split('\n'); + return lines[0] === '---' && lines.slice(1).includes('---'); +} + +/** Returns the memory's effective type: the nested `metadata.type` when present, else a top-level `type`. */ +function effectiveType(fields: Record): string | null { + const metadata = fields.metadata; + if (isRecord(metadata) && typeof metadata.type === 'string') { + return metadata.type; + } + return typeof fields.type === 'string' ? fields.type : null; +} + +/** Returns a top-level string frontmatter field, or `null` when absent or non-string. */ +function stringField(fields: Record, key: string): string | null { + const value = fields[key]; + return typeof value === 'string' ? value : null; +} + +/** Returns the origin session id from `metadata.originSessionId` or a top-level `originSessionId`, else `null`. */ +function originSessionId(fields: Record): string | null { + const metadata = fields.metadata; + if (isRecord(metadata) && typeof metadata.originSessionId === 'string') { + return metadata.originSessionId; + } + return stringField(fields, 'originSessionId'); +} + +// endregion | Helpers diff --git a/packages/agents/src/migrate-feedback-memories/reconcile-memory-index.ts b/packages/agents/src/migrate-feedback-memories/reconcile-memory-index.ts new file mode 100644 index 00000000..fe9513ee --- /dev/null +++ b/packages/agents/src/migrate-feedback-memories/reconcile-memory-index.ts @@ -0,0 +1,68 @@ +/** + * Removes a memory's entry from `MEMORY.md` content, matching the list item by its link target's file basename rather + * than by title. Matching on the basename is what makes reconciliation robust across the store-to-store variance in how + * entries are titled and filenamed. When removing the entry empties its section, the now-orphaned `##` header (and its + * blank lines) is dropped too. A basename with no matching line is a no-op that reports `removed: false`. + */ +export function removeMemoryIndexEntry(content: string, fileBasename: string): { content: string; removed: boolean } { + const target = `](${fileBasename})`; + const lines = content.split('\n'); + const kept: string[] = []; + let removed = false; + + for (const line of lines) { + if (isListItem(line) && line.includes(target)) { + removed = true; + continue; + } + kept.push(line); + } + + if (!removed) { + return { content, removed: false }; + } + return { content: dropEmptySections(kept).join('\n'), removed: true }; +} + +// region | Helpers + +/** True when a line is a markdown list item (`-` or `*` bullet, at any indentation). */ +function isListItem(line: string): boolean { + return /^\s*[-*] /.test(line); +} + +/** + * Drops any `##` section whose body — the lines between its header and the next `#`/`##` header or end of file — is + * entirely blank, along with that blank body. A section carrying prose or a surviving list item is left untouched, so + * only a section genuinely emptied by an entry removal is collapsed. + */ +function dropEmptySections(lines: readonly string[]): string[] { + const out: string[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + if (line === undefined) { + break; + } + if (!line.startsWith('## ')) { + out.push(line); + index += 1; + continue; + } + + let end = index + 1; + while (end < lines.length && !/^#{1,2} /.test(lines[end] ?? '')) { + end += 1; + } + const body = lines.slice(index + 1, end); + if (body.some((bodyLine) => bodyLine.trim() !== '')) { + out.push(line, ...body); + } + index = end; + } + + return out; +} + +// endregion | Helpers diff --git a/packages/agents/src/migrate-feedback-memories/resolve-projects-root.ts b/packages/agents/src/migrate-feedback-memories/resolve-projects-root.ts new file mode 100644 index 00000000..b44e79e7 --- /dev/null +++ b/packages/agents/src/migrate-feedback-memories/resolve-projects-root.ts @@ -0,0 +1,16 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Resolves the machine's Claude project-stores root (`/projects`). The config dir is `CLAUDE_CONFIG_DIR` + * when set — matching how Claude Code relocates its state — otherwise `/.claude`. An injected `home` overrides the + * ambient home directory, which is the single seam tests and the smoke test use to point the walk at a fixture instead + * of the developer's real `~/.claude`. + */ +export function resolveProjectsRoot(input: { home?: string; env?: NodeJS.ProcessEnv }): string { + const configDir = input.env?.CLAUDE_CONFIG_DIR; + // Treat an empty CLAUDE_CONFIG_DIR as unset — an exported-but-empty shell variable should not resolve to a bogus + // relative `projects` path, and this lets a caller neutralize an ambient value by passing an empty string. + const base = configDir !== undefined && configDir !== '' ? configDir : join(input.home ?? homedir(), '.claude'); + return join(base, 'projects'); +} diff --git a/packages/agents/src/migrate-feedback-memories/types.ts b/packages/agents/src/migrate-feedback-memories/types.ts new file mode 100644 index 00000000..a7a211a7 --- /dev/null +++ b/packages/agents/src/migrate-feedback-memories/types.ts @@ -0,0 +1,90 @@ +// Shapes for the migrate-feedback-memories helper: the enumerated feedback-memory record, and the JSON results emitted +// to stdout by the `enumerate` and `delete` subcommands. +// +// Each helper subcommand prints a discriminated union on `ok`. Recoverable failures return `{ ok: false, error, message }`; +// successes return `{ ok: true, ... }`. System errors (permission denied, out-of-disk) print to stderr and exit non-zero. + +/** + * A single feedback memory discovered during enumeration, carrying the provenance a caller needs to route it. Identity + * is drawn from parsed frontmatter, not the filename, so both the legacy top-level `type:` schema and the current nested + * `metadata.type:` schema are represented uniformly here. + */ +export interface FeedbackMemory { + /** Absolute path to the memory file. */ + path: string; + /** The project-store slug (the `` directory name under the projects root); the origin-project identifier. */ + store: string; + /** Machine hostname captured at enumeration time. */ + machine: string; + /** Filename stem, without the `.md` extension. */ + slug: string; + /** The frontmatter `name`, when present (a slug in newer stores, a human title in older ones). */ + name: string | null; + /** The frontmatter `description`, when present. */ + description: string | null; + /** The originating session id, read from `metadata.originSessionId` or a top-level `originSessionId`, when present. */ + originSessionId: string | null; + /** The memory body (everything after the frontmatter block). */ + body: string; + /** Absolute path to the sibling `MEMORY.md` index for this store. */ + memoryIndexPath: string; +} + +/** A memory file that could not be read as a note, surfaced rather than silently dropped. */ +export interface SkippedMemory { + /** Absolute path to the unreadable file. */ + path: string; + /** Why the file was skipped (missing or malformed frontmatter). */ + reason: string; +} + +/** The `enumerate` subcommand's stdout payload on success. */ +export interface EnumerateSuccess { + ok: true; + /** Machine hostname the enumeration ran on. */ + machine: string; + /** Absolute path of the projects root that was walked. */ + projectsRoot: string; + /** Every feedback memory found, ordered by store then slug for stable output. */ + memories: FeedbackMemory[]; + /** Files whose frontmatter could not be parsed. */ + skipped: SkippedMemory[]; +} + +/** The `enumerate` subcommand's stdout payload when the projects root is absent. */ +export interface EnumerateFailure { + ok: false; + error: 'no-projects-root'; + message: string; +} + +export type EnumerateResult = EnumerateSuccess | EnumerateFailure; + +/** The outcome of deleting one memory and reconciling its store's `MEMORY.md`. */ +export interface DeleteOutcome { + /** Absolute path of the memory targeted for deletion. */ + path: string; + /** Whether the file was removed (false when it was already absent). */ + deleted: boolean; + /** Whether a matching `MEMORY.md` line was found and removed. */ + indexUpdated: boolean; + /** A non-fatal note, e.g. the file was already gone or the store has no `MEMORY.md` line for it. */ + note?: string; +} + +/** The `delete` subcommand's stdout payload on success (per-path outcomes are individually reported). */ +export interface DeleteSuccess { + ok: true; + results: DeleteOutcome[]; +} + +/** The stdout payload for a recoverable helper failure (bad arguments or an unknown subcommand). */ +export interface MigrateFailure { + ok: false; + error: 'invalid-args'; + message: string; +} + +export type DeleteResult = DeleteSuccess | MigrateFailure; + +export type MigrateResult = EnumerateResult | DeleteResult;