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
17 changes: 8 additions & 9 deletions packages/agents/content/skills/kb-add/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,13 @@ The split is deliberate: the helper is narrow and mechanical; the classification

## Arguments

| Argument | Description | Required |
| ----------------- | --------------------------------------------------------------------------------------- | -------- |
| `--diataxis` | The note's Diátaxis label (e.g. `howto`, `concept`, `reference`, `tutorial`). | No |
| `--title` | The note title; also doubles as the filename. | Yes |
| `--kb` | Explicit knowledge base name; overrides the discovered `.kb/` and the registry default. | No |
| `--folder` | KB-relative folder under which to write the note. Defaults to the KB root. | No |
| `--tags` | Comma-separated tag list. Known aliases are canonicalized at write time. | No |
| `--last-verified` | `YYYY-MM-DD` date the note's claims were last verified. | No |
| Argument | Description | Required |
| ------------ | --------------------------------------------------------------------------------------- | -------- |
| `--diataxis` | The note's Diátaxis label (e.g. `howto`, `concept`, `reference`, `tutorial`). | No |
| `--title` | The note title; also doubles as the filename. | Yes |
| `--kb` | Explicit knowledge base name; overrides the discovered `.kb/` and the registry default. | No |
| `--folder` | KB-relative folder under which to write the note. Defaults to the KB root. | No |
| `--tags` | Comma-separated tag list. Known aliases are canonicalized at write time. | No |

A value-bearing flag accepts both `--diataxis howto` and `--diataxis=howto`. The note body is read from stdin to EOF; an empty body is allowed when a stub note is appropriate.

Expand Down Expand Up @@ -79,7 +78,7 @@ Pipe the composed body to the bundled helper. A heredoc keeps multi-line bodies
cat <<'EOF' | node "$(dirname "$SKILL_PATH")/kb-add.mjs" \
--diataxis <label> --title "<title>" \
[--kb <name>] [--folder <kb-relative-folder>] \
[--tags <comma,separated>] [--last-verified YYYY-MM-DD]
[--tags <comma,separated>]
<note body, may span multiple lines and contain any characters>
EOF
```
Expand Down
25 changes: 12 additions & 13 deletions packages/agents/scripts/bundle-skill-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Build step: bundle each skill's TypeScript helper into a single self-contained `.mjs` placed inside
* Build step: Bundle each skill's TypeScript helper into a single self-contained `.mjs` placed inside
* the skill's content directory.
*
* A skill installs to a platform directory outside the monorepo, so it cannot import a private workspace package.
Expand Down Expand Up @@ -186,7 +186,7 @@ function makeDeriveSessionContextSmokeTest(): SmokeTestInvocation {
};
}

/** Assert the deriver emitted the expected field set for the smoke fixture. */
/** Asserts that the deriver emitted the expected field set for the smoke fixture. */
function assertDeriveSessionContextOutput(result: unknown): void {
if (!isRecord(result)) {
throw new TypeError('expected object result from derive-session-context');
Expand Down Expand Up @@ -217,11 +217,10 @@ function assertDeriveSessionContextOutput(result: unknown): void {
}

/**
* Type guard: narrows `value` to a plain object with unknown property values.
* Type guard: Narrows `value` to a plain object with unknown property values.
*
* Intentionally kept local rather than imported from `src/lib/type-guards.ts`: this build
* script sits outside the runtime surface, and importing runtime source into build tooling
* would couple the two for the sake of a one-line guard.
* Intentionally kept local rather than imported from `src/lib/type-guards.ts`: This build script sits outside the
* runtime surface; importing runtime source into build tooling would couple the two for the sake of a one-line guard.
*/
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
Expand All @@ -234,8 +233,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
* the fixture dir so the dev's real `~/.claude/kb.yaml` does not pollute KB resolution.
*
* The fixture is process-lifetime — `mkdtempSync` runs at module load and the OS reclaims short-lived temp
* directories without explicit cleanup. The seed note's `updated:` field is bumped by every invocation;
* `--bump-updated` is idempotent at the field level so re-runs within the same day are no-ops on disk.
* directories without explicit cleanup. The seed note's `updated:` field is rewritten to the current instant
* on every invocation.
*/
function makeKbEditSmokeTest(): SmokeTestInvocation {
const fixtureDir = mkdtempSync(path.join(tmpdir(), 'kb-edit-smoke-'));
Expand All @@ -254,7 +253,7 @@ function makeKbEditSmokeTest(): SmokeTestInvocation {
};
}

/** Assert the kb-edit smoke produced an ok bump-updated result with a today-shaped `updated:` field. */
/** Asserts that the kb-edit smoke produced an ok bump-updated result with second-precision UTC `updated:` timestamp. */
function assertKbEditSmokeResult(result: unknown): void {
if (!isRecord(result)) {
throw new TypeError('expected object result from kb-edit');
Expand All @@ -269,8 +268,8 @@ function assertKbEditSmokeResult(result: unknown): void {
if (!isRecord(frontmatter)) {
throw new TypeError('expected frontmatter object on kb-edit result');
}
if (typeof frontmatter.updated !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(frontmatter.updated)) {
throw new Error(`expected updated to be YYYY-MM-DD, got ${JSON.stringify(frontmatter.updated)}`);
if (typeof frontmatter.updated !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(frontmatter.updated)) {
throw new Error(`expected updated to be YYYY-MM-DDTHH:MM:SSZ, got ${JSON.stringify(frontmatter.updated)}`);
}
}

Expand Down Expand Up @@ -301,8 +300,8 @@ function makeKbCurateSmokeTest(): SmokeTestInvocation {
}

/**
* Assert the kb-curate smoke produced an ok read-only report that actually enumerated the seed note. The seed note
* carries an unresolved wikilink, so a non-empty enumeration always surfaces a `wikilinks.unresolved` finding; its
* Asserts that the kb-curate smoke produced an ok read-only report that actually enumerated the seed note. The seed
* note carries an unresolved wikilink, so a non-empty enumeration always surfaces a `wikilinks.unresolved` finding; its
* absence means the bundle enumerated nothing — a broken `content/` scoping must fail here rather than pass with an
* empty report.
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/agents/src/capture-event/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ describe(normalizeRemoteUrl, () => {
});

describe(runCapture, () => {
it('writes recordType: event and returns a ULID id and ISO capturedAt', async () => {
it('writes recordType: event and returns a ULID id and second-precision capturedAt', async () => {
const { home } = await makeStore('codeassembly');
const repo = await makeRepoWithRemote('git@github.com:williamthorsen/codeassembly.git');

Expand All @@ -140,7 +140,7 @@ describe(runCapture, () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(result.capturedAt).toBe('2026-06-04T06:57:22.000Z');
expect(result.capturedAt).toBe('2026-06-04T06:57:22Z');
expect(result.store).toBe('codeassembly');
const written = await readFile(result.path, 'utf8');
expect(written).toMatch(/^recordType: event$/m);
Expand Down
3 changes: 2 additions & 1 deletion packages/agents/src/capture-event/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { KbRoot } from '@codeassembly/kb';
import { loadSchema } from '@codeassembly/kb/schema';
import { ulid } from 'ulid';

import { formatUtcTimestamp } from '../kb-shared/note-helpers.ts';
import { resolveStoreByName } from '../kb-shared/resolve-store-by-name.ts';
import { isEnoent } from '../lib/type-guards.ts';
import { prepareEvent } from './prepare-event.ts';
Expand Down Expand Up @@ -113,7 +114,7 @@ export async function runCapture(input: {
args,
context,
id: ulid(),
capturedAt: input.now.toISOString(),
capturedAt: formatUtcTimestamp(input.now),
schema,
body,
});
Expand Down
12 changes: 7 additions & 5 deletions packages/agents/src/kb-add/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async function runChild(input: {
}

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

/** Build a Readable stream that emits the given body and ends. */
function bodyStream(body: string): Readable {
Expand All @@ -66,8 +66,6 @@ describe(parseArgs, () => {
'My note',
'--tags',
'one, two,three',
'--last-verified',
'2026-01-15',
]);

expect(parsed).toEqual({
Expand All @@ -76,7 +74,6 @@ describe(parseArgs, () => {
diataxis: 'howto',
title: 'My note',
tags: ['one', 'two', 'three'],
lastVerified: '2026-01-15',
});
});

Expand All @@ -94,7 +91,6 @@ describe(parseArgs, () => {
expect(parsed.kb).toBeNull();
expect(parsed.folder).toBeNull();
expect(parsed.tags).toEqual([]);
expect(parsed.lastVerified).toBeNull();
});

it('defaults --diataxis to null when omitted', () => {
Expand All @@ -118,6 +114,10 @@ describe(parseArgs, () => {
it('rejects the retired --type flag as unknown', () => {
expect(() => parseArgs(['--type', 'howto', '--title', 'X'])).toThrow(/unknown flag/);
});

it('rejects the retired --last-verified flag as unknown', () => {
expect(() => parseArgs(['--last-verified', '2026-01-15', '--title', 'X'])).toThrow(/unknown flag/);
});
});

describe(runAdd, () => {
Expand All @@ -138,6 +138,8 @@ describe(runAdd, () => {
expect(result.kb.source).toBe('discovered');
expect(result.frontmatter.title).toBe('Working with streams');
expect(result.frontmatter.created).toBe(TODAY);
expect(result.frontmatter.updated).toBe(TODAY);
expect(result.frontmatter.extra['last-verified']).toBe(TODAY);
const content = await readFile(result.path, 'utf8');
expect(content).toContain('title: Working with streams');
expect(content).toContain('How to work with Node streams.');
Expand Down
30 changes: 5 additions & 25 deletions packages/agents/src/kb-add/__tests__/prepare-note.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,14 @@ import { prepareNote } from '../prepare-note.ts';
import type { ParsedArgs } from '../types.ts';

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

const baseArgs: ParsedArgs = {
kb: null,
folder: null,
diataxis: 'howto',
title: 'Working with Node streams',
tags: ['streams'],
lastVerified: null,
};

const emptyAliases: AliasMap = new Map();
Expand Down Expand Up @@ -55,13 +54,14 @@ describe(prepareNote, () => {
}
});

it('fills in UTC created and updated dates from now', () => {
it('stamps created, updated, and last-verified from one second-precision instant', () => {
const result = prepareNote({ args: baseArgs, schema: defaultSchema, aliases: emptyAliases, now: NOW });

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.prepared.frontmatter.created).toBe(TODAY);
expect(result.prepared.frontmatter.updated).toBe(TODAY);
expect(result.prepared.frontmatter.extra['last-verified']).toBe(TODAY);
}
});

Expand Down Expand Up @@ -102,18 +102,8 @@ describe(prepareNote, () => {
}
});

it('emits last-verified into the extra map when supplied', () => {
const args: ParsedArgs = { ...baseArgs, lastVerified: '2026-01-15' };
const result = prepareNote({ args, schema: defaultSchema, aliases: emptyAliases, now: NOW });

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.prepared.frontmatter.extra['last-verified']).toBe('2026-01-15');
}
});

it('round-trips: rendered frontmatter re-parses back to the prepared shape', () => {
const args: ParsedArgs = { ...baseArgs, lastVerified: '2026-01-15', tags: ['node.js', 'streams'] };
const args: ParsedArgs = { ...baseArgs, tags: ['node.js', 'streams'] };
const result = prepareNote({ args, schema: defaultSchema, aliases, now: NOW });

expect(result.ok).toBe(true);
Expand All @@ -126,18 +116,8 @@ describe(prepareNote, () => {
created: TODAY,
updated: TODAY,
tags: ['nodejs', 'streams'],
extra: { diataxis: 'howto', 'last-verified': '2026-01-15' },
extra: { diataxis: 'howto', 'last-verified': TODAY },
});
}
});

it('refuses to proceed when last-verified is not a valid YYYY-MM-DD date', () => {
const args: ParsedArgs = { ...baseArgs, lastVerified: '2026-99-99' };
const result = prepareNote({ args, schema: defaultSchema, aliases: emptyAliases, now: NOW });

expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.findings.some((finding) => finding.rule === 'frontmatter.date')).toBe(true);
}
});
});
5 changes: 2 additions & 3 deletions packages/agents/src/kb-add/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { AddResult, ParsedArgs } from './types.ts';
import { writeNote } from './write-note.ts';

/** Flag names that take a value. */
const VALUE_FLAGS = ['kb', 'folder', 'diataxis', 'title', 'tags', 'last-verified'] as const;
const VALUE_FLAGS = ['kb', 'folder', 'diataxis', 'title', 'tags'] as const;
type ValueFlag = (typeof VALUE_FLAGS)[number];

/** Executes the helper from `process.argv` and writes the JSON result to stdout. */
Expand Down Expand Up @@ -85,7 +85,6 @@ export function parseArgs(argv: readonly string[]): ParsedArgs {
diataxis: raw.diataxis ?? null,
title,
tags: raw.tags === undefined ? [] : parseTagList(raw.tags),
lastVerified: raw['last-verified'] ?? null,
};
}

Expand Down Expand Up @@ -224,7 +223,7 @@ function isEntryPoint(): boolean {
}
}

/** Matches a `--kb`/`--folder`/`--title`/`--tags`/`--last-verified` flag (and the optional `--diataxis` Diátaxis label), returning its key and any inline `=value`. */
/** Matches a `--kb`/`--folder`/`--title`/`--tags` flag (and the optional `--diataxis` Diátaxis label), returning its key and any inline `=value`. */
function matchValueFlag(arg: string): { key: ValueFlag; inlineValue: string | null } | null {
for (const key of VALUE_FLAGS) {
if (arg === `--${key}`) {
Expand Down
18 changes: 8 additions & 10 deletions packages/agents/src/kb-add/prepare-note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { parseNoteContent, writeFrontmatter } from '@codeassembly/kb/frontmatter
import { frontmatterRule, runRules } from '@codeassembly/kb/rules';
import { canonicalize } from '@codeassembly/kb/tags';

import { dedupeInOrder, formatUtcDate } from '../kb-shared/note-helpers.ts';
import { dedupeInOrder, formatUtcTimestamp } from '../kb-shared/note-helpers.ts';
import type { ParsedArgs, PreparedNote } from './types.ts';

/** Successful preparation: a fully-typed `Frontmatter` plus the canonicalization audit trail. */
Expand All @@ -22,11 +22,11 @@ export interface PrepareFailure {
export type PrepareOutcome = PrepareSuccess | PrepareFailure;

/**
* Composes a typed `Frontmatter` from parsed CLI args, fills in UTC `created` and `updated` dates, canonicalizes
* tags via the supplied alias map, and validates the result against the destination KB's schema using the
* `frontmatterRule` from kb. Every note carries `recordType: assertion` as the stored discriminant — `kb-add` only
* writes assertions. Any Diátaxis label the agent supplies via `--diataxis` is a vault facet, written to the note's
* `extra` fields rather than a top-level field.
* Composes a typed `Frontmatter` from parsed CLI args, stamps `created`, `updated`, and `last-verified` from one
* second-precision UTC instant so the note is born verified, canonicalizes tags via the supplied alias map, and
* validates the result against the destination KB's schema using the `frontmatterRule` from kb. Every note carries
* `recordType: assertion` as the stored discriminant — `kb-add` only writes assertions. Any Diátaxis label the agent
* supplies via `--diataxis` is a vault facet, written to the note's `extra` fields rather than a top-level field.
*
* Validation is performed by round-tripping the rendered frontmatter through `parseNoteContent` and feeding the parsed
* note through `runRules`. The round trip is the cheapest way to give the rule a real `ParsedNote` carrying valid
Expand All @@ -42,15 +42,13 @@ export function prepareNote(input: { args: ParsedArgs; schema: Schema; aliases:
// original list intact for the audit trail and deduplicate the written tag list in first-occurrence order so the
// note doesn't ship `['nodejs', 'nodejs']`.
const canonicalTags = dedupeInOrder(originalTags.map((tag) => canonicalize(tag, aliases)));
const today = formatUtcDate(now);
const today = formatUtcTimestamp(now);

const extra: Record<string, unknown> = {};
if (args.diataxis !== null) {
extra.diataxis = args.diataxis;
}
if (args.lastVerified !== null) {
extra['last-verified'] = args.lastVerified;
}
extra['last-verified'] = today;

const frontmatter: Frontmatter = {
title: args.title,
Expand Down
4 changes: 1 addition & 3 deletions packages/agents/src/kb-add/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,11 @@ export interface ParsedArgs {
title: string;
/** The proposed tag list, in the order the agent supplied them. */
tags: string[];
/** Optional `last-verified` date (`YYYY-MM-DD`). */
lastVerified: string | null;
}

/** The prepared note ready to be written: the rendered frontmatter, the body, and the canonicalization audit trail. */
export interface PreparedNote {
/** Frontmatter with canonical tags, UTC dates filled in, and the optional `last-verified` field merged into `extra`. */
/** Frontmatter with canonical tags and a born-verified triad: `created`, `updated`, and the `extra['last-verified']` field stamped from one instant. */
frontmatter: Frontmatter;
/** Tag list as the agent supplied it, before alias canonicalization. */
originalTags: string[];
Expand Down
2 changes: 1 addition & 1 deletion packages/agents/src/kb-edit/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest';
import { parseArgs, runEdit } from '../cli.ts';

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

const SAMPLE_NOTE = `---
title: Sample
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe(append, () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.body).toBe('First paragraph.\n\nSecond paragraph.\n');
expect(result.frontmatter.updated).toBe('2026-05-24');
expect(result.frontmatter.updated).toBe('2026-05-24T14:35:00Z');
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe(bumpUpdated, () => {
it('sets updated to today (UTC) and leaves other fields untouched', () => {
const result = bumpUpdated({ frontmatter: frontmatter(), body: 'unchanged body', now: NOW });

expect(result.frontmatter.updated).toBe('2026-05-24');
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.tags).toEqual(['example']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe(retag, () => {
});

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

it('returns originalTags and canonicalTags for audit', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ describe(prepareSupersedeWith, () => {
now: NOW,
});

expect(result.old.frontmatter.updated).toBe('2026-05-24');
expect(result.new.frontmatter.updated).toBe('2026-05-24');
expect(result.old.frontmatter.updated).toBe('2026-05-24T14:35:00Z');
expect(result.new.frontmatter.updated).toBe('2026-05-24T14:35:00Z');
});

it('writes KB-relative pointers when notes are in subfolders', () => {
Expand Down
Loading
Loading