@papi-ai/server 0.7.62 → 0.7.63

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.
@@ -60,6 +60,7 @@ __export(git_exports, {
60
60
  isGitAvailable: () => isGitAvailable,
61
61
  isGitRepo: () => isGitRepo,
62
62
  listGroupedCycleBranches: () => listGroupedCycleBranches,
63
+ listOpenPullRequests: () => listOpenPullRequests,
63
64
  listOrphanFeatBranches: () => listOrphanFeatBranches,
64
65
  mergePullRequest: () => mergePullRequest,
65
66
  normalizeGitUrl: () => normalizeGitUrl,
@@ -346,6 +347,26 @@ function isGhAvailable() {
346
347
  return false;
347
348
  }
348
349
  }
350
+ function listOpenPullRequests(cwd) {
351
+ if (!isGhAvailable()) return null;
352
+ try {
353
+ const out = execFileSync(
354
+ "gh",
355
+ ["pr", "list", "--state", "open", "--limit", "100", "--json", "number,title,author,headRefName,createdAt"],
356
+ { cwd, encoding: "utf-8" }
357
+ );
358
+ const raw = JSON.parse(out);
359
+ return raw.map((p) => ({
360
+ number: p.number,
361
+ title: p.title,
362
+ author: p.author?.login ?? "unknown",
363
+ headRefName: p.headRefName,
364
+ createdAt: p.createdAt
365
+ }));
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
349
370
  function getOriginRepoSlug(cwd) {
350
371
  try {
351
372
  const url = execFileSync("git", ["remote", "get-url", "origin"], {
package/dist/index.js CHANGED
@@ -61,6 +61,7 @@ __export(git_exports, {
61
61
  isGitAvailable: () => isGitAvailable,
62
62
  isGitRepo: () => isGitRepo,
63
63
  listGroupedCycleBranches: () => listGroupedCycleBranches,
64
+ listOpenPullRequests: () => listOpenPullRequests,
64
65
  listOrphanFeatBranches: () => listOrphanFeatBranches,
65
66
  mergePullRequest: () => mergePullRequest,
66
67
  normalizeGitUrl: () => normalizeGitUrl,
@@ -347,6 +348,26 @@ function isGhAvailable() {
347
348
  return false;
348
349
  }
349
350
  }
351
+ function listOpenPullRequests(cwd) {
352
+ if (!isGhAvailable()) return null;
353
+ try {
354
+ const out = execFileSync(
355
+ "gh",
356
+ ["pr", "list", "--state", "open", "--limit", "100", "--json", "number,title,author,headRefName,createdAt"],
357
+ { cwd, encoding: "utf-8" }
358
+ );
359
+ const raw = JSON.parse(out);
360
+ return raw.map((p) => ({
361
+ number: p.number,
362
+ title: p.title,
363
+ author: p.author?.login ?? "unknown",
364
+ headRefName: p.headRefName,
365
+ createdAt: p.createdAt
366
+ }));
367
+ } catch {
368
+ return null;
369
+ }
370
+ }
350
371
  function getOriginRepoSlug(cwd) {
351
372
  try {
352
373
  const url = execFileSync("git", ["remote", "get-url", "origin"], {
@@ -2040,6 +2061,76 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
2040
2061
  }
2041
2062
  });
2042
2063
 
2064
+ // src/lib/reap-orphans.ts
2065
+ var reap_orphans_exports = {};
2066
+ __export(reap_orphans_exports, {
2067
+ formatReapSummary: () => formatReapSummary,
2068
+ isPapiServerCommand: () => isPapiServerCommand,
2069
+ listProcesses: () => listProcesses,
2070
+ reapOrphans: () => reapOrphans,
2071
+ selectReapableOrphans: () => selectReapableOrphans
2072
+ });
2073
+ import { execFileSync as execFileSync6 } from "child_process";
2074
+ function isPapiServerCommand(command) {
2075
+ return /@papi-ai[/\\]server/.test(command);
2076
+ }
2077
+ function selectReapableOrphans(procs, selfPid) {
2078
+ return procs.filter(
2079
+ (p) => p.pid !== selfPid && p.ppid === 1 && isPapiServerCommand(p.command)
2080
+ );
2081
+ }
2082
+ function listProcesses() {
2083
+ if (process.platform === "win32") return null;
2084
+ try {
2085
+ const out = execFileSync6("ps", ["-A", "-o", "pid=,ppid=,command="], { encoding: "utf-8" });
2086
+ const procs = [];
2087
+ for (const line of out.split("\n")) {
2088
+ const trimmed = line.trim();
2089
+ if (!trimmed) continue;
2090
+ const m = /^(\d+)\s+(\d+)\s+(.*)$/.exec(trimmed);
2091
+ if (!m) continue;
2092
+ procs.push({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3] });
2093
+ }
2094
+ return procs;
2095
+ } catch {
2096
+ return null;
2097
+ }
2098
+ }
2099
+ function reapOrphans(opts = {}) {
2100
+ const procs = listProcesses();
2101
+ if (procs === null) return { unsupported: true, candidates: [], reaped: [] };
2102
+ const orphans = selectReapableOrphans(procs, process.pid);
2103
+ const candidates = orphans.map((p) => p.pid);
2104
+ const reaped = [];
2105
+ if (!opts.dryRun) {
2106
+ for (const pid of candidates) {
2107
+ try {
2108
+ process.kill(pid, "SIGTERM");
2109
+ reaped.push(pid);
2110
+ } catch {
2111
+ }
2112
+ }
2113
+ }
2114
+ return { unsupported: false, candidates, reaped };
2115
+ }
2116
+ function formatReapSummary(result, dryRun) {
2117
+ if (result.unsupported) {
2118
+ return "Orphan reaper: unsupported on this platform \u2014 skipped (no processes touched).";
2119
+ }
2120
+ if (result.candidates.length === 0) {
2121
+ return "Orphan reaper: no parentless @papi-ai/server processes found.";
2122
+ }
2123
+ if (dryRun) {
2124
+ return `Orphan reaper: ${result.candidates.length} parentless PAPI server process(es) found: ${result.candidates.join(", ")} (run with --reap-orphans to terminate).`;
2125
+ }
2126
+ return `Orphan reaper: terminated ${result.reaped.length} parentless PAPI server process(es): ${result.reaped.join(", ")}.`;
2127
+ }
2128
+ var init_reap_orphans = __esm({
2129
+ "src/lib/reap-orphans.ts"() {
2130
+ "use strict";
2131
+ }
2132
+ });
2133
+
2043
2134
  // ../../node_modules/postgres/src/query.js
2044
2135
  function cachedError(xs) {
2045
2136
  if (originCache.has(xs))
@@ -4506,12 +4597,16 @@ async function runDoctor(cliArgs2 = []) {
4506
4597
  const fixPool = cliArgs2.includes("--fix-pool") || cliArgs2.includes("--terminate-wedged");
4507
4598
  const pool = await diagnosePool({ fix: fixPool });
4508
4599
  process.stdout.write("\n" + formatPoolReport(pool) + "\n");
4600
+ const reap = cliArgs2.includes("--reap-orphans");
4601
+ const reapResult = reapOrphans({ dryRun: !reap });
4602
+ process.stdout.write("\n" + formatReapSummary(reapResult, !reap) + "\n");
4509
4603
  return 0;
4510
4604
  }
4511
4605
  var SECRET_VARS, WEDGED_IDLE_TX_SECONDS, WEDGED_ACTIVE_SECONDS, __testing;
4512
4606
  var init_doctor = __esm({
4513
4607
  "src/cli/doctor.ts"() {
4514
4608
  "use strict";
4609
+ init_reap_orphans();
4515
4610
  SECRET_VARS = /* @__PURE__ */ new Set(["PAPI_DATA_API_KEY", "DATABASE_URL", "PAPI_ENDPOINT"]);
4516
4611
  WEDGED_IDLE_TX_SECONDS = 300;
4517
4612
  WEDGED_ACTIVE_SECONDS = 300;
@@ -15940,6 +16035,11 @@ Confidence: ${input.confidence}. Captured mid-conversation via strategy_change c
15940
16035
  }
15941
16036
 
15942
16037
  // src/tools/strategy.ts
16038
+ function toDateLabel(value) {
16039
+ if (typeof value === "string") return value.slice(0, 10);
16040
+ const d = value instanceof Date ? value : new Date(value);
16041
+ return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
16042
+ }
15943
16043
  var reviewPrepareCache = new PerCallerCache();
15944
16044
  var strategyReviewTool = {
15945
16045
  name: "strategy_review",
@@ -16241,7 +16341,7 @@ This topic will surface in the next \`strategy_review\`.`
16241
16341
  const lines = topics.map((t, i) => {
16242
16342
  const cycleSuffix = t.sourceCycle != null ? ` (Cycle ${t.sourceCycle})` : "";
16243
16343
  return `${i + 1}. ${t.topic}
16244
- _source: ${t.source}${cycleSuffix} \xB7 queued ${t.createdAt.slice(0, 10)}_`;
16344
+ _source: ${t.source}${cycleSuffix} \xB7 queued ${toDateLabel(t.createdAt)}_`;
16245
16345
  });
16246
16346
  return textResponse(
16247
16347
  `**Pending Agenda (${topics.length})** \u2014 surfaces at next strategy review
@@ -19727,6 +19827,61 @@ async function completeRelease(tracker, opts) {
19727
19827
  }
19728
19828
  await tracker.recordStep("released", { metadata: { version: opts.version } });
19729
19829
  }
19830
+ var OPEN_PR_STALE_DAYS = 21;
19831
+ function taskIdFromBranch(headRefName) {
19832
+ const m = /^feat\/(task-\d+)\b/.exec(headRefName);
19833
+ return m ? m[1] : null;
19834
+ }
19835
+ function classifyOpenPr(pr, cycleNum, inReviewCycleTaskIds, nowMs) {
19836
+ const head = pr.headRefName;
19837
+ const ageDays = (nowMs - Date.parse(pr.createdAt)) / 864e5;
19838
+ if (new RegExp(`^feat/cycle-${cycleNum}-`).test(head)) {
19839
+ return { pr, bucket: "cycle-branch", action: `merge into this release \u2014 \`${head}\` is this cycle's branch and still open` };
19840
+ }
19841
+ const taskId = taskIdFromBranch(head);
19842
+ if (taskId && inReviewCycleTaskIds.has(taskId)) {
19843
+ return { pr, bucket: "held-adhoc-this-cycle", action: `MERGE before closing \u2014 ${taskId} is In Review pinned to Cycle ${cycleNum}` };
19844
+ }
19845
+ if (taskId) {
19846
+ return { pr, bucket: "held-adhoc-other", action: "held adhoc not pinned to this cycle \u2014 defer, or merge if ready" };
19847
+ }
19848
+ if (Number.isFinite(ageDays) && ageDays > OPEN_PR_STALE_DAYS) {
19849
+ return { pr, bucket: "stale", action: `review/close \u2014 open ${Math.round(ageDays)}d with no cycle link` };
19850
+ }
19851
+ return { pr, bucket: "external-other", action: "review manually \u2014 merge, defer, or close" };
19852
+ }
19853
+ var OPEN_PR_BUCKET_ORDER = [
19854
+ "held-adhoc-this-cycle",
19855
+ "cycle-branch",
19856
+ "external-other",
19857
+ "stale",
19858
+ "held-adhoc-other"
19859
+ ];
19860
+ function buildOpenPrSweepLines(openPrs, cycleNum, inReviewCycleTaskIds, nowMs) {
19861
+ const lines = [];
19862
+ if (openPrs === null) {
19863
+ lines.push("", "**Open-PR sweep:** skipped \u2014 `gh` unavailable. Run `gh pr list` yourself to check for held/external PRs before considering the cycle closed.");
19864
+ } else if (openPrs.length > 0) {
19865
+ const classified = openPrs.map((pr) => classifyOpenPr(pr, cycleNum, inReviewCycleTaskIds, nowMs)).sort((a, b2) => OPEN_PR_BUCKET_ORDER.indexOf(a.bucket) - OPEN_PR_BUCKET_ORDER.indexOf(b2.bucket));
19866
+ lines.push("", `**Open-PR sweep \u2014 ${openPrs.length} open PR(s). Resolve each before considering Cycle ${cycleNum} closed:**`);
19867
+ for (const c of classified) {
19868
+ lines.push(`- #${c.pr.number} \`${c.pr.headRefName}\` by ${c.pr.author} \u2014 [${c.bucket}] ${c.action}`);
19869
+ }
19870
+ }
19871
+ if (inReviewCycleTaskIds.size > 0) {
19872
+ const prByTask = /* @__PURE__ */ new Map();
19873
+ for (const p of openPrs ?? []) {
19874
+ const id = taskIdFromBranch(p.headRefName);
19875
+ if (id) prByTask.set(id, p.number);
19876
+ }
19877
+ const rows = [...inReviewCycleTaskIds].sort().map((id) => {
19878
+ const prNum = prByTask.get(id);
19879
+ return ` - ${id}${prNum ? ` (PR #${prNum})` : " (no open PR found)"}`;
19880
+ });
19881
+ lines.push("", `\u26A0\uFE0F **${inReviewCycleTaskIds.size} task(s) still In Review, pinned to Cycle ${cycleNum} \u2014 their work is NOT merged. Accept/merge or defer before closing:**`, ...rows);
19882
+ }
19883
+ return lines;
19884
+ }
19730
19885
 
19731
19886
  // src/tools/release.ts
19732
19887
  init_git();
@@ -20217,6 +20372,20 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
20217
20372
  if (result.warnings?.length) {
20218
20373
  lines.push("", "\u26A0\uFE0F Warnings: " + result.warnings.join("; "));
20219
20374
  }
20375
+ try {
20376
+ const openPrs = listOpenPullRequests(config2.projectRoot);
20377
+ const closedCycle = result.cycleClosed ?? 0;
20378
+ let inReviewIds = /* @__PURE__ */ new Set();
20379
+ if (closedCycle > 0) {
20380
+ const board = await adapter2.queryBoard();
20381
+ inReviewIds = new Set(
20382
+ board.filter((t) => t.status === "In Review" && t.cycle === closedCycle).map((t) => t.id)
20383
+ );
20384
+ }
20385
+ const sweep = buildOpenPrSweepLines(openPrs, closedCycle, inReviewIds, Date.now());
20386
+ if (sweep.length > 0) lines.push(...sweep);
20387
+ } catch {
20388
+ }
20220
20389
  tracker.mark("surface-discovered-issues");
20221
20390
  try {
20222
20391
  const closedCycle = result.cycleClosed ?? 0;
@@ -20958,8 +21127,8 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
20958
21127
  } else {
20959
21128
  const stashLabel = `papi-autostash/${taskId}-${Math.floor(Date.now() / 1e3)}`;
20960
21129
  try {
20961
- const { execFileSync: execFileSync6 } = await import("child_process");
20962
- execFileSync6("git", ["stash", "push", "-u", "-m", stashLabel, "--", ...toStash], {
21130
+ const { execFileSync: execFileSync7 } = await import("child_process");
21131
+ execFileSync7("git", ["stash", "push", "-u", "-m", stashLabel, "--", ...toStash], {
20963
21132
  cwd: config2.projectRoot,
20964
21133
  encoding: "utf-8"
20965
21134
  });
@@ -20985,8 +21154,8 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
20985
21154
  if (hasRemote(config2.projectRoot) && !featureBranchExistsLocally) {
20986
21155
  if (featureBranchOnOrigin) {
20987
21156
  try {
20988
- const { execFileSync: execFileSync6 } = await import("child_process");
20989
- execFileSync6("git", ["fetch", "origin", `${featureBranch}:${featureBranch}`], {
21157
+ const { execFileSync: execFileSync7 } = await import("child_process");
21158
+ execFileSync7("git", ["fetch", "origin", `${featureBranch}:${featureBranch}`], {
20990
21159
  cwd: config2.projectRoot,
20991
21160
  encoding: "utf-8",
20992
21161
  timeout: 6e4
@@ -22110,6 +22279,10 @@ var buildExecuteTool = {
22110
22279
  enum: ["yes", "no", "partial"],
22111
22280
  description: "Whether the build was completed. Required for complete."
22112
22281
  },
22282
+ acceptance_confirmed: {
22283
+ type: "boolean",
22284
+ description: `task-2833: set true to assert every acceptance criterion in the task's BUILD HANDOFF was met. Required to record a completed:"yes" build when the handoff lists acceptance criteria \u2014 without it, build_execute returns the criteria checklist and does NOT mark the task Done (the report is not discarded; re-send with acceptance_confirmed:true). Tasks with no acceptance criteria, and completed:"partial"/"no", are unaffected.`
22285
+ },
22113
22286
  effort: {
22114
22287
  type: "string",
22115
22288
  enum: ["XS", "S", "M", "L", "XL"],
@@ -22565,6 +22738,25 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
22565
22738
  if (!parsedEstimatedEffort) {
22566
22739
  return errorResponse(`Invalid estimated_effort value "${estimatedEffort}". Must be one of: XS, S, M, L, XL.`);
22567
22740
  }
22741
+ const acceptanceConfirmed = args.acceptance_confirmed === true;
22742
+ if (completed === "yes" && !acceptanceConfirmed) {
22743
+ const gateInfo = adapter2.getProjectInfo ? await adapter2.getProjectInfo().catch(() => null) : null;
22744
+ const gateCaps = gateInfo?.capabilities ?? {};
22745
+ const gateTask = isCapabilityEnabled(gateCaps, "acceptanceGate") ? await adapter2.getTask(taskId).catch(() => null) : null;
22746
+ const criteria = (gateTask?.buildHandoff?.acceptanceCriteria ?? []).filter((c) => c && c.trim());
22747
+ if (criteria.length > 0) {
22748
+ const checklist = criteria.map((c) => ` - [ ] ${c}`).join("\n");
22749
+ return textResponse(
22750
+ `**Acceptance criteria not yet confirmed for ${taskId}.**
22751
+
22752
+ This task's BUILD HANDOFF lists ${criteria.length} acceptance criteri${criteria.length === 1 ? "on" : "a"}. Confirm each was met, then re-call \`build_execute\` complete with the SAME report fields plus \`acceptance_confirmed: true\`:
22753
+
22754
+ ${checklist}
22755
+
22756
+ Your report was NOT discarded and the task is NOT yet Done \u2014 re-send with \`acceptance_confirmed: true\` to record completion. If a criterion was NOT met, the task is not complete: finish it, or report \`completed: "partial"\` with what remains in \`surprises\`.`
22757
+ );
22758
+ }
22759
+ }
22568
22760
  const tracker = new ProgressTracker("complete_validate").bindStream(adapter2, { stage: "build", taskId });
22569
22761
  try {
22570
22762
  tracker.mark("complete_build");
@@ -24222,6 +24414,13 @@ Re-run build_execute complete with a production_verification field, then re-subm
24222
24414
 
24223
24415
  // src/tools/review.ts
24224
24416
  var REVIEW_DISPATCH_THRESHOLD = 50 * 1024;
24417
+ var REVIEW_DISPATCH_CEILING = 40 * 1024;
24418
+ var REVIEW_ECHO_CAP = 4e3;
24419
+ function trimForEcho(text) {
24420
+ if (text.length <= REVIEW_ECHO_CAP) return text;
24421
+ return `${text.slice(0, REVIEW_ECHO_CAP)}
24422
+ \u2026[trimmed ${text.length - REVIEW_ECHO_CAP} chars]`;
24423
+ }
24225
24424
  var REVIEW_RUBRIC = [
24226
24425
  "You are reviewing a completed PAPI build for acceptance. Judge:",
24227
24426
  "- Correctness: does the change do what the build report claims, without obvious bugs?",
@@ -24551,6 +24750,7 @@ async function handleReviewSubmit(adapter2, config2, args) {
24551
24750
  const autoDispatchOptIn = args.dispatch !== "inline" && process.env.PAPI_AUTO_DISPATCH !== "false" && isCapabilityEnabled(caps, "prReviewer");
24552
24751
  const autoDispatchEligible = !verdict && autoDispatchOptIn;
24553
24752
  const capabilityAutoReviewEligible = verdict === "accept" && !autoReview && autoDispatchOptIn;
24753
+ let capabilityReviewSkippedNote = "";
24554
24754
  if ((explicitDispatch || autoDispatchEligible || capabilityAutoReviewEligible) && stage === "build-acceptance" && taskId) {
24555
24755
  const dispatch = await buildReviewDispatch(
24556
24756
  adapter2,
@@ -24560,8 +24760,16 @@ async function handleReviewSubmit(adapter2, config2, args) {
24560
24760
  );
24561
24761
  if (!dispatch.ok) {
24562
24762
  if (explicitDispatch) return errorResponse(dispatch.error);
24563
- } else if (explicitDispatch || capabilityAutoReviewEligible || dispatch.contextBytes > REVIEW_DISPATCH_THRESHOLD) {
24763
+ } else if (explicitDispatch || autoDispatchEligible && dispatch.contextBytes > REVIEW_DISPATCH_THRESHOLD) {
24564
24764
  return textResponse(dispatch.prompt);
24765
+ } else if (capabilityAutoReviewEligible) {
24766
+ if (dispatch.contextBytes <= REVIEW_DISPATCH_CEILING) {
24767
+ return textResponse(dispatch.prompt);
24768
+ }
24769
+ const kb = (dispatch.contextBytes / 1024).toFixed(0);
24770
+ capabilityReviewSkippedNote = `
24771
+
24772
+ > \u26A0\uFE0F pr-reviewer auto-review skipped \u2014 the diff/build-report (~${kb} KB) exceeds the ${REVIEW_DISPATCH_CEILING / 1024} KB inline-dispatch ceiling, which would overflow the response and drop the accept (task-2854). Verdict recorded directly. To review the diff explicitly, run \`review_submit ${taskId} build-acceptance accept dispatch:"subagent"\`.`;
24565
24773
  }
24566
24774
  }
24567
24775
  if (explicitDispatch && stage !== "build-acceptance") {
@@ -24721,36 +24929,51 @@ ${overlap}`;
24721
24929
 
24722
24930
  \u2705 Verdict recorded. All cycle tasks are Done, but **auto-release is owner-only** \u2014 your identity does not match this project's owner, so no release was cut. Push your branch and open a PR for the owner to run \`release\`.${resolutionNote}`;
24723
24931
  } else if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done")) {
24724
- const baseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
24725
- const unmergedCycleBranches = isGitAvailable() && isGitRepo(config2.projectRoot) ? listGroupedCycleBranches(config2.projectRoot, result.currentCycle, baseBranch) : [];
24726
- if (unmergedCycleBranches.length > 0) {
24932
+ let planRunCount = null;
24933
+ if (typeof adapter2.countPlanRunsForCycle === "function") {
24934
+ try {
24935
+ planRunCount = await adapter2.countPlanRunsForCycle(result.currentCycle);
24936
+ } catch {
24937
+ planRunCount = null;
24938
+ }
24939
+ }
24940
+ if (planRunCount === 0) {
24727
24941
  autoReleaseNote = `
24728
24942
 
24729
24943
  ---
24730
24944
 
24945
+ \u26A0\uFE0F **Auto-release skipped** \u2014 Cycle ${result.currentCycle} has **no plan run** (100% injected/adhoc work), so it was never planned. Auto-release only fires for planned cycles, to avoid silently shipping a cycle nobody opened (C340 incident). All tasks are Done \u2014 run \`release\` explicitly to close and ship this cycle.`;
24946
+ } else {
24947
+ const baseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
24948
+ const unmergedCycleBranches = isGitAvailable() && isGitRepo(config2.projectRoot) ? listGroupedCycleBranches(config2.projectRoot, result.currentCycle, baseBranch) : [];
24949
+ if (unmergedCycleBranches.length > 0) {
24950
+ autoReleaseNote = `
24951
+
24952
+ ---
24953
+
24731
24954
  \u26A0\uFE0F **Auto-release skipped** \u2014 all tasks are Done but ${unmergedCycleBranches.length} cycle branch(es) not yet merged: \`${unmergedCycleBranches.join("`, `")}\`.
24732
24955
 
24733
24956
  Merge or squash those PRs first, then run \`release\` manually.`;
24734
- } else {
24735
- try {
24736
- const allReviews = await adapter2.getRecentReviews(200);
24737
- const cycleReviews = allReviews.filter(
24738
- (r) => r.cycle === result.currentCycle && r.stage === "build-acceptance"
24739
- );
24740
- const reviewsWithAutoReview = cycleReviews.filter((r) => r.autoReview);
24741
- if (reviewsWithAutoReview.length > 0) {
24742
- const verdictCounts = { pass: 0, warn: 0, fail: 0 };
24743
- const findingsBySeverity = { error: 0, warning: 0, info: 0 };
24744
- for (const r of reviewsWithAutoReview) {
24745
- if (r.autoReview) {
24746
- verdictCounts[r.autoReview.verdict] = (verdictCounts[r.autoReview.verdict] ?? 0) + 1;
24747
- for (const f of r.autoReview.findings) {
24748
- findingsBySeverity[f.severity] = (findingsBySeverity[f.severity] ?? 0) + 1;
24957
+ } else {
24958
+ try {
24959
+ const allReviews = await adapter2.getRecentReviews(200);
24960
+ const cycleReviews = allReviews.filter(
24961
+ (r) => r.cycle === result.currentCycle && r.stage === "build-acceptance"
24962
+ );
24963
+ const reviewsWithAutoReview = cycleReviews.filter((r) => r.autoReview);
24964
+ if (reviewsWithAutoReview.length > 0) {
24965
+ const verdictCounts = { pass: 0, warn: 0, fail: 0 };
24966
+ const findingsBySeverity = { error: 0, warning: 0, info: 0 };
24967
+ for (const r of reviewsWithAutoReview) {
24968
+ if (r.autoReview) {
24969
+ verdictCounts[r.autoReview.verdict] = (verdictCounts[r.autoReview.verdict] ?? 0) + 1;
24970
+ for (const f of r.autoReview.findings) {
24971
+ findingsBySeverity[f.severity] = (findingsBySeverity[f.severity] ?? 0) + 1;
24972
+ }
24749
24973
  }
24750
24974
  }
24751
- }
24752
- const totalFindings = findingsBySeverity.error + findingsBySeverity.warning + findingsBySeverity.info;
24753
- batchSummaryNote = `
24975
+ const totalFindings = findingsBySeverity.error + findingsBySeverity.warning + findingsBySeverity.info;
24976
+ batchSummaryNote = `
24754
24977
 
24755
24978
  ---
24756
24979
 
@@ -24758,42 +24981,42 @@ Merge or squash those PRs first, then run \`release\` manually.`;
24758
24981
 
24759
24982
  - Verdicts: ${verdictCounts.pass} pass, ${verdictCounts.warn} warn, ${verdictCounts.fail} fail
24760
24983
  ` + (totalFindings > 0 ? `- Findings: ${findingsBySeverity.error} error${findingsBySeverity.error !== 1 ? "s" : ""}, ${findingsBySeverity.warning} warning${findingsBySeverity.warning !== 1 ? "s" : ""}, ${findingsBySeverity.info} info` : "- No findings logged");
24984
+ }
24985
+ } catch {
24761
24986
  }
24762
- } catch {
24763
- }
24764
- const version = `v0.${result.currentCycle}.0`;
24765
- const autoGate = evaluateReleaseGate(caps, config2.gateCommand, void 0);
24766
- if (autoGate.action !== "proceed") {
24767
- autoReleaseNote = `
24987
+ const version = `v0.${result.currentCycle}.0`;
24988
+ const autoGate = evaluateReleaseGate(caps, config2.gateCommand, void 0);
24989
+ if (autoGate.action !== "proceed") {
24990
+ autoReleaseNote = `
24768
24991
 
24769
24992
  ---
24770
24993
 
24771
24994
  \u26A0\uFE0F **Auto-release skipped** \u2014 a release quality gate is configured (\`${config2.gateCommand}\`), and it cannot be run from inside \`review_submit\`.
24772
24995
 
24773
24996
  Run \`release\` manually: PAPI will hand you the gate command, then release once you report it green.`;
24774
- } else {
24775
- const releaseTracker = new ProgressTracker("auto-release").bindStream(adapter2, { stage: "release" });
24776
- await beginRelease(releaseTracker, result.currentCycle);
24777
- const releaseResult = await createRelease(config2, baseBranch, version, adapter2, result.currentCycle);
24778
- await recordReadinessVerified(releaseTracker);
24779
- await recordQualityGate(releaseTracker, autoGate, caps);
24780
- await tracker.recordStep("auto_release_triggered", { metadata: { version: releaseResult.version } });
24781
- const autoChangelogDirective = buildChangelogDirective(
24782
- caps,
24783
- buildCycleUpdateCurationDirective(releaseResult.version, releaseResult.cycleClosed ?? 0)
24784
- );
24785
- const autoDeployDirective = buildDeployHookDirective(caps, config2.deployCommand);
24786
- await completeRelease(releaseTracker, {
24787
- cycleClosed: releaseResult.cycleClosed ?? null,
24788
- version: releaseResult.version,
24789
- caps,
24790
- branchMerges: releaseResult.groupedBranchMerges ?? [],
24791
- changelogEmitted: Boolean(autoChangelogDirective),
24792
- deployHookEmitted: Boolean(autoDeployDirective)
24793
- });
24794
- const pushInfo = releaseResult.pushNotes.join(" ");
24795
- const groupedMergeNote = releaseResult.groupedBranchMerges?.length ? "\n" + releaseResult.groupedBranchMerges.map((r) => `- Merged shared branch \`${r.branch}\` via PR: ${r.prUrl ?? "n/a"}`).join("\n") : "";
24796
- autoReleaseNote = `
24997
+ } else {
24998
+ const releaseTracker = new ProgressTracker("auto-release").bindStream(adapter2, { stage: "release" });
24999
+ await beginRelease(releaseTracker, result.currentCycle);
25000
+ const releaseResult = await createRelease(config2, baseBranch, version, adapter2, result.currentCycle);
25001
+ await recordReadinessVerified(releaseTracker);
25002
+ await recordQualityGate(releaseTracker, autoGate, caps);
25003
+ await tracker.recordStep("auto_release_triggered", { metadata: { version: releaseResult.version } });
25004
+ const autoChangelogDirective = buildChangelogDirective(
25005
+ caps,
25006
+ buildCycleUpdateCurationDirective(releaseResult.version, releaseResult.cycleClosed ?? 0)
25007
+ );
25008
+ const autoDeployDirective = buildDeployHookDirective(caps, config2.deployCommand);
25009
+ await completeRelease(releaseTracker, {
25010
+ cycleClosed: releaseResult.cycleClosed ?? null,
25011
+ version: releaseResult.version,
25012
+ caps,
25013
+ branchMerges: releaseResult.groupedBranchMerges ?? [],
25014
+ changelogEmitted: Boolean(autoChangelogDirective),
25015
+ deployHookEmitted: Boolean(autoDeployDirective)
25016
+ });
25017
+ const pushInfo = releaseResult.pushNotes.join(" ");
25018
+ const groupedMergeNote = releaseResult.groupedBranchMerges?.length ? "\n" + releaseResult.groupedBranchMerges.map((r) => `- Merged shared branch \`${r.branch}\` via PR: ${r.prUrl ?? "n/a"}`).join("\n") : "";
25019
+ autoReleaseNote = `
24797
25020
 
24798
25021
  ---
24799
25022
 
@@ -24804,13 +25027,14 @@ Run \`release\` manually: PAPI will hand you the gate command, then release once
24804
25027
  - ${releaseResult.tagMessage}
24805
25028
  - ${pushInfo}` + groupedMergeNote + (releaseResult.warnings?.length ? `
24806
25029
  - Warnings: ${releaseResult.warnings.join(", ")}` : "") + // task-2598 (C328): the auto path previously swallowed the curated
24807
- // cycle-update directive that the manual path emits, so an auto-released
24808
- // cycle never prompted the Discord post. Same directive, same gate.
24809
- (autoChangelogDirective ? `
25030
+ // cycle-update directive that the manual path emits, so an auto-released
25031
+ // cycle never prompted the Discord post. Same directive, same gate.
25032
+ (autoChangelogDirective ? `
24810
25033
  ${autoChangelogDirective}` : "") + (autoDeployDirective ? `
24811
25034
  ${autoDeployDirective}` : "") + `
24812
25035
 
24813
25036
  Run \`plan\` to create Cycle ${result.currentCycle + 1}.`;
25037
+ }
24814
25038
  }
24815
25039
  }
24816
25040
  }
@@ -24870,9 +25094,9 @@ Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
24870
25094
  `**${result.stageLabel}** recorded for ${result.taskId}.
24871
25095
 
24872
25096
  - **Verdict:** ${result.verdict}
24873
- - **Comments:** ${result.comments}
25097
+ - **Comments:** ${trimForEcho(result.comments)}
24874
25098
 
24875
- ${statusNote}${autoReviewNote}${securityNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
25099
+ ${statusNote}${capabilityReviewSkippedNote}${autoReviewNote}${securityNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
24876
25100
  );
24877
25101
  } catch (err) {
24878
25102
  const message = err instanceof Error ? err.message : String(err);
@@ -29738,6 +29962,13 @@ function startHttpTransport(opts) {
29738
29962
  const ip = clientIp(req);
29739
29963
  const origin = req.headers.origin;
29740
29964
  const cors = corsHeaders(origin);
29965
+ const pathname = (() => {
29966
+ try {
29967
+ return new URL(req.url ?? "/", "http://internal").pathname;
29968
+ } catch {
29969
+ return req.url ?? "/";
29970
+ }
29971
+ })();
29741
29972
  if (req.method === "OPTIONS") {
29742
29973
  if (Object.keys(cors).length === 0) {
29743
29974
  sendError(res, { status: 403, body: { error: "Origin not allowed" } });
@@ -29752,12 +29983,12 @@ function startHttpTransport(opts) {
29752
29983
  sendError(res, { status: 400, body: { error: "HTTPS required" } });
29753
29984
  return;
29754
29985
  }
29755
- if (req.method === "GET" && req.url === "/healthz") {
29986
+ if (req.method === "GET" && pathname === "/healthz") {
29756
29987
  res.writeHead(200, { "Content-Type": "text/plain", ...cors });
29757
29988
  res.end("ok");
29758
29989
  return;
29759
29990
  }
29760
- if (req.method === "GET" && req.url === "/.well-known/oauth-protected-resource") {
29991
+ if (req.method === "GET" && pathname === "/.well-known/oauth-protected-resource") {
29761
29992
  res.writeHead(200, {
29762
29993
  "Content-Type": "application/json",
29763
29994
  "Cache-Control": "public, max-age=3600",
@@ -29773,7 +30004,7 @@ function startHttpTransport(opts) {
29773
30004
  );
29774
30005
  return;
29775
30006
  }
29776
- if (req.method === "GET" && req.url === "/.well-known/glama.json") {
30007
+ if (req.method === "GET" && pathname === "/.well-known/glama.json") {
29777
30008
  res.writeHead(200, {
29778
30009
  "Content-Type": "application/json",
29779
30010
  "Cache-Control": "public, max-age=3600",
@@ -29787,7 +30018,7 @@ function startHttpTransport(opts) {
29787
30018
  );
29788
30019
  return;
29789
30020
  }
29790
- if (req.method === "GET" && req.url === "/.well-known/oauth-authorization-server") {
30021
+ if (req.method === "GET" && pathname === "/.well-known/oauth-authorization-server") {
29791
30022
  res.writeHead(302, {
29792
30023
  Location: `${DASHBOARD_ORIGIN}/.well-known/oauth-authorization-server`,
29793
30024
  "Cache-Control": "public, max-age=3600",
@@ -29796,7 +30027,7 @@ function startHttpTransport(opts) {
29796
30027
  res.end();
29797
30028
  return;
29798
30029
  }
29799
- if (req.url !== "/mcp" && req.url !== "/sse") {
30030
+ if (pathname !== "/mcp" && pathname !== "/sse") {
29800
30031
  sendError(res, { status: 404, body: { error: "Not found" } }, cors);
29801
30032
  return;
29802
30033
  }
@@ -30076,6 +30307,7 @@ Options:
30076
30307
  --yes, -y Skip confirmation prompts (reset only)
30077
30308
  --idle-mcp Flag PAPI-idle projects in audit (needs DATABASE_URL; read-only)
30078
30309
  --fix-pool Terminate this role's confirmed-wedged DB backends (doctor only; guarded)
30310
+ --reap-orphans Terminate parentless @papi-ai/server processes (doctor only; guarded)
30079
30311
 
30080
30312
  Getting started:
30081
30313
  1. Run "npx @papi-ai/server setup" in any project folder
@@ -30238,6 +30470,15 @@ if (isHttpMode && httpPort !== void 0) {
30238
30470
  process.stderr.write("[papi] Fatal: stdio mode requires an MCP server instance.\n");
30239
30471
  process.exit(1);
30240
30472
  }
30473
+ try {
30474
+ const { reapOrphans: reapOrphans2 } = await Promise.resolve().then(() => (init_reap_orphans(), reap_orphans_exports));
30475
+ const swept = reapOrphans2({});
30476
+ if (swept.reaped.length > 0) {
30477
+ process.stderr.write(`[papi] Reaped ${swept.reaped.length} orphaned server process(es): ${swept.reaped.join(", ")}
30478
+ `);
30479
+ }
30480
+ } catch {
30481
+ }
30241
30482
  const transport = new StdioServerTransport();
30242
30483
  await server.connect(transport);
30243
30484
  const projectName = basename2(config.projectRoot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.62",
3
+ "version": "0.7.63",
4
4
  "description": "PAPI MCP server — AI-powered sprint planning, build execution, and strategy review for software projects",
5
5
  "license": "Elastic-2.0",
6
6
  "mcpName": "io.github.getpapi/papi",
@@ -38,6 +38,7 @@ When a conversation starts — fresh window, new session, or after context compr
38
38
  - **All in-cycle, in-module tasks share `feat/cycle-N-<module>`** regardless of complexity. One branch per module per cycle, merged together. Module-less tasks fall back to a per-task branch.
39
39
  - **Dependent tasks (any size):** When a task's BUILD HANDOFF lists a `DEPENDS ON` task from the same cycle, `build_execute` automatically reuses the upstream task's branch so commits stack for a single PR. Do not create a separate branch manually.
40
40
  - **Commit per task within grouped branches** — traceable git history.
41
+ - **Integration + gate before release when a cycle fans to more than 4 module branches.** A wide cycle has no single point where all branches are proven together, so a collision between two branches only surfaces at release. When a cycle has more than 4 module branches, before `release`: (1) cut an integration branch off your main branch, (2) merge every cycle module branch into it, (3) run your full local gate/test suite on the integrated result, (4) resolve any cross-branch collisions there, (5) release from the integrated branch. This is a habit, not automation — release does not block on branch count.
41
42
  - **Never use `build_execute` with `light=true` on shared branches.** Light mode commits directly to the current branch without creating a PR. When a shared branch is squash-merged, those commits are collapsed — any CLAUDE.md or documentation changes are stripped. Use light mode only on isolated single-task branches where no squash-merge will occur.
42
43
 
43
44
  ## Plumbing Is Autonomous