@papi-ai/server 0.7.83 → 0.7.84

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.
@@ -214,6 +214,7 @@ __export(git_exports, {
214
214
  listGroupedCycleBranches: () => listGroupedCycleBranches,
215
215
  listOpenPullRequests: () => listOpenPullRequests,
216
216
  listOrphanFeatBranches: () => listOrphanFeatBranches,
217
+ memberBranchSlug: () => memberBranchSlug,
217
218
  mergePullRequest: () => mergePullRequest,
218
219
  normalizeGitUrl: () => normalizeGitUrl,
219
220
  pickModuleCycleBranch: () => pickModuleCycleBranch,
@@ -1106,9 +1107,20 @@ function detectUnrecordedCommits(cwd, baseBranch) {
1106
1107
  function taskBranchName(taskId) {
1107
1108
  return `feat/${taskId}`;
1108
1109
  }
1109
- function cycleBranchName(cycleNumber, module) {
1110
+ function cycleBranchName(cycleNumber, module, memberSlug) {
1110
1111
  const slug = module.toLowerCase().replace(/&/g, "and").replace(/&/g, "and").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1111
- return `feat/cycle-${cycleNumber}-${slug}`;
1112
+ const member = memberSlug ? `-${memberSlug}` : "";
1113
+ return `feat/cycle-${cycleNumber}-${slug}${member}`;
1114
+ }
1115
+ function memberBranchSlug(member) {
1116
+ const sanitise = (raw) => raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 20).replace(/-+$/g, "");
1117
+ const fromName = member.displayName ? sanitise(member.displayName) : "";
1118
+ if (fromName) return fromName;
1119
+ const localPart = member.email ? member.email.split("@")[0] : "";
1120
+ const fromEmail = localPart ? sanitise(localPart) : "";
1121
+ if (fromEmail) return fromEmail;
1122
+ const fromId = member.userId ? sanitise(member.userId).slice(0, 8) : "";
1123
+ return fromId || void 0;
1112
1124
  }
1113
1125
  function getHeadCommitSha(cwd) {
1114
1126
  try {
@@ -1231,9 +1243,9 @@ function isBranchContentAlreadyInBase(cwd, branch, baseBranch) {
1231
1243
  return false;
1232
1244
  }
1233
1245
  }
1234
- function pickModuleCycleBranch(candidates, cycleNumber, module) {
1246
+ function pickModuleCycleBranch(candidates, cycleNumber, module, memberSlug) {
1235
1247
  if (candidates.length === 0) return void 0;
1236
- const expected = cycleBranchName(cycleNumber, module);
1248
+ const expected = cycleBranchName(cycleNumber, module, memberSlug);
1237
1249
  return candidates.find((b) => b === expected);
1238
1250
  }
1239
1251
  function listOrphanFeatBranches(cwd, baseBranch) {
@@ -2431,8 +2443,8 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
2431
2443
  async listContributors() {
2432
2444
  return this.invoke("listContributors", []);
2433
2445
  }
2434
- async addContributorByEmail(email) {
2435
- return this.invoke("addContributorByEmail", [email]);
2446
+ async addContributorByEmail(email, role) {
2447
+ return this.invoke("addContributorByEmail", [email, role]);
2436
2448
  }
2437
2449
  async removeContributorByEmail(email) {
2438
2450
  return this.invoke("removeContributorByEmail", [email]);
package/dist/index.js CHANGED
@@ -760,7 +760,7 @@ function parsePhaseBlock(block) {
760
760
  if (isNaN(order)) return null;
761
761
  return { id, slug, label, description, status, order };
762
762
  }
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;
763
+ var CAPABILITY_REGISTRY, CAPABILITY_KEYS, ASSIGNABLE_CONTRIBUTOR_ROLES, SENSITIVE_CHANGELOG_PATTERNS, CONTRIBUTOR_PRICING_URL, HOSTED_APP_URL, HOSTED_MCP_URL, HOSTED_SUPABASE_URL, SEVERITY_ORDER, NONE_RE, AD_HOC_PLACEHOLDER_RE, MIN_REASON_LENGTH, LOW_SEVERITIES, WAB_WINDOW_DAYS, WAB_DEFAULT_WEEKS, WAB_WEEK_MS, PRODUCT_MARKER, MECHANICS_MARKER, CHECK_VALUE_MAX, VALID_EFFORT_SIZES, SECTION_HEADERS, VALID_EFFORT_SIZES2, EFFORT_SCALE, NONE_PATTERN, NONE_PATTERN2, VALID_STATUSES, PHASES_START, PHASES_END;
764
764
  var init_dist = __esm({
765
765
  "../shared/dist/index.js"() {
766
766
  "use strict";
@@ -803,6 +803,7 @@ var init_dist = __esm({
803
803
  { key: "papiMetaFraming", label: "PAPI self-referential framing", description: "Includes PAPI-meta build-discipline framing in handoff output.", step: "build" }
804
804
  ];
805
805
  CAPABILITY_KEYS = CAPABILITY_REGISTRY.map((c) => c.key);
806
+ ASSIGNABLE_CONTRIBUTOR_ROLES = ["editor", "viewer"];
806
807
  SENSITIVE_CHANGELOG_PATTERNS = [
807
808
  // ── Test-user machinery ────────────────────────────────────────────────
808
809
  /@test\.papi\.dev/i,
@@ -949,6 +950,7 @@ __export(git_exports, {
949
950
  listGroupedCycleBranches: () => listGroupedCycleBranches,
950
951
  listOpenPullRequests: () => listOpenPullRequests,
951
952
  listOrphanFeatBranches: () => listOrphanFeatBranches,
953
+ memberBranchSlug: () => memberBranchSlug,
952
954
  mergePullRequest: () => mergePullRequest,
953
955
  normalizeGitUrl: () => normalizeGitUrl,
954
956
  pickModuleCycleBranch: () => pickModuleCycleBranch,
@@ -1841,9 +1843,20 @@ function detectUnrecordedCommits(cwd, baseBranch) {
1841
1843
  function taskBranchName(taskId) {
1842
1844
  return `feat/${taskId}`;
1843
1845
  }
1844
- function cycleBranchName(cycleNumber, module) {
1846
+ function cycleBranchName(cycleNumber, module, memberSlug) {
1845
1847
  const slug = module.toLowerCase().replace(/&/g, "and").replace(/&/g, "and").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1846
- return `feat/cycle-${cycleNumber}-${slug}`;
1848
+ const member = memberSlug ? `-${memberSlug}` : "";
1849
+ return `feat/cycle-${cycleNumber}-${slug}${member}`;
1850
+ }
1851
+ function memberBranchSlug(member) {
1852
+ const sanitise = (raw) => raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 20).replace(/-+$/g, "");
1853
+ const fromName = member.displayName ? sanitise(member.displayName) : "";
1854
+ if (fromName) return fromName;
1855
+ const localPart = member.email ? member.email.split("@")[0] : "";
1856
+ const fromEmail = localPart ? sanitise(localPart) : "";
1857
+ if (fromEmail) return fromEmail;
1858
+ const fromId = member.userId ? sanitise(member.userId).slice(0, 8) : "";
1859
+ return fromId || void 0;
1847
1860
  }
1848
1861
  function getHeadCommitSha(cwd) {
1849
1862
  try {
@@ -1966,9 +1979,9 @@ function isBranchContentAlreadyInBase(cwd, branch, baseBranch) {
1966
1979
  return false;
1967
1980
  }
1968
1981
  }
1969
- function pickModuleCycleBranch(candidates, cycleNumber, module) {
1982
+ function pickModuleCycleBranch(candidates, cycleNumber, module, memberSlug) {
1970
1983
  if (candidates.length === 0) return void 0;
1971
- const expected = cycleBranchName(cycleNumber, module);
1984
+ const expected = cycleBranchName(cycleNumber, module, memberSlug);
1972
1985
  return candidates.find((b2) => b2 === expected);
1973
1986
  }
1974
1987
  function listOrphanFeatBranches(cwd, baseBranch) {
@@ -3232,8 +3245,8 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
3232
3245
  async listContributors() {
3233
3246
  return this.invoke("listContributors", []);
3234
3247
  }
3235
- async addContributorByEmail(email) {
3236
- return this.invoke("addContributorByEmail", [email]);
3248
+ async addContributorByEmail(email, role) {
3249
+ return this.invoke("addContributorByEmail", [email, role]);
3237
3250
  }
3238
3251
  async removeContributorByEmail(email) {
3239
3252
  return this.invoke("removeContributorByEmail", [email]);
@@ -10155,6 +10168,38 @@ function computeCarryForwardStaleness(log2, doneTaskIds) {
10155
10168
  ].join("\n");
10156
10169
  }
10157
10170
 
10171
+ // src/lib/refs.ts
10172
+ var MAX_LABEL = 55;
10173
+ function truncateLabel(title, max = MAX_LABEL) {
10174
+ if (title.length <= max) return title;
10175
+ return `${title.slice(0, max - 1).trimEnd()}\u2026`;
10176
+ }
10177
+ function formatRef(id, title) {
10178
+ const label = title?.trim();
10179
+ if (!label) return id;
10180
+ if (label.toLowerCase() === id.toLowerCase()) return id;
10181
+ return `${id} (${truncateLabel(label)})`;
10182
+ }
10183
+ function formatRefList(ids, titles) {
10184
+ return ids.map((id) => formatRef(id, lookupTitle(id, titles))).join(", ");
10185
+ }
10186
+ function lookupTitle(id, titles) {
10187
+ if (!titles) return void 0;
10188
+ return titles.get(id) ?? titles.get(id.toLowerCase()) ?? titles.get(id.toUpperCase());
10189
+ }
10190
+ function titleMap(entities) {
10191
+ const map = /* @__PURE__ */ new Map();
10192
+ for (const e of entities) {
10193
+ if (!e.title) continue;
10194
+ for (const key of [e.id, e.displayId]) {
10195
+ if (!key) continue;
10196
+ map.set(key, e.title);
10197
+ map.set(key.toLowerCase(), e.title);
10198
+ }
10199
+ }
10200
+ return map;
10201
+ }
10202
+
10158
10203
  // src/lib/blocker.ts
10159
10204
  function idMatches(candidate, ref) {
10160
10205
  if (!candidate) return false;
@@ -10188,16 +10233,31 @@ function isBlockerResolved(blocker, ctx) {
10188
10233
  return false;
10189
10234
  }
10190
10235
  }
10191
- function formatBlockerWaiting(blocker) {
10236
+ function formatBlockerWaiting(blocker, refTitle) {
10237
+ const ref = formatRef(blocker.ref, refTitle);
10192
10238
  switch (blocker.type) {
10193
10239
  case "depends-on":
10194
- return `waiting on ${blocker.ref} \u2014 clears when that task is Done`;
10240
+ return `waiting on ${ref} \u2014 clears when that task is Done`;
10195
10241
  case "owner-action":
10196
- return `waiting on owner action ${blocker.ref} \u2014 clears when you complete it`;
10242
+ return `waiting on owner action ${ref} \u2014 clears when you complete it`;
10243
+ case "decision-gate":
10244
+ return `waiting on decision ${ref} \u2014 clears when that decision is resolved`;
10245
+ default:
10246
+ return `waiting on ${ref}`;
10247
+ }
10248
+ }
10249
+ function resolveBlockerTitle(blocker, ctx) {
10250
+ switch (blocker.type) {
10251
+ case "depends-on":
10252
+ return ctx.tasks.find(
10253
+ (t) => idMatches(t.displayId, blocker.ref) || idMatches(t.id, blocker.ref)
10254
+ )?.title;
10197
10255
  case "decision-gate":
10198
- return `waiting on decision ${blocker.ref} \u2014 clears when that decision is resolved`;
10256
+ return ctx.decisions.find(
10257
+ (d) => idMatches(d.displayId, blocker.ref) || idMatches(d.id, blocker.ref)
10258
+ )?.title;
10199
10259
  default:
10200
- return `waiting on ${blocker.ref}`;
10260
+ return void 0;
10201
10261
  }
10202
10262
  }
10203
10263
 
@@ -10428,11 +10488,22 @@ async function parkDecisionConflicts(adapter2, conflicts, newTaskIdMap, blockedC
10428
10488
  if (conflicts.length === 0) return [];
10429
10489
  const scheduled = new Set(scheduledTaskIds.map((id) => id.toLowerCase()));
10430
10490
  const results = [];
10491
+ let adTitles = /* @__PURE__ */ new Map();
10492
+ try {
10493
+ if (adapter2.getActiveDecisions) adTitles = titleMap(await adapter2.getActiveDecisions());
10494
+ } catch {
10495
+ }
10431
10496
  for (const conflict of conflicts) {
10432
10497
  const resolvedTaskId = newTaskIdMap.get(conflict.taskId) ?? conflict.taskId;
10433
- const base = { ...conflict, resolvedTaskId, parked: false };
10498
+ const adTitle = lookupTitle(conflict.adId, adTitles);
10499
+ const base = { ...conflict, resolvedTaskId, parked: false, adTitle };
10434
10500
  if (scheduled.has(resolvedTaskId.toLowerCase())) {
10435
- results.push({ ...base, skipReason: "scheduled in this cycle \u2014 left in the cycle, conflict still surfaced" });
10501
+ const [scheduledTask] = await adapter2.getTasks([resolvedTaskId]).catch(() => []);
10502
+ results.push({
10503
+ ...base,
10504
+ taskTitle: scheduledTask?.title,
10505
+ skipReason: "scheduled in this cycle \u2014 left in the cycle, conflict still surfaced"
10506
+ });
10436
10507
  continue;
10437
10508
  }
10438
10509
  try {
@@ -10441,6 +10512,7 @@ async function parkDecisionConflicts(adapter2, conflicts, newTaskIdMap, blockedC
10441
10512
  results.push({ ...base, skipReason: "task not found" });
10442
10513
  continue;
10443
10514
  }
10515
+ base.taskTitle = task.title;
10444
10516
  if (!PARKABLE_STATUSES.has(task.status)) {
10445
10517
  results.push({ ...base, skipReason: `status is ${task.status} \u2014 left as-is` });
10446
10518
  continue;
@@ -10453,7 +10525,7 @@ async function parkDecisionConflicts(adapter2, conflicts, newTaskIdMap, blockedC
10453
10525
  blocker: {
10454
10526
  type: "decision-gate",
10455
10527
  ref: conflict.adId,
10456
- reason: conflict.conflict || `Contradicts ${conflict.adId} \u2014 awaiting the owner's decision.`,
10528
+ reason: conflict.conflict || `Contradicts ${formatRef(conflict.adId, adTitle)} \u2014 awaiting the owner's decision.`,
10457
10529
  blockedCycle
10458
10530
  }
10459
10531
  });
@@ -10780,7 +10852,8 @@ async function computeResurfacedBlockers(adapter2, tasks, decisions, currentCycl
10780
10852
  const resolvedLines = [];
10781
10853
  for (const t of blocked) {
10782
10854
  if (t.blocker && isBlockerResolved(t.blocker, ctx)) {
10783
- resolvedLines.push(`- **${t.displayId}** (${t.title}) \u2014 ${formatBlockerWaiting(t.blocker)} \u2192 now RESOLVED, ready to unblock`);
10855
+ const waiting = formatBlockerWaiting(t.blocker, resolveBlockerTitle(t.blocker, ctx));
10856
+ resolvedLines.push(`- **${t.displayId}** (${t.title}) \u2014 ${waiting} \u2192 now RESOLVED, ready to unblock`);
10784
10857
  }
10785
10858
  }
10786
10859
  return resolvedLines.length > 0 ? resolvedLines.join("\n") : void 0;
@@ -13085,6 +13158,22 @@ var planTool = {
13085
13158
  ]
13086
13159
  }
13087
13160
  };
13161
+ function formatDecisionConflicts(conflicts) {
13162
+ if (conflicts.length === 0) return [];
13163
+ const lines = [""];
13164
+ lines.push(`\u{1F500} **${conflicts.length} decision(s) need your call \u2014 work was NOT dropped.**`);
13165
+ 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:");
13166
+ for (const c of conflicts) {
13167
+ const ad = formatRef(c.adId, c.adTitle);
13168
+ const state = c.parked ? `Blocked behind ${ad}` : `still ${c.skipReason ?? "unparked"}`;
13169
+ lines.push(`- **${formatRef(c.resolvedTaskId, c.taskTitle)} vs ${ad}** \u2014 ${c.conflict} _(${state})_`);
13170
+ if (c.options.length > 0) lines.push(` Options: ${c.options.join(" | ")}`);
13171
+ if (c.recommendation) lines.push(` Planner recommends: ${c.recommendation}`);
13172
+ }
13173
+ lines.push("");
13174
+ 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.');
13175
+ return lines;
13176
+ }
13088
13177
  function formatPlanResult(result) {
13089
13178
  const modeLabel = result.mode === "bootstrap" ? "Bootstrap" : "Full";
13090
13179
  const cycleLabel = `Cycle ${result.cycleNumber + 1}`;
@@ -13131,20 +13220,7 @@ function formatPlanResult(result) {
13131
13220
  lines.push('To apply these cancellations, re-run `plan` with `mode: "apply"` and `confirm_cancellations: true`.');
13132
13221
  lines.push("To keep these tasks, do nothing \u2014 they remain on the board.");
13133
13222
  }
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
- }
13223
+ lines.push(...formatDecisionConflicts(result.decisionConflicts ?? []));
13148
13224
  if (result.skipHandoffs) {
13149
13225
  const taskCount = result.writeSummary?.taskIds.length ?? 0;
13150
13226
  lines.push("", `Next: run \`handoff_generate\` to create BUILD HANDOFFs for your ${taskCount} cycle task(s), then \`build_list\` to start building.`);
@@ -13381,6 +13457,85 @@ ${result.userMessage}
13381
13457
  }
13382
13458
  }
13383
13459
 
13460
+ // src/lib/capabilities.ts
13461
+ init_dist();
13462
+
13463
+ // src/lib/directive-builders.ts
13464
+ function buildPrReviewerDirective(caps) {
13465
+ if (!isCapabilityEnabled(caps, "prReviewer")) return null;
13466
+ return `
13467
+
13468
+ **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.`;
13469
+ }
13470
+ function buildChangelogDirective(caps, inner) {
13471
+ if (!isCapabilityEnabled(caps, "changelog")) return null;
13472
+ return inner;
13473
+ }
13474
+ function buildDiscoveredIssuesDirective(caps, issueLines) {
13475
+ if (!isCapabilityEnabled(caps, "discoveredIssues")) return null;
13476
+ if (issueLines.length === 0) return null;
13477
+ return [
13478
+ "",
13479
+ "---",
13480
+ "",
13481
+ `## Discovered Issues (${issueLines.length})`,
13482
+ "",
13483
+ ...issueLines,
13484
+ "",
13485
+ "*These issues were logged during builds \u2014 triage them in the next plan.*"
13486
+ ].join("\n");
13487
+ }
13488
+ function buildModelRecommendationDirective(caps, tierBlock) {
13489
+ if (!isCapabilityEnabled(caps, "modelRecommendation")) return null;
13490
+ return tierBlock;
13491
+ }
13492
+ function buildVerifyHealthCheckDirective(caps) {
13493
+ if (!isCapabilityEnabled(caps, "verifyHealthCheck")) return null;
13494
+ return `
13495
+
13496
+ **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.`;
13497
+ }
13498
+ function buildGestaltPreBuildDirective(caps) {
13499
+ if (!isCapabilityEnabled(caps, "gestaltPreBuild")) return null;
13500
+ return `
13501
+
13502
+ **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.`;
13503
+ }
13504
+ function buildBatchBuildRollupDirective(caps) {
13505
+ if (!isCapabilityEnabled(caps, "batchBuildRollup")) return null;
13506
+ return `
13507
+
13508
+ **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.`;
13509
+ }
13510
+ function buildSecurityScanDirective(caps) {
13511
+ if (!isCapabilityEnabled(caps, "securityScan")) return null;
13512
+ return `
13513
+
13514
+ **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.`;
13515
+ }
13516
+ function buildDeployHookDirective(caps, deployCommand) {
13517
+ if (!isCapabilityEnabled(caps, "deployHook")) return null;
13518
+ const command = deployCommand?.trim() || void 0;
13519
+ if (!command) return null;
13520
+ return `
13521
+
13522
+ ---
13523
+
13524
+ ## Deploy \u2014 run after this release
13525
+
13526
+ The post-release deploy hook is on and a deploy command is configured, so ship the merged release now.
13527
+
13528
+ Run your deploy command yourself:
13529
+ \`\`\`
13530
+ ${command}
13531
+ \`\`\`
13532
+ 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.`;
13533
+ }
13534
+ function buildPapiMetaFramingDirective(caps, inner) {
13535
+ if (!isCapabilityEnabled(caps, "papiMetaFraming")) return null;
13536
+ return inner;
13537
+ }
13538
+
13384
13539
  // src/services/strategy.ts
13385
13540
  import { randomUUID as randomUUID5, createHash as createHash4 } from "crypto";
13386
13541
  import { execFileSync as execFileSync2 } from "child_process";
@@ -13737,8 +13892,10 @@ ${lines.join("\n")}
13737
13892
  }
13738
13893
  let notesWithAlignment = input.notes || "";
13739
13894
  let detectedAdIds = [];
13895
+ let adTitles = /* @__PURE__ */ new Map();
13740
13896
  try {
13741
13897
  const ads = await adapter2.getActiveDecisions();
13898
+ adTitles = titleMap(ads);
13742
13899
  const ideaCombined = `${input.text} ${input.notes ?? ""}`;
13743
13900
  const alignmentMatches = findAdAlignmentMatches(ideaCombined, ads);
13744
13901
  if (alignmentMatches.length > 0) {
@@ -13828,7 +13985,7 @@ AD-CONFLICT: ${conflictsWithAd} \u2014 submitter flagged this as cutting against
13828
13985
  blocker: {
13829
13986
  type: "decision-gate",
13830
13987
  ref: conflictsWithAd,
13831
- reason: `Submitted as cutting against ${conflictsWithAd}. Awaiting the owner's decision.`,
13988
+ reason: `Submitted as cutting against ${formatRef(conflictsWithAd, lookupTitle(conflictsWithAd, adTitles))}. Awaiting the owner's decision.`,
13832
13989
  blockedCycle: health.totalCycles
13833
13990
  }
13834
13991
  });
@@ -13847,21 +14004,36 @@ AD-CONFLICT: ${conflictsWithAd} \u2014 submitter flagged this as cutting against
13847
14004
  routing: "task",
13848
14005
  task,
13849
14006
  message: `${task.id}: "${task.title}" \u2014 ${landing}${typeNote}${visibilityNote}${mismatchNote}`,
13850
- ...adIds.length > 0 ? { adConflicts: { adIds, explicit: Boolean(conflictsWithAd), gated } } : {}
14007
+ ...adIds.length > 0 ? {
14008
+ adConflicts: {
14009
+ adIds,
14010
+ explicit: Boolean(conflictsWithAd),
14011
+ gated,
14012
+ // task-3347: id → title for exactly the ADs named above, so the tool
14013
+ // layer renders `AD-12 (Single board view)` without a second read.
14014
+ adTitles: Object.fromEntries(
14015
+ adIds.flatMap((id) => {
14016
+ const title = lookupTitle(id, adTitles);
14017
+ return title ? [[id, title]] : [];
14018
+ })
14019
+ )
14020
+ }
14021
+ } : {}
13851
14022
  };
13852
14023
  }
13853
- function buildAdConflictNote(conflictsWithAd, detectedAdIds, gated) {
14024
+ function buildAdConflictNote(conflictsWithAd, detectedAdIds, gated, adTitles) {
13854
14025
  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).`;
14026
+ const ref = formatRef(conflictsWithAd, lookupTitle(conflictsWithAd, adTitles));
14027
+ 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
14028
  return `
13857
14029
 
13858
- \u{1F500} **Conflicts with ${conflictsWithAd}.** ${state}
14030
+ \u{1F500} **Conflicts with ${ref}.** ${state}
13859
14031
  **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
14032
  }
13861
14033
  if (detectedAdIds.length > 0) {
13862
14034
  return `
13863
14035
 
13864
- \u{1F500} **Possible conflict with ${detectedAdIds.join(", ")}** (keyword match \u2014 verify it is real).
14036
+ \u{1F500} **Possible conflict with ${formatRefList(detectedAdIds, adTitles)}** (keyword match \u2014 verify it is real).
13865
14037
  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
14038
  }
13867
14039
  return "";
@@ -14332,7 +14504,8 @@ async function assembleContext2(adapter2, cycleNumber, cyclesSinceLastReview, pr
14332
14504
  pendingRecs,
14333
14505
  registeredDocs,
14334
14506
  docsWithPendingActions,
14335
- decisionScores
14507
+ decisionScores,
14508
+ allClosedTasks
14336
14509
  ] = await Promise.all([
14337
14510
  adapter2.readProductBrief(),
14338
14511
  // Strategy review needs to see retired ADs to triage/restore them as needed.
@@ -14361,7 +14534,15 @@ async function assembleContext2(adapter2, cycleNumber, cyclesSinceLastReview, pr
14361
14534
  // Doc registry — docs with pending actions for staleness audit
14362
14535
  adapter2.searchDocs?.({ hasPendingActions: true, limit: 20 })?.catch(() => []) ?? Promise.resolve([]),
14363
14536
  // task-2264: decision risk scores + movement, so the review can show trajectory.
14364
- adapter2.getDecisionScorePatterns?.()?.catch(() => []) ?? Promise.resolve([])
14537
+ adapter2.getDecisionScorePatterns?.()?.catch(() => []) ?? Promise.resolve([]),
14538
+ // task-3331 (C363): UNWINDOWED closed-task ids, purely to exclude already-closed
14539
+ // tasks from the repeat-deferral detector. Deliberately NOT reusing
14540
+ // recentDoneTasks: that set is windowed with `cycleSince: lastReviewCycleNum`
14541
+ // because the surfaces it feeds (review-window comments) genuinely want the
14542
+ // window. Carry-forward history spans many cycles, so a task that shipped BEFORE
14543
+ // the window was invisible to the exclusion and kept being flagged. Compact +
14544
+ // Cancelled, matching what services/plan.ts already does correctly.
14545
+ adapter2.queryBoard({ status: ["Done", "Cancelled"], compact: true }).catch(() => [])
14365
14546
  ]);
14366
14547
  const pendingAgendaTopics = await (adapter2.getPendingAgendaTopics?.().catch(() => []) ?? Promise.resolve([]));
14367
14548
  const tasks = [...activeTasks, ...recentDoneTasks];
@@ -14643,7 +14824,7 @@ ${lines.join("\n")}`;
14643
14824
  { label: "docActionStaleness", hasData: docActionStalenessText !== void 0 }
14644
14825
  ]);
14645
14826
  const doneTaskIds = new Set(
14646
- recentDoneTasks.map((t) => t.displayId ?? t.id).filter((id) => !!id)
14827
+ allClosedTasks.map((t) => t.displayId ?? t.id).filter((id) => !!id)
14647
14828
  );
14648
14829
  const earnedPushback = computeEarnedPushback({ reports, log: recentLog, doneTaskIds });
14649
14830
  const context = {
@@ -15792,6 +15973,35 @@ function toDateLabel(value) {
15792
15973
  const d = value instanceof Date ? value : new Date(value);
15793
15974
  return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
15794
15975
  }
15976
+ var STRATEGY_REVIEW_CHANNEL_ENV = "DISCORD_STRATEGY_REVIEW_CHANNEL_ID";
15977
+ function buildStrategyReviewPostDirective(cycleNumber) {
15978
+ const channelId = process.env[STRATEGY_REVIEW_CHANNEL_ENV]?.trim();
15979
+ if (!channelId) return null;
15980
+ return [
15981
+ "",
15982
+ "---",
15983
+ "## \u{1F4E3} Post the patch notes to Discord #papi-strategy-review",
15984
+ "",
15985
+ `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.`,
15986
+ "",
15987
+ "**LOCKED TEMPLATE \u2014 every patch note uses this exact shape (do NOT improvise the structure):**",
15988
+ "- `title`: `PAPI Patch Notes v<version>`",
15989
+ "- `description`: `Covering Cycles <start>\u2013<end>`",
15990
+ "- `color`: `#6C5CE7` (PAPI purple)",
15991
+ "- `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)",
15992
+ "- `footer`: `PAPI v<version> \xB7 <date>`",
15993
+ "",
15994
+ "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.",
15995
+ "",
15996
+ "Then:",
15997
+ "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.",
15998
+ "2. Show the draft embed for approval BEFORE posting.",
15999
+ `3. On approval, send the embed to channel \`${channelId}\`.`,
16000
+ "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.",
16001
+ "",
16002
+ "Skip only if nothing user-facing shipped in the whole window, and say so explicitly rather than moving on silently."
16003
+ ].join("\n");
16004
+ }
15795
16005
  var reviewPrepareCache = new PerCallerCache();
15796
16006
  var strategyReviewTool = {
15797
16007
  name: "strategy_review",
@@ -15973,6 +16183,19 @@ ${recLines.join("\n")}
15973
16183
  }
15974
16184
  } catch {
15975
16185
  }
16186
+ let caps = {};
16187
+ try {
16188
+ const info = adapter2.getProjectInfo ? await adapter2.getProjectInfo() : null;
16189
+ caps = info?.capabilities ?? {};
16190
+ } catch {
16191
+ caps = {};
16192
+ }
16193
+ const postDirective = buildChangelogDirective(
16194
+ caps,
16195
+ buildStrategyReviewPostDirective(result.cycleNumber)
16196
+ );
16197
+ const postStep = postDirective ? `4. Post the patch notes to Discord \u2014 see the directive at the end of this output.
16198
+ 5. Then run \`plan\` for the next cycle.` : `4. Then run \`plan\` for the next cycle.`;
15976
16199
  const actionReminder = `
15977
16200
 
15978
16201
  ---
@@ -15981,8 +16204,10 @@ ${recLines.join("\n")}
15981
16204
  1. Review the AD changes above \u2014 are any resolved/modified ADs still referenced by active tasks?
15982
16205
  2. Act on strategic recommendations \u2014 cancel, defer, or reprioritise tasks as suggested.
15983
16206
  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);
16207
+ ` + postStep;
16208
+ const response = textResponse(
16209
+ header + result.displayText + slackNote + recsCloseOut + actionReminder + (postDirective ?? "")
16210
+ );
15986
16211
  return {
15987
16212
  ...response,
15988
16213
  ...contextBytes !== void 0 ? { _contextBytes: contextBytes } : {},
@@ -16386,7 +16611,17 @@ var BOARD_COLUMNS = [
16386
16611
  { name: "createdAt", header: "Created", value: (t) => t.createdAt ?? "-" },
16387
16612
  { name: "source", header: "Source", value: (t) => t.source ?? "-" }
16388
16613
  ];
16389
- var BOARD_FIELDS = BOARD_COLUMNS.map((c) => c.name);
16614
+ var ASSIGNEE_FIELD = "assignee";
16615
+ function assigneeColumn(nameOf) {
16616
+ return {
16617
+ name: ASSIGNEE_FIELD,
16618
+ header: "Assignee",
16619
+ // An unresolved id still renders as a short id rather than blank: "someone holds
16620
+ // this" is the fact that matters, and a blank cell reads as unclaimed.
16621
+ value: (t) => t.assigneeId ? nameOf.get(t.assigneeId) ?? t.assigneeId.slice(0, 8) : "\u2014"
16622
+ };
16623
+ }
16624
+ var BOARD_FIELDS = [...BOARD_COLUMNS.map((c) => c.name), ASSIGNEE_FIELD];
16390
16625
  var BOARD_SINGLE_LABELS = {
16391
16626
  id: "Task",
16392
16627
  title: "Title",
@@ -16445,6 +16680,11 @@ var boardViewTool = {
16445
16680
  enum: ["full", "summary"],
16446
16681
  description: 'Output mode: "full" (default) shows task table, "summary" shows counts only.'
16447
16682
  },
16683
+ assignee: {
16684
+ type: "string",
16685
+ enum: ["me", "unclaimed"],
16686
+ 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.'
16687
+ },
16448
16688
  fields: {
16449
16689
  type: "string",
16450
16690
  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 +16897,16 @@ function formatSingleTask(t, fields) {
16657
16897
  if (t.why?.trim()) lines.push(`- **Why:** ${t.why.trim()}`);
16658
16898
  return lines.join("\n");
16659
16899
  }
16660
- function formatBoard(result, fields) {
16900
+ function formatBoard(result, fields, assignee) {
16661
16901
  if (result.tasks.length === 0) {
16662
16902
  return "No tasks found.";
16663
16903
  }
16664
- const columns = selectColumns(BOARD_COLUMNS, fields);
16904
+ const available = assignee ? [
16905
+ ...BOARD_COLUMNS.slice(0, 4),
16906
+ assignee,
16907
+ ...BOARD_COLUMNS.slice(4)
16908
+ ] : BOARD_COLUMNS;
16909
+ const columns = selectColumns(available, fields);
16665
16910
  const headers = columns.map((c) => c.header);
16666
16911
  const rows = result.tasks.map((t) => columns.map((c) => c.value(t)));
16667
16912
  const lines = [];
@@ -16690,6 +16935,38 @@ function formatSummary(summary) {
16690
16935
  }
16691
16936
  return lines.join("\n");
16692
16937
  }
16938
+ async function resolveBoardMembership(adapter2) {
16939
+ const empty = { isMultiMember: false, nameOf: /* @__PURE__ */ new Map(), callerUserId: null };
16940
+ if (!adapterSupports(adapter2, "listContributors")) return empty;
16941
+ let contributors;
16942
+ try {
16943
+ contributors = await adapter2.listContributors();
16944
+ } catch {
16945
+ return empty;
16946
+ }
16947
+ if (contributors.length <= 1) return empty;
16948
+ const nameOf = new Map(
16949
+ contributors.map((c) => [c.userId, c.displayName || c.email || c.userId.slice(0, 8)])
16950
+ );
16951
+ let callerUserId = null;
16952
+ try {
16953
+ if (adapterSupports(adapter2, "getOwnerIdentity")) {
16954
+ callerUserId = (await adapter2.getOwnerIdentity()).callerUserId ?? null;
16955
+ } else if (adapterSupports(adapter2, "getProjectOwnerUserId")) {
16956
+ callerUserId = await adapter2.getProjectOwnerUserId();
16957
+ }
16958
+ } catch {
16959
+ callerUserId = null;
16960
+ }
16961
+ return { isMultiMember: true, nameOf, callerUserId };
16962
+ }
16963
+ function applyAssigneeFilter(result, filter, callerUserId) {
16964
+ if (!filter) return result;
16965
+ const tasks = result.tasks.filter(
16966
+ (t) => filter === "unclaimed" ? !t.assigneeId : t.assigneeId === callerUserId
16967
+ );
16968
+ return { ...result, tasks, returned: tasks.length, total: tasks.length, hasMore: false };
16969
+ }
16693
16970
  async function handleBoardView(adapter2, args) {
16694
16971
  const mode = args.mode;
16695
16972
  const projection = resolveFields(args.fields, BOARD_FIELDS, "board_view");
@@ -16708,6 +16985,18 @@ async function handleBoardView(adapter2, args) {
16708
16985
  const meta2 = emitMeta ? { total: 1, filtered: 1, returned: 1, limit: null, offset: 0 } : null;
16709
16986
  return textResponse(withMeta(formatSingleTask(task, fields), meta2));
16710
16987
  }
16988
+ const membership = await resolveBoardMembership(adapter2);
16989
+ const assigneeFilterRaw = typeof args.assignee === "string" ? args.assignee.trim().toLowerCase() : void 0;
16990
+ if (assigneeFilterRaw !== void 0 && assigneeFilterRaw !== "me" && assigneeFilterRaw !== "unclaimed") {
16991
+ return errorResponse(
16992
+ `Unknown assignee filter "${args.assignee}". Use assignee="me" for your own claims, or assignee="unclaimed" for the open pool.`
16993
+ );
16994
+ }
16995
+ if (assigneeFilterRaw === "me" && !membership.callerUserId) {
16996
+ return errorResponse(
16997
+ 'assignee="me" needs an identified caller, and this connection has none. Use assignee="unclaimed" to see the open pool.'
16998
+ );
16999
+ }
16711
17000
  const result = await viewBoard(adapter2, void 0, {
16712
17001
  phase: args.phase,
16713
17002
  status: args.status,
@@ -16716,7 +17005,12 @@ async function handleBoardView(adapter2, args) {
16716
17005
  query: args.query,
16717
17006
  cycle: args.cycle
16718
17007
  });
16719
- let output = formatBoard(result, fields);
17008
+ const filtered = applyAssigneeFilter(result, assigneeFilterRaw, membership.callerUserId);
17009
+ let output = formatBoard(
17010
+ filtered,
17011
+ fields,
17012
+ membership.isMultiMember ? assigneeColumn(membership.nameOf) : null
17013
+ );
16720
17014
  if (!fields) {
16721
17015
  try {
16722
17016
  const comments = await adapter2.getRecentTaskComments?.(30);
@@ -18658,6 +18952,118 @@ async function ensureMcpJsonGitignored(projectRoot) {
18658
18952
  }
18659
18953
  }
18660
18954
 
18955
+ // src/tools/join.ts
18956
+ var joinTool = {
18957
+ name: "join",
18958
+ 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.",
18959
+ annotations: { title: "Join Project", readOnlyHint: true, destructiveHint: false, openWorldHint: false },
18960
+ inputSchema: {
18961
+ type: "object",
18962
+ properties: {
18963
+ limit: {
18964
+ type: "integer",
18965
+ description: "Maximum claimable tasks to list (default 10)."
18966
+ }
18967
+ },
18968
+ required: []
18969
+ }
18970
+ };
18971
+ function formatDecisions(decisions) {
18972
+ if (decisions.length === 0) {
18973
+ return "_No Active Decisions recorded yet._";
18974
+ }
18975
+ const shown = decisions.slice(0, 15);
18976
+ const lines = shown.map((d) => `- **${d.id}** \u2014 ${d.title ?? "(untitled)"}${d.confidence ? ` _(${d.confidence})_` : ""}`);
18977
+ const more = decisions.length > shown.length ? `
18978
+ - _\u2026and ${decisions.length - shown.length} more. Run \`ad_view\` for the full set._` : "";
18979
+ return lines.join("\n") + more;
18980
+ }
18981
+ function formatDocs(docs) {
18982
+ if (docs.length === 0) {
18983
+ return "_No registered canon docs. Ask the project owner what to read._";
18984
+ }
18985
+ return docs.slice(0, 12).map((d) => `- \`${d.path}\`${d.title ? ` \u2014 ${d.title}` : ""}`).join("\n");
18986
+ }
18987
+ function formatPool(tasks, limit) {
18988
+ if (tasks.length === 0) {
18989
+ return "_The shared backlog is empty right now \u2014 ask the owner what to pick up._";
18990
+ }
18991
+ const rows = tasks.slice(0, limit).map(
18992
+ (t) => `| ${t.id} | ${t.title} | ${t.priority ?? "-"} | ${t.complexity ?? "-"} |`
18993
+ );
18994
+ const more = tasks.length > limit ? `
18995
+
18996
+ _\u2026and ${tasks.length - limit} more in the pool._` : "";
18997
+ return `| Task | Summary | Priority | Effort |
18998
+ |---|---|---|---|
18999
+ ${rows.join("\n")}${more}`;
19000
+ }
19001
+ 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.";
19002
+ async function handleJoin(adapter2, config2, args) {
19003
+ const limit = typeof args.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 10;
19004
+ let projectLabel = "this project";
19005
+ try {
19006
+ const brief = adapterSupports(adapter2, "readProductBrief") ? await adapter2.readProductBrief() : "";
19007
+ const heading = brief.match(/^#\s+(.+)$/m)?.[1]?.trim();
19008
+ if (heading) projectLabel = heading;
19009
+ } catch {
19010
+ }
19011
+ const gate = await resolveOwnerGate(adapter2, config2);
19012
+ const [decisions, docs, pool] = await Promise.all([
19013
+ adapter2.getActiveDecisions().catch(() => []),
19014
+ // SECURITY: visibility scoping is delegated to searchDocs' requesterUserId
19015
+ // (task-1977) rather than reading rows directly — a contributors-tier or
19016
+ // private doc must not become readable just because someone joined. Passing
19017
+ // the caller means a non-owner sees public docs plus the contributors-tier
19018
+ // docs they are actually a cohort member of; private is excluded fail-closed.
19019
+ adapterSupports(adapter2, "searchDocs") ? adapter2.searchDocs({ requesterUserId: gate.callerUserId ?? void 0, limit: 12 }).catch(() => []) : Promise.resolve([]),
19020
+ // The claimable pool is the shared backlog: unassigned AND not yet pulled into
19021
+ // anyone's cycle. Same invariant task_claim enforces (assigneeId == null &&
19022
+ // cycle == null), so nothing is listed here that a claim would then refuse.
19023
+ adapter2.queryBoard({ status: ["Backlog"] }).catch(() => [])
19024
+ ]);
19025
+ const claimable = pool.filter((t) => !t.assigneeId && t.cycle == null);
19026
+ let conventionLine = "";
19027
+ try {
19028
+ if (adapterSupports(adapter2, "listConventions")) {
19029
+ const conventions = await adapter2.listConventions();
19030
+ if (conventions.length > 0) {
19031
+ conventionLine = `
19032
+
19033
+ ## Conventions that ride every build
19034
+ ` + conventions.slice(0, 8).map((c) => `- ${c.rule}`).join("\n");
19035
+ }
19036
+ }
19037
+ } catch {
19038
+ }
19039
+ const roleLine = gate.callerIsOwner ? `You are the **owner** of this project.` : `You are a **member** of this project.`;
19040
+ return textResponse(
19041
+ `# Joined \u2014 ${projectLabel}
19042
+
19043
+ ${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.
19044
+
19045
+ **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.
19046
+
19047
+ ## Decisions already made
19048
+ 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.
19049
+
19050
+ ${formatDecisions(decisions)}
19051
+
19052
+ ## Canon docs to read
19053
+ ${formatDocs(docs)}` + conventionLine + `
19054
+
19055
+ ## Work you can claim
19056
+ ${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.
19057
+
19058
+ ${formatPool(claimable, limit)}
19059
+
19060
+ ## Your next move
19061
+ 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.
19062
+
19063
+ Do NOT run \`setup\`. It is for creating a project, and this one already exists.`
19064
+ );
19065
+ }
19066
+
18661
19067
  // src/tools/setup.ts
18662
19068
  var setupTool = {
18663
19069
  name: "setup",
@@ -18875,8 +19281,36 @@ function formatSetupNarration(result) {
18875
19281
  ...steps.map((s, i) => `${i + 1}. ${s}`)
18876
19282
  ];
18877
19283
  }
19284
+ async function refuseIfJoiner(adapter2, config2) {
19285
+ let gate;
19286
+ try {
19287
+ gate = await resolveOwnerGate(adapter2, config2);
19288
+ } catch {
19289
+ return null;
19290
+ }
19291
+ if (!gate.enforced || gate.callerIsOwner) return null;
19292
+ if (gate.resolutionError || !gate.callerUserId) return null;
19293
+ let alreadySetUp = false;
19294
+ try {
19295
+ if (adapterSupports(adapter2, "readProductBrief")) {
19296
+ alreadySetUp = (await adapter2.readProductBrief()).trim().length > 0;
19297
+ }
19298
+ } catch {
19299
+ return null;
19300
+ }
19301
+ if (!alreadySetUp) return null;
19302
+ return `This project is already set up, and you are joining it rather than creating it.
19303
+
19304
+ \`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.
19305
+
19306
+ ${JOIN_POINTER}
19307
+
19308
+ If you genuinely need to re-scaffold this project, ask its owner to run setup.`;
19309
+ }
18878
19310
  async function handleSetup(adapter2, config2, args, clientName) {
18879
19311
  const toolMode = args.mode;
19312
+ const joinerRefusal = await refuseIfJoiner(adapter2, config2);
19313
+ if (joinerRefusal) return errorResponse(joinerRefusal);
18880
19314
  const REQUIRED_FIELDS = ["project_name"];
18881
19315
  const missing = REQUIRED_FIELDS.filter((f) => !args[f] || typeof args[f] === "string" && !args[f].trim());
18882
19316
  if (missing.length > 0) {
@@ -19154,89 +19588,10 @@ Examples (use whichever provider you have): ${spec.examples}.
19154
19588
  _Recommendation only \u2014 PAPI never selects or runs a model. Policy: XS/S \u2192 cheap, M/L \u2192 capable, XL \u2192 frontier._`;
19155
19589
  }
19156
19590
 
19157
- // src/lib/capabilities.ts
19158
- init_dist();
19159
-
19160
19591
  // src/tools/build.ts
19161
19592
  init_dist();
19162
19593
  init_dist();
19163
19594
 
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
19595
  // src/services/build.ts
19241
19596
  init_dist();
19242
19597
  import { randomUUID as randomUUID6 } from "crypto";
@@ -21335,6 +21690,20 @@ async function safeUpdateBlocker(adapter2, taskId, blocker) {
21335
21690
  } catch {
21336
21691
  }
21337
21692
  }
21693
+ async function resolveMemberBranchSlug(adapter2) {
21694
+ if (!adapterSupports(adapter2, "listContributors")) return void 0;
21695
+ try {
21696
+ const members = await adapter2.listContributors();
21697
+ if (members.length <= 1) return void 0;
21698
+ const callerUserId = adapterSupports(adapter2, "getOwnerIdentity") ? (await adapter2.getOwnerIdentity()).callerUserId ?? null : adapterSupports(adapter2, "getProjectOwnerUserId") ? await adapter2.getProjectOwnerUserId() : null;
21699
+ if (!callerUserId) return void 0;
21700
+ const me = members.find((m) => m.userId === callerUserId);
21701
+ if (!me) return void 0;
21702
+ return memberBranchSlug({ displayName: me.displayName, email: me.email, userId: me.userId });
21703
+ } catch {
21704
+ return void 0;
21705
+ }
21706
+ }
21338
21707
  async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21339
21708
  const task = await adapter2.getTask(taskId);
21340
21709
  if (!task) {
@@ -21437,13 +21806,14 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21437
21806
  }
21438
21807
  const inFlightTaskBranch = !depBranchReuse && branchExists(config2.projectRoot, taskBranchName(taskId)) ? taskBranchName(taskId) : void 0;
21439
21808
  const useSharedBranch = !depBranchReuse && !inFlightTaskBranch && !!task.module && cycleNumber > 0;
21809
+ const memberSlug = useSharedBranch ? await resolveMemberBranchSlug(adapter2) : void 0;
21440
21810
  let originCycleBranch;
21441
21811
  if (useSharedBranch && hasRemote(config2.projectRoot)) {
21442
21812
  const baseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
21443
21813
  const remoteCycleBranches = listGroupedCycleBranches(config2.projectRoot, cycleNumber, baseBranch);
21444
- originCycleBranch = pickModuleCycleBranch(remoteCycleBranches, cycleNumber, task.module);
21814
+ originCycleBranch = pickModuleCycleBranch(remoteCycleBranches, cycleNumber, task.module, memberSlug);
21445
21815
  }
21446
- const featureBranch = depBranchReuse ? depBranchReuse.branch : inFlightTaskBranch ? inFlightTaskBranch : useSharedBranch ? originCycleBranch ?? cycleBranchName(cycleNumber, task.module) : taskBranchName(taskId);
21816
+ const featureBranch = depBranchReuse ? depBranchReuse.branch : inFlightTaskBranch ? inFlightTaskBranch : useSharedBranch ? originCycleBranch ?? cycleBranchName(cycleNumber, task.module, memberSlug) : taskBranchName(taskId);
21447
21817
  if (depBranchReuse) {
21448
21818
  branchLines.push(
21449
21819
  `Reusing branch '${depBranchReuse.branch}' from dependency ${depBranchReuse.upstreamId} \u2014 commits will stack for a single PR.`
@@ -24749,7 +25119,10 @@ Re-submit with \`notes: "... Reference: <path>"\` to link one, or ignore if none
24749
25119
  const conflictNote = result.adConflicts ? buildAdConflictNote(
24750
25120
  result.adConflicts.explicit ? result.adConflicts.adIds[0] : void 0,
24751
25121
  result.adConflicts.explicit ? [] : result.adConflicts.adIds,
24752
- result.adConflicts.gated
25122
+ result.adConflicts.gated,
25123
+ // task-3347: resolved alongside the ids, so the directive names the
25124
+ // decision the user is being asked to rule on.
25125
+ new Map(Object.entries(result.adConflicts.adTitles ?? {}))
24753
25126
  ) : "";
24754
25127
  return textResponse(`${result.message}${overrideNote}${branchNote}${truncateWarning}${refNudge}${conflictNote}`);
24755
25128
  }
@@ -27972,6 +28345,29 @@ ${writeNote}
27972
28345
  return textResponse(output);
27973
28346
  }
27974
28347
 
28348
+ // src/lib/workspace-mode.ts
28349
+ init_git();
28350
+ var cache3 = /* @__PURE__ */ new Map();
28351
+ function resolveWorkspaceMode(projectRoot) {
28352
+ const cached2 = cache3.get(projectRoot);
28353
+ if (cached2) return cached2;
28354
+ let result;
28355
+ if (!isGitAvailable()) {
28356
+ result = { mode: "no-git", gitBacked: false, reason: "git-not-installed" };
28357
+ } else if (!isGitRepo(projectRoot)) {
28358
+ result = { mode: "no-git", gitBacked: false, reason: "not-a-repo" };
28359
+ } else {
28360
+ result = { mode: "git", gitBacked: true, reason: null };
28361
+ }
28362
+ cache3.set(projectRoot, result);
28363
+ return result;
28364
+ }
28365
+ function formatWorkspaceModeLine(result) {
28366
+ if (result.gitBacked) return void 0;
28367
+ const because = result.reason === "git-not-installed" ? "no version control was detected on this machine" : "this folder is not under version control";
28368
+ 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.`;
28369
+ }
28370
+
27975
28371
  // src/services/health.ts
27976
28372
  function computeZoomOutWarning(cycleNumber, lastZoomOutCycle) {
27977
28373
  if (cycleNumber <= 0) return "";
@@ -28309,15 +28705,16 @@ async function findUnblockCandidates(adapter2, currentCycle) {
28309
28705
  const blocker = blocked.blocker;
28310
28706
  if (!blocker) continue;
28311
28707
  const resolved = isBlockerResolved(blocker, ctx);
28708
+ const blockerTitle = resolveBlockerTitle(blocker, ctx) ?? "";
28312
28709
  candidates.push({
28313
28710
  blockedId: blocked.displayId ?? blocked.id,
28314
28711
  blockedTitle: blocked.title,
28315
28712
  blockerId: blocker.ref,
28316
- blockerTitle: "",
28713
+ blockerTitle,
28317
28714
  blockerCycle: blocker.blockedCycle,
28318
28715
  typed: {
28319
28716
  kind: blocker.type,
28320
- waiting: formatBlockerWaiting(blocker),
28717
+ waiting: formatBlockerWaiting(blocker, blockerTitle),
28321
28718
  resolved
28322
28719
  }
28323
28720
  });
@@ -28383,9 +28780,9 @@ function formatUnblockSection(candidates) {
28383
28780
  lines.push("");
28384
28781
  for (const c of resolved) {
28385
28782
  if (c.typed) {
28386
- lines.push(`- **${c.blockedId}** (${c.blockedTitle}) \u2014 ${c.typed.kind}: ${c.blockerId} now resolved \u2705`);
28783
+ lines.push(`- **${c.blockedId}** (${c.blockedTitle}) \u2014 ${c.typed.kind}: ${formatRef(c.blockerId, c.blockerTitle)} now resolved \u2705`);
28387
28784
  } else {
28388
- lines.push(`- **${c.blockedId}** (${c.blockedTitle}) \u2014 blocker **${c.blockerId}** Done in C${c.blockerCycle}`);
28785
+ lines.push(`- **${c.blockedId}** (${c.blockedTitle}) \u2014 blocker **${formatRef(c.blockerId, c.blockerTitle)}** Done in C${c.blockerCycle}`);
28389
28786
  }
28390
28787
  }
28391
28788
  }
@@ -29005,6 +29402,13 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
29005
29402
  lines.push(`**Connection:** ${statusIcon} ${statusLabel}`);
29006
29403
  lines.push("");
29007
29404
  }
29405
+ if (projectRoot) {
29406
+ const modeLine = formatWorkspaceModeLine(resolveWorkspaceMode(projectRoot));
29407
+ if (modeLine) {
29408
+ lines.push(modeLine);
29409
+ lines.push("");
29410
+ }
29411
+ }
29008
29412
  const harness = detectHarness(clientName);
29009
29413
  const roleNote = connectOnlyRoleNote(harness);
29010
29414
  if (roleNote) {
@@ -29349,6 +29753,41 @@ async function computeTeamSummary(adapter2, contributorsInput) {
29349
29753
  const reviewQueue = tasks.filter((t) => t.status === "In Review").length;
29350
29754
  return `**Team:** ${members} members \xB7 ${pool} in pool \xB7 ${inFlight} in flight \xB7 ${reviewQueue} in review`;
29351
29755
  }
29756
+ async function computeCohortVisibility(adapter2, config2, contributorsInput) {
29757
+ if (!adapterSupports(adapter2, "listContributors")) return void 0;
29758
+ let members;
29759
+ try {
29760
+ members = (await (contributorsInput ?? adapter2.listContributors())).length;
29761
+ } catch {
29762
+ return void 0;
29763
+ }
29764
+ if (members <= 1) return void 0;
29765
+ let gate;
29766
+ try {
29767
+ gate = await resolveOwnerGate(adapter2, config2);
29768
+ } catch {
29769
+ return void 0;
29770
+ }
29771
+ if (gate.enforced && !gate.callerIsOwner) return void 0;
29772
+ let tasks;
29773
+ try {
29774
+ tasks = await adapter2.queryBoard({ compact: true });
29775
+ } catch {
29776
+ return void 0;
29777
+ }
29778
+ const open = tasks.filter(
29779
+ (t) => t.status !== "Done" && t.status !== "Cancelled" && t.status !== "Archived"
29780
+ );
29781
+ if (open.length === 0) return void 0;
29782
+ const publicCount = open.filter((t) => !t.visibility || t.visibility === "public").length;
29783
+ const contributorCount = publicCount + open.filter((t) => t.visibility === "contributors").length;
29784
+ const hiddenFromContributors = open.length - contributorCount;
29785
+ if (hiddenFromContributors === 0) {
29786
+ return `**Cohort visibility:** your ${members - 1} teammate(s) can see the whole active board (${open.length} tasks).`;
29787
+ }
29788
+ const pct = Math.round(hiddenFromContributors / open.length * 100);
29789
+ 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.**`;
29790
+ }
29352
29791
  async function computeReleaseHistory(adapter2, contributorsInput) {
29353
29792
  if (!adapterSupports(adapter2, "listContributors")) return void 0;
29354
29793
  let contributors;
@@ -29654,7 +30093,7 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
29654
30093
  return adWords.some((w) => docText.includes(w));
29655
30094
  });
29656
30095
  const cycleLabel = `C${doc.cycleUpdated ?? doc.cycleCreated}`;
29657
- const adRef = relatedAds.length > 0 ? ` \u2014 may relate to ${relatedAds.map((a) => a.displayId).join(", ")}` : "";
30096
+ const adRef = relatedAds.length > 0 ? ` \u2014 may relate to ${relatedAds.map((a) => formatRef(a.displayId, a.title)).join(", ")}` : "";
29658
30097
  const pendingActions = doc.actions?.filter((a) => a.status === "pending") ?? [];
29659
30098
  lines.push(`- **${doc.title}** [${cycleLabel}${adRef}]`);
29660
30099
  for (const action of pendingActions.slice(0, 2)) {
@@ -29761,8 +30200,8 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
29761
30200
  const mismatches = detectBoardMismatches(config2.projectRoot, allTasks);
29762
30201
  if (mismatches.codeAhead.length > 0 || mismatches.staleInProgress.length > 0) {
29763
30202
  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\`.`);
30203
+ 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.`);
30204
+ 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
30205
  reconciliationNote2 = lines.join("\n");
29767
30206
  }
29768
30207
  } catch {
@@ -29776,7 +30215,7 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
29776
30215
  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
30216
  for (const m of merged) {
29778
30217
  const ref = m.pr ? `${m.pr} (\`${m.commit}\`)` : `\`${m.commit}\``;
29779
- lines.push(`- \u26A0\uFE0F **${m.displayId}** \u2014 merged via ${ref}: ${m.subject}`);
30218
+ lines.push(`- \u26A0\uFE0F **${formatRef(m.displayId, m.title)}** \u2014 merged via ${ref}: ${m.subject}`);
29780
30219
  }
29781
30220
  mergedInProgressNote2 = lines.join("\n");
29782
30221
  }
@@ -29909,7 +30348,7 @@ ${versionDrift}` : "";
29909
30348
  }
29910
30349
  tracker.mark("parallel-tail");
29911
30350
  const sharedContributorsPromise = adapterSupports(adapter2, "listContributors") ? adapter2.listContributors().catch(() => []) : Promise.resolve([]);
29912
- const [unblockCandidates, subAgents, teamSummaryLine, releaseHistoryLine, carryForwardRefs] = await Promise.all([
30351
+ const [unblockCandidates, subAgents, teamSummaryLine, releaseHistoryLine, cohortVisibilityLine, carryForwardRefs] = await Promise.all([
29913
30352
  tracked("unblock-candidates", () => findUnblockCandidates(adapter2, currentCycle))().catch(() => []),
29914
30353
  // task-1866: discover project sub-agents for the orient surface (read-only, never throws).
29915
30354
  listAgents(config2.projectRoot),
@@ -29918,6 +30357,8 @@ ${versionDrift}` : "";
29918
30357
  // byte-identical there.
29919
30358
  tracked("team-summary", () => computeTeamSummary(adapter2, sharedContributorsPromise))().catch(() => void 0),
29920
30359
  tracked("release-history", () => computeReleaseHistory(adapter2, sharedContributorsPromise))().catch(() => void 0),
30360
+ // task-3339 (C363): owner-only readout of how much of the board the cohort cannot see.
30361
+ tracked("cohort-visibility", () => computeCohortVisibility(adapter2, config2, sharedContributorsPromise))().catch(() => void 0),
29921
30362
  // task-2751 (C332): resolve every task-NNNN mentioned in the Carry-Forward
29922
30363
  // prose to its title so orient can name it inline. Built from the board
29923
30364
  // already in hand — no extra query.
@@ -29927,7 +30368,7 @@ ${versionDrift}` : "";
29927
30368
  const unblockNote = unblockSection ? `
29928
30369
 
29929
30370
  ${unblockSection}` : "";
29930
- const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
30371
+ const teamSummary = [teamSummaryLine, releaseHistoryLine, cohortVisibilityLine].filter(Boolean).join("\n") || void 0;
29931
30372
  let deferredGateNote = "";
29932
30373
  if (deepHousekeeping) {
29933
30374
  try {
@@ -31059,15 +31500,36 @@ function readLlmResponse(args) {
31059
31500
  }
31060
31501
 
31061
31502
  // 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);
31503
+ function adColumns(all) {
31504
+ const titles = titleMap(all);
31505
+ return [
31506
+ { name: "id", header: "AD", value: (d) => d.id },
31507
+ { name: "title", header: "Title", value: (d) => d.title },
31508
+ { name: "confidence", header: "Confidence", value: (d) => d.confidence },
31509
+ { name: "superseded", header: "Superseded", value: (d) => d.superseded ? "yes" : "no" },
31510
+ {
31511
+ name: "supersededBy",
31512
+ header: "Superseded By",
31513
+ value: (d) => d.supersededBy ? formatRef(d.supersededBy, lookupTitle(d.supersededBy, titles)) : "-"
31514
+ },
31515
+ { name: "body", header: "Body", value: (d) => d.body.replace(/\s+/g, " ").trim() }
31516
+ ];
31517
+ }
31518
+ var AD_FIELDS = adColumns([]).map((c) => c.name);
31519
+ function supersededNote(d, all) {
31520
+ if (!d.superseded) return "";
31521
+ const by = d.supersededBy;
31522
+ if (!by) return " [SUPERSEDED by unknown]";
31523
+ return ` [SUPERSEDED by ${formatRef(by, lookupTitle(by, titleMap(all)))}]`;
31524
+ }
31525
+ function formatAdBody(d, all) {
31526
+ return `## ${d.id}: ${d.title} [${d.confidence}]${supersededNote(d, all)}
31527
+
31528
+ ${d.body}`;
31529
+ }
31530
+ function formatAdList(decisions, all) {
31531
+ return decisions.map((d) => formatAdBody(d, all)).join("\n\n---\n\n");
31532
+ }
31071
31533
  var adViewTool = {
31072
31534
  name: "ad_view",
31073
31535
  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 +31592,11 @@ async function handleAdView(adapter2, args) {
31130
31592
  }
31131
31593
  const meta2 = emitMeta ? { total: decisions.length, filtered: 1, returned: 1, limit: null, offset: 0 } : null;
31132
31594
  if (fields) {
31133
- const columns = selectColumns(AD_COLUMNS, fields);
31595
+ const columns = selectColumns(adColumns(decisions), fields);
31134
31596
  const body = columns.map((c) => `- **${c.header}:** ${c.value(target)}`).join("\n");
31135
31597
  return textResponse(withMeta(body, meta2));
31136
31598
  }
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
- );
31599
+ return textResponse(withMeta(formatAdBody(target, decisions), meta2));
31143
31600
  }
31144
31601
  const filtered = includeSuperseded ? decisions : decisions.filter((d) => !d.superseded);
31145
31602
  const paged = limit === null && offset === 0 ? filtered : filtered.slice(offset, limit === null ? void 0 : offset + limit);
@@ -31148,16 +31605,11 @@ ${target.body}`, meta2)
31148
31605
  return textResponse(withMeta("No active decisions found.", meta));
31149
31606
  }
31150
31607
  if (fields) {
31151
- const columns = selectColumns(AD_COLUMNS, fields);
31608
+ const columns = selectColumns(adColumns(decisions), fields);
31152
31609
  const rows = paged.map((d) => columns.map((c) => c.value(d)));
31153
31610
  return textResponse(withMeta(renderTable(columns.map((c) => c.header), rows), meta));
31154
31611
  }
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");
31612
+ const formatted = formatAdList(paged, decisions);
31161
31613
  return textResponse(withMeta(`# Active Decisions (${paged.length})
31162
31614
 
31163
31615
  ${formatted}`, meta));
@@ -31478,14 +31930,24 @@ On hosted/remote sessions this is now your DEFAULT project \u2014 calls without
31478
31930
  }
31479
31931
 
31480
31932
  // src/tools/contributor.ts
31933
+ init_dist();
31481
31934
  var contributorAddTool = {
31482
31935
  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.",
31936
+ 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
31937
  annotations: { title: "Add Contributor", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
31485
31938
  inputSchema: {
31486
31939
  type: "object",
31487
31940
  properties: {
31488
- email: { type: "string", description: 'Email of the PAPI account to add (e.g. "wes@example.com").' }
31941
+ email: { type: "string", description: 'Email of the PAPI account to add (e.g. "wes@example.com").' },
31942
+ role: {
31943
+ type: "string",
31944
+ enum: [...ASSIGNABLE_CONTRIBUTOR_ROLES],
31945
+ 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.`
31946
+ },
31947
+ project: {
31948
+ type: "string",
31949
+ 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."
31950
+ }
31489
31951
  },
31490
31952
  required: ["email"]
31491
31953
  }
@@ -31550,21 +32012,37 @@ function requireEmail(args) {
31550
32012
  return EMAIL_SHAPE.test(email) ? email : null;
31551
32013
  }
31552
32014
  async function handleContributorAdd(adapter2, config2, args) {
31553
- const denied = await denyUnlessOwner(adapter2, config2);
31554
- if (denied) return errorResponse(denied);
31555
32015
  const email = requireEmail(args);
31556
32016
  if (!email) return errorResponse('A valid email is required. Example: contributor_add email="wes@example.com"');
32017
+ const rawRole = typeof args.role === "string" ? args.role.trim().toLowerCase() : void 0;
32018
+ if (rawRole !== void 0 && !ASSIGNABLE_CONTRIBUTOR_ROLES.includes(rawRole)) {
32019
+ return errorResponse(
32020
+ `Invalid role "${args.role}". Assignable roles: ${ASSIGNABLE_CONTRIBUTOR_ROLES.join(", ")}. Project ownership cannot be granted through contributor_add.`
32021
+ );
32022
+ }
32023
+ let target = adapter2;
32024
+ let overrideNote = "";
32025
+ try {
32026
+ ({ adapter: target, overrideNote } = await resolvePerCallProjectAdapter(adapter2, args));
32027
+ } catch (err) {
32028
+ if (err instanceof ProjectResolutionError) return errorResponse(err.message);
32029
+ throw err;
32030
+ }
32031
+ const denied = await denyUnlessOwner(target, config2);
32032
+ if (denied) return errorResponse(denied);
31557
32033
  try {
31558
- const entry = await adapter2.addContributorByEmail(email);
32034
+ const entry = await target.addContributorByEmail(email, rawRole);
31559
32035
  const name = entry.displayName ? ` (${entry.displayName})` : "";
31560
- const upsell = await resolveContributorUpsell(adapter2);
32036
+ const upsell = await resolveContributorUpsell(target);
31561
32037
  const upsellSuffix = upsell ? `
31562
32038
 
31563
32039
  ${upsell}` : "";
32040
+ const where = overrideNote ? ` ${overrideNote}` : " on this project";
32041
+ 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
32042
  return textResponse(
31565
- `\u2705 Added **${entry.email ?? email}**${name} as a contributor.
32043
+ `\u2705 **${entry.email ?? email}**${name} is now a **${entry.role}**${where}.
31566
32044
 
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
32045
+ ` + roleLine + upsellSuffix
31568
32046
  );
31569
32047
  } catch (err) {
31570
32048
  return errorResponse(err instanceof Error ? err.message : String(err));
@@ -32069,12 +32547,13 @@ async function readPapiResource(adapter2, uri) {
32069
32547
  const ads = await adapter2.getActiveDecisions({ includeRetired: true });
32070
32548
  const target = ads.find((d) => d.id === adId);
32071
32549
  if (!target) throw new Error(`Active Decision not found in this project: ${adId}`);
32072
- const supersededNote = target.superseded ? ` [SUPERSEDED by ${target.supersededBy ?? "unknown"}]` : "";
32550
+ const titles = titleMap(ads);
32551
+ const supersededNote2 = target.superseded ? ` [SUPERSEDED by ${target.supersededBy ? formatRef(target.supersededBy, lookupTitle(target.supersededBy, titles)) : "unknown"}]` : "";
32073
32552
  const supersedes = ads.filter((d) => d.supersededBy === target.id).map((d) => d.id);
32074
32553
  const chain = supersedes.length > 0 ? `
32075
32554
 
32076
- _Supersedes: ${supersedes.join(", ")}_` : "";
32077
- return md(`## ${target.id}: ${target.title} [${target.confidence}]${supersededNote}
32555
+ _Supersedes: ${formatRefList(supersedes, titles)}_` : "";
32556
+ return md(`## ${target.id}: ${target.title} [${target.confidence}]${supersededNote2}
32078
32557
 
32079
32558
  ${target.body}${chain}`);
32080
32559
  }
@@ -32302,6 +32781,7 @@ var PAPI_TOOLS = [
32302
32781
  boardArchiveTool,
32303
32782
  boardEditTool,
32304
32783
  setupTool,
32784
+ joinTool,
32305
32785
  buildListTool,
32306
32786
  buildDescribeTool,
32307
32787
  buildExecuteTool,
@@ -32467,6 +32947,8 @@ function createServer(adapter2, config2, observation) {
32467
32947
  return handleBoardEdit(adapter2, safeArgs);
32468
32948
  case "setup":
32469
32949
  return handleSetup(adapter2, config2, safeArgs, server2.getClientVersion()?.name);
32950
+ case "join":
32951
+ return handleJoin(adapter2, config2, safeArgs);
32470
32952
  case "build_list":
32471
32953
  return handleBuildList(adapter2, config2, safeArgs);
32472
32954
  case "build_describe":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.83",
3
+ "version": "0.7.84",
4
4
  "description": "PAPI MCP server — AI-powered sprint planning, build execution, and strategy review for software projects",
5
5
  "license": "Elastic-2.0",
6
6
  "mcpName": "io.github.getpapi/papi",
@@ -51,8 +51,8 @@
51
51
  "dependencies": {
52
52
  "@anthropic-ai/sdk": "^0.82.0",
53
53
  "@modelcontextprotocol/sdk": "^1.27.1",
54
- "@papi-ai/adapter-pg": "^0.2.0",
55
- "@papi-ai/shared": "^0.2.0",
54
+ "@papi-ai/adapter-pg": "^0.2.15",
55
+ "@papi-ai/shared": "^0.2.4",
56
56
  "@papi-ai/skills": "^0.1.0",
57
57
  "js-yaml": "^4.1.0"
58
58
  },