feat(limps): AI agent UX hardening — next_steps, sanitization, hooks, templates, exit codes#145
feat(limps): AI agent UX hardening — next_steps, sanitization, hooks, templates, exit codes#145paulbreuler wants to merge 8 commits intomainfrom
Conversation
…ooks, templates
Implements 8 improvements derived from urdocs-old and hermes-agent patterns:
1. **next_steps guidance** — Tool responses embed ordered NextStep arrays so AI
agents know what to call next without inference. New `src/utils/tool-response.ts`
helper; applied to create_plan, get_next_task, update_task_status, list_plans,
get_plan_status. CLI JsonSuccess envelope gains optional next_steps field.
2. **Semantic exit codes** — handleJsonOutput uses exitCodeForError() to map
validation errors (NOT_FOUND, ALREADY_EXISTS, PLAN_NOT_FOUND) → exit 1,
system errors (EACCES, ENOSPC) → exit 2. Constants in errors.ts.
3. **Input sanitization** — sanitizePlanName() rejects null bytes, path traversal,
enforces 100-char limit; sanitizeDocPath() rejects bidi-override/zero-width chars.
scanForInjection() does non-blocking FTS5 content scanning with console.error warning.
Wired into create-plan, create-doc, update-doc, indexDocument.
4. **Filesystem event hooks** — src/hooks/runner.ts discovers {event}.mjs files under
~/.limps/hooks/ (configurable via hooksPath in ServerConfig). 5-second timeout,
errors never propagate. Events: server:startup/shutdown, plan:created, plan:deleted,
agent:status-changed, doc:indexed/created/updated/deleted.
5. **Richer plan templates** — create_plan accepts templateName ('default'|'spec').
New packages/limps/templates/spec.md with 12-section structure (Goal, Context,
Critical Requirements, Features, Acceptance Criteria, Non-Goals, Assumptions,
Verification Plan, Rollback Plan, Success Metrics, Decision Log, Status).
6. **Tool availability guards** — list_plans, get_next_task, list_agents check
existsSync(plansPath) and return descriptive errors. ping extended with structured
{availability: {plansPath, docsPaths, db, graphDb}} report.
7. **Idempotent plan creation** — create_plan ifExists param ('return'|'fail', default
'return'). On duplicate, returns {alreadyExists: true, ...next_steps} as success
instead of error, enabling safe retry patterns for AI agents.
8. **Progressive disclosure resource** — plans://meta/* resource backed by SQLite
queries returns lightweight {planId, name, taskCounts, source} without reading
all agent files. Falls back to filesystem scan when not yet indexed.
All 1576 tests pass, npm run validate clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… human-friendly output
The create plan command was rendering raw JSON text as green output after
the tool-response refactor. This commit:
- Adds --template <default|spec> flag so users can select plan templates
- Adds --json flag for machine-readable JSON envelope with next_steps
- Parses JSON payload from handleCreatePlan and renders Ink UI:
"Plan created: <name>" or "Plan already exists: <name>"
- Shows "Template: 12-section specification" hint for spec template
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…withTimeout, extractStatus Applies to files added in the AI agent UX hardening feature: - sanitize-input.ts: extract rejectNullBytes/rejectPathTraversal guards shared between sanitizePlanName and sanitizeDocPath (was copy-pasted) - hooks/runner.ts: extract withTimeout<T> helper eliminating the duplicate Promise.race + setTimeout pattern used for import and fn-call phases - errors.ts: extract codeToExitCode(code) collapsing two identical DocumentError/LimpsError branches in exitCodeForError into one - plans-meta.ts: extract StatusCounts type + extractStatus(content) helper shared between DB and FS query paths; also reuse type in PlanMeta interface No behaviour change. All 1576 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
planDir paths containing % or _ would be treated as SQL LIKE wildcards, causing unintended matches. Escape them with ESCAPE '\' before the query. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The script contains machine-specific vault paths and should not be committed. Added to .gitignore under a dedicated section. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens the limps MCP server/CLI for better AI-agent UX by adding structured next_steps guidance, input sanitization and injection scanning, filesystem hooks, new plan templates, and semantic exit codes—plus a new lightweight plans://meta/* resource.
Changes:
- Added
next_stepssupport across tool/CLI JSON responses and introduced shared helpers for consistent JSON payloads. - Introduced sanitization/injection scanning utilities and wired them into document/plan flows, plus a filesystem-based hooks runner for lifecycle events.
- Added plan templating improvements (
spectemplate), idempotent plan creation behavior, ping availability reporting, and a new DB-backed plan meta resource.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/limps/tests/utils/tool-response.test.ts | Adds tests for withNextSteps() and toJsonText() helpers. |
| packages/limps/tests/utils/sanitize-input.test.ts | Adds tests for plan/doc sanitization and injection scan logging. |
| packages/limps/tests/utils/errors-exit-codes.test.ts | Adds tests for exit code constants and mapping behavior. |
| packages/limps/tests/update-task-status.test.ts | Updates tests to parse JSON tool output payloads. |
| packages/limps/tests/json-output.test.ts | Updates expectations for semantic exit codes (generic error → 2). |
| packages/limps/tests/hooks/runner.test.ts | Adds tests for the new hooks runner. |
| packages/limps/tests/create-plan.test.ts | Updates tests for idempotent plan creation (ifExists). |
| packages/limps/tests/cli/create-plan.test.ts | Updates CLI integration tests to parse JSON payloads. |
| packages/limps/templates/spec.md | Adds a 12-section “spec” plan template. |
| packages/limps/src/utils/tool-response.ts | Introduces helper functions for adding next_steps and serializing JSON. |
| packages/limps/src/utils/sanitize-input.ts | Adds plan/doc sanitization and non-blocking injection scanning. |
| packages/limps/src/utils/errors.ts | Adds semantic exit code constants and exitCodeForError(). |
| packages/limps/src/types.ts | Adds the NextStep type. |
| packages/limps/src/tools/update-task-status.ts | Switches tool output to JSON text + hook emission + next-step suggestions. |
| packages/limps/src/tools/update-doc.ts | Adds doc path sanitization and emits doc:updated hooks. |
| packages/limps/src/tools/ping.ts | Extends ping response with availability diagnostics. |
| packages/limps/src/tools/list-plans.ts | Adds plansPath availability guard + next-step guidance in JSON output. |
| packages/limps/src/tools/list-agents.ts | Adds plansPath availability guard. |
| packages/limps/src/tools/get-plan-status.ts | Adds next_steps suggestions and JSON output formatting. |
| packages/limps/src/tools/get-next-task.ts | Adds plansPath guard + next_steps guidance for status updates. |
| packages/limps/src/tools/delete-doc.ts | Emits doc:deleted hooks. |
| packages/limps/src/tools/create-plan.ts | Adds templates, idempotent behavior, sanitization, hooks, and next-step guidance. |
| packages/limps/src/tools/create-doc.ts | Adds doc path sanitization and emits doc:created hooks. |
| packages/limps/src/server-shared.ts | Emits server:startup and server:shutdown hooks. |
| packages/limps/src/resources/plans-meta.ts | Adds new plans://meta/* resource (DB fast-path with FS fallback). |
| packages/limps/src/resources/index.ts | Registers the new “Plan Meta” resource URI and handler. |
| packages/limps/src/indexer.ts | Adds injection scanning and emits doc:indexed hooks on indexing. |
| packages/limps/src/hooks/types.ts | Defines hook events, payload, and function signature types. |
| packages/limps/src/hooks/runner.ts | Implements hook discovery and execution with timeouts and error isolation. |
| packages/limps/src/config.ts | Adds hooksPath config option. |
| packages/limps/src/commands/plan/create.tsx | Fixes CLI rendering by parsing JSON tool output; adds template flag + JSON mode handling. |
| packages/limps/src/cli/json-output.ts | Extends success envelope with optional next_steps and uses semantic exit codes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Resolve the filesystem path for a hook file. | ||
| * Returns `null` when no matching file exists. | ||
| */ | ||
| function resolveHookPath(hooksPath: string, event: HookEvent): string | null { | ||
| const candidates = [ | ||
| join(hooksPath, `${event}.mjs`), | ||
| join(hooksPath, `${event}.js`), | ||
| join(hooksPath, event, 'index.mjs'), | ||
| join(hooksPath, event, 'index.js'), | ||
| ]; | ||
|
|
There was a problem hiding this comment.
Hook filenames are derived directly from the event name (e.g. plan:created.mjs). On Windows, : is not a valid filename character, so this hook resolution scheme (and the documented examples) won't work on Windows installs. Consider mapping HookEvent → a filesystem-safe name (e.g., replace : with __ or URL-encode), while keeping the logical event name in the payload.
| * Resolve the filesystem path for a hook file. | |
| * Returns `null` when no matching file exists. | |
| */ | |
| function resolveHookPath(hooksPath: string, event: HookEvent): string | null { | |
| const candidates = [ | |
| join(hooksPath, `${event}.mjs`), | |
| join(hooksPath, `${event}.js`), | |
| join(hooksPath, event, 'index.mjs'), | |
| join(hooksPath, event, 'index.js'), | |
| ]; | |
| * Map a logical hook event name to a filesystem-safe key. | |
| * | |
| * Currently replaces `:` with `__` so that events like `plan:created` | |
| * map to filenames/directories that are valid on Windows. | |
| */ | |
| function toFilesystemEventKey(event: HookEvent): string { | |
| return event.replace(/:/g, '__'); | |
| } | |
| /** | |
| * Resolve the filesystem path for a hook file. | |
| * Returns `null` when no matching file exists. | |
| */ | |
| function resolveHookPath(hooksPath: string, event: HookEvent): string | null { | |
| const eventKey = toFilesystemEventKey(event); | |
| const candidates: string[] = [ | |
| // Original event-based filenames/directories (backwards compatibility) | |
| join(hooksPath, `${event}.mjs`), | |
| join(hooksPath, `${event}.js`), | |
| join(hooksPath, event, 'index.mjs'), | |
| join(hooksPath, event, 'index.js'), | |
| ]; | |
| // Filesystem-safe variants for platforms where `:` is not a valid path character (e.g. Windows) | |
| if (eventKey !== event) { | |
| candidates.push( | |
| join(hooksPath, `${eventKey}.mjs`), | |
| join(hooksPath, `${eventKey}.js`), | |
| join(hooksPath, eventKey, 'index.mjs'), | |
| join(hooksPath, eventKey, 'index.js') | |
| ); | |
| } |
| }; | ||
| } | ||
|
|
||
| const planName = planDir.split('/').pop() ?? planId; |
There was a problem hiding this comment.
planName is derived via planDir.split('/'), which breaks on Windows paths (backslashes). Use path.basename(planDir) (and import it from path) to keep this resource handler cross-platform.
| // Non-blocking hook notification | ||
| void runHooks('doc:indexed', { path: filePath, title: metadata.title }); |
There was a problem hiding this comment.
runHooks('doc:indexed', ...) is called without passing a hooksPath, so it will always use DEFAULT_HOOKS_PATH and ignore context.config.hooksPath (which other callers pass explicitly). If hooksPath is meant to be configurable globally, consider threading it into indexDocument() (e.g., an optional parameter) so indexing-triggered hooks run from the configured directory.
| // Check DB writable | ||
| let dbWritable = false; | ||
| try { | ||
| db.prepare('SELECT 1').get(); | ||
| dbWritable = true; | ||
| } catch { | ||
| dbWritable = false; | ||
| } |
There was a problem hiding this comment.
dbWritable is set based on SELECT 1, which only proves the DB connection can read/execute statements—not that it's writable. Either rename this to something like dbReachable/dbReady, or perform a side-effect-free write check (e.g., create/drop a TEMP table) so the availability report is accurate.
| try { | ||
| // Sanitize path: reject null bytes, bidi overrides, and path traversal | ||
| sanitizeDocPath(path); | ||
|
|
||
| // Validate path | ||
| const validated = validatePath(path, repoRoot); | ||
|
|
There was a problem hiding this comment.
sanitizeDocPath() returns a trimmed/sanitized path, but the return value is ignored here, so the (trimmed) value is not what validatePath() and file operations use. Consider assigning the result (e.g., const sanitizedPath = sanitizeDocPath(path)) and using that for validation and subsequent logic.
| // Escape LIKE metacharacters so a planDir containing % or _ is treated literally | ||
| const escaped = planDir.replace(/[%_]/g, '\\$&'); | ||
| const rows = db | ||
| .prepare( | ||
| `SELECT content FROM documents WHERE path LIKE ? ESCAPE '\\' AND path LIKE '%/agents/%'` | ||
| ) | ||
| .all(`${escaped}%`) as { content: string }[]; |
There was a problem hiding this comment.
queryPlanMetaFromDb() sets ESCAPE '\\' but only escapes % and _ in planDir. If planDir contains backslashes (Windows) they will be treated as escape characters in the LIKE pattern and can cause incorrect matches/zero results. Also, the secondary filter path LIKE '%/agents/%' assumes / separators. Consider normalizing stored paths to POSIX separators (recommended), or escaping backslashes here and using a separator-agnostic filter.
| /** | ||
| * Semantic exit codes for CLI commands. | ||
| * Scripts and AI-driven pipelines can distinguish error categories. | ||
| * | ||
| * 0 = success | ||
| * 1 = validation error (bad input, NOT_FOUND, ALREADY_EXISTS, PLAN_NOT_FOUND) | ||
| * 2 = system error (DB lock, ENOSPC, EACCES, INTERNAL_ERROR) | ||
| * 3 = usage error (wrong flags, missing required args) | ||
| */ | ||
| export const EXIT_SUCCESS = 0; | ||
| export const EXIT_VALIDATION = 1; | ||
| export const EXIT_SYSTEM = 2; | ||
| export const EXIT_USAGE = 3; | ||
|
|
||
| const VALIDATION_CODES: ReadonlySet<string> = new Set([ | ||
| 'NOT_FOUND', | ||
| 'ENOENT', | ||
| 'ALREADY_EXISTS', | ||
| 'EEXIST', | ||
| 'PLAN_NOT_FOUND', | ||
| 'TASK_NOT_FOUND', | ||
| 'VALIDATION_ERROR', | ||
| 'RESTRICTED_PATH', | ||
| ]); | ||
|
|
||
| const SYSTEM_CODES: ReadonlySet<string> = new Set([ | ||
| 'EACCES', | ||
| 'PERMISSION_DENIED', | ||
| 'ENOSPC', | ||
| 'INTERNAL_ERROR', | ||
| 'EISDIR', | ||
| 'ENOTDIR', | ||
| ]); | ||
|
|
||
| /** | ||
| * Map a known error code string to a semantic exit code. | ||
| * Defaults to EXIT_VALIDATION for unrecognised codes on typed errors. | ||
| */ | ||
| function codeToExitCode(code: string): number { | ||
| if (VALIDATION_CODES.has(code)) return EXIT_VALIDATION; | ||
| if (SYSTEM_CODES.has(code)) return EXIT_SYSTEM; | ||
| return EXIT_VALIDATION; | ||
| } | ||
|
|
||
| /** | ||
| * Map an error to a semantic exit code. | ||
| * | ||
| * @param err - The error to map | ||
| * @returns Exit code (0, 1, 2, or 3) | ||
| */ | ||
| export function exitCodeForError(err: unknown): number { | ||
| if (err instanceof DocumentError || err instanceof LimpsError) { | ||
| return codeToExitCode(err.code); | ||
| } | ||
|
|
||
| return EXIT_SYSTEM; | ||
| } |
There was a problem hiding this comment.
The comment/docs here claim exit codes can be 0/1/2/3 (including usage errors), but exitCodeForError() never returns EXIT_USAGE and there’s no mapping for common usage/arg-parse errors. Either implement a usage-error path (and map appropriate error types/codes to EXIT_USAGE), or update the documentation to avoid advertising an exit code that can’t be produced.
| } catch (err) { | ||
| setError((err as Error).message); | ||
| const message = (err as Error).message; | ||
| if (isJsonMode(options)) { | ||
| outputJson(wrapError(message, { code: 'CREATE_PLAN_FAILED' }), 1); | ||
| } |
There was a problem hiding this comment.
In JSON mode, the catch path hardcodes exit code 1 (outputJson(..., 1)), which will misclassify system errors (e.g. DB open/permission failures) now that semantic exit codes exist. Consider using exitCodeForError(err) to choose the exit code for thrown errors so scripts/CI can distinguish validation vs system failures.
| try { | ||
| // Sanitize path: reject null bytes, bidi overrides, and path traversal | ||
| sanitizeDocPath(path); | ||
|
|
||
| // Validate path (must not exist, must be writable) | ||
| const validated = validatePath(path, repoRoot, { | ||
| requireWritable: true, |
There was a problem hiding this comment.
sanitizeDocPath() returns a trimmed/sanitized path, but the return value is ignored. That means leading/trailing whitespace (and any future normalization done in sanitizeDocPath) won’t actually be applied to the path used by validatePath() and subsequent filesystem operations. Consider const sanitizedPath = sanitizeDocPath(path) and using sanitizedPath from that point onward.
| join(process.cwd(), 'templates', templateFileName), | ||
| join(__dirname, '..', '..', 'templates', templateFileName), | ||
| // For 'default' also try plan.md if spec.md not found | ||
| ...(templateName === 'default' | ||
| ? [ | ||
| join(process.cwd(), 'templates', 'plan.md'), | ||
| join(__dirname, '..', '..', 'templates', 'plan.md'), |
There was a problem hiding this comment.
The new spec template is resolved from .../templates/spec.md relative to the package source tree. However, packages/limps/package.json only publishes dist/ (no templates/), so in an installed package this lookup will likely fail and silently fall back to the minimal built-in template (making templateName: 'spec' ineffective unless users create templates/spec.md in their CWD). Consider bundling/copying templates/*.md into dist (and/or adding templates to the published files list) and resolving against the runtime location.
| join(process.cwd(), 'templates', templateFileName), | |
| join(__dirname, '..', '..', 'templates', templateFileName), | |
| // For 'default' also try plan.md if spec.md not found | |
| ...(templateName === 'default' | |
| ? [ | |
| join(process.cwd(), 'templates', 'plan.md'), | |
| join(__dirname, '..', '..', 'templates', 'plan.md'), | |
| // User-provided templates in the current working directory | |
| join(process.cwd(), 'templates', templateFileName), | |
| // Built-in templates shipped with the package, resolved relative to the runtime location | |
| join(__dirname, 'templates', templateFileName), | |
| // For 'default' also try plan.md if spec.md not found | |
| ...(templateName === 'default' | |
| ? [ | |
| join(process.cwd(), 'templates', 'plan.md'), | |
| join(__dirname, 'templates', 'plan.md'), |
…ds, and INVEST
## Phase 1 — CLI abbreviation & help redesign
- Add GROUP_ALIASES map (gn→gen, pl→plan, dc→docs, gr→graph, hl→health, sv→server, cf→config, pr→proposals)
- Expand argv aliases before Pastel app.run() in cli.tsx
- Redesign root help to gh-style concise listing with inline abbreviations
- Add 'gen' group to completion engine and ROOT_COMMANDS
## Phase 2 — Document model + templates + config extensions
- IDocument interface with Persona, Journey, Spec, AgentTask concrete classes
- serializeDocument/serializeFrontmatter with YAML frontmatter rendering
- resolvePrimer() — 3-tier resolution: override > plan PRIMER.md > global config
- parseDocument() factory via gray-matter
- validateInvest() — full INVEST criteria scoring (I/N/V/E/S/T, 0-100 score, warnings)
- Templates: persona.md, journey.md, agent.md, primer.md
- ServerConfig extended: workspacePath, personasPath, journeysPath, specsPath, primerPath, acpPort
## Phase 3 — Gen command group + INVEST CLI + MCP tools
- limps gen persona/journey/spec/agent/agents/primer subcommands
- limps plan check invest <plan> — INVEST compliance table for all agents
- generate_artifact, validate_invest, get_primer MCP tools registered in CORE_TOOL_NAMES
- gen agent --strict fails with exit 1 if INVEST score < 60
## Phase 4 — ACP hub
- AcpServer: HTTP server routing POST /acp/{tool} to 5 tool handlers
- AcpSessionManager: EventEmitter tracking agent sessions + lifecycle events
- Tools: claim_task, update_status, create_artifact, validate_invest, list_tasks
- acpPort config field; server wires into server-main startup
## Phase 5 — TUI mission control dashboard
- Three-pane Ink layout: Plans tree | Live ACP feed | Context + INVEST health
- useAcpEvents hook subscribing to AcpSessionManager events
- usePlanTree hook (DB integration TODO: #149)
- limps tui command launching full dashboard with ACP server
## Security fix — symlink traversal in process_doc (Critical)
- process_doc was missing checkSymlinkAncestors + checkPathSafety calls present in process_docs
- Confirmed exploitable: symlink inside plans dir → /etc/passwd readable via MCP
- Fix: symlink ancestor check moved before existsSync so path components are validated
even when the final target file does not exist
- TDD: 4 new tests in tests/tools/process-doc.test.ts (legit file, /etc symlink,
parent dir symlink, ../ traversal)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… gen commands Export JSON_OPTION from json-output.ts as single source of truth for the --json flag schema. All 6 gen commands (persona, journey, spec, agent, agents, primer) now import and use it instead of inline z.boolean() definitions, fixing blank help text on --json in gen command --help output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Implements 8 improvements to the limps MCP server derived from patterns in urdocs-old and hermes-agent reference projects. The primary goal is to improve AI agent UX: reduce inference round-trips, harden security, and add lightweight extensibility — without major architectural changes.
Changes
Next-steps guidance (reduce agent inference)
next_stepsarrays (NextStep[]) so agents know what to call nextJsonSuccess<T>envelope extended with optionalnext_stepsfield;wrapSuccess()accepts stepssrc/utils/tool-response.tswithtoJsonText()andwithNextSteps()helpersSemantic exit codes
exitCodeForError()inerrors.tsmaps validation errors → exit 1, system errors → exit 2handleJsonOutputuses it instead of hardcoded1EXIT_SUCCESS/VALIDATION/SYSTEM/USAGEexported for scripting and CI/CDInput sanitization + injection scanning
sanitizePlanName(): rejects null bytes, path traversal (..), enforces 100-char limit, normalises to slugsanitizeDocPath(): rejects null bytes, bidi-override/zero-width/BOM chars, path traversalscanForInjection(): non-blocking FTS5 content scan; logsconsole.errorwarning, never throwscreate-plan,create-doc,update-doc,indexDocumentFilesystem event hooks
src/hooks/runner.tsdiscovers{event}.mjsfiles under~/.limps/hooks/(configurable viahooksPath)server:startup/shutdown,plan:created/deleted,agent:status-changed,doc:indexed/created/updated/deletedPlan templates
create_plan/limps plan createaccepttemplateName(default|spec)packages/limps/templates/spec.md— 12-section specification template--template specflag exposed on the CLI commandTool availability guards
list_plans,get_next_task,list_agentsreturn descriptive errors whenplansPathdoesn't existpingextended with structured{ availability: { plansPath, docsPaths, db, graphDb } }reportIdempotent plan creation
create_plan/limps plan createacceptifExists: 'return' | 'fail'(default'return'){ alreadyExists: true, ...next_steps }as success — safe for agent retry patternsProgressive disclosure resource
plans://meta/*MCP resource: SQLite-backed, returns{ planId, name, taskCounts, source }without reading all agent filesCLI fix
limps plan createpreviously rendered raw JSON as green text after the tool-response refactorTests
tests/utils/sanitize-input.test.ts— 22 tests (null bytes, path traversal, Unicode, slug normalisation, injection scan)tests/utils/tool-response.test.ts— 6 tests (withNextSteps, toJsonText)tests/utils/errors-exit-codes.test.ts— 10 tests (exit code constants, exitCodeForError mapping)tests/hooks/runner.test.ts— 5 tests (missing hooks dir, missing hook file, hook execution, error isolation)Code Review
queryPlanMetaFromDbpatched before merge)Notes / Risks
scanForInjectionis intentionally non-blocking — it logs warnings but never prevents indexing. This is by design to avoid losing legitimate content.plans://meta/*resource is additive — existingplans://summary/*andplans://full/*resources are unchanged.ifExists: 'return'(new default) is a behaviour change forcreate_plan: previously, duplicate names returned an error. Existing test suite updated to reflect the new default.