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 @@ -17,6 +17,7 @@ docs/plans/

# Bundled skill helpers (generated by the agents build)
packages/agents/content/skills/kb-add/kb-add.mjs
packages/agents/content/skills/kb-edit/kb-edit.mjs
packages/agents/content/skills/kb-retrieve/kb-retrieve.mjs
packages/agents/content/skills/update-jira-ticket/update-jira-ticket.mjs

Expand Down
1 change: 1 addition & 0 deletions packages/agents/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dist/
# Generated esbuild bundles of skill helpers, not authored source.
content/skills/derive-session-context/derive-session-context.mjs
content/skills/kb-add/kb-add.mjs
content/skills/kb-edit/kb-edit.mjs
content/skills/kb-retrieve/kb-retrieve.mjs
content/skills/update-jira-ticket/update-jira-ticket.mjs

Expand Down
114 changes: 114 additions & 0 deletions packages/agents/content/skills/kb-edit/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
name: kb-edit
description: Mutate an existing knowledge-base note via a single mechanical operation — bump updated, mark verified, replace tags, append a section, or supersede with another note
user-invocable: true
---

# Edit an existing knowledge-base note

Apply a single mutation to a note that already exists in a knowledge base. A bundled helper does the mechanical work — it resolves the writable KB the note belongs to, loads the note, runs alias canonicalization where relevant, validates the resulting frontmatter against the destination schema, and writes atomically. You do the judgment work — pick which operation fits the change, supply the new tags or body content, and decide when supersession is the right move.

The split is deliberate: the helper is narrow and mechanical; the operation choice is wide and judgment-driven. Treat the helper as a guardrail. It refuses to write into a KB marked `readonly: true`, refuses notes that fail schema validation after the edit, and refuses to leave a half-finished supersede chain.

For new notes, use `kb-add`. For finding notes, use `kb-retrieve`. For periodic vault hygiene (broken wikilinks, stale verifications, tag drift), use `kb-curate` once it ships.

**Announce at start:** "Using kb-edit to {short description of the change}."

## Arguments

| Argument | Description | Required |
| ---------------------- | ------------------------------------------------------------------------------------- | -------- |
| `<path>` | Path to the existing note. Absolute or relative to the current working directory. | Yes |
| `--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 |
| `--supersede-with <p>` | Mark `<path>` superseded by `<p>`. Two-file atomic write; both notes bump `updated:`. | One op |

A value-bearing flag accepts both `--retag node,react` and `--retag=node,react`. Exactly one operation flag is required per invocation; combining two is rejected with `invalid-args`. The note body for `--append` is read from stdin to EOF; empty or whitespace-only stdin is rejected.

### KB selection

The destination knowledge base is inferred by walking up from the note's directory for a `.kb/` folder. There is no `--kb` override: the note's location is the selector. A KB whose `kb.yaml` entry sets `readonly: true` refuses writes with `readonly-kb`.

## Runtime dependencies

- **`node` ≥ 24** — the bundled helper inherits the Node version floor of `@codeassembly/kb-core`.

## Modes

- **Default mode**: Pick the operation, decide tags/body, present the proposed change to the user, run the helper after confirmation.
- **Auto mode (`--auto`)**: Pick the operation and supply inputs without asking. The agent never prompts in this mode.

The `--auto` flag is consumed by you, not by the bundled helper; it controls whether you present the proposal for confirmation before invoking the helper.

## Operations: when to use each

- **`--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`.
- **`--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.

## Process

### 1. Pick the operation

Identify which single operation fits the change. If you have several distinct changes in mind (retag and append, say), they become two separate invocations — the helper rejects combined flags.

### 2. Survey context with kb-retrieve when warranted

For `--retag` and `--supersede-with`, run `kb-retrieve` for related notes first: a retag is often a vault-wide pattern change worth applying consistently, and a supersession needs the right successor identified.

### 3. Present the proposal (default mode)

In default mode, present the note path, the operation, and the operation-specific inputs (new tags, addition body, supersede target). Wait for confirmation or a redirect. In auto mode, skip this step.

### 4. Invoke the helper

`--bump-updated`, `--verify`, `--retag`, and `--supersede-with` take no stdin:

```bash
node "$(dirname "$SKILL_PATH")/kb-edit.mjs" <path> --bump-updated
node "$(dirname "$SKILL_PATH")/kb-edit.mjs" <path> --verify
node "$(dirname "$SKILL_PATH")/kb-edit.mjs" <path> --retag "tag1,tag2"
node "$(dirname "$SKILL_PATH")/kb-edit.mjs" <old-path> --supersede-with <new-path>
```

`--append` reads the new body from stdin. A heredoc keeps the addition legible without quoting gymnastics:

```bash
cat <<'EOF' | node "$(dirname "$SKILL_PATH")/kb-edit.mjs" <path> --append
A new section appended to the existing body. The helper adds a separating
blank line and bumps `updated:` to today (UTC).
EOF
```

Or, when the skill directory is known:

```bash
node {platform_home_dir}/skills/kb-edit/kb-edit.mjs Tools/tmux/tmux-insights.md --verify
```

### 5. Handle the result

The helper prints a JSON object to stdout. On success the payload carries `ok: true`, the resolved `kb`, the written `frontmatter`, and (for `--retag`) the `originalTags` / `canonicalTags` audit trail. `--supersede-with` returns `oldFrontmatter` and `newFrontmatter` for both files.

On failure, `ok: false` plus a categorical `error` code:

| Code | What it means | What to do |
| -------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `invalid-args` | Missing/extra flags, unknown flag, empty `--append` stdin, or cross-KB supersede. | Correct the invocation. The message names the specific defect. |
| `no-kb-resolvable` | The note's path is not inside any discoverable `.kb/`. | Confirm the path; the note may live outside a KB or the wrong path was supplied. |
| `note-not-found` | The path does not exist. | Confirm the path. For `--supersede-with`, the _new_ path gets `supersede-target-missing` instead. |
| `note-parse` | The note's frontmatter block is malformed (YAML error, missing block, non-map). | Repair the frontmatter manually; the helper will not rewrite a note it cannot parse. |
| `schema-validation` | The resulting frontmatter does not pass the KB's schema. | Inspect `details.findings`; either fix the source note or declare a schema change. |
| `readonly-kb` | The note resolves into a KB marked `readonly: true` in `kb.yaml`. | Switch to a writable KB or update the registry entry intentionally. |
| `supersede-target-missing` | The `--supersede-with` target path does not exist. | Create the new note (use `kb-add`) before issuing the supersession. |
| `partial-supersede` | A `--supersede-with` write committed one side, the rollback also failed. | Inspect both paths in `details`; resolve the inconsistency manually before retrying. |

System failures (out-of-disk, permission denied) print to stderr and exit non-zero. They are out of band and never appear as a structured `error` code.

## Completion

A mutated note at the reported path (or two notes for `--supersede-with`) with frontmatter valid per the destination KB's schema, plus the canonicalization audit trail for `--retag` so the user can verify which alias tags were rewritten.
1 change: 1 addition & 0 deletions packages/agents/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default [
'content/skills/_platforms/**',
'content/skills/derive-session-context/derive-session-context.mjs',
'content/skills/kb-add/kb-add.mjs',
'content/skills/kb-edit/kb-edit.mjs',
'content/skills/kb-retrieve/kb-retrieve.mjs',
'content/skills/update-jira-ticket/update-jira-ticket.mjs',
]),
Expand Down
52 changes: 52 additions & 0 deletions packages/agents/scripts/bundle-skill-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export const targets: BundleTarget[] = [
entry: 'src/kb-add/cli.ts',
outFile: 'content/skills/kb-add/kb-add.mjs',
},
{
entry: 'src/kb-edit/cli.ts',
outFile: 'content/skills/kb-edit/kb-edit.mjs',
smokeTest: makeKbEditSmokeTest(),
},
{
entry: 'src/kb-retrieve/cli.ts',
outFile: 'content/skills/kb-retrieve/kb-retrieve.mjs',
Expand Down Expand Up @@ -137,6 +142,53 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

/**
* Stands up a fixture KB with a single seed note and returns a `SmokeTestInvocation` that runs the bundle with
* `--bump-updated` against it. Exercises the load → mutate → write-back pipeline end to end, which is the only
* code path that wires the bundled `parseNote`, schema loader, and atomic write together. `HOME` is overridden to
* 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.
*/
function makeKbEditSmokeTest(): SmokeTestInvocation {
const fixtureDir = mkdtempSync(path.join(tmpdir(), 'kb-edit-smoke-'));
mkdirSync(path.join(fixtureDir, '.kb'), { recursive: true });
const notePath = path.join(fixtureDir, 'Smoke.md');
writeFileSync(
notePath,
'---\ntitle: Smoke\ntype: howto\ncreated: 2026-05-01\nupdated: 2026-05-01\ntags: [smoke]\n---\n\nSmoke body.\n',
'utf8',
);
return {
args: [notePath, '--bump-updated'],
cwd: fixtureDir,
env: { ...process.env, HOME: fixtureDir },
assertResult: assertKbEditSmokeResult,
};
}

/** Assert the kb-edit smoke produced an ok bump-updated result with a today-shaped `updated:` field. */
function assertKbEditSmokeResult(result: unknown): void {
if (!isRecord(result)) {
throw new TypeError('expected object result from kb-edit');
}
if (result.ok !== true) {
throw new Error(`expected ok: true, got ${JSON.stringify(result)}`);
}
if (result.operation !== 'bump-updated') {
throw new Error(`expected operation 'bump-updated', got ${JSON.stringify(result.operation)}`);
}
const frontmatter = result.frontmatter;
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)}`);
}
}

/** Assert the parsed smoke-test result reports a composition-code-inline-mark finding. */
function assertCompositionViolationFinding(result: unknown): void {
if (!isRecord(result)) {
Expand Down
33 changes: 33 additions & 0 deletions packages/agents/src/kb-add/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,39 @@ describe(runAdd, () => {
expect(content).toBe('pre-existing\n');
});

it('returns readonly-kb when the explicit --kb names a readonly registry entry', async () => {
// Stand up an isolated HOME with a `.agents/kb.yaml` declaring the only writable target as readonly.
// runAdd resolves through resolveWritableKb, so the refusal surfaces as a top-level readonly-kb error
// without ever touching disk inside the KB.
const kbPath = await makeKb();
const homeDir = await mkdtemp(join(tmpdir(), 'kb-add-readonly-'));
await mkdir(join(homeDir, '.agents'), { recursive: true });
await writeFile(
join(homeDir, '.agents', 'kb.yaml'),
`kbs:\n locked:\n path: ${kbPath}\n readonly: true\n`,
'utf8',
);

const result = await runAdd({
argv: ['--kb', 'locked', '--type', 'howto', '--title', 'Refused'],
stdin: bodyStream(''),
// startDir avoids the KB so discovery does not produce a writable fallback.
startDir: homeDir,
now: NOW,
home: homeDir,
});

expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe('readonly-kb');
expect(result.details?.readonlyKbName).toBe('locked');
expect(result.details?.readonlyKbPath).toBe(kbPath);
}
// No note should have landed in the readonly KB.
const entries = await readdir(kbPath);
expect(entries.filter((name) => name !== '.kb')).toEqual([]);
});

it('returns invalid-args when --title is missing', async () => {
const kbPath = await makeKb();

Expand Down
42 changes: 29 additions & 13 deletions packages/agents/src/kb-add/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import type { AliasMap, KbRoot } from '@codeassembly/kb-core';
import { loadSchema } from '@codeassembly/kb-core/schema';
import { loadAliases } from '@codeassembly/kb-core/tags';

import { resolveWritableKb } from '../kb-shared/resolve-writable-kb.ts';
import { prepareNote } from './prepare-note.ts';
import { resolveKb } from './resolve-kb.ts';
import type { AddResult, ParsedArgs } from './types.ts';
import { writeNote } from './write-note.ts';

Expand Down Expand Up @@ -115,24 +115,40 @@ export async function runAdd(input: {
return { ok: false, error: 'invalid-args', message: error instanceof Error ? error.message : String(error) };
}

const resolved = await resolveKb({
const resolved = await resolveWritableKb({
startDir: input.startDir,
explicitKb: args.kb,
...(input.home !== undefined && { home: input.home }),
});
if (!resolved.ok) {
const failure: AddResult = {
ok: false,
error: 'no-kb-resolvable',
message:
resolved.requestedKb === null
? 'no .kb/ discovered, no registry default configured, and no --kb supplied'
: `--kb "${resolved.requestedKb}" does not match any registered knowledge base`,
};
if (resolved.requestedKb !== null) {
failure.details = { requestedKb: resolved.requestedKb };
switch (resolved.reason) {
case 'no-kb-resolvable': {
const failure: AddResult = {
ok: false,
error: 'no-kb-resolvable',
message:
resolved.requestedKb === null
? 'no .kb/ discovered, no registry default configured, and no --kb supplied'
: `--kb "${resolved.requestedKb}" does not match any registered knowledge base`,
};
if (resolved.requestedKb !== null) {
failure.details = { requestedKb: resolved.requestedKb };
}
return failure;
}
case 'readonly-kb':
return {
ok: false,
error: 'readonly-kb',
message: `knowledge base "${resolved.kbName}" is marked readonly in kb.yaml; writes are refused`,
details: { readonlyKbName: resolved.kbName, readonlyKbPath: resolved.kbPath },
};
default: {
// Exhaustiveness check: a new ResolveKbOutcome variant will surface here at compile time.
const _exhaustive: never = resolved;
throw new Error(`unhandled resolveWritableKb failure: ${JSON.stringify(_exhaustive)}`);
}
}
return failure;
}
const kb = resolved.kb;

Expand Down
20 changes: 1 addition & 19 deletions packages/agents/src/kb-add/prepare-note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { parseNoteContent, writeFrontmatter } from '@codeassembly/kb-core/frontm
import { frontmatterRule, runRules } from '@codeassembly/kb-core/rules';
import { canonicalize } from '@codeassembly/kb-core/tags';

import { dedupeInOrder, formatUtcDate } 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 Down Expand Up @@ -66,25 +67,6 @@ export function prepareNote(input: { args: ParsedArgs; schema: Schema; aliases:

// region | Helpers

/** Returns `values` with duplicate entries dropped, preserving first-occurrence order. */
function dedupeInOrder(values: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (seen.has(value)) {
continue;
}
seen.add(value);
result.push(value);
}
return result;
}

/** Formats a `Date` as a UTC `YYYY-MM-DD` string. */
function formatUtcDate(date: Date): string {
return date.toISOString().slice(0, 10);
}

/** Renders the frontmatter to a note string, re-parses it, and runs the frontmatter rule against the parsed shape. */
function validate(input: { frontmatter: Frontmatter; schema: Schema }): Finding[] {
const rendered = writeFrontmatter({ frontmatter: input.frontmatter, body: '' });
Expand Down
Loading
Loading