@papi-ai/server 0.7.98 → 0.7.104

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
@@ -401,6 +401,7 @@ function parseBuildHandoff(markdown) {
401
401
  taskTitle,
402
402
  cycle,
403
403
  whyNow,
404
+ relevantDecisions: parseBulletsOnly(sections.get("RELEVANT ACTIVE DECISIONS") ?? ""),
404
405
  scope: parseBulletList(sections.get("SCOPE (DO THIS)") ?? ""),
405
406
  scopeBoundary: parseBulletList(sections.get("SCOPE BOUNDARY (DO NOT DO THIS)") ?? ""),
406
407
  acceptanceCriteria: parseChecklist(sections.get("ACCEPTANCE CRITERIA") ?? ""),
@@ -424,6 +425,7 @@ function coerceBuildHandoff(fields, taskId) {
424
425
  taskTitle: str(fields.taskTitle),
425
426
  cycle: typeof fields.cycle === "number" ? fields.cycle : 0,
426
427
  whyNow: str(fields.whyNow),
428
+ relevantDecisions: ensureArray(fields.relevantDecisions),
427
429
  scope,
428
430
  scopeBoundary,
429
431
  // task-3223: the structured path is where an object criterion (text + an
@@ -471,6 +473,14 @@ function serializeBuildHandoff(raw) {
471
473
  lines.push(`Task: ${handoff.taskTitle}`);
472
474
  lines.push(`Cycle: ${handoff.cycle}`);
473
475
  lines.push(`Why now: ${handoff.whyNow}`);
476
+ const relevantDecisions = ensureArray(handoff.relevantDecisions);
477
+ if (relevantDecisions.length > 0) {
478
+ lines.push("");
479
+ lines.push("RELEVANT ACTIVE DECISIONS");
480
+ for (const item of relevantDecisions) {
481
+ lines.push(`- ${item}`);
482
+ }
483
+ }
474
484
  lines.push("");
475
485
  lines.push("SCOPE (DO THIS)");
476
486
  for (const item of ensureArray(handoff.scope)) {
@@ -850,7 +860,7 @@ var init_dist = __esm({
850
860
  { 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
861
  ];
852
862
  CAPABILITY_KEYS = CAPABILITY_REGISTRY.map((c) => c.key);
853
- ASSIGNABLE_CONTRIBUTOR_ROLES = ["editor", "viewer"];
863
+ ASSIGNABLE_CONTRIBUTOR_ROLES = ["release_manager", "editor", "viewer"];
854
864
  SENSITIVE_CHANGELOG_PATTERNS = [
855
865
  // ── Test-user machinery ────────────────────────────────────────────────
856
866
  /@test\.papi\.dev/i,
@@ -912,6 +922,7 @@ var init_dist = __esm({
912
922
  CHECK_VALUE_MAX = 200;
913
923
  VALID_EFFORT_SIZES = /* @__PURE__ */ new Set(["XS", "S", "M", "L", "XL"]);
914
924
  SECTION_HEADERS = [
925
+ "RELEVANT ACTIVE DECISIONS",
915
926
  "SCOPE (DO THIS)",
916
927
  "WHY NOT SIMPLER",
917
928
  "SCOPE BOUNDARY (DO NOT DO THIS)",
@@ -948,6 +959,7 @@ __export(git_exports, {
948
959
  checkoutBranch: () => checkoutBranch,
949
960
  commitSinglePath: () => commitSinglePath,
950
961
  commitStagedOnly: () => commitStagedOnly,
962
+ computeAheadBehind: () => computeAheadBehind,
951
963
  createAndCheckoutBranch: () => createAndCheckoutBranch,
952
964
  createPullRequest: () => createPullRequest,
953
965
  createTag: () => createTag,
@@ -958,8 +970,10 @@ __export(git_exports, {
958
970
  detectUnrecordedCommits: () => detectUnrecordedCommits,
959
971
  ensureLatestDevelop: () => ensureLatestDevelop,
960
972
  ensureTagAtHead: () => ensureTagAtHead,
973
+ fetchBaseBranch: () => fetchBaseBranch,
961
974
  findContributorReleasePullRequests: () => findContributorReleasePullRequests,
962
975
  findTaskCommitsOnBase: () => findTaskCommitsOnBase,
976
+ getBaseDivergence: () => getBaseDivergence,
963
977
  getBranchDiff: () => getBranchDiff,
964
978
  getCommitFiles: () => getCommitFiles,
965
979
  getCommitsSinceTag: () => getCommitsSinceTag,
@@ -994,6 +1008,7 @@ __export(git_exports, {
994
1008
  hasUnpushedCommits: () => hasUnpushedCommits,
995
1009
  isBranchContentAlreadyInBase: () => isBranchContentAlreadyInBase,
996
1010
  isBranchMergedInto: () => isBranchMergedInto,
1011
+ isCommitReachable: () => isCommitReachable,
997
1012
  isGhAvailable: () => isGhAvailable,
998
1013
  isGitAvailable: () => isGitAvailable,
999
1014
  isGitRepo: () => isGitRepo,
@@ -2056,6 +2071,19 @@ function isBranchMergedInto(cwd, branch, baseBranch) {
2056
2071
  return false;
2057
2072
  }
2058
2073
  }
2074
+ function isCommitReachable(cwd, commit, baseBranch) {
2075
+ const recordedCommit = commit.trim();
2076
+ if (!recordedCommit) return false;
2077
+ try {
2078
+ execFileSync("git", ["merge-base", "--is-ancestor", recordedCommit, baseBranch], {
2079
+ cwd,
2080
+ stdio: "ignore"
2081
+ });
2082
+ return true;
2083
+ } catch {
2084
+ return false;
2085
+ }
2086
+ }
2059
2087
  function isBranchContentAlreadyInBase(cwd, branch, baseBranch) {
2060
2088
  const resolveCommit = (ref) => {
2061
2089
  try {
@@ -2082,6 +2110,55 @@ function isBranchContentAlreadyInBase(cwd, branch, baseBranch) {
2082
2110
  return false;
2083
2111
  }
2084
2112
  }
2113
+ function fetchBaseBranch(cwd, baseBranch, timeoutMs = GIT_FETCH_TIMEOUT_MS) {
2114
+ if (!isGitAvailable() || !isGitRepo(cwd) || !hasRemote(cwd)) {
2115
+ return { fetched: false, compareRef: baseBranch };
2116
+ }
2117
+ const result = spawnSync("git", ["fetch", "--quiet", "origin", baseBranch], {
2118
+ cwd,
2119
+ encoding: "utf-8",
2120
+ timeout: timeoutMs
2121
+ });
2122
+ if (result.error || result.status !== 0) {
2123
+ const raw = result.error?.message ?? ((result.stderr || "").trim() || `git exited ${result.status}`);
2124
+ const isTimeout = result.signal === "SIGTERM" || /ETIMEDOUT/.test(raw);
2125
+ return {
2126
+ fetched: false,
2127
+ compareRef: baseBranch,
2128
+ 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})`
2129
+ };
2130
+ }
2131
+ return { fetched: true, compareRef: `origin/${baseBranch}` };
2132
+ }
2133
+ function computeAheadBehind(cwd, compareRef, ref = "HEAD") {
2134
+ try {
2135
+ const out = execFileSync(
2136
+ "git",
2137
+ ["rev-list", "--left-right", "--count", `${compareRef}...${ref}`],
2138
+ { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
2139
+ ).trim();
2140
+ const [behindStr, aheadStr] = out.split(/\s+/);
2141
+ const behind = parseInt(behindStr, 10);
2142
+ const ahead = parseInt(aheadStr, 10);
2143
+ if (Number.isNaN(behind) || Number.isNaN(ahead)) return null;
2144
+ return { ahead, behind };
2145
+ } catch {
2146
+ return null;
2147
+ }
2148
+ }
2149
+ function getBaseDivergence(cwd, preferredBase, opts) {
2150
+ const baseBranch = resolveBaseBranch(cwd, preferredBase);
2151
+ const fetch2 = fetchBaseBranch(cwd, baseBranch, opts?.timeoutMs);
2152
+ const divergence = computeAheadBehind(cwd, fetch2.compareRef, opts?.ref ?? "HEAD");
2153
+ return {
2154
+ baseBranch,
2155
+ compareRef: fetch2.compareRef,
2156
+ fetched: fetch2.fetched,
2157
+ ahead: divergence?.ahead ?? null,
2158
+ behind: divergence?.behind ?? null,
2159
+ warning: fetch2.warning
2160
+ };
2161
+ }
2085
2162
  function pickModuleCycleBranch(candidates, cycleNumber, module, memberSlug) {
2086
2163
  if (candidates.length === 0) return void 0;
2087
2164
  const expected = cycleBranchName(cycleNumber, module, memberSlug);
@@ -2186,7 +2263,7 @@ function getRemoteBranchFiles(cwd, branch, baseBranch) {
2186
2263
  return [];
2187
2264
  }
2188
2265
  }
2189
- var AUTO_WRITTEN_PATHS, GIT_NETWORK_TIMEOUT_MS, MERGE_RETRY_DELAY_MS, MERGE_MAX_RETRIES;
2266
+ var AUTO_WRITTEN_PATHS, GIT_NETWORK_TIMEOUT_MS, MERGE_RETRY_DELAY_MS, MERGE_MAX_RETRIES, GIT_FETCH_TIMEOUT_MS;
2190
2267
  var init_git = __esm({
2191
2268
  "src/lib/git.ts"() {
2192
2269
  "use strict";
@@ -2194,6 +2271,7 @@ var init_git = __esm({
2194
2271
  GIT_NETWORK_TIMEOUT_MS = 6e4;
2195
2272
  MERGE_RETRY_DELAY_MS = 2e3;
2196
2273
  MERGE_MAX_RETRIES = 3;
2274
+ GIT_FETCH_TIMEOUT_MS = 5e3;
2197
2275
  }
2198
2276
  });
2199
2277
 
@@ -7505,11 +7583,78 @@ init_dist();
7505
7583
 
7506
7584
  // src/lib/formatters.ts
7507
7585
  init_dist();
7586
+ var CONFIDENCE_RANK = { HIGH: 0, MEDIUM: 1, LOW: 2 };
7587
+ function rankDecisionsForPlan(decisions) {
7588
+ return [...decisions].sort((a, b2) => {
7589
+ const confDiff = (CONFIDENCE_RANK[a.confidence] ?? 1) - (CONFIDENCE_RANK[b2.confidence] ?? 1);
7590
+ if (confDiff !== 0) return confDiff;
7591
+ return (b2.modifiedCycle ?? b2.createdCycle ?? 0) - (a.modifiedCycle ?? a.createdCycle ?? 0);
7592
+ });
7593
+ }
7508
7594
  function formatActiveDecisionsForPlan(decisions) {
7509
7595
  if (decisions.length === 0) return "No active decisions.";
7510
- return decisions.filter((d) => !d.superseded).map((d) => `### ${d.id}: ${d.title} [Confidence: ${d.confidence}]
7596
+ const active = decisions.filter((d) => !d.superseded);
7597
+ if (active.length === 0) return "No active decisions.";
7598
+ const softBudget = Number(process.env.PAPI_AD_CONTEXT_BUDGET) || 4e4;
7599
+ const hardBudget = Math.round(softBudget * 1.3);
7600
+ const ranked = rankDecisionsForPlan(active);
7601
+ const fullBlocks = [];
7602
+ const oneLiners = [];
7603
+ let spent = 0;
7604
+ let degradedFrom = -1;
7605
+ for (let i = 0; i < ranked.length; i++) {
7606
+ const d = ranked[i];
7607
+ const fullBlock = `### ${d.id}: ${d.title} [Confidence: ${d.confidence}]
7608
+
7609
+ ${d.body}`;
7610
+ const fullCost = Buffer.byteLength(`${fullBlock}
7611
+
7612
+ `, "utf-8");
7613
+ if (spent + fullCost <= softBudget) {
7614
+ fullBlocks.push(fullBlock);
7615
+ spent += fullCost;
7616
+ continue;
7617
+ }
7618
+ degradedFrom = i;
7619
+ break;
7620
+ }
7621
+ const compactedIds = [];
7622
+ const omittedIds = [];
7623
+ if (degradedFrom >= 0) {
7624
+ for (let i = degradedFrom; i < ranked.length; i++) {
7625
+ const d = ranked[i];
7626
+ const line = `- ${d.id} (${d.title}) [${d.confidence}]`;
7627
+ const cost = Buffer.byteLength(`${line}
7628
+ `, "utf-8");
7629
+ if (spent + cost <= hardBudget) {
7630
+ oneLiners.push(line);
7631
+ compactedIds.push(d.id);
7632
+ spent += cost;
7633
+ } else {
7634
+ omittedIds.push(d.id);
7635
+ }
7636
+ }
7637
+ }
7638
+ const parts = [...fullBlocks];
7639
+ if (oneLiners.length > 0) {
7640
+ parts.push(`### Other Active Decisions (compacted for budget)
7511
7641
 
7512
- ${d.body}`).join("\n\n");
7642
+ ${oneLiners.join("\n")}`);
7643
+ }
7644
+ if (compactedIds.length > 0 || omittedIds.length > 0) {
7645
+ const noteLines = [
7646
+ `### Context Budget \u2014 Active Decisions trimmed`,
7647
+ "",
7648
+ `${compactedIds.length + omittedIds.length} of ${ranked.length} Active Decisions were compacted to one-liners to keep this plan payload under ~${Math.round(softBudget / 1024)} KB (lowest confidence/oldest first). Full bodies above are complete and untrimmed.`
7649
+ ];
7650
+ if (omittedIds.length > 0) {
7651
+ noteLines.push(
7652
+ `${omittedIds.length} AD one-liner(s) were additionally omitted from the list above for exceeding the hard budget, but are not forgotten: ${omittedIds.join(", ")}. Raise PAPI_AD_CONTEXT_BUDGET in the MCP server env to see their one-liners, or run ad_view for full detail on any specific one.`
7653
+ );
7654
+ }
7655
+ parts.push(noteLines.join("\n"));
7656
+ }
7657
+ return parts.join("\n\n");
7513
7658
  }
7514
7659
  function formatActiveDecisionsForReview(decisions) {
7515
7660
  if (decisions.length === 0) return "No active decisions.";
@@ -8314,6 +8459,9 @@ Why now: [justification]
8314
8459
  DEPENDS ON
8315
8460
  [Optional \u2014 comma-separated task IDs this task depends on (e.g. "task-123, task-124"). Include only when another task in this same cycle must be built first because this task consumes artifacts it creates (e.g. new adapter method, new type, new migration). The builder will reuse the upstream task's branch so dependent commits stack on the same branch for a single PR. Omit this section entirely if there are no intra-cycle dependencies.]
8316
8461
 
8462
+ RELEVANT ACTIVE DECISIONS
8463
+ [Optional \u2014 self-check which Active Decisions actually bind the FILES LIKELY TOUCHED and module below, out of the full AD list already in your context. List the top 3-6 as "AD-N (title) \u2014 one-line reason this AD binds the files/approach". Rank by blast radius (an AD that would be VIOLATED by a naive implementation outranks one that's merely thematically adjacent). This is a judgement call \u2014 no server-side scoring feeds this. Omit this section entirely when nothing in the AD list is genuinely relevant to what this task touches; do not force a match.]
8464
+
8317
8465
  SCOPE (DO THIS)
8318
8466
  [specific deliverables \u2014 write for the simplest viable path first]
8319
8467
 
@@ -9649,6 +9797,9 @@ Why now: [justification]
9649
9797
  DEPENDS ON
9650
9798
  [Optional \u2014 comma-separated task IDs this task depends on (e.g. "task-123, task-124"). Include only when another task in this same cycle must be built first because this task consumes artifacts it creates (e.g. new adapter method, new type, new migration). The builder will reuse the upstream task's branch so dependent commits stack on the same branch for a single PR. Omit this section entirely if there are no intra-cycle dependencies.]
9651
9799
 
9800
+ RELEVANT ACTIVE DECISIONS
9801
+ [Optional \u2014 self-check which Active Decisions actually bind the FILES LIKELY TOUCHED and module below, out of the full AD list already in your context. List the top 3-6 as "AD-N (title) \u2014 one-line reason this AD binds the files/approach". Rank by blast radius (an AD that would be VIOLATED by a naive implementation outranks one that's merely thematically adjacent). This is a judgement call \u2014 no server-side scoring feeds this. Omit this section entirely when nothing in the AD list is genuinely relevant to what this task touches; do not force a match.]
9802
+
9652
9803
  SCOPE (DO THIS)
9653
9804
  [specific deliverables \u2014 write for the simplest viable path first]
9654
9805
 
@@ -10193,7 +10344,7 @@ function isProjectOwner(callerUserId, ownerUserId) {
10193
10344
  if (caller.length === 0 || owner.length === 0) return false;
10194
10345
  return caller === owner;
10195
10346
  }
10196
- var CYCLE_ROLES = ["owner", "editor"];
10347
+ var CYCLE_ROLES = ["owner", "release_manager", "editor"];
10197
10348
  async function resolveCycleGate(adapter2, gate) {
10198
10349
  if (!gate.enforced) return { allowed: true, role: null };
10199
10350
  if (gate.callerIsOwner) return { allowed: true, role: "owner" };
@@ -10218,6 +10369,13 @@ async function resolveCycleGate(adapter2, gate) {
10218
10369
  };
10219
10370
  }
10220
10371
  }
10372
+ function formatOwnerGateResolutionError(gate) {
10373
+ if (!gate.resolutionError) return null;
10374
+ if (gate.transport === "proxy") {
10375
+ return "Hosted bearer identity could not be verified. Retry once connectivity is restored.";
10376
+ }
10377
+ return `Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.`;
10378
+ }
10221
10379
  async function resolveCallerUserId(adapter2, config2) {
10222
10380
  const gate = await resolveOwnerGate(adapter2, config2);
10223
10381
  if (gate.enforced && !gate.callerUserId) {
@@ -10229,7 +10387,7 @@ async function resolveOwnerGate(adapter2, config2) {
10229
10387
  if (adapterSupports(adapter2, "getOwnerIdentity")) {
10230
10388
  try {
10231
10389
  const identity = await adapter2.getOwnerIdentity();
10232
- const callerUserId = identity.callerUserId ?? config2.userId ?? null;
10390
+ const callerUserId = identity.callerUserId ?? null;
10233
10391
  return {
10234
10392
  enforced: true,
10235
10393
  callerIsOwner: isProjectOwner(callerUserId, identity.ownerUserId),
@@ -10241,7 +10399,7 @@ async function resolveOwnerGate(adapter2, config2) {
10241
10399
  return {
10242
10400
  enforced: true,
10243
10401
  callerIsOwner: false,
10244
- callerUserId: config2.userId ?? null,
10402
+ callerUserId: null,
10245
10403
  ownerUserId: null,
10246
10404
  resolutionError: err instanceof Error ? err.message : String(err),
10247
10405
  transport: "proxy"
@@ -10796,6 +10954,23 @@ function idMatches(candidate, ref) {
10796
10954
  if (!candidate) return false;
10797
10955
  return candidate.toLowerCase() === ref.toLowerCase();
10798
10956
  }
10957
+ function findDecision(ref, decisions) {
10958
+ return decisions.find((d) => idMatches(d.displayId, ref) || idMatches(d.id, ref));
10959
+ }
10960
+ function resolveCurrentDecision(ref, decisions, maxDepth = 10) {
10961
+ let current = findDecision(ref, decisions);
10962
+ if (!current) return void 0;
10963
+ const visited = /* @__PURE__ */ new Set([current.id]);
10964
+ let depth = 0;
10965
+ while (current?.superseded === true && current.supersededBy && depth < maxDepth) {
10966
+ const next = findDecision(current.supersededBy, decisions);
10967
+ if (!next || visited.has(next.id)) break;
10968
+ visited.add(next.id);
10969
+ current = next;
10970
+ depth += 1;
10971
+ }
10972
+ return current;
10973
+ }
10799
10974
  function isBlockerResolved(blocker, ctx) {
10800
10975
  if (!blocker || !blocker.ref) return false;
10801
10976
  switch (blocker.type) {
@@ -10810,9 +10985,7 @@ function isBlockerResolved(blocker, ctx) {
10810
10985
  return action != null && action.completed_at != null;
10811
10986
  }
10812
10987
  case "decision-gate": {
10813
- const decision = ctx.decisions.find(
10814
- (d) => idMatches(d.displayId, blocker.ref) || idMatches(d.id, blocker.ref)
10815
- );
10988
+ const decision = resolveCurrentDecision(blocker.ref, ctx.decisions);
10816
10989
  if (decision?.superseded === true) return true;
10817
10990
  const resolvedOutcomes = /* @__PURE__ */ new Set(["validated"]);
10818
10991
  if (decision?.outcome && resolvedOutcomes.has(decision.outcome)) return true;
@@ -10828,9 +11001,7 @@ function isBlockerResolved(blocker, ctx) {
10828
11001
  }
10829
11002
  function blockerNeedsRedecision(blocker, ctx) {
10830
11003
  if (!blocker || blocker.type !== "decision-gate" || !blocker.ref) return false;
10831
- const decision = ctx.decisions.find(
10832
- (d) => idMatches(d.displayId, blocker.ref) || idMatches(d.id, blocker.ref)
10833
- );
11004
+ const decision = resolveCurrentDecision(blocker.ref, ctx.decisions);
10834
11005
  return decision?.resolutionState === "withdrawn";
10835
11006
  }
10836
11007
  function formatBlockerWaiting(blocker, refTitle) {
@@ -10853,9 +11024,7 @@ function resolveBlockerTitle(blocker, ctx) {
10853
11024
  (t) => idMatches(t.displayId, blocker.ref) || idMatches(t.id, blocker.ref)
10854
11025
  )?.title;
10855
11026
  case "decision-gate":
10856
- return ctx.decisions.find(
10857
- (d) => idMatches(d.displayId, blocker.ref) || idMatches(d.id, blocker.ref)
10858
- )?.title;
11027
+ return resolveCurrentDecision(blocker.ref, ctx.decisions)?.title;
10859
11028
  default:
10860
11029
  return void 0;
10861
11030
  }
@@ -12193,11 +12362,11 @@ ${cleanContent}`;
12193
12362
  if (!task.buildHandoff) {
12194
12363
  await adapter2.updateTask?.(taskId, {
12195
12364
  buildHandoff: h.buildHandoff,
12196
- cycle: cycleNumber
12365
+ cycle: newCycleNumber
12197
12366
  });
12198
12367
  repairedHandoffs.push(taskId);
12199
- } else if (task.cycle !== cycleNumber) {
12200
- await adapter2.updateTask?.(taskId, { cycle: cycleNumber });
12368
+ } else if (task.cycle !== newCycleNumber) {
12369
+ await adapter2.updateTask?.(taskId, { cycle: newCycleNumber });
12201
12370
  repairedCycles.push(taskId);
12202
12371
  }
12203
12372
  } catch {
@@ -17687,7 +17856,11 @@ var boardDeprioritiseTool = {
17687
17856
  },
17688
17857
  blocker_ref: {
17689
17858
  type: "string",
17690
- description: "Required when blocker_type is set. The identifier being waited on: a task display-id (depends-on), an AD/decision id (decision-gate), or an owner_action id (owner-action)."
17859
+ description: "Required when blocker_type is set, EXCEPT owner-action with owner_action_name (that mode creates the owner_action and derives the ref itself). The identifier being waited on: a task display-id (depends-on), an AD/decision id (decision-gate), or an existing owner_action id (owner-action, link mode)."
17860
+ },
17861
+ owner_action_name: {
17862
+ type: "string",
17863
+ description: 'Optional, blocker_type="owner-action" only. When set, CREATES a new owner action with this name and unlocks_task_id set to this task in the same insert, instead of linking to an existing one \u2014 collapses the old create-then-link two-step into one call. Omit blocker_ref in this mode; it is derived from the created row.'
17691
17864
  },
17692
17865
  defer: {
17693
17866
  type: "boolean",
@@ -17705,15 +17878,26 @@ var boardDeprioritiseTool = {
17705
17878
  required: ["task_id"],
17706
17879
  // task-2802: mirror the handler's guards so the agent sees the dependency up
17707
17880
  // front. handleBoardDeprioritise requires `reason` for both block and cancel,
17708
- // and requires `blocker_ref` whenever `blocker_type` is set. Keyed on explicit
17709
- // values an omitted action defaults to "backlog" and triggers neither.
17881
+ // and requires `blocker_ref` whenever `blocker_type` is set EXCEPT the
17882
+ // task-3451 owner-action create mode (blocker_type='owner-action' +
17883
+ // owner_action_name), which derives its ref from the row it creates.
17884
+ // Keyed on explicit values — an omitted action defaults to "backlog" and
17885
+ // triggers neither.
17710
17886
  allOf: [
17711
17887
  {
17712
17888
  if: { properties: { action: { enum: ["block", "cancel"] } }, required: ["action"] },
17713
17889
  then: { required: ["reason"] }
17714
17890
  },
17715
17891
  {
17716
- if: { required: ["blocker_type"] },
17892
+ if: {
17893
+ required: ["blocker_type"],
17894
+ not: {
17895
+ allOf: [
17896
+ { properties: { blocker_type: { const: "owner-action" } }, required: ["blocker_type"] },
17897
+ { required: ["owner_action_name"] }
17898
+ ]
17899
+ }
17900
+ },
17717
17901
  then: { required: ["blocker_ref"] }
17718
17902
  }
17719
17903
  ]
@@ -18018,13 +18202,18 @@ async function handleBoardDeprioritise(adapter2, args) {
18018
18202
  return errorResponse("reason is required when blocking a task \u2014 explain what external dependency or gate is blocking it.");
18019
18203
  }
18020
18204
  const blockerType = args.blocker_type;
18021
- const blockerRef = args.blocker_ref;
18205
+ let blockerRef = args.blocker_ref;
18206
+ const ownerActionName = args.owner_action_name;
18207
+ const isOwnerActionCreateMode = blockerType === "owner-action" && Boolean(ownerActionName);
18022
18208
  const validTypes = /* @__PURE__ */ new Set(["depends-on", "decision-gate", "owner-action"]);
18023
18209
  if (blockerType !== void 0 && !validTypes.has(blockerType)) {
18024
18210
  return errorResponse(`blocker_type must be one of: depends-on, decision-gate, owner-action (got "${blockerType}").`);
18025
18211
  }
18026
- if (blockerType !== void 0 && !blockerRef) {
18027
- return errorResponse("blocker_ref is required when blocker_type is set \u2014 provide the task display-id, decision id, or owner_action id being waited on.");
18212
+ if (blockerType !== void 0 && !blockerRef && !isOwnerActionCreateMode) {
18213
+ return errorResponse("blocker_ref is required when blocker_type is set \u2014 provide the task display-id, decision id, or owner_action id being waited on (or owner_action_name to create a new owner action).");
18214
+ }
18215
+ if (ownerActionName && blockerType !== "owner-action") {
18216
+ return errorResponse('owner_action_name only applies when blocker_type is "owner-action".');
18028
18217
  }
18029
18218
  try {
18030
18219
  const task = await adapter2.getTask(taskId);
@@ -18037,25 +18226,41 @@ async function handleBoardDeprioritise(adapter2, args) {
18037
18226
  notes: `${existingNotes}BLOCKED: ${reason}`
18038
18227
  };
18039
18228
  let typedSuffix = "";
18040
- if (blockerType !== void 0 && blockerRef) {
18041
- const health = await adapter2.getCycleHealth().catch(() => null);
18042
- const blockedCycle = health?.totalCycles ?? 0;
18043
- updates.blocker = {
18044
- type: blockerType,
18045
- ref: blockerRef,
18046
- reason,
18047
- blockedCycle
18048
- };
18049
- typedSuffix = `
18050
-
18051
- Blocker: **${blockerType}** \u2192 ${blockerRef} (auto-unblock scanned at plan/orient).`;
18052
- if (blockerType === "owner-action" && adapter2.linkOwnerActionToTask && adapter2.getProjectOwnerUserId) {
18229
+ if (blockerType !== void 0 && (blockerRef || isOwnerActionCreateMode)) {
18230
+ if (isOwnerActionCreateMode && adapter2.createOwnerAction && adapter2.getProjectOwnerUserId) {
18053
18231
  try {
18054
18232
  const ownerUserId = await adapter2.getProjectOwnerUserId();
18055
- if (ownerUserId) await adapter2.linkOwnerActionToTask(blockerRef, task.uuid, ownerUserId);
18233
+ if (ownerUserId) {
18234
+ const created = await adapter2.createOwnerAction(ownerUserId, {
18235
+ name: ownerActionName,
18236
+ dependency: reason,
18237
+ unlocks_task_id: task.uuid
18238
+ });
18239
+ blockerRef = created.id;
18240
+ }
18056
18241
  } catch {
18057
18242
  }
18058
18243
  }
18244
+ if (blockerRef) {
18245
+ const health = await adapter2.getCycleHealth().catch(() => null);
18246
+ const blockedCycle = health?.totalCycles ?? 0;
18247
+ updates.blocker = {
18248
+ type: blockerType,
18249
+ ref: blockerRef,
18250
+ reason,
18251
+ blockedCycle
18252
+ };
18253
+ typedSuffix = `
18254
+
18255
+ Blocker: **${blockerType}** \u2192 ${blockerRef} (auto-unblock scanned at plan/orient).`;
18256
+ if (blockerType === "owner-action" && !isOwnerActionCreateMode && adapter2.linkOwnerActionToTask && adapter2.getProjectOwnerUserId) {
18257
+ try {
18258
+ const ownerUserId = await adapter2.getProjectOwnerUserId();
18259
+ if (ownerUserId) await adapter2.linkOwnerActionToTask(blockerRef, task.uuid, ownerUserId);
18260
+ } catch {
18261
+ }
18262
+ }
18263
+ }
18059
18264
  }
18060
18265
  await adapter2.updateTask(taskId, updates);
18061
18266
  return textResponse(`Blocked **${taskId}** (${task.title}).
@@ -20948,6 +21153,27 @@ async function resolveCycleToClose(adapter2, version, callerUserId) {
20948
21153
  }
20949
21154
  return inferCycleFromVersion(version);
20950
21155
  }
21156
+ async function findUnreachableDoneTaskCommits(config2, adapter2, cycleNumber, baseBranch) {
21157
+ if (cycleNumber <= 0 || !adapter2.getBuildReportsSince) return { unreachable: [] };
21158
+ const [tasks, reports] = await Promise.all([
21159
+ adapter2.queryBoard({ cycleSince: cycleNumber, compact: true }),
21160
+ adapter2.getBuildReportsSince(cycleNumber)
21161
+ ]);
21162
+ const doneTasks = tasks.filter((task) => task.cycle === cycleNumber && task.status === "Done");
21163
+ const cycleReports = reports.filter((report) => report.cycle === cycleNumber && report.completed === "Yes");
21164
+ const fetch2 = fetchBaseBranch(config2.projectRoot, baseBranch);
21165
+ const unreachable = doneTasks.flatMap((task) => {
21166
+ 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) => {
21167
+ const aDate = a.createdAt ?? a.date;
21168
+ const bDate = b2.createdAt ?? b2.date;
21169
+ return aDate.localeCompare(bDate);
21170
+ }).at(-1);
21171
+ const commitSha = report?.commitSha?.trim();
21172
+ if (!commitSha || isCommitReachable(config2.projectRoot, commitSha, fetch2.compareRef)) return [];
21173
+ return [{ taskId: task.displayId, commitSha }];
21174
+ });
21175
+ return { unreachable, fetchWarning: fetch2.warning };
21176
+ }
20951
21177
  async function closeCycleState(config2, adapter2, version, cycleNum, options) {
20952
21178
  const warnings = [];
20953
21179
  const force = options?.force ?? false;
@@ -21143,7 +21369,7 @@ async function contributorAutoPrRelease(config2, adapter2, version, productionBa
21143
21369
  const title = `Release ${version}: ${branch}`;
21144
21370
  const body = `Contributor release PR for cycle ${cycleNum || "?"} (\`${branch}\`).
21145
21371
 
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.`;
21372
+ 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
21373
  const created = createPullRequest(config2.projectRoot, branch, productionBaseBranch, title, body);
21148
21374
  prs.push(
21149
21375
  created.success ? { branch, url: created.message.trim() || getPullRequestUrl(config2.projectRoot, branch) } : { branch, url: null, error: created.message }
@@ -21244,6 +21470,25 @@ async function createRelease(config2, branch, version, adapter2, cycleNum, optio
21244
21470
  }
21245
21471
  }
21246
21472
  }
21473
+ if (adapter2 && resolvedCycleNum > 0) {
21474
+ const resolvedBase = resolveBaseBranch(config2.projectRoot, branch);
21475
+ const { unreachable, fetchWarning } = await findUnreachableDoneTaskCommits(
21476
+ config2,
21477
+ adapter2,
21478
+ resolvedCycleNum,
21479
+ resolvedBase
21480
+ );
21481
+ if (fetchWarning) warnings.push(fetchWarning);
21482
+ if (unreachable.length > 0) {
21483
+ const details = unreachable.map(({ taskId, commitSha }) => ` - ${taskId}: ${commitSha}`).join("\n");
21484
+ throw new Error(
21485
+ `Release blocked \u2014 ${unreachable.length} Done task implementation commit(s) are not reachable from ${resolvedBase}:
21486
+ ${details}
21487
+
21488
+ Merge the recorded commit into the release branch, or correct the build receipt before retrying release.`
21489
+ );
21490
+ }
21491
+ }
21247
21492
  if (adapter2 && resolvedCycleNum > 0) {
21248
21493
  try {
21249
21494
  const stampedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -21589,6 +21834,7 @@ function sanitiseBranchSuffix(branch) {
21589
21834
  return branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
21590
21835
  }
21591
21836
  var CYCLE_UPDATES_CHANNEL_ENV = "DISCORD_CYCLE_UPDATES_CHANNEL_ID";
21837
+ var RELEASE_CAPABLE_ROLES = /* @__PURE__ */ new Set(["editor", "release_manager"]);
21592
21838
  function buildCycleUpdateCurationDirective(version, cycleClosed, projectChannelId) {
21593
21839
  const channelId = projectChannelId?.trim() || process.env[CYCLE_UPDATES_CHANNEL_ENV]?.trim();
21594
21840
  if (!channelId) return null;
@@ -21772,9 +22018,10 @@ async function handleRelease(adapter2, config2, args, clientName) {
21772
22018
  const editorsReleaseLikeOwner = isCapabilityEnabled(releaseCaps, "editorRelease");
21773
22019
  const cycleGate = editorsReleaseLikeOwner ? await resolveCycleGate(adapter2, gate) : { allowed: gate.callerIsOwner, role: gate.callerIsOwner ? "owner" : null };
21774
22020
  if (gate.enforced && !cycleGate.allowed) {
21775
- const resolutionNote = gate.resolutionError ? `
22021
+ const resolutionMessage = formatOwnerGateResolutionError(gate);
22022
+ const resolutionNote = resolutionMessage ? `
21776
22023
 
21777
- Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.` : "";
22024
+ ${resolutionMessage}` : "";
21778
22025
  if (gate.transport === "pg" && gate.callerUserId === null) {
21779
22026
  return errorResponse(
21780
22027
  `Release needs to know who you are, and this setup has no PAPI_USER_ID yet.
@@ -21788,17 +22035,18 @@ Then reconnect your AI tool and run release again. (Direct/pg setups read identi
21788
22035
  }
21789
22036
  tracker.mark("contributor-role-gate");
21790
22037
  const callerRole = adapter2.getContributorRole && gate.callerUserId ? await adapter2.getContributorRole(gate.callerUserId).catch(() => null) : null;
21791
- if (callerRole !== "editor") {
22038
+ if (!RELEASE_CAPABLE_ROLES.has(callerRole ?? "")) {
21792
22039
  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;
22040
+ 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
22041
  return errorResponse(
21795
- `Release to ${productionBaseBranch} needs an owner or editor role.
22042
+ `Release to ${productionBaseBranch} needs an owner, editor, or release_manager role.
21796
22043
 
21797
22044
  ` + roleNote + `
21798
22045
 
21799
22046
  Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
21800
22047
  );
21801
22048
  }
22049
+ const releaseRole = callerRole === "release_manager" ? "release manager" : "editor";
21802
22050
  tracker.mark("contributor-pr-reconciliation");
21803
22051
  try {
21804
22052
  const reconciled = await reconcileContributorReleasePrs(config2, adapter2, gate.callerUserId, cycleToClose);
@@ -21833,7 +22081,7 @@ ${buildHostedGitDirective({
21833
22081
  return textResponse(
21834
22082
  `## Release ${version} \u2014 contributor PR merged
21835
22083
 
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.
22084
+ 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
22085
 
21838
22086
  ${latest.cycle ? `Cycle ${latest.cycle}` : "The contributor cycle"} remains **complete**, and the release is now recorded as **released** in PAPI.` + localHandoff + `
21839
22087
 
@@ -21847,7 +22095,7 @@ Next: run \`plan\` to start your next cycle.`
21847
22095
 
21848
22096
  ${prLines}
21849
22097
 
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.
22098
+ 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
22099
 
21852
22100
  After it is merged, run \`release\` again so PAPI can detect the merge and finish the release record.`
21853
22101
  );
@@ -21857,7 +22105,7 @@ After it is merged, run \`release\` again so PAPI can detect the merge and finis
21857
22105
  }
21858
22106
  if (isHostedTransport()) {
21859
22107
  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.`
22108
+ `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
22109
  );
21862
22110
  }
21863
22111
  tracker.mark("contributor-auto-pr");
@@ -21877,7 +22125,7 @@ After it is merged, run \`release\` again so PAPI can detect the merge and finis
21877
22125
  return textResponse(
21878
22126
  `## Release ${version} \u2014 contributor PR opened
21879
22127
 
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.
22128
+ 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
22129
 
21882
22130
  **Pull request(s):**
21883
22131
  ${prLines.join("\n")}
@@ -22929,13 +23177,18 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
22929
23177
  if (!task) {
22930
23178
  throw new Error(`Task "${taskId}" not found on the Cycle Board.`);
22931
23179
  }
23180
+ const branchLines = [];
23181
+ const gate = await resolveOwnerGate(adapter2, config2);
22932
23182
  if (task.assigneeId) {
22933
- const gate = await resolveOwnerGate(adapter2, config2);
22934
23183
  if (!gate.callerUserId || gate.callerUserId !== task.assigneeId) {
22935
23184
  throw new Error(
22936
23185
  `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
23186
  );
22938
23187
  }
23188
+ } else if (gate.enforced) {
23189
+ branchLines.push(
23190
+ `\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.)`
23191
+ );
22939
23192
  }
22940
23193
  if (task.status === "Done" || task.status === "Archived") {
22941
23194
  throw new Error(`Task "${taskId}" (${task.title}) is already ${task.status}. Cannot execute a completed task.`);
@@ -22973,7 +23226,6 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
22973
23226
  err.unresolvedDeps = unresolvedDeps;
22974
23227
  throw err;
22975
23228
  }
22976
- const branchLines = [];
22977
23229
  const startCaps = adapter2.getProjectInfo ? (await adapter2.getProjectInfo().catch(() => null))?.capabilities ?? {} : {};
22978
23230
  const autoBranchEnabled = isCapabilityEnabled(startCaps, "autoBranch");
22979
23231
  if (options.light) {
@@ -24656,6 +24908,41 @@ The registry stores metadata and a summary \u2014 not the body. This doc has one
24656
24908
  return `
24657
24909
  - **Durability:** committed \`${path8}\` (was untracked)`;
24658
24910
  }
24911
+ var DOC_OVERLAP_COVERAGE_THRESHOLD = 0.6;
24912
+ async function findOverlappingDocs(target, type, title, summary) {
24913
+ if (!target.searchDocs) return [];
24914
+ const newKeywords = extractKeywords(`${title} ${summary}`);
24915
+ if (newKeywords.size < 2) return [];
24916
+ let candidates;
24917
+ try {
24918
+ candidates = await target.searchDocs({ type, status: "active" });
24919
+ } catch {
24920
+ return [];
24921
+ }
24922
+ const matches = [];
24923
+ for (const doc of candidates) {
24924
+ const docKeywords = extractKeywords(`${doc.title} ${doc.summary}`);
24925
+ if (docKeywords.size < 2) continue;
24926
+ let covered = 0;
24927
+ for (const word of newKeywords) if (docKeywords.has(word)) covered++;
24928
+ const coverage = covered / newKeywords.size;
24929
+ if (coverage >= DOC_OVERLAP_COVERAGE_THRESHOLD) {
24930
+ matches.push({ path: doc.path, title: doc.title, coverage });
24931
+ }
24932
+ }
24933
+ return matches.sort((a, b2) => b2.coverage - a.coverage).slice(0, 3);
24934
+ }
24935
+ function overlapReconciliationNote(overlaps) {
24936
+ if (overlaps.length === 0) return "";
24937
+ const named = overlaps.map((o) => `\`${o.path}\` ("${o.title}")`).join(", ");
24938
+ return `
24939
+
24940
+ \u26A0\uFE0F **Possible overlap** with existing active doc(s): ${named} \u2014 verify it is real, then classify:
24941
+ - **AGREES** \u2014 restates the existing doc. Don't duplicate; nothing to register.
24942
+ - **EXTENDS** \u2014 new ground the existing doc doesn't cover. Fine to register as-is.
24943
+ - **CONTRADICTS** \u2014 cuts against the existing doc. Surface to the owner \u2014 never resolve silently (task-3219: a contradiction is not a veto).
24944
+ - **SUPERSEDES** \u2014 this doc should replace the existing one. Re-run doc_register with \`superseded_by_path\` pointing at the overlapping doc's path.`;
24945
+ }
24659
24946
  async function handleDocRegister(adapter2, args, config2) {
24660
24947
  const adapterType = config2?.adapterType ?? "unknown";
24661
24948
  const continueHint = "doc_register is advisory \u2014 your build/plan/review flow is unaffected. Fix the input (or wait for the registry to recover) and re-run doc_register; or just continue without it.";
@@ -24714,6 +25001,7 @@ async function handleDocRegister(adapter2, args, config2) {
24714
25001
  );
24715
25002
  }
24716
25003
  try {
25004
+ const overlaps = supersededByPath ? [] : await findOverlappingDocs(target, type, title, summary);
24717
25005
  let supersededBy;
24718
25006
  if (supersededByPath) {
24719
25007
  const existing = await target.getDoc?.(supersededByPath);
@@ -24789,7 +25077,7 @@ ${decision.message}`;
24789
25077
  - **Visibility:** ${visibilityLabel}
24790
25078
  - **Tags:** ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}
24791
25079
  - **Actions:** ${actions?.length ?? 0} items
24792
- - **ID:** ${entry.id}` + bodyNote + durability
25080
+ - **ID:** ${entry.id}` + bodyNote + durability + overlapReconciliationNote(overlaps)
24793
25081
  );
24794
25082
  } catch (err) {
24795
25083
  const message = err instanceof Error ? err.message : String(err);
@@ -27490,11 +27778,17 @@ async function recordAdHoc(adapter2, input) {
27490
27778
  const promoted = targetCycle !== null;
27491
27779
  const landInReview = held || promoted && input.stage === "release";
27492
27780
  let task;
27781
+ const warnings = [];
27493
27782
  if (input.taskId) {
27494
27783
  const existing = await adapter2.getTask(input.taskId);
27495
27784
  if (!existing) {
27496
27785
  throw new Error(`Task "${input.taskId}" not found on the board. Check the task ID and try again.`);
27497
27786
  }
27787
+ if (!existing.assigneeId) {
27788
+ warnings.push(
27789
+ `\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.`
27790
+ );
27791
+ }
27498
27792
  const updatePayload = {
27499
27793
  notes: existing.notes ? `${existing.notes}
27500
27794
  [ad-hoc] ${input.notes || "Work recorded via ad_hoc"}` : `[ad-hoc] ${input.notes || "Work recorded via ad_hoc"}`
@@ -27565,7 +27859,7 @@ async function recordAdHoc(adapter2, input) {
27565
27859
  scopeAccuracy: "accurate"
27566
27860
  };
27567
27861
  await adapter2.appendBuildReport(report);
27568
- return { task, report };
27862
+ return { task, report, warnings: warnings.length > 0 ? warnings : void 0 };
27569
27863
  }
27570
27864
 
27571
27865
  // src/tools/ad-hoc.ts
@@ -27776,6 +28070,7 @@ async function handleAdHoc(adapter2, config2, args) {
27776
28070
  } catch {
27777
28071
  }
27778
28072
  }
28073
+ const warningsNote = result.warnings?.length ? "\n\n" + result.warnings.map((w) => `> ${w}`).join("\n") : "";
27779
28074
  const truncateWarning = notesTruncated ? ` (notes truncated to ${MAX_NOTES_LENGTH} chars)` : "";
27780
28075
  const taskModule = result.task.module || "Core";
27781
28076
  const typeLabel = result.task.taskType || typeRaw;
@@ -27808,7 +28103,7 @@ async function handleAdHoc(adapter2, config2, args) {
27808
28103
  } catch {
27809
28104
  }
27810
28105
  return textResponse(
27811
- `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
28106
+ `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.` + warningsNote + `
27812
28107
 
27813
28108
  ## Held for the next cycle \u2014 branch + commit, do NOT merge
27814
28109
  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 +28121,7 @@ _To correct: board_edit ${result.task.id} with updated fields._` + await recordA
27826
28121
  );
27827
28122
  }
27828
28123
  return textResponse(
27829
- `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
28124
+ `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.` + warningsNote + `
27830
28125
  _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
28126
  );
27832
28127
  }
@@ -29226,14 +29521,14 @@ ${overlap}`;
29226
29521
  const roleGate = await resolveCycleGate(adapter2, gate);
29227
29522
  const callerMayRelease = editorReleaseEnabled ? roleGate.allowed : !gate.enforced || gate.callerIsOwner;
29228
29523
  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`;
29524
+ 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
29525
  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.`;
29526
+ 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
29527
  autoReleaseNote = `
29233
29528
 
29234
29529
  ---
29235
29530
 
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}`;
29531
+ \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
29532
  } else if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done")) {
29238
29533
  let planRunCount = null;
29239
29534
  if (adapterSupports(adapter2, "countPlanRunsForCycle")) {
@@ -31347,6 +31642,7 @@ async function getHierarchyPosition(adapter2, projectId) {
31347
31642
  }
31348
31643
  }
31349
31644
  var GIT_TAG_TIMEOUT_MS = 2e3;
31645
+ var ORIGIN_FETCH_TIMEOUT_MS = 3e3;
31350
31646
  async function checkNpmVersionDrift() {
31351
31647
  try {
31352
31648
  const pkgPath = join18(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
@@ -31563,6 +31859,17 @@ async function computePendingDecisionsWarning(adapter2, callerUserId, decisions)
31563
31859
  if (awaiting === 0) return void 0;
31564
31860
  return `\u{1F4CB} ${awaiting} decision${awaiting === 1 ? "" : "s"} awaiting your position \u2014 run \`ad_view\` then \`decision_resolve\` to agree/object/abstain.`;
31565
31861
  }
31862
+ function formatOriginDivergenceWarning(d) {
31863
+ if (d.behind != null && d.behind > 0) {
31864
+ const aheadNote = d.ahead && d.ahead > 0 ? ` (and ${d.ahead} ahead)` : "";
31865
+ 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.`;
31866
+ }
31867
+ if (d.warning) return `\u2139\uFE0F ${d.warning}`;
31868
+ if (d.fetched && d.behind == null) {
31869
+ 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.`;
31870
+ }
31871
+ return void 0;
31872
+ }
31566
31873
  async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVersion = "unknown") {
31567
31874
  let resolvedAdapter = rawAdapter;
31568
31875
  let projectOverrideNote = "";
@@ -31659,6 +31966,7 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
31659
31966
  ttfvOutcome,
31660
31967
  latestTagOutcome,
31661
31968
  versionDriftOutcome,
31969
+ originDivergenceOutcome,
31662
31970
  researchSignalsOutcome,
31663
31971
  recsOutcome,
31664
31972
  pendingReviewOutcome,
@@ -31813,6 +32121,18 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
31813
32121
  // version-drift is enrichment, gated behind `full`/deep_housekeeping.
31814
32122
  tracked("git-tag", async () => getLatestTag(config2.projectRoot, GIT_TAG_TIMEOUT_MS)),
31815
32123
  tracked("npm-version-drift", async () => fullEnrichment ? checkNpmVersionDrift() : void 0),
32124
+ // Nova/vibe-tycoon feedback (task-XXXX): how far is this checkout
32125
+ // actually behind origin/<base>? Deliberately UNGATED by full/
32126
+ // deep_housekeeping — runs on every orient call by default, because the
32127
+ // fetch is bounded (ORIGIN_FETCH_TIMEOUT_MS) and fails soft. No remote
32128
+ // configured is the common no-op case: zero network I/O, zero latency.
32129
+ // Escape hatch mirrors PAPI_ORIENT_FANOUT_CONCURRENCY's env-var pattern,
32130
+ // default ON (opposite polarity from deep_housekeeping's opt-in — this
32131
+ // one must not require opting in).
32132
+ tracked("origin-divergence", async () => {
32133
+ if (!hasLocalWorkspace() || process.env.PAPI_ORIENT_ORIGIN_CHECK === "false") return void 0;
32134
+ return getBaseDivergence(config2.projectRoot, config2.baseBranch, { timeoutMs: ORIGIN_FETCH_TIMEOUT_MS });
32135
+ }),
31816
32136
  // Research Signals — research docs with pending actions since last strategy review.
31817
32137
  // task-2172: heavy (doc search + AD cross-reference) and rarely actioned
31818
32138
  // mid-session — gated behind `full`/deep_housekeeping to keep the default lean.
@@ -32010,6 +32330,9 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
32010
32330
  if (proxyWarning) buildResult.warnings.push(proxyWarning);
32011
32331
  const p1Warning = p1BacklogOutcome.status === "fulfilled" ? p1BacklogOutcome.value : void 0;
32012
32332
  if (p1Warning) buildResult.warnings.push(p1Warning);
32333
+ const originDivergence = originDivergenceOutcome.status === "fulfilled" ? originDivergenceOutcome.value : void 0;
32334
+ const originDivergenceWarning = originDivergence ? formatOriginDivergenceWarning(originDivergence) : void 0;
32335
+ if (originDivergenceWarning) buildResult.warnings.push(originDivergenceWarning);
32013
32336
  const pendingDecisionsWarning = pendingDecisionsOutcome.status === "fulfilled" ? pendingDecisionsOutcome.value : void 0;
32014
32337
  if (pendingDecisionsWarning) buildResult.warnings.push(pendingDecisionsWarning);
32015
32338
  const ownerActionsWarning = ownerActionsOutcome.status === "fulfilled" ? ownerActionsOutcome.value : void 0;
@@ -32144,7 +32467,42 @@ ${section}`;
32144
32467
  const runtimeIdentityNote = `
32145
32468
 
32146
32469
  ${formatRuntimeIdentity(getRuntimeIdentity(config2, serverVersion))}`;
32147
- return { ...textResponse(projectOverrideLine + projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary, clientName, carryForwardRefs) + runtimeIdentityNote + unblockNote + deferredGateNote + feedbackResolvedNote + alertsNote + ttfvNote + reconciliationNote + mergedInProgressNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + onboardingCoachingNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection), _cycleNumber: healthResult.cycleNumber };
32470
+ let effDeepHint = deepHint;
32471
+ let effOnboardingCoachingNote = onboardingCoachingNote;
32472
+ let effStaleSkillsNote = staleSkillsNote;
32473
+ let effResearchSignalsNote = researchSignalsNote;
32474
+ const droppedNotes = [];
32475
+ const assembleOrientOutput = () => projectOverrideLine + projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary, clientName, carryForwardRefs) + runtimeIdentityNote + unblockNote + deferredGateNote + feedbackResolvedNote + alertsNote + ttfvNote + reconciliationNote + mergedInProgressNote + unrecordedNote + unregisteredDocsNote + effStaleSkillsNote + effResearchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + effOnboardingCoachingNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + effDeepHint + enrichmentFilesSection;
32476
+ const orientBudgetSoft = Number(process.env.PAPI_ORIENT_CONTEXT_BUDGET) || 6e4;
32477
+ let assembled = assembleOrientOutput();
32478
+ if (Buffer.byteLength(assembled, "utf-8") > orientBudgetSoft && effDeepHint) {
32479
+ effDeepHint = "";
32480
+ droppedNotes.push("deep-housekeeping tip");
32481
+ assembled = assembleOrientOutput();
32482
+ }
32483
+ if (Buffer.byteLength(assembled, "utf-8") > orientBudgetSoft && effOnboardingCoachingNote) {
32484
+ effOnboardingCoachingNote = "";
32485
+ droppedNotes.push("onboarding coaching");
32486
+ assembled = assembleOrientOutput();
32487
+ }
32488
+ if (Buffer.byteLength(assembled, "utf-8") > orientBudgetSoft && effStaleSkillsNote) {
32489
+ effStaleSkillsNote = "";
32490
+ droppedNotes.push("stale skills");
32491
+ assembled = assembleOrientOutput();
32492
+ }
32493
+ if (Buffer.byteLength(assembled, "utf-8") > orientBudgetSoft && effResearchSignalsNote) {
32494
+ effResearchSignalsNote = "";
32495
+ droppedNotes.push("research signals");
32496
+ assembled = assembleOrientOutput();
32497
+ }
32498
+ if (droppedNotes.length > 0) {
32499
+ assembled += `
32500
+
32501
+ ---
32502
+
32503
+ *Context budget: ${droppedNotes.length} advisory note(s) omitted to keep this orient payload under ~${Math.round(orientBudgetSoft / 1024)} KB \u2014 ${droppedNotes.join(", ")}. Raise \`PAPI_ORIENT_CONTEXT_BUDGET\` in the MCP server env, or re-run without \`deep_housekeeping\`/\`full\` for a smaller payload by default.*`;
32504
+ }
32505
+ return { ...textResponse(assembled), _cycleNumber: healthResult.cycleNumber };
32148
32506
  } catch (err) {
32149
32507
  const message = err instanceof Error ? err.message : String(err);
32150
32508
  const isKnownFriendly = /^(Orient failed|Project not found|No project|Setup required)/i.test(message);
@@ -33350,7 +33708,7 @@ async function resolveDecisionAuthority(adapter2, gate, decisionType) {
33350
33708
  allowed: isQuorumEligible,
33351
33709
  role,
33352
33710
  isOwnerOverride: false,
33353
- ...isQuorumEligible ? {} : { resolutionError: "only owner/editor members participate in group resolution" }
33711
+ ...isQuorumEligible ? {} : { resolutionError: "only owner/release_manager/editor members participate in group resolution" }
33354
33712
  };
33355
33713
  }
33356
33714
  function normaliseMode(value) {
@@ -33398,14 +33756,14 @@ var decisionResolveTool = {
33398
33756
  action: {
33399
33757
  type: "string",
33400
33758
  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.'
33759
+ 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
33760
  },
33403
33761
  comment: { type: "string", description: "Optional note recorded alongside your position or transition." }
33404
33762
  },
33405
33763
  required: ["ad_id", "action"]
33406
33764
  }
33407
33765
  };
33408
- function findDecision(decisions, adId) {
33766
+ function findDecision2(decisions, adId) {
33409
33767
  const trimmed = adId.trim();
33410
33768
  return decisions.find((d) => d.id === trimmed || d.displayId === trimmed);
33411
33769
  }
@@ -33438,7 +33796,7 @@ async function handleDecisionResolve(adapter2, config2, args) {
33438
33796
  } catch (err) {
33439
33797
  return errorResponse(`Failed to read active decisions: ${err instanceof Error ? err.message : String(err)}`);
33440
33798
  }
33441
- const target = findDecision(decisions, adId);
33799
+ const target = findDecision2(decisions, adId);
33442
33800
  if (!target) return errorResponse(`AD not found: ${adId}. Run ad_view to see available decisions.`);
33443
33801
  if (target.resolutionState !== "proposed") {
33444
33802
  return errorResponse(
@@ -33475,7 +33833,7 @@ async function handleDecisionResolve(adapter2, config2, args) {
33475
33833
  positions.map((p) => ({ userId: p.userId, status: p.status })),
33476
33834
  quorumMembersFrom(contributors)
33477
33835
  );
33478
- quorumNote = quorum.met ? '\n\nEvery owner/editor member has now agreed or abstained \u2014 this decision is ready to `decision_resolve action="resolve"`.' : `
33836
+ 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
33837
 
33480
33838
  Still waiting on: ${quorum.missing.length > 0 ? quorum.missing.join(", ") : "nobody"}${quorum.objected.length > 0 ? `; objected: ${quorum.objected.join(", ")}` : ""}.`;
33481
33839
  } catch {
@@ -34027,7 +34385,7 @@ Mapping after: ${mappingAfter}`
34027
34385
  init_dist();
34028
34386
  var contributorAddTool = {
34029
34387
  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.`,
34388
+ 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
34389
  annotations: { title: "Add Contributor", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
34032
34390
  inputSchema: {
34033
34391
  type: "object",
@@ -34036,7 +34394,7 @@ var contributorAddTool = {
34036
34394
  role: {
34037
34395
  type: "string",
34038
34396
  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.`
34397
+ 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
34398
  },
34041
34399
  project: {
34042
34400
  type: "string",
@@ -34084,7 +34442,8 @@ async function denyUnlessOwner(adapter2, config2) {
34084
34442
  if (capDenied) return capDenied;
34085
34443
  const gate = await resolveOwnerGate(adapter2, config2);
34086
34444
  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.)` : "";
34445
+ const resolutionMessage = formatOwnerGateResolutionError(gate);
34446
+ const note = resolutionMessage ? ` (${resolutionMessage})` : "";
34088
34447
  return `Contributor management is restricted to the project owner. Your identity does not match this project's owner.${note}`;
34089
34448
  }
34090
34449
  return null;
@@ -34097,7 +34456,8 @@ async function denyUnlessMember(adapter2, config2) {
34097
34456
  if (gate.callerIsOwner) return null;
34098
34457
  const callerRole = gate.callerUserId && adapterSupports(adapter2, "getContributorRole") ? await adapter2.getContributorRole(gate.callerUserId).catch(() => null) : null;
34099
34458
  if (callerRole) return null;
34100
- const note = gate.resolutionError ? ` (Identity resolution failed: ${gate.resolutionError} \u2014 the gate fails closed; retry once connectivity is restored.)` : "";
34459
+ const resolutionMessage = formatOwnerGateResolutionError(gate);
34460
+ const note = resolutionMessage ? ` (${resolutionMessage})` : "";
34101
34461
  return `Listing contributors is restricted to project members. Your identity does not match this project's owner or any contributor.${note}`;
34102
34462
  }
34103
34463
  var EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -34132,7 +34492,7 @@ async function handleContributorAdd(adapter2, config2, args) {
34132
34492
 
34133
34493
  ${upsell}` : "";
34134
34494
  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.`;
34495
+ 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
34496
  return textResponse(
34137
34497
  `\u2705 **${entry.email ?? email}**${name} is now a **${entry.role}**${where}.
34138
34498
 
package/dist/prompts.js CHANGED
@@ -78,6 +78,9 @@ Why now: [justification]
78
78
  DEPENDS ON
79
79
  [Optional \u2014 comma-separated task IDs this task depends on (e.g. "task-123, task-124"). Include only when another task in this same cycle must be built first because this task consumes artifacts it creates (e.g. new adapter method, new type, new migration). The builder will reuse the upstream task's branch so dependent commits stack on the same branch for a single PR. Omit this section entirely if there are no intra-cycle dependencies.]
80
80
 
81
+ RELEVANT ACTIVE DECISIONS
82
+ [Optional \u2014 self-check which Active Decisions actually bind the FILES LIKELY TOUCHED and module below, out of the full AD list already in your context. List the top 3-6 as "AD-N (title) \u2014 one-line reason this AD binds the files/approach". Rank by blast radius (an AD that would be VIOLATED by a naive implementation outranks one that's merely thematically adjacent). This is a judgement call \u2014 no server-side scoring feeds this. Omit this section entirely when nothing in the AD list is genuinely relevant to what this task touches; do not force a match.]
83
+
81
84
  SCOPE (DO THIS)
82
85
  [specific deliverables \u2014 write for the simplest viable path first]
83
86
 
@@ -1413,6 +1416,9 @@ Why now: [justification]
1413
1416
  DEPENDS ON
1414
1417
  [Optional \u2014 comma-separated task IDs this task depends on (e.g. "task-123, task-124"). Include only when another task in this same cycle must be built first because this task consumes artifacts it creates (e.g. new adapter method, new type, new migration). The builder will reuse the upstream task's branch so dependent commits stack on the same branch for a single PR. Omit this section entirely if there are no intra-cycle dependencies.]
1415
1418
 
1419
+ RELEVANT ACTIVE DECISIONS
1420
+ [Optional \u2014 self-check which Active Decisions actually bind the FILES LIKELY TOUCHED and module below, out of the full AD list already in your context. List the top 3-6 as "AD-N (title) \u2014 one-line reason this AD binds the files/approach". Rank by blast radius (an AD that would be VIOLATED by a naive implementation outranks one that's merely thematically adjacent). This is a judgement call \u2014 no server-side scoring feeds this. Omit this section entirely when nothing in the AD list is genuinely relevant to what this task touches; do not force a match.]
1421
+
1416
1422
  SCOPE (DO THIS)
1417
1423
  [specific deliverables \u2014 write for the simplest viable path first]
1418
1424
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.98",
4
- "description": "PAPI MCP server \u2014 AI-powered sprint planning, build execution, and strategy review for software projects",
3
+ "version": "0.7.104",
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",
7
7
  "type": "module",