@papi-ai/server 0.7.77 → 0.7.79

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
@@ -561,11 +561,13 @@ function ensureTagAtHead(cwd, tag, message) {
561
561
  message: `tag "${tag}" already exists but points at ${target ? target.slice(0, 7) : "an unknown commit"}, not the current HEAD (${head ? head.slice(0, 7) : "unknown"}). If it is left over from an aborted release, delete it and re-run release: \`git tag -d ${tag}\` (and \`git push origin :refs/tags/${tag}\` if it was pushed). Otherwise use a different version.`
562
562
  };
563
563
  }
564
- function getLatestTag(cwd) {
564
+ function getLatestTag(cwd, timeoutMs) {
565
565
  try {
566
566
  return execFileSync("git", ["describe", "--tags", "--abbrev=0"], {
567
567
  cwd,
568
- encoding: "utf-8"
568
+ encoding: "utf-8",
569
+ stdio: ["ignore", "pipe", "pipe"],
570
+ ...timeoutMs != null ? { timeout: timeoutMs } : {}
569
571
  }).trim() || null;
570
572
  } catch {
571
573
  return null;
@@ -807,20 +809,26 @@ function findTaskCommitsOnBase(cwd, preferredBase, displayIds) {
807
809
  try {
808
810
  raw = execFileSync(
809
811
  "git",
810
- ["log", base, "--format=%h%x01%s", "-n", "1000"],
812
+ ["log", base, "--format=%h%x01%s%x01%b%x02", "-n", "1000"],
811
813
  { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
812
814
  );
813
815
  } catch {
814
816
  return out;
815
817
  }
816
- const commits = raw.split("\n").map((line) => {
817
- const idx = line.indexOf("");
818
- if (idx === -1) return null;
819
- return { hash: line.slice(0, idx).trim(), subject: line.slice(idx + 1).trim() };
820
- }).filter((c) => c !== null && c.hash !== "");
818
+ const commits = raw.split("").map((record) => {
819
+ const parts = record.split("");
820
+ if (parts.length < 2) return null;
821
+ return {
822
+ hash: parts[0].trim(),
823
+ subject: parts[1].trim(),
824
+ body: (parts[2] ?? "").trim()
825
+ };
826
+ }).filter(
827
+ (c) => c !== null && c.hash !== ""
828
+ );
821
829
  for (const displayId of displayIds) {
822
830
  const re = new RegExp(`(^|[^\\w-])${escapeRegexLiteral(displayId)}([^\\w-]|$)`);
823
- const hit = commits.find((c) => re.test(c.subject));
831
+ const hit = commits.find((c) => re.test(c.subject)) ?? commits.find((c) => re.test(c.body));
824
832
  if (!hit) continue;
825
833
  const prMatch = hit.subject.match(/#(\d+)/);
826
834
  out.set(displayId, {
@@ -1406,10 +1414,39 @@ var init_proxy_adapter = __esm({
1406
1414
  endpoint;
1407
1415
  apiKey;
1408
1416
  projectId;
1417
+ onAuthRejected;
1409
1418
  constructor(config2) {
1410
1419
  this.endpoint = config2.endpoint.replace(/\/$/, "");
1411
1420
  this.apiKey = config2.apiKey;
1412
1421
  this.projectId = config2.projectId ?? "";
1422
+ this.onAuthRejected = config2.onAuthRejected;
1423
+ }
1424
+ /**
1425
+ * task-1773: bearer-only auth probe. Hits the USER-scoped `project-list` route
1426
+ * (no projectId needed), so it answers exactly one question: does the proxy
1427
+ * still accept this bearer?
1428
+ *
1429
+ * Returns the HTTP status, or 0 when the call could not be made at all
1430
+ * (network error / timeout). Callers MUST treat 0 — and any status that is
1431
+ * neither 2xx nor 401 — as "no signal", never as a rejection: a proxy outage
1432
+ * must not masquerade as a revoked token and force every user to re-auth.
1433
+ */
1434
+ async probeBearerStatus() {
1435
+ try {
1436
+ const response = await fetch(`${this.endpoint}/project-list`, {
1437
+ method: "POST",
1438
+ headers: {
1439
+ "Content-Type": "application/json",
1440
+ "Authorization": `Bearer ${this.apiKey}`
1441
+ },
1442
+ body: "{}",
1443
+ signal: AbortSignal.timeout(5e3)
1444
+ });
1445
+ if (response.status === 401) this.onAuthRejected?.();
1446
+ return response.status;
1447
+ } catch {
1448
+ return 0;
1449
+ }
1413
1450
  }
1414
1451
  /** Resolved project ID — available after ensureProject() completes. */
1415
1452
  getProjectId() {
@@ -1426,7 +1463,8 @@ var init_proxy_adapter = __esm({
1426
1463
  return wrapWithForwarding(new _ProxyPapiAdapter({
1427
1464
  endpoint: this.endpoint,
1428
1465
  apiKey: this.apiKey,
1429
- projectId
1466
+ projectId,
1467
+ onAuthRejected: this.onAuthRejected
1430
1468
  }));
1431
1469
  }
1432
1470
  /**
@@ -1513,6 +1551,7 @@ var init_proxy_adapter = __esm({
1513
1551
  message = errorBody;
1514
1552
  }
1515
1553
  if (response.status === 401) {
1554
+ this.onAuthRejected?.();
1516
1555
  throw new Error(
1517
1556
  `Auth: Invalid API key \u2014 PAPI_DATA_API_KEY was rejected by the proxy.
1518
1557
  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.
@@ -2091,6 +2130,7 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
2091
2130
  } catch {
2092
2131
  message = errorBody;
2093
2132
  }
2133
+ if (response.status === 401) this.onAuthRejected?.();
2094
2134
  throw new Error(`Proxy error (${response.status}) on ${route}: ${message}`);
2095
2135
  }
2096
2136
  const body = await response.json();
@@ -4412,7 +4452,7 @@ __export(doctor_exports, {
4412
4452
  });
4413
4453
  import { existsSync as existsSync12, readFileSync as readFileSync15 } from "fs";
4414
4454
  import { homedir as homedir4 } from "os";
4415
- import { join as join21 } from "path";
4455
+ import { join as join22 } from "path";
4416
4456
  function redact(name, value) {
4417
4457
  if (!value) return "(empty)";
4418
4458
  if (SECRET_VARS.has(name)) {
@@ -4423,9 +4463,9 @@ function redact(name, value) {
4423
4463
  }
4424
4464
  function findMcpJson() {
4425
4465
  const candidates = [
4426
- join21(process.cwd(), ".mcp.json"),
4427
- join21(homedir4(), ".claude", ".mcp.json"),
4428
- join21(homedir4(), ".mcp.json")
4466
+ join22(process.cwd(), ".mcp.json"),
4467
+ join22(homedir4(), ".claude", ".mcp.json"),
4468
+ join22(homedir4(), ".mcp.json")
4429
4469
  ];
4430
4470
  for (const path7 of candidates) {
4431
4471
  if (!existsSync12(path7)) continue;
@@ -4708,9 +4748,9 @@ __export(reset_exports, {
4708
4748
  removePapiEntry: () => removePapiEntry,
4709
4749
  runReset: () => runReset
4710
4750
  });
4711
- import { existsSync as existsSync13, readFileSync as readFileSync16, writeFileSync as writeFileSync8 } from "fs";
4751
+ import { existsSync as existsSync13, readFileSync as readFileSync16, writeFileSync as writeFileSync9 } from "fs";
4712
4752
  import { homedir as homedir5 } from "os";
4713
- import { join as join22 } from "path";
4753
+ import { join as join23 } from "path";
4714
4754
  import { createInterface } from "readline/promises";
4715
4755
  function findResetTarget() {
4716
4756
  for (const path7 of CANDIDATE_PATHS()) {
@@ -4789,7 +4829,7 @@ async function runReset(args = []) {
4789
4829
  }
4790
4830
  }
4791
4831
  try {
4792
- writeFileSync8(target.path, removePapiEntry(target), "utf-8");
4832
+ writeFileSync9(target.path, removePapiEntry(target), "utf-8");
4793
4833
  process.stdout.write(`
4794
4834
  \u2713 Removed papi entry from ${target.path}
4795
4835
  `);
@@ -4806,9 +4846,9 @@ var init_reset = __esm({
4806
4846
  "src/cli/reset.ts"() {
4807
4847
  "use strict";
4808
4848
  CANDIDATE_PATHS = () => [
4809
- join22(process.cwd(), ".mcp.json"),
4810
- join22(homedir5(), ".claude", ".mcp.json"),
4811
- join22(homedir5(), ".mcp.json")
4849
+ join23(process.cwd(), ".mcp.json"),
4850
+ join23(homedir5(), ".claude", ".mcp.json"),
4851
+ join23(homedir5(), ".mcp.json")
4812
4852
  ];
4813
4853
  }
4814
4854
  });
@@ -4821,7 +4861,7 @@ __export(audit_exports, {
4821
4861
  });
4822
4862
  import { existsSync as existsSync14, readFileSync as readFileSync17, readdirSync as readdirSync7 } from "fs";
4823
4863
  import { homedir as homedir6 } from "os";
4824
- import { join as join23 } from "path";
4864
+ import { join as join24 } from "path";
4825
4865
  function safeListDirs(dir) {
4826
4866
  try {
4827
4867
  return readdirSync7(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort((a, b2) => a.localeCompare(b2));
@@ -4837,7 +4877,7 @@ function safeListFiles(dir, ext) {
4837
4877
  }
4838
4878
  }
4839
4879
  function readMcp(projectPath) {
4840
- const path7 = join23(projectPath, ".mcp.json");
4880
+ const path7 = join24(projectPath, ".mcp.json");
4841
4881
  if (!existsSync14(path7)) return { servers: [] };
4842
4882
  try {
4843
4883
  const parsed = JSON.parse(readFileSync17(path7, "utf-8"));
@@ -4872,18 +4912,18 @@ function auditProjectSync(projectPath, name) {
4872
4912
  path: projectPath,
4873
4913
  papiProjectId,
4874
4914
  mcpServers: servers,
4875
- skills: safeListDirs(join23(projectPath, ".claude", "skills")),
4876
- agentSkills: safeListDirs(join23(projectPath, ".agents", "skills")),
4877
- agents: safeListFiles(join23(projectPath, ".claude", "agents"), ".md"),
4878
- hooks: safeListFiles(join23(projectPath, ".claude", "hooks"), ".sh")
4915
+ skills: safeListDirs(join24(projectPath, ".claude", "skills")),
4916
+ agentSkills: safeListDirs(join24(projectPath, ".agents", "skills")),
4917
+ agents: safeListFiles(join24(projectPath, ".claude", "agents"), ".md"),
4918
+ hooks: safeListFiles(join24(projectPath, ".claude", "hooks"), ".sh")
4879
4919
  };
4880
4920
  }
4881
4921
  function discoverProjects() {
4882
4922
  const out = [];
4883
4923
  for (const root of PROJECT_ROOTS) {
4884
4924
  for (const name of safeListDirs(root)) {
4885
- const path7 = join23(root, name);
4886
- if (existsSync14(join23(path7, ".mcp.json")) || existsSync14(join23(path7, ".claude"))) {
4925
+ const path7 = join24(root, name);
4926
+ if (existsSync14(join24(path7, ".mcp.json")) || existsSync14(join24(path7, ".claude"))) {
4887
4927
  out.push({ name, path: path7 });
4888
4928
  }
4889
4929
  }
@@ -5048,9 +5088,9 @@ var PROJECT_ROOTS, GLOBAL_SKILLS_DIR, GLOBAL_CLAUDE_JSON, IDLE_WINDOW_DAYS, GLOB
5048
5088
  var init_audit = __esm({
5049
5089
  "src/cli/audit.ts"() {
5050
5090
  "use strict";
5051
- PROJECT_ROOTS = [join23(homedir6(), "Ai-App-Projects"), join23(homedir6(), "android-projects")];
5052
- GLOBAL_SKILLS_DIR = join23(homedir6(), ".claude", "skills");
5053
- GLOBAL_CLAUDE_JSON = join23(homedir6(), ".claude.json");
5091
+ PROJECT_ROOTS = [join24(homedir6(), "Ai-App-Projects"), join24(homedir6(), "android-projects")];
5092
+ GLOBAL_SKILLS_DIR = join24(homedir6(), ".claude", "skills");
5093
+ GLOBAL_CLAUDE_JSON = join24(homedir6(), ".claude.json");
5054
5094
  IDLE_WINDOW_DAYS = 30;
5055
5095
  GLOBALIZE_THRESHOLD = 3;
5056
5096
  __testing2 = { readMcp, computeFlags, formatReport: formatReport2, discoverProjects, auditProjectSync };
@@ -5062,8 +5102,8 @@ var setup_exports = {};
5062
5102
  __export(setup_exports, {
5063
5103
  runSetup: () => runSetup
5064
5104
  });
5065
- import { existsSync as existsSync15, readFileSync as readFileSync18, writeFileSync as writeFileSync9, chmodSync as chmodSync2, statSync as statSync9 } from "fs";
5066
- import { join as join24 } from "path";
5105
+ import { existsSync as existsSync15, readFileSync as readFileSync18, writeFileSync as writeFileSync10, chmodSync as chmodSync2, statSync as statSync9 } from "fs";
5106
+ import { join as join25 } from "path";
5067
5107
  function baseUrl() {
5068
5108
  const fromEnv = process.env["PAPI_HOST"] ?? process.env["PAPI_BASE_URL"];
5069
5109
  if (fromEnv) return fromEnv.replace(/\/$/, "");
@@ -5095,7 +5135,7 @@ function sleep(ms) {
5095
5135
  return new Promise((resolve4) => setTimeout(resolve4, ms));
5096
5136
  }
5097
5137
  function writeMcpJson(opts) {
5098
- const path7 = join24(process.cwd(), ".mcp.json");
5138
+ const path7 = join25(process.cwd(), ".mcp.json");
5099
5139
  let parsed = {};
5100
5140
  if (existsSync15(path7)) {
5101
5141
  try {
@@ -5120,7 +5160,7 @@ function writeMcpJson(opts) {
5120
5160
  }
5121
5161
  mcpServers.papi = papiEntry;
5122
5162
  parsed.mcpServers = mcpServers;
5123
- writeFileSync9(path7, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
5163
+ writeFileSync10(path7, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
5124
5164
  try {
5125
5165
  const mode = statSync9(path7).mode & 511;
5126
5166
  if (mode !== 384) chmodSync2(path7, 384);
@@ -5231,7 +5271,7 @@ var init_setup = __esm({
5231
5271
 
5232
5272
  // src/index.ts
5233
5273
  import { readFileSync as readFileSync19 } from "fs";
5234
- import { dirname as dirname7, join as join25, basename as basename2 } from "path";
5274
+ import { dirname as dirname7, join as join26, basename as basename2 } from "path";
5235
5275
  import { fileURLToPath as fileURLToPath5 } from "url";
5236
5276
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5237
5277
  import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
@@ -5647,9 +5687,12 @@ function parsePlanningLog(content, activeDecisionsContent, cycleLogContent) {
5647
5687
  var VALID_EFFORT_SIZES = /* @__PURE__ */ new Set(["XS", "S", "M", "L", "XL"]);
5648
5688
  var SECTION_HEADERS = [
5649
5689
  "SCOPE (DO THIS)",
5690
+ "WHY NOT SIMPLER",
5650
5691
  "SCOPE BOUNDARY (DO NOT DO THIS)",
5651
5692
  "ACCEPTANCE CRITERIA",
5693
+ "PRE-MORTEM",
5652
5694
  "SECURITY CONSIDERATIONS",
5695
+ "DEPLOY VERIFICATION",
5653
5696
  "PRE-BUILD VERIFICATION",
5654
5697
  "FILES LIKELY TOUCHED",
5655
5698
  "EFFORT"
@@ -5688,7 +5731,7 @@ function parseBulletsOnly(text) {
5688
5731
  return text.split("\n").filter((l) => /^\s*-\s/.test(l)).map((l) => l.replace(/^\s*-\s*/, "").trim()).filter((l) => l.length > 0);
5689
5732
  }
5690
5733
  function parseChecklist(text) {
5691
- return text.split("\n").map((l) => l.replace(/^\s*\[[ x]]\s*/, "").trim()).filter((l) => l.length > 0);
5734
+ return text.split("\n").map((l) => l.replace(/^\s*(?:[-*+]\s*)?(?:\[[ xX]\]\s*)?/, "").trim()).filter((l) => l.length > 0);
5692
5735
  }
5693
5736
  function parseBuildHandoff(markdown) {
5694
5737
  if (typeof markdown !== "string" || !markdown.trim()) return null;
@@ -6338,13 +6381,21 @@ var EFFORT_SCALE = {
6338
6381
  XL: 5
6339
6382
  };
6340
6383
  function effortOrdinal(effort) {
6384
+ if (typeof effort !== "string") return void 0;
6341
6385
  const normalized = effort.trim().toUpperCase();
6342
6386
  return EFFORT_SCALE[normalized];
6343
6387
  }
6388
+ function isUnparsedEffort(effort) {
6389
+ if (typeof effort !== "string" || effort.trim().length === 0) return false;
6390
+ return effortOrdinal(effort) === void 0;
6391
+ }
6344
6392
  function calculateCycleMetrics(reports, currentCycle, window = 5) {
6345
6393
  const recentReports = reports.filter(
6346
6394
  (r) => r.cycle > currentCycle - window && r.cycle <= currentCycle
6347
6395
  );
6396
+ const unparsedEffortCount = recentReports.filter(
6397
+ (r) => isUnparsedEffort(r.actualEffort) || isUnparsedEffort(r.estimatedEffort)
6398
+ ).length;
6348
6399
  const perCycle = /* @__PURE__ */ new Map();
6349
6400
  for (const r of recentReports) {
6350
6401
  const group = perCycle.get(r.cycle) ?? [];
@@ -6381,7 +6432,7 @@ function calculateCycleMetrics(reports, currentCycle, window = 5) {
6381
6432
  effortPoints: reps.reduce((s, r) => s + (effortOrdinal(r.actualEffort) ?? 0), 0)
6382
6433
  });
6383
6434
  }
6384
- return { accuracy, velocity };
6435
+ return { accuracy, velocity, unparsedEffortCount };
6385
6436
  }
6386
6437
  function serializeAccuracyRow(a) {
6387
6438
  return `| ${a.cycle} | ${a.reports} | ${a.matchRate}% | ${a.mae} | ${a.bias >= 0 ? "+" : ""}${a.bias} |`;
@@ -8031,6 +8082,7 @@ async function createAdapter(optionsOrType, maybePapiDir) {
8031
8082
  case "pg": {
8032
8083
  const { PgAdapter, PgPapiAdapter, configFromEnv } = await import("@papi-ai/adapter-pg");
8033
8084
  let projectId = process.env["PAPI_PROJECT_ID"];
8085
+ const projectIdWasPreSupplied = Boolean(projectId);
8034
8086
  const projectRoot = options.projectRoot ?? process.env["PAPI_PROJECT_DIR"] ?? process.cwd();
8035
8087
  let rootHash = null;
8036
8088
  let originUrl = null;
@@ -8094,6 +8146,26 @@ async function createAdapter(optionsOrType, maybePapiDir) {
8094
8146
  }
8095
8147
  const config2 = papiEndpoint ? { connectionString: papiEndpoint } : configFromEnv();
8096
8148
  validateDatabaseUrl(config2.connectionString);
8149
+ if (projectIdWasPreSupplied) {
8150
+ const ownershipProbe = new PgAdapter(config2);
8151
+ try {
8152
+ const owned = await ownershipProbe.findProjectById(projectId, resolveUserId);
8153
+ if (!owned) {
8154
+ throw new Error(
8155
+ `PAPI_PROJECT_ID ${projectId} does not belong to you.
8156
+
8157
+ The project exists under a different owner, or does not exist at all. PAPI refuses to attach to a project you do not own \u2014 writing to it would put your cycles, tasks and Active Decisions into somebody else's project.
8158
+
8159
+ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the project from your git remote), then reconnect.`
8160
+ );
8161
+ }
8162
+ } finally {
8163
+ try {
8164
+ await ownershipProbe.close();
8165
+ } catch {
8166
+ }
8167
+ }
8168
+ }
8097
8169
  const { ensureSchema } = await import("@papi-ai/adapter-pg");
8098
8170
  try {
8099
8171
  await ensureSchema(config2);
@@ -8328,7 +8400,7 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
8328
8400
  // src/server.ts
8329
8401
  import { readFileSync as readFileSync14 } from "fs";
8330
8402
  import { access as access4, readdir as readdir4, readFile as readFile9 } from "fs/promises";
8331
- import { join as join20, dirname as dirname6 } from "path";
8403
+ import { join as join21, dirname as dirname6 } from "path";
8332
8404
  import { fileURLToPath as fileURLToPath4 } from "url";
8333
8405
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8334
8406
  import {
@@ -8461,15 +8533,23 @@ function formatBuildReports(reports, opts) {
8461
8533
  _\u2026and ${reports.length - capped.length} older build report(s) omitted to bound context size._` : "";
8462
8534
  return body + omitted;
8463
8535
  }
8464
- function extractReferencedTaskIds(report) {
8465
- const prose = [report.surprises, report.architectureNotes, report.deadEnds, report.discoveredIssues].filter((s) => typeof s === "string" && s.length > 0).join("\n");
8536
+ function extractTaskReferences(report) {
8537
+ const raw = [report.surprises, report.architectureNotes, report.deadEnds, report.discoveredIssues].filter((s) => typeof s === "string" && s.length > 0).join("\n");
8538
+ const prose = raw.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~/g, " ").replace(/`[^`\n]*`/g, " ");
8466
8539
  const own = report.taskId?.toLowerCase();
8467
- const found = /* @__PURE__ */ new Set();
8540
+ const resolves = /* @__PURE__ */ new Set();
8541
+ for (const m of prose.matchAll(/\bresolves:\s*((?:task-\d+\b\s*,?\s*)+)/gi)) {
8542
+ for (const idMatch of m[1].matchAll(/\btask-\d+\b/gi)) {
8543
+ const id = idMatch[0].toLowerCase();
8544
+ if (id !== own) resolves.add(id);
8545
+ }
8546
+ }
8547
+ const mentions = /* @__PURE__ */ new Set();
8468
8548
  for (const m of prose.matchAll(/\btask-\d+\b/gi)) {
8469
8549
  const id = m[0].toLowerCase();
8470
- if (id !== own) found.add(id);
8550
+ if (id !== own && !resolves.has(id)) mentions.add(id);
8471
8551
  }
8472
- return [...found].sort();
8552
+ return { resolves: [...resolves].sort(), mentions: [...mentions].sort() };
8473
8553
  }
8474
8554
  function formatRecentlyShippedCapabilities(reports) {
8475
8555
  const completed = reports.filter((r) => r.completed === "Yes" || r.completed === "Partial");
@@ -8485,16 +8565,26 @@ function formatRecentlyShippedCapabilities(reports) {
8485
8565
  }
8486
8566
  return parts.join("\n");
8487
8567
  });
8568
+ const resolvedBy = /* @__PURE__ */ new Map();
8488
8569
  const namedBy = /* @__PURE__ */ new Map();
8489
8570
  const completedIds = new Set(completed.map((r) => r.taskId?.toLowerCase()).filter(Boolean));
8571
+ const record = (into, ref, namer) => {
8572
+ const namers = into.get(ref) ?? [];
8573
+ namers.push(namer);
8574
+ into.set(ref, namers);
8575
+ };
8490
8576
  for (const r of completed) {
8491
- for (const ref of extractReferencedTaskIds(r)) {
8577
+ const { resolves, mentions } = extractTaskReferences(r);
8578
+ for (const ref of resolves) {
8492
8579
  if (completedIds.has(ref)) continue;
8493
- const namers = namedBy.get(ref) ?? [];
8494
- namers.push(r.taskId);
8495
- namedBy.set(ref, namers);
8580
+ record(resolvedBy, ref, r.taskId);
8581
+ }
8582
+ for (const ref of mentions) {
8583
+ if (completedIds.has(ref)) continue;
8584
+ record(namedBy, ref, r.taskId);
8496
8585
  }
8497
8586
  }
8587
+ for (const ref of resolvedBy.keys()) namedBy.delete(ref);
8498
8588
  const out = [
8499
8589
  `${completed.length} task(s) completed in recent cycles:`,
8500
8590
  "",
@@ -8502,17 +8592,31 @@ function formatRecentlyShippedCapabilities(reports) {
8502
8592
  "",
8503
8593
  "Cross-reference candidate tasks against this list. If >80% of a candidate task's scope appears here, recommend cancellation or scope reduction instead of scheduling."
8504
8594
  ];
8595
+ if (resolvedBy.size > 0) {
8596
+ out.push(
8597
+ "",
8598
+ "### \u26A0 Declared resolved by a shipped task \u2014 VERIFY, THEN CLOSE",
8599
+ "",
8600
+ "A shipped report explicitly claimed each of these with `resolves: <task-id>`, but",
8601
+ "the task is not itself marked complete. That is a declaration of intent, not proof:",
8602
+ "confirm against the live code, then close it with a boardCorrection rather than",
8603
+ "spending a cycle slot on work that already shipped.",
8604
+ "",
8605
+ ...[...resolvedBy.entries()].sort(([a], [b2]) => a.localeCompare(b2)).map(([ref, namers]) => `- **${ref}** \u2014 declared resolved by ${namers.join(", ")}`)
8606
+ );
8607
+ }
8505
8608
  if (namedBy.size > 0) {
8506
8609
  out.push(
8507
8610
  "",
8508
8611
  "### \u26A0 Named by a shipped task \u2014 VERIFY BEFORE SCHEDULING",
8509
8612
  "",
8510
- "These task IDs are referenced in the build reports above but are NOT themselves",
8511
- "completed. A discovery is often fixed as a side effect of a sibling task's diff and",
8512
- "never marked done, so it survives into this plan carrying notes that are no longer",
8513
- "true (C357 gave task-3043 a P1 slot this way \u2014 task-2998 had already fixed it).",
8514
- "For each one below: check the naming report and the live code BEFORE scheduling it.",
8515
- "If it is already fixed, close it with a boardCorrection instead of spending a slot.",
8613
+ "These task IDs merely APPEAR in the build reports above \u2014 no report claimed to have",
8614
+ "resolved them, and they are not themselves completed. A discovery is often fixed as a",
8615
+ "side effect of a sibling task's diff and never marked done, so it survives into this",
8616
+ "plan carrying notes that are no longer true (C357 gave task-3043 a P1 slot this way \u2014",
8617
+ "task-2998 had already fixed it). Weaker signal than the section above: a mention can",
8618
+ 'equally mean "related to" or "still blocked by". Read the naming report and the live',
8619
+ "code BEFORE scheduling; never close on a mention alone.",
8516
8620
  "",
8517
8621
  ...[...namedBy.entries()].sort(([a], [b2]) => a.localeCompare(b2)).map(([ref, namers]) => `- **${ref}** \u2014 named by ${namers.join(", ")}`)
8518
8622
  );
@@ -9089,6 +9193,19 @@ If a candidate AD body could be invalidated by running a SQL query, refreshing a
9089
9193
  **Negative example (reject):** "External user feedback is now flowing. Stonebridge Systems is actively building." \u2014 this is a fact about the current state of the world. Capture as dogfood/signal observation; do not mint.
9090
9194
 
9091
9195
  This rule applies to: new ADs proposed during planning (Step 9), strategy review AD updates (section 5), and strategy_change AD updates. If you find an existing AD that violates this rule during housekeeping, propose deleting it (action: "delete") with a one-line rationale.`;
9196
+ var AD_CONFLICT_SURFACING_RULES = `**A contradiction is NOT a veto \u2014 surface it, never silently shelve it.**
9197
+
9198
+ Active Decisions are *active*: they can be superseded, modified, or abandoned. You do NOT have authority to kill a piece of work simply because it cuts against one. That is the user's call, and they can only make it if you show it to them.
9199
+
9200
+ **Never do any of these to a task solely because it contradicts, competes with, or duplicates the intent of an existing AD:** cancel it; drop it to P3; defer it silently; omit it from the cycle without comment; or reduce its scope down to the part that fits the AD.
9201
+
9202
+ **Instead, emit a \`decisionConflicts\` entry** for it (see the structured-output schema) with: the task, the AD it collides with, one line on what the AD says versus what the task implies, 2-3 concrete options, and your recommendation. Also render a \`## Decisions Needed\` section in Part 1 listing the same, immediately before the BUILD HANDOFFs.
9203
+
9204
+ Apply will park each conflicted task as **Blocked** with a \`decision-gate\` blocker pointing at that AD. It clears automatically the moment the AD is resolved, confirmed, validated, or superseded \u2014 so the work returns to the board on its own once the user has decided. The task is preserved, not killed.
9205
+
9206
+ **Emit a conflict ONLY when the AD is the reason the work would not otherwise proceed.** If the task is fine to schedule, schedule it. If it is genuinely obsolete, duplicated, or already shipped, cancel it on THOSE grounds and say so \u2014 that is not an AD conflict. Do not manufacture conflicts to look thorough; a plan with zero real conflicts should emit an empty array.
9207
+
9208
+ This applies equally to *competing* work: two tasks proposing rival approaches to the same problem is a decision for the user, not a coin-flip for you.`;
9092
9209
  var OUTPUT_QUALITY_RUBRIC = `Quality bar \u2014 before emitting, self-score the artifact 1-10 on five dimensions: (1) **Clarity** \u2014 could a third LLM act on it with no extra context? (2) **Scope tightness** \u2014 one focused unit of work, not three bundled together. (3) **Specificity** \u2014 it names concrete files/paths/tasks/ADs, not vague references like "the auth module". (4) **Dependency surfacing** \u2014 prerequisite or upstream items are called out explicitly. (5) **Success-criteria concreteness** \u2014 "done" is testable and observable, not "looks good". Sum to /50. If any single dimension scores \u22644, or the total is <35, revise the artifact and re-score before emitting \u2014 do not ship a below-threshold artifact. This is a self-check gate, not an output field: do NOT add the scores to the artifact or the structured JSON.`;
9093
9210
  var PLAN_SYSTEM = `You are the PAPI Cycle Planner \u2014 an autonomous planning engine for software projects.
9094
9211
  You receive project context and produce a planning cycle output with a BUILD HANDOFF.
@@ -9169,7 +9286,8 @@ After your natural language output, include this EXACT format on its own line:
9169
9286
  "newTasks": [],
9170
9287
  "boardCorrections": [],
9171
9288
  "productBrief": null,
9172
- "activeDecisions": []
9289
+ "activeDecisions": [],
9290
+ "decisionConflicts": [{"taskId": "string \u2014 an existing task ID or a newTasks tempId", "adId": "string \u2014 AD-N this collides with", "conflict": "string \u2014 one line: what the AD says vs what this task implies", "options": ["string \u2014 concrete option, e.g. 'Supersede AD-12 and schedule the task'"], "recommendation": "string \u2014 which option you would take and why, in one sentence"}]
9173
9291
  }
9174
9292
  \`\`\`
9175
9293
 
@@ -9206,6 +9324,7 @@ Everything in Part 1 (natural language) is **display-only**. Part 2 (structured
9206
9324
  - Updated or created Active Decisions in Part 1? \u2192 Put them in \`activeDecisions\` array (with id and full body including ### heading)
9207
9325
  - Found board corrections (wrong priority, missing fields, stale status) in Part 1? \u2192 Put them in \`boardCorrections\` array
9208
9326
  - Generated BUILD HANDOFFs in Part 1? \u2192 Put them in \`cycleHandoffs\` array
9327
+ - Wrote a \`## Decisions Needed\` section in Part 1? \u2192 Put every entry in \`decisionConflicts\`. Nothing in that array means the user is never asked, and the work is lost. Omit the array (or leave it empty) when there are genuinely no AD conflicts.
9209
9328
  - **\`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.
9210
9329
 
9211
9330
  **Example with populated fields (DO NOT copy literally \u2014 adapt to your actual analysis):**
@@ -9393,7 +9512,7 @@ Standard planning cycle with full board review.
9393
9512
  **\u26A0\uFE0F PRIORITY RECALIBRATION \u2014 do NOT rubber-stamp the submitted priority.** The priority set at idea submission reflects the submitter's view at that time, which may be outdated by the time the planner runs. For EVERY unreviewed task, evaluate its priority FROM SCRATCH against: (a) current horizon/stage/phase goals, (b) recent Active Decision changes, (c) recently shipped functionality that makes this task more or less urgent. If your assessed priority differs from the submitted one, set the new priority in \`boardCorrections\` and include the change in a **Priority Recalibration** paragraph in your cycle log (Step 8): list each changed task by ID, old priority \u2192 new priority, and a 1-sentence rationale. This paragraph is how the user sees what the planner recalibrated and why. If no priorities changed during triage, omit the paragraph.
9394
9513
  Also set complexity using the full range \u2014 **XS, Small, Medium, Large, XL** \u2014 based on actual scope, not conservatively. XS = single-line or config change. Small = one file, < 50 lines. Medium = 2-5 files. Large = cross-module, multiple components. XL = architectural, multi-day.
9395
9514
  **Module classification for cross-cutting tasks:** When a task title contains "audit"/"unfiltered"/"scoping"/"leak" plus a database-entity name (e.g. "audit ... cycle_learnings reads", "unfiltered cycle_tasks queries"), classify the module by the actual code surface that reads/writes the entity \u2014 not by the tool names mentioned in the title. The reasoning surface (e.g. "health" or "strategy_review") is often unrelated to the data-access surface. Resolve this by treating the entity name as the routing signal: tasks touching dashboard read/write paths belong to the Dashboard module even if the title mentions an MCP tool. Misclassification routes the task to the wrong shared cycle branch and surfaces the wrong MODULE INSTRUCTIONS to the builder.
9396
- If a task is clearly obsolete, duplicated, or rejected, set its status to "Cancelled" with a \`closureReason\` explaining why.
9515
+ If a task is clearly obsolete, duplicated, or rejected, set its status to "Cancelled" with a \`closureReason\` explaining why. **"It contradicts an Active Decision" is NOT one of those reasons** \u2014 route it to \`decisionConflicts\` instead (see the AD Conflict Surfacing rule below step 7).
9397
9516
  **\u2192 PERSIST:** For each task you set reviewed: true, corrected fields on, or marked "Cancelled", include it in \`boardCorrections\` in Part 2.
9398
9517
 
9399
9518
  3. **Board Integrity** \u2014 All tasks have complete fields? Priority still accurate? Duplicates? Stale In Progress tasks?
@@ -9436,6 +9555,10 @@ Standard planning cycle with full board review.
9436
9555
  **Epic-aware batching:** Epic is the primary grouping signal for theme coherence. When multiple candidate tasks share the same epic (e.g. "Onboarding Redesign", "Dashboard Polish"), prefer co-scheduling them \u2014 they solve connected problems and benefit from shared context during the build. Steps: (1) After filtering by priority, group eligible tasks by epic. (2) If an epic has 3+ eligible tasks, prefer scheduling 2-4 of them together over cherry-picking across epics. (3) Report the epic distribution in the cycle log (e.g. "4 tasks from Onboarding epic, 1 from Platform"). Priority still overrides: a P0 fix from a different epic always takes precedence.
9437
9556
  **Opportunity clustering:** If backlog tasks have an \`opportunity\` field populated, group them by opportunity before selecting. Tasks sharing the same opportunity solve the same user problem \u2014 co-scheduling them produces more coherent cycles. Report opportunity clusters in the cycle log when present (e.g. "3 tasks clustered under 'planner accuracy' opportunity").
9438
9557
 
9558
+ ${AD_CONFLICT_SURFACING_RULES}
9559
+
9560
+ **Where this fires:** during Inbox Triage (step 2), the Priority Drift Check (step 3), and selection (step 7). A task whose notes already carry an \`AD-CONFLICT:\` line, or an "AD-N alignment/conflict candidate" note from the idea tool, is a mandatory candidate \u2014 read it, and either schedule it, cancel it on non-AD grounds, or emit the conflict. Do not leave it sitting silently in Backlog for a third cycle.
9561
+
9439
9562
  8. **Cycle Log** \u2014 Write 5-10 line entry: what was triaged, what was recommended and why, observations, AD updates. Include a **Priority Recalibration** paragraph if any unreviewed task priorities were changed during triage (Step 2) \u2014 list each by ID with old \u2192 new priority and rationale. Include a **Priority Drift Suggestions** paragraph if reviewed task drift was detected (Step 3).
9440
9563
  **Cycle Notes** \u2014 Optionally include 1-3 lines of cycle-level observations in \`cycleLogNotes\`: estimation accuracy patterns, recurring blockers, velocity trends, or dependency signals. These notes persist across cycles so future planning runs can learn from them. Use null if there are no noteworthy observations this cycle.
9441
9564
 
@@ -9834,6 +9957,17 @@ function coerceToString(value) {
9834
9957
  if (value === null || value === void 0) return "";
9835
9958
  return JSON.stringify(value, null, 2);
9836
9959
  }
9960
+ function coerceCarryForward(value) {
9961
+ if (value === null || value === void 0) return { value: null };
9962
+ if (typeof value === "string") {
9963
+ const trimmed = value.trim();
9964
+ return { value: trimmed.length > 0 ? trimmed : null };
9965
+ }
9966
+ const shape = Array.isArray(value) ? "array" : typeof value;
9967
+ const warning = `cycleLogCarryForward was ${shape}, not a string \u2014 DROPPED rather than persisted. Carry-forward is prose that orient parses for the WHAT SHIPS FOR USERS / RELEASE MECHANICS labels; a non-string value cannot carry them. Re-run plan apply with cycleLogCarryForward as a single string (or null) to record one for this cycle.`;
9968
+ console.error(`[plan] ${warning}`);
9969
+ return { value: null, warning };
9970
+ }
9837
9971
  function coerceStructuredOutput(parsed) {
9838
9972
  const cycleHandoffs = Array.isArray(parsed.cycleHandoffs) ? parsed.cycleHandoffs.map((h) => {
9839
9973
  const { taskId: _t, buildHandoff: _b, ...rest } = h;
@@ -9873,10 +10007,19 @@ function coerceStructuredOutput(parsed) {
9873
10007
  body: coerceToString(ad.body)
9874
10008
  })) : [];
9875
10009
  const cycleTaskIds = Array.isArray(parsed.cycleTaskIds) ? parsed.cycleTaskIds.map((id) => coerceToString(id)) : void 0;
10010
+ const carryForward = coerceCarryForward(parsed.cycleLogCarryForward);
10011
+ const decisionConflicts = Array.isArray(parsed.decisionConflicts) ? parsed.decisionConflicts.map((c) => ({
10012
+ taskId: coerceToString(c.taskId).trim(),
10013
+ adId: coerceToString(c.adId).trim(),
10014
+ conflict: coerceToString(c.conflict).trim(),
10015
+ options: Array.isArray(c.options) ? c.options.map((o) => coerceToString(o)) : [],
10016
+ recommendation: coerceToString(c.recommendation).trim()
10017
+ })).filter((c) => c.taskId.length > 0 && c.adId.length > 0) : void 0;
9876
10018
  return {
9877
10019
  cycleLogTitle: coerceToString(parsed.cycleLogTitle),
9878
10020
  cycleLogContent: coerceToString(parsed.cycleLogContent),
9879
- cycleLogCarryForward: parsed.cycleLogCarryForward === null ? null : coerceToString(parsed.cycleLogCarryForward),
10021
+ cycleLogCarryForward: carryForward.value,
10022
+ ...carryForward.warning ? { coercionWarnings: [carryForward.warning] } : {},
9880
10023
  cycleLogNotes: parsed.cycleLogNotes === null ? null : coerceToString(parsed.cycleLogNotes),
9881
10024
  nextMode: "Full",
9882
10025
  boardHealth: coerceToString(parsed.boardHealth),
@@ -9887,7 +10030,8 @@ function coerceStructuredOutput(parsed) {
9887
10030
  newTasks,
9888
10031
  boardCorrections,
9889
10032
  productBrief: parsed.productBrief === null ? null : coerceToString(parsed.productBrief),
9890
- activeDecisions
10033
+ activeDecisions,
10034
+ decisionConflicts
9891
10035
  };
9892
10036
  }
9893
10037
  var REVIEW_SYSTEM_COMPRESSION_SECTION = `
@@ -9962,6 +10106,7 @@ You MUST cover these 5 sections. Each is mandatory.
9962
10106
  - Only flag ADs that represent a genuine strategic question requiring owner input
9963
10107
  - Note any hierarchy/phase issues worth correcting (1-2 bullets max)
9964
10108
  - Delete ADs that are legacy, process-level, or redundant without discussion
10109
+ - **Blocked-on-decision tasks are evidence (task-3219).** If the board carries tasks Blocked behind a \`decision-gate\` on an AD, that AD is actively costing the project work. Resolve it here \u2014 supersede, modify, or explicitly reaffirm it with a one-line reason. Reaffirming is a legitimate answer; leaving it unaddressed is not, because the blocked work stays parked until someone rules.
9965
10110
 
9966
10111
  ${AD_REJECTION_RULES}
9967
10112
 
@@ -11575,6 +11720,52 @@ function applyContextTier(ctx, cycleCount) {
11575
11720
  }
11576
11721
  return { tier, label };
11577
11722
  }
11723
+ var PARKABLE_STATUSES = /* @__PURE__ */ new Set([
11724
+ "Backlog",
11725
+ "In Cycle",
11726
+ "Ready",
11727
+ "Deferred"
11728
+ ]);
11729
+ async function parkDecisionConflicts(adapter2, conflicts, newTaskIdMap, blockedCycle, scheduledTaskIds = []) {
11730
+ if (conflicts.length === 0) return [];
11731
+ const scheduled = new Set(scheduledTaskIds.map((id) => id.toLowerCase()));
11732
+ const results = [];
11733
+ for (const conflict of conflicts) {
11734
+ const resolvedTaskId = newTaskIdMap.get(conflict.taskId) ?? conflict.taskId;
11735
+ const base = { ...conflict, resolvedTaskId, parked: false };
11736
+ if (scheduled.has(resolvedTaskId.toLowerCase())) {
11737
+ results.push({ ...base, skipReason: "scheduled in this cycle \u2014 left in the cycle, conflict still surfaced" });
11738
+ continue;
11739
+ }
11740
+ try {
11741
+ const [task] = await adapter2.getTasks([resolvedTaskId]);
11742
+ if (!task) {
11743
+ results.push({ ...base, skipReason: "task not found" });
11744
+ continue;
11745
+ }
11746
+ if (!PARKABLE_STATUSES.has(task.status)) {
11747
+ results.push({ ...base, skipReason: `status is ${task.status} \u2014 left as-is` });
11748
+ continue;
11749
+ }
11750
+ if (task.status === "Deferred") {
11751
+ await adapter2.updateTask(resolvedTaskId, { status: "Backlog" });
11752
+ }
11753
+ await adapter2.updateTask(resolvedTaskId, {
11754
+ status: "Blocked",
11755
+ blocker: {
11756
+ type: "decision-gate",
11757
+ ref: conflict.adId,
11758
+ reason: conflict.conflict || `Contradicts ${conflict.adId} \u2014 awaiting the owner's decision.`,
11759
+ blockedCycle
11760
+ }
11761
+ });
11762
+ results.push({ ...base, parked: true });
11763
+ } catch (err) {
11764
+ results.push({ ...base, skipReason: err instanceof Error ? err.message : String(err) });
11765
+ }
11766
+ }
11767
+ return results;
11768
+ }
11578
11769
  function determineMode(totalCycles) {
11579
11770
  if (totalCycles === 0) return "bootstrap";
11580
11771
  return "full";
@@ -13086,6 +13277,7 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
13086
13277
  let writeBackWarnings;
13087
13278
  let writeSummary;
13088
13279
  let skippedCancellations;
13280
+ let decisionConflicts;
13089
13281
  const persistenceFailed = !data;
13090
13282
  if (data) {
13091
13283
  try {
@@ -13097,8 +13289,9 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
13097
13289
  contextHashes,
13098
13290
  { confirmCancellations: planRunMeta?.confirmCancellations === true, ownerUserId: applyScope.callerUserId ?? void 0 }
13099
13291
  );
13100
- if (wbWarnings.length > 0) {
13101
- writeBackWarnings = wbWarnings;
13292
+ const allWarnings = [...data.coercionWarnings ?? [], ...wbWarnings];
13293
+ if (allWarnings.length > 0) {
13294
+ writeBackWarnings = allWarnings;
13102
13295
  }
13103
13296
  if (skipped.length > 0) {
13104
13297
  skippedCancellations = skipped;
@@ -13111,6 +13304,19 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
13111
13304
  for (const [placeholder, realId] of newTaskIdMap) {
13112
13305
  resolvedDisplayText = resolvedDisplayText.replaceAll(placeholder, realId);
13113
13306
  }
13307
+ if (data.decisionConflicts && data.decisionConflicts.length > 0) {
13308
+ try {
13309
+ const parked = await parkDecisionConflicts(
13310
+ adapter2,
13311
+ data.decisionConflicts,
13312
+ newTaskIdMap,
13313
+ cycleNumber + 1,
13314
+ ws.taskIds
13315
+ );
13316
+ if (parked.length > 0) decisionConflicts = parked;
13317
+ } catch {
13318
+ }
13319
+ }
13114
13320
  if (adapter2.insertPlanRun) {
13115
13321
  const durationMs = Date.now() - applyStartMs + (planRunMeta?.prepareStartMs !== void 0 ? Date.now() - planRunMeta.prepareStartMs : 0);
13116
13322
  adapter2.insertPlanRun({
@@ -13163,7 +13369,8 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
13163
13369
  writeBackFailed,
13164
13370
  writeBackWarnings,
13165
13371
  writeSummary,
13166
- skippedCancellations
13372
+ skippedCancellations,
13373
+ decisionConflicts
13167
13374
  };
13168
13375
  }
13169
13376
  async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker, density) {
@@ -13782,6 +13989,47 @@ async function resolveLlmResponse(inlineResponse, filePath) {
13782
13989
  return { ok: true, llmResponse: contents };
13783
13990
  }
13784
13991
 
13992
+ // src/lib/overflow.ts
13993
+ import { writeFileSync as writeFileSync2 } from "fs";
13994
+ import { tmpdir } from "os";
13995
+ import { join as join3 } from "path";
13996
+ import { createHash as createHash2 } from "crypto";
13997
+ var DISPATCH_THRESHOLD_BYTES = 50 * 1024;
13998
+ var ELIDE_THRESHOLD_BYTES = 90 * 1024;
13999
+ var INLINE_CEILING_BYTES = 40 * 1024;
14000
+ function byteLength(s) {
14001
+ return Buffer.byteLength(s, "utf-8");
14002
+ }
14003
+ function shouldDispatch(contextBytes, explicit) {
14004
+ if (explicit === true) return true;
14005
+ if (explicit === false) return false;
14006
+ if (process.env.PAPI_AUTO_DISPATCH === "false") return false;
14007
+ return contextBytes > DISPATCH_THRESHOLD_BYTES;
14008
+ }
14009
+ function canSpill(adapterType) {
14010
+ return adapterType !== "proxy";
14011
+ }
14012
+ function spillPath(kind, projectId, callerKey) {
14013
+ const id = createHash2("sha256").update(`${projectId ?? "no-project"}|${callerKey ?? "default"}`).digest("hex").slice(0, 16);
14014
+ return join3(tmpdir(), `papi-${kind}-${id}.md`);
14015
+ }
14016
+ function spill(kind, content, projectId, callerKey) {
14017
+ const path7 = spillPath(kind, projectId, callerKey);
14018
+ writeFileSync2(path7, content, { mode: 384 });
14019
+ return path7;
14020
+ }
14021
+ function planDelivery(input) {
14022
+ const bytes = byteLength(input.payload);
14023
+ if (bytes <= INLINE_CEILING_BYTES) return { mode: "inline", payload: input.payload };
14024
+ if (!canSpill(input.adapterType)) return { mode: "undeliverable", bytes };
14025
+ try {
14026
+ const path7 = spill(input.kind, input.payload, input.projectId, input.callerKey);
14027
+ return { mode: "spilled", path: path7, bytes };
14028
+ } catch {
14029
+ return { mode: "undeliverable", bytes };
14030
+ }
14031
+ }
14032
+
13785
14033
  // src/services/session-guidance.ts
13786
14034
  var DEFAULT_CALLER_KEY = "__default__";
13787
14035
  var sessionStates = /* @__PURE__ */ new Map();
@@ -13982,20 +14230,20 @@ var PerCallerCache = class {
13982
14230
  };
13983
14231
 
13984
14232
  // src/lib/plan-prepare-store.ts
13985
- import { createHash as createHash2 } from "crypto";
13986
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, existsSync, statSync } from "fs";
13987
- import { tmpdir } from "os";
13988
- import { join as join3 } from "path";
14233
+ import { createHash as createHash3 } from "crypto";
14234
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync3, unlinkSync, existsSync, statSync } from "fs";
14235
+ import { tmpdir as tmpdir2 } from "os";
14236
+ import { join as join4 } from "path";
13989
14237
  var SPILL_TTL_MS = 24 * 60 * 60 * 1e3;
13990
14238
  var DEFAULT_CALLER_KEY3 = "__default__";
13991
- function spillPath(projectId, callerKey) {
13992
- const id = createHash2("sha256").update(`${projectId ?? "no-project"}|${callerKey ?? DEFAULT_CALLER_KEY3}`).digest("hex").slice(0, 16);
13993
- return join3(tmpdir(), `papi-plan-prepare-${id}.json`);
14239
+ function spillPath2(projectId, callerKey) {
14240
+ const id = createHash3("sha256").update(`${projectId ?? "no-project"}|${callerKey ?? DEFAULT_CALLER_KEY3}`).digest("hex").slice(0, 16);
14241
+ return join4(tmpdir2(), `papi-plan-prepare-${id}.json`);
13994
14242
  }
13995
14243
  function savePrepareSpill(projectId, callerKey, state) {
13996
14244
  try {
13997
- writeFileSync2(
13998
- spillPath(projectId, callerKey),
14245
+ writeFileSync3(
14246
+ spillPath2(projectId, callerKey),
13999
14247
  JSON.stringify({ savedAt: Date.now(), state }),
14000
14248
  { mode: 384 }
14001
14249
  );
@@ -14003,7 +14251,7 @@ function savePrepareSpill(projectId, callerKey, state) {
14003
14251
  }
14004
14252
  }
14005
14253
  function loadPrepareSpill(projectId, callerKey) {
14006
- const path7 = spillPath(projectId, callerKey);
14254
+ const path7 = spillPath2(projectId, callerKey);
14007
14255
  try {
14008
14256
  if (!existsSync(path7)) return void 0;
14009
14257
  if (Date.now() - statSync(path7).mtimeMs > SPILL_TTL_MS) {
@@ -14018,18 +14266,18 @@ function loadPrepareSpill(projectId, callerKey) {
14018
14266
  }
14019
14267
  function clearPrepareSpill(projectId, callerKey) {
14020
14268
  try {
14021
- const path7 = spillPath(projectId, callerKey);
14269
+ const path7 = spillPath2(projectId, callerKey);
14022
14270
  if (existsSync(path7)) unlinkSync(path7);
14023
14271
  } catch {
14024
14272
  }
14025
14273
  }
14026
14274
  function contextPath(projectId, callerKey) {
14027
- const id = createHash2("sha256").update(`${projectId ?? "no-project"}|${callerKey ?? DEFAULT_CALLER_KEY3}`).digest("hex").slice(0, 16);
14028
- return join3(tmpdir(), `papi-plan-context-${id}.md`);
14275
+ const id = createHash3("sha256").update(`${projectId ?? "no-project"}|${callerKey ?? DEFAULT_CALLER_KEY3}`).digest("hex").slice(0, 16);
14276
+ return join4(tmpdir2(), `papi-plan-context-${id}.md`);
14029
14277
  }
14030
14278
  function savePrepareContextFile(projectId, callerKey, content) {
14031
14279
  const path7 = contextPath(projectId, callerKey);
14032
- writeFileSync2(path7, content, { mode: 384 });
14280
+ writeFileSync3(path7, content, { mode: 384 });
14033
14281
  return path7;
14034
14282
  }
14035
14283
 
@@ -14169,6 +14417,20 @@ function formatPlanResult(result) {
14169
14417
  lines.push('To apply these cancellations, re-run `plan` with `mode: "apply"` and `confirm_cancellations: true`.');
14170
14418
  lines.push("To keep these tasks, do nothing \u2014 they remain on the board.");
14171
14419
  }
14420
+ const conflicts = result.decisionConflicts ?? [];
14421
+ if (conflicts.length > 0) {
14422
+ lines.push("");
14423
+ lines.push(`\u{1F500} **${conflicts.length} decision(s) need your call \u2014 work was NOT dropped.**`);
14424
+ lines.push("These tasks cut against a live Active Decision. An AD is *active* \u2014 it can be superseded, so the planner parked the work instead of shelving it:");
14425
+ for (const c of conflicts) {
14426
+ const state = c.parked ? `Blocked behind ${c.adId}` : `still ${c.skipReason ?? "unparked"}`;
14427
+ lines.push(`- **${c.resolvedTaskId} vs ${c.adId}** \u2014 ${c.conflict} _(${state})_`);
14428
+ if (c.options.length > 0) lines.push(` Options: ${c.options.join(" | ")}`);
14429
+ if (c.recommendation) lines.push(` Planner recommends: ${c.recommendation}`);
14430
+ }
14431
+ lines.push("");
14432
+ lines.push('Decide: `strategy_change` with `mode: "capture"` to supersede or modify the AD (parked tasks auto-unblock once it moves), or `board_edit` to cancel the task if the AD stands.');
14433
+ }
14172
14434
  if (result.skipHandoffs) {
14173
14435
  const taskCount = result.writeSummary?.taskIds.length ?? 0;
14174
14436
  lines.push("", `Next: run \`handoff_generate\` to create BUILD HANDOFFs for your ${taskCount} cycle task(s), then \`build_list\` to start building.`);
@@ -14291,16 +14553,8 @@ async function handlePlan(adapter2, config2, args) {
14291
14553
  };
14292
14554
  planPrepareCache.set(callerKey, prepareState);
14293
14555
  savePrepareSpill(adapter2.getProjectId?.(), callerKey, prepareState);
14294
- const autoDispatchEnabled = process.env.PAPI_AUTO_DISPATCH !== "false";
14295
- const autoDispatchThreshold = 50 * 1024;
14296
- let dispatch;
14297
- if (args.dispatch === "inline" || args.dispatch === "subagent") {
14298
- dispatch = args.dispatch;
14299
- } else if (autoDispatchEnabled && result.contextBytes !== void 0 && result.contextBytes > autoDispatchThreshold) {
14300
- dispatch = "subagent";
14301
- } else {
14302
- dispatch = "inline";
14303
- }
14556
+ const explicit = args.dispatch === "inline" ? false : args.dispatch === "subagent" ? true : void 0;
14557
+ const dispatch = shouldDispatch(result.contextBytes ?? 0, explicit) ? "subagent" : "inline";
14304
14558
  const modeLabel = result.mode === "bootstrap" ? "Bootstrap" : "Full";
14305
14559
  const header = result.strategyReviewWarning ? `${result.strategyReviewWarning}
14306
14560
  ` : "";
@@ -14414,10 +14668,10 @@ ${result.userMessage}
14414
14668
  }
14415
14669
 
14416
14670
  // src/services/strategy.ts
14417
- import { randomUUID as randomUUID10, createHash as createHash3 } from "crypto";
14671
+ import { randomUUID as randomUUID10, createHash as createHash4 } from "crypto";
14418
14672
  import { execFileSync as execFileSync2 } from "child_process";
14419
14673
  import { existsSync as existsSync2, readdirSync, statSync as statSync2 } from "fs";
14420
- import { join as join4 } from "path";
14674
+ import { join as join5 } from "path";
14421
14675
  import { homedir as homedir2 } from "os";
14422
14676
 
14423
14677
  // src/services/idea.ts
@@ -14767,14 +15021,22 @@ ${lines.join("\n")}
14767
15021
  }
14768
15022
  }
14769
15023
  let notesWithAlignment = input.notes || "";
15024
+ let detectedAdIds = [];
14770
15025
  try {
14771
15026
  const ads = await adapter2.getActiveDecisions();
14772
15027
  const ideaCombined = `${input.text} ${input.notes ?? ""}`;
14773
15028
  const alignmentMatches = findAdAlignmentMatches(ideaCombined, ads);
14774
15029
  if (alignmentMatches.length > 0) {
14775
15030
  notesWithAlignment = `${notesWithAlignment}${formatAdAlignmentNote(alignmentMatches)}`.trim();
15031
+ detectedAdIds = alignmentMatches.map((m) => m.displayId);
14776
15032
  }
14777
15033
  } catch {
15034
+ }
15035
+ const conflictsWithAd = input.conflictsWithAd?.trim();
15036
+ if (conflictsWithAd) {
15037
+ notesWithAlignment = `${notesWithAlignment}
15038
+
15039
+ AD-CONFLICT: ${conflictsWithAd} \u2014 submitter flagged this as cutting against that decision. Owner decides: supersede the AD or drop the task.`.trim();
14778
15040
  }
14779
15041
  const inherited = await resolveVisibilityFromDocs(adapter2, [input.docRef]);
14780
15042
  const task = await adapter2.createTask({
@@ -14843,17 +15105,52 @@ ${lines.join("\n")}
14843
15105
  } catch {
14844
15106
  }
14845
15107
  }
15108
+ let gated = false;
15109
+ if (conflictsWithAd) {
15110
+ try {
15111
+ await adapter2.updateTask(task.id, {
15112
+ status: "Blocked",
15113
+ blocker: {
15114
+ type: "decision-gate",
15115
+ ref: conflictsWithAd,
15116
+ reason: `Submitted as cutting against ${conflictsWithAd}. Awaiting the owner's decision.`,
15117
+ blockedCycle: health.totalCycles
15118
+ }
15119
+ });
15120
+ gated = true;
15121
+ } catch {
15122
+ }
15123
+ }
14846
15124
  const typeNote = typeInferred ? ` [type: ${taskType} \u2014 inferred from text]` : "";
14847
15125
  const visibilityNote = inherited.visibility !== "public" ? ` [visibility: ${inherited.visibility} \u2014 inherited from source doc]` : "";
14848
15126
  const mismatchNote = inherited.mismatchWarning ? `
14849
15127
 
14850
15128
  \u26A0\uFE0F ${inherited.mismatchWarning}` : "";
15129
+ const adIds = conflictsWithAd ? [conflictsWithAd] : detectedAdIds;
15130
+ const landing = gated ? "created and gated on the decision" : "added to backlog";
14851
15131
  return {
14852
15132
  routing: "task",
14853
15133
  task,
14854
- message: `${task.id}: "${task.title}" \u2014 added to backlog${typeNote}${visibilityNote}${mismatchNote}`
15134
+ message: `${task.id}: "${task.title}" \u2014 ${landing}${typeNote}${visibilityNote}${mismatchNote}`,
15135
+ ...adIds.length > 0 ? { adConflicts: { adIds, explicit: Boolean(conflictsWithAd), gated } } : {}
14855
15136
  };
14856
15137
  }
15138
+ function buildAdConflictNote(conflictsWithAd, detectedAdIds, gated) {
15139
+ if (conflictsWithAd) {
15140
+ const state = gated ? `Parked as **Blocked** behind a decision gate on ${conflictsWithAd} \u2014 it auto-unblocks once that AD is superseded, modified, or reaffirmed.` : `Recorded against ${conflictsWithAd} (gate could not be applied \u2014 the AD-CONFLICT note is on the task).`;
15141
+ return `
15142
+
15143
+ \u{1F500} **Conflicts with ${conflictsWithAd}.** ${state}
15144
+ **Tell the user now** \u2014 an AD is *active*, so this is their call to make, not yours. Offer both paths: supersede the AD via \`strategy_change\`, or leave the AD standing and drop the task.`;
15145
+ }
15146
+ if (detectedAdIds.length > 0) {
15147
+ return `
15148
+
15149
+ \u{1F500} **Possible conflict with ${detectedAdIds.join(", ")}** (keyword match \u2014 verify it is real).
15150
+ If it IS a real contradiction, **do not shelve the idea on those grounds**: surface it to the user and re-submit with \`conflicts_with_ad\` so the task is gated on the decision instead of quietly competing in the backlog.`;
15151
+ }
15152
+ return "";
15153
+ }
14857
15154
  var CANVAS_SECTION_LABELS = {
14858
15155
  landscape: "Landscape References",
14859
15156
  journeys: "User Journeys",
@@ -15424,7 +15721,7 @@ ${surpriseDigestText}` : buildReportsText;
15424
15721
  try {
15425
15722
  const fullCanvasText = formatDiscoveryCanvas(canvas);
15426
15723
  if (fullCanvasText) {
15427
- const canvasHash = createHash3("md5").update(fullCanvasText).digest("hex");
15724
+ const canvasHash = createHash4("md5").update(fullCanvasText).digest("hex");
15428
15725
  const lastReview = previousStrategyReviews?.[0];
15429
15726
  const prevHash = lastReview?.structuredData?.canvasHash;
15430
15727
  if (prevHash && prevHash === canvasHash) {
@@ -15494,11 +15791,11 @@ ${lines.join("\n")}`;
15494
15791
  }
15495
15792
  let recentPlansText;
15496
15793
  try {
15497
- const plansDir = join4(homedir2(), ".claude", "plans");
15794
+ const plansDir = join5(homedir2(), ".claude", "plans");
15498
15795
  if (existsSync2(plansDir)) {
15499
15796
  const lastReviewDate = previousStrategyReviews?.[0]?.createdAt ? new Date(previousStrategyReviews[0].createdAt) : /* @__PURE__ */ new Date(0);
15500
15797
  const planFiles = readdirSync(plansDir).filter((f) => f.endsWith(".md")).map((f) => {
15501
- const fullPath = join4(plansDir, f);
15798
+ const fullPath = join5(plansDir, f);
15502
15799
  const stat4 = statSync2(fullPath);
15503
15800
  return { name: f, modified: stat4.mtime, size: stat4.size };
15504
15801
  }).filter((f) => f.modified > lastReviewDate).sort((a, b2) => b2.modified.getTime() - a.modified.getTime()).slice(0, 15);
@@ -15515,7 +15812,7 @@ ${lines.join("\n")}`;
15515
15812
  }
15516
15813
  let unregisteredDocsText;
15517
15814
  try {
15518
- const docsDir = join4(projectRoot, "docs");
15815
+ const docsDir = join5(projectRoot, "docs");
15519
15816
  if (hasLocalWorkspace() && existsSync2(docsDir)) {
15520
15817
  const registeredPaths = new Set(
15521
15818
  (registeredDocs ?? []).map((d) => d.path).filter(Boolean)
@@ -15523,7 +15820,7 @@ ${lines.join("\n")}`;
15523
15820
  const allDocFiles = [];
15524
15821
  const scanDir = (dir, prefix) => {
15525
15822
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
15526
- if (entry.isDirectory()) scanDir(join4(dir, entry.name), `${prefix}${entry.name}/`);
15823
+ if (entry.isDirectory()) scanDir(join5(dir, entry.name), `${prefix}${entry.name}/`);
15527
15824
  else if (entry.name.endsWith(".md")) allDocFiles.push(`${prefix}${entry.name}`);
15528
15825
  }
15529
15826
  };
@@ -15780,6 +16077,65 @@ function extractDecisionEvidence(ad, eventType, warnings) {
15780
16077
  }
15781
16078
  return { evidenceRef, metricDelta };
15782
16079
  }
16080
+ function asDecisionBatchApplier(adapter2) {
16081
+ const candidate = adapter2;
16082
+ return typeof candidate.applyActiveDecisionUpdates === "function" ? candidate : void 0;
16083
+ }
16084
+ function routeDecisionUpdate(ad, adapter2, cycleNumber, warnings) {
16085
+ const action = ad.action;
16086
+ let route;
16087
+ if (action === "delete" && adapter2.deleteActiveDecision) {
16088
+ route = "delete";
16089
+ } else if (action === "new" && adapter2.upsertActiveDecision) {
16090
+ route = "upsert";
16091
+ } else {
16092
+ route = "update";
16093
+ }
16094
+ const titleMatch = ad.body.match(/^###\s+\S+:\s*([^\n[]+?)(?:\s*\[|$)/m);
16095
+ const confidenceMatch = ad.body.match(/\[Confidence:\s*(HIGH|MEDIUM|LOW)\]/i);
16096
+ const eventType = action === "delete" ? "invalidated" : action === "confidence_change" ? "confidence_changed" : action === "supersede" ? "superseded" : action === "new" ? "created" : "modified";
16097
+ const evidence = extractDecisionEvidence(ad, eventType, warnings);
16098
+ return {
16099
+ id: ad.id,
16100
+ body: ad.body,
16101
+ route,
16102
+ action,
16103
+ title: titleMatch ? titleMatch[1].trim() : ad.id,
16104
+ confidence: confidenceMatch ? confidenceMatch[1].toUpperCase() : "MEDIUM",
16105
+ event: {
16106
+ decisionId: ad.id,
16107
+ eventType,
16108
+ cycle: cycleNumber,
16109
+ source: "strategy_review",
16110
+ sourceRef: `cycle-${cycleNumber}-review`,
16111
+ detail: `Action: ${action}`,
16112
+ evidenceRef: evidence.evidenceRef,
16113
+ metricDelta: evidence.metricDelta
16114
+ }
16115
+ };
16116
+ }
16117
+ async function applyDecisionUpdates(adapter2, updates, cycleNumber, warnings) {
16118
+ if (updates.length === 0) return;
16119
+ const applies = updates.map((ad) => routeDecisionUpdate(ad, adapter2, cycleNumber, warnings));
16120
+ const batch = asDecisionBatchApplier(adapter2);
16121
+ if (batch) {
16122
+ await batch.applyActiveDecisionUpdates(applies, cycleNumber);
16123
+ return;
16124
+ }
16125
+ for (const apply of applies) {
16126
+ if (apply.route === "delete") {
16127
+ await adapter2.deleteActiveDecision(apply.id);
16128
+ } else if (apply.route === "upsert") {
16129
+ await adapter2.upsertActiveDecision(apply.id, apply.body, apply.title, apply.confidence, cycleNumber);
16130
+ } else {
16131
+ await adapter2.updateActiveDecision(apply.id, apply.body, cycleNumber, apply.action);
16132
+ }
16133
+ try {
16134
+ await adapter2.appendDecisionEvent(apply.event);
16135
+ } catch {
16136
+ }
16137
+ }
16138
+ }
15783
16139
  async function writeBack2(adapter2, cycleNumber, data, fullAnalysis, warnings) {
15784
16140
  const cleanTitle = data.sessionLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
15785
16141
  const cleanContent = data.sessionLogContent.replace(/^#{1,3}\s+(?:Cycle|Session)\s+\d+\s*—[^\n]*\n*/i, "").trim();
@@ -15804,7 +16160,7 @@ ${cleanContent}`;
15804
16160
  const currentCanvas = await adapter2.readDiscoveryCanvas();
15805
16161
  const canvasText = formatDiscoveryCanvas(currentCanvas);
15806
16162
  if (canvasText) {
15807
- return { ...sd, canvasHash: createHash3("md5").update(canvasText).digest("hex") };
16163
+ return { ...sd, canvasHash: createHash4("md5").update(canvasText).digest("hex") };
15808
16164
  }
15809
16165
  } catch {
15810
16166
  }
@@ -15821,34 +16177,7 @@ ${cleanContent}`;
15821
16177
  } catch {
15822
16178
  }
15823
16179
  if (data.activeDecisionUpdates && data.activeDecisionUpdates.length > 0) {
15824
- await Promise.all(data.activeDecisionUpdates.map(async (ad) => {
15825
- if (ad.action === "delete" && adapter2.deleteActiveDecision) {
15826
- await adapter2.deleteActiveDecision(ad.id);
15827
- } else if (ad.action === "new" && adapter2.upsertActiveDecision) {
15828
- const titleMatch = ad.body.match(/^###\s+\S+:\s*([^\n[]+?)(?:\s*\[|$)/m);
15829
- const title = titleMatch ? titleMatch[1].trim() : ad.id;
15830
- const confidenceMatch = ad.body.match(/\[Confidence:\s*(HIGH|MEDIUM|LOW)\]/i);
15831
- const confidence = confidenceMatch ? confidenceMatch[1].toUpperCase() : "MEDIUM";
15832
- await adapter2.upsertActiveDecision(ad.id, ad.body, title, confidence, cycleNumber);
15833
- } else {
15834
- await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, ad.action);
15835
- }
15836
- const eventType = ad.action === "delete" ? "invalidated" : ad.action === "confidence_change" ? "confidence_changed" : ad.action === "supersede" ? "superseded" : ad.action === "new" ? "created" : "modified";
15837
- const evidence = extractDecisionEvidence(ad, eventType, warnings);
15838
- try {
15839
- await adapter2.appendDecisionEvent({
15840
- decisionId: ad.id,
15841
- eventType,
15842
- cycle: cycleNumber,
15843
- source: "strategy_review",
15844
- sourceRef: `cycle-${cycleNumber}-review`,
15845
- detail: `Action: ${ad.action}`,
15846
- evidenceRef: evidence.evidenceRef,
15847
- metricDelta: evidence.metricDelta
15848
- });
15849
- } catch {
15850
- }
15851
- }));
16180
+ await applyDecisionUpdates(adapter2, data.activeDecisionUpdates, cycleNumber, warnings);
15852
16181
  }
15853
16182
  try {
15854
16183
  if (adapter2.confirmPendingActiveDecisions) {
@@ -16606,37 +16935,45 @@ async function prepareStrategyChange(adapter2, text, adapterType) {
16606
16935
  async function applyStrategyChangeOutput(adapter2, rawLlmOutput, cycleNumber) {
16607
16936
  return processStrategyChangeOutput(adapter2, rawLlmOutput, cycleNumber);
16608
16937
  }
16938
+ function asDecisionIdAllocator(adapter2) {
16939
+ const candidate = adapter2;
16940
+ return typeof candidate.allocateActiveDecision === "function" ? candidate : void 0;
16941
+ }
16942
+ var AD_ID_PLACEHOLDER = "{{AD_ID}}";
16943
+ function toAdBodyTemplate(body) {
16944
+ return body.replace(/^(\s*#{1,6}\s+)AD-\d+\b/m, `$1${AD_ID_PLACEHOLDER}`);
16945
+ }
16609
16946
  async function captureDecision(adapter2, input) {
16610
16947
  const health = await adapter2.getCycleHealth();
16611
16948
  const cycleNumber = health.totalCycles;
16612
- let adId;
16613
- let adAction;
16614
- if (input.adId) {
16615
- adId = input.adId;
16616
- adAction = "updated";
16617
- } else {
16618
- const existingAds = await adapter2.getActiveDecisions({ includeRetired: true });
16619
- const maxNum = existingAds.reduce((max, ad) => {
16620
- const match = ad.id.match(/^AD-(\d+)$/);
16621
- return match ? Math.max(max, parseInt(match[1], 10)) : max;
16622
- }, 0);
16623
- adId = `AD-${maxNum + 1}`;
16624
- adAction = "created";
16949
+ const supersedesId = input.supersedes?.trim() || void 0;
16950
+ if (supersedesId && input.confidenceOnly) {
16951
+ throw new Error("supersedes cannot be combined with confidence_only \u2014 a confidence bump does not replace a decision.");
16952
+ }
16953
+ if (supersedesId) {
16954
+ const all = await adapter2.getActiveDecisions({ includeRetired: true });
16955
+ if (!all.some((d) => d.id === supersedesId)) {
16956
+ throw new Error(`supersedes: ${supersedesId} does not exist on this project. Check the AD id (e.g. "AD-42").`);
16957
+ }
16958
+ if (supersedesId === input.adId) {
16959
+ throw new Error("An AD cannot supersede itself.");
16960
+ }
16625
16961
  }
16626
16962
  if (input.confidenceOnly) {
16627
16963
  if (!input.adId) {
16628
16964
  throw new Error('confidence_only requires adId \u2014 provide the AD to update (e.g. "AD-12")');
16629
16965
  }
16966
+ const adId2 = input.adId;
16630
16967
  if (adapter2.upsertActiveDecision) {
16631
16968
  const existing = await adapter2.getActiveDecisions({ includeRetired: false });
16632
- const current = existing.find((d) => d.id === adId);
16969
+ const current = existing.find((d) => d.id === adId2);
16633
16970
  const preservedBody = current?.body ?? `- **Decision:** ${input.text}`;
16634
16971
  const preservedTitle = current?.title ?? input.text.slice(0, 80);
16635
- await adapter2.upsertActiveDecision(adId, preservedBody, preservedTitle, input.confidence, cycleNumber);
16972
+ await adapter2.upsertActiveDecision(adId2, preservedBody, preservedTitle, input.confidence, cycleNumber);
16636
16973
  }
16637
16974
  try {
16638
16975
  await adapter2.appendDecisionEvent({
16639
- decisionId: adId,
16976
+ decisionId: adId2,
16640
16977
  eventType: "modified",
16641
16978
  cycle: cycleNumber,
16642
16979
  source: "strategy_change",
@@ -16645,18 +16982,66 @@ async function captureDecision(adapter2, input) {
16645
16982
  });
16646
16983
  } catch {
16647
16984
  }
16648
- return { cycleNumber, adId, adAction: "updated" };
16985
+ return { cycleNumber, adId: adId2, adAction: "updated" };
16649
16986
  }
16650
16987
  const title = input.text.length > 80 ? input.text.slice(0, 77) + "..." : input.text;
16651
- const adBody = input.adBody ?? `### ${adId}: ${title} [Confidence: ${input.confidence}]
16988
+ const supersedesLine = supersedesId ? `
16989
+ - **Supersedes:** ${supersedesId}` : "";
16990
+ const bodyTemplate = input.adBody ? toAdBodyTemplate(input.adBody) : `### ${AD_ID_PLACEHOLDER}: ${title} [Confidence: ${input.confidence}]
16652
16991
 
16653
- - **Decision:** ${input.text}
16992
+ - **Decision:** ${input.text}${supersedesLine}
16654
16993
  - **Evidence:** Captured from conversation, Cycle ${cycleNumber}.
16655
16994
  - **Status:** Active`;
16656
- if (adapter2.upsertActiveDecision) {
16657
- await adapter2.upsertActiveDecision(adId, adBody, title, input.confidence, cycleNumber);
16995
+ let adId;
16996
+ let adAction;
16997
+ if (input.adId) {
16998
+ adId = input.adId;
16999
+ adAction = "updated";
17000
+ const adBody = bodyTemplate.split(AD_ID_PLACEHOLDER).join(adId);
17001
+ if (adapter2.upsertActiveDecision) {
17002
+ await adapter2.upsertActiveDecision(adId, adBody, title, input.confidence, cycleNumber);
17003
+ } else {
17004
+ await adapter2.updateActiveDecision(adId, adBody, cycleNumber);
17005
+ }
16658
17006
  } else {
16659
- await adapter2.updateActiveDecision(adId, adBody, cycleNumber);
17007
+ adAction = "created";
17008
+ const allocator = asDecisionIdAllocator(adapter2);
17009
+ if (allocator) {
17010
+ adId = await allocator.allocateActiveDecision(bodyTemplate, title, input.confidence, cycleNumber);
17011
+ } else {
17012
+ const existingAds = await adapter2.getActiveDecisions({ includeRetired: true });
17013
+ const maxNum = existingAds.reduce((max, ad) => {
17014
+ const match = ad.id.match(/^AD-(\d+)$/);
17015
+ return match ? Math.max(max, parseInt(match[1], 10)) : max;
17016
+ }, 0);
17017
+ adId = `AD-${maxNum + 1}`;
17018
+ const adBody = bodyTemplate.split(AD_ID_PLACEHOLDER).join(adId);
17019
+ if (adapter2.upsertActiveDecision) {
17020
+ await adapter2.upsertActiveDecision(adId, adBody, title, input.confidence, cycleNumber);
17021
+ } else {
17022
+ await adapter2.updateActiveDecision(adId, adBody, cycleNumber);
17023
+ }
17024
+ }
17025
+ }
17026
+ if (supersedesId) {
17027
+ const all = await adapter2.getActiveDecisions({ includeRetired: true });
17028
+ const prior = all.find((d) => d.id === supersedesId);
17029
+ const priorBody = prior?.body ?? "";
17030
+ const note = `
17031
+
17032
+ - **Superseded by:** ${adId} (Cycle ${cycleNumber}) \u2014 ${input.text}`;
17033
+ await adapter2.updateActiveDecision(supersedesId, `${priorBody}${note}`, cycleNumber, "supersede");
17034
+ try {
17035
+ await adapter2.appendDecisionEvent({
17036
+ decisionId: supersedesId,
17037
+ eventType: "superseded",
17038
+ cycle: cycleNumber,
17039
+ source: "strategy_change",
17040
+ sourceRef: `cycle-${cycleNumber}-capture`,
17041
+ detail: `Superseded by ${adId}: ${input.text.slice(0, 180)}`
17042
+ });
17043
+ } catch {
17044
+ }
16660
17045
  }
16661
17046
  try {
16662
17047
  await adapter2.appendDecisionEvent({
@@ -16677,11 +17062,13 @@ async function captureDecision(adapter2, input) {
16677
17062
  title: `Decision captured: ${adId}`,
16678
17063
  content: `**${adAction === "created" ? "New" : "Updated"} Active Decision** \u2014 ${adId}: ${input.text}
16679
17064
 
16680
- Confidence: ${input.confidence}. Captured mid-conversation via strategy_change capture mode (Cycle ${cycleNumber}).`
17065
+ Confidence: ${input.confidence}. Captured mid-conversation via strategy_change capture mode (Cycle ${cycleNumber}).` + (supersedesId ? `
17066
+
17067
+ Supersedes ${supersedesId} (retired, kept as history).` : "")
16681
17068
  });
16682
17069
  } catch {
16683
17070
  }
16684
- return { cycleNumber, adId, adAction };
17071
+ return { cycleNumber, adId, adAction, supersededId: supersedesId };
16685
17072
  }
16686
17073
 
16687
17074
  // src/tools/strategy.ts
@@ -16797,6 +17184,10 @@ var strategyChangeTool = {
16797
17184
  type: "boolean",
16798
17185
  description: `When true (mode "capture" + ad_id required), only update the confidence level \u2014 leave the AD body unchanged. Use when evidence strength changes but the decision itself hasn't shifted.`
16799
17186
  },
17187
+ supersedes: {
17188
+ type: "string",
17189
+ description: 'Existing AD ID this new decision replaces, e.g. "AD-42" (mode "capture" only). The named AD is marked superseded and kept as history \u2014 never overwritten. Use this instead of passing ad_id when the decision has CHANGED rather than been refined.'
17190
+ },
16800
17191
  north_star: {
16801
17192
  type: "string",
16802
17193
  description: 'mode "capture" only \u2014 set/update the project North Star statement directly. orient and the project foundation read it. No decision text required when this is provided.'
@@ -16893,16 +17284,8 @@ ${recLines.join("\n")}
16893
17284
  userMessage: result.userMessage,
16894
17285
  contextBytes: reviewContextBytes
16895
17286
  });
16896
- const autoDispatchEnabled = process.env.PAPI_AUTO_DISPATCH !== "false";
16897
- const autoDispatchThreshold = 50 * 1024;
16898
- let dispatch;
16899
- if (args.dispatch === "inline" || args.dispatch === "subagent") {
16900
- dispatch = args.dispatch;
16901
- } else if (autoDispatchEnabled && reviewContextBytes > autoDispatchThreshold) {
16902
- dispatch = "subagent";
16903
- } else {
16904
- dispatch = "inline";
16905
- }
17287
+ const explicit = args.dispatch === "inline" ? false : args.dispatch === "subagent" ? true : void 0;
17288
+ const dispatch = shouldDispatch(reviewContextBytes, explicit) ? "subagent" : "inline";
16906
17289
  if (dispatch === "subagent") {
16907
17290
  const dispatchPrompt = buildSubagentDispatchPrompt({
16908
17291
  tool: "strategy_review",
@@ -17030,19 +17413,24 @@ orient and the project foundation will read this value.`
17030
17413
  const confidence = args.confidence ?? "MEDIUM";
17031
17414
  const adBody = args.ad_body;
17032
17415
  const confidenceOnly = args.confidence_only === true;
17416
+ const supersedes = args.supersedes?.trim();
17033
17417
  const result = await captureDecision(adapter2, {
17034
17418
  text: text2.trim(),
17035
17419
  adId: adId?.trim(),
17036
17420
  confidence,
17037
17421
  adBody: adBody?.trim(),
17038
- confidenceOnly
17422
+ confidenceOnly,
17423
+ supersedes
17039
17424
  });
17040
17425
  const captureLabel = confidenceOnly ? `Updated confidence on **${result.adId}** to ${confidence} (body preserved)` : `${result.adAction === "created" ? "Created" : "Updated"} **${result.adId}**: ${text2.trim()}
17041
17426
  Confidence: ${confidence}`;
17427
+ const supersedeLine = result.supersededId ? `
17428
+
17429
+ **${result.supersededId}** marked superseded by ${result.adId} \u2014 retained as history, not overwritten.` : "";
17042
17430
  return textResponse(
17043
17431
  `**Decision Captured \u2014 Cycle ${result.cycleNumber}**
17044
17432
 
17045
- ${captureLabel}
17433
+ ${captureLabel}${supersedeLine}
17046
17434
 
17047
17435
  Decision event logged.`
17048
17436
  );
@@ -17153,8 +17541,8 @@ async function viewBoard(adapter2, phaseFilter, options) {
17153
17541
  const bi = PRIORITY_ORDER.indexOf(b2.priority);
17154
17542
  const priorityDiff = (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
17155
17543
  if (priorityDiff !== 0) return priorityDiff;
17156
- const aDate = a.createdAt ? String(a.createdAt) : "";
17157
- const bDate = b2.createdAt ? String(b2.createdAt) : "";
17544
+ const aDate = a.createdAt ?? "";
17545
+ const bDate = b2.createdAt ?? "";
17158
17546
  return bDate.localeCompare(aDate);
17159
17547
  });
17160
17548
  const total = filtered.length;
@@ -17508,6 +17896,10 @@ var boardEditTool = {
17508
17896
  actual_effort: {
17509
17897
  $ref: "#/$defs/effortSize",
17510
17898
  description: "task-2182: correct the actual effort on this task's LATEST build report (fixes a mis-recorded actual)."
17899
+ },
17900
+ project: {
17901
+ type: "string",
17902
+ description: "Project id (UUID) or slug whose board this task lives on, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. Use project_switch to change the session default. PASS THIS WHENEVER YOU KNOW WHICH REPO THE SESSION IS IN: on a multi-project account a connection with no project bound cannot be resolved, and PAPI will stop and ask rather than guess."
17511
17903
  }
17512
17904
  },
17513
17905
  required: ["task_id"]
@@ -17761,6 +18153,14 @@ async function handleBoardEdit(adapter2, args) {
17761
18153
  if (!taskId) {
17762
18154
  return errorResponse("task_id is required.");
17763
18155
  }
18156
+ let target = adapter2;
18157
+ let overrideNote = "";
18158
+ try {
18159
+ ({ adapter: target, overrideNote } = await resolvePerCallProjectAdapter(adapter2, args));
18160
+ } catch (err) {
18161
+ if (err instanceof ProjectResolutionError) return errorResponse(err.message);
18162
+ throw err;
18163
+ }
17764
18164
  const updates = {};
17765
18165
  const changes = [];
17766
18166
  for (const field of EDITABLE_FIELDS) {
@@ -17788,7 +18188,7 @@ async function handleBoardEdit(adapter2, args) {
17788
18188
  updates.cycle = null;
17789
18189
  changes.push("cycle");
17790
18190
  } else if (typeof rawCycle === "number" && Number.isInteger(rawCycle) && rawCycle > 0) {
17791
- const health = await adapter2.getCycleHealth().catch(() => null);
18191
+ const health = await target.getCycleHealth().catch(() => null);
17792
18192
  const activeCycle = health?.totalCycles ?? 0;
17793
18193
  if (rawCycle > activeCycle + 1) {
17794
18194
  return errorResponse(
@@ -17807,7 +18207,7 @@ async function handleBoardEdit(adapter2, args) {
17807
18207
  return errorResponse("No fields to update. Pass at least one field (title, priority, complexity, module, epic, phase, notes, status, maturity, cycle).");
17808
18208
  }
17809
18209
  try {
17810
- const task = await adapter2.getTask(taskId);
18210
+ const task = await target.getTask(taskId);
17811
18211
  if (!task) {
17812
18212
  return errorResponse(`Task ${taskId} not found.`);
17813
18213
  }
@@ -17826,7 +18226,7 @@ async function handleBoardEdit(adapter2, args) {
17826
18226
  const idx = changes.indexOf("notes");
17827
18227
  if (idx >= 0) changes.splice(idx, 1);
17828
18228
  } else {
17829
- const health = await adapter2.getCycleHealth().catch(() => null);
18229
+ const health = await target.getCycleHealth().catch(() => null);
17830
18230
  const activeCycle = health?.totalCycles ?? null;
17831
18231
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
17832
18232
  const stamp = activeCycle != null ? `[C${activeCycle} ${date}]` : `[${date}]`;
@@ -17851,7 +18251,7 @@ ${existing}` : entry;
17851
18251
  }
17852
18252
  let autoAssignedCycle = null;
17853
18253
  if (updates.status === "In Cycle") {
17854
- const health = await adapter2.getCycleHealth().catch(() => null);
18254
+ const health = await target.getCycleHealth().catch(() => null);
17855
18255
  const activeCycle = health?.totalCycles ?? null;
17856
18256
  if (activeCycle != null && activeCycle > 0) {
17857
18257
  updates.cycle = activeCycle;
@@ -17863,25 +18263,25 @@ ${existing}` : entry;
17863
18263
  updates.cancelledBy = "user";
17864
18264
  }
17865
18265
  if (effortCorrection.estimatedEffort || effortCorrection.actualEffort) {
17866
- if (!adapter2.correctLatestBuildReportEffort) {
18266
+ if (!target.correctLatestBuildReportEffort) {
17867
18267
  return errorResponse("Correcting build-report effort requires a database adapter (pg). The md adapter does not support it.");
17868
18268
  }
17869
- await adapter2.correctLatestBuildReportEffort(taskId, effortCorrection);
18269
+ await target.correctLatestBuildReportEffort(taskId, effortCorrection);
17870
18270
  }
17871
18271
  if (Object.keys(updates).length > 0) {
17872
- await adapter2.updateTask(taskId, updates);
18272
+ await target.updateTask(taskId, updates);
17873
18273
  }
17874
- if ((updates.status === "Done" || updates.status === "Cancelled") && adapter2.updateDogfoodEntryStatus) {
18274
+ if ((updates.status === "Done" || updates.status === "Cancelled") && target.updateDogfoodEntryStatus) {
17875
18275
  try {
17876
- const dogfoodLog = await adapter2.getDogfoodLog?.(50) ?? [];
18276
+ const dogfoodLog = await target.getDogfoodLog?.(50) ?? [];
17877
18277
  const linked = dogfoodLog.filter((e) => e.linkedTaskId === taskId || e.linkedTaskId === task.id);
17878
18278
  const newStatus = "resolved";
17879
- await Promise.all(linked.map((e) => adapter2.updateDogfoodEntryStatus(e.id, newStatus)));
18279
+ await Promise.all(linked.map((e) => target.updateDogfoodEntryStatus(e.id, newStatus)));
17880
18280
  } catch {
17881
18281
  }
17882
18282
  }
17883
18283
  const lines = [
17884
- `Updated **${taskId}** (${updates.title ?? task.title})`,
18284
+ `Updated **${taskId}**${overrideNote} (${updates.title ?? task.title})`,
17885
18285
  "",
17886
18286
  `**Changes:** ${changes.map((f) => `${f} \u2192 ${String(updates[f])}`).join(", ")}`
17887
18287
  ];
@@ -17896,15 +18296,15 @@ ${existing}` : entry;
17896
18296
 
17897
18297
  // src/services/setup.ts
17898
18298
  import { mkdir, writeFile as writeFile2, readFile as readFile4, readdir, access as access2, stat as stat2, chmod } from "fs/promises";
17899
- import { join as join9, basename, extname, dirname as dirname3 } from "path";
18299
+ import { join as join10, basename, extname, dirname as dirname3, relative } from "path";
17900
18300
  import { execFileSync as execFileSync3 } from "child_process";
17901
18301
 
17902
18302
  // src/lib/detect-codebase.ts
17903
18303
  import { existsSync as existsSync3 } from "fs";
17904
18304
  import { readdirSync as readdirSync2, statSync as statSync3 } from "fs";
17905
- import { join as join5 } from "path";
18305
+ import { join as join6 } from "path";
17906
18306
  function detectCodebaseType(projectRoot) {
17907
- if (existsSync3(join5(projectRoot, ".git"))) {
18307
+ if (existsSync3(join6(projectRoot, ".git"))) {
17908
18308
  return "existing_codebase";
17909
18309
  }
17910
18310
  const manifests = [
@@ -17918,7 +18318,7 @@ function detectCodebaseType(projectRoot) {
17918
18318
  "CMakeLists.txt"
17919
18319
  ];
17920
18320
  for (const manifest of manifests) {
17921
- if (existsSync3(join5(projectRoot, manifest))) {
18321
+ if (existsSync3(join6(projectRoot, manifest))) {
17922
18322
  return "existing_codebase";
17923
18323
  }
17924
18324
  }
@@ -17926,7 +18326,7 @@ function detectCodebaseType(projectRoot) {
17926
18326
  const entries = readdirSync2(projectRoot).filter((f) => !f.startsWith("."));
17927
18327
  const fileCount = entries.filter((f) => {
17928
18328
  try {
17929
- return statSync3(join5(projectRoot, f)).isFile();
18329
+ return statSync3(join6(projectRoot, f)).isFile();
17930
18330
  } catch {
17931
18331
  return false;
17932
18332
  }
@@ -17939,20 +18339,20 @@ function detectCodebaseType(projectRoot) {
17939
18339
 
17940
18340
  // src/lib/agents-bundle.ts
17941
18341
  import { readFileSync as readFileSync3, existsSync as existsSync4, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
17942
- import { dirname, join as join6, resolve } from "path";
18342
+ import { dirname, join as join7, resolve } from "path";
17943
18343
  import { fileURLToPath } from "url";
17944
- var PROJECT_BUNDLE_REL = join6(".agents", "skills", "papi-cycle");
18344
+ var PROJECT_BUNDLE_REL = join7(".agents", "skills", "papi-cycle");
17945
18345
  function bundleDestRel(rel) {
17946
- return rel === "AGENTS.md" ? "AGENTS.md" : join6(PROJECT_BUNDLE_REL, rel);
18346
+ return rel === "AGENTS.md" ? "AGENTS.md" : join7(PROJECT_BUNDLE_REL, rel);
17947
18347
  }
17948
18348
  function claudeSkillDestRel(rel) {
17949
- return rel === "AGENTS.md" ? void 0 : join6(".claude", "skills", rel);
18349
+ return rel === "AGENTS.md" ? void 0 : join7(".claude", "skills", rel);
17950
18350
  }
17951
18351
  function resolveBundleDir() {
17952
18352
  let dir = dirname(fileURLToPath(import.meta.url));
17953
18353
  for (let i = 0; i < 5; i++) {
17954
- const candidate = join6(dir, "skills", "papi-cycle");
17955
- if (existsSync4(join6(candidate, "AGENTS.md"))) return candidate;
18354
+ const candidate = join7(dir, "skills", "papi-cycle");
18355
+ if (existsSync4(join7(candidate, "AGENTS.md"))) return candidate;
17956
18356
  const parent = resolve(dir, "..");
17957
18357
  if (parent === dir) break;
17958
18358
  dir = parent;
@@ -17964,8 +18364,8 @@ function readBundleFiles(bundleDir = resolveBundleDir()) {
17964
18364
  const files = [];
17965
18365
  const walk = (abs, rel) => {
17966
18366
  for (const entry of readdirSync3(abs, { withFileTypes: true })) {
17967
- const childAbs = join6(abs, entry.name);
17968
- const childRel = rel ? join6(rel, entry.name) : entry.name;
18367
+ const childAbs = join7(abs, entry.name);
18368
+ const childRel = rel ? join7(rel, entry.name) : entry.name;
17969
18369
  if (entry.isDirectory()) walk(childAbs, childRel);
17970
18370
  else if (entry.isFile()) files.push({ rel: childRel, content: readFileSync3(childAbs, "utf8") });
17971
18371
  }
@@ -17974,13 +18374,14 @@ function readBundleFiles(bundleDir = resolveBundleDir()) {
17974
18374
  return files;
17975
18375
  }
17976
18376
  function planBundleInstall(projectRoot, projectName, opts = {}) {
18377
+ const canProbe = opts.skipExisting === true && projectRoot !== "";
17977
18378
  const out = {};
17978
18379
  for (const f of readBundleFiles()) {
17979
18380
  const content = f.rel === "AGENTS.md" ? f.content.replace(/\{\{project_name\}\}/g, projectName) : f.content;
17980
18381
  for (const rel of [bundleDestRel(f.rel), claudeSkillDestRel(f.rel)]) {
17981
18382
  if (!rel) continue;
17982
- const dest = join6(projectRoot, rel);
17983
- if (opts.skipExisting && existsSync4(dest) && statSync4(dest).isFile()) continue;
18383
+ const dest = join7(projectRoot, rel);
18384
+ if (canProbe && existsSync4(dest) && statSync4(dest).isFile()) continue;
17984
18385
  out[dest] = content;
17985
18386
  }
17986
18387
  }
@@ -17989,19 +18390,19 @@ function planBundleInstall(projectRoot, projectName, opts = {}) {
17989
18390
 
17990
18391
  // src/lib/design-bundle.ts
17991
18392
  import { readFileSync as readFileSync4, existsSync as existsSync5, statSync as statSync5 } from "fs";
17992
- import { dirname as dirname2, join as join7, resolve as resolve2 } from "path";
18393
+ import { dirname as dirname2, join as join8, resolve as resolve2 } from "path";
17993
18394
  import { fileURLToPath as fileURLToPath2 } from "url";
17994
18395
  var DESIGN_ASSETS = [
17995
- { srcRel: join7("agents", "frontend-design-engineer.md"), destRel: join7(".claude", "agents", "frontend-design-engineer.md"), executable: false },
17996
- { srcRel: join7("skills", "design-critique", "SKILL.md"), destRel: join7(".claude", "skills", "design-critique", "SKILL.md"), executable: false },
17997
- { srcRel: join7("hooks", "frontend-design-guard.sh"), destRel: join7(".claude", "hooks", "frontend-design-guard.sh"), executable: true }
18396
+ { srcRel: join8("agents", "frontend-design-engineer.md"), destRel: join8(".claude", "agents", "frontend-design-engineer.md"), executable: false },
18397
+ { srcRel: join8("skills", "design-critique", "SKILL.md"), destRel: join8(".claude", "skills", "design-critique", "SKILL.md"), executable: false },
18398
+ { srcRel: join8("hooks", "frontend-design-guard.sh"), destRel: join8(".claude", "hooks", "frontend-design-guard.sh"), executable: true }
17998
18399
  ];
17999
18400
  var DESIGN_HOOK_COMMAND = ".claude/hooks/frontend-design-guard.sh";
18000
18401
  function resolveDesignAssetsDir() {
18001
18402
  let dir = dirname2(fileURLToPath2(import.meta.url));
18002
18403
  for (let i = 0; i < 5; i++) {
18003
- const candidate = join7(dir, "design-assets");
18004
- if (existsSync5(join7(candidate, "agents", "frontend-design-engineer.md"))) return candidate;
18404
+ const candidate = join8(dir, "design-assets");
18405
+ if (existsSync5(join8(candidate, "agents", "frontend-design-engineer.md"))) return candidate;
18005
18406
  const parent = resolve2(dir, "..");
18006
18407
  if (parent === dir) break;
18007
18408
  dir = parent;
@@ -18013,9 +18414,9 @@ function planDesignInstall(projectRoot, opts = {}) {
18013
18414
  if (!assetsDir) return [];
18014
18415
  const out = [];
18015
18416
  for (const asset of DESIGN_ASSETS) {
18016
- const srcAbs = join7(assetsDir, asset.srcRel);
18417
+ const srcAbs = join8(assetsDir, asset.srcRel);
18017
18418
  if (!existsSync5(srcAbs)) continue;
18018
- const dest = projectRoot ? join7(projectRoot, asset.destRel) : asset.destRel;
18419
+ const dest = projectRoot ? join8(projectRoot, asset.destRel) : asset.destRel;
18019
18420
  if (opts.skipExisting && projectRoot && existsSync5(dest) && statSync5(dest).isFile()) continue;
18020
18421
  out.push({ dest, content: readFileSync4(srcAbs, "utf8"), executable: asset.executable });
18021
18422
  }
@@ -18024,9 +18425,9 @@ function planDesignInstall(projectRoot, opts = {}) {
18024
18425
 
18025
18426
  // src/lib/skill-detection.ts
18026
18427
  import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync5, statSync as statSync6 } from "fs";
18027
- import { join as join8 } from "path";
18428
+ import { join as join9 } from "path";
18028
18429
  function readPackageJson(projectRoot) {
18029
- const path7 = join8(projectRoot, "package.json");
18430
+ const path7 = join9(projectRoot, "package.json");
18030
18431
  if (!existsSync6(path7)) return null;
18031
18432
  try {
18032
18433
  const raw = readFileSync5(path7, "utf-8");
@@ -18050,7 +18451,7 @@ function detectsFrontendStack(projectRoot) {
18050
18451
  return hasDependencyMatching(allDeps(readPackageJson(projectRoot)), FRONTEND_DEP_PATTERN);
18051
18452
  }
18052
18453
  function hasGitHubWorkflows(projectRoot) {
18053
- const dir = join8(projectRoot, ".github", "workflows");
18454
+ const dir = join9(projectRoot, ".github", "workflows");
18054
18455
  if (!existsSync6(dir)) return false;
18055
18456
  try {
18056
18457
  const entries = readdirSync4(dir);
@@ -18060,7 +18461,7 @@ function hasGitHubWorkflows(projectRoot) {
18060
18461
  }
18061
18462
  }
18062
18463
  function envExampleMentionsStaging(projectRoot) {
18063
- const path7 = join8(projectRoot, ".env.example");
18464
+ const path7 = join9(projectRoot, ".env.example");
18064
18465
  if (!existsSync6(path7)) return false;
18065
18466
  try {
18066
18467
  const raw = readFileSync5(path7, "utf-8");
@@ -18070,8 +18471,8 @@ function envExampleMentionsStaging(projectRoot) {
18070
18471
  }
18071
18472
  }
18072
18473
  function hasVercelConfig(projectRoot) {
18073
- if (existsSync6(join8(projectRoot, "vercel.json"))) return true;
18074
- const vercelDir = join8(projectRoot, ".vercel");
18474
+ if (existsSync6(join9(projectRoot, "vercel.json"))) return true;
18475
+ const vercelDir = join9(projectRoot, ".vercel");
18075
18476
  if (!existsSync6(vercelDir)) return false;
18076
18477
  try {
18077
18478
  return statSync6(vercelDir).isDirectory();
@@ -18318,6 +18719,7 @@ Active Decisions (ADs) track architectural and product choices with confidence l
18318
18719
  - ADs are for product/architecture choices only, not process preferences.
18319
18720
  - When new evidence appears, update AD confidence via \`strategy_change\`.
18320
18721
  - Supersede rather than overwrite \u2014 old decisions stay as history.
18722
+ - **A contradiction is not a veto.** If an idea or task cuts against a live AD, that is a decision for the user \u2014 never your reason to drop it. Submit it with \`idea\` + \`conflicts_with_ad: "AD-N"\`: the task is created and parked behind a decision gate, surfaces in \`orient\` as waiting on the owner, and returns to the board on its own once the AD is superseded, modified, or reaffirmed. Shelving the work instead means the AD never gets tested by the evidence that would have challenged it.
18321
18723
  - New ADs should include a \`### Reversal Trigger\` section: specify the signal that would invalidate the stance, the action to take (modify/supersede/abandon), and why writing it now prevents sunk-cost drift later.
18322
18724
  `;
18323
18725
  var CLAUDE_MD_TIER_2 = `
@@ -18515,6 +18917,13 @@ function formatFilesToWriteSection(collector) {
18515
18917
  '- `mode: "create"` \u2014 create the file with `content`. If `skip_if_exists: true`, skip when the file already exists.',
18516
18918
  '- `mode: "overwrite"` \u2014 replace the file fully with `content`.',
18517
18919
  '- `mode: "append"` \u2014 append `content` to the existing file (create it first if missing).',
18920
+ '- `mode: "merge-json"` \u2014 the file is JSON. If it does not exist, create it with `content`.',
18921
+ " If it DOES exist, parse both, merge `content` INTO the existing object, and write the",
18922
+ " result back. Merge rules: objects merge key-by-key (recursing into nested objects);",
18923
+ " arrays UNION, keeping the existing order and appending only values not already present;",
18924
+ " scalars in `content` do not overwrite an existing value. NEVER remove, reorder or rewrite",
18925
+ " anything already in the file \u2014 this mode only ADDS. If the existing file is malformed",
18926
+ " JSON, leave it untouched and tell the user rather than replacing their file.",
18518
18927
  ""
18519
18928
  ].join("\n");
18520
18929
  }
@@ -18546,6 +18955,7 @@ function shouldWriteClaudeMd(clientName) {
18546
18955
  return !clientName || /claude/i.test(clientName);
18547
18956
  }
18548
18957
  async function scaffoldPapiDir(adapter2, config2, input, collector) {
18958
+ const preparedFiles = [];
18549
18959
  const isPg = config2.adapterType === "pg" || config2.adapterType === "proxy";
18550
18960
  const vars = {
18551
18961
  project_name: input.projectName,
@@ -18555,12 +18965,12 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18555
18965
  if (!isPg) {
18556
18966
  try {
18557
18967
  await access2(config2.papiDir);
18558
- return false;
18968
+ return { createdProject: false, preparedFiles };
18559
18969
  } catch {
18560
18970
  await mkdir(config2.papiDir, { recursive: true });
18561
18971
  for (const [filename, template] of Object.entries(FILE_TEMPLATES)) {
18562
18972
  const content = substitute(template, vars);
18563
- await writeFile2(join9(config2.papiDir, filename), content, "utf-8");
18973
+ await writeFile2(join10(config2.papiDir, filename), content, "utf-8");
18564
18974
  }
18565
18975
  }
18566
18976
  } else {
@@ -18570,7 +18980,7 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18570
18980
  const templateContent = substitute(PRODUCT_BRIEF_TEMPLATE, vars);
18571
18981
  await adapter2.updateProductBrief(templateContent);
18572
18982
  } else {
18573
- return false;
18983
+ return { createdProject: false, preparedFiles };
18574
18984
  }
18575
18985
  } catch {
18576
18986
  }
@@ -18578,13 +18988,13 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18578
18988
  const useCollector = config2.adapterType === "proxy";
18579
18989
  const docsRel = "docs";
18580
18990
  const commandsRel = ".claude/commands";
18581
- const commandsDir = useCollector ? commandsRel : join9(config2.projectRoot, ".claude", "commands");
18582
- const docsDir = useCollector ? docsRel : join9(config2.projectRoot, "docs");
18991
+ const commandsDir = useCollector ? commandsRel : join10(config2.projectRoot, ".claude", "commands");
18992
+ const docsDir = useCollector ? docsRel : join10(config2.projectRoot, "docs");
18583
18993
  if (!useCollector) {
18584
18994
  await mkdir(commandsDir, { recursive: true });
18585
18995
  await mkdir(docsDir, { recursive: true });
18586
18996
  }
18587
- const claudeMdPath = useCollector ? "CLAUDE.md" : join9(config2.projectRoot, "CLAUDE.md");
18997
+ const claudeMdPath = useCollector ? "CLAUDE.md" : join10(config2.projectRoot, "CLAUDE.md");
18588
18998
  let claudeMdExists = false;
18589
18999
  if (!useCollector) {
18590
19000
  try {
@@ -18593,7 +19003,7 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18593
19003
  } catch {
18594
19004
  }
18595
19005
  }
18596
- const docsIndexPath = useCollector ? `${docsRel}/INDEX.md` : join9(docsDir, "INDEX.md");
19006
+ const docsIndexPath = useCollector ? `${docsRel}/INDEX.md` : join10(docsDir, "INDEX.md");
18597
19007
  let docsIndexExists = false;
18598
19008
  if (!useCollector) {
18599
19009
  try {
@@ -18603,9 +19013,9 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18603
19013
  }
18604
19014
  }
18605
19015
  const scaffoldFiles = {
18606
- [useCollector ? `${commandsRel}/papi-audit.md` : join9(commandsDir, "papi-audit.md")]: PAPI_AUDIT_COMMAND_TEMPLATE,
18607
- [useCollector ? `${commandsRel}/test.md` : join9(commandsDir, "test.md")]: TEST_COMMAND_TEMPLATE,
18608
- [useCollector ? `${docsRel}/README.md` : join9(docsDir, "README.md")]: substitute(DOCS_README_TEMPLATE, vars)
19016
+ [useCollector ? `${commandsRel}/papi-audit.md` : join10(commandsDir, "papi-audit.md")]: PAPI_AUDIT_COMMAND_TEMPLATE,
19017
+ [useCollector ? `${commandsRel}/test.md` : join10(commandsDir, "test.md")]: TEST_COMMAND_TEMPLATE,
19018
+ [useCollector ? `${docsRel}/README.md` : join10(docsDir, "README.md")]: substitute(DOCS_README_TEMPLATE, vars)
18609
19019
  };
18610
19020
  if (!docsIndexExists) {
18611
19021
  scaffoldFiles[docsIndexPath] = substitute(DOCS_INDEX_TEMPLATE, vars);
@@ -18632,7 +19042,7 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18632
19042
  if (useCollector) {
18633
19043
  scaffoldFiles[".cursor/rules/papi.mdc"] = substitute(CURSOR_RULES_TEMPLATE, vars);
18634
19044
  } else {
18635
- const cursorDir = join9(config2.projectRoot, ".cursor");
19045
+ const cursorDir = join10(config2.projectRoot, ".cursor");
18636
19046
  let cursorDetected = false;
18637
19047
  try {
18638
19048
  await access2(cursorDir);
@@ -18640,8 +19050,8 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18640
19050
  } catch {
18641
19051
  }
18642
19052
  if (cursorDetected) {
18643
- const cursorRulesDir = join9(cursorDir, "rules");
18644
- const cursorRulesPath = join9(cursorRulesDir, "papi.mdc");
19053
+ const cursorRulesDir = join10(cursorDir, "rules");
19054
+ const cursorRulesPath = join10(cursorRulesDir, "papi.mdc");
18645
19055
  await mkdir(cursorRulesDir, { recursive: true });
18646
19056
  try {
18647
19057
  await access2(cursorRulesPath);
@@ -18659,6 +19069,9 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18659
19069
  await writeFile2(filepath, content, "utf-8");
18660
19070
  }
18661
19071
  }
19072
+ for (const p of Object.keys(scaffoldFiles)) {
19073
+ preparedFiles.push(useCollector ? p : relative(config2.projectRoot, p));
19074
+ }
18662
19075
  if (!useCollector && detectsFrontendStack(config2.projectRoot)) {
18663
19076
  for (const entry of planDesignInstall(config2.projectRoot, { skipExisting: true })) {
18664
19077
  await mkdir(dirname3(entry.dest), { recursive: true });
@@ -18683,20 +19096,38 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18683
19096
  }]);
18684
19097
  }
18685
19098
  if (useCollector) {
18686
- collector.add({
18687
- path: ".claude/settings.json",
18688
- content: JSON.stringify({ permissions: { allow: [PAPI_PERMISSION] } }, null, 2) + "\n",
18689
- mode: "create",
18690
- skip_if_exists: true
18691
- });
19099
+ collector.add(papiSettingsCollectorEntry());
18692
19100
  } else {
18693
19101
  await ensurePapiPermission(config2.projectRoot);
18694
19102
  }
18695
- return true;
19103
+ return { createdProject: true, preparedFiles };
19104
+ }
19105
+ var PAPI_PERMISSIONS = ["mcp__papi__*", "mcp__plugin_papi_papi__*"];
19106
+ function papiSettingsCollectorEntry() {
19107
+ return {
19108
+ path: ".claude/settings.json",
19109
+ content: JSON.stringify(mergePapiPermissions({}), null, 2) + "\n",
19110
+ mode: "merge-json"
19111
+ };
19112
+ }
19113
+ function mergePapiPermissions(settings) {
19114
+ if (!settings.permissions || typeof settings.permissions !== "object") {
19115
+ settings.permissions = {};
19116
+ }
19117
+ const perms = settings.permissions;
19118
+ if (!Array.isArray(perms.allow)) {
19119
+ perms.allow = [];
19120
+ }
19121
+ const allow = perms.allow;
19122
+ for (const permission of PAPI_PERMISSIONS) {
19123
+ if (!allow.includes(permission)) {
19124
+ allow.push(permission);
19125
+ }
19126
+ }
19127
+ return settings;
18696
19128
  }
18697
- var PAPI_PERMISSION = "mcp__papi__*";
18698
19129
  async function ensurePapiPermission(projectRoot) {
18699
- const settingsPath = join9(projectRoot, ".claude", "settings.json");
19130
+ const settingsPath = join10(projectRoot, ".claude", "settings.json");
18700
19131
  try {
18701
19132
  let settings = {};
18702
19133
  try {
@@ -18704,24 +19135,14 @@ async function ensurePapiPermission(projectRoot) {
18704
19135
  settings = JSON.parse(existing);
18705
19136
  } catch {
18706
19137
  }
18707
- if (!settings.permissions || typeof settings.permissions !== "object") {
18708
- settings.permissions = {};
18709
- }
18710
- const perms = settings.permissions;
18711
- if (!Array.isArray(perms.allow)) {
18712
- perms.allow = [];
18713
- }
18714
- const allow = perms.allow;
18715
- if (!allow.includes(PAPI_PERMISSION)) {
18716
- allow.push(PAPI_PERMISSION);
18717
- }
18718
- await mkdir(join9(projectRoot, ".claude"), { recursive: true });
19138
+ mergePapiPermissions(settings);
19139
+ await mkdir(join10(projectRoot, ".claude"), { recursive: true });
18719
19140
  await writeFile2(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
18720
19141
  } catch {
18721
19142
  }
18722
19143
  }
18723
19144
  async function ensureDesignHookRegistered(projectRoot) {
18724
- const settingsPath = join9(projectRoot, ".claude", "settings.json");
19145
+ const settingsPath = join10(projectRoot, ".claude", "settings.json");
18725
19146
  try {
18726
19147
  let settings = {};
18727
19148
  try {
@@ -18751,7 +19172,7 @@ async function ensureDesignHookRegistered(projectRoot) {
18751
19172
  chain.push({ type: "command", command: DESIGN_HOOK_COMMAND });
18752
19173
  }
18753
19174
  }
18754
- await mkdir(join9(projectRoot, ".claude"), { recursive: true });
19175
+ await mkdir(join10(projectRoot, ".claude"), { recursive: true });
18755
19176
  await writeFile2(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
18756
19177
  } catch {
18757
19178
  }
@@ -18857,7 +19278,7 @@ ${conventionsText.trim()}
18857
19278
  );
18858
19279
  } else {
18859
19280
  try {
18860
- const claudeMdPath = join9(config2.projectRoot, "CLAUDE.md");
19281
+ const claudeMdPath = join10(config2.projectRoot, "CLAUDE.md");
18861
19282
  const existing = await readFile4(claudeMdPath, "utf-8");
18862
19283
  if (existing.includes(CONVENTIONS_SENTINEL) || existing.includes(CONVENTIONS_HEADING)) {
18863
19284
  warnings.push(
@@ -18944,13 +19365,13 @@ async function scanCodebase(projectRoot) {
18944
19365
  }
18945
19366
  let packageJson;
18946
19367
  try {
18947
- const content = await readFile4(join9(projectRoot, "package.json"), "utf-8");
19368
+ const content = await readFile4(join10(projectRoot, "package.json"), "utf-8");
18948
19369
  packageJson = JSON.parse(content);
18949
19370
  } catch {
18950
19371
  }
18951
19372
  let readme;
18952
19373
  for (const name of ["README.md", "readme.md", "README.txt", "README"]) {
18953
- const content = await safeReadFile(join9(projectRoot, name), 5e3);
19374
+ const content = await safeReadFile(join10(projectRoot, name), 5e3);
18954
19375
  if (content) {
18955
19376
  readme = content;
18956
19377
  break;
@@ -18960,7 +19381,7 @@ async function scanCodebase(projectRoot) {
18960
19381
  let totalFiles = topLevelFiles.length;
18961
19382
  for (const dir of topLevelDirs) {
18962
19383
  try {
18963
- const entries = await readdir(join9(projectRoot, dir), { withFileTypes: true });
19384
+ const entries = await readdir(join10(projectRoot, dir), { withFileTypes: true });
18964
19385
  const files = entries.filter((e) => e.isFile());
18965
19386
  const extensions = [...new Set(files.map((f) => extname(f.name).toLowerCase()).filter(Boolean))];
18966
19387
  totalFiles += files.length;
@@ -19043,7 +19464,7 @@ function formatCodebaseSummary(scan, sourceContents) {
19043
19464
  }
19044
19465
  async function prepareSetup(adapter2, config2, input) {
19045
19466
  const prepareCollector = new FileWriteCollector();
19046
- const createdProject = await scaffoldPapiDir(adapter2, config2, input, prepareCollector);
19467
+ const { createdProject, preparedFiles } = await scaffoldPapiDir(adapter2, config2, input, prepareCollector);
19047
19468
  let existingBrief;
19048
19469
  try {
19049
19470
  existingBrief = await adapter2.readProductBrief();
@@ -19212,12 +19633,13 @@ async function prepareSetup(adapter2, config2, input) {
19212
19633
  preScanInstruction,
19213
19634
  newProjectInstruction,
19214
19635
  warnings: warnings.length > 0 ? warnings : void 0,
19215
- filesToWrite: prepareCollector.isEmpty() ? void 0 : prepareCollector
19636
+ filesToWrite: prepareCollector.isEmpty() ? void 0 : prepareCollector,
19637
+ preparedFiles
19216
19638
  };
19217
19639
  }
19218
19640
  async function applySetup(adapter2, config2, input, briefText, adSeedText, conventionsText, initialTasksText, northStarText) {
19219
19641
  const collector = new FileWriteCollector();
19220
- const createdProject = await scaffoldPapiDir(adapter2, config2, input, collector);
19642
+ const { createdProject, preparedFiles } = await scaffoldPapiDir(adapter2, config2, input, collector);
19221
19643
  let effectiveBriefText = briefText;
19222
19644
  let briefRegenerated = false;
19223
19645
  if (!effectiveBriefText.trim()) {
@@ -19346,7 +19768,7 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
19346
19768
  collector.add({ path: "CLAUDE.md", content: dogfoodSection, mode: "append" });
19347
19769
  } else {
19348
19770
  try {
19349
- const claudeMdPath = join9(config2.projectRoot, "CLAUDE.md");
19771
+ const claudeMdPath = join10(config2.projectRoot, "CLAUDE.md");
19350
19772
  const existing = await readFile4(claudeMdPath, "utf-8");
19351
19773
  if (!existing.includes("Dogfood Logging")) {
19352
19774
  await writeFile2(claudeMdPath, existing + dogfoodSection, "utf-8");
@@ -19391,7 +19813,7 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
19391
19813
  cursorScaffolded = true;
19392
19814
  } else {
19393
19815
  try {
19394
- await access2(join9(config2.projectRoot, ".cursor", "rules", "papi.mdc"));
19816
+ await access2(join10(config2.projectRoot, ".cursor", "rules", "papi.mdc"));
19395
19817
  cursorScaffolded = true;
19396
19818
  } catch {
19397
19819
  }
@@ -19407,16 +19829,17 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
19407
19829
  cursorScaffolded,
19408
19830
  gitignoreNote,
19409
19831
  warnings: warnings.length > 0 ? warnings : void 0,
19410
- filesToWrite: collector.isEmpty() ? void 0 : collector
19832
+ filesToWrite: collector.isEmpty() ? void 0 : collector,
19833
+ preparedFiles
19411
19834
  };
19412
19835
  }
19413
19836
  async function ensureMcpJsonGitignored(projectRoot) {
19414
19837
  try {
19415
- await access2(join9(projectRoot, ".git"));
19838
+ await access2(join10(projectRoot, ".git"));
19416
19839
  } catch {
19417
19840
  return void 0;
19418
19841
  }
19419
- const gitignorePath = join9(projectRoot, ".gitignore");
19842
+ const gitignorePath = join10(projectRoot, ".gitignore");
19420
19843
  let existing = "";
19421
19844
  try {
19422
19845
  existing = await readFile4(gitignorePath, "utf-8");
@@ -19565,8 +19988,14 @@ function extractInput(args) {
19565
19988
  codebaseScan: args.codebase_scan && typeof args.codebase_scan === "object" ? args.codebase_scan : void 0
19566
19989
  };
19567
19990
  }
19991
+ function harnessFilesLabel(prepared, writesClaudeMd) {
19992
+ const HARNESS_FILES = ["AGENTS.md", "CLAUDE.md"];
19993
+ const emitted = HARNESS_FILES.filter((f) => prepared?.includes(f));
19994
+ if (emitted.length > 0) return emitted.join(", ");
19995
+ return writesClaudeMd ? "AGENTS.md, CLAUDE.md" : "AGENTS.md";
19996
+ }
19568
19997
  function formatSuccessResponse(result, constraints, writesClaudeMd = true) {
19569
- const harnessFiles = writesClaudeMd ? "AGENTS.md, CLAUDE.md" : "AGENTS.md";
19998
+ const harnessFiles = harnessFilesLabel(result.preparedFiles, writesClaudeMd);
19570
19999
  const prefix = result.createdProject ? `PAPI project "${result.projectName}" initialised and ` : "";
19571
20000
  const briefRegenNote = result.briefRegenerated ? `
19572
20001
 
@@ -19982,8 +20411,8 @@ function buildPapiMetaFramingDirective(caps, inner) {
19982
20411
 
19983
20412
  // src/services/build.ts
19984
20413
  import { randomUUID as randomUUID11 } from "crypto";
19985
- import { readdirSync as readdirSync5, existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, mkdirSync as mkdirSync3 } from "fs";
19986
- import { join as join12 } from "path";
20414
+ import { readdirSync as readdirSync5, existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync5, unlinkSync as unlinkSync3, mkdirSync as mkdirSync3 } from "fs";
20415
+ import { join as join13 } from "path";
19987
20416
  import { splitFindings, findingKey } from "@papi-ai/shared";
19988
20417
 
19989
20418
  // src/lib/db-only-notices.ts
@@ -20150,7 +20579,7 @@ init_git();
20150
20579
  // src/services/release.ts
20151
20580
  init_telemetry();
20152
20581
  import { writeFile as writeFile3, readFile as readFile5 } from "fs/promises";
20153
- import { join as join10 } from "path";
20582
+ import { join as join11 } from "path";
20154
20583
  import { execFileSync as execFileSync4 } from "child_process";
20155
20584
  import { isSensitiveChangelogLine } from "@papi-ai/shared";
20156
20585
  init_git();
@@ -20624,7 +21053,7 @@ async function createRelease(config2, branch, version, adapter2, cycleNum, optio
20624
21053
  }
20625
21054
  }
20626
21055
  const latestTag = getLatestTag(config2.projectRoot);
20627
- const changelogPath = join10(config2.projectRoot, "CHANGELOG.md");
21056
+ const changelogPath = join11(config2.projectRoot, "CHANGELOG.md");
20628
21057
  if (!latestTag) {
20629
21058
  const initialContent = INITIAL_RELEASE_NOTES.replace("v0.1.0-alpha", version);
20630
21059
  if (config2.adapterType === "proxy") {
@@ -20780,33 +21209,12 @@ import { execFile } from "child_process";
20780
21209
  import { promisify } from "util";
20781
21210
  var execFileAsync = promisify(execFile);
20782
21211
  var REGISTRY_TIMEOUT_MS = 12e4;
20783
- async function updateOfficialMCPRegistry(version) {
20784
- const token = process.env["MCP_REGISTRY_GITHUB_TOKEN"]?.trim();
20785
- if (!token) {
20786
- return {
20787
- registry: "Official MCP Registry",
20788
- updated: false,
20789
- message: `Listing not updated \u2014 set MCP_REGISTRY_GITHUB_TOKEN to automate, or run manually: npx mcp-publisher publish`
20790
- };
20791
- }
20792
- try {
20793
- await execFileAsync("npx", ["mcp-publisher", "publish", "--yes"], {
20794
- env: { ...process.env, GITHUB_TOKEN: token },
20795
- timeout: REGISTRY_TIMEOUT_MS
20796
- });
20797
- return {
20798
- registry: "Official MCP Registry",
20799
- updated: true,
20800
- message: `Listing updated to ${version}.`
20801
- };
20802
- } catch (err) {
20803
- const detail = err instanceof Error ? err.message.slice(0, 300) : String(err).slice(0, 300);
20804
- return {
20805
- registry: "Official MCP Registry",
20806
- updated: false,
20807
- message: `Update failed \u2014 run manually: npx mcp-publisher publish. (${detail})`
20808
- };
20809
- }
21212
+ function officialMCPRegistryGuidance(version) {
21213
+ return {
21214
+ registry: "Official MCP Registry",
21215
+ updated: false,
21216
+ message: `Listing not updated (manual step). From a checkout of getpapi/papi: \`mcp-publisher login github\` (once), then \`mcp-publisher publish\` \u2014 bump server.json to ${version} first. Requires the Go CLI (brew install mcp-publisher), NOT the npm package of the same name.`
21217
+ };
20810
21218
  }
20811
21219
  async function updateSmithery(version) {
20812
21220
  const key = process.env["SMITHERY_API_KEY"]?.trim();
@@ -20837,11 +21245,8 @@ async function updateSmithery(version) {
20837
21245
  }
20838
21246
  }
20839
21247
  async function updateRegistryListings(version) {
20840
- const [official, smithery] = await Promise.all([
20841
- updateOfficialMCPRegistry(version),
20842
- updateSmithery(version)
20843
- ]);
20844
- return [official, smithery];
21248
+ const [smithery] = await Promise.all([updateSmithery(version)]);
21249
+ return [officialMCPRegistryGuidance(version), smithery];
20845
21250
  }
20846
21251
  async function updateCursorDirectory(version) {
20847
21252
  const token = process.env["CURSOR_DIRECTORY_GITHUB_TOKEN"]?.trim();
@@ -21459,21 +21864,21 @@ function detectWorktreeCollision(input) {
21459
21864
  init_git();
21460
21865
 
21461
21866
  // src/lib/build-checkpoint.ts
21462
- import { createHash as createHash4 } from "crypto";
21463
- import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync6, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
21464
- import { join as join11 } from "path";
21867
+ import { createHash as createHash5 } from "crypto";
21868
+ import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync6, unlinkSync as unlinkSync2, writeFileSync as writeFileSync4 } from "fs";
21869
+ import { join as join12 } from "path";
21465
21870
  var BUILD_CHECKPOINT_VERSION = 1;
21466
21871
  function cwdHash(cwd) {
21467
- return createHash4("sha256").update(realpathOrSelf(cwd)).digest("hex").slice(0, 12);
21872
+ return createHash5("sha256").update(realpathOrSelf(cwd)).digest("hex").slice(0, 12);
21468
21873
  }
21469
21874
  function safeTaskId(taskId) {
21470
21875
  return taskId.replace(/[^a-zA-Z0-9._-]/g, "_");
21471
21876
  }
21472
21877
  function checkpointDir(cwd) {
21473
- return join11(cwd, ".papi", "state");
21878
+ return join12(cwd, ".papi", "state");
21474
21879
  }
21475
21880
  function checkpointPath(cwd, taskId) {
21476
- return join11(checkpointDir(cwd), `build-${safeTaskId(taskId)}.${cwdHash(cwd)}.json`);
21881
+ return join12(checkpointDir(cwd), `build-${safeTaskId(taskId)}.${cwdHash(cwd)}.json`);
21477
21882
  }
21478
21883
  function writeBuildCheckpoint(input) {
21479
21884
  try {
@@ -21491,7 +21896,7 @@ function writeBuildCheckpoint(input) {
21491
21896
  cwd: realpathOrSelf(input.cwd),
21492
21897
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
21493
21898
  };
21494
- writeFileSync3(checkpointPath(input.cwd, input.taskId), JSON.stringify(checkpoint, null, 2) + "\n", "utf-8");
21899
+ writeFileSync4(checkpointPath(input.cwd, input.taskId), JSON.stringify(checkpoint, null, 2) + "\n", "utf-8");
21495
21900
  } catch {
21496
21901
  }
21497
21902
  }
@@ -21750,7 +22155,7 @@ function pathBasename(p) {
21750
22155
  const parts = p.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
21751
22156
  return parts[parts.length - 1] ?? p;
21752
22157
  }
21753
- function isPathInPredictedScope(changedPath, predicted) {
22158
+ function matchedPredictedEntry(changedPath, predicted) {
21754
22159
  const changed = changedPath.replace(/\\/g, "/");
21755
22160
  const changedLower = changed.toLowerCase();
21756
22161
  const changedBase = pathBasename(changedPath);
@@ -21758,17 +22163,20 @@ function isPathInPredictedScope(changedPath, predicted) {
21758
22163
  const entry = raw.replace(/\\/g, "/").replace(/\/+$/, "").trim();
21759
22164
  if (!entry) continue;
21760
22165
  if (entry.includes("*")) {
21761
- if (globToRegExp(entry).test(changed)) return true;
21762
- if (!entry.includes("/") && globToRegExp(entry).test(changedBase)) return true;
22166
+ if (globToRegExp(entry).test(changed)) return entry;
22167
+ if (!entry.includes("/") && globToRegExp(entry).test(changedBase)) return entry;
21763
22168
  continue;
21764
22169
  }
21765
- if (pathBasename(entry) === changedBase) return true;
22170
+ if (pathBasename(entry) === changedBase) return entry;
21766
22171
  const entryLower = entry.toLowerCase();
21767
22172
  if (changedLower === entryLower || changedLower.startsWith(`${entryLower}/`)) {
21768
- return true;
22173
+ return entry;
21769
22174
  }
21770
22175
  }
21771
- return false;
22176
+ return null;
22177
+ }
22178
+ function isPathInPredictedScope(changedPath, predicted) {
22179
+ return matchedPredictedEntry(changedPath, predicted) !== null;
21772
22180
  }
21773
22181
  function autoCommit(config2, taskId, taskTitle, predictedFiles) {
21774
22182
  const cwd = config2.projectRoot;
@@ -21794,6 +22202,17 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
21794
22202
  if (staged.length > 0) {
21795
22203
  return safeRun(() => commitStagedOnly(cwd, message)) + ` (selective staging respected: ${staged.length} file(s)).`;
21796
22204
  }
22205
+ const checkpoint = readBuildCheckpointIfLocal({ cwd, taskId });
22206
+ const headSha = getHeadCommitSha(cwd);
22207
+ if (checkpoint?.lastCommitSha && headSha && checkpoint.lastCommitSha !== headSha) {
22208
+ const leftover = getModifiedFiles(cwd);
22209
+ if (leftover.length === 0) {
22210
+ return "Auto-commit: skipped (builder already committed; working tree clean).";
22211
+ }
22212
+ const sample = leftover.slice(0, 10).join(", ");
22213
+ const more = leftover.length > 10 ? ` (+${leftover.length - 10} more)` : "";
22214
+ return `Auto-commit: skipped \u2014 you already committed during this build, and ${leftover.length} file(s) are still modified. They were NOT committed, because at this point PAPI cannot tell your deliberately-excluded work from a concurrent session's files (task-3054). Left uncommitted: ${sample}${more}. If any belong to ${taskId}, \`git add\` them and re-run build_execute complete.`;
22215
+ }
21797
22216
  const modified = getModifiedFiles(cwd);
21798
22217
  if (modified.length === 0) {
21799
22218
  return "Auto-commit: skipped (no working-tree changes).";
@@ -21807,7 +22226,9 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
21807
22226
  const more = outOfScope.length > 10 ? ` (+${outOfScope.length - 10} more)` : "";
21808
22227
  return `${commitResult} (staged all ${modified.length} changed file(s)). \u2139\uFE0F Scope drift: ${outOfScope.length} committed file(s) were outside the handoff's FILES LIKELY TOUCHED \u2014 handoff under-predicted: ${sample}${more}.`;
21809
22228
  }
21810
- return `${commitResult} (staged all ${modified.length} changed file(s), all within FILES LIKELY TOUCHED).`;
22229
+ const matches = modified.slice(0, 5).map((p) => `${p} \u2190 ${matchedPredictedEntry(p, cleanedPredicted) ?? "?"}`).join(", ");
22230
+ const extra = modified.length > 5 ? ` (+${modified.length - 5} more)` : "";
22231
+ return `${commitResult} (staged all ${modified.length} changed file(s), each matched to FILES LIKELY TOUCHED: ${matches}${extra}).`;
21811
22232
  }
21812
22233
  return `${commitResult} (staged all ${modified.length} changed file(s)).`;
21813
22234
  }
@@ -22369,15 +22790,15 @@ function writeActiveTaskScope(projectRoot, taskId, filesLikelyTouched, adapterTy
22369
22790
  collector.add({ path: ".papi/active-task-scope.txt", content, mode: "overwrite" });
22370
22791
  return;
22371
22792
  }
22372
- const papiDir = join12(projectRoot, ".papi");
22793
+ const papiDir = join13(projectRoot, ".papi");
22373
22794
  if (!existsSync8(papiDir)) {
22374
22795
  mkdirSync3(papiDir, { recursive: true });
22375
22796
  }
22376
- const scopePath = join12(papiDir, "active-task-scope.txt");
22377
- writeFileSync4(scopePath, content, "utf-8");
22797
+ const scopePath = join13(papiDir, "active-task-scope.txt");
22798
+ writeFileSync5(scopePath, content, "utf-8");
22378
22799
  }
22379
22800
  function clearActiveTaskScope(projectRoot) {
22380
- const scopePath = join12(projectRoot, ".papi", "active-task-scope.txt");
22801
+ const scopePath = join13(projectRoot, ".papi", "active-task-scope.txt");
22381
22802
  if (existsSync8(scopePath)) {
22382
22803
  unlinkSync3(scopePath);
22383
22804
  }
@@ -22837,29 +23258,39 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22837
23258
  let docWarning;
22838
23259
  try {
22839
23260
  if (adapter2.searchDocs && hasLocalWorkspace() && await ownsLocalWorkspace(adapter2, config2.projectRoot)) {
22840
- const docsDir = join12(config2.projectRoot, "docs");
23261
+ const docsDir = join13(config2.projectRoot, "docs");
22841
23262
  if (existsSync8(docsDir)) {
22842
23263
  const scanDir = (dir, depth = 0) => {
22843
23264
  if (depth > 8) return [];
22844
23265
  const entries = readdirSync5(dir, { withFileTypes: true });
22845
23266
  const files = [];
22846
23267
  for (const e of entries) {
22847
- const full = join12(dir, e.name);
23268
+ const full = join13(dir, e.name);
22848
23269
  if (e.isDirectory() && !e.isSymbolicLink()) files.push(...scanDir(full, depth + 1));
22849
23270
  else if (e.name.endsWith(".md")) files.push(full.replace(config2.projectRoot + "/", ""));
22850
23271
  }
22851
23272
  return files;
22852
23273
  };
22853
23274
  const mdFiles = scanDir(docsDir);
23275
+ let branchDocs;
23276
+ try {
23277
+ branchDocs = new Set(
23278
+ getFilesChangedFromBase(config2.projectRoot, "origin/main").filter((f) => f.startsWith("docs/") && f.endsWith(".md"))
23279
+ );
23280
+ } catch {
23281
+ branchDocs = /* @__PURE__ */ new Set();
23282
+ }
22854
23283
  const registered = await adapter2.searchDocs({ status: "all", limit: 500 });
22855
23284
  const registeredPaths = new Set(registered.map((d) => d.path));
22856
- const unregistered = mdFiles.filter((f) => !registeredPaths.has(f));
23285
+ const unregistered = mdFiles.filter(
23286
+ (f) => !registeredPaths.has(f) && branchDocs.has(f)
23287
+ );
22857
23288
  if (unregistered.length > 0 && adapter2.registerDoc) {
22858
23289
  const autoRegistered = [];
22859
23290
  const failed = [];
22860
23291
  for (const docPath of unregistered) {
22861
23292
  try {
22862
- const meta = extractDocMeta(join12(config2.projectRoot, docPath), docPath, cycleNumber);
23293
+ const meta = extractDocMeta(join13(config2.projectRoot, docPath), docPath, cycleNumber);
22863
23294
  await adapter2.registerDoc({
22864
23295
  title: meta.title,
22865
23296
  type: meta.type,
@@ -23029,15 +23460,15 @@ ${instructions}`;
23029
23460
  }
23030
23461
 
23031
23462
  // src/tools/doc-registry.ts
23032
- import { readdirSync as readdirSync6, existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync5, mkdirSync as mkdirSync4 } from "fs";
23033
- import { join as join13, relative, isAbsolute as isAbsolute2, dirname as dirname4, resolve as resolve3, sep } from "path";
23463
+ import { readdirSync as readdirSync6, existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync6, mkdirSync as mkdirSync4 } from "fs";
23464
+ import { join as join14, relative as relative2, isAbsolute as isAbsolute2, dirname as dirname4, resolve as resolve3, sep } from "path";
23034
23465
  import { homedir as homedir3 } from "os";
23035
23466
  import { randomUUID as randomUUID12 } from "crypto";
23036
23467
  import { docDeletionBlockMessage } from "@papi-ai/shared";
23037
23468
  init_git();
23038
23469
 
23039
23470
  // src/services/entitlements.ts
23040
- import { evaluateContributorGate } from "@papi-ai/shared";
23471
+ import { evaluateContributorGate, isSelfHostedDeployment } from "@papi-ai/shared";
23041
23472
  var FREE_PROJECT_CAP = 3;
23042
23473
  var DOC_STORAGE_CEILING_BY_TIER = {
23043
23474
  free: { bytes: 25 * 1024 * 1024, docs: 200 },
@@ -23060,6 +23491,7 @@ function isPaidTier(tier) {
23060
23491
  return tier !== null && PAID_TIERS.has(tier);
23061
23492
  }
23062
23493
  async function enforceProjectCap(adapter2, target) {
23494
+ if (isSelfHostedDeployment(process.env.PAPI_SELF_HOST)) return null;
23063
23495
  const tier = await resolveTier(adapter2);
23064
23496
  if (tier === null || isPaidTier(tier)) return null;
23065
23497
  if (typeof adapter2.listUserProjects !== "function") return null;
@@ -23170,7 +23602,11 @@ var docRegisterTool = {
23170
23602
  },
23171
23603
  description: "Actionable findings from the document."
23172
23604
  },
23173
- superseded_by_path: { type: "string", description: "Path of the doc that supersedes this one (sets status to superseded)." }
23605
+ superseded_by_path: { type: "string", description: "Path of the doc that supersedes this one (sets status to superseded)." },
23606
+ project: {
23607
+ type: "string",
23608
+ description: "Project id (UUID) or slug to register this doc under, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. Use project_switch to change the session default. PASS THIS WHENEVER YOU KNOW WHICH REPO THE SESSION IS IN: on a multi-project account a connection with no project bound cannot be resolved, and PAPI will stop and ask rather than guess."
23609
+ }
23174
23610
  },
23175
23611
  required: ["path", "title", "type", "summary", "cycle"]
23176
23612
  }
@@ -23265,7 +23701,7 @@ function ensureDocDurable(path7, projectRoot) {
23265
23701
  \u26A0\uFE0F **Registered, but NOT durable.** ${reason}
23266
23702
  The registry stores metadata and a summary \u2014 not the body. This doc has one copy, on disk, and a branch switch or stash can take it.
23267
23703
  **Fix:** ${fix}`;
23268
- if (!existsSync9(join13(projectRoot, path7))) {
23704
+ if (!existsSync9(join14(projectRoot, path7))) {
23269
23705
  return warn(
23270
23706
  `No file exists at \`${path7}\`.`,
23271
23707
  `write the doc body to that path, then re-run doc_register.`
@@ -23327,6 +23763,22 @@ async function handleDocRegister(adapter2, args, config2) {
23327
23763
  continueHint
23328
23764
  );
23329
23765
  }
23766
+ let target = adapter2;
23767
+ let overrideNote = "";
23768
+ try {
23769
+ ({ adapter: target, overrideNote } = await resolvePerCallProjectAdapter(adapter2, args));
23770
+ } catch (err) {
23771
+ if (err instanceof ProjectResolutionError) return errorResponse(err.message);
23772
+ throw err;
23773
+ }
23774
+ if (!target.registerDoc) {
23775
+ return docRegisterSoftFail(
23776
+ adapterType,
23777
+ "adapter-capability-check",
23778
+ "Doc registry not available on the resolved project adapter \u2014 requires the pg/proxy adapter.",
23779
+ "Continue without it \u2014 nothing is blocked."
23780
+ );
23781
+ }
23330
23782
  if (!path7.toLowerCase().endsWith(".md")) {
23331
23783
  return docRegisterSoftFail(
23332
23784
  adapterType,
@@ -23338,13 +23790,13 @@ async function handleDocRegister(adapter2, args, config2) {
23338
23790
  try {
23339
23791
  let supersededBy;
23340
23792
  if (supersededByPath) {
23341
- const existing = await adapter2.getDoc?.(supersededByPath);
23793
+ const existing = await target.getDoc?.(supersededByPath);
23342
23794
  if (existing) {
23343
23795
  supersededBy = existing.id;
23344
- await adapter2.updateDocStatus?.(existing.id, "superseded", void 0);
23796
+ await target.updateDocStatus?.(existing.id, "superseded", void 0);
23345
23797
  }
23346
23798
  }
23347
- const entry = await adapter2.registerDoc({
23799
+ const entry = await target.registerDoc({
23348
23800
  title,
23349
23801
  type,
23350
23802
  path: path7,
@@ -23363,23 +23815,23 @@ async function handleDocRegister(adapter2, args, config2) {
23363
23815
  let body = supplied;
23364
23816
  if (body === void 0 && hasLocalWorkspace()) {
23365
23817
  try {
23366
- const abs = isAbsolute2(entry.path) ? entry.path : join13(config2?.projectRoot ?? process.cwd(), entry.path);
23818
+ const abs = isAbsolute2(entry.path) ? entry.path : join14(config2?.projectRoot ?? process.cwd(), entry.path);
23367
23819
  if (existsSync9(abs)) body = readFileSync8(abs, "utf8");
23368
23820
  } catch {
23369
23821
  }
23370
23822
  }
23371
23823
  if (body === void 0) {
23372
23824
  bodyNote = hasLocalWorkspace() ? "\n\n_Body not stored \u2014 the file could not be read from disk. Pass `body` to store it._" : "\n\n_Body not stored \u2014 no local workspace on this session. Pass `body` to store it._";
23373
- } else if (typeof adapter2.storeDocBody !== "function") {
23825
+ } else if (typeof target.storeDocBody !== "function") {
23374
23826
  bodyNote = "\n\n_Body not stored \u2014 this adapter does not support body storage._";
23375
23827
  } else {
23376
- const decision = await checkDocStorageCap(adapter2, Buffer.byteLength(body, "utf8"));
23828
+ const decision = await checkDocStorageCap(target, Buffer.byteLength(body, "utf8"));
23377
23829
  if (!decision.storeBody) {
23378
23830
  bodyNote = `
23379
23831
 
23380
23832
  ${decision.message}`;
23381
23833
  } else {
23382
- const result = await adapter2.storeDocBody({
23834
+ const result = await target.storeDocBody({
23383
23835
  docId: entry.id,
23384
23836
  body,
23385
23837
  // Resolved by resolveDocVisibility above — the SAME resolution the
@@ -23403,7 +23855,7 @@ ${decision.message}`;
23403
23855
  durability = "";
23404
23856
  }
23405
23857
  return textResponse(
23406
- `**Registered:** ${entry.title}
23858
+ `**Registered:** ${entry.title}${overrideNote}
23407
23859
  - **Path:** ${entry.path}
23408
23860
  - **Type:** ${entry.type} | **Status:** ${entry.status}
23409
23861
  - **Visibility:** ${visibilityLabel}
@@ -23437,7 +23889,7 @@ async function handleDocSearch(adapter2, args, config2) {
23437
23889
  const lines = docs.map((d) => {
23438
23890
  const actionCount = d.actions?.filter((a) => a.status === "pending").length ?? 0;
23439
23891
  const actionNote = actionCount > 0 ? ` | ${actionCount} pending action(s)` : "";
23440
- const missingNote = root && d.path && !existsSync9(join13(root, d.path)) ? `
23892
+ const missingNote = root && d.path && !existsSync9(join14(root, d.path)) ? `
23441
23893
  > \u26A0\uFE0F **File missing on disk** \u2014 the registry points at \`${d.path}\` but nothing is there. Recover it with \`doc_read\` (\`id_or_path: "${d.path}", write_to_disk: true\`) if its body was stored, or check \`git stash list\` for a papi-autostash entry.` : "";
23442
23894
  return `### ${d.title}
23443
23895
  **Type:** ${d.type} | **Status:** ${d.status} | **Cycle:** ${d.cycleCreated}${d.cycleUpdated ? `\u2192${d.cycleUpdated}` : ""}${actionNote}
@@ -23531,7 +23983,7 @@ function restoreBodyToDisk(projectRoot, docPath, body) {
23531
23983
  `;
23532
23984
  }
23533
23985
  mkdirSync4(dirname4(target.abs), { recursive: true });
23534
- writeFileSync5(target.abs, body, "utf8");
23986
+ writeFileSync6(target.abs, body, "utf8");
23535
23987
  return `- **Restored:** wrote ${Buffer.byteLength(body, "utf8").toLocaleString()} bytes to \`${docPath}\`
23536
23988
  `;
23537
23989
  } catch (err) {
@@ -23546,11 +23998,11 @@ function scanMdFiles(dir, rootDir) {
23546
23998
  try {
23547
23999
  const entries = readdirSync6(dir, { withFileTypes: true });
23548
24000
  for (const entry of entries) {
23549
- const full = join13(dir, entry.name);
24001
+ const full = join14(dir, entry.name);
23550
24002
  if (entry.isDirectory()) {
23551
24003
  files.push(...scanMdFiles(full, rootDir));
23552
24004
  } else if (entry.name.endsWith(".md")) {
23553
- files.push(relative(rootDir, full).replace(/\\/g, "/"));
24005
+ files.push(relative2(rootDir, full).replace(/\\/g, "/"));
23554
24006
  }
23555
24007
  }
23556
24008
  } catch {
@@ -23571,7 +24023,7 @@ function extractTitle(filePath) {
23571
24023
  async function detectUnregisteredDocsNote(adapter2, config2) {
23572
24024
  try {
23573
24025
  if (!adapter2.searchDocs || !hasLocalWorkspace()) return "";
23574
- const docsDir = join13(config2.projectRoot, "docs");
24026
+ const docsDir = join14(config2.projectRoot, "docs");
23575
24027
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
23576
24028
  if (docsFiles.length === 0) return "";
23577
24029
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
@@ -23597,17 +24049,17 @@ async function handleDocScan(adapter2, config2, args) {
23597
24049
  const includePlans = args.include_plans ?? false;
23598
24050
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
23599
24051
  const registeredPaths = new Set(registered.map((d) => d.path));
23600
- const docsDir = join13(config2.projectRoot, "docs");
24052
+ const docsDir = join14(config2.projectRoot, "docs");
23601
24053
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
23602
24054
  const unregisteredDocs = docsFiles.filter((f) => !registeredPaths.has(f));
23603
24055
  let unregisteredPlans = [];
23604
24056
  if (includePlans) {
23605
- const plansDir = join13(homedir3(), ".claude", "plans");
24057
+ const plansDir = join14(homedir3(), ".claude", "plans");
23606
24058
  if (existsSync9(plansDir)) {
23607
24059
  const planFiles = scanMdFiles(plansDir, plansDir);
23608
24060
  unregisteredPlans = planFiles.map((f) => `plans/${f}`).filter((f) => !registeredPaths.has(f)).map((f) => ({
23609
24061
  path: f,
23610
- title: extractTitle(join13(plansDir, f.replace("plans/", "")))
24062
+ title: extractTitle(join14(plansDir, f.replace("plans/", "")))
23611
24063
  }));
23612
24064
  }
23613
24065
  }
@@ -23618,7 +24070,7 @@ async function handleDocScan(adapter2, config2, args) {
23618
24070
  if (unregisteredDocs.length > 0) {
23619
24071
  lines.push(`## Unregistered Docs (${unregisteredDocs.length})`);
23620
24072
  for (const f of unregisteredDocs) {
23621
- const title = extractTitle(join13(config2.projectRoot, f));
24073
+ const title = extractTitle(join14(config2.projectRoot, f));
23622
24074
  lines.push(`- \`${f}\`${title ? ` \u2014 ${title}` : ""}`);
23623
24075
  }
23624
24076
  }
@@ -23967,7 +24419,7 @@ var buildExecuteTool = {
23967
24419
  },
23968
24420
  fixed_issues: {
23969
24421
  type: "array",
23970
- description: `cycle_learnings UUIDs of discovered issues this build FIXED. Stamps resolved_at (via the existing discovered_issue_resolve path) so the hub's "What PAPI caught" surface counts them as fixed on the caught\u2192fixed ledger \u2014 triage-and-fix at build time, no separate tool call. Distinct from resolves_learnings, which only LINKS a learning to this task without closing it. Best-effort and idempotent.`,
24422
+ description: `cycle_learnings UUIDs of discovered issues this build FIXED. SEND THIS whenever your work closed an issue listed under "OPEN DISCOVERED ISSUES" in the BUILD HANDOFF \u2014 that block prints the exact UUIDs to copy. Nothing else stamps a fix, so an unreported one is indistinguishable from an unfixed issue: the hub's "What PAPI caught" caught\u2192fixed ledger reads zero until this is passed. Stamps resolved_at via the existing discovered_issue_resolve path \u2014 triage-and-fix at build time, no separate tool call. Distinct from resolves_learnings, which only LINKS a learning to this task without closing it. Do not pass UUIDs for issues you did not actually fix. Best-effort and idempotent.`,
23971
24423
  items: { type: "string" }
23972
24424
  },
23973
24425
  production_verification: {
@@ -24325,7 +24777,7 @@ ${entries}`;
24325
24777
 
24326
24778
  **OPEN DISCOVERED ISSUES** (${top.length} shown):
24327
24779
  ${rows}
24328
- 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.`;
24780
+ CONTRACT \u2014 on complete, pass \`fixed_issues\` with the UUID of any issue above that this build closed, and say so even if the answer is none. Closing an issue without stamping it leaves the caught\u2192fixed ledger reading zero, which is what it reads today. Do NOT fix out-of-scope issues just to clear the list \u2014 the ask is to REPORT what you closed, not to close more.`;
24329
24781
  }
24330
24782
  }
24331
24783
  } catch {
@@ -24572,8 +25024,25 @@ Your report was NOT discarded and the task is NOT yet Done \u2014 re-send with \
24572
25024
  fixedNote = `
24573
25025
 
24574
25026
  \u2705 Marked ${fixedResolvedCount} discovered issue(s) FIXED \u2014 resolved_at stamped, now counted as fixed on the hub's caught\u2192fixed ledger.`;
24575
- } else if (discoveredIssues && discoveredIssues.trim() !== "" && !/^none\b/i.test(discoveredIssues.trim())) {
24576
- 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.";
25027
+ } else {
25028
+ let candidates = [];
25029
+ try {
25030
+ if (adapter2.getCycleLearnings) {
25031
+ const open = (await adapter2.getCycleLearnings({ category: "issue", limit: 20 })).filter((l) => !l.resolvedAt && l.id);
25032
+ const moduleTag = result.task?.module?.trim().toLowerCase();
25033
+ candidates = open.filter((l) => !moduleTag || l.tags.some((t) => t.toLowerCase() === moduleTag)).slice(0, 5).map((l) => ` - \`${l.id}\` \xB7 ${l.severity ?? "P3"} \xB7 ${l.summary.slice(0, 120)}`);
25034
+ }
25035
+ } catch {
25036
+ }
25037
+ if (candidates.length > 0) {
25038
+ fixedNote = `
25039
+
25040
+ \u2139\uFE0F No \`fixed_issues\` passed. Open issues in this module that this build could have closed:
25041
+ ${candidates.join("\n")}
25042
+ If any are now fixed, re-run complete with their UUIDs in \`fixed_issues\` \u2014 nothing else stamps them, so an unreported fix is indistinguishable from an unfixed issue on the hub ledger.`;
25043
+ } else if (discoveredIssues && discoveredIssues.trim() !== "" && !/^none\b/i.test(discoveredIssues.trim())) {
25044
+ 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.";
25045
+ }
24577
25046
  }
24578
25047
  return textResponse(formatCompleteResult(result) + fixedNote + docsNote + batchRollupNote);
24579
25048
  } catch (err) {
@@ -24787,6 +25256,10 @@ var ideaTool = {
24787
25256
  type: "string",
24788
25257
  description: "What user problem does this solve? Auto-fill from problem context in notes when submitting ideas that describe a user pain point. The planner uses this to cluster backlog tasks by opportunity."
24789
25258
  },
25259
+ conflicts_with_ad: {
25260
+ type: "string",
25261
+ description: 'The Active Decision this idea cuts against, e.g. "AD-12". USE THIS INSTEAD OF DROPPING THE IDEA. An AD is *active* \u2014 it can be superseded \u2014 so a contradiction is a decision for the user, never a reason for you to shelve work during research or scoping. The task is created and parked Blocked behind a decision-gate on that AD: it shows in `orient` as a decision waiting on the owner, and returns to the board automatically once the AD is superseded, modified, or reaffirmed.'
25262
+ },
24790
25263
  project: {
24791
25264
  type: "string",
24792
25265
  description: "Project id (UUID) or slug to write this idea to, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. Use project_switch to change the session default. PASS THIS WHENEVER YOU KNOW WHICH REPO THE SESSION IS IN: on a multi-project account a connection with no project bound cannot be resolved, and PAPI will stop and ask rather than guess."
@@ -24819,7 +25292,8 @@ async function handleIdea(adapter2, config2, args) {
24819
25292
  force: args.force === true,
24820
25293
  docRef: args.doc_ref?.trim(),
24821
25294
  type: args.type,
24822
- opportunity: args.opportunity?.trim()
25295
+ opportunity: args.opportunity?.trim(),
25296
+ conflictsWithAd: args.conflicts_with_ad?.trim()
24823
25297
  };
24824
25298
  let target = adapter2;
24825
25299
  let overrideNote = "";
@@ -24864,7 +25338,12 @@ Re-submit with \`notes: "... Reference: <path>"\` to link one, or ignore if none
24864
25338
  } catch {
24865
25339
  }
24866
25340
  }
24867
- return textResponse(`${result.message}${overrideNote}${branchNote}${truncateWarning}${refNudge}`);
25341
+ const conflictNote = result.adConflicts ? buildAdConflictNote(
25342
+ result.adConflicts.explicit ? result.adConflicts.adIds[0] : void 0,
25343
+ result.adConflicts.explicit ? [] : result.adConflicts.adIds,
25344
+ result.adConflicts.gated
25345
+ ) : "";
25346
+ return textResponse(`${result.message}${overrideNote}${branchNote}${truncateWarning}${refNudge}${conflictNote}`);
24868
25347
  }
24869
25348
  return textResponse(`${result.message}${overrideNote}`);
24870
25349
  }
@@ -25603,6 +26082,13 @@ init_git();
25603
26082
 
25604
26083
  // src/services/ad-hoc.ts
25605
26084
  import { randomUUID as randomUUID15 } from "crypto";
26085
+ function resolveAdHocBranch(input) {
26086
+ if (input.held) return `feat/${input.taskId}`;
26087
+ const current = input.currentBranch?.trim();
26088
+ if (!current) return void 0;
26089
+ if (input.baseBranch && current === input.baseBranch.trim()) return void 0;
26090
+ return current;
26091
+ }
25606
26092
  function resolveAdHocCycle(cycle, latest, latestComplete) {
25607
26093
  if (cycle === void 0) return null;
25608
26094
  if (typeof cycle === "number") return cycle;
@@ -25657,8 +26143,32 @@ async function recordAdHoc(adapter2, input) {
25657
26143
  ...targetCycle !== null ? { cycle: targetCycle } : {},
25658
26144
  notes: input.notes ? `[ad-hoc] ${input.notes}` : "[ad-hoc]",
25659
26145
  taskType: input.taskType || "task",
25660
- source: "ad_hoc"
26146
+ source: "ad_hoc",
26147
+ // task-2597: record the branch at creation for unheld work — the branch is
26148
+ // already known. Held work needs the allocated display id first (below).
26149
+ ...held ? {} : (() => {
26150
+ const branch = resolveAdHocBranch({
26151
+ held: false,
26152
+ taskId: "",
26153
+ currentBranch: input.currentBranch,
26154
+ baseBranch: input.baseBranch
26155
+ });
26156
+ return branch ? { branchName: branch } : {};
26157
+ })()
25661
26158
  });
26159
+ if (held) {
26160
+ const branch = resolveAdHocBranch({ held: true, taskId: task.id });
26161
+ if (branch) {
26162
+ try {
26163
+ await adapter2.updateTask(task.id, { branchName: branch });
26164
+ task = { ...task, branchName: branch };
26165
+ } catch (err) {
26166
+ console.error(
26167
+ `[ad-hoc] branch_name persist skipped for ${task.id} (non-fatal): ` + (err instanceof Error ? err.message : String(err))
26168
+ );
26169
+ }
26170
+ }
26171
+ }
25662
26172
  }
25663
26173
  const report = {
25664
26174
  uuid: randomUUID15(),
@@ -25748,6 +26258,10 @@ var adHocTool = {
25748
26258
  hold: {
25749
26259
  type: "boolean",
25750
26260
  description: "task-2477: held-adhoc. When true, do NOT force-complete or commit to main \u2014 record the task In Review pinned to the NEXT cycle (current + 1) so the planner won't re-plan it, and return a branch/PR directive (commit on feat/<task-id>, never main, leave unmerged) so it rides the next cycle's review \u2192 release bundled with planned work. One-call replacement for the two-call ad_hoc + board_edit stopgap. Takes precedence over `cycle`/`stage`."
26261
+ },
26262
+ project: {
26263
+ type: "string",
26264
+ description: "Project id (UUID) or slug to record this work under, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. Use project_switch to change the session default. PASS THIS WHENEVER YOU KNOW WHICH REPO THE SESSION IS IN: on a multi-project account a connection with no project bound cannot be resolved, and PAPI will stop and ask rather than guess."
25751
26265
  }
25752
26266
  },
25753
26267
  required: []
@@ -25784,7 +26298,18 @@ async function handleAdHoc(adapter2, config2, args) {
25784
26298
  else if (rawCycle === "current" || rawCycle === "next-if-plan-not-run") cycleArg = rawCycle;
25785
26299
  const stageArg = args.stage === "release" ? "release" : void 0;
25786
26300
  const holdArg = args.hold === true;
25787
- const result = await recordAdHoc(adapter2, {
26301
+ let target = adapter2;
26302
+ let overrideNote = "";
26303
+ try {
26304
+ ({ adapter: target, overrideNote } = await resolvePerCallProjectAdapter(adapter2, args));
26305
+ } catch (err) {
26306
+ if (err instanceof ProjectResolutionError) return errorResponse(err.message);
26307
+ throw err;
26308
+ }
26309
+ const gitUsable = !overrideNote && isGitAvailable() && isGitRepo(config2.projectRoot);
26310
+ const currentBranch = gitUsable ? getCurrentBranch(config2.projectRoot) : null;
26311
+ const baseBranch = gitUsable ? resolveBaseBranch(config2.projectRoot, config2.baseBranch) : null;
26312
+ const result = await recordAdHoc(target, {
25788
26313
  title: title || "",
25789
26314
  taskId,
25790
26315
  notes: rawNotes,
@@ -25797,9 +26322,11 @@ async function handleAdHoc(adapter2, config2, args) {
25797
26322
  owner: config2.projectOwner,
25798
26323
  cycle: cycleArg,
25799
26324
  stage: stageArg,
25800
- hold: holdArg
26325
+ hold: holdArg,
26326
+ currentBranch,
26327
+ baseBranch
25801
26328
  });
25802
- if (!holdArg && isGitAvailable() && isGitRepo(config2.projectRoot)) {
26329
+ if (!holdArg && gitUsable) {
25803
26330
  try {
25804
26331
  stageDirAndCommit(
25805
26332
  config2.projectRoot,
@@ -25817,7 +26344,7 @@ async function handleAdHoc(adapter2, config2, args) {
25817
26344
  const branch = `feat/${result.task.id}`;
25818
26345
  let collisionBlock = "";
25819
26346
  try {
25820
- const board = await adapter2.queryBoard({ status: ["In Progress"] });
26347
+ const board = await target.queryBoard({ status: ["In Progress"] });
25821
26348
  const otherInProgress = board.filter((t) => t.id !== result.task.id && t.displayId !== result.task.id).map((t) => ({ taskId: t.displayId || t.id, branch: (t.branchName ?? "").trim() })).filter((t) => t.branch.length > 0);
25822
26349
  const collision = detectWorktreeCollision({
25823
26350
  taskId: result.task.id,
@@ -25841,7 +26368,7 @@ async function handleAdHoc(adapter2, config2, args) {
25841
26368
  } catch {
25842
26369
  }
25843
26370
  return textResponse(
25844
- `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning}${promoNote} Build report attached.
26371
+ `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
25845
26372
 
25846
26373
  ## Held for the next cycle \u2014 branch + commit, do NOT merge
25847
26374
  The task is recorded **In Review** and pinned to **Cycle ${result.task.cycle}**, so the planner won't re-plan it and it rides that cycle's review \u2192 release bundled with planned work.
@@ -25859,7 +26386,7 @@ _To correct: board_edit ${result.task.id} with updated fields._`
25859
26386
  );
25860
26387
  }
25861
26388
  return textResponse(
25862
- `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning}${promoNote} Build report attached.
26389
+ `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
25863
26390
  _To correct: board_edit ${result.task.id} with updated fields._`
25864
26391
  );
25865
26392
  }
@@ -25869,11 +26396,11 @@ init_git();
25869
26396
 
25870
26397
  // src/services/reconcile.ts
25871
26398
  import { readFileSync as readFileSync9 } from "fs";
25872
- import { join as join14 } from "path";
26399
+ import { join as join15 } from "path";
25873
26400
  function loadDocsIndex(projectRoot) {
25874
26401
  if (!hasLocalWorkspace()) return "";
25875
26402
  try {
25876
- const indexPath = join14(projectRoot, "docs", "INDEX.md");
26403
+ const indexPath = join15(projectRoot, "docs", "INDEX.md");
25877
26404
  const raw = readFileSync9(indexPath, "utf8");
25878
26405
  const rows = raw.split("\n").filter((l) => l.startsWith("| ["));
25879
26406
  if (rows.length === 0) return "";
@@ -26450,7 +26977,7 @@ Produce your analysis and structured output above. Present Part 1 to the user an
26450
26977
 
26451
26978
  // src/tools/review.ts
26452
26979
  import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
26453
- import { join as join15 } from "path";
26980
+ import { join as join16 } from "path";
26454
26981
  init_git();
26455
26982
 
26456
26983
  // src/services/review.ts
@@ -26648,8 +27175,6 @@ Re-run build_execute complete with a production_verification field, then re-subm
26648
27175
  }
26649
27176
 
26650
27177
  // src/tools/review.ts
26651
- var REVIEW_DISPATCH_THRESHOLD = 50 * 1024;
26652
- var REVIEW_DISPATCH_CEILING = 40 * 1024;
26653
27178
  var REVIEW_ECHO_CAP = 4e3;
26654
27179
  function trimForEcho(text) {
26655
27180
  if (text.length <= REVIEW_ECHO_CAP) return text;
@@ -26679,7 +27204,7 @@ ${task.buildReport}` : "### Build Report\n(none recorded)";
26679
27204
  ${diff}
26680
27205
  \`\`\`` : "### Branch diff vs base\n(no diff resolved \u2014 not a git repo, no base ref, or no committed changes)";
26681
27206
  let projectContext = "";
26682
- const ctxPath = join15(config2.projectRoot, ".agents", "papi-context.md");
27207
+ const ctxPath = join16(config2.projectRoot, ".agents", "papi-context.md");
26683
27208
  if (existsSync10(ctxPath)) {
26684
27209
  try {
26685
27210
  projectContext = `### Project context (.agents/papi-context.md)
@@ -26858,7 +27383,7 @@ function mergeAfterAccept(config2, taskId) {
26858
27383
  };
26859
27384
  }
26860
27385
  const details = [];
26861
- const papiDir = join15(config2.projectRoot, ".papi");
27386
+ const papiDir = join16(config2.projectRoot, ".papi");
26862
27387
  if (existsSync10(papiDir)) {
26863
27388
  try {
26864
27389
  const commitResult = stageDirAndCommit(
@@ -26997,7 +27522,7 @@ async function handleReviewSubmit(adapter2, config2, args) {
26997
27522
  if (typeof rawPreset === "string" && rawPreset.length > 0 && !reviewPreset) {
26998
27523
  return errorResponse(`review_preset "${rawPreset}" is not valid. Use "gate", "full", or "security-focused".`);
26999
27524
  }
27000
- const autoDispatchOptIn = args.dispatch !== "inline" && process.env.PAPI_AUTO_DISPATCH !== "false" && isCapabilityEnabled(caps, "prReviewer");
27525
+ const autoDispatchOptIn = args.dispatch !== "inline" && isCapabilityEnabled(caps, "prReviewer");
27001
27526
  const autoDispatchEligible = !verdict && autoDispatchOptIn;
27002
27527
  const capabilityAutoReviewEligible = verdict === "accept" && !autoReview && autoDispatchOptIn;
27003
27528
  let capabilityReviewSkippedNote = "";
@@ -27010,16 +27535,27 @@ async function handleReviewSubmit(adapter2, config2, args) {
27010
27535
  );
27011
27536
  if (!dispatch.ok) {
27012
27537
  if (explicitDispatch) return errorResponse(dispatch.error);
27013
- } else if (explicitDispatch || autoDispatchEligible && dispatch.contextBytes > REVIEW_DISPATCH_THRESHOLD) {
27538
+ } else if (explicitDispatch || autoDispatchEligible && shouldDispatch(dispatch.contextBytes)) {
27014
27539
  return textResponse(dispatch.prompt);
27015
27540
  } else if (capabilityAutoReviewEligible) {
27016
- if (dispatch.contextBytes <= REVIEW_DISPATCH_CEILING) {
27017
- return textResponse(dispatch.prompt);
27541
+ const delivery = planDelivery({
27542
+ payload: dispatch.prompt,
27543
+ kind: "review-context",
27544
+ hasPendingWrite: true,
27545
+ adapterType: config2.adapterType,
27546
+ projectId: config2.projectId,
27547
+ callerKey: taskId
27548
+ });
27549
+ if (delivery.mode === "inline") {
27550
+ return textResponse(delivery.payload);
27018
27551
  }
27019
27552
  const kb = (dispatch.contextBytes / 1024).toFixed(0);
27020
- capabilityReviewSkippedNote = `
27553
+ capabilityReviewSkippedNote = delivery.mode === "spilled" ? `
27554
+
27555
+ > \u{1F4C4} pr-reviewer auto-review was too large to return inline (~${kb} KB), so it was written to a file instead of being dropped (task-3030). Verdict recorded. To run the review, read:
27556
+ > \`${delivery.path}\`` : `
27021
27557
 
27022
- > \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"\`.`;
27558
+ > \u26A0\uFE0F pr-reviewer auto-review could not be delivered \u2014 the diff/build-report (~${kb} KB) exceeds the inline ceiling and this connection has no shared filesystem to spill to. Verdict recorded. To review the diff explicitly, run \`review_submit ${taskId} build-acceptance accept dispatch:"subagent"\`.`;
27023
27559
  }
27024
27560
  }
27025
27561
  if (explicitDispatch && stage !== "build-acceptance") {
@@ -28491,7 +29027,7 @@ function formatDeferredGateSection(sweep) {
28491
29027
 
28492
29028
  // src/tools/agent-list.ts
28493
29029
  import { readdir as readdir2, readFile as readFile7 } from "fs/promises";
28494
- import { join as join16 } from "path";
29030
+ import { join as join17 } from "path";
28495
29031
  var NO_AGENTS_HINT = "No project sub-agents found in `.claude/agents/`. Add a `*.md` file with `name` + `description` frontmatter \u2014 see the 1926-Census marketing sub-agent for a reference implementation.";
28496
29032
  function parseAgentFrontmatter(content) {
28497
29033
  const match = content.match(/^---\n([\s\S]*?)\n---/);
@@ -28503,7 +29039,7 @@ function parseAgentFrontmatter(content) {
28503
29039
  return { name: nameMatch?.[1].trim(), description };
28504
29040
  }
28505
29041
  async function listAgents(projectRoot) {
28506
- const agentsDir = join16(projectRoot, ".claude", "agents");
29042
+ const agentsDir = join17(projectRoot, ".claude", "agents");
28507
29043
  let files;
28508
29044
  try {
28509
29045
  files = await readdir2(agentsDir);
@@ -28514,7 +29050,7 @@ async function listAgents(projectRoot) {
28514
29050
  for (const file of files.filter((f) => f.endsWith(".md"))) {
28515
29051
  let content;
28516
29052
  try {
28517
- content = await readFile7(join16(agentsDir, file), "utf-8");
29053
+ content = await readFile7(join17(agentsDir, file), "utf-8");
28518
29054
  } catch {
28519
29055
  continue;
28520
29056
  }
@@ -28522,7 +29058,7 @@ async function listAgents(projectRoot) {
28522
29058
  agents.push({
28523
29059
  name: meta?.name ?? file.replace(/\.md$/, ""),
28524
29060
  description: meta?.description ?? "",
28525
- path: join16(".claude", "agents", file)
29061
+ path: join17(".claude", "agents", file)
28526
29062
  });
28527
29063
  }
28528
29064
  agents.sort((a, b2) => a.name.localeCompare(b2.name));
@@ -28663,8 +29199,8 @@ async function verifyProject(adapter2) {
28663
29199
  // src/tools/orient.ts
28664
29200
  import { execFile as execFile2 } from "child_process";
28665
29201
  import { promisify as promisify2 } from "util";
28666
- import { readFileSync as readFileSync11, writeFileSync as writeFileSync6, existsSync as existsSync11 } from "fs";
28667
- import { join as join17 } from "path";
29202
+ import { readFileSync as readFileSync11, writeFileSync as writeFileSync7, existsSync as existsSync11 } from "fs";
29203
+ import { join as join18 } from "path";
28668
29204
 
28669
29205
  // src/lib/exit-criteria-evaluators.ts
28670
29206
  var TEST_ACCOUNT_PATTERN = "ftue-%@test.papi.dev";
@@ -28720,6 +29256,7 @@ var UNEVALUATABLE = {
28720
29256
  "c596a082-9ad3-4089-b497-46cd189e892b": "threshold is provisional pending an owner ruling \u2014 no settled bar to evaluate against"
28721
29257
  };
28722
29258
  async function evaluateExitCriteria(adapter2, projectId, criteria) {
29259
+ const hasProject = typeof projectId === "string" && projectId.trim() !== "";
28723
29260
  return Promise.all(criteria.map(async (c) => {
28724
29261
  const evaluator = EVALUATORS[c.id];
28725
29262
  if (!evaluator) {
@@ -28727,6 +29264,14 @@ async function evaluateExitCriteria(adapter2, projectId, criteria) {
28727
29264
  if (!reason) return { ...c, met: c.met, autoEvaluated: false };
28728
29265
  return c.met ? { ...c, met: true, autoEvaluated: false } : { ...c, met: null, unevaluatedReason: reason, autoEvaluated: false };
28729
29266
  }
29267
+ if (!hasProject) {
29268
+ return {
29269
+ ...c,
29270
+ met: c.met ? true : null,
29271
+ unevaluatedReason: "project id unavailable \u2014 this criterion is measured against a specific project and orient could not resolve one",
29272
+ autoEvaluated: false
29273
+ };
29274
+ }
28730
29275
  try {
28731
29276
  const result = await evaluator(adapter2, projectId);
28732
29277
  return {
@@ -28737,10 +29282,11 @@ async function evaluateExitCriteria(adapter2, projectId, criteria) {
28737
29282
  autoEvaluated: result.met !== null
28738
29283
  };
28739
29284
  } catch (err) {
29285
+ console.error(`[exit-criteria] evaluator for criterion ${c.id} threw:`, err);
28740
29286
  return {
28741
29287
  ...c,
28742
29288
  met: c.met ? true : null,
28743
- unevaluatedReason: `evaluator failed: ${err instanceof Error ? err.message : String(err)}`,
29289
+ unevaluatedReason: "evaluator failed \u2014 see server logs. The stored value is shown and is unverified.",
28744
29290
  autoEvaluated: false
28745
29291
  };
28746
29292
  }
@@ -29119,9 +29665,13 @@ async function getHierarchyPosition(adapter2, projectId) {
29119
29665
  // task-3008: EVALUATE rather than read the hand-ticked boolean. Best-effort —
29120
29666
  // a failing evaluator degrades that criterion to unevaluated and leaves the
29121
29667
  // rest intact, because orient is the first call of every session.
29668
+ // task-3226: pass the id THROUGH, undefined and all. This used to coerce to
29669
+ // `''`, which is not a uuid and blew up every SQL-backed evaluator — the
29670
+ // coercion looked like a safe default and was the bug. evaluateExitCriteria
29671
+ // now reports "project id unavailable" for that case, which is the truth.
29122
29672
  stageExitCriteria: await evaluateExitCriteria(
29123
29673
  adapter2,
29124
- projectId ?? "",
29674
+ projectId,
29125
29675
  activeStage.exitCriteria ?? []
29126
29676
  ).catch(() => (activeStage.exitCriteria ?? []).map((c) => ({ ...c, autoEvaluated: false })))
29127
29677
  };
@@ -29129,21 +29679,10 @@ async function getHierarchyPosition(adapter2, projectId) {
29129
29679
  return void 0;
29130
29680
  }
29131
29681
  }
29132
- async function getLatestGitTag(projectRoot) {
29133
- try {
29134
- const { stdout } = await execFileAsync2("git", ["describe", "--tags", "--abbrev=0"], {
29135
- encoding: "utf-8",
29136
- cwd: projectRoot,
29137
- timeout: 2e3
29138
- });
29139
- return stdout.trim() || null;
29140
- } catch {
29141
- return null;
29142
- }
29143
- }
29682
+ var GIT_TAG_TIMEOUT_MS = 2e3;
29144
29683
  async function checkNpmVersionDrift() {
29145
29684
  try {
29146
- const pkgPath = join17(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
29685
+ const pkgPath = join18(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
29147
29686
  const pkg = JSON.parse(readFileSync11(pkgPath, "utf-8"));
29148
29687
  const localVersion = pkg.version;
29149
29688
  const packageName = pkg.name;
@@ -29486,7 +30025,7 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
29486
30025
  // Latest git tag + npm version drift (both exec calls with own timeouts).
29487
30026
  // task-2172: git-tag stays (the latest tag is part of the core summary);
29488
30027
  // version-drift is enrichment, gated behind `full`/deep_housekeeping.
29489
- tracked("git-tag", () => getLatestGitTag(config2.projectRoot)),
30028
+ tracked("git-tag", async () => getLatestTag(config2.projectRoot, GIT_TAG_TIMEOUT_MS)),
29490
30029
  tracked("npm-version-drift", async () => fullEnrichment ? checkNpmVersionDrift() : void 0),
29491
30030
  // Research Signals — research docs with pending actions since last strategy review.
29492
30031
  // task-2172: heavy (doc search + AD cross-reference) and rarely actioned
@@ -29844,7 +30383,7 @@ function enrichClaudeMd(projectRoot, cycleNumber, adapterType, collector, client
29844
30383
 
29845
30384
  \u{1F4DD} **CLAUDE.md enriched** \u2014 added ${tierNames2.join(" + ")} guidance for cycle ${cycleNumber}+ projects.`;
29846
30385
  }
29847
- const claudeMdPath = join17(projectRoot, "CLAUDE.md");
30386
+ const claudeMdPath = join18(projectRoot, "CLAUDE.md");
29848
30387
  if (!existsSync11(claudeMdPath)) return "";
29849
30388
  const content = readFileSync11(claudeMdPath, "utf-8");
29850
30389
  const additions = [];
@@ -29855,7 +30394,7 @@ function enrichClaudeMd(projectRoot, cycleNumber, adapterType, collector, client
29855
30394
  additions.push(dedupeEnrichmentBlob(content, CLAUDE_MD_TIER_2));
29856
30395
  }
29857
30396
  if (additions.length === 0) return "";
29858
- writeFileSync6(claudeMdPath, content + additions.join(""), "utf-8");
30397
+ writeFileSync7(claudeMdPath, content + additions.join(""), "utf-8");
29859
30398
  const tierNames = [];
29860
30399
  if (additions.some((a) => a.includes(CLAUDE_MD_ENRICHMENT_SENTINEL_T1))) tierNames.push("Established (batch building, strategy reviews, AD lifecycle)");
29861
30400
  if (additions.some((a) => a.includes(CLAUDE_MD_ENRICHMENT_SENTINEL_T2))) tierNames.push("Mature (idea pipeline, doc registry, advanced patterns)");
@@ -30377,6 +30916,10 @@ var zoomOutTool = {
30377
30916
  enum: ["prepare", "apply"],
30378
30917
  description: '"prepare" returns the retrospective prompt. "apply" accepts your output. Defaults to "prepare" when omitted.'
30379
30918
  },
30919
+ llm_response_file: {
30920
+ type: "string",
30921
+ description: 'Absolute path to a file containing the zoom-out output (mode "apply" only). LOCAL stdio servers only \u2014 on the hosted connection the server cannot read files on your machine. Use when the response is too large to pass inline (some hosts cap tool inputs around 50KB). Mutually exclusive with llm_response.'
30922
+ },
30380
30923
  llm_response: {
30381
30924
  type: "string",
30382
30925
  description: 'Your raw output from executing the retrospective prompt (mode "apply" only).'
@@ -30398,10 +30941,12 @@ async function handleZoomOut(adapter2, config2, args) {
30398
30941
  const toolMode = args.mode;
30399
30942
  try {
30400
30943
  if (toolMode === "apply") {
30401
- const llmResponse = args.llm_response;
30402
- if (!llmResponse || !llmResponse.trim()) {
30403
- return errorResponse('llm_response is required for mode "apply".');
30404
- }
30944
+ const resolved = await resolveLlmResponse(
30945
+ args.llm_response,
30946
+ args.llm_response_file
30947
+ );
30948
+ if (!resolved.ok) return errorResponse(resolved.error);
30949
+ const llmResponse = resolved.llmResponse;
30405
30950
  const cycleNumber = typeof args.cycle_number === "number" ? args.cycle_number : 0;
30406
30951
  const result = await applyZoomOut(adapter2, llmResponse, cycleNumber);
30407
30952
  return textResponse(
@@ -30416,16 +30961,8 @@ Retrospective saved. Use insights to inform your next \`strategy_review\` or \`p
30416
30961
  }
30417
30962
  {
30418
30963
  const result = await prepareZoomOut(adapter2, config2.projectRoot);
30419
- const autoDispatchEnabled = process.env.PAPI_AUTO_DISPATCH !== "false";
30420
- const autoDispatchThreshold = 50 * 1024;
30421
- let dispatch;
30422
- if (args.dispatch === "inline" || args.dispatch === "subagent") {
30423
- dispatch = args.dispatch;
30424
- } else if (autoDispatchEnabled && result.contextBytes > autoDispatchThreshold) {
30425
- dispatch = "subagent";
30426
- } else {
30427
- dispatch = "inline";
30428
- }
30964
+ const explicit = args.dispatch === "inline" ? false : args.dispatch === "subagent" ? true : void 0;
30965
+ const dispatch = shouldDispatch(result.contextBytes, explicit) ? "subagent" : "inline";
30429
30966
  if (dispatch === "subagent") {
30430
30967
  const dispatchPrompt = buildSubagentDispatchPrompt({
30431
30968
  tool: "zoom_out",
@@ -30670,8 +31207,8 @@ ${result.userMessage}
30670
31207
  import { readFileSync as readFileSync12, statSync as statSync7 } from "fs";
30671
31208
 
30672
31209
  // src/services/scope-brief.ts
30673
- import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync5 } from "fs";
30674
- import { join as join18, dirname as dirname5 } from "path";
31210
+ import { writeFileSync as writeFileSync8, mkdirSync as mkdirSync5 } from "fs";
31211
+ import { join as join19, dirname as dirname5 } from "path";
30675
31212
  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.
30676
31213
 
30677
31214
  A scope document must:
@@ -30728,14 +31265,14 @@ async function applyScopeBrief(adapter2, input) {
30728
31265
  }
30729
31266
  const slug = input.taskId.replace(/[^a-z0-9-]/g, "-").toLowerCase();
30730
31267
  const relPath = `docs/scopes/${slug}.md`;
30731
- const absPath = join18(input.projectRoot, relPath);
31268
+ const absPath = join19(input.projectRoot, relPath);
30732
31269
  const docBody = addFrontmatter(docContent, task, input.cycleNumber);
30733
31270
  const collector = new FileWriteCollector();
30734
31271
  if (input.adapterType === "proxy") {
30735
31272
  collector.add({ path: relPath, content: docBody, mode: "overwrite" });
30736
31273
  } else {
30737
31274
  mkdirSync5(dirname5(absPath), { recursive: true });
30738
- writeFileSync7(absPath, docBody, "utf-8");
31275
+ writeFileSync8(absPath, docBody, "utf-8");
30739
31276
  }
30740
31277
  const taskCount = countSubTasks(docContent);
30741
31278
  const summary = buildSummary(task, taskCount);
@@ -31022,7 +31559,7 @@ ${formatted}`, meta));
31022
31559
 
31023
31560
  // src/lib/dist-staleness.ts
31024
31561
  import { readFileSync as readFileSync13, statSync as statSync8 } from "fs";
31025
- import { createHash as createHash5 } from "crypto";
31562
+ import { createHash as createHash6 } from "crypto";
31026
31563
  import { fileURLToPath as fileURLToPath3 } from "url";
31027
31564
  var BOOT_MS = Date.now();
31028
31565
  var DEFAULT_SKEW_MS = 2e3;
@@ -31036,7 +31573,7 @@ var SELF_PATH = (() => {
31036
31573
  })();
31037
31574
  function hashFile(path7) {
31038
31575
  try {
31039
- return createHash5("sha256").update(readFileSync13(path7)).digest("hex");
31576
+ return createHash6("sha256").update(readFileSync13(path7)).digest("hex");
31040
31577
  } catch {
31041
31578
  return null;
31042
31579
  }
@@ -31686,19 +32223,19 @@ Its build reports, comments, and history moved with it; the cycle assignment was
31686
32223
 
31687
32224
  // src/services/harness-inventory.ts
31688
32225
  import { readdir as readdir3, readFile as readFile8, stat as stat3 } from "fs/promises";
31689
- import { join as join19 } from "path";
31690
- import { createHash as createHash6 } from "crypto";
32226
+ import { join as join20 } from "path";
32227
+ import { createHash as createHash7 } from "crypto";
31691
32228
  var RECOMMENDED_HOOKS = ["stop-release-check.sh", "claude-md-size-guard.sh"];
31692
32229
  async function computeFingerprint(root) {
31693
32230
  const parts = [];
31694
32231
  for (const sub of [".claude/skills", ".claude/agents", ".claude/hooks"]) {
31695
- const dir = join19(root, sub);
32232
+ const dir = join20(root, sub);
31696
32233
  try {
31697
32234
  const names = (await readdir3(dir)).sort((a, b2) => a.localeCompare(b2));
31698
32235
  for (const name of names) {
31699
32236
  let mtime = "";
31700
32237
  try {
31701
- mtime = String(Math.floor((await stat3(join19(dir, name))).mtimeMs));
32238
+ mtime = String(Math.floor((await stat3(join20(dir, name))).mtimeMs));
31702
32239
  } catch {
31703
32240
  }
31704
32241
  parts.push(`${sub}/${name}:${mtime}`);
@@ -31713,11 +32250,11 @@ async function computeFingerprint(root) {
31713
32250
  } catch {
31714
32251
  parts.push("manifest:none");
31715
32252
  }
31716
- return createHash6("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
32253
+ return createHash7("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
31717
32254
  }
31718
32255
  async function readSkillDescription(skillDir) {
31719
32256
  try {
31720
- const content = await readFile8(join19(skillDir, "SKILL.md"), "utf-8");
32257
+ const content = await readFile8(join20(skillDir, "SKILL.md"), "utf-8");
31721
32258
  const fm = content.match(/^---\n([\s\S]*?)\n---/);
31722
32259
  if (!fm) return void 0;
31723
32260
  const desc = fm[1].match(/^description:\s*[>|]?\s*\n?([\s\S]*?)(?=\n\w+:|\n---|$)/m);
@@ -31737,17 +32274,17 @@ async function scanInventory(root, toolDefs) {
31737
32274
  version = loadManifest().packageVersion;
31738
32275
  } catch {
31739
32276
  }
31740
- const skillsDir = join19(root, ".claude", "skills");
32277
+ const skillsDir = join20(root, ".claude", "skills");
31741
32278
  try {
31742
32279
  const dirents = await readdir3(skillsDir, { withFileTypes: true });
31743
32280
  for (const d of dirents.filter((e) => e.isDirectory())) {
31744
32281
  entries.push({
31745
32282
  kind: "skill",
31746
32283
  name: d.name,
31747
- description: await readSkillDescription(join19(skillsDir, d.name)),
32284
+ description: await readSkillDescription(join20(skillsDir, d.name)),
31748
32285
  version,
31749
32286
  status: stale.has(d.name) ? "stale_fork" : "ok",
31750
- path: join19(".claude", "skills", d.name)
32287
+ path: join20(".claude", "skills", d.name)
31751
32288
  });
31752
32289
  }
31753
32290
  } catch {
@@ -31763,9 +32300,9 @@ async function scanInventory(root, toolDefs) {
31763
32300
  }
31764
32301
  const present = /* @__PURE__ */ new Set();
31765
32302
  try {
31766
- for (const f of (await readdir3(join19(root, ".claude", "hooks"))).filter((n) => n.endsWith(".sh"))) {
32303
+ for (const f of (await readdir3(join20(root, ".claude", "hooks"))).filter((n) => n.endsWith(".sh"))) {
31767
32304
  present.add(f);
31768
- entries.push({ kind: "hook", name: f, status: "ok", path: join19(".claude", "hooks", f) });
32305
+ entries.push({ kind: "hook", name: f, status: "ok", path: join20(".claude", "hooks", f) });
31769
32306
  }
31770
32307
  } catch {
31771
32308
  }
@@ -32045,6 +32582,7 @@ If this is legitimate, reach out at https://getpapi.ai and we'll lift it \u2014
32045
32582
  }
32046
32583
 
32047
32584
  // src/server.ts
32585
+ var mdModeWarned = false;
32048
32586
  var DEFAULT_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_TOOL_TIMEOUT_MS ?? "30000", 10);
32049
32587
  var LONG_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_LONG_TOOL_TIMEOUT_MS ?? "180000", 10);
32050
32588
  var WEDGE_PENDING_FRACTION = Math.min(1, Math.max(0, parseFloat(process.env.PAPI_WEDGE_PENDING_FRACTION ?? "0.6")));
@@ -32210,7 +32748,7 @@ function createServer(adapter2, config2) {
32210
32748
  const __pkgDir = dirname6(__pkgFilename);
32211
32749
  let serverVersion = "unknown";
32212
32750
  try {
32213
- const pkg = JSON.parse(readFileSync14(join20(__pkgDir, "..", "package.json"), "utf-8"));
32751
+ const pkg = JSON.parse(readFileSync14(join21(__pkgDir, "..", "package.json"), "utf-8"));
32214
32752
  serverVersion = pkg.version ?? "unknown";
32215
32753
  } catch {
32216
32754
  }
@@ -32221,14 +32759,15 @@ function createServer(adapter2, config2) {
32221
32759
  // task-1801: `resources` capability for the PAPI read surface exposed as MCP resources.
32222
32760
  { capabilities: { tools: {}, prompts: {}, resources: {} }, instructions: UNIVERSAL_FRAME }
32223
32761
  );
32224
- if (config2.adapterType === "md") {
32762
+ if (config2.adapterType === "md" && !mdModeWarned) {
32763
+ mdModeWarned = true;
32225
32764
  process.stderr.write(
32226
32765
  "\n\u26A0 PAPI is running in md mode \u2014 your cycles are not visible on the hosted dashboard.\n Configure DATABASE_URL or sign up at https://getpapi.ai/setup to enable observability.\n\n"
32227
32766
  );
32228
32767
  }
32229
32768
  const __filename = fileURLToPath4(import.meta.url);
32230
32769
  const __dirname2 = dirname6(__filename);
32231
- const skillsDir = join20(__dirname2, "..", "skills");
32770
+ const skillsDir = join21(__dirname2, "..", "skills");
32232
32771
  function parseSkillFrontmatter(content) {
32233
32772
  const match = content.match(/^---\n([\s\S]*?)\n---/);
32234
32773
  if (!match) return null;
@@ -32246,7 +32785,7 @@ function createServer(adapter2, config2) {
32246
32785
  const mdFiles = files.filter((f) => f.endsWith(".md"));
32247
32786
  const prompts = [];
32248
32787
  for (const file of mdFiles) {
32249
- const content = await readFile9(join20(skillsDir, file), "utf-8");
32788
+ const content = await readFile9(join21(skillsDir, file), "utf-8");
32250
32789
  const meta = parseSkillFrontmatter(content);
32251
32790
  if (meta) {
32252
32791
  prompts.push({ name: meta.name, description: meta.description });
@@ -32262,7 +32801,7 @@ function createServer(adapter2, config2) {
32262
32801
  try {
32263
32802
  const files = await readdir4(skillsDir);
32264
32803
  for (const file of files.filter((f) => f.endsWith(".md"))) {
32265
- const content = await readFile9(join20(skillsDir, file), "utf-8");
32804
+ const content = await readFile9(join21(skillsDir, file), "utf-8");
32266
32805
  const meta = parseSkillFrontmatter(content);
32267
32806
  if (meta?.name === name) {
32268
32807
  const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
@@ -32548,6 +33087,7 @@ ${usageLine(decision.usage)}`;
32548
33087
  // src/transport-http.ts
32549
33088
  init_proxy_adapter();
32550
33089
  import { createServer as createHttpServer } from "http";
33090
+ import { createHash as createHash8 } from "crypto";
32551
33091
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
32552
33092
  var BEARER_PREFIX = "papi_";
32553
33093
  var BEARER_REGEX = /^(papi_|papi_oauth_)[a-f0-9]{64}$/;
@@ -32577,6 +33117,7 @@ var FRIENDLY_GET_HTML = `<!doctype html>
32577
33117
  <code>https://mcp.getpapi.ai/mcp</code>.</p>
32578
33118
  <a class="btn" href="https://getpapi.ai/docs/install">See the install guide \u2192</a>
32579
33119
  </div></body></html>`;
33120
+ var KNOWN_INSTALL_CLIENTS = /* @__PURE__ */ new Set(["claude-code-plugin"]);
32580
33121
  var MAX_BODY_BYTES = 1 * 1024 * 1024;
32581
33122
  var IP_RATE_WINDOW_MS = 6e4;
32582
33123
  var IP_RATE_MAX = 60;
@@ -32615,7 +33156,7 @@ function corsHeaders(origin) {
32615
33156
  return {
32616
33157
  "Access-Control-Allow-Origin": origin,
32617
33158
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
32618
- "Access-Control-Allow-Headers": "Authorization, Content-Type, x-papi-project-id",
33159
+ "Access-Control-Allow-Headers": "Authorization, Content-Type, x-papi-project-id, x-papi-client",
32619
33160
  "Vary": "Origin"
32620
33161
  };
32621
33162
  }
@@ -32657,6 +33198,48 @@ function sendError(res, err, extraHeaders = {}) {
32657
33198
  });
32658
33199
  res.end(JSON.stringify(err.body));
32659
33200
  }
33201
+ function sendUnauthorized(res, reason, extraHeaders = {}) {
33202
+ const challenge = reason === "invalid_token" ? `Bearer realm="papi", error="invalid_token", error_description="The access token was rejected: it has been revoked or has expired.", resource_metadata="${RESOURCE_METADATA_URL}"` : `Bearer realm="papi", resource_metadata="${RESOURCE_METADATA_URL}"`;
33203
+ sendError(
33204
+ res,
33205
+ { status: 401, body: { error: "Unauthorized", reason } },
33206
+ { ...extraHeaders, "WWW-Authenticate": challenge }
33207
+ );
33208
+ }
33209
+ var AUTH_VALID_TTL_MS = 5 * 6e4;
33210
+ var AUTH_REJECTED_TTL_MS = 6e4;
33211
+ var AUTH_CACHE_MAX = 5e3;
33212
+ var authVerdicts = /* @__PURE__ */ new Map();
33213
+ function bearerKey(bearer) {
33214
+ return createHash8("sha256").update(bearer).digest("hex");
33215
+ }
33216
+ function readAuthVerdict(bearer, now = Date.now()) {
33217
+ const hit = authVerdicts.get(bearerKey(bearer));
33218
+ if (!hit) return void 0;
33219
+ if (hit.expires <= now) {
33220
+ authVerdicts.delete(bearerKey(bearer));
33221
+ return void 0;
33222
+ }
33223
+ return hit.verdict;
33224
+ }
33225
+ function recordAuthVerdict(bearer, verdict, now = Date.now()) {
33226
+ if (authVerdicts.size >= AUTH_CACHE_MAX) {
33227
+ for (const [k, v] of authVerdicts) {
33228
+ if (v.expires <= now) authVerdicts.delete(k);
33229
+ }
33230
+ if (authVerdicts.size >= AUTH_CACHE_MAX) {
33231
+ const oldest = authVerdicts.keys().next();
33232
+ if (!oldest.done) authVerdicts.delete(oldest.value);
33233
+ }
33234
+ }
33235
+ const ttl = verdict === "valid" ? AUTH_VALID_TTL_MS : AUTH_REJECTED_TTL_MS;
33236
+ authVerdicts.set(bearerKey(bearer), { verdict, expires: now + ttl });
33237
+ }
33238
+ function classifyAuthProbeStatus(status) {
33239
+ if (status === 401) return "rejected";
33240
+ if (status >= 200 && status < 300) return "valid";
33241
+ return void 0;
33242
+ }
32660
33243
  function startHttpTransport(opts) {
32661
33244
  const { port, host, baseConfig, pkgVersion: pkgVersion2, dataEndpoint } = opts;
32662
33245
  const httpServer = createHttpServer((req, res) => {
@@ -32758,21 +33341,11 @@ function startHttpTransport(opts) {
32758
33341
  ip,
32759
33342
  status: 401
32760
33343
  });
32761
- sendError(
32762
- res,
32763
- {
32764
- status: 401,
32765
- body: {
32766
- error: "Unauthorized",
32767
- reason: hasHeader ? "malformed_bearer" : "missing_bearer"
32768
- }
32769
- },
32770
- {
32771
- "WWW-Authenticate": `Bearer realm="papi", resource_metadata="${RESOURCE_METADATA_URL}"`
32772
- }
32773
- );
33344
+ sendUnauthorized(res, hasHeader ? "malformed_bearer" : "missing_bearer");
32774
33345
  return;
32775
33346
  }
33347
+ const clientHeader = req.headers["x-papi-client"];
33348
+ const installClient = typeof clientHeader === "string" && KNOWN_INSTALL_CLIENTS.has(clientHeader) ? clientHeader : "direct";
32776
33349
  const projectIdHeader = req.headers["x-papi-project-id"];
32777
33350
  const projectId = typeof projectIdHeader === "string" && projectIdHeader.length > 0 ? projectIdHeader : void 0;
32778
33351
  if (req.method !== "POST" && req.method !== "GET") {
@@ -32815,6 +33388,13 @@ function startHttpTransport(opts) {
32815
33388
  return;
32816
33389
  }
32817
33390
  }
33391
+ logEvent({
33392
+ level: "info",
33393
+ msg: "mcp_request",
33394
+ ip,
33395
+ bearer_prefix: bearerPrefix(bearer),
33396
+ install_client: installClient
33397
+ });
32818
33398
  void dispatchRequest({
32819
33399
  req,
32820
33400
  res,
@@ -32895,6 +33475,18 @@ Example: add \`project="${projects[0].slug}"\` to the tool arguments.`;
32895
33475
  res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) });
32896
33476
  res.end(JSON.stringify(payload));
32897
33477
  }
33478
+ function sendProjectUnverifiable(res, origin, body) {
33479
+ if (res.headersSent) return;
33480
+ const id = (body && typeof body === "object" ? body.id : null) ?? null;
33481
+ const text = 'PAPI couldn\'t verify which project this call belongs to \u2014 the project lookup failed, so it is stopping rather than guessing and writing to the wrong project.\n\nRetry in a moment. If it keeps happening, name the project explicitly with `project="<slug>"` in the tool arguments, or set the x-papi-project-id header.';
33482
+ const payload = {
33483
+ jsonrpc: "2.0",
33484
+ id,
33485
+ result: { content: [{ type: "text", text }], isError: true }
33486
+ };
33487
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) });
33488
+ res.end(JSON.stringify(payload));
33489
+ }
32898
33490
  function resolveEffectiveProjectId(body, headerProjectId) {
32899
33491
  const explicitProject = extractProjectOverride(body);
32900
33492
  try {
@@ -32904,17 +33496,59 @@ function resolveEffectiveProjectId(body, headerProjectId) {
32904
33496
  throw err;
32905
33497
  }
32906
33498
  }
33499
+ async function resolveAuthVerdict(bearer, dataEndpoint) {
33500
+ const cached2 = readAuthVerdict(bearer);
33501
+ if (cached2) return cached2;
33502
+ const probe = new ProxyPapiAdapter({ endpoint: dataEndpoint, apiKey: bearer });
33503
+ const verdict = classifyAuthProbeStatus(await probe.probeBearerStatus());
33504
+ if (verdict) recordAuthVerdict(bearer, verdict);
33505
+ return verdict;
33506
+ }
32907
33507
  async function dispatchRequest(args) {
32908
33508
  const { req, res, body, bearer, projectId, ip, baseConfig, dataEndpoint } = args;
33509
+ const calledTool = extractToolName(body);
33510
+ if (calledTool !== void 0) {
33511
+ const authVerdict = await resolveAuthVerdict(bearer, dataEndpoint);
33512
+ if (authVerdict === "rejected") {
33513
+ logEvent({
33514
+ level: "warn",
33515
+ msg: "auth_revoked",
33516
+ ip,
33517
+ bearer_prefix: bearerPrefix(bearer),
33518
+ status: 401,
33519
+ reason: "proxy_rejected_bearer"
33520
+ });
33521
+ if (!res.headersSent) {
33522
+ sendUnauthorized(res, "invalid_token", corsHeaders(req.headers.origin));
33523
+ }
33524
+ return;
33525
+ }
33526
+ }
32909
33527
  let effectiveProjectId = resolveEffectiveProjectId(body, projectId);
32910
33528
  if (effectiveProjectId === void 0) {
32911
- const toolName = extractToolName(body);
33529
+ const toolName = calledTool;
32912
33530
  if (toolName && !PROJECT_OPTIONAL_TOOLS.has(toolName)) {
32913
33531
  let projects = [];
33532
+ let probeFailed = false;
32914
33533
  try {
32915
- const probe = new ProxyPapiAdapter({ endpoint: dataEndpoint, apiKey: bearer });
33534
+ const probe = new ProxyPapiAdapter({
33535
+ endpoint: dataEndpoint,
33536
+ apiKey: bearer,
33537
+ onAuthRejected: () => recordAuthVerdict(bearer, "rejected")
33538
+ });
32916
33539
  projects = await probe.listUserProjects();
32917
33540
  } catch {
33541
+ probeFailed = true;
33542
+ }
33543
+ if (probeFailed) {
33544
+ logEvent({
33545
+ level: "warn",
33546
+ msg: "project_probe_failed",
33547
+ ip,
33548
+ bearer_prefix: bearerPrefix(bearer)
33549
+ });
33550
+ sendProjectUnverifiable(res, req.headers.origin, body);
33551
+ return;
32918
33552
  }
32919
33553
  if (projects.length === 1) {
32920
33554
  effectiveProjectId = projects[0].id;
@@ -32937,10 +33571,14 @@ async function dispatchRequest(args) {
32937
33571
  }
32938
33572
  }
32939
33573
  }
32940
- const adapter2 = new ProxyPapiAdapter({
33574
+ const adapter2 = createProxyAdapter({
32941
33575
  endpoint: dataEndpoint,
32942
33576
  apiKey: bearer,
32943
- projectId: effectiveProjectId
33577
+ projectId: effectiveProjectId,
33578
+ // task-1773: a 401 raised mid-tool-call cannot change THIS response — the MCP
33579
+ // transport already owns it — but it marks the bearer so the very next request
33580
+ // short-circuits to a 401 + WWW-Authenticate and the client re-authenticates.
33581
+ onAuthRejected: () => recordAuthVerdict(bearer, "rejected")
32944
33582
  });
32945
33583
  const requestConfig = {
32946
33584
  ...baseConfig,
@@ -32988,7 +33626,7 @@ async function dispatchRequest(args) {
32988
33626
  var __dirname = dirname7(fileURLToPath5(import.meta.url));
32989
33627
  var pkgVersion = "unknown";
32990
33628
  try {
32991
- const pkg = JSON.parse(readFileSync19(join25(__dirname, "..", "package.json"), "utf-8"));
33629
+ const pkg = JSON.parse(readFileSync19(join26(__dirname, "..", "package.json"), "utf-8"));
32992
33630
  pkgVersion = pkg.version;
32993
33631
  } catch {
32994
33632
  }