@papi-ai/server 0.7.61 → 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.
@@ -88,6 +88,13 @@ Your output is **not done** if ANY of these is true (these are falsifiable — c
88
88
  - [ ] Guidance, state, or a next action explained in a sentence of helper text where a visual cue
89
89
  (position, size, colour, icon, a signpost, motion-on-change) would carry it — prose used as a
90
90
  crutch for missing visual steering.
91
+ - [ ] A label, its value, and its explanation each taking their own line ("The Stacked Caption").
92
+ Anywhere the three exist, they share ONE line.
93
+ - [ ] A new datum added as a sibling row beside a row it could extend ("The Siblinged Row").
94
+ Extend the existing row — inline suffix, extra cell, chip on the same baseline — never stack
95
+ a twin row underneath.
96
+ - [ ] An eyebrow label or an explanatory subtitle above/below a heading ("The Eyebrow Crutch").
97
+ Labels carry themselves; if a heading needs a subtitle to be understood, the heading is wrong.
91
98
  - [ ] More than ~3 sections fully visible above the fold.
92
99
  - [ ] Identical-sized cards in a uniform row (flat hierarchy — needs a dominant 2×+ cell).
93
100
  - [ ] Monospace on labels, status, timestamps, or nav.
@@ -108,6 +108,12 @@ Run through every named anti-pattern and flag matches. Common universal ones:
108
108
  - Guidance, state, or a next action carried by a sentence of helper text where a visual cue
109
109
  (position, size, colour, an icon, a signpost, motion-on-change) would do — prose as a crutch for
110
110
  missing visual steering. Prefer showing over telling.
111
+ - A label, its value, and its explanation each taking their own line ("The Stacked Caption") —
112
+ anywhere the three exist, they share ONE line.
113
+ - A new datum added as a sibling row beside a row it could extend ("The Siblinged Row") — extend
114
+ the existing row (inline suffix, extra cell, chip on the same baseline), never stack a twin row.
115
+ - An eyebrow label or explanatory subtitle above/below a heading ("The Eyebrow Crutch") — labels
116
+ carry themselves; a heading that needs a subtitle to be understood is the wrong heading.
111
117
  - Identical-sized cards in a uniform row (flat hierarchy).
112
118
  - Monospace on labels / status / timestamps / nav.
113
119
  - Header, label, and value at the same size+weight.
@@ -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;
@@ -5059,7 +5154,7 @@ var init_setup = __esm({
5059
5154
 
5060
5155
  // src/index.ts
5061
5156
  import { readFileSync as readFileSync16 } from "fs";
5062
- import { dirname as dirname6, join as join24 } from "path";
5157
+ import { dirname as dirname6, join as join24, basename as basename2 } from "path";
5063
5158
  import { fileURLToPath as fileURLToPath4 } from "url";
5064
5159
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5065
5160
  import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
@@ -12765,6 +12860,7 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
12765
12860
  const contextBytes2 = Buffer.byteLength(userMessage2, "utf-8");
12766
12861
  console.error(`[plan-perf] contextBytes=${contextBytes2} (handoffs-only)`);
12767
12862
  const planSystemPrompt2 = await getPrompt("plan-system");
12863
+ await recordPlanGenerationActive(tracker, incomingCycle);
12768
12864
  return {
12769
12865
  mode: "full",
12770
12866
  // apply phase treats it the same
@@ -12825,6 +12921,7 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
12825
12921
  } catch {
12826
12922
  }
12827
12923
  const planSystemPrompt = await getPrompt("plan-system");
12924
+ await recordPlanGenerationActive(tracker, incomingCycle);
12828
12925
  return {
12829
12926
  mode,
12830
12927
  cycleNumber,
@@ -12848,6 +12945,15 @@ async function streamPlanStageSteps(tracker, newCycleNumber) {
12848
12945
  await tracker.recordStep(step);
12849
12946
  }
12850
12947
  }
12948
+ async function recordPlanGenerationActive(tracker, incomingCycleNumber) {
12949
+ if (!tracker) return;
12950
+ await tracker.recordStep("recommendation", {
12951
+ cycle: incomingCycleNumber,
12952
+ stage: "plan",
12953
+ status: "active",
12954
+ metadata: { phase: "generating" }
12955
+ });
12956
+ }
12851
12957
  async function applyPlan(adapter2, config2, rawLlmOutput, mode, cycleNumber, strategyReviewWarning, contextHashes, planRunMeta, tracker) {
12852
12958
  const applyTimer = startTimer();
12853
12959
  console.error(`[plan-perf] applyPlan: start (llm_response=${rawLlmOutput.length} chars)`);
@@ -15929,6 +16035,11 @@ Confidence: ${input.confidence}. Captured mid-conversation via strategy_change c
15929
16035
  }
15930
16036
 
15931
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
+ }
15932
16043
  var reviewPrepareCache = new PerCallerCache();
15933
16044
  var strategyReviewTool = {
15934
16045
  name: "strategy_review",
@@ -16230,7 +16341,7 @@ This topic will surface in the next \`strategy_review\`.`
16230
16341
  const lines = topics.map((t, i) => {
16231
16342
  const cycleSuffix = t.sourceCycle != null ? ` (Cycle ${t.sourceCycle})` : "";
16232
16343
  return `${i + 1}. ${t.topic}
16233
- _source: ${t.source}${cycleSuffix} \xB7 queued ${t.createdAt.slice(0, 10)}_`;
16344
+ _source: ${t.source}${cycleSuffix} \xB7 queued ${toDateLabel(t.createdAt)}_`;
16234
16345
  });
16235
16346
  return textResponse(
16236
16347
  `**Pending Agenda (${topics.length})** \u2014 surfaces at next strategy review
@@ -19716,6 +19827,61 @@ async function completeRelease(tracker, opts) {
19716
19827
  }
19717
19828
  await tracker.recordStep("released", { metadata: { version: opts.version } });
19718
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
+ }
19719
19885
 
19720
19886
  // src/tools/release.ts
19721
19887
  init_git();
@@ -20206,6 +20372,20 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
20206
20372
  if (result.warnings?.length) {
20207
20373
  lines.push("", "\u26A0\uFE0F Warnings: " + result.warnings.join("; "));
20208
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
+ }
20209
20389
  tracker.mark("surface-discovered-issues");
20210
20390
  try {
20211
20391
  const closedCycle = result.cycleClosed ?? 0;
@@ -20947,8 +21127,8 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
20947
21127
  } else {
20948
21128
  const stashLabel = `papi-autostash/${taskId}-${Math.floor(Date.now() / 1e3)}`;
20949
21129
  try {
20950
- const { execFileSync: execFileSync6 } = await import("child_process");
20951
- 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], {
20952
21132
  cwd: config2.projectRoot,
20953
21133
  encoding: "utf-8"
20954
21134
  });
@@ -20974,8 +21154,8 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
20974
21154
  if (hasRemote(config2.projectRoot) && !featureBranchExistsLocally) {
20975
21155
  if (featureBranchOnOrigin) {
20976
21156
  try {
20977
- const { execFileSync: execFileSync6 } = await import("child_process");
20978
- execFileSync6("git", ["fetch", "origin", `${featureBranch}:${featureBranch}`], {
21157
+ const { execFileSync: execFileSync7 } = await import("child_process");
21158
+ execFileSync7("git", ["fetch", "origin", `${featureBranch}:${featureBranch}`], {
20979
21159
  cwd: config2.projectRoot,
20980
21160
  encoding: "utf-8",
20981
21161
  timeout: 6e4
@@ -22099,6 +22279,10 @@ var buildExecuteTool = {
22099
22279
  enum: ["yes", "no", "partial"],
22100
22280
  description: "Whether the build was completed. Required for complete."
22101
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
+ },
22102
22286
  effort: {
22103
22287
  type: "string",
22104
22288
  enum: ["XS", "S", "M", "L", "XL"],
@@ -22436,6 +22620,27 @@ ${entries}`;
22436
22620
  }
22437
22621
  } catch {
22438
22622
  }
22623
+ let openIssuesSection = "";
22624
+ try {
22625
+ if (adapter2.getCycleLearnings) {
22626
+ const issues = (await adapter2.getCycleLearnings({ category: "issue", limit: 20 })).filter((l) => !l.resolvedAt && l.id);
22627
+ const taskIds = new Set([result.task.id, result.task.displayId].filter(Boolean));
22628
+ const moduleTag = result.task.module?.trim().toLowerCase();
22629
+ const score = (l) => (taskIds.has(l.taskId) ? 2 : 0) + (moduleTag && l.tags.some((t) => t.toLowerCase() === moduleTag) ? 1 : 0);
22630
+ const top = issues.sort((a, b2) => score(b2) - score(a)).slice(0, 8);
22631
+ if (top.length > 0) {
22632
+ const rows = top.map((l) => `- \`${l.id}\` \xB7 ${l.severity ?? "P3"} \xB7 ${l.summary}`).join("\n");
22633
+ openIssuesSection = `
22634
+
22635
+ ---
22636
+
22637
+ **OPEN DISCOVERED ISSUES** (${top.length} shown):
22638
+ ${rows}
22639
+ If this build fixes any of these, pass their UUIDs in \`fixed_issues\` on complete \u2014 that stamps them FIXED on the caught\u2192fixed ledger. Do not fix out-of-scope issues just to clear the list.`;
22640
+ }
22641
+ }
22642
+ } catch {
22643
+ }
22439
22644
  const moduleInstructions = getModuleInstructions(result.task.module);
22440
22645
  const moduleContext = await getModuleContext(adapter2, result.task);
22441
22646
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
@@ -22444,7 +22649,7 @@ ${entries}`;
22444
22649
  formatModelRecommendation(result.task.buildHandoff?.effort ?? result.task.complexity)
22445
22650
  ) ?? "";
22446
22651
  const gestaltNote = buildGestaltPreBuildDirective(caps) ?? "";
22447
- return textResponse(header + serializeBuildHandoff(result.task.buildHandoff) + modelNote + gestaltNote + adSection + moduleInstructions + moduleContext + dogfoodSection + verificationNote + buildDisciplineNote + chainInstruction + phaseNote + filesToWriteSection);
22652
+ return textResponse(header + serializeBuildHandoff(result.task.buildHandoff) + modelNote + gestaltNote + adSection + moduleInstructions + moduleContext + dogfoodSection + openIssuesSection + verificationNote + buildDisciplineNote + chainInstruction + phaseNote + filesToWriteSection);
22448
22653
  } catch (err) {
22449
22654
  if (isNoHandoffError(err)) {
22450
22655
  const lines = [
@@ -22533,6 +22738,25 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
22533
22738
  if (!parsedEstimatedEffort) {
22534
22739
  return errorResponse(`Invalid estimated_effort value "${estimatedEffort}". Must be one of: XS, S, M, L, XL.`);
22535
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
+ }
22536
22760
  const tracker = new ProgressTracker("complete_validate").bindStream(adapter2, { stage: "build", taskId });
22537
22761
  try {
22538
22762
  tracker.mark("complete_build");
@@ -22618,9 +22842,14 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
22618
22842
  batchRollupNote = "";
22619
22843
  }
22620
22844
  }
22621
- const fixedNote = fixedResolvedCount > 0 ? `
22845
+ let fixedNote = "";
22846
+ if (fixedResolvedCount > 0) {
22847
+ fixedNote = `
22622
22848
 
22623
- \u2705 Marked ${fixedResolvedCount} discovered issue(s) FIXED \u2014 resolved_at stamped, now counted as fixed on the hub's caught\u2192fixed ledger.` : "";
22849
+ \u2705 Marked ${fixedResolvedCount} discovered issue(s) FIXED \u2014 resolved_at stamped, now counted as fixed on the hub's caught\u2192fixed ledger.`;
22850
+ } else if (discoveredIssues && discoveredIssues.trim() !== "" && !/^none\b/i.test(discoveredIssues.trim())) {
22851
+ fixedNote = "\n\n\u2139\uFE0F This build filed discovered issues but passed no `fixed_issues`. When a future build fixes one, pass its UUID in `fixed_issues` so it counts as FIXED (not just auto-cleared) on the hub ledger.";
22852
+ }
22624
22853
  return textResponse(formatCompleteResult(result) + fixedNote + docsNote + batchRollupNote);
22625
22854
  } catch (err) {
22626
22855
  const message = err instanceof Error ? err.message : String(err);
@@ -23088,7 +23317,11 @@ async function handleBug(adapter2, config2, args) {
23088
23317
  type,
23089
23318
  description: text,
23090
23319
  diagnostics,
23091
- status: "open",
23320
+ // Canonical triage vocabulary (submitted|investigating|fixed|wont_fix).
23321
+ // Was 'open' — a third vocabulary that made the owner triage console
23322
+ // render new upstream reports as un-triaged. Adapters pass status through,
23323
+ // so emitting canonical here keeps hosted + pg writes consistent.
23324
+ status: "submitted",
23092
23325
  notifyRequested,
23093
23326
  contactOk
23094
23327
  });
@@ -24181,6 +24414,13 @@ Re-run build_execute complete with a production_verification field, then re-subm
24181
24414
 
24182
24415
  // src/tools/review.ts
24183
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
+ }
24184
24424
  var REVIEW_RUBRIC = [
24185
24425
  "You are reviewing a completed PAPI build for acceptance. Judge:",
24186
24426
  "- Correctness: does the change do what the build report claims, without obvious bugs?",
@@ -24510,6 +24750,7 @@ async function handleReviewSubmit(adapter2, config2, args) {
24510
24750
  const autoDispatchOptIn = args.dispatch !== "inline" && process.env.PAPI_AUTO_DISPATCH !== "false" && isCapabilityEnabled(caps, "prReviewer");
24511
24751
  const autoDispatchEligible = !verdict && autoDispatchOptIn;
24512
24752
  const capabilityAutoReviewEligible = verdict === "accept" && !autoReview && autoDispatchOptIn;
24753
+ let capabilityReviewSkippedNote = "";
24513
24754
  if ((explicitDispatch || autoDispatchEligible || capabilityAutoReviewEligible) && stage === "build-acceptance" && taskId) {
24514
24755
  const dispatch = await buildReviewDispatch(
24515
24756
  adapter2,
@@ -24519,8 +24760,16 @@ async function handleReviewSubmit(adapter2, config2, args) {
24519
24760
  );
24520
24761
  if (!dispatch.ok) {
24521
24762
  if (explicitDispatch) return errorResponse(dispatch.error);
24522
- } else if (explicitDispatch || capabilityAutoReviewEligible || dispatch.contextBytes > REVIEW_DISPATCH_THRESHOLD) {
24763
+ } else if (explicitDispatch || autoDispatchEligible && dispatch.contextBytes > REVIEW_DISPATCH_THRESHOLD) {
24523
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"\`.`;
24524
24773
  }
24525
24774
  }
24526
24775
  if (explicitDispatch && stage !== "build-acceptance") {
@@ -24680,36 +24929,51 @@ ${overlap}`;
24680
24929
 
24681
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}`;
24682
24931
  } else if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done")) {
24683
- const baseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
24684
- const unmergedCycleBranches = isGitAvailable() && isGitRepo(config2.projectRoot) ? listGroupedCycleBranches(config2.projectRoot, result.currentCycle, baseBranch) : [];
24685
- 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) {
24686
24941
  autoReleaseNote = `
24687
24942
 
24688
24943
  ---
24689
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
+
24690
24954
  \u26A0\uFE0F **Auto-release skipped** \u2014 all tasks are Done but ${unmergedCycleBranches.length} cycle branch(es) not yet merged: \`${unmergedCycleBranches.join("`, `")}\`.
24691
24955
 
24692
24956
  Merge or squash those PRs first, then run \`release\` manually.`;
24693
- } else {
24694
- try {
24695
- const allReviews = await adapter2.getRecentReviews(200);
24696
- const cycleReviews = allReviews.filter(
24697
- (r) => r.cycle === result.currentCycle && r.stage === "build-acceptance"
24698
- );
24699
- const reviewsWithAutoReview = cycleReviews.filter((r) => r.autoReview);
24700
- if (reviewsWithAutoReview.length > 0) {
24701
- const verdictCounts = { pass: 0, warn: 0, fail: 0 };
24702
- const findingsBySeverity = { error: 0, warning: 0, info: 0 };
24703
- for (const r of reviewsWithAutoReview) {
24704
- if (r.autoReview) {
24705
- verdictCounts[r.autoReview.verdict] = (verdictCounts[r.autoReview.verdict] ?? 0) + 1;
24706
- for (const f of r.autoReview.findings) {
24707
- 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
+ }
24708
24973
  }
24709
24974
  }
24710
- }
24711
- const totalFindings = findingsBySeverity.error + findingsBySeverity.warning + findingsBySeverity.info;
24712
- batchSummaryNote = `
24975
+ const totalFindings = findingsBySeverity.error + findingsBySeverity.warning + findingsBySeverity.info;
24976
+ batchSummaryNote = `
24713
24977
 
24714
24978
  ---
24715
24979
 
@@ -24717,42 +24981,42 @@ Merge or squash those PRs first, then run \`release\` manually.`;
24717
24981
 
24718
24982
  - Verdicts: ${verdictCounts.pass} pass, ${verdictCounts.warn} warn, ${verdictCounts.fail} fail
24719
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 {
24720
24986
  }
24721
- } catch {
24722
- }
24723
- const version = `v0.${result.currentCycle}.0`;
24724
- const autoGate = evaluateReleaseGate(caps, config2.gateCommand, void 0);
24725
- if (autoGate.action !== "proceed") {
24726
- autoReleaseNote = `
24987
+ const version = `v0.${result.currentCycle}.0`;
24988
+ const autoGate = evaluateReleaseGate(caps, config2.gateCommand, void 0);
24989
+ if (autoGate.action !== "proceed") {
24990
+ autoReleaseNote = `
24727
24991
 
24728
24992
  ---
24729
24993
 
24730
24994
  \u26A0\uFE0F **Auto-release skipped** \u2014 a release quality gate is configured (\`${config2.gateCommand}\`), and it cannot be run from inside \`review_submit\`.
24731
24995
 
24732
24996
  Run \`release\` manually: PAPI will hand you the gate command, then release once you report it green.`;
24733
- } else {
24734
- const releaseTracker = new ProgressTracker("auto-release").bindStream(adapter2, { stage: "release" });
24735
- await beginRelease(releaseTracker, result.currentCycle);
24736
- const releaseResult = await createRelease(config2, baseBranch, version, adapter2, result.currentCycle);
24737
- await recordReadinessVerified(releaseTracker);
24738
- await recordQualityGate(releaseTracker, autoGate, caps);
24739
- await tracker.recordStep("auto_release_triggered", { metadata: { version: releaseResult.version } });
24740
- const autoChangelogDirective = buildChangelogDirective(
24741
- caps,
24742
- buildCycleUpdateCurationDirective(releaseResult.version, releaseResult.cycleClosed ?? 0)
24743
- );
24744
- const autoDeployDirective = buildDeployHookDirective(caps, config2.deployCommand);
24745
- await completeRelease(releaseTracker, {
24746
- cycleClosed: releaseResult.cycleClosed ?? null,
24747
- version: releaseResult.version,
24748
- caps,
24749
- branchMerges: releaseResult.groupedBranchMerges ?? [],
24750
- changelogEmitted: Boolean(autoChangelogDirective),
24751
- deployHookEmitted: Boolean(autoDeployDirective)
24752
- });
24753
- const pushInfo = releaseResult.pushNotes.join(" ");
24754
- const groupedMergeNote = releaseResult.groupedBranchMerges?.length ? "\n" + releaseResult.groupedBranchMerges.map((r) => `- Merged shared branch \`${r.branch}\` via PR: ${r.prUrl ?? "n/a"}`).join("\n") : "";
24755
- 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 = `
24756
25020
 
24757
25021
  ---
24758
25022
 
@@ -24763,13 +25027,14 @@ Run \`release\` manually: PAPI will hand you the gate command, then release once
24763
25027
  - ${releaseResult.tagMessage}
24764
25028
  - ${pushInfo}` + groupedMergeNote + (releaseResult.warnings?.length ? `
24765
25029
  - Warnings: ${releaseResult.warnings.join(", ")}` : "") + // task-2598 (C328): the auto path previously swallowed the curated
24766
- // cycle-update directive that the manual path emits, so an auto-released
24767
- // cycle never prompted the Discord post. Same directive, same gate.
24768
- (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 ? `
24769
25033
  ${autoChangelogDirective}` : "") + (autoDeployDirective ? `
24770
25034
  ${autoDeployDirective}` : "") + `
24771
25035
 
24772
25036
  Run \`plan\` to create Cycle ${result.currentCycle + 1}.`;
25037
+ }
24773
25038
  }
24774
25039
  }
24775
25040
  }
@@ -24829,9 +25094,9 @@ Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
24829
25094
  `**${result.stageLabel}** recorded for ${result.taskId}.
24830
25095
 
24831
25096
  - **Verdict:** ${result.verdict}
24832
- - **Comments:** ${result.comments}
25097
+ - **Comments:** ${trimForEcho(result.comments)}
24833
25098
 
24834
- ${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}`
24835
25100
  );
24836
25101
  } catch (err) {
24837
25102
  const message = err instanceof Error ? err.message : String(err);
@@ -29697,6 +29962,13 @@ function startHttpTransport(opts) {
29697
29962
  const ip = clientIp(req);
29698
29963
  const origin = req.headers.origin;
29699
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
+ })();
29700
29972
  if (req.method === "OPTIONS") {
29701
29973
  if (Object.keys(cors).length === 0) {
29702
29974
  sendError(res, { status: 403, body: { error: "Origin not allowed" } });
@@ -29711,12 +29983,12 @@ function startHttpTransport(opts) {
29711
29983
  sendError(res, { status: 400, body: { error: "HTTPS required" } });
29712
29984
  return;
29713
29985
  }
29714
- if (req.method === "GET" && req.url === "/healthz") {
29986
+ if (req.method === "GET" && pathname === "/healthz") {
29715
29987
  res.writeHead(200, { "Content-Type": "text/plain", ...cors });
29716
29988
  res.end("ok");
29717
29989
  return;
29718
29990
  }
29719
- if (req.method === "GET" && req.url === "/.well-known/oauth-protected-resource") {
29991
+ if (req.method === "GET" && pathname === "/.well-known/oauth-protected-resource") {
29720
29992
  res.writeHead(200, {
29721
29993
  "Content-Type": "application/json",
29722
29994
  "Cache-Control": "public, max-age=3600",
@@ -29732,7 +30004,7 @@ function startHttpTransport(opts) {
29732
30004
  );
29733
30005
  return;
29734
30006
  }
29735
- if (req.method === "GET" && req.url === "/.well-known/glama.json") {
30007
+ if (req.method === "GET" && pathname === "/.well-known/glama.json") {
29736
30008
  res.writeHead(200, {
29737
30009
  "Content-Type": "application/json",
29738
30010
  "Cache-Control": "public, max-age=3600",
@@ -29746,7 +30018,7 @@ function startHttpTransport(opts) {
29746
30018
  );
29747
30019
  return;
29748
30020
  }
29749
- if (req.method === "GET" && req.url === "/.well-known/oauth-authorization-server") {
30021
+ if (req.method === "GET" && pathname === "/.well-known/oauth-authorization-server") {
29750
30022
  res.writeHead(302, {
29751
30023
  Location: `${DASHBOARD_ORIGIN}/.well-known/oauth-authorization-server`,
29752
30024
  "Cache-Control": "public, max-age=3600",
@@ -29755,7 +30027,7 @@ function startHttpTransport(opts) {
29755
30027
  res.end();
29756
30028
  return;
29757
30029
  }
29758
- if (req.url !== "/mcp" && req.url !== "/sse") {
30030
+ if (pathname !== "/mcp" && pathname !== "/sse") {
29759
30031
  sendError(res, { status: 404, body: { error: "Not found" } }, cors);
29760
30032
  return;
29761
30033
  }
@@ -30035,6 +30307,7 @@ Options:
30035
30307
  --yes, -y Skip confirmation prompts (reset only)
30036
30308
  --idle-mcp Flag PAPI-idle projects in audit (needs DATABASE_URL; read-only)
30037
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)
30038
30311
 
30039
30312
  Getting started:
30040
30313
  1. Run "npx @papi-ai/server setup" in any project folder
@@ -30197,6 +30470,21 @@ if (isHttpMode && httpPort !== void 0) {
30197
30470
  process.stderr.write("[papi] Fatal: stdio mode requires an MCP server instance.\n");
30198
30471
  process.exit(1);
30199
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
+ }
30200
30482
  const transport = new StdioServerTransport();
30201
30483
  await server.connect(transport);
30484
+ const projectName = basename2(config.projectRoot);
30485
+ const projectIdShort = config.projectId ? ` (${config.projectId.slice(0, 8)})` : process.env.PAPI_PROJECT_ID ? ` (${process.env.PAPI_PROJECT_ID.slice(0, 8)})` : "";
30486
+ process.stderr.write(
30487
+ `[papi] Connected \u2014 project: ${projectName}${projectIdShort}, adapter: ${config.adapterType}, v${pkgVersion}
30488
+ `
30489
+ );
30202
30490
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.61",
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