From 5ad3f290e75b96708c50b76fdfeecb0dce9d2d4b Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 07:48:30 -0700 Subject: [PATCH 1/9] agents|feat: Attach causes to every error that wraps a caught failure An error the CLI raises while wrapping a caught failure now carries that failure as its cause, so a stack trace reaches the original error rather than stopping at the diagnostic that replaced it. Wrapped messages join the underlying failure with a colon, replacing the em-dash two of them used. --- packages/agents/package.json | 1 + packages/agents/src/commands/validate.ts | 5 ++- .../__tests__/read-preferences.unit.test.ts | 8 ++++ .../read-preferences.ts | 4 +- .../kb-search/__tests__/recall.unit.test.ts | 9 ++++ packages/agents/src/kb-search/recall.ts | 2 +- .../lib/__tests__/path-rewriter.unit.test.ts | 19 ++++++++- .../__tests__/preferences-schema.unit.test.ts | 4 +- .../__tests__/rulebook-deploy.unit.test.ts | 41 +++++++++++++++++++ .../__tests__/work-types-schema.unit.test.ts | 4 +- .../agents/src/lib/claude-hook-settings.ts | 5 ++- .../agents/src/lib/codeassembly-schema.ts | 4 +- packages/agents/src/lib/package-sources.ts | 4 +- packages/agents/src/lib/path-rewriter.ts | 5 ++- packages/agents/src/lib/rulebook-deploy.ts | 2 +- .../__tests__/branch-helpers.tool.test.ts | 4 ++ packages/agents/src/shared/branch-helpers.ts | 5 ++- pnpm-lock.yaml | 3 ++ 18 files changed, 108 insertions(+), 21 deletions(-) create mode 100644 packages/agents/src/lib/__tests__/rulebook-deploy.unit.test.ts diff --git a/packages/agents/package.json b/packages/agents/package.json index 93e1dcd3..d55ca537 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "@williamthorsen/kb": "workspace:*", + "@williamthorsen/toolbelt.errors": "0.2.0", "codeassembly-lifecycle": "workspace:*", "ulid": "3.0.2", "yaml": "2.9.0", diff --git a/packages/agents/src/commands/validate.ts b/packages/agents/src/commands/validate.ts index ca4378bd..8be25a35 100644 --- a/packages/agents/src/commands/validate.ts +++ b/packages/agents/src/commands/validate.ts @@ -2,6 +2,8 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; +import { chainError } from '@williamthorsen/toolbelt.errors/candidate'; + import { type ContentDefect, validateContentRoot } from '../lib/content-validation.ts'; import { ALL_HARNESS_IDS } from '../lib/harness.ts'; import { findContentPath } from '../lib/package-sources.ts'; @@ -99,8 +101,7 @@ function parseManifest(manifestPath: string, raw: string): unknown { try { return JSON.parse(raw); } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Cannot read ${manifestPath}: ${message}`, { cause: error }); + throw chainError(`Cannot read ${manifestPath}`, error); } } diff --git a/packages/agents/src/derive-session-context/__tests__/read-preferences.unit.test.ts b/packages/agents/src/derive-session-context/__tests__/read-preferences.unit.test.ts index 2098901a..5e62f89f 100644 --- a/packages/agents/src/derive-session-context/__tests__/read-preferences.unit.test.ts +++ b/packages/agents/src/derive-session-context/__tests__/read-preferences.unit.test.ts @@ -65,6 +65,14 @@ describe(readPreferences, () => { await expect(readPreferences({ cwd: projectDir, home: homeDir })).rejects.toThrow(/malformed YAML/); }); + it('attaches the YAML parse failure as the cause', async () => { + await writeProjectYaml(projectDir, 'project:\n slug: [unclosed list\n'); + await expect(readPreferences({ cwd: projectDir, home: homeDir })).rejects.toHaveProperty( + 'cause', + expect.any(Error), + ); + }); + it('tolerates unknown top-level keys', async () => { await writeProjectYaml(projectDir, 'mystery_key: "mystery value"\n'); const result = await readPreferences({ cwd: projectDir, home: homeDir }); diff --git a/packages/agents/src/derive-session-context/read-preferences.ts b/packages/agents/src/derive-session-context/read-preferences.ts index aef6b4ab..a8a92bbe 100644 --- a/packages/agents/src/derive-session-context/read-preferences.ts +++ b/packages/agents/src/derive-session-context/read-preferences.ts @@ -11,6 +11,7 @@ import { homedir } from 'node:os'; import path from 'node:path'; import process from 'node:process'; +import { chainError } from '@williamthorsen/toolbelt.errors/candidate'; import { parse as parseYaml } from 'yaml'; import { isEnoent, isRecord } from '../lib/type-guards.ts'; @@ -69,8 +70,7 @@ async function readOptionalYaml(filePath: string): Promise<{ value: unknown } | try { parsed = parseYaml(text); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`${filePath}: malformed YAML — ${message}`); + throw chainError(`${filePath}: malformed YAML`, error); } // An empty document parses to `null`; treat it as "file present but empty". return { value: parsed ?? {} }; diff --git a/packages/agents/src/kb-search/__tests__/recall.unit.test.ts b/packages/agents/src/kb-search/__tests__/recall.unit.test.ts index 0f4c8606..f7568ab7 100644 --- a/packages/agents/src/kb-search/__tests__/recall.unit.test.ts +++ b/packages/agents/src/kb-search/__tests__/recall.unit.test.ts @@ -101,6 +101,15 @@ describe(recallNotes, () => { ); }); + it('attaches the spawn failure as the cause of the remediation hint', async () => { + const runner = vi.fn().mockRejectedValue(buildProcessError('ENOENT')); + + await expect(recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner })).rejects.toHaveProperty( + 'cause', + expect.any(Error), + ); + }); + it('rethrows a ripgrep failure that is neither a no-match exit nor an absent binary', async () => { const runner = vi.fn().mockRejectedValue(buildProcessError(2)); diff --git a/packages/agents/src/kb-search/recall.ts b/packages/agents/src/kb-search/recall.ts index 5a193faa..770dc02c 100644 --- a/packages/agents/src/kb-search/recall.ts +++ b/packages/agents/src/kb-search/recall.ts @@ -224,7 +224,7 @@ async function runRipgrep(input: { pattern: string; searchDir: string; runner: P return ''; } if (isMissingBinary(error)) { - throw new Error('kb-retrieve requires ripgrep (`rg`) on PATH. Install it and retry.'); + throw new Error('kb-retrieve requires ripgrep (`rg`) on PATH. Install it and retry.', { cause: error }); } throw error; } diff --git a/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts b/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts index 44a32c66..09a48f15 100644 --- a/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts +++ b/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts @@ -9,8 +9,9 @@ import { isRewritableLinkTarget, rewriteMarkdownPaths, rewritePathsInDirectory, + rewritePathsInFile, rewriteTemplateVariables, -} from '../path-rewriter.js'; +} from '../path-rewriter.ts'; describe(isRewritableLinkTarget, () => { it.each(['../_data/concision.md', './modules/review-cycle.md', 'SKILL.md', 'scripts/run.sh'])( @@ -163,6 +164,22 @@ describe(rewriteTemplateVariables, () => { }); }); +describe(rewritePathsInFile, () => { + const absentPath = path.join(tmpdir(), 'path-rewriter-absent.md'); + + it('names the file it could not rewrite', async () => { + await expect(rewritePathsInFile(absentPath, 'absent.md', '.claude/skills', '.claude', 'claude')).rejects.toThrow( + /Failed to rewrite paths in/, + ); + }); + + it('attaches the read failure as the cause', async () => { + await expect( + rewritePathsInFile(absentPath, 'absent.md', '.claude/skills', '.claude', 'claude'), + ).rejects.toHaveProperty('cause', expect.any(Error)); + }); +}); + describe(rewritePathsInDirectory, () => { let tempDir: string; let skillsDestDir: string; diff --git a/packages/agents/src/lib/__tests__/preferences-schema.unit.test.ts b/packages/agents/src/lib/__tests__/preferences-schema.unit.test.ts index b051af88..25653b10 100644 --- a/packages/agents/src/lib/__tests__/preferences-schema.unit.test.ts +++ b/packages/agents/src/lib/__tests__/preferences-schema.unit.test.ts @@ -8,6 +8,7 @@ import { FLAG, registerSchema, validate } from '@hyperjump/json-schema/draft-202 // failure path below, never as part of an assertion. The stable per-dialect API is used for all // pass/fail assertions. import { BASIC } from '@hyperjump/json-schema/experimental'; +import { chainError } from '@williamthorsen/toolbelt.errors/candidate'; import { describe, expect, it } from 'vitest'; import { parse as parseYaml } from 'yaml'; @@ -167,8 +168,7 @@ function parseSchemaFile(filePath: string): JsonSchemaDraft202012Object { // `JSON.parse` returns `any`; the typed local variable narrows without a type assertion. parsed = JSON.parse(text); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse schema at ${filePath}: ${message}`); + throw chainError(`Failed to parse schema at ${filePath}`, error); } return parsed; } diff --git a/packages/agents/src/lib/__tests__/rulebook-deploy.unit.test.ts b/packages/agents/src/lib/__tests__/rulebook-deploy.unit.test.ts new file mode 100644 index 00000000..157ce37c --- /dev/null +++ b/packages/agents/src/lib/__tests__/rulebook-deploy.unit.test.ts @@ -0,0 +1,41 @@ +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import type { SourceResolver } from '../content-sources.ts'; +import { resolveRulebook } from '../rulebook-deploy.ts'; + +const ABSENT_DIR = path.join(tmpdir(), 'rulebook-deploy-absent-source'); + +describe(resolveRulebook, () => { + it('names the origin and the path when the resolved source carries no rulebook file', async () => { + await expect(resolveRulebook('ghost', buildAlwaysResolvingResolver(ABSENT_DIR))).rejects.toThrow( + /Declared rulebook "ghost" was not found in the library/, + ); + }); + + it('attaches the read failure as the cause', async () => { + await expect(resolveRulebook('ghost', buildAlwaysResolvingResolver(ABSENT_DIR))).rejects.toHaveProperty( + 'cause', + expect.any(Error), + ); + }); +}); + +// region | Helpers + +/** + * Builds a resolver that reports every slug as resolving under `dir` without probing for the file. This is the + * only way to reach the read failure: the real resolver resolves by existence, so it never hands back a directory + * whose frontmatter file is missing. + */ +function buildAlwaysResolvingResolver(dir: string): SourceResolver { + return { + libraryDir: dir, + sources: [], + resolve: () => Promise.resolve({ dir, source: undefined }), + }; +} + +// endregion | Helpers diff --git a/packages/agents/src/lib/__tests__/work-types-schema.unit.test.ts b/packages/agents/src/lib/__tests__/work-types-schema.unit.test.ts index 13922b15..187ee0ca 100644 --- a/packages/agents/src/lib/__tests__/work-types-schema.unit.test.ts +++ b/packages/agents/src/lib/__tests__/work-types-schema.unit.test.ts @@ -8,6 +8,7 @@ import { FLAG, registerSchema, validate } from '@hyperjump/json-schema/draft-202 // failure path below, never as part of an assertion. The stable per-dialect API is used for all // pass/fail assertions. import { BASIC } from '@hyperjump/json-schema/experimental'; +import { chainError } from '@williamthorsen/toolbelt.errors/candidate'; import { describe, expect, it } from 'vitest'; /** Recursive shape of any JSON-decoded value, matching the validator's `Json` parameter. */ @@ -314,8 +315,7 @@ function parseJsonFile(filePath: string, label: string): T { // `JSON.parse` returns `any`; the typed local variable narrows without a type assertion. parsed = JSON.parse(text); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to read or parse ${label} at ${filePath}: ${message}`); + throw chainError(`Failed to read or parse ${label} at ${filePath}`, error); } return parsed; } diff --git a/packages/agents/src/lib/claude-hook-settings.ts b/packages/agents/src/lib/claude-hook-settings.ts index 7bc285d4..57ca7cd3 100644 --- a/packages/agents/src/lib/claude-hook-settings.ts +++ b/packages/agents/src/lib/claude-hook-settings.ts @@ -7,6 +7,8 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { chainError } from '@williamthorsen/toolbelt.errors/candidate'; + import { checkHookEntries, type ClaudeHookEntry, ensureHookEntries, removeHookEntries } from './claude-hook-entries.ts'; import type { EnsureResult, EntryCheck, RemoveResult } from './managed-entry-contract.ts'; import { isEnoent } from './type-guards.ts'; @@ -88,8 +90,7 @@ function parseSettings(text: string, filePath: string): unknown { try { return JSON.parse(text); } catch (error: unknown) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`Cannot parse ${filePath} as JSON: ${detail}`, { cause: error }); + throw chainError(`Cannot parse ${filePath} as JSON`, error); } } diff --git a/packages/agents/src/lib/codeassembly-schema.ts b/packages/agents/src/lib/codeassembly-schema.ts index 5ee11528..dcfabd16 100644 --- a/packages/agents/src/lib/codeassembly-schema.ts +++ b/packages/agents/src/lib/codeassembly-schema.ts @@ -1,3 +1,4 @@ +import { chainError } from '@williamthorsen/toolbelt.errors/candidate'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod'; @@ -99,8 +100,7 @@ export function parseCodeAssemblyFile( try { parsed = parseYaml(raw); } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Invalid codeassembly.yaml${where}: malformed YAML — ${message}`, { cause: error }); + throw chainError(`Invalid codeassembly.yaml${where}: malformed YAML`, error); } // An empty or comment-only document parses to nullish; treat it as "nothing declared". diff --git a/packages/agents/src/lib/package-sources.ts b/packages/agents/src/lib/package-sources.ts index 0d1ebf89..0307d855 100644 --- a/packages/agents/src/lib/package-sources.ts +++ b/packages/agents/src/lib/package-sources.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; +import { chainError } from '@williamthorsen/toolbelt.errors/candidate'; import { z } from 'zod'; import { isMissingFile } from './type-guards.ts'; @@ -160,8 +161,7 @@ function parsePackageManifest(name: string, raw: string): unknown { try { return JSON.parse(raw); } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Package "${name}" has an unreadable package.json: ${message}`, { cause: error }); + throw chainError(`Package "${name}" has an unreadable package.json`, error); } } diff --git a/packages/agents/src/lib/path-rewriter.ts b/packages/agents/src/lib/path-rewriter.ts index 1d4813ac..a98d0e93 100644 --- a/packages/agents/src/lib/path-rewriter.ts +++ b/packages/agents/src/lib/path-rewriter.ts @@ -1,6 +1,8 @@ import { lstat, readdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { chainError } from '@williamthorsen/toolbelt.errors/candidate'; + /** * The Markdown link grammar this module rewrites: `[text](target)`, capturing text then target. Exported because the * grammar and the passthrough predicate together define what gets rewritten, so a caller inspecting links must match @@ -121,8 +123,7 @@ export async function rewritePathsInFile( await writeFile(filePath, rewritten, 'utf8'); } } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to rewrite paths in ${filePath}: ${message}`); + throw chainError(`Failed to rewrite paths in ${filePath}`, error); } } diff --git a/packages/agents/src/lib/rulebook-deploy.ts b/packages/agents/src/lib/rulebook-deploy.ts index e39c4a4e..76dc3389 100644 --- a/packages/agents/src/lib/rulebook-deploy.ts +++ b/packages/agents/src/lib/rulebook-deploy.ts @@ -40,7 +40,7 @@ export async function resolveRulebook(slug: string, resolver: SourceResolver): P } catch (error: unknown) { if (isEnoent(error)) { const origin = resolved.source === undefined ? 'the library' : `source "${resolved.source}"`; - throw new Error(`Declared rulebook "${slug}" was not found in ${origin} at ${srcPath}`); + throw new Error(`Declared rulebook "${slug}" was not found in ${origin} at ${srcPath}`, { cause: error }); } throw error; } diff --git a/packages/agents/src/shared/__tests__/branch-helpers.tool.test.ts b/packages/agents/src/shared/__tests__/branch-helpers.tool.test.ts index 6fec0b41..af4fc421 100644 --- a/packages/agents/src/shared/__tests__/branch-helpers.tool.test.ts +++ b/packages/agents/src/shared/__tests__/branch-helpers.tool.test.ts @@ -76,4 +76,8 @@ describe(resolveCurrentBranch, () => { it('throws outside a git repository', async () => { await expect(resolveCurrentBranch(scratch)).rejects.toThrow(/Could not resolve current branch/); }); + + it('attaches the git failure as the cause', async () => { + await expect(resolveCurrentBranch(scratch)).rejects.toHaveProperty('cause', expect.any(Error)); + }); }); diff --git a/packages/agents/src/shared/branch-helpers.ts b/packages/agents/src/shared/branch-helpers.ts index 809c3f8e..c8aa4f5d 100644 --- a/packages/agents/src/shared/branch-helpers.ts +++ b/packages/agents/src/shared/branch-helpers.ts @@ -2,6 +2,8 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { chainError } from '@williamthorsen/toolbelt.errors/candidate'; + const execFileAsync = promisify(execFile); /** @@ -14,8 +16,7 @@ export async function resolveCurrentBranch(cwd: string): Promise { const { stdout } = await execFileAsync('git', ['-C', cwd, 'branch', '--show-current']); return stdout.trim(); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Could not resolve current branch (is this a git repository?): ${message}`); + throw chainError('Could not resolve current branch (is this a git repository?)', error); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f3b1de7..ab929983 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: '@williamthorsen/kb': specifier: workspace:* version: link:../kb + '@williamthorsen/toolbelt.errors': + specifier: 0.2.0 + version: 0.2.0 codeassembly-lifecycle: specifier: workspace:* version: link:../lifecycle From 7caf2360df424102608e50ece1dab3bd125986d8 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 08:00:11 -0700 Subject: [PATCH 2/9] agents|refactor: Replace inline error-message extraction with describeError An `Error` carrying no message now reports as its class name rather than as an empty string, so a diagnostic built from one names something. The bin wrapper keeps its own copy: its top-level imports must stay resolvable without an install, so it can still report a missing build. --- packages/agents/bin/codeassembly.js | 2 ++ .../__tests__/content-link-resolution.unit.test.ts | 3 ++- .../scripts/__tests__/smoke-test-skill-helpers.ts | 4 +++- packages/agents/src/capture-event/cli.ts | 7 ++++--- packages/agents/src/capture-lede-decision/cli.ts | 7 ++++--- packages/agents/src/cli.ts | 4 +++- packages/agents/src/commands/install.ts | 7 +++---- packages/agents/src/commands/library-list.ts | 3 ++- packages/agents/src/commands/status.ts | 4 +++- .../src/commands/sync/__tests__/sync.tool.test.ts | 3 ++- packages/agents/src/commands/uninstall.ts | 4 +++- packages/agents/src/derive-session-context/cli.ts | 6 ++++-- packages/agents/src/emit-event/cli.ts | 13 +++++++------ packages/agents/src/feedback-memories/cli.ts | 6 ++++-- packages/agents/src/kb-add/cli.ts | 9 +++++---- packages/agents/src/kb-add/declare-domain.ts | 3 ++- packages/agents/src/kb-curate/apply.ts | 5 +++-- packages/agents/src/kb-curate/cli.ts | 7 ++++--- packages/agents/src/kb-edit/cli.ts | 9 +++++---- packages/agents/src/kb-edit/commit-supersede.ts | 4 +++- packages/agents/src/kb-retrieve-events/cli.ts | 3 ++- packages/agents/src/kb-retrieve/cli.ts | 4 +++- packages/agents/src/kb-shared/note-helpers.ts | 3 ++- packages/agents/src/kb-update-events/cli.ts | 9 +++++---- .../lib/__tests__/preferences-schema.unit.test.ts | 3 ++- .../lib/__tests__/work-types-schema.unit.test.ts | 3 ++- packages/agents/src/lib/content-validation.ts | 6 +----- packages/agents/src/lib/guidance-hooks.ts | 7 ++----- packages/agents/src/lib/source-validation.ts | 4 +++- packages/agents/src/relay-hook-event/cli.ts | 13 +++++++------ packages/agents/src/update-jira-ticket/cli.ts | 6 ++++-- 31 files changed, 101 insertions(+), 70 deletions(-) diff --git a/packages/agents/bin/codeassembly.js b/packages/agents/bin/codeassembly.js index 54d8ae07..2990e0b7 100755 --- a/packages/agents/bin/codeassembly.js +++ b/packages/agents/bin/codeassembly.js @@ -1,5 +1,7 @@ #!/usr/bin/env node +// Imports only node builtins: a top-level import resolves before the gate below runs, so an +// unresolvable dependency would replace this file's build-first message with ERR_MODULE_NOT_FOUND. import { existsSync } from 'node:fs'; // Thin wrapper so pnpm can symlink the bin at install time, before `dist/` diff --git a/packages/agents/content/__tests__/content-link-resolution.unit.test.ts b/packages/agents/content/__tests__/content-link-resolution.unit.test.ts index 9953e869..755bd5eb 100644 --- a/packages/agents/content/__tests__/content-link-resolution.unit.test.ts +++ b/packages/agents/content/__tests__/content-link-resolution.unit.test.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { beforeAll, describe, expect, it } from 'vitest'; import { collectHeadingSlugs, findUnterminatedFence, normalizeForAnchorScan } from '../../src/lib/anchor-resolution.ts'; @@ -129,7 +130,7 @@ async function findRulebookRejections(): Promise> { rulebooks, }); } catch (error) { - rejections.push(error instanceof Error ? error.message : String(error)); + rejections.push(describeError(error)); } } return rejections; diff --git a/packages/agents/scripts/__tests__/smoke-test-skill-helpers.ts b/packages/agents/scripts/__tests__/smoke-test-skill-helpers.ts index f12e2525..13fffa0f 100644 --- a/packages/agents/scripts/__tests__/smoke-test-skill-helpers.ts +++ b/packages/agents/scripts/__tests__/smoke-test-skill-helpers.ts @@ -13,6 +13,8 @@ import { spawn } from 'node:child_process'; import path from 'node:path'; import process from 'node:process'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; + import { bundleSkillHelpers, type BundleTarget, packageRoot, targets } from '../bundle-skill-helpers.ts'; import { makeCaptureEventSmokeTest, @@ -64,7 +66,7 @@ for (const target of targets) { console.info(`Smoke test passed: ${target.outFile} exits 0 with valid JSON.`); } catch (error) { failed = true; - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); console.error(`Smoke test failed: ${target.outFile} — ${message}`); } } diff --git a/packages/agents/src/capture-event/cli.ts b/packages/agents/src/capture-event/cli.ts index 5e9fdf48..49d9d46f 100644 --- a/packages/agents/src/capture-event/cli.ts +++ b/packages/agents/src/capture-event/cli.ts @@ -15,6 +15,7 @@ import { parseEvent, renderEvent, } from '@williamthorsen/kb/records'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { ulid } from 'ulid'; import { formatMissingStoreMessage } from '../kb-shared/format-missing-store.ts'; @@ -54,7 +55,7 @@ async function main(): Promise { }); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`capture-event: ${message}\n`); process.exit(1); } @@ -90,7 +91,7 @@ export async function runCapture(input: { try { args = parseArgs(input.argv); } catch (error) { - return { ok: false, error: 'invalid-args', message: error instanceof Error ? error.message : String(error) }; + return { ok: false, error: 'invalid-args', message: describeError(error) }; } const resolved = await resolveCaptureTarget({ @@ -316,7 +317,7 @@ function isEntryPoint(): boolean { try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`capture-event: warning: could not determine entry point: ${message}\n`); return false; } diff --git a/packages/agents/src/capture-lede-decision/cli.ts b/packages/agents/src/capture-lede-decision/cli.ts index db4147e5..dc0d8cc2 100644 --- a/packages/agents/src/capture-lede-decision/cli.ts +++ b/packages/agents/src/capture-lede-decision/cli.ts @@ -6,6 +6,7 @@ import process from 'node:process'; import type { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { ulid } from 'ulid'; import { writeEvent } from '../capture-event/write-event.ts'; @@ -87,7 +88,7 @@ async function main(): Promise { }); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`capture-lede-decision: ${message}\n`); process.exit(1); } @@ -123,7 +124,7 @@ export async function runDecision(input: { try { args = parseArgs(input.argv); } catch (error) { - return { ok: false, error: 'invalid-args', message: error instanceof Error ? error.message : String(error) }; + return { ok: false, error: 'invalid-args', message: describeError(error) }; } const resolved = await resolveEpisode({ @@ -313,7 +314,7 @@ function isEntryPoint(): boolean { try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`capture-lede-decision: warning: could not determine entry point: ${message}\n`); return false; } diff --git a/packages/agents/src/cli.ts b/packages/agents/src/cli.ts index 949ed090..b6ab2006 100644 --- a/packages/agents/src/cli.ts +++ b/packages/agents/src/cli.ts @@ -2,6 +2,8 @@ /* eslint unicorn/no-process-exit: off */ import process from 'node:process'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; + import { configureHooksCommand } from './commands/configure-hooks.ts'; import { generateLabelMap, printGenerateUsage } from './commands/generate-label-map.ts'; import { initCommand, initGlobalCommand } from './commands/init.ts'; @@ -305,7 +307,7 @@ async function runSync(options: InstallOptions, global: boolean, warnOnly: boole if (!warnOnly) { throw error; } - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); console.warn(`⚠️ sync failed: ${message}\n Deployed guidance may be stale.`); } } diff --git a/packages/agents/src/commands/install.ts b/packages/agents/src/commands/install.ts index c435038a..1ecaace4 100644 --- a/packages/agents/src/commands/install.ts +++ b/packages/agents/src/commands/install.ts @@ -1,6 +1,8 @@ import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; + import { extractAmbientRegionContent, hasAmbientRegion, injectAmbientRegion } from '../lib/ambient-region.ts'; import { assertAnchorsResolve } from '../lib/anchor-resolution.ts'; import { resolveContentDir } from '../lib/content-resolver.ts'; @@ -148,10 +150,7 @@ export async function installCommand( try { await ensureHarnessHookEntries(harnessId, baseDir); } catch (error) { - console.warn( - ` ⚠️ Skipping hook wiring: ${error instanceof Error ? error.message : String(error)} ` + - '(fix the config, then run configure-hooks)', - ); + console.warn(` ⚠️ Skipping hook wiring: ${describeError(error)} (fix the config, then run configure-hooks)`); } } } diff --git a/packages/agents/src/commands/library-list.ts b/packages/agents/src/commands/library-list.ts index 31dc3040..8dc80838 100644 --- a/packages/agents/src/commands/library-list.ts +++ b/packages/agents/src/commands/library-list.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { parse as parseYaml } from 'yaml'; import { ARTIFACT_TYPES, type ArtifactType } from '../lib/artifact-types.ts'; @@ -279,7 +280,7 @@ function readNameAndDescription(content: string): { name?: string; description?: /** Warns to stderr that an artifact was skipped because its frontmatter could not be parsed. */ function warnSkipped(type: ArtifactType, file: string, error: unknown): void { - const reason = error instanceof Error ? error.message : String(error); + const reason = describeError(error); console.warn(` ⚠️ Skipping ${type} ${file}: ${reason}`); } diff --git a/packages/agents/src/commands/status.ts b/packages/agents/src/commands/status.ts index 5b741b70..f97426eb 100644 --- a/packages/agents/src/commands/status.ts +++ b/packages/agents/src/commands/status.ts @@ -1,3 +1,5 @@ +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; + import { resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.js'; import { readHomeProvenance } from '../lib/home-provenance.ts'; import { detectDrift, getManifestPath, readManifest, resolveSharedHome } from '../lib/manifest.js'; @@ -94,7 +96,7 @@ async function reportHookEntryStatus( statuses = await checkHarnessHookEntries(harnessId, baseDir); } catch (error) { // An unparseable config is itself a status worth reporting; it must not abort the rest of the report. - console.warn(` ⚠️ Hooks: could not read the config: ${error instanceof Error ? error.message : String(error)}`); + console.warn(` ⚠️ Hooks: could not read the config: ${describeError(error)}`); return; } const presentCount = statuses.filter((entry) => entry.status === 'present').length; diff --git a/packages/agents/src/commands/sync/__tests__/sync.tool.test.ts b/packages/agents/src/commands/sync/__tests__/sync.tool.test.ts index 8cfda508..45cab0c5 100644 --- a/packages/agents/src/commands/sync/__tests__/sync.tool.test.ts +++ b/packages/agents/src/commands/sync/__tests__/sync.tool.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { unindent } from '@williamthorsen/toolbelt.strings/candidate'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -812,7 +813,7 @@ describe(syncCommand, () => { try { await syncCommand(makeOptions(), projectRoot, contentDir, homeDir); } catch (error: unknown) { - message = error instanceof Error ? error.message : String(error); + message = describeError(error); } expect(message).toContain('shared'); diff --git a/packages/agents/src/commands/uninstall.ts b/packages/agents/src/commands/uninstall.ts index 0cdda205..e1a91796 100644 --- a/packages/agents/src/commands/uninstall.ts +++ b/packages/agents/src/commands/uninstall.ts @@ -1,3 +1,5 @@ +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; + import { classifyOwnedEntry } from '../lib/entry-remover.ts'; import { resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.js'; import { removeItem } from '../lib/installer.js'; @@ -37,7 +39,7 @@ export async function uninstallCommand( try { await removeHarnessHookEntries(harnessId, baseDir); } catch (error) { - console.warn(` ⚠️ Skipping hook-entry removal: ${error instanceof Error ? error.message : String(error)}`); + console.warn(` ⚠️ Skipping hook-entry removal: ${describeError(error)}`); } const harnessManifest = manifest.harnesses[harnessId]; diff --git a/packages/agents/src/derive-session-context/cli.ts b/packages/agents/src/derive-session-context/cli.ts index 1b00d105..706fd5e1 100644 --- a/packages/agents/src/derive-session-context/cli.ts +++ b/packages/agents/src/derive-session-context/cli.ts @@ -36,6 +36,8 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; + import { isEnoent, isRecord } from '../lib/type-guards.ts'; import { resolveCurrentBranch, sanitizeBranch } from '../shared/branch-helpers.ts'; import { resolveProjectRoot } from '../shared/resolve-project-root.ts'; @@ -91,7 +93,7 @@ async function main(): Promise { }); process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`derive-session-context: ${message}\n`); process.exit(1); } @@ -438,7 +440,7 @@ function isMain(): boolean { // most plausibly means we *are* the entry point (the CLI is being invoked through a stale link). // Returning `false` here would silently no-op the CLI; running `main()` defensively at worst // runs the script on an unexpected import, which surfaces an error rather than a silent skip. - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`derive-session-context: warning: could not determine entry point: ${message}\n`); return true; } diff --git a/packages/agents/src/emit-event/cli.ts b/packages/agents/src/emit-event/cli.ts index 48cf2f0c..1eaff68e 100644 --- a/packages/agents/src/emit-event/cli.ts +++ b/packages/agents/src/emit-event/cli.ts @@ -20,6 +20,7 @@ import { homedir } from 'node:os'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { EVENT_TYPES, isEventType } from 'codeassembly-lifecycle'; import { ulid } from 'ulid'; @@ -55,7 +56,7 @@ async function main(): Promise { } catch (error) { // The never-block backstop. `runEmit` converts every failure it anticipates into a structured result, so reaching // here means something unforeseen threw — which still must not take down the skill being observed. - result = failure('internal-error', error instanceof Error ? error.message : String(error)); + result = failure('internal-error', describeError(error)); } process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } @@ -84,7 +85,7 @@ export async function runEmit(input: { try { args = parseArgs(input.argv); } catch (error) { - return failure('invalid-args', error instanceof Error ? error.message : String(error)); + return failure('invalid-args', describeError(error)); } const payload = parsePayload(args.payload); @@ -114,7 +115,7 @@ export async function runEmit(input: { try { await appendEvent({ filePath, envelope }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); return failure('write-failed', `could not append the event to ${filePath}: ${message}`); } @@ -188,7 +189,7 @@ async function resolveBranch(cwd: string): Promise { try { branch = await resolveCurrentBranch(cwd); } catch (error) { - warn(`${error instanceof Error ? error.message : String(error)}; omitting the branch`); + warn(`${describeError(error)}; omitting the branch`); return undefined; } if (branch === '') { @@ -216,7 +217,7 @@ function parsePayload( try { parsed = JSON.parse(raw); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); return { ok: false, message: `--payload is not valid JSON: ${message}` }; } @@ -250,7 +251,7 @@ function isEntryPoint(): boolean { try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); } catch (error) { - warn(`could not determine entry point: ${error instanceof Error ? error.message : String(error)}`); + warn(`could not determine entry point: ${describeError(error)}`); return false; } } diff --git a/packages/agents/src/feedback-memories/cli.ts b/packages/agents/src/feedback-memories/cli.ts index 0b15aebe..537823ff 100644 --- a/packages/agents/src/feedback-memories/cli.ts +++ b/packages/agents/src/feedback-memories/cli.ts @@ -5,6 +5,8 @@ import process from 'node:process'; import type { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; + import { readAll } from '../lib/stream-helpers.ts'; import { deleteMemories } from './delete-memory.ts'; import { enumerateFeedbackMemories } from './enumerate.ts'; @@ -31,7 +33,7 @@ async function main(): Promise { // The helper's contract is exit 0 with a structured `{ ok: false, ... }` for recoverable failures. Unexpected // throws (permission denied, out-of-disk) take the catch arm below. } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`feedback-memories: ${message}\n`); process.exit(1); } @@ -279,7 +281,7 @@ function isEntryPoint(): boolean { try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`feedback-memories: warning: could not determine entry point: ${message}\n`); return false; } diff --git a/packages/agents/src/kb-add/cli.ts b/packages/agents/src/kb-add/cli.ts index 326eb542..103a3066 100644 --- a/packages/agents/src/kb-add/cli.ts +++ b/packages/agents/src/kb-add/cli.ts @@ -9,6 +9,7 @@ import type { AliasMap, KbRoot } from '@williamthorsen/kb'; import { isKbLoaderError } from '@williamthorsen/kb/config'; import { resolveKbDir } from '@williamthorsen/kb/layout'; import { loadAliases } from '@williamthorsen/kb/tags'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { formatMissingDestinationMessage } from '../kb-shared/format-missing-destination.ts'; import { type ResolvedKb, resolveWritableKb } from '../kb-shared/resolve-writable-kb.ts'; @@ -37,7 +38,7 @@ async function main(): Promise { // The helper's contract is exit 0 with a structured `{ ok: false, ... }` for recoverable failures. // System failures (unexpected throws) take the catch arm below. } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`kb-add: ${message}\n`); process.exit(1); } @@ -133,7 +134,7 @@ export async function runAdd(input: { try { args = parseArgs(input.argv); } catch (error) { - return { ok: false, error: 'invalid-args', message: error instanceof Error ? error.message : String(error) }; + return { ok: false, error: 'invalid-args', message: describeError(error) }; } if (args.mode === 'survey') { @@ -248,7 +249,7 @@ function isEntryPoint(): boolean { try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`kb-add: warning: could not determine entry point: ${message}\n`); return false; } @@ -263,7 +264,7 @@ async function loadAliasesWithWarning(input: { kbRoot: KbRoot }): Promise ({ path, provisional })), }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); const subject = domain === null ? 'the note placement' : `domain "${domain}"`; return { domain, added: [], warning: `could not declare ${subject} in ${TAXONOMY_FILE}: ${message}` }; } diff --git a/packages/agents/src/kb-curate/apply.ts b/packages/agents/src/kb-curate/apply.ts index 15190fd4..287ff740 100644 --- a/packages/agents/src/kb-curate/apply.ts +++ b/packages/agents/src/kb-curate/apply.ts @@ -4,6 +4,7 @@ import type { Finding } from '@williamthorsen/kb'; import type { EnumeratedNote } from '@williamthorsen/kb/check'; import { asStringList } from '@williamthorsen/kb/note-io'; import { buildVaultIndex } from '@williamthorsen/kb/vault-integrity'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { canonicalizeTags } from './apply/canonicalize-tags.ts'; import { rewriteWikilinks } from './apply/rewrite-wikilinks.ts'; @@ -72,7 +73,7 @@ async function rewriteStalePathLinks(input: { notes: readonly EnumeratedNote[] } rule: 'wikilinks.path-rewrite', ok: false, operation: 'rewrite-wikilink', - message: error instanceof Error ? error.message : String(error), + message: describeError(error), }); continue; } @@ -102,7 +103,7 @@ async function rewriteStalePathLinks(input: { notes: readonly EnumeratedNote[] } rule: 'wikilinks.path-rewrite', ok: false, operation: 'rewrite-wikilink', - message: error instanceof Error ? error.message : String(error), + message: describeError(error), }); } } diff --git a/packages/agents/src/kb-curate/cli.ts b/packages/agents/src/kb-curate/cli.ts index 9ef31e97..7c3a0f9c 100644 --- a/packages/agents/src/kb-curate/cli.ts +++ b/packages/agents/src/kb-curate/cli.ts @@ -8,6 +8,7 @@ import type { Finding } from '@williamthorsen/kb'; import type { EnumeratedNote } from '@williamthorsen/kb/check'; import { check } from '@williamthorsen/kb/check'; import { isKbLoaderError } from '@williamthorsen/kb/config'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { formatMissingDestinationMessage } from '../kb-shared/format-missing-destination.ts'; import type { ResolvedKb } from '../kb-shared/resolve-writable-kb.ts'; @@ -31,7 +32,7 @@ async function main(): Promise { // The helper's contract is exit 0 with a structured `{ ok: false, ... }` for recoverable failures. // System failures (unexpected throws) take the catch arm below. } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`kb-curate: ${message}\n`); process.exit(1); } @@ -100,7 +101,7 @@ export async function runCurate(input: { try { args = parseArgs(input.argv); } catch (error) { - return { ok: false, error: 'invalid-args', message: error instanceof Error ? error.message : String(error) }; + return { ok: false, error: 'invalid-args', message: describeError(error) }; } const mode = args.apply ? 'apply' : 'report'; @@ -308,7 +309,7 @@ function isEntryPoint(): boolean { try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`kb-curate: warning: could not determine entry point: ${message}\n`); return false; } diff --git a/packages/agents/src/kb-edit/cli.ts b/packages/agents/src/kb-edit/cli.ts index 4f433300..96d2a0c9 100644 --- a/packages/agents/src/kb-edit/cli.ts +++ b/packages/agents/src/kb-edit/cli.ts @@ -10,6 +10,7 @@ import type { AliasMap, KbRoot } from '@williamthorsen/kb'; import { resolveKbDir } from '@williamthorsen/kb/layout'; import type { KbAssertion } from '@williamthorsen/kb/records'; import { loadAliases } from '@williamthorsen/kb/tags'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { splitCommaList } from '../kb-shared/note-helpers.ts'; import type { ResolvedKb } from '../kb-shared/resolve-writable-kb.ts'; @@ -60,7 +61,7 @@ async function main(): Promise { // The helper's contract is exit 0 with a structured `{ ok: false, ... }` for recoverable failures. // System failures (unexpected throws) take the catch arm below. } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`kb-edit: ${message}\n`); process.exit(1); } @@ -109,7 +110,7 @@ export async function runEdit(input: { return { ok: false, error: 'invalid-args', - message: error instanceof Error ? error.message : String(error), + message: describeError(error), }; } @@ -252,7 +253,7 @@ async function loadAliasesWithWarning(input: { kbRoot: KbRoot }): Promise {}); - const originalMessage = renameError instanceof Error ? renameError.message : String(renameError); + const originalMessage = describeError(renameError); const rollback = await tryRollbackOld({ oldPath: input.oldPath, originalContent: input.oldOriginalContent, diff --git a/packages/agents/src/kb-retrieve-events/cli.ts b/packages/agents/src/kb-retrieve-events/cli.ts index 22e07c7b..ea0e8a8c 100644 --- a/packages/agents/src/kb-retrieve-events/cli.ts +++ b/packages/agents/src/kb-retrieve-events/cli.ts @@ -5,6 +5,7 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { EVENT_IMPACT_LEVELS, type EventImpact, isEventImpact } from '@williamthorsen/kb/records'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import type { RecallFn } from '../kb-search/recall.ts'; import { recordTypeOf, searchNotes } from '../kb-search/search.ts'; @@ -44,7 +45,7 @@ async function main(): Promise { const result = await runRetrieveEvents({ argv: process.argv.slice(2), startDir: process.cwd() }); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`kb-retrieve-events: ${message}\n`); process.exit(1); } diff --git a/packages/agents/src/kb-retrieve/cli.ts b/packages/agents/src/kb-retrieve/cli.ts index d438e401..5cf55c0b 100644 --- a/packages/agents/src/kb-retrieve/cli.ts +++ b/packages/agents/src/kb-retrieve/cli.ts @@ -4,6 +4,8 @@ import { realpathSync } from 'node:fs'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; + import type { RecallFn } from '../kb-search/recall.ts'; import { recordTypeOf, searchNotes } from '../kb-search/search.ts'; import type { RecallFilters } from '../kb-search/types.ts'; @@ -46,7 +48,7 @@ async function main(): Promise { }); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`kb-retrieve: ${message}\n`); process.exit(1); } diff --git a/packages/agents/src/kb-shared/note-helpers.ts b/packages/agents/src/kb-shared/note-helpers.ts index 007fc86c..255ffe05 100644 --- a/packages/agents/src/kb-shared/note-helpers.ts +++ b/packages/agents/src/kb-shared/note-helpers.ts @@ -1,5 +1,6 @@ import type { ParsedNote } from '@williamthorsen/kb/frontmatter'; import { parseNote } from '@williamthorsen/kb/frontmatter'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; /** Whole-day divisor for converting a date delta in milliseconds to an age in days. */ const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1_000; @@ -86,6 +87,6 @@ export async function parseNoteSafely(path: string): Promise { try { return { note: await parseNote({ path }) }; } catch (error) { - return { note: null, error: error instanceof Error ? error.message : String(error) }; + return { note: null, error: describeError(error) }; } } diff --git a/packages/agents/src/kb-update-events/cli.ts b/packages/agents/src/kb-update-events/cli.ts index 1d387f68..07bf0a85 100644 --- a/packages/agents/src/kb-update-events/cli.ts +++ b/packages/agents/src/kb-update-events/cli.ts @@ -9,6 +9,7 @@ import { resolveEventPath, resolveKbDir } from '@williamthorsen/kb/layout'; import { type ReadNote, readNote, writeNote } from '@williamthorsen/kb/note-io'; import { EVENT_IMPACT_LEVELS, isEventImpact, type KbEvent, parseEvent, renderEvent } from '@williamthorsen/kb/records'; import { loadAliases } from '@williamthorsen/kb/tags'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { formatMissingStoreMessage } from '../kb-shared/format-missing-store.ts'; import { isSafeEventId, splitCommaList } from '../kb-shared/note-helpers.ts'; @@ -35,7 +36,7 @@ async function main(): Promise { const result = await runUpdate({ argv: process.argv.slice(2) }); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`kb-update-events: ${message}\n`); process.exit(1); } @@ -63,7 +64,7 @@ export async function runUpdate(input: { argv: readonly string[]; home?: string try { args = parseArgs(input.argv); } catch (error) { - return { ok: false, error: 'invalid-args', message: error instanceof Error ? error.message : String(error) }; + return { ok: false, error: 'invalid-args', message: describeError(error) }; } const resolved = await resolveCaptureTarget({ @@ -222,7 +223,7 @@ function isEntryPoint(): boolean { try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`kb-update-events: warning: could not determine entry point: ${message}\n`); return false; } @@ -234,7 +235,7 @@ async function loadAliasesForStore(storePath: string): Promise { try { return await loadAliases({ kbRoot }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`kb-update-events: warning: could not load tag aliases: ${message}\n`); return new Map(); } diff --git a/packages/agents/src/lib/__tests__/preferences-schema.unit.test.ts b/packages/agents/src/lib/__tests__/preferences-schema.unit.test.ts index 25653b10..506ff494 100644 --- a/packages/agents/src/lib/__tests__/preferences-schema.unit.test.ts +++ b/packages/agents/src/lib/__tests__/preferences-schema.unit.test.ts @@ -8,6 +8,7 @@ import { FLAG, registerSchema, validate } from '@hyperjump/json-schema/draft-202 // failure path below, never as part of an assertion. The stable per-dialect API is used for all // pass/fail assertions. import { BASIC } from '@hyperjump/json-schema/experimental'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { chainError } from '@williamthorsen/toolbelt.errors/candidate'; import { describe, expect, it } from 'vitest'; import { parse as parseYaml } from 'yaml'; @@ -185,7 +186,7 @@ function registerSchemaIdempotent(schemaToRegister: JsonSchemaDraft202012Object, try { registerSchema(schemaToRegister, id); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); const isDuplicateRegistration = message.includes('already been registered') && message.includes(id); if (!isDuplicateRegistration) { throw error; diff --git a/packages/agents/src/lib/__tests__/work-types-schema.unit.test.ts b/packages/agents/src/lib/__tests__/work-types-schema.unit.test.ts index 187ee0ca..7d50eec4 100644 --- a/packages/agents/src/lib/__tests__/work-types-schema.unit.test.ts +++ b/packages/agents/src/lib/__tests__/work-types-schema.unit.test.ts @@ -8,6 +8,7 @@ import { FLAG, registerSchema, validate } from '@hyperjump/json-schema/draft-202 // failure path below, never as part of an assertion. The stable per-dialect API is used for all // pass/fail assertions. import { BASIC } from '@hyperjump/json-schema/experimental'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { chainError } from '@williamthorsen/toolbelt.errors/candidate'; import { describe, expect, it } from 'vitest'; @@ -332,7 +333,7 @@ function registerSchemaIdempotent(schemaToRegister: JsonSchemaDraft202012Object, try { registerSchema(schemaToRegister, id); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); const isDuplicateRegistration = message.includes('already been registered') && message.includes(id); if (!isDuplicateRegistration) { throw error; diff --git a/packages/agents/src/lib/content-validation.ts b/packages/agents/src/lib/content-validation.ts index 4d8f6572..adc46fb3 100644 --- a/packages/agents/src/lib/content-validation.ts +++ b/packages/agents/src/lib/content-validation.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { parse as parseYaml } from 'yaml'; import { ARTIFACT_TYPE_VALUES, ARTIFACT_TYPES, artifactFrontmatterPath, type ArtifactType } from './artifact-types.ts'; @@ -133,11 +134,6 @@ function declaresRetiredHarnessesKey(content: string): boolean { return isRecord(parsed) && parsed[RETIRED_HARNESSES_KEY] !== undefined; } -/** Renders an unknown thrown value as the message a report line carries. */ -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - /** * Reports the two delivery collisions that only a whole root can see: two skill-delivery rulebooks resolving to one * skill name, and a name claimed by both the rulebook-skill and declared-skill namespaces. Each is attributed to one diff --git a/packages/agents/src/lib/guidance-hooks.ts b/packages/agents/src/lib/guidance-hooks.ts index b362eae7..4db5b263 100644 --- a/packages/agents/src/lib/guidance-hooks.ts +++ b/packages/agents/src/lib/guidance-hooks.ts @@ -1,3 +1,5 @@ +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; + import { assertAnchorsResolve } from './anchor-resolution.ts'; import { renderRulebookBlock } from './sentinel-inliner.ts'; @@ -266,11 +268,6 @@ function demoteHeadings(body: string): string { return demoted.join('\n'); } -/** Renders an unknown thrown value as the message it carries, or as itself when it carries none. */ -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - /** Index of the line closing a leading frontmatter block, or `-1` when the body opens with none. */ function findFrontmatterEnd(lines: ReadonlyArray): number { return lines[0] === '---' ? lines.indexOf('---', 1) : -1; diff --git a/packages/agents/src/lib/source-validation.ts b/packages/agents/src/lib/source-validation.ts index 8152fbd9..c212a31f 100644 --- a/packages/agents/src/lib/source-validation.ts +++ b/packages/agents/src/lib/source-validation.ts @@ -1,6 +1,8 @@ import { constants } from 'node:fs'; import { access, stat } from 'node:fs/promises'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; + import { isMissingFile } from './type-guards.ts'; /** @@ -54,6 +56,6 @@ export async function describeSourceProblem(dir: string): Promise { } catch (error) { // The never-block backstop. `runRelay` converts every failure it anticipates into a structured result, so reaching // here means something unforeseen threw — which still must not disturb the session being observed. - result = failure('internal-error', error instanceof Error ? error.message : String(error)); + result = failure('internal-error', describeError(error)); } process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } @@ -99,7 +100,7 @@ export async function runRelay(input: { try { args = parseArgs(input.argv); } catch (error) { - return failure('invalid-args', error instanceof Error ? error.message : String(error)); + return failure('invalid-args', describeError(error)); } const mapping = resolveHookMapping({ harness: args.harness, hook: args.hook }); @@ -141,7 +142,7 @@ export async function runRelay(input: { try { await appendEvent({ filePath, envelope }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); return failure('write-failed', `could not append the event to ${filePath}: ${message}`); } @@ -203,7 +204,7 @@ export function parseHookPayload(input: { try { parsed = JSON.parse(input.stdin); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); return { ok: false, message: `the hook payload is not valid JSON: ${message}` }; } @@ -251,7 +252,7 @@ function isEntryPoint(): boolean { try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); } catch (error) { - warn(`could not determine entry point: ${error instanceof Error ? error.message : String(error)}`); + warn(`could not determine entry point: ${describeError(error)}`); return false; } } @@ -286,7 +287,7 @@ async function resolveBranch(cwd: string): Promise { try { branch = await resolveCurrentBranch(cwd); } catch (error) { - warn(`${error instanceof Error ? error.message : String(error)}; omitting the branch`); + warn(`${describeError(error)}; omitting the branch`); return undefined; } if (branch === '') { diff --git a/packages/agents/src/update-jira-ticket/cli.ts b/packages/agents/src/update-jira-ticket/cli.ts index da8d546d..6ec7b36a 100644 --- a/packages/agents/src/update-jira-ticket/cli.ts +++ b/packages/agents/src/update-jira-ticket/cli.ts @@ -10,6 +10,8 @@ import { realpathSync } from 'node:fs'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; + import { readAll } from '../lib/stream-helpers.ts'; import { check } from './check.ts'; @@ -20,7 +22,7 @@ async function main(): Promise { const result = check(html); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`update-jira-ticket: ${message}\n`); process.exit(1); } @@ -45,7 +47,7 @@ function isEntryPoint(): boolean { try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); process.stderr.write(`update-jira-ticket: warning: could not determine entry point: ${message}\n`); return false; } From 7397b698272fe1ca31d7a9acf3ab8e8b83c446e6 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 08:04:08 -0700 Subject: [PATCH 3/9] agents|fix: Keep replacement patterns literal when expanding template variables Installing content for a harness whose home directory or identifier contains `$&`, ``$` ``, `$'`, or `$1` now writes that value verbatim. Those sequences were previously read as replacement patterns and expanded into the template variable they were replacing. --- .../agents/src/lib/__tests__/path-rewriter.unit.test.ts | 6 ++++++ packages/agents/src/lib/path-rewriter.ts | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts b/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts index 09a48f15..c4055dd3 100644 --- a/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts +++ b/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts @@ -153,6 +153,12 @@ describe(rewriteTemplateVariables, () => { expect(rewriteTemplateVariables(content, '.rovodev', 'rovo')).toBe('~/.rovodev/scripts/describe-change.sh'); }); + it('inserts a substitution value carrying a replacement pattern verbatim', () => { + const content = '{harness_home_dir}/x --harness {harness_id}'; + + expect(rewriteTemplateVariables(content, '.cl$&aude', "cl$'aude")).toBe("~/.cl$&aude/x --harness cl$'aude"); + }); + it('replaces {harness_id} with the harness identifier, leaving no placeholder', () => { const content = 'node {harness_home_dir}/skills/capture-event/capture-event.mjs --harness {harness_id}'; expect(rewriteTemplateVariables(content, '.claude', 'claude')).toBe( diff --git a/packages/agents/src/lib/path-rewriter.ts b/packages/agents/src/lib/path-rewriter.ts index a98d0e93..ef9c5de7 100644 --- a/packages/agents/src/lib/path-rewriter.ts +++ b/packages/agents/src/lib/path-rewriter.ts @@ -99,7 +99,9 @@ export function rewriteMarkdownPaths(content: string, fileRelPath: string, ancho * harness. */ export function rewriteTemplateVariables(content: string, homeDir: string, harnessId: string): string { - return content.replaceAll('{harness_home_dir}', `~/${homeDir}`).replaceAll('{harness_id}', harnessId); + // Replacer functions, not strings: a string replacement expands `$&`, `` $` ``, `$'`, and `$n`, so a + // substitution value carrying one of them would be rewritten into the match it was meant to replace. + return content.replaceAll('{harness_home_dir}', () => `~/${homeDir}`).replaceAll('{harness_id}', () => harnessId); } /** From a6c91a16a9883acb542933d11c28a553e7b34425 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 08:08:54 -0700 Subject: [PATCH 4/9] agents|refactor: Clear the last violations of the deferred lint rules agents has no remaining violation of the ten rules its lint deferral held at warning level. --- .../collection-dispositions.unit.test.ts | 4 +-- .../content-path-conventions.unit.test.ts | 2 +- .../vetted-store-conventions.unit.test.ts | 10 +++++-- .../scripts/testing/smoke-test-utils.ts | 2 +- .../agents/src/capture-event/write-event.ts | 6 ++++- .../commands/__tests__/install.unit.test.ts | 8 +++--- packages/agents/src/commands/library-list.ts | 3 ++- .../commands/sync/__tests__/sync.tool.test.ts | 5 +++- packages/agents/src/commands/uninstall.ts | 4 +-- .../compose-manifest.ts | 5 ++-- .../agents/src/kb-edit/commit-supersede.ts | 23 ++++++++++++---- .../claude-hook-settings.unit.test.ts | 27 ++++++++++--------- .../agents/src/lib/claude-hook-entries.ts | 4 +-- 13 files changed, 65 insertions(+), 38 deletions(-) diff --git a/packages/agents/content/__tests__/collection-dispositions.unit.test.ts b/packages/agents/content/__tests__/collection-dispositions.unit.test.ts index 60b7911f..3b18230c 100644 --- a/packages/agents/content/__tests__/collection-dispositions.unit.test.ts +++ b/packages/agents/content/__tests__/collection-dispositions.unit.test.ts @@ -84,12 +84,12 @@ describe('collection dispositions', () => { // it is constrained by no vetted-closure rule. it('keeps every standalone artifact out of the collections’ combined closure', async () => { const collections = await readExplicitCollections(contentDir); - const closure = await resolveClosure({ collection: [...collections.keys()] }, libraryResolver(contentDir)); + const closure = await resolveClosure({ collection: collections.keys().toArray() }, libraryResolver(contentDir)); const defects = findClosureDefects( 'every collection', listClosureIds(closure), - [...collections.keys()], + collections.keys().toArray(), buildClaimMap(collections, Object.keys(STANDALONE)), ); diff --git a/packages/agents/content/__tests__/content-path-conventions.unit.test.ts b/packages/agents/content/__tests__/content-path-conventions.unit.test.ts index 8e13c374..a8937d81 100644 --- a/packages/agents/content/__tests__/content-path-conventions.unit.test.ts +++ b/packages/agents/content/__tests__/content-path-conventions.unit.test.ts @@ -86,7 +86,7 @@ function formatViolations(violations: ReadonlyArray): string { const header = `Found ${violations.length} raw \`${FORBIDDEN_PATTERN}\` reference(s) in installable Markdown. ` + `Replace each with one of: ` + - `(a) \`{harness_home_dir}/...\` for runtime references the agent reads or executes; ` + + '(a) `{harness_home_dir}/...` for runtime references the agent reads or executes; ' + `(b) a relative Markdown link \`[text](relative/path.md)\` (the install pipeline rewrites it); ` + `(c) add the file to the ALLOWLIST in this test if the reference is an intentional source-tree citation. ` + `See \`packages/agents/content/_partials/README.md\` § "Path references in installed content" for the convention.`; diff --git a/packages/agents/content/__tests__/vetted-store-conventions.unit.test.ts b/packages/agents/content/__tests__/vetted-store-conventions.unit.test.ts index 03930ae2..3f1d701c 100644 --- a/packages/agents/content/__tests__/vetted-store-conventions.unit.test.ts +++ b/packages/agents/content/__tests__/vetted-store-conventions.unit.test.ts @@ -174,7 +174,10 @@ async function listClosureFiles(contentDir: string): Promise { - return [...text.matchAll(CODE_SPAN_PATTERN)].map((match) => match[1] ?? ''); + return text + .matchAll(CODE_SPAN_PATTERN) + .map((match) => match[1] ?? '') + .toArray(); } /** Lists every Markdown file under `relativeDir`, recursively, as paths relative to `root`. */ @@ -194,7 +197,10 @@ async function listMarkdownFilesUnder(root: string, relativeDir: string): Promis /** Reads each value a store flag takes within one span of text, in either the spaced or the `=` form. */ function readFlagValues(text: string): Array { - return [...text.matchAll(STORE_FLAG_PATTERN)].flatMap((match) => (match[1] === undefined ? [] : [match[1]])); + return text + .matchAll(STORE_FLAG_PATTERN) + .flatMap((match) => (match[1] === undefined ? [] : [match[1]])) + .toArray(); } /** diff --git a/packages/agents/scripts/testing/smoke-test-utils.ts b/packages/agents/scripts/testing/smoke-test-utils.ts index 939b8349..2de3499c 100644 --- a/packages/agents/scripts/testing/smoke-test-utils.ts +++ b/packages/agents/scripts/testing/smoke-test-utils.ts @@ -320,7 +320,7 @@ function assertCaptureEventSmokeResult(result: unknown): void { throw new Error(`expected an ISO-8601 capturedAt, got ${JSON.stringify(result.capturedAt)}`); } if (typeof result.path !== 'string' || !result.path.endsWith(`${result.id}.md`)) { - throw new Error(`expected a written record path ending in {id}.md, got ${JSON.stringify(result.path)}`); + throw new Error(`expected a written record path ending in ${result.id}.md, got ${JSON.stringify(result.path)}`); } const written = readFileSync(result.path, 'utf8'); if (!/^recordType: event$/m.test(written)) { diff --git a/packages/agents/src/capture-event/write-event.ts b/packages/agents/src/capture-event/write-event.ts index 08bc26f5..b8faeba8 100644 --- a/packages/agents/src/capture-event/write-event.ts +++ b/packages/agents/src/capture-event/write-event.ts @@ -21,7 +21,11 @@ export async function writeEvent(input: { storePath: string; id: string; content try { await link(tempPath, targetPath); } finally { - await unlink(tempPath).catch(() => {}); + try { + await unlink(tempPath); + } catch { + // A temp file left behind is not worth masking the link outcome the caller is waiting on. + } } return targetPath; diff --git a/packages/agents/src/commands/__tests__/install.unit.test.ts b/packages/agents/src/commands/__tests__/install.unit.test.ts index 91f6e51d..d1bc110a 100644 --- a/packages/agents/src/commands/__tests__/install.unit.test.ts +++ b/packages/agents/src/commands/__tests__/install.unit.test.ts @@ -187,11 +187,9 @@ describe(installCommand, () => { const manifest = await readManifest(getManifestPath(tempDir)); const entries = manifest.harnesses.claude?.entries ?? []; - for (const entry of entries.filter((e) => e.relativePath.startsWith('skills/'))) { - expect(entry.linked).toBe(false); - } - for (const entry of entries.filter((e) => e.relativePath.startsWith('scripts/'))) { - expect(entry.linked).toBe(true); + for (const entry of entries) { + if (entry.relativePath.startsWith('skills/')) expect(entry.linked).toBe(false); + if (entry.relativePath.startsWith('scripts/')) expect(entry.linked).toBe(true); } }); diff --git a/packages/agents/src/commands/library-list.ts b/packages/agents/src/commands/library-list.ts index 8dc80838..629d9043 100644 --- a/packages/agents/src/commands/library-list.ts +++ b/packages/agents/src/commands/library-list.ts @@ -288,7 +288,8 @@ function warnSkipped(type: ArtifactType, file: string, error: unknown): void { function wrapText(text: string, width: number): Array { const lines: Array = []; let current = ''; - for (const word of text.split(/\s+/).filter(Boolean)) { + for (const word of text.split(/\s+/)) { + if (word === '') continue; if (current === '') { current = word; } else if (current.length + 1 + word.length <= width) { diff --git a/packages/agents/src/commands/sync/__tests__/sync.tool.test.ts b/packages/agents/src/commands/sync/__tests__/sync.tool.test.ts index 45cab0c5..6c4c5227 100644 --- a/packages/agents/src/commands/sync/__tests__/sync.tool.test.ts +++ b/packages/agents/src/commands/sync/__tests__/sync.tool.test.ts @@ -1783,10 +1783,13 @@ describe(syncCommand, () => { await writeFile(path.join(dataDir, 'rovo.yaml'), ROVO_OVERLAY, 'utf8'); } + /** Default fixture body, carrying one tool token and one home-dir token for the transform to rewrite. */ + const SUBAGENT_BODY = 'Use {tool:Read}; run `{harness_home_dir}/scripts/x.sh`.'; + /** Writes a fixture subagent `.md` into the temp content library's `subagents/`. */ async function writeLibrarySubagent( slug: string, - { body = `# ${slug}\n\nUse {tool:Read}; run \`{harness_home_dir}/scripts/x.sh\`.` }: { body?: string } = {}, + { body = `# ${slug}\n\n${SUBAGENT_BODY}` }: { body?: string } = {}, ): Promise { const dir = path.join(contentDir, 'subagents'); await mkdir(dir, { recursive: true }); diff --git a/packages/agents/src/commands/uninstall.ts b/packages/agents/src/commands/uninstall.ts index e1a91796..7b98872a 100644 --- a/packages/agents/src/commands/uninstall.ts +++ b/packages/agents/src/commands/uninstall.ts @@ -21,8 +21,6 @@ export async function uninstallCommand( // Uninstall shared guidance unconditionally (not gated by harness detection) const updatedShared = await uninstallSharedGuidance(manifest, options, baseDir); - let remainingHarnesses = { ...manifest.harnesses }; - // uninstallSharedGuidance above is a no-op when manifest.shared is undefined, // so this guard safely covers the "nothing installed at all" case. if (harnesses.length === 0 && !manifest.shared) { @@ -30,6 +28,8 @@ export async function uninstallCommand( return; } + let remainingHarnesses = { ...manifest.harnesses }; + for (const harnessId of harnesses) { console.info(`\nUninstalling for harness: ${harnessId}`); diff --git a/packages/agents/src/derive-session-context/compose-manifest.ts b/packages/agents/src/derive-session-context/compose-manifest.ts index a22107db..b1b23ada 100644 --- a/packages/agents/src/derive-session-context/compose-manifest.ts +++ b/packages/agents/src/derive-session-context/compose-manifest.ts @@ -47,8 +47,9 @@ export function composeManifest(input: { const { preferences, branchName, cwd, home, now, remoteUrl = null } = input; const ticketRefPrefix = preferences.project?.ticket_ref_prefix; - const ticketResult = - ticketRefPrefix === undefined ? extractTicketId({ branchName }) : extractTicketId({ branchName, ticketRefPrefix }); + const ticketResult = extractTicketId( + ticketRefPrefix === undefined ? { branchName } : { branchName, ticketRefPrefix }, + ); const projectSlug = preferences.project?.slug ?? preferences.repository?.slug ?? path.basename(cwd); diff --git a/packages/agents/src/kb-edit/commit-supersede.ts b/packages/agents/src/kb-edit/commit-supersede.ts index 7f57c6fa..6be32d1f 100644 --- a/packages/agents/src/kb-edit/commit-supersede.ts +++ b/packages/agents/src/kb-edit/commit-supersede.ts @@ -68,15 +68,15 @@ export async function commitSupersede(input: { try { await io.writeFile(newTmp, input.newNewContent, 'utf8'); } catch (error) { - await io.unlink(oldTmp).catch(() => {}); + await unlinkQuietly(io, oldTmp); throw error; } try { await io.rename(oldTmp, input.oldPath); } catch (error) { - await io.unlink(oldTmp).catch(() => {}); - await io.unlink(newTmp).catch(() => {}); + await unlinkQuietly(io, oldTmp); + await unlinkQuietly(io, newTmp); throw error; } @@ -84,7 +84,7 @@ export async function commitSupersede(input: { await io.rename(newTmp, input.newPath); return { ok: true }; } catch (renameError) { - await io.unlink(newTmp).catch(() => {}); + await unlinkQuietly(io, newTmp); const originalMessage = describeError(renameError); const rollback = await tryRollbackOld({ oldPath: input.oldPath, @@ -99,6 +99,8 @@ export async function commitSupersede(input: { } } +// region | Helpers + /** Restores the captured original bytes to `oldPath` via temp + rename. Returns ok on success. */ async function tryRollbackOld(input: { oldPath: string; @@ -111,7 +113,18 @@ async function tryRollbackOld(input: { await input.io.rename(rollbackTmp, input.oldPath); return { ok: true }; } catch { - await input.io.unlink(rollbackTmp).catch(() => {}); + await unlinkQuietly(input.io, rollbackTmp); return { ok: false }; } } + +/** Deletes a temp file, ignoring a failure so the caller's own error is the one that surfaces. */ +async function unlinkQuietly(io: CommitSupersedeIo, filePath: string): Promise { + try { + await io.unlink(filePath); + } catch { + // A temp file left behind is the lesser of the two failures being handled here. + } +} + +// endregion | Helpers diff --git a/packages/agents/src/lib/__tests__/claude-hook-settings.unit.test.ts b/packages/agents/src/lib/__tests__/claude-hook-settings.unit.test.ts index d4d82e7d..4a2b6f54 100644 --- a/packages/agents/src/lib/__tests__/claude-hook-settings.unit.test.ts +++ b/packages/agents/src/lib/__tests__/claude-hook-settings.unit.test.ts @@ -15,26 +15,27 @@ const ENTRY: ClaudeHookEntry = { event: 'PreToolUse', group: GROUP }; /** Invalid JSON — a trailing comma — as a hand-edited settings file might well hold. */ const UNPARSEABLE = '{\n "model": "opus",\n}\n'; -let dir: string; +/** Per-test scratch directory, refreshed by `beforeEach` so each test writes into its own. */ +const scratch = { dir: '' }; beforeEach(async () => { - dir = path.join(tmpdir(), `agents-test-hook-settings-${Date.now()}-${Math.random().toString(36).slice(2)}`); - await mkdir(dir, { recursive: true }); + scratch.dir = path.join(tmpdir(), `agents-test-hook-settings-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(scratch.dir, { recursive: true }); }); afterEach(async () => { - await rm(dir, { recursive: true, force: true }); + await rm(scratch.dir, { recursive: true, force: true }); }); describe(checkClaudeHookEntries, () => { it('reports every entry absent when the file does not exist', async () => { - const checks = await checkClaudeHookEntries(path.join(dir, 'absent.json'), [ENTRY], SENTINEL); + const checks = await checkClaudeHookEntries(path.join(scratch.dir, 'absent.json'), [ENTRY], SENTINEL); expect(checks).toEqual([{ entry: ENTRY, status: 'absent' }]); }); it('reports the entry present once ensure has installed it', async () => { - const file = path.join(dir, 'settings.json'); + const file = path.join(scratch.dir, 'settings.json'); await ensureClaudeHookEntries(file, [ENTRY], SENTINEL); expect(await checkClaudeHookEntries(file, [ENTRY], SENTINEL)).toEqual([{ entry: ENTRY, status: 'present' }]); @@ -50,7 +51,7 @@ describe(checkClaudeHookEntries, () => { describe(ensureClaudeHookEntries, () => { it('creates the file and its parent directory when absent', async () => { - const file = path.join(dir, 'nested', 'settings.json'); + const file = path.join(scratch.dir, 'nested', 'settings.json'); const result = await ensureClaudeHookEntries(file, [ENTRY], SENTINEL); @@ -59,7 +60,7 @@ describe(ensureClaudeHookEntries, () => { }); it('does not rewrite the file when the entry is already installed', async () => { - const file = path.join(dir, 'settings.json'); + const file = path.join(scratch.dir, 'settings.json'); await ensureClaudeHookEntries(file, [ENTRY], SENTINEL); const firstMtime = statSync(file).mtimeMs; @@ -139,10 +140,10 @@ describe(ensureClaudeHookEntries, () => { }); it('updates the target of a symlinked settings file without replacing the link', async () => { - const target = path.join(dir, 'dotfiles', 'settings.json'); + const target = path.join(scratch.dir, 'dotfiles', 'settings.json'); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, '{}\n', 'utf8'); - const link = path.join(dir, 'settings.json'); + const link = path.join(scratch.dir, 'settings.json'); await symlink(target, link); await ensureClaudeHookEntries(link, [ENTRY], SENTINEL); @@ -152,7 +153,7 @@ describe(ensureClaudeHookEntries, () => { }); it('creates no file when a supplied entry does not carry the sentinel', async () => { - const file = path.join(dir, 'settings.json'); + const file = path.join(scratch.dir, 'settings.json'); const unmarked: ClaudeHookEntry = { event: 'PreToolUse', group: { hooks: [{ command: 'echo hi' }] } }; await expect(ensureClaudeHookEntries(file, [unmarked], SENTINEL)).rejects.toThrow(/sentinel/); @@ -173,7 +174,7 @@ describe(removeClaudeHookEntries, () => { }); it('creates no file when the settings file does not exist', async () => { - const file = path.join(dir, 'absent.json'); + const file = path.join(scratch.dir, 'absent.json'); expect(await removeClaudeHookEntries(file, SENTINEL)).toEqual({ changed: false, removedCount: 0 }); expect(existsSync(file)).toBe(false); @@ -207,7 +208,7 @@ describe('an unparseable settings file', () => { /** Writes fixture text to the temp directory's settings file and returns its path. */ async function writeSettings(text: string): Promise { - const file = path.join(dir, 'settings.json'); + const file = path.join(scratch.dir, 'settings.json'); await writeFile(file, text, 'utf8'); return file; } diff --git a/packages/agents/src/lib/claude-hook-entries.ts b/packages/agents/src/lib/claude-hook-entries.ts index 10064a0a..d1ed4897 100644 --- a/packages/agents/src/lib/claude-hook-entries.ts +++ b/packages/agents/src/lib/claude-hook-entries.ts @@ -105,13 +105,13 @@ export function removeHookEntries(settings: unknown, sentinel: string): HookEntr const nextHooks: Record = {}; let removedCount = 0; - for (const event of Object.keys(hooks)) { + for (const [event, groups] of Object.entries(hooks)) { const current = readEventGroups(hooks, event); const retained = current.filter((group) => !isOwnedGroup(group, sentinel)); removedCount += current.length - retained.length; if (retained.length === current.length) { - nextHooks[event] = hooks[event]; + nextHooks[event] = groups; } else if (retained.length > 0) { nextHooks[event] = retained; } From d17fe48d90a282d8831616df1b80cba8cb226987 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 08:13:11 -0700 Subject: [PATCH 5/9] refactor: Retire agents' lint deferral and guard against its return No package holds a lint deferral, so every rule the shared preset sets to error now fails a gate rather than passing as a warning. A repo-root test fails when a package reintroduces one, either as a rule list or as a strict-lint promotion cap. --- .../__tests__/lint-deferrals.unit.test.ts | 45 +++++++++++++++++++ .../.config/eslint/deferred-lint-rules.ts | 12 ----- packages/agents/.config/strict-lint.config.ts | 10 ----- packages/agents/eslint.config.ts | 6 --- packages/agents/tsconfig.json | 1 - 5 files changed, 45 insertions(+), 29 deletions(-) create mode 100644 .config/eslint/__tests__/lint-deferrals.unit.test.ts delete mode 100644 packages/agents/.config/eslint/deferred-lint-rules.ts delete mode 100644 packages/agents/.config/strict-lint.config.ts diff --git a/.config/eslint/__tests__/lint-deferrals.unit.test.ts b/.config/eslint/__tests__/lint-deferrals.unit.test.ts new file mode 100644 index 00000000..3c40373a --- /dev/null +++ b/.config/eslint/__tests__/lint-deferrals.unit.test.ts @@ -0,0 +1,45 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const PACKAGES_DIR = fileURLToPath(new URL('../../../packages/', import.meta.url)); +const DEFERRED_RULES_PATH = '.config/eslint/deferred-lint-rules.ts'; +const PACKAGE_STRICT_LINT_PATH = '.config/strict-lint.config.ts'; + +// A deferral holds rules the shared preset sets to `error` at `warn`, where they neither block a gate nor get +// fixed. Both halves are checked, because either alone restores the mechanism: the rule list, and the +// `maxSeverity` cap that stops strict-lint promoting those warnings back to errors. +describe('lint deferrals', () => { + it('are declared by no package', () => { + expect(listPackagesCarrying(DEFERRED_RULES_PATH)).toEqual([]); + }); + + it('are not reintroduced as a strict-lint promotion cap', () => { + const capping = listPackagesCarrying(PACKAGE_STRICT_LINT_PATH).filter((packageName) => + readFileSync(path.join(PACKAGES_DIR, packageName, PACKAGE_STRICT_LINT_PATH), 'utf8').includes('maxSeverity'), + ); + + expect(capping).toEqual([]); + }); +}); + +// region | Helpers + +/** Names every workspace package holding a file at `relativePath`. */ +function listPackagesCarrying(relativePath: string): string[] { + return listWorkspacePackages().filter((packageName) => + existsSync(path.join(PACKAGES_DIR, packageName, relativePath)), + ); +} + +/** Names every directory under `packages/` that holds a workspace package. */ +function listWorkspacePackages(): string[] { + return readdirSync(PACKAGES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && existsSync(path.join(PACKAGES_DIR, entry.name, 'package.json'))) + .map((entry) => entry.name) + .toSorted(); +} + +// endregion | Helpers diff --git a/packages/agents/.config/eslint/deferred-lint-rules.ts b/packages/agents/.config/eslint/deferred-lint-rules.ts deleted file mode 100644 index d914d673..00000000 --- a/packages/agents/.config/eslint/deferred-lint-rules.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const deferredLintRules = { - 'unicorn/no-declarations-before-early-exit': 'warn', - 'unicorn/no-duplicate-loops': 'warn', - 'unicorn/no-incorrect-template-string-interpolation': 'warn', - 'unicorn/no-top-level-assignment-in-function': 'warn', - 'unicorn/no-unsafe-string-replacement': 'warn', - 'unicorn/prefer-await': 'warn', - 'unicorn/prefer-iterator-to-array': 'warn', - 'unicorn/prefer-minimal-ternary': 'warn', - 'unicorn/prefer-object-iterable-methods': 'warn', - 'preserve-caught-error': 'warn', -} as const; diff --git a/packages/agents/.config/strict-lint.config.ts b/packages/agents/.config/strict-lint.config.ts deleted file mode 100644 index 5f432e5f..00000000 --- a/packages/agents/.config/strict-lint.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { StrictLintConfig } from '@williamthorsen/strict-lint'; - -import { deferredLintRules } from './eslint/deferred-lint-rules.ts'; - -const config: StrictLintConfig = { - // Keep the deferred rules as warnings; strict-lint otherwise promotes every warning to an error. - maxSeverity: deferredLintRules, -}; - -export default config; diff --git a/packages/agents/eslint.config.ts b/packages/agents/eslint.config.ts index 378f2c9f..6b6de417 100644 --- a/packages/agents/eslint.config.ts +++ b/packages/agents/eslint.config.ts @@ -1,7 +1,6 @@ import { defineConfig, globalIgnores } from 'eslint/config'; import baseConfig from '../../eslint.config.ts'; -import { deferredLintRules } from './.config/eslint/deferred-lint-rules.ts'; const config = defineConfig([ ...baseConfig, @@ -11,11 +10,6 @@ const config = defineConfig([ 'content/skills/**/*.mjs', 'content/skills/**/*-example.ts', ]), - { - files: ['**/*.ts', '**/*.mts', '**/*.tsx', '**/*.md/*.ts', '**/*.js'], - plugins: {}, - rules: deferredLintRules, - }, { files: ['package.json'], rules: { diff --git a/packages/agents/tsconfig.json b/packages/agents/tsconfig.json index 3635134e..07159869 100644 --- a/packages/agents/tsconfig.json +++ b/packages/agents/tsconfig.json @@ -3,7 +3,6 @@ "extends": "../../tsconfig.json", // prettier-ignore "include": [ - ".config/**/*.ts", ".readyup/**/*.ts", // Content-convention suites sit beside the content tree they cover. `nmr compile` builds from `src/` alone, so // this reaches typechecking and linting without adding anything to the emitted package. From 2a65843a45888e412f65d1ab57660042924448ee Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 08:26:27 -0700 Subject: [PATCH 6/9] refactor: Import TypeScript modules by their own extension Every relative import in agents and the root scripts names the `.ts` file it resolves to, rather than the `.js` file the build emits from it. --- .../__tests__/generate-label-map.unit.test.ts | 2 +- packages/agents/src/commands/install.ts | 12 ++++++------ packages/agents/src/commands/status.ts | 6 +++--- packages/agents/src/commands/uninstall.ts | 8 ++++---- .../src/lib/__tests__/content-resolver.unit.test.ts | 2 +- .../lib/__tests__/directive-expander.unit.test.ts | 2 +- .../lib/__tests__/frontmatter-merger.unit.test.ts | 2 +- .../agents/src/lib/__tests__/installer.unit.test.ts | 2 +- .../agents/src/lib/__tests__/manifest.unit.test.ts | 4 ++-- .../src/lib/__tests__/marker-injector.unit.test.ts | 2 +- .../__tests__/shared-guidance-policy.unit.test.ts | 2 +- .../lib/__tests__/tool-name-rewriter.unit.test.ts | 2 +- packages/agents/src/lib/manifest.ts | 2 +- .../replace-separator-comments.unit.test.ts | 2 +- 14 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/agents/src/commands/__tests__/generate-label-map.unit.test.ts b/packages/agents/src/commands/__tests__/generate-label-map.unit.test.ts index a3f4fca7..c6f67819 100644 --- a/packages/agents/src/commands/__tests__/generate-label-map.unit.test.ts +++ b/packages/agents/src/commands/__tests__/generate-label-map.unit.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { generateLabelMap, printGenerateUsage, readReleaseKitVersion } from '../generate-label-map.js'; +import { generateLabelMap, printGenerateUsage, readReleaseKitVersion } from '../generate-label-map.ts'; interface LabelMap { readonly $schema: string; diff --git a/packages/agents/src/commands/install.ts b/packages/agents/src/commands/install.ts index 1ecaace4..9f3808f9 100644 --- a/packages/agents/src/commands/install.ts +++ b/packages/agents/src/commands/install.ts @@ -10,7 +10,7 @@ import { expandIncludes } from '../lib/directive-expander.ts'; import { emitReport } from '../lib/emit-report.ts'; import { describePruneResult, pruneOrphanedEntries } from '../lib/entry-remover.ts'; import { stripGuidanceHooks } from '../lib/guidance-hooks.ts'; -import { HARNESSES, resolveHarnessIds, resolveHarnessPaths, resolveSkillsPathPrefix } from '../lib/harness.js'; +import { HARNESSES, resolveHarnessIds, resolveHarnessPaths, resolveSkillsPathPrefix } from '../lib/harness.ts'; import { loadHarnessOverlay } from '../lib/harness-overlay.ts'; import { recordHomeProvenance } from '../lib/home-provenance.ts'; import { assertDesignatedWriter } from '../lib/home-writer-guard.ts'; @@ -23,12 +23,12 @@ import { readManifest, resolveSharedHome, writeManifest, -} from '../lib/manifest.js'; -import { buildSourceUrl, injectMarkerInFile, injectMarkersInDirectory } from '../lib/marker-injector.js'; -import { homeAnchor, rewritePathsInFile } from '../lib/path-rewriter.js'; +} from '../lib/manifest.ts'; +import { buildSourceUrl, injectMarkerInFile, injectMarkersInDirectory } from '../lib/marker-injector.ts'; +import { homeAnchor, rewritePathsInFile } from '../lib/path-rewriter.ts'; import { readRunningPackageVersion, resolveRunningPackageRoot } from '../lib/running-package.ts'; import { type RenderedSkillEntry, renderSupportEntry } from '../lib/skill-transform.ts'; -import { loadToolMapping } from '../lib/tool-name-rewriter.js'; +import { loadToolMapping } from '../lib/tool-name-rewriter.ts'; import { isEnoent } from '../lib/type-guards.ts'; import type { AgentsManifest, @@ -38,7 +38,7 @@ import type { InstallOptions, ManifestEntry, SharedManifest, -} from '../lib/types.js'; +} from '../lib/types.ts'; import { ensureHarnessHookEntries } from './configure-hooks.ts'; /** diff --git a/packages/agents/src/commands/status.ts b/packages/agents/src/commands/status.ts index f97426eb..d347e64c 100644 --- a/packages/agents/src/commands/status.ts +++ b/packages/agents/src/commands/status.ts @@ -1,9 +1,9 @@ import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; -import { resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.js'; +import { resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.ts'; import { readHomeProvenance } from '../lib/home-provenance.ts'; -import { detectDrift, getManifestPath, readManifest, resolveSharedHome } from '../lib/manifest.js'; -import type { HarnessId, InstallOptions } from '../lib/types.js'; +import { detectDrift, getManifestPath, readManifest, resolveSharedHome } from '../lib/manifest.ts'; +import type { HarnessId, InstallOptions } from '../lib/types.ts'; import { checkHarnessHookEntries, type HookEntryStatus } from './configure-hooks.ts'; /** diff --git a/packages/agents/src/commands/uninstall.ts b/packages/agents/src/commands/uninstall.ts index 7b98872a..716cb3eb 100644 --- a/packages/agents/src/commands/uninstall.ts +++ b/packages/agents/src/commands/uninstall.ts @@ -1,10 +1,10 @@ import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { classifyOwnedEntry } from '../lib/entry-remover.ts'; -import { resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.js'; -import { removeItem } from '../lib/installer.js'; -import { getManifestPath, readManifest, resolveSharedHome, writeManifest } from '../lib/manifest.js'; -import type { AgentsManifest, InstallOptions, ManifestEntry, SharedManifest } from '../lib/types.js'; +import { resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.ts'; +import { removeItem } from '../lib/installer.ts'; +import { getManifestPath, readManifest, resolveSharedHome, writeManifest } from '../lib/manifest.ts'; +import type { AgentsManifest, InstallOptions, ManifestEntry, SharedManifest } from '../lib/types.ts'; import { removeHarnessHookEntries } from './configure-hooks.ts'; /** diff --git a/packages/agents/src/lib/__tests__/content-resolver.unit.test.ts b/packages/agents/src/lib/__tests__/content-resolver.unit.test.ts index 2bbd9d54..9bab2d6c 100644 --- a/packages/agents/src/lib/__tests__/content-resolver.unit.test.ts +++ b/packages/agents/src/lib/__tests__/content-resolver.unit.test.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { resolveContentDir } from '../content-resolver.js'; +import { resolveContentDir } from '../content-resolver.ts'; describe('resolveContentDir', () => { it('should resolve to a directory that exists', () => { diff --git a/packages/agents/src/lib/__tests__/directive-expander.unit.test.ts b/packages/agents/src/lib/__tests__/directive-expander.unit.test.ts index 1a8d2177..a5f19570 100644 --- a/packages/agents/src/lib/__tests__/directive-expander.unit.test.ts +++ b/packages/agents/src/lib/__tests__/directive-expander.unit.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { DirectiveExpansionError, expandIncludes } from '../directive-expander.js'; +import { DirectiveExpansionError, expandIncludes } from '../directive-expander.ts'; describe(expandIncludes, () => { let contentDir: string; diff --git a/packages/agents/src/lib/__tests__/frontmatter-merger.unit.test.ts b/packages/agents/src/lib/__tests__/frontmatter-merger.unit.test.ts index 87ef74e2..5a95bea7 100644 --- a/packages/agents/src/lib/__tests__/frontmatter-merger.unit.test.ts +++ b/packages/agents/src/lib/__tests__/frontmatter-merger.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { mergeFrontmatter, parseFrontmatter, parseOverlayOverrides } from '../frontmatter-merger.js'; +import { mergeFrontmatter, parseFrontmatter, parseOverlayOverrides } from '../frontmatter-merger.ts'; describe('parseFrontmatter', () => { it('should extract agent name from frontmatter', () => { diff --git a/packages/agents/src/lib/__tests__/installer.unit.test.ts b/packages/agents/src/lib/__tests__/installer.unit.test.ts index a31c0bc0..32385cea 100644 --- a/packages/agents/src/lib/__tests__/installer.unit.test.ts +++ b/packages/agents/src/lib/__tests__/installer.unit.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { checkSymlinkSafety, copyItem, linkItem, removeItem } from '../installer.js'; +import { checkSymlinkSafety, copyItem, linkItem, removeItem } from '../installer.ts'; describe('installer', () => { let tempDir: string; diff --git a/packages/agents/src/lib/__tests__/manifest.unit.test.ts b/packages/agents/src/lib/__tests__/manifest.unit.test.ts index a37da66e..3fdd407e 100644 --- a/packages/agents/src/lib/__tests__/manifest.unit.test.ts +++ b/packages/agents/src/lib/__tests__/manifest.unit.test.ts @@ -4,8 +4,8 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { computeContentHash, createEmptyManifest, detectDrift, readManifest, writeManifest } from '../manifest.js'; -import type { AgentsManifest, ManifestEntry } from '../types.js'; +import { computeContentHash, createEmptyManifest, detectDrift, readManifest, writeManifest } from '../manifest.ts'; +import type { AgentsManifest, ManifestEntry } from '../types.ts'; describe('manifest', () => { let tempDir: string; diff --git a/packages/agents/src/lib/__tests__/marker-injector.unit.test.ts b/packages/agents/src/lib/__tests__/marker-injector.unit.test.ts index 8027f599..ba12736e 100644 --- a/packages/agents/src/lib/__tests__/marker-injector.unit.test.ts +++ b/packages/agents/src/lib/__tests__/marker-injector.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildSourceUrl, injectProvenanceMarker, SOURCE_REF } from '../marker-injector.js'; +import { buildSourceUrl, injectProvenanceMarker, SOURCE_REF } from '../marker-injector.ts'; describe(injectProvenanceMarker, () => { const sourceUrl = diff --git a/packages/agents/src/lib/__tests__/shared-guidance-policy.unit.test.ts b/packages/agents/src/lib/__tests__/shared-guidance-policy.unit.test.ts index 01ab7143..237529ae 100644 --- a/packages/agents/src/lib/__tests__/shared-guidance-policy.unit.test.ts +++ b/packages/agents/src/lib/__tests__/shared-guidance-policy.unit.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { resolveContentDir } from '../content-resolver.js'; +import { resolveContentDir } from '../content-resolver.ts'; interface LinkViolation { file: string; diff --git a/packages/agents/src/lib/__tests__/tool-name-rewriter.unit.test.ts b/packages/agents/src/lib/__tests__/tool-name-rewriter.unit.test.ts index 86ac817d..afd446a7 100644 --- a/packages/agents/src/lib/__tests__/tool-name-rewriter.unit.test.ts +++ b/packages/agents/src/lib/__tests__/tool-name-rewriter.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { loadToolMapping, rewriteToolNames, ToolNameRewriteError } from '../tool-name-rewriter.js'; +import { loadToolMapping, rewriteToolNames, ToolNameRewriteError } from '../tool-name-rewriter.ts'; const IDENTITY_MAP = new Map([ ['Bash', 'Bash'], diff --git a/packages/agents/src/lib/manifest.ts b/packages/agents/src/lib/manifest.ts index 6603f07e..1134e2a4 100644 --- a/packages/agents/src/lib/manifest.ts +++ b/packages/agents/src/lib/manifest.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { hasAmbientRegion, stripAmbientRegionContent } from './ambient-region.ts'; import { isRecord } from './type-guards.ts'; -import type { AgentsManifest, ManifestEntry } from './types.js'; +import type { AgentsManifest, ManifestEntry } from './types.ts'; /** Shared guidance home directory relative to the user's home. */ const SHARED_HOME_DIR = '.agents'; diff --git a/scripts/__tests__/replace-separator-comments.unit.test.ts b/scripts/__tests__/replace-separator-comments.unit.test.ts index ebe26a6c..03256bf3 100644 --- a/scripts/__tests__/replace-separator-comments.unit.test.ts +++ b/scripts/__tests__/replace-separator-comments.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { isFoldable, transformFile } from '../replace-separator-comments.js'; +import { isFoldable, transformFile } from '../replace-separator-comments.ts'; describe(isFoldable, () => { const foldable = [ From 7e802135b5c361de10faab2426c64d7a43eed940 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 08:59:46 -0700 Subject: [PATCH 7/9] agents|refactor: Extract the last error message through describeError The KB config-health warning builds its detail through the shared describer, so an `Error` with an empty message names its class there as it does everywhere else in agents. --- .config/{eslint => }/__tests__/lint-deferrals.unit.test.ts | 0 packages/agents/src/kb-search/search.ts | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) rename .config/{eslint => }/__tests__/lint-deferrals.unit.test.ts (100%) diff --git a/.config/eslint/__tests__/lint-deferrals.unit.test.ts b/.config/__tests__/lint-deferrals.unit.test.ts similarity index 100% rename from .config/eslint/__tests__/lint-deferrals.unit.test.ts rename to .config/__tests__/lint-deferrals.unit.test.ts diff --git a/packages/agents/src/kb-search/search.ts b/packages/agents/src/kb-search/search.ts index aa4b444d..ca377a9a 100644 --- a/packages/agents/src/kb-search/search.ts +++ b/packages/agents/src/kb-search/search.ts @@ -4,6 +4,7 @@ import type { NoteScopeMatcher } from '@williamthorsen/kb/config'; import { createNoteScopeMatcher, defaultKbConfig, loadKbConfig } from '@williamthorsen/kb/config'; import type { ParsedNote } from '@williamthorsen/kb/frontmatter'; import { resolveKbDir } from '@williamthorsen/kb/layout'; +import { describeError } from '@williamthorsen/toolbelt.errors/candidate'; import { extractString, parseNoteSafely } from '../kb-shared/note-helpers.ts'; import type { RecallFn } from './recall.ts'; @@ -143,7 +144,7 @@ async function loadMatchersForHits(input: { */ function formatConfigInvalid(input: { kbPath: string; scopedKbs: ScopedKb[]; error: unknown }): string { const name = input.scopedKbs.find((kb) => kb.path === input.kbPath)?.name ?? null; - const message = input.error instanceof Error ? input.error.message : String(input.error); + const message = describeError(input.error); return name === null ? `discovered KB config invalid at ${input.kbPath}: ${message}` : `registry KB "${name}" config invalid: ${message}`; From 349c33fff77288d5a3f0ab1c1d0adb63d108bbe6 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 08:59:56 -0700 Subject: [PATCH 8/9] agents|docs: Name the replacement patterns a string replacement expands `replaceAll` with a string search value expands `$$`, `$&`, ``$` ``, and `$'`, and inserts `$1` verbatim because a string search captures nothing. The template-variable expander's note now names that set, and its test carries `$$` alongside `$&` and `$'`. --- packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts | 2 +- packages/agents/src/lib/path-rewriter.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts b/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts index c4055dd3..de071402 100644 --- a/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts +++ b/packages/agents/src/lib/__tests__/path-rewriter.unit.test.ts @@ -156,7 +156,7 @@ describe(rewriteTemplateVariables, () => { it('inserts a substitution value carrying a replacement pattern verbatim', () => { const content = '{harness_home_dir}/x --harness {harness_id}'; - expect(rewriteTemplateVariables(content, '.cl$&aude', "cl$'aude")).toBe("~/.cl$&aude/x --harness cl$'aude"); + expect(rewriteTemplateVariables(content, '.cl$&$$aude', "cl$'aude")).toBe("~/.cl$&$$aude/x --harness cl$'aude"); }); it('replaces {harness_id} with the harness identifier, leaving no placeholder', () => { diff --git a/packages/agents/src/lib/path-rewriter.ts b/packages/agents/src/lib/path-rewriter.ts index ef9c5de7..a4668960 100644 --- a/packages/agents/src/lib/path-rewriter.ts +++ b/packages/agents/src/lib/path-rewriter.ts @@ -99,7 +99,7 @@ export function rewriteMarkdownPaths(content: string, fileRelPath: string, ancho * harness. */ export function rewriteTemplateVariables(content: string, homeDir: string, harnessId: string): string { - // Replacer functions, not strings: a string replacement expands `$&`, `` $` ``, `$'`, and `$n`, so a + // Replacer functions, not strings: a string replacement expands `$$`, `$&`, `` $` ``, and `$'`, so a // substitution value carrying one of them would be rewritten into the match it was meant to replace. return content.replaceAll('{harness_home_dir}', () => `~/${homeDir}`).replaceAll('{harness_id}', () => harnessId); } From a23b4006f415cd0be3f7816111fdee9f6dada913 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 09:00:03 -0700 Subject: [PATCH 9/9] root|tests: Move the deferral guard beside the configs it defends The guard sits at `.config/__tests__/`, beside `strict-lint.config.ts` and the other tool configs, rather than under a `.config/eslint/` directory that holds no configuration. --- .config/__tests__/lint-deferrals.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/__tests__/lint-deferrals.unit.test.ts b/.config/__tests__/lint-deferrals.unit.test.ts index 3c40373a..a8b27466 100644 --- a/.config/__tests__/lint-deferrals.unit.test.ts +++ b/.config/__tests__/lint-deferrals.unit.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -const PACKAGES_DIR = fileURLToPath(new URL('../../../packages/', import.meta.url)); +const PACKAGES_DIR = fileURLToPath(new URL('../../packages/', import.meta.url)); const DEFERRED_RULES_PATH = '.config/eslint/deferred-lint-rules.ts'; const PACKAGE_STRICT_LINT_PATH = '.config/strict-lint.config.ts';