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
11 changes: 9 additions & 2 deletions packages/agents/content/skills/kb-edit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ For new notes, use `kb-add`. For finding notes, use `kb-retrieve`. For periodic
| `--bump-updated` | Set `updated:` to today (UTC). Body unchanged. | One op |
| `--verify` | Set `last-verified:` to today (UTC). Does **not** bump `updated:`. | One op |
| `--append` | Append the body read from stdin after a separating blank line, and bump `updated:`. | One op |
| `--retag <list>` | Replace `tags:` with the comma-separated list. Canonicalizes; bumps `updated:`. | One op |
| `--retag <list>` | Replace `tags:` with the comma-separated list. Canonicalizes; does **not** bump `updated:`. | One op |
| `--add-addressed-by <refs>` | Append comma-separated reference(s) to each target's `addressed-by` list; bumps `updated:`. | One op |
| `--supersede-with <p>` | Mark `<path>` superseded by `<p>`. Two-file atomic write; both notes bump `updated:`. | One op |

Expand Down Expand Up @@ -50,10 +50,17 @@ The `--auto` flag is consumed by you, not by the bundled helper; it controls whe
- **`--bump-updated`** — A non-empirical edit to the note (rewording, restructuring, fact correction) where the body change is made out of band and you want only to refresh `updated:`. Rare on its own; mostly an audit-trail tool.
- **`--verify`** — You reran the note's instructions or re-confirmed its claims and they still hold. Use this for the "I just checked; still good" path. Does not bump `updated:` because nothing about the content changed.
- **`--append`** — Add a section to an existing note. The new content lands after the existing body with a separating blank line. Use for accumulating findings or extending a list.
- **`--retag`** — Replace the tag list wholesale (canonicalized through the KB's `.kb/tag-aliases.yaml`). Use when tags drift, when restructuring categories, or when remediating findings from `kb-curate`.
- **`--retag`** — Replace the tag list wholesale (canonicalized through the KB's `.kb/tag-aliases.yaml`). Use when tags drift, when restructuring categories, or when remediating findings from `kb-curate`. Curatorial: it changes how a record is found, not what it asserts, so it leaves `updated:` unchanged.
- **`--add-addressed-by`** — Record what addressed a problem: append references to a record's recall-facing `addressed-by` list so the response surfaces when the record is later recalled. Pass several target notes to link one response (a fix note, a PR, a commit) to all the incidents it resolved in a single run.
- **`--supersede-with`** — Mark an old note deprecated and point it at its replacement. Both notes' frontmatter is updated atomically (best-effort): old gains `superseded-by` and the `deprecated` tag, new gains `supersedes`. Use when a note is no longer canonical but should remain discoverable.

## Update semantics: which operations bump `updated:`

`updated:` records the last _substantive_ change to a record — what it asserts, its body, or its lifecycle state. Operations that make no such change leave `updated:` untouched. Classify each new operation against this rule deliberately rather than in isolation:

- **Bump `updated:`** (substantive change): `--append` (body change), `--add-addressed-by` (records a response relation), and `--supersede-with` (lifecycle-state change). `--bump-updated` is the explicit escape hatch for an out-of-band edit made elsewhere.
- **Leave `updated:` unchanged** (no substantive change): `--retag` (curatorial: reorganizes findability only) and `--verify` (re-confirmation: content is unchanged).

## Process

### 1. Pick the operation
Expand Down
22 changes: 12 additions & 10 deletions packages/agents/src/kb-edit/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ const TODAY = '2026-05-24T14:35:00Z';
const SAMPLE_NOTE = `---
title: Sample
recordType: assertion
created: 2026-05-01
updated: 2026-05-01
created: 2026-05-01T08:17:23Z
updated: 2026-05-01T08:17:23Z
tags: [sample]
type: howto
---
Expand Down Expand Up @@ -223,14 +223,14 @@ describe(runEdit, () => {
expect(result.ok).toBe(true);
if (result.ok && result.operation === 'verify') {
expect(result.frontmatter.extra['last-verified']).toBe(TODAY);
expect(result.frontmatter.updated).toBe('2026-05-01');
expect(result.frontmatter.updated).toBe('2026-05-01T08:17:23Z');
const written = await readFile(notePath, 'utf8');
expect(written).toContain(`last-verified: ${TODAY}`);
expect(written).toContain('updated: 2026-05-01');
expect(written).toContain('updated: 2026-05-01T08:17:23Z');
}
});

it('replaces tags via --retag and surfaces canonicalization audit', async () => {
it('replaces tags via --retag without bumping updated and surfaces canonicalization audit', async () => {
const { kbPath, notePath } = await makeKbWithNote();

const result = await runEdit({
Expand All @@ -246,7 +246,9 @@ describe(runEdit, () => {
expect(result.originalTags).toEqual(['one', 'two', 'three']);
expect(result.canonicalTags).toEqual(['one', 'two', 'three']);
expect(result.frontmatter.tags).toEqual(['one', 'two', 'three']);
expect(result.frontmatter.updated).toBe(TODAY);
expect(result.frontmatter.updated).toBe('2026-05-01T08:17:23Z');
const written = await readFile(notePath, 'utf8');
expect(written).toContain('updated: 2026-05-01T08:17:23Z');
}
});

Expand Down Expand Up @@ -384,7 +386,7 @@ describe(runEdit, () => {
const path = join(kbPath, 'bad-record-type.md');
await writeFile(
path,
'---\ntitle: x\nrecordType: rant\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [x]\n---\n\nbody\n',
'---\ntitle: x\nrecordType: rant\ncreated: 2026-05-01T08:17:23Z\nupdated: 2026-05-01T08:17:23Z\ntags: [x]\n---\n\nbody\n',
'utf8',
);

Expand Down Expand Up @@ -503,7 +505,7 @@ describe(runEdit, () => {
// New note has a recordType outside the schema vocabulary; supersede-with validates both before either rename.
await writeFile(
newPath,
'---\ntitle: x\nrecordType: rant\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [x]\n---\n\nbody\n',
'---\ntitle: x\nrecordType: rant\ncreated: 2026-05-01T08:17:23Z\nupdated: 2026-05-01T08:17:23Z\ntags: [x]\n---\n\nbody\n',
'utf8',
);

Expand Down Expand Up @@ -554,7 +556,7 @@ describe(runEdit, () => {
// Old note already carries the deprecated tag; supersede-with should not duplicate it.
await writeFile(
oldPath,
'---\ntitle: Old\nrecordType: assertion\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [legacy, deprecated]\ntype: howto\n---\n\nbody\n',
'---\ntitle: Old\nrecordType: assertion\ncreated: 2026-05-01T08:17:23Z\nupdated: 2026-05-01T08:17:23Z\ntags: [legacy, deprecated]\ntype: howto\n---\n\nbody\n',
'utf8',
);
await writeFile(newPath, SAMPLE_NOTE.replace('Sample', 'New'), 'utf8');
Expand Down Expand Up @@ -671,7 +673,7 @@ describe(runEdit, () => {
// A recordType outside the schema vocabulary: appending re-validates the frontmatter, so this record fails.
await writeFile(
badType,
'---\ntitle: x\nrecordType: rant\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [x]\n---\n\nbody\n',
'---\ntitle: x\nrecordType: rant\ncreated: 2026-05-01T08:17:23Z\nupdated: 2026-05-01T08:17:23Z\ntags: [x]\n---\n\nbody\n',
'utf8',
);

Expand Down
10 changes: 5 additions & 5 deletions packages/agents/src/kb-edit/__tests__/write-back.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ function validFrontmatter(overrides: Partial<Frontmatter> = {}): Frontmatter {
return {
title: 'Example',
recordType: 'assertion',
created: '2026-05-01',
updated: '2026-05-01',
created: '2026-05-01T08:17:23Z',
updated: '2026-05-01T08:17:23Z',
tags: ['example'],
extra: {},
...overrides,
Expand Down Expand Up @@ -88,16 +88,16 @@ describe(writeBackNote, () => {
it('round-trips a structurally identical note when frontmatter is unchanged', async () => {
const path = await makeTempPath('kb-edit-wb-roundtrip-');
const original =
'---\ntitle: x\nrecordType: assertion\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [a]\ntype: howto\n---\n\nbody\n';
'---\ntitle: x\nrecordType: assertion\ncreated: 2026-05-01T08:17:23Z\nupdated: 2026-05-01T08:17:23Z\ntags: [a]\ntype: howto\n---\n\nbody\n';
await writeFile(path, original, 'utf8');

const result = await writeBackNote({
path,
frontmatter: {
title: 'x',
recordType: 'assertion',
created: '2026-05-01',
updated: '2026-05-01',
created: '2026-05-01T08:17:23Z',
updated: '2026-05-01T08:17:23Z',
tags: ['a'],
extra: { type: 'howto' },
},
Expand Down
2 changes: 1 addition & 1 deletion packages/agents/src/kb-edit/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ async function prepareOperation(input: {
return { ok: true, ...result };
}
case 'retag': {
const result = retag({ frontmatter, body, tags: input.args.tags, aliases: input.aliases, now: input.now });
const result = retag({ frontmatter, body, tags: input.args.tags, aliases: input.aliases });
return { ok: true, ...result };
}
case 'append': {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ function frontmatter(overrides: Partial<Frontmatter> = {}): Frontmatter {
return {
title: 'Example',
recordType: 'event',
created: '2026-05-01',
updated: '2026-05-01',
created: '2026-05-01T08:17:23Z',
updated: '2026-05-01T08:17:23Z',
tags: ['example'],
extra: {},
...overrides,
Expand Down Expand Up @@ -88,6 +88,6 @@ describe(addAddressedBy, () => {
addAddressedBy({ frontmatter: fm, body: 'b', references: ['[[fix]]'], now: NOW });

expect(fm.extra['addressed-by']).toEqual(['#789']);
expect(fm.updated).toBe('2026-05-01');
expect(fm.updated).toBe('2026-05-01T08:17:23Z');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ function frontmatter(overrides: Partial<Frontmatter> = {}): Frontmatter {
return {
title: 'Example',
recordType: 'assertion',
created: '2026-05-01',
updated: '2026-05-01',
created: '2026-05-01T08:17:23Z',
updated: '2026-05-01T08:17:23Z',
tags: ['example'],
extra: {},
...overrides,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ function frontmatter(overrides: Partial<Frontmatter> = {}): Frontmatter {
return {
title: 'Example',
recordType: 'assertion',
created: '2026-05-01',
updated: '2026-05-01',
created: '2026-05-01T08:17:23Z',
updated: '2026-05-01T08:17:23Z',
tags: ['example'],
extra: {},
...overrides,
Expand All @@ -23,17 +23,17 @@ describe(bumpUpdated, () => {

expect(result.frontmatter.updated).toBe('2026-05-24T14:35:00Z');
expect(result.frontmatter.title).toBe('Example');
expect(result.frontmatter.created).toBe('2026-05-01');
expect(result.frontmatter.created).toBe('2026-05-01T08:17:23Z');
expect(result.frontmatter.tags).toEqual(['example']);
expect(result.body).toBe('unchanged body');
});

it('preserves extra fields including last-verified', () => {
const fm = frontmatter({ extra: { 'last-verified': '2026-05-10', 'applies-to': 'node 24' } });
const fm = frontmatter({ extra: { 'last-verified': '2026-05-10T16:05:47Z', 'applies-to': 'node 24' } });

const result = bumpUpdated({ frontmatter: fm, body: 'b', now: NOW });

expect(result.frontmatter.extra).toEqual({ 'last-verified': '2026-05-10', 'applies-to': 'node 24' });
expect(result.frontmatter.extra).toEqual({ 'last-verified': '2026-05-10T16:05:47Z', 'applies-to': 'node 24' });
});

it('does not mutate the input frontmatter', () => {
Expand Down
16 changes: 5 additions & 11 deletions packages/agents/src/kb-edit/operations/__tests__/retag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ import { describe, expect, it } from 'vitest';

import { retag } from '../retag.ts';

const NOW = new Date('2026-05-24T14:35:00Z');

function frontmatter(overrides: Partial<Frontmatter> = {}): Frontmatter {
return {
title: 'Example',
recordType: 'assertion',
created: '2026-05-01',
updated: '2026-05-01',
created: '2026-05-01T08:17:23Z',
updated: '2026-05-01T08:17:23Z',
tags: ['old', 'tags'],
extra: {},
...overrides,
Expand All @@ -24,17 +22,16 @@ const NODE_ALIASES: AliasMap = new Map([
]);

describe(retag, () => {
it('replaces the tag list with the supplied tags and bumps updated', () => {
it('replaces the tag list with the supplied tags and leaves updated unchanged', () => {
const result = retag({
frontmatter: frontmatter(),
body: 'b',
tags: ['new', 'set'],
aliases: NO_ALIASES,
now: NOW,
});

expect(result.frontmatter.tags).toEqual(['new', 'set']);
expect(result.frontmatter.updated).toBe('2026-05-24T14:35:00Z');
expect(result.frontmatter.updated).toBe('2026-05-01T08:17:23Z');
});

it('returns originalTags and canonicalTags for audit', () => {
Expand All @@ -43,7 +40,6 @@ describe(retag, () => {
body: 'b',
tags: ['node.js', 'react'],
aliases: NODE_ALIASES,
now: NOW,
});

expect(result.originalTags).toEqual(['node.js', 'react']);
Expand All @@ -58,7 +54,6 @@ describe(retag, () => {
body: 'b',
tags: ['node.js', 'react', 'node'],
aliases: NODE_ALIASES,
now: NOW,
});

expect(result.canonicalTags).toEqual(['nodejs', 'react']);
Expand All @@ -70,7 +65,6 @@ describe(retag, () => {
body: 'b',
tags: [],
aliases: NO_ALIASES,
now: NOW,
});

expect(result.frontmatter.tags).toEqual([]);
Expand All @@ -80,7 +74,7 @@ describe(retag, () => {
it('does not mutate the input frontmatter', () => {
const fm = frontmatter();

retag({ frontmatter: fm, body: 'b', tags: ['x'], aliases: NO_ALIASES, now: NOW });
retag({ frontmatter: fm, body: 'b', tags: ['x'], aliases: NO_ALIASES });

expect(fm.tags).toEqual(['old', 'tags']);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ function note(input: { name: string; tags?: string[]; extra?: Record<string, unk
const frontmatter: Frontmatter = {
title: input.name.replace(/\.md$/, ''),
recordType: 'assertion',
created: '2026-05-01',
updated: '2026-05-01',
created: '2026-05-01T08:17:23Z',
updated: '2026-05-01T08:17:23Z',
tags,
extra,
};
Expand Down Expand Up @@ -130,7 +130,7 @@ describe(prepareSupersedeWith, () => {

it('preserves other extra fields on both notes', () => {
const oldNote = note({ name: 'old.md', extra: { 'applies-to': 'node 22' } });
const newNote = note({ name: 'new.md', extra: { 'last-verified': '2026-04-15' } });
const newNote = note({ name: 'new.md', extra: { 'last-verified': '2026-04-15T13:28:14Z' } });

const result = prepareSupersedeWith({
oldNote,
Expand All @@ -143,6 +143,6 @@ describe(prepareSupersedeWith, () => {
});

expect(result.old.frontmatter.extra['applies-to']).toBe('node 22');
expect(result.new.frontmatter.extra['last-verified']).toBe('2026-04-15');
expect(result.new.frontmatter.extra['last-verified']).toBe('2026-04-15T13:28:14Z');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ function frontmatter(overrides: Partial<Frontmatter> = {}): Frontmatter {
return {
title: 'Example',
recordType: 'assertion',
created: '2026-05-01',
updated: '2026-05-01',
created: '2026-05-01T08:17:23Z',
updated: '2026-05-01T08:17:23Z',
tags: ['example'],
extra: {},
...overrides,
Expand All @@ -22,7 +22,7 @@ describe(verify, () => {
const result = verify({ frontmatter: frontmatter(), body: 'body', now: NOW });

expect(result.frontmatter.extra['last-verified']).toBe('2026-05-24T14:35:00Z');
expect(result.frontmatter.updated).toBe('2026-05-01');
expect(result.frontmatter.updated).toBe('2026-05-01T08:17:23Z');
});

it('adds last-verified when the field is absent', () => {
Expand All @@ -34,7 +34,7 @@ describe(verify, () => {
});

it('overwrites an existing last-verified value', () => {
const fm = frontmatter({ extra: { 'last-verified': '2026-01-15' } });
const fm = frontmatter({ extra: { 'last-verified': '2026-01-15T11:42:09Z' } });

const result = verify({ frontmatter: fm, body: 'b', now: NOW });

Expand Down
17 changes: 8 additions & 9 deletions packages/agents/src/kb-edit/operations/retag.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,28 @@
import type { AliasMap, Frontmatter } from '@codeassembly/kb';
import { canonicalize } from '@codeassembly/kb/tags';

import { dedupeInOrder, formatUtcTimestamp } from '../../kb-shared/note-helpers.ts';
import { dedupeInOrder } from '../../kb-shared/note-helpers.ts';

/**
* Replaces the tag list, canonicalizing each entry through the supplied alias map, deduplicating in
* first-occurrence order, and bumping `updated:`. An empty list is a valid result.
* Replaces the tag list, canonicalizing each entry through the supplied alias map and deduplicating in
* first-occurrence order. An empty list is a valid result. Does **not** bump `updated:` — retagging is a
* curatorial edit that reorganizes how a record is found, not a substantive change to what it asserts.
*
* Canonicalization can collapse distinct inputs onto the same canonical, so dedupe runs after canonicalize.
* The pre-canonicalization list is returned as `originalTags` so the caller can surface an audit trail.
*/
export function retag(input: {
export function retag(input: { frontmatter: Frontmatter; body: string; tags: readonly string[]; aliases: AliasMap }): {
frontmatter: Frontmatter;
body: string;
tags: readonly string[];
aliases: AliasMap;
now: Date;
}): { frontmatter: Frontmatter; body: string; originalTags: string[]; canonicalTags: string[] } {
originalTags: string[];
canonicalTags: string[];
} {
const originalTags = [...input.tags];
const canonicalTags = dedupeInOrder(originalTags.map((tag) => canonicalize(tag, input.aliases)));
return {
frontmatter: {
...input.frontmatter,
tags: canonicalTags,
updated: formatUtcTimestamp(input.now),
extra: { ...input.frontmatter.extra },
},
body: input.body,
Expand Down
Loading