@papi-ai/server 0.7.62 → 0.7.64

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.
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"], {
@@ -1306,12 +1327,12 @@ var init_proxy_adapter = __esm({
1306
1327
  "listContributorReleasePrs",
1307
1328
  "claimReview",
1308
1329
  "getSiblingAds",
1309
- "getSiblingRepoTasks",
1310
- // task-2828 (C339): attributed-intelligence analytics reader — pg-only this cycle.
1311
- // Hosted forwarding needs a SECURITY DEFINER RPC + edge handler (like task-2394 did
1312
- // for getModuleEstimationStats); until then keep it here so hosted degrades to a
1313
- // safe `undefined` rather than forwarding into a 403. Wire under task-2390.
1314
- "getModelOutcomeStats"
1330
+ "getSiblingRepoTasks"
1331
+ // task-2828 (C339): attributed-intelligence analytics reader — pg-only that cycle.
1332
+ // task-2864 (C343): WIRED. getModelOutcomeStats now has an edge case handler (raw
1333
+ // SQL via postgres.js mirroring the pg query + inlined computeModelOutcomes bucketing)
1334
+ // + an ALLOWED_METHODS entry, so it forwards. countPlanRunsForCycle wired alongside it
1335
+ // (task-2860's auto-release guard reader). Both REMOVED from NO_FORWARD.
1315
1336
  // task-2394 (C329) — Batch A wired: findPendingDocActionsForTask,
1316
1337
  // getModuleEstimationStats and getDecisionScorePatterns now have edge case handlers
1317
1338
  // (each backed by a SECURITY DEFINER RPC, migration 20260714140000) plus
@@ -1444,7 +1465,8 @@ var init_proxy_adapter = __esm({
1444
1465
  if (response.status === 401) {
1445
1466
  throw new Error(
1446
1467
  `Auth: Invalid API key \u2014 PAPI_DATA_API_KEY was rejected by the proxy.
1447
- Check PAPI_DATA_API_KEY in your .mcp.json config. You can regenerate it from the PAPI dashboard.
1468
+ This usually means the key was revoked or replaced. Mint a fresh key in the Connect panel on your PAPI dashboard (https://getpapi.ai/hub), then update PAPI_DATA_API_KEY in your .mcp.json.
1469
+ Moving off a local install? Switch to the remote MCP: https://getpapi.ai/docs/install
1448
1470
  (${response.status} on ${method}: ${message})`
1449
1471
  );
1450
1472
  }
@@ -2040,6 +2062,76 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
2040
2062
  }
2041
2063
  });
2042
2064
 
2065
+ // src/lib/reap-orphans.ts
2066
+ var reap_orphans_exports = {};
2067
+ __export(reap_orphans_exports, {
2068
+ formatReapSummary: () => formatReapSummary,
2069
+ isPapiServerCommand: () => isPapiServerCommand,
2070
+ listProcesses: () => listProcesses,
2071
+ reapOrphans: () => reapOrphans,
2072
+ selectReapableOrphans: () => selectReapableOrphans
2073
+ });
2074
+ import { execFileSync as execFileSync6 } from "child_process";
2075
+ function isPapiServerCommand(command) {
2076
+ return /@papi-ai[/\\]server/.test(command);
2077
+ }
2078
+ function selectReapableOrphans(procs, selfPid) {
2079
+ return procs.filter(
2080
+ (p) => p.pid !== selfPid && p.ppid === 1 && isPapiServerCommand(p.command)
2081
+ );
2082
+ }
2083
+ function listProcesses() {
2084
+ if (process.platform === "win32") return null;
2085
+ try {
2086
+ const out = execFileSync6("ps", ["-A", "-o", "pid=,ppid=,command="], { encoding: "utf-8" });
2087
+ const procs = [];
2088
+ for (const line of out.split("\n")) {
2089
+ const trimmed = line.trim();
2090
+ if (!trimmed) continue;
2091
+ const m = /^(\d+)\s+(\d+)\s+(.*)$/.exec(trimmed);
2092
+ if (!m) continue;
2093
+ procs.push({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3] });
2094
+ }
2095
+ return procs;
2096
+ } catch {
2097
+ return null;
2098
+ }
2099
+ }
2100
+ function reapOrphans(opts = {}) {
2101
+ const procs = listProcesses();
2102
+ if (procs === null) return { unsupported: true, candidates: [], reaped: [] };
2103
+ const orphans = selectReapableOrphans(procs, process.pid);
2104
+ const candidates = orphans.map((p) => p.pid);
2105
+ const reaped = [];
2106
+ if (!opts.dryRun) {
2107
+ for (const pid of candidates) {
2108
+ try {
2109
+ process.kill(pid, "SIGTERM");
2110
+ reaped.push(pid);
2111
+ } catch {
2112
+ }
2113
+ }
2114
+ }
2115
+ return { unsupported: false, candidates, reaped };
2116
+ }
2117
+ function formatReapSummary(result, dryRun) {
2118
+ if (result.unsupported) {
2119
+ return "Orphan reaper: unsupported on this platform \u2014 skipped (no processes touched).";
2120
+ }
2121
+ if (result.candidates.length === 0) {
2122
+ return "Orphan reaper: no parentless @papi-ai/server processes found.";
2123
+ }
2124
+ if (dryRun) {
2125
+ return `Orphan reaper: ${result.candidates.length} parentless PAPI server process(es) found: ${result.candidates.join(", ")} (run with --reap-orphans to terminate).`;
2126
+ }
2127
+ return `Orphan reaper: terminated ${result.reaped.length} parentless PAPI server process(es): ${result.reaped.join(", ")}.`;
2128
+ }
2129
+ var init_reap_orphans = __esm({
2130
+ "src/lib/reap-orphans.ts"() {
2131
+ "use strict";
2132
+ }
2133
+ });
2134
+
2043
2135
  // ../../node_modules/postgres/src/query.js
2044
2136
  function cachedError(xs) {
2045
2137
  if (originCache.has(xs))
@@ -4242,7 +4334,7 @@ __export(doctor_exports, {
4242
4334
  __testing: () => __testing,
4243
4335
  runDoctor: () => runDoctor
4244
4336
  });
4245
- import { existsSync as existsSync11, readFileSync as readFileSync12 } from "fs";
4337
+ import { existsSync as existsSync11, readFileSync as readFileSync13 } from "fs";
4246
4338
  import { homedir as homedir4 } from "os";
4247
4339
  import { join as join20 } from "path";
4248
4340
  function redact(name, value) {
@@ -4262,7 +4354,7 @@ function findMcpJson() {
4262
4354
  for (const path7 of candidates) {
4263
4355
  if (!existsSync11(path7)) continue;
4264
4356
  try {
4265
- const raw = readFileSync12(path7, "utf-8");
4357
+ const raw = readFileSync13(path7, "utf-8");
4266
4358
  const parsed = JSON.parse(raw);
4267
4359
  const papiEntry = parsed.papi ?? parsed.mcpServers?.papi;
4268
4360
  if (!papiEntry) continue;
@@ -4506,12 +4598,16 @@ async function runDoctor(cliArgs2 = []) {
4506
4598
  const fixPool = cliArgs2.includes("--fix-pool") || cliArgs2.includes("--terminate-wedged");
4507
4599
  const pool = await diagnosePool({ fix: fixPool });
4508
4600
  process.stdout.write("\n" + formatPoolReport(pool) + "\n");
4601
+ const reap = cliArgs2.includes("--reap-orphans");
4602
+ const reapResult = reapOrphans({ dryRun: !reap });
4603
+ process.stdout.write("\n" + formatReapSummary(reapResult, !reap) + "\n");
4509
4604
  return 0;
4510
4605
  }
4511
4606
  var SECRET_VARS, WEDGED_IDLE_TX_SECONDS, WEDGED_ACTIVE_SECONDS, __testing;
4512
4607
  var init_doctor = __esm({
4513
4608
  "src/cli/doctor.ts"() {
4514
4609
  "use strict";
4610
+ init_reap_orphans();
4515
4611
  SECRET_VARS = /* @__PURE__ */ new Set(["PAPI_DATA_API_KEY", "DATABASE_URL", "PAPI_ENDPOINT"]);
4516
4612
  WEDGED_IDLE_TX_SECONDS = 300;
4517
4613
  WEDGED_ACTIVE_SECONDS = 300;
@@ -4536,7 +4632,7 @@ __export(reset_exports, {
4536
4632
  removePapiEntry: () => removePapiEntry,
4537
4633
  runReset: () => runReset
4538
4634
  });
4539
- import { existsSync as existsSync12, readFileSync as readFileSync13, writeFileSync as writeFileSync6 } from "fs";
4635
+ import { existsSync as existsSync12, readFileSync as readFileSync14, writeFileSync as writeFileSync6 } from "fs";
4540
4636
  import { homedir as homedir5 } from "os";
4541
4637
  import { join as join21 } from "path";
4542
4638
  import { createInterface } from "readline/promises";
@@ -4546,7 +4642,7 @@ function findResetTarget() {
4546
4642
  let raw;
4547
4643
  let parsed;
4548
4644
  try {
4549
- raw = readFileSync13(path7, "utf-8");
4645
+ raw = readFileSync14(path7, "utf-8");
4550
4646
  parsed = JSON.parse(raw);
4551
4647
  } catch {
4552
4648
  continue;
@@ -4647,7 +4743,7 @@ __export(audit_exports, {
4647
4743
  __testing: () => __testing2,
4648
4744
  runAudit: () => runAudit
4649
4745
  });
4650
- import { existsSync as existsSync13, readFileSync as readFileSync14, readdirSync as readdirSync7 } from "fs";
4746
+ import { existsSync as existsSync13, readFileSync as readFileSync15, readdirSync as readdirSync7 } from "fs";
4651
4747
  import { homedir as homedir6 } from "os";
4652
4748
  import { join as join22 } from "path";
4653
4749
  function safeListDirs(dir) {
@@ -4668,7 +4764,7 @@ function readMcp(projectPath) {
4668
4764
  const path7 = join22(projectPath, ".mcp.json");
4669
4765
  if (!existsSync13(path7)) return { servers: [] };
4670
4766
  try {
4671
- const parsed = JSON.parse(readFileSync14(path7, "utf-8"));
4767
+ const parsed = JSON.parse(readFileSync15(path7, "utf-8"));
4672
4768
  const mcpServers = parsed.mcpServers ?? {};
4673
4769
  const servers = Object.keys(mcpServers);
4674
4770
  if (parsed.papi && !servers.includes("papi")) servers.push("papi");
@@ -4724,7 +4820,7 @@ function readGlobalSkills() {
4724
4820
  function readGlobalMcpServers() {
4725
4821
  if (!existsSync13(GLOBAL_CLAUDE_JSON)) return [];
4726
4822
  try {
4727
- const parsed = JSON.parse(readFileSync14(GLOBAL_CLAUDE_JSON, "utf-8"));
4823
+ const parsed = JSON.parse(readFileSync15(GLOBAL_CLAUDE_JSON, "utf-8"));
4728
4824
  const servers = parsed.mcpServers ?? {};
4729
4825
  return Object.keys(servers).sort((a, b2) => a.localeCompare(b2));
4730
4826
  } catch {
@@ -4890,7 +4986,7 @@ var setup_exports = {};
4890
4986
  __export(setup_exports, {
4891
4987
  runSetup: () => runSetup
4892
4988
  });
4893
- import { existsSync as existsSync14, readFileSync as readFileSync15, writeFileSync as writeFileSync7, chmodSync as chmodSync2, statSync as statSync7 } from "fs";
4989
+ import { existsSync as existsSync14, readFileSync as readFileSync16, writeFileSync as writeFileSync7, chmodSync as chmodSync2, statSync as statSync8 } from "fs";
4894
4990
  import { join as join23 } from "path";
4895
4991
  function baseUrl() {
4896
4992
  const fromEnv = process.env["PAPI_HOST"] ?? process.env["PAPI_BASE_URL"];
@@ -4927,7 +5023,7 @@ function writeMcpJson(opts) {
4927
5023
  let parsed = {};
4928
5024
  if (existsSync14(path7)) {
4929
5025
  try {
4930
- parsed = JSON.parse(readFileSync15(path7, "utf-8"));
5026
+ parsed = JSON.parse(readFileSync16(path7, "utf-8"));
4931
5027
  } catch {
4932
5028
  throw new Error(`.mcp.json at ${path7} is not valid JSON. Fix it or remove it before re-running setup.`);
4933
5029
  }
@@ -4950,7 +5046,7 @@ function writeMcpJson(opts) {
4950
5046
  parsed.mcpServers = mcpServers;
4951
5047
  writeFileSync7(path7, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
4952
5048
  try {
4953
- const mode = statSync7(path7).mode & 511;
5049
+ const mode = statSync8(path7).mode & 511;
4954
5050
  if (mode !== 384) chmodSync2(path7, 384);
4955
5051
  } catch {
4956
5052
  }
@@ -5058,7 +5154,7 @@ var init_setup = __esm({
5058
5154
  });
5059
5155
 
5060
5156
  // src/index.ts
5061
- import { readFileSync as readFileSync16 } from "fs";
5157
+ import { readFileSync as readFileSync17 } from "fs";
5062
5158
  import { dirname as dirname6, join as join24, basename as basename2 } from "path";
5063
5159
  import { fileURLToPath as fileURLToPath4 } from "url";
5064
5160
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -5104,7 +5200,6 @@ function loadConfig() {
5104
5200
  "\nPAPI is running, but no project is configured here.\n" + HELP_FOOTER + "\n"
5105
5201
  );
5106
5202
  }
5107
- const anthropicApiKey = process.env.PAPI_API_KEY ?? "";
5108
5203
  const autoCommit2 = process.env.PAPI_AUTO_COMMIT !== "false";
5109
5204
  const baseBranch = process.env.PAPI_BASE_BRANCH ?? "main";
5110
5205
  const autoPR = process.env.PAPI_AUTO_PR !== "false";
@@ -5178,7 +5273,6 @@ Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env con
5178
5273
  return {
5179
5274
  projectRoot,
5180
5275
  papiDir: path.join(projectRoot, ".papi"),
5181
- anthropicApiKey,
5182
5276
  autoCommit: autoCommit2,
5183
5277
  baseBranch,
5184
5278
  autoPR,
@@ -5469,6 +5563,9 @@ var SECTION_HEADERS = [
5469
5563
  "FILES LIKELY TOUCHED",
5470
5564
  "EFFORT"
5471
5565
  ];
5566
+ function normaliseHeaderLine(line) {
5567
+ return line.trim().replace(/^#{1,6}\s*/, "").replace(/^-\s+/, "").replace(/^\*\*(.*?)\*\*$/, "$1").replace(/:\s*$/, "").trim();
5568
+ }
5472
5569
  function splitSections(text) {
5473
5570
  const sections = /* @__PURE__ */ new Map();
5474
5571
  const lines = text.split("\n");
@@ -5481,8 +5578,8 @@ function splitSections(text) {
5481
5578
  }
5482
5579
  };
5483
5580
  for (const line of lines) {
5484
- const trimmed = line.trim();
5485
- const matched = SECTION_HEADERS.find((h) => trimmed === h);
5581
+ const normalised = normaliseHeaderLine(line);
5582
+ const matched = SECTION_HEADERS.find((h) => normalised === h);
5486
5583
  if (matched) {
5487
5584
  flush();
5488
5585
  currentSection = matched;
@@ -5503,7 +5600,10 @@ function parseChecklist(text) {
5503
5600
  return text.split("\n").map((l) => l.replace(/^\s*\[[ x]]\s*/, "").trim()).filter((l) => l.length > 0);
5504
5601
  }
5505
5602
  function parseBuildHandoff(markdown) {
5506
- if (!markdown.includes("BUILD HANDOFF")) return null;
5603
+ if (typeof markdown !== "string" || !markdown.trim()) return null;
5604
+ if (!markdown.includes("BUILD HANDOFF") && splitSections(markdown).size === 0) {
5605
+ return null;
5606
+ }
5507
5607
  const taskIdMatch = markdown.match(/BUILD HANDOFF\s*—\s*(task-\d+)/);
5508
5608
  const taskTitleMatch = markdown.match(/^Task:\s*(.+)$/m);
5509
5609
  const cycleMatch = markdown.match(/^Cycle:\s*(\d+)$/m);
@@ -5538,6 +5638,28 @@ function parseBuildHandoff(markdown) {
5538
5638
  effort
5539
5639
  };
5540
5640
  }
5641
+ function coerceBuildHandoff(fields, taskId) {
5642
+ const scope = ensureArray(fields.scope);
5643
+ const scopeBoundary = ensureArray(fields.scopeBoundary);
5644
+ if (scope.length === 0 && scopeBoundary.length === 0) return null;
5645
+ const effortRaw = typeof fields.effort === "string" ? fields.effort.trim().toUpperCase() : "";
5646
+ const effort = VALID_EFFORT_SIZES.has(effortRaw) ? effortRaw : "M";
5647
+ const str = (v) => typeof v === "string" ? v.trim() : "";
5648
+ return {
5649
+ uuid: str(fields.uuid) || randomUUID2(),
5650
+ taskId: str(fields.taskId) || taskId || "",
5651
+ taskTitle: str(fields.taskTitle),
5652
+ cycle: typeof fields.cycle === "number" ? fields.cycle : 0,
5653
+ whyNow: str(fields.whyNow),
5654
+ scope,
5655
+ scopeBoundary,
5656
+ acceptanceCriteria: ensureArray(fields.acceptanceCriteria),
5657
+ securityConsiderations: str(fields.securityConsiderations),
5658
+ verificationFiles: ensureArray(fields.verificationFiles),
5659
+ filesLikelyTouched: ensureArray(fields.filesLikelyTouched),
5660
+ effort
5661
+ };
5662
+ }
5541
5663
  function ensureArray(value) {
5542
5664
  if (Array.isArray(value)) return value;
5543
5665
  if (typeof value === "string") {
@@ -8071,7 +8193,7 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
8071
8193
  }
8072
8194
 
8073
8195
  // src/server.ts
8074
- import { readFileSync as readFileSync11 } from "fs";
8196
+ import { readFileSync as readFileSync12 } from "fs";
8075
8197
  import { access as access4, readdir as readdir4, readFile as readFile9 } from "fs/promises";
8076
8198
  import { join as join19, dirname as dirname5 } from "path";
8077
8199
  import { fileURLToPath as fileURLToPath3 } from "url";
@@ -8912,6 +9034,7 @@ Everything in Part 1 (natural language) is **display-only**. Part 2 (structured
8912
9034
  - Updated or created Active Decisions in Part 1? \u2192 Put them in \`activeDecisions\` array (with id and full body including ### heading)
8913
9035
  - Found board corrections (wrong priority, missing fields, stale status) in Part 1? \u2192 Put them in \`boardCorrections\` array
8914
9036
  - Generated BUILD HANDOFFs in Part 1? \u2192 Put them in \`cycleHandoffs\` array
9037
+ - **\`complexity\` uses the LONG forms only** \u2014 "XS", "Small", "Medium", "Large", "XL". Do NOT reuse the handoff EFFORT short-forms (S/M/L) for task complexity.
8915
9038
 
8916
9039
  **Example with populated fields (DO NOT copy literally \u2014 adapt to your actual analysis):**
8917
9040
  \`\`\`json
@@ -8952,13 +9075,20 @@ This is Cycle 0 \u2014 the first planning cycle for a brand-new project.
8952
9075
  - Do NOT assume web-app patterns (routes, pages, components) unless the brief explicitly describes a web application
8953
9076
  - All tasks: status Backlog, priority P1-P2, reviewed true, phase "Phase 1"
8954
9077
 
9078
+ **REASONING LINE (task-2868 \u2014 the first cycle must SHOW PAPI's intelligence, not read as a generic list).** For EVERY task, the \`notes\` field MUST OPEN with a single line in the exact form:
9079
+ \`Why: <one plain-language sentence, \u2264160 chars, on why THIS task earns its place in the first cycle>\`
9080
+ Then a blank line, then any other notes. Rules:
9081
+ - Task 1's \`Why:\` must also justify its POSITION \u2014 say why it is the opening slice (the thinnest thing that puts the core loop in front of the user). This one line is the cycle's sequencing rationale; the dashboard surfaces it above the first task.
9082
+ - Plain language for a semi-technical builder \u2014 no jargon, no raw IDs, not a restatement of the title.
9083
+ - This is a hard requirement: the dashboard's first-cycle experience reads and renders this \`Why:\` line per task.
9084
+
8955
9085
  4. **First Active Decision** \u2014 If the description implies a clear architectural choice, create AD-1 with Confidence: MEDIUM. If no clear choice, skip this.
8956
9086
 
8957
9087
  5. **BUILD HANDOFFs** \u2014 Generate a full BUILD HANDOFF block for EVERY task created in step 3 (all 3-5 tasks). Include each in the \`cycleHandoffs\` array. **tempId join (REQUIRED \u2014 mismatches silently scramble handoffs):** give EVERY \`newTasks\` entry a unique \`tempId\` (\`"new-1"\`, \`"new-2"\`, \u2026), and set each \`cycleHandoffs\` \`taskId\` to the EXACT \`tempId\` of the newTask it belongs to. Do NOT rely on array order, and do NOT reuse a tempId. The builder needs handoffs to run \`build_execute\` \u2014 without them, tasks must be completed via \`ad_hoc\`, which breaks the normal flow.
8958
9088
 
8959
9089
  ### Structured output for Bootstrap:
8960
9090
  In the JSON block, you MUST include:
8961
- - "newTasks": array of task objects with ALL fields: title, status, priority, complexity, module, epic, phase, owner, notes. **This is how tasks get created on the board. If this array is empty, NO tasks will exist.**
9091
+ - "newTasks": array of task objects with ALL fields: title, status, priority, complexity, module, epic, phase, owner, notes. **This is how tasks get created on the board. If this array is empty, NO tasks will exist.** Every \`notes\` value MUST open with the \`Why: <sentence>\` reasoning line described in step 3 (the dashboard's first-cycle experience renders it).
8962
9092
  - "productBrief": the full Product Brief markdown content. **If null, the brief stays as the template.**
8963
9093
  - "activeDecisions": array of {id, body} objects. **If you created AD-1 in Part 1 but this array is empty, the AD will NOT be saved.**
8964
9094
  - "recommendedTaskId": null (the handler will use the first new task)
@@ -9502,10 +9632,17 @@ function coerceToString(value) {
9502
9632
  return JSON.stringify(value, null, 2);
9503
9633
  }
9504
9634
  function coerceStructuredOutput(parsed) {
9505
- const cycleHandoffs = Array.isArray(parsed.cycleHandoffs) ? parsed.cycleHandoffs.map((h) => ({
9506
- taskId: coerceToString(h.taskId),
9507
- buildHandoff: coerceToString(h.buildHandoff)
9508
- })) : [];
9635
+ const cycleHandoffs = Array.isArray(parsed.cycleHandoffs) ? parsed.cycleHandoffs.map((h) => {
9636
+ const { taskId: _t, buildHandoff: _b, ...rest } = h;
9637
+ if (typeof h.buildHandoff === "object" && h.buildHandoff !== null && !Array.isArray(h.buildHandoff)) {
9638
+ Object.assign(rest, h.buildHandoff);
9639
+ }
9640
+ return {
9641
+ taskId: coerceToString(h.taskId),
9642
+ buildHandoff: typeof h.buildHandoff === "string" ? h.buildHandoff : "",
9643
+ ...Object.keys(rest).length > 0 ? { structuredFields: rest } : {}
9644
+ };
9645
+ }) : [];
9509
9646
  const newTasks = Array.isArray(parsed.newTasks) ? parsed.newTasks.map((t) => ({
9510
9647
  // task-2242: stable join key (optional — undefined falls back to index).
9511
9648
  tempId: t.tempId !== void 0 && t.tempId !== null ? coerceToString(t.tempId) : void 0,
@@ -10615,9 +10752,12 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false)
10615
10752
  skipped++;
10616
10753
  continue;
10617
10754
  }
10618
- const parsed = parseBuildHandoff(handoff.buildHandoff);
10755
+ const parsed = parseBuildHandoff(handoff.buildHandoff) ?? (handoff.structuredFields ? coerceBuildHandoff(handoff.structuredFields, handoff.taskId) : null);
10619
10756
  if (!parsed) {
10620
- warnings.push(`Failed to parse handoff for ${handoff.taskId}`);
10757
+ const received = (handoff.buildHandoff ?? "").slice(0, 80);
10758
+ warnings.push(
10759
+ `Failed to parse handoff for ${handoff.taskId}. Received: "${received}${(handoff.buildHandoff ?? "").length > 80 ? "\u2026" : ""}". Send EITHER the BUILD HANDOFF markdown template in \`buildHandoff\` (section headers on their own lines: SCOPE (DO THIS) / SCOPE BOUNDARY (DO NOT DO THIS) / ACCEPTANCE CRITERIA / EFFORT, bullets as "- item") OR structured fields alongside taskId: {scope: string[], scopeBoundary: string[], acceptanceCriteria: string[], effort: "XS|S|M|L|XL"}.`
10760
+ );
10621
10761
  continue;
10622
10762
  }
10623
10763
  const invalidFields = validateHandoffScope(parsed);
@@ -10625,7 +10765,6 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false)
10625
10765
  warnings.push(
10626
10766
  `Rejected handoff for ${handoff.taskId}: missing or empty ${invalidFields.join(", ")}. Handoffs without explicit scope produce ambiguous builds.`
10627
10767
  );
10628
- skipped++;
10629
10768
  continue;
10630
10769
  }
10631
10770
  if (!parsed.createdAt) {
@@ -11100,6 +11239,20 @@ async function resolveOwnerGate(adapter2, config2) {
11100
11239
  }
11101
11240
 
11102
11241
  // src/services/plan.ts
11242
+ var COMPLEXITY_ALIASES = {
11243
+ "XS": "XS",
11244
+ "S": "Small",
11245
+ "SMALL": "Small",
11246
+ "M": "Medium",
11247
+ "MEDIUM": "Medium",
11248
+ "L": "Large",
11249
+ "LARGE": "Large",
11250
+ "XL": "XL"
11251
+ };
11252
+ function normalizeComplexity(value) {
11253
+ const key = (value ?? "").trim().toUpperCase();
11254
+ return COMPLEXITY_ALIASES[key] ?? "Small";
11255
+ }
11103
11256
  var PLAN_BUILD_REPORT_BUDGET = { maxReports: 12, fieldBudget: 280 };
11104
11257
  function leadChainWithRecommended(chain, recommendedTaskId) {
11105
11258
  const rec = recommendedTaskId?.trim();
@@ -12038,10 +12191,13 @@ ${cleanContent}`;
12038
12191
  } catch {
12039
12192
  }
12040
12193
  const handoffs = (data.cycleHandoffs ?? []).map((h) => {
12041
- const parsed = parseBuildHandoff(h.buildHandoff);
12194
+ const parsed = parseBuildHandoff(h.buildHandoff) ?? (h.structuredFields ? coerceBuildHandoff(h.structuredFields, h.taskId) : null);
12042
12195
  if (parsed && !parsed.createdAt) {
12043
12196
  parsed.createdAt = (/* @__PURE__ */ new Date()).toISOString();
12044
12197
  }
12198
+ if (!parsed) {
12199
+ console.error(`[plan] dropping unparseable handoff for ${h.taskId} (not the BUILD HANDOFF template and no structured scope fields)`);
12200
+ }
12045
12201
  return { taskId: h.taskId, handoff: parsed };
12046
12202
  }).filter((h) => h.handoff != null);
12047
12203
  const cycleTaskIds = data.cycleTaskIds?.length ? data.cycleTaskIds : (data.cycleHandoffs ?? []).map((h) => h.taskId);
@@ -12128,7 +12284,7 @@ ${cleanContent}`;
12128
12284
  title: t.title,
12129
12285
  status: t.status || "Backlog",
12130
12286
  priority: t.priority || "P1 High",
12131
- complexity: t.complexity || "Small",
12287
+ complexity: normalizeComplexity(t.complexity),
12132
12288
  module: t.module || "Core",
12133
12289
  epic: t.epic || "Platform",
12134
12290
  phase: t.phase || "Phase 1",
@@ -12276,7 +12432,7 @@ ${cleanContent}`;
12276
12432
  title: task.title,
12277
12433
  status: task.status || "Backlog",
12278
12434
  priority: task.priority || "P1 High",
12279
- complexity: task.complexity || "Small",
12435
+ complexity: normalizeComplexity(task.complexity),
12280
12436
  module: task.module || "Core",
12281
12437
  epic: task.epic || "Platform",
12282
12438
  phase: task.phase || "Phase 1",
@@ -13288,6 +13444,13 @@ async function resolveLlmResponse(inlineResponse, filePath) {
13288
13444
  }
13289
13445
  const resolvedPath = filePath.trim();
13290
13446
  if (!isAbsolute(resolvedPath)) {
13447
+ const looksWindows = /^[A-Za-z]:[\\/]/.test(resolvedPath) || resolvedPath.startsWith("\\\\");
13448
+ if (looksWindows && process.platform !== "win32") {
13449
+ return {
13450
+ ok: false,
13451
+ error: `llm_response_file points at a path on YOUR machine (${resolvedPath}), but this PAPI server runs remotely and cannot read your filesystem. llm_response_file only works with a locally installed (stdio) server. On the hosted connection, pass the content inline via llm_response.`
13452
+ };
13453
+ }
13291
13454
  return {
13292
13455
  ok: false,
13293
13456
  error: `llm_response_file must be an absolute path, got: ${resolvedPath}`
@@ -13534,7 +13697,7 @@ var planTool = {
13534
13697
  },
13535
13698
  llm_response_file: {
13536
13699
  type: "string",
13537
- description: 'Absolute path to a file containing the plan output (mode "apply" only). Use this when the response is too large to pass as a string parameter (some hosts cap inputs around 50KB). The file must be absolute, exist, and be \u2264500KB. Mutually exclusive with llm_response.'
13700
+ description: 'Absolute path to a file containing the plan output (mode "apply" only). LOCAL stdio servers only \u2014 on the hosted connection the server cannot read files on your machine; pass llm_response inline instead. Use this when the response is too large to pass as a string parameter (some hosts cap inputs around 50KB). The file must be absolute, exist, and be \u2264500KB. Mutually exclusive with llm_response.'
13538
13701
  },
13539
13702
  cycle_number: {
13540
13703
  type: "number",
@@ -13609,7 +13772,7 @@ function formatPlanResult(result) {
13609
13772
  return textResponse(
13610
13773
  `${pullLine}**${modeLabel} Mode \u2014 ${cycleLabel}**
13611
13774
 
13612
- \u26A0\uFE0F **Persistence failed:** Structured output could not be parsed. Cycle log, board corrections, handoffs, and Active Decisions were NOT saved. Try running \`plan\` again.`
13775
+ \u26A0\uFE0F **Persistence failed:** Structured output could not be parsed. Cycle log, board corrections, handoffs, and Active Decisions were NOT saved. Your llm_response must contain the literal marker \`<!-- PAPI_STRUCTURED_OUTPUT -->\` followed by the JSON inside a \`\`\`json code fence \u2014 resend with that exact envelope (do not strip the marker or the fence).`
13613
13776
  );
13614
13777
  }
13615
13778
  const lines = [];
@@ -15940,6 +16103,11 @@ Confidence: ${input.confidence}. Captured mid-conversation via strategy_change c
15940
16103
  }
15941
16104
 
15942
16105
  // src/tools/strategy.ts
16106
+ function toDateLabel(value) {
16107
+ if (typeof value === "string") return value.slice(0, 10);
16108
+ const d = value instanceof Date ? value : new Date(value);
16109
+ return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
16110
+ }
15943
16111
  var reviewPrepareCache = new PerCallerCache();
15944
16112
  var strategyReviewTool = {
15945
16113
  name: "strategy_review",
@@ -15959,7 +16127,7 @@ var strategyReviewTool = {
15959
16127
  },
15960
16128
  llm_response_file: {
15961
16129
  type: "string",
15962
- description: 'Absolute path to a file containing the review output (mode "apply" only). Use this when the response is too large to pass as a string parameter (some hosts cap inputs around 50KB). The file must be absolute, exist, and be \u2264500KB. Mutually exclusive with llm_response.'
16130
+ description: 'Absolute path to a file containing the review output (mode "apply" only). LOCAL stdio servers only \u2014 on the hosted connection the server cannot read files on your machine; pass llm_response inline instead. Use this when the response is too large to pass as a string parameter (some hosts cap inputs around 50KB). The file must be absolute, exist, and be \u2264500KB. Mutually exclusive with llm_response.'
15963
16131
  },
15964
16132
  cycle_number: {
15965
16133
  type: "number",
@@ -16241,7 +16409,7 @@ This topic will surface in the next \`strategy_review\`.`
16241
16409
  const lines = topics.map((t, i) => {
16242
16410
  const cycleSuffix = t.sourceCycle != null ? ` (Cycle ${t.sourceCycle})` : "";
16243
16411
  return `${i + 1}. ${t.topic}
16244
- _source: ${t.source}${cycleSuffix} \xB7 queued ${t.createdAt.slice(0, 10)}_`;
16412
+ _source: ${t.source}${cycleSuffix} \xB7 queued ${toDateLabel(t.createdAt)}_`;
16245
16413
  });
16246
16414
  return textResponse(
16247
16415
  `**Pending Agenda (${topics.length})** \u2014 surfaces at next strategy review
@@ -17934,7 +18102,9 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
17934
18102
  }
17935
18103
  if (adapter2.upsertActiveDecision) {
17936
18104
  const title = ad.title || ad.body.split("\n")[0].replace(/^#+\s*/, "").slice(0, 120);
17937
- await adapter2.upsertActiveDecision(ad.id, ad.body, title, ad.confidence || "MEDIUM", 0);
18105
+ const confidenceMatch = ad.body.match(/\[Confidence:\s*(HIGH|MEDIUM|LOW)\]/i);
18106
+ const confidence = confidenceMatch ? confidenceMatch[1].toUpperCase() : ad.confidence || "MEDIUM";
18107
+ await adapter2.upsertActiveDecision(ad.id, ad.body, title, confidence, 0);
17938
18108
  } else {
17939
18109
  await adapter2.updateActiveDecision(ad.id, ad.body, 0);
17940
18110
  }
@@ -18714,10 +18884,14 @@ ${[created, skipped].filter(Boolean).join(", ")}.${idea}`;
18714
18884
  \u26A0\uFE0F **Setup warnings (non-blocking):**
18715
18885
  ${result.warnings.map((w) => `- ${w}`).join("\n")}` : "";
18716
18886
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
18717
- return textResponse(
18718
- `${prefix}Product Brief generated and saved.${briefRegenNote}${adNote}${northStarNote}${taskNote}${constraintsHint}${editorNote}${gitignoreNote}${warningsNote}
18887
+ const isHosted = !!result.filesToWrite && !result.filesToWrite.isEmpty();
18888
+ const filesNote = isHosted ? `
18889
+
18890
+ **Important:** This is a remote PAPI connection, so the server could not write to your project directory. Setup prepared your files (${harnessFiles}, .claude/settings.json, docs/) and returned them in the scaffolding section below \u2014 **write each one to disk, then commit** before running \`build_execute\` (it requires a clean working directory).` : `
18719
18891
 
18720
- **Important:** Setup created/modified files (${harnessFiles}, .claude/settings.json, docs/). Commit these changes before running \`build_execute\` \u2014 it requires a clean working directory.
18892
+ **Important:** Setup created/modified files (${harnessFiles}, .claude/settings.json, docs/). Commit these changes before running \`build_execute\` \u2014 it requires a clean working directory.`;
18893
+ return textResponse(
18894
+ `${prefix}Product Brief generated and saved.${briefRegenNote}${adNote}${northStarNote}${taskNote}${constraintsHint}${editorNote}${gitignoreNote}${warningsNote}${filesNote}
18721
18895
 
18722
18896
  Tip: See \`docs/templates/example-project-brief.md\` for an example of a well-written brief.
18723
18897
 
@@ -19727,6 +19901,61 @@ async function completeRelease(tracker, opts) {
19727
19901
  }
19728
19902
  await tracker.recordStep("released", { metadata: { version: opts.version } });
19729
19903
  }
19904
+ var OPEN_PR_STALE_DAYS = 21;
19905
+ function taskIdFromBranch(headRefName) {
19906
+ const m = /^feat\/(task-\d+)\b/.exec(headRefName);
19907
+ return m ? m[1] : null;
19908
+ }
19909
+ function classifyOpenPr(pr, cycleNum, inReviewCycleTaskIds, nowMs) {
19910
+ const head = pr.headRefName;
19911
+ const ageDays = (nowMs - Date.parse(pr.createdAt)) / 864e5;
19912
+ if (new RegExp(`^feat/cycle-${cycleNum}-`).test(head)) {
19913
+ return { pr, bucket: "cycle-branch", action: `merge into this release \u2014 \`${head}\` is this cycle's branch and still open` };
19914
+ }
19915
+ const taskId = taskIdFromBranch(head);
19916
+ if (taskId && inReviewCycleTaskIds.has(taskId)) {
19917
+ return { pr, bucket: "held-adhoc-this-cycle", action: `MERGE before closing \u2014 ${taskId} is In Review pinned to Cycle ${cycleNum}` };
19918
+ }
19919
+ if (taskId) {
19920
+ return { pr, bucket: "held-adhoc-other", action: "held adhoc not pinned to this cycle \u2014 defer, or merge if ready" };
19921
+ }
19922
+ if (Number.isFinite(ageDays) && ageDays > OPEN_PR_STALE_DAYS) {
19923
+ return { pr, bucket: "stale", action: `review/close \u2014 open ${Math.round(ageDays)}d with no cycle link` };
19924
+ }
19925
+ return { pr, bucket: "external-other", action: "review manually \u2014 merge, defer, or close" };
19926
+ }
19927
+ var OPEN_PR_BUCKET_ORDER = [
19928
+ "held-adhoc-this-cycle",
19929
+ "cycle-branch",
19930
+ "external-other",
19931
+ "stale",
19932
+ "held-adhoc-other"
19933
+ ];
19934
+ function buildOpenPrSweepLines(openPrs, cycleNum, inReviewCycleTaskIds, nowMs) {
19935
+ const lines = [];
19936
+ if (openPrs === null) {
19937
+ 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.");
19938
+ } else if (openPrs.length > 0) {
19939
+ 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));
19940
+ lines.push("", `**Open-PR sweep \u2014 ${openPrs.length} open PR(s). Resolve each before considering Cycle ${cycleNum} closed:**`);
19941
+ for (const c of classified) {
19942
+ lines.push(`- #${c.pr.number} \`${c.pr.headRefName}\` by ${c.pr.author} \u2014 [${c.bucket}] ${c.action}`);
19943
+ }
19944
+ }
19945
+ if (inReviewCycleTaskIds.size > 0) {
19946
+ const prByTask = /* @__PURE__ */ new Map();
19947
+ for (const p of openPrs ?? []) {
19948
+ const id = taskIdFromBranch(p.headRefName);
19949
+ if (id) prByTask.set(id, p.number);
19950
+ }
19951
+ const rows = [...inReviewCycleTaskIds].sort().map((id) => {
19952
+ const prNum = prByTask.get(id);
19953
+ return ` - ${id}${prNum ? ` (PR #${prNum})` : " (no open PR found)"}`;
19954
+ });
19955
+ 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);
19956
+ }
19957
+ return lines;
19958
+ }
19730
19959
 
19731
19960
  // src/tools/release.ts
19732
19961
  init_git();
@@ -20217,6 +20446,20 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
20217
20446
  if (result.warnings?.length) {
20218
20447
  lines.push("", "\u26A0\uFE0F Warnings: " + result.warnings.join("; "));
20219
20448
  }
20449
+ try {
20450
+ const openPrs = listOpenPullRequests(config2.projectRoot);
20451
+ const closedCycle = result.cycleClosed ?? 0;
20452
+ let inReviewIds = /* @__PURE__ */ new Set();
20453
+ if (closedCycle > 0) {
20454
+ const board = await adapter2.queryBoard();
20455
+ inReviewIds = new Set(
20456
+ board.filter((t) => t.status === "In Review" && t.cycle === closedCycle).map((t) => t.id)
20457
+ );
20458
+ }
20459
+ const sweep = buildOpenPrSweepLines(openPrs, closedCycle, inReviewIds, Date.now());
20460
+ if (sweep.length > 0) lines.push(...sweep);
20461
+ } catch {
20462
+ }
20220
20463
  tracker.mark("surface-discovered-issues");
20221
20464
  try {
20222
20465
  const closedCycle = result.cycleClosed ?? 0;
@@ -20958,8 +21201,8 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
20958
21201
  } else {
20959
21202
  const stashLabel = `papi-autostash/${taskId}-${Math.floor(Date.now() / 1e3)}`;
20960
21203
  try {
20961
- const { execFileSync: execFileSync6 } = await import("child_process");
20962
- execFileSync6("git", ["stash", "push", "-u", "-m", stashLabel, "--", ...toStash], {
21204
+ const { execFileSync: execFileSync7 } = await import("child_process");
21205
+ execFileSync7("git", ["stash", "push", "-u", "-m", stashLabel, "--", ...toStash], {
20963
21206
  cwd: config2.projectRoot,
20964
21207
  encoding: "utf-8"
20965
21208
  });
@@ -20985,8 +21228,8 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
20985
21228
  if (hasRemote(config2.projectRoot) && !featureBranchExistsLocally) {
20986
21229
  if (featureBranchOnOrigin) {
20987
21230
  try {
20988
- const { execFileSync: execFileSync6 } = await import("child_process");
20989
- execFileSync6("git", ["fetch", "origin", `${featureBranch}:${featureBranch}`], {
21231
+ const { execFileSync: execFileSync7 } = await import("child_process");
21232
+ execFileSync7("git", ["fetch", "origin", `${featureBranch}:${featureBranch}`], {
20990
21233
  cwd: config2.projectRoot,
20991
21234
  encoding: "utf-8",
20992
21235
  timeout: 6e4
@@ -22110,6 +22353,10 @@ var buildExecuteTool = {
22110
22353
  enum: ["yes", "no", "partial"],
22111
22354
  description: "Whether the build was completed. Required for complete."
22112
22355
  },
22356
+ acceptance_confirmed: {
22357
+ type: "boolean",
22358
+ 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.`
22359
+ },
22113
22360
  effort: {
22114
22361
  type: "string",
22115
22362
  enum: ["XS", "S", "M", "L", "XL"],
@@ -22565,6 +22812,25 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
22565
22812
  if (!parsedEstimatedEffort) {
22566
22813
  return errorResponse(`Invalid estimated_effort value "${estimatedEffort}". Must be one of: XS, S, M, L, XL.`);
22567
22814
  }
22815
+ const acceptanceConfirmed = args.acceptance_confirmed === true;
22816
+ if (completed === "yes" && !acceptanceConfirmed) {
22817
+ const gateInfo = adapter2.getProjectInfo ? await adapter2.getProjectInfo().catch(() => null) : null;
22818
+ const gateCaps = gateInfo?.capabilities ?? {};
22819
+ const gateTask = isCapabilityEnabled(gateCaps, "acceptanceGate") ? await adapter2.getTask(taskId).catch(() => null) : null;
22820
+ const criteria = (gateTask?.buildHandoff?.acceptanceCriteria ?? []).filter((c) => c && c.trim());
22821
+ if (criteria.length > 0) {
22822
+ const checklist = criteria.map((c) => ` - [ ] ${c}`).join("\n");
22823
+ return textResponse(
22824
+ `**Acceptance criteria not yet confirmed for ${taskId}.**
22825
+
22826
+ 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\`:
22827
+
22828
+ ${checklist}
22829
+
22830
+ 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\`.`
22831
+ );
22832
+ }
22833
+ }
22568
22834
  const tracker = new ProgressTracker("complete_validate").bindStream(adapter2, { stage: "build", taskId });
22569
22835
  try {
22570
22836
  tracker.mark("complete_build");
@@ -23042,7 +23308,7 @@ var bugTool = {
23042
23308
  severity: {
23043
23309
  type: "string",
23044
23310
  enum: ["critical", "major", "minor"],
23045
- description: 'Bug severity (default: "major"). Critical = P1, Major/Minor = P2.'
23311
+ description: 'Bug severity (default: "major"). Critical = P0, Major = P1, Minor = P2.'
23046
23312
  },
23047
23313
  notes: {
23048
23314
  type: "string",
@@ -24222,6 +24488,13 @@ Re-run build_execute complete with a production_verification field, then re-subm
24222
24488
 
24223
24489
  // src/tools/review.ts
24224
24490
  var REVIEW_DISPATCH_THRESHOLD = 50 * 1024;
24491
+ var REVIEW_DISPATCH_CEILING = 40 * 1024;
24492
+ var REVIEW_ECHO_CAP = 4e3;
24493
+ function trimForEcho(text) {
24494
+ if (text.length <= REVIEW_ECHO_CAP) return text;
24495
+ return `${text.slice(0, REVIEW_ECHO_CAP)}
24496
+ \u2026[trimmed ${text.length - REVIEW_ECHO_CAP} chars]`;
24497
+ }
24225
24498
  var REVIEW_RUBRIC = [
24226
24499
  "You are reviewing a completed PAPI build for acceptance. Judge:",
24227
24500
  "- Correctness: does the change do what the build report claims, without obvious bugs?",
@@ -24551,6 +24824,7 @@ async function handleReviewSubmit(adapter2, config2, args) {
24551
24824
  const autoDispatchOptIn = args.dispatch !== "inline" && process.env.PAPI_AUTO_DISPATCH !== "false" && isCapabilityEnabled(caps, "prReviewer");
24552
24825
  const autoDispatchEligible = !verdict && autoDispatchOptIn;
24553
24826
  const capabilityAutoReviewEligible = verdict === "accept" && !autoReview && autoDispatchOptIn;
24827
+ let capabilityReviewSkippedNote = "";
24554
24828
  if ((explicitDispatch || autoDispatchEligible || capabilityAutoReviewEligible) && stage === "build-acceptance" && taskId) {
24555
24829
  const dispatch = await buildReviewDispatch(
24556
24830
  adapter2,
@@ -24560,8 +24834,16 @@ async function handleReviewSubmit(adapter2, config2, args) {
24560
24834
  );
24561
24835
  if (!dispatch.ok) {
24562
24836
  if (explicitDispatch) return errorResponse(dispatch.error);
24563
- } else if (explicitDispatch || capabilityAutoReviewEligible || dispatch.contextBytes > REVIEW_DISPATCH_THRESHOLD) {
24837
+ } else if (explicitDispatch || autoDispatchEligible && dispatch.contextBytes > REVIEW_DISPATCH_THRESHOLD) {
24564
24838
  return textResponse(dispatch.prompt);
24839
+ } else if (capabilityAutoReviewEligible) {
24840
+ if (dispatch.contextBytes <= REVIEW_DISPATCH_CEILING) {
24841
+ return textResponse(dispatch.prompt);
24842
+ }
24843
+ const kb = (dispatch.contextBytes / 1024).toFixed(0);
24844
+ capabilityReviewSkippedNote = `
24845
+
24846
+ > \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
24847
  }
24566
24848
  }
24567
24849
  if (explicitDispatch && stage !== "build-acceptance") {
@@ -24721,36 +25003,51 @@ ${overlap}`;
24721
25003
 
24722
25004
  \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
25005
  } 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) {
25006
+ let planRunCount = null;
25007
+ if (typeof adapter2.countPlanRunsForCycle === "function") {
25008
+ try {
25009
+ planRunCount = await adapter2.countPlanRunsForCycle(result.currentCycle);
25010
+ } catch {
25011
+ planRunCount = null;
25012
+ }
25013
+ }
25014
+ if (planRunCount === 0) {
24727
25015
  autoReleaseNote = `
24728
25016
 
24729
25017
  ---
24730
25018
 
25019
+ \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.`;
25020
+ } else {
25021
+ const baseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
25022
+ const unmergedCycleBranches = isGitAvailable() && isGitRepo(config2.projectRoot) ? listGroupedCycleBranches(config2.projectRoot, result.currentCycle, baseBranch) : [];
25023
+ if (unmergedCycleBranches.length > 0) {
25024
+ autoReleaseNote = `
25025
+
25026
+ ---
25027
+
24731
25028
  \u26A0\uFE0F **Auto-release skipped** \u2014 all tasks are Done but ${unmergedCycleBranches.length} cycle branch(es) not yet merged: \`${unmergedCycleBranches.join("`, `")}\`.
24732
25029
 
24733
25030
  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;
25031
+ } else {
25032
+ try {
25033
+ const allReviews = await adapter2.getRecentReviews(200);
25034
+ const cycleReviews = allReviews.filter(
25035
+ (r) => r.cycle === result.currentCycle && r.stage === "build-acceptance"
25036
+ );
25037
+ const reviewsWithAutoReview = cycleReviews.filter((r) => r.autoReview);
25038
+ if (reviewsWithAutoReview.length > 0) {
25039
+ const verdictCounts = { pass: 0, warn: 0, fail: 0 };
25040
+ const findingsBySeverity = { error: 0, warning: 0, info: 0 };
25041
+ for (const r of reviewsWithAutoReview) {
25042
+ if (r.autoReview) {
25043
+ verdictCounts[r.autoReview.verdict] = (verdictCounts[r.autoReview.verdict] ?? 0) + 1;
25044
+ for (const f of r.autoReview.findings) {
25045
+ findingsBySeverity[f.severity] = (findingsBySeverity[f.severity] ?? 0) + 1;
25046
+ }
24749
25047
  }
24750
25048
  }
24751
- }
24752
- const totalFindings = findingsBySeverity.error + findingsBySeverity.warning + findingsBySeverity.info;
24753
- batchSummaryNote = `
25049
+ const totalFindings = findingsBySeverity.error + findingsBySeverity.warning + findingsBySeverity.info;
25050
+ batchSummaryNote = `
24754
25051
 
24755
25052
  ---
24756
25053
 
@@ -24758,42 +25055,42 @@ Merge or squash those PRs first, then run \`release\` manually.`;
24758
25055
 
24759
25056
  - Verdicts: ${verdictCounts.pass} pass, ${verdictCounts.warn} warn, ${verdictCounts.fail} fail
24760
25057
  ` + (totalFindings > 0 ? `- Findings: ${findingsBySeverity.error} error${findingsBySeverity.error !== 1 ? "s" : ""}, ${findingsBySeverity.warning} warning${findingsBySeverity.warning !== 1 ? "s" : ""}, ${findingsBySeverity.info} info` : "- No findings logged");
25058
+ }
25059
+ } catch {
24761
25060
  }
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 = `
25061
+ const version = `v0.${result.currentCycle}.0`;
25062
+ const autoGate = evaluateReleaseGate(caps, config2.gateCommand, void 0);
25063
+ if (autoGate.action !== "proceed") {
25064
+ autoReleaseNote = `
24768
25065
 
24769
25066
  ---
24770
25067
 
24771
25068
  \u26A0\uFE0F **Auto-release skipped** \u2014 a release quality gate is configured (\`${config2.gateCommand}\`), and it cannot be run from inside \`review_submit\`.
24772
25069
 
24773
25070
  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 = `
25071
+ } else {
25072
+ const releaseTracker = new ProgressTracker("auto-release").bindStream(adapter2, { stage: "release" });
25073
+ await beginRelease(releaseTracker, result.currentCycle);
25074
+ const releaseResult = await createRelease(config2, baseBranch, version, adapter2, result.currentCycle);
25075
+ await recordReadinessVerified(releaseTracker);
25076
+ await recordQualityGate(releaseTracker, autoGate, caps);
25077
+ await tracker.recordStep("auto_release_triggered", { metadata: { version: releaseResult.version } });
25078
+ const autoChangelogDirective = buildChangelogDirective(
25079
+ caps,
25080
+ buildCycleUpdateCurationDirective(releaseResult.version, releaseResult.cycleClosed ?? 0)
25081
+ );
25082
+ const autoDeployDirective = buildDeployHookDirective(caps, config2.deployCommand);
25083
+ await completeRelease(releaseTracker, {
25084
+ cycleClosed: releaseResult.cycleClosed ?? null,
25085
+ version: releaseResult.version,
25086
+ caps,
25087
+ branchMerges: releaseResult.groupedBranchMerges ?? [],
25088
+ changelogEmitted: Boolean(autoChangelogDirective),
25089
+ deployHookEmitted: Boolean(autoDeployDirective)
25090
+ });
25091
+ const pushInfo = releaseResult.pushNotes.join(" ");
25092
+ const groupedMergeNote = releaseResult.groupedBranchMerges?.length ? "\n" + releaseResult.groupedBranchMerges.map((r) => `- Merged shared branch \`${r.branch}\` via PR: ${r.prUrl ?? "n/a"}`).join("\n") : "";
25093
+ autoReleaseNote = `
24797
25094
 
24798
25095
  ---
24799
25096
 
@@ -24804,13 +25101,14 @@ Run \`release\` manually: PAPI will hand you the gate command, then release once
24804
25101
  - ${releaseResult.tagMessage}
24805
25102
  - ${pushInfo}` + groupedMergeNote + (releaseResult.warnings?.length ? `
24806
25103
  - 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 ? `
25104
+ // cycle-update directive that the manual path emits, so an auto-released
25105
+ // cycle never prompted the Discord post. Same directive, same gate.
25106
+ (autoChangelogDirective ? `
24810
25107
  ${autoChangelogDirective}` : "") + (autoDeployDirective ? `
24811
25108
  ${autoDeployDirective}` : "") + `
24812
25109
 
24813
25110
  Run \`plan\` to create Cycle ${result.currentCycle + 1}.`;
25111
+ }
24814
25112
  }
24815
25113
  }
24816
25114
  }
@@ -24870,9 +25168,9 @@ Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
24870
25168
  `**${result.stageLabel}** recorded for ${result.taskId}.
24871
25169
 
24872
25170
  - **Verdict:** ${result.verdict}
24873
- - **Comments:** ${result.comments}
25171
+ - **Comments:** ${trimForEcho(result.comments)}
24874
25172
 
24875
- ${statusNote}${autoReviewNote}${securityNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
25173
+ ${statusNote}${capabilityReviewSkippedNote}${autoReviewNote}${securityNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
24876
25174
  );
24877
25175
  } catch (err) {
24878
25176
  const message = err instanceof Error ? err.message : String(err);
@@ -26911,6 +27209,7 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
26911
27209
  // Skill proposals — one-time codebase scan on first orient per project.
26912
27210
  // PERF: hasToolMilestone (LIMIT 1 EXISTS) replaces readToolMetrics (5000-row pull).
26913
27211
  tracked("skill-scan", async () => {
27212
+ if (!hasLocalWorkspace()) return "";
26914
27213
  const alreadyScanned = adapter2.hasToolMilestone ? await adapter2.hasToolMilestone("milestone:skill_scan_completed") : (await adapter2.readToolMetrics()).some((m) => m.tool === "milestone:skill_scan_completed");
26915
27214
  if (alreadyScanned) return "";
26916
27215
  const proposals = scanForSkillSignals(config2.projectRoot);
@@ -27805,11 +28104,11 @@ var handoffGenerateTool = {
27805
28104
  },
27806
28105
  llm_response: {
27807
28106
  type: "string",
27808
- description: 'Your raw output from executing the handoff prompt (mode "apply" only). Must include both Part 1 (markdown) and Part 2 (structured JSON after <!-- PAPI_STRUCTURED_OUTPUT -->). Mutually exclusive with llm_response_file.'
28107
+ description: 'Your raw output from executing the handoff prompt (mode "apply" only). Must include both Part 1 (markdown) and Part 2 (structured JSON after <!-- PAPI_STRUCTURED_OUTPUT -->, inside a ```json fence). Each cycleHandoffs entry needs EITHER a buildHandoff string in the BUILD HANDOFF markdown template (section headers on their own lines: SCOPE (DO THIS) / SCOPE BOUNDARY (DO NOT DO THIS) / ACCEPTANCE CRITERIA / EFFORT) OR structured fields alongside taskId: {scope: string[], scopeBoundary: string[], acceptanceCriteria: string[], effort: "XS|S|M|L|XL"}. Mutually exclusive with llm_response_file.'
27809
28108
  },
27810
28109
  llm_response_file: {
27811
28110
  type: "string",
27812
- description: 'Absolute path to a file containing the handoff output (mode "apply" only). Use instead of llm_response when the output exceeds tool parameter size limits. Mutually exclusive with llm_response.'
28111
+ description: 'Absolute path to a file containing the handoff output (mode "apply" only). LOCAL stdio servers only \u2014 on the hosted connection the server cannot read files on your machine; pass llm_response inline instead. Use instead of llm_response when the output exceeds tool parameter size limits. Mutually exclusive with llm_response.'
27813
28112
  },
27814
28113
  cycle_number: {
27815
28114
  type: "number",
@@ -27906,10 +28205,12 @@ ${result.userMessage}
27906
28205
  }
27907
28206
  }
27908
28207
 
28208
+ // src/tools/scope-brief.ts
28209
+ import { readFileSync as readFileSync11, statSync as statSync7 } from "fs";
28210
+
27909
28211
  // src/services/scope-brief.ts
27910
28212
  import { writeFileSync as writeFileSync5, mkdirSync as mkdirSync3 } from "fs";
27911
28213
  import { join as join17, dirname as dirname4 } from "path";
27912
- import Anthropic from "@anthropic-ai/sdk";
27913
28214
  var SCOPE_BRIEF_SYSTEM = `You are a technical scoping tool. You receive a brief-class task (too large to build directly) and decompose it into a structured scope document.
27914
28215
 
27915
28216
  A scope document must:
@@ -27940,7 +28241,18 @@ Return the scope document in Markdown. Use this exact structure:
27940
28241
  <Any unknowns that affect scope, or "None">
27941
28242
 
27942
28243
  Return ONLY the markdown document. No preamble, no commentary.`;
27943
- async function runScopeBrief(adapter2, input) {
28244
+ async function buildScopeBriefPrompt(adapter2, taskId) {
28245
+ const tasks = await adapter2.queryBoard({});
28246
+ const task = tasks.find((t) => t.id === taskId || t.displayId === taskId);
28247
+ if (!task) {
28248
+ throw new Error(`Task not found: ${taskId}`);
28249
+ }
28250
+ if (task.scopeClass !== "brief") {
28251
+ throw new Error(`Task ${taskId} is not brief-class (scopeClass=${task.scopeClass ?? "task"}). Only brief-class tasks can be scoped.`);
28252
+ }
28253
+ return { task, systemPrompt: SCOPE_BRIEF_SYSTEM, userContext: buildTaskContext(task) };
28254
+ }
28255
+ async function applyScopeBrief(adapter2, input) {
27944
28256
  const tasks = await adapter2.queryBoard({});
27945
28257
  const task = tasks.find((t) => t.id === input.taskId || t.displayId === input.taskId);
27946
28258
  if (!task) {
@@ -27949,17 +28261,9 @@ async function runScopeBrief(adapter2, input) {
27949
28261
  if (task.scopeClass !== "brief") {
27950
28262
  throw new Error(`Task ${input.taskId} is not brief-class (scopeClass=${task.scopeClass ?? "task"}). Only brief-class tasks can be scoped.`);
27951
28263
  }
27952
- const taskContext = buildTaskContext(task);
27953
- const client = new Anthropic({ apiKey: input.apiKey });
27954
- const response = await client.messages.create({
27955
- model: "claude-sonnet-4-6",
27956
- max_tokens: 2048,
27957
- system: SCOPE_BRIEF_SYSTEM,
27958
- messages: [{ role: "user", content: taskContext }]
27959
- });
27960
- const docContent = response.content[0].type === "text" ? response.content[0].text.trim() : "";
28264
+ const docContent = input.llmMarkdown.trim();
27961
28265
  if (!docContent) {
27962
- throw new Error("LLM returned empty scope document");
28266
+ throw new Error("Scope document is empty \u2014 pass the markdown you produced from the scope prompt as llm_response.");
27963
28267
  }
27964
28268
  const slug = input.taskId.replace(/[^a-z0-9-]/g, "-").toLowerCase();
27965
28269
  const relPath = `docs/scopes/${slug}.md`;
@@ -28044,7 +28348,7 @@ function buildSummary(task, taskCount) {
28044
28348
  // src/tools/scope-brief.ts
28045
28349
  var scopeBriefTool = {
28046
28350
  name: "scope_brief",
28047
- description: "Decompose a brief-class task (Large/XL, too large to build directly) into a structured scope document. Runs an LLM pass to produce sub-tasks, writes docs/scopes/<task-id>.md, registers it in the doc registry, and marks the source task as decomposed. Use before planning a cycle that includes brief-class tasks.",
28351
+ description: 'Decompose a brief-class task (Large/XL, too large to build directly) into a structured scope document. Two phases, like plan/strategy \u2014 PAPI never calls a model itself (AD-58): first call (mode "prepare") returns a scoping prompt for YOU to run in your own AI workspace; then call again (mode "apply") with your markdown output in llm_response to write docs/scopes/<task-id>.md, register it, and mark the source task decomposed. Use before planning a cycle that includes brief-class tasks.',
28048
28352
  annotations: { title: "Scope Brief", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
28049
28353
  inputSchema: {
28050
28354
  type: "object",
@@ -28052,42 +28356,102 @@ var scopeBriefTool = {
28052
28356
  task_id: {
28053
28357
  type: "string",
28054
28358
  description: 'ID of the brief-class task to decompose (e.g. "task-042").'
28359
+ },
28360
+ mode: {
28361
+ type: "string",
28362
+ enum: ["prepare", "apply"],
28363
+ description: '"prepare" (default) returns the scoping prompt for you to run. "apply" persists the scope document you produced.'
28364
+ },
28365
+ llm_response: {
28366
+ type: "string",
28367
+ description: 'Your markdown scope document from running the prepare prompt (mode "apply" only).'
28368
+ },
28369
+ llm_response_file: {
28370
+ type: "string",
28371
+ description: 'Absolute path to a file holding your markdown scope document (mode "apply" only). LOCAL stdio servers only \u2014 the hosted server cannot read your machine. Mutually exclusive with llm_response; must be absolute, exist, and be \u2264500KB.'
28055
28372
  }
28056
28373
  },
28057
28374
  required: ["task_id"]
28058
28375
  }
28059
28376
  };
28377
+ var MAX_RESPONSE_FILE_BYTES = 500 * 1024;
28060
28378
  async function handleScopeBrief(adapter2, config2, args) {
28061
28379
  const taskId = args.task_id;
28062
28380
  if (!taskId) {
28063
28381
  return errorResponse("task_id is required.");
28064
28382
  }
28065
- const apiKey = process.env["ANTHROPIC_API_KEY"];
28066
- if (!apiKey) {
28067
- return errorResponse("ANTHROPIC_API_KEY is not set \u2014 scope_brief requires LLM access.");
28068
- }
28383
+ const mode = args.mode ?? "prepare";
28069
28384
  const health = await adapter2.getCycleHealth().catch(() => ({ totalCycles: 0 }));
28070
- try {
28071
- const result = await runScopeBrief(adapter2, {
28072
- taskId,
28073
- apiKey,
28074
- projectRoot: config2.projectRoot,
28075
- cycleNumber: health.totalCycles,
28076
- adapterType: config2.adapterType
28077
- });
28078
- const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
28079
- return textResponse(
28080
- `**Scope document created:** \`${result.docPath}\`
28385
+ if (mode === "prepare") {
28386
+ try {
28387
+ const { task, systemPrompt, userContext } = await buildScopeBriefPrompt(adapter2, taskId);
28388
+ return textResponse(
28389
+ `**Scope Brief \u2014 prepare** for ${task.id}: ${task.title}
28390
+
28391
+ Run the scoping pass below in YOUR AI workspace (PAPI does not call a model \u2014 AD-58), then call \`scope_brief\` again with \`mode: "apply"\`, the same \`task_id\`, and your markdown document in \`llm_response\`.
28392
+
28393
+ ---
28394
+
28395
+ ## Scoping instructions
28396
+
28397
+ ${systemPrompt}
28398
+
28399
+ ---
28400
+
28401
+ ## Task to decompose
28402
+
28403
+ ${userContext}`
28404
+ );
28405
+ } catch (err) {
28406
+ const message = err instanceof Error ? err.message : String(err);
28407
+ return errorResponse(`scope_brief prepare failed: ${message}`);
28408
+ }
28409
+ }
28410
+ if (mode === "apply") {
28411
+ const llmMarkdown = readLlmResponse(args);
28412
+ if (!llmMarkdown) {
28413
+ return errorResponse('mode "apply" requires your scope document \u2014 pass it in llm_response (or llm_response_file on a local server).');
28414
+ }
28415
+ try {
28416
+ const result = await applyScopeBrief(adapter2, {
28417
+ taskId,
28418
+ llmMarkdown,
28419
+ projectRoot: config2.projectRoot,
28420
+ cycleNumber: health.totalCycles,
28421
+ adapterType: config2.adapterType
28422
+ });
28423
+ const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
28424
+ return textResponse(
28425
+ `**Scope document created:** \`${result.docPath}\`
28081
28426
  - **Sub-tasks defined:** ${result.taskCount}
28082
28427
  - **Doc registry ID:** ${result.docId}
28083
28428
  - **Source task:** ${taskId} marked as decomposed
28084
28429
 
28085
28430
  **Next step:** Review \`${result.docPath}\` and submit each sub-task via \`idea\` with \`Reference: ${result.docPath}\` in notes. The planner will pick them up in the next cycle.` + filesToWriteSection
28086
- );
28087
- } catch (err) {
28088
- const message = err instanceof Error ? err.message : String(err);
28089
- return errorResponse(`scope_brief failed: ${message}`);
28431
+ );
28432
+ } catch (err) {
28433
+ const message = err instanceof Error ? err.message : String(err);
28434
+ return errorResponse(`scope_brief apply failed: ${message}`);
28435
+ }
28436
+ }
28437
+ return errorResponse(`Unknown mode "${mode}" \u2014 use "prepare" or "apply".`);
28438
+ }
28439
+ function readLlmResponse(args) {
28440
+ const inline = args.llm_response;
28441
+ if (inline && inline.trim()) return inline;
28442
+ const filePath = args.llm_response_file;
28443
+ if (filePath && filePath.trim()) {
28444
+ try {
28445
+ if (statSync7(filePath).size > MAX_RESPONSE_FILE_BYTES) {
28446
+ throw new Error(`llm_response_file exceeds ${MAX_RESPONSE_FILE_BYTES} bytes`);
28447
+ }
28448
+ const body = readFileSync11(filePath, "utf-8");
28449
+ return body.trim() ? body : null;
28450
+ } catch (err) {
28451
+ throw new Error(`could not read llm_response_file: ${err instanceof Error ? err.message : String(err)}`);
28452
+ }
28090
28453
  }
28454
+ return null;
28091
28455
  }
28092
28456
 
28093
28457
  // src/tools/ad-view.ts
@@ -29313,7 +29677,7 @@ function createServer(adapter2, config2) {
29313
29677
  const __pkgDir = dirname5(__pkgFilename);
29314
29678
  let serverVersion = "unknown";
29315
29679
  try {
29316
- const pkg = JSON.parse(readFileSync11(join19(__pkgDir, "..", "package.json"), "utf-8"));
29680
+ const pkg = JSON.parse(readFileSync12(join19(__pkgDir, "..", "package.json"), "utf-8"));
29317
29681
  serverVersion = pkg.version ?? "unknown";
29318
29682
  } catch {
29319
29683
  }
@@ -29738,6 +30102,13 @@ function startHttpTransport(opts) {
29738
30102
  const ip = clientIp(req);
29739
30103
  const origin = req.headers.origin;
29740
30104
  const cors = corsHeaders(origin);
30105
+ const pathname = (() => {
30106
+ try {
30107
+ return new URL(req.url ?? "/", "http://internal").pathname;
30108
+ } catch {
30109
+ return req.url ?? "/";
30110
+ }
30111
+ })();
29741
30112
  if (req.method === "OPTIONS") {
29742
30113
  if (Object.keys(cors).length === 0) {
29743
30114
  sendError(res, { status: 403, body: { error: "Origin not allowed" } });
@@ -29752,12 +30123,12 @@ function startHttpTransport(opts) {
29752
30123
  sendError(res, { status: 400, body: { error: "HTTPS required" } });
29753
30124
  return;
29754
30125
  }
29755
- if (req.method === "GET" && req.url === "/healthz") {
30126
+ if (req.method === "GET" && pathname === "/healthz") {
29756
30127
  res.writeHead(200, { "Content-Type": "text/plain", ...cors });
29757
30128
  res.end("ok");
29758
30129
  return;
29759
30130
  }
29760
- if (req.method === "GET" && req.url === "/.well-known/oauth-protected-resource") {
30131
+ if (req.method === "GET" && pathname === "/.well-known/oauth-protected-resource") {
29761
30132
  res.writeHead(200, {
29762
30133
  "Content-Type": "application/json",
29763
30134
  "Cache-Control": "public, max-age=3600",
@@ -29773,7 +30144,7 @@ function startHttpTransport(opts) {
29773
30144
  );
29774
30145
  return;
29775
30146
  }
29776
- if (req.method === "GET" && req.url === "/.well-known/glama.json") {
30147
+ if (req.method === "GET" && pathname === "/.well-known/glama.json") {
29777
30148
  res.writeHead(200, {
29778
30149
  "Content-Type": "application/json",
29779
30150
  "Cache-Control": "public, max-age=3600",
@@ -29787,7 +30158,7 @@ function startHttpTransport(opts) {
29787
30158
  );
29788
30159
  return;
29789
30160
  }
29790
- if (req.method === "GET" && req.url === "/.well-known/oauth-authorization-server") {
30161
+ if (req.method === "GET" && pathname === "/.well-known/oauth-authorization-server") {
29791
30162
  res.writeHead(302, {
29792
30163
  Location: `${DASHBOARD_ORIGIN}/.well-known/oauth-authorization-server`,
29793
30164
  "Cache-Control": "public, max-age=3600",
@@ -29796,7 +30167,7 @@ function startHttpTransport(opts) {
29796
30167
  res.end();
29797
30168
  return;
29798
30169
  }
29799
- if (req.url !== "/mcp" && req.url !== "/sse") {
30170
+ if (pathname !== "/mcp" && pathname !== "/sse") {
29800
30171
  sendError(res, { status: 404, body: { error: "Not found" } }, cors);
29801
30172
  return;
29802
30173
  }
@@ -30052,7 +30423,7 @@ async function dispatchRequest(args) {
30052
30423
  var __dirname = dirname6(fileURLToPath4(import.meta.url));
30053
30424
  var pkgVersion = "unknown";
30054
30425
  try {
30055
- const pkg = JSON.parse(readFileSync16(join24(__dirname, "..", "package.json"), "utf-8"));
30426
+ const pkg = JSON.parse(readFileSync17(join24(__dirname, "..", "package.json"), "utf-8"));
30056
30427
  pkgVersion = pkg.version;
30057
30428
  } catch {
30058
30429
  }
@@ -30076,6 +30447,7 @@ Options:
30076
30447
  --yes, -y Skip confirmation prompts (reset only)
30077
30448
  --idle-mcp Flag PAPI-idle projects in audit (needs DATABASE_URL; read-only)
30078
30449
  --fix-pool Terminate this role's confirmed-wedged DB backends (doctor only; guarded)
30450
+ --reap-orphans Terminate parentless @papi-ai/server processes (doctor only; guarded)
30079
30451
 
30080
30452
  Getting started:
30081
30453
  1. Run "npx @papi-ai/server setup" in any project folder
@@ -30238,6 +30610,15 @@ if (isHttpMode && httpPort !== void 0) {
30238
30610
  process.stderr.write("[papi] Fatal: stdio mode requires an MCP server instance.\n");
30239
30611
  process.exit(1);
30240
30612
  }
30613
+ try {
30614
+ const { reapOrphans: reapOrphans2 } = await Promise.resolve().then(() => (init_reap_orphans(), reap_orphans_exports));
30615
+ const swept = reapOrphans2({});
30616
+ if (swept.reaped.length > 0) {
30617
+ process.stderr.write(`[papi] Reaped ${swept.reaped.length} orphaned server process(es): ${swept.reaped.join(", ")}
30618
+ `);
30619
+ }
30620
+ } catch {
30621
+ }
30241
30622
  const transport = new StdioServerTransport();
30242
30623
  await server.connect(transport);
30243
30624
  const projectName = basename2(config.projectRoot);