@papi-ai/server 0.7.83 → 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, 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,9 +809,24 @@ 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);
829
+ ASSIGNABLE_CONTRIBUTOR_ROLES = ["editor", "viewer"];
806
830
  SENSITIVE_CHANGELOG_PATTERNS = [
807
831
  // ── Test-user machinery ────────────────────────────────────────────────
808
832
  /@test\.papi\.dev/i,
@@ -851,6 +875,8 @@ var init_dist = __esm({
851
875
  };
852
876
  NONE_RE = /^(none|n\/a|no(ne)? (found|discovered))\.?$/i;
853
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;
854
880
  MIN_REASON_LENGTH = 12;
855
881
  LOW_SEVERITIES = /* @__PURE__ */ new Set(["P2", "P3"]);
856
882
  WAB_WINDOW_DAYS = 7;
@@ -906,6 +932,7 @@ __export(git_exports, {
906
932
  detectUnrecordedCommits: () => detectUnrecordedCommits,
907
933
  ensureLatestDevelop: () => ensureLatestDevelop,
908
934
  ensureTagAtHead: () => ensureTagAtHead,
935
+ findContributorReleasePullRequests: () => findContributorReleasePullRequests,
909
936
  findTaskCommitsOnBase: () => findTaskCommitsOnBase,
910
937
  getBranchDiff: () => getBranchDiff,
911
938
  getCommitFiles: () => getCommitFiles,
@@ -923,6 +950,7 @@ __export(git_exports, {
923
950
  getOriginRepoSlug: () => getOriginRepoSlug,
924
951
  getOriginUrl: () => getOriginUrl,
925
952
  getPathsDifferingFrom: () => getPathsDifferingFrom,
953
+ getPullRequestState: () => getPullRequestState,
926
954
  getPullRequestUrl: () => getPullRequestUrl,
927
955
  getRemoteBranchFiles: () => getRemoteBranchFiles,
928
956
  getRootCommitHash: () => getRootCommitHash,
@@ -949,6 +977,7 @@ __export(git_exports, {
949
977
  listGroupedCycleBranches: () => listGroupedCycleBranches,
950
978
  listOpenPullRequests: () => listOpenPullRequests,
951
979
  listOrphanFeatBranches: () => listOrphanFeatBranches,
980
+ memberBranchSlug: () => memberBranchSlug,
952
981
  mergePullRequest: () => mergePullRequest,
953
982
  normalizeGitUrl: () => normalizeGitUrl,
954
983
  pickModuleCycleBranch: () => pickModuleCycleBranch,
@@ -1841,9 +1870,20 @@ function detectUnrecordedCommits(cwd, baseBranch) {
1841
1870
  function taskBranchName(taskId) {
1842
1871
  return `feat/${taskId}`;
1843
1872
  }
1844
- function cycleBranchName(cycleNumber, module) {
1873
+ function cycleBranchName(cycleNumber, module, memberSlug) {
1845
1874
  const slug = module.toLowerCase().replace(/&amp;/g, "and").replace(/&/g, "and").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1846
- return `feat/cycle-${cycleNumber}-${slug}`;
1875
+ const member = memberSlug ? `-${memberSlug}` : "";
1876
+ return `feat/cycle-${cycleNumber}-${slug}${member}`;
1877
+ }
1878
+ function memberBranchSlug(member) {
1879
+ const sanitise = (raw) => raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 20).replace(/-+$/g, "");
1880
+ const fromName = member.displayName ? sanitise(member.displayName) : "";
1881
+ if (fromName) return fromName;
1882
+ const localPart = member.email ? member.email.split("@")[0] : "";
1883
+ const fromEmail = localPart ? sanitise(localPart) : "";
1884
+ if (fromEmail) return fromEmail;
1885
+ const fromId = member.userId ? sanitise(member.userId).slice(0, 8) : "";
1886
+ return fromId || void 0;
1847
1887
  }
1848
1888
  function getHeadCommitSha(cwd) {
1849
1889
  try {
@@ -1874,6 +1914,56 @@ function getPullRequestUrl(cwd, branch) {
1874
1914
  return null;
1875
1915
  }
1876
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
+ }
1877
1967
  function squashMergePullRequest(cwd, branch) {
1878
1968
  const repo = getOriginRepoSlug(cwd);
1879
1969
  const baseArgs = ["pr", "merge", branch, "--squash", "--delete-branch"];
@@ -1966,9 +2056,9 @@ function isBranchContentAlreadyInBase(cwd, branch, baseBranch) {
1966
2056
  return false;
1967
2057
  }
1968
2058
  }
1969
- function pickModuleCycleBranch(candidates, cycleNumber, module) {
2059
+ function pickModuleCycleBranch(candidates, cycleNumber, module, memberSlug) {
1970
2060
  if (candidates.length === 0) return void 0;
1971
- const expected = cycleBranchName(cycleNumber, module);
2061
+ const expected = cycleBranchName(cycleNumber, module, memberSlug);
1972
2062
  return candidates.find((b2) => b2 === expected);
1973
2063
  }
1974
2064
  function listOrphanFeatBranches(cwd, baseBranch) {
@@ -2307,6 +2397,10 @@ var init_proxy_adapter = __esm({
2307
2397
  "dismissRecommendation",
2308
2398
  "findPendingDocActionsForTask",
2309
2399
  "getActiveDecisions",
2400
+ "getContributorRole",
2401
+ "listContributorReleasePrs",
2402
+ "recordContributorReleasePr",
2403
+ "setContributorReleasePrStatus",
2310
2404
  "getActiveStage",
2311
2405
  "getBuildReportCountForTask",
2312
2406
  "getBuildReportsSince",
@@ -2446,7 +2540,7 @@ var init_proxy_adapter = __esm({
2446
2540
  "applyActiveDecisionUpdates",
2447
2541
  // getToolCallCount + updatePhaseStatus are ABSENT: they now have edge handlers and
2448
2542
  // are served through the forwarder (getToolCallCount = SUP-2026-026 handler #1).
2449
- "getContributorRole",
2543
+ // getContributorRole is edge-wired and binds identity from the bearer.
2450
2544
  // task-3018 (C356): storeDocBody + getDocBodyUsage are now WIRED — edge case
2451
2545
  // handlers + ALLOWED_METHODS/WRITE_METHODS entries exist, so they forward and
2452
2546
  // hosted callers get body storage. Removed from this list, as task-3017 required.
@@ -2454,9 +2548,6 @@ var init_proxy_adapter = __esm({
2454
2548
  // of the owner-action queue. Six readers were wired C329 (task-2412) but the
2455
2549
  // producer stayed here, so the hosted Owner Action Queue was structurally empty
2456
2550
  // (AD-74 inverted). Now forwards; the edge binds user_id to the bearer ([C]).
2457
- "recordContributorReleasePr",
2458
- "setContributorReleasePrStatus",
2459
- "listContributorReleasePrs",
2460
2551
  "claimReview",
2461
2552
  "getSiblingAds",
2462
2553
  "getSiblingRepoTasks",
@@ -3172,8 +3263,8 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
3172
3263
  getCycleLearningPatterns() {
3173
3264
  return this.invoke("getCycleLearningPatterns", []);
3174
3265
  }
3175
- updateCycleLearningActionRef(learningId, taskDisplayId) {
3176
- return this.invoke("updateCycleLearningActionRef", [learningId, taskDisplayId]);
3266
+ updateCycleLearningActionRef(learningId, taskDisplayId, opts) {
3267
+ return this.invoke("updateCycleLearningActionRef", [learningId, taskDisplayId, opts ?? {}]);
3177
3268
  }
3178
3269
  // --- Strategy Review Drafts ---
3179
3270
  savePendingReviewResponse(cycleNumber, rawResponse) {
@@ -3232,8 +3323,8 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
3232
3323
  async listContributors() {
3233
3324
  return this.invoke("listContributors", []);
3234
3325
  }
3235
- async addContributorByEmail(email) {
3236
- return this.invoke("addContributorByEmail", [email]);
3326
+ async addContributorByEmail(email, role) {
3327
+ return this.invoke("addContributorByEmail", [email, role]);
3237
3328
  }
3238
3329
  async removeContributorByEmail(email) {
3239
3330
  return this.invoke("removeContributorByEmail", [email]);
@@ -3279,7 +3370,11 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
3279
3370
  */
3280
3371
  async getMeteredUsage() {
3281
3372
  const body = await this.postRoute("metering", {});
3282
- 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
+ };
3283
3378
  }
3284
3379
  async listUserProjects() {
3285
3380
  const body = await this.postRoute("project-list", {});
@@ -6391,7 +6486,7 @@ async function runSetup() {
6391
6486
  log(`Project: ${result.project_slug}`);
6392
6487
  }
6393
6488
  log("");
6394
- 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.`);
6395
6490
  return 0;
6396
6491
  }
6397
6492
  var DEFAULT_BASE_URL, MAX_POLL_SECONDS, NETWORK_RETRY_LIMIT;
@@ -6499,7 +6594,7 @@ function loadConfig() {
6499
6594
  ${conflicting} is set in your environment, but PAPI_DATA_API_KEY is also set \u2014
6500
6595
  you have a hosted PAPI account, so the database URL is being ignored anyway.
6501
6596
 
6502
- 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,
6503
6598
  or remove it from your shell rc file (~/.zshrc, ~/.bashrc).
6504
6599
 
6505
6600
  If you intentionally self-host, set PAPI_SELF_HOST=1 in your environment to bypass this guard.`
@@ -6944,7 +7039,7 @@ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the
6944
7039
  Get started in 3 steps:
6945
7040
  1. Sign up at ${dashboardUrl ? `${dashboardUrl}/login` : "your configured PAPI dashboard"}
6946
7041
  2. Complete the onboarding wizard \u2014 it generates your .mcp.json config
6947
- 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
6948
7043
 
6949
7044
  Already have an account? Make sure PAPI_DATA_API_KEY is set in your .mcp.json env config.`
6950
7045
  );
@@ -7870,7 +7965,11 @@ The guard above says what to reject. This says what to propose. Rejecting is not
7870
7965
 
7871
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.
7872
7967
 
7873
- **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.`;
7874
7973
  var AD_CONFLICT_SURFACING_RULES = `**A contradiction is NOT a veto \u2014 surface it, never silently shelve it.**
7875
7974
 
7876
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.
@@ -8857,7 +8956,7 @@ After your natural language output, include this EXACT format on its own line:
8857
8956
  "activeDecisionUpdates": [
8858
8957
  {
8859
8958
  "id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
8860
- "action": "confidence_change | modify | resolve | supersede | new | delete",
8959
+ "action": "confidence_change | modify | resolve | supersede | new | delete | demote",
8861
8960
  "body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
8862
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.",
8863
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."
@@ -9109,7 +9208,7 @@ After your natural language output, include this EXACT format on its own line:
9109
9208
  "activeDecisionUpdates": [
9110
9209
  {
9111
9210
  "id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
9112
- "action": "confidence_change | modify | resolve | supersede | new | delete",
9211
+ "action": "confidence_change | modify | resolve | supersede | new | delete | demote",
9113
9212
  "body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
9114
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.",
9115
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."
@@ -9620,13 +9719,20 @@ async function getPrompt(name) {
9620
9719
  }
9621
9720
 
9622
9721
  // src/lib/foundation.ts
9623
- var cache2 = null;
9722
+ var cache2 = /* @__PURE__ */ new Map();
9624
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
+ }
9625
9729
  var MAX_TOKENS = 800;
9626
9730
  var MAX_CHARS = MAX_TOKENS * 4;
9627
9731
  async function buildProjectFoundation(adapter2) {
9628
9732
  const now = Date.now();
9629
- 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;
9630
9736
  const [briefRaw, decisions, northStar, horizons, stages] = await Promise.all([
9631
9737
  safe(() => adapter2.readProductBrief(), ""),
9632
9738
  safe(() => adapter2.getActiveDecisions(), []),
@@ -9648,7 +9754,7 @@ async function buildProjectFoundation(adapter2) {
9648
9754
  const userType = extractUserType(briefRaw);
9649
9755
  if (userType) lines.push(`Primary user: ${userType}`);
9650
9756
  if (lines.length === 0) {
9651
- cache2 = { content: "", expiresAt: now + CACHE_TTL_MS2 };
9757
+ cache2.set(cacheKey, { content: "", expiresAt: now + CACHE_TTL_MS2 });
9652
9758
  return "";
9653
9759
  }
9654
9760
  const body = lines.join("\n");
@@ -9661,7 +9767,7 @@ ${body}
9661
9767
  [/project_foundation]
9662
9768
 
9663
9769
  ` : block;
9664
- cache2 = { content: final, expiresAt: now + CACHE_TTL_MS2 };
9770
+ cache2.set(cacheKey, { content: final, expiresAt: now + CACHE_TTL_MS2 });
9665
9771
  return final;
9666
9772
  }
9667
9773
  async function safe(fn, fallback) {
@@ -10155,6 +10261,46 @@ function computeCarryForwardStaleness(log2, doneTaskIds) {
10155
10261
  ].join("\n");
10156
10262
  }
10157
10263
 
10264
+ // src/lib/refs.ts
10265
+ var MAX_LABEL = 55;
10266
+ function truncateLabel(title, max = MAX_LABEL) {
10267
+ if (title.length <= max) return title;
10268
+ return `${title.slice(0, max - 1).trimEnd()}\u2026`;
10269
+ }
10270
+ function formatRef(id, title) {
10271
+ const label = title?.trim();
10272
+ if (!label) return id;
10273
+ if (label.toLowerCase() === id.toLowerCase()) return id;
10274
+ return `${id} (${truncateLabel(label)})`;
10275
+ }
10276
+ function formatRefList(ids, titles) {
10277
+ return ids.map((id) => formatRef(id, lookupTitle(id, titles))).join(", ");
10278
+ }
10279
+ function lookupTitle(id, titles) {
10280
+ if (!titles) return void 0;
10281
+ return titles.get(id) ?? titles.get(id.toLowerCase()) ?? titles.get(id.toUpperCase());
10282
+ }
10283
+ function titleMap(entities) {
10284
+ const map = /* @__PURE__ */ new Map();
10285
+ for (const e of entities) {
10286
+ if (!e.title) continue;
10287
+ for (const key of [e.id, e.displayId, e.uuid]) {
10288
+ if (!key) continue;
10289
+ map.set(key, e.title);
10290
+ map.set(key.toLowerCase(), e.title);
10291
+ }
10292
+ }
10293
+ return map;
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
+ }
10303
+
10158
10304
  // src/lib/blocker.ts
10159
10305
  function idMatches(candidate, ref) {
10160
10306
  if (!candidate) return false;
@@ -10188,16 +10334,31 @@ function isBlockerResolved(blocker, ctx) {
10188
10334
  return false;
10189
10335
  }
10190
10336
  }
10191
- function formatBlockerWaiting(blocker) {
10337
+ function formatBlockerWaiting(blocker, refTitle) {
10338
+ const ref = formatRef(blocker.ref, refTitle);
10192
10339
  switch (blocker.type) {
10193
10340
  case "depends-on":
10194
- return `waiting on ${blocker.ref} \u2014 clears when that task is Done`;
10341
+ return `waiting on ${ref} \u2014 clears when that task is Done`;
10195
10342
  case "owner-action":
10196
- return `waiting on owner action ${blocker.ref} \u2014 clears when you complete it`;
10343
+ return `waiting on owner action ${ref} \u2014 clears when you complete it`;
10344
+ case "decision-gate":
10345
+ return `waiting on decision ${ref} \u2014 clears when that decision is resolved`;
10346
+ default:
10347
+ return `waiting on ${ref}`;
10348
+ }
10349
+ }
10350
+ function resolveBlockerTitle(blocker, ctx) {
10351
+ switch (blocker.type) {
10352
+ case "depends-on":
10353
+ return ctx.tasks.find(
10354
+ (t) => idMatches(t.displayId, blocker.ref) || idMatches(t.id, blocker.ref)
10355
+ )?.title;
10197
10356
  case "decision-gate":
10198
- return `waiting on decision ${blocker.ref} \u2014 clears when that decision is resolved`;
10357
+ return ctx.decisions.find(
10358
+ (d) => idMatches(d.displayId, blocker.ref) || idMatches(d.id, blocker.ref)
10359
+ )?.title;
10199
10360
  default:
10200
- return `waiting on ${blocker.ref}`;
10361
+ return void 0;
10201
10362
  }
10202
10363
  }
10203
10364
 
@@ -10303,6 +10464,31 @@ function isProjectOwner(callerUserId, ownerUserId) {
10303
10464
  if (caller.length === 0 || owner.length === 0) return false;
10304
10465
  return caller === owner;
10305
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
+ }
10306
10492
  async function resolveOwnerGate(adapter2, config2) {
10307
10493
  if (adapterSupports(adapter2, "getOwnerIdentity")) {
10308
10494
  try {
@@ -10368,6 +10554,48 @@ function normalizeComplexity(value) {
10368
10554
  const key = (value ?? "").trim().toUpperCase();
10369
10555
  return COMPLEXITY_ALIASES[key] ?? "Small";
10370
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
+ }
10371
10599
  var PLAN_BUILD_REPORT_BUDGET = { maxReports: 12, fieldBudget: 280 };
10372
10600
  function leadChainWithRecommended(chain, recommendedTaskId) {
10373
10601
  const rec = recommendedTaskId?.trim();
@@ -10384,10 +10612,7 @@ async function resolvePlanScope(adapter2, config2) {
10384
10612
  }
10385
10613
  function filterToPersonalBacklog(tasks, scope) {
10386
10614
  if (!scope.callerUserId) return tasks;
10387
- if (scope.callerIsOwner) {
10388
- return tasks.filter((t) => !t.assigneeId || t.assigneeId === scope.callerUserId);
10389
- }
10390
- return tasks.filter((t) => t.assigneeId === scope.callerUserId);
10615
+ return tasks.filter((t) => !t.assigneeId || t.assigneeId === scope.callerUserId);
10391
10616
  }
10392
10617
  function determineContextTier(cycleCount) {
10393
10618
  if (cycleCount <= 5) return 1;
@@ -10428,11 +10653,22 @@ async function parkDecisionConflicts(adapter2, conflicts, newTaskIdMap, blockedC
10428
10653
  if (conflicts.length === 0) return [];
10429
10654
  const scheduled = new Set(scheduledTaskIds.map((id) => id.toLowerCase()));
10430
10655
  const results = [];
10656
+ let adTitles = /* @__PURE__ */ new Map();
10657
+ try {
10658
+ if (adapter2.getActiveDecisions) adTitles = titleMap(await adapter2.getActiveDecisions());
10659
+ } catch {
10660
+ }
10431
10661
  for (const conflict of conflicts) {
10432
10662
  const resolvedTaskId = newTaskIdMap.get(conflict.taskId) ?? conflict.taskId;
10433
- const base = { ...conflict, resolvedTaskId, parked: false };
10663
+ const adTitle = lookupTitle(conflict.adId, adTitles);
10664
+ const base = { ...conflict, resolvedTaskId, parked: false, adTitle };
10434
10665
  if (scheduled.has(resolvedTaskId.toLowerCase())) {
10435
- results.push({ ...base, skipReason: "scheduled in this cycle \u2014 left in the cycle, conflict still surfaced" });
10666
+ const [scheduledTask] = await adapter2.getTasks([resolvedTaskId]).catch(() => []);
10667
+ results.push({
10668
+ ...base,
10669
+ taskTitle: scheduledTask?.title,
10670
+ skipReason: "scheduled in this cycle \u2014 left in the cycle, conflict still surfaced"
10671
+ });
10436
10672
  continue;
10437
10673
  }
10438
10674
  try {
@@ -10441,6 +10677,7 @@ async function parkDecisionConflicts(adapter2, conflicts, newTaskIdMap, blockedC
10441
10677
  results.push({ ...base, skipReason: "task not found" });
10442
10678
  continue;
10443
10679
  }
10680
+ base.taskTitle = task.title;
10444
10681
  if (!PARKABLE_STATUSES.has(task.status)) {
10445
10682
  results.push({ ...base, skipReason: `status is ${task.status} \u2014 left as-is` });
10446
10683
  continue;
@@ -10453,7 +10690,7 @@ async function parkDecisionConflicts(adapter2, conflicts, newTaskIdMap, blockedC
10453
10690
  blocker: {
10454
10691
  type: "decision-gate",
10455
10692
  ref: conflict.adId,
10456
- reason: conflict.conflict || `Contradicts ${conflict.adId} \u2014 awaiting the owner's decision.`,
10693
+ reason: conflict.conflict || `Contradicts ${formatRef(conflict.adId, adTitle)} \u2014 awaiting the owner's decision.`,
10457
10694
  blockedCycle
10458
10695
  }
10459
10696
  });
@@ -10780,7 +11017,8 @@ async function computeResurfacedBlockers(adapter2, tasks, decisions, currentCycl
10780
11017
  const resolvedLines = [];
10781
11018
  for (const t of blocked) {
10782
11019
  if (t.blocker && isBlockerResolved(t.blocker, ctx)) {
10783
- resolvedLines.push(`- **${t.displayId}** (${t.title}) \u2014 ${formatBlockerWaiting(t.blocker)} \u2192 now RESOLVED, ready to unblock`);
11020
+ const waiting = formatBlockerWaiting(t.blocker, resolveBlockerTitle(t.blocker, ctx));
11021
+ resolvedLines.push(`- **${t.displayId}** (${t.title}) \u2014 ${waiting} \u2192 now RESOLVED, ready to unblock`);
10784
11022
  }
10785
11023
  }
10786
11024
  return resolvedLines.length > 0 ? resolvedLines.join("\n") : void 0;
@@ -11338,6 +11576,14 @@ function applyCancellationGuard(corrections, confirmCancellations) {
11338
11576
  async function transactionalWriteBack(adapter2, cycleNumber, data, contextHashes, options = {}) {
11339
11577
  const writeBackTimer = startTimer();
11340
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
+ }
11341
11587
  const skippedCancellations = [];
11342
11588
  const cleanTitle = data.cycleLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
11343
11589
  const cleanContent = data.cycleLogContent.replace(/^#{1,3}\s+(?:Cycle|Session)\s+\d+\s*—[^\n]*\n*/i, "").trim();
@@ -11444,7 +11690,7 @@ ${cleanContent}`;
11444
11690
  // task-2242: stable handoff join key
11445
11691
  title: t.title,
11446
11692
  status: t.status || "Backlog",
11447
- priority: t.priority || "P1 High",
11693
+ priority: normalizePriority(t.priority),
11448
11694
  complexity: normalizeComplexity(t.complexity),
11449
11695
  module: t.module || "Core",
11450
11696
  epic: t.epic || "Platform",
@@ -11592,7 +11838,7 @@ ${cleanContent}`;
11592
11838
  displayId: "",
11593
11839
  title: task.title,
11594
11840
  status: task.status || "Backlog",
11595
- priority: task.priority || "P1 High",
11841
+ priority: normalizePriority(task.priority),
11596
11842
  complexity: normalizeComplexity(task.complexity),
11597
11843
  module: task.module || "Core",
11598
11844
  epic: task.epic || "Platform",
@@ -11894,7 +12140,7 @@ async function validateAndPrepare(adapter2, force, callerUserId, adapterType) {
11894
12140
  try {
11895
12141
  const health = await adapter2.getCycleHealth();
11896
12142
  cycleNumber = health.totalCycles;
11897
- mode = determineMode(health.totalCycles);
12143
+ mode = determineMode(health.projectCycleCount ?? health.totalCycles);
11898
12144
  const blockingCycle = callerUserId ? await resolveCallerLatestCycle(adapter2, callerUserId) : void 0;
11899
12145
  const latestStatus = callerUserId ? blockingCycle?.status : health.latestCycleStatus;
11900
12146
  const blockingNumber = blockingCycle?.number ?? cycleNumber;
@@ -13085,6 +13331,22 @@ var planTool = {
13085
13331
  ]
13086
13332
  }
13087
13333
  };
13334
+ function formatDecisionConflicts(conflicts) {
13335
+ if (conflicts.length === 0) return [];
13336
+ const lines = [""];
13337
+ lines.push(`\u{1F500} **${conflicts.length} decision(s) need your call \u2014 work was NOT dropped.**`);
13338
+ lines.push("These tasks cut against a live Active Decision. An AD is *active* \u2014 it can be superseded, so the planner parked the work instead of shelving it:");
13339
+ for (const c of conflicts) {
13340
+ const ad = formatRef(c.adId, c.adTitle);
13341
+ const state = c.parked ? `Blocked behind ${ad}` : `still ${c.skipReason ?? "unparked"}`;
13342
+ lines.push(`- **${formatRef(c.resolvedTaskId, c.taskTitle)} vs ${ad}** \u2014 ${c.conflict} _(${state})_`);
13343
+ if (c.options.length > 0) lines.push(` Options: ${c.options.join(" | ")}`);
13344
+ if (c.recommendation) lines.push(` Planner recommends: ${c.recommendation}`);
13345
+ }
13346
+ lines.push("");
13347
+ lines.push('Decide: `strategy_change` with `mode: "capture"` to supersede or modify the AD (parked tasks auto-unblock once it moves), or `board_edit` to cancel the task if the AD stands.');
13348
+ return lines;
13349
+ }
13088
13350
  function formatPlanResult(result) {
13089
13351
  const modeLabel = result.mode === "bootstrap" ? "Bootstrap" : "Full";
13090
13352
  const cycleLabel = `Cycle ${result.cycleNumber + 1}`;
@@ -13131,20 +13393,7 @@ function formatPlanResult(result) {
13131
13393
  lines.push('To apply these cancellations, re-run `plan` with `mode: "apply"` and `confirm_cancellations: true`.');
13132
13394
  lines.push("To keep these tasks, do nothing \u2014 they remain on the board.");
13133
13395
  }
13134
- const conflicts = result.decisionConflicts ?? [];
13135
- if (conflicts.length > 0) {
13136
- lines.push("");
13137
- lines.push(`\u{1F500} **${conflicts.length} decision(s) need your call \u2014 work was NOT dropped.**`);
13138
- lines.push("These tasks cut against a live Active Decision. An AD is *active* \u2014 it can be superseded, so the planner parked the work instead of shelving it:");
13139
- for (const c of conflicts) {
13140
- const state = c.parked ? `Blocked behind ${c.adId}` : `still ${c.skipReason ?? "unparked"}`;
13141
- lines.push(`- **${c.resolvedTaskId} vs ${c.adId}** \u2014 ${c.conflict} _(${state})_`);
13142
- if (c.options.length > 0) lines.push(` Options: ${c.options.join(" | ")}`);
13143
- if (c.recommendation) lines.push(` Planner recommends: ${c.recommendation}`);
13144
- }
13145
- lines.push("");
13146
- lines.push('Decide: `strategy_change` with `mode: "capture"` to supersede or modify the AD (parked tasks auto-unblock once it moves), or `board_edit` to cancel the task if the AD stands.');
13147
- }
13396
+ lines.push(...formatDecisionConflicts(result.decisionConflicts ?? []));
13148
13397
  if (result.skipHandoffs) {
13149
13398
  const taskCount = result.writeSummary?.taskIds.length ?? 0;
13150
13399
  lines.push("", `Next: run \`handoff_generate\` to create BUILD HANDOFFs for your ${taskCount} cycle task(s), then \`build_list\` to start building.`);
@@ -13381,6 +13630,85 @@ ${result.userMessage}
13381
13630
  }
13382
13631
  }
13383
13632
 
13633
+ // src/lib/capabilities.ts
13634
+ init_dist();
13635
+
13636
+ // src/lib/directive-builders.ts
13637
+ function buildPrReviewerDirective(caps) {
13638
+ if (!isCapabilityEnabled(caps, "prReviewer")) return null;
13639
+ return `
13640
+
13641
+ **Quality Gate:** no auto-review attached. PAPI's standard pre-accept step is a code review of the branch diff \u2014 run \`review_submit\` with \`dispatch:"subagent"\` to auto-review, or attach \`auto_review\` findings. Risk-tier work (auth, data, migrations, CI) should always carry one.`;
13642
+ }
13643
+ function buildChangelogDirective(caps, inner) {
13644
+ if (!isCapabilityEnabled(caps, "changelog")) return null;
13645
+ return inner;
13646
+ }
13647
+ function buildDiscoveredIssuesDirective(caps, issueLines) {
13648
+ if (!isCapabilityEnabled(caps, "discoveredIssues")) return null;
13649
+ if (issueLines.length === 0) return null;
13650
+ return [
13651
+ "",
13652
+ "---",
13653
+ "",
13654
+ `## Discovered Issues (${issueLines.length})`,
13655
+ "",
13656
+ ...issueLines,
13657
+ "",
13658
+ "*These issues were logged during builds \u2014 triage them in the next plan.*"
13659
+ ].join("\n");
13660
+ }
13661
+ function buildModelRecommendationDirective(caps, tierBlock) {
13662
+ if (!isCapabilityEnabled(caps, "modelRecommendation")) return null;
13663
+ return tierBlock;
13664
+ }
13665
+ function buildVerifyHealthCheckDirective(caps) {
13666
+ if (!isCapabilityEnabled(caps, "verifyHealthCheck")) return null;
13667
+ return `
13668
+
13669
+ **Health check:** before you consider the cycle shipped, verify cycle state (plan validity, review coverage, branch hygiene) \u2014 run the \`papi-verify\` skill or a quick \`board_view\` pass.`;
13670
+ }
13671
+ function buildGestaltPreBuildDirective(caps) {
13672
+ if (!isCapabilityEnabled(caps, "gestaltPreBuild")) return null;
13673
+ return `
13674
+
13675
+ **Gestalt pre-build:** on the first task of a multi-task cycle, read every task's BUILD HANDOFF together (shared files, sequencing, module split) before building \u2014 one-time check at cycle start.`;
13676
+ }
13677
+ function buildBatchBuildRollupDirective(caps) {
13678
+ if (!isCapabilityEnabled(caps, "batchBuildRollup")) return null;
13679
+ return `
13680
+
13681
+ **Batch rollup:** after the final task of a batch, emit a cycle-level rollup \u2014 build summary table + discovered issues grouped by severity + next action.`;
13682
+ }
13683
+ function buildSecurityScanDirective(caps) {
13684
+ if (!isCapabilityEnabled(caps, "securityScan")) return null;
13685
+ return `
13686
+
13687
+ **Security scan:** risk-tier changes (auth, data, migrations, secrets, endpoints) should carry a security pass \u2014 OWASP top-10 + secret-exposure check on the changed surface before merge.`;
13688
+ }
13689
+ function buildDeployHookDirective(caps, deployCommand) {
13690
+ if (!isCapabilityEnabled(caps, "deployHook")) return null;
13691
+ const command = deployCommand?.trim() || void 0;
13692
+ if (!command) return null;
13693
+ return `
13694
+
13695
+ ---
13696
+
13697
+ ## Deploy \u2014 run after this release
13698
+
13699
+ The post-release deploy hook is on and a deploy command is configured, so ship the merged release now.
13700
+
13701
+ Run your deploy command yourself:
13702
+ \`\`\`
13703
+ ${command}
13704
+ \`\`\`
13705
+ PAPI never runs this command itself (AD-58) \u2014 you run it in your own environment. Turn the "Post-release deploy" capability off in the dashboard, or unset PAPI_DEPLOY, to stop this reminder.`;
13706
+ }
13707
+ function buildPapiMetaFramingDirective(caps, inner) {
13708
+ if (!isCapabilityEnabled(caps, "papiMetaFraming")) return null;
13709
+ return inner;
13710
+ }
13711
+
13384
13712
  // src/services/strategy.ts
13385
13713
  import { randomUUID as randomUUID5, createHash as createHash4 } from "crypto";
13386
13714
  import { execFileSync as execFileSync2 } from "child_process";
@@ -13737,8 +14065,10 @@ ${lines.join("\n")}
13737
14065
  }
13738
14066
  let notesWithAlignment = input.notes || "";
13739
14067
  let detectedAdIds = [];
14068
+ let adTitles = /* @__PURE__ */ new Map();
13740
14069
  try {
13741
14070
  const ads = await adapter2.getActiveDecisions();
14071
+ adTitles = titleMap(ads);
13742
14072
  const ideaCombined = `${input.text} ${input.notes ?? ""}`;
13743
14073
  const alignmentMatches = findAdAlignmentMatches(ideaCombined, ads);
13744
14074
  if (alignmentMatches.length > 0) {
@@ -13828,7 +14158,7 @@ AD-CONFLICT: ${conflictsWithAd} \u2014 submitter flagged this as cutting against
13828
14158
  blocker: {
13829
14159
  type: "decision-gate",
13830
14160
  ref: conflictsWithAd,
13831
- reason: `Submitted as cutting against ${conflictsWithAd}. Awaiting the owner's decision.`,
14161
+ reason: `Submitted as cutting against ${formatRef(conflictsWithAd, lookupTitle(conflictsWithAd, adTitles))}. Awaiting the owner's decision.`,
13832
14162
  blockedCycle: health.totalCycles
13833
14163
  }
13834
14164
  });
@@ -13847,21 +14177,36 @@ AD-CONFLICT: ${conflictsWithAd} \u2014 submitter flagged this as cutting against
13847
14177
  routing: "task",
13848
14178
  task,
13849
14179
  message: `${task.id}: "${task.title}" \u2014 ${landing}${typeNote}${visibilityNote}${mismatchNote}`,
13850
- ...adIds.length > 0 ? { adConflicts: { adIds, explicit: Boolean(conflictsWithAd), gated } } : {}
14180
+ ...adIds.length > 0 ? {
14181
+ adConflicts: {
14182
+ adIds,
14183
+ explicit: Boolean(conflictsWithAd),
14184
+ gated,
14185
+ // task-3347: id → title for exactly the ADs named above, so the tool
14186
+ // layer renders `AD-12 (Single board view)` without a second read.
14187
+ adTitles: Object.fromEntries(
14188
+ adIds.flatMap((id) => {
14189
+ const title = lookupTitle(id, adTitles);
14190
+ return title ? [[id, title]] : [];
14191
+ })
14192
+ )
14193
+ }
14194
+ } : {}
13851
14195
  };
13852
14196
  }
13853
- function buildAdConflictNote(conflictsWithAd, detectedAdIds, gated) {
14197
+ function buildAdConflictNote(conflictsWithAd, detectedAdIds, gated, adTitles) {
13854
14198
  if (conflictsWithAd) {
13855
- const state = gated ? `Parked as **Blocked** behind a decision gate on ${conflictsWithAd} \u2014 it auto-unblocks once that AD is superseded, modified, or reaffirmed.` : `Recorded against ${conflictsWithAd} (gate could not be applied \u2014 the AD-CONFLICT note is on the task).`;
14199
+ const ref = formatRef(conflictsWithAd, lookupTitle(conflictsWithAd, adTitles));
14200
+ const state = gated ? `Parked as **Blocked** behind a decision gate on ${ref} \u2014 it auto-unblocks once that AD is superseded, modified, or reaffirmed.` : `Recorded against ${ref} (gate could not be applied \u2014 the AD-CONFLICT note is on the task).`;
13856
14201
  return `
13857
14202
 
13858
- \u{1F500} **Conflicts with ${conflictsWithAd}.** ${state}
14203
+ \u{1F500} **Conflicts with ${ref}.** ${state}
13859
14204
  **Tell the user now** \u2014 an AD is *active*, so this is their call to make, not yours. Offer both paths: supersede the AD via \`strategy_change\`, or leave the AD standing and drop the task.`;
13860
14205
  }
13861
14206
  if (detectedAdIds.length > 0) {
13862
14207
  return `
13863
14208
 
13864
- \u{1F500} **Possible conflict with ${detectedAdIds.join(", ")}** (keyword match \u2014 verify it is real).
14209
+ \u{1F500} **Possible conflict with ${formatRefList(detectedAdIds, adTitles)}** (keyword match \u2014 verify it is real).
13865
14210
  If it IS a real contradiction, **do not shelve the idea on those grounds**: surface it to the user and re-submit with \`conflicts_with_ad\` so the task is gated on the decision instead of quietly competing in the backlog.`;
13866
14211
  }
13867
14212
  return "";
@@ -14332,7 +14677,8 @@ async function assembleContext2(adapter2, cycleNumber, cyclesSinceLastReview, pr
14332
14677
  pendingRecs,
14333
14678
  registeredDocs,
14334
14679
  docsWithPendingActions,
14335
- decisionScores
14680
+ decisionScores,
14681
+ allClosedTasks
14336
14682
  ] = await Promise.all([
14337
14683
  adapter2.readProductBrief(),
14338
14684
  // Strategy review needs to see retired ADs to triage/restore them as needed.
@@ -14361,7 +14707,15 @@ async function assembleContext2(adapter2, cycleNumber, cyclesSinceLastReview, pr
14361
14707
  // Doc registry — docs with pending actions for staleness audit
14362
14708
  adapter2.searchDocs?.({ hasPendingActions: true, limit: 20 })?.catch(() => []) ?? Promise.resolve([]),
14363
14709
  // task-2264: decision risk scores + movement, so the review can show trajectory.
14364
- adapter2.getDecisionScorePatterns?.()?.catch(() => []) ?? Promise.resolve([])
14710
+ adapter2.getDecisionScorePatterns?.()?.catch(() => []) ?? Promise.resolve([]),
14711
+ // task-3331 (C363): UNWINDOWED closed-task ids, purely to exclude already-closed
14712
+ // tasks from the repeat-deferral detector. Deliberately NOT reusing
14713
+ // recentDoneTasks: that set is windowed with `cycleSince: lastReviewCycleNum`
14714
+ // because the surfaces it feeds (review-window comments) genuinely want the
14715
+ // window. Carry-forward history spans many cycles, so a task that shipped BEFORE
14716
+ // the window was invisible to the exclusion and kept being flagged. Compact +
14717
+ // Cancelled, matching what services/plan.ts already does correctly.
14718
+ adapter2.queryBoard({ status: ["Done", "Cancelled"], compact: true }).catch(() => [])
14365
14719
  ]);
14366
14720
  const pendingAgendaTopics = await (adapter2.getPendingAgendaTopics?.().catch(() => []) ?? Promise.resolve([]));
14367
14721
  const tasks = [...activeTasks, ...recentDoneTasks];
@@ -14643,7 +14997,7 @@ ${lines.join("\n")}`;
14643
14997
  { label: "docActionStaleness", hasData: docActionStalenessText !== void 0 }
14644
14998
  ]);
14645
14999
  const doneTaskIds = new Set(
14646
- recentDoneTasks.map((t) => t.displayId ?? t.id).filter((id) => !!id)
15000
+ allClosedTasks.map((t) => t.displayId ?? t.id).filter((id) => !!id)
14647
15001
  );
14648
15002
  const earnedPushback = computeEarnedPushback({ reports, log: recentLog, doneTaskIds });
14649
15003
  const context = {
@@ -14796,6 +15150,29 @@ function asDecisionBatchApplier(adapter2) {
14796
15150
  const candidate = adapter2;
14797
15151
  return typeof candidate.applyActiveDecisionUpdates === "function" ? candidate : void 0;
14798
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
+ }
14799
15176
  function routeDecisionUpdate(ad, adapter2, cycleNumber, warnings) {
14800
15177
  const action = ad.action;
14801
15178
  let route;
@@ -15542,7 +15919,10 @@ ${cleanContent}`;
15542
15919
  });
15543
15920
  if (data.activeDecisionUpdates && data.activeDecisionUpdates.length > 0) {
15544
15921
  for (const ad of data.activeDecisionUpdates) {
15545
- 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) {
15546
15926
  await adapter2.deleteActiveDecision(ad.id);
15547
15927
  } else if (ad.action === "new" && adapter2.upsertActiveDecision) {
15548
15928
  const titleMatch = ad.body.match(/^###\s+\S+:\s*([^\n[]+?)(?:\s*\[|$)/m);
@@ -15792,6 +16172,35 @@ function toDateLabel(value) {
15792
16172
  const d = value instanceof Date ? value : new Date(value);
15793
16173
  return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
15794
16174
  }
16175
+ var STRATEGY_REVIEW_CHANNEL_ENV = "DISCORD_STRATEGY_REVIEW_CHANNEL_ID";
16176
+ function buildStrategyReviewPostDirective(cycleNumber) {
16177
+ const channelId = process.env[STRATEGY_REVIEW_CHANNEL_ENV]?.trim();
16178
+ if (!channelId) return null;
16179
+ return [
16180
+ "",
16181
+ "---",
16182
+ "## \u{1F4E3} Post the patch notes to Discord #papi-strategy-review",
16183
+ "",
16184
+ `This review closed **Cycle ${cycleNumber}**. Post a curated, safety-gated **rich embed** (NOT plain content, so links do not unfurl) to #papi-strategy-review (id \`${channelId}\`) via \`mcp__discord__send_embed\`. Run \`/patch-notes\` \u2014 it owns the mechanics.`,
16185
+ "",
16186
+ "**LOCKED TEMPLATE \u2014 every patch note uses this exact shape (do NOT improvise the structure):**",
16187
+ "- `title`: `PAPI Patch Notes v<version>`",
16188
+ "- `description`: `Covering Cycles <start>\u2013<end>`",
16189
+ "- `color`: `#6C5CE7` (PAPI purple)",
16190
+ "- `fields`: one per non-empty category, `inline: false`, `\u2022 ` bullets \u2014 \u{1F680} New Features, \u{1F6E0} Improvements, \u{1F41B} Bug Fixes, \u2699\uFE0F Under the Hood, \u{1F52E} What's Coming (always included)",
16191
+ "- `footer`: `PAPI v<version> \xB7 <date>`",
16192
+ "",
16193
+ "This is the DEEP multi-cycle note covering the whole review window, not a single release. Aim for 5-7 items in the strong sections. Strip task-IDs, branch names and internal framing; only changes an existing user would notice.",
16194
+ "",
16195
+ "Then:",
16196
+ "1. Run the build-in-public safety gate: NO external usernames, NO contributor or private work, NO owner cost/commercial data, NO failure-rate or funnel percentages.",
16197
+ "2. Show the draft embed for approval BEFORE posting.",
16198
+ `3. On approval, send the embed to channel \`${channelId}\`.`,
16199
+ "4. Then run `/content-pack --source=strategy_review` \u2014 a review is the richest content source PAPI produces, and it is the cadence the Blog and Substack lanes exist for.",
16200
+ "",
16201
+ "Skip only if nothing user-facing shipped in the whole window, and say so explicitly rather than moving on silently."
16202
+ ].join("\n");
16203
+ }
15795
16204
  var reviewPrepareCache = new PerCallerCache();
15796
16205
  var strategyReviewTool = {
15797
16206
  name: "strategy_review",
@@ -15973,6 +16382,19 @@ ${recLines.join("\n")}
15973
16382
  }
15974
16383
  } catch {
15975
16384
  }
16385
+ let caps = {};
16386
+ try {
16387
+ const info = adapter2.getProjectInfo ? await adapter2.getProjectInfo() : null;
16388
+ caps = info?.capabilities ?? {};
16389
+ } catch {
16390
+ caps = {};
16391
+ }
16392
+ const postDirective = buildChangelogDirective(
16393
+ caps,
16394
+ buildStrategyReviewPostDirective(result.cycleNumber)
16395
+ );
16396
+ const postStep = postDirective ? `4. Post the patch notes to Discord \u2014 see the directive at the end of this output.
16397
+ 5. Then run \`plan\` for the next cycle.` : `4. Then run \`plan\` for the next cycle.`;
15976
16398
  const actionReminder = `
15977
16399
 
15978
16400
  ---
@@ -15981,8 +16403,10 @@ ${recLines.join("\n")}
15981
16403
  1. Review the AD changes above \u2014 are any resolved/modified ADs still referenced by active tasks?
15982
16404
  2. Act on strategic recommendations \u2014 cancel, defer, or reprioritise tasks as suggested.
15983
16405
  3. Run \`strategy_change\` if any recommendation requires an AD update not already captured.
15984
- 4. Then run \`plan\` for the next cycle.`;
15985
- const response = textResponse(header + result.displayText + slackNote + recsCloseOut + actionReminder);
16406
+ ` + postStep;
16407
+ const response = textResponse(
16408
+ header + result.displayText + slackNote + recsCloseOut + actionReminder + (postDirective ?? "")
16409
+ );
15986
16410
  return {
15987
16411
  ...response,
15988
16412
  ...contextBytes !== void 0 ? { _contextBytes: contextBytes } : {},
@@ -16386,7 +16810,17 @@ var BOARD_COLUMNS = [
16386
16810
  { name: "createdAt", header: "Created", value: (t) => t.createdAt ?? "-" },
16387
16811
  { name: "source", header: "Source", value: (t) => t.source ?? "-" }
16388
16812
  ];
16389
- var BOARD_FIELDS = BOARD_COLUMNS.map((c) => c.name);
16813
+ var ASSIGNEE_FIELD = "assignee";
16814
+ function assigneeColumn(nameOf) {
16815
+ return {
16816
+ name: ASSIGNEE_FIELD,
16817
+ header: "Assignee",
16818
+ // An unresolved id still renders as a short id rather than blank: "someone holds
16819
+ // this" is the fact that matters, and a blank cell reads as unclaimed.
16820
+ value: (t) => t.assigneeId ? nameOf.get(t.assigneeId) ?? t.assigneeId.slice(0, 8) : "\u2014"
16821
+ };
16822
+ }
16823
+ var BOARD_FIELDS = [...BOARD_COLUMNS.map((c) => c.name), ASSIGNEE_FIELD];
16390
16824
  var BOARD_SINGLE_LABELS = {
16391
16825
  id: "Task",
16392
16826
  title: "Title",
@@ -16445,6 +16879,11 @@ var boardViewTool = {
16445
16879
  enum: ["full", "summary"],
16446
16880
  description: 'Output mode: "full" (default) shows task table, "summary" shows counts only.'
16447
16881
  },
16882
+ assignee: {
16883
+ type: "string",
16884
+ enum: ["me", "unclaimed"],
16885
+ description: 'Filter by who holds the task on a shared project. "me" shows only your own claims; "unclaimed" shows the open pool nobody has taken. Omit to see everything. On a multi-member project the board also gains an Assignee column showing who holds each task.'
16886
+ },
16448
16887
  fields: {
16449
16888
  type: "string",
16450
16889
  description: `Optional sparse-field projection \u2014 comma-separated column names, e.g. "title,status". Returns ONLY those columns (id is always included so rows stay addressable) which is far cheaper on session context than the full table. Allowed: ${BOARD_FIELDS.join(", ")}. An unknown name is an error.`
@@ -16657,11 +17096,16 @@ function formatSingleTask(t, fields) {
16657
17096
  if (t.why?.trim()) lines.push(`- **Why:** ${t.why.trim()}`);
16658
17097
  return lines.join("\n");
16659
17098
  }
16660
- function formatBoard(result, fields) {
17099
+ function formatBoard(result, fields, assignee) {
16661
17100
  if (result.tasks.length === 0) {
16662
17101
  return "No tasks found.";
16663
17102
  }
16664
- const columns = selectColumns(BOARD_COLUMNS, fields);
17103
+ const available = assignee ? [
17104
+ ...BOARD_COLUMNS.slice(0, 4),
17105
+ assignee,
17106
+ ...BOARD_COLUMNS.slice(4)
17107
+ ] : BOARD_COLUMNS;
17108
+ const columns = selectColumns(available, fields);
16665
17109
  const headers = columns.map((c) => c.header);
16666
17110
  const rows = result.tasks.map((t) => columns.map((c) => c.value(t)));
16667
17111
  const lines = [];
@@ -16690,6 +17134,38 @@ function formatSummary(summary) {
16690
17134
  }
16691
17135
  return lines.join("\n");
16692
17136
  }
17137
+ async function resolveBoardMembership(adapter2) {
17138
+ const empty = { isMultiMember: false, nameOf: /* @__PURE__ */ new Map(), callerUserId: null };
17139
+ if (!adapterSupports(adapter2, "listContributors")) return empty;
17140
+ let contributors;
17141
+ try {
17142
+ contributors = await adapter2.listContributors();
17143
+ } catch {
17144
+ return empty;
17145
+ }
17146
+ if (contributors.length <= 1) return empty;
17147
+ const nameOf = new Map(
17148
+ contributors.map((c) => [c.userId, c.displayName || c.email || c.userId.slice(0, 8)])
17149
+ );
17150
+ let callerUserId = null;
17151
+ try {
17152
+ if (adapterSupports(adapter2, "getOwnerIdentity")) {
17153
+ callerUserId = (await adapter2.getOwnerIdentity()).callerUserId ?? null;
17154
+ } else if (adapterSupports(adapter2, "getProjectOwnerUserId")) {
17155
+ callerUserId = await adapter2.getProjectOwnerUserId();
17156
+ }
17157
+ } catch {
17158
+ callerUserId = null;
17159
+ }
17160
+ return { isMultiMember: true, nameOf, callerUserId };
17161
+ }
17162
+ function applyAssigneeFilter(result, filter, callerUserId) {
17163
+ if (!filter) return result;
17164
+ const tasks = result.tasks.filter(
17165
+ (t) => filter === "unclaimed" ? !t.assigneeId : t.assigneeId === callerUserId
17166
+ );
17167
+ return { ...result, tasks, returned: tasks.length, total: tasks.length, hasMore: false };
17168
+ }
16693
17169
  async function handleBoardView(adapter2, args) {
16694
17170
  const mode = args.mode;
16695
17171
  const projection = resolveFields(args.fields, BOARD_FIELDS, "board_view");
@@ -16708,6 +17184,18 @@ async function handleBoardView(adapter2, args) {
16708
17184
  const meta2 = emitMeta ? { total: 1, filtered: 1, returned: 1, limit: null, offset: 0 } : null;
16709
17185
  return textResponse(withMeta(formatSingleTask(task, fields), meta2));
16710
17186
  }
17187
+ const membership = await resolveBoardMembership(adapter2);
17188
+ const assigneeFilterRaw = typeof args.assignee === "string" ? args.assignee.trim().toLowerCase() : void 0;
17189
+ if (assigneeFilterRaw !== void 0 && assigneeFilterRaw !== "me" && assigneeFilterRaw !== "unclaimed") {
17190
+ return errorResponse(
17191
+ `Unknown assignee filter "${args.assignee}". Use assignee="me" for your own claims, or assignee="unclaimed" for the open pool.`
17192
+ );
17193
+ }
17194
+ if (assigneeFilterRaw === "me" && !membership.callerUserId) {
17195
+ return errorResponse(
17196
+ 'assignee="me" needs an identified caller, and this connection has none. Use assignee="unclaimed" to see the open pool.'
17197
+ );
17198
+ }
16711
17199
  const result = await viewBoard(adapter2, void 0, {
16712
17200
  phase: args.phase,
16713
17201
  status: args.status,
@@ -16716,7 +17204,12 @@ async function handleBoardView(adapter2, args) {
16716
17204
  query: args.query,
16717
17205
  cycle: args.cycle
16718
17206
  });
16719
- let output = formatBoard(result, fields);
17207
+ const filtered = applyAssigneeFilter(result, assigneeFilterRaw, membership.callerUserId);
17208
+ let output = formatBoard(
17209
+ filtered,
17210
+ fields,
17211
+ membership.isMultiMember ? assigneeColumn(membership.nameOf) : null
17212
+ );
16720
17213
  if (!fields) {
16721
17214
  try {
16722
17215
  const comments = await adapter2.getRecentTaskComments?.(30);
@@ -16943,11 +17436,13 @@ async function handleBoardEdit(adapter2, args) {
16943
17436
  } else {
16944
17437
  const health = await target.getCycleHealth().catch(() => null);
16945
17438
  const activeCycle = health?.totalCycles ?? null;
16946
- const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
16947
- 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]`;
16948
17441
  const entry = `${stamp} ${trimmed}`;
16949
17442
  updates.notes = existing.trim().length > 0 ? `${entry}
16950
17443
 
17444
+ ---
17445
+
16951
17446
  ${existing}` : entry;
16952
17447
  }
16953
17448
  }
@@ -18658,6 +19153,118 @@ async function ensureMcpJsonGitignored(projectRoot) {
18658
19153
  }
18659
19154
  }
18660
19155
 
19156
+ // src/tools/join.ts
19157
+ var joinTool = {
19158
+ name: "join",
19159
+ description: "Join a project that already exists, as a new team member. Run this instead of `setup` when someone else already set the project up \u2014 setup is for creating a project, and running it as a joiner writes scaffolding into a repo that already has it. `join` confirms which project you are bound to, shows the Active Decisions and canon docs you are expected to read, and shows the task pool you can claim from. It writes NOTHING to your repo. Does not call the Anthropic API.",
19160
+ annotations: { title: "Join Project", readOnlyHint: true, destructiveHint: false, openWorldHint: false },
19161
+ inputSchema: {
19162
+ type: "object",
19163
+ properties: {
19164
+ limit: {
19165
+ type: "integer",
19166
+ description: "Maximum claimable tasks to list (default 10)."
19167
+ }
19168
+ },
19169
+ required: []
19170
+ }
19171
+ };
19172
+ function formatDecisions(decisions) {
19173
+ if (decisions.length === 0) {
19174
+ return "_No Active Decisions recorded yet._";
19175
+ }
19176
+ const shown = decisions.slice(0, 15);
19177
+ const lines = shown.map((d) => `- **${d.id}** \u2014 ${d.title ?? "(untitled)"}${d.confidence ? ` _(${d.confidence})_` : ""}`);
19178
+ const more = decisions.length > shown.length ? `
19179
+ - _\u2026and ${decisions.length - shown.length} more. Run \`ad_view\` for the full set._` : "";
19180
+ return lines.join("\n") + more;
19181
+ }
19182
+ function formatDocs(docs) {
19183
+ if (docs.length === 0) {
19184
+ return "_No registered canon docs. Ask the project owner what to read._";
19185
+ }
19186
+ return docs.slice(0, 12).map((d) => `- \`${d.path}\`${d.title ? ` \u2014 ${d.title}` : ""}`).join("\n");
19187
+ }
19188
+ function formatPool(tasks, limit) {
19189
+ if (tasks.length === 0) {
19190
+ return "_The shared backlog is empty right now \u2014 ask the owner what to pick up._";
19191
+ }
19192
+ const rows = tasks.slice(0, limit).map(
19193
+ (t) => `| ${t.id} | ${t.title} | ${t.priority ?? "-"} | ${t.complexity ?? "-"} |`
19194
+ );
19195
+ const more = tasks.length > limit ? `
19196
+
19197
+ _\u2026and ${tasks.length - limit} more in the pool._` : "";
19198
+ return `| Task | Summary | Priority | Effort |
19199
+ |---|---|---|---|
19200
+ ${rows.join("\n")}${more}`;
19201
+ }
19202
+ var JOIN_POINTER = "Run `join` instead \u2014 it shows you the project, its Active Decisions, the docs to read, and the tasks you can claim, without writing anything to the repo.";
19203
+ async function handleJoin(adapter2, config2, args) {
19204
+ const limit = typeof args.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 10;
19205
+ let projectLabel = "this project";
19206
+ try {
19207
+ const brief = adapterSupports(adapter2, "readProductBrief") ? await adapter2.readProductBrief() : "";
19208
+ const heading = brief.match(/^#\s+(.+)$/m)?.[1]?.trim();
19209
+ if (heading) projectLabel = heading;
19210
+ } catch {
19211
+ }
19212
+ const gate = await resolveOwnerGate(adapter2, config2);
19213
+ const [decisions, docs, pool] = await Promise.all([
19214
+ adapter2.getActiveDecisions().catch(() => []),
19215
+ // SECURITY: visibility scoping is delegated to searchDocs' requesterUserId
19216
+ // (task-1977) rather than reading rows directly — a contributors-tier or
19217
+ // private doc must not become readable just because someone joined. Passing
19218
+ // the caller means a non-owner sees public docs plus the contributors-tier
19219
+ // docs they are actually a cohort member of; private is excluded fail-closed.
19220
+ adapterSupports(adapter2, "searchDocs") ? adapter2.searchDocs({ requesterUserId: gate.callerUserId ?? void 0, limit: 12 }).catch(() => []) : Promise.resolve([]),
19221
+ // The claimable pool is the shared backlog: unassigned AND not yet pulled into
19222
+ // anyone's cycle. Same invariant task_claim enforces (assigneeId == null &&
19223
+ // cycle == null), so nothing is listed here that a claim would then refuse.
19224
+ adapter2.queryBoard({ status: ["Backlog"] }).catch(() => [])
19225
+ ]);
19226
+ const claimable = pool.filter((t) => !t.assigneeId && t.cycle == null);
19227
+ let conventionLine = "";
19228
+ try {
19229
+ if (adapterSupports(adapter2, "listConventions")) {
19230
+ const conventions = await adapter2.listConventions();
19231
+ if (conventions.length > 0) {
19232
+ conventionLine = `
19233
+
19234
+ ## Conventions that ride every build
19235
+ ` + conventions.slice(0, 8).map((c) => `- ${c.rule}`).join("\n");
19236
+ }
19237
+ }
19238
+ } catch {
19239
+ }
19240
+ const roleLine = gate.callerIsOwner ? `You are the **owner** of this project.` : `You are a **member** of this project.`;
19241
+ return textResponse(
19242
+ `# Joined \u2014 ${projectLabel}
19243
+
19244
+ ${roleLine} Nothing has been written to your repo \u2014 \`join\` is read-only, and this project's harness files are already committed by whoever set it up.
19245
+
19246
+ **If that is not the project you expected, stop here.** Your connection is bound to the wrong project; fix \`PAPI_PROJECT_ID\` (local) or the \`x-papi-project-id\` header (remote), or run \`project_switch\`, before writing anything.
19247
+
19248
+ ## Decisions already made
19249
+ These are settled stances, not suggestions. Read them before proposing a different approach \u2014 if you disagree with one, say so rather than working around it.
19250
+
19251
+ ${formatDecisions(decisions)}
19252
+
19253
+ ## Canon docs to read
19254
+ ${formatDocs(docs)}` + conventionLine + `
19255
+
19256
+ ## Work you can claim
19257
+ ${claimable.length} task(s) in the shared backlog. Claim one with \`task_claim <task-id>\` \u2014 it becomes yours and shows against your name on the board.
19258
+
19259
+ ${formatPool(claimable, limit)}
19260
+
19261
+ ## Your next move
19262
+ Run \`orient\` to see where the project stands and what your own cycle looks like. You get your OWN cycle numbering \u2014 your cycle 1 is independent of everyone else's.
19263
+
19264
+ Do NOT run \`setup\`. It is for creating a project, and this one already exists.`
19265
+ );
19266
+ }
19267
+
18661
19268
  // src/tools/setup.ts
18662
19269
  var setupTool = {
18663
19270
  name: "setup",
@@ -18875,8 +19482,36 @@ function formatSetupNarration(result) {
18875
19482
  ...steps.map((s, i) => `${i + 1}. ${s}`)
18876
19483
  ];
18877
19484
  }
19485
+ async function refuseIfJoiner(adapter2, config2) {
19486
+ let gate;
19487
+ try {
19488
+ gate = await resolveOwnerGate(adapter2, config2);
19489
+ } catch {
19490
+ return null;
19491
+ }
19492
+ if (!gate.enforced || gate.callerIsOwner) return null;
19493
+ if (gate.resolutionError || !gate.callerUserId) return null;
19494
+ let alreadySetUp = false;
19495
+ try {
19496
+ if (adapterSupports(adapter2, "readProductBrief")) {
19497
+ alreadySetUp = (await adapter2.readProductBrief()).trim().length > 0;
19498
+ }
19499
+ } catch {
19500
+ return null;
19501
+ }
19502
+ if (!alreadySetUp) return null;
19503
+ return `This project is already set up, and you are joining it rather than creating it.
19504
+
19505
+ \`setup\` writes the project harness \u2014 AGENTS.md, CLAUDE.md, .claude/settings.json, docs/ \u2014 into your repo. Those files are already here and committed by whoever set the project up, so running setup would rewrite shared files and record "first" Active Decisions into a registry that already has them.
19506
+
19507
+ ${JOIN_POINTER}
19508
+
19509
+ If you genuinely need to re-scaffold this project, ask its owner to run setup.`;
19510
+ }
18878
19511
  async function handleSetup(adapter2, config2, args, clientName) {
18879
19512
  const toolMode = args.mode;
19513
+ const joinerRefusal = await refuseIfJoiner(adapter2, config2);
19514
+ if (joinerRefusal) return errorResponse(joinerRefusal);
18880
19515
  const REQUIRED_FIELDS = ["project_name"];
18881
19516
  const missing = REQUIRED_FIELDS.filter((f) => !args[f] || typeof args[f] === "string" && !args[f].trim());
18882
19517
  if (missing.length > 0) {
@@ -19154,89 +19789,10 @@ Examples (use whichever provider you have): ${spec.examples}.
19154
19789
  _Recommendation only \u2014 PAPI never selects or runs a model. Policy: XS/S \u2192 cheap, M/L \u2192 capable, XL \u2192 frontier._`;
19155
19790
  }
19156
19791
 
19157
- // src/lib/capabilities.ts
19158
- init_dist();
19159
-
19160
19792
  // src/tools/build.ts
19161
19793
  init_dist();
19162
19794
  init_dist();
19163
19795
 
19164
- // src/lib/directive-builders.ts
19165
- function buildPrReviewerDirective(caps) {
19166
- if (!isCapabilityEnabled(caps, "prReviewer")) return null;
19167
- return `
19168
-
19169
- **Quality Gate:** no auto-review attached. PAPI's standard pre-accept step is a code review of the branch diff \u2014 run \`review_submit\` with \`dispatch:"subagent"\` to auto-review, or attach \`auto_review\` findings. Risk-tier work (auth, data, migrations, CI) should always carry one.`;
19170
- }
19171
- function buildChangelogDirective(caps, inner) {
19172
- if (!isCapabilityEnabled(caps, "changelog")) return null;
19173
- return inner;
19174
- }
19175
- function buildDiscoveredIssuesDirective(caps, issueLines) {
19176
- if (!isCapabilityEnabled(caps, "discoveredIssues")) return null;
19177
- if (issueLines.length === 0) return null;
19178
- return [
19179
- "",
19180
- "---",
19181
- "",
19182
- `## Discovered Issues (${issueLines.length})`,
19183
- "",
19184
- ...issueLines,
19185
- "",
19186
- "*These issues were logged during builds \u2014 triage them in the next plan.*"
19187
- ].join("\n");
19188
- }
19189
- function buildModelRecommendationDirective(caps, tierBlock) {
19190
- if (!isCapabilityEnabled(caps, "modelRecommendation")) return null;
19191
- return tierBlock;
19192
- }
19193
- function buildVerifyHealthCheckDirective(caps) {
19194
- if (!isCapabilityEnabled(caps, "verifyHealthCheck")) return null;
19195
- return `
19196
-
19197
- **Health check:** before you consider the cycle shipped, verify cycle state (plan validity, review coverage, branch hygiene) \u2014 run the \`papi-verify\` skill or a quick \`board_view\` pass.`;
19198
- }
19199
- function buildGestaltPreBuildDirective(caps) {
19200
- if (!isCapabilityEnabled(caps, "gestaltPreBuild")) return null;
19201
- return `
19202
-
19203
- **Gestalt pre-build:** on the first task of a multi-task cycle, read every task's BUILD HANDOFF together (shared files, sequencing, module split) before building \u2014 one-time check at cycle start.`;
19204
- }
19205
- function buildBatchBuildRollupDirective(caps) {
19206
- if (!isCapabilityEnabled(caps, "batchBuildRollup")) return null;
19207
- return `
19208
-
19209
- **Batch rollup:** after the final task of a batch, emit a cycle-level rollup \u2014 build summary table + discovered issues grouped by severity + next action.`;
19210
- }
19211
- function buildSecurityScanDirective(caps) {
19212
- if (!isCapabilityEnabled(caps, "securityScan")) return null;
19213
- return `
19214
-
19215
- **Security scan:** risk-tier changes (auth, data, migrations, secrets, endpoints) should carry a security pass \u2014 OWASP top-10 + secret-exposure check on the changed surface before merge.`;
19216
- }
19217
- function buildDeployHookDirective(caps, deployCommand) {
19218
- if (!isCapabilityEnabled(caps, "deployHook")) return null;
19219
- const command = deployCommand?.trim() || void 0;
19220
- if (!command) return null;
19221
- return `
19222
-
19223
- ---
19224
-
19225
- ## Deploy \u2014 run after this release
19226
-
19227
- The post-release deploy hook is on and a deploy command is configured, so ship the merged release now.
19228
-
19229
- Run your deploy command yourself:
19230
- \`\`\`
19231
- ${command}
19232
- \`\`\`
19233
- PAPI never runs this command itself (AD-58) \u2014 you run it in your own environment. Turn the "Post-release deploy" capability off in the dashboard, or unset PAPI_DEPLOY, to stop this reminder.`;
19234
- }
19235
- function buildPapiMetaFramingDirective(caps, inner) {
19236
- if (!isCapabilityEnabled(caps, "papiMetaFraming")) return null;
19237
- return inner;
19238
- }
19239
-
19240
19796
  // src/services/build.ts
19241
19797
  init_dist();
19242
19798
  import { randomUUID as randomUUID6 } from "crypto";
@@ -19716,6 +20272,47 @@ To override, pass force=true (emits a telemetry warning).`
19716
20272
  }
19717
20273
  return { resolvedCycleNum, warnings, force, skipVersion };
19718
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
+ }
19719
20316
  async function contributorAutoPrRelease(config2, adapter2, version, productionBaseBranch, options) {
19720
20317
  if (!isGhAvailable()) {
19721
20318
  throw new Error(
@@ -20234,8 +20831,18 @@ async function postReleaseToX(version, cycleClosed) {
20234
20831
  }
20235
20832
  function buildHostedReleaseOutput(params) {
20236
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;
20237
20845
  const tagAnnotation = `Release ${version}`;
20238
- const titleSuffix = skipVersion ? " (skip version)" : "";
20239
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";
20240
20847
  const commands = skipVersion ? `git checkout ${branch}
20241
20848
  git pull
@@ -20252,17 +20859,13 @@ git -c user.name="Your Name" -c user.email="you@example.com" tag -a ${version} -
20252
20859
  \`\`\`
20253
20860
 
20254
20861
  `;
20255
- return `## Release ${version}${titleSuffix} \u2014 cycle closed in the DB
20256
-
20257
- ${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
20258
- ` + warningsBlock + `
20259
- 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}.
20260
20863
 
20261
20864
  Finish the release locally:
20262
20865
  \`\`\`
20263
20866
  ` + commands + `\`\`\`
20264
20867
 
20265
- ` + 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();
20266
20869
  }
20267
20870
  var releaseTool = {
20268
20871
  name: "release",
@@ -20350,7 +20953,16 @@ async function handleRelease(adapter2, config2, args) {
20350
20953
  await beginRelease(tracker, cycleToClose);
20351
20954
  const productionBaseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
20352
20955
  if (branch === productionBaseBranch) {
20353
- 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) {
20354
20966
  const resolutionNote = gate.resolutionError ? `
20355
20967
 
20356
20968
  Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.` : "";
@@ -20371,13 +20983,62 @@ Then reconnect your AI tool and run release again. (Direct/pg setups read identi
20371
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.`;
20372
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;
20373
20985
  return errorResponse(
20374
- `Release to ${productionBaseBranch} is restricted to the project owner.
20986
+ `Release to ${productionBaseBranch} needs an owner or editor role.
20375
20987
 
20376
20988
  ` + roleNote + `
20377
20989
 
20378
20990
  Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
20379
20991
  );
20380
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
+ }
20381
21042
  tracker.mark("contributor-auto-pr");
20382
21043
  try {
20383
21044
  const cr = await contributorAutoPrRelease(config2, adapter2, version, productionBaseBranch, {
@@ -20393,9 +21054,9 @@ Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
20393
21054
  \u26A0\uFE0F Warnings: ${cr.warnings.join("; ")}
20394
21055
  ` : "";
20395
21056
  return textResponse(
20396
- `## Release ${version} \u2014 PR opened for owner review
21057
+ `## Release ${version} \u2014 contributor PR opened
20397
21058
 
20398
- 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.
20399
21060
 
20400
21061
  **Pull request(s):**
20401
21062
  ${prLines.join("\n")}
@@ -20405,7 +21066,7 @@ ${failLines.join("\n")}
20405
21066
  ` : "") + warnBlock + `
20406
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.
20407
21068
 
20408
- Next: run \`plan\` to start your next cycle.`
21069
+ After the PR is merged, run \`release\` again so PAPI can reconcile it.`
20409
21070
  );
20410
21071
  } catch (err) {
20411
21072
  return errorResponse(err instanceof Error ? err.message : String(err));
@@ -21335,6 +21996,20 @@ async function safeUpdateBlocker(adapter2, taskId, blocker) {
21335
21996
  } catch {
21336
21997
  }
21337
21998
  }
21999
+ async function resolveMemberBranchSlug(adapter2) {
22000
+ if (!adapterSupports(adapter2, "listContributors")) return void 0;
22001
+ try {
22002
+ const members = await adapter2.listContributors();
22003
+ if (members.length <= 1) return void 0;
22004
+ const callerUserId = adapterSupports(adapter2, "getOwnerIdentity") ? (await adapter2.getOwnerIdentity()).callerUserId ?? null : adapterSupports(adapter2, "getProjectOwnerUserId") ? await adapter2.getProjectOwnerUserId() : null;
22005
+ if (!callerUserId) return void 0;
22006
+ const me = members.find((m) => m.userId === callerUserId);
22007
+ if (!me) return void 0;
22008
+ return memberBranchSlug({ displayName: me.displayName, email: me.email, userId: me.userId });
22009
+ } catch {
22010
+ return void 0;
22011
+ }
22012
+ }
21338
22013
  async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21339
22014
  const task = await adapter2.getTask(taskId);
21340
22015
  if (!task) {
@@ -21437,13 +22112,14 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21437
22112
  }
21438
22113
  const inFlightTaskBranch = !depBranchReuse && branchExists(config2.projectRoot, taskBranchName(taskId)) ? taskBranchName(taskId) : void 0;
21439
22114
  const useSharedBranch = !depBranchReuse && !inFlightTaskBranch && !!task.module && cycleNumber > 0;
22115
+ const memberSlug = useSharedBranch ? await resolveMemberBranchSlug(adapter2) : void 0;
21440
22116
  let originCycleBranch;
21441
22117
  if (useSharedBranch && hasRemote(config2.projectRoot)) {
21442
22118
  const baseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
21443
22119
  const remoteCycleBranches = listGroupedCycleBranches(config2.projectRoot, cycleNumber, baseBranch);
21444
- originCycleBranch = pickModuleCycleBranch(remoteCycleBranches, cycleNumber, task.module);
22120
+ originCycleBranch = pickModuleCycleBranch(remoteCycleBranches, cycleNumber, task.module, memberSlug);
21445
22121
  }
21446
- const featureBranch = depBranchReuse ? depBranchReuse.branch : inFlightTaskBranch ? inFlightTaskBranch : useSharedBranch ? originCycleBranch ?? cycleBranchName(cycleNumber, task.module) : taskBranchName(taskId);
22122
+ const featureBranch = depBranchReuse ? depBranchReuse.branch : inFlightTaskBranch ? inFlightTaskBranch : useSharedBranch ? originCycleBranch ?? cycleBranchName(cycleNumber, task.module, memberSlug) : taskBranchName(taskId);
21447
22123
  if (depBranchReuse) {
21448
22124
  branchLines.push(
21449
22125
  `Reusing branch '${depBranchReuse.branch}' from dependency ${depBranchReuse.upstreamId} \u2014 commits will stack for a single PR.`
@@ -22779,8 +23455,19 @@ function isPaidTier(tier) {
22779
23455
  }
22780
23456
  async function enforceProjectCap(adapter2, target) {
22781
23457
  if (isSelfHostedDeployment(process.env.PAPI_SELF_HOST)) return null;
22782
- const tier = await resolveTier(adapter2);
22783
- 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;
22784
23471
  if (!adapterSupports(adapter2, "listUserProjects")) return null;
22785
23472
  let projects;
22786
23473
  try {
@@ -22792,17 +23479,20 @@ async function enforceProjectCap(adapter2, target) {
22792
23479
  (p) => target.papiDir && p.papi_dir && p.papi_dir === target.papiDir || target.name && p.name && p.name.trim().toLowerCase() === target.name.trim().toLowerCase()
22793
23480
  );
22794
23481
  if (matchesExisting) return null;
22795
- if (projects.length >= FREE_PROJECT_CAP) return projectCapMessage(projects.length);
23482
+ if (projects.length >= effectiveLimit) {
23483
+ return projectCapMessage(projects.length, effectiveLimit, entitlement.tier);
23484
+ }
22796
23485
  return null;
22797
23486
  }
22798
23487
  async function resolveContributorUpsell(adapter2) {
22799
23488
  const tier = await resolveTier(adapter2);
22800
23489
  return evaluateContributorGate(tier).upsell ?? null;
22801
23490
  }
22802
- function projectCapMessage(currentCount) {
22803
- 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).**
22804
23494
 
22805
- 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}
22806
23496
 
22807
23497
  Your existing projects are untouched, and you can keep working in any of them.`;
22808
23498
  }
@@ -22981,14 +23671,24 @@ Diagnostic JSON:
22981
23671
  ${JSON.stringify(payload, null, 2)}`
22982
23672
  );
22983
23673
  }
22984
- function ensureDocDurable(path7, projectRoot) {
23674
+ function ensureDocDurable(path7, projectRoot, bodyStored = false) {
22985
23675
  if (!hasLocalWorkspace() || !projectRoot) return "";
22986
- 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 `
22987
23682
 
22988
23683
  \u26A0\uFE0F **Registered, but NOT durable.** ${reason}
22989
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.
22990
23685
  **Fix:** ${fix}`;
23686
+ };
22991
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
+ }
22992
23692
  return warn(
22993
23693
  `No file exists at \`${path7}\`.`,
22994
23694
  `write the doc body to that path, then re-run doc_register.`
@@ -23005,7 +23705,11 @@ The registry stores metadata and a summary \u2014 not the body. This doc has one
23005
23705
  if (isPathIgnored(projectRoot, path7)) {
23006
23706
  return warn(
23007
23707
  `\`${path7}\` is excluded by .gitignore, so it cannot be committed (docs/private/ is ignored by design).`,
23008
- `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.`
23009
23713
  );
23010
23714
  }
23011
23715
  const result = commitSinglePath(projectRoot, path7, `docs: register ${path7}`);
@@ -23098,6 +23802,7 @@ async function handleDocRegister(adapter2, args, config2) {
23098
23802
  visibility
23099
23803
  });
23100
23804
  let bodyNote = "";
23805
+ let bodyIsStored = false;
23101
23806
  try {
23102
23807
  const supplied = typeof args.body === "string" ? args.body : void 0;
23103
23808
  let body = supplied;
@@ -23128,6 +23833,7 @@ ${decision.message}`;
23128
23833
  visibility: entry.visibility ?? visibility,
23129
23834
  ownerUserId: entry.ownerUserId
23130
23835
  });
23836
+ bodyIsStored = true;
23131
23837
  bodyNote = result.stored ? `
23132
23838
  - **Body stored:** ${result.byteSize.toLocaleString()} bytes` : "\n- **Body:** unchanged since last registration";
23133
23839
  }
@@ -23138,7 +23844,7 @@ ${decision.message}`;
23138
23844
  const visibilityLabel = entry.visibility === "contributors" ? "team member" : entry.visibility ?? "private";
23139
23845
  let durability = "";
23140
23846
  try {
23141
- durability = ensureDocDurable(entry.path, config2?.projectRoot);
23847
+ durability = ensureDocDurable(entry.path, config2?.projectRoot, bodyIsStored);
23142
23848
  } catch {
23143
23849
  durability = "";
23144
23850
  }
@@ -23855,19 +24561,106 @@ function formatListItem(task) {
23855
24561
  }
23856
24562
  return base;
23857
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
+ ]);
23858
24647
  function filterRelevantADs(ads, task) {
24648
+ const live = ads.filter((ad) => !ad.superseded);
23859
24649
  const keywords = [];
23860
24650
  if (task.module) keywords.push(task.module.toLowerCase());
23861
24651
  if (task.epic) keywords.push(task.epic.toLowerCase());
23862
24652
  if (task.phase) keywords.push(task.phase.toLowerCase());
23863
- 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));
23864
24654
  keywords.push(...titleWords);
23865
- if (keywords.length === 0) return [];
23866
- return ads.filter((ad) => {
23867
- if (ad.superseded) return false;
24655
+ const matches = keywords.length === 0 ? [] : live.filter((ad) => {
23868
24656
  const text = `${ad.title} ${ad.body}`.toLowerCase();
23869
24657
  return keywords.some((kw) => text.includes(kw));
23870
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 };
23871
24664
  }
23872
24665
  async function getModuleContext(adapter2, task) {
23873
24666
  if (!task.module) return "";
@@ -23893,9 +24686,18 @@ async function getModuleContext(adapter2, task) {
23893
24686
  return "";
23894
24687
  }
23895
24688
  }
23896
- function formatRelevantADs(ads) {
23897
- if (ads.length === 0) return "";
24689
+ function formatRelevantADs(ads, stats) {
24690
+ if (ads.length === 0 && !stats) return "";
23898
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
+ }
23899
24701
  for (const ad of ads) {
23900
24702
  const bodyLines = ad.body.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
23901
24703
  const summary = bodyLines[0]?.trim().slice(0, 120) ?? "";
@@ -24076,7 +24878,7 @@ If >80% of the scope is already implemented, call \`build_execute\` with complet
24076
24878
  try {
24077
24879
  const allADs = await adapter2.getActiveDecisions();
24078
24880
  const relevant = filterRelevantADs(allADs, result.task);
24079
- adSection = formatRelevantADs(relevant);
24881
+ adSection = formatRelevantADs(relevant.ads, { matched: relevant.matched, total: relevant.total });
24080
24882
  } catch {
24081
24883
  }
24082
24884
  let dogfoodSection = "";
@@ -24749,7 +25551,10 @@ Re-submit with \`notes: "... Reference: <path>"\` to link one, or ignore if none
24749
25551
  const conflictNote = result.adConflicts ? buildAdConflictNote(
24750
25552
  result.adConflicts.explicit ? result.adConflicts.adIds[0] : void 0,
24751
25553
  result.adConflicts.explicit ? [] : result.adConflicts.adIds,
24752
- result.adConflicts.gated
25554
+ result.adConflicts.gated,
25555
+ // task-3347: resolved alongside the ids, so the directive names the
25556
+ // decision the user is being asked to rule on.
25557
+ new Map(Object.entries(result.adConflicts.adTitles ?? {}))
24753
25558
  ) : "";
24754
25559
  return textResponse(`${result.message}${overrideNote}${branchNote}${truncateWarning}${refNudge}${conflictNote}`);
24755
25560
  }
@@ -25617,6 +26422,21 @@ async function recordAdHoc(adapter2, input) {
25617
26422
  var VALID_EFFORTS = ["XS", "S", "M", "L", "XL"];
25618
26423
  var VALID_PRIORITIES = ["P0 Critical", "P1 High", "P2 Medium", "P3 Low"];
25619
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
+ }
25620
26440
  function inferTaskType(description) {
25621
26441
  const lower = description.toLowerCase();
25622
26442
  if (/\bfix\b|bug|error|crash|broken|regression|defect/.test(lower)) return "bug";
@@ -25843,12 +26663,12 @@ The task is recorded **In Review** and pinned to **Cycle ${result.task.cycle}**,
25843
26663
  \`\`\`
25844
26664
  2. Leave the branch **unmerged** \u2014 it is picked up by the next cycle's \`release\`.
25845
26665
 
25846
- _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)
25847
26667
  );
25848
26668
  }
25849
26669
  return textResponse(
25850
26670
  `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
25851
- _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)
25852
26672
  );
25853
26673
  }
25854
26674
 
@@ -27222,14 +28042,23 @@ ${overlap}`;
27222
28042
  const allTasks = await adapter2.queryBoard();
27223
28043
  const cycleTasks = allTasks.filter((t) => t.cycle === result.currentCycle);
27224
28044
  const gate = await resolveOwnerGate(adapter2, config2);
27225
- const callerIsOwner = !gate.enforced || gate.callerIsOwner;
27226
- if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done") && !callerIsOwner) {
27227
- 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.)` : "";
27228
28057
  autoReleaseNote = `
27229
28058
 
27230
28059
  ---
27231
28060
 
27232
- \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}`;
27233
28062
  } else if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done")) {
27234
28063
  let planRunCount = null;
27235
28064
  if (adapterSupports(adapter2, "countPlanRunsForCycle")) {
@@ -27483,7 +28312,7 @@ import { access as access2, readFile as readFile5, writeFile as writeFile3 } fro
27483
28312
  import path5 from "path";
27484
28313
  var initTool = {
27485
28314
  name: "init",
27486
- 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.",
27487
28316
  annotations: { title: "Initialise Project", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
27488
28317
  inputSchema: {
27489
28318
  type: "object",
@@ -27972,6 +28801,29 @@ ${writeNote}
27972
28801
  return textResponse(output);
27973
28802
  }
27974
28803
 
28804
+ // src/lib/workspace-mode.ts
28805
+ init_git();
28806
+ var cache3 = /* @__PURE__ */ new Map();
28807
+ function resolveWorkspaceMode(projectRoot) {
28808
+ const cached2 = cache3.get(projectRoot);
28809
+ if (cached2) return cached2;
28810
+ let result;
28811
+ if (!isGitAvailable()) {
28812
+ result = { mode: "no-git", gitBacked: false, reason: "git-not-installed" };
28813
+ } else if (!isGitRepo(projectRoot)) {
28814
+ result = { mode: "no-git", gitBacked: false, reason: "not-a-repo" };
28815
+ } else {
28816
+ result = { mode: "git", gitBacked: true, reason: null };
28817
+ }
28818
+ cache3.set(projectRoot, result);
28819
+ return result;
28820
+ }
28821
+ function formatWorkspaceModeLine(result) {
28822
+ if (result.gitBacked) return void 0;
28823
+ const because = result.reason === "git-not-installed" ? "no version control was detected on this machine" : "this folder is not under version control";
28824
+ return `**Mode:** running without version control \u2014 ${because}. The full loop works: plan, build, review and release all run normally, and your work is saved to PAPI either way. Version control would add branch-per-task, change history and pull-request review on top.`;
28825
+ }
28826
+
27975
28827
  // src/services/health.ts
27976
28828
  function computeZoomOutWarning(cycleNumber, lastZoomOutCycle) {
27977
28829
  if (cycleNumber <= 0) return "";
@@ -28309,15 +29161,16 @@ async function findUnblockCandidates(adapter2, currentCycle) {
28309
29161
  const blocker = blocked.blocker;
28310
29162
  if (!blocker) continue;
28311
29163
  const resolved = isBlockerResolved(blocker, ctx);
29164
+ const blockerTitle = resolveBlockerTitle(blocker, ctx) ?? "";
28312
29165
  candidates.push({
28313
29166
  blockedId: blocked.displayId ?? blocked.id,
28314
29167
  blockedTitle: blocked.title,
28315
29168
  blockerId: blocker.ref,
28316
- blockerTitle: "",
29169
+ blockerTitle,
28317
29170
  blockerCycle: blocker.blockedCycle,
28318
29171
  typed: {
28319
29172
  kind: blocker.type,
28320
- waiting: formatBlockerWaiting(blocker),
29173
+ waiting: formatBlockerWaiting(blocker, blockerTitle),
28321
29174
  resolved
28322
29175
  }
28323
29176
  });
@@ -28383,9 +29236,9 @@ function formatUnblockSection(candidates) {
28383
29236
  lines.push("");
28384
29237
  for (const c of resolved) {
28385
29238
  if (c.typed) {
28386
- lines.push(`- **${c.blockedId}** (${c.blockedTitle}) \u2014 ${c.typed.kind}: ${c.blockerId} now resolved \u2705`);
29239
+ lines.push(`- **${c.blockedId}** (${c.blockedTitle}) \u2014 ${c.typed.kind}: ${formatRef(c.blockerId, c.blockerTitle)} now resolved \u2705`);
28387
29240
  } else {
28388
- lines.push(`- **${c.blockedId}** (${c.blockedTitle}) \u2014 blocker **${c.blockerId}** Done in C${c.blockerCycle}`);
29241
+ lines.push(`- **${c.blockedId}** (${c.blockedTitle}) \u2014 blocker **${formatRef(c.blockerId, c.blockerTitle)}** Done in C${c.blockerCycle}`);
28389
29242
  }
28390
29243
  }
28391
29244
  }
@@ -28903,6 +29756,13 @@ var orientTool = {
28903
29756
  full: {
28904
29757
  type: "boolean",
28905
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."
28906
29766
  }
28907
29767
  },
28908
29768
  required: []
@@ -29005,6 +29865,13 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
29005
29865
  lines.push(`**Connection:** ${statusIcon} ${statusLabel}`);
29006
29866
  lines.push("");
29007
29867
  }
29868
+ if (projectRoot && hasLocalWorkspace()) {
29869
+ const modeLine = formatWorkspaceModeLine(resolveWorkspaceMode(projectRoot));
29870
+ if (modeLine) {
29871
+ lines.push(modeLine);
29872
+ lines.push("");
29873
+ }
29874
+ }
29008
29875
  const harness = detectHarness(clientName);
29009
29876
  const roleNote = connectOnlyRoleNote(harness);
29010
29877
  if (roleNote) {
@@ -29349,6 +30216,42 @@ async function computeTeamSummary(adapter2, contributorsInput) {
29349
30216
  const reviewQueue = tasks.filter((t) => t.status === "In Review").length;
29350
30217
  return `**Team:** ${members} members \xB7 ${pool} in pool \xB7 ${inFlight} in flight \xB7 ${reviewQueue} in review`;
29351
30218
  }
30219
+ async function computeCohortVisibility(adapter2, config2, contributorsInput) {
30220
+ if (!adapterSupports(adapter2, "listContributors")) return void 0;
30221
+ let members;
30222
+ try {
30223
+ members = (await (contributorsInput ?? adapter2.listContributors())).length;
30224
+ } catch {
30225
+ return void 0;
30226
+ }
30227
+ if (members <= 1) return void 0;
30228
+ let gate;
30229
+ try {
30230
+ gate = await resolveOwnerGate(adapter2, config2);
30231
+ } catch {
30232
+ return void 0;
30233
+ }
30234
+ const cycleGate = await resolveCycleGate(adapter2, gate);
30235
+ if (!cycleGate.allowed) return void 0;
30236
+ let tasks;
30237
+ try {
30238
+ tasks = await adapter2.queryBoard({ compact: true });
30239
+ } catch {
30240
+ return void 0;
30241
+ }
30242
+ const open = tasks.filter(
30243
+ (t) => t.status !== "Done" && t.status !== "Cancelled" && t.status !== "Archived"
30244
+ );
30245
+ if (open.length === 0) return void 0;
30246
+ const publicCount = open.filter((t) => !t.visibility || t.visibility === "public").length;
30247
+ const contributorCount = publicCount + open.filter((t) => t.visibility === "contributors").length;
30248
+ const hiddenFromContributors = open.length - contributorCount;
30249
+ if (hiddenFromContributors === 0) {
30250
+ return `**Cohort visibility:** your ${members - 1} teammate(s) can see the whole active board (${open.length} tasks).`;
30251
+ }
30252
+ const pct = Math.round(hiddenFromContributors / open.length * 100);
30253
+ return `**Cohort visibility:** of ${open.length} active tasks, a contributor sees ${contributorCount} and a public-tier viewer sees ${publicCount} \u2014 **${hiddenFromContributors} (${pct}%) are private and invisible to your team.**`;
30254
+ }
29352
30255
  async function computeReleaseHistory(adapter2, contributorsInput) {
29353
30256
  if (!adapterSupports(adapter2, "listContributors")) return void 0;
29354
30257
  let contributors;
@@ -29412,7 +30315,21 @@ function withBoardMemo(adapter2) {
29412
30315
  });
29413
30316
  }
29414
30317
  async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVersion = "unknown") {
29415
- 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);
29416
30333
  const environment = normaliseEnvironment(args.environment);
29417
30334
  const deepHousekeeping = args.deep_housekeeping === true;
29418
30335
  const fullEnrichment = args.full === true || deepHousekeeping;
@@ -29654,7 +30571,7 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
29654
30571
  return adWords.some((w) => docText.includes(w));
29655
30572
  });
29656
30573
  const cycleLabel = `C${doc.cycleUpdated ?? doc.cycleCreated}`;
29657
- const adRef = relatedAds.length > 0 ? ` \u2014 may relate to ${relatedAds.map((a) => a.displayId).join(", ")}` : "";
30574
+ const adRef = relatedAds.length > 0 ? ` \u2014 may relate to ${relatedAds.map((a) => formatRef(a.displayId, a.title)).join(", ")}` : "";
29658
30575
  const pendingActions = doc.actions?.filter((a) => a.status === "pending") ?? [];
29659
30576
  lines.push(`- **${doc.title}** [${cycleLabel}${adRef}]`);
29660
30577
  for (const action of pendingActions.slice(0, 2)) {
@@ -29761,8 +30678,8 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
29761
30678
  const mismatches = detectBoardMismatches(config2.projectRoot, allTasks);
29762
30679
  if (mismatches.codeAhead.length > 0 || mismatches.staleInProgress.length > 0) {
29763
30680
  const lines = ["\n\n## Reconciliation"];
29764
- for (const m of mismatches.codeAhead) lines.push(`\u26A0\uFE0F **${m.displayId}** \u2014 branch \`${m.branch}\` merged to main but task is still Backlog. Run \`board_edit\` to mark Done.`);
29765
- for (const m of mismatches.staleInProgress) lines.push(`\u26A0\uFE0F **${m.displayId}** \u2014 In Progress but no feature branch found. May be stale or built without \`build_execute\`.`);
30681
+ for (const m of mismatches.codeAhead) lines.push(`\u26A0\uFE0F **${formatRef(m.displayId, m.title)}** \u2014 branch \`${m.branch}\` merged to main but task is still Backlog. Run \`board_edit\` to mark Done.`);
30682
+ for (const m of mismatches.staleInProgress) lines.push(`\u26A0\uFE0F **${formatRef(m.displayId, m.title)}** \u2014 In Progress but no feature branch found. May be stale or built without \`build_execute\`.`);
29766
30683
  reconciliationNote2 = lines.join("\n");
29767
30684
  }
29768
30685
  } catch {
@@ -29776,7 +30693,7 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
29776
30693
  lines.push(`${merged.length} In-Progress task(s) already merged to the default branch \u2014 close them out with \`build_execute\` complete (or \`board_edit\` to mark Done):`);
29777
30694
  for (const m of merged) {
29778
30695
  const ref = m.pr ? `${m.pr} (\`${m.commit}\`)` : `\`${m.commit}\``;
29779
- lines.push(`- \u26A0\uFE0F **${m.displayId}** \u2014 merged via ${ref}: ${m.subject}`);
30696
+ lines.push(`- \u26A0\uFE0F **${formatRef(m.displayId, m.title)}** \u2014 merged via ${ref}: ${m.subject}`);
29780
30697
  }
29781
30698
  mergedInProgressNote2 = lines.join("\n");
29782
30699
  }
@@ -29909,7 +30826,7 @@ ${versionDrift}` : "";
29909
30826
  }
29910
30827
  tracker.mark("parallel-tail");
29911
30828
  const sharedContributorsPromise = adapterSupports(adapter2, "listContributors") ? adapter2.listContributors().catch(() => []) : Promise.resolve([]);
29912
- const [unblockCandidates, subAgents, teamSummaryLine, releaseHistoryLine, carryForwardRefs] = await Promise.all([
30829
+ const [unblockCandidates, subAgents, teamSummaryLine, releaseHistoryLine, cohortVisibilityLine, carryForwardRefs] = await Promise.all([
29913
30830
  tracked("unblock-candidates", () => findUnblockCandidates(adapter2, currentCycle))().catch(() => []),
29914
30831
  // task-1866: discover project sub-agents for the orient surface (read-only, never throws).
29915
30832
  listAgents(config2.projectRoot),
@@ -29918,6 +30835,8 @@ ${versionDrift}` : "";
29918
30835
  // byte-identical there.
29919
30836
  tracked("team-summary", () => computeTeamSummary(adapter2, sharedContributorsPromise))().catch(() => void 0),
29920
30837
  tracked("release-history", () => computeReleaseHistory(adapter2, sharedContributorsPromise))().catch(() => void 0),
30838
+ // task-3339 (C363): owner-only readout of how much of the board the cohort cannot see.
30839
+ tracked("cohort-visibility", () => computeCohortVisibility(adapter2, config2, sharedContributorsPromise))().catch(() => void 0),
29921
30840
  // task-2751 (C332): resolve every task-NNNN mentioned in the Carry-Forward
29922
30841
  // prose to its title so orient can name it inline. Built from the board
29923
30842
  // already in hand — no extra query.
@@ -29927,7 +30846,7 @@ ${versionDrift}` : "";
29927
30846
  const unblockNote = unblockSection ? `
29928
30847
 
29929
30848
  ${unblockSection}` : "";
29930
- const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
30849
+ const teamSummary = [teamSummaryLine, releaseHistoryLine, cohortVisibilityLine].filter(Boolean).join("\n") || void 0;
29931
30850
  let deferredGateNote = "";
29932
30851
  if (deepHousekeeping) {
29933
30852
  try {
@@ -29957,7 +30876,7 @@ ${section}`;
29957
30876
  const runtimeIdentityNote = `
29958
30877
 
29959
30878
  ${formatRuntimeIdentity(getRuntimeIdentity(config2, serverVersion))}`;
29960
- 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 };
29961
30880
  } catch (err) {
29962
30881
  const message = err instanceof Error ? err.message : String(err);
29963
30882
  const isKnownFriendly = /^(Orient failed|Project not found|No project|Setup required)/i.test(message);
@@ -31059,15 +31978,35 @@ function readLlmResponse(args) {
31059
31978
  }
31060
31979
 
31061
31980
  // src/tools/ad-view.ts
31062
- var AD_COLUMNS = [
31063
- { name: "id", header: "AD", value: (d) => d.id },
31064
- { name: "title", header: "Title", value: (d) => d.title },
31065
- { name: "confidence", header: "Confidence", value: (d) => d.confidence },
31066
- { name: "superseded", header: "Superseded", value: (d) => d.superseded ? "yes" : "no" },
31067
- { name: "supersededBy", header: "Superseded By", value: (d) => d.supersededBy ?? "-" },
31068
- { name: "body", header: "Body", value: (d) => d.body.replace(/\s+/g, " ").trim() }
31069
- ];
31070
- var AD_FIELDS = AD_COLUMNS.map((c) => c.name);
31981
+ function adColumns(all) {
31982
+ return [
31983
+ { name: "id", header: "AD", value: (d) => d.id },
31984
+ { name: "title", header: "Title", value: (d) => d.title },
31985
+ { name: "confidence", header: "Confidence", value: (d) => d.confidence },
31986
+ { name: "superseded", header: "Superseded", value: (d) => d.superseded ? "yes" : "no" },
31987
+ {
31988
+ name: "supersededBy",
31989
+ header: "Superseded By",
31990
+ value: (d) => d.supersededBy ? formatEntityRef(d.supersededBy, all) : "-"
31991
+ },
31992
+ { name: "body", header: "Body", value: (d) => d.body.replace(/\s+/g, " ").trim() }
31993
+ ];
31994
+ }
31995
+ var AD_FIELDS = adColumns([]).map((c) => c.name);
31996
+ function supersededNote(d, all) {
31997
+ if (!d.superseded) return "";
31998
+ const by = d.supersededBy;
31999
+ if (!by) return " [SUPERSEDED by unknown]";
32000
+ return ` [SUPERSEDED by ${formatEntityRef(by, all)}]`;
32001
+ }
32002
+ function formatAdBody(d, all) {
32003
+ return `## ${d.id}: ${d.title} [${d.confidence}]${supersededNote(d, all)}
32004
+
32005
+ ${d.body}`;
32006
+ }
32007
+ function formatAdList(decisions, all) {
32008
+ return decisions.map((d) => formatAdBody(d, all)).join("\n\n---\n\n");
32009
+ }
31071
32010
  var adViewTool = {
31072
32011
  name: "ad_view",
31073
32012
  description: "View one or all Active Decisions with full bodies. Use when you need to read the complete reasoning and evidence behind a specific AD before running strategy_change.",
@@ -31130,16 +32069,11 @@ async function handleAdView(adapter2, args) {
31130
32069
  }
31131
32070
  const meta2 = emitMeta ? { total: decisions.length, filtered: 1, returned: 1, limit: null, offset: 0 } : null;
31132
32071
  if (fields) {
31133
- const columns = selectColumns(AD_COLUMNS, fields);
32072
+ const columns = selectColumns(adColumns(decisions), fields);
31134
32073
  const body = columns.map((c) => `- **${c.header}:** ${c.value(target)}`).join("\n");
31135
32074
  return textResponse(withMeta(body, meta2));
31136
32075
  }
31137
- const supersededNote = target.superseded ? ` [SUPERSEDED by ${target.supersededBy}]` : "";
31138
- return textResponse(
31139
- withMeta(`## ${target.id}: ${target.title} [${target.confidence}]${supersededNote}
31140
-
31141
- ${target.body}`, meta2)
31142
- );
32076
+ return textResponse(withMeta(formatAdBody(target, decisions), meta2));
31143
32077
  }
31144
32078
  const filtered = includeSuperseded ? decisions : decisions.filter((d) => !d.superseded);
31145
32079
  const paged = limit === null && offset === 0 ? filtered : filtered.slice(offset, limit === null ? void 0 : offset + limit);
@@ -31148,16 +32082,11 @@ ${target.body}`, meta2)
31148
32082
  return textResponse(withMeta("No active decisions found.", meta));
31149
32083
  }
31150
32084
  if (fields) {
31151
- const columns = selectColumns(AD_COLUMNS, fields);
32085
+ const columns = selectColumns(adColumns(decisions), fields);
31152
32086
  const rows = paged.map((d) => columns.map((c) => c.value(d)));
31153
32087
  return textResponse(withMeta(renderTable(columns.map((c) => c.header), rows), meta));
31154
32088
  }
31155
- const formatted = paged.map((d) => {
31156
- const supersededNote = d.superseded ? ` [SUPERSEDED by ${d.supersededBy}]` : "";
31157
- return `## ${d.id}: ${d.title} [${d.confidence}]${supersededNote}
31158
-
31159
- ${d.body}`;
31160
- }).join("\n\n---\n\n");
32089
+ const formatted = formatAdList(paged, decisions);
31161
32090
  return textResponse(withMeta(`# Active Decisions (${paged.length})
31162
32091
 
31163
32092
  ${formatted}`, meta));
@@ -31285,7 +32214,10 @@ async function handleLearningAction(adapter2, args) {
31285
32214
  if (!learningId?.trim()) return errorResponse('learning_id is required for mode "mark".');
31286
32215
  if (!actionTaken) return errorResponse('action_taken is required for mode "mark".');
31287
32216
  try {
31288
- await adapter2.updateCycleLearningActionRef(learningId.trim(), actionRef?.trim() ?? actionTaken);
32217
+ await adapter2.updateCycleLearningActionRef(learningId.trim(), actionRef?.trim() ?? actionTaken, {
32218
+ actionTaken,
32219
+ force: true
32220
+ });
31289
32221
  return textResponse(
31290
32222
  `Learning \`${learningId}\` marked as **${actionTaken}**${actionRef ? ` \u2192 \`${actionRef}\`` : ""}.`
31291
32223
  );
@@ -31478,14 +32410,24 @@ On hosted/remote sessions this is now your DEFAULT project \u2014 calls without
31478
32410
  }
31479
32411
 
31480
32412
  // src/tools/contributor.ts
32413
+ init_dist();
31481
32414
  var contributorAddTool = {
31482
32415
  name: "contributor_add",
31483
- description: "Add a contributor to the current project by email (owner-only). The person must already have a PAPI account. Grants cohort membership on project_contributors \u2014 contributors-tier visibility, no roles yet.",
32416
+ description: `Add a contributor to a project by email, or change an existing member's role (owner-only). The person must already have a PAPI account. Grants membership on project_contributors. Pass role="editor" for a working teammate who needs to run the full cycle \u2014 the default, "viewer", cannot release. Calling it again with a different role promotes or demotes that member.`,
31484
32417
  annotations: { title: "Add Contributor", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
31485
32418
  inputSchema: {
31486
32419
  type: "object",
31487
32420
  properties: {
31488
- email: { type: "string", description: 'Email of the PAPI account to add (e.g. "wes@example.com").' }
32421
+ email: { type: "string", description: 'Email of the PAPI account to add (e.g. "wes@example.com").' },
32422
+ role: {
32423
+ type: "string",
32424
+ enum: [...ASSIGNABLE_CONTRIBUTOR_ROLES],
32425
+ description: `Membership role. "editor" can run the full build/review/release cycle; "viewer" has read-only visibility and is REFUSED by the release gate. Defaults to "viewer" for a new member; omit it when changing nothing to leave an existing member's role untouched. Project ownership cannot be granted here.`
32426
+ },
32427
+ project: {
32428
+ type: "string",
32429
+ description: "Project id (UUID) or slug to add this contributor to, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. Does NOT change your default project."
32430
+ }
31489
32431
  },
31490
32432
  required: ["email"]
31491
32433
  }
@@ -31550,21 +32492,37 @@ function requireEmail(args) {
31550
32492
  return EMAIL_SHAPE.test(email) ? email : null;
31551
32493
  }
31552
32494
  async function handleContributorAdd(adapter2, config2, args) {
31553
- const denied = await denyUnlessOwner(adapter2, config2);
31554
- if (denied) return errorResponse(denied);
31555
32495
  const email = requireEmail(args);
31556
32496
  if (!email) return errorResponse('A valid email is required. Example: contributor_add email="wes@example.com"');
32497
+ const rawRole = typeof args.role === "string" ? args.role.trim().toLowerCase() : void 0;
32498
+ if (rawRole !== void 0 && !ASSIGNABLE_CONTRIBUTOR_ROLES.includes(rawRole)) {
32499
+ return errorResponse(
32500
+ `Invalid role "${args.role}". Assignable roles: ${ASSIGNABLE_CONTRIBUTOR_ROLES.join(", ")}. Project ownership cannot be granted through contributor_add.`
32501
+ );
32502
+ }
32503
+ let target = adapter2;
32504
+ let overrideNote = "";
31557
32505
  try {
31558
- const entry = await adapter2.addContributorByEmail(email);
32506
+ ({ adapter: target, overrideNote } = await resolvePerCallProjectAdapter(adapter2, args));
32507
+ } catch (err) {
32508
+ if (err instanceof ProjectResolutionError) return errorResponse(err.message);
32509
+ throw err;
32510
+ }
32511
+ const denied = await denyUnlessOwner(target, config2);
32512
+ if (denied) return errorResponse(denied);
32513
+ try {
32514
+ const entry = await target.addContributorByEmail(email, rawRole);
31559
32515
  const name = entry.displayName ? ` (${entry.displayName})` : "";
31560
- const upsell = await resolveContributorUpsell(adapter2);
32516
+ const upsell = await resolveContributorUpsell(target);
31561
32517
  const upsellSuffix = upsell ? `
31562
32518
 
31563
32519
  ${upsell}` : "";
32520
+ const where = overrideNote ? ` ${overrideNote}` : " on this project";
32521
+ const roleLine = entry.role === "editor" ? `They can run the full cycle \u2014 plan, build, review and release.` : `**They are a "${entry.role}" and cannot run \`release\`.** Re-run with role="editor" if they are a working teammate rather than an observer.`;
31564
32522
  return textResponse(
31565
- `\u2705 Added **${entry.email ?? email}**${name} as a contributor.
32523
+ `\u2705 **${entry.email ?? email}**${name} is now a **${entry.role}**${where}.
31566
32524
 
31567
- They now have contributors-tier visibility on this project. Roles and invites land with MU-2 \u2014 today membership is the whole model.` + upsellSuffix
32525
+ ` + roleLine + upsellSuffix
31568
32526
  );
31569
32527
  } catch (err) {
31570
32528
  return errorResponse(err instanceof Error ? err.message : String(err));
@@ -32069,12 +33027,13 @@ async function readPapiResource(adapter2, uri) {
32069
33027
  const ads = await adapter2.getActiveDecisions({ includeRetired: true });
32070
33028
  const target = ads.find((d) => d.id === adId);
32071
33029
  if (!target) throw new Error(`Active Decision not found in this project: ${adId}`);
32072
- const supersededNote = target.superseded ? ` [SUPERSEDED by ${target.supersededBy ?? "unknown"}]` : "";
33030
+ const titles = titleMap(ads);
33031
+ const supersededNote2 = target.superseded ? ` [SUPERSEDED by ${target.supersededBy ? formatRef(target.supersededBy, lookupTitle(target.supersededBy, titles)) : "unknown"}]` : "";
32073
33032
  const supersedes = ads.filter((d) => d.supersededBy === target.id).map((d) => d.id);
32074
33033
  const chain = supersedes.length > 0 ? `
32075
33034
 
32076
- _Supersedes: ${supersedes.join(", ")}_` : "";
32077
- return md(`## ${target.id}: ${target.title} [${target.confidence}]${supersededNote}
33035
+ _Supersedes: ${formatRefList(supersedes, titles)}_` : "";
33036
+ return md(`## ${target.id}: ${target.title} [${target.confidence}]${supersededNote2}
32078
33037
 
32079
33038
  ${target.body}${chain}`);
32080
33039
  }
@@ -32214,7 +33173,7 @@ var inflightWork = /* @__PURE__ */ new Map();
32214
33173
  var ToolTimeoutError = class extends Error {
32215
33174
  constructor(toolName, expectedMs, actualMs, wedged, pendingNote) {
32216
33175
  super(
32217
- 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\`).`
32218
33177
  );
32219
33178
  this.toolName = toolName;
32220
33179
  this.expectedMs = expectedMs;
@@ -32302,6 +33261,7 @@ var PAPI_TOOLS = [
32302
33261
  boardArchiveTool,
32303
33262
  boardEditTool,
32304
33263
  setupTool,
33264
+ joinTool,
32305
33265
  buildListTool,
32306
33266
  buildDescribeTool,
32307
33267
  buildExecuteTool,
@@ -32467,6 +33427,8 @@ function createServer(adapter2, config2, observation) {
32467
33427
  return handleBoardEdit(adapter2, safeArgs);
32468
33428
  case "setup":
32469
33429
  return handleSetup(adapter2, config2, safeArgs, server2.getClientVersion()?.name);
33430
+ case "join":
33431
+ return handleJoin(adapter2, config2, safeArgs);
32470
33432
  case "build_list":
32471
33433
  return handleBuildList(adapter2, config2, safeArgs);
32472
33434
  case "build_describe":
@@ -32712,7 +33674,12 @@ var FRIENDLY_GET_HTML = `<!doctype html>
32712
33674
  <code>https://mcp.getpapi.ai/mcp</code>.</p>
32713
33675
  <a class="btn" href="https://getpapi.ai/docs/install">See the install guide \u2192</a>
32714
33676
  </div></body></html>`;
32715
- 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
+ ]);
32716
33683
  var MAX_BODY_BYTES = 1 * 1024 * 1024;
32717
33684
  var SLOW_REQUEST_MS = 5e3;
32718
33685
  var CRITICAL_REQUEST_MS = 3e4;
@@ -33325,7 +34292,7 @@ Options:
33325
34292
  Getting started:
33326
34293
  1. Run "npx @papi-ai/server setup" in any project folder
33327
34294
  2. Approve in the browser when it opens
33328
- 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"
33329
34296
 
33330
34297
  Stuck?
33331
34298
  npx @papi-ai/server doctor # diagnose your config