loopctl-mcp-server 2.72.0 → 2.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -251,21 +251,21 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
251
251
  | `knowledge_stats` | Aggregate article counts (`total`, `by_category`, `by_status`) via cheap `COUNT(*) GROUP BY` within agent's visible set — no article metadata loaded. Agent callers see only their own and `shared` articles. Counts span all statuses. Optional: `project_id`. |
252
252
  | `knowledge_count` | Count articles matching filters **without returning rows** within agent's visible set. Agent callers see only their own and `shared` articles. Same filters as `knowledge_list` (`category`, `status`, `tags`, `match`, `source_type`, `source_id`, `idempotency_key`, `project_id`). With `tags`+`match: all` (+`status`) → "how many published articles tagged both X and Y (that I can see)". Returns `{ count }`. |
253
253
  | `knowledge_facets` | Count articles grouped by **distinct tag** within agent's visible set, no rows. Agent callers see only their own and `shared` articles. `tag_prefix` (e.g. `book-`) gives the distinct count of a tag family plus per-member totals. Returns `{ data: { tag: count }, meta: { distinct_count } }`. Optional: `category`, `status`, `tags`, `match`, `project_id`, `limit`. |
254
- | `knowledge_search` | Search the knowledge wiki by topic (keyword, semantic, or combined). Returns snippets. **Ranked, published-only, and LAGS writes by minutes while embeddings index — do NOT use for existence/idempotency/dedup checks (a fresh write false-negatives); use `knowledge_list` for that.** `q` is optional when `tags`/`category` are supplied — that **list mode** returns the complete filtered set paginated via `offset`/`limit` over `meta.total_count`. `meta.total_count` is mode-dependent — read `meta.total_count_scope` (`keyword_matches`/`ranked_corpus`/`merged_candidates`/`filtered_set`) and don't use a relevance-mode count to size the wiki (use `knowledge_list` or `knowledge_stats`). Optional: `project_id`, `story_id` for attribution. |
254
+ | `knowledge_search` | Search the knowledge wiki by topic (keyword, semantic, or combined). Returns snippets. **Ranked, published-only, and LAGS writes by minutes while embeddings index — do NOT use for existence/idempotency/dedup checks (a fresh write false-negatives); use `knowledge_list` for that.** `query` (historically `q`, still accepted) is optional when `tags`/`category` are supplied — that **list mode** returns the complete filtered set paginated via `offset`/`limit` over `meta.total_count`. `meta.total_count` is mode-dependent — read `meta.total_count_scope` (`keyword_matches`/`ranked_corpus`/`merged_candidates`/`filtered_set`) and don't use a relevance-mode count to size the wiki (use `knowledge_list` or `knowledge_stats`). On the semantic/combined paths `meta.ann_iterative_scan` (`off`/`applied`/`unavailable`, with `meta.ann_iterative_scan_reason` alongside `unavailable`) discloses whether the vector read ran with pgvector's iterative scan — `unavailable` means results may be INCOMPLETE, which `meta.fallback` cannot tell you. Optional: `project_id`, `story_id` for attribution. |
255
255
  | `knowledge_hybrid_search` | Resolve a topic to a **single best answer with provenance** (US-31.4). Runs combined keyword+semantic over the full ranked pool, then decides whether a governed **curated** source actually answers. `meta.provenance` is `curated` (trust it — the canonical article is first in `data`, `meta.curated_article_id` points at it) or `retrieved` (best fuzzy match, `curated_article_id` null); `meta.confidence` is the winner's absolute score. Prefer over `knowledge_search` when you want one trustworthy answer, not a list to triage. Degrades to keyword-only like `knowledge_search` when embeddings are unavailable. Required: `query`. Optional: `project_id`, `category`, `tags`, `match`, `limit`, `offset`. |
256
- | `knowledge_progressive_index` | Progressive disclosure — a **cheap, capped index** of what's relevant to a topic (compact stubs: `id`/`title`/`category`/`summary`, **no bodies**), curated-preferred and hub-enriched, capped at top-K (`meta.truncated` when the pool exceeded it). Survey a topic without flooding context, then open only what you need via `knowledge_progressive_drill`. Required: `topic`. Optional: `category`, `limit`. |
256
+ | `knowledge_progressive_index` | Progressive disclosure — a **cheap, capped index** of what's relevant to a topic (compact stubs: `id`/`title`/`category`/`summary`, **no bodies**), curated-preferred and hub-enriched, capped at top-K (`meta.truncated` when the pool exceeded it). Survey a topic without flooding context, then open only what you need via `knowledge_progressive_drill`. Required: `query` (historically `topic`, still accepted). Optional: `category`, `limit`. |
257
257
  | `knowledge_heat_index` | Browse the corpus with **no query at all** — capped compact stubs (`id`/`title`/`category`/`heat`/`summary`, **no bodies**) ranked by how many **distinct readers** (agents, not key rows — repeat reads by one reader count once, ties broken by the number of distinct days read, never by raw read count) opened each article inside a window. Every other retrieval tool starts from a query and so shares one failure mode: a paraphrase, or material topically central but lexically dissimilar to the question, comes back empty and reads as "the KB has nothing" rather than "I asked badly". Reach for it when a search came back empty or thin, or to survey what the fleet actually reads before you know what to ask. **Ordering is usage, not relevance**, and drilling a listed article (`knowledge_progressive_drill`) does not add heat to what it opened, at any scope — otherwise being shown would produce the rank that shows it. A `knowledge_get` of the same id does count. `meta` states `heat_window` (the default window is snapped to a UTC day boundary so the payload is stable between refreshes and safe in a cached prefix; an explicit `since` is served verbatim), `counted_access_types`, `char_budget`/`chars` (BYTES of the encoded stub array, framing included), `truncated` and `unresolved`. Both read tools open a stub, canonicals included — pick by what the read MEANS: a drill is uncounted, a `get` is a counted vote. Optional: `category`, `limit`, `since`. |
258
258
  | `knowledge_progressive_drill` | Open one stub from `knowledge_progressive_index` or `knowledge_heat_index` — returns the **full article body** for the given id, scope-enforced. Resolves both tenant-owned articles and published system canonicals (the same set those indexes surface). Every article opened this way is recorded under an access type `knowledge_heat_index` does not count, whatever its scope, so that index can never rank on the reads it caused. `knowledge_get` reaches the same ids and DOES count — use it when the read is a deliberate vote rather than a step in following a list. Required: `article_id`. |
259
- | `knowledge_list` | List articles (`id`, `title`, `category`, `status`, `tags`, `source_type`, `source_id`, `idempotency_key`, timestamps), filtered + paginated. **Body-less summary by default** (safe to page up to `limit=1000`); pass `include_body: true` to also return `body`, in which case the page is bounded by a ~5 MB byte budget — continue via `meta.next_offset` while `meta.has_more`. **Lag-free, all-status** read of the DB of record — unlike `knowledge_search` (ranked, published-only, lags writes) and `knowledge_index` (id/title/category only). The right tool to enumerate/dedup/repair and for idempotency/existence checks: filter by `tags`, `source_type`+`source_id`, or `idempotency_key` and read `meta.total_count` (exact). Single full body → `knowledge_get`; relevant bodies → `knowledge_context`; bulk dump → `knowledge_export`. Optional: `project_id`, `category`, `status`, `tags`, `source_type`, `source_id`, `idempotency_key`, `offset`, `limit`, `include_body`. |
259
+ | `knowledge_list` | List articles (`id`, `title`, `category`, `status`, `tags`, `source_type`, `source_id`, timestamps), filtered + paginated. **Body-less summary by default** (safe to page up to `limit=1000`); pass `include_body: true` to also return `body`, in which case the page is bounded by a ~5 MB byte budget — continue via `meta.next_offset` while `meta.has_more`. **Lag-free, all-status** read of the DB of record — unlike `knowledge_search` (ranked, published-only, lags writes) and `knowledge_index` (id/title/category only). The right tool to enumerate/dedup/repair and for idempotency/existence checks: filter by `tags`, `source_type`+`source_id`, or `idempotency_key` and read `meta.total_count` (exact) — `idempotency_key` is a FILTER only and is never returned in a row, so you check a key you already hold. Single full body → `knowledge_get`; relevant bodies → `knowledge_context`; bulk dump → `knowledge_export`. Optional: `project_id`, `category`, `status`, `tags`, `source_type`, `source_id`, `idempotency_key`, `offset`, `limit`, `include_body`. |
260
260
  | `knowledge_get` | Get full article content by ID. Use after search to read an article in detail. Resolves tenant-owned articles **and published system canonicals**, and records a COUNTED read (it feeds `knowledge_heat_index`) — reach for `knowledge_progressive_drill` instead when you are merely following an index this system just produced. Each link carries only its FAR side (`article: {id, title}`, plus `similarity` when scored); both arrays are ranked (open conflicts first, then descending similarity, then oldest-first for the unscored) and capped at 25 per direction, with `links_total` / `links_truncated` reporting the truth (`count` returns both, so one cheap call tells you whether the full fetch is capped). Pass `links: "count"` or `"none"` when you only want the text — on a well-linked hub the link block is several times the body. `potential_conflicts` is returned in all three modes, itself capped at 25 with `conflicts_total` / `conflicts_truncated`. Optional: `links`, `project_id`, `story_id` for attribution. |
261
261
  | `knowledge_context` | Get relevance-and-recency-ranked full articles for a task query. Best knowledge for your current context. **Agent-memory scoping**: `memory_types` (comma-separated, OR — observation/finding/summary/decision/question/task), `agents` (comma-separated agent_ids, OR), `conversation_id` (exact) filter on article `metadata` (JSONB `@>`). Optional: `project_id`, `story_id` for attribution, `limit`, `recency_weight`. |
262
262
  | `knowledge_graph` | Multi-hop traversal of the published article-link graph from `article_id` (depth 1–3, default 1), **bounded to agent's visible articles**. Agent callers see only their own and `shared` articles. Bidirectional, cycle-safe, bounded to 100 nodes / 500 edges (`truncated` flags a cap). Returns `nodes` (`id`/`title`/`category`/`depth`) + `edges` (`source_article_id`/`target_article_id`/`relationship_type`). Explore typed connections beyond `knowledge_context`'s 1-hop links. Required: `article_id`. Optional: `depth`, `project_id`. |
263
- | `knowledge_suggest_links` | Ranked typed-link **candidates** for an article by embedding similarity among **visible articles** — **read-only** (creates nothing). Excludes the article itself + any already-linked article (either direction, any type); only embedded published articles visible to the caller. Agent callers see only their own and `shared` articles. Returns `{id, title, category, similarity_score}` highest-first, to create as a **typed** link (relates_to/derived_from/contradicts/supersedes). Required: `article_id`. Optional: `threshold` (cosine floor 0–1, default 0.5), `limit` (default 5). |
263
+ | `knowledge_suggest_links` | Ranked typed-link **candidates** for an article by embedding similarity among **visible articles** — **read-only** (creates nothing). Excludes the article itself + any already-linked article (either direction, any type); only embedded published articles visible to the caller. Agent callers see only their own and `shared` articles. Returns `{id, title, category, similarity_score}` highest-first, to create as a **typed** link (relates_to/derived_from/contradicts/supersedes). `meta.ann_iterative_scan` (`off`/`applied`/`unavailable`, with `meta.ann_iterative_scan_reason` alongside `unavailable`) discloses whether the vector read ran with pgvector's iterative scan — `unavailable` means the list may be INCOMPLETE, which `meta.recall_truncated: false` does NOT cover, so do not read a short list as "no neighbours". Required: `article_id`. Optional: `threshold` (cosine floor 0–1, default 0.5), `limit` (default 5). |
264
264
  | `knowledge_distant_pairs` | Distant-but-bridgeable article pairs in the optimal-novelty embedding band (cosine distance, default 0.3–0.7) — the creative sweet spot. Sampled from **agent's visible published articles**; agent callers see only their own and `shared` articles. `bridge_path: true` requires a ≤2-hop link path. Returns `{a, b, distance}` pairs, paginated. Optional: `min_distance`, `max_distance`, `bridge_path`, `limit` (default 20, max 100), `offset`. |
265
265
  | `knowledge_novelty` | Score ideas by novelty: embeds each idea's text, returns `novelty_score` = cosine distance to the nearest **visible** prior proposal (0 = identical, higher = more novel, up to 2.0; `null` when the idea text is blank, no visible priors exist, or embedding fails — see `meta.prior_count`). Agent callers see only their own and `shared` articles as priors. Priors default to articles tagged `proposal`. Provide ideas as `texts` (strings) OR `ideas` (strings or objects), ≤50. Optional: `prior_tag`. |
266
266
  | `knowledge_random_walk` | Random walk through the link graph from `start_id` (no cycles, up to `length` nodes), traversing only **agent's visible published articles**, surfacing unexpected connections. Agent callers see only their own and `shared` articles. Returns `{id, title, category}` in walk order. Required: `start_id`. Optional: `length` (default 4, max 25). |
267
267
  | `knowledge_conflicts` | List potential-conflict article pairs — published articles flagged "too similar to comfortably coexist" by the auto-linker / nightly lint sweep, highest-overlap first. The KB only FLAGS the pair; it does NOT decide redundancy-vs-contradiction — that's your call with live context. Each entry has both articles (id/title/status/category) + similarity. Then merge (supersede one, `knowledge_create` the merged article, or PATCH) or reconcile if they genuinely disagree. Paginated with `total_count` in meta. Agent role. Optional: `limit` (default 50, max 1000, clamped), `offset`. |
268
- | `knowledge_resolve_conflict` | Record YOUR verdict on a potential-conflict pair (from `knowledge_conflicts`). Dispositions: `dismiss` (false positive, drops from queue), `supersede` (one wins — pass `authoritative_article_id`; nightly executor links + retires loser, only at `confidence:"high"`, reversible/audited), `merge` (at high confidence an LLM synthesizes both into ONE new DRAFT, sources preserved, never auto-published). Non-destructive at agent role — you record intent, the privileged nightly job executes. Last-write-wins per pair. Required: `source_article_id`, `target_article_id`, `disposition`. Optional: `authoritative_article_id`, `classification`, `evidence`, `confidence`. |
268
+ | `knowledge_resolve_conflict` | Record YOUR verdict on a potential-conflict pair (from `knowledge_conflicts`). Dispositions: `dismiss` (false positive, drops from queue), `supersede` (one wins — pass `authoritative_article_id`; nightly executor links + retires loser, only at `confidence:"high"`, reversible/audited), `merge` (at high confidence an LLM synthesizes both into ONE new DRAFT, sources preserved, never auto-published). Non-destructive at agent role — you record intent, the privileged nightly job executes. Last-write-wins per pair. **`supersede` is the one disposition that retires an article unattended**, so its `confidence` is capped server-side: an agent-role `"high"` is recorded as `"medium"` (`data.requested_confidence` + `note` say so) and the pair stays in `knowledge_conflicts` until an orchestrator+ key records it at high; a `high` supersede also REQUIRES `evidence` (422 without it). `merge` is never capped. Required: `source_article_id`, `target_article_id`, `disposition`. Optional: `authoritative_article_id`, `classification`, `evidence` (required for a high supersede), `confidence`. |
269
269
  | `knowledge_create` | Create a new knowledge article. File findings, document patterns, or record decisions. **Published immediately by default** (visible per `metadata.visibility` — default `owner` for agent authors, only visible to that agent; `shared` for visibility to all agents) — the response `note` says which outcome occurred. Pass `draft: true` to stage it for later review instead (publish afterwards with `knowledge_publish`). Pass `metadata: {visibility: "shared"}` to make the article visible to other agents; higher roles can set visibility and agent_id explicitly. Pass `idempotency_key` for idempotent capture (re-creating with the same key is a no-op returning the existing article — no partial duplicates). RESERVED TAG NAMESPACE: a tag starting with `idem-` must be `idem-<family>-<digest>` (digest = 12 or 40 lowercase hex chars, e.g. `idem-url-7ebe1ca33431`) or the write is rejected 422 — never silently rewritten; put topics outside that prefix and use `idempotency_key` for idempotent capture. Optional: `category`, `tags`, `project_id`, `draft`, `idempotency_key`, `source_type`, `source_id`, `metadata`. |
270
270
  | `knowledge_update` | Edit an EXISTING article IN PLACE, **preserving its ID** (IDs are load-bearing — cited in project CLAUDE.mds and cross-links). Fold in a new fact, tidy a hub, retag, or reclassify without churning a new row. Send only the fields to change; `tags` REPLACES the whole array. A changed body/tags re-triggers embedding + auto-linking. Agent role — KB-content curation (non-destructive + audited; the in-place edit overwrites the prior body, so it is not reversible either); visibility-scoped, so another agent's private/owner memory 404s. The reserved `idem-` tag namespace applies here too (see `knowledge_create`). Required: `article_id`. Optional: `title`, `body`, `category`, `tags`, `metadata`. |
271
271
  | `knowledge_okf_export` | **Requires `LOOPCTL_USER_KEY`.** Export the wiki as a portable OKF (Open Knowledge Format) v0.1 bundle of markdown files. Writes to `out_dir`, or returns `{files, meta}` inline. |
@@ -284,11 +284,11 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
284
284
  | Tool | Description |
285
285
  |---|---|
286
286
  | `memory_remember` | Write to your own working memory. `tier` selects the substrate: `long_term` (default; requires `text`, embedded asynchronously and later recalled by semantic similarity via `memory_recall`) or `session` (short-term; requires `session_id`, `content`, `expires_at` — pruned after expiry, not semantically recalled). Returns 201 with the stored memory. Optional: `confidence`, `tags`, `source_session_id`, `metadata` (long-term); `role` (session). |
287
- | `memory_recall` | Semantically recall your own long-term memories most similar to `query`. When embedding generation is unavailable the response degrades to a recent-first text match with `meta.fallback: true` and a stable `meta.reason` (score is `null` on that path) — check `meta.fallback` before treating a short/empty result as a genuinely empty scope. `meta.total_count`/`meta.underfilled` are also returned. Optional: `limit`, `include_superseded`. |
287
+ | `memory_recall` | Semantically recall your own long-term memories most similar to `query`. When embedding generation is unavailable the response degrades to a recent-first text match with `meta.fallback: true` and a stable `meta.reason` (score is `null` on that path) — check `meta.fallback` before treating a short/empty result as a genuinely empty scope. `meta.total_count`/`meta.underfilled` are also returned. On the semantic path `meta.ann_iterative_scan` (`off`/`applied`/`unavailable`, with `meta.ann_iterative_scan_reason` alongside `unavailable`) discloses whether the vector read ran with pgvector's iterative scan — `unavailable` means results may be INCOMPLETE, which `meta.fallback`/`meta.underfilled` cannot tell you. It is absent on the ILIKE fallback AND on an `include_superseded: true` recall (a bounded exact top-k, no index scan), so absence never means the fallback ran. Optional: `limit`, `include_superseded`. |
288
288
  | `memory_list` | List your own long-term memories, newest first, paginated with `meta.total_count/limit/offset` (the true scoped count, never silently capped by `limit`). Optional: `limit`, `offset`, `include_superseded`, `all_subjects` (superadmin only; ignored for non-superadmin keys). |
289
289
  | `memory_forget` | Delete one of your own long-term memories by id. A foreign-subject, foreign-tenant, or unknown id returns 404 (no existence leak). Required: `id`. |
290
290
  | `memory_promote` | Call at session end to compile this session's short-term (`session`-tier) memory into durable `long_term` memory — unlike `memory_remember` (a single explicit write), this compiles the whole session in one shot; fire it once at session end, not per turn. Returns 202 with `{session_id, status: "enqueued"}` — promotion runs asynchronously, so the resulting memory is recallable via `memory_recall` only after the worker drains. You can only promote your own sessions (scope resolved server-side from your key). Required: `session_id`. |
291
- | `recall_context` | ONE round-trip returning the re-ranked `global ∪ active-project` union of long-term MEMORY **and** KNOWLEDGE for `query` — what you previously assembled by calling `memory_recall` and `knowledge_search` separately. Pass `project_id` (from `resolve_project`) to merge global with that project on both sides; absent → global-only. The knowledge half is combined-search *summaries* (not full bodies — use `knowledge_context` for those). Response carries merged `results` (each tagged `source: memory\|knowledge`) plus the untouched per-source `memory`/`knowledge` envelopes; `meta.degraded?` flags a one-sided degrade (the other side is still returned — never a 500). A blank query, or one over 500 chars, is a `422` up front. Required: `query`. Optional: `project_id`, `limit`. |
291
+ | `recall_context` | ONE round-trip returning the re-ranked `global ∪ active-project` union of long-term MEMORY **and** KNOWLEDGE for `query` — what you previously assembled by calling `memory_recall` and `knowledge_search` separately. Pass `project_id` (from `resolve_project`) to merge global with that project on both sides; absent → global-only. The knowledge half is combined-search *summaries* (not full bodies — use `knowledge_context` for those). Response carries merged `results` (each tagged `source: memory\|knowledge`) plus the untouched per-source `memory`/`knowledge` envelopes; `meta.degraded?` flags a one-sided degrade (the other side is still returned — never a 500). Each per-source envelope's `meta.ann_iterative_scan` describes only THAT half's vector read, and the two are resolved independently, so they may differ. A blank query, or one over 500 chars, is a `422` up front. Required: `query`. Optional: `project_id`, `limit`. |
292
292
  | `memory_graduate` | Graduate ONE of your long-term memories into a durable Knowledge Wiki article — the explicit, on-demand version of the hourly graduation sweep. Use when a private memory has proven valuable enough to become durable knowledge. **Visibility**: the graduated article stays **owner-visible** (`metadata.visibility: "owner"`, keyed to your subject) — discoverable by YOU, NOT peer-readable (graduation does not share a memory to teammates; `re_scope: "global"` widens only the project scope, not visibility). Scope is key-derived (you can only graduate your OWN memory; a foreign/unknown `memory_id` → 404). DEDUPED by the novelty gate: `data.verdict` is `created` (novel → published) or `gated_to_draft` (near-dup → review draft) with a new article (**201**), or `duplicate`/`deduplicated` (already represented → canonical article, nothing created) (**200**). By default the article inherits the memory's project scope; pass `re_scope: "global"` to promote a PROJECT memory to a tenant-wide article — only valid on its FIRST graduation, and only if the hourly sweep hasn't graduated it project-scoped first (`409` `already_graduated` otherwise). An already-graduated global memory re-graduates idempotently (**200**). `503` `gate_unavailable` if the embedding backend is down — retry later. Required: `memory_id`. Optional: `re_scope` (`inherit`\|`global`). |
293
293
 
294
294
  ### Knowledge Management Tools (orchestrator key)
@@ -379,14 +379,14 @@ Key distribution for the dispatch pattern (Epic 26): per-dispatch ephemeral keys
379
379
  | Tool | Description |
380
380
  |---|---|
381
381
  | `signup` | **US-26.7.1.** Create a NEW **agent-rooted (KB-tier)** tenant and mint its one-time root API key — entirely through this call, no human operator, no hardware authenticator, no existing API key required. The tenant gets the FULL knowledge-wiki surface but **cannot** perform work-breakdown / chain-of-custody operations (those require a separate human-anchored tenant via the WebAuthn ceremony at `https://loopctl.com/signup`). Rate-limited per client IP (<= 5/hour). The `raw_key` is shown ONCE — save it immediately (e.g. as `LOOPCTL_USER_KEY`). Required: `name`, `slug`, `email`. |
382
- | `dispatch` | Mint an ephemeral, scoped api_key for a sub-agent dispatch, carrying its lineage path. The `raw_key` is returned ONCE — pass it to the sub-agent's launch args, never store it in env vars; it expires after `expires_in_seconds` (default 3600, max 14400). Required: `role` (`agent`/`orchestrator`), `agent_id`. Optional: `parent_dispatch_id`, `story_id`. **LCP-1 §9.2 signed profile:** optionally enroll an agent key via `agent_pubkey` (hex) + `alg` + an `attestation` (from `custody_sign_attestation`) + `attestation_conditions`. |
382
+ | `dispatch` | Mint an ephemeral, scoped api_key for a sub-agent dispatch, carrying its lineage path. The `raw_key` is returned ONCE — pass it to the sub-agent's launch args, never store it in env vars; it expires after `expires_in_seconds` (default 3600, max 14400). Required: `role` (`agent`/`orchestrator`), `agent_id`, and `parent_dispatch_id` for every caller a dispatch minted — a dispatch may only be minted INSIDE the caller's own lineage. Omitting `parent_dispatch_id` requests a ROOT dispatch (a new independent lineage tree), which only the tenant's `user`-role operator key may create; anyone else gets `403 root_dispatch_forbidden` with their own dispatch id in `remediation.your_dispatch_id`. A parent outside the caller's lineage is `403 parent_outside_caller_lineage`. Optional: `story_id`. **LCP-1 §9.2 signed profile:** optionally enroll an agent key via `agent_pubkey` (hex) + `alg` + an `attestation` (from `custody_sign_attestation`) + `attestation_conditions`. |
383
383
  | `register_custody_owner_key` | **LCP-1 §9.2.** Register/rotate the tenant custody OWNER key — the root of trust the attestation chain hangs from. Private half stays with you; requires `LOOPCTL_USER_KEY` and a human-anchored tenant. Required: `owner_pubkey` (hex). ROTATION additionally requires `rotation_proof` (from `custody_sign_owner_rotation`); first registration needs none. |
384
384
  | `list_enrolled_agent_keys` | **LCP-1 §9.1.1 transparency.** List the agent public keys enrolled under your tenant, reconstructed from the tamper-evident audit chain (not a mutable listing). Compare against the keys you generated; any excess is operator-minted. Keyset-paged (`limit`, `cursor`). |
385
385
  | `custody_generate_keypair` | **LCP-1 §9.** Generate an Ed25519 keypair LOCALLY (private key never leaves the process). Returns `public_key_hex` + `private_key_hex`. |
386
386
  | `custody_sign_attestation` | **LCP-1 §9.2.** Sign an attestation over an agent key to enroll it — with the OWNER private key (root, `lineage_path: []`) or the PARENT agent private key (child, `lineage_path` = parent's). Returns the hex `attestation` for `dispatch`. |
387
387
  | `custody_sign_claim` | **LCP-1 §9.3.** Sign a custody claim with your enrolled agent private key. Returns a `claim` object to pass as the `claim` param to `report_story`/`review_complete`/`verify_story` when the deployment runs the signed profile. |
388
388
  | `custody_sign_owner_rotation` | **LCP-1 §9.2.** Sign an owner-key ROTATION proof with the OUTGOING owner private key, proving possession before it re-roots the attestation chain. Binds the old key + its set-at (Unix microseconds) so a captured proof is not replayable after a rotate-back. Returns `rotation_proof` for `register_custody_owner_key`. |
389
- | `recover_cap` | Re-mint a capability token for a story you're assigned to, after a session crash lost your cap. Required: `story_id`. Optional: `cap_type` (`start_cap`/`report_cap`, default `start_cap`), `lineage`. |
389
+ | `recover_cap` | Re-mint the `start_cap` for a story you're assigned to, after a session crash lost your cap. Required: `story_id`. No other parameters: recovery only ever mints a `start_cap` (any other `cap_type` is a 422 AND is recorded as a forgery attempt), and the lineage is always derived server-side. |
390
390
  | `get_sth` | Get the latest Signed Tree Head for a tenant's tamper-evident audit chain. Public — no auth required. Required: `tenant_id`. |
391
391
  | `request_authenticator_challenge` | **US-26.7.2.** Step 1 of the opt-in WebAuthn trust-tier upgrade ceremony: issues a registration challenge for enrolling a hardware authenticator against an EXISTING agent-rooted (KB-tier) tenant, promoting it to `human_anchored` on success. Requires an interactive WebAuthn client. |
392
392
  | `enroll_authenticator` | Step 2 of the WebAuthn trust-tier upgrade ceremony: completes enrollment with the attestation produced by `navigator.credentials.create()` against the challenge from `request_authenticator_challenge`. On a tenant's first enrollment the tenant is promoted to `human_anchored`. |
package/index.js CHANGED
@@ -15,6 +15,9 @@ import os from "node:os";
15
15
  import crypto from "node:crypto";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import path, { dirname, join } from "node:path";
18
+ import { applyArgAliases } from "./lib/arg-aliases.js";
19
+ import { clientContextHeader } from "./lib/client-context.js";
20
+ import { degradedSearchNotice } from "./lib/search-notices.js";
18
21
  import {
19
22
  projectsPath,
20
23
  ingestionJobsPath,
@@ -115,6 +118,19 @@ function resolveKey(keyOverride) {
115
118
  );
116
119
  }
117
120
 
121
+ let clientContextHeaderCache;
122
+ function cachedClientContextHeader() {
123
+ if (clientContextHeaderCache === undefined) {
124
+ try {
125
+ clientContextHeaderCache = clientContextHeader({ version: SERVER_VERSION }) || null;
126
+ } catch {
127
+ // Analytics must never break a tool call.
128
+ clientContextHeaderCache = null;
129
+ }
130
+ }
131
+ return clientContextHeaderCache;
132
+ }
133
+
118
134
  async function apiCall(method, path, body, keyOverride, { exactKey = false, timeoutMs } = {}) {
119
135
  const url = `${getBaseUrl()}${path}`;
120
136
  // Secret-managing tools pass exactKey:true so the request uses the EXACT
@@ -139,6 +155,14 @@ async function apiCall(method, path, body, keyOverride, { exactKey = false, time
139
155
  Accept: "application/json",
140
156
  };
141
157
 
158
+ // #658: client-asserted context (session id, main-vs-child, repo, effort). None of it is
159
+ // derivable server-side — an api key names a KEY, and under the v2 dispatch pattern a key
160
+ // is minted per dispatch. UNTRUSTED and analytics-only; the server stores it under a
161
+ // `client_` prefix and never authorizes on it. Computed once: the environment does not
162
+ // change mid-process, and a per-call `os.hostname()` + git shell-out is not hot-path work.
163
+ const clientCtx = cachedClientContextHeader();
164
+ if (clientCtx) headers["x-loopctl-client-context"] = clientCtx;
165
+
142
166
  const serializedBody =
143
167
  body !== undefined && body !== null ? JSON.stringify(body) : undefined;
144
168
 
@@ -355,7 +379,10 @@ function llmRemediationNotice(result) {
355
379
  */
356
380
  function withRemediationNotice(result) {
357
381
  const base = toContent(result);
358
- const notice = llmRemediationNotice(result);
382
+ // Two notice sources, in priority order: the BYO-key remediation (more specific), then
383
+ // the degraded-search notice (#658). At most one is prepended — stacking two leading
384
+ // blocks buries both. `degradedSearchNotice` already declines the no_embedding_key case.
385
+ const notice = llmRemediationNotice(result) || degradedSearchNotice(result);
359
386
  if (!notice) return base;
360
387
  return { ...base, content: [{ type: "text", text: notice }, ...base.content] };
361
388
  }
@@ -952,6 +979,103 @@ async function contractStory({ story_id, story_title, ac_count }) {
952
979
  return toContent(result);
953
980
  }
954
981
 
982
+ // --- L1 capability plumbing (issue #621) ---
983
+ //
984
+ // A tenant with an audit signing key MUST present a capability token on the
985
+ // custody transitions that consume one, or the call fails 403 missing_capability.
986
+ // `start` is the only such transition (#621): claim returns the start_cap it mints.
987
+ // Callers should not have to carry it by hand — so we cache what the server issues
988
+ // and attach it automatically, while still honouring an explicit `capability`.
989
+ //
990
+ // The cache is per-process and therefore does NOT survive a session crash. That is
991
+ // what the recovery paths below are for; never treat a cache miss as fatal.
992
+ // Bounded so a long-lived server process cannot grow these without limit: this
993
+ // module lives for the life of the MCP connection and sees every story the agent
994
+ // touches. Insertion order is Map/Set iteration order in JS, so evicting the
995
+ // first key drops the oldest entry.
996
+ const CAP_CACHE_MAX = 256;
997
+ const SPENT_CAPS_MAX = 1024;
998
+
999
+ function boundedSet(store, max) {
1000
+ while (store.size > max) {
1001
+ const oldest = store.keys().next();
1002
+ if (oldest.done) break;
1003
+ store.delete(oldest.value);
1004
+ }
1005
+ }
1006
+
1007
+ const capCache = new Map();
1008
+
1009
+ const capKey = (storyId, typ) => `${storyId}:${typ}`;
1010
+
1011
+ function rememberCap(storyId, cap) {
1012
+ if (cap && cap.cap_id && cap.typ) {
1013
+ // Store the expiry alongside the id. A capability has a bounded TTL, and an
1014
+ // agent can sit between claim and start for longer than that (a review pause,
1015
+ // a crash-and-resume) — handing back a dead token afterwards costs a
1016
+ // round-trip and surfaces a confusing refusal instead of the recovery path.
1017
+ capCache.set(capKey(storyId, cap.typ), {
1018
+ capId: cap.cap_id,
1019
+ expiresAt: cap.expires_at ? Date.parse(cap.expires_at) : null,
1020
+ });
1021
+ boundedSet(capCache, CAP_CACHE_MAX);
1022
+ }
1023
+ }
1024
+
1025
+ function takeCap(storyId, typ) {
1026
+ const key = capKey(storyId, typ);
1027
+ const entry = capCache.get(key);
1028
+ // Capabilities are single-use: once handed to a call, drop it so a retry does
1029
+ // not present the same live token twice.
1030
+ capCache.delete(key);
1031
+ if (!entry) return undefined;
1032
+ // Treat an unparseable expiry as usable — the server is the authority on
1033
+ // expiry, and refusing here on a date we failed to parse would strand a token
1034
+ // that is actually fine.
1035
+ if (entry.expiresAt && Date.now() >= entry.expiresAt) return undefined;
1036
+ return entry.capId;
1037
+ }
1038
+
1039
+ // Cap ids this process has already handed to a call. Delivery is stateless
1040
+ // server-side and does NOT consume, so without this a retry (or a second tool
1041
+ // call) would present the same live token twice — and a double consume is
1042
+ // `:replay`, which IS byzantine and halts the whole tenant. takeCap gets the
1043
+ // same property for free by deleting on read.
1044
+ const spentCaps = new Set();
1045
+
1046
+ // Fetches a live capability of `typ` already issued to this caller's lineage.
1047
+ // Returns undefined when there is none — callers must degrade gracefully.
1048
+ async function fetchCap(storyId, typ, key) {
1049
+ try {
1050
+ const result = await apiCall("GET", `/api/v1/stories/${storyId}/capabilities`, null, key);
1051
+ const caps = (result && result.data) || [];
1052
+ const match = caps.find((c) => c.typ === typ && !spentCaps.has(c.cap_id));
1053
+ if (!match) return undefined;
1054
+ spentCaps.add(match.cap_id);
1055
+ boundedSet(spentCaps, SPENT_CAPS_MAX);
1056
+ return match.cap_id;
1057
+ } catch {
1058
+ return undefined;
1059
+ }
1060
+ }
1061
+
1062
+ // Re-mints a start_cap for a story this agent owns (session-crash recovery).
1063
+ // Only start_cap is recoverable by design — an agent must never be able to mint a
1064
+ // capability to verify or report its own work.
1065
+ async function recoverStartCap(storyId) {
1066
+ try {
1067
+ const result = await apiCall(
1068
+ "POST",
1069
+ `/api/v1/stories/${storyId}/recover-cap`,
1070
+ null,
1071
+ process.env.LOOPCTL_AGENT_KEY
1072
+ );
1073
+ return result && result.data && result.data.cap_id;
1074
+ } catch {
1075
+ return undefined;
1076
+ }
1077
+ }
1078
+
955
1079
  async function claimStory({ story_id }) {
956
1080
  const result = await apiCall(
957
1081
  "POST",
@@ -959,14 +1083,25 @@ async function claimStory({ story_id }) {
959
1083
  null,
960
1084
  process.env.LOOPCTL_AGENT_KEY
961
1085
  );
1086
+ // The claim response carries the start_cap that POST /start will require.
1087
+ rememberCap(story_id, result && result.capability);
962
1088
  return toContent(result);
963
1089
  }
964
1090
 
965
- async function startStory({ story_id }) {
1091
+ async function startStory({ story_id, capability }) {
1092
+ // Cache first, then DELIVERY of the token claim already minted, then recovery
1093
+ // (which mints a fresh one). Delivery also covers a legacy env-var key, whose
1094
+ // lineage is [] and which recover-cap cannot serve at all.
1095
+ const cap =
1096
+ capability ||
1097
+ takeCap(story_id, "start_cap") ||
1098
+ (await fetchCap(story_id, "start_cap", process.env.LOOPCTL_AGENT_KEY)) ||
1099
+ (await recoverStartCap(story_id));
1100
+
966
1101
  const result = await apiCall(
967
1102
  "POST",
968
1103
  `/api/v1/stories/${story_id}/start`,
969
- null,
1104
+ cap ? { capability: cap } : null,
970
1105
  process.env.LOOPCTL_AGENT_KEY
971
1106
  );
972
1107
  return toContent(result);
@@ -1024,6 +1159,9 @@ async function reviewComplete({ story_id, review_type, findings_count, fixes_cou
1024
1159
 
1025
1160
  // --- Verification Tools (orch key) ---
1026
1161
 
1162
+ // Verify consumes NO capability (#621): no token can be bound to the principal
1163
+ // this endpoint permits to spend it. The gate is structural — loopctl selects the
1164
+ // verifier lineage and compares it against the implementer's server-side.
1027
1165
  async function verifyStory({ story_id, summary, review_type, claim }) {
1028
1166
  const body = {};
1029
1167
  if (summary) body.summary = summary;
@@ -1359,9 +1497,16 @@ async function knowledgeFacets({
1359
1497
  return toContent(result);
1360
1498
  }
1361
1499
 
1362
- async function knowledgeSearch({ q, project_id, story_id, category, tags, match, mode, limit, offset }) {
1500
+ async function knowledgeSearch({ q, project_id, story_id, category, tags, match, mode, format, limit, offset }) {
1363
1501
  const params = new URLSearchParams();
1364
1502
  if (q != null && q !== "") params.set("q", q);
1503
+ // `format` is the SHAPE of the response, not a different search (#678). The server
1504
+ // dispatches `stubs` to the same progressive_index/3 and `bodies` to the same
1505
+ // get_context/3 that knowledge_progressive_index and knowledge_context call, so those
1506
+ // tools remain and are not retired — they are now siblings on one path rather than
1507
+ // separate doors an agent has to choose between. That choice was unobservable and
1508
+ // therefore confounded any measurement of the ranking behind it.
1509
+ if (format) params.set("format", format);
1365
1510
  if (project_id) params.set("project_id", project_id);
1366
1511
  if (story_id) params.set("story_id", story_id);
1367
1512
  if (category) params.set("category", category);
@@ -1399,9 +1544,13 @@ async function knowledgeHybridSearch({ query, project_id, category, tags, match,
1399
1544
  return withRemediationNotice(result);
1400
1545
  }
1401
1546
 
1402
- async function knowledgeProgressiveIndex({ topic, category, limit }) {
1547
+ async function knowledgeProgressiveIndex({ topic, query, category, limit }) {
1403
1548
  const params = new URLSearchParams();
1404
- if (topic != null) params.set("topic", topic);
1549
+ // The endpoint's parameter is `topic`; `query` is the canonical spelling this surface
1550
+ // converged on (#652 item 6). An explicit `topic` still wins, so a caller passing both
1551
+ // gets what it asked for.
1552
+ const resolved = topic != null && topic !== "" ? topic : query;
1553
+ if (resolved != null) params.set("topic", resolved);
1405
1554
  if (category) params.set("category", category);
1406
1555
  if (limit != null) params.set("limit", String(limit));
1407
1556
 
@@ -1429,10 +1578,19 @@ async function knowledgeHeatIndex({ category, limit, since }) {
1429
1578
  return toContent(result);
1430
1579
  }
1431
1580
 
1432
- async function knowledgeProgressiveDrill({ article_id }) {
1581
+ async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offset }) {
1582
+ const params = new URLSearchParams();
1583
+ // 0 is meaningful on both (whole body / start at the beginning), so test for
1584
+ // null/undefined rather than truthiness.
1585
+ if (body_max_bytes !== undefined && body_max_bytes !== null)
1586
+ params.set("body_max_bytes", String(body_max_bytes));
1587
+ if (body_offset !== undefined && body_offset !== null)
1588
+ params.set("body_offset", String(body_offset));
1589
+ const qs = params.toString();
1590
+ const base = `/api/v1/knowledge/progressive/${article_id}`;
1433
1591
  const result = await apiCall(
1434
1592
  "GET",
1435
- `/api/v1/knowledge/progressive/${article_id}`,
1593
+ qs ? `${base}?${qs}` : base,
1436
1594
  null,
1437
1595
  process.env.LOOPCTL_AGENT_KEY,
1438
1596
  );
@@ -1476,11 +1634,24 @@ async function knowledgeList({
1476
1634
  return toContent(result);
1477
1635
  }
1478
1636
 
1479
- async function knowledgeGet({ article_id, project_id, story_id, links }) {
1637
+ async function knowledgeGet({
1638
+ article_id,
1639
+ project_id,
1640
+ story_id,
1641
+ links,
1642
+ body_max_bytes,
1643
+ body_offset,
1644
+ }) {
1480
1645
  const params = new URLSearchParams();
1481
1646
  if (project_id) params.set("project_id", project_id);
1482
1647
  if (story_id) params.set("story_id", story_id);
1483
1648
  if (links) params.set("links", links);
1649
+ // 0 is meaningful on both (whole body / start at the beginning), so test for
1650
+ // null/undefined rather than truthiness.
1651
+ if (body_max_bytes !== undefined && body_max_bytes !== null)
1652
+ params.set("body_max_bytes", String(body_max_bytes));
1653
+ if (body_offset !== undefined && body_offset !== null)
1654
+ params.set("body_offset", String(body_offset));
1484
1655
  const qs = params.toString();
1485
1656
  const path = qs ? `/api/v1/articles/${article_id}?${qs}` : `/api/v1/articles/${article_id}`;
1486
1657
  const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
@@ -2772,9 +2943,12 @@ async function getSystemArticles({ slug, category } = {}) {
2772
2943
  return toContent(result);
2773
2944
  }
2774
2945
 
2775
- // US-26: Cap recovery after session crash
2776
- async function recoverCap({ story_id, cap_type, lineage }) {
2777
- const body = { cap_type: cap_type || "start_cap", lineage: lineage || [] };
2946
+ // US-26: Cap recovery after session crash. start_cap is the ONLY recoverable type
2947
+ // (#621) the server answers any other cap_type with 422 AND records a
2948
+ // cap_recovery_forgery_attempt against the caller, so never forward one. `lineage`
2949
+ // is resolved server-side from the authenticating key and was always ignored.
2950
+ async function recoverCap({ story_id }) {
2951
+ const body = { cap_type: "start_cap" };
2778
2952
  const result = await apiCall("POST", `/api/v1/stories/${story_id}/recover-cap`, body);
2779
2953
  return toContent(result);
2780
2954
  }
@@ -3563,7 +3737,10 @@ const TOOLS = [
3563
3737
  {
3564
3738
  name: "start_story",
3565
3739
  description:
3566
- "Agent starts work on a claimed story. Transitions assigned -> implementing. Uses the AGENT key.",
3740
+ "Agent starts work on a claimed story. Transitions assigned -> implementing. Uses the AGENT key. " +
3741
+ "The L1 capability (start_cap) is handled for you: it is taken from the claim_story response, " +
3742
+ "and re-minted via recover-cap if this process lost it (e.g. after a session crash). " +
3743
+ "Pass `capability` only to override that.",
3567
3744
  inputSchema: {
3568
3745
  type: "object",
3569
3746
  properties: {
@@ -3571,6 +3748,13 @@ const TOOLS = [
3571
3748
  type: "string",
3572
3749
  description: "The UUID of the story.",
3573
3750
  },
3751
+ capability: {
3752
+ type: "string",
3753
+ description:
3754
+ "Optional start_cap cap_id. Normally omitted — supplied automatically from the " +
3755
+ "claim response or recovered. A tenant with an audit signing key cannot start " +
3756
+ "without one (403 missing_capability).",
3757
+ },
3574
3758
  },
3575
3759
  required: ["story_id"],
3576
3760
  },
@@ -4023,7 +4207,7 @@ const TOOLS = [
4023
4207
  name: "knowledge_list",
4024
4208
  description:
4025
4209
  "List articles (id, title, category, status, tags, source_type, source_id, " +
4026
- "idempotency_key, timestamps), filtered and paginated. **Body-less summary by default** " +
4210
+ "timestamps), filtered and paginated. **Body-less summary by default** " +
4027
4211
  "— the right tool to enumerate, dedup, or repair at scale (safe to page up to limit=1000). " +
4028
4212
  "Pass `include_body: true` to also return the full `body`, in which case the server bounds " +
4029
4213
  "the page by a ~5 MB serialized-body budget and returns meta.next_offset/has_more/" +
@@ -4034,7 +4218,9 @@ const TOOLS = [
4034
4218
  "of record (draft, published, archived, superseded visible). Use for idempotency/existence " +
4035
4219
  "checks: filter by `tags`, `source_type`+`source_id`, or `idempotency_key` and read " +
4036
4220
  "`meta.total_count` (exact) to answer \"does an article for X already exist?\" reliably " +
4037
- "right after a write. Paginate via offset/limit.",
4221
+ "right after a write `idempotency_key` is a FILTER only and is never returned in a " +
4222
+ "row, so you check a key you already hold rather than reading back the keys other " +
4223
+ "callers chose. Paginate via offset/limit.",
4038
4224
  inputSchema: {
4039
4225
  type: "object",
4040
4226
  properties: {
@@ -4072,7 +4258,8 @@ const TOOLS = [
4072
4258
  type: "string",
4073
4259
  description:
4074
4260
  "Optional: filter by exact idempotency_key — the lag-free existence check for a " +
4075
- "prior capture.",
4261
+ "prior capture. Filter only: it is not returned in the rows, so read " +
4262
+ "`meta.total_count`.",
4076
4263
  },
4077
4264
  offset: {
4078
4265
  type: "integer",
@@ -4258,7 +4445,11 @@ const TOOLS = [
4258
4445
  "Suggest ranked typed-link CANDIDATES for an article by embedding similarity — " +
4259
4446
  "READ-ONLY, creates nothing. Excludes the article itself and any already-linked " +
4260
4447
  "article (either direction, any relationship type); only embedded published articles. " +
4261
- "Returns { data: [{id, title, category, similarity_score}] } highest-similarity first. " +
4448
+ "Returns { data: [{id, title, category, similarity_score}], meta } highest-similarity " +
4449
+ "first. Read `meta.ann_iterative_scan` before concluding an article has no neighbours: " +
4450
+ "`unavailable` (with `meta.ann_iterative_scan_reason`) means the vector read ran without " +
4451
+ "pgvector's iterative scan and the list may be INCOMPLETE — `meta.recall_truncated: false` " +
4452
+ "does NOT cover that case. " +
4262
4453
  "Review them and create the one you want as a TYPED link (relates_to/derived_from/" +
4263
4454
  "contradicts/supersedes) — unlike the auto-linker which only makes ambient relates_to. " +
4264
4455
  "Optional: threshold (cosine floor 0–1, default 0.5), limit (default 5).",
@@ -4385,14 +4576,24 @@ const TOOLS = [
4385
4576
  "— a stable tag naming WHY (e.g. no_embedding_key, embedding_circuit_open, " +
4386
4577
  "embedding_provider_error_<status>, embedding_timeout). When the reason is a MISSING " +
4387
4578
  "embedding key (no_embedding_key), the result leads with an ACTION REQUIRED notice + " +
4388
- "meta.remediation telling you to provision it with set_llm_config (BYO — do it once).",
4579
+ "meta.remediation telling you to provision it with set_llm_config (BYO — do it once). " +
4580
+ "On the semantic/combined paths meta.ann_iterative_scan (`off`/`applied`/`unavailable`, " +
4581
+ "with meta.ann_iterative_scan_reason alongside `unavailable`) discloses whether the vector " +
4582
+ "read ran with pgvector's iterative scan — `unavailable` means results may be INCOMPLETE, " +
4583
+ "which meta.fallback and the total_count fields cannot tell you.",
4389
4584
  inputSchema: {
4390
4585
  type: "object",
4391
4586
  properties: {
4392
- q: {
4587
+ query: {
4393
4588
  type: "string",
4394
4589
  description:
4395
- "Search query string. Optional when tags/category are supplied (enumeration mode).",
4590
+ "Search query string. Optional when tags/category are supplied (enumeration mode). " +
4591
+ "`query` is the canonical spelling across every search-shaped tool here; `q` is " +
4592
+ "the historical name and is still accepted.",
4593
+ },
4594
+ q: {
4595
+ type: "string",
4596
+ description: "Deprecated alias for `query`. Accepted; prefer `query`.",
4396
4597
  },
4397
4598
  project_id: {
4398
4599
  type: "string",
@@ -4422,6 +4623,21 @@ const TOOLS = [
4422
4623
  enum: ["keyword", "semantic", "combined"],
4423
4624
  description: "Optional: search mode (keyword, semantic, or combined).",
4424
4625
  },
4626
+ format: {
4627
+ type: "string",
4628
+ enum: ["results", "stubs", "bodies"],
4629
+ description:
4630
+ "Optional: the SHAPE of the response, not a different search. 'results' " +
4631
+ "(default) is ranked results plus snippets and is the only shape that " +
4632
+ "supports cursor pagination. 'stubs' returns capped stubs with one hop of hub " +
4633
+ "enrichment — use it to survey a broad topic without pulling bodies into " +
4634
+ "context, then knowledge_progressive_drill into a chosen stub. 'bodies' " +
4635
+ "returns full article bodies plus linked references for one deep read. " +
4636
+ "'stubs' and 'bodies' REQUIRE a query; sending either without one is a 400, " +
4637
+ "as is an unknown value (it is never silently downgraded to 'results'). These " +
4638
+ "dispatch to exactly the same code knowledge_progressive_index and " +
4639
+ "knowledge_context call, which both remain available.",
4640
+ },
4425
4641
  limit: {
4426
4642
  type: "integer",
4427
4643
  description: "Optional: maximum number of results to return.",
@@ -4500,9 +4716,15 @@ const TOOLS = [
4500
4716
  inputSchema: {
4501
4717
  type: "object",
4502
4718
  properties: {
4719
+ query: {
4720
+ type: "string",
4721
+ description:
4722
+ "The topic to index (max 500 characters). Required unless `topic` is given. " +
4723
+ "`query` is the canonical spelling across every search-shaped tool here.",
4724
+ },
4503
4725
  topic: {
4504
4726
  type: "string",
4505
- description: "The topic to index (max 500 characters). Required.",
4727
+ description: "Historical name for `query`. Accepted; prefer `query`.",
4506
4728
  },
4507
4729
  category: {
4508
4730
  type: "string",
@@ -4513,7 +4735,7 @@ const TOOLS = [
4513
4735
  description: "Optional: top-K override (clamped to the configured cap).",
4514
4736
  },
4515
4737
  },
4516
- required: ["topic"],
4738
+ required: [],
4517
4739
  },
4518
4740
  },
4519
4741
  {
@@ -4572,7 +4794,11 @@ const TOOLS = [
4572
4794
  "reach — both resolve the same ids now. A drill records an UNCOUNTED read, so " +
4573
4795
  "following an index never raises the heat of what that index just showed you; " +
4574
4796
  "knowledge_get records a counted one, which is a vote that the article was worth " +
4575
- "opening on its own. Following a list is not a vote.",
4797
+ "opening on its own. Following a list is not a vote.\n\n" +
4798
+ "BODY: served in a byte WINDOW (default 32000 bytes) exactly like knowledge_get, so " +
4799
+ "an oversized article comes back in parts instead of being rejected whole by a client " +
4800
+ "token cap. Read body_truncated / next_body_offset to continue, or pass " +
4801
+ "body_max_bytes: 0 for the whole body.",
4576
4802
  inputSchema: {
4577
4803
  type: "object",
4578
4804
  properties: {
@@ -4581,6 +4807,19 @@ const TOOLS = [
4581
4807
  format: "uuid",
4582
4808
  description: "The UUID of the article to open (from a progressive index stub).",
4583
4809
  },
4810
+ body_max_bytes: {
4811
+ type: "integer",
4812
+ minimum: 0,
4813
+ description:
4814
+ "Optional: serialized-body byte budget (default 32000). 0 returns the whole body.",
4815
+ },
4816
+ body_offset: {
4817
+ type: "integer",
4818
+ minimum: 0,
4819
+ description:
4820
+ "Optional: byte offset to start the body window at (default 0). Pass the " +
4821
+ "previous response's next_body_offset to read the next part.",
4822
+ },
4584
4823
  },
4585
4824
  required: ["article_id"],
4586
4825
  },
@@ -4610,14 +4849,22 @@ const TOOLS = [
4610
4849
  "`potential_conflicts` is returned in all three modes, so opting out of the link " +
4611
4850
  "list never hides a conflict from you; it is capped at 25 (strongest first) with " +
4612
4851
  "`conflicts_total` / `conflicts_truncated`. To actually traverse the graph, use " +
4613
- "knowledge_graph rather than raising this cap.",
4852
+ "knowledge_graph rather than raising this cap.\n\n" +
4853
+ "BODY: the body is served in a byte WINDOW (default 32000 bytes) so an oversized " +
4854
+ "article is returned in parts instead of being rejected whole by a client token " +
4855
+ "cap - four measured reads of 61-82KB were discarded that way after the search had " +
4856
+ "already found them. Every response carries body_bytes (the full size), body_offset, " +
4857
+ "body_returned_bytes, body_truncated and next_body_offset; pass next_body_offset back " +
4858
+ "as body_offset to continue, or body_max_bytes: 0 for the whole body in one read.",
4614
4859
  inputSchema: {
4615
4860
  type: "object",
4616
4861
  properties: {
4617
4862
  article_id: {
4618
4863
  type: "string",
4619
- format: "uuid",
4620
- description: "The UUID of the article.",
4864
+ description:
4865
+ "The UUID of the article. A unique ID PREFIX (>= 8 hex characters) also " +
4866
+ "resolves, so copy what you have rather than reconstructing 36 characters " +
4867
+ "from memory; an ambiguous prefix is a 404, never a guess.",
4621
4868
  },
4622
4869
  links: {
4623
4870
  type: "string",
@@ -4628,6 +4875,20 @@ const TOOLS = [
4628
4875
  "link fields. potential_conflicts (capped, with conflicts_total) is always " +
4629
4876
  "returned.",
4630
4877
  },
4878
+ body_max_bytes: {
4879
+ type: "integer",
4880
+ minimum: 0,
4881
+ description:
4882
+ "Optional: serialized-body byte budget (default 32000). 0 returns the whole " +
4883
+ "body. Read body_truncated / next_body_offset to continue.",
4884
+ },
4885
+ body_offset: {
4886
+ type: "integer",
4887
+ minimum: 0,
4888
+ description:
4889
+ "Optional: byte offset to start the body window at (default 0). Pass the " +
4890
+ "previous response's next_body_offset to read the next part.",
4891
+ },
4631
4892
  project_id: {
4632
4893
  type: "string",
4633
4894
  format: "uuid",
@@ -4944,7 +5205,12 @@ const TOOLS = [
4944
5205
  "match with `meta.fallback: true` and a stable `meta.reason` (score is null on that " +
4945
5206
  "path) — check meta.fallback before treating a short/empty result as a genuinely " +
4946
5207
  "empty scope. `meta.total_count` and `meta.underfilled` are also returned so you can " +
4947
- "distinguish a short page from a hard cap.",
5208
+ "distinguish a short page from a hard cap. Check `meta.ann_iterative_scan` too: " +
5209
+ "`unavailable` (with `meta.ann_iterative_scan_reason`) means the vector read ran " +
5210
+ "without pgvector's iterative scan and may be INCOMPLETE — a short page then is not " +
5211
+ "evidence of a sparse scope, and meta.fallback/underfilled cannot tell you that. It " +
5212
+ "is absent on the ILIKE fallback AND on an `include_superseded: true` recall (a " +
5213
+ "bounded exact top-k, no index scan), so absence never means the fallback ran.",
4948
5214
  inputSchema: {
4949
5215
  type: "object",
4950
5216
  properties: {
@@ -4979,7 +5245,10 @@ const TOOLS = [
4979
5245
  "untouched per-source `memory` and `knowledge` envelopes so you can re-rank. " +
4980
5246
  "Cross-source scores are heuristic, not calibrated. If the knowledge search " +
4981
5247
  "degrades (embedding unavailable) or errors, the memory side is still returned and " +
4982
- "meta.degraded is true — never a hard failure.",
5248
+ "meta.degraded is true — never a hard failure. Each envelope's " +
5249
+ "`meta.ann_iterative_scan` discloses whether THAT half's vector read ran with " +
5250
+ "pgvector's iterative scan (`unavailable` ⇒ possibly incomplete); the two halves " +
5251
+ "are resolved independently and may differ.",
4983
5252
  inputSchema: {
4984
5253
  type: "object",
4985
5254
  properties: {
@@ -5400,10 +5669,16 @@ const TOOLS = [
5400
5669
  "synthesize the two into ONE new DRAFT — both sources preserved, never auto-published, " +
5401
5670
  "for you/a human to review and publish). Non-destructive " +
5402
5671
  "at agent role — you record intent; the privileged nightly job executes it. " +
5672
+ "NOTE for an agent-role key: 'supersede' is the one disposition that RETIRES an " +
5673
+ "article unattended, so its confidence is capped server-side — your \"high\" is " +
5674
+ "recorded as \"medium\" (see data.requested_confidence and note in the response) and " +
5675
+ "the pair STAYS in knowledge_conflicts until an orchestrator+ key records it at high. " +
5676
+ "'merge' is never capped and executes normally at agent role. " +
5403
5677
  "Last-write-wins per pair, so re-recording with fresher ground truth overrides. " +
5404
5678
  "Resolve only conflicts material to your current task; adjudicate against the actual " +
5405
- "system, and if you can't tell which is right, leave it (or record low confidence) " +
5406
- "rather than guessing.",
5679
+ "system, and if you can't tell which is right, LEAVE IT UNRECORDED rather than " +
5680
+ "guessing recording low confidence is not a way to park it, it closes the verdict " +
5681
+ "as dismissed on the next nightly run.",
5407
5682
  inputSchema: {
5408
5683
  type: "object",
5409
5684
  properties: {
@@ -5440,14 +5715,21 @@ const TOOLS = [
5440
5715
  type: "string",
5441
5716
  description:
5442
5717
  "Why you're sure — ideally a ground-truth reference (commit, file:line, URL, or the " +
5443
- "observed behavior). Recorded for audit and for a human reviewing low-confidence calls.",
5718
+ "observed behavior). Recorded for audit and for a human reviewing low-confidence " +
5719
+ "calls. REQUIRED for a supersede OR merge recorded at confidence 'high' (422 " +
5720
+ "without it) — every verdict the executor applies unattended must say why.",
5444
5721
  },
5445
5722
  confidence: {
5446
5723
  type: "string",
5447
5724
  enum: ["high", "medium", "low"],
5448
5725
  description:
5449
- "high, medium, or low. supersede auto-executes only at 'high'; lower confidence is " +
5450
- "recorded but left for review. Default medium.",
5726
+ "high, medium, or low. Default medium. supersede/merge auto-execute only at 'high'; " +
5727
+ "recorded LOWER, the next nightly run closes the verdict as dismissed (both " +
5728
+ "articles retained) and the pair leaves the conflict queue — it is NOT left for " +
5729
+ "review, so re-record at 'high' if you mean it to apply. On a supersede the value " +
5730
+ "is a REQUEST: it is capped to 'medium' unless the calling key is orchestrator+, " +
5731
+ "and a CAPPED verdict is the exception — it stays in the queue for an " +
5732
+ "orchestrator+ key. merge is not capped.",
5451
5733
  },
5452
5734
  },
5453
5735
  required: ["source_article_id", "target_article_id", "disposition"],
@@ -6362,13 +6644,22 @@ const TOOLS = [
6362
6644
  description:
6363
6645
  "Mint an ephemeral api_key for a sub-agent dispatch. " +
6364
6646
  "The raw_key is returned ONCE — pass it to the sub-agent via its launch arguments, " +
6365
- "never store it in env vars. The key expires after expires_in_seconds.",
6647
+ "never store it in env vars. The key expires after expires_in_seconds. " +
6648
+ "Pass parent_dispatch_id: a dispatch may only be minted INSIDE the caller's own " +
6649
+ "lineage. Omitting it starts a new independent lineage tree, which only the " +
6650
+ "tenant's user-role operator key may do — every other caller gets 403 " +
6651
+ "root_dispatch_forbidden, and the 403 body returns the caller's own dispatch id " +
6652
+ "as remediation.your_dispatch_id.",
6366
6653
  inputSchema: {
6367
6654
  type: "object",
6368
6655
  properties: {
6369
6656
  parent_dispatch_id: {
6370
6657
  type: "string",
6371
- description: "UUID of the parent dispatch (omit for root dispatch).",
6658
+ description:
6659
+ "UUID of the parent dispatch — your own dispatch id, or one of its " +
6660
+ "descendants. Required in practice: omitting it requests a ROOT dispatch, " +
6661
+ "which is 403 root_dispatch_forbidden for any caller a dispatch minted. " +
6662
+ "A parent outside your lineage is 403 parent_outside_caller_lineage.",
6372
6663
  },
6373
6664
  role: {
6374
6665
  type: "string",
@@ -6694,13 +6985,11 @@ const TOOLS = [
6694
6985
  },
6695
6986
  {
6696
6987
  name: "recover_cap",
6697
- description: "Re-mint a capability token for a story you're assigned to. Use after a session crash when you've lost your cap.",
6988
+ description: "Re-mint the start_cap for a story you're assigned to. Use after a session crash when you've lost it. start_cap is the only recoverable capability: asking for any other type is refused and logged as a forgery attempt.",
6698
6989
  inputSchema: {
6699
6990
  type: "object",
6700
6991
  properties: {
6701
6992
  story_id: { type: "string", description: "Story UUID." },
6702
- cap_type: { type: "string", enum: ["start_cap", "report_cap"], description: "Which cap to recover (default: start_cap)." },
6703
- lineage: { type: "array", items: { type: "string" }, description: "Your dispatch lineage path." },
6704
6993
  },
6705
6994
  required: ["story_id"],
6706
6995
  },
@@ -6767,6 +7056,17 @@ const server = new Server(
6767
7056
  }
6768
7057
  );
6769
7058
 
7059
+ // The parameter names each STATIC tool declares, so the alias layer can tell a rescue from
7060
+ // an inert convenience fill. Dynamic per-tenant `cr_*` tools are absent here and fall back
7061
+ // to reporting every fill (the conservative default).
7062
+ const DECLARED_TOOL_ARGS = new Map(
7063
+ TOOLS.map((t) => [t.name, Object.keys(t.inputSchema?.properties ?? {})]),
7064
+ );
7065
+
7066
+ function declaredToolArgs(name) {
7067
+ return DECLARED_TOOL_ARGS.get(name);
7068
+ }
7069
+
6770
7070
  server.setRequestHandler(ListToolsRequestSchema, async () => {
6771
7071
  // Static hand-maintained tools PLUS the calling tenant's per-tenant generated
6772
7072
  // Context Retriever tools (US-30.5). fetchGeneratedTools degrades to the static
@@ -6776,7 +7076,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
6776
7076
  });
6777
7077
 
6778
7078
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
6779
- const { name, arguments: args } = request.params;
7079
+ const { name } = request.params;
7080
+ // The callback keeps the schema inconsistency MEASURABLE rather than merely survivable:
7081
+ // every rescue is a call that would have been a hard 400 before, and a count of them is
7082
+ // the evidence for eventually converging the spellings instead of aliasing forever.
7083
+ //
7084
+ // The tool's OWN declared parameters are passed so only a genuine rescue is reported. The
7085
+ // fill is bidirectional, so without this a correct `knowledge_search` call carrying `q`
7086
+ // also filled `query` and logged a "rescue" — counting normal traffic, and drowning the
7087
+ // signal the count exists to carry.
7088
+ const args = applyArgAliases(
7089
+ request.params.arguments,
7090
+ ({ canonical, alias }) => {
7091
+ process.stderr.write(
7092
+ `[loopctl-mcp] arg alias applied: '${alias}' -> '${canonical}' on tool '${name}'\n`,
7093
+ );
7094
+ },
7095
+ declaredToolArgs(name),
7096
+ );
6780
7097
 
6781
7098
  switch (name) {
6782
7099
  // Project Tools
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Argument-name aliases for MCP tool calls (#657).
3
+ *
4
+ * WHY THIS EXISTS. A catalogue of 1,932 real agent searches, mined from Claude session
5
+ * transcripts across two machines, found that 86 `knowledge_search` calls — 8% of every
6
+ * search call made — failed with `400 Query parameter 'q' is required`. 85 of those had
7
+ * passed `query` instead of `q`, and most also passed `max_results` instead of `limit`.
8
+ *
9
+ * The queries were not the problem. They were the best queries in the corpus:
10
+ *
11
+ * {"query": "custody halt tenant threshold byzantine detection", "max_results": "1"}
12
+ * {"query": "LCP-1 signed custody profile attestation enrollment owner key"}
13
+ * {"query": "dropping a legacy pgvector HNSW index shared_buffers eviction retirement"}
14
+ *
15
+ * Every one was discarded over a synonym. Agents reach for `query`/`max_results` because
16
+ * that is what the surrounding ecosystem uses (WebSearch takes `query`; several MCP servers
17
+ * take `max_results`), and no amount of tool-description wording reliably overrides that
18
+ * habit — these calls were made by agents that had the schema in context.
19
+ *
20
+ * A search costs an embedding call and a turn of human attention. Refusing one over a
21
+ * spelling is the worst trade available. Accept both.
22
+ *
23
+ * CANONICAL WINS. An explicit canonical value is never overwritten, so a caller passing both
24
+ * `q` and `query` gets exactly what it asked for. Only a missing/blank canonical is filled.
25
+ */
26
+
27
+ // BIDIRECTIONAL BY DESIGN. The 86 failures were not agents guessing wrong — this MCP
28
+ // server's own surface is inconsistent, and `knowledge_search` is the odd one out:
29
+ //
30
+ // query -> knowledge_hybrid_search, knowledge_context, memory_recall, recall_context
31
+ // q -> knowledge_search (alone)
32
+ //
33
+ // Four sibling tools take `query`; the single most-used tool takes `q`. An agent that
34
+ // learns `query` from any neighbour and applies it to knowledge_search gets a 400. So the
35
+ // mapping runs BOTH ways: whichever spelling arrives, the other is filled in, and each tool
36
+ // reads the key it declares. Handlers select named arguments when building their request,
37
+ // so the extra key is inert rather than forwarded.
38
+ //
39
+ // Renaming knowledge_search's parameter instead would be a breaking change for every
40
+ // existing caller that already passes `q` correctly. Accepting both costs nothing.
41
+ //
42
+ // A BIDIRECTIONAL FILL IS NOT A BIDIRECTIONAL RESCUE. Because a blank canonical is filled
43
+ // from whichever spelling arrived, a perfectly correct `knowledge_search` call carrying `q`
44
+ // ALSO gets `query` populated. Counting that as a rescue made the metric count normal
45
+ // traffic — far more often than real rescues — so it could not support the decision it was
46
+ // built to inform. `declared` (below) is the tool's own parameter list: a rescue is counted
47
+ // only when the key the HANDLER READS was blank and the alias supplied it.
48
+ //
49
+ // ONLY OBSERVED ALIASES. This table lists what agents were MEASURED to send, nothing more.
50
+ // The first draft also mapped `search`, `text`, `top_k`, `topK`, `n` and the camelCase id
51
+ // spellings — all invented, none seen in the data — and the drift guard below immediately
52
+ // caught two of them colliding with real parameters that mean something else entirely:
53
+ // four tools declare `query` as their own canonical, and `memory_remember` declares `text`
54
+ // for the CONTENT of a memory. Aliasing that into `q` would have copied a memory's body
55
+ // into a search-query slot.
56
+ //
57
+ // A speculative alias is not free: it is a silent rename of somebody else's parameter.
58
+ // Add one only when a real failing call is observed to need it.
59
+ //
60
+ // `max_results` has NO reverse entry on purpose: no tool declares it, so filling it from
61
+ // `limit` rescued nothing and merely put a stderr write on the hot path of every call that
62
+ // paginated correctly.
63
+ const ARG_ALIASES = {
64
+ q: ["query"],
65
+ query: ["q"],
66
+ limit: ["max_results"],
67
+ };
68
+
69
+ function isBlank(v) {
70
+ return v === undefined || v === null || v === "";
71
+ }
72
+
73
+ /**
74
+ * Returns a NEW args object with canonical keys filled in from any alias present.
75
+ * Non-object input (null/undefined/array) is returned unchanged.
76
+ *
77
+ * `declared` is the set/array of parameter names the CALLED TOOL actually declares. When
78
+ * supplied, only a fill of a declared key is reported as a rescue — the rest are inert
79
+ * conveniences. Omit it and every fill is reported (the conservative default for a tool
80
+ * whose schema is not known, e.g. the per-tenant `cr_*` tools).
81
+ */
82
+ function applyArgAliases(args, onAliasUsed, declared) {
83
+ if (!args || typeof args !== "object" || Array.isArray(args)) return args;
84
+
85
+ const out = { ...args };
86
+ const declaredSet = declared ? new Set(declared) : null;
87
+
88
+ for (const [canonical, aliases] of Object.entries(ARG_ALIASES)) {
89
+ if (!isBlank(out[canonical])) continue;
90
+ for (const alias of aliases) {
91
+ if (!isBlank(out[alias])) {
92
+ out[canonical] = out[alias];
93
+ // Report every real rescue. Aliasing treats the SYMPTOM — the real defect is that
94
+ // the tool surface spells the same parameter three ways (`q`, `query`, `topic`). If
95
+ // the rescue is invisible, the inconsistency costs nothing measurable and never gets
96
+ // fixed, and the alias table quietly becomes load-bearing forever. Counting it
97
+ // keeps the residual cost on the books — which only works if the count is of calls
98
+ // that WOULD HAVE FAILED, hence the `declared` gate.
99
+ const rescued = declaredSet === null || declaredSet.has(canonical);
100
+ if (rescued && typeof onAliasUsed === "function") {
101
+ try {
102
+ onAliasUsed({ canonical, alias });
103
+ } catch {
104
+ // Telemetry must never break a tool call.
105
+ }
106
+ }
107
+ break;
108
+ }
109
+ }
110
+ }
111
+
112
+ // Some callers send a numeric arg as a string ("6"). Coerce so downstream validation and
113
+ // the outbound query string both see a number rather than rejecting or double-encoding.
114
+ if (typeof out.limit === "string" && /^\d+$/.test(out.limit.trim())) {
115
+ out.limit = parseInt(out.limit.trim(), 10);
116
+ }
117
+
118
+ return out;
119
+ }
120
+
121
+ export { ARG_ALIASES, applyArgAliases };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Client context reported alongside every search (#658).
3
+ *
4
+ * WHY THE CLIENT HAS TO SUPPLY THIS. None of it is derivable server-side. An api_key
5
+ * identifies a KEY, and under the v2 dispatch pattern a key is minted per dispatch, so the
6
+ * server cannot tell which agent searched, at what effort, from which repo, or whether the
7
+ * caller was a main session or a dispatched subagent. The MCP server runs inside the
8
+ * agent's own process, so it can simply read its environment.
9
+ *
10
+ * UNTRUSTED BY CONSTRUCTION. Every field here is client-asserted and trivially spoofable.
11
+ * It is ANALYTICS ONLY and must never gate access or authorize anything — the api_key
12
+ * remains the sole authority. The server stores these under a `client_` prefix so no later
13
+ * reader mistakes them for server-derived facts.
14
+ *
15
+ * WHAT IS ACTUALLY AVAILABLE — re-measured 2026-08-12 by reading /proc/<mcp-pid>/environ on
16
+ * a live session, which corrected two entries an earlier pass got wrong:
17
+ *
18
+ * CLAUDE_CODE_SESSION_ID=<uuid> -> session id, available
19
+ * CLAUDE_CODE_ENTRYPOINT=cli -> entrypoint, available
20
+ * CLAUDE_SESSION_ID -> ABSENT (the CODE_ spelling is the one that is set)
21
+ * CLAUDE_EFFORT -> ABSENT from THIS process. It is set for Bash-tool
22
+ * invocations, which is where the earlier claim that
23
+ * it was "available" came from; the MCP server does
24
+ * not get it, so `effort` is enriched offline.
25
+ * CLAUDE_CODE_CHILD_SESSION -> ABSENT, and it would not mean what it looks like:
26
+ * it is set to 1 for Bash-tool invocations of a MAIN
27
+ * session, so it marks "a child PROCESS", not "a
28
+ * dispatched agent".
29
+ * (no model variable) -> MODEL IS NOT AVAILABLE
30
+ *
31
+ * THE KIND REPORTED HERE IS THE SESSION'S, NOT THE CALLER'S. One MCP server process is
32
+ * spawned per session and serves the main session AND every agent it dispatches, and the
33
+ * environment above is read once and cached for the life of that process. So `kind` is a
34
+ * property of the session, and every search it labels comes back `main`. The three-way
35
+ * main/subagent/workflow split a measurement actually needs is recoverable only from the
36
+ * transcript, where `isSidechain` plus the file's path give it unambiguously.
37
+ *
38
+ * Two fields are therefore sent as JOIN KEYS rather than as answers: `session_id` is what
39
+ * lets `mix loopctl.enrich_search_events` find the transcript that records the model, the
40
+ * effort and the real kind. Note that a RESUMED session breaks even that — the new process
41
+ * reports a fresh session id while the transcript keeps appending under the original — so
42
+ * the enrichment carries a query-only fallback for exactly that case.
43
+ */
44
+
45
+ import { execFileSync } from "node:child_process";
46
+ import os from "node:os";
47
+
48
+ function env(name) {
49
+ const v = process.env[name];
50
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
51
+ }
52
+
53
+ /**
54
+ * The repo the AGENT was working in — which is NOT the project a KB search was scoped to.
55
+ * "Which repos lean on the knowledge base, and which never touch it" cannot be answered
56
+ * without it, and the scoped project_id does not answer it.
57
+ *
58
+ * Derived from the git remote so it is stable across machines and checkout paths; falls
59
+ * back to the directory basename when there is no remote. Cached: this runs per search and
60
+ * shelling out per call would put a process spawn on the hot path.
61
+ */
62
+ let repoCache;
63
+ function detectRepo() {
64
+ if (repoCache !== undefined) return repoCache;
65
+ repoCache = null;
66
+ try {
67
+ const remote = execFileSync("git", ["remote", "get-url", "origin"], {
68
+ encoding: "utf8",
69
+ stdio: ["ignore", "pipe", "ignore"],
70
+ timeout: 2000,
71
+ }).trim();
72
+ // Normalise git@host:owner/repo.git and https://host/owner/repo(.git) to owner/repo.
73
+ const m = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
74
+ if (m) repoCache = m[1];
75
+ } catch {
76
+ try {
77
+ repoCache = process.cwd().split("/").filter(Boolean).pop() || null;
78
+ } catch {
79
+ repoCache = null;
80
+ }
81
+ }
82
+ return repoCache;
83
+ }
84
+
85
+ /** Test seam: forget the cached repo. */
86
+ function resetRepoCache() {
87
+ repoCache = undefined;
88
+ }
89
+
90
+ /**
91
+ * Builds the client-context payload. Every field is optional — a missing value is omitted
92
+ * rather than sent as null, so "we could not observe this" and "this was empty" stay
93
+ * distinguishable in the data.
94
+ */
95
+ function clientContext({ version } = {}) {
96
+ const ctx = {
97
+ session_id: env("CLAUDE_SESSION_ID") || env("CLAUDE_CODE_SESSION_ID"),
98
+ effort: env("CLAUDE_EFFORT"),
99
+ model: env("CLAUDE_MODEL") || env("ANTHROPIC_MODEL"),
100
+ host: os.hostname(),
101
+ repo: detectRepo() || undefined,
102
+ entrypoint: env("CLAUDE_CODE_ENTRYPOINT"),
103
+ version,
104
+ };
105
+
106
+ // The SESSION's kind — see the header. This process is shared by the main session and
107
+ // every agent it dispatches, so in practice this resolves to "main" for all of them and
108
+ // is NOT caller-level evidence. It is still worth sending: it is the only thing available
109
+ // before the offline enrichment runs, and the enrichment treats it as an assertion to be
110
+ // corrected rather than as a value to be preserved.
111
+ //
112
+ // Do not try to widen it to three values from the environment. CLAUDE_CODE_WORKFLOWS is a
113
+ // feature flag present in main sessions too, and CLAUDE_CODE_CHILD_SESSION marks a child
114
+ // PROCESS rather than a dispatched agent. Guessing here would be worse than the null it
115
+ // replaces, because the number this column exists to support is a comparison BETWEEN
116
+ // kinds.
117
+ const child = env("CLAUDE_CODE_CHILD_SESSION");
118
+ if (child !== undefined) {
119
+ ctx.kind = child === "1" || child.toLowerCase() === "true" ? "child" : "main";
120
+ } else if (ctx.session_id) {
121
+ // A MAIN session sets no child marker at all, so keying on the marker alone filed
122
+ // every main session under NULL — beside every request from an older client that sends
123
+ // no context, which is the one population NULL has to keep meaning. An absent marker on
124
+ // a recognisable session IS main; with nothing identifying the caller, kind stays
125
+ // absent rather than guessed.
126
+ ctx.kind = "main";
127
+ }
128
+
129
+ for (const k of Object.keys(ctx)) {
130
+ if (ctx[k] === undefined || ctx[k] === null || ctx[k] === "") delete ctx[k];
131
+ }
132
+ return ctx;
133
+ }
134
+
135
+ /**
136
+ * Encodes the context for transport as a single header. Base64 keeps arbitrary repo names
137
+ * and hostnames from breaking header parsing, and one header keeps the surface small.
138
+ * Returns undefined when there is nothing to report, so no empty header is sent.
139
+ */
140
+ function clientContextHeader(opts) {
141
+ const ctx = clientContext(opts);
142
+ if (Object.keys(ctx).length === 0) return undefined;
143
+ return Buffer.from(JSON.stringify(ctx), "utf8").toString("base64");
144
+ }
145
+
146
+ export { clientContext, clientContextHeader, detectRepo, resetRepoCache };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Leading notices for search results whose META carries something the agent must act on
3
+ * (#658).
4
+ *
5
+ * THE DEFECT THIS FIXES. When semantic ranking is unavailable the server degrades to
6
+ * keyword-only and says so LOUDLY — `meta.fallback: true`, `meta.degraded: true`,
7
+ * `meta.fallback_reason`, plus telemetry and a server-side warning log. The degradation is
8
+ * not silent at the server. It is silent at the CLIENT: agents do not read `meta`.
9
+ *
10
+ * Measured across real session transcripts on two machines: every degraded response that
11
+ * came back empty was treated by the receiving agent as "the knowledge base has nothing".
12
+ * It is a low-volume failure (roughly a dozen in ~1,900 searches) and a totally deceptive
13
+ * one — and it lands hardest on the BEST queries, because the keyword fallback lane uses
14
+ * AND semantics, so a long specific query is the most likely to match nothing at all.
15
+ *
16
+ * The remedy the agent needs is the opposite of the obvious one: do NOT rephrase. Different
17
+ * words cannot fix a provider timeout. Retry the SAME query.
18
+ *
19
+ * This mirrors the existing BYO-LLM `no_embedding_key` ACTION REQUIRED notice, which
20
+ * already established that a meta-only disclosure is not enough to change behaviour.
21
+ */
22
+
23
+ /** True when a result degraded to a fallback lane, whatever the cause. */
24
+ function isDegraded(meta) {
25
+ return Boolean(meta && (meta.fallback === true || meta.degraded === true));
26
+ }
27
+
28
+ function resultCount(result) {
29
+ if (!result || typeof result !== "object") return null;
30
+ if (Array.isArray(result.data)) return result.data.length;
31
+ if (Array.isArray(result.results)) return result.results.length;
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * Returns a notice string when a search DEGRADED, or null otherwise.
37
+ *
38
+ * The empty case gets the strong wording because it is the one that misleads: an empty
39
+ * degraded response is indistinguishable from a genuine miss. A degraded response that
40
+ * still returned rows gets a softer note — the results are real but the ranking was not
41
+ * the one requested, so they may be incomplete.
42
+ */
43
+ function degradedSearchNotice(result) {
44
+ const meta = result && result.meta;
45
+ if (!isDegraded(meta)) return null;
46
+
47
+ // The BYO-key case already has its own, more specific ACTION REQUIRED notice; do not
48
+ // stack two notices on one result.
49
+ if (meta.fallback_reason === "no_embedding_key") return null;
50
+
51
+ const reason = meta.fallback_reason || "unknown";
52
+ const count = resultCount(result);
53
+
54
+ if (count === 0) {
55
+ return (
56
+ `DEGRADED SEARCH — THIS IS NOT "NO RESULTS". Semantic ranking was unavailable ` +
57
+ `(${reason}), so this ran keyword-only, and the keyword lane requires ALL terms to ` +
58
+ `match — a long or specific query returns nothing even when the answer exists. ` +
59
+ `Do NOT conclude the knowledge base lacks this, and do NOT rephrase: different ` +
60
+ `words cannot fix a provider failure. RETRY THE SAME QUERY.`
61
+ );
62
+ }
63
+
64
+ return (
65
+ `PARTIAL SEARCH — semantic ranking was unavailable (${reason}), so these are ` +
66
+ `keyword-only matches and may be incomplete. Retry the same query for full ranking.`
67
+ );
68
+ }
69
+
70
+ export { degradedSearchNotice, isDegraded };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.72.0",
3
+ "version": "2.74.0",
4
4
  "description": "MCP server for loopctl \u2014 structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",