diff --git a/packages/agents/content/skills/kb-edit/SKILL.md b/packages/agents/content/skills/kb-edit/SKILL.md index e4aac5e5..18a2425f 100644 --- a/packages/agents/content/skills/kb-edit/SKILL.md +++ b/packages/agents/content/skills/kb-edit/SKILL.md @@ -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 ` | Replace `tags:` with the comma-separated list. Canonicalizes; bumps `updated:`. | One op | +| `--retag ` | Replace `tags:` with the comma-separated list. Canonicalizes; does **not** bump `updated:`. | One op | | `--add-addressed-by ` | Append comma-separated reference(s) to each target's `addressed-by` list; bumps `updated:`. | One op | | `--supersede-with

` | Mark `` superseded by `

`. Two-file atomic write; both notes bump `updated:`. | One op | @@ -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 diff --git a/packages/agents/src/kb-edit/__tests__/cli.test.ts b/packages/agents/src/kb-edit/__tests__/cli.test.ts index 601958c4..8f407301 100644 --- a/packages/agents/src/kb-edit/__tests__/cli.test.ts +++ b/packages/agents/src/kb-edit/__tests__/cli.test.ts @@ -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 --- @@ -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({ @@ -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'); } }); @@ -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', ); @@ -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', ); @@ -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'); @@ -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', ); diff --git a/packages/agents/src/kb-edit/__tests__/write-back.test.ts b/packages/agents/src/kb-edit/__tests__/write-back.test.ts index 015c2bff..3b6427b5 100644 --- a/packages/agents/src/kb-edit/__tests__/write-back.test.ts +++ b/packages/agents/src/kb-edit/__tests__/write-back.test.ts @@ -19,8 +19,8 @@ function validFrontmatter(overrides: Partial = {}): 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, @@ -88,7 +88,7 @@ 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({ @@ -96,8 +96,8 @@ describe(writeBackNote, () => { 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' }, }, diff --git a/packages/agents/src/kb-edit/cli.ts b/packages/agents/src/kb-edit/cli.ts index 3bd355a7..25051540 100644 --- a/packages/agents/src/kb-edit/cli.ts +++ b/packages/agents/src/kb-edit/cli.ts @@ -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': { diff --git a/packages/agents/src/kb-edit/operations/__tests__/add-addressed-by.test.ts b/packages/agents/src/kb-edit/operations/__tests__/add-addressed-by.test.ts index c50969bb..cb04b2f9 100644 --- a/packages/agents/src/kb-edit/operations/__tests__/add-addressed-by.test.ts +++ b/packages/agents/src/kb-edit/operations/__tests__/add-addressed-by.test.ts @@ -10,8 +10,8 @@ function frontmatter(overrides: Partial = {}): 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, @@ -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'); }); }); diff --git a/packages/agents/src/kb-edit/operations/__tests__/append.test.ts b/packages/agents/src/kb-edit/operations/__tests__/append.test.ts index 2ae605e3..13ae5051 100644 --- a/packages/agents/src/kb-edit/operations/__tests__/append.test.ts +++ b/packages/agents/src/kb-edit/operations/__tests__/append.test.ts @@ -9,8 +9,8 @@ function frontmatter(overrides: Partial = {}): 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, diff --git a/packages/agents/src/kb-edit/operations/__tests__/bump-updated.test.ts b/packages/agents/src/kb-edit/operations/__tests__/bump-updated.test.ts index f9741389..5bb405a1 100644 --- a/packages/agents/src/kb-edit/operations/__tests__/bump-updated.test.ts +++ b/packages/agents/src/kb-edit/operations/__tests__/bump-updated.test.ts @@ -9,8 +9,8 @@ function frontmatter(overrides: Partial = {}): 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, @@ -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', () => { diff --git a/packages/agents/src/kb-edit/operations/__tests__/retag.test.ts b/packages/agents/src/kb-edit/operations/__tests__/retag.test.ts index 76ed8ba1..61a0ef29 100644 --- a/packages/agents/src/kb-edit/operations/__tests__/retag.test.ts +++ b/packages/agents/src/kb-edit/operations/__tests__/retag.test.ts @@ -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 { 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, @@ -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', () => { @@ -43,7 +40,6 @@ describe(retag, () => { body: 'b', tags: ['node.js', 'react'], aliases: NODE_ALIASES, - now: NOW, }); expect(result.originalTags).toEqual(['node.js', 'react']); @@ -58,7 +54,6 @@ describe(retag, () => { body: 'b', tags: ['node.js', 'react', 'node'], aliases: NODE_ALIASES, - now: NOW, }); expect(result.canonicalTags).toEqual(['nodejs', 'react']); @@ -70,7 +65,6 @@ describe(retag, () => { body: 'b', tags: [], aliases: NO_ALIASES, - now: NOW, }); expect(result.frontmatter.tags).toEqual([]); @@ -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']); }); diff --git a/packages/agents/src/kb-edit/operations/__tests__/supersede-with.test.ts b/packages/agents/src/kb-edit/operations/__tests__/supersede-with.test.ts index 0f8e1143..0e9dfed8 100644 --- a/packages/agents/src/kb-edit/operations/__tests__/supersede-with.test.ts +++ b/packages/agents/src/kb-edit/operations/__tests__/supersede-with.test.ts @@ -12,8 +12,8 @@ function note(input: { name: string; tags?: string[]; extra?: Record { 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, @@ -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'); }); }); diff --git a/packages/agents/src/kb-edit/operations/__tests__/verify.test.ts b/packages/agents/src/kb-edit/operations/__tests__/verify.test.ts index 42ab0227..df0a79ea 100644 --- a/packages/agents/src/kb-edit/operations/__tests__/verify.test.ts +++ b/packages/agents/src/kb-edit/operations/__tests__/verify.test.ts @@ -9,8 +9,8 @@ function frontmatter(overrides: Partial = {}): 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, @@ -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', () => { @@ -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 }); diff --git a/packages/agents/src/kb-edit/operations/retag.ts b/packages/agents/src/kb-edit/operations/retag.ts index 809771d5..2a58f678 100644 --- a/packages/agents/src/kb-edit/operations/retag.ts +++ b/packages/agents/src/kb-edit/operations/retag.ts @@ -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,