From 8b778c8483a68ede776e1eed545d2faedd028237 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Sat, 4 Apr 2026 14:02:42 +1100 Subject: [PATCH 1/6] feat: move skill into plugin as canonical location - Skill relocated from skills/ost-tools/ to plugin/skills/ost-tools/ (git rename). plugin/skills/ is now the single source; standalone install means copying from there to ~/.claude/skills/. - Fix plugin manifest: moved to .claude-plugin/plugin.json (correct spec location), updated skills path to ./skills/. - Update AGENTS.md, CLAUDE.md, README.md to reference new location and document both plugin install and standalone skill install. --- AGENTS.md | 2 +- README.md | 10 +- plugin/.claude-plugin/plugin.json | 12 ++ plugin/commands/dump-node.md | 5 + plugin/commands/validate.md | 5 + plugin/hooks/hooks.json | 28 +++ plugin/scripts/post-edit.sh | 36 ++++ plugin/scripts/pre-edit.sh | 39 ++++ {skills => plugin/skills}/ost-tools/SKILL.md | 0 .../skills}/ost-tools/references/commands.md | 0 .../ost-tools/references/schema-authoring.md | 0 .../ost-tools/references/schema-design.md | 0 src/commands/validate-file.ts | 177 ++++++++++++++++++ src/commands/validate.ts | 4 +- src/index.ts | 12 ++ 15 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 plugin/.claude-plugin/plugin.json create mode 100644 plugin/commands/dump-node.md create mode 100644 plugin/commands/validate.md create mode 100644 plugin/hooks/hooks.json create mode 100755 plugin/scripts/post-edit.sh create mode 100755 plugin/scripts/pre-edit.sh rename {skills => plugin/skills}/ost-tools/SKILL.md (100%) rename {skills => plugin/skills}/ost-tools/references/commands.md (100%) rename {skills => plugin/skills}/ost-tools/references/schema-authoring.md (100%) rename {skills => plugin/skills}/ost-tools/references/schema-design.md (100%) create mode 100644 src/commands/validate-file.ts diff --git a/AGENTS.md b/AGENTS.md index c806e65..7482161 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ There are several places that need reviewing and updating with any new feature o - README.md - documentation, also displayed with `ost-tools readme` command - AGENTS.md - this file - docs/* - includes architecture, concepts etc -- skills/ost-tools/* - skills information for AI agents +- plugin/skills/ost-tools/* - skills information for AI agents (canonical; plugin is the single source) ## Project Context diff --git a/README.md b/README.md index d54e797..a6c1747 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,16 @@ bunx ost-tools validate ## Setup for AI Agents -An AI agent skill is included with this project. To install it run: +A Claude Code plugin is included at `plugin/`. It provides validation hooks, slash commands, and agent skills. Install it with: ``` -npx skills add mindsocket/ost-tools +claude plugin install mindsocket/ost-tools +``` + +Skills can also be installed standalone without the plugin: + +``` +npx skills add https://github.com/mindsocket/ost-tools/tree/main/plugin/skills/ost-tools ``` ## Concepts diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..7c4238d --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "ost-tools", + "version": "0.8.3", + "description": "Validate and work with ost-tools spaces from Claude Code — includes validation hooks, slash commands, and agent skills.", + "homepage": "https://github.com/mindsocket/ost-tools", + "repository": "https://github.com/mindsocket/ost-tools", + "license": "MIT", + "keywords": ["ost-tools", "validation", "markdown", "schema"], + "skills": "./skills/", + "commands": "./commands/", + "hooks": "./hooks/hooks.json" +} diff --git a/plugin/commands/dump-node.md b/plugin/commands/dump-node.md new file mode 100644 index 0000000..e4743f7 --- /dev/null +++ b/plugin/commands/dump-node.md @@ -0,0 +1,5 @@ +--- +description: Dump parsed node data for a space to inspect resolved fields and relationships +--- + +Run `ost-tools dump ` for the relevant space and show the parsed node data as JSON. This is useful for debugging validation rules, inspecting resolved parent references, and understanding what JSONata rules see. Ask which space to dump if not clear from context. diff --git a/plugin/commands/validate.md b/plugin/commands/validate.md new file mode 100644 index 0000000..2fa856b --- /dev/null +++ b/plugin/commands/validate.md @@ -0,0 +1,5 @@ +--- +description: Validate the current space or a specific file against its schema +--- + +Validate the ost-tools space. If a file path is given, validate just that file using `ost-tools validate-file --json`. Otherwise ask which space to validate and run `ost-tools validate `. Show any errors clearly and offer to fix them. diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json new file mode 100644 index 0000000..2bbca96 --- /dev/null +++ b/plugin/hooks/hooks.json @@ -0,0 +1,28 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/pre-edit.sh", + "timeout": 30 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/post-edit.sh", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugin/scripts/post-edit.sh b/plugin/scripts/post-edit.sh new file mode 100755 index 0000000..75eb623 --- /dev/null +++ b/plugin/scripts/post-edit.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Post-edit hook: validate the edited file after a write/edit operation. +# Reports errors attributable to the file. Claude should fix any errors +# it introduced (compare against the pre-edit baseline shown earlier). + +set -euo pipefail + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +if [[ -z "$FILE_PATH" ]]; then + exit 0 +fi + +if [[ "$FILE_PATH" != *.md ]]; then + exit 0 +fi + +RESULT=$(bunx ost-tools validate-file "$FILE_PATH" --json 2>/dev/null || true) + +IN_SPACE=$(echo "$RESULT" | jq -r '.inSpace // false') +if [[ "$IN_SPACE" != "true" ]]; then + exit 0 +fi + +ERROR_COUNT=$(echo "$RESULT" | jq -r '.errorCount // 0') +SPACE=$(echo "$RESULT" | jq -r '.space // ""') +LABEL=$(echo "$RESULT" | jq -r '.label // ""') + +if [[ "$ERROR_COUNT" -eq 0 ]]; then + echo "[ost-tools] $LABEL (space: $SPACE) — valid" +else + echo "[ost-tools] $LABEL (space: $SPACE) — $ERROR_COUNT error(s) after edit:" + echo "$RESULT" | jq -r '.errors[] | " [\(.kind)] \(.message)"' + echo "Fix any errors you introduced (check pre-edit baseline above to identify pre-existing ones)." +fi diff --git a/plugin/scripts/pre-edit.sh b/plugin/scripts/pre-edit.sh new file mode 100755 index 0000000..1648192 --- /dev/null +++ b/plugin/scripts/pre-edit.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Pre-edit hook: capture validation baseline before a file is modified. +# Output is fed to Claude as context so it knows which errors pre-existed the edit. +# Claude should not attempt to fix errors that were already present before the edit. + +set -euo pipefail + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +# Not all edit tools provide file_path (e.g. MultiEdit uses the same field, but check anyway) +if [[ -z "$FILE_PATH" ]]; then + exit 0 +fi + +# Only run for .md files +if [[ "$FILE_PATH" != *.md ]]; then + exit 0 +fi + +RESULT=$(bunx ost-tools validate-file "$FILE_PATH" --json 2>/dev/null || true) + +# If the file isn't in any space, nothing to report +IN_SPACE=$(echo "$RESULT" | jq -r '.inSpace // false') +if [[ "$IN_SPACE" != "true" ]]; then + exit 0 +fi + +ERROR_COUNT=$(echo "$RESULT" | jq -r '.errorCount // 0') +SPACE=$(echo "$RESULT" | jq -r '.space // ""') +LABEL=$(echo "$RESULT" | jq -r '.label // ""') + +if [[ "$ERROR_COUNT" -eq 0 ]]; then + echo "[ost-tools] Pre-edit baseline: $LABEL (space: $SPACE) — no existing errors" +else + echo "[ost-tools] Pre-edit baseline: $LABEL (space: $SPACE) — $ERROR_COUNT pre-existing error(s):" + echo "$RESULT" | jq -r '.errors[] | " [\(.kind)] \(.message)"' + echo "Do not fix these — they existed before your edit." +fi diff --git a/skills/ost-tools/SKILL.md b/plugin/skills/ost-tools/SKILL.md similarity index 100% rename from skills/ost-tools/SKILL.md rename to plugin/skills/ost-tools/SKILL.md diff --git a/skills/ost-tools/references/commands.md b/plugin/skills/ost-tools/references/commands.md similarity index 100% rename from skills/ost-tools/references/commands.md rename to plugin/skills/ost-tools/references/commands.md diff --git a/skills/ost-tools/references/schema-authoring.md b/plugin/skills/ost-tools/references/schema-authoring.md similarity index 100% rename from skills/ost-tools/references/schema-authoring.md rename to plugin/skills/ost-tools/references/schema-authoring.md diff --git a/skills/ost-tools/references/schema-design.md b/plugin/skills/ost-tools/references/schema-design.md similarity index 100% rename from skills/ost-tools/references/schema-design.md rename to plugin/skills/ost-tools/references/schema-design.md diff --git a/src/commands/validate-file.ts b/src/commands/validate-file.ts new file mode 100644 index 0000000..940f773 --- /dev/null +++ b/src/commands/validate-file.ts @@ -0,0 +1,177 @@ +import { isAbsolute, relative, resolve } from 'node:path'; +import type { Config, SpaceConfig } from '../config'; +import { getSpaceConfigDir, loadConfig, resolveSchema } from '../config'; +import { readSpace } from '../read/read-space'; +import { loadSchema } from '../schema/schema'; +import { validateGraph } from '../schema/validate-graph'; +import { validateRules } from '../schema/validate-rules'; +import type { SpaceContext } from '../types'; +import { formatErrors } from './validate'; + +export interface FileError { + kind: 'schema' | 'broken-link' | 'duplicate' | 'rule' | 'hierarchy'; + message: string; +} + +export interface FileValidationResult { + file: string; + label: string; + space: string; + errors: FileError[]; + errorCount: number; + inSpace: true; +} + +export interface FileNotInSpaceResult { + file: string; + inSpace: false; + message: string; +} + +export type ValidateFileOutput = FileValidationResult | FileNotInSpaceResult; + +/** Find which space a file belongs to by checking directory containment. */ +function resolveFileSpace(filePath: string, config: Config): { space: SpaceConfig; label: string } | null { + const absFile = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath); + + for (const space of config.spaces) { + const absSpace = isAbsolute(space.path) ? space.path : resolve(process.cwd(), space.path); + // Trailing slash prevents prefix-match false positives (e.g. /foo matching /foobar/) + const spaceDir = absSpace.endsWith('/') ? absSpace : `${absSpace}/`; + if (absFile.startsWith(spaceDir) || absFile === absSpace) { + return { space, label: relative(absSpace, absFile) }; + } + } + return null; +} + +function buildContextForSpace(space: SpaceConfig, config: Config): SpaceContext { + const resolvedSchemaPath = resolveSchema(config, space); + const { schema, schemaRefRegistry, schemaValidator } = loadSchema(resolvedSchemaPath); + const configDir = getSpaceConfigDir(space.name); + return { space, config, resolvedSchemaPath, schema, schemaRefRegistry, schemaValidator, configDir }; +} + +/** + * Validate a single file within its space. + * + * Reads the full space (required for graph correctness) but filters all reported + * errors to only those attributable to the target file. Exits 0 if the file is + * not in any configured space (not an error — hooks call this on all file writes). + */ +export async function validateFile(filePath: string, options: { json?: boolean } = {}): Promise { + const config = loadConfig(); + const resolved = resolveFileSpace(filePath, config); + + if (!resolved) { + const result: FileNotInSpaceResult = { + file: filePath, + inSpace: false, + message: 'File does not belong to any configured space.', + }; + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } + return 0; + } + + const { space, label } = resolved; + const context = buildContextForSpace(space, config); + const { schema, schemaRefRegistry, schemaValidator } = context; + const metadata = schema.metadata; + + const readResult = await readSpace(context); + const { nodes } = readResult; + + const errors: FileError[] = []; + + // Schema validation errors for this node + for (const node of nodes) { + if (node.label !== label) continue; + const valid = schemaValidator(node.schemaData); + if (!valid) { + const formatted = formatErrors( + schemaValidator.errors ?? [], + schema, + schemaRefRegistry, + node.schemaData as Record, + ); + for (const { message } of formatted) { + errors.push({ kind: 'schema', message }); + } + } + } + + // Duplicate key errors — include if this file is one of the duplicates + const titleToFiles = new Map(); + for (const node of nodes) { + if (!titleToFiles.has(node.title)) titleToFiles.set(node.title, []); + titleToFiles.get(node.title)!.push(node.label); + } + for (const [title, files] of titleToFiles) { + if (files.length > 1 && files.includes(label)) { + const others = files.filter((f) => f !== label); + errors.push({ + kind: 'duplicate', + message: `Duplicate title "${title}" also exists in: ${others.join(', ')}`, + }); + } + } + + // Broken links and hierarchy violations from this file + const hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs); + for (const { file, parent, error } of hierarchyValidation.refErrors) { + if (file === label) { + errors.push({ kind: 'broken-link', message: `${parent} → ${error}` }); + } + } + for (const v of hierarchyValidation.violations) { + if (v.file === label) { + errors.push({ kind: 'hierarchy', message: v.description }); + } + } + + // Rule violations for this node + if (metadata.rules) { + const ruleViolations = await validateRules(nodes, metadata.rules); + for (const v of ruleViolations) { + if (v.file === label) { + errors.push({ kind: 'rule', message: `[${v.ruleId}] ${v.description}` }); + } + } + } + + const result: FileValidationResult = { + file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath), + label, + space: space.name, + errors, + errorCount: errors.length, + inSpace: true, + }; + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + printHumanReadable(result); + } + + return errors.length > 0 ? 1 : 0; +} + +function printHumanReadable(result: FileValidationResult): void { + const reset = '\x1b[0m'; + const green = '\x1b[32m'; + const red = '\x1b[31m'; + + if (result.errorCount === 0) { + console.log(`${green}✓${reset} ${result.label} (space: ${result.space})`); + return; + } + + console.log(`\n${red}✗${reset} ${result.label} (space: ${result.space}) — ${result.errorCount} error(s)\n`); + for (const err of result.errors) { + console.log(` [${err.kind}] ${err.message}`); + } + console.log(''); +} diff --git a/src/commands/validate.ts b/src/commands/validate.ts index f9684e6..db1605e 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -10,7 +10,7 @@ import { buildSpaceGraph } from '../space-graph'; import type { GraphViolation, RuleViolation, SchemaWithMetadata, SpaceContext } from '../types'; import { extractEntityInfo } from './schemas'; -interface FormattedError { +export interface FormattedError { message: string; dedupeKey: string; } @@ -31,7 +31,7 @@ interface ValidationResult { * Format AJV errors for better readability. * Groups related errors and extracts helpful context like allowed values. */ -function formatErrors( +export function formatErrors( errors: ErrorObject[], schema: SchemaWithMetadata, schemaRefRegistry: Parameters[1], diff --git a/src/index.ts b/src/index.ts index a959d09..f58f945 100755 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,8 @@ import { show } from './commands/show'; import { listSpaces } from './commands/spaces'; import { templateSync } from './commands/template-sync'; import { validate, watchValidate } from './commands/validate'; +import { validateFile } from './commands/validate-file'; + import { getSpaceConfigDir, loadConfig, resolveSchema, setConfigPath } from './config'; import { miroSync } from './integrations/miro/sync'; import { loadSchema } from './schema/schema'; @@ -52,6 +54,16 @@ program.hook('preAction', () => { setConfigPath(program.opts().config); }); +program + .command('validate-file') + .description('Validate a single file within its space') + .argument('', 'Path to the file to validate') + .option('--json', 'Output results as JSON (machine-readable, for hooks)') + .action(async (filePath, options) => { + const exitCode = await validateFile(filePath, { json: options.json }); + process.exit(exitCode); + }); + program .command('validate') .description('Validate space against JSON schema') From 896e783e90de117ed490f75bfc8caf688e24a06b Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Mon, 6 Apr 2026 14:14:33 +1000 Subject: [PATCH 2/6] chore: various fixes - update biome and bun dependencies to version 2.4.10 - add use cases document - consolidate validation commands and improve error handling - enhance pre-edit and stop hooks for better validation tracking --- .claude-plugin/marketplace.json | 14 +++ .gitignore | 1 + AGENTS.md | 7 +- README.md | 2 +- biome.json | 2 +- bun.lock | 20 ++-- docs/use-cases.md | 94 ++++++++++++++++ package.json | 3 +- plugin/.claude-plugin/plugin.json | 7 +- plugin/commands/dump-node.md | 5 - plugin/commands/dump-space.md | 11 ++ plugin/commands/validate-space.md | 14 +++ plugin/commands/validate.md | 5 - plugin/hooks/hooks.json | 18 ++- plugin/scripts/on-stop.ts | 177 ++++++++++++++++++++++++++++++ plugin/scripts/post-edit.sh | 36 ------ plugin/scripts/pre-edit.sh | 39 ------- plugin/scripts/pre-edit.ts | 91 +++++++++++++++ plugin/skills/ost-tools/SKILL.md | 7 +- src/commands/validate-file.ts | 32 +++--- src/commands/validate.ts | 59 +++++++++- src/filter/augment-nodes.ts | 2 + src/index.ts | 3 +- tsconfig.json | 2 +- 24 files changed, 517 insertions(+), 134 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 docs/use-cases.md delete mode 100644 plugin/commands/dump-node.md create mode 100644 plugin/commands/dump-space.md create mode 100644 plugin/commands/validate-space.md delete mode 100644 plugin/commands/validate.md create mode 100755 plugin/scripts/on-stop.ts delete mode 100755 plugin/scripts/post-edit.sh delete mode 100755 plugin/scripts/pre-edit.sh create mode 100755 plugin/scripts/pre-edit.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..4492349 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "ost-tools", + "description": "Plugins for working with structured Obsidian markdown content using ost-tools.", + "owner": { + "name": "Roger Barnes", + "email": "roger@mindsocket.com.au" + }, + "plugins": [ + { + "name": "ost-tools", + "source": "./plugin/ost-tools" + } + ] +} diff --git a/.gitignore b/.gitignore index 225ef45..c4a3b8e 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json config.json .claude/settings.local.json CLAUDE.local.md +.claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md index 7482161..da5ec70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,10 @@ Tools for working with Opportunity Solution Tree structures and other product ma Get a list of commands: `bun run src/index.ts --help` Space names (e.g. `personal`, `politics`) are resolved via a config file - `$OST_TOOLS_CONFIG`, `$XDG_CONFIG_HOME/ost-tools/config.json`, `--config ` param, or `./config.json` +## Claude Code Plugin + +A Claude Code plugin lives at `plugin/`. It includes skills, commands and hooks used when working with collections of Obsidian markdown content (aka a space) in a vault. + ## Definition of done There are several places that need reviewing and updating with any new feature or change added: @@ -14,7 +18,7 @@ There are several places that need reviewing and updating with any new feature o - README.md - documentation, also displayed with `ost-tools readme` command - AGENTS.md - this file - docs/* - includes architecture, concepts etc -- plugin/skills/ost-tools/* - skills information for AI agents (canonical; plugin is the single source) +- plugin/* - skills, commands, hooks, and scripts; update any affected parts ## Project Context @@ -33,6 +37,7 @@ Before starting new work, review [docs/concepts.md](docs/concepts.md) for canoni - `bun run test` — unit tests (fixtures in `tests/`) - `bun run test:smoke` — smoke tests that run `validate` against every space in `config.json` (`smoke/`) +- `bun run test:hook` — test plugin hooks in Claude Code (`hook-test/`) ## Debugging diff --git a/README.md b/README.md index a6c1747..2fbc85b 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ Filter expressions are used with `--filter` and in config `views`. They use a `S | `SELECT {spec}` | Expand from all nodes via SELECT (no WHERE filter — returns all nodes, expanded per spec) | | `{jsonata}` | Bare JSONata, treated as a WHERE predicate (convenience shorthand) | -The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression evaluated per node. Within the expression, each node's fields are accessible directly (e.g. `resolvedType`, `status`, any schema fields like `title`). Additionally, two pre-computed traversal arrays are available: +The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression evaluated per node. Within the expression, each node's fields are accessible directly (e.g. `resolvedType`, `status`, any schema fields like `title`). Two built-in fields are always available regardless of schema: `label` (relative file path, e.g. `"solutions/My Solution.md"`) and `title` (node display name). Additionally, two pre-computed traversal arrays are available: - **`ancestors[]`** — flat array of ancestor nodes, nearest first, deduplicated. Each entry includes all schema fields of the ancestor node, plus: - `_field` — the edge field name that connects to the ancestor diff --git a/biome.json b/biome.json index 9aeae2f..df4a434 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/bun.lock b/bun.lock index 893165d..5ff3170 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "unist-util-visit": "^5.1.0", }, "devDependencies": { - "@biomejs/biome": "^2.4.7", + "@biomejs/biome": "^2.4.10", "@types/bun": "latest", "@types/js-yaml": "^4.0.9", "json-schema-to-ts": "^3.1.1", @@ -32,23 +32,23 @@ "packages": { "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], - "@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="], + "@biomejs/biome": ["@biomejs/biome@2.4.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], diff --git a/docs/use-cases.md b/docs/use-cases.md new file mode 100644 index 0000000..b6b3521 --- /dev/null +++ b/docs/use-cases.md @@ -0,0 +1,94 @@ +# Use Cases + +A catalog of use cases for ost-tools, covering both direct CLI usage and agentic AI-assisted workflows. + +**Status values:** +- `done` — implemented as a CLI command or plugin feature +- `agent` — achievable today via an AI agent with existing commands +- `partial` — some coverage but meaningful gaps remain +- `none` — not currently supported; would require new capabilities + +> [!note] Format support +> This document covers Markdown format only where direct access to files is available. + +--- + +## Validation & Compliance + +| Use case | Description | Status | Coverage | +|---|---|---|---| +| **Validate content** | Verify that space content conforms to schema, link integrity, rules, and hierarchy | | | +| — Validate a space | Check all files in a space in one pass | done | `ost-tools validate` · `/validate-space` command | +| — Validate a single file | Validate one file in context of its space | done | `ost-tools validate-file` · auto-validation hook on `.md` save | +| — Watch for changes | Continuously re-validate as files are edited | done | `ost-tools validate --watch` | +| **Resolve validation errors** | Investigate and fix schema, link, or rule violations once identified | agent | ost-tools skill (troubleshooting guide; `dump` for rule debugging) | + +--- + +## Schema Development + +| Use case | Description | Status | Coverage | +|---|---|---|---| +| **Design a schema** | Build a schema for content that doesn't have one yet | | | +| — Analyse unstructured content | Inventory entity types, fields, and relationships across existing markdown files before writing a schema | agent | ost-tools skill (`schema-design.md` process) | +| — Model the hierarchy and relationships | Identify the structural backbone and lateral relationships, then express them in `$metadata` | agent | ost-tools skill (`schema-design.md`); `schemas show --mermaid-erd` to verify | +| **Extend or maintain a schema** | Add to or refine an existing schema | | | +| — Author types and properties | Add new entity types, required/optional fields, enum definitions, and `$ref` partials | agent | ost-tools skill (`schema-authoring.md`) | +| — Develop rules | Write JSONata rules to enforce workflow, best-practice, or coherence constraints | agent | ost-tools skill; `dump` for iterating on rule expressions | +| — Inspect schema structure | Understand entity types, required fields, hierarchy levels, relationships, and rules | done | `ost-tools schemas show [--mermaid-erd] [--space]` | +| — Keep templates in sync | Update Obsidian templates to reflect current schema examples and field descriptions | done | `ost-tools template-sync [--create-missing] [--dry-run]` | + +--- + +## Visualisation & Exploration + +| Use case | Description | Status | Coverage | +|---|---|---|---| +| **Visualise a space** | Produce a structured view of the space's nodes and relationships | | | +| — Tree view | Print hierarchy, spot orphans, verify parent links | done | `ost-tools show` · `ost-tools render markdown.bullets` | +| — Filtered / sliced view | Show a subset of nodes matching a type, status, or relationship condition | done | `ost-tools show --filter` · named views in config | +| — Mermaid diagram | Visual node map with type-based styling, exportable to `.mmd` | done | `ost-tools diagram [--output]` · `ost-tools render markdown.mermaid` | +| — Miro board sync | Push nodes and connectors to a Miro board as interactive cards | done | `ost-tools miro-sync [--new-frame] [--dry-run]` | +| — Custom output format | Render a space in a pluggable format (registered by a render plugin) | partial | `ost-tools render ` · `ost-tools render list` shows registered formats | +| **Inspect and debug** | Examine parsed content in detail | | | +| — Inspect parsed nodes | See resolved types, parent refs, and field values as JSON; debug fieldMap and rule inputs | done | `ost-tools dump` | +| — Make sense of current content | Orient across a space: summarise what's there, identify gaps, understand structure | agent | `show`, `dump` + agent reasoning with ost-tools skill | + +--- + +## Content Authoring + +| Use case | Description | Status | Coverage | +|---|---|---|---| +| **Author new content compliantly** | Create or edit nodes in Obsidian while staying schema-conformant | partial | Validation hook on `.md` save; schema-driven templates; ost-tools skill for inline guidance | +| **Discover and curate inputs** | Identify new information (research, signals, events) to add to a space and integrate it | none | — | + +--- + +## Analysis & Intelligence + +| Use case | Description | Status | Coverage | +|---|---|---|---| +| **Assess coherence and quality** | Evaluate whether the content is internally consistent and strategically sound | | | +| — Suggest improvements | Surface gaps, stale content, weak links, or missing nodes | agent | Agent reads space via `dump`/`show`; ost-tools skill provides domain context | +| — Coach or workshop content | Critique and iterate on the strategic coherence or completeness of a space | agent | Agent-driven; ost-tools skill for schema/rule context | +| — Facilitate a decision or clarification | Use the space as context to support a strategic choice or stakeholder conversation | agent | Agent-driven with space content as grounding | +| **Strategy dashboard and alerts** | Summarise key metrics, flag rule violations, track changes over time | none | — | + +--- + +## Content Transformation + +| Use case | Description | Status | Coverage | +|---|---|---|---| +| **Docs to deck** | Convert space content into a presentation or slide format | none | — | +| **Deck to docs** | Extract structured content from a presentation and integrate it into a space | none | — | + +--- + +## Workflow Automation + +| Use case | Description | Status | Coverage | +|---|---|---|---| +| **Automate workflow rituals** | Run planning, review, or triage workflows against space content on a schedule or trigger | none | — | +| **Automate gardening and hygiene** | Scheduled or triggered cleanup: link repair, status updates, orphan triage | partial | `validate --watch` for live feedback; hooks for save-time checks; manual agent-driven triage | diff --git a/package.json b/package.json index 4654a88..39a563d 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "diagram": "bun run src/index.ts diagram", "test": "bun test tests/", "test:smoke": "bun test smoke/", + "test:hook": "bun test hook-test/", "test:all": "bun test", "lint": "biome check", "lint:fix": "biome check --write", @@ -36,7 +37,7 @@ "postversion": "git push --follow-tags" }, "devDependencies": { - "@biomejs/biome": "^2.4.7", + "@biomejs/biome": "^2.4.10", "@types/bun": "latest", "@types/js-yaml": "^4.0.9", "json-schema-to-ts": "^3.1.1", diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 7c4238d..65575a3 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,12 +1,9 @@ { "name": "ost-tools", "version": "0.8.3", - "description": "Validate and work with ost-tools spaces from Claude Code — includes validation hooks, slash commands, and agent skills.", + "description": "Structured content authoring for Obsidian — automatic validation hooks, slash commands, and agent skills to keep markdown files compliant with your schema.", "homepage": "https://github.com/mindsocket/ost-tools", "repository": "https://github.com/mindsocket/ost-tools", "license": "MIT", - "keywords": ["ost-tools", "validation", "markdown", "schema"], - "skills": "./skills/", - "commands": "./commands/", - "hooks": "./hooks/hooks.json" + "keywords": ["ost-tools", "validation", "markdown", "schema"] } diff --git a/plugin/commands/dump-node.md b/plugin/commands/dump-node.md deleted file mode 100644 index e4743f7..0000000 --- a/plugin/commands/dump-node.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -description: Dump parsed node data for a space to inspect resolved fields and relationships ---- - -Run `ost-tools dump ` for the relevant space and show the parsed node data as JSON. This is useful for debugging validation rules, inspecting resolved parent references, and understanding what JSONata rules see. Ask which space to dump if not clear from context. diff --git a/plugin/commands/dump-space.md b/plugin/commands/dump-space.md new file mode 100644 index 0000000..6bc4fdb --- /dev/null +++ b/plugin/commands/dump-space.md @@ -0,0 +1,11 @@ +--- +name: dump-space +description: Dump all parsed node data for an Obsidian space as JSON for debugging +--- + +## Parsed node data +!`bunx ost-tools dump $ARGUMENTS` + +## Your task + +Show the parsed node data above. Use it to inspect resolved fields, parent references, and what JSONata rules see at evaluation time. Useful for debugging unexpected validation errors or hierarchy issues. If no space name was given, ask which space to inspect. diff --git a/plugin/commands/validate-space.md b/plugin/commands/validate-space.md new file mode 100644 index 0000000..909328a --- /dev/null +++ b/plugin/commands/validate-space.md @@ -0,0 +1,14 @@ +--- +name: validate-space +description: Validate structured markdown content in an Obsidian space against its schema +--- + +## Available spaces +!`bunx ost-tools spaces 2>/dev/null` + +## Validation results +!`bunx ost-tools validate $ARGUMENTS` + +## Your task + +Report any validation errors clearly and offer to investigate and fix them. If no space name was given, list the spaces above and ask which to validate. diff --git a/plugin/commands/validate.md b/plugin/commands/validate.md deleted file mode 100644 index 2fa856b..0000000 --- a/plugin/commands/validate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -description: Validate the current space or a specific file against its schema ---- - -Validate the ost-tools space. If a file path is given, validate just that file using `ost-tools validate-file --json`. Otherwise ask which space to validate and run `ost-tools validate `. Show any errors clearly and offer to fix them. diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 2bbca96..6dd262a 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -2,24 +2,30 @@ "hooks": { "PreToolUse": [ { - "matcher": "Write|Edit|MultiEdit", + "matcher": "Write|Edit", "hooks": [ { + "if": "Write(*.md)", "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/pre-edit.sh", + "command": "bun run ${CLAUDE_PLUGIN_ROOT}/scripts/pre-edit.ts", + "timeout": 30 + }, + { + "if": "Edit(*.md)", + "type": "command", + "command": "bun run ${CLAUDE_PLUGIN_ROOT}/scripts/pre-edit.ts", "timeout": 30 } ] } ], - "PostToolUse": [ + "Stop": [ { - "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/post-edit.sh", - "timeout": 30 + "command": "bun run ${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.ts", + "timeout": 60 } ] } diff --git a/plugin/scripts/on-stop.ts b/plugin/scripts/on-stop.ts new file mode 100755 index 0000000..2e6a483 --- /dev/null +++ b/plugin/scripts/on-stop.ts @@ -0,0 +1,177 @@ +#!/usr/bin/env bun +/** + * Stop hook: re-validates all markdown files touched during this session turn. + * Compares fresh results against pre-edit baselines captured by the PreToolUse hook. + * Exits 2 with error details on stderr if new violations were introduced; 0 otherwise. + * Clears the session state file after analysis. + */ + +export {}; + +interface HookInput { + session_id?: string; + stop_hook_active?: boolean; +} + +interface HookState { + session_id: string; + timestamp: number; + tool: 'Write' | 'Edit'; + file: string; + errors: object | null; +} + +interface ValidationError { + kind: string; + message: string; +} + +interface ValidationResult { + inSpace?: boolean; + label?: string; + space?: string; + errors?: Record; +} + +const LOG_FILE = '/tmp/ost-tools-stop-hook.log'; +const LOG_ENABLED = false; + +function log(message: string): void { + if (!LOG_ENABLED) return; + const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' '); + const logMsg = `[${timestamp}] ${message}\n`; + Bun.write(Bun.file(LOG_FILE), logMsg, { createPath: true }); +} + +async function main() { + const INPUT_TEXT = await Bun.stdin.text(); + log(`INPUT=${INPUT_TEXT}`); + + const INPUT = JSON.parse(INPUT_TEXT) as HookInput; + const SESSION_ID = INPUT.session_id ?? 'unknown'; + const STOP_HOOK_ACTIVE = INPUT.stop_hook_active ?? false; + log(`SESSION_ID=${SESSION_ID}`); + log(`STOP_HOOK_ACTIVE=${STOP_HOOK_ACTIVE}`); + + // Guard against infinite loop: stop hooks can trigger another stop cycle + if (STOP_HOOK_ACTIVE === true) { + log('Stop hook already active, exiting to avoid infinite loop'); + process.exit(0); + } + log('Stop hook not active, proceeding'); + + const STATE_DIR = process.env.OST_TOOLS_STATE_DIR ?? '/tmp'; + const STATE_FILE = `${STATE_DIR}/ost-tools-hook-${SESSION_ID}.jsonl`; + log(`STATE_FILE=${STATE_FILE}`); + + const stateFile = Bun.file(STATE_FILE); + const stateFileExists = await stateFile.exists(); + if (!stateFileExists) { + log('State file does not exist, exiting'); + process.exit(0); + } + log('State file exists, proceeding'); + + const newErrors: string[] = []; + let hasNewErrors = false; + + // Read all entries and keep only the latest per file (by timestamp) + const lines = (await stateFile.text()) + .trim() + .split('\n') + .filter((l) => l); + const entries: HookState[] = lines.map((l) => JSON.parse(l)); + + // Group by file and keep latest + const latestByFile = new Map(); + for (const entry of entries) { + const existing = latestByFile.get(entry.file); + if (!existing || entry.timestamp > existing.timestamp) { + latestByFile.set(entry.file, entry); + } + } + + log(`Processing ${latestByFile.size} entries`); + + for (const [FILE, entry] of latestByFile) { + log(`Processing entry: ${JSON.stringify(entry)}`); + const { tool, errors: BASELINE } = entry; + log(` FILE=${FILE}, TOOL=${tool}, BASELINE=${JSON.stringify(BASELINE)}`); + + const fileExists = await Bun.file(FILE).exists(); + if (!fileExists) { + log(' File does not exist, skipping'); + continue; + } + + const proc = Bun.$`bunx ost-tools validate-file ${FILE} --json`.quiet().nothrow(); + const resultText = await proc.text(); + const result = resultText ? (JSON.parse(resultText) as ValidationResult) : {}; + log(` FRESH_RESULT=${JSON.stringify(result)}`); + + const IN_SPACE = result.inSpace ?? false; + + if (IN_SPACE !== true) { + log(' File not in space, skipping'); + continue; + } + + const FRESH_ERRORS = result.errors ?? {}; + log(` FRESH_ERRORS=${JSON.stringify(FRESH_ERRORS)}`); + + let newErrorsForFile: Record; + + if (tool === 'Write') { + // New file — all errors are new + newErrorsForFile = FRESH_ERRORS; + log(' New file: all errors are new'); + } else { + // Edit — only errors absent from the baseline are new + const baselineKeys = new Set(Object.keys(BASELINE ?? {})); + const freshEntries = Object.entries(FRESH_ERRORS); + newErrorsForFile = Object.fromEntries(freshEntries.filter(([key]) => !baselineKeys.has(key))) as Record< + string, + ValidationError + >; + log(` Edit mode: computed NEW_ERRORS=${JSON.stringify(newErrorsForFile)}`); + } + + const newCount = Object.keys(newErrorsForFile).length; + log(` NEW_COUNT=${newCount}`); + + if (newCount > 0) { + const LABEL = result.label ?? FILE; + const SPACE = result.space ?? 'unknown'; + log(` New errors found in ${LABEL} (space: ${SPACE})`); + + const errorLines = Object.values(newErrorsForFile) + .map((val) => ` [${val.kind}] ${val.message}`) + .join('\n'); + + newErrors.push(` ${LABEL} (space: ${SPACE}) — ${newCount} new error(s):\n${errorLines}`); + hasNewErrors = true; + log(' Set HAS_NEW_ERRORS=1'); + } else { + log(' No new errors for this file'); + } + } + + // Clean up state file + await Bun.$`rm -f ${STATE_FILE}`; + log('Removed state file'); + + if (hasNewErrors) { + log('Exiting with code 2 (new errors found)'); + const errorMsg = `ost-tools: new validation errors introduced this session - use ost-tools skill to resolve:\n${newErrors.join('\n')}`; + console.error(errorMsg); + process.exit(2); + } + + log('Exiting with code 0 (no new errors)'); +} + +main().catch((err) => { + log(`Error: ${String(err)}`); + console.error(err); + process.exit(1); +}); diff --git a/plugin/scripts/post-edit.sh b/plugin/scripts/post-edit.sh deleted file mode 100755 index 75eb623..0000000 --- a/plugin/scripts/post-edit.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Post-edit hook: validate the edited file after a write/edit operation. -# Reports errors attributable to the file. Claude should fix any errors -# it introduced (compare against the pre-edit baseline shown earlier). - -set -euo pipefail - -INPUT=$(cat) -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') - -if [[ -z "$FILE_PATH" ]]; then - exit 0 -fi - -if [[ "$FILE_PATH" != *.md ]]; then - exit 0 -fi - -RESULT=$(bunx ost-tools validate-file "$FILE_PATH" --json 2>/dev/null || true) - -IN_SPACE=$(echo "$RESULT" | jq -r '.inSpace // false') -if [[ "$IN_SPACE" != "true" ]]; then - exit 0 -fi - -ERROR_COUNT=$(echo "$RESULT" | jq -r '.errorCount // 0') -SPACE=$(echo "$RESULT" | jq -r '.space // ""') -LABEL=$(echo "$RESULT" | jq -r '.label // ""') - -if [[ "$ERROR_COUNT" -eq 0 ]]; then - echo "[ost-tools] $LABEL (space: $SPACE) — valid" -else - echo "[ost-tools] $LABEL (space: $SPACE) — $ERROR_COUNT error(s) after edit:" - echo "$RESULT" | jq -r '.errors[] | " [\(.kind)] \(.message)"' - echo "Fix any errors you introduced (check pre-edit baseline above to identify pre-existing ones)." -fi diff --git a/plugin/scripts/pre-edit.sh b/plugin/scripts/pre-edit.sh deleted file mode 100755 index 1648192..0000000 --- a/plugin/scripts/pre-edit.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# Pre-edit hook: capture validation baseline before a file is modified. -# Output is fed to Claude as context so it knows which errors pre-existed the edit. -# Claude should not attempt to fix errors that were already present before the edit. - -set -euo pipefail - -INPUT=$(cat) -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') - -# Not all edit tools provide file_path (e.g. MultiEdit uses the same field, but check anyway) -if [[ -z "$FILE_PATH" ]]; then - exit 0 -fi - -# Only run for .md files -if [[ "$FILE_PATH" != *.md ]]; then - exit 0 -fi - -RESULT=$(bunx ost-tools validate-file "$FILE_PATH" --json 2>/dev/null || true) - -# If the file isn't in any space, nothing to report -IN_SPACE=$(echo "$RESULT" | jq -r '.inSpace // false') -if [[ "$IN_SPACE" != "true" ]]; then - exit 0 -fi - -ERROR_COUNT=$(echo "$RESULT" | jq -r '.errorCount // 0') -SPACE=$(echo "$RESULT" | jq -r '.space // ""') -LABEL=$(echo "$RESULT" | jq -r '.label // ""') - -if [[ "$ERROR_COUNT" -eq 0 ]]; then - echo "[ost-tools] Pre-edit baseline: $LABEL (space: $SPACE) — no existing errors" -else - echo "[ost-tools] Pre-edit baseline: $LABEL (space: $SPACE) — $ERROR_COUNT pre-existing error(s):" - echo "$RESULT" | jq -r '.errors[] | " [\(.kind)] \(.message)"' - echo "Do not fix these — they existed before your edit." -fi diff --git a/plugin/scripts/pre-edit.ts b/plugin/scripts/pre-edit.ts new file mode 100755 index 0000000..8deb53a --- /dev/null +++ b/plugin/scripts/pre-edit.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env bun +/** + * PreToolUse hook for Write and Edit on *.md files. + * Write (new files): records the filename with no baseline errors. + * Edit (existing files): validates before the edit and records baseline errors. + * Appends one JSONL line to a per-session state file for the Stop hook to analyse. + */ + +import { appendFileSync, mkdirSync } from 'node:fs'; + +interface HookInput { + tool_name?: string; + tool_input?: { + file_path?: string; + }; + session_id?: string; +} + +interface HookState { + session_id: string; + timestamp: number; + tool: 'Write' | 'Edit'; + file: string; + errors: object | null; +} + +interface ValidationResult { + inSpace?: boolean; + errors?: object; +} + +async function main() { + const INPUT_TEXT = await Bun.stdin.text(); + const INPUT = JSON.parse(INPUT_TEXT) as HookInput; + + // DEBUG: log full input and env to stderr + console.error('[ost-tools pre-edit hook] input:', JSON.stringify(INPUT)); + console.error('[ost-tools pre-edit hook] OST_TOOLS_STATE_DIR:', process.env.OST_TOOLS_STATE_DIR); + + const TOOL = INPUT.tool_name ?? ''; + const FILE_PATH = INPUT.tool_input?.file_path; + const SESSION_ID = INPUT.session_id ?? 'unknown'; + + if (!FILE_PATH) { + process.exit(0); + } + + const STATE_DIR = process.env.OST_TOOLS_STATE_DIR ?? '/tmp'; + const STATE_FILE = `${STATE_DIR}/ost-tools-hook-${SESSION_ID}.jsonl`; + const TIMESTAMP = Date.now(); + + if (TOOL === 'Write') { + // New file — record filename only, no baseline to establish + const entry: HookState = { + session_id: SESSION_ID, + timestamp: TIMESTAMP, + tool: 'Write', + file: FILE_PATH, + errors: null, + }; + mkdirSync(STATE_DIR, { recursive: true }); + appendFileSync(STATE_FILE, `${JSON.stringify(entry)}\n`); + process.exit(0); + } + + // Edit — validate current state as pre-edit baseline + const proc = Bun.$`bunx ost-tools validate-file ${FILE_PATH} --json`.quiet().nothrow(); + const resultText = await proc.text(); + const result = resultText ? (JSON.parse(resultText) as ValidationResult) : {}; + const IN_SPACE = result.inSpace ?? false; + + if (IN_SPACE !== true) { + process.exit(0); + } + + const ERRORS = result.errors ?? {}; + const entry: HookState = { + session_id: SESSION_ID, + timestamp: TIMESTAMP, + tool: 'Edit', + file: FILE_PATH, + errors: ERRORS, + }; + mkdirSync(STATE_DIR, { recursive: true }); + appendFileSync(STATE_FILE, `${JSON.stringify(entry)}\n`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/plugin/skills/ost-tools/SKILL.md b/plugin/skills/ost-tools/SKILL.md index f872bd9..8dbde45 100644 --- a/plugin/skills/ost-tools/SKILL.md +++ b/plugin/skills/ost-tools/SKILL.md @@ -1,9 +1,10 @@ --- name: ost-tools description: > - Use this skill when working with ost-tools — a CLI that validates Obsidian markdown frontmatter - against JSON schemas for structured knowledge "spaces", and converts to other formats. Trigger when: (1) validating a space after - content edits, (2) writing or updating a schema file or rules, (3) configuring a space or designing a schema (new or existing), (4) troubleshooting validation errors, (5) running `ost-tools` commands. + Use this skill when editing structured markdown content in Obsidian to ensure it conforms to a + schema, or when authoring or debugging schemas. Trigger when: (1) content needs validating after + edits, (2) schema files or rules need creating or updating, (3) configuring or designing a schema + for a space, (4) troubleshooting unexpected validation errors, (5) running ost-tools CLI commands. --- # ost-tools diff --git a/src/commands/validate-file.ts b/src/commands/validate-file.ts index 940f773..5eeb08c 100644 --- a/src/commands/validate-file.ts +++ b/src/commands/validate-file.ts @@ -8,16 +8,12 @@ import { validateRules } from '../schema/validate-rules'; import type { SpaceContext } from '../types'; import { formatErrors } from './validate'; -export interface FileError { - kind: 'schema' | 'broken-link' | 'duplicate' | 'rule' | 'hierarchy'; - message: string; -} - export interface FileValidationResult { file: string; label: string; space: string; - errors: FileError[]; + /** Errors keyed by composite id (e.g. `schema:/status:enum:active`, `rule:my-rule-id`). */ + errors: Record; errorCount: number; inSpace: true; } @@ -83,7 +79,7 @@ export async function validateFile(filePath: string, options: { json?: boolean } const readResult = await readSpace(context); const { nodes } = readResult; - const errors: FileError[] = []; + const errors: Record = {}; // Schema validation errors for this node for (const node of nodes) { @@ -96,8 +92,8 @@ export async function validateFile(filePath: string, options: { json?: boolean } schemaRefRegistry, node.schemaData as Record, ); - for (const { message } of formatted) { - errors.push({ kind: 'schema', message }); + for (const { message, dedupeKey } of formatted) { + errors[`schema:${dedupeKey}`] = { kind: 'schema', message }; } } } @@ -111,10 +107,10 @@ export async function validateFile(filePath: string, options: { json?: boolean } for (const [title, files] of titleToFiles) { if (files.length > 1 && files.includes(label)) { const others = files.filter((f) => f !== label); - errors.push({ + errors[`duplicate:${title}`] = { kind: 'duplicate', message: `Duplicate title "${title}" also exists in: ${others.join(', ')}`, - }); + }; } } @@ -122,12 +118,12 @@ export async function validateFile(filePath: string, options: { json?: boolean } const hierarchyValidation = validateGraph(nodes, metadata, readResult.unresolvedRefs); for (const { file, parent, error } of hierarchyValidation.refErrors) { if (file === label) { - errors.push({ kind: 'broken-link', message: `${parent} → ${error}` }); + errors[`broken-link:${parent}`] = { kind: 'broken-link', message: `${parent} → ${error}` }; } } for (const v of hierarchyValidation.violations) { if (v.file === label) { - errors.push({ kind: 'hierarchy', message: v.description }); + errors[`hierarchy:${v.description}`] = { kind: 'hierarchy', message: v.description }; } } @@ -136,7 +132,7 @@ export async function validateFile(filePath: string, options: { json?: boolean } const ruleViolations = await validateRules(nodes, metadata.rules); for (const v of ruleViolations) { if (v.file === label) { - errors.push({ kind: 'rule', message: `[${v.ruleId}] ${v.description}` }); + errors[`rule:${v.ruleId}`] = { kind: 'rule', message: `[${v.ruleId}] ${v.description}` }; } } } @@ -146,7 +142,7 @@ export async function validateFile(filePath: string, options: { json?: boolean } label, space: space.name, errors, - errorCount: errors.length, + errorCount: Object.keys(errors).length, inSpace: true, }; @@ -156,7 +152,7 @@ export async function validateFile(filePath: string, options: { json?: boolean } printHumanReadable(result); } - return errors.length > 0 ? 1 : 0; + return Object.keys(errors).length > 0 ? 1 : 0; } function printHumanReadable(result: FileValidationResult): void { @@ -170,8 +166,8 @@ function printHumanReadable(result: FileValidationResult): void { } console.log(`\n${red}✗${reset} ${result.label} (space: ${result.space}) — ${result.errorCount} error(s)\n`); - for (const err of result.errors) { - console.log(` [${err.kind}] ${err.message}`); + for (const { kind, message } of Object.values(result.errors)) { + console.log(` [${kind}] ${message}`); } console.log(''); } diff --git a/src/commands/validate.ts b/src/commands/validate.ts index db1605e..dad916c 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -132,7 +132,7 @@ export function formatErrors( return formatted; } -export async function validate(context: SpaceContext): Promise { +export async function validate(context: SpaceContext, options: { json?: boolean } = {}): Promise { const { schema, schemaRefRegistry, schemaValidator } = context; const metadata = schema.metadata; @@ -198,6 +198,63 @@ export async function validate(context: SpaceContext): Promise { result.ruleViolations = await validateRules(nodes, metadata.rules); } + // JSON output mode + if (options.json) { + const errorsByFile: Record> = {}; + + const addError = (file: string, key: string, kind: string, message: string) => { + if (!errorsByFile[file]) errorsByFile[file] = {}; + errorsByFile[file][key] = { kind, message }; + }; + + for (const { file, errors: ajvErrors, nodeData } of result.nodeErrors) { + const formatted = formatErrors(ajvErrors, schema, schemaRefRegistry, nodeData); + for (const { message, dedupeKey } of formatted) { + addError(file, `schema:${dedupeKey}`, 'schema', message); + } + } + for (const { file, parent, error } of result.refErrors) { + addError(file, `broken-link:${parent}`, 'broken-link', `${parent} → ${error}`); + } + for (const { title, files } of result.duplicateErrors) { + for (const file of files) { + const others = files.filter((f) => f !== file); + addError( + file, + `duplicate:${title}`, + 'duplicate', + `Duplicate title "${title}" also exists in: ${others.join(', ')}`, + ); + } + } + for (const v of result.ruleViolations) { + if (v.file) { + addError(v.file, `rule:${v.ruleId}`, 'rule', `[${v.ruleId}] ${v.description}`); + } + } + for (const v of result.hierarchyViolations) { + addError(v.file, `hierarchy:${v.description}`, 'hierarchy', v.description); + } + + const errorCount = Object.values(errorsByFile).reduce((sum, errs) => sum + Object.keys(errs).length, 0); + console.log( + JSON.stringify( + { + space: context.space.name, + valid: errorCount === 0, + validCount: result.validCount, + errorCount, + errors: errorsByFile, + orphanCount: result.orphanCount, + parseIgnored: result.parseIgnored, + }, + null, + 2, + ), + ); + return errorCount > 0 ? 1 : 0; + } + // Report const reset = '\x1b[0m'; const green = '\x1b[32m'; diff --git a/src/filter/augment-nodes.ts b/src/filter/augment-nodes.ts index f82f70c..7d7037b 100644 --- a/src/filter/augment-nodes.ts +++ b/src/filter/augment-nodes.ts @@ -19,6 +19,8 @@ export type AugmentedFlatNode = Record & { function flattenData(node: SpaceNode): Record { return { ...node.schemaData, + label: node.label, + title: node.title, resolvedType: node.resolvedType, }; } diff --git a/src/index.ts b/src/index.ts index f58f945..820c966 100755 --- a/src/index.ts +++ b/src/index.ts @@ -69,12 +69,13 @@ program .description('Validate space against JSON schema') .argument('', 'Space name') .option('-w, --watch', 'Watch for changes and re-run validation') + .option('--json', 'Output results as JSON') .action(async (spaceName, options) => { const context = buildSpaceContext(spaceName); if (options.watch) { await watchValidate(context); } else { - const exitCode = await validate(context); + const exitCode = await validate(context, { json: options.json }); process.exit(exitCode); } }); diff --git a/tsconfig.json b/tsconfig.json index 1717c1d..4670a99 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,5 +31,5 @@ "noPropertyAccessFromIndexSignature": false }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "tests", "smoke"] + "exclude": ["node_modules", "dist", "tests", "smoke", "hook-test"] } From 4bbbfecfe5880c8a83734b8342f3b7e535926ea0 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Mon, 6 Apr 2026 20:10:38 +1000 Subject: [PATCH 3/6] Hook tests - unit and e2e using claude agent SDK. Also cleaned up tsconfig to include tests in default tsc coverage. --- .gitignore | 4 +- AGENTS.md | 24 +- README.md | 7 +- bun.lock | 205 ++++++++++++++++++ hook-test/fixture-utils.ts | 48 ++++ hook-test/fixtures/vault/Root.md | 8 + hook-test/fixtures/vault/broken.md | 9 + hook-test/fixtures/vault/valid.md | 9 + hook-test/harness.ts | 150 +++++++++++++ hook-test/hooks.test.ts | 138 ++++++++++++ hook-test/unit/on-stop.test.ts | 179 +++++++++++++++ hook-test/unit/pre-edit.test.ts | 154 +++++++++++++ package.json | 7 +- plugin/scripts/on-stop.ts | 113 +++++----- plugin/scripts/pre-edit.ts | 62 ++++-- src/read/wikilink-utils.ts | 8 +- tests/config.test.ts | 12 +- .../plugins/ost-tools-custom-plugin.ts | 1 + tests/render/registry.test.ts | 8 +- tests/validate/strict.test.ts | 10 +- tsconfig.build.json | 17 ++ tsconfig.json | 6 +- 22 files changed, 1058 insertions(+), 121 deletions(-) create mode 100644 hook-test/fixture-utils.ts create mode 100644 hook-test/fixtures/vault/Root.md create mode 100644 hook-test/fixtures/vault/broken.md create mode 100644 hook-test/fixtures/vault/valid.md create mode 100644 hook-test/harness.ts create mode 100644 hook-test/hooks.test.ts create mode 100644 hook-test/unit/on-stop.test.ts create mode 100644 hook-test/unit/pre-edit.test.ts create mode 100644 tsconfig.build.json diff --git a/.gitignore b/.gitignore index c4a3b8e..6f9bc1c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .miro-cache/ config.json .claude/settings.local.json -CLAUDE.local.md .claude/worktrees/ +CLAUDE.local.md + +hook-test/fixtures/.state/ diff --git a/AGENTS.md b/AGENTS.md index da5ec70..35565b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,26 +22,32 @@ There are several places that need reviewing and updating with any new feature o ## Project Context -This project validates data in markdown files against a JSON schema representing product and strategy frameworks, including Opportunity Solution Trees. +This project validates data in markdown files against a JSON schema representing knowledge bases, and product and strategy frameworks, including Opportunity Solution Trees. Before starting new work, review [docs/concepts.md](docs/concepts.md) for canonical terminology. Use and maintain the definitions there as the source of truth when naming things in code, tests, comments, and documentation. ## Key Files - config — JSON5 file with spaces registered -- `schemas/` — Bundled default schema files (JSON5) using the ost-tools schema dialect and top-level `$metadata`. Files starting with `_` are "partials" (fragments for `$ref`) and are loaded automatically. Local partials in a schema's directory **must** have unique `$id`s. -- `src/metadata-contract.ts` — Single source of truth for the `$metadata` contract (TS `as const` + inferred types) -- `schemas/generated/_ost_tools_schema_meta.json` — Generated metaschema artifact (regenerate with `bun run generate:schema-meta`) +- `schemas/` — Bundled default schema files (JSON5) using the ost-tools schema dialect and top-level `$metadata`. Files starting with `_` are "partials" (fragments for `$ref`). +- `src/metadata-contract.ts` — Single source of truth for the `$metadata` contract +- `schemas/generated/_ost_tools_schema_meta.json` — Generated metaschema (generated on build or with `bun run generate:schema-meta`) ## Testing - +For most development only the main unit tests need re-running regularly. - `bun run test` — unit tests (fixtures in `tests/`) -- `bun run test:smoke` — smoke tests that run `validate` against every space in `config.json` (`smoke/`) -- `bun run test:hook` — test plugin hooks in Claude Code (`hook-test/`) +- `bun run test:hook` — unit test plugin hooks (`hook-test/unit/`) - hook development only +- `bun run test:hook:e2e` — test plugin hooks in Claude Code (`hook-test/`) - hook development only +- `bun run test:smoke` — smoke tests run against locally configured spaces - only use when changes could affect compatibility. + +## Dual TypeScript Configuration + +- **`tsconfig.json`** — Main config for type-checking across all code - use `bun run typecheck` +- **`tsconfig.build.json`** — Production build config (only compiles `src/` to `dist/`) - use `bun run build` ## Debugging -- `bun run src/index.ts dump ` — Output parsed node data with resolved parents, useful for debugging rule violations +- `bun run src/index.ts dump ` — Output parsed node data ## Hooks -A Stop hook runs linting, autoformatting and unit tests. If it reports issues related to change you made, address them. +Address issues related to change you made if a Stop hook reports them. diff --git a/README.md b/README.md index 2fbc85b..052d674 100644 --- a/README.md +++ b/README.md @@ -367,10 +367,13 @@ Keeps Obsidian template files in sync with schema examples: # Run a command against a configured space bun run src/index.ts validate personal -# Run unit tests +# Run type checking (checks all code including tests) +bun run typecheck + +# Run core unit tests bun run test -# Run smoke tests against all locally configured spaces +# Run occasional smoke tests against all locally configured spaces bun run test:smoke # Build compiled output diff --git a/bun.lock b/bun.lock index 5ff3170..3545628 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "unist-util-visit": "^5.1.0", }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.92", "@biomejs/biome": "^2.4.10", "@types/bun": "latest", "@types/js-yaml": "^4.0.9", @@ -30,6 +31,10 @@ }, }, "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.92", "", { "dependencies": { "@anthropic-ai/sdk": "^0.80.0", "@modelcontextprotocol/sdk": "^1.27.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-loYyxVUC5gBwHjGi9Fv0b84mduJTp9Z3Pum+y/7IVQDb4NynKfVQl6l4VeDKZaW+1QTQtd25tY4hwUznD7Krqw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.80.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g=="], + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], "@biomejs/biome": ["@biomejs/biome@2.4.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="], @@ -50,6 +55,42 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="], + "@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], @@ -64,14 +105,26 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -80,18 +133,56 @@ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], @@ -100,18 +191,56 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.12.10", "", {}, "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "jsonata": ["jsonata@2.1.0", "", {}, "sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], @@ -142,6 +271,8 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], @@ -164,6 +295,10 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -220,8 +355,38 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], @@ -232,16 +397,44 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -256,10 +449,22 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], diff --git a/hook-test/fixture-utils.ts b/hook-test/fixture-utils.ts new file mode 100644 index 0000000..4ab02f2 --- /dev/null +++ b/hook-test/fixture-utils.ts @@ -0,0 +1,48 @@ +/** + * Utilities for isolating test fixtures so tests never modify git-tracked files. + */ + +import { cpSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const BASE_FIXTURES = join(import.meta.dir, 'fixtures'); + +export interface IsolatedFixtures { + /** Root of the isolated copy */ + fixtureDir: string; + /** Isolated vault directory (contains the markdown files) */ + vaultDir: string; + /** Absolute path to the isolated config.json (vault path rewritten to absolute) */ + configPath: string; + /** Remove the isolated copy */ + cleanup: () => void; +} + +/** + * Copies hook-test/fixtures/ to a unique temp directory. + * Rewrites config.json so the space path is absolute (not relative). + * Returns paths and a cleanup function. + */ +export function isolateFixtures(): IsolatedFixtures { + const id = crypto.randomUUID(); + // Use realpathSync to resolve macOS /var -> /private/var symlink so paths + // match what Claude Code reports in hook inputs. + const fixtureDir = join(realpathSync(tmpdir()), `ost-hook-test-${id}`); + const vaultDir = join(fixtureDir, 'vault'); + const configPath = join(fixtureDir, 'config.json'); + + mkdirSync(fixtureDir, { recursive: true }); + cpSync(BASE_FIXTURES, fixtureDir, { recursive: true }); + + // Rewrite config.json: replace relative vault path with absolute path + const config = { spaces: [{ name: 'test-space', path: vaultDir }] }; + writeFileSync(configPath, JSON.stringify(config, null, 2)); + + return { + fixtureDir, + vaultDir, + configPath, + cleanup: () => rmSync(fixtureDir, { recursive: true, force: true }), + }; +} diff --git a/hook-test/fixtures/vault/Root.md b/hook-test/fixtures/vault/Root.md new file mode 100644 index 0000000..4fcdbac --- /dev/null +++ b/hook-test/fixtures/vault/Root.md @@ -0,0 +1,8 @@ +--- +type: vision +status: identified +--- + +# Root + +Root node with no parent. diff --git a/hook-test/fixtures/vault/broken.md b/hook-test/fixtures/vault/broken.md new file mode 100644 index 0000000..1186397 --- /dev/null +++ b/hook-test/fixtures/vault/broken.md @@ -0,0 +1,9 @@ +--- +type: mission +parent: "[[Nonexistent Node]]" +status: identified +--- + +# Broken Note + +A note with a non-existent parent, used for testing baseline error capture. diff --git a/hook-test/fixtures/vault/valid.md b/hook-test/fixtures/vault/valid.md new file mode 100644 index 0000000..06b3583 --- /dev/null +++ b/hook-test/fixtures/vault/valid.md @@ -0,0 +1,9 @@ +--- +type: mission +parent: "[[Root]]" +status: identified +--- + +# Test Title + +A valid note with proper parent. diff --git a/hook-test/harness.ts b/hook-test/harness.ts new file mode 100644 index 0000000..0cf3392 --- /dev/null +++ b/hook-test/harness.ts @@ -0,0 +1,150 @@ +/** + * E2E test harness: runs a Claude Code session via the Agent SDK with hooks + * wired directly to the ost-tools hook logic. + * + * All output (SDK messages, errors) is written to files in outputDir so tests + * can inspect them after the run without relying on in-process buffering. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { runOnStop } from '../plugin/scripts/on-stop'; +import { runPreEdit } from '../plugin/scripts/pre-edit'; + +const OST_TOOLS_BIN = join(import.meta.dir, '..', 'src', 'index.ts'); + +export interface RunClaudeOptions { + /** Prompt to send to Claude */ + prompt: string; + /** Isolated copy of fixtures — Claude's working directory */ + fixtureDir: string; + /** Directory where all output files are written */ + outputDir: string; + /** Absolute path to the isolated config.json */ + configPath: string; +} + +export interface RunClaudeResult { + /** 0 = no new errors, 2 = Stop hook detected new validation errors */ + exitCode: number; + /** Actual session ID assigned by Claude (from the init message) */ + sessionId: string; + outputDir: string; + /** Directory where hook state files live during the session */ + stateDir: string; + /** + * State entries captured by the PreToolUse hook before the Stop hook deletes the file. + * Each entry describes an Edit or Write operation on a .md file in a configured space. + */ + stateEntries: object[]; + /** All SDK messages written as JSONL */ + messagesFile: string; +} + +export async function runClaude(options: RunClaudeOptions): Promise { + const { prompt, fixtureDir, outputDir, configPath } = options; + + const stateDir = join(outputDir, 'state'); + const messagesFile = join(outputDir, 'messages.jsonl'); + const errorsFile = join(outputDir, 'errors.txt'); + + mkdirSync(outputDir, { recursive: true }); + + const hookOpts = { stateDir, ostToolsBin: OST_TOOLS_BIN, configPath }; + let stopResult: { hasNewErrors: boolean; errorMessage?: string } = { hasNewErrors: false }; + let capturedStateEntries: object[] = []; + let actualSessionId = 'unknown'; + const messages: object[] = []; + + try { + const q = query({ + prompt, + options: { + cwd: fixtureDir, + additionalDirectories: [fixtureDir], + permissionMode: 'acceptEdits', + hooks: { + PreToolUse: [ + { + matcher: 'Write|Edit', + hooks: [ + async (input) => { + await runPreEdit( + { + tool_name: (input as { tool_name?: string }).tool_name, + tool_input: (input as { tool_input?: { file_path?: string } }).tool_input, + session_id: input.session_id, + }, + hookOpts, + ); + return {}; + }, + ], + }, + ], + Stop: [ + { + hooks: [ + async (input) => { + // Capture state entries before runOnStop deletes the state file + const stateFile = join(stateDir, `ost-tools-hook-${input.session_id}.jsonl`); + if (existsSync(stateFile)) { + capturedStateEntries = readFileSync(stateFile, 'utf-8') + .trim() + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l)); + } + + stopResult = await runOnStop( + { + session_id: input.session_id, + stop_hook_active: (input as { stop_hook_active?: boolean }).stop_hook_active ?? false, + }, + hookOpts, + ); + // Always allow the stop — we report the result via exitCode + return {}; + }, + ], + }, + ], + }, + }, + }); + + for await (const message of q) { + messages.push(message); + // Capture actual session ID from the init message + if ( + actualSessionId === 'unknown' && + (message as { type?: string; subtype?: string }).type === 'system' && + (message as { type?: string; subtype?: string }).subtype === 'init' + ) { + actualSessionId = (message as { session_id?: string }).session_id ?? 'unknown'; + } + } + } catch (err) { + writeFileSync(errorsFile, String(err)); + } + + writeFileSync(messagesFile, `${messages.map((m) => JSON.stringify(m)).join('\n')}\n`); + + const exitCode = stopResult.hasNewErrors ? 2 : 0; + + if (stopResult.hasNewErrors && stopResult.errorMessage) { + writeFileSync(join(outputDir, 'stop-hook-errors.txt'), stopResult.errorMessage); + } + + console.log(`[harness] output dir: ${outputDir}`); + + return { + exitCode, + sessionId: actualSessionId, + outputDir, + stateDir, + stateEntries: capturedStateEntries, + messagesFile, + }; +} diff --git a/hook-test/hooks.test.ts b/hook-test/hooks.test.ts new file mode 100644 index 0000000..85e7372 --- /dev/null +++ b/hook-test/hooks.test.ts @@ -0,0 +1,138 @@ +/** + * End-to-end tests for ost-tools plugin hooks. + * + * These tests run real Claude Code sessions via the Agent SDK to verify that + * PreToolUse and Stop hooks fire and behave correctly. + * + * They are slow (each test spawns a full Claude session) and require a valid + * Claude API key. + */ + +import { afterEach, describe, expect, it } from 'bun:test'; +import { existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { type IsolatedFixtures, isolateFixtures } from './fixture-utils'; +import { runClaude } from './harness'; + +// Claude sessions take 30-90 seconds +const TEST_TIMEOUT = 120_000; + +let fixtures: IsolatedFixtures; + +afterEach(() => { + fixtures?.cleanup(); +}); + +function makeOutputDir(): string { + return join(tmpdir(), `ost-e2e-out-${crypto.randomUUID()}`); +} + +describe('clean edit of valid file', () => { + it( + 'captures an Edit state entry, exits 0, and cleans up the state file', + async () => { + fixtures = isolateFixtures(); + const outputDir = makeOutputDir(); + const validFile = join(fixtures.vaultDir, 'valid.md'); + + const result = await runClaude({ + prompt: `Read valid.md, then change the title to "Modified Title". Only edit the file, no other output.`, + fixtureDir: fixtures.fixtureDir, + outputDir, + configPath: fixtures.configPath, + }); + + expect(result.exitCode).toBe(0); + expect(existsSync(join(outputDir, 'stop-hook-errors.txt'))).toBe(false); + + const editEntry = result.stateEntries.find((e) => (e as { tool: string }).tool === 'Edit'); + expect(editEntry).toBeDefined(); + expect((editEntry as { file: string }).file).toBe(validFile); + + // State entries captured before deletion — confirms the file was written + expect(result.stateEntries.length).toBeGreaterThan(0); + // Stop hook must delete the state file after analysis + expect(existsSync(join(result.stateDir, `ost-tools-hook-${result.sessionId}.jsonl`))).toBe(false); + }, + TEST_TIMEOUT, + ); +}); + +describe('Write hook', () => { + it( + 'captures a state entry with null errors when Claude writes a new .md file', + async () => { + fixtures = isolateFixtures(); + const outputDir = makeOutputDir(); + + const result = await runClaude({ + prompt: `Create a new file called new-note.md in the current directory with this exact content: +--- +type: mission +parent: "[[Root]]" +status: identified +--- + +# New Note + +A new note.`, + fixtureDir: fixtures.fixtureDir, + outputDir, + configPath: fixtures.configPath, + }); + + expect(result.exitCode).toBe(0); + + const writeEntry = result.stateEntries.find((e) => (e as { tool: string }).tool === 'Write'); + expect(writeEntry).toBeDefined(); + expect((writeEntry as { errors: null }).errors).toBeNull(); + }, + TEST_TIMEOUT, + ); +}); + +describe('Stop hook', () => { + it( + 'reports errors (exitCode 2) when an edit introduces a broken parent link', + async () => { + fixtures = isolateFixtures(); + const outputDir = makeOutputDir(); + + const result = await runClaude({ + prompt: `Edit valid.md and change the parent field in the frontmatter to "[[Nonexistent Node]]". Only edit the file, no other output.`, + fixtureDir: fixtures.fixtureDir, + outputDir, + configPath: fixtures.configPath, + }); + + expect(result.exitCode).toBe(2); + + const errorContent = readFileSync(join(outputDir, 'stop-hook-errors.txt'), 'utf-8'); + expect(errorContent).toContain('ost-tools: new validation errors'); + expect(errorContent).toContain('broken-link'); + expect(errorContent).toContain('Nonexistent Node'); + }, + TEST_TIMEOUT, + ); + + it( + 'reports no errors (exitCode 0) when editing a file that already has errors', + async () => { + fixtures = isolateFixtures(); + const outputDir = makeOutputDir(); + + // broken.md already has a broken parent link — editing its title should not + // introduce new errors beyond what was there at baseline + const result = await runClaude({ + prompt: `Edit broken.md and change its title to "Still Broken". Only edit the file, no other output.`, + fixtureDir: fixtures.fixtureDir, + outputDir, + configPath: fixtures.configPath, + }); + + expect(result.exitCode).toBe(0); + }, + TEST_TIMEOUT, + ); +}); diff --git a/hook-test/unit/on-stop.test.ts b/hook-test/unit/on-stop.test.ts new file mode 100644 index 0000000..496ecaa --- /dev/null +++ b/hook-test/unit/on-stop.test.ts @@ -0,0 +1,179 @@ +/** + * Unit tests for plugin/scripts/on-stop.ts + * + * Tests call runOnStop() directly — no Claude process involved. + * State files are written manually to set up each scenario. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runOnStop } from '../../plugin/scripts/on-stop'; +import { type IsolatedFixtures, isolateFixtures } from '../fixture-utils'; + +const OST_TOOLS_BIN = join(import.meta.dir, '../../src/index.ts'); + +let fixtures: IsolatedFixtures; +let stateDir: string; + +beforeEach(() => { + fixtures = isolateFixtures(); + stateDir = join(tmpdir(), `ost-on-stop-state-${crypto.randomUUID()}`); + mkdirSync(stateDir, { recursive: true }); +}); + +afterEach(() => { + fixtures.cleanup(); + rmSync(stateDir, { recursive: true, force: true }); +}); + +const opts = () => ({ + stateDir, + ostToolsBin: OST_TOOLS_BIN, + configPath: fixtures.configPath, +}); + +function writeStateFile(sessionId: string, entries: object[]): string { + const file = join(stateDir, `ost-tools-hook-${sessionId}.jsonl`); + writeFileSync(file, `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`); + return file; +} + +function stateEntry(file: string, tool: 'Edit' | 'Write', errors: object | null = null, timestamp = Date.now()) { + return { session_id: 'test', timestamp, tool, file, errors }; +} + +describe('No-op cases', () => { + it('returns no errors when state file does not exist', async () => { + const result = await runOnStop({ session_id: crypto.randomUUID() }, opts()); + expect(result).toEqual({ hasNewErrors: false }); + }); + + it('returns no errors when stop_hook_active is true (loop guard)', async () => { + const sessionId = crypto.randomUUID(); + writeStateFile(sessionId, [stateEntry(join(fixtures.vaultDir, 'valid.md'), 'Edit', {})]); + + const result = await runOnStop({ session_id: sessionId, stop_hook_active: true }, opts()); + expect(result).toEqual({ hasNewErrors: false }); + }); +}); + +describe('Write entries', () => { + it('returns no errors for a new file that is still valid', async () => { + const sessionId = crypto.randomUUID(); + writeStateFile(sessionId, [stateEntry(join(fixtures.vaultDir, 'valid.md'), 'Write', null)]); + + const result = await runOnStop({ session_id: sessionId }, opts()); + expect(result.hasNewErrors).toBe(false); + }); + + it('returns errors for a new file that has validation errors', async () => { + const sessionId = crypto.randomUUID(); + writeStateFile(sessionId, [stateEntry(join(fixtures.vaultDir, 'broken.md'), 'Write', null)]); + + const result = await runOnStop({ session_id: sessionId }, opts()); + expect(result.hasNewErrors).toBe(true); + expect(result.errorMessage).toContain('ost-tools: new validation errors'); + expect(result.errorMessage).toContain('broken-link'); + }); +}); + +describe('Edit entries', () => { + it('returns no errors when the file still has the same errors as the baseline', async () => { + const sessionId = crypto.randomUUID(); + // broken.md already has broken-link error — baseline captures it + const baseline = { 'broken-link:[[Nonexistent Node]]': { kind: 'broken-link', message: 'test' } }; + writeStateFile(sessionId, [stateEntry(join(fixtures.vaultDir, 'broken.md'), 'Edit', baseline)]); + + const result = await runOnStop({ session_id: sessionId }, opts()); + expect(result.hasNewErrors).toBe(false); + }); + + it('returns errors when a new error is introduced by an edit', async () => { + const sessionId = crypto.randomUUID(); + // valid.md had no errors at baseline, but now we point it at a broken parent + const validPath = join(fixtures.vaultDir, 'valid.md'); + writeStateFile(sessionId, [stateEntry(validPath, 'Edit', {})]); + + // Simulate the edit: change parent to a non-existent node + writeFileSync(validPath, '---\ntype: mission\nparent: "[[Ghost Node]]"\nstatus: identified\n---\n\n# Test Title\n'); + + const result = await runOnStop({ session_id: sessionId }, opts()); + expect(result.hasNewErrors).toBe(true); + expect(result.errorMessage).toContain('broken-link'); + expect(result.errorMessage).toContain('Ghost Node'); + }); + + it('returns no errors when a pre-existing error is fixed', async () => { + const sessionId = crypto.randomUUID(); + const brokenPath = join(fixtures.vaultDir, 'broken.md'); + const baseline = { 'broken-link:[[Nonexistent Node]]': { kind: 'broken-link', message: 'test' } }; + writeStateFile(sessionId, [stateEntry(brokenPath, 'Edit', baseline)]); + + // Fix the file + writeFileSync(brokenPath, '---\ntype: mission\nparent: "[[Root]]"\nstatus: identified\n---\n\n# Broken Note\n'); + + const result = await runOnStop({ session_id: sessionId }, opts()); + expect(result.hasNewErrors).toBe(false); + }); +}); + +describe('Multiple files', () => { + it('reports errors only for the file that introduced them', async () => { + const sessionId = crypto.randomUUID(); + const validPath = join(fixtures.vaultDir, 'valid.md'); + const brokenPath = join(fixtures.vaultDir, 'broken.md'); + + // valid.md: clean edit, no new errors + // broken.md: had broken-link at baseline, still has it + const baseline = { 'broken-link:[[Nonexistent Node]]': { kind: 'broken-link', message: 'test' } }; + writeStateFile(sessionId, [stateEntry(validPath, 'Edit', {}), stateEntry(brokenPath, 'Edit', baseline)]); + + const result = await runOnStop({ session_id: sessionId }, opts()); + expect(result.hasNewErrors).toBe(false); + }); + + it('uses only the latest entry per file when there are duplicates', async () => { + const sessionId = crypto.randomUUID(); + const validPath = join(fixtures.vaultDir, 'valid.md'); + + // Two entries for same file: first has no baseline errors (clean), second was the most recent edit + const entries = [ + stateEntry(validPath, 'Edit', {}, Date.now() - 1000), + stateEntry(validPath, 'Edit', {}, Date.now()), + ]; + writeStateFile(sessionId, entries); + + const result = await runOnStop({ session_id: sessionId }, opts()); + expect(result.hasNewErrors).toBe(false); + }); +}); + +describe('State file lifecycle', () => { + it('deletes the state file after running, regardless of result', async () => { + const sessionId = crypto.randomUUID(); + const stateFile = writeStateFile(sessionId, [stateEntry(join(fixtures.vaultDir, 'valid.md'), 'Edit', {})]); + + await runOnStop({ session_id: sessionId }, opts()); + + expect(existsSync(stateFile)).toBe(false); + }); + + it('deletes the state file even when new errors are found', async () => { + const sessionId = crypto.randomUUID(); + const stateFile = writeStateFile(sessionId, [stateEntry(join(fixtures.vaultDir, 'broken.md'), 'Write', null)]); + + await runOnStop({ session_id: sessionId }, opts()); + + expect(existsSync(stateFile)).toBe(false); + }); + + it('skips files that no longer exist without crashing', async () => { + const sessionId = crypto.randomUUID(); + writeStateFile(sessionId, [stateEntry(join(fixtures.vaultDir, 'deleted.md'), 'Edit', {})]); + + const result = await runOnStop({ session_id: sessionId }, opts()); + expect(result.hasNewErrors).toBe(false); + }); +}); diff --git a/hook-test/unit/pre-edit.test.ts b/hook-test/unit/pre-edit.test.ts new file mode 100644 index 0000000..45ac973 --- /dev/null +++ b/hook-test/unit/pre-edit.test.ts @@ -0,0 +1,154 @@ +/** + * Unit tests for plugin/scripts/pre-edit.ts + * + * Tests call runPreEdit() directly — no Claude process involved. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runPreEdit } from '../../plugin/scripts/pre-edit'; +import { type IsolatedFixtures, isolateFixtures } from '../fixture-utils'; + +const OST_TOOLS_BIN = join(import.meta.dir, '../../src/index.ts'); + +let fixtures: IsolatedFixtures; +let stateDir: string; + +beforeEach(() => { + fixtures = isolateFixtures(); + stateDir = join(tmpdir(), `ost-pre-edit-state-${crypto.randomUUID()}`); +}); + +afterEach(() => { + fixtures.cleanup(); + rmSync(stateDir, { recursive: true, force: true }); +}); + +const opts = () => ({ + stateDir, + ostToolsBin: OST_TOOLS_BIN, + configPath: fixtures.configPath, +}); + +function readState(sessionId: string): object[] { + const file = join(stateDir, `ost-tools-hook-${sessionId}.jsonl`); + if (!existsSync(file)) return []; + return readFileSync(file, 'utf-8') + .trim() + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l)); +} + +describe('Write operations', () => { + it('records entry with null errors for a new .md file', async () => { + const sessionId = crypto.randomUUID(); + const filePath = join(fixtures.vaultDir, 'new-note.md'); + + await runPreEdit({ tool_name: 'Write', tool_input: { file_path: filePath }, session_id: sessionId }, opts()); + + const entries = readState(sessionId); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ tool: 'Write', file: filePath, errors: null }); + }); + + it('creates the state directory if it does not exist', async () => { + const deepStateDir = join(stateDir, 'a', 'b', 'c'); + const sessionId = crypto.randomUUID(); + + await runPreEdit( + { tool_name: 'Write', tool_input: { file_path: join(fixtures.vaultDir, 'x.md') }, session_id: sessionId }, + { ...opts(), stateDir: deepStateDir }, + ); + + expect(existsSync(deepStateDir)).toBe(true); + }); +}); + +describe('Edit operations', () => { + it('records entry with empty errors for a valid in-space .md file', async () => { + const sessionId = crypto.randomUUID(); + const filePath = join(fixtures.vaultDir, 'valid.md'); + + await runPreEdit({ tool_name: 'Edit', tool_input: { file_path: filePath }, session_id: sessionId }, opts()); + + const entries = readState(sessionId); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ tool: 'Edit', file: filePath }); + expect((entries[0] as { errors: object }).errors).toEqual({}); + }); + + it('records pre-existing errors for a broken in-space .md file', async () => { + const sessionId = crypto.randomUUID(); + const filePath = join(fixtures.vaultDir, 'broken.md'); + + await runPreEdit({ tool_name: 'Edit', tool_input: { file_path: filePath }, session_id: sessionId }, opts()); + + const entries = readState(sessionId); + expect(entries).toHaveLength(1); + const errors = (entries[0] as { errors: Record }).errors; + expect(Object.keys(errors)).toContain('broken-link:[[Nonexistent Node]]'); + }); + + it('does not write a state entry for a .md file not in any space', async () => { + const sessionId = crypto.randomUUID(); + // File outside the fixture vault — not registered in any space + const filePath = join(tmpdir(), 'unrelated.md'); + + await runPreEdit({ tool_name: 'Edit', tool_input: { file_path: filePath }, session_id: sessionId }, opts()); + + expect(readState(sessionId)).toHaveLength(0); + }); + + it('appends separate entries for multiple edits in the same session', async () => { + const sessionId = crypto.randomUUID(); + + await runPreEdit( + { tool_name: 'Edit', tool_input: { file_path: join(fixtures.vaultDir, 'valid.md') }, session_id: sessionId }, + opts(), + ); + await runPreEdit( + { tool_name: 'Edit', tool_input: { file_path: join(fixtures.vaultDir, 'broken.md') }, session_id: sessionId }, + opts(), + ); + + const entries = readState(sessionId); + expect(entries).toHaveLength(2); + expect(entries.map((e) => (e as { file: string }).file)).toContain(join(fixtures.vaultDir, 'valid.md')); + expect(entries.map((e) => (e as { file: string }).file)).toContain(join(fixtures.vaultDir, 'broken.md')); + }); +}); + +describe('Edge cases', () => { + it('returns without writing when file_path is missing', async () => { + const sessionId = crypto.randomUUID(); + + await runPreEdit({ tool_name: 'Edit', tool_input: {}, session_id: sessionId }, opts()); + + expect(readState(sessionId)).toHaveLength(0); + }); + + it('returns without writing when tool_input is missing', async () => { + const sessionId = crypto.randomUUID(); + + await runPreEdit({ tool_name: 'Edit', session_id: sessionId }, opts()); + + expect(readState(sessionId)).toHaveLength(0); + }); + + it('includes session_id and timestamp in every entry', async () => { + const sessionId = crypto.randomUUID(); + const before = Date.now(); + + await runPreEdit( + { tool_name: 'Write', tool_input: { file_path: join(fixtures.vaultDir, 'x.md') }, session_id: sessionId }, + opts(), + ); + + const [entry] = readState(sessionId) as Array<{ session_id: string; timestamp: number }>; + expect(entry!.session_id).toBe(sessionId); + expect(entry!.timestamp).toBeGreaterThanOrEqual(before); + }); +}); diff --git a/package.json b/package.json index 39a563d..bb89be8 100644 --- a/package.json +++ b/package.json @@ -22,13 +22,15 @@ "scripts": { "clean": "rm -rf dist", "generate:schema-meta": "bun run scripts/generate-schema-meta.ts", - "build": "bun run clean && bun run generate:schema-meta && tsc && cp -r schemas dist/ && chmod +x dist/index.js", + "typecheck": "tsc --noEmit", + "build": "bun run clean && bun run generate:schema-meta && tsc -p tsconfig.build.json && cp -r schemas dist/ && chmod +x dist/index.js", "prepublishOnly": "bun run build", "validate": "bun run src/index.ts validate", "diagram": "bun run src/index.ts diagram", "test": "bun test tests/", "test:smoke": "bun test smoke/", - "test:hook": "bun test hook-test/", + "test:hook": "bun test hook-test/unit/", + "test:hook:e2e": "bun test hook-test/hooks.test.ts", "test:all": "bun test", "lint": "biome check", "lint:fix": "biome check --write", @@ -37,6 +39,7 @@ "postversion": "git push --follow-tags" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.92", "@biomejs/biome": "^2.4.10", "@types/bun": "latest", "@types/js-yaml": "^4.0.9", diff --git a/plugin/scripts/on-stop.ts b/plugin/scripts/on-stop.ts index 2e6a483..c29d491 100755 --- a/plugin/scripts/on-stop.ts +++ b/plugin/scripts/on-stop.ts @@ -1,18 +1,25 @@ #!/usr/bin/env bun -/** - * Stop hook: re-validates all markdown files touched during this session turn. - * Compares fresh results against pre-edit baselines captured by the PreToolUse hook. - * Exits 2 with error details on stderr if new violations were introduced; 0 otherwise. - * Clears the session state file after analysis. - */ -export {}; - -interface HookInput { +export interface OnStopInput { session_id?: string; stop_hook_active?: boolean; } +export interface OnStopOptions { + /** Overrides OST_TOOLS_STATE_DIR env var */ + stateDir?: string; + /** Path to ost-tools entry point. When set, uses `bun run ` instead of `bunx ost-tools`. */ + ostToolsBin?: string; + /** Path to config file. When set, passed as OST_TOOLS_CONFIG to validate-file subprocess. */ + configPath?: string; +} + +export interface OnStopResult { + hasNewErrors: boolean; + /** Present when hasNewErrors is true */ + errorMessage?: string; +} + interface HookState { session_id: string; timestamp: number; @@ -33,44 +40,23 @@ interface ValidationResult { errors?: Record; } -const LOG_FILE = '/tmp/ost-tools-stop-hook.log'; -const LOG_ENABLED = false; - -function log(message: string): void { - if (!LOG_ENABLED) return; - const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' '); - const logMsg = `[${timestamp}] ${message}\n`; - Bun.write(Bun.file(LOG_FILE), logMsg, { createPath: true }); -} - -async function main() { - const INPUT_TEXT = await Bun.stdin.text(); - log(`INPUT=${INPUT_TEXT}`); - - const INPUT = JSON.parse(INPUT_TEXT) as HookInput; - const SESSION_ID = INPUT.session_id ?? 'unknown'; - const STOP_HOOK_ACTIVE = INPUT.stop_hook_active ?? false; - log(`SESSION_ID=${SESSION_ID}`); - log(`STOP_HOOK_ACTIVE=${STOP_HOOK_ACTIVE}`); +export async function runOnStop(input: OnStopInput, options?: OnStopOptions): Promise { + const SESSION_ID = input.session_id ?? 'unknown'; + const STOP_HOOK_ACTIVE = input.stop_hook_active ?? false; // Guard against infinite loop: stop hooks can trigger another stop cycle if (STOP_HOOK_ACTIVE === true) { - log('Stop hook already active, exiting to avoid infinite loop'); - process.exit(0); + return { hasNewErrors: false }; } - log('Stop hook not active, proceeding'); - const STATE_DIR = process.env.OST_TOOLS_STATE_DIR ?? '/tmp'; + const STATE_DIR = options?.stateDir ?? process.env.OST_TOOLS_STATE_DIR ?? '/tmp'; const STATE_FILE = `${STATE_DIR}/ost-tools-hook-${SESSION_ID}.jsonl`; - log(`STATE_FILE=${STATE_FILE}`); const stateFile = Bun.file(STATE_FILE); const stateFileExists = await stateFile.exists(); if (!stateFileExists) { - log('State file does not exist, exiting'); - process.exit(0); + return { hasNewErrors: false }; } - log('State file exists, proceeding'); const newErrors: string[] = []; let hasNewErrors = false; @@ -91,40 +77,38 @@ async function main() { } } - log(`Processing ${latestByFile.size} entries`); + const BIN = options?.ostToolsBin ?? process.env.OST_TOOLS_BIN; + const env: Record = { ...process.env }; + if (options?.configPath) { + env.OST_TOOLS_CONFIG = options.configPath; + } for (const [FILE, entry] of latestByFile) { - log(`Processing entry: ${JSON.stringify(entry)}`); const { tool, errors: BASELINE } = entry; - log(` FILE=${FILE}, TOOL=${tool}, BASELINE=${JSON.stringify(BASELINE)}`); const fileExists = await Bun.file(FILE).exists(); if (!fileExists) { - log(' File does not exist, skipping'); continue; } - const proc = Bun.$`bunx ost-tools validate-file ${FILE} --json`.quiet().nothrow(); + const proc = BIN + ? Bun.$`bun run ${BIN} validate-file ${FILE} --json`.env(env).quiet().nothrow() + : Bun.$`bunx ost-tools validate-file ${FILE} --json`.env(env).quiet().nothrow(); const resultText = await proc.text(); const result = resultText ? (JSON.parse(resultText) as ValidationResult) : {}; - log(` FRESH_RESULT=${JSON.stringify(result)}`); const IN_SPACE = result.inSpace ?? false; - if (IN_SPACE !== true) { - log(' File not in space, skipping'); continue; } const FRESH_ERRORS = result.errors ?? {}; - log(` FRESH_ERRORS=${JSON.stringify(FRESH_ERRORS)}`); let newErrorsForFile: Record; if (tool === 'Write') { // New file — all errors are new newErrorsForFile = FRESH_ERRORS; - log(' New file: all errors are new'); } else { // Edit — only errors absent from the baseline are new const baselineKeys = new Set(Object.keys(BASELINE ?? {})); @@ -133,16 +117,13 @@ async function main() { string, ValidationError >; - log(` Edit mode: computed NEW_ERRORS=${JSON.stringify(newErrorsForFile)}`); } const newCount = Object.keys(newErrorsForFile).length; - log(` NEW_COUNT=${newCount}`); if (newCount > 0) { const LABEL = result.label ?? FILE; const SPACE = result.space ?? 'unknown'; - log(` New errors found in ${LABEL} (space: ${SPACE})`); const errorLines = Object.values(newErrorsForFile) .map((val) => ` [${val.kind}] ${val.message}`) @@ -150,28 +131,34 @@ async function main() { newErrors.push(` ${LABEL} (space: ${SPACE}) — ${newCount} new error(s):\n${errorLines}`); hasNewErrors = true; - log(' Set HAS_NEW_ERRORS=1'); - } else { - log(' No new errors for this file'); } } // Clean up state file await Bun.$`rm -f ${STATE_FILE}`; - log('Removed state file'); if (hasNewErrors) { - log('Exiting with code 2 (new errors found)'); - const errorMsg = `ost-tools: new validation errors introduced this session - use ost-tools skill to resolve:\n${newErrors.join('\n')}`; - console.error(errorMsg); - process.exit(2); + const errorMessage = `ost-tools: new validation errors introduced this session - use ost-tools skill to resolve:\n${newErrors.join('\n')}`; + return { hasNewErrors: true, errorMessage }; } - log('Exiting with code 0 (no new errors)'); + return { hasNewErrors: false }; } -main().catch((err) => { - log(`Error: ${String(err)}`); - console.error(err); - process.exit(1); -}); +async function main() { + const INPUT_TEXT = await Bun.stdin.text(); + const INPUT = JSON.parse(INPUT_TEXT) as OnStopInput; + const result = await runOnStop(INPUT); + + if (result.hasNewErrors) { + console.error(result.errorMessage); + process.exit(2); + } +} + +if (import.meta.main) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/plugin/scripts/pre-edit.ts b/plugin/scripts/pre-edit.ts index 8deb53a..e23addb 100755 --- a/plugin/scripts/pre-edit.ts +++ b/plugin/scripts/pre-edit.ts @@ -8,7 +8,7 @@ import { appendFileSync, mkdirSync } from 'node:fs'; -interface HookInput { +export interface PreEditInput { tool_name?: string; tool_input?: { file_path?: string; @@ -16,6 +16,15 @@ interface HookInput { session_id?: string; } +export interface PreEditOptions { + /** Overrides OST_TOOLS_STATE_DIR env var */ + stateDir?: string; + /** Path to ost-tools entry point. When set, uses `bun run ` instead of `bunx ost-tools`. */ + ostToolsBin?: string; + /** Path to config file. When set, passed as OST_TOOLS_CONFIG to validate-file subprocess. */ + configPath?: string; +} + interface HookState { session_id: string; timestamp: number; @@ -29,28 +38,20 @@ interface ValidationResult { errors?: object; } -async function main() { - const INPUT_TEXT = await Bun.stdin.text(); - const INPUT = JSON.parse(INPUT_TEXT) as HookInput; - - // DEBUG: log full input and env to stderr - console.error('[ost-tools pre-edit hook] input:', JSON.stringify(INPUT)); - console.error('[ost-tools pre-edit hook] OST_TOOLS_STATE_DIR:', process.env.OST_TOOLS_STATE_DIR); - - const TOOL = INPUT.tool_name ?? ''; - const FILE_PATH = INPUT.tool_input?.file_path; - const SESSION_ID = INPUT.session_id ?? 'unknown'; +export async function runPreEdit(input: PreEditInput, options?: PreEditOptions): Promise { + const TOOL = input.tool_name ?? ''; + const FILE_PATH = input.tool_input?.file_path; + const SESSION_ID = input.session_id ?? 'unknown'; if (!FILE_PATH) { - process.exit(0); + return; } - const STATE_DIR = process.env.OST_TOOLS_STATE_DIR ?? '/tmp'; + const STATE_DIR = options?.stateDir ?? process.env.OST_TOOLS_STATE_DIR ?? '/tmp'; const STATE_FILE = `${STATE_DIR}/ost-tools-hook-${SESSION_ID}.jsonl`; const TIMESTAMP = Date.now(); if (TOOL === 'Write') { - // New file — record filename only, no baseline to establish const entry: HookState = { session_id: SESSION_ID, timestamp: TIMESTAMP, @@ -60,17 +61,26 @@ async function main() { }; mkdirSync(STATE_DIR, { recursive: true }); appendFileSync(STATE_FILE, `${JSON.stringify(entry)}\n`); - process.exit(0); + return; } // Edit — validate current state as pre-edit baseline - const proc = Bun.$`bunx ost-tools validate-file ${FILE_PATH} --json`.quiet().nothrow(); + const BIN = options?.ostToolsBin ?? process.env.OST_TOOLS_BIN; + const env: Record = { ...process.env }; + if (options?.configPath) { + env.OST_TOOLS_CONFIG = options.configPath; + } + + const proc = BIN + ? Bun.$`bun run ${BIN} validate-file ${FILE_PATH} --json`.env(env).quiet().nothrow() + : Bun.$`bunx ost-tools validate-file ${FILE_PATH} --json`.env(env).quiet().nothrow(); + const resultText = await proc.text(); const result = resultText ? (JSON.parse(resultText) as ValidationResult) : {}; const IN_SPACE = result.inSpace ?? false; if (IN_SPACE !== true) { - process.exit(0); + return; } const ERRORS = result.errors ?? {}; @@ -85,7 +95,15 @@ async function main() { appendFileSync(STATE_FILE, `${JSON.stringify(entry)}\n`); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +async function main() { + const INPUT_TEXT = await Bun.stdin.text(); + const INPUT = JSON.parse(INPUT_TEXT) as PreEditInput; + await runPreEdit(INPUT); +} + +if (import.meta.main) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/src/read/wikilink-utils.ts b/src/read/wikilink-utils.ts index 465845c..f18e84c 100644 --- a/src/read/wikilink-utils.ts +++ b/src/read/wikilink-utils.ts @@ -1,4 +1,4 @@ -import type { SpaceNode } from '../types'; +import type { BaseNode } from '../types'; /** * Extract the lookup key from a wikilink string such as: @@ -18,11 +18,11 @@ export function wikilinkToTarget(wikilink: string): string { * Builds a fast lookup index mapping link targets to nodes. * Used for both hierarchy and relationship validation. * - * @param nodes The complete set of SpaceNodes + * @param nodes The complete set of nodes (BaseNode, SpaceNode, or subtypes) * @returns Map of target strings to nodes. If a target is ambiguous (points to multiple nodes), its value is null. */ -export function buildTargetIndex(nodes: SpaceNode[]): Map { - const index = new Map(); +export function buildTargetIndex(nodes: T[]): Map { + const index = new Map(); for (const node of nodes) { for (const target of node.linkTargets) { diff --git a/tests/config.test.ts b/tests/config.test.ts index a3bf45f..f53c87d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -134,9 +134,9 @@ describe('loadConfig with includeSpacesFrom', () => { expect(config.spaces).toHaveLength(3); // Main config spaces come first, then included in order - expect(config.spaces[0].name).toBe('main-space'); - expect(config.spaces[1].name).toBe('first-space'); - expect(config.spaces[2].name).toBe('second-space'); + expect(config.spaces[0]!.name).toBe('main-space'); + expect(config.spaces[1]!.name).toBe('first-space'); + expect(config.spaces[2]!.name).toBe('second-space'); }); it('handles absolute paths in includeSpacesFrom', () => { @@ -167,7 +167,7 @@ describe('loadConfig with includeSpacesFrom', () => { const config = loadConfig(); expect(config.spaces).toHaveLength(1); - expect(config.spaces[0].name).toBe('abs-space'); + expect(config.spaces[0]!.name).toBe('abs-space'); }); it('throws error when included config has duplicate name', () => { @@ -301,10 +301,10 @@ describe('loadConfig with includeSpacesFrom', () => { // Verify the included config file was updated const updatedConfig = JSON5.parse(readFileSync(otherConfigPath, 'utf-8')) as Config; - expect(updatedConfig.spaces[0].miroFrameId).toBe('new-frame-id'); + expect(updatedConfig.spaces[0]!.miroFrameId).toBe('new-frame-id'); // Verify the main config was not modified const mainConfig = JSON5.parse(readFileSync(mainConfigPath, 'utf-8')) as Config; - expect(mainConfig.spaces[0].name).toBe('main-space'); + expect(mainConfig.spaces[0]!.name).toBe('main-space'); }); }); diff --git a/tests/fixtures/plugins/ost-tools-custom-plugin.ts b/tests/fixtures/plugins/ost-tools-custom-plugin.ts index af79fa5..793da46 100644 --- a/tests/fixtures/plugins/ost-tools-custom-plugin.ts +++ b/tests/fixtures/plugins/ost-tools-custom-plugin.ts @@ -7,6 +7,7 @@ const customPlugin: OstToolsPlugin = { async parse(): Promise { return { nodes: [], + parseIgnored: [], diagnostics: { source: 'custom' }, }; }, diff --git a/tests/render/registry.test.ts b/tests/render/registry.test.ts index cb53ac1..fe1a57e 100644 --- a/tests/render/registry.test.ts +++ b/tests/render/registry.test.ts @@ -27,9 +27,9 @@ describe('buildFormatRegistry', () => { const loaded = [makePlugin('ost-tools-markdown', [{ name: 'bullets', description: 'Bullet list' }])]; const registry = buildFormatRegistry(loaded); expect(registry).toHaveLength(1); - expect(registry[0].qualifiedName).toBe('markdown.bullets'); - expect(registry[0].format.name).toBe('bullets'); - expect(registry[0].format.description).toBe('Bullet list'); + expect(registry[0]!.qualifiedName).toBe('markdown.bullets'); + expect(registry[0]!.format.name).toBe('bullets'); + expect(registry[0]!.format.description).toBe('Bullet list'); }); it('collects formats from multiple plugins', () => { @@ -52,6 +52,6 @@ describe('buildFormatRegistry', () => { ]; const registry = buildFormatRegistry(loaded); expect(registry).toHaveLength(1); - expect(registry[0].qualifiedName).toBe('markdown.bullets'); + expect(registry[0]!.qualifiedName).toBe('markdown.bullets'); }); }); diff --git a/tests/validate/strict.test.ts b/tests/validate/strict.test.ts index e9175f1..753ef52 100644 --- a/tests/validate/strict.test.ts +++ b/tests/validate/strict.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from 'bun:test'; import { join } from 'node:path'; import { readSpaceDirectory, readSpaceOnAPage } from '../../src/plugins/markdown/read-space'; import { bundledSchemasDir, createValidator } from '../../src/schema/schema'; -import type { SpaceNode } from '../../src/types'; +import type { BaseNode } from '../../src/types'; import { makePluginContext } from '../helpers/context'; const STRICT_SCHEMA_PATH = join(bundledSchemasDir, 'strict_ost.json'); @@ -192,7 +192,7 @@ describe('Strict OST schema validation', () => { }); describe('valid fixtures (directory format)', () => { - let nodes: SpaceNode[]; + let nodes: BaseNode[]; beforeAll(async () => { ({ nodes } = await readSpaceDirectory(makePluginContext(VALID_DIR, STRICT_SCHEMA_PATH))); @@ -210,7 +210,7 @@ describe('Strict OST schema validation', () => { }); describe('valid fixtures (on-a-page format)', () => { - let nodes: SpaceNode[]; + let nodes: BaseNode[]; beforeAll(() => { ({ nodes } = readSpaceOnAPage(makePluginContext(VALID_ON_A_PAGE, STRICT_SCHEMA_PATH))); @@ -228,7 +228,7 @@ describe('Strict OST schema validation', () => { }); describe('valid-tree.md (minimal on-a-page)', () => { - let nodes: SpaceNode[]; + let nodes: BaseNode[]; beforeAll(() => { ({ nodes } = readSpaceOnAPage(makePluginContext(VALID_TREE, STRICT_SCHEMA_PATH))); @@ -246,7 +246,7 @@ describe('Strict OST schema validation', () => { }); describe('invalid fixtures', () => { - let nodes: SpaceNode[]; + let nodes: BaseNode[]; beforeAll(async () => { // Read from invalid directory - note that readSpaceDirectory doesn't validate diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..55948f3 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src" + }, + "exclude": [ + "node_modules", + "dist", + "tests", + "smoke", + "hook-test", + "plugin/scripts", + "scripts", + "**/*.test.ts", + "**/*.spec.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 4670a99..19f38b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ "noEmit": false, "declaration": true, "outDir": "dist", - "rootDir": "src", + "rootDir": ".", "paths": { "@/*": ["./src/*"], "*": ["./*"] }, // Best practices @@ -30,6 +30,6 @@ "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "tests", "smoke", "hook-test"] + "include": ["src/**/*", "tests/**/*", "smoke/**/*", "hook-test/**/*", "plugin/scripts/**/*", "scripts/**/*"], + "exclude": ["node_modules", "dist"] } From a4427acb59d1f7c265c4706cad00ab559e182617 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Mon, 6 Apr 2026 20:45:58 +1000 Subject: [PATCH 4/6] Fix path security issue --- plugin/scripts/on-stop.ts | 4 ++-- plugin/scripts/pre-edit.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugin/scripts/on-stop.ts b/plugin/scripts/on-stop.ts index c29d491..fa2fc6b 100755 --- a/plugin/scripts/on-stop.ts +++ b/plugin/scripts/on-stop.ts @@ -92,8 +92,8 @@ export async function runOnStop(input: OnStopInput, options?: OnStopOptions): Pr } const proc = BIN - ? Bun.$`bun run ${BIN} validate-file ${FILE} --json`.env(env).quiet().nothrow() - : Bun.$`bunx ost-tools validate-file ${FILE} --json`.env(env).quiet().nothrow(); + ? Bun.$`bun run ${[BIN]} validate-file ${[FILE]} --json`.env(env).quiet().nothrow() + : Bun.$`bunx ost-tools validate-file ${[FILE]} --json`.env(env).quiet().nothrow(); const resultText = await proc.text(); const result = resultText ? (JSON.parse(resultText) as ValidationResult) : {}; diff --git a/plugin/scripts/pre-edit.ts b/plugin/scripts/pre-edit.ts index e23addb..bf74a16 100755 --- a/plugin/scripts/pre-edit.ts +++ b/plugin/scripts/pre-edit.ts @@ -72,8 +72,8 @@ export async function runPreEdit(input: PreEditInput, options?: PreEditOptions): } const proc = BIN - ? Bun.$`bun run ${BIN} validate-file ${FILE_PATH} --json`.env(env).quiet().nothrow() - : Bun.$`bunx ost-tools validate-file ${FILE_PATH} --json`.env(env).quiet().nothrow(); + ? Bun.$`bun run ${[BIN]} validate-file ${[FILE_PATH]} --json`.env(env).quiet().nothrow() + : Bun.$`bunx ost-tools validate-file ${[FILE_PATH]} --json`.env(env).quiet().nothrow(); const resultText = await proc.text(); const result = resultText ? (JSON.parse(resultText) as ValidationResult) : {}; From 3a3214c975afd9cc6190f49d982f0b94c727beb7 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Mon, 6 Apr 2026 21:30:09 +1000 Subject: [PATCH 5/6] Skill and schema improvements --- plugin/skills/ost-tools/SKILL.md | 8 +++ .../ost-tools/references/schema-authoring.md | 32 +++++++-- schemas/knowledge_wiki.json | 65 +++++++------------ src/commands/schemas.ts | 11 ++-- 4 files changed, 67 insertions(+), 49 deletions(-) diff --git a/plugin/skills/ost-tools/SKILL.md b/plugin/skills/ost-tools/SKILL.md index 8dbde45..d7b661f 100644 --- a/plugin/skills/ost-tools/SKILL.md +++ b/plugin/skills/ost-tools/SKILL.md @@ -77,6 +77,14 @@ Embedded nodes are nodes that live physically inside another node's file (via ta - When you want to group related child nodes under a stable heading in a parent's body. - For developing a template page with structured headings and lists or tables to fill in. +## Authoring content frontmatter + +When writing or editing Obsidian markdown frontmatter: + +- **Do not include `title`** — Obsidian derives the page title from the filename. +- **Tags use plain strings** — In Obsidian frontmatter, tags are listed as plain strings without a `#` prefix (e.g. `tags: [adhd, reading]`). The `#` prefix is only used for inline tags in the document body. +- **Check entity descriptions before assigning a type to an existing document** — Run `schemas show --space `. The description for each entity type, as well as any rules, should be carefully considered as part of determining that a type is appropriate for an existing document. + ## Non-obvious issues **All files appear as "Non-space (no type field)"** — the space uses a different field name for the entity discriminator diff --git a/plugin/skills/ost-tools/references/schema-authoring.md b/plugin/skills/ost-tools/references/schema-authoring.md index 25a0bc9..0b82afd 100644 --- a/plugin/skills/ost-tools/references/schema-authoring.md +++ b/plugin/skills/ost-tools/references/schema-authoring.md @@ -119,25 +119,47 @@ Schema definitions use the mapped target names. Use `bunx ost-tools schemas show _ost_tools_base.json` to inspect built-in defs. +**Available partials in `_ost_tools_base`:** + +| Def | Purpose | Use when | +|---|---|---| +| `baseNodeProps` | `title`, `content`, `tags` — universal node fields | All schemas | +| `ostEntityProps` | `status` (required, lifecycle enum), `summary`, `status_tweet` | OST-domain schemas only — carries OST lifecycle semantics. Do not use in non-OST schemas (e.g., knowledge wikis, general content). | +| `parentNodeProps` | `parent` wikilink field | When hierarchy uses default `parent` field | +| `wikilink` | Wikilink string pattern | Any field referencing another node | +| `summary` | Short summary string | Any schema that needs a summary property | +| `status` | OST lifecycle status enum | OST-domain schemas only | + Convention: - define reusable concepts in `$defs` - reference via `$ref` from `oneOf` entries +- **always check existing schemas** (`general.json`, `strict_ost.json`, `knowledge_wiki.json`) before authoring — use them as consistency references for property names, patterns, and structure ## `oneOf` authoring pattern +Each entity type entry should have: +- a clear `description` explaining the purpose of the type +- an `allOf` pulling in relevant partials (always `baseNodeProps`; domain-specific partials only when appropriate) +- `examples` covering required fields at minimum — **exclude `title`**, which is derived from the filename in Obsidian, not written in frontmatter + ```json5 { "type": "object", + "description": "A specific, scoped explanation of what this entity type represents and when to use it.", "allOf": [ - { "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }, - { "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" } + { "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" } + // add other partials only if appropriate to the domain of this schema ], "properties": { - "type": { "const": "opportunity" } + "type": { "const": "opportunity" }, + "summary": { + "$ref": "ost-tools://_ost_tools_base#/$defs/summary" + }, // add domain-specific properties here }, - "required": ["type"], + "required": ["type", "status"], "additionalProperties": true, - "examples": [{ "type": "opportunity", "status": "identified" }] + "examples": [{ "type": "opportunity", "summary": "Summarise the opportunity at a high level" }] + // examples must not include "title" — title = filename, not a frontmatter field } ``` diff --git a/schemas/knowledge_wiki.json b/schemas/knowledge_wiki.json index 9487040..d826def 100644 --- a/schemas/knowledge_wiki.json +++ b/schemas/knowledge_wiki.json @@ -9,8 +9,7 @@ "study": "source", "article": "source", "paper": "source", - "research": "source", - "journal": "note" + "research": "source" }, "relationships": [ { @@ -56,12 +55,12 @@ { "type": "object", "description": "A processed summary of a raw source (article, paper, book, video, thread, podcast, etc.). Captures key information from the source in structured form with provenance.", - "allOf": [ - { "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }, - { "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }], "properties": { "type": { "const": "source" }, + "summary": { + "$ref": "ost-tools://_ost_tools_base#/$defs/summary" + }, "url": { "type": "string", "description": "URL of the original source" @@ -80,7 +79,8 @@ }, "published_date": { "type": "string", - "description": "Original publication date (ISO 8601 or natural language)" + "format": "date", + "description": "Original publication date" }, "source_type": { "type": "string", @@ -97,24 +97,22 @@ "examples": [ { "type": "source", - "title": "The Design of Everyday Things - Don Norman", - "author": "Don Norman", - "url": "https://example.com/design-of-everyday-things", + "author": "Joe Bloggs", + "url": "https://example.com/design-of-yesterday-things", "source_type": "book", - "tags": ["design", "ux", "psychology"], - "status": "active" + "tags": ["design", "ux", "psychology"] } ] }, { "type": "object", "description": "A concept or entity page — defines and frames a specific idea, mechanism, or domain term. May draw from multiple sources. The building blocks of the wiki's knowledge graph.", - "allOf": [ - { "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }, - { "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }], "properties": { "type": { "const": "concept" }, + "summary": { + "$ref": "ost-tools://_ost_tools_base#/$defs/summary" + }, "sources": { "type": "array", "items": { "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink" }, @@ -131,22 +129,20 @@ "examples": [ { "type": "concept", - "title": "Affordance", "summary": "A property of an object that signals how it can be used, first described by Gibson and popularised in design by Norman", - "tags": ["design", "ux"], - "status": "active" + "tags": ["design", "ux"] } ] }, { "type": "object", "description": "A synthesis page — connects multiple concepts and sources into a coherent model, framework, or thesis. The highest-value pages in a knowledge wiki. Must reference contributing sources.", - "allOf": [ - { "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }, - { "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }], "properties": { "type": { "const": "synthesis" }, + "summary": { + "$ref": "ost-tools://_ost_tools_base#/$defs/summary" + }, "sources": { "type": "array", "items": { "$ref": "ost-tools://_ost_tools_base#/$defs/wikilink" }, @@ -163,25 +159,21 @@ "examples": [ { "type": "synthesis", - "title": "Affordances and feedback loops in interface design", "summary": "How Norman's affordance model and feedback principles combine to explain intuitive vs confusing interfaces", "sources": ["[[The Design of Everyday Things - Don Norman]]"], - "concepts": ["[[Affordance]]", "[[Feedback loop]]"], - "status": "active" + "concepts": ["[[Affordance]]", "[[Feedback loop]]"] } ] }, { "type": "object", "description": "Personal notes, journal entries, appointments, experiential records. Your own data about your journey — not sourced from external material.", - "allOf": [ - { "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }, - { "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }], "properties": { "type": { "const": "note" }, "date": { "type": "string", + "format": "date", "description": "Date this entry was created or relates to" } }, @@ -190,20 +182,15 @@ "examples": [ { "type": "note", - "title": "Reflections after reading chapter 3", "date": "2024-03-15", - "tags": ["reading-log"], - "status": "active" + "tags": ["reading-log"] } ] }, { "type": "object", "description": "The wiki index — a catalog of all pages with links, summaries, and metadata. Updated by the agent on ingest. Used as the primary navigation entry point.", - "allOf": [ - { "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }, - { "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" } - ], + "allOf": [{ "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }], "properties": { "type": { "const": "index" } }, @@ -211,9 +198,7 @@ "additionalProperties": true, "examples": [ { - "type": "index", - "title": "index", - "status": "active" + "type": "index" } ] } diff --git a/src/commands/schemas.ts b/src/commands/schemas.ts index f6aed07..0d7c9fa 100644 --- a/src/commands/schemas.ts +++ b/src/commands/schemas.ts @@ -23,6 +23,7 @@ function extractRefs(obj: unknown, refs: Set): void { interface EntityVariant { types: string[]; + description?: string; properties: string[]; required: string[]; } @@ -39,7 +40,8 @@ function extractEntities( schema: SchemaWithMetadata, ): EntityVariant[] { return oneOf.map((entry) => { - const { properties, required } = mergeVariantProperties(entry as AnySchemaObject, schema, schemaRefRegistry); + const entryObj = entry as AnySchemaObject; + const { properties, required } = mergeVariantProperties(entryObj, schema, schemaRefRegistry); const typeDef = properties.type as AnySchemaObject | undefined; let types: string[] = []; if (typeDef?.const) types = [String(typeDef.const)]; @@ -47,6 +49,7 @@ function extractEntities( return { types, + description: typeof entryObj.description === 'string' ? entryObj.description : undefined, properties: Object.keys(properties).filter((k) => k !== 'type'), required: required.filter((r) => r !== 'type'), }; @@ -92,13 +95,13 @@ function showEntities( ): void { const entities = extractEntities(oneOf, schemaRefRegistry, schema); console.log('\nEntities:'); - for (const { types, properties, required } of entities) { + for (const { types, description, properties, required } of entities) { const label = types.length > 0 ? types.join(', ') : '(unknown)'; if (properties.length === 0) { - console.log(` ${label}`); + console.log(` ${label}${description ? ` — ${description}` : ''}`); } else { const propList = properties.map((p) => (required.includes(p) ? `${p}*` : p)).join(' '); - console.log(` ${label}`); + console.log(` ${label}${description ? ` — ${description}` : ''}`); console.log(` ${propList}`); } } From 8719acee6b97d4e1aba6785717079393d9093f4a Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Mon, 6 Apr 2026 21:37:54 +1000 Subject: [PATCH 6/6] fix[biome]: ignore console.log in tests --- biome.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/biome.json b/biome.json index df4a434..076bd6a 100644 --- a/biome.json +++ b/biome.json @@ -20,6 +20,9 @@ "recommended": true, "style": { "noNonNullAssertion": "off" + }, + "suspicious": { + "noConsole": "off" } } },