@papi-ai/server 0.7.84 → 0.7.85

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/dist/index.js CHANGED
@@ -73,11 +73,20 @@ function resolveDeploymentProfile(env) {
73
73
  }
74
74
  };
75
75
  }
76
+ function isNoneLeadProse(trimmed) {
77
+ const m = NONE_LEAD_RE.exec(trimmed);
78
+ if (!m) return false;
79
+ if (/\bP[0-3]\s*[:—–-]/.test(trimmed)) return false;
80
+ const rest = (m.groups?.rest ?? "").replace(/^[-•]\s*/, "").trim();
81
+ if (CONTRAST_LEAD_RE.test(rest)) return false;
82
+ return true;
83
+ }
76
84
  function splitFindings(raw, kind) {
77
85
  if (!raw) return [];
78
86
  const trimmed = raw.trim();
79
87
  if (trimmed.length === 0 || NONE_RE.test(trimmed)) return [];
80
88
  if (kind !== "issue" && AD_HOC_PLACEHOLDER_RE.test(trimmed)) return [];
89
+ if (isNoneLeadProse(trimmed)) return [];
81
90
  const chunks = trimmed.split(/\n+/).flatMap((line) => line.split(/(?=\bP[0-3]\s*[:—-])/)).map((s) => s.trim()).filter((s) => s.length > 0 && !NONE_RE.test(s));
82
91
  const items = [];
83
92
  for (const chunk of chunks) {
@@ -760,7 +769,7 @@ function parsePhaseBlock(block) {
760
769
  if (isNaN(order)) return null;
761
770
  return { id, slug, label, description, status, order };
762
771
  }
763
- var CAPABILITY_REGISTRY, CAPABILITY_KEYS, ASSIGNABLE_CONTRIBUTOR_ROLES, SENSITIVE_CHANGELOG_PATTERNS, CONTRIBUTOR_PRICING_URL, HOSTED_APP_URL, HOSTED_MCP_URL, HOSTED_SUPABASE_URL, SEVERITY_ORDER, NONE_RE, AD_HOC_PLACEHOLDER_RE, MIN_REASON_LENGTH, LOW_SEVERITIES, WAB_WINDOW_DAYS, WAB_DEFAULT_WEEKS, WAB_WEEK_MS, PRODUCT_MARKER, MECHANICS_MARKER, CHECK_VALUE_MAX, VALID_EFFORT_SIZES, SECTION_HEADERS, VALID_EFFORT_SIZES2, EFFORT_SCALE, NONE_PATTERN, NONE_PATTERN2, VALID_STATUSES, PHASES_START, PHASES_END;
772
+ var CAPABILITY_REGISTRY, CAPABILITY_KEYS, ASSIGNABLE_CONTRIBUTOR_ROLES, SENSITIVE_CHANGELOG_PATTERNS, CONTRIBUTOR_PRICING_URL, HOSTED_APP_URL, HOSTED_MCP_URL, HOSTED_SUPABASE_URL, SEVERITY_ORDER, NONE_RE, AD_HOC_PLACEHOLDER_RE, NONE_LEAD_RE, CONTRAST_LEAD_RE, MIN_REASON_LENGTH, LOW_SEVERITIES, WAB_WINDOW_DAYS, WAB_DEFAULT_WEEKS, WAB_WEEK_MS, PRODUCT_MARKER, MECHANICS_MARKER, CHECK_VALUE_MAX, VALID_EFFORT_SIZES, SECTION_HEADERS, VALID_EFFORT_SIZES2, EFFORT_SCALE, NONE_PATTERN, NONE_PATTERN2, VALID_STATUSES, PHASES_START, PHASES_END;
764
773
  var init_dist = __esm({
765
774
  "../shared/dist/index.js"() {
766
775
  "use strict";
@@ -800,7 +809,21 @@ var init_dist = __esm({
800
809
  { key: "autoBranch", label: "Auto branch", description: "Creates a feature branch per task/cycle at build start.", step: "build" },
801
810
  { key: "autoCommit", label: "Auto commit", description: "Commits your work automatically when a build completes.", step: "build" },
802
811
  { key: "autoPush", label: "Auto push & PR", description: "Pushes the branch and opens a pull request on build complete.", step: "build" },
803
- { key: "papiMetaFraming", label: "PAPI self-referential framing", description: "Includes PAPI-meta build-discipline framing in handoff output.", step: "build" }
812
+ { key: "papiMetaFraming", label: "PAPI self-referential framing", description: "Includes PAPI-meta build-discipline framing in handoff output.", step: "build" },
813
+ // Editor-equal release. Two legitimate team shapes need opposite answers, so
814
+ // this is a PROJECT policy rather than a global rule:
815
+ //
816
+ // OFF (default) — an editor's release opens a CONTRIBUTOR PR and PAPI records
817
+ // the release when GitHub merges it. Right for an open//multi-contributor
818
+ // product where the project owner is the last gate before production.
819
+ // ON — an editor releases on exactly the owner's path. Right for a small team
820
+ // of trusted peers who each run whole cycles end to end, where routing every
821
+ // close back through one person is the bottleneck, not the safeguard.
822
+ //
823
+ // Defaults OFF so existing projects are byte-identical, and because widening who
824
+ // can ship to production must be a deliberate choice, never a silent upgrade.
825
+ // A VIEWER is denied either way — this moves the editor line only.
826
+ { key: "editorRelease", label: "Editors release like the owner", description: "Lets editors run a full release instead of opening a contributor PR.", step: "release", defaultEnabled: false }
804
827
  ];
805
828
  CAPABILITY_KEYS = CAPABILITY_REGISTRY.map((c) => c.key);
806
829
  ASSIGNABLE_CONTRIBUTOR_ROLES = ["editor", "viewer"];
@@ -852,6 +875,8 @@ var init_dist = __esm({
852
875
  };
853
876
  NONE_RE = /^(none|n\/a|no(ne)? (found|discovered))\.?$/i;
854
877
  AD_HOC_PLACEHOLDER_RE = /^none\s*[—–-]\s*ad[-\s]?hoc work\.?$/i;
878
+ NONE_LEAD_RE = /^(none|n\/a|no(ne)? (found|discovered))\s*[.,:;—–-]\s*(?<rest>.*)$/is;
879
+ CONTRAST_LEAD_RE = /^(but|however|although|though|except|aside from|other than|besides)\b/i;
855
880
  MIN_REASON_LENGTH = 12;
856
881
  LOW_SEVERITIES = /* @__PURE__ */ new Set(["P2", "P3"]);
857
882
  WAB_WINDOW_DAYS = 7;
@@ -907,6 +932,7 @@ __export(git_exports, {
907
932
  detectUnrecordedCommits: () => detectUnrecordedCommits,
908
933
  ensureLatestDevelop: () => ensureLatestDevelop,
909
934
  ensureTagAtHead: () => ensureTagAtHead,
935
+ findContributorReleasePullRequests: () => findContributorReleasePullRequests,
910
936
  findTaskCommitsOnBase: () => findTaskCommitsOnBase,
911
937
  getBranchDiff: () => getBranchDiff,
912
938
  getCommitFiles: () => getCommitFiles,
@@ -924,6 +950,7 @@ __export(git_exports, {
924
950
  getOriginRepoSlug: () => getOriginRepoSlug,
925
951
  getOriginUrl: () => getOriginUrl,
926
952
  getPathsDifferingFrom: () => getPathsDifferingFrom,
953
+ getPullRequestState: () => getPullRequestState,
927
954
  getPullRequestUrl: () => getPullRequestUrl,
928
955
  getRemoteBranchFiles: () => getRemoteBranchFiles,
929
956
  getRootCommitHash: () => getRootCommitHash,
@@ -1887,6 +1914,56 @@ function getPullRequestUrl(cwd, branch) {
1887
1914
  return null;
1888
1915
  }
1889
1916
  }
1917
+ function getPullRequestState(cwd, prUrl) {
1918
+ try {
1919
+ const output = execFileSync(
1920
+ "gh",
1921
+ ["pr", "view", prUrl, "--json", "url,state,mergedAt"],
1922
+ { cwd, encoding: "utf-8" }
1923
+ ).trim();
1924
+ if (!output) return null;
1925
+ const parsed = JSON.parse(output);
1926
+ if (!parsed.url || !["OPEN", "CLOSED", "MERGED"].includes(parsed.state ?? "")) return null;
1927
+ return {
1928
+ url: parsed.url,
1929
+ state: parsed.state,
1930
+ mergedAt: parsed.mergedAt ?? null
1931
+ };
1932
+ } catch {
1933
+ return null;
1934
+ }
1935
+ }
1936
+ function findContributorReleasePullRequests(cwd, cycle) {
1937
+ if (!Number.isInteger(cycle) || cycle <= 0) return [];
1938
+ try {
1939
+ const output = execFileSync(
1940
+ "gh",
1941
+ [
1942
+ "pr",
1943
+ "list",
1944
+ "--state",
1945
+ "all",
1946
+ "--limit",
1947
+ "20",
1948
+ "--search",
1949
+ `"Contributor release PR for cycle ${cycle}" in:body`,
1950
+ "--json",
1951
+ "url,state,mergedAt,headRefName"
1952
+ ],
1953
+ { cwd, encoding: "utf-8" }
1954
+ ).trim();
1955
+ if (!output) return [];
1956
+ const parsed = JSON.parse(output);
1957
+ return parsed.flatMap((pr) => pr.url && pr.headRefName && ["OPEN", "CLOSED", "MERGED"].includes(pr.state ?? "") ? [{
1958
+ url: pr.url,
1959
+ state: pr.state,
1960
+ mergedAt: pr.mergedAt ?? null,
1961
+ branch: pr.headRefName
1962
+ }] : []);
1963
+ } catch {
1964
+ return [];
1965
+ }
1966
+ }
1890
1967
  function squashMergePullRequest(cwd, branch) {
1891
1968
  const repo = getOriginRepoSlug(cwd);
1892
1969
  const baseArgs = ["pr", "merge", branch, "--squash", "--delete-branch"];
@@ -2320,6 +2397,10 @@ var init_proxy_adapter = __esm({
2320
2397
  "dismissRecommendation",
2321
2398
  "findPendingDocActionsForTask",
2322
2399
  "getActiveDecisions",
2400
+ "getContributorRole",
2401
+ "listContributorReleasePrs",
2402
+ "recordContributorReleasePr",
2403
+ "setContributorReleasePrStatus",
2323
2404
  "getActiveStage",
2324
2405
  "getBuildReportCountForTask",
2325
2406
  "getBuildReportsSince",
@@ -2459,7 +2540,7 @@ var init_proxy_adapter = __esm({
2459
2540
  "applyActiveDecisionUpdates",
2460
2541
  // getToolCallCount + updatePhaseStatus are ABSENT: they now have edge handlers and
2461
2542
  // are served through the forwarder (getToolCallCount = SUP-2026-026 handler #1).
2462
- "getContributorRole",
2543
+ // getContributorRole is edge-wired and binds identity from the bearer.
2463
2544
  // task-3018 (C356): storeDocBody + getDocBodyUsage are now WIRED — edge case
2464
2545
  // handlers + ALLOWED_METHODS/WRITE_METHODS entries exist, so they forward and
2465
2546
  // hosted callers get body storage. Removed from this list, as task-3017 required.
@@ -2467,9 +2548,6 @@ var init_proxy_adapter = __esm({
2467
2548
  // of the owner-action queue. Six readers were wired C329 (task-2412) but the
2468
2549
  // producer stayed here, so the hosted Owner Action Queue was structurally empty
2469
2550
  // (AD-74 inverted). Now forwards; the edge binds user_id to the bearer ([C]).
2470
- "recordContributorReleasePr",
2471
- "setContributorReleasePrStatus",
2472
- "listContributorReleasePrs",
2473
2551
  "claimReview",
2474
2552
  "getSiblingAds",
2475
2553
  "getSiblingRepoTasks",
@@ -3185,8 +3263,8 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
3185
3263
  getCycleLearningPatterns() {
3186
3264
  return this.invoke("getCycleLearningPatterns", []);
3187
3265
  }
3188
- updateCycleLearningActionRef(learningId, taskDisplayId) {
3189
- return this.invoke("updateCycleLearningActionRef", [learningId, taskDisplayId]);
3266
+ updateCycleLearningActionRef(learningId, taskDisplayId, opts) {
3267
+ return this.invoke("updateCycleLearningActionRef", [learningId, taskDisplayId, opts ?? {}]);
3190
3268
  }
3191
3269
  // --- Strategy Review Drafts ---
3192
3270
  savePendingReviewResponse(cycleNumber, rawResponse) {
@@ -3292,7 +3370,11 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
3292
3370
  */
3293
3371
  async getMeteredUsage() {
3294
3372
  const body = await this.postRoute("metering", {});
3295
- return { tier: body.tier ?? "free", monthlyToolCalls: body.monthlyToolCalls ?? 0 };
3373
+ return {
3374
+ tier: body.tier ?? "free",
3375
+ monthlyToolCalls: body.monthlyToolCalls ?? 0,
3376
+ projectLimitOverride: body.projectLimitOverride ?? null
3377
+ };
3296
3378
  }
3297
3379
  async listUserProjects() {
3298
3380
  const body = await this.postRoute("project-list", {});
@@ -6404,7 +6486,7 @@ async function runSetup() {
6404
6486
  log(`Project: ${result.project_slug}`);
6405
6487
  }
6406
6488
  log("");
6407
- log(`Done. Open Claude Code in this folder and say "run setup" to scaffold your first plan.`);
6489
+ log(`Done. Open your AI client in this folder and say "run setup" to scaffold your first plan.`);
6408
6490
  return 0;
6409
6491
  }
6410
6492
  var DEFAULT_BASE_URL, MAX_POLL_SECONDS, NETWORK_RETRY_LIMIT;
@@ -6512,7 +6594,7 @@ function loadConfig() {
6512
6594
  ${conflicting} is set in your environment, but PAPI_DATA_API_KEY is also set \u2014
6513
6595
  you have a hosted PAPI account, so the database URL is being ignored anyway.
6514
6596
 
6515
- Fix: \`unset ${conflicting}\` (and PAPI_ADAPTER if also set) before starting Claude Code,
6597
+ Fix: \`unset ${conflicting}\` (and PAPI_ADAPTER if also set) before starting your AI client,
6516
6598
  or remove it from your shell rc file (~/.zshrc, ~/.bashrc).
6517
6599
 
6518
6600
  If you intentionally self-host, set PAPI_SELF_HOST=1 in your environment to bypass this guard.`
@@ -6957,7 +7039,7 @@ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the
6957
7039
  Get started in 3 steps:
6958
7040
  1. Sign up at ${dashboardUrl ? `${dashboardUrl}/login` : "your configured PAPI dashboard"}
6959
7041
  2. Complete the onboarding wizard \u2014 it generates your .mcp.json config
6960
- 3. Download the config, place it in your project root, and restart Claude Code
7042
+ 3. Download the config, place it in your project root, and restart your AI client
6961
7043
 
6962
7044
  Already have an account? Make sure PAPI_DATA_API_KEY is set in your .mcp.json env config.`
6963
7045
  );
@@ -7883,7 +7965,11 @@ The guard above says what to reject. This says what to propose. Rejecting is not
7883
7965
 
7884
7966
  **Copy variants are NEVER decisions.** A tagline, a headline, a value proposition, a piece of marketing or product wording: swapping one for another constrains no future work, so it fails test (b) outright. Wording changes as often as the market teaches you something, and routing every edit through decision ceremony is what buries the handful of decisions that genuinely constrain the project. Edit the wording where the wording lives. If two variants are being compared against a measurement, that is an experiment, not a decision.
7885
7967
 
7886
- **A decision decaying into a convention is the healthy path, not a failure.** When a live AD stops being arguable \u2014 the alternatives are no longer on the table and nobody would re-litigate it \u2014 say so during housekeeping and propose retiring it into a convention. An AD registry where most entries are settled is one nobody reads.`;
7968
+ **A decision decaying into a convention is the healthy path, not a failure.** When a live AD stops being arguable \u2014 the alternatives are no longer on the table and nobody would re-litigate it \u2014 propose demoting it during housekeeping with \`action: "demote"\`. An AD registry where most entries are settled is one nobody reads.
7969
+
7970
+ DEMOTION IS THE THIRD TRANSITION, and it is not either of the other two. \`supersede\` replaces a stance with a NEW one; \`delete\` removes something that was never a decision. \`demote\` is for a decision that was RIGHT and still holds \u2014 it keeps riding every build handoff as a convention and simply stops occupying decision surface. Apply test (c) to each live AD: could a competent person argue the other side TODAY, on current evidence? If no, propose \`demote\` and give the one-line reason it is no longer contested.
7971
+
7972
+ PROPOSE, NEVER ASSUME. A demotion is a judgement about whether something is still arguable, and the owner is the one who knows. Surface candidates with reasoning and let the human confirm on apply, exactly as board corrections and cancellations are gated. Do not demote in bulk to tidy the registry.`;
7887
7973
  var AD_CONFLICT_SURFACING_RULES = `**A contradiction is NOT a veto \u2014 surface it, never silently shelve it.**
7888
7974
 
7889
7975
  Active Decisions are *active*: they can be superseded, modified, or abandoned. You do NOT have authority to kill a piece of work simply because it cuts against one. That is the user's call, and they can only make it if you show it to them.
@@ -8870,7 +8956,7 @@ After your natural language output, include this EXACT format on its own line:
8870
8956
  "activeDecisionUpdates": [
8871
8957
  {
8872
8958
  "id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
8873
- "action": "confidence_change | modify | resolve | supersede | new | delete",
8959
+ "action": "confidence_change | modify | resolve | supersede | new | delete | demote",
8874
8960
  "body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
8875
8961
  "evidenceRef": "string (optional) \u2014 for DELIBERATE decisions (modify / resolve / delete = validate/modify/invalidate), a pointer to the evidence that justified the change: a doc path (docs/research/foo.md), a build-report id, or a metric name. Builds the decision->outcome ledger. Omit if no concrete evidence.",
8876
8962
  "metricDelta": "object (optional) \u2014 { "metric": string, "before": number, "after": number, "delta": number } \u2014 which metric moved and by how much. Use a REAL metric name from cycle_metrics_snapshots where possible (e.g. scope_accuracy, velocity, est_actual_drift). Omit if no metric moved."
@@ -9122,7 +9208,7 @@ After your natural language output, include this EXACT format on its own line:
9122
9208
  "activeDecisionUpdates": [
9123
9209
  {
9124
9210
  "id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
9125
- "action": "confidence_change | modify | resolve | supersede | new | delete",
9211
+ "action": "confidence_change | modify | resolve | supersede | new | delete | demote",
9126
9212
  "body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
9127
9213
  "evidenceRef": "string (optional) \u2014 for DELIBERATE changes (modify / resolve / delete), a pointer to the justifying evidence: a doc path, a build-report id, or a metric name. Builds the decision->outcome ledger. Omit if none.",
9128
9214
  "metricDelta": "object (optional) \u2014 { "metric": string, "before": number, "after": number, "delta": number } \u2014 which metric moved. Prefer a real metric name from cycle_metrics_snapshots. Omit if none."
@@ -9633,13 +9719,20 @@ async function getPrompt(name) {
9633
9719
  }
9634
9720
 
9635
9721
  // src/lib/foundation.ts
9636
- var cache2 = null;
9722
+ var cache2 = /* @__PURE__ */ new Map();
9637
9723
  var CACHE_TTL_MS2 = 5 * 60 * 1e3;
9724
+ var UNKNOWN_PROJECT_KEY = "\0unknown-project";
9725
+ function cacheKeyFor(adapter2) {
9726
+ const id = adapter2.getProjectId?.();
9727
+ return id && id.length > 0 ? id : UNKNOWN_PROJECT_KEY;
9728
+ }
9638
9729
  var MAX_TOKENS = 800;
9639
9730
  var MAX_CHARS = MAX_TOKENS * 4;
9640
9731
  async function buildProjectFoundation(adapter2) {
9641
9732
  const now = Date.now();
9642
- if (cache2 && cache2.expiresAt > now) return cache2.content;
9733
+ const cacheKey = cacheKeyFor(adapter2);
9734
+ const hit = cache2.get(cacheKey);
9735
+ if (hit && hit.expiresAt > now) return hit.content;
9643
9736
  const [briefRaw, decisions, northStar, horizons, stages] = await Promise.all([
9644
9737
  safe(() => adapter2.readProductBrief(), ""),
9645
9738
  safe(() => adapter2.getActiveDecisions(), []),
@@ -9661,7 +9754,7 @@ async function buildProjectFoundation(adapter2) {
9661
9754
  const userType = extractUserType(briefRaw);
9662
9755
  if (userType) lines.push(`Primary user: ${userType}`);
9663
9756
  if (lines.length === 0) {
9664
- cache2 = { content: "", expiresAt: now + CACHE_TTL_MS2 };
9757
+ cache2.set(cacheKey, { content: "", expiresAt: now + CACHE_TTL_MS2 });
9665
9758
  return "";
9666
9759
  }
9667
9760
  const body = lines.join("\n");
@@ -9674,7 +9767,7 @@ ${body}
9674
9767
  [/project_foundation]
9675
9768
 
9676
9769
  ` : block;
9677
- cache2 = { content: final, expiresAt: now + CACHE_TTL_MS2 };
9770
+ cache2.set(cacheKey, { content: final, expiresAt: now + CACHE_TTL_MS2 });
9678
9771
  return final;
9679
9772
  }
9680
9773
  async function safe(fn, fallback) {
@@ -10191,7 +10284,7 @@ function titleMap(entities) {
10191
10284
  const map = /* @__PURE__ */ new Map();
10192
10285
  for (const e of entities) {
10193
10286
  if (!e.title) continue;
10194
- for (const key of [e.id, e.displayId]) {
10287
+ for (const key of [e.id, e.displayId, e.uuid]) {
10195
10288
  if (!key) continue;
10196
10289
  map.set(key, e.title);
10197
10290
  map.set(key.toLowerCase(), e.title);
@@ -10199,6 +10292,14 @@ function titleMap(entities) {
10199
10292
  }
10200
10293
  return map;
10201
10294
  }
10295
+ function formatEntityRef(value, entities) {
10296
+ const needle = value.toLowerCase();
10297
+ const match = entities.find(
10298
+ (e) => e.uuid?.toLowerCase() === needle || e.id?.toLowerCase() === needle || e.displayId?.toLowerCase() === needle
10299
+ );
10300
+ if (!match) return formatRef(value, lookupTitle(value, titleMap(entities)));
10301
+ return formatRef(match.displayId || match.id || value, match.title);
10302
+ }
10202
10303
 
10203
10304
  // src/lib/blocker.ts
10204
10305
  function idMatches(candidate, ref) {
@@ -10363,6 +10464,31 @@ function isProjectOwner(callerUserId, ownerUserId) {
10363
10464
  if (caller.length === 0 || owner.length === 0) return false;
10364
10465
  return caller === owner;
10365
10466
  }
10467
+ var CYCLE_ROLES = ["owner", "editor"];
10468
+ async function resolveCycleGate(adapter2, gate) {
10469
+ if (!gate.enforced) return { allowed: true, role: null };
10470
+ if (gate.callerIsOwner) return { allowed: true, role: "owner" };
10471
+ if (!gate.callerUserId) {
10472
+ return {
10473
+ allowed: false,
10474
+ role: null,
10475
+ resolutionError: gate.resolutionError ?? "no caller identity could be resolved"
10476
+ };
10477
+ }
10478
+ if (!adapterSupports(adapter2, "getContributorRole")) {
10479
+ return { allowed: false, role: null, resolutionError: "role lookup is unavailable on this transport" };
10480
+ }
10481
+ try {
10482
+ const role = await adapter2.getContributorRole(gate.callerUserId);
10483
+ return { allowed: role != null && CYCLE_ROLES.includes(role), role: role ?? null };
10484
+ } catch (err) {
10485
+ return {
10486
+ allowed: false,
10487
+ role: null,
10488
+ resolutionError: err instanceof Error ? err.message : String(err)
10489
+ };
10490
+ }
10491
+ }
10366
10492
  async function resolveOwnerGate(adapter2, config2) {
10367
10493
  if (adapterSupports(adapter2, "getOwnerIdentity")) {
10368
10494
  try {
@@ -10428,6 +10554,48 @@ function normalizeComplexity(value) {
10428
10554
  const key = (value ?? "").trim().toUpperCase();
10429
10555
  return COMPLEXITY_ALIASES[key] ?? "Small";
10430
10556
  }
10557
+ var PRIORITY_ALIASES = {
10558
+ "P0": "P0 Critical",
10559
+ "P1": "P1 High",
10560
+ "P2": "P2 Medium",
10561
+ "P3": "P3 Low",
10562
+ "CRITICAL": "P0 Critical",
10563
+ "URGENT": "P0 Critical",
10564
+ "HIGH": "P1 High",
10565
+ "MEDIUM": "P2 Medium",
10566
+ "MED": "P2 Medium",
10567
+ "LOW": "P3 Low",
10568
+ "P0 CRITICAL": "P0 Critical",
10569
+ "P1 HIGH": "P1 High",
10570
+ "P2 MEDIUM": "P2 Medium",
10571
+ "P3 LOW": "P3 Low"
10572
+ };
10573
+ function resolvePriority(value) {
10574
+ const key = (value ?? "").trim().toUpperCase();
10575
+ if (!key) return "P1 High";
10576
+ return PRIORITY_ALIASES[key] ?? null;
10577
+ }
10578
+ function normalizePriority(value) {
10579
+ return resolvePriority(value) ?? "P1 High";
10580
+ }
10581
+ function validateNewTaskVocab(tasks) {
10582
+ const problems = [];
10583
+ for (const t of tasks) {
10584
+ const name = t.title?.trim() || "(untitled task)";
10585
+ if (resolvePriority(t.priority) === null) {
10586
+ problems.push(
10587
+ `"${name}": priority "${t.priority}" is not one of P0 Critical / P1 High / P2 Medium / P3 Low`
10588
+ );
10589
+ }
10590
+ const cx = (t.complexity ?? "").trim();
10591
+ if (cx && !COMPLEXITY_ALIASES[cx.toUpperCase()]) {
10592
+ problems.push(
10593
+ `"${name}": complexity "${t.complexity}" is not one of XS / Small / Medium / Large / XL`
10594
+ );
10595
+ }
10596
+ }
10597
+ return problems;
10598
+ }
10431
10599
  var PLAN_BUILD_REPORT_BUDGET = { maxReports: 12, fieldBudget: 280 };
10432
10600
  function leadChainWithRecommended(chain, recommendedTaskId) {
10433
10601
  const rec = recommendedTaskId?.trim();
@@ -10444,10 +10612,7 @@ async function resolvePlanScope(adapter2, config2) {
10444
10612
  }
10445
10613
  function filterToPersonalBacklog(tasks, scope) {
10446
10614
  if (!scope.callerUserId) return tasks;
10447
- if (scope.callerIsOwner) {
10448
- return tasks.filter((t) => !t.assigneeId || t.assigneeId === scope.callerUserId);
10449
- }
10450
- return tasks.filter((t) => t.assigneeId === scope.callerUserId);
10615
+ return tasks.filter((t) => !t.assigneeId || t.assigneeId === scope.callerUserId);
10451
10616
  }
10452
10617
  function determineContextTier(cycleCount) {
10453
10618
  if (cycleCount <= 5) return 1;
@@ -11411,6 +11576,14 @@ function applyCancellationGuard(corrections, confirmCancellations) {
11411
11576
  async function transactionalWriteBack(adapter2, cycleNumber, data, contextHashes, options = {}) {
11412
11577
  const writeBackTimer = startTimer();
11413
11578
  const newCycleNumber = cycleNumber + 1;
11579
+ const vocabProblems = validateNewTaskVocab(data.newTasks ?? []);
11580
+ if (vocabProblems.length > 0) {
11581
+ throw new Error(
11582
+ `plan apply refused before writing: ${vocabProblems.length} task(s) use values the board does not accept.
11583
+ ` + vocabProblems.map((p) => ` - ${p}`).join("\n") + `
11584
+ Nothing was written \u2014 no cycle was created and no tasks were changed. Correct these values in the plan output and re-run apply.`
11585
+ );
11586
+ }
11414
11587
  const skippedCancellations = [];
11415
11588
  const cleanTitle = data.cycleLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
11416
11589
  const cleanContent = data.cycleLogContent.replace(/^#{1,3}\s+(?:Cycle|Session)\s+\d+\s*—[^\n]*\n*/i, "").trim();
@@ -11517,7 +11690,7 @@ ${cleanContent}`;
11517
11690
  // task-2242: stable handoff join key
11518
11691
  title: t.title,
11519
11692
  status: t.status || "Backlog",
11520
- priority: t.priority || "P1 High",
11693
+ priority: normalizePriority(t.priority),
11521
11694
  complexity: normalizeComplexity(t.complexity),
11522
11695
  module: t.module || "Core",
11523
11696
  epic: t.epic || "Platform",
@@ -11665,7 +11838,7 @@ ${cleanContent}`;
11665
11838
  displayId: "",
11666
11839
  title: task.title,
11667
11840
  status: task.status || "Backlog",
11668
- priority: task.priority || "P1 High",
11841
+ priority: normalizePriority(task.priority),
11669
11842
  complexity: normalizeComplexity(task.complexity),
11670
11843
  module: task.module || "Core",
11671
11844
  epic: task.epic || "Platform",
@@ -11967,7 +12140,7 @@ async function validateAndPrepare(adapter2, force, callerUserId, adapterType) {
11967
12140
  try {
11968
12141
  const health = await adapter2.getCycleHealth();
11969
12142
  cycleNumber = health.totalCycles;
11970
- mode = determineMode(health.totalCycles);
12143
+ mode = determineMode(health.projectCycleCount ?? health.totalCycles);
11971
12144
  const blockingCycle = callerUserId ? await resolveCallerLatestCycle(adapter2, callerUserId) : void 0;
11972
12145
  const latestStatus = callerUserId ? blockingCycle?.status : health.latestCycleStatus;
11973
12146
  const blockingNumber = blockingCycle?.number ?? cycleNumber;
@@ -14977,6 +15150,29 @@ function asDecisionBatchApplier(adapter2) {
14977
15150
  const candidate = adapter2;
14978
15151
  return typeof candidate.applyActiveDecisionUpdates === "function" ? candidate : void 0;
14979
15152
  }
15153
+ async function demoteDecisionToConvention(ad, adapter2, cycleNumber, warnings) {
15154
+ if (!adapter2.createConvention) {
15155
+ warnings.push(
15156
+ `AD ${ad.id}: demotion skipped \u2014 this adapter cannot create conventions, and retiring the decision without writing the rule would lose the stance entirely. Left live.`
15157
+ );
15158
+ return false;
15159
+ }
15160
+ const rule = ad.body.trim();
15161
+ if (!rule) {
15162
+ warnings.push(`AD ${ad.id}: demotion skipped \u2014 empty body, nothing to carry into a convention. Left live.`);
15163
+ return false;
15164
+ }
15165
+ try {
15166
+ await adapter2.createConvention({ rule });
15167
+ } catch (err) {
15168
+ warnings.push(
15169
+ `AD ${ad.id}: demotion skipped \u2014 writing the convention failed (${err instanceof Error ? err.message : String(err)}). The decision is left live rather than retired with nothing to replace it.`
15170
+ );
15171
+ return false;
15172
+ }
15173
+ await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, "demote");
15174
+ return true;
15175
+ }
14980
15176
  function routeDecisionUpdate(ad, adapter2, cycleNumber, warnings) {
14981
15177
  const action = ad.action;
14982
15178
  let route;
@@ -15723,7 +15919,10 @@ ${cleanContent}`;
15723
15919
  });
15724
15920
  if (data.activeDecisionUpdates && data.activeDecisionUpdates.length > 0) {
15725
15921
  for (const ad of data.activeDecisionUpdates) {
15726
- if (ad.action === "delete" && adapter2.deleteActiveDecision) {
15922
+ if (ad.action === "demote") {
15923
+ const demoted = await demoteDecisionToConvention(ad, adapter2, cycleNumber, evidenceWarnings);
15924
+ if (!demoted) continue;
15925
+ } else if (ad.action === "delete" && adapter2.deleteActiveDecision) {
15727
15926
  await adapter2.deleteActiveDecision(ad.id);
15728
15927
  } else if (ad.action === "new" && adapter2.upsertActiveDecision) {
15729
15928
  const titleMatch = ad.body.match(/^###\s+\S+:\s*([^\n[]+?)(?:\s*\[|$)/m);
@@ -17237,11 +17436,13 @@ async function handleBoardEdit(adapter2, args) {
17237
17436
  } else {
17238
17437
  const health = await target.getCycleHealth().catch(() => null);
17239
17438
  const activeCycle = health?.totalCycles ?? null;
17240
- const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
17241
- const stamp = activeCycle != null ? `[C${activeCycle} ${date}]` : `[${date}]`;
17439
+ const stampedAt = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
17440
+ const stamp = activeCycle != null ? `[C${activeCycle} ${stampedAt} UTC]` : `[${stampedAt} UTC]`;
17242
17441
  const entry = `${stamp} ${trimmed}`;
17243
17442
  updates.notes = existing.trim().length > 0 ? `${entry}
17244
17443
 
17444
+ ---
17445
+
17245
17446
  ${existing}` : entry;
17246
17447
  }
17247
17448
  }
@@ -20071,6 +20272,47 @@ To override, pass force=true (emits a telemetry warning).`
20071
20272
  }
20072
20273
  return { resolvedCycleNum, warnings, force, skipVersion };
20073
20274
  }
20275
+ async function reconcileContributorReleasePrs(config2, adapter2, callerUserId, cycle) {
20276
+ if (!callerUserId || !adapter2?.listContributorReleasePrs || !adapter2.setContributorReleasePrStatus) {
20277
+ return { merged: [], open: [] };
20278
+ }
20279
+ const records = await adapter2.listContributorReleasePrs();
20280
+ let own = records.filter((record) => record.contributorUserId === callerUserId);
20281
+ if (own.length === 0 && cycle && adapter2.recordContributorReleasePr) {
20282
+ const discovered = findContributorReleasePullRequests(config2.projectRoot, cycle);
20283
+ for (const pr of discovered) {
20284
+ await adapter2.recordContributorReleasePr({
20285
+ prUrl: pr.url,
20286
+ branch: pr.branch,
20287
+ cycle,
20288
+ contributorUserId: callerUserId
20289
+ });
20290
+ }
20291
+ own = discovered.map((pr, index) => ({
20292
+ id: `discovered-${index}`,
20293
+ contributorUserId: callerUserId,
20294
+ prUrl: pr.url,
20295
+ branch: pr.branch,
20296
+ cycle,
20297
+ status: "open",
20298
+ createdAt: pr.mergedAt ?? "",
20299
+ updatedAt: pr.mergedAt ?? ""
20300
+ }));
20301
+ }
20302
+ const merged = [];
20303
+ const open = [];
20304
+ for (const record of own) {
20305
+ const discoveredState = findContributorReleasePullRequests(config2.projectRoot, record.cycle ?? 0).find((pr) => pr.url === record.prUrl);
20306
+ const state = discoveredState ?? getPullRequestState(config2.projectRoot, record.prUrl);
20307
+ if (state?.state === "MERGED") {
20308
+ await adapter2.setContributorReleasePrStatus(record.prUrl, "merged");
20309
+ merged.push({ prUrl: record.prUrl, branch: record.branch, cycle: record.cycle });
20310
+ } else if (state?.state === "OPEN") {
20311
+ open.push({ prUrl: record.prUrl, branch: record.branch, cycle: record.cycle });
20312
+ }
20313
+ }
20314
+ return { merged, open };
20315
+ }
20074
20316
  async function contributorAutoPrRelease(config2, adapter2, version, productionBaseBranch, options) {
20075
20317
  if (!isGhAvailable()) {
20076
20318
  throw new Error(
@@ -20589,8 +20831,18 @@ async function postReleaseToX(version, cycleClosed) {
20589
20831
  }
20590
20832
  function buildHostedReleaseOutput(params) {
20591
20833
  const { version, branch, cyclePart, warningsBlock, skipVersion } = params;
20834
+ const handoff = buildHostedGitHandoff({ version, branch, skipVersion });
20835
+ return `## Release ${version}${skipVersion ? " (skip version)" : ""} \u2014 cycle closed in the DB
20836
+
20837
+ ${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
20838
+ ` + warningsBlock + `
20839
+ ${handoff}
20840
+
20841
+ Next: cycle closed! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`;
20842
+ }
20843
+ function buildHostedGitHandoff(params) {
20844
+ const { version, branch, skipVersion } = params;
20592
20845
  const tagAnnotation = `Release ${version}`;
20593
- const titleSuffix = skipVersion ? " (skip version)" : "";
20594
20846
  const gitHalf = skipVersion ? "the git half of the release (the CHANGELOG.md commit and the branch push) must run on your own machine" : "the git half of the release (tag, push, CHANGELOG.md) must run on your own machine";
20595
20847
  const commands = skipVersion ? `git checkout ${branch}
20596
20848
  git pull
@@ -20607,17 +20859,13 @@ git -c user.name="Your Name" -c user.email="you@example.com" tag -a ${version} -
20607
20859
  \`\`\`
20608
20860
 
20609
20861
  `;
20610
- return `## Release ${version}${titleSuffix} \u2014 cycle closed in the DB
20611
-
20612
- ${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
20613
- ` + warningsBlock + `
20614
- The hosted remote MCP transport (mcp.getpapi.ai) has no checkout of your project and no git binary, so ${gitHalf}.
20862
+ return `The hosted remote MCP transport (mcp.getpapi.ai) has no checkout of your project and no git binary, so ${gitHalf}.
20615
20863
 
20616
20864
  Finish the release locally:
20617
20865
  \`\`\`
20618
20866
  ` + commands + `\`\`\`
20619
20867
 
20620
- ` + identityBlock + `Next: cycle closed! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`;
20868
+ ` + identityBlock.trimEnd();
20621
20869
  }
20622
20870
  var releaseTool = {
20623
20871
  name: "release",
@@ -20705,7 +20953,16 @@ async function handleRelease(adapter2, config2, args) {
20705
20953
  await beginRelease(tracker, cycleToClose);
20706
20954
  const productionBaseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
20707
20955
  if (branch === productionBaseBranch) {
20708
- if (gate.enforced && !gate.callerIsOwner) {
20956
+ let releaseCaps = {};
20957
+ try {
20958
+ const info = adapter2.getProjectInfo ? await adapter2.getProjectInfo() : null;
20959
+ releaseCaps = info?.capabilities ?? {};
20960
+ } catch {
20961
+ releaseCaps = {};
20962
+ }
20963
+ const editorsReleaseLikeOwner = isCapabilityEnabled(releaseCaps, "editorRelease");
20964
+ const cycleGate = editorsReleaseLikeOwner ? await resolveCycleGate(adapter2, gate) : { allowed: gate.callerIsOwner, role: gate.callerIsOwner ? "owner" : null };
20965
+ if (gate.enforced && !cycleGate.allowed) {
20709
20966
  const resolutionNote = gate.resolutionError ? `
20710
20967
 
20711
20968
  Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.` : "";
@@ -20726,13 +20983,62 @@ Then reconnect your AI tool and run release again. (Direct/pg setups read identi
20726
20983
  const ownerHint = gate.transport === "pg" ? `If you ARE the owner, set PAPI_USER_ID to your account UUID in .mcp.json (getpapi.ai \u2192 Settings \u2192 Account).` : `If you ARE the owner, your identity comes from your API key \u2014 check you are using YOUR key for YOUR project.`;
20727
20984
  const roleNote = callerRole === "viewer" ? `Your role on this project is "viewer", which cannot release. Ask the owner (or an editor) to release, or ask for editor access.` : `Your identity does not match this project's owner, and you do not have an editor role to open a release PR. If you are a contributor, ask the owner for editor access (or push your branch and open a PR manually). ` + ownerHint;
20728
20985
  return errorResponse(
20729
- `Release to ${productionBaseBranch} is restricted to the project owner.
20986
+ `Release to ${productionBaseBranch} needs an owner or editor role.
20730
20987
 
20731
20988
  ` + roleNote + `
20732
20989
 
20733
20990
  Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
20734
20991
  );
20735
20992
  }
20993
+ tracker.mark("contributor-pr-reconciliation");
20994
+ try {
20995
+ const reconciled = await reconcileContributorReleasePrs(config2, adapter2, gate.callerUserId, cycleToClose);
20996
+ if (reconciled.merged.length > 0) {
20997
+ const latest = reconciled.merged[0];
20998
+ let caps2 = {};
20999
+ try {
21000
+ const info = adapter2.getProjectInfo ? await adapter2.getProjectInfo() : null;
21001
+ caps2 = info?.capabilities ?? {};
21002
+ } catch {
21003
+ caps2 = {};
21004
+ }
21005
+ await recordReadinessVerified(tracker);
21006
+ await recordQualityGate(tracker, evaluateReleaseGate(caps2, config2.gateCommand, gateResult), caps2);
21007
+ await completeRelease(tracker, {
21008
+ cycleClosed: latest.cycle,
21009
+ version,
21010
+ caps: caps2,
21011
+ branchMerges: latest.branch ? [{ branch: latest.branch, prUrl: latest.prUrl }] : [],
21012
+ changelogEmitted: false
21013
+ });
21014
+ const localHandoff = isHostedTransport() ? `
21015
+
21016
+ ${buildHostedGitHandoff({ version, branch, skipVersion: skipVersion ?? false })}` : "";
21017
+ return textResponse(
21018
+ `## Release ${version} \u2014 contributor PR merged
21019
+
21020
+ PAPI detected that ${latest.prUrl} was merged on GitHub and reconciled the contributor release. GitHub granted the merge permission; your PAPI **editor** role granted the release workflow.
21021
+
21022
+ ${latest.cycle ? `Cycle ${latest.cycle}` : "The contributor cycle"} remains **complete**, and the release is now recorded as **released** in PAPI.` + localHandoff + `
21023
+
21024
+ Next: run \`plan\` to start your next cycle.`
21025
+ );
21026
+ }
21027
+ if (reconciled.open.length > 0) {
21028
+ const prLines = reconciled.open.map((p) => `- ${p.prUrl}`).join("\n");
21029
+ return textResponse(
21030
+ `## Release ${version} \u2014 contributor PR awaiting merge
21031
+
21032
+ ${prLines}
21033
+
21034
+ Your PAPI **editor** role allowed this release PR. Merge permission is controlled separately by GitHub: if GitHub lets you merge, review and merge it there; otherwise a repository maintainer must merge it.
21035
+
21036
+ After it is merged, run \`release\` again so PAPI can detect the merge and finish the release record.`
21037
+ );
21038
+ }
21039
+ } catch (err) {
21040
+ console.error(`[release] contributor PR reconciliation failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
21041
+ }
20736
21042
  tracker.mark("contributor-auto-pr");
20737
21043
  try {
20738
21044
  const cr = await contributorAutoPrRelease(config2, adapter2, version, productionBaseBranch, {
@@ -20748,9 +21054,9 @@ Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
20748
21054
  \u26A0\uFE0F Warnings: ${cr.warnings.join("; ")}
20749
21055
  ` : "";
20750
21056
  return textResponse(
20751
- `## Release ${version} \u2014 PR opened for owner review
21057
+ `## Release ${version} \u2014 contributor PR opened
20752
21058
 
20753
- You released as an **editor**, so PAPI opened a pull request to \`${productionBaseBranch}\` instead of merging directly. The owner reviews and merges it.
21059
+ You released as an **editor**, so PAPI opened a pull request to \`${productionBaseBranch}\` instead of merging directly. GitHub merge permission is separate from your PAPI role: merge it yourself if GitHub allows, otherwise a repository maintainer must merge it.
20754
21060
 
20755
21061
  **Pull request(s):**
20756
21062
  ${prLines.join("\n")}
@@ -20760,7 +21066,7 @@ ${failLines.join("\n")}
20760
21066
  ` : "") + warnBlock + `
20761
21067
  ${cyclePart} is now marked **complete** in PAPI \u2014 your work is shipped from your side. Any changes the owner requests come forward as a fast-follow in your next cycle; your closed cycle is not rewound.
20762
21068
 
20763
- Next: run \`plan\` to start your next cycle.`
21069
+ After the PR is merged, run \`release\` again so PAPI can reconcile it.`
20764
21070
  );
20765
21071
  } catch (err) {
20766
21072
  return errorResponse(err instanceof Error ? err.message : String(err));
@@ -23149,8 +23455,19 @@ function isPaidTier(tier) {
23149
23455
  }
23150
23456
  async function enforceProjectCap(adapter2, target) {
23151
23457
  if (isSelfHostedDeployment(process.env.PAPI_SELF_HOST)) return null;
23152
- const tier = await resolveTier(adapter2);
23153
- if (tier === null || isPaidTier(tier)) return null;
23458
+ let entitlement = null;
23459
+ if (adapterSupports(adapter2, "getMeteredUsage")) {
23460
+ try {
23461
+ entitlement = await adapter2.getMeteredUsage();
23462
+ } catch {
23463
+ return null;
23464
+ }
23465
+ }
23466
+ if (!entitlement) return null;
23467
+ const override = entitlement.projectLimitOverride;
23468
+ if (override === 0) return null;
23469
+ const effectiveLimit = typeof override === "number" && Number.isInteger(override) && override > 0 ? override : FREE_PROJECT_CAP;
23470
+ if (override == null && isPaidTier(entitlement.tier)) return null;
23154
23471
  if (!adapterSupports(adapter2, "listUserProjects")) return null;
23155
23472
  let projects;
23156
23473
  try {
@@ -23162,17 +23479,20 @@ async function enforceProjectCap(adapter2, target) {
23162
23479
  (p) => target.papiDir && p.papi_dir && p.papi_dir === target.papiDir || target.name && p.name && p.name.trim().toLowerCase() === target.name.trim().toLowerCase()
23163
23480
  );
23164
23481
  if (matchesExisting) return null;
23165
- if (projects.length >= FREE_PROJECT_CAP) return projectCapMessage(projects.length);
23482
+ if (projects.length >= effectiveLimit) {
23483
+ return projectCapMessage(projects.length, effectiveLimit, entitlement.tier);
23484
+ }
23166
23485
  return null;
23167
23486
  }
23168
23487
  async function resolveContributorUpsell(adapter2) {
23169
23488
  const tier = await resolveTier(adapter2);
23170
23489
  return evaluateContributorGate(tier).upsell ?? null;
23171
23490
  }
23172
- function projectCapMessage(currentCount) {
23173
- return `**You're on the Free plan (${currentCount} of ${FREE_PROJECT_CAP} projects).**
23491
+ function projectCapMessage(currentCount, limit = FREE_PROJECT_CAP, tier = "free") {
23492
+ const nextStep = isPaidTier(tier) ? "request more projects" : "upgrade to Pro or request more projects";
23493
+ return `**You've reached your account project limit (${currentCount} of ${limit} projects).**
23174
23494
 
23175
- Free covers up to ${FREE_PROJECT_CAP} projects. To run more, upgrade to Pro for unlimited projects at a flat monthly price (no usage bills): ${PRICING_URL}
23495
+ Your account currently covers up to ${limit} projects. To run more, ${nextStep} at a flat monthly price (no usage bills): ${PRICING_URL}
23176
23496
 
23177
23497
  Your existing projects are untouched, and you can keep working in any of them.`;
23178
23498
  }
@@ -23351,14 +23671,24 @@ Diagnostic JSON:
23351
23671
  ${JSON.stringify(payload, null, 2)}`
23352
23672
  );
23353
23673
  }
23354
- function ensureDocDurable(path7, projectRoot) {
23674
+ function ensureDocDurable(path7, projectRoot, bodyStored = false) {
23355
23675
  if (!hasLocalWorkspace() || !projectRoot) return "";
23356
- const warn = (reason, fix) => `
23676
+ const warn = (reason, fix) => {
23677
+ if (bodyStored) {
23678
+ return `
23679
+ - **Not in git:** ${reason} The body is stored in the registry, so it survives a branch switch or stash; the file on disk is not version-controlled.`;
23680
+ }
23681
+ return `
23357
23682
 
23358
23683
  \u26A0\uFE0F **Registered, but NOT durable.** ${reason}
23359
23684
  The registry stores metadata and a summary \u2014 not the body. This doc has one copy, on disk, and a branch switch or stash can take it.
23360
23685
  **Fix:** ${fix}`;
23686
+ };
23361
23687
  if (!existsSync9(join13(projectRoot, path7))) {
23688
+ if (bodyStored) {
23689
+ return `
23690
+ - **No file at \`${path7}\`:** the body is stored in the registry, but nothing is on disk at the registered path. Write it there so readers resolve it.`;
23691
+ }
23362
23692
  return warn(
23363
23693
  `No file exists at \`${path7}\`.`,
23364
23694
  `write the doc body to that path, then re-run doc_register.`
@@ -23375,7 +23705,11 @@ The registry stores metadata and a summary \u2014 not the body. This doc has one
23375
23705
  if (isPathIgnored(projectRoot, path7)) {
23376
23706
  return warn(
23377
23707
  `\`${path7}\` is excluded by .gitignore, so it cannot be committed (docs/private/ is ignored by design).`,
23378
- `keep a copy outside the working tree, or move the doc to a tracked folder if it is not owner-only.`
23708
+ // task-3358: no longer suggests moving the doc to a tracked folder. For anything
23709
+ // under docs/private/ that flips resolveDocVisibility off the private tier and
23710
+ // fails the no-private-artifacts gate — advising a leak to fix a durability gap.
23711
+ // Only reachable now when the body was NOT stored, where a copy is the real fix.
23712
+ `pass \`body\` to doc_register so the registry stores the content, or keep a copy outside the working tree.`
23379
23713
  );
23380
23714
  }
23381
23715
  const result = commitSinglePath(projectRoot, path7, `docs: register ${path7}`);
@@ -23468,6 +23802,7 @@ async function handleDocRegister(adapter2, args, config2) {
23468
23802
  visibility
23469
23803
  });
23470
23804
  let bodyNote = "";
23805
+ let bodyIsStored = false;
23471
23806
  try {
23472
23807
  const supplied = typeof args.body === "string" ? args.body : void 0;
23473
23808
  let body = supplied;
@@ -23498,6 +23833,7 @@ ${decision.message}`;
23498
23833
  visibility: entry.visibility ?? visibility,
23499
23834
  ownerUserId: entry.ownerUserId
23500
23835
  });
23836
+ bodyIsStored = true;
23501
23837
  bodyNote = result.stored ? `
23502
23838
  - **Body stored:** ${result.byteSize.toLocaleString()} bytes` : "\n- **Body:** unchanged since last registration";
23503
23839
  }
@@ -23508,7 +23844,7 @@ ${decision.message}`;
23508
23844
  const visibilityLabel = entry.visibility === "contributors" ? "team member" : entry.visibility ?? "private";
23509
23845
  let durability = "";
23510
23846
  try {
23511
- durability = ensureDocDurable(entry.path, config2?.projectRoot);
23847
+ durability = ensureDocDurable(entry.path, config2?.projectRoot, bodyIsStored);
23512
23848
  } catch {
23513
23849
  durability = "";
23514
23850
  }
@@ -24225,19 +24561,106 @@ function formatListItem(task) {
24225
24561
  }
24226
24562
  return base;
24227
24563
  }
24564
+ var AD_KEYWORD_STOPWORDS = /* @__PURE__ */ new Set([
24565
+ "the",
24566
+ "and",
24567
+ "for",
24568
+ "but",
24569
+ "not",
24570
+ "you",
24571
+ "all",
24572
+ "any",
24573
+ "can",
24574
+ "has",
24575
+ "had",
24576
+ "was",
24577
+ "are",
24578
+ "its",
24579
+ "our",
24580
+ "out",
24581
+ "off",
24582
+ "own",
24583
+ "via",
24584
+ "per",
24585
+ "use",
24586
+ "get",
24587
+ "set",
24588
+ "new",
24589
+ "old",
24590
+ "one",
24591
+ "two",
24592
+ "now",
24593
+ "why",
24594
+ "how",
24595
+ "who",
24596
+ "let",
24597
+ "may",
24598
+ "yet",
24599
+ "too",
24600
+ "from",
24601
+ "that",
24602
+ "this",
24603
+ "with",
24604
+ "into",
24605
+ "when",
24606
+ "then",
24607
+ "than",
24608
+ "them",
24609
+ "they",
24610
+ "their",
24611
+ "there",
24612
+ "here",
24613
+ "have",
24614
+ "been",
24615
+ "were",
24616
+ "will",
24617
+ "would",
24618
+ "should",
24619
+ "could",
24620
+ "make",
24621
+ "made",
24622
+ "more",
24623
+ "most",
24624
+ "less",
24625
+ "only",
24626
+ "also",
24627
+ "just",
24628
+ "like",
24629
+ "over",
24630
+ "some",
24631
+ "such",
24632
+ "each",
24633
+ "both",
24634
+ "does",
24635
+ "done",
24636
+ "stop",
24637
+ "keep",
24638
+ "take",
24639
+ "give",
24640
+ "work",
24641
+ "thing",
24642
+ "things",
24643
+ "about",
24644
+ "after",
24645
+ "before"
24646
+ ]);
24228
24647
  function filterRelevantADs(ads, task) {
24648
+ const live = ads.filter((ad) => !ad.superseded);
24229
24649
  const keywords = [];
24230
24650
  if (task.module) keywords.push(task.module.toLowerCase());
24231
24651
  if (task.epic) keywords.push(task.epic.toLowerCase());
24232
24652
  if (task.phase) keywords.push(task.phase.toLowerCase());
24233
- const titleWords = (task.title ?? task.displayId ?? "").toLowerCase().split(/\s+/).filter((w) => w.length > 3);
24653
+ const titleWords = (task.title ?? task.displayId ?? "").toLowerCase().split(/[^a-z0-9-]+/).filter((w) => w.length > 2 && !AD_KEYWORD_STOPWORDS.has(w));
24234
24654
  keywords.push(...titleWords);
24235
- if (keywords.length === 0) return [];
24236
- return ads.filter((ad) => {
24237
- if (ad.superseded) return false;
24655
+ const matches = keywords.length === 0 ? [] : live.filter((ad) => {
24238
24656
  const text = `${ad.title} ${ad.body}`.toLowerCase();
24239
24657
  return keywords.some((kw) => text.includes(kw));
24240
24658
  });
24659
+ const selected = [...matches];
24660
+ for (const ad of live) {
24661
+ if (ad.confidence === "HIGH" && !selected.includes(ad)) selected.push(ad);
24662
+ }
24663
+ return { ads: selected, matched: matches.length, total: live.length };
24241
24664
  }
24242
24665
  async function getModuleContext(adapter2, task) {
24243
24666
  if (!task.module) return "";
@@ -24263,9 +24686,18 @@ async function getModuleContext(adapter2, task) {
24263
24686
  return "";
24264
24687
  }
24265
24688
  }
24266
- function formatRelevantADs(ads) {
24267
- if (ads.length === 0) return "";
24689
+ function formatRelevantADs(ads, stats) {
24690
+ if (ads.length === 0 && !stats) return "";
24268
24691
  const lines = ["\n\n---\n\n**ACTIVE DECISIONS (relevant):**"];
24692
+ if (stats) {
24693
+ lines.push(
24694
+ `_${stats.matched} of ${stats.total} active decisions matched this task's keywords${ads.length > stats.matched ? `; HIGH-confidence decisions are always included` : ""}. Keyword matching is approximate \u2014 an absent decision has not been ruled out._`
24695
+ );
24696
+ }
24697
+ if (ads.length === 0) {
24698
+ lines.push("\n_No active decisions matched. This is a filter result, not a statement that none apply._");
24699
+ return lines.join("\n");
24700
+ }
24269
24701
  for (const ad of ads) {
24270
24702
  const bodyLines = ad.body.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
24271
24703
  const summary = bodyLines[0]?.trim().slice(0, 120) ?? "";
@@ -24446,7 +24878,7 @@ If >80% of the scope is already implemented, call \`build_execute\` with complet
24446
24878
  try {
24447
24879
  const allADs = await adapter2.getActiveDecisions();
24448
24880
  const relevant = filterRelevantADs(allADs, result.task);
24449
- adSection = formatRelevantADs(relevant);
24881
+ adSection = formatRelevantADs(relevant.ads, { matched: relevant.matched, total: relevant.total });
24450
24882
  } catch {
24451
24883
  }
24452
24884
  let dogfoodSection = "";
@@ -25990,6 +26422,21 @@ async function recordAdHoc(adapter2, input) {
25990
26422
  var VALID_EFFORTS = ["XS", "S", "M", "L", "XL"];
25991
26423
  var VALID_PRIORITIES = ["P0 Critical", "P1 High", "P2 Medium", "P3 Low"];
25992
26424
  var VALID_TYPES = ["task", "bug", "research", "discovery", "spike", "idea"];
26425
+ var AD_HOC_CONFLICT_GATE_INSTRUCTION = "\n**AD conflict gate:** This work is already done. If any of it cuts against a decision above, say so now \u2014 add a `task_comment` on this task, or pass `proposal` to record the boundary as its own Decision or Convention. A conflict noticed here and not written down is one nobody can find later. A contradiction is not a veto \u2014 surfacing it is the owner's call to make, not yours to skip.";
26426
+ async function adHocDecisionSection(adapter2, task) {
26427
+ try {
26428
+ const allADs = await adapter2.getActiveDecisions();
26429
+ const relevant = filterRelevantADs(allADs, task);
26430
+ if (relevant.total === 0) return "";
26431
+ const section = formatRelevantADs(relevant.ads, {
26432
+ matched: relevant.matched,
26433
+ total: relevant.total
26434
+ });
26435
+ return section.replace(AD_CONFLICT_GATE_INSTRUCTION, AD_HOC_CONFLICT_GATE_INSTRUCTION);
26436
+ } catch {
26437
+ return "";
26438
+ }
26439
+ }
25993
26440
  function inferTaskType(description) {
25994
26441
  const lower = description.toLowerCase();
25995
26442
  if (/\bfix\b|bug|error|crash|broken|regression|defect/.test(lower)) return "bug";
@@ -26216,12 +26663,12 @@ The task is recorded **In Review** and pinned to **Cycle ${result.task.cycle}**,
26216
26663
  \`\`\`
26217
26664
  2. Leave the branch **unmerged** \u2014 it is picked up by the next cycle's \`release\`.
26218
26665
 
26219
- _To correct: board_edit ${result.task.id} with updated fields._` + await recordAdHocProposal(adapter2, args, { cycleNumber: result.task.cycle ?? 0, sourceTaskId: result.task.id })
26666
+ _To correct: board_edit ${result.task.id} with updated fields._` + await recordAdHocProposal(adapter2, args, { cycleNumber: result.task.cycle ?? 0, sourceTaskId: result.task.id }) + await adHocDecisionSection(target, result.task)
26220
26667
  );
26221
26668
  }
26222
26669
  return textResponse(
26223
26670
  `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
26224
- _To correct: board_edit ${result.task.id} with updated fields._` + await recordAdHocProposal(adapter2, args, { cycleNumber: result.task.cycle ?? 0, sourceTaskId: result.task.id })
26671
+ _To correct: board_edit ${result.task.id} with updated fields._` + await recordAdHocProposal(adapter2, args, { cycleNumber: result.task.cycle ?? 0, sourceTaskId: result.task.id }) + await adHocDecisionSection(target, result.task)
26225
26672
  );
26226
26673
  }
26227
26674
 
@@ -27595,14 +28042,23 @@ ${overlap}`;
27595
28042
  const allTasks = await adapter2.queryBoard();
27596
28043
  const cycleTasks = allTasks.filter((t) => t.cycle === result.currentCycle);
27597
28044
  const gate = await resolveOwnerGate(adapter2, config2);
27598
- const callerIsOwner = !gate.enforced || gate.callerIsOwner;
27599
- if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done") && !callerIsOwner) {
27600
- const resolutionNote = gate.resolutionError ? ` (Identity resolution failed: ${gate.resolutionError} \u2014 the gate fails closed; the owner can run \`release\` directly.)` : "";
28045
+ let releaseCaps = {};
28046
+ try {
28047
+ const info = adapter2.getProjectInfo ? await adapter2.getProjectInfo() : null;
28048
+ releaseCaps = info?.capabilities ?? {};
28049
+ } catch {
28050
+ releaseCaps = {};
28051
+ }
28052
+ const cycleGate = isCapabilityEnabled(releaseCaps, "editorRelease") ? await resolveCycleGate(adapter2, gate) : { allowed: !gate.enforced || gate.callerIsOwner, role: gate.callerIsOwner ? "owner" : null };
28053
+ const callerMayRelease = cycleGate.allowed;
28054
+ if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done") && !callerMayRelease) {
28055
+ const why = cycleGate.role === "viewer" ? `your role on this project is "viewer"` : `your role on this project could not be confirmed`;
28056
+ const resolutionNote = cycleGate.resolutionError ? ` (Role resolution failed: ${cycleGate.resolutionError} \u2014 the gate fails closed.)` : "";
27601
28057
  autoReleaseNote = `
27602
28058
 
27603
28059
  ---
27604
28060
 
27605
- \u2705 Verdict recorded. All cycle tasks are Done, but **auto-release is owner-only** \u2014 your identity does not match this project's owner, so no release was cut. Push your branch and open a PR for the owner to run \`release\`.${resolutionNote}`;
28061
+ \u2705 Verdict recorded. All cycle tasks are Done, but no release was cut \u2014 **auto-release needs an owner or editor role**, and ${why}. Push your branch and open a PR, or ask for editor access.${resolutionNote}`;
27606
28062
  } else if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done")) {
27607
28063
  let planRunCount = null;
27608
28064
  if (adapterSupports(adapter2, "countPlanRunsForCycle")) {
@@ -27856,7 +28312,7 @@ import { access as access2, readFile as readFile5, writeFile as writeFile3 } fro
27856
28312
  import path5 from "path";
27857
28313
  var initTool = {
27858
28314
  name: "init",
27859
- description: "Write the MCP config file that connects this project to PAPI. Generates .mcp.json (Claude Code default) or the equivalent for Cursor, VS Code, Windsurf, OpenCode, Amazon Q, Kilo Code, Gemini CLI, Codex CLI, or Hermes Agent. Config-only \u2014 does not create any project data. Run this first, then run `setup` to create your PAPI project.",
28315
+ description: "Write the MCP config file that connects this project to PAPI. Generates .mcp.json, or the equivalent for whichever MCP client you use \u2014 Claude Code, Cursor, VS Code, Windsurf, OpenCode, Amazon Q, Kilo Code, Gemini CLI, Codex CLI, or Hermes Agent. Config-only \u2014 does not create any project data. Run this first, then run `setup` to create your PAPI project.",
27860
28316
  annotations: { title: "Initialise Project", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
27861
28317
  inputSchema: {
27862
28318
  type: "object",
@@ -29300,6 +29756,13 @@ var orientTool = {
29300
29756
  full: {
29301
29757
  type: "boolean",
29302
29758
  description: "Run the heavy enrichment blocks that the lean default path skips: Research Signals (doc search) and npm version-drift. Default false \u2014 the lean path keeps the per-session query count down so orient stays well under the tool timeout on large projects. Implied automatically when deep_housekeeping is true."
29759
+ },
29760
+ // SUP-2026-027: orient stopped multi-project accounts asking which project
29761
+ // to use, while declaring no way to answer — the caller could not pass one
29762
+ // without the schema advertising it. Same contract as board_view / bug.
29763
+ project: {
29764
+ type: "string",
29765
+ description: "Project id (UUID) or slug to orient on, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. Use project_switch to change the session default. PASS THIS WHENEVER YOU KNOW WHICH REPO THE SESSION IS IN: on a multi-project account a connection with no project bound cannot be resolved, and PAPI will stop and ask rather than guess."
29303
29766
  }
29304
29767
  },
29305
29768
  required: []
@@ -29402,7 +29865,7 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
29402
29865
  lines.push(`**Connection:** ${statusIcon} ${statusLabel}`);
29403
29866
  lines.push("");
29404
29867
  }
29405
- if (projectRoot) {
29868
+ if (projectRoot && hasLocalWorkspace()) {
29406
29869
  const modeLine = formatWorkspaceModeLine(resolveWorkspaceMode(projectRoot));
29407
29870
  if (modeLine) {
29408
29871
  lines.push(modeLine);
@@ -29768,7 +30231,8 @@ async function computeCohortVisibility(adapter2, config2, contributorsInput) {
29768
30231
  } catch {
29769
30232
  return void 0;
29770
30233
  }
29771
- if (gate.enforced && !gate.callerIsOwner) return void 0;
30234
+ const cycleGate = await resolveCycleGate(adapter2, gate);
30235
+ if (!cycleGate.allowed) return void 0;
29772
30236
  let tasks;
29773
30237
  try {
29774
30238
  tasks = await adapter2.queryBoard({ compact: true });
@@ -29851,7 +30315,21 @@ function withBoardMemo(adapter2) {
29851
30315
  });
29852
30316
  }
29853
30317
  async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVersion = "unknown") {
29854
- const adapter2 = withBoardMemo(rawAdapter);
30318
+ let resolvedAdapter = rawAdapter;
30319
+ let projectOverrideNote = "";
30320
+ try {
30321
+ ({ adapter: resolvedAdapter, overrideNote: projectOverrideNote } = await resolvePerCallProjectAdapter(
30322
+ rawAdapter,
30323
+ args
30324
+ ));
30325
+ } catch (err) {
30326
+ if (err instanceof ProjectResolutionError) return errorResponse(err.message);
30327
+ throw err;
30328
+ }
30329
+ const projectOverrideLine = projectOverrideNote ? `_Project override${projectOverrideNote}_
30330
+
30331
+ ` : "";
30332
+ const adapter2 = withBoardMemo(resolvedAdapter);
29855
30333
  const environment = normaliseEnvironment(args.environment);
29856
30334
  const deepHousekeeping = args.deep_housekeeping === true;
29857
30335
  const fullEnrichment = args.full === true || deepHousekeeping;
@@ -30398,7 +30876,7 @@ ${section}`;
30398
30876
  const runtimeIdentityNote = `
30399
30877
 
30400
30878
  ${formatRuntimeIdentity(getRuntimeIdentity(config2, serverVersion))}`;
30401
- return { ...textResponse(projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary, clientName, carryForwardRefs) + runtimeIdentityNote + unblockNote + deferredGateNote + feedbackResolvedNote + alertsNote + ttfvNote + reconciliationNote + mergedInProgressNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + onboardingCoachingNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection), _cycleNumber: healthResult.cycleNumber };
30879
+ return { ...textResponse(projectOverrideLine + projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary, clientName, carryForwardRefs) + runtimeIdentityNote + unblockNote + deferredGateNote + feedbackResolvedNote + alertsNote + ttfvNote + reconciliationNote + mergedInProgressNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + onboardingCoachingNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection), _cycleNumber: healthResult.cycleNumber };
30402
30880
  } catch (err) {
30403
30881
  const message = err instanceof Error ? err.message : String(err);
30404
30882
  const isKnownFriendly = /^(Orient failed|Project not found|No project|Setup required)/i.test(message);
@@ -31501,7 +31979,6 @@ function readLlmResponse(args) {
31501
31979
 
31502
31980
  // src/tools/ad-view.ts
31503
31981
  function adColumns(all) {
31504
- const titles = titleMap(all);
31505
31982
  return [
31506
31983
  { name: "id", header: "AD", value: (d) => d.id },
31507
31984
  { name: "title", header: "Title", value: (d) => d.title },
@@ -31510,7 +31987,7 @@ function adColumns(all) {
31510
31987
  {
31511
31988
  name: "supersededBy",
31512
31989
  header: "Superseded By",
31513
- value: (d) => d.supersededBy ? formatRef(d.supersededBy, lookupTitle(d.supersededBy, titles)) : "-"
31990
+ value: (d) => d.supersededBy ? formatEntityRef(d.supersededBy, all) : "-"
31514
31991
  },
31515
31992
  { name: "body", header: "Body", value: (d) => d.body.replace(/\s+/g, " ").trim() }
31516
31993
  ];
@@ -31520,7 +31997,7 @@ function supersededNote(d, all) {
31520
31997
  if (!d.superseded) return "";
31521
31998
  const by = d.supersededBy;
31522
31999
  if (!by) return " [SUPERSEDED by unknown]";
31523
- return ` [SUPERSEDED by ${formatRef(by, lookupTitle(by, titleMap(all)))}]`;
32000
+ return ` [SUPERSEDED by ${formatEntityRef(by, all)}]`;
31524
32001
  }
31525
32002
  function formatAdBody(d, all) {
31526
32003
  return `## ${d.id}: ${d.title} [${d.confidence}]${supersededNote(d, all)}
@@ -31737,7 +32214,10 @@ async function handleLearningAction(adapter2, args) {
31737
32214
  if (!learningId?.trim()) return errorResponse('learning_id is required for mode "mark".');
31738
32215
  if (!actionTaken) return errorResponse('action_taken is required for mode "mark".');
31739
32216
  try {
31740
- await adapter2.updateCycleLearningActionRef(learningId.trim(), actionRef?.trim() ?? actionTaken);
32217
+ await adapter2.updateCycleLearningActionRef(learningId.trim(), actionRef?.trim() ?? actionTaken, {
32218
+ actionTaken,
32219
+ force: true
32220
+ });
31741
32221
  return textResponse(
31742
32222
  `Learning \`${learningId}\` marked as **${actionTaken}**${actionRef ? ` \u2192 \`${actionRef}\`` : ""}.`
31743
32223
  );
@@ -32693,7 +33173,7 @@ var inflightWork = /* @__PURE__ */ new Map();
32693
33173
  var ToolTimeoutError = class extends Error {
32694
33174
  constructor(toolName, expectedMs, actualMs, wedged, pendingNote) {
32695
33175
  super(
32696
- wedged ? `Tool '${toolName}' timed out after ${actualMs}ms (budget ${expectedMs}ms). A database operation ran the entire time without returning \u2014 the connection is likely wedged or contended (SUP-2026-012); a second PAPI session open against the same database is a common cause.${pendingNote} The MCP server is restarting; reconnect with /mcp in Claude Code (or restart your MCP host), and close any other open PAPI session, before the next call.` : `Tool '${toolName}' timed out after ${actualMs}ms (budget ${expectedMs}ms). No single operation was stuck \u2014 this is cumulative query load exceeding the budget, not a wedged pool, and the database is healthy.${pendingNote} Retry the call; \`orient\` runs a lean path by default (heavy enrichment is opt-in via \`full: true\`).`
33176
+ wedged ? `Tool '${toolName}' timed out after ${actualMs}ms (budget ${expectedMs}ms). A database operation ran the entire time without returning \u2014 the connection is likely wedged or contended (SUP-2026-012); a second PAPI session open against the same database is a common cause.${pendingNote} The MCP server is restarting; reconnect to the PAPI server in your AI client (in some clients that is /mcp; otherwise restart the client), and close any other open PAPI session, before the next call.` : `Tool '${toolName}' timed out after ${actualMs}ms (budget ${expectedMs}ms). No single operation was stuck \u2014 this is cumulative query load exceeding the budget, not a wedged pool, and the database is healthy.${pendingNote} Retry the call; \`orient\` runs a lean path by default (heavy enrichment is opt-in via \`full: true\`).`
32697
33177
  );
32698
33178
  this.toolName = toolName;
32699
33179
  this.expectedMs = expectedMs;
@@ -33194,7 +33674,12 @@ var FRIENDLY_GET_HTML = `<!doctype html>
33194
33674
  <code>https://mcp.getpapi.ai/mcp</code>.</p>
33195
33675
  <a class="btn" href="https://getpapi.ai/docs/install">See the install guide \u2192</a>
33196
33676
  </div></body></html>`;
33197
- var KNOWN_INSTALL_CLIENTS = /* @__PURE__ */ new Set(["claude-code-plugin"]);
33677
+ var KNOWN_INSTALL_CLIENTS = /* @__PURE__ */ new Set([
33678
+ "claude-code-plugin",
33679
+ "cursor-plugin",
33680
+ "gemini-extension",
33681
+ "codex-plugin"
33682
+ ]);
33198
33683
  var MAX_BODY_BYTES = 1 * 1024 * 1024;
33199
33684
  var SLOW_REQUEST_MS = 5e3;
33200
33685
  var CRITICAL_REQUEST_MS = 3e4;
@@ -33807,7 +34292,7 @@ Options:
33807
34292
  Getting started:
33808
34293
  1. Run "npx @papi-ai/server setup" in any project folder
33809
34294
  2. Approve in the browser when it opens
33810
- 3. Open Claude Code in the same folder and say "run setup"
34295
+ 3. Open your AI client in the same folder and say "run setup"
33811
34296
 
33812
34297
  Stuck?
33813
34298
  npx @papi-ai/server doctor # diagnose your config