loopctl-mcp-server 2.111.2 → 2.113.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
@@ -415,6 +415,9 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
415
415
  |---|---|
416
416
  | `llm_config` | **Check your onboarding status.** Get the tenant's BYO LLM config: per-operation models and whether each key is set — `has_api_key` (Anthropic) / `has_embedding_key` (OpenAI embedding) — plus masked last-4 hints. Never returns a key. Requires **user** key. |
417
417
  | `set_llm_config` | **First-time setup (do once).** Set/rotate the tenant's OWN **Anthropic `api_key`** (powers ingest) AND **OpenAI `embedding_api_key`** (powers semantic search) — both stored encrypted, never returned — plus the per-operation models (`extraction_model`/`classification_model`/`merge_model`/`embedding_model`). Any subset; partial-merge (omitting a key leaves it untouched). **Private tier (US-41.3):** also sets `chat_provider` (`anthropic` | `openai_compatible`), `chat_base_url`, `extraction_model` (required on that provider) and `chat_api_key` (optional — omit it for a keyless local server) so ingest extraction / classification / merge / memory promotion run against YOUR OWN OpenAI-compatible endpoint and document text never leaves your boundary. Every model the config resolves to is PROBED with a trivial completion before it is saved (a 422 persists nothing); the probe re-runs on any chat-surface change (provider, base_url, key rotation, model), a private-range host is refused by the SSRF guard, and moving to a NEW host needs a matching `chat_api_key` or `acknowledge_key_transmission: true`. See [First-time setup](#first-time-setup--provision-your-byo-llm-keys). Requires **user** key. |
418
+ | `github_credential` | **Is there a GitHub credential for this tenant?** (#936, `GET /api/v1/tenants/me/github-credential`) `has_token`, a masked last-4 `token_hint`, `updated_at`, and `operator_repositories`: the repositories the operator's token is lent for (`VERIFICATION_OPERATOR_TOKEN_TENANTS`) while the tenant has no token. Never returns the token. Reach for it when the merge gate refuses `{pull_request_unavailable, credential_unavailable}` or verification records `credential_unavailable`. Requires **user** key. |
419
+ | `set_github_credential` | **Set or replace the tenant's own GitHub token** (#936, `PUT /api/v1/tenants/me/github-credential`). Every GitHub call loopctl makes for the tenant — merge gate, story and post-deploy verification, thread checkpoint reads, issue closing — then uses it for EVERY repository, replacing the operator's token even where the operator lent it. A fine-grained token on your own repositories with read access to Contents, Pull requests, Actions, Commit statuses and Deployments (plus Issues: write for issue closing). Stored encrypted, never returned, and not checked against GitHub on the way in: a token that cannot read a repository shows up as that repository's forge refusal. A thread-mode merge is still written by loopctl's GitHub App, and only for a repository the operator named for the tenant: this token never licenses it (escalated `app_not_licensed`). 422 when blank, over the server's length bound or containing anything but letters, digits and underscores; `403 custody_tier_required` on an agent-rooted tenant. Required: `token`. Requires **user** key. |
420
+ | `clear_github_credential` | **Remove the tenant's GitHub token** (#936, `DELETE /api/v1/tenants/me/github-credential`). Calls then use the operator's token only for `operator_repositories` and are refused `credential_unavailable` everywhere else, so a pr-mode repository outside that list stops merging. Idempotent. Requires **user** key. |
418
421
  | `knowledge_llm_usage` | Per-tenant LLM token-usage summary, grouped by operation + model + source_type + day over an optional `from`/`to` range (defaults to a 90-day lookback; effective window echoed in `meta.from`/`meta.to`), with `limit`/`offset` pagination. Record-only (no budget enforcement). Requires orchestrator key. |
419
422
 
420
423
  ### Knowledge Analytics Tools (orchestrator key)
@@ -533,9 +536,9 @@ The order to wire it up, the stage machine these tools move a story through, and
533
536
  | `thread_stage_report` | **Report a stage transition of the story you claimed yourself on a thread-mode repository** (`POST /api/v1/stories/:id/stage/transitions`, US-45.9): an interactive claim of a thread-mode story is a change thread with no runner, so its claimant moves the delivery stage as a runner would (with the checkpoint's `head_sha` only entering `ci`), never into `merged` and never `merge_refused`: loopctl's App makes and records the merge; after it, `merged` -> `deployed` with the `release_id`. The first report (from `claimed`) enters the claim. A resend of a report that already landed answers 200 with `replayed: true`. On the key `claim_story` claims with. Refusals: 404; 409 `not_claimant`, `stale_claim_epoch`, `claim_not_live` (renew the lease with `renew_story_claim` while you work), `not_interactive_thread_claim`, `effect_conflict` (the body names the recorded `head_sha`), `stale_stage` (the body names the story's stage and recorded effects); 422 `invalid_payload`; 503 `busy`. |
534
537
  | `force_unclaim_story` | **Take a story back from the agent holding it** (`POST /api/v1/stories/:id/force-unclaim`). Resets it to `agent_status: pending` with `assigned_agent_id` cleared AND makes the delivery stage row follow the release back to `queued`. **A delivery story then goes to `escalated`, not back to the queue** (loopctl US-44.4): an operator taking a story back is a human decision, so a release that leaves the stage row at `queued` escalates it over `operator_released` in the same transaction, spending no attempt against the retry ceiling — it is not left in the queue behind the human's back. Put it back to work with `resolve_escalation` `to: queued`, which releases (a no-op by then) AND re-contracts. A story with no delivery stage row is simply left `pending`. A story parked at `claimed` with nobody on it is the residue of a compensation that did not complete, not what a refused dispatch normally leaves: placement releases the claim inline when a runner refuses, and the claim lease releases it unattended once `claimed_until` passes — reach for this to get it back now, or when both of those left it held. Requeues from any stage a claim holds; a stage no claim holds keeps its stage and is rebound to the new epoch; `done` and `failed` are untouched. For an ESCALATED story use `resolve_escalation` instead — that one releases AND re-contracts, so its `queued` really is placeable. Requires an orchestrator-ROLE key: the action is `exact_role: :orchestrator`, so a user or superadmin key is 403'd like any other non-member. Put it in `LOOPCTL_ORCH_KEY` (which is then the key sent, and a global `LOOPCTL_API_KEY` does not displace it) or, with no orchestrator key set, in `LOOPCTL_API_KEY`. The orchestrator key must be linked to a registered agent (400 otherwise) and the tenant must be human-anchored (403 `custody_tier_required` otherwise). Does not touch `verified_status`. Run again on an already-pending story it is still an operator's release: a row stranded behind the story's claim epoch is rebound, an in-flight row is requeued, and a row then at `queued` — including one already sitting there — is escalated over `operator_released`, as on the first run; a row already escalated, or anywhere else at that epoch, is left alone. Every 500 means the whole release rolled back and the story is still claimed: `audit_chain_append_failed` (the escalation's chain entry was refused) is a server-side condition a re-run meets again until an operator acts; `force_unclaim_failed` (the server log names the step) — call it again. A 422 means the release write itself was rejected. No request body. Required: `story_id` (refused locally if it is not a UUID). |
535
538
  | `merge_precondition` | **Run the merge gate over a story's real pull request** (`POST /api/v1/stories/:id/merge-precondition`). Both delivery gates run again over the diff that exists, plus custody, the 12-file / 1000-line hard bound, the head-has-not-moved check and the self-deploy exclusion; the story must be at stage `ci`. **Gate A reads what triage persisted, never the caller**: the lens verdicts recorded with the story's most recent triage, or a human's re-queue of a Gate A escalation — `gate_a_inputs` on the answer says which, and `missing` refuses with `gate_a_inputs_missing`. This tool sends no trio. Decisions: `allow` (recorded against the head; the only thing that licenses a merge), `refuse` (the story is escalated before this returns), `already_merged`, `head_moved`, `unevaluated` (503 with `Retry-After`). **Thread mode** (the current claim was placed under a source with `mode: thread` — the mode is bound to the dispatch at placement): no pull request; the gate judges the latest checkpoint recorded under the story's CURRENT claim, on the branch that claim's dispatch ran on, and adds refusals `empty_change`, `checkpoint_tree_mismatch`, `no_checkpoint_recorded` and `claim_ended` (the claim was released after an earlier one recorded checkpoints); the source's runners must be at contract 1.20.0+ sending checkpoint messages, or every story is refused `no_checkpoint_recorded`; the branch is judged first, and a missing branch in a readable repository (`branch_missing`), a head nobody recorded (`branch_head_unrecorded`), an earlier checkpoint (`branch_head_regressed`), or a head the stage row did not record is `head_moved` back to implementing while the claim is LIVE, and a refusal naming `claim_not_live` (escalated) when it is not; an unreadable repository refuses `pull_request_unavailable`; the diff judged is the three-dot diff against the merge base, so a base that moved on is not a refusal, and a checkpoint the base already contains (its branch deleted or not) is `already_merged` only under a recorded allow naming it, otherwise `checkpoint_on_base_without_allow`; a thread loopctl could not read refuses `thread_unreadable`; CI is read by the checkpoint's exact SHA from the GitHub Actions runs and jobs APIs (only a job of a GitHub Actions workflow run that a push of the thread branch at that commit triggered satisfies a required check, and only by concluding `success` — a skipped job fails; statuses and other check runs are recorded or ignored, never trusted) against the source's `required_checks` — a failed one refuses `required_check_failed`, one still running or not yet reported is `unevaluated` (`required_check_pending` / `required_check_missing`, retry after the answer's `Retry-After`; neither counts toward the unevaluated bound, and past the gate's CI wait limit from the story's entry into `ci` both are refused `required_check_timed_out`; per name the newest run of each workflow counts and every workflow must pass; a checkpoint changing `.github/workflows/` or `.github/actions/` is refused `ci_definition_changed`), a source requiring none refuses `required_checks_unset`, a failed read is `ci_evidence_unavailable`, and `local-gate` is recorded but never counted; the answer's `ci_evidence` is what was read and is copied onto the checkpoint; the answer carries `mode` (bound at placement, `pr` when the claim has no accepted dispatch whatever the source says now, null only on ledger contention) and `base_sha` (that merge base), an allow records it, and the merge executor merges only while the base head still equals it. Requires an orchestrator- or user-ROLE key (`exact_role: [:orchestrator, :user]`, so an agent key is 403'd); `LOOPCTL_ORCH_KEY` is sent when set, else `LOOPCTL_API_KEY`. 403 `custody_tier_required` without a human anchor, 404 for an unknown story, 422 when the story is not at `ci`. Required: `story_id` (refused locally if not a UUID) and `claim_epoch` (a non-negative integer, refused locally otherwise); optional `effect_proof`. |
536
- | `intake_source_enroll` | **Bind a GitHub repository to a work project and mint its webhook secret** (`POST /api/v1/intake/sources`) — the step that gives the delivery loop an input, and the row `place_dispatch` reads `repo` and `base_branch` from (its 409 `no_intake_source` means this has not been done; 409 `ambiguous_intake_source` means it has been done twice). **The secret is returned ONCE and can never be read again** — the column is encrypted at rest and redacted on the schema, so no other tool carries it and losing it costs a revoke, a re-enrolment and a reconfigured GitHub webhook. **This tool does not return it either:** a tool result lands in the transcript and the audit log, so it is written to the required `secret_file` with mode 0600 (the handling `runner_enroll` gives a runner credential) and the result carries the source row, the `webhook_url` and that path. The path is reserved before the request, so an existing file or an unwritable directory is refused with nothing enrolled, and any outcome that is not a clean creation withholds the response body — an unparsed 2xx body IS the secret. A 2xx carrying the source and the secret but no webhook path is RECOVERED rather than discarded (the path is derived from the source id, flagged `webhook_url_derived`), and an outcome that proves a source id but no usable secret REVOKES that source rather than leaving it holding the repository's unique slot. Then configure GitHub: Payload URL = the returned `webhook_url`, content type `application/json`, Secret = the contents of the file, and the **Issues** event only — `gh api repos/OWNER/REPO/hooks -f name=web -f config[url]=<webhook_url> -f config[content_type]=json -f config[secret]="$(cat <secret_file>)" -f 'events[]=issues'`. Every later delivery failure is the SAME 401 `invalid_signature` — unknown or revoked source, suspended tenant, missing or wrong signature, or a payload whose `repository.full_name` does not match — so the webhook's Recent Deliveries tab cannot tell you which. `base_branch` is set HERE: it defaults to `master` when the body does not name it, so name `main` for a repository created on GitHub since 2020 or every dispatch is cut from a trunk that does not exist. It is not nullable (a dispatch must name a branch), so null or blank is refused rather than falling back to the default, and `intake_source_update` changes it afterwards. Refusals: 403 `api_key_mint_forbidden` if your key was minted by a dispatch (this mints a credential belonging to no lineage, so only an unlineaged operator key may), 403 `custody_tier_required` on an agent-rooted tenant, 422 for a repository that is not `owner/name`, an ACTIVE source already binding it, a project that is missing / archived / not a work project, or a `target_epic_id` outside that project. `mode` picks the merge route: `pr` (the default when omitted) or `thread`, where the merge gate evaluates the story's latest recorded checkpoint instead of a pull request — which needs the source's runners at contract 1.20.0+ sending checkpoint messages, or every story is refused `no_checkpoint_recorded`; any other value, null included, is refused. The mode is bound to each implement dispatch when it is placed, so it decides only what stories placed afterwards get; a change is always allowed. A `thread` source must also name `required_checks` (422 otherwise): the CI checks the merge gate requires on a checkpoint's exact commit, GitHub Actions job names, bounded in number and length by the server; `local-gate` is refused because whoever pushed posts it. Story verification judges a story's commit against the same `required_checks` in EITHER mode, satisfied only by a job of a push run of the story's branch; it first needs an entry for this tenant in the operator's `VERIFICATION_OPERATOR_TOKEN_TENANTS` (`credential_unavailable` otherwise, whatever the list holds); then a `pr` source that names none records `no_required_checks` and reads nothing, so naming them is how a pr source opts in, and a source that names some has its (tenant, repository) pair checked against that list before any GitHub read (`credential_unavailable` if the pair is not an entry). Required: `repo_full_name`, `project_id`, `secret_file`. Optional: `base_branch`, `mode`, `required_checks`, and `target_epic_id` — **omitting the epic is not a neutral default**: with no epic every triaged report is escalated to a human and every record stays `pending_triage` until `intake_source_update` names one. Requires `LOOPCTL_USER_KEY`. |
539
+ | `intake_source_enroll` | **Bind a GitHub repository to a work project and mint its webhook secret** (`POST /api/v1/intake/sources`) — the step that gives the delivery loop an input, and the row `place_dispatch` reads `repo` and `base_branch` from (its 409 `no_intake_source` means this has not been done; 409 `ambiguous_intake_source` means it has been done twice). **The secret is returned ONCE and can never be read again** — the column is encrypted at rest and redacted on the schema, so no other tool carries it and losing it costs a revoke, a re-enrolment and a reconfigured GitHub webhook. **This tool does not return it either:** a tool result lands in the transcript and the audit log, so it is written to the required `secret_file` with mode 0600 (the handling `runner_enroll` gives a runner credential) and the result carries the source row, the `webhook_url` and that path. The path is reserved before the request, so an existing file or an unwritable directory is refused with nothing enrolled, and any outcome that is not a clean creation withholds the response body — an unparsed 2xx body IS the secret. A 2xx carrying the source and the secret but no webhook path is RECOVERED rather than discarded (the path is derived from the source id, flagged `webhook_url_derived`), and an outcome that proves a source id but no usable secret REVOKES that source rather than leaving it holding the repository's unique slot. Then configure GitHub: Payload URL = the returned `webhook_url`, content type `application/json`, Secret = the contents of the file, and the **Issues** event only — `gh api repos/OWNER/REPO/hooks -f name=web -f config[url]=<webhook_url> -f config[content_type]=json -f config[secret]="$(cat <secret_file>)" -f 'events[]=issues'`. Every later delivery failure is the SAME 401 `invalid_signature` — unknown or revoked source, suspended tenant, missing or wrong signature, or a payload whose `repository.full_name` does not match — so the webhook's Recent Deliveries tab cannot tell you which. `base_branch` is set HERE: it defaults to `master` when the body does not name it, so name `main` for a repository created on GitHub since 2020 or every dispatch is cut from a trunk that does not exist. It is not nullable (a dispatch must name a branch), so null or blank is refused rather than falling back to the default, and `intake_source_update` changes it afterwards. Refusals: 403 `api_key_mint_forbidden` if your key was minted by a dispatch (this mints a credential belonging to no lineage, so only an unlineaged operator key may), 403 `custody_tier_required` on an agent-rooted tenant, 422 for a repository that is not `owner/name`, an ACTIVE source already binding it, a project that is missing / archived / not a work project, or a `target_epic_id` outside that project. `mode` picks the merge route: `pr` (the default when omitted) or `thread`, where the merge gate evaluates the story's latest recorded checkpoint instead of a pull request — which needs the source's runners at contract 1.20.0+ sending checkpoint messages, or every story is refused `no_checkpoint_recorded`; any other value, null included, is refused. The mode is bound to each implement dispatch when it is placed, so it decides only what stories placed afterwards get; a change is always allowed. A `thread` source must also name `required_checks` (422 otherwise): the CI checks the merge gate requires on a checkpoint's exact commit, GitHub Actions job names, bounded in number and length by the server; `local-gate` is refused because whoever pushed posts it. Story verification judges a story's commit against the same `required_checks` in EITHER mode, satisfied only by a job of a push run of the story's branch; it first needs a GitHub credential for this tenant, its own token (`set_github_credential`) or an entry in the operator's `VERIFICATION_OPERATOR_TOKEN_TENANTS` (`credential_unavailable` otherwise, whatever the list holds); then a `pr` source that names none records `no_required_checks` and reads nothing, so naming them is how a pr source opts in, and a source that names some has its (tenant, repository) credential resolved before any GitHub read (`credential_unavailable` if there is none). Required: `repo_full_name`, `project_id`, `secret_file`. Optional: `base_branch`, `mode`, `required_checks`, and `target_epic_id` — **omitting the epic is not a neutral default**: with no epic every triaged report is escalated to a human and every record stays `pending_triage` until `intake_source_update` names one. Requires `LOOPCTL_USER_KEY`. |
537
540
  | `intake_source_list` | List the tenant's intake sources (`GET /api/v1/intake/sources`): `id` (the webhook URL is `/api/v1/intake/github/<id>`), `project_id`, `repo_full_name`, `base_branch`, `mode` (`pr` or `thread`), `target_epic_id`, `revoked_at`, timestamps. **The webhook secret is not here and is not anywhere** — it is returned once by `intake_source_enroll`, so this is not how to recover one. Use it to find the source behind a 409 `no_intake_source` or `ambiguous_intake_source`, to check a repository's `base_branch` before placing work, and to identify a source left behind by an enrolment whose outcome was unknown. Optional: `include_revoked`. Requires `LOOPCTL_USER_KEY`; the only one of the four that does not also need a human-anchored tenant. |
538
- | `intake_source_update` | **Set where a source's work lands** (`PATCH /api/v1/intake/sources/:id`): `target_epic_id`, the epic triaged stories are created in, `base_branch`, the branch every dispatch for this repository is cut from, `mode` (`pr` or `thread`), whether the merge gate reads a pull request or the story's latest recorded thread checkpoint, and `required_checks`, the CI checks a thread checkpoint must pass on its exact commit and the checks story verification judges a story's commit by in either mode — once the tenant has an entry in the operator's `VERIFICATION_OPERATOR_TOKEN_TENANTS` (`credential_unavailable` otherwise), a pr source naming none records `no_required_checks` and reads nothing, and one naming some has its (tenant, repository) pair checked against that list before any GitHub read (a thread source must name at least one, judged over the source as it will be, so switching to `thread` without them or clearing them on a thread source is 422; `local-gate` is refused). Presence decides — a field you do not name is left exactly as it was, and naming none of them is 422 `nothing_to_update`. `mode` has no cleared state either, and a null or unknown value is refused locally; A change is always allowed and affects only stories placed afterwards: each implement dispatch records the mode, and the base branch, it was placed under, and the merge gate reads those. **Do not send `target_epic_id: null` to mean "not changing this":** an explicit null is the only way to CLEAR the epic, which returns the source to escalating every report instead of filing a story. `base_branch` has no cleared state and a null there is refused locally. This is how a source already pointed at the wrong trunk is corrected — `intake_source_enroll` takes `base_branch` itself, so a `main` repository no longer needs a second call — and the remedy for a source with no epic — every record from it stays `pending_triage` and is retried until one is named, then they promote on the next run with nothing lost. Revoked sources are 404, not a no-op: revoking clears the target so the epic can be deleted, and repointing one would put that block back. 422 when the epic is not in this source's project. Required: `source_id` (refused locally if it is not a UUID — the server answers the same 404 for malformed and unknown). Requires `LOOPCTL_USER_KEY` and a human-anchored tenant. |
541
+ | `intake_source_update` | **Set where a source's work lands** (`PATCH /api/v1/intake/sources/:id`): `target_epic_id`, the epic triaged stories are created in, `base_branch`, the branch every dispatch for this repository is cut from, `mode` (`pr` or `thread`), whether the merge gate reads a pull request or the story's latest recorded thread checkpoint, and `required_checks`, the CI checks a thread checkpoint must pass on its exact commit and the checks story verification judges a story's commit by in either mode — once the tenant has a GitHub credential, its own token (`set_github_credential`) or an entry in the operator's `VERIFICATION_OPERATOR_TOKEN_TENANTS` (`credential_unavailable` otherwise), a pr source naming none records `no_required_checks` and reads nothing, and one naming some has its (tenant, repository) credential resolved before any GitHub read (a thread source must name at least one, judged over the source as it will be, so switching to `thread` without them or clearing them on a thread source is 422; `local-gate` is refused). Presence decides — a field you do not name is left exactly as it was, and naming none of them is 422 `nothing_to_update`. `mode` has no cleared state either, and a null or unknown value is refused locally; A change is always allowed and affects only stories placed afterwards: each implement dispatch records the mode, and the base branch, it was placed under, and the merge gate reads those. **Do not send `target_epic_id: null` to mean "not changing this":** an explicit null is the only way to CLEAR the epic, which returns the source to escalating every report instead of filing a story. `base_branch` has no cleared state and a null there is refused locally. This is how a source already pointed at the wrong trunk is corrected — `intake_source_enroll` takes `base_branch` itself, so a `main` repository no longer needs a second call — and the remedy for a source with no epic — every record from it stays `pending_triage` and is retried until one is named, then they promote on the next run with nothing lost. Revoked sources are 404, not a no-op: revoking clears the target so the epic can be deleted, and repointing one would put that block back. 422 when the epic is not in this source's project. Required: `source_id` (refused locally if it is not a UUID — the server answers the same 404 for malformed and unknown). Requires `LOOPCTL_USER_KEY` and a human-anchored tenant. |
539
542
  | `intake_source_revoke` | **Revoke an intake source** (`DELETE /api/v1/intake/sources/:id`). The verb is DELETE and the action is a revoke: `revoked_at` is stamped, the row is kept, and `intake_source_list` still returns it with `include_revoked`; nothing already received is discarded. Afterwards every delivery to that URL is refused 401 `invalid_signature` exactly as a wrong secret is, so the GitHub webhook does not know it has been cut off and should be deleted on the repository too. Idempotent — a second revoke answers 200 with the original `revoked_at`. It frees the repository's uniqueness slot (the index is partial on not-yet-revoked), so a source with a lost secret, a wrong project or a typo'd repository is corrected by revoking and enrolling again; and it clears `target_epic_id`, which is what makes that epic deletable and is the remedy an epic-delete refusal names. Required: `source_id` (refused locally if it is not a UUID). Requires `LOOPCTL_USER_KEY` and a human-anchored tenant. |
540
543
 
541
544
  ### Dispatch & Chain of Custody (v2) Tools
package/index.js CHANGED
@@ -15,6 +15,7 @@ import os from "node:os";
15
15
  import crypto from "node:crypto";
16
16
  import path from "node:path";
17
17
  import { applyArgAliases } from "./lib/arg-aliases.js";
18
+ import { articleId } from "./lib/article-id.js";
18
19
  import { clientContextHeader } from "./lib/client-context.js";
19
20
  import { resolveClaimSessionId } from "./lib/claim-session.js";
20
21
  import { degradedSearchNotice } from "./lib/search-notices.js";
@@ -78,6 +79,11 @@ import {
78
79
  } from "./lib/intake-sources.js";
79
80
  import { mcpVersion as mcpVersionRequest, packageVersion } from "./lib/mcp-version.js";
80
81
  import { revokeDispatch as revokeDispatchRequest } from "./lib/dispatch-revoke.js";
82
+ import {
83
+ clearGithubCredential as clearGithubCredentialRequest,
84
+ githubCredential as githubCredentialRequest,
85
+ setGithubCredential as setGithubCredentialRequest,
86
+ } from "./lib/github-credential.js";
81
87
  import {
82
88
  createEpic as createEpicRequest,
83
89
  deleteEpic as deleteEpicRequest,
@@ -1663,8 +1669,10 @@ async function knowledgeStats({ project_id }) {
1663
1669
  }
1664
1670
 
1665
1671
  async function knowledgeGraph({ article_id, depth, project_id }) {
1672
+ const art = articleId(article_id);
1673
+ if (art.refusal) return toContent(art.refusal);
1666
1674
  const params = new URLSearchParams();
1667
- params.set("article_id", article_id);
1675
+ params.set("article_id", art.id);
1668
1676
  if (depth != null) params.set("depth", String(depth));
1669
1677
  if (project_id) params.set("project_id", project_id);
1670
1678
  const result = await apiCall(
@@ -1677,11 +1685,13 @@ async function knowledgeGraph({ article_id, depth, project_id }) {
1677
1685
  }
1678
1686
 
1679
1687
  async function knowledgeSuggestLinks({ article_id, limit, threshold }) {
1688
+ const art = articleId(article_id);
1689
+ if (art.refusal) return toContent(art.refusal);
1680
1690
  const params = new URLSearchParams();
1681
1691
  if (limit != null) params.set("limit", String(limit));
1682
1692
  if (threshold != null) params.set("threshold", String(threshold));
1683
1693
  const qs = params.toString();
1684
- const base = `/api/v1/knowledge/articles/${article_id}/suggested_links`;
1694
+ const base = `/api/v1/knowledge/articles/${art.id}/suggested_links`;
1685
1695
  const result = await apiCall(
1686
1696
  "GET",
1687
1697
  qs ? `${base}?${qs}` : base,
@@ -1866,6 +1876,8 @@ async function knowledgeHeatIndex({ category, limit, since }) {
1866
1876
  }
1867
1877
 
1868
1878
  async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offset }) {
1879
+ const art = articleId(article_id, { prefix: true });
1880
+ if (art.refusal) return toContent(art.refusal);
1869
1881
  const params = new URLSearchParams();
1870
1882
  // 0 is meaningful on both (whole body / start at the beginning), so test for
1871
1883
  // null/undefined rather than truthiness.
@@ -1874,7 +1886,7 @@ async function knowledgeProgressiveDrill({ article_id, body_max_bytes, body_offs
1874
1886
  if (body_offset !== undefined && body_offset !== null)
1875
1887
  params.set("body_offset", String(body_offset));
1876
1888
  const qs = params.toString();
1877
- const base = `/api/v1/knowledge/progressive/${article_id}`;
1889
+ const base = `/api/v1/knowledge/progressive/${art.id}`;
1878
1890
  const result = await apiCall(
1879
1891
  "GET",
1880
1892
  qs ? `${base}?${qs}` : base,
@@ -1937,6 +1949,11 @@ async function knowledgeGet({
1937
1949
  body_max_bytes,
1938
1950
  body_offset,
1939
1951
  }) {
1952
+ // A missing id went out as /undefined and a malformed one as whatever it spelled
1953
+ // ('', '#', '..', 'x?y' each reach a different route); the server's answer read as
1954
+ // "this article does not exist". Refused here with status 0, before any request.
1955
+ const art = articleId(article_id, { prefix: true });
1956
+ if (art.refusal) return toContent(art.refusal);
1940
1957
  const params = new URLSearchParams();
1941
1958
  if (project_id) params.set("project_id", project_id);
1942
1959
  if (story_id) params.set("story_id", story_id);
@@ -1948,7 +1965,7 @@ async function knowledgeGet({
1948
1965
  if (body_offset !== undefined && body_offset !== null)
1949
1966
  params.set("body_offset", String(body_offset));
1950
1967
  const qs = params.toString();
1951
- const path = qs ? `/api/v1/articles/${article_id}?${qs}` : `/api/v1/articles/${article_id}`;
1968
+ const path = qs ? `/api/v1/articles/${art.id}?${qs}` : `/api/v1/articles/${art.id}`;
1952
1969
  const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
1953
1970
  return toContent(result);
1954
1971
  }
@@ -2026,6 +2043,8 @@ async function knowledgeCreate({
2026
2043
  // provided fields change. Agent role — KB-content curation, visibility-scoped
2027
2044
  // server-side (another agent's private/owner memory 404s).
2028
2045
  async function knowledgeUpdate({ article_id, title, body, category, tags, metadata }) {
2046
+ const art = articleId(article_id);
2047
+ if (art.refusal) return toContent(art.refusal);
2029
2048
  const payload = {};
2030
2049
  if (title != null) payload.title = title;
2031
2050
  if (body != null) payload.body = body;
@@ -2035,7 +2054,7 @@ async function knowledgeUpdate({ article_id, title, body, category, tags, metada
2035
2054
 
2036
2055
  const result = await apiCall(
2037
2056
  "PATCH",
2038
- `/api/v1/articles/${article_id}`,
2057
+ `/api/v1/articles/${art.id}`,
2039
2058
  payload,
2040
2059
  process.env.LOOPCTL_AGENT_KEY,
2041
2060
  );
@@ -2226,7 +2245,9 @@ async function memoryGraduate({ memory_id, re_scope }) {
2226
2245
  // --- Knowledge Management Tools (orch key) ---
2227
2246
 
2228
2247
  async function knowledgePublish({ article_id }) {
2229
- const result = await apiCall("POST", `/api/v1/articles/${article_id}/publish`, null, process.env.LOOPCTL_ORCH_KEY);
2248
+ const art = articleId(article_id);
2249
+ if (art.refusal) return toContent(art.refusal);
2250
+ const result = await apiCall("POST", `/api/v1/articles/${art.id}/publish`, null, process.env.LOOPCTL_ORCH_KEY);
2230
2251
  return toContent(result);
2231
2252
  }
2232
2253
 
@@ -2281,9 +2302,11 @@ async function knowledgeBulkUnpublish({ article_ids }) {
2281
2302
  }
2282
2303
 
2283
2304
  async function knowledgeUnpublish({ article_id }) {
2305
+ const art = articleId(article_id);
2306
+ if (art.refusal) return toContent(art.refusal);
2284
2307
  const result = await apiCall(
2285
2308
  "POST",
2286
- `/api/v1/articles/${article_id}/unpublish`,
2309
+ `/api/v1/articles/${art.id}/unpublish`,
2287
2310
  null,
2288
2311
  process.env.LOOPCTL_USER_KEY
2289
2312
  );
@@ -2294,9 +2317,11 @@ async function knowledgeUnpublish({ article_id }) {
2294
2317
  // audited, visibility-scoped server-side). NOT reversible in code — #606/#605: `:archived`
2295
2318
  // is a terminal status. The row survives; nothing automated brings it back.
2296
2319
  async function knowledgeArchive({ article_id }) {
2320
+ const art = articleId(article_id);
2321
+ if (art.refusal) return toContent(art.refusal);
2297
2322
  const result = await apiCall(
2298
2323
  "POST",
2299
- `/api/v1/articles/${article_id}/archive`,
2324
+ `/api/v1/articles/${art.id}/archive`,
2300
2325
  null,
2301
2326
  process.env.LOOPCTL_AGENT_KEY
2302
2327
  );
@@ -2307,9 +2332,11 @@ async function knowledgeArchive({ article_id }) {
2307
2332
  // one member of that family that undoes: nothing is destroyed and nothing is rebuilt, so
2308
2333
  // knowledge_unsuppress restores the article to every read path immediately.
2309
2334
  async function knowledgeSuppress({ article_id, reason }) {
2335
+ const art = articleId(article_id);
2336
+ if (art.refusal) return toContent(art.refusal);
2310
2337
  const result = await apiCall(
2311
2338
  "POST",
2312
- `/api/v1/articles/${article_id}/suppress`,
2339
+ `/api/v1/articles/${art.id}/suppress`,
2313
2340
  { reason },
2314
2341
  process.env.LOOPCTL_AGENT_KEY
2315
2342
  );
@@ -2317,9 +2344,11 @@ async function knowledgeSuppress({ article_id, reason }) {
2317
2344
  }
2318
2345
 
2319
2346
  async function knowledgeUnsuppress({ article_id }) {
2347
+ const art = articleId(article_id);
2348
+ if (art.refusal) return toContent(art.refusal);
2320
2349
  const result = await apiCall(
2321
2350
  "POST",
2322
- `/api/v1/articles/${article_id}/unsuppress`,
2351
+ `/api/v1/articles/${art.id}/unsuppress`,
2323
2352
  null,
2324
2353
  process.env.LOOPCTL_AGENT_KEY
2325
2354
  );
@@ -2328,9 +2357,11 @@ async function knowledgeUnsuppress({ article_id }) {
2328
2357
 
2329
2358
  // #331: soft-delete (archive) is agent-role KB curation, same as knowledge_archive.
2330
2359
  async function knowledgeDelete({ article_id }) {
2360
+ const art = articleId(article_id);
2361
+ if (art.refusal) return toContent(art.refusal);
2331
2362
  const result = await apiCall(
2332
2363
  "DELETE",
2333
- `/api/v1/articles/${article_id}`,
2364
+ `/api/v1/articles/${art.id}`,
2334
2365
  null,
2335
2366
  process.env.LOOPCTL_AGENT_KEY
2336
2367
  );
@@ -2610,6 +2641,27 @@ async function setLlmConfig({
2610
2641
  return toContent(result);
2611
2642
  }
2612
2643
 
2644
+ // --- #936: the tenant's own GitHub token ------------------------------------
2645
+ // A stored credential, so EXACT user key only, like set_llm_config.
2646
+ function userKeyApi() {
2647
+ return {
2648
+ apiCall: (method, path, body) =>
2649
+ apiCall(method, path, body, process.env.LOOPCTL_USER_KEY, { exactKey: true }),
2650
+ };
2651
+ }
2652
+
2653
+ async function githubCredential(args) {
2654
+ return toContent(await githubCredentialRequest(args, userKeyApi()));
2655
+ }
2656
+
2657
+ async function setGithubCredential(args) {
2658
+ return toContent(await setGithubCredentialRequest(args, userKeyApi()));
2659
+ }
2660
+
2661
+ async function clearGithubCredential(args) {
2662
+ return toContent(await clearGithubCredentialRequest(args, userKeyApi()));
2663
+ }
2664
+
2613
2665
  // --- US-41.4: fail-closed no-egress guard -----------------------------------
2614
2666
  //
2615
2667
  // egress_posture is a READ available at AGENT role: an agent must be able to
@@ -2786,9 +2838,11 @@ async function knowledgeCurationLog({ kind, since, limit, offset } = {}) {
2786
2838
  }
2787
2839
 
2788
2840
  async function knowledgeArticleStats({ article_id }) {
2841
+ const art = articleId(article_id, { prefix: true });
2842
+ if (art.refusal) return toContent(art.refusal);
2789
2843
  const result = await apiCall(
2790
2844
  "GET",
2791
- `/api/v1/knowledge/articles/${article_id}/stats`,
2845
+ `/api/v1/knowledge/articles/${art.id}/stats`,
2792
2846
  null,
2793
2847
  process.env.LOOPCTL_ORCH_KEY
2794
2848
  );
@@ -5516,7 +5570,8 @@ const TOOLS = [
5516
5570
  article_id: {
5517
5571
  type: "string",
5518
5572
  format: "uuid",
5519
- description: "Starting article UUID (required).",
5573
+ description:
5574
+ "Starting article UUID (required). A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
5520
5575
  },
5521
5576
  depth: {
5522
5577
  type: "integer",
@@ -5549,7 +5604,8 @@ const TOOLS = [
5549
5604
  article_id: {
5550
5605
  type: "string",
5551
5606
  format: "uuid",
5552
- description: "The article to suggest links for (required).",
5607
+ description:
5608
+ "The article to suggest links for (required). A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
5553
5609
  },
5554
5610
  limit: { type: "integer", description: "Max candidates (default 5)." },
5555
5611
  threshold: {
@@ -5899,8 +5955,8 @@ const TOOLS = [
5899
5955
  properties: {
5900
5956
  article_id: {
5901
5957
  type: "string",
5902
- format: "uuid",
5903
- description: "The UUID of the article to open (from a progressive index stub).",
5958
+ description:
5959
+ "The UUID of the article to open (from a progressive index stub). Or a prefix of at least 8 hex digits (the server matches its first 8). A missing, blank, shorter or non-hex value is refused locally with status 0 and no request is sent.",
5904
5960
  },
5905
5961
  body_max_bytes: {
5906
5962
  type: "integer",
@@ -5959,7 +6015,10 @@ const TOOLS = [
5959
6015
  description:
5960
6016
  "The UUID of the article. A unique ID PREFIX (>= 8 hex characters) also " +
5961
6017
  "resolves, so copy what you have rather than reconstructing 36 characters " +
5962
- "from memory; an ambiguous prefix is a 404, never a guess.",
6018
+ "from memory; an ambiguous prefix is a 404, never a guess. The server matches a " +
6019
+ "prefix on its first 8 hex digits only. Surrounding whitespace is trimmed. A " +
6020
+ "missing or blank id, fewer than 8 hex digits, or any character other than hex " +
6021
+ "digits and dashes is refused locally with status 0 and no request is sent.",
5963
6022
  },
5964
6023
  links: {
5965
6024
  type: "string",
@@ -6206,7 +6265,8 @@ const TOOLS = [
6206
6265
  properties: {
6207
6266
  article_id: {
6208
6267
  type: "string",
6209
- description: "The UUID of the article to edit (preserved across the update).",
6268
+ description:
6269
+ "The UUID of the article to edit (preserved across the update). A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6210
6270
  },
6211
6271
  title: {
6212
6272
  type: "string",
@@ -6584,7 +6644,8 @@ const TOOLS = [
6584
6644
  properties: {
6585
6645
  article_id: {
6586
6646
  type: "string",
6587
- description: "The UUID of the draft article to publish.",
6647
+ description:
6648
+ "The UUID of the draft article to publish. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6588
6649
  },
6589
6650
  },
6590
6651
  required: ["article_id"],
@@ -6652,7 +6713,8 @@ const TOOLS = [
6652
6713
  properties: {
6653
6714
  article_id: {
6654
6715
  type: "string",
6655
- description: "The UUID of the published article to unpublish.",
6716
+ description:
6717
+ "The UUID of the published article to unpublish. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6656
6718
  },
6657
6719
  },
6658
6720
  required: ["article_id"],
@@ -6675,7 +6737,8 @@ const TOOLS = [
6675
6737
  properties: {
6676
6738
  article_id: {
6677
6739
  type: "string",
6678
- description: "The UUID of the article to archive.",
6740
+ description:
6741
+ "The UUID of the article to archive. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6679
6742
  },
6680
6743
  },
6681
6744
  required: ["article_id"],
@@ -6710,7 +6773,8 @@ const TOOLS = [
6710
6773
  properties: {
6711
6774
  article_id: {
6712
6775
  type: "string",
6713
- description: "The UUID of the article to take out of retrieval.",
6776
+ description:
6777
+ "The UUID of the article to take out of retrieval. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6714
6778
  },
6715
6779
  reason: {
6716
6780
  type: "string",
@@ -6738,7 +6802,8 @@ const TOOLS = [
6738
6802
  properties: {
6739
6803
  article_id: {
6740
6804
  type: "string",
6741
- description: "The UUID of the article to restore to retrieval.",
6805
+ description:
6806
+ "The UUID of the article to restore to retrieval. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6742
6807
  },
6743
6808
  },
6744
6809
  required: ["article_id"],
@@ -6760,7 +6825,8 @@ const TOOLS = [
6760
6825
  properties: {
6761
6826
  article_id: {
6762
6827
  type: "string",
6763
- description: "The UUID of the article to delete.",
6828
+ description:
6829
+ "The UUID of the article to delete. A full UUID: anything else (missing, blank, a prefix) is refused locally with status 0 and no request is sent.",
6764
6830
  },
6765
6831
  },
6766
6832
  required: ["article_id"],
@@ -7596,6 +7662,59 @@ const TOOLS = [
7596
7662
  required: ["host"],
7597
7663
  },
7598
7664
  },
7665
+ {
7666
+ name: "github_credential",
7667
+ description:
7668
+ "CHECK this tenant's GitHub credential (#936): `has_token`, a masked last-4 " +
7669
+ "`token_hint`, `updated_at`, and `operator_repositories` — the repositories the " +
7670
+ "operator's token is lent for while you have no token of your own. NEVER returns the " +
7671
+ "token. Reach for it when a merge gate refusal says " +
7672
+ "`{pull_request_unavailable, credential_unavailable}`, a verification run records " +
7673
+ "`credential_unavailable`, or an issue closure is waiting on that reason: it " +
7674
+ "tells you whether the repository has any credential at all. Requires " +
7675
+ "LOOPCTL_USER_KEY (403 for any other role).",
7676
+ inputSchema: { type: "object", properties: {}, required: [] },
7677
+ },
7678
+ {
7679
+ name: "set_github_credential",
7680
+ description:
7681
+ "SET OR REPLACE this tenant's own GitHub token (#936, PUT " +
7682
+ "/api/v1/tenants/me/github-credential). From then on EVERY GitHub call loopctl makes for " +
7683
+ "this tenant uses it — the merge gate, story and post-deploy verification, thread " +
7684
+ "checkpoint reads and issue closing — for EVERY repository, replacing the operator's " +
7685
+ "token even for repositories the operator lent it for. Use a fine-grained token limited " +
7686
+ "to your own intake-source repositories with read access to Contents, Pull requests, " +
7687
+ "Actions, Commit statuses and Deployments, plus Issues: write if loopctl should close " +
7688
+ "the issues stories came from. Stored encrypted, never returned. NOT checked against " +
7689
+ "GitHub here: a token that cannot read a repository shows up as that repository's forge " +
7690
+ "refusal (a 403/404 in the merge gate or verification), so check a story after setting " +
7691
+ "it. A thread-mode MERGE is still written by loopctl's GitHub App, and only for a " +
7692
+ "repository the operator named for your tenant: this token never licenses it (escalated " +
7693
+ "`app_not_licensed`). 422 when blank, over the server's length bound or containing " +
7694
+ "anything but letters, digits and underscores; 403 " +
7695
+ "custody_tier_required on an agent-rooted tenant. Requires LOOPCTL_USER_KEY (403 for " +
7696
+ "any other role).",
7697
+ inputSchema: {
7698
+ type: "object",
7699
+ properties: {
7700
+ token: {
7701
+ type: "string",
7702
+ description: "The GitHub token. Write-only; stored encrypted, never returned.",
7703
+ },
7704
+ },
7705
+ required: ["token"],
7706
+ },
7707
+ },
7708
+ {
7709
+ name: "clear_github_credential",
7710
+ description:
7711
+ "REMOVE this tenant's GitHub token (#936, DELETE /api/v1/tenants/me/github-credential). " +
7712
+ "Every GitHub call for the tenant then uses the operator's token ONLY for the " +
7713
+ "repositories `github_credential` lists as `operator_repositories`, and is refused " +
7714
+ "`credential_unavailable` for every other one — a pr-mode repository outside that list " +
7715
+ "stops merging. Idempotent. Requires LOOPCTL_USER_KEY (403 for any other role).",
7716
+ inputSchema: { type: "object", properties: {}, required: [] },
7717
+ },
7599
7718
  {
7600
7719
  name: "llm_config",
7601
7720
  description:
@@ -8013,7 +8132,8 @@ const TOOLS = [
8013
8132
  properties: {
8014
8133
  article_id: {
8015
8134
  type: "string",
8016
- description: "The UUID of the article to inspect.",
8135
+ description:
8136
+ "The UUID of the article to inspect. Or a prefix of at least 8 hex digits (the server matches its first 8). A missing, blank, shorter or non-hex value is refused locally with status 0 and no request is sent.",
8017
8137
  },
8018
8138
  },
8019
8139
  required: ["article_id"],
@@ -9023,13 +9143,13 @@ const TOOLS = [
9023
9143
  "merge gate allows it, and the checks story verification judges a story's commit " +
9024
9144
  "by in EITHER mode: GitHub Actions job names as they appear on the commit, satisfied " +
9025
9145
  "only by a job of a PUSH run of the story's branch. A `thread` source must name at " +
9026
- "least one (422 otherwise). Verification first needs an entry for this tenant in " +
9027
- "the operator's VERIFICATION_OPERATOR_TOKEN_TENANTS (`credential_unavailable` " +
9028
- "otherwise, whatever this list holds); then a `pr` source that names none records " +
9029
- "`no_required_checks` and reads nothing (naming them is how a pr source opts in), " +
9030
- "and a source that names some has its (tenant, repository) pair checked against " +
9031
- "that list before any GitHub read (`credential_unavailable` if the pair is not an " +
9032
- "entry). Distinct, non-blank names, bounded in number and length by the server (422 past them). " +
9146
+ "least one (422 otherwise). Verification first needs a GitHub credential for this " +
9147
+ "tenant: its own token (set_github_credential) or an entry in the operator's " +
9148
+ "VERIFICATION_OPERATOR_TOKEN_TENANTS (`credential_unavailable` otherwise, whatever " +
9149
+ "this list holds); then a `pr` source that names none records `no_required_checks` " +
9150
+ "and reads nothing (naming them is how a pr source opts in), and a source that " +
9151
+ "names some has its (tenant, repository) credential resolved before any GitHub " +
9152
+ "read (`credential_unavailable` if there is none). Distinct, non-blank names, bounded in number and length by the server (422 past them). " +
9033
9153
  "`local-gate` is refused: whoever pushed posts it, so it is only recorded.",
9034
9154
  },
9035
9155
  secret_file: {
@@ -9140,13 +9260,13 @@ const TOOLS = [
9140
9260
  "merge gate allows it, and the checks story verification judges a story's commit " +
9141
9261
  "by in EITHER mode: GitHub Actions job names as they appear on the commit, satisfied " +
9142
9262
  "only by a job of a PUSH run of the story's branch. A `thread` source must name at " +
9143
- "least one (422 otherwise). Verification first needs an entry for this tenant in " +
9144
- "the operator's VERIFICATION_OPERATOR_TOKEN_TENANTS (`credential_unavailable` " +
9145
- "otherwise, whatever this list holds); then a `pr` source that names none records " +
9146
- "`no_required_checks` and reads nothing (naming them is how a pr source opts in), " +
9147
- "and a source that names some has its (tenant, repository) pair checked against " +
9148
- "that list before any GitHub read (`credential_unavailable` if the pair is not an " +
9149
- "entry). Distinct, non-blank names, bounded in number and length by the server (422 past them). " +
9263
+ "least one (422 otherwise). Verification first needs a GitHub credential for this " +
9264
+ "tenant: its own token (set_github_credential) or an entry in the operator's " +
9265
+ "VERIFICATION_OPERATOR_TOKEN_TENANTS (`credential_unavailable` otherwise, whatever " +
9266
+ "this list holds); then a `pr` source that names none records `no_required_checks` " +
9267
+ "and reads nothing (naming them is how a pr source opts in), and a source that " +
9268
+ "names some has its (tenant, repository) credential resolved before any GitHub " +
9269
+ "read (`credential_unavailable` if there is none). Distinct, non-blank names, bounded in number and length by the server (422 past them). " +
9150
9270
  "`local-gate` is refused. Omit to leave the current list alone.",
9151
9271
  },
9152
9272
  },
@@ -9825,6 +9945,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
9825
9945
  );
9826
9946
  },
9827
9947
  declaredToolArgs(name),
9948
+ name,
9828
9949
  );
9829
9950
 
9830
9951
  switch (name) {
@@ -10182,6 +10303,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
10182
10303
  case "set_llm_config":
10183
10304
  return await setLlmConfig(args);
10184
10305
 
10306
+ // #936: the tenant's own GitHub token
10307
+ case "github_credential":
10308
+ return await githubCredential(args);
10309
+
10310
+ case "set_github_credential":
10311
+ return await setGithubCredential(args);
10312
+
10313
+ case "clear_github_credential":
10314
+ return await clearGithubCredential(args);
10315
+
10185
10316
  case "knowledge_llm_usage":
10186
10317
  return await knowledgeLlmUsage(args);
10187
10318
 
@@ -66,8 +66,26 @@ const ARG_ALIASES = {
66
66
  limit: ["max_results"],
67
67
  };
68
68
 
69
+ // TOOL-SCOPED aliases, keyed by tool name, applied only when the called tool declares the
70
+ // canonical and not the alias. `id` cannot go in the global table above: memory_forget and
71
+ // others declare `id` as their own parameter, and the drift guard refuses it there.
72
+ //
73
+ // `article_id <- id` on knowledge_get is measured: 17 of 96 knowledge_get calls in mac-mini's
74
+ // session transcripts (2026-10-01) passed `{"id": ...}`, the key every search result carries
75
+ // its id under, and the request went out as `/api/v1/articles/undefined`. All 30
76
+ // knowledge_get 404s in production for 2026-09-25..30 were that request.
77
+ // knowledge_progressive_drill is the same mechanism: its documented input is a progressive
78
+ // index stub, which carries the id under the same `id` key. A write verb never joins: there
79
+ // the alias would turn a harmless miss into a write against an id copied from the wrong row.
80
+ const TOOL_SCOPED_ALIASES = {
81
+ knowledge_get: { article_id: ["id"] },
82
+ knowledge_progressive_drill: { article_id: ["id"] },
83
+ };
84
+
85
+ // Whitespace-only is blank, matching articleId(), which trims: otherwise a " " article_id
86
+ // blocks the rescue here and is then refused as missing there, discarding a good `id`.
69
87
  function isBlank(v) {
70
- return v === undefined || v === null || v === "";
88
+ return v === undefined || v === null || (typeof v === "string" && v.trim() === "");
71
89
  }
72
90
 
73
91
  /**
@@ -78,8 +96,11 @@ function isBlank(v) {
78
96
  * supplied, only a fill of a declared key is reported as a rescue — the rest are inert
79
97
  * conveniences. Omit it and every fill is reported (the conservative default for a tool
80
98
  * whose schema is not known, e.g. the per-tenant `cr_*` tools).
99
+ *
100
+ * `toolName` selects that tool's TOOL_SCOPED_ALIASES, which apply only when BOTH `declared`
101
+ * and `toolName` are given.
81
102
  */
82
- function applyArgAliases(args, onAliasUsed, declared) {
103
+ function applyArgAliases(args, onAliasUsed, declared, toolName) {
83
104
  if (!args || typeof args !== "object" || Array.isArray(args)) return args;
84
105
 
85
106
  const out = { ...args };
@@ -109,6 +130,24 @@ function applyArgAliases(args, onAliasUsed, declared) {
109
130
  }
110
131
  }
111
132
 
133
+ const scoped =
134
+ declaredSet !== null && toolName && Object.hasOwn(TOOL_SCOPED_ALIASES, toolName)
135
+ ? TOOL_SCOPED_ALIASES[toolName]
136
+ : {};
137
+ for (const [canonical, aliases] of Object.entries(scoped)) {
138
+ if (!declaredSet.has(canonical) || !isBlank(out[canonical])) continue;
139
+ const alias = aliases.find((a) => !declaredSet.has(a) && !isBlank(out[a]));
140
+ if (alias === undefined) continue;
141
+ out[canonical] = out[alias];
142
+ if (typeof onAliasUsed === "function") {
143
+ try {
144
+ onAliasUsed({ canonical, alias });
145
+ } catch {
146
+ // Telemetry must never break a tool call.
147
+ }
148
+ }
149
+ }
150
+
112
151
  // Some callers send a numeric arg as a string ("6"). Coerce so downstream validation and
113
152
  // the outbound query string both see a number rather than rejecting or double-encoding.
114
153
  if (typeof out.limit === "string" && /^\d+$/.test(out.limit.trim())) {
@@ -118,4 +157,4 @@ function applyArgAliases(args, onAliasUsed, declared) {
118
157
  return out;
119
158
  }
120
159
 
121
- export { ARG_ALIASES, applyArgAliases };
160
+ export { ARG_ALIASES, TOOL_SCOPED_ALIASES, applyArgAliases };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * An `article_id` bound for a URL path or query: trimmed (an id pasted with a trailing newline
3
+ * is the id), then held to the shared `uuid()` check in lib/delivery-loop.js. Returns `{ id }`
4
+ * to send, or `{ refusal }` (status 0, value never echoed).
5
+ *
6
+ * The reads whose server path resolves an id prefix (#652: knowledge_get,
7
+ * knowledge_progressive_drill, knowledge_article_stats) pass `{ prefix: true }`; every other
8
+ * article verb takes a full UUID. A missing id used to go out as `/undefined` and come back as
9
+ * a 404 that agents read as "the article does not exist".
10
+ */
11
+ import { uuid } from "./delivery-loop.js";
12
+
13
+ export function articleId(value, { prefix = false } = {}) {
14
+ const trimmed = typeof value === "string" ? value.trim() : value;
15
+ const refusal = uuid(trimmed, "article_id", { prefix });
16
+ return refusal ? { refusal } : { id: trimmed };
17
+ }
@@ -48,7 +48,9 @@ function refuse(body) {
48
48
  }
49
49
 
50
50
  /**
51
- * A client-side shape check on the ids that go into a URL PATH: `story_id` and `runner_id`.
51
+ * A client-side shape check on the ids that go into a URL PATH: `story_id` and `runner_id`
52
+ * here, and `article_id` on the knowledge verbs through `lib/article-id.js` (which adds the
53
+ * `prefix` grammar its reads accept).
52
54
  *
53
55
  * EXPORTED, because it is one check and not a pattern to copy. `update_story`
54
56
  * (`lib/story-update.js`) interpolates a `story_id` into a path exactly as the verbs below do
@@ -111,15 +113,30 @@ function refuse(body) {
111
113
  * into the transcript.
112
114
  */
113
115
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
114
-
115
- export function uuid(value, field) {
116
- if (typeof value !== "string" || value.trim() === "") {
116
+ const PREFIX_RE = /^[0-9a-f-]+$/i;
117
+
118
+ // `prefix: true` also accepts what the server's id-prefix fallback resolves (#652,
119
+ // `Knowledge.id_prefix_range/1`, reached by knowledge_get, knowledge_progressive_drill and
120
+ // knowledge_article_stats): hex digits and dashes with at least 8 hex digits, dashless or
121
+ // partial. The server compares only the FIRST 8 hex digits and resolves when exactly one
122
+ // article has them: the rest of a longer value is not checked, so a full-length id with a
123
+ // wrong tail resolves to the article owning its first 8 (#652's design, for confabulated tails).
124
+ // Only hex and dashes are accepted, so nothing in a prefix can change the route ('#', '?', '/').
125
+ export function uuid(value, field, { prefix = false } = {}) {
126
+ if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) {
117
127
  return refuse(`\`${field}\` is required.`);
118
128
  }
119
129
 
130
+ if (typeof value !== "string") {
131
+ return refuse(`\`${field}\` must be a string; got a ${Array.isArray(value) ? "array" : typeof value}.`);
132
+ }
133
+
134
+ if (prefix && PREFIX_RE.test(value) && value.replace(/-/g, "").length >= 8) return null;
135
+
120
136
  if (!UUID_RE.test(value)) {
121
137
  return refuse(
122
- `\`${field}\` must be a UUID (8-4-4-4 hex digits then 12, lowercase or upper). ` +
138
+ `\`${field}\` must be a UUID (8-4-4-4 hex digits then 12, lowercase or upper)` +
139
+ (prefix ? " or a prefix of at least 8 hex digits. " : ". ") +
123
140
  `Got a ${value.length}-character string that is not one. The value is not repeated ` +
124
141
  `here: a malformed id is often something pasted into the wrong argument, and a tool ` +
125
142
  `result lands in the transcript.`,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `github_credential`, `set_github_credential`, `clear_github_credential` — the tenant's own
3
+ * GitHub token (#936), `GET|PUT|DELETE /api/v1/tenants/me/github-credential`.
4
+ *
5
+ * loopctl calls GitHub for a tenant in the merge gate, story and post-deploy verification,
6
+ * thread checkpoint reads and issue closing. Each call authenticates with the tenant's own
7
+ * token when one is set, else with the operator's token for the (tenant, repository) pairs
8
+ * the operator named in VERIFICATION_OPERATOR_TOKEN_TENANTS, else not at all
9
+ * (`credential_unavailable`). The tenant's token is what lets loopctl serve a repository
10
+ * the operator's token cannot, without ever reading one the tenant cannot.
11
+ *
12
+ * All three need the `:user` key: the endpoint manages a stored credential. The token is
13
+ * never returned — reads show `has_token`, a last-4 `token_hint` and the repositories the
14
+ * operator's token is lent for.
15
+ */
16
+
17
+ export const GITHUB_CREDENTIAL_PATH = "/api/v1/tenants/me/github-credential";
18
+
19
+ function refuse(body) {
20
+ return { error: true, status: 0, body };
21
+ }
22
+
23
+ export async function githubCredential(_args = {}, { apiCall } = {}) {
24
+ return apiCall("GET", GITHUB_CREDENTIAL_PATH, null);
25
+ }
26
+
27
+ /**
28
+ * A missing or non-string token is refused HERE, before any call, and the refusal never
29
+ * repeats the value: a tool result lands in the transcript.
30
+ */
31
+ export async function setGithubCredential({ token } = {}, { apiCall } = {}) {
32
+ if (typeof token !== "string" || token.trim() === "") {
33
+ return refuse(
34
+ "`token` is required: a GitHub token (a fine-grained token limited to your own " +
35
+ "repositories is the intended shape) with read access to contents, pull requests, " +
36
+ "actions, commit statuses and deployments, plus issues: write for issue closing.",
37
+ );
38
+ }
39
+
40
+ return apiCall("PUT", GITHUB_CREDENTIAL_PATH, { token });
41
+ }
42
+
43
+ export async function clearGithubCredential(_args = {}, { apiCall } = {}) {
44
+ return apiCall("DELETE", GITHUB_CREDENTIAL_PATH, null);
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.111.2",
3
+ "version": "2.113.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",