@papi-ai/server 0.7.98 → 0.7.103

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.
@@ -189,6 +189,7 @@ __export(git_exports, {
189
189
  checkoutBranch: () => checkoutBranch,
190
190
  commitSinglePath: () => commitSinglePath,
191
191
  commitStagedOnly: () => commitStagedOnly,
192
+ computeAheadBehind: () => computeAheadBehind,
192
193
  createAndCheckoutBranch: () => createAndCheckoutBranch,
193
194
  createPullRequest: () => createPullRequest,
194
195
  createTag: () => createTag,
@@ -199,8 +200,10 @@ __export(git_exports, {
199
200
  detectUnrecordedCommits: () => detectUnrecordedCommits,
200
201
  ensureLatestDevelop: () => ensureLatestDevelop,
201
202
  ensureTagAtHead: () => ensureTagAtHead,
203
+ fetchBaseBranch: () => fetchBaseBranch,
202
204
  findContributorReleasePullRequests: () => findContributorReleasePullRequests,
203
205
  findTaskCommitsOnBase: () => findTaskCommitsOnBase,
206
+ getBaseDivergence: () => getBaseDivergence,
204
207
  getBranchDiff: () => getBranchDiff,
205
208
  getCommitFiles: () => getCommitFiles,
206
209
  getCommitsSinceTag: () => getCommitsSinceTag,
@@ -235,6 +238,7 @@ __export(git_exports, {
235
238
  hasUnpushedCommits: () => hasUnpushedCommits,
236
239
  isBranchContentAlreadyInBase: () => isBranchContentAlreadyInBase,
237
240
  isBranchMergedInto: () => isBranchMergedInto,
241
+ isCommitReachable: () => isCommitReachable,
238
242
  isGhAvailable: () => isGhAvailable,
239
243
  isGitAvailable: () => isGitAvailable,
240
244
  isGitRepo: () => isGitRepo,
@@ -1297,6 +1301,19 @@ function isBranchMergedInto(cwd, branch, baseBranch) {
1297
1301
  return false;
1298
1302
  }
1299
1303
  }
1304
+ function isCommitReachable(cwd, commit, baseBranch) {
1305
+ const recordedCommit = commit.trim();
1306
+ if (!recordedCommit) return false;
1307
+ try {
1308
+ execFileSync("git", ["merge-base", "--is-ancestor", recordedCommit, baseBranch], {
1309
+ cwd,
1310
+ stdio: "ignore"
1311
+ });
1312
+ return true;
1313
+ } catch {
1314
+ return false;
1315
+ }
1316
+ }
1300
1317
  function isBranchContentAlreadyInBase(cwd, branch, baseBranch) {
1301
1318
  const resolveCommit = (ref) => {
1302
1319
  try {
@@ -1323,6 +1340,55 @@ function isBranchContentAlreadyInBase(cwd, branch, baseBranch) {
1323
1340
  return false;
1324
1341
  }
1325
1342
  }
1343
+ function fetchBaseBranch(cwd, baseBranch, timeoutMs = GIT_FETCH_TIMEOUT_MS) {
1344
+ if (!isGitAvailable() || !isGitRepo(cwd) || !hasRemote(cwd)) {
1345
+ return { fetched: false, compareRef: baseBranch };
1346
+ }
1347
+ const result = spawnSync("git", ["fetch", "--quiet", "origin", baseBranch], {
1348
+ cwd,
1349
+ encoding: "utf-8",
1350
+ timeout: timeoutMs
1351
+ });
1352
+ if (result.error || result.status !== 0) {
1353
+ const raw = result.error?.message ?? ((result.stderr || "").trim() || `git exited ${result.status}`);
1354
+ const isTimeout = result.signal === "SIGTERM" || /ETIMEDOUT/.test(raw);
1355
+ return {
1356
+ fetched: false,
1357
+ compareRef: baseBranch,
1358
+ warning: isTimeout ? `Fetch from origin timed out after ${Math.round(timeoutMs / 1e3)}s \u2014 comparing against the local '${baseBranch}' ref, which may be stale.` : `Fetch from origin failed \u2014 comparing against the local '${baseBranch}' ref, which may be stale. (${raw})`
1359
+ };
1360
+ }
1361
+ return { fetched: true, compareRef: `origin/${baseBranch}` };
1362
+ }
1363
+ function computeAheadBehind(cwd, compareRef, ref = "HEAD") {
1364
+ try {
1365
+ const out = execFileSync(
1366
+ "git",
1367
+ ["rev-list", "--left-right", "--count", `${compareRef}...${ref}`],
1368
+ { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
1369
+ ).trim();
1370
+ const [behindStr, aheadStr] = out.split(/\s+/);
1371
+ const behind = parseInt(behindStr, 10);
1372
+ const ahead = parseInt(aheadStr, 10);
1373
+ if (Number.isNaN(behind) || Number.isNaN(ahead)) return null;
1374
+ return { ahead, behind };
1375
+ } catch {
1376
+ return null;
1377
+ }
1378
+ }
1379
+ function getBaseDivergence(cwd, preferredBase, opts) {
1380
+ const baseBranch = resolveBaseBranch(cwd, preferredBase);
1381
+ const fetch2 = fetchBaseBranch(cwd, baseBranch, opts?.timeoutMs);
1382
+ const divergence = computeAheadBehind(cwd, fetch2.compareRef, opts?.ref ?? "HEAD");
1383
+ return {
1384
+ baseBranch,
1385
+ compareRef: fetch2.compareRef,
1386
+ fetched: fetch2.fetched,
1387
+ ahead: divergence?.ahead ?? null,
1388
+ behind: divergence?.behind ?? null,
1389
+ warning: fetch2.warning
1390
+ };
1391
+ }
1326
1392
  function pickModuleCycleBranch(candidates, cycleNumber, module, memberSlug) {
1327
1393
  if (candidates.length === 0) return void 0;
1328
1394
  const expected = cycleBranchName(cycleNumber, module, memberSlug);
@@ -1427,7 +1493,7 @@ function getRemoteBranchFiles(cwd, branch, baseBranch) {
1427
1493
  return [];
1428
1494
  }
1429
1495
  }
1430
- var AUTO_WRITTEN_PATHS, GIT_NETWORK_TIMEOUT_MS, MERGE_RETRY_DELAY_MS, MERGE_MAX_RETRIES;
1496
+ var AUTO_WRITTEN_PATHS, GIT_NETWORK_TIMEOUT_MS, MERGE_RETRY_DELAY_MS, MERGE_MAX_RETRIES, GIT_FETCH_TIMEOUT_MS;
1431
1497
  var init_git = __esm({
1432
1498
  "src/lib/git.ts"() {
1433
1499
  "use strict";
@@ -1435,6 +1501,7 @@ var init_git = __esm({
1435
1501
  GIT_NETWORK_TIMEOUT_MS = 6e4;
1436
1502
  MERGE_RETRY_DELAY_MS = 2e3;
1437
1503
  MERGE_MAX_RETRIES = 3;
1504
+ GIT_FETCH_TIMEOUT_MS = 5e3;
1438
1505
  }
1439
1506
  });
1440
1507
 
package/dist/index.js CHANGED
@@ -850,7 +850,7 @@ var init_dist = __esm({
850
850
  { 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 }
851
851
  ];
852
852
  CAPABILITY_KEYS = CAPABILITY_REGISTRY.map((c) => c.key);
853
- ASSIGNABLE_CONTRIBUTOR_ROLES = ["editor", "viewer"];
853
+ ASSIGNABLE_CONTRIBUTOR_ROLES = ["release_manager", "editor", "viewer"];
854
854
  SENSITIVE_CHANGELOG_PATTERNS = [
855
855
  // ── Test-user machinery ────────────────────────────────────────────────
856
856
  /@test\.papi\.dev/i,
@@ -948,6 +948,7 @@ __export(git_exports, {
948
948
  checkoutBranch: () => checkoutBranch,
949
949
  commitSinglePath: () => commitSinglePath,
950
950
  commitStagedOnly: () => commitStagedOnly,
951
+ computeAheadBehind: () => computeAheadBehind,
951
952
  createAndCheckoutBranch: () => createAndCheckoutBranch,
952
953
  createPullRequest: () => createPullRequest,
953
954
  createTag: () => createTag,
@@ -958,8 +959,10 @@ __export(git_exports, {
958
959
  detectUnrecordedCommits: () => detectUnrecordedCommits,
959
960
  ensureLatestDevelop: () => ensureLatestDevelop,
960
961
  ensureTagAtHead: () => ensureTagAtHead,
962
+ fetchBaseBranch: () => fetchBaseBranch,
961
963
  findContributorReleasePullRequests: () => findContributorReleasePullRequests,
962
964
  findTaskCommitsOnBase: () => findTaskCommitsOnBase,
965
+ getBaseDivergence: () => getBaseDivergence,
963
966
  getBranchDiff: () => getBranchDiff,
964
967
  getCommitFiles: () => getCommitFiles,
965
968
  getCommitsSinceTag: () => getCommitsSinceTag,
@@ -994,6 +997,7 @@ __export(git_exports, {
994
997
  hasUnpushedCommits: () => hasUnpushedCommits,
995
998
  isBranchContentAlreadyInBase: () => isBranchContentAlreadyInBase,
996
999
  isBranchMergedInto: () => isBranchMergedInto,
1000
+ isCommitReachable: () => isCommitReachable,
997
1001
  isGhAvailable: () => isGhAvailable,
998
1002
  isGitAvailable: () => isGitAvailable,
999
1003
  isGitRepo: () => isGitRepo,
@@ -2056,6 +2060,19 @@ function isBranchMergedInto(cwd, branch, baseBranch) {
2056
2060
  return false;
2057
2061
  }
2058
2062
  }
2063
+ function isCommitReachable(cwd, commit, baseBranch) {
2064
+ const recordedCommit = commit.trim();
2065
+ if (!recordedCommit) return false;
2066
+ try {
2067
+ execFileSync("git", ["merge-base", "--is-ancestor", recordedCommit, baseBranch], {
2068
+ cwd,
2069
+ stdio: "ignore"
2070
+ });
2071
+ return true;
2072
+ } catch {
2073
+ return false;
2074
+ }
2075
+ }
2059
2076
  function isBranchContentAlreadyInBase(cwd, branch, baseBranch) {
2060
2077
  const resolveCommit = (ref) => {
2061
2078
  try {
@@ -2082,6 +2099,55 @@ function isBranchContentAlreadyInBase(cwd, branch, baseBranch) {
2082
2099
  return false;
2083
2100
  }
2084
2101
  }
2102
+ function fetchBaseBranch(cwd, baseBranch, timeoutMs = GIT_FETCH_TIMEOUT_MS) {
2103
+ if (!isGitAvailable() || !isGitRepo(cwd) || !hasRemote(cwd)) {
2104
+ return { fetched: false, compareRef: baseBranch };
2105
+ }
2106
+ const result = spawnSync("git", ["fetch", "--quiet", "origin", baseBranch], {
2107
+ cwd,
2108
+ encoding: "utf-8",
2109
+ timeout: timeoutMs
2110
+ });
2111
+ if (result.error || result.status !== 0) {
2112
+ const raw = result.error?.message ?? ((result.stderr || "").trim() || `git exited ${result.status}`);
2113
+ const isTimeout = result.signal === "SIGTERM" || /ETIMEDOUT/.test(raw);
2114
+ return {
2115
+ fetched: false,
2116
+ compareRef: baseBranch,
2117
+ warning: isTimeout ? `Fetch from origin timed out after ${Math.round(timeoutMs / 1e3)}s \u2014 comparing against the local '${baseBranch}' ref, which may be stale.` : `Fetch from origin failed \u2014 comparing against the local '${baseBranch}' ref, which may be stale. (${raw})`
2118
+ };
2119
+ }
2120
+ return { fetched: true, compareRef: `origin/${baseBranch}` };
2121
+ }
2122
+ function computeAheadBehind(cwd, compareRef, ref = "HEAD") {
2123
+ try {
2124
+ const out = execFileSync(
2125
+ "git",
2126
+ ["rev-list", "--left-right", "--count", `${compareRef}...${ref}`],
2127
+ { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
2128
+ ).trim();
2129
+ const [behindStr, aheadStr] = out.split(/\s+/);
2130
+ const behind = parseInt(behindStr, 10);
2131
+ const ahead = parseInt(aheadStr, 10);
2132
+ if (Number.isNaN(behind) || Number.isNaN(ahead)) return null;
2133
+ return { ahead, behind };
2134
+ } catch {
2135
+ return null;
2136
+ }
2137
+ }
2138
+ function getBaseDivergence(cwd, preferredBase, opts) {
2139
+ const baseBranch = resolveBaseBranch(cwd, preferredBase);
2140
+ const fetch2 = fetchBaseBranch(cwd, baseBranch, opts?.timeoutMs);
2141
+ const divergence = computeAheadBehind(cwd, fetch2.compareRef, opts?.ref ?? "HEAD");
2142
+ return {
2143
+ baseBranch,
2144
+ compareRef: fetch2.compareRef,
2145
+ fetched: fetch2.fetched,
2146
+ ahead: divergence?.ahead ?? null,
2147
+ behind: divergence?.behind ?? null,
2148
+ warning: fetch2.warning
2149
+ };
2150
+ }
2085
2151
  function pickModuleCycleBranch(candidates, cycleNumber, module, memberSlug) {
2086
2152
  if (candidates.length === 0) return void 0;
2087
2153
  const expected = cycleBranchName(cycleNumber, module, memberSlug);
@@ -2186,7 +2252,7 @@ function getRemoteBranchFiles(cwd, branch, baseBranch) {
2186
2252
  return [];
2187
2253
  }
2188
2254
  }
2189
- var AUTO_WRITTEN_PATHS, GIT_NETWORK_TIMEOUT_MS, MERGE_RETRY_DELAY_MS, MERGE_MAX_RETRIES;
2255
+ var AUTO_WRITTEN_PATHS, GIT_NETWORK_TIMEOUT_MS, MERGE_RETRY_DELAY_MS, MERGE_MAX_RETRIES, GIT_FETCH_TIMEOUT_MS;
2190
2256
  var init_git = __esm({
2191
2257
  "src/lib/git.ts"() {
2192
2258
  "use strict";
@@ -2194,6 +2260,7 @@ var init_git = __esm({
2194
2260
  GIT_NETWORK_TIMEOUT_MS = 6e4;
2195
2261
  MERGE_RETRY_DELAY_MS = 2e3;
2196
2262
  MERGE_MAX_RETRIES = 3;
2263
+ GIT_FETCH_TIMEOUT_MS = 5e3;
2197
2264
  }
2198
2265
  });
2199
2266
 
@@ -10193,7 +10260,7 @@ function isProjectOwner(callerUserId, ownerUserId) {
10193
10260
  if (caller.length === 0 || owner.length === 0) return false;
10194
10261
  return caller === owner;
10195
10262
  }
10196
- var CYCLE_ROLES = ["owner", "editor"];
10263
+ var CYCLE_ROLES = ["owner", "release_manager", "editor"];
10197
10264
  async function resolveCycleGate(adapter2, gate) {
10198
10265
  if (!gate.enforced) return { allowed: true, role: null };
10199
10266
  if (gate.callerIsOwner) return { allowed: true, role: "owner" };
@@ -10218,6 +10285,13 @@ async function resolveCycleGate(adapter2, gate) {
10218
10285
  };
10219
10286
  }
10220
10287
  }
10288
+ function formatOwnerGateResolutionError(gate) {
10289
+ if (!gate.resolutionError) return null;
10290
+ if (gate.transport === "proxy") {
10291
+ return "Hosted bearer identity could not be verified. Retry once connectivity is restored.";
10292
+ }
10293
+ return `Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.`;
10294
+ }
10221
10295
  async function resolveCallerUserId(adapter2, config2) {
10222
10296
  const gate = await resolveOwnerGate(adapter2, config2);
10223
10297
  if (gate.enforced && !gate.callerUserId) {
@@ -10229,7 +10303,7 @@ async function resolveOwnerGate(adapter2, config2) {
10229
10303
  if (adapterSupports(adapter2, "getOwnerIdentity")) {
10230
10304
  try {
10231
10305
  const identity = await adapter2.getOwnerIdentity();
10232
- const callerUserId = identity.callerUserId ?? config2.userId ?? null;
10306
+ const callerUserId = identity.callerUserId ?? null;
10233
10307
  return {
10234
10308
  enforced: true,
10235
10309
  callerIsOwner: isProjectOwner(callerUserId, identity.ownerUserId),
@@ -10241,7 +10315,7 @@ async function resolveOwnerGate(adapter2, config2) {
10241
10315
  return {
10242
10316
  enforced: true,
10243
10317
  callerIsOwner: false,
10244
- callerUserId: config2.userId ?? null,
10318
+ callerUserId: null,
10245
10319
  ownerUserId: null,
10246
10320
  resolutionError: err instanceof Error ? err.message : String(err),
10247
10321
  transport: "proxy"
@@ -12193,11 +12267,11 @@ ${cleanContent}`;
12193
12267
  if (!task.buildHandoff) {
12194
12268
  await adapter2.updateTask?.(taskId, {
12195
12269
  buildHandoff: h.buildHandoff,
12196
- cycle: cycleNumber
12270
+ cycle: newCycleNumber
12197
12271
  });
12198
12272
  repairedHandoffs.push(taskId);
12199
- } else if (task.cycle !== cycleNumber) {
12200
- await adapter2.updateTask?.(taskId, { cycle: cycleNumber });
12273
+ } else if (task.cycle !== newCycleNumber) {
12274
+ await adapter2.updateTask?.(taskId, { cycle: newCycleNumber });
12201
12275
  repairedCycles.push(taskId);
12202
12276
  }
12203
12277
  } catch {
@@ -20948,6 +21022,27 @@ async function resolveCycleToClose(adapter2, version, callerUserId) {
20948
21022
  }
20949
21023
  return inferCycleFromVersion(version);
20950
21024
  }
21025
+ async function findUnreachableDoneTaskCommits(config2, adapter2, cycleNumber, baseBranch) {
21026
+ if (cycleNumber <= 0 || !adapter2.getBuildReportsSince) return { unreachable: [] };
21027
+ const [tasks, reports] = await Promise.all([
21028
+ adapter2.queryBoard({ cycleSince: cycleNumber, compact: true }),
21029
+ adapter2.getBuildReportsSince(cycleNumber)
21030
+ ]);
21031
+ const doneTasks = tasks.filter((task) => task.cycle === cycleNumber && task.status === "Done");
21032
+ const cycleReports = reports.filter((report) => report.cycle === cycleNumber && report.completed === "Yes");
21033
+ const fetch2 = fetchBaseBranch(config2.projectRoot, baseBranch);
21034
+ const unreachable = doneTasks.flatMap((task) => {
21035
+ const report = cycleReports.filter((candidate) => candidate.taskId === task.id || candidate.taskId === task.displayId || candidate.displayId === task.displayId).filter((candidate) => Boolean(candidate.commitSha?.trim())).sort((a, b2) => {
21036
+ const aDate = a.createdAt ?? a.date;
21037
+ const bDate = b2.createdAt ?? b2.date;
21038
+ return aDate.localeCompare(bDate);
21039
+ }).at(-1);
21040
+ const commitSha = report?.commitSha?.trim();
21041
+ if (!commitSha || isCommitReachable(config2.projectRoot, commitSha, fetch2.compareRef)) return [];
21042
+ return [{ taskId: task.displayId, commitSha }];
21043
+ });
21044
+ return { unreachable, fetchWarning: fetch2.warning };
21045
+ }
20951
21046
  async function closeCycleState(config2, adapter2, version, cycleNum, options) {
20952
21047
  const warnings = [];
20953
21048
  const force = options?.force ?? false;
@@ -21143,7 +21238,7 @@ async function contributorAutoPrRelease(config2, adapter2, version, productionBa
21143
21238
  const title = `Release ${version}: ${branch}`;
21144
21239
  const body = `Contributor release PR for cycle ${cycleNum || "?"} (\`${branch}\`).
21145
21240
 
21146
- Opened by a non-owner editor via \`release\` (task-2244). The project owner reviews and merges this into \`${productionBaseBranch}\`; the contributor's own cycle is already marked complete in PAPI.`;
21241
+ Opened by a non-owner release-capable member via \`release\` (task-2244). The project owner reviews and merges this into \`${productionBaseBranch}\`; the contributor's own cycle is already marked complete in PAPI.`;
21147
21242
  const created = createPullRequest(config2.projectRoot, branch, productionBaseBranch, title, body);
21148
21243
  prs.push(
21149
21244
  created.success ? { branch, url: created.message.trim() || getPullRequestUrl(config2.projectRoot, branch) } : { branch, url: null, error: created.message }
@@ -21244,6 +21339,25 @@ async function createRelease(config2, branch, version, adapter2, cycleNum, optio
21244
21339
  }
21245
21340
  }
21246
21341
  }
21342
+ if (adapter2 && resolvedCycleNum > 0) {
21343
+ const resolvedBase = resolveBaseBranch(config2.projectRoot, branch);
21344
+ const { unreachable, fetchWarning } = await findUnreachableDoneTaskCommits(
21345
+ config2,
21346
+ adapter2,
21347
+ resolvedCycleNum,
21348
+ resolvedBase
21349
+ );
21350
+ if (fetchWarning) warnings.push(fetchWarning);
21351
+ if (unreachable.length > 0) {
21352
+ const details = unreachable.map(({ taskId, commitSha }) => ` - ${taskId}: ${commitSha}`).join("\n");
21353
+ throw new Error(
21354
+ `Release blocked \u2014 ${unreachable.length} Done task implementation commit(s) are not reachable from ${resolvedBase}:
21355
+ ${details}
21356
+
21357
+ Merge the recorded commit into the release branch, or correct the build receipt before retrying release.`
21358
+ );
21359
+ }
21360
+ }
21247
21361
  if (adapter2 && resolvedCycleNum > 0) {
21248
21362
  try {
21249
21363
  const stampedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -21589,6 +21703,7 @@ function sanitiseBranchSuffix(branch) {
21589
21703
  return branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
21590
21704
  }
21591
21705
  var CYCLE_UPDATES_CHANNEL_ENV = "DISCORD_CYCLE_UPDATES_CHANNEL_ID";
21706
+ var RELEASE_CAPABLE_ROLES = /* @__PURE__ */ new Set(["editor", "release_manager"]);
21592
21707
  function buildCycleUpdateCurationDirective(version, cycleClosed, projectChannelId) {
21593
21708
  const channelId = projectChannelId?.trim() || process.env[CYCLE_UPDATES_CHANNEL_ENV]?.trim();
21594
21709
  if (!channelId) return null;
@@ -21772,9 +21887,10 @@ async function handleRelease(adapter2, config2, args, clientName) {
21772
21887
  const editorsReleaseLikeOwner = isCapabilityEnabled(releaseCaps, "editorRelease");
21773
21888
  const cycleGate = editorsReleaseLikeOwner ? await resolveCycleGate(adapter2, gate) : { allowed: gate.callerIsOwner, role: gate.callerIsOwner ? "owner" : null };
21774
21889
  if (gate.enforced && !cycleGate.allowed) {
21775
- const resolutionNote = gate.resolutionError ? `
21890
+ const resolutionMessage = formatOwnerGateResolutionError(gate);
21891
+ const resolutionNote = resolutionMessage ? `
21776
21892
 
21777
- Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.` : "";
21893
+ ${resolutionMessage}` : "";
21778
21894
  if (gate.transport === "pg" && gate.callerUserId === null) {
21779
21895
  return errorResponse(
21780
21896
  `Release needs to know who you are, and this setup has no PAPI_USER_ID yet.
@@ -21788,17 +21904,18 @@ Then reconnect your AI tool and run release again. (Direct/pg setups read identi
21788
21904
  }
21789
21905
  tracker.mark("contributor-role-gate");
21790
21906
  const callerRole = adapter2.getContributorRole && gate.callerUserId ? await adapter2.getContributorRole(gate.callerUserId).catch(() => null) : null;
21791
- if (callerRole !== "editor") {
21907
+ if (!RELEASE_CAPABLE_ROLES.has(callerRole ?? "")) {
21792
21908
  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.`;
21793
- 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;
21909
+ const roleNote = callerRole === "viewer" ? `Your role on this project is "viewer", which cannot release. Ask the owner (or an editor or release manager) to release, or ask for release access.` : `Your identity does not match this project's owner, and you do not have an editor or release_manager role to open a release PR. If you are a contributor, ask the owner for editor or release_manager access (or push your branch and open a PR manually). ` + ownerHint;
21794
21910
  return errorResponse(
21795
- `Release to ${productionBaseBranch} needs an owner or editor role.
21911
+ `Release to ${productionBaseBranch} needs an owner, editor, or release_manager role.
21796
21912
 
21797
21913
  ` + roleNote + `
21798
21914
 
21799
21915
  Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
21800
21916
  );
21801
21917
  }
21918
+ const releaseRole = callerRole === "release_manager" ? "release manager" : "editor";
21802
21919
  tracker.mark("contributor-pr-reconciliation");
21803
21920
  try {
21804
21921
  const reconciled = await reconcileContributorReleasePrs(config2, adapter2, gate.callerUserId, cycleToClose);
@@ -21833,7 +21950,7 @@ ${buildHostedGitDirective({
21833
21950
  return textResponse(
21834
21951
  `## Release ${version} \u2014 contributor PR merged
21835
21952
 
21836
- 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.
21953
+ PAPI detected that ${latest.prUrl} was merged on GitHub and reconciled the contributor release. GitHub granted the merge permission; your PAPI **${releaseRole}** role granted the release workflow.
21837
21954
 
21838
21955
  ${latest.cycle ? `Cycle ${latest.cycle}` : "The contributor cycle"} remains **complete**, and the release is now recorded as **released** in PAPI.` + localHandoff + `
21839
21956
 
@@ -21847,7 +21964,7 @@ Next: run \`plan\` to start your next cycle.`
21847
21964
 
21848
21965
  ${prLines}
21849
21966
 
21850
- 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.
21967
+ Your PAPI **${releaseRole}** 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.
21851
21968
 
21852
21969
  After it is merged, run \`release\` again so PAPI can detect the merge and finish the release record.`
21853
21970
  );
@@ -21857,7 +21974,7 @@ After it is merged, run \`release\` again so PAPI can detect the merge and finis
21857
21974
  }
21858
21975
  if (isHostedTransport()) {
21859
21976
  return errorResponse(
21860
- `Release blocked \u2014 your PAPI editor role is valid, but this hosted connection cannot access your local checkout or GitHub CLI to open the release PR. Run release from a workspace-connected MCP client for this repository, or ask the project owner to release it.`
21977
+ `Release blocked \u2014 your PAPI ${releaseRole} role is valid, but this hosted connection cannot access your local checkout or GitHub CLI to open the release PR. Run release from a workspace-connected MCP client for this repository, or ask the project owner to release it.`
21861
21978
  );
21862
21979
  }
21863
21980
  tracker.mark("contributor-auto-pr");
@@ -21877,7 +21994,7 @@ After it is merged, run \`release\` again so PAPI can detect the merge and finis
21877
21994
  return textResponse(
21878
21995
  `## Release ${version} \u2014 contributor PR opened
21879
21996
 
21880
- 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.
21997
+ You released as a **${releaseRole}**, 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.
21881
21998
 
21882
21999
  **Pull request(s):**
21883
22000
  ${prLines.join("\n")}
@@ -22929,13 +23046,18 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
22929
23046
  if (!task) {
22930
23047
  throw new Error(`Task "${taskId}" not found on the Cycle Board.`);
22931
23048
  }
23049
+ const branchLines = [];
23050
+ const gate = await resolveOwnerGate(adapter2, config2);
22932
23051
  if (task.assigneeId) {
22933
- const gate = await resolveOwnerGate(adapter2, config2);
22934
23052
  if (!gate.callerUserId || gate.callerUserId !== task.assigneeId) {
22935
23053
  throw new Error(
22936
23054
  `Task "${taskId}" (${task.title}) is claimed by another member \u2014 only its assignee can build it. Have the claimer build it, or run \`task_unclaim\` to release it first.`
22937
23055
  );
22938
23056
  }
23057
+ } else if (gate.enforced) {
23058
+ branchLines.push(
23059
+ `\u2139\uFE0F **${taskId}** has no assignee \u2014 this is an unclaimed pool task, buildable by anyone. Run \`task_claim\` first for exclusive ownership while you build it. (Not blocking.)`
23060
+ );
22939
23061
  }
22940
23062
  if (task.status === "Done" || task.status === "Archived") {
22941
23063
  throw new Error(`Task "${taskId}" (${task.title}) is already ${task.status}. Cannot execute a completed task.`);
@@ -22973,7 +23095,6 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
22973
23095
  err.unresolvedDeps = unresolvedDeps;
22974
23096
  throw err;
22975
23097
  }
22976
- const branchLines = [];
22977
23098
  const startCaps = adapter2.getProjectInfo ? (await adapter2.getProjectInfo().catch(() => null))?.capabilities ?? {} : {};
22978
23099
  const autoBranchEnabled = isCapabilityEnabled(startCaps, "autoBranch");
22979
23100
  if (options.light) {
@@ -27490,11 +27611,17 @@ async function recordAdHoc(adapter2, input) {
27490
27611
  const promoted = targetCycle !== null;
27491
27612
  const landInReview = held || promoted && input.stage === "release";
27492
27613
  let task;
27614
+ const warnings = [];
27493
27615
  if (input.taskId) {
27494
27616
  const existing = await adapter2.getTask(input.taskId);
27495
27617
  if (!existing) {
27496
27618
  throw new Error(`Task "${input.taskId}" not found on the board. Check the task ID and try again.`);
27497
27619
  }
27620
+ if (!existing.assigneeId) {
27621
+ warnings.push(
27622
+ `\u2139\uFE0F ${input.taskId} has no assignee \u2014 recording ad-hoc work against an unclaimed pool task. If someone else might be building it too, check for an open PR/branch on the same files before merging.`
27623
+ );
27624
+ }
27498
27625
  const updatePayload = {
27499
27626
  notes: existing.notes ? `${existing.notes}
27500
27627
  [ad-hoc] ${input.notes || "Work recorded via ad_hoc"}` : `[ad-hoc] ${input.notes || "Work recorded via ad_hoc"}`
@@ -27565,7 +27692,7 @@ async function recordAdHoc(adapter2, input) {
27565
27692
  scopeAccuracy: "accurate"
27566
27693
  };
27567
27694
  await adapter2.appendBuildReport(report);
27568
- return { task, report };
27695
+ return { task, report, warnings: warnings.length > 0 ? warnings : void 0 };
27569
27696
  }
27570
27697
 
27571
27698
  // src/tools/ad-hoc.ts
@@ -27776,6 +27903,7 @@ async function handleAdHoc(adapter2, config2, args) {
27776
27903
  } catch {
27777
27904
  }
27778
27905
  }
27906
+ const warningsNote = result.warnings?.length ? "\n\n" + result.warnings.map((w) => `> ${w}`).join("\n") : "";
27779
27907
  const truncateWarning = notesTruncated ? ` (notes truncated to ${MAX_NOTES_LENGTH} chars)` : "";
27780
27908
  const taskModule = result.task.module || "Core";
27781
27909
  const typeLabel = result.task.taskType || typeRaw;
@@ -27808,7 +27936,7 @@ async function handleAdHoc(adapter2, config2, args) {
27808
27936
  } catch {
27809
27937
  }
27810
27938
  return textResponse(
27811
- `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
27939
+ `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.` + warningsNote + `
27812
27940
 
27813
27941
  ## Held for the next cycle \u2014 branch + commit, do NOT merge
27814
27942
  The task is recorded **In Review** and pinned to **Cycle ${result.task.cycle}**, so the planner won't re-plan it and it rides that cycle's review \u2192 release bundled with planned work.
@@ -27826,7 +27954,7 @@ _To correct: board_edit ${result.task.id} with updated fields._` + await recordA
27826
27954
  );
27827
27955
  }
27828
27956
  return textResponse(
27829
- `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
27957
+ `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.` + warningsNote + `
27830
27958
  _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)
27831
27959
  );
27832
27960
  }
@@ -29226,14 +29354,14 @@ ${overlap}`;
29226
29354
  const roleGate = await resolveCycleGate(adapter2, gate);
29227
29355
  const callerMayRelease = editorReleaseEnabled ? roleGate.allowed : !gate.enforced || gate.callerIsOwner;
29228
29356
  if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done") && !callerMayRelease) {
29229
- const why = roleGate.role === "viewer" ? `your role on this project is "viewer"` : roleGate.role === "editor" && !editorReleaseEnabled ? `your "editor" role is valid, but this project keeps auto-release owner-only because "Editors release like the owner" is off` : `your role on this project could not be confirmed`;
29357
+ const why = roleGate.role === "viewer" ? `your role on this project is "viewer"` : (roleGate.role === "editor" || roleGate.role === "release_manager") && !editorReleaseEnabled ? `your "${roleGate.role}" role is valid, but this project keeps auto-release owner-only because "Editors release like the owner" is off` : `your role on this project could not be confirmed`;
29230
29358
  const resolutionNote = roleGate.resolutionError ? ` (Role resolution failed: ${roleGate.resolutionError} \u2014 the gate fails closed.)` : "";
29231
- const nextStep = roleGate.role === "editor" && !editorReleaseEnabled ? `Run \`release\` explicitly to open your contributor PR.` : `Push your branch and open a PR, or ask for editor access.`;
29359
+ const nextStep = (roleGate.role === "editor" || roleGate.role === "release_manager") && !editorReleaseEnabled ? `Run \`release\` explicitly to open your contributor PR.` : `Push your branch and open a PR, or ask for editor or release manager access.`;
29232
29360
  autoReleaseNote = `
29233
29361
 
29234
29362
  ---
29235
29363
 
29236
- \u2705 Verdict recorded. All cycle tasks are Done, but no release was cut \u2014 **auto-release needs an owner or editor role**, and ${why}. ${nextStep}${resolutionNote}`;
29364
+ \u2705 Verdict recorded. All cycle tasks are Done, but no release was cut \u2014 **auto-release needs an owner, editor, or release manager role**, and ${why}. ${nextStep}${resolutionNote}`;
29237
29365
  } else if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done")) {
29238
29366
  let planRunCount = null;
29239
29367
  if (adapterSupports(adapter2, "countPlanRunsForCycle")) {
@@ -31347,6 +31475,7 @@ async function getHierarchyPosition(adapter2, projectId) {
31347
31475
  }
31348
31476
  }
31349
31477
  var GIT_TAG_TIMEOUT_MS = 2e3;
31478
+ var ORIGIN_FETCH_TIMEOUT_MS = 3e3;
31350
31479
  async function checkNpmVersionDrift() {
31351
31480
  try {
31352
31481
  const pkgPath = join18(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
@@ -31563,6 +31692,17 @@ async function computePendingDecisionsWarning(adapter2, callerUserId, decisions)
31563
31692
  if (awaiting === 0) return void 0;
31564
31693
  return `\u{1F4CB} ${awaiting} decision${awaiting === 1 ? "" : "s"} awaiting your position \u2014 run \`ad_view\` then \`decision_resolve\` to agree/object/abstain.`;
31565
31694
  }
31695
+ function formatOriginDivergenceWarning(d) {
31696
+ if (d.behind != null && d.behind > 0) {
31697
+ const aheadNote = d.ahead && d.ahead > 0 ? ` (and ${d.ahead} ahead)` : "";
31698
+ return `\u26A0\uFE0F This checkout is ${d.behind} commit${d.behind === 1 ? "" : "s"} behind \`origin/${d.baseBranch}\`${aheadNote} \u2014 pull before trusting "Done"/merged state here.`;
31699
+ }
31700
+ if (d.warning) return `\u2139\uFE0F ${d.warning}`;
31701
+ if (d.fetched && d.behind == null) {
31702
+ return `\u2139\uFE0F Could not determine how far this checkout is behind \`origin/${d.baseBranch}\` (e.g. a shallow clone) \u2014 treat "Done"/merged state here as unverified.`;
31703
+ }
31704
+ return void 0;
31705
+ }
31566
31706
  async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVersion = "unknown") {
31567
31707
  let resolvedAdapter = rawAdapter;
31568
31708
  let projectOverrideNote = "";
@@ -31659,6 +31799,7 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
31659
31799
  ttfvOutcome,
31660
31800
  latestTagOutcome,
31661
31801
  versionDriftOutcome,
31802
+ originDivergenceOutcome,
31662
31803
  researchSignalsOutcome,
31663
31804
  recsOutcome,
31664
31805
  pendingReviewOutcome,
@@ -31813,6 +31954,18 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
31813
31954
  // version-drift is enrichment, gated behind `full`/deep_housekeeping.
31814
31955
  tracked("git-tag", async () => getLatestTag(config2.projectRoot, GIT_TAG_TIMEOUT_MS)),
31815
31956
  tracked("npm-version-drift", async () => fullEnrichment ? checkNpmVersionDrift() : void 0),
31957
+ // Nova/vibe-tycoon feedback (task-XXXX): how far is this checkout
31958
+ // actually behind origin/<base>? Deliberately UNGATED by full/
31959
+ // deep_housekeeping — runs on every orient call by default, because the
31960
+ // fetch is bounded (ORIGIN_FETCH_TIMEOUT_MS) and fails soft. No remote
31961
+ // configured is the common no-op case: zero network I/O, zero latency.
31962
+ // Escape hatch mirrors PAPI_ORIENT_FANOUT_CONCURRENCY's env-var pattern,
31963
+ // default ON (opposite polarity from deep_housekeeping's opt-in — this
31964
+ // one must not require opting in).
31965
+ tracked("origin-divergence", async () => {
31966
+ if (!hasLocalWorkspace() || process.env.PAPI_ORIENT_ORIGIN_CHECK === "false") return void 0;
31967
+ return getBaseDivergence(config2.projectRoot, config2.baseBranch, { timeoutMs: ORIGIN_FETCH_TIMEOUT_MS });
31968
+ }),
31816
31969
  // Research Signals — research docs with pending actions since last strategy review.
31817
31970
  // task-2172: heavy (doc search + AD cross-reference) and rarely actioned
31818
31971
  // mid-session — gated behind `full`/deep_housekeeping to keep the default lean.
@@ -32010,6 +32163,9 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
32010
32163
  if (proxyWarning) buildResult.warnings.push(proxyWarning);
32011
32164
  const p1Warning = p1BacklogOutcome.status === "fulfilled" ? p1BacklogOutcome.value : void 0;
32012
32165
  if (p1Warning) buildResult.warnings.push(p1Warning);
32166
+ const originDivergence = originDivergenceOutcome.status === "fulfilled" ? originDivergenceOutcome.value : void 0;
32167
+ const originDivergenceWarning = originDivergence ? formatOriginDivergenceWarning(originDivergence) : void 0;
32168
+ if (originDivergenceWarning) buildResult.warnings.push(originDivergenceWarning);
32013
32169
  const pendingDecisionsWarning = pendingDecisionsOutcome.status === "fulfilled" ? pendingDecisionsOutcome.value : void 0;
32014
32170
  if (pendingDecisionsWarning) buildResult.warnings.push(pendingDecisionsWarning);
32015
32171
  const ownerActionsWarning = ownerActionsOutcome.status === "fulfilled" ? ownerActionsOutcome.value : void 0;
@@ -33350,7 +33506,7 @@ async function resolveDecisionAuthority(adapter2, gate, decisionType) {
33350
33506
  allowed: isQuorumEligible,
33351
33507
  role,
33352
33508
  isOwnerOverride: false,
33353
- ...isQuorumEligible ? {} : { resolutionError: "only owner/editor members participate in group resolution" }
33509
+ ...isQuorumEligible ? {} : { resolutionError: "only owner/release_manager/editor members participate in group resolution" }
33354
33510
  };
33355
33511
  }
33356
33512
  function normaliseMode(value) {
@@ -33398,7 +33554,7 @@ var decisionResolveTool = {
33398
33554
  action: {
33399
33555
  type: "string",
33400
33556
  enum: ["agree", "object", "abstain", "resolve", "withdraw"],
33401
- description: 'agree/object/abstain cast your own position on a proposed decision. resolve/withdraw transition the decision itself and require authority \u2014 owner-only in "owner" mode, the configured resolver (or owner override) in "per_type" mode, or full owner/editor quorum (or an owner force-resolve) in "group" mode.'
33557
+ description: 'agree/object/abstain cast your own position on a proposed decision. resolve/withdraw transition the decision itself and require authority \u2014 owner-only in "owner" mode, the configured resolver (or owner override) in "per_type" mode, or full owner/release_manager/editor quorum (or an owner force-resolve) in "group" mode.'
33402
33558
  },
33403
33559
  comment: { type: "string", description: "Optional note recorded alongside your position or transition." }
33404
33560
  },
@@ -33475,7 +33631,7 @@ async function handleDecisionResolve(adapter2, config2, args) {
33475
33631
  positions.map((p) => ({ userId: p.userId, status: p.status })),
33476
33632
  quorumMembersFrom(contributors)
33477
33633
  );
33478
- quorumNote = quorum.met ? '\n\nEvery owner/editor member has now agreed or abstained \u2014 this decision is ready to `decision_resolve action="resolve"`.' : `
33634
+ quorumNote = quorum.met ? '\n\nEvery owner/release_manager/editor member has now agreed or abstained \u2014 this decision is ready to `decision_resolve action="resolve"`.' : `
33479
33635
 
33480
33636
  Still waiting on: ${quorum.missing.length > 0 ? quorum.missing.join(", ") : "nobody"}${quorum.objected.length > 0 ? `; objected: ${quorum.objected.join(", ")}` : ""}.`;
33481
33637
  } catch {
@@ -34027,7 +34183,7 @@ Mapping after: ${mappingAfter}`
34027
34183
  init_dist();
34028
34184
  var contributorAddTool = {
34029
34185
  name: "contributor_add",
34030
- 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.`,
34186
+ 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="release_manager" for a teammate who owns approved releases, or role="editor" for a working teammate who needs to run the full cycle \u2014 the default, "viewer", is read-only. Calling it again with a different role promotes or demotes that member.`,
34031
34187
  annotations: { title: "Add Contributor", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
34032
34188
  inputSchema: {
34033
34189
  type: "object",
@@ -34036,7 +34192,7 @@ var contributorAddTool = {
34036
34192
  role: {
34037
34193
  type: "string",
34038
34194
  enum: [...ASSIGNABLE_CONTRIBUTOR_ROLES],
34039
- 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.`
34195
+ description: `Membership role. "release_manager" can run the build/review cycle and carry approved releases through the release path; "editor" can run the full build/review/release cycle; "viewer" has read-only visibility. 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.`
34040
34196
  },
34041
34197
  project: {
34042
34198
  type: "string",
@@ -34084,7 +34240,8 @@ async function denyUnlessOwner(adapter2, config2) {
34084
34240
  if (capDenied) return capDenied;
34085
34241
  const gate = await resolveOwnerGate(adapter2, config2);
34086
34242
  if (gate.enforced && !gate.callerIsOwner) {
34087
- const note = gate.resolutionError ? ` (Identity resolution failed: ${gate.resolutionError} \u2014 the gate fails closed; retry once connectivity is restored.)` : "";
34243
+ const resolutionMessage = formatOwnerGateResolutionError(gate);
34244
+ const note = resolutionMessage ? ` (${resolutionMessage})` : "";
34088
34245
  return `Contributor management is restricted to the project owner. Your identity does not match this project's owner.${note}`;
34089
34246
  }
34090
34247
  return null;
@@ -34097,7 +34254,8 @@ async function denyUnlessMember(adapter2, config2) {
34097
34254
  if (gate.callerIsOwner) return null;
34098
34255
  const callerRole = gate.callerUserId && adapterSupports(adapter2, "getContributorRole") ? await adapter2.getContributorRole(gate.callerUserId).catch(() => null) : null;
34099
34256
  if (callerRole) return null;
34100
- const note = gate.resolutionError ? ` (Identity resolution failed: ${gate.resolutionError} \u2014 the gate fails closed; retry once connectivity is restored.)` : "";
34257
+ const resolutionMessage = formatOwnerGateResolutionError(gate);
34258
+ const note = resolutionMessage ? ` (${resolutionMessage})` : "";
34101
34259
  return `Listing contributors is restricted to project members. Your identity does not match this project's owner or any contributor.${note}`;
34102
34260
  }
34103
34261
  var EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -34132,7 +34290,7 @@ async function handleContributorAdd(adapter2, config2, args) {
34132
34290
 
34133
34291
  ${upsell}` : "";
34134
34292
  const where = overrideNote ? ` ${overrideNote}` : " on this project";
34135
- 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.`;
34293
+ const roleLine = entry.role === "release_manager" ? `They can run the build/review cycle and carry approved releases through the release path.` : 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" or role="release_manager" if they need to work on the project.`;
34136
34294
  return textResponse(
34137
34295
  `\u2705 **${entry.email ?? email}**${name} is now a **${entry.role}**${where}.
34138
34296
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.98",
3
+ "version": "0.7.103",
4
4
  "description": "PAPI MCP server \u2014 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",