@papi-ai/server 0.7.76 → 0.7.78

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, {
@@ -1353,6 +1361,14 @@ var init_proxy_adapter = __esm({
1353
1361
  // (1) local-only
1354
1362
  "close",
1355
1363
  "initRls",
1364
+ // task-3207 (C357): commitBuildComplete, commitReviewSubmit, commitRelease have no
1365
+ // edge case handler and no ALLOWED_METHODS entry — forwarding them 403s at the edge
1366
+ // with no try/catch at the call site, crashing build_execute/review_submit/release
1367
+ // completion for every hosted user. Restores the intended graceful degradation
1368
+ // (separate appendBuildReport + updateTaskStatus calls) until they're atomically wired.
1369
+ "commitBuildComplete",
1370
+ "commitReviewSubmit",
1371
+ "commitRelease",
1356
1372
  // (2) not-yet-wired hosted gaps — shrink as data-proxy handlers land (task-2390).
1357
1373
  // getToolCallCount + updatePhaseStatus are ABSENT: they now have edge handlers and
1358
1374
  // are served through the forwarder (getToolCallCount = SUP-2026-026 handler #1).
@@ -1398,10 +1414,39 @@ var init_proxy_adapter = __esm({
1398
1414
  endpoint;
1399
1415
  apiKey;
1400
1416
  projectId;
1417
+ onAuthRejected;
1401
1418
  constructor(config2) {
1402
1419
  this.endpoint = config2.endpoint.replace(/\/$/, "");
1403
1420
  this.apiKey = config2.apiKey;
1404
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
+ }
1405
1450
  }
1406
1451
  /** Resolved project ID — available after ensureProject() completes. */
1407
1452
  getProjectId() {
@@ -1418,7 +1463,8 @@ var init_proxy_adapter = __esm({
1418
1463
  return wrapWithForwarding(new _ProxyPapiAdapter({
1419
1464
  endpoint: this.endpoint,
1420
1465
  apiKey: this.apiKey,
1421
- projectId
1466
+ projectId,
1467
+ onAuthRejected: this.onAuthRejected
1422
1468
  }));
1423
1469
  }
1424
1470
  /**
@@ -1505,6 +1551,7 @@ var init_proxy_adapter = __esm({
1505
1551
  message = errorBody;
1506
1552
  }
1507
1553
  if (response.status === 401) {
1554
+ this.onAuthRejected?.();
1508
1555
  throw new Error(
1509
1556
  `Auth: Invalid API key \u2014 PAPI_DATA_API_KEY was rejected by the proxy.
1510
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.
@@ -2083,6 +2130,7 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
2083
2130
  } catch {
2084
2131
  message = errorBody;
2085
2132
  }
2133
+ if (response.status === 401) this.onAuthRejected?.();
2086
2134
  throw new Error(`Proxy error (${response.status}) on ${route}: ${message}`);
2087
2135
  }
2088
2136
  const body = await response.json();
@@ -2200,7 +2248,7 @@ var init_reap_orphans = __esm({
2200
2248
  }
2201
2249
  });
2202
2250
 
2203
- // ../../node_modules/postgres/src/query.js
2251
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/query.js
2204
2252
  function cachedError(xs) {
2205
2253
  if (originCache.has(xs))
2206
2254
  return originCache.get(xs);
@@ -2212,7 +2260,7 @@ function cachedError(xs) {
2212
2260
  }
2213
2261
  var originCache, originStackCache, originError, CLOSE, Query;
2214
2262
  var init_query = __esm({
2215
- "../../node_modules/postgres/src/query.js"() {
2263
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/query.js"() {
2216
2264
  "use strict";
2217
2265
  originCache = /* @__PURE__ */ new Map();
2218
2266
  originStackCache = /* @__PURE__ */ new Map();
@@ -2343,7 +2391,7 @@ var init_query = __esm({
2343
2391
  }
2344
2392
  });
2345
2393
 
2346
- // ../../node_modules/postgres/src/errors.js
2394
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/errors.js
2347
2395
  function connection(x, options, socket) {
2348
2396
  const { host, port } = socket || options;
2349
2397
  const error = Object.assign(
@@ -2381,7 +2429,7 @@ function notSupported(x) {
2381
2429
  }
2382
2430
  var PostgresError, Errors;
2383
2431
  var init_errors = __esm({
2384
- "../../node_modules/postgres/src/errors.js"() {
2432
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/errors.js"() {
2385
2433
  "use strict";
2386
2434
  PostgresError = class extends Error {
2387
2435
  constructor(x) {
@@ -2399,7 +2447,7 @@ var init_errors = __esm({
2399
2447
  }
2400
2448
  });
2401
2449
 
2402
- // ../../node_modules/postgres/src/types.js
2450
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/types.js
2403
2451
  function handleValue(x, parameters, types2, options) {
2404
2452
  let value = x instanceof Parameter ? x.value : x;
2405
2453
  if (value === void 0) {
@@ -2514,7 +2562,7 @@ function createJsonTransform(fn) {
2514
2562
  }
2515
2563
  var types, NotTagged, Identifier, Parameter, Builder, defaultHandlers, builders, serializers, parsers, mergeUserTypes, escapeIdentifier, inferType, escapeBackslash, escapeQuote, arraySerializer, arrayParserState, arrayParser, toCamel, toPascal, toKebab, fromCamel, fromPascal, fromKebab, camel, pascal, kebab;
2516
2564
  var init_types = __esm({
2517
- "../../node_modules/postgres/src/types.js"() {
2565
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/types.js"() {
2518
2566
  "use strict";
2519
2567
  init_query();
2520
2568
  init_errors();
@@ -2693,10 +2741,10 @@ var init_types = __esm({
2693
2741
  }
2694
2742
  });
2695
2743
 
2696
- // ../../node_modules/postgres/src/result.js
2744
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/result.js
2697
2745
  var Result;
2698
2746
  var init_result = __esm({
2699
- "../../node_modules/postgres/src/result.js"() {
2747
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/result.js"() {
2700
2748
  "use strict";
2701
2749
  Result = class extends Array {
2702
2750
  constructor() {
@@ -2716,7 +2764,7 @@ var init_result = __esm({
2716
2764
  }
2717
2765
  });
2718
2766
 
2719
- // ../../node_modules/postgres/src/queue.js
2767
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/queue.js
2720
2768
  function Queue(initial = []) {
2721
2769
  let xs = initial.slice();
2722
2770
  let index = 0;
@@ -2743,13 +2791,13 @@ function Queue(initial = []) {
2743
2791
  }
2744
2792
  var queue_default;
2745
2793
  var init_queue = __esm({
2746
- "../../node_modules/postgres/src/queue.js"() {
2794
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/queue.js"() {
2747
2795
  "use strict";
2748
2796
  queue_default = Queue;
2749
2797
  }
2750
2798
  });
2751
2799
 
2752
- // ../../node_modules/postgres/src/bytes.js
2800
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/bytes.js
2753
2801
  function fit(x) {
2754
2802
  if (buffer.length - b.i < x) {
2755
2803
  const prev = buffer, length = prev.length;
@@ -2763,7 +2811,7 @@ function reset() {
2763
2811
  }
2764
2812
  var size, buffer, messages, b, bytes_default;
2765
2813
  var init_bytes = __esm({
2766
- "../../node_modules/postgres/src/bytes.js"() {
2814
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/bytes.js"() {
2767
2815
  "use strict";
2768
2816
  size = 256;
2769
2817
  buffer = Buffer.allocUnsafe(size);
@@ -2828,7 +2876,7 @@ var init_bytes = __esm({
2828
2876
  }
2829
2877
  });
2830
2878
 
2831
- // ../../node_modules/postgres/src/connection.js
2879
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/connection.js
2832
2880
  import net from "net";
2833
2881
  import tls from "tls";
2834
2882
  import crypto2 from "crypto";
@@ -3622,7 +3670,7 @@ function timer(fn, seconds) {
3622
3670
  }
3623
3671
  var connection_default, uid, Sync, Flush, SSLRequest, ExecuteUnnamed, DescribeUnnamed, noop, retryRoutines, errorFields;
3624
3672
  var init_connection = __esm({
3625
- "../../node_modules/postgres/src/connection.js"() {
3673
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/connection.js"() {
3626
3674
  "use strict";
3627
3675
  init_types();
3628
3676
  init_errors();
@@ -3685,7 +3733,7 @@ var init_connection = __esm({
3685
3733
  }
3686
3734
  });
3687
3735
 
3688
- // ../../node_modules/postgres/src/subscribe.js
3736
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/subscribe.js
3689
3737
  function Subscribe(postgres2, options) {
3690
3738
  const subscribers = /* @__PURE__ */ new Map(), slot = "postgresjs_" + Math.random().toString(36).slice(2), state = {};
3691
3739
  let connection2, stream, ended = false;
@@ -3896,14 +3944,14 @@ function parseEvent(x) {
3896
3944
  }
3897
3945
  var noop2;
3898
3946
  var init_subscribe = __esm({
3899
- "../../node_modules/postgres/src/subscribe.js"() {
3947
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/subscribe.js"() {
3900
3948
  "use strict";
3901
3949
  noop2 = () => {
3902
3950
  };
3903
3951
  }
3904
3952
  });
3905
3953
 
3906
- // ../../node_modules/postgres/src/large.js
3954
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/large.js
3907
3955
  import Stream2 from "stream";
3908
3956
  function largeObject(sql, oid, mode = 131072 | 262144) {
3909
3957
  return new Promise(async (resolve4, reject) => {
@@ -3969,12 +4017,12 @@ function largeObject(sql, oid, mode = 131072 | 262144) {
3969
4017
  });
3970
4018
  }
3971
4019
  var init_large = __esm({
3972
- "../../node_modules/postgres/src/large.js"() {
4020
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/large.js"() {
3973
4021
  "use strict";
3974
4022
  }
3975
4023
  });
3976
4024
 
3977
- // ../../node_modules/postgres/src/index.js
4025
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/index.js
3978
4026
  var src_exports = {};
3979
4027
  __export(src_exports, {
3980
4028
  default: () => src_default
@@ -4364,7 +4412,7 @@ function osUsername() {
4364
4412
  }
4365
4413
  var src_default;
4366
4414
  var init_src = __esm({
4367
- "../../node_modules/postgres/src/index.js"() {
4415
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/index.js"() {
4368
4416
  "use strict";
4369
4417
  init_types();
4370
4418
  init_connection();
@@ -5259,6 +5307,15 @@ var HELP_FOOTER_MD = `
5259
5307
  var STRATEGY_REVIEW_OFFER_GAP = 5;
5260
5308
  var STRATEGY_REVIEW_BLOCK_GAP = 7;
5261
5309
  var ZOOM_OUT_OFFER_GAP = 25;
5310
+ function isDatabaseBackedAdapter(known) {
5311
+ if (known) return known === "pg" || known === "proxy";
5312
+ try {
5313
+ const { adapterType } = loadConfig();
5314
+ return adapterType === "pg" || adapterType === "proxy";
5315
+ } catch {
5316
+ return process.env.PAPI_ADAPTER !== "md";
5317
+ }
5318
+ }
5262
5319
  function loadConfig() {
5263
5320
  const projectArgIdx = process.argv.indexOf("--project");
5264
5321
  const configuredRoot = projectArgIdx !== -1 ? process.argv[projectArgIdx + 1] : process.env.PAPI_PROJECT_DIR;
@@ -5368,7 +5425,7 @@ Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env con
5368
5425
  import path3 from "path";
5369
5426
  import { execSync } from "child_process";
5370
5427
 
5371
- // ../adapter-md/dist/index.js
5428
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/packages/adapter-md/dist/index.js
5372
5429
  import { readFile, writeFile, access } from "fs/promises";
5373
5430
  import { randomUUID as randomUUID6 } from "crypto";
5374
5431
  import { join } from "path";
@@ -5630,9 +5687,12 @@ function parsePlanningLog(content, activeDecisionsContent, cycleLogContent) {
5630
5687
  var VALID_EFFORT_SIZES = /* @__PURE__ */ new Set(["XS", "S", "M", "L", "XL"]);
5631
5688
  var SECTION_HEADERS = [
5632
5689
  "SCOPE (DO THIS)",
5690
+ "WHY NOT SIMPLER",
5633
5691
  "SCOPE BOUNDARY (DO NOT DO THIS)",
5634
5692
  "ACCEPTANCE CRITERIA",
5693
+ "PRE-MORTEM",
5635
5694
  "SECURITY CONSIDERATIONS",
5695
+ "DEPLOY VERIFICATION",
5636
5696
  "PRE-BUILD VERIFICATION",
5637
5697
  "FILES LIKELY TOUCHED",
5638
5698
  "EFFORT"
@@ -5671,7 +5731,7 @@ function parseBulletsOnly(text) {
5671
5731
  return text.split("\n").filter((l) => /^\s*-\s/.test(l)).map((l) => l.replace(/^\s*-\s*/, "").trim()).filter((l) => l.length > 0);
5672
5732
  }
5673
5733
  function parseChecklist(text) {
5674
- 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);
5675
5735
  }
5676
5736
  function parseBuildHandoff(markdown) {
5677
5737
  if (typeof markdown !== "string" || !markdown.trim()) return null;
@@ -6321,9 +6381,59 @@ var EFFORT_SCALE = {
6321
6381
  XL: 5
6322
6382
  };
6323
6383
  function effortOrdinal(effort) {
6384
+ if (typeof effort !== "string") return void 0;
6324
6385
  const normalized = effort.trim().toUpperCase();
6325
6386
  return EFFORT_SCALE[normalized];
6326
6387
  }
6388
+ function isUnparsedEffort(effort) {
6389
+ if (typeof effort !== "string" || effort.trim().length === 0) return false;
6390
+ return effortOrdinal(effort) === void 0;
6391
+ }
6392
+ function calculateCycleMetrics(reports, currentCycle, window = 5) {
6393
+ const recentReports = reports.filter(
6394
+ (r) => r.cycle > currentCycle - window && r.cycle <= currentCycle
6395
+ );
6396
+ const unparsedEffortCount = recentReports.filter(
6397
+ (r) => isUnparsedEffort(r.actualEffort) || isUnparsedEffort(r.estimatedEffort)
6398
+ ).length;
6399
+ const perCycle = /* @__PURE__ */ new Map();
6400
+ for (const r of recentReports) {
6401
+ const group = perCycle.get(r.cycle) ?? [];
6402
+ group.push(r);
6403
+ perCycle.set(r.cycle, group);
6404
+ }
6405
+ const accuracy = [];
6406
+ const velocity = [];
6407
+ const sortedCycles = [...perCycle.keys()].sort((a, b2) => a - b2);
6408
+ for (const cycle of sortedCycles) {
6409
+ const reps = perCycle.get(cycle);
6410
+ const deltas = [];
6411
+ for (const r of reps) {
6412
+ const actual = effortOrdinal(r.actualEffort);
6413
+ const estimated = effortOrdinal(r.estimatedEffort);
6414
+ if (actual !== void 0 && estimated !== void 0) {
6415
+ deltas.push(actual - estimated);
6416
+ }
6417
+ }
6418
+ if (deltas.length > 0) {
6419
+ accuracy.push({
6420
+ cycle,
6421
+ reports: deltas.length,
6422
+ matchRate: Math.round(deltas.filter((d) => d === 0).length / deltas.length * 100),
6423
+ mae: Math.round(deltas.reduce((s, d) => s + Math.abs(d), 0) / deltas.length * 10) / 10,
6424
+ bias: Math.round(deltas.reduce((s, d) => s + d, 0) / deltas.length * 10) / 10
6425
+ });
6426
+ }
6427
+ velocity.push({
6428
+ cycle,
6429
+ completed: reps.filter((r) => r.completed === "Yes").length,
6430
+ partial: reps.filter((r) => r.completed === "Partial").length,
6431
+ failed: reps.filter((r) => r.completed === "No").length,
6432
+ effortPoints: reps.reduce((s, r) => s + (effortOrdinal(r.actualEffort) ?? 0), 0)
6433
+ });
6434
+ }
6435
+ return { accuracy, velocity, unparsedEffortCount };
6436
+ }
6327
6437
  function serializeAccuracyRow(a) {
6328
6438
  return `| ${a.cycle} | ${a.reports} | ${a.matchRate}% | ${a.mae} | ${a.bias >= 0 ? "+" : ""}${a.bias} |`;
6329
6439
  }
@@ -7972,6 +8082,7 @@ async function createAdapter(optionsOrType, maybePapiDir) {
7972
8082
  case "pg": {
7973
8083
  const { PgAdapter, PgPapiAdapter, configFromEnv } = await import("@papi-ai/adapter-pg");
7974
8084
  let projectId = process.env["PAPI_PROJECT_ID"];
8085
+ const projectIdWasPreSupplied = Boolean(projectId);
7975
8086
  const projectRoot = options.projectRoot ?? process.env["PAPI_PROJECT_DIR"] ?? process.cwd();
7976
8087
  let rootHash = null;
7977
8088
  let originUrl = null;
@@ -8035,6 +8146,26 @@ async function createAdapter(optionsOrType, maybePapiDir) {
8035
8146
  }
8036
8147
  const config2 = papiEndpoint ? { connectionString: papiEndpoint } : configFromEnv();
8037
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
+ }
8038
8169
  const { ensureSchema } = await import("@papi-ai/adapter-pg");
8039
8170
  try {
8040
8171
  await ensureSchema(config2);
@@ -8402,6 +8533,24 @@ function formatBuildReports(reports, opts) {
8402
8533
  _\u2026and ${reports.length - capped.length} older build report(s) omitted to bound context size._` : "";
8403
8534
  return body + omitted;
8404
8535
  }
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, " ");
8539
+ const own = report.taskId?.toLowerCase();
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();
8548
+ for (const m of prose.matchAll(/\btask-\d+\b/gi)) {
8549
+ const id = m[0].toLowerCase();
8550
+ if (id !== own && !resolves.has(id)) mentions.add(id);
8551
+ }
8552
+ return { resolves: [...resolves].sort(), mentions: [...mentions].sort() };
8553
+ }
8405
8554
  function formatRecentlyShippedCapabilities(reports) {
8406
8555
  const completed = reports.filter((r) => r.completed === "Yes" || r.completed === "Partial");
8407
8556
  if (completed.length === 0) return void 0;
@@ -8416,13 +8565,63 @@ function formatRecentlyShippedCapabilities(reports) {
8416
8565
  }
8417
8566
  return parts.join("\n");
8418
8567
  });
8419
- return [
8568
+ const resolvedBy = /* @__PURE__ */ new Map();
8569
+ const namedBy = /* @__PURE__ */ new Map();
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
+ };
8576
+ for (const r of completed) {
8577
+ const { resolves, mentions } = extractTaskReferences(r);
8578
+ for (const ref of resolves) {
8579
+ if (completedIds.has(ref)) continue;
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);
8585
+ }
8586
+ }
8587
+ for (const ref of resolvedBy.keys()) namedBy.delete(ref);
8588
+ const out = [
8420
8589
  `${completed.length} task(s) completed in recent cycles:`,
8421
8590
  "",
8422
8591
  ...lines,
8423
8592
  "",
8424
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."
8425
- ].join("\n");
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
+ }
8608
+ if (namedBy.size > 0) {
8609
+ out.push(
8610
+ "",
8611
+ "### \u26A0 Named by a shipped task \u2014 VERIFY BEFORE SCHEDULING",
8612
+ "",
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.",
8620
+ "",
8621
+ ...[...namedBy.entries()].sort(([a], [b2]) => a.localeCompare(b2)).map(([ref, namers]) => `- **${ref}** \u2014 named by ${namers.join(", ")}`)
8622
+ );
8623
+ }
8624
+ return out.join("\n");
8426
8625
  }
8427
8626
  function formatCycleLog(entries) {
8428
8627
  if (entries.length === 0) return "No cycle log entries yet.";
@@ -8669,13 +8868,14 @@ function computeSnapshotsFromBuildReports(reports, tasks) {
8669
8868
  const cycleReports = reportsByCycle.get(sn) ?? [];
8670
8869
  const cycleTaskRows = tasksByCycle.get(sn);
8671
8870
  const withEffort = cycleReports.filter((r) => r.estimatedEffort && r.actualEffort);
8672
- const accurate = withEffort.filter((r) => r.estimatedEffort === r.actualEffort).length;
8673
- const matchRate = withEffort.length > 0 ? Math.round(accurate / withEffort.length * 100) : 0;
8871
+ const [computedAccuracy] = calculateCycleMetrics(withEffort, sn, 1).accuracy;
8674
8872
  const { completed, total, plannedPoints, deliveredPoints } = computeCycleEffort(cycleTaskRows, cycleReports);
8675
8873
  snapshots.push({
8676
8874
  cycle: sn,
8677
8875
  date: (/* @__PURE__ */ new Date()).toISOString(),
8678
- accuracy: [{ cycle: sn, reports: cycleReports.length, matchRate, mae: 0, bias: 0 }],
8876
+ // No report in this cycle carried BOTH an estimate and an actual, so there
8877
+ // is genuinely nothing to measure. Zeros here mean "no data", not "no bias".
8878
+ accuracy: [computedAccuracy ?? { cycle: sn, reports: 0, matchRate: 0, mae: 0, bias: 0 }],
8679
8879
  velocity: [{
8680
8880
  cycle: sn,
8681
8881
  completed,
@@ -9196,7 +9396,7 @@ var PLAN_FRAGMENT_RESEARCH = `
9196
9396
  var PLAN_FRAGMENT_BUG = `
9197
9397
  **Bug task detection:** When a task's task type is "bug" or the title starts with "Bug:" or "Fix:", apply these rules:
9198
9398
  - **Auto-P1:** If the task's current priority is P2 or lower, upgrade it to "P1 High" via a boardCorrections entry in Part 2. Note the upgrade in Part 1 analysis.
9199
- - Replace the standard SCOPE (DO THIS) section with bug-specific sections:
9399
+ - Inside SCOPE (DO THIS), use these bug-specific subsections. KEEP the "SCOPE (DO THIS)" header and put these subsections INSIDE it \u2014 the handoff parser only recognises the standard headers (packages/adapter-md build-handoff.ts SECTION_HEADERS) and a handoff with no SCOPE section is rejected outright (task-3050, C357):
9200
9400
  - **REPRODUCE:** Exact steps to reproduce the bug before touching any code. If the task notes describe the symptoms, include them. If not, the first build step is "confirm the bug reproduces."
9201
9401
  - **ROOT CAUSE:** One-sentence hypothesis for the root cause (what is wrong, not what the user sees). The builder must confirm or correct this before implementing a fix.
9202
9402
  - **MINIMAL FIX:** The smallest code change that resolves the root cause. "Bug fix \u2014 minimal blast radius. Change only what is necessary. Do not refactor surrounding code or expand scope."
@@ -9221,7 +9421,7 @@ var PLAN_FRAGMENT_SPIKE = `
9221
9421
  - Keep SCOPE BOUNDARY, SECURITY CONSIDERATIONS, and PRE-BUILD VERIFICATION as normal.
9222
9422
  - Spikes should be estimated conservatively: XS or S. If a spike needs M+ effort, it's not a spike \u2014 reclassify as a research task.`;
9223
9423
  var PLAN_FRAGMENT_DESIGN_BRIEF = `
9224
- **Design brief task detection:** When a task's task type is "design-brief", generate a DESIGN BRIEF handoff. Replace the standard SCOPE (DO THIS) section with these type-specific sections:
9424
+ **Design brief task detection:** When a task's task type is "design-brief", generate a DESIGN BRIEF handoff. Inside SCOPE (DO THIS), use these type-specific subsections. KEEP the "SCOPE (DO THIS)" header and put these subsections INSIDE it \u2014 the handoff parser only recognises the standard headers (packages/adapter-md build-handoff.ts SECTION_HEADERS) and a handoff with no SCOPE section is rejected outright (task-3050, C357):
9225
9425
  - AUDIENCE: Who this design is for \u2014 persona and context of use (e.g. "non-technical Owner, first dashboard visit")
9226
9426
  - BRAND CONSTRAINTS: Palette, typography, tone \u2014 pull from \`.impeccable.md\` (dev patterns, anti-patterns, component rules) AND \`docs/branding/brand-book.html\` (brand identity, positioning, voice canon) if present. If neither exists, state "No brand doc \u2014 Owner should define constraints before starting."
9227
9427
  - DELIVERABLE FORMAT: What the output looks like \u2014 design handoff package / annotated mockup / style spec. Be specific so the person doing the work knows what "done" means.
@@ -9229,7 +9429,7 @@ var PLAN_FRAGMENT_DESIGN_BRIEF = `
9229
9429
  Keep SCOPE BOUNDARY, ACCEPTANCE CRITERIA, SECURITY CONSIDERATIONS, and PRE-BUILD VERIFICATION sections as normal.
9230
9430
  Add to ACCEPTANCE CRITERIA: "[ ] Deliverable format confirmed with Owner before starting" and "[ ] Design output is self-contained \u2014 includes enough context for a developer to implement without further clarification."`;
9231
9431
  var PLAN_FRAGMENT_RESEARCH_BRIEF = `
9232
- **Research brief task detection:** When a task's task type is "research-brief", generate a RESEARCH BRIEF handoff. Replace the standard SCOPE (DO THIS) section with:
9432
+ **Research brief task detection:** When a task's task type is "research-brief", generate a RESEARCH BRIEF handoff. Inside SCOPE (DO THIS), use these subsections. KEEP the "SCOPE (DO THIS)" header and put these subsections INSIDE it \u2014 the handoff parser only recognises the standard headers (packages/adapter-md build-handoff.ts SECTION_HEADERS) and a handoff with no SCOPE section is rejected outright (task-3050, C357):
9233
9433
  - GOAL: The specific question this research answers \u2014 one sentence, phrased as a question (e.g. "What onboarding patterns do our top 3 competitors use?")
9234
9434
  - TIME-BOX: Maximum effort allowed \u2014 XS or S. Stop when the time-box is hit and report what was found, even if incomplete.
9235
9435
  - OUTPUT: Where findings land \u2014 a doc at \`docs/research/[topic]-findings.md\` or inline in the build report. State the path.
@@ -9237,7 +9437,7 @@ var PLAN_FRAGMENT_RESEARCH_BRIEF = `
9237
9437
  Keep SCOPE BOUNDARY, ACCEPTANCE CRITERIA, SECURITY CONSIDERATIONS, and PRE-BUILD VERIFICATION as normal.
9238
9438
  Add to ACCEPTANCE CRITERIA: "[ ] Question answered OR time-box hit \u2014 whichever comes first" and "[ ] Findings doc saved before any follow-up tasks are submitted."`;
9239
9439
  var PLAN_FRAGMENT_MARKETING_BRIEF = `
9240
- **Marketing brief task detection:** When a task's task type is "marketing-brief", generate a MARKETING BRIEF handoff. Replace the standard SCOPE (DO THIS) section with:
9440
+ **Marketing brief task detection:** When a task's task type is "marketing-brief", generate a MARKETING BRIEF handoff. Inside SCOPE (DO THIS), use these subsections. KEEP the "SCOPE (DO THIS)" header and put these subsections INSIDE it \u2014 the handoff parser only recognises the standard headers (packages/adapter-md build-handoff.ts SECTION_HEADERS) and a handoff with no SCOPE section is rejected outright (task-3050, C357):
9241
9441
  - AUDIENCE: Who this marketing content targets \u2014 persona, awareness level, channel context (e.g. "cold Discord visitor, zero PAPI context")
9242
9442
  - CHANNEL: Where this content lives \u2014 Discord, landing page, email, social, etc.
9243
9443
  - MESSAGE FRAME: The core message to land \u2014 one sentence. What does the reader need to believe after seeing this? (e.g. "PAPI makes AI-assisted building systematic, not chaotic.")
@@ -9245,7 +9445,7 @@ var PLAN_FRAGMENT_MARKETING_BRIEF = `
9245
9445
  Keep SCOPE BOUNDARY, ACCEPTANCE CRITERIA, SECURITY CONSIDERATIONS, and PRE-BUILD VERIFICATION as normal.
9246
9446
  Add to ACCEPTANCE CRITERIA: "[ ] Message Frame confirmed with Owner before drafting" and "[ ] Final content reviewed by Owner before publishing."`;
9247
9447
  var PLAN_FRAGMENT_OPS_BRIEF = `
9248
- **Ops brief task detection:** When a task's task type is "ops-brief", generate an OPS BRIEF handoff. Replace the standard SCOPE (DO THIS) section with:
9448
+ **Ops brief task detection:** When a task's task type is "ops-brief", generate an OPS BRIEF handoff. Inside SCOPE (DO THIS), use these subsections. KEEP the "SCOPE (DO THIS)" header and put these subsections INSIDE it \u2014 the handoff parser only recognises the standard headers (packages/adapter-md build-handoff.ts SECTION_HEADERS) and a handoff with no SCOPE section is rejected outright (task-3050, C357):
9249
9449
  - SYSTEM: Which system or service this ops task touches \u2014 Vercel, Railway, Supabase, GitHub Actions, DNS, etc.
9250
9450
  - RISK: What could go wrong \u2014 data loss, downtime, broken deployments. Include estimated blast radius (e.g. "affects all authenticated users").
9251
9451
  - ROLLBACK PLAN: Exact steps to undo the change if something breaks. Must be specific enough to execute under pressure.
@@ -9738,6 +9938,17 @@ function coerceToString(value) {
9738
9938
  if (value === null || value === void 0) return "";
9739
9939
  return JSON.stringify(value, null, 2);
9740
9940
  }
9941
+ function coerceCarryForward(value) {
9942
+ if (value === null || value === void 0) return { value: null };
9943
+ if (typeof value === "string") {
9944
+ const trimmed = value.trim();
9945
+ return { value: trimmed.length > 0 ? trimmed : null };
9946
+ }
9947
+ const shape = Array.isArray(value) ? "array" : typeof value;
9948
+ 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.`;
9949
+ console.error(`[plan] ${warning}`);
9950
+ return { value: null, warning };
9951
+ }
9741
9952
  function coerceStructuredOutput(parsed) {
9742
9953
  const cycleHandoffs = Array.isArray(parsed.cycleHandoffs) ? parsed.cycleHandoffs.map((h) => {
9743
9954
  const { taskId: _t, buildHandoff: _b, ...rest } = h;
@@ -9777,10 +9988,12 @@ function coerceStructuredOutput(parsed) {
9777
9988
  body: coerceToString(ad.body)
9778
9989
  })) : [];
9779
9990
  const cycleTaskIds = Array.isArray(parsed.cycleTaskIds) ? parsed.cycleTaskIds.map((id) => coerceToString(id)) : void 0;
9991
+ const carryForward = coerceCarryForward(parsed.cycleLogCarryForward);
9780
9992
  return {
9781
9993
  cycleLogTitle: coerceToString(parsed.cycleLogTitle),
9782
9994
  cycleLogContent: coerceToString(parsed.cycleLogContent),
9783
- cycleLogCarryForward: parsed.cycleLogCarryForward === null ? null : coerceToString(parsed.cycleLogCarryForward),
9995
+ cycleLogCarryForward: carryForward.value,
9996
+ ...carryForward.warning ? { coercionWarnings: [carryForward.warning] } : {},
9784
9997
  cycleLogNotes: parsed.cycleLogNotes === null ? null : coerceToString(parsed.cycleLogNotes),
9785
9998
  nextMode: "Full",
9786
9999
  boardHealth: coerceToString(parsed.boardHealth),
@@ -10888,8 +11101,9 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false)
10888
11101
  }
10889
11102
  const invalidFields = validateHandoffScope(parsed);
10890
11103
  if (invalidFields.length > 0) {
11104
+ const scopeMissing = invalidFields.includes("scope");
10891
11105
  warnings.push(
10892
- `Rejected handoff for ${handoff.taskId}: missing or empty ${invalidFields.join(", ")}. Handoffs without explicit scope produce ambiguous builds.`
11106
+ `Rejected handoff for ${handoff.taskId}: missing or empty ${invalidFields.join(", ")}. Handoffs without explicit scope produce ambiguous builds.` + (scopeMissing ? ` If this is a bug/design-brief/research-brief/marketing-brief/ops-brief task, KEEP the "SCOPE (DO THIS)" header and nest the type-specific sections (REPRODUCE / ROOT CAUSE / MINIMAL FIX / \u2026) inside it \u2014 the parser only recognises the standard headers, so replacing SCOPE outright drops it entirely.` : "")
10893
11107
  );
10894
11108
  continue;
10895
11109
  }
@@ -12894,7 +13108,7 @@ async function assertSingleActiveCycle(adapter2, opts = {}) {
12894
13108
  }
12895
13109
  return notes;
12896
13110
  }
12897
- async function validateAndPrepare(adapter2, force, callerUserId) {
13111
+ async function validateAndPrepare(adapter2, force, callerUserId, adapterType) {
12898
13112
  let mode;
12899
13113
  let cycleNumber;
12900
13114
  let strategyReviewWarning = "";
@@ -12956,7 +13170,7 @@ Run \`strategy_review\` first, or pass \`force: true\` to bypass this gate.`
12956
13170
  if (err instanceof Error && (err.message.startsWith("Strategy Review") || err.message.startsWith("Cycle ") || err.message.startsWith("Stale reviews"))) {
12957
13171
  throw err;
12958
13172
  }
12959
- const isPg = process.env.PAPI_ADAPTER === "pg" || process.env.PAPI_ADAPTER === "proxy";
13173
+ const isPg = isDatabaseBackedAdapter(adapterType);
12960
13174
  throw new Error(
12961
13175
  isPg ? "Could not read cycle health from the database. Check your DATABASE_URL and verify the project exists." : "Could not read cycle health from PLANNING_LOG.md. Run setup first to initialise your PAPI project."
12962
13176
  );
@@ -13000,8 +13214,9 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
13000
13214
  contextHashes,
13001
13215
  { confirmCancellations: planRunMeta?.confirmCancellations === true, ownerUserId: applyScope.callerUserId ?? void 0 }
13002
13216
  );
13003
- if (wbWarnings.length > 0) {
13004
- writeBackWarnings = wbWarnings;
13217
+ const allWarnings = [...data.coercionWarnings ?? [], ...wbWarnings];
13218
+ if (allWarnings.length > 0) {
13219
+ writeBackWarnings = allWarnings;
13005
13220
  }
13006
13221
  if (skipped.length > 0) {
13007
13222
  skippedCancellations = skipped;
@@ -13074,7 +13289,7 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
13074
13289
  tracker?.mark("validate_and_prepare");
13075
13290
  let t = startTimer();
13076
13291
  const prepareScope = await resolvePlanScope(adapter2, config2);
13077
- const { mode, cycleNumber, strategyReviewWarning } = await validateAndPrepare(adapter2, force, prepareScope.callerUserId);
13292
+ const { mode, cycleNumber, strategyReviewWarning } = await validateAndPrepare(adapter2, force, prepareScope.callerUserId, config2.adapterType);
13078
13293
  const validateMs = t();
13079
13294
  const incomingCycle = cycleNumber + 1;
13080
13295
  tracker?.setStreamScope({ cycle: incomingCycle });
@@ -15683,6 +15898,65 @@ function extractDecisionEvidence(ad, eventType, warnings) {
15683
15898
  }
15684
15899
  return { evidenceRef, metricDelta };
15685
15900
  }
15901
+ function asDecisionBatchApplier(adapter2) {
15902
+ const candidate = adapter2;
15903
+ return typeof candidate.applyActiveDecisionUpdates === "function" ? candidate : void 0;
15904
+ }
15905
+ function routeDecisionUpdate(ad, adapter2, cycleNumber, warnings) {
15906
+ const action = ad.action;
15907
+ let route;
15908
+ if (action === "delete" && adapter2.deleteActiveDecision) {
15909
+ route = "delete";
15910
+ } else if (action === "new" && adapter2.upsertActiveDecision) {
15911
+ route = "upsert";
15912
+ } else {
15913
+ route = "update";
15914
+ }
15915
+ const titleMatch = ad.body.match(/^###\s+\S+:\s*([^\n[]+?)(?:\s*\[|$)/m);
15916
+ const confidenceMatch = ad.body.match(/\[Confidence:\s*(HIGH|MEDIUM|LOW)\]/i);
15917
+ const eventType = action === "delete" ? "invalidated" : action === "confidence_change" ? "confidence_changed" : action === "supersede" ? "superseded" : action === "new" ? "created" : "modified";
15918
+ const evidence = extractDecisionEvidence(ad, eventType, warnings);
15919
+ return {
15920
+ id: ad.id,
15921
+ body: ad.body,
15922
+ route,
15923
+ action,
15924
+ title: titleMatch ? titleMatch[1].trim() : ad.id,
15925
+ confidence: confidenceMatch ? confidenceMatch[1].toUpperCase() : "MEDIUM",
15926
+ event: {
15927
+ decisionId: ad.id,
15928
+ eventType,
15929
+ cycle: cycleNumber,
15930
+ source: "strategy_review",
15931
+ sourceRef: `cycle-${cycleNumber}-review`,
15932
+ detail: `Action: ${action}`,
15933
+ evidenceRef: evidence.evidenceRef,
15934
+ metricDelta: evidence.metricDelta
15935
+ }
15936
+ };
15937
+ }
15938
+ async function applyDecisionUpdates(adapter2, updates, cycleNumber, warnings) {
15939
+ if (updates.length === 0) return;
15940
+ const applies = updates.map((ad) => routeDecisionUpdate(ad, adapter2, cycleNumber, warnings));
15941
+ const batch = asDecisionBatchApplier(adapter2);
15942
+ if (batch) {
15943
+ await batch.applyActiveDecisionUpdates(applies, cycleNumber);
15944
+ return;
15945
+ }
15946
+ for (const apply of applies) {
15947
+ if (apply.route === "delete") {
15948
+ await adapter2.deleteActiveDecision(apply.id);
15949
+ } else if (apply.route === "upsert") {
15950
+ await adapter2.upsertActiveDecision(apply.id, apply.body, apply.title, apply.confidence, cycleNumber);
15951
+ } else {
15952
+ await adapter2.updateActiveDecision(apply.id, apply.body, cycleNumber, apply.action);
15953
+ }
15954
+ try {
15955
+ await adapter2.appendDecisionEvent(apply.event);
15956
+ } catch {
15957
+ }
15958
+ }
15959
+ }
15686
15960
  async function writeBack2(adapter2, cycleNumber, data, fullAnalysis, warnings) {
15687
15961
  const cleanTitle = data.sessionLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
15688
15962
  const cleanContent = data.sessionLogContent.replace(/^#{1,3}\s+(?:Cycle|Session)\s+\d+\s*—[^\n]*\n*/i, "").trim();
@@ -15724,34 +15998,7 @@ ${cleanContent}`;
15724
15998
  } catch {
15725
15999
  }
15726
16000
  if (data.activeDecisionUpdates && data.activeDecisionUpdates.length > 0) {
15727
- await Promise.all(data.activeDecisionUpdates.map(async (ad) => {
15728
- if (ad.action === "delete" && adapter2.deleteActiveDecision) {
15729
- await adapter2.deleteActiveDecision(ad.id);
15730
- } else if (ad.action === "new" && adapter2.upsertActiveDecision) {
15731
- const titleMatch = ad.body.match(/^###\s+\S+:\s*([^\n[]+?)(?:\s*\[|$)/m);
15732
- const title = titleMatch ? titleMatch[1].trim() : ad.id;
15733
- const confidenceMatch = ad.body.match(/\[Confidence:\s*(HIGH|MEDIUM|LOW)\]/i);
15734
- const confidence = confidenceMatch ? confidenceMatch[1].toUpperCase() : "MEDIUM";
15735
- await adapter2.upsertActiveDecision(ad.id, ad.body, title, confidence, cycleNumber);
15736
- } else {
15737
- await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, ad.action);
15738
- }
15739
- const eventType = ad.action === "delete" ? "invalidated" : ad.action === "confidence_change" ? "confidence_changed" : ad.action === "supersede" ? "superseded" : ad.action === "new" ? "created" : "modified";
15740
- const evidence = extractDecisionEvidence(ad, eventType, warnings);
15741
- try {
15742
- await adapter2.appendDecisionEvent({
15743
- decisionId: ad.id,
15744
- eventType,
15745
- cycle: cycleNumber,
15746
- source: "strategy_review",
15747
- sourceRef: `cycle-${cycleNumber}-review`,
15748
- detail: `Action: ${ad.action}`,
15749
- evidenceRef: evidence.evidenceRef,
15750
- metricDelta: evidence.metricDelta
15751
- });
15752
- } catch {
15753
- }
15754
- }));
16001
+ await applyDecisionUpdates(adapter2, data.activeDecisionUpdates, cycleNumber, warnings);
15755
16002
  }
15756
16003
  try {
15757
16004
  if (adapter2.confirmPendingActiveDecisions) {
@@ -16007,7 +16254,7 @@ async function prepareStrategyReview(adapter2, force, projectRoot, adapterType,
16007
16254
  };
16008
16255
  }
16009
16256
  } catch {
16010
- const isPg = process.env.PAPI_ADAPTER === "pg" || process.env.PAPI_ADAPTER === "proxy";
16257
+ const isPg = isDatabaseBackedAdapter(adapterType);
16011
16258
  throw new Error(
16012
16259
  isPg ? "Could not read cycle health from the database. Check your DATABASE_URL and verify the project exists." : "Could not read cycle health from PLANNING_LOG.md. Run setup first to initialise your PAPI project."
16013
16260
  );
@@ -16451,13 +16698,13 @@ ${cleanContent}`;
16451
16698
  ${evidenceWarnings.map((w) => `- ${w}`).join("\n")}` : displayText;
16452
16699
  return { cycleNumber, displayText: fullText, writeBackFailed };
16453
16700
  }
16454
- async function prepareStrategyChange(adapter2, text) {
16701
+ async function prepareStrategyChange(adapter2, text, adapterType) {
16455
16702
  let cycleNumber;
16456
16703
  try {
16457
16704
  const health = await adapter2.getCycleHealth();
16458
16705
  cycleNumber = health.totalCycles;
16459
16706
  } catch {
16460
- const isPg = process.env.PAPI_ADAPTER === "pg" || process.env.PAPI_ADAPTER === "proxy";
16707
+ const isPg = isDatabaseBackedAdapter(adapterType);
16461
16708
  throw new Error(
16462
16709
  isPg ? "Could not read cycle health from the database. Check your DATABASE_URL and verify the project exists." : "Could not read cycle health from PLANNING_LOG.md. Run setup first to initialise your PAPI project."
16463
16710
  );
@@ -16509,37 +16756,45 @@ async function prepareStrategyChange(adapter2, text) {
16509
16756
  async function applyStrategyChangeOutput(adapter2, rawLlmOutput, cycleNumber) {
16510
16757
  return processStrategyChangeOutput(adapter2, rawLlmOutput, cycleNumber);
16511
16758
  }
16759
+ function asDecisionIdAllocator(adapter2) {
16760
+ const candidate = adapter2;
16761
+ return typeof candidate.allocateActiveDecision === "function" ? candidate : void 0;
16762
+ }
16763
+ var AD_ID_PLACEHOLDER = "{{AD_ID}}";
16764
+ function toAdBodyTemplate(body) {
16765
+ return body.replace(/^(\s*#{1,6}\s+)AD-\d+\b/m, `$1${AD_ID_PLACEHOLDER}`);
16766
+ }
16512
16767
  async function captureDecision(adapter2, input) {
16513
16768
  const health = await adapter2.getCycleHealth();
16514
16769
  const cycleNumber = health.totalCycles;
16515
- let adId;
16516
- let adAction;
16517
- if (input.adId) {
16518
- adId = input.adId;
16519
- adAction = "updated";
16520
- } else {
16521
- const existingAds = await adapter2.getActiveDecisions({ includeRetired: true });
16522
- const maxNum = existingAds.reduce((max, ad) => {
16523
- const match = ad.id.match(/^AD-(\d+)$/);
16524
- return match ? Math.max(max, parseInt(match[1], 10)) : max;
16525
- }, 0);
16526
- adId = `AD-${maxNum + 1}`;
16527
- adAction = "created";
16770
+ const supersedesId = input.supersedes?.trim() || void 0;
16771
+ if (supersedesId && input.confidenceOnly) {
16772
+ throw new Error("supersedes cannot be combined with confidence_only \u2014 a confidence bump does not replace a decision.");
16773
+ }
16774
+ if (supersedesId) {
16775
+ const all = await adapter2.getActiveDecisions({ includeRetired: true });
16776
+ if (!all.some((d) => d.id === supersedesId)) {
16777
+ throw new Error(`supersedes: ${supersedesId} does not exist on this project. Check the AD id (e.g. "AD-42").`);
16778
+ }
16779
+ if (supersedesId === input.adId) {
16780
+ throw new Error("An AD cannot supersede itself.");
16781
+ }
16528
16782
  }
16529
16783
  if (input.confidenceOnly) {
16530
16784
  if (!input.adId) {
16531
16785
  throw new Error('confidence_only requires adId \u2014 provide the AD to update (e.g. "AD-12")');
16532
16786
  }
16787
+ const adId2 = input.adId;
16533
16788
  if (adapter2.upsertActiveDecision) {
16534
16789
  const existing = await adapter2.getActiveDecisions({ includeRetired: false });
16535
- const current = existing.find((d) => d.id === adId);
16790
+ const current = existing.find((d) => d.id === adId2);
16536
16791
  const preservedBody = current?.body ?? `- **Decision:** ${input.text}`;
16537
16792
  const preservedTitle = current?.title ?? input.text.slice(0, 80);
16538
- await adapter2.upsertActiveDecision(adId, preservedBody, preservedTitle, input.confidence, cycleNumber);
16793
+ await adapter2.upsertActiveDecision(adId2, preservedBody, preservedTitle, input.confidence, cycleNumber);
16539
16794
  }
16540
16795
  try {
16541
16796
  await adapter2.appendDecisionEvent({
16542
- decisionId: adId,
16797
+ decisionId: adId2,
16543
16798
  eventType: "modified",
16544
16799
  cycle: cycleNumber,
16545
16800
  source: "strategy_change",
@@ -16548,18 +16803,66 @@ async function captureDecision(adapter2, input) {
16548
16803
  });
16549
16804
  } catch {
16550
16805
  }
16551
- return { cycleNumber, adId, adAction: "updated" };
16806
+ return { cycleNumber, adId: adId2, adAction: "updated" };
16552
16807
  }
16553
16808
  const title = input.text.length > 80 ? input.text.slice(0, 77) + "..." : input.text;
16554
- const adBody = input.adBody ?? `### ${adId}: ${title} [Confidence: ${input.confidence}]
16809
+ const supersedesLine = supersedesId ? `
16810
+ - **Supersedes:** ${supersedesId}` : "";
16811
+ const bodyTemplate = input.adBody ? toAdBodyTemplate(input.adBody) : `### ${AD_ID_PLACEHOLDER}: ${title} [Confidence: ${input.confidence}]
16555
16812
 
16556
- - **Decision:** ${input.text}
16813
+ - **Decision:** ${input.text}${supersedesLine}
16557
16814
  - **Evidence:** Captured from conversation, Cycle ${cycleNumber}.
16558
16815
  - **Status:** Active`;
16559
- if (adapter2.upsertActiveDecision) {
16560
- await adapter2.upsertActiveDecision(adId, adBody, title, input.confidence, cycleNumber);
16816
+ let adId;
16817
+ let adAction;
16818
+ if (input.adId) {
16819
+ adId = input.adId;
16820
+ adAction = "updated";
16821
+ const adBody = bodyTemplate.split(AD_ID_PLACEHOLDER).join(adId);
16822
+ if (adapter2.upsertActiveDecision) {
16823
+ await adapter2.upsertActiveDecision(adId, adBody, title, input.confidence, cycleNumber);
16824
+ } else {
16825
+ await adapter2.updateActiveDecision(adId, adBody, cycleNumber);
16826
+ }
16561
16827
  } else {
16562
- await adapter2.updateActiveDecision(adId, adBody, cycleNumber);
16828
+ adAction = "created";
16829
+ const allocator = asDecisionIdAllocator(adapter2);
16830
+ if (allocator) {
16831
+ adId = await allocator.allocateActiveDecision(bodyTemplate, title, input.confidence, cycleNumber);
16832
+ } else {
16833
+ const existingAds = await adapter2.getActiveDecisions({ includeRetired: true });
16834
+ const maxNum = existingAds.reduce((max, ad) => {
16835
+ const match = ad.id.match(/^AD-(\d+)$/);
16836
+ return match ? Math.max(max, parseInt(match[1], 10)) : max;
16837
+ }, 0);
16838
+ adId = `AD-${maxNum + 1}`;
16839
+ const adBody = bodyTemplate.split(AD_ID_PLACEHOLDER).join(adId);
16840
+ if (adapter2.upsertActiveDecision) {
16841
+ await adapter2.upsertActiveDecision(adId, adBody, title, input.confidence, cycleNumber);
16842
+ } else {
16843
+ await adapter2.updateActiveDecision(adId, adBody, cycleNumber);
16844
+ }
16845
+ }
16846
+ }
16847
+ if (supersedesId) {
16848
+ const all = await adapter2.getActiveDecisions({ includeRetired: true });
16849
+ const prior = all.find((d) => d.id === supersedesId);
16850
+ const priorBody = prior?.body ?? "";
16851
+ const note = `
16852
+
16853
+ - **Superseded by:** ${adId} (Cycle ${cycleNumber}) \u2014 ${input.text}`;
16854
+ await adapter2.updateActiveDecision(supersedesId, `${priorBody}${note}`, cycleNumber, "supersede");
16855
+ try {
16856
+ await adapter2.appendDecisionEvent({
16857
+ decisionId: supersedesId,
16858
+ eventType: "superseded",
16859
+ cycle: cycleNumber,
16860
+ source: "strategy_change",
16861
+ sourceRef: `cycle-${cycleNumber}-capture`,
16862
+ detail: `Superseded by ${adId}: ${input.text.slice(0, 180)}`
16863
+ });
16864
+ } catch {
16865
+ }
16563
16866
  }
16564
16867
  try {
16565
16868
  await adapter2.appendDecisionEvent({
@@ -16580,11 +16883,13 @@ async function captureDecision(adapter2, input) {
16580
16883
  title: `Decision captured: ${adId}`,
16581
16884
  content: `**${adAction === "created" ? "New" : "Updated"} Active Decision** \u2014 ${adId}: ${input.text}
16582
16885
 
16583
- Confidence: ${input.confidence}. Captured mid-conversation via strategy_change capture mode (Cycle ${cycleNumber}).`
16886
+ Confidence: ${input.confidence}. Captured mid-conversation via strategy_change capture mode (Cycle ${cycleNumber}).` + (supersedesId ? `
16887
+
16888
+ Supersedes ${supersedesId} (retired, kept as history).` : "")
16584
16889
  });
16585
16890
  } catch {
16586
16891
  }
16587
- return { cycleNumber, adId, adAction };
16892
+ return { cycleNumber, adId, adAction, supersededId: supersedesId };
16588
16893
  }
16589
16894
 
16590
16895
  // src/tools/strategy.ts
@@ -16700,6 +17005,10 @@ var strategyChangeTool = {
16700
17005
  type: "boolean",
16701
17006
  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.`
16702
17007
  },
17008
+ supersedes: {
17009
+ type: "string",
17010
+ 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.'
17011
+ },
16703
17012
  north_star: {
16704
17013
  type: "string",
16705
17014
  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.'
@@ -16933,19 +17242,24 @@ orient and the project foundation will read this value.`
16933
17242
  const confidence = args.confidence ?? "MEDIUM";
16934
17243
  const adBody = args.ad_body;
16935
17244
  const confidenceOnly = args.confidence_only === true;
17245
+ const supersedes = args.supersedes?.trim();
16936
17246
  const result = await captureDecision(adapter2, {
16937
17247
  text: text2.trim(),
16938
17248
  adId: adId?.trim(),
16939
17249
  confidence,
16940
17250
  adBody: adBody?.trim(),
16941
- confidenceOnly
17251
+ confidenceOnly,
17252
+ supersedes
16942
17253
  });
16943
17254
  const captureLabel = confidenceOnly ? `Updated confidence on **${result.adId}** to ${confidence} (body preserved)` : `${result.adAction === "created" ? "Created" : "Updated"} **${result.adId}**: ${text2.trim()}
16944
17255
  Confidence: ${confidence}`;
17256
+ const supersedeLine = result.supersededId ? `
17257
+
17258
+ **${result.supersededId}** marked superseded by ${result.adId} \u2014 retained as history, not overwritten.` : "";
16945
17259
  return textResponse(
16946
17260
  `**Decision Captured \u2014 Cycle ${result.cycleNumber}**
16947
17261
 
16948
- ${captureLabel}
17262
+ ${captureLabel}${supersedeLine}
16949
17263
 
16950
17264
  Decision event logged.`
16951
17265
  );
@@ -16976,7 +17290,7 @@ Decision event logged.`
16976
17290
  return errorResponse("text is required for strategy_change. Describe the strategic shift to apply.");
16977
17291
  }
16978
17292
  {
16979
- const result = await prepareStrategyChange(adapter2, text);
17293
+ const result = await prepareStrategyChange(adapter2, text, _config.adapterType);
16980
17294
  return textResponse(
16981
17295
  `## PAPI Strategy Change \u2014 Prepare Phase (Cycle ${result.cycleNumber})
16982
17296
 
@@ -17056,8 +17370,8 @@ async function viewBoard(adapter2, phaseFilter, options) {
17056
17370
  const bi = PRIORITY_ORDER.indexOf(b2.priority);
17057
17371
  const priorityDiff = (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
17058
17372
  if (priorityDiff !== 0) return priorityDiff;
17059
- const aDate = a.createdAt ? String(a.createdAt) : "";
17060
- const bDate = b2.createdAt ? String(b2.createdAt) : "";
17373
+ const aDate = a.createdAt ?? "";
17374
+ const bDate = b2.createdAt ?? "";
17061
17375
  return bDate.localeCompare(aDate);
17062
17376
  });
17063
17377
  const total = filtered.length;
@@ -17411,6 +17725,10 @@ var boardEditTool = {
17411
17725
  actual_effort: {
17412
17726
  $ref: "#/$defs/effortSize",
17413
17727
  description: "task-2182: correct the actual effort on this task's LATEST build report (fixes a mis-recorded actual)."
17728
+ },
17729
+ project: {
17730
+ type: "string",
17731
+ 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."
17414
17732
  }
17415
17733
  },
17416
17734
  required: ["task_id"]
@@ -17664,6 +17982,14 @@ async function handleBoardEdit(adapter2, args) {
17664
17982
  if (!taskId) {
17665
17983
  return errorResponse("task_id is required.");
17666
17984
  }
17985
+ let target = adapter2;
17986
+ let overrideNote = "";
17987
+ try {
17988
+ ({ adapter: target, overrideNote } = await resolvePerCallProjectAdapter(adapter2, args));
17989
+ } catch (err) {
17990
+ if (err instanceof ProjectResolutionError) return errorResponse(err.message);
17991
+ throw err;
17992
+ }
17667
17993
  const updates = {};
17668
17994
  const changes = [];
17669
17995
  for (const field of EDITABLE_FIELDS) {
@@ -17691,7 +18017,7 @@ async function handleBoardEdit(adapter2, args) {
17691
18017
  updates.cycle = null;
17692
18018
  changes.push("cycle");
17693
18019
  } else if (typeof rawCycle === "number" && Number.isInteger(rawCycle) && rawCycle > 0) {
17694
- const health = await adapter2.getCycleHealth().catch(() => null);
18020
+ const health = await target.getCycleHealth().catch(() => null);
17695
18021
  const activeCycle = health?.totalCycles ?? 0;
17696
18022
  if (rawCycle > activeCycle + 1) {
17697
18023
  return errorResponse(
@@ -17710,7 +18036,7 @@ async function handleBoardEdit(adapter2, args) {
17710
18036
  return errorResponse("No fields to update. Pass at least one field (title, priority, complexity, module, epic, phase, notes, status, maturity, cycle).");
17711
18037
  }
17712
18038
  try {
17713
- const task = await adapter2.getTask(taskId);
18039
+ const task = await target.getTask(taskId);
17714
18040
  if (!task) {
17715
18041
  return errorResponse(`Task ${taskId} not found.`);
17716
18042
  }
@@ -17729,7 +18055,7 @@ async function handleBoardEdit(adapter2, args) {
17729
18055
  const idx = changes.indexOf("notes");
17730
18056
  if (idx >= 0) changes.splice(idx, 1);
17731
18057
  } else {
17732
- const health = await adapter2.getCycleHealth().catch(() => null);
18058
+ const health = await target.getCycleHealth().catch(() => null);
17733
18059
  const activeCycle = health?.totalCycles ?? null;
17734
18060
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
17735
18061
  const stamp = activeCycle != null ? `[C${activeCycle} ${date}]` : `[${date}]`;
@@ -17754,7 +18080,7 @@ ${existing}` : entry;
17754
18080
  }
17755
18081
  let autoAssignedCycle = null;
17756
18082
  if (updates.status === "In Cycle") {
17757
- const health = await adapter2.getCycleHealth().catch(() => null);
18083
+ const health = await target.getCycleHealth().catch(() => null);
17758
18084
  const activeCycle = health?.totalCycles ?? null;
17759
18085
  if (activeCycle != null && activeCycle > 0) {
17760
18086
  updates.cycle = activeCycle;
@@ -17766,25 +18092,25 @@ ${existing}` : entry;
17766
18092
  updates.cancelledBy = "user";
17767
18093
  }
17768
18094
  if (effortCorrection.estimatedEffort || effortCorrection.actualEffort) {
17769
- if (!adapter2.correctLatestBuildReportEffort) {
18095
+ if (!target.correctLatestBuildReportEffort) {
17770
18096
  return errorResponse("Correcting build-report effort requires a database adapter (pg). The md adapter does not support it.");
17771
18097
  }
17772
- await adapter2.correctLatestBuildReportEffort(taskId, effortCorrection);
18098
+ await target.correctLatestBuildReportEffort(taskId, effortCorrection);
17773
18099
  }
17774
18100
  if (Object.keys(updates).length > 0) {
17775
- await adapter2.updateTask(taskId, updates);
18101
+ await target.updateTask(taskId, updates);
17776
18102
  }
17777
- if ((updates.status === "Done" || updates.status === "Cancelled") && adapter2.updateDogfoodEntryStatus) {
18103
+ if ((updates.status === "Done" || updates.status === "Cancelled") && target.updateDogfoodEntryStatus) {
17778
18104
  try {
17779
- const dogfoodLog = await adapter2.getDogfoodLog?.(50) ?? [];
18105
+ const dogfoodLog = await target.getDogfoodLog?.(50) ?? [];
17780
18106
  const linked = dogfoodLog.filter((e) => e.linkedTaskId === taskId || e.linkedTaskId === task.id);
17781
18107
  const newStatus = "resolved";
17782
- await Promise.all(linked.map((e) => adapter2.updateDogfoodEntryStatus(e.id, newStatus)));
18108
+ await Promise.all(linked.map((e) => target.updateDogfoodEntryStatus(e.id, newStatus)));
17783
18109
  } catch {
17784
18110
  }
17785
18111
  }
17786
18112
  const lines = [
17787
- `Updated **${taskId}** (${updates.title ?? task.title})`,
18113
+ `Updated **${taskId}**${overrideNote} (${updates.title ?? task.title})`,
17788
18114
  "",
17789
18115
  `**Changes:** ${changes.map((f) => `${f} \u2192 ${String(updates[f])}`).join(", ")}`
17790
18116
  ];
@@ -18588,7 +18914,7 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18588
18914
  if (useCollector) {
18589
18915
  collector.add({
18590
18916
  path: ".claude/settings.json",
18591
- content: JSON.stringify({ permissions: { allow: [PAPI_PERMISSION] } }, null, 2) + "\n",
18917
+ content: JSON.stringify({ permissions: { allow: [...PAPI_PERMISSIONS] } }, null, 2) + "\n",
18592
18918
  mode: "create",
18593
18919
  skip_if_exists: true
18594
18920
  });
@@ -18597,7 +18923,7 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18597
18923
  }
18598
18924
  return true;
18599
18925
  }
18600
- var PAPI_PERMISSION = "mcp__papi__*";
18926
+ var PAPI_PERMISSIONS = ["mcp__papi__*", "mcp__plugin_papi_papi__*"];
18601
18927
  async function ensurePapiPermission(projectRoot) {
18602
18928
  const settingsPath = join9(projectRoot, ".claude", "settings.json");
18603
18929
  try {
@@ -18615,8 +18941,10 @@ async function ensurePapiPermission(projectRoot) {
18615
18941
  perms.allow = [];
18616
18942
  }
18617
18943
  const allow = perms.allow;
18618
- if (!allow.includes(PAPI_PERMISSION)) {
18619
- allow.push(PAPI_PERMISSION);
18944
+ for (const permission of PAPI_PERMISSIONS) {
18945
+ if (!allow.includes(permission)) {
18946
+ allow.push(permission);
18947
+ }
18620
18948
  }
18621
18949
  await mkdir(join9(projectRoot, ".claude"), { recursive: true });
18622
18950
  await writeFile2(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
@@ -21653,7 +21981,7 @@ function pathBasename(p) {
21653
21981
  const parts = p.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
21654
21982
  return parts[parts.length - 1] ?? p;
21655
21983
  }
21656
- function isPathInPredictedScope(changedPath, predicted) {
21984
+ function matchedPredictedEntry(changedPath, predicted) {
21657
21985
  const changed = changedPath.replace(/\\/g, "/");
21658
21986
  const changedLower = changed.toLowerCase();
21659
21987
  const changedBase = pathBasename(changedPath);
@@ -21661,17 +21989,20 @@ function isPathInPredictedScope(changedPath, predicted) {
21661
21989
  const entry = raw.replace(/\\/g, "/").replace(/\/+$/, "").trim();
21662
21990
  if (!entry) continue;
21663
21991
  if (entry.includes("*")) {
21664
- if (globToRegExp(entry).test(changed)) return true;
21665
- if (!entry.includes("/") && globToRegExp(entry).test(changedBase)) return true;
21992
+ if (globToRegExp(entry).test(changed)) return entry;
21993
+ if (!entry.includes("/") && globToRegExp(entry).test(changedBase)) return entry;
21666
21994
  continue;
21667
21995
  }
21668
- if (pathBasename(entry) === changedBase) return true;
21996
+ if (pathBasename(entry) === changedBase) return entry;
21669
21997
  const entryLower = entry.toLowerCase();
21670
21998
  if (changedLower === entryLower || changedLower.startsWith(`${entryLower}/`)) {
21671
- return true;
21999
+ return entry;
21672
22000
  }
21673
22001
  }
21674
- return false;
22002
+ return null;
22003
+ }
22004
+ function isPathInPredictedScope(changedPath, predicted) {
22005
+ return matchedPredictedEntry(changedPath, predicted) !== null;
21675
22006
  }
21676
22007
  function autoCommit(config2, taskId, taskTitle, predictedFiles) {
21677
22008
  const cwd = config2.projectRoot;
@@ -21697,6 +22028,17 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
21697
22028
  if (staged.length > 0) {
21698
22029
  return safeRun(() => commitStagedOnly(cwd, message)) + ` (selective staging respected: ${staged.length} file(s)).`;
21699
22030
  }
22031
+ const checkpoint = readBuildCheckpointIfLocal({ cwd, taskId });
22032
+ const headSha = getHeadCommitSha(cwd);
22033
+ if (checkpoint?.lastCommitSha && headSha && checkpoint.lastCommitSha !== headSha) {
22034
+ const leftover = getModifiedFiles(cwd);
22035
+ if (leftover.length === 0) {
22036
+ return "Auto-commit: skipped (builder already committed; working tree clean).";
22037
+ }
22038
+ const sample = leftover.slice(0, 10).join(", ");
22039
+ const more = leftover.length > 10 ? ` (+${leftover.length - 10} more)` : "";
22040
+ 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.`;
22041
+ }
21700
22042
  const modified = getModifiedFiles(cwd);
21701
22043
  if (modified.length === 0) {
21702
22044
  return "Auto-commit: skipped (no working-tree changes).";
@@ -21710,7 +22052,9 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
21710
22052
  const more = outOfScope.length > 10 ? ` (+${outOfScope.length - 10} more)` : "";
21711
22053
  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}.`;
21712
22054
  }
21713
- return `${commitResult} (staged all ${modified.length} changed file(s), all within FILES LIKELY TOUCHED).`;
22055
+ const matches = modified.slice(0, 5).map((p) => `${p} \u2190 ${matchedPredictedEntry(p, cleanedPredicted) ?? "?"}`).join(", ");
22056
+ const extra = modified.length > 5 ? ` (+${modified.length - 5} more)` : "";
22057
+ return `${commitResult} (staged all ${modified.length} changed file(s), each matched to FILES LIKELY TOUCHED: ${matches}${extra}).`;
21714
22058
  }
21715
22059
  return `${commitResult} (staged all ${modified.length} changed file(s)).`;
21716
22060
  }
@@ -22754,9 +23098,19 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22754
23098
  return files;
22755
23099
  };
22756
23100
  const mdFiles = scanDir(docsDir);
23101
+ let branchDocs;
23102
+ try {
23103
+ branchDocs = new Set(
23104
+ getFilesChangedFromBase(config2.projectRoot, "origin/main").filter((f) => f.startsWith("docs/") && f.endsWith(".md"))
23105
+ );
23106
+ } catch {
23107
+ branchDocs = /* @__PURE__ */ new Set();
23108
+ }
22757
23109
  const registered = await adapter2.searchDocs({ status: "all", limit: 500 });
22758
23110
  const registeredPaths = new Set(registered.map((d) => d.path));
22759
- const unregistered = mdFiles.filter((f) => !registeredPaths.has(f));
23111
+ const unregistered = mdFiles.filter(
23112
+ (f) => !registeredPaths.has(f) && branchDocs.has(f)
23113
+ );
22760
23114
  if (unregistered.length > 0 && adapter2.registerDoc) {
22761
23115
  const autoRegistered = [];
22762
23116
  const failed = [];
@@ -22940,7 +23294,7 @@ import { docDeletionBlockMessage } from "@papi-ai/shared";
22940
23294
  init_git();
22941
23295
 
22942
23296
  // src/services/entitlements.ts
22943
- import { evaluateContributorGate } from "@papi-ai/shared";
23297
+ import { evaluateContributorGate, isSelfHostedDeployment } from "@papi-ai/shared";
22944
23298
  var FREE_PROJECT_CAP = 3;
22945
23299
  var DOC_STORAGE_CEILING_BY_TIER = {
22946
23300
  free: { bytes: 25 * 1024 * 1024, docs: 200 },
@@ -22963,6 +23317,7 @@ function isPaidTier(tier) {
22963
23317
  return tier !== null && PAID_TIERS.has(tier);
22964
23318
  }
22965
23319
  async function enforceProjectCap(adapter2, target) {
23320
+ if (isSelfHostedDeployment(process.env.PAPI_SELF_HOST)) return null;
22966
23321
  const tier = await resolveTier(adapter2);
22967
23322
  if (tier === null || isPaidTier(tier)) return null;
22968
23323
  if (typeof adapter2.listUserProjects !== "function") return null;
@@ -23073,7 +23428,11 @@ var docRegisterTool = {
23073
23428
  },
23074
23429
  description: "Actionable findings from the document."
23075
23430
  },
23076
- superseded_by_path: { type: "string", description: "Path of the doc that supersedes this one (sets status to superseded)." }
23431
+ superseded_by_path: { type: "string", description: "Path of the doc that supersedes this one (sets status to superseded)." },
23432
+ project: {
23433
+ type: "string",
23434
+ 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."
23435
+ }
23077
23436
  },
23078
23437
  required: ["path", "title", "type", "summary", "cycle"]
23079
23438
  }
@@ -23230,6 +23589,22 @@ async function handleDocRegister(adapter2, args, config2) {
23230
23589
  continueHint
23231
23590
  );
23232
23591
  }
23592
+ let target = adapter2;
23593
+ let overrideNote = "";
23594
+ try {
23595
+ ({ adapter: target, overrideNote } = await resolvePerCallProjectAdapter(adapter2, args));
23596
+ } catch (err) {
23597
+ if (err instanceof ProjectResolutionError) return errorResponse(err.message);
23598
+ throw err;
23599
+ }
23600
+ if (!target.registerDoc) {
23601
+ return docRegisterSoftFail(
23602
+ adapterType,
23603
+ "adapter-capability-check",
23604
+ "Doc registry not available on the resolved project adapter \u2014 requires the pg/proxy adapter.",
23605
+ "Continue without it \u2014 nothing is blocked."
23606
+ );
23607
+ }
23233
23608
  if (!path7.toLowerCase().endsWith(".md")) {
23234
23609
  return docRegisterSoftFail(
23235
23610
  adapterType,
@@ -23241,13 +23616,13 @@ async function handleDocRegister(adapter2, args, config2) {
23241
23616
  try {
23242
23617
  let supersededBy;
23243
23618
  if (supersededByPath) {
23244
- const existing = await adapter2.getDoc?.(supersededByPath);
23619
+ const existing = await target.getDoc?.(supersededByPath);
23245
23620
  if (existing) {
23246
23621
  supersededBy = existing.id;
23247
- await adapter2.updateDocStatus?.(existing.id, "superseded", void 0);
23622
+ await target.updateDocStatus?.(existing.id, "superseded", void 0);
23248
23623
  }
23249
23624
  }
23250
- const entry = await adapter2.registerDoc({
23625
+ const entry = await target.registerDoc({
23251
23626
  title,
23252
23627
  type,
23253
23628
  path: path7,
@@ -23273,16 +23648,16 @@ async function handleDocRegister(adapter2, args, config2) {
23273
23648
  }
23274
23649
  if (body === void 0) {
23275
23650
  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._";
23276
- } else if (typeof adapter2.storeDocBody !== "function") {
23651
+ } else if (typeof target.storeDocBody !== "function") {
23277
23652
  bodyNote = "\n\n_Body not stored \u2014 this adapter does not support body storage._";
23278
23653
  } else {
23279
- const decision = await checkDocStorageCap(adapter2, Buffer.byteLength(body, "utf8"));
23654
+ const decision = await checkDocStorageCap(target, Buffer.byteLength(body, "utf8"));
23280
23655
  if (!decision.storeBody) {
23281
23656
  bodyNote = `
23282
23657
 
23283
23658
  ${decision.message}`;
23284
23659
  } else {
23285
- const result = await adapter2.storeDocBody({
23660
+ const result = await target.storeDocBody({
23286
23661
  docId: entry.id,
23287
23662
  body,
23288
23663
  // Resolved by resolveDocVisibility above — the SAME resolution the
@@ -23306,7 +23681,7 @@ ${decision.message}`;
23306
23681
  durability = "";
23307
23682
  }
23308
23683
  return textResponse(
23309
- `**Registered:** ${entry.title}
23684
+ `**Registered:** ${entry.title}${overrideNote}
23310
23685
  - **Path:** ${entry.path}
23311
23686
  - **Type:** ${entry.type} | **Status:** ${entry.status}
23312
23687
  - **Visibility:** ${visibilityLabel}
@@ -23870,7 +24245,7 @@ var buildExecuteTool = {
23870
24245
  },
23871
24246
  fixed_issues: {
23872
24247
  type: "array",
23873
- 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.`,
24248
+ 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.`,
23874
24249
  items: { type: "string" }
23875
24250
  },
23876
24251
  production_verification: {
@@ -24228,7 +24603,7 @@ ${entries}`;
24228
24603
 
24229
24604
  **OPEN DISCOVERED ISSUES** (${top.length} shown):
24230
24605
  ${rows}
24231
- 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.`;
24606
+ 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.`;
24232
24607
  }
24233
24608
  }
24234
24609
  } catch {
@@ -24475,8 +24850,25 @@ Your report was NOT discarded and the task is NOT yet Done \u2014 re-send with \
24475
24850
  fixedNote = `
24476
24851
 
24477
24852
  \u2705 Marked ${fixedResolvedCount} discovered issue(s) FIXED \u2014 resolved_at stamped, now counted as fixed on the hub's caught\u2192fixed ledger.`;
24478
- } else if (discoveredIssues && discoveredIssues.trim() !== "" && !/^none\b/i.test(discoveredIssues.trim())) {
24479
- 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.";
24853
+ } else {
24854
+ let candidates = [];
24855
+ try {
24856
+ if (adapter2.getCycleLearnings) {
24857
+ const open = (await adapter2.getCycleLearnings({ category: "issue", limit: 20 })).filter((l) => !l.resolvedAt && l.id);
24858
+ const moduleTag = result.task?.module?.trim().toLowerCase();
24859
+ 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)}`);
24860
+ }
24861
+ } catch {
24862
+ }
24863
+ if (candidates.length > 0) {
24864
+ fixedNote = `
24865
+
24866
+ \u2139\uFE0F No \`fixed_issues\` passed. Open issues in this module that this build could have closed:
24867
+ ${candidates.join("\n")}
24868
+ 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.`;
24869
+ } else if (discoveredIssues && discoveredIssues.trim() !== "" && !/^none\b/i.test(discoveredIssues.trim())) {
24870
+ 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.";
24871
+ }
24480
24872
  }
24481
24873
  return textResponse(formatCompleteResult(result) + fixedNote + docsNote + batchRollupNote);
24482
24874
  } catch (err) {
@@ -25506,6 +25898,13 @@ init_git();
25506
25898
 
25507
25899
  // src/services/ad-hoc.ts
25508
25900
  import { randomUUID as randomUUID15 } from "crypto";
25901
+ function resolveAdHocBranch(input) {
25902
+ if (input.held) return `feat/${input.taskId}`;
25903
+ const current = input.currentBranch?.trim();
25904
+ if (!current) return void 0;
25905
+ if (input.baseBranch && current === input.baseBranch.trim()) return void 0;
25906
+ return current;
25907
+ }
25509
25908
  function resolveAdHocCycle(cycle, latest, latestComplete) {
25510
25909
  if (cycle === void 0) return null;
25511
25910
  if (typeof cycle === "number") return cycle;
@@ -25560,8 +25959,32 @@ async function recordAdHoc(adapter2, input) {
25560
25959
  ...targetCycle !== null ? { cycle: targetCycle } : {},
25561
25960
  notes: input.notes ? `[ad-hoc] ${input.notes}` : "[ad-hoc]",
25562
25961
  taskType: input.taskType || "task",
25563
- source: "ad_hoc"
25962
+ source: "ad_hoc",
25963
+ // task-2597: record the branch at creation for unheld work — the branch is
25964
+ // already known. Held work needs the allocated display id first (below).
25965
+ ...held ? {} : (() => {
25966
+ const branch = resolveAdHocBranch({
25967
+ held: false,
25968
+ taskId: "",
25969
+ currentBranch: input.currentBranch,
25970
+ baseBranch: input.baseBranch
25971
+ });
25972
+ return branch ? { branchName: branch } : {};
25973
+ })()
25564
25974
  });
25975
+ if (held) {
25976
+ const branch = resolveAdHocBranch({ held: true, taskId: task.id });
25977
+ if (branch) {
25978
+ try {
25979
+ await adapter2.updateTask(task.id, { branchName: branch });
25980
+ task = { ...task, branchName: branch };
25981
+ } catch (err) {
25982
+ console.error(
25983
+ `[ad-hoc] branch_name persist skipped for ${task.id} (non-fatal): ` + (err instanceof Error ? err.message : String(err))
25984
+ );
25985
+ }
25986
+ }
25987
+ }
25565
25988
  }
25566
25989
  const report = {
25567
25990
  uuid: randomUUID15(),
@@ -25651,6 +26074,10 @@ var adHocTool = {
25651
26074
  hold: {
25652
26075
  type: "boolean",
25653
26076
  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`."
26077
+ },
26078
+ project: {
26079
+ type: "string",
26080
+ 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."
25654
26081
  }
25655
26082
  },
25656
26083
  required: []
@@ -25687,7 +26114,18 @@ async function handleAdHoc(adapter2, config2, args) {
25687
26114
  else if (rawCycle === "current" || rawCycle === "next-if-plan-not-run") cycleArg = rawCycle;
25688
26115
  const stageArg = args.stage === "release" ? "release" : void 0;
25689
26116
  const holdArg = args.hold === true;
25690
- const result = await recordAdHoc(adapter2, {
26117
+ let target = adapter2;
26118
+ let overrideNote = "";
26119
+ try {
26120
+ ({ adapter: target, overrideNote } = await resolvePerCallProjectAdapter(adapter2, args));
26121
+ } catch (err) {
26122
+ if (err instanceof ProjectResolutionError) return errorResponse(err.message);
26123
+ throw err;
26124
+ }
26125
+ const gitUsable = !overrideNote && isGitAvailable() && isGitRepo(config2.projectRoot);
26126
+ const currentBranch = gitUsable ? getCurrentBranch(config2.projectRoot) : null;
26127
+ const baseBranch = gitUsable ? resolveBaseBranch(config2.projectRoot, config2.baseBranch) : null;
26128
+ const result = await recordAdHoc(target, {
25691
26129
  title: title || "",
25692
26130
  taskId,
25693
26131
  notes: rawNotes,
@@ -25700,9 +26138,11 @@ async function handleAdHoc(adapter2, config2, args) {
25700
26138
  owner: config2.projectOwner,
25701
26139
  cycle: cycleArg,
25702
26140
  stage: stageArg,
25703
- hold: holdArg
26141
+ hold: holdArg,
26142
+ currentBranch,
26143
+ baseBranch
25704
26144
  });
25705
- if (!holdArg && isGitAvailable() && isGitRepo(config2.projectRoot)) {
26145
+ if (!holdArg && gitUsable) {
25706
26146
  try {
25707
26147
  stageDirAndCommit(
25708
26148
  config2.projectRoot,
@@ -25720,7 +26160,7 @@ async function handleAdHoc(adapter2, config2, args) {
25720
26160
  const branch = `feat/${result.task.id}`;
25721
26161
  let collisionBlock = "";
25722
26162
  try {
25723
- const board = await adapter2.queryBoard({ status: ["In Progress"] });
26163
+ const board = await target.queryBoard({ status: ["In Progress"] });
25724
26164
  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);
25725
26165
  const collision = detectWorktreeCollision({
25726
26166
  taskId: result.task.id,
@@ -25744,7 +26184,7 @@ async function handleAdHoc(adapter2, config2, args) {
25744
26184
  } catch {
25745
26185
  }
25746
26186
  return textResponse(
25747
- `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning}${promoNote} Build report attached.
26187
+ `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
25748
26188
 
25749
26189
  ## Held for the next cycle \u2014 branch + commit, do NOT merge
25750
26190
  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.
@@ -25762,7 +26202,7 @@ _To correct: board_edit ${result.task.id} with updated fields._`
25762
26202
  );
25763
26203
  }
25764
26204
  return textResponse(
25765
- `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning}${promoNote} Build report attached.
26205
+ `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
25766
26206
  _To correct: board_edit ${result.task.id} with updated fields._`
25767
26207
  );
25768
26208
  }
@@ -29032,18 +29472,7 @@ async function getHierarchyPosition(adapter2, projectId) {
29032
29472
  return void 0;
29033
29473
  }
29034
29474
  }
29035
- async function getLatestGitTag(projectRoot) {
29036
- try {
29037
- const { stdout } = await execFileAsync2("git", ["describe", "--tags", "--abbrev=0"], {
29038
- encoding: "utf-8",
29039
- cwd: projectRoot,
29040
- timeout: 2e3
29041
- });
29042
- return stdout.trim() || null;
29043
- } catch {
29044
- return null;
29045
- }
29046
- }
29475
+ var GIT_TAG_TIMEOUT_MS = 2e3;
29047
29476
  async function checkNpmVersionDrift() {
29048
29477
  try {
29049
29478
  const pkgPath = join17(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
@@ -29389,7 +29818,7 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
29389
29818
  // Latest git tag + npm version drift (both exec calls with own timeouts).
29390
29819
  // task-2172: git-tag stays (the latest tag is part of the core summary);
29391
29820
  // version-drift is enrichment, gated behind `full`/deep_housekeeping.
29392
- tracked("git-tag", () => getLatestGitTag(config2.projectRoot)),
29821
+ tracked("git-tag", async () => getLatestTag(config2.projectRoot, GIT_TAG_TIMEOUT_MS)),
29393
29822
  tracked("npm-version-drift", async () => fullEnrichment ? checkNpmVersionDrift() : void 0),
29394
29823
  // Research Signals — research docs with pending actions since last strategy review.
29395
29824
  // task-2172: heavy (doc search + AD cross-reference) and rarely actioned
@@ -31948,6 +32377,7 @@ If this is legitimate, reach out at https://getpapi.ai and we'll lift it \u2014
31948
32377
  }
31949
32378
 
31950
32379
  // src/server.ts
32380
+ var mdModeWarned = false;
31951
32381
  var DEFAULT_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_TOOL_TIMEOUT_MS ?? "30000", 10);
31952
32382
  var LONG_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_LONG_TOOL_TIMEOUT_MS ?? "180000", 10);
31953
32383
  var WEDGE_PENDING_FRACTION = Math.min(1, Math.max(0, parseFloat(process.env.PAPI_WEDGE_PENDING_FRACTION ?? "0.6")));
@@ -32124,7 +32554,8 @@ function createServer(adapter2, config2) {
32124
32554
  // task-1801: `resources` capability for the PAPI read surface exposed as MCP resources.
32125
32555
  { capabilities: { tools: {}, prompts: {}, resources: {} }, instructions: UNIVERSAL_FRAME }
32126
32556
  );
32127
- if (config2.adapterType === "md") {
32557
+ if (config2.adapterType === "md" && !mdModeWarned) {
32558
+ mdModeWarned = true;
32128
32559
  process.stderr.write(
32129
32560
  "\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"
32130
32561
  );
@@ -32451,6 +32882,7 @@ ${usageLine(decision.usage)}`;
32451
32882
  // src/transport-http.ts
32452
32883
  init_proxy_adapter();
32453
32884
  import { createServer as createHttpServer } from "http";
32885
+ import { createHash as createHash7 } from "crypto";
32454
32886
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
32455
32887
  var BEARER_PREFIX = "papi_";
32456
32888
  var BEARER_REGEX = /^(papi_|papi_oauth_)[a-f0-9]{64}$/;
@@ -32480,6 +32912,7 @@ var FRIENDLY_GET_HTML = `<!doctype html>
32480
32912
  <code>https://mcp.getpapi.ai/mcp</code>.</p>
32481
32913
  <a class="btn" href="https://getpapi.ai/docs/install">See the install guide \u2192</a>
32482
32914
  </div></body></html>`;
32915
+ var KNOWN_INSTALL_CLIENTS = /* @__PURE__ */ new Set(["claude-code-plugin"]);
32483
32916
  var MAX_BODY_BYTES = 1 * 1024 * 1024;
32484
32917
  var IP_RATE_WINDOW_MS = 6e4;
32485
32918
  var IP_RATE_MAX = 60;
@@ -32518,7 +32951,7 @@ function corsHeaders(origin) {
32518
32951
  return {
32519
32952
  "Access-Control-Allow-Origin": origin,
32520
32953
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
32521
- "Access-Control-Allow-Headers": "Authorization, Content-Type, x-papi-project-id",
32954
+ "Access-Control-Allow-Headers": "Authorization, Content-Type, x-papi-project-id, x-papi-client",
32522
32955
  "Vary": "Origin"
32523
32956
  };
32524
32957
  }
@@ -32560,6 +32993,48 @@ function sendError(res, err, extraHeaders = {}) {
32560
32993
  });
32561
32994
  res.end(JSON.stringify(err.body));
32562
32995
  }
32996
+ function sendUnauthorized(res, reason, extraHeaders = {}) {
32997
+ 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}"`;
32998
+ sendError(
32999
+ res,
33000
+ { status: 401, body: { error: "Unauthorized", reason } },
33001
+ { ...extraHeaders, "WWW-Authenticate": challenge }
33002
+ );
33003
+ }
33004
+ var AUTH_VALID_TTL_MS = 5 * 6e4;
33005
+ var AUTH_REJECTED_TTL_MS = 6e4;
33006
+ var AUTH_CACHE_MAX = 5e3;
33007
+ var authVerdicts = /* @__PURE__ */ new Map();
33008
+ function bearerKey(bearer) {
33009
+ return createHash7("sha256").update(bearer).digest("hex");
33010
+ }
33011
+ function readAuthVerdict(bearer, now = Date.now()) {
33012
+ const hit = authVerdicts.get(bearerKey(bearer));
33013
+ if (!hit) return void 0;
33014
+ if (hit.expires <= now) {
33015
+ authVerdicts.delete(bearerKey(bearer));
33016
+ return void 0;
33017
+ }
33018
+ return hit.verdict;
33019
+ }
33020
+ function recordAuthVerdict(bearer, verdict, now = Date.now()) {
33021
+ if (authVerdicts.size >= AUTH_CACHE_MAX) {
33022
+ for (const [k, v] of authVerdicts) {
33023
+ if (v.expires <= now) authVerdicts.delete(k);
33024
+ }
33025
+ if (authVerdicts.size >= AUTH_CACHE_MAX) {
33026
+ const oldest = authVerdicts.keys().next();
33027
+ if (!oldest.done) authVerdicts.delete(oldest.value);
33028
+ }
33029
+ }
33030
+ const ttl = verdict === "valid" ? AUTH_VALID_TTL_MS : AUTH_REJECTED_TTL_MS;
33031
+ authVerdicts.set(bearerKey(bearer), { verdict, expires: now + ttl });
33032
+ }
33033
+ function classifyAuthProbeStatus(status) {
33034
+ if (status === 401) return "rejected";
33035
+ if (status >= 200 && status < 300) return "valid";
33036
+ return void 0;
33037
+ }
32563
33038
  function startHttpTransport(opts) {
32564
33039
  const { port, host, baseConfig, pkgVersion: pkgVersion2, dataEndpoint } = opts;
32565
33040
  const httpServer = createHttpServer((req, res) => {
@@ -32661,21 +33136,11 @@ function startHttpTransport(opts) {
32661
33136
  ip,
32662
33137
  status: 401
32663
33138
  });
32664
- sendError(
32665
- res,
32666
- {
32667
- status: 401,
32668
- body: {
32669
- error: "Unauthorized",
32670
- reason: hasHeader ? "malformed_bearer" : "missing_bearer"
32671
- }
32672
- },
32673
- {
32674
- "WWW-Authenticate": `Bearer realm="papi", resource_metadata="${RESOURCE_METADATA_URL}"`
32675
- }
32676
- );
33139
+ sendUnauthorized(res, hasHeader ? "malformed_bearer" : "missing_bearer");
32677
33140
  return;
32678
33141
  }
33142
+ const clientHeader = req.headers["x-papi-client"];
33143
+ const installClient = typeof clientHeader === "string" && KNOWN_INSTALL_CLIENTS.has(clientHeader) ? clientHeader : "direct";
32679
33144
  const projectIdHeader = req.headers["x-papi-project-id"];
32680
33145
  const projectId = typeof projectIdHeader === "string" && projectIdHeader.length > 0 ? projectIdHeader : void 0;
32681
33146
  if (req.method !== "POST" && req.method !== "GET") {
@@ -32718,6 +33183,13 @@ function startHttpTransport(opts) {
32718
33183
  return;
32719
33184
  }
32720
33185
  }
33186
+ logEvent({
33187
+ level: "info",
33188
+ msg: "mcp_request",
33189
+ ip,
33190
+ bearer_prefix: bearerPrefix(bearer),
33191
+ install_client: installClient
33192
+ });
32721
33193
  void dispatchRequest({
32722
33194
  req,
32723
33195
  res,
@@ -32798,6 +33270,18 @@ Example: add \`project="${projects[0].slug}"\` to the tool arguments.`;
32798
33270
  res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) });
32799
33271
  res.end(JSON.stringify(payload));
32800
33272
  }
33273
+ function sendProjectUnverifiable(res, origin, body) {
33274
+ if (res.headersSent) return;
33275
+ const id = (body && typeof body === "object" ? body.id : null) ?? null;
33276
+ 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.';
33277
+ const payload = {
33278
+ jsonrpc: "2.0",
33279
+ id,
33280
+ result: { content: [{ type: "text", text }], isError: true }
33281
+ };
33282
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) });
33283
+ res.end(JSON.stringify(payload));
33284
+ }
32801
33285
  function resolveEffectiveProjectId(body, headerProjectId) {
32802
33286
  const explicitProject = extractProjectOverride(body);
32803
33287
  try {
@@ -32807,17 +33291,59 @@ function resolveEffectiveProjectId(body, headerProjectId) {
32807
33291
  throw err;
32808
33292
  }
32809
33293
  }
33294
+ async function resolveAuthVerdict(bearer, dataEndpoint) {
33295
+ const cached2 = readAuthVerdict(bearer);
33296
+ if (cached2) return cached2;
33297
+ const probe = new ProxyPapiAdapter({ endpoint: dataEndpoint, apiKey: bearer });
33298
+ const verdict = classifyAuthProbeStatus(await probe.probeBearerStatus());
33299
+ if (verdict) recordAuthVerdict(bearer, verdict);
33300
+ return verdict;
33301
+ }
32810
33302
  async function dispatchRequest(args) {
32811
33303
  const { req, res, body, bearer, projectId, ip, baseConfig, dataEndpoint } = args;
33304
+ const calledTool = extractToolName(body);
33305
+ if (calledTool !== void 0) {
33306
+ const authVerdict = await resolveAuthVerdict(bearer, dataEndpoint);
33307
+ if (authVerdict === "rejected") {
33308
+ logEvent({
33309
+ level: "warn",
33310
+ msg: "auth_revoked",
33311
+ ip,
33312
+ bearer_prefix: bearerPrefix(bearer),
33313
+ status: 401,
33314
+ reason: "proxy_rejected_bearer"
33315
+ });
33316
+ if (!res.headersSent) {
33317
+ sendUnauthorized(res, "invalid_token", corsHeaders(req.headers.origin));
33318
+ }
33319
+ return;
33320
+ }
33321
+ }
32812
33322
  let effectiveProjectId = resolveEffectiveProjectId(body, projectId);
32813
33323
  if (effectiveProjectId === void 0) {
32814
- const toolName = extractToolName(body);
33324
+ const toolName = calledTool;
32815
33325
  if (toolName && !PROJECT_OPTIONAL_TOOLS.has(toolName)) {
32816
33326
  let projects = [];
33327
+ let probeFailed = false;
32817
33328
  try {
32818
- const probe = new ProxyPapiAdapter({ endpoint: dataEndpoint, apiKey: bearer });
33329
+ const probe = new ProxyPapiAdapter({
33330
+ endpoint: dataEndpoint,
33331
+ apiKey: bearer,
33332
+ onAuthRejected: () => recordAuthVerdict(bearer, "rejected")
33333
+ });
32819
33334
  projects = await probe.listUserProjects();
32820
33335
  } catch {
33336
+ probeFailed = true;
33337
+ }
33338
+ if (probeFailed) {
33339
+ logEvent({
33340
+ level: "warn",
33341
+ msg: "project_probe_failed",
33342
+ ip,
33343
+ bearer_prefix: bearerPrefix(bearer)
33344
+ });
33345
+ sendProjectUnverifiable(res, req.headers.origin, body);
33346
+ return;
32821
33347
  }
32822
33348
  if (projects.length === 1) {
32823
33349
  effectiveProjectId = projects[0].id;
@@ -32840,10 +33366,14 @@ async function dispatchRequest(args) {
32840
33366
  }
32841
33367
  }
32842
33368
  }
32843
- const adapter2 = new ProxyPapiAdapter({
33369
+ const adapter2 = createProxyAdapter({
32844
33370
  endpoint: dataEndpoint,
32845
33371
  apiKey: bearer,
32846
- projectId: effectiveProjectId
33372
+ projectId: effectiveProjectId,
33373
+ // task-1773: a 401 raised mid-tool-call cannot change THIS response — the MCP
33374
+ // transport already owns it — but it marks the bearer so the very next request
33375
+ // short-circuits to a 401 + WWW-Authenticate and the client re-authenticates.
33376
+ onAuthRejected: () => recordAuthVerdict(bearer, "rejected")
32847
33377
  });
32848
33378
  const requestConfig = {
32849
33379
  ...baseConfig,