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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions packages/agents/content/skills/migrate-feedback-memories/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 "<one-line lesson>" \
--store codeassembly \
--harness {harness_id} \
--tags feedback \
[--skill <slug>] [--impact <level>]
<the generalized lesson>

Origin: project <store>, machine <machine>, session <originSessionId>.
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' "<path>" "<path>" … | 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 <slug>` when the lesson targets a specific skill.
- `--impact <low|medium|high|critical>` — 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.
68 changes: 68 additions & 0 deletions packages/agents/scripts/bundle-skill-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
];

/**
Expand Down Expand Up @@ -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 =
Expand Down
105 changes: 105 additions & 0 deletions packages/agents/src/migrate-feedback-memories/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<home>/.claude/projects/<store>/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');
});
});
Loading
Loading