@papi-ai/server 0.7.77 → 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, {
@@ -1406,10 +1414,39 @@ var init_proxy_adapter = __esm({
1406
1414
  endpoint;
1407
1415
  apiKey;
1408
1416
  projectId;
1417
+ onAuthRejected;
1409
1418
  constructor(config2) {
1410
1419
  this.endpoint = config2.endpoint.replace(/\/$/, "");
1411
1420
  this.apiKey = config2.apiKey;
1412
1421
  this.projectId = config2.projectId ?? "";
1422
+ this.onAuthRejected = config2.onAuthRejected;
1423
+ }
1424
+ /**
1425
+ * task-1773: bearer-only auth probe. Hits the USER-scoped `project-list` route
1426
+ * (no projectId needed), so it answers exactly one question: does the proxy
1427
+ * still accept this bearer?
1428
+ *
1429
+ * Returns the HTTP status, or 0 when the call could not be made at all
1430
+ * (network error / timeout). Callers MUST treat 0 — and any status that is
1431
+ * neither 2xx nor 401 — as "no signal", never as a rejection: a proxy outage
1432
+ * must not masquerade as a revoked token and force every user to re-auth.
1433
+ */
1434
+ async probeBearerStatus() {
1435
+ try {
1436
+ const response = await fetch(`${this.endpoint}/project-list`, {
1437
+ method: "POST",
1438
+ headers: {
1439
+ "Content-Type": "application/json",
1440
+ "Authorization": `Bearer ${this.apiKey}`
1441
+ },
1442
+ body: "{}",
1443
+ signal: AbortSignal.timeout(5e3)
1444
+ });
1445
+ if (response.status === 401) this.onAuthRejected?.();
1446
+ return response.status;
1447
+ } catch {
1448
+ return 0;
1449
+ }
1413
1450
  }
1414
1451
  /** Resolved project ID — available after ensureProject() completes. */
1415
1452
  getProjectId() {
@@ -1426,7 +1463,8 @@ var init_proxy_adapter = __esm({
1426
1463
  return wrapWithForwarding(new _ProxyPapiAdapter({
1427
1464
  endpoint: this.endpoint,
1428
1465
  apiKey: this.apiKey,
1429
- projectId
1466
+ projectId,
1467
+ onAuthRejected: this.onAuthRejected
1430
1468
  }));
1431
1469
  }
1432
1470
  /**
@@ -1513,6 +1551,7 @@ var init_proxy_adapter = __esm({
1513
1551
  message = errorBody;
1514
1552
  }
1515
1553
  if (response.status === 401) {
1554
+ this.onAuthRejected?.();
1516
1555
  throw new Error(
1517
1556
  `Auth: Invalid API key \u2014 PAPI_DATA_API_KEY was rejected by the proxy.
1518
1557
  This usually means the key was revoked or replaced. Mint a fresh key in the Connect panel on your PAPI dashboard (https://getpapi.ai/hub), then update PAPI_DATA_API_KEY in your .mcp.json.
@@ -2091,6 +2130,7 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
2091
2130
  } catch {
2092
2131
  message = errorBody;
2093
2132
  }
2133
+ if (response.status === 401) this.onAuthRejected?.();
2094
2134
  throw new Error(`Proxy error (${response.status}) on ${route}: ${message}`);
2095
2135
  }
2096
2136
  const body = await response.json();
@@ -2208,7 +2248,7 @@ var init_reap_orphans = __esm({
2208
2248
  }
2209
2249
  });
2210
2250
 
2211
- // ../../node_modules/postgres/src/query.js
2251
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/query.js
2212
2252
  function cachedError(xs) {
2213
2253
  if (originCache.has(xs))
2214
2254
  return originCache.get(xs);
@@ -2220,7 +2260,7 @@ function cachedError(xs) {
2220
2260
  }
2221
2261
  var originCache, originStackCache, originError, CLOSE, Query;
2222
2262
  var init_query = __esm({
2223
- "../../node_modules/postgres/src/query.js"() {
2263
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/query.js"() {
2224
2264
  "use strict";
2225
2265
  originCache = /* @__PURE__ */ new Map();
2226
2266
  originStackCache = /* @__PURE__ */ new Map();
@@ -2351,7 +2391,7 @@ var init_query = __esm({
2351
2391
  }
2352
2392
  });
2353
2393
 
2354
- // ../../node_modules/postgres/src/errors.js
2394
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/errors.js
2355
2395
  function connection(x, options, socket) {
2356
2396
  const { host, port } = socket || options;
2357
2397
  const error = Object.assign(
@@ -2389,7 +2429,7 @@ function notSupported(x) {
2389
2429
  }
2390
2430
  var PostgresError, Errors;
2391
2431
  var init_errors = __esm({
2392
- "../../node_modules/postgres/src/errors.js"() {
2432
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/errors.js"() {
2393
2433
  "use strict";
2394
2434
  PostgresError = class extends Error {
2395
2435
  constructor(x) {
@@ -2407,7 +2447,7 @@ var init_errors = __esm({
2407
2447
  }
2408
2448
  });
2409
2449
 
2410
- // ../../node_modules/postgres/src/types.js
2450
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/types.js
2411
2451
  function handleValue(x, parameters, types2, options) {
2412
2452
  let value = x instanceof Parameter ? x.value : x;
2413
2453
  if (value === void 0) {
@@ -2522,7 +2562,7 @@ function createJsonTransform(fn) {
2522
2562
  }
2523
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;
2524
2564
  var init_types = __esm({
2525
- "../../node_modules/postgres/src/types.js"() {
2565
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/types.js"() {
2526
2566
  "use strict";
2527
2567
  init_query();
2528
2568
  init_errors();
@@ -2701,10 +2741,10 @@ var init_types = __esm({
2701
2741
  }
2702
2742
  });
2703
2743
 
2704
- // ../../node_modules/postgres/src/result.js
2744
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/result.js
2705
2745
  var Result;
2706
2746
  var init_result = __esm({
2707
- "../../node_modules/postgres/src/result.js"() {
2747
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/result.js"() {
2708
2748
  "use strict";
2709
2749
  Result = class extends Array {
2710
2750
  constructor() {
@@ -2724,7 +2764,7 @@ var init_result = __esm({
2724
2764
  }
2725
2765
  });
2726
2766
 
2727
- // ../../node_modules/postgres/src/queue.js
2767
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/queue.js
2728
2768
  function Queue(initial = []) {
2729
2769
  let xs = initial.slice();
2730
2770
  let index = 0;
@@ -2751,13 +2791,13 @@ function Queue(initial = []) {
2751
2791
  }
2752
2792
  var queue_default;
2753
2793
  var init_queue = __esm({
2754
- "../../node_modules/postgres/src/queue.js"() {
2794
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/queue.js"() {
2755
2795
  "use strict";
2756
2796
  queue_default = Queue;
2757
2797
  }
2758
2798
  });
2759
2799
 
2760
- // ../../node_modules/postgres/src/bytes.js
2800
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/bytes.js
2761
2801
  function fit(x) {
2762
2802
  if (buffer.length - b.i < x) {
2763
2803
  const prev = buffer, length = prev.length;
@@ -2771,7 +2811,7 @@ function reset() {
2771
2811
  }
2772
2812
  var size, buffer, messages, b, bytes_default;
2773
2813
  var init_bytes = __esm({
2774
- "../../node_modules/postgres/src/bytes.js"() {
2814
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/bytes.js"() {
2775
2815
  "use strict";
2776
2816
  size = 256;
2777
2817
  buffer = Buffer.allocUnsafe(size);
@@ -2836,7 +2876,7 @@ var init_bytes = __esm({
2836
2876
  }
2837
2877
  });
2838
2878
 
2839
- // ../../node_modules/postgres/src/connection.js
2879
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/connection.js
2840
2880
  import net from "net";
2841
2881
  import tls from "tls";
2842
2882
  import crypto2 from "crypto";
@@ -3630,7 +3670,7 @@ function timer(fn, seconds) {
3630
3670
  }
3631
3671
  var connection_default, uid, Sync, Flush, SSLRequest, ExecuteUnnamed, DescribeUnnamed, noop, retryRoutines, errorFields;
3632
3672
  var init_connection = __esm({
3633
- "../../node_modules/postgres/src/connection.js"() {
3673
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/connection.js"() {
3634
3674
  "use strict";
3635
3675
  init_types();
3636
3676
  init_errors();
@@ -3693,7 +3733,7 @@ var init_connection = __esm({
3693
3733
  }
3694
3734
  });
3695
3735
 
3696
- // ../../node_modules/postgres/src/subscribe.js
3736
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/subscribe.js
3697
3737
  function Subscribe(postgres2, options) {
3698
3738
  const subscribers = /* @__PURE__ */ new Map(), slot = "postgresjs_" + Math.random().toString(36).slice(2), state = {};
3699
3739
  let connection2, stream, ended = false;
@@ -3904,14 +3944,14 @@ function parseEvent(x) {
3904
3944
  }
3905
3945
  var noop2;
3906
3946
  var init_subscribe = __esm({
3907
- "../../node_modules/postgres/src/subscribe.js"() {
3947
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/subscribe.js"() {
3908
3948
  "use strict";
3909
3949
  noop2 = () => {
3910
3950
  };
3911
3951
  }
3912
3952
  });
3913
3953
 
3914
- // ../../node_modules/postgres/src/large.js
3954
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/large.js
3915
3955
  import Stream2 from "stream";
3916
3956
  function largeObject(sql, oid, mode = 131072 | 262144) {
3917
3957
  return new Promise(async (resolve4, reject) => {
@@ -3977,12 +4017,12 @@ function largeObject(sql, oid, mode = 131072 | 262144) {
3977
4017
  });
3978
4018
  }
3979
4019
  var init_large = __esm({
3980
- "../../node_modules/postgres/src/large.js"() {
4020
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/large.js"() {
3981
4021
  "use strict";
3982
4022
  }
3983
4023
  });
3984
4024
 
3985
- // ../../node_modules/postgres/src/index.js
4025
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/index.js
3986
4026
  var src_exports = {};
3987
4027
  __export(src_exports, {
3988
4028
  default: () => src_default
@@ -4372,7 +4412,7 @@ function osUsername() {
4372
4412
  }
4373
4413
  var src_default;
4374
4414
  var init_src = __esm({
4375
- "../../node_modules/postgres/src/index.js"() {
4415
+ "../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/node_modules/postgres/src/index.js"() {
4376
4416
  "use strict";
4377
4417
  init_types();
4378
4418
  init_connection();
@@ -5385,7 +5425,7 @@ Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env con
5385
5425
  import path3 from "path";
5386
5426
  import { execSync } from "child_process";
5387
5427
 
5388
- // ../adapter-md/dist/index.js
5428
+ // ../../../../../Users/cathaloullivan/Ai-App-Projects/PapiUI/packages/adapter-md/dist/index.js
5389
5429
  import { readFile, writeFile, access } from "fs/promises";
5390
5430
  import { randomUUID as randomUUID6 } from "crypto";
5391
5431
  import { join } from "path";
@@ -5647,9 +5687,12 @@ function parsePlanningLog(content, activeDecisionsContent, cycleLogContent) {
5647
5687
  var VALID_EFFORT_SIZES = /* @__PURE__ */ new Set(["XS", "S", "M", "L", "XL"]);
5648
5688
  var SECTION_HEADERS = [
5649
5689
  "SCOPE (DO THIS)",
5690
+ "WHY NOT SIMPLER",
5650
5691
  "SCOPE BOUNDARY (DO NOT DO THIS)",
5651
5692
  "ACCEPTANCE CRITERIA",
5693
+ "PRE-MORTEM",
5652
5694
  "SECURITY CONSIDERATIONS",
5695
+ "DEPLOY VERIFICATION",
5653
5696
  "PRE-BUILD VERIFICATION",
5654
5697
  "FILES LIKELY TOUCHED",
5655
5698
  "EFFORT"
@@ -5688,7 +5731,7 @@ function parseBulletsOnly(text) {
5688
5731
  return text.split("\n").filter((l) => /^\s*-\s/.test(l)).map((l) => l.replace(/^\s*-\s*/, "").trim()).filter((l) => l.length > 0);
5689
5732
  }
5690
5733
  function parseChecklist(text) {
5691
- return text.split("\n").map((l) => l.replace(/^\s*\[[ x]]\s*/, "").trim()).filter((l) => l.length > 0);
5734
+ return text.split("\n").map((l) => l.replace(/^\s*(?:[-*+]\s*)?(?:\[[ xX]\]\s*)?/, "").trim()).filter((l) => l.length > 0);
5692
5735
  }
5693
5736
  function parseBuildHandoff(markdown) {
5694
5737
  if (typeof markdown !== "string" || !markdown.trim()) return null;
@@ -6338,13 +6381,21 @@ var EFFORT_SCALE = {
6338
6381
  XL: 5
6339
6382
  };
6340
6383
  function effortOrdinal(effort) {
6384
+ if (typeof effort !== "string") return void 0;
6341
6385
  const normalized = effort.trim().toUpperCase();
6342
6386
  return EFFORT_SCALE[normalized];
6343
6387
  }
6388
+ function isUnparsedEffort(effort) {
6389
+ if (typeof effort !== "string" || effort.trim().length === 0) return false;
6390
+ return effortOrdinal(effort) === void 0;
6391
+ }
6344
6392
  function calculateCycleMetrics(reports, currentCycle, window = 5) {
6345
6393
  const recentReports = reports.filter(
6346
6394
  (r) => r.cycle > currentCycle - window && r.cycle <= currentCycle
6347
6395
  );
6396
+ const unparsedEffortCount = recentReports.filter(
6397
+ (r) => isUnparsedEffort(r.actualEffort) || isUnparsedEffort(r.estimatedEffort)
6398
+ ).length;
6348
6399
  const perCycle = /* @__PURE__ */ new Map();
6349
6400
  for (const r of recentReports) {
6350
6401
  const group = perCycle.get(r.cycle) ?? [];
@@ -6381,7 +6432,7 @@ function calculateCycleMetrics(reports, currentCycle, window = 5) {
6381
6432
  effortPoints: reps.reduce((s, r) => s + (effortOrdinal(r.actualEffort) ?? 0), 0)
6382
6433
  });
6383
6434
  }
6384
- return { accuracy, velocity };
6435
+ return { accuracy, velocity, unparsedEffortCount };
6385
6436
  }
6386
6437
  function serializeAccuracyRow(a) {
6387
6438
  return `| ${a.cycle} | ${a.reports} | ${a.matchRate}% | ${a.mae} | ${a.bias >= 0 ? "+" : ""}${a.bias} |`;
@@ -8031,6 +8082,7 @@ async function createAdapter(optionsOrType, maybePapiDir) {
8031
8082
  case "pg": {
8032
8083
  const { PgAdapter, PgPapiAdapter, configFromEnv } = await import("@papi-ai/adapter-pg");
8033
8084
  let projectId = process.env["PAPI_PROJECT_ID"];
8085
+ const projectIdWasPreSupplied = Boolean(projectId);
8034
8086
  const projectRoot = options.projectRoot ?? process.env["PAPI_PROJECT_DIR"] ?? process.cwd();
8035
8087
  let rootHash = null;
8036
8088
  let originUrl = null;
@@ -8094,6 +8146,26 @@ async function createAdapter(optionsOrType, maybePapiDir) {
8094
8146
  }
8095
8147
  const config2 = papiEndpoint ? { connectionString: papiEndpoint } : configFromEnv();
8096
8148
  validateDatabaseUrl(config2.connectionString);
8149
+ if (projectIdWasPreSupplied) {
8150
+ const ownershipProbe = new PgAdapter(config2);
8151
+ try {
8152
+ const owned = await ownershipProbe.findProjectById(projectId, resolveUserId);
8153
+ if (!owned) {
8154
+ throw new Error(
8155
+ `PAPI_PROJECT_ID ${projectId} does not belong to you.
8156
+
8157
+ The project exists under a different owner, or does not exist at all. PAPI refuses to attach to a project you do not own \u2014 writing to it would put your cycles, tasks and Active Decisions into somebody else's project.
8158
+
8159
+ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the project from your git remote), then reconnect.`
8160
+ );
8161
+ }
8162
+ } finally {
8163
+ try {
8164
+ await ownershipProbe.close();
8165
+ } catch {
8166
+ }
8167
+ }
8168
+ }
8097
8169
  const { ensureSchema } = await import("@papi-ai/adapter-pg");
8098
8170
  try {
8099
8171
  await ensureSchema(config2);
@@ -8461,15 +8533,23 @@ function formatBuildReports(reports, opts) {
8461
8533
  _\u2026and ${reports.length - capped.length} older build report(s) omitted to bound context size._` : "";
8462
8534
  return body + omitted;
8463
8535
  }
8464
- function extractReferencedTaskIds(report) {
8465
- const prose = [report.surprises, report.architectureNotes, report.deadEnds, report.discoveredIssues].filter((s) => typeof s === "string" && s.length > 0).join("\n");
8536
+ function extractTaskReferences(report) {
8537
+ const raw = [report.surprises, report.architectureNotes, report.deadEnds, report.discoveredIssues].filter((s) => typeof s === "string" && s.length > 0).join("\n");
8538
+ const prose = raw.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~/g, " ").replace(/`[^`\n]*`/g, " ");
8466
8539
  const own = report.taskId?.toLowerCase();
8467
- const found = /* @__PURE__ */ new Set();
8540
+ const resolves = /* @__PURE__ */ new Set();
8541
+ for (const m of prose.matchAll(/\bresolves:\s*((?:task-\d+\b\s*,?\s*)+)/gi)) {
8542
+ for (const idMatch of m[1].matchAll(/\btask-\d+\b/gi)) {
8543
+ const id = idMatch[0].toLowerCase();
8544
+ if (id !== own) resolves.add(id);
8545
+ }
8546
+ }
8547
+ const mentions = /* @__PURE__ */ new Set();
8468
8548
  for (const m of prose.matchAll(/\btask-\d+\b/gi)) {
8469
8549
  const id = m[0].toLowerCase();
8470
- if (id !== own) found.add(id);
8550
+ if (id !== own && !resolves.has(id)) mentions.add(id);
8471
8551
  }
8472
- return [...found].sort();
8552
+ return { resolves: [...resolves].sort(), mentions: [...mentions].sort() };
8473
8553
  }
8474
8554
  function formatRecentlyShippedCapabilities(reports) {
8475
8555
  const completed = reports.filter((r) => r.completed === "Yes" || r.completed === "Partial");
@@ -8485,16 +8565,26 @@ function formatRecentlyShippedCapabilities(reports) {
8485
8565
  }
8486
8566
  return parts.join("\n");
8487
8567
  });
8568
+ const resolvedBy = /* @__PURE__ */ new Map();
8488
8569
  const namedBy = /* @__PURE__ */ new Map();
8489
8570
  const completedIds = new Set(completed.map((r) => r.taskId?.toLowerCase()).filter(Boolean));
8571
+ const record = (into, ref, namer) => {
8572
+ const namers = into.get(ref) ?? [];
8573
+ namers.push(namer);
8574
+ into.set(ref, namers);
8575
+ };
8490
8576
  for (const r of completed) {
8491
- for (const ref of extractReferencedTaskIds(r)) {
8577
+ const { resolves, mentions } = extractTaskReferences(r);
8578
+ for (const ref of resolves) {
8579
+ if (completedIds.has(ref)) continue;
8580
+ record(resolvedBy, ref, r.taskId);
8581
+ }
8582
+ for (const ref of mentions) {
8492
8583
  if (completedIds.has(ref)) continue;
8493
- const namers = namedBy.get(ref) ?? [];
8494
- namers.push(r.taskId);
8495
- namedBy.set(ref, namers);
8584
+ record(namedBy, ref, r.taskId);
8496
8585
  }
8497
8586
  }
8587
+ for (const ref of resolvedBy.keys()) namedBy.delete(ref);
8498
8588
  const out = [
8499
8589
  `${completed.length} task(s) completed in recent cycles:`,
8500
8590
  "",
@@ -8502,17 +8592,31 @@ function formatRecentlyShippedCapabilities(reports) {
8502
8592
  "",
8503
8593
  "Cross-reference candidate tasks against this list. If >80% of a candidate task's scope appears here, recommend cancellation or scope reduction instead of scheduling."
8504
8594
  ];
8595
+ if (resolvedBy.size > 0) {
8596
+ out.push(
8597
+ "",
8598
+ "### \u26A0 Declared resolved by a shipped task \u2014 VERIFY, THEN CLOSE",
8599
+ "",
8600
+ "A shipped report explicitly claimed each of these with `resolves: <task-id>`, but",
8601
+ "the task is not itself marked complete. That is a declaration of intent, not proof:",
8602
+ "confirm against the live code, then close it with a boardCorrection rather than",
8603
+ "spending a cycle slot on work that already shipped.",
8604
+ "",
8605
+ ...[...resolvedBy.entries()].sort(([a], [b2]) => a.localeCompare(b2)).map(([ref, namers]) => `- **${ref}** \u2014 declared resolved by ${namers.join(", ")}`)
8606
+ );
8607
+ }
8505
8608
  if (namedBy.size > 0) {
8506
8609
  out.push(
8507
8610
  "",
8508
8611
  "### \u26A0 Named by a shipped task \u2014 VERIFY BEFORE SCHEDULING",
8509
8612
  "",
8510
- "These task IDs are referenced in the build reports above but are NOT themselves",
8511
- "completed. A discovery is often fixed as a side effect of a sibling task's diff and",
8512
- "never marked done, so it survives into this plan carrying notes that are no longer",
8513
- "true (C357 gave task-3043 a P1 slot this way \u2014 task-2998 had already fixed it).",
8514
- "For each one below: check the naming report and the live code BEFORE scheduling it.",
8515
- "If it is already fixed, close it with a boardCorrection instead of spending a slot.",
8613
+ "These task IDs merely APPEAR in the build reports above \u2014 no report claimed to have",
8614
+ "resolved them, and they are not themselves completed. A discovery is often fixed as a",
8615
+ "side effect of a sibling task's diff and never marked done, so it survives into this",
8616
+ "plan carrying notes that are no longer true (C357 gave task-3043 a P1 slot this way \u2014",
8617
+ "task-2998 had already fixed it). Weaker signal than the section above: a mention can",
8618
+ 'equally mean "related to" or "still blocked by". Read the naming report and the live',
8619
+ "code BEFORE scheduling; never close on a mention alone.",
8516
8620
  "",
8517
8621
  ...[...namedBy.entries()].sort(([a], [b2]) => a.localeCompare(b2)).map(([ref, namers]) => `- **${ref}** \u2014 named by ${namers.join(", ")}`)
8518
8622
  );
@@ -9834,6 +9938,17 @@ function coerceToString(value) {
9834
9938
  if (value === null || value === void 0) return "";
9835
9939
  return JSON.stringify(value, null, 2);
9836
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
+ }
9837
9952
  function coerceStructuredOutput(parsed) {
9838
9953
  const cycleHandoffs = Array.isArray(parsed.cycleHandoffs) ? parsed.cycleHandoffs.map((h) => {
9839
9954
  const { taskId: _t, buildHandoff: _b, ...rest } = h;
@@ -9873,10 +9988,12 @@ function coerceStructuredOutput(parsed) {
9873
9988
  body: coerceToString(ad.body)
9874
9989
  })) : [];
9875
9990
  const cycleTaskIds = Array.isArray(parsed.cycleTaskIds) ? parsed.cycleTaskIds.map((id) => coerceToString(id)) : void 0;
9991
+ const carryForward = coerceCarryForward(parsed.cycleLogCarryForward);
9876
9992
  return {
9877
9993
  cycleLogTitle: coerceToString(parsed.cycleLogTitle),
9878
9994
  cycleLogContent: coerceToString(parsed.cycleLogContent),
9879
- cycleLogCarryForward: parsed.cycleLogCarryForward === null ? null : coerceToString(parsed.cycleLogCarryForward),
9995
+ cycleLogCarryForward: carryForward.value,
9996
+ ...carryForward.warning ? { coercionWarnings: [carryForward.warning] } : {},
9880
9997
  cycleLogNotes: parsed.cycleLogNotes === null ? null : coerceToString(parsed.cycleLogNotes),
9881
9998
  nextMode: "Full",
9882
9999
  boardHealth: coerceToString(parsed.boardHealth),
@@ -13097,8 +13214,9 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
13097
13214
  contextHashes,
13098
13215
  { confirmCancellations: planRunMeta?.confirmCancellations === true, ownerUserId: applyScope.callerUserId ?? void 0 }
13099
13216
  );
13100
- if (wbWarnings.length > 0) {
13101
- writeBackWarnings = wbWarnings;
13217
+ const allWarnings = [...data.coercionWarnings ?? [], ...wbWarnings];
13218
+ if (allWarnings.length > 0) {
13219
+ writeBackWarnings = allWarnings;
13102
13220
  }
13103
13221
  if (skipped.length > 0) {
13104
13222
  skippedCancellations = skipped;
@@ -15780,6 +15898,65 @@ function extractDecisionEvidence(ad, eventType, warnings) {
15780
15898
  }
15781
15899
  return { evidenceRef, metricDelta };
15782
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
+ }
15783
15960
  async function writeBack2(adapter2, cycleNumber, data, fullAnalysis, warnings) {
15784
15961
  const cleanTitle = data.sessionLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
15785
15962
  const cleanContent = data.sessionLogContent.replace(/^#{1,3}\s+(?:Cycle|Session)\s+\d+\s*—[^\n]*\n*/i, "").trim();
@@ -15821,34 +15998,7 @@ ${cleanContent}`;
15821
15998
  } catch {
15822
15999
  }
15823
16000
  if (data.activeDecisionUpdates && data.activeDecisionUpdates.length > 0) {
15824
- await Promise.all(data.activeDecisionUpdates.map(async (ad) => {
15825
- if (ad.action === "delete" && adapter2.deleteActiveDecision) {
15826
- await adapter2.deleteActiveDecision(ad.id);
15827
- } else if (ad.action === "new" && adapter2.upsertActiveDecision) {
15828
- const titleMatch = ad.body.match(/^###\s+\S+:\s*([^\n[]+?)(?:\s*\[|$)/m);
15829
- const title = titleMatch ? titleMatch[1].trim() : ad.id;
15830
- const confidenceMatch = ad.body.match(/\[Confidence:\s*(HIGH|MEDIUM|LOW)\]/i);
15831
- const confidence = confidenceMatch ? confidenceMatch[1].toUpperCase() : "MEDIUM";
15832
- await adapter2.upsertActiveDecision(ad.id, ad.body, title, confidence, cycleNumber);
15833
- } else {
15834
- await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, ad.action);
15835
- }
15836
- const eventType = ad.action === "delete" ? "invalidated" : ad.action === "confidence_change" ? "confidence_changed" : ad.action === "supersede" ? "superseded" : ad.action === "new" ? "created" : "modified";
15837
- const evidence = extractDecisionEvidence(ad, eventType, warnings);
15838
- try {
15839
- await adapter2.appendDecisionEvent({
15840
- decisionId: ad.id,
15841
- eventType,
15842
- cycle: cycleNumber,
15843
- source: "strategy_review",
15844
- sourceRef: `cycle-${cycleNumber}-review`,
15845
- detail: `Action: ${ad.action}`,
15846
- evidenceRef: evidence.evidenceRef,
15847
- metricDelta: evidence.metricDelta
15848
- });
15849
- } catch {
15850
- }
15851
- }));
16001
+ await applyDecisionUpdates(adapter2, data.activeDecisionUpdates, cycleNumber, warnings);
15852
16002
  }
15853
16003
  try {
15854
16004
  if (adapter2.confirmPendingActiveDecisions) {
@@ -16606,37 +16756,45 @@ async function prepareStrategyChange(adapter2, text, adapterType) {
16606
16756
  async function applyStrategyChangeOutput(adapter2, rawLlmOutput, cycleNumber) {
16607
16757
  return processStrategyChangeOutput(adapter2, rawLlmOutput, cycleNumber);
16608
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
+ }
16609
16767
  async function captureDecision(adapter2, input) {
16610
16768
  const health = await adapter2.getCycleHealth();
16611
16769
  const cycleNumber = health.totalCycles;
16612
- let adId;
16613
- let adAction;
16614
- if (input.adId) {
16615
- adId = input.adId;
16616
- adAction = "updated";
16617
- } else {
16618
- const existingAds = await adapter2.getActiveDecisions({ includeRetired: true });
16619
- const maxNum = existingAds.reduce((max, ad) => {
16620
- const match = ad.id.match(/^AD-(\d+)$/);
16621
- return match ? Math.max(max, parseInt(match[1], 10)) : max;
16622
- }, 0);
16623
- adId = `AD-${maxNum + 1}`;
16624
- adAction = "created";
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
+ }
16625
16782
  }
16626
16783
  if (input.confidenceOnly) {
16627
16784
  if (!input.adId) {
16628
16785
  throw new Error('confidence_only requires adId \u2014 provide the AD to update (e.g. "AD-12")');
16629
16786
  }
16787
+ const adId2 = input.adId;
16630
16788
  if (adapter2.upsertActiveDecision) {
16631
16789
  const existing = await adapter2.getActiveDecisions({ includeRetired: false });
16632
- const current = existing.find((d) => d.id === adId);
16790
+ const current = existing.find((d) => d.id === adId2);
16633
16791
  const preservedBody = current?.body ?? `- **Decision:** ${input.text}`;
16634
16792
  const preservedTitle = current?.title ?? input.text.slice(0, 80);
16635
- await adapter2.upsertActiveDecision(adId, preservedBody, preservedTitle, input.confidence, cycleNumber);
16793
+ await adapter2.upsertActiveDecision(adId2, preservedBody, preservedTitle, input.confidence, cycleNumber);
16636
16794
  }
16637
16795
  try {
16638
16796
  await adapter2.appendDecisionEvent({
16639
- decisionId: adId,
16797
+ decisionId: adId2,
16640
16798
  eventType: "modified",
16641
16799
  cycle: cycleNumber,
16642
16800
  source: "strategy_change",
@@ -16645,18 +16803,66 @@ async function captureDecision(adapter2, input) {
16645
16803
  });
16646
16804
  } catch {
16647
16805
  }
16648
- return { cycleNumber, adId, adAction: "updated" };
16806
+ return { cycleNumber, adId: adId2, adAction: "updated" };
16649
16807
  }
16650
16808
  const title = input.text.length > 80 ? input.text.slice(0, 77) + "..." : input.text;
16651
- 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}]
16652
16812
 
16653
- - **Decision:** ${input.text}
16813
+ - **Decision:** ${input.text}${supersedesLine}
16654
16814
  - **Evidence:** Captured from conversation, Cycle ${cycleNumber}.
16655
16815
  - **Status:** Active`;
16656
- if (adapter2.upsertActiveDecision) {
16657
- 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
+ }
16658
16827
  } else {
16659
- 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
+ }
16660
16866
  }
16661
16867
  try {
16662
16868
  await adapter2.appendDecisionEvent({
@@ -16677,11 +16883,13 @@ async function captureDecision(adapter2, input) {
16677
16883
  title: `Decision captured: ${adId}`,
16678
16884
  content: `**${adAction === "created" ? "New" : "Updated"} Active Decision** \u2014 ${adId}: ${input.text}
16679
16885
 
16680
- 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).` : "")
16681
16889
  });
16682
16890
  } catch {
16683
16891
  }
16684
- return { cycleNumber, adId, adAction };
16892
+ return { cycleNumber, adId, adAction, supersededId: supersedesId };
16685
16893
  }
16686
16894
 
16687
16895
  // src/tools/strategy.ts
@@ -16797,6 +17005,10 @@ var strategyChangeTool = {
16797
17005
  type: "boolean",
16798
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.`
16799
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
+ },
16800
17012
  north_star: {
16801
17013
  type: "string",
16802
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.'
@@ -17030,19 +17242,24 @@ orient and the project foundation will read this value.`
17030
17242
  const confidence = args.confidence ?? "MEDIUM";
17031
17243
  const adBody = args.ad_body;
17032
17244
  const confidenceOnly = args.confidence_only === true;
17245
+ const supersedes = args.supersedes?.trim();
17033
17246
  const result = await captureDecision(adapter2, {
17034
17247
  text: text2.trim(),
17035
17248
  adId: adId?.trim(),
17036
17249
  confidence,
17037
17250
  adBody: adBody?.trim(),
17038
- confidenceOnly
17251
+ confidenceOnly,
17252
+ supersedes
17039
17253
  });
17040
17254
  const captureLabel = confidenceOnly ? `Updated confidence on **${result.adId}** to ${confidence} (body preserved)` : `${result.adAction === "created" ? "Created" : "Updated"} **${result.adId}**: ${text2.trim()}
17041
17255
  Confidence: ${confidence}`;
17256
+ const supersedeLine = result.supersededId ? `
17257
+
17258
+ **${result.supersededId}** marked superseded by ${result.adId} \u2014 retained as history, not overwritten.` : "";
17042
17259
  return textResponse(
17043
17260
  `**Decision Captured \u2014 Cycle ${result.cycleNumber}**
17044
17261
 
17045
- ${captureLabel}
17262
+ ${captureLabel}${supersedeLine}
17046
17263
 
17047
17264
  Decision event logged.`
17048
17265
  );
@@ -17153,8 +17370,8 @@ async function viewBoard(adapter2, phaseFilter, options) {
17153
17370
  const bi = PRIORITY_ORDER.indexOf(b2.priority);
17154
17371
  const priorityDiff = (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
17155
17372
  if (priorityDiff !== 0) return priorityDiff;
17156
- const aDate = a.createdAt ? String(a.createdAt) : "";
17157
- const bDate = b2.createdAt ? String(b2.createdAt) : "";
17373
+ const aDate = a.createdAt ?? "";
17374
+ const bDate = b2.createdAt ?? "";
17158
17375
  return bDate.localeCompare(aDate);
17159
17376
  });
17160
17377
  const total = filtered.length;
@@ -17508,6 +17725,10 @@ var boardEditTool = {
17508
17725
  actual_effort: {
17509
17726
  $ref: "#/$defs/effortSize",
17510
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."
17511
17732
  }
17512
17733
  },
17513
17734
  required: ["task_id"]
@@ -17761,6 +17982,14 @@ async function handleBoardEdit(adapter2, args) {
17761
17982
  if (!taskId) {
17762
17983
  return errorResponse("task_id is required.");
17763
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
+ }
17764
17993
  const updates = {};
17765
17994
  const changes = [];
17766
17995
  for (const field of EDITABLE_FIELDS) {
@@ -17788,7 +18017,7 @@ async function handleBoardEdit(adapter2, args) {
17788
18017
  updates.cycle = null;
17789
18018
  changes.push("cycle");
17790
18019
  } else if (typeof rawCycle === "number" && Number.isInteger(rawCycle) && rawCycle > 0) {
17791
- const health = await adapter2.getCycleHealth().catch(() => null);
18020
+ const health = await target.getCycleHealth().catch(() => null);
17792
18021
  const activeCycle = health?.totalCycles ?? 0;
17793
18022
  if (rawCycle > activeCycle + 1) {
17794
18023
  return errorResponse(
@@ -17807,7 +18036,7 @@ async function handleBoardEdit(adapter2, args) {
17807
18036
  return errorResponse("No fields to update. Pass at least one field (title, priority, complexity, module, epic, phase, notes, status, maturity, cycle).");
17808
18037
  }
17809
18038
  try {
17810
- const task = await adapter2.getTask(taskId);
18039
+ const task = await target.getTask(taskId);
17811
18040
  if (!task) {
17812
18041
  return errorResponse(`Task ${taskId} not found.`);
17813
18042
  }
@@ -17826,7 +18055,7 @@ async function handleBoardEdit(adapter2, args) {
17826
18055
  const idx = changes.indexOf("notes");
17827
18056
  if (idx >= 0) changes.splice(idx, 1);
17828
18057
  } else {
17829
- const health = await adapter2.getCycleHealth().catch(() => null);
18058
+ const health = await target.getCycleHealth().catch(() => null);
17830
18059
  const activeCycle = health?.totalCycles ?? null;
17831
18060
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
17832
18061
  const stamp = activeCycle != null ? `[C${activeCycle} ${date}]` : `[${date}]`;
@@ -17851,7 +18080,7 @@ ${existing}` : entry;
17851
18080
  }
17852
18081
  let autoAssignedCycle = null;
17853
18082
  if (updates.status === "In Cycle") {
17854
- const health = await adapter2.getCycleHealth().catch(() => null);
18083
+ const health = await target.getCycleHealth().catch(() => null);
17855
18084
  const activeCycle = health?.totalCycles ?? null;
17856
18085
  if (activeCycle != null && activeCycle > 0) {
17857
18086
  updates.cycle = activeCycle;
@@ -17863,25 +18092,25 @@ ${existing}` : entry;
17863
18092
  updates.cancelledBy = "user";
17864
18093
  }
17865
18094
  if (effortCorrection.estimatedEffort || effortCorrection.actualEffort) {
17866
- if (!adapter2.correctLatestBuildReportEffort) {
18095
+ if (!target.correctLatestBuildReportEffort) {
17867
18096
  return errorResponse("Correcting build-report effort requires a database adapter (pg). The md adapter does not support it.");
17868
18097
  }
17869
- await adapter2.correctLatestBuildReportEffort(taskId, effortCorrection);
18098
+ await target.correctLatestBuildReportEffort(taskId, effortCorrection);
17870
18099
  }
17871
18100
  if (Object.keys(updates).length > 0) {
17872
- await adapter2.updateTask(taskId, updates);
18101
+ await target.updateTask(taskId, updates);
17873
18102
  }
17874
- if ((updates.status === "Done" || updates.status === "Cancelled") && adapter2.updateDogfoodEntryStatus) {
18103
+ if ((updates.status === "Done" || updates.status === "Cancelled") && target.updateDogfoodEntryStatus) {
17875
18104
  try {
17876
- const dogfoodLog = await adapter2.getDogfoodLog?.(50) ?? [];
18105
+ const dogfoodLog = await target.getDogfoodLog?.(50) ?? [];
17877
18106
  const linked = dogfoodLog.filter((e) => e.linkedTaskId === taskId || e.linkedTaskId === task.id);
17878
18107
  const newStatus = "resolved";
17879
- await Promise.all(linked.map((e) => adapter2.updateDogfoodEntryStatus(e.id, newStatus)));
18108
+ await Promise.all(linked.map((e) => target.updateDogfoodEntryStatus(e.id, newStatus)));
17880
18109
  } catch {
17881
18110
  }
17882
18111
  }
17883
18112
  const lines = [
17884
- `Updated **${taskId}** (${updates.title ?? task.title})`,
18113
+ `Updated **${taskId}**${overrideNote} (${updates.title ?? task.title})`,
17885
18114
  "",
17886
18115
  `**Changes:** ${changes.map((f) => `${f} \u2192 ${String(updates[f])}`).join(", ")}`
17887
18116
  ];
@@ -18685,7 +18914,7 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18685
18914
  if (useCollector) {
18686
18915
  collector.add({
18687
18916
  path: ".claude/settings.json",
18688
- content: JSON.stringify({ permissions: { allow: [PAPI_PERMISSION] } }, null, 2) + "\n",
18917
+ content: JSON.stringify({ permissions: { allow: [...PAPI_PERMISSIONS] } }, null, 2) + "\n",
18689
18918
  mode: "create",
18690
18919
  skip_if_exists: true
18691
18920
  });
@@ -18694,7 +18923,7 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
18694
18923
  }
18695
18924
  return true;
18696
18925
  }
18697
- var PAPI_PERMISSION = "mcp__papi__*";
18926
+ var PAPI_PERMISSIONS = ["mcp__papi__*", "mcp__plugin_papi_papi__*"];
18698
18927
  async function ensurePapiPermission(projectRoot) {
18699
18928
  const settingsPath = join9(projectRoot, ".claude", "settings.json");
18700
18929
  try {
@@ -18712,8 +18941,10 @@ async function ensurePapiPermission(projectRoot) {
18712
18941
  perms.allow = [];
18713
18942
  }
18714
18943
  const allow = perms.allow;
18715
- if (!allow.includes(PAPI_PERMISSION)) {
18716
- allow.push(PAPI_PERMISSION);
18944
+ for (const permission of PAPI_PERMISSIONS) {
18945
+ if (!allow.includes(permission)) {
18946
+ allow.push(permission);
18947
+ }
18717
18948
  }
18718
18949
  await mkdir(join9(projectRoot, ".claude"), { recursive: true });
18719
18950
  await writeFile2(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
@@ -21750,7 +21981,7 @@ function pathBasename(p) {
21750
21981
  const parts = p.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
21751
21982
  return parts[parts.length - 1] ?? p;
21752
21983
  }
21753
- function isPathInPredictedScope(changedPath, predicted) {
21984
+ function matchedPredictedEntry(changedPath, predicted) {
21754
21985
  const changed = changedPath.replace(/\\/g, "/");
21755
21986
  const changedLower = changed.toLowerCase();
21756
21987
  const changedBase = pathBasename(changedPath);
@@ -21758,17 +21989,20 @@ function isPathInPredictedScope(changedPath, predicted) {
21758
21989
  const entry = raw.replace(/\\/g, "/").replace(/\/+$/, "").trim();
21759
21990
  if (!entry) continue;
21760
21991
  if (entry.includes("*")) {
21761
- if (globToRegExp(entry).test(changed)) return true;
21762
- 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;
21763
21994
  continue;
21764
21995
  }
21765
- if (pathBasename(entry) === changedBase) return true;
21996
+ if (pathBasename(entry) === changedBase) return entry;
21766
21997
  const entryLower = entry.toLowerCase();
21767
21998
  if (changedLower === entryLower || changedLower.startsWith(`${entryLower}/`)) {
21768
- return true;
21999
+ return entry;
21769
22000
  }
21770
22001
  }
21771
- return false;
22002
+ return null;
22003
+ }
22004
+ function isPathInPredictedScope(changedPath, predicted) {
22005
+ return matchedPredictedEntry(changedPath, predicted) !== null;
21772
22006
  }
21773
22007
  function autoCommit(config2, taskId, taskTitle, predictedFiles) {
21774
22008
  const cwd = config2.projectRoot;
@@ -21794,6 +22028,17 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
21794
22028
  if (staged.length > 0) {
21795
22029
  return safeRun(() => commitStagedOnly(cwd, message)) + ` (selective staging respected: ${staged.length} file(s)).`;
21796
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
+ }
21797
22042
  const modified = getModifiedFiles(cwd);
21798
22043
  if (modified.length === 0) {
21799
22044
  return "Auto-commit: skipped (no working-tree changes).";
@@ -21807,7 +22052,9 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
21807
22052
  const more = outOfScope.length > 10 ? ` (+${outOfScope.length - 10} more)` : "";
21808
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}.`;
21809
22054
  }
21810
- 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}).`;
21811
22058
  }
21812
22059
  return `${commitResult} (staged all ${modified.length} changed file(s)).`;
21813
22060
  }
@@ -22851,9 +23098,19 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22851
23098
  return files;
22852
23099
  };
22853
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
+ }
22854
23109
  const registered = await adapter2.searchDocs({ status: "all", limit: 500 });
22855
23110
  const registeredPaths = new Set(registered.map((d) => d.path));
22856
- const unregistered = mdFiles.filter((f) => !registeredPaths.has(f));
23111
+ const unregistered = mdFiles.filter(
23112
+ (f) => !registeredPaths.has(f) && branchDocs.has(f)
23113
+ );
22857
23114
  if (unregistered.length > 0 && adapter2.registerDoc) {
22858
23115
  const autoRegistered = [];
22859
23116
  const failed = [];
@@ -23037,7 +23294,7 @@ import { docDeletionBlockMessage } from "@papi-ai/shared";
23037
23294
  init_git();
23038
23295
 
23039
23296
  // src/services/entitlements.ts
23040
- import { evaluateContributorGate } from "@papi-ai/shared";
23297
+ import { evaluateContributorGate, isSelfHostedDeployment } from "@papi-ai/shared";
23041
23298
  var FREE_PROJECT_CAP = 3;
23042
23299
  var DOC_STORAGE_CEILING_BY_TIER = {
23043
23300
  free: { bytes: 25 * 1024 * 1024, docs: 200 },
@@ -23060,6 +23317,7 @@ function isPaidTier(tier) {
23060
23317
  return tier !== null && PAID_TIERS.has(tier);
23061
23318
  }
23062
23319
  async function enforceProjectCap(adapter2, target) {
23320
+ if (isSelfHostedDeployment(process.env.PAPI_SELF_HOST)) return null;
23063
23321
  const tier = await resolveTier(adapter2);
23064
23322
  if (tier === null || isPaidTier(tier)) return null;
23065
23323
  if (typeof adapter2.listUserProjects !== "function") return null;
@@ -23170,7 +23428,11 @@ var docRegisterTool = {
23170
23428
  },
23171
23429
  description: "Actionable findings from the document."
23172
23430
  },
23173
- 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
+ }
23174
23436
  },
23175
23437
  required: ["path", "title", "type", "summary", "cycle"]
23176
23438
  }
@@ -23327,6 +23589,22 @@ async function handleDocRegister(adapter2, args, config2) {
23327
23589
  continueHint
23328
23590
  );
23329
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
+ }
23330
23608
  if (!path7.toLowerCase().endsWith(".md")) {
23331
23609
  return docRegisterSoftFail(
23332
23610
  adapterType,
@@ -23338,13 +23616,13 @@ async function handleDocRegister(adapter2, args, config2) {
23338
23616
  try {
23339
23617
  let supersededBy;
23340
23618
  if (supersededByPath) {
23341
- const existing = await adapter2.getDoc?.(supersededByPath);
23619
+ const existing = await target.getDoc?.(supersededByPath);
23342
23620
  if (existing) {
23343
23621
  supersededBy = existing.id;
23344
- await adapter2.updateDocStatus?.(existing.id, "superseded", void 0);
23622
+ await target.updateDocStatus?.(existing.id, "superseded", void 0);
23345
23623
  }
23346
23624
  }
23347
- const entry = await adapter2.registerDoc({
23625
+ const entry = await target.registerDoc({
23348
23626
  title,
23349
23627
  type,
23350
23628
  path: path7,
@@ -23370,16 +23648,16 @@ async function handleDocRegister(adapter2, args, config2) {
23370
23648
  }
23371
23649
  if (body === void 0) {
23372
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._";
23373
- } else if (typeof adapter2.storeDocBody !== "function") {
23651
+ } else if (typeof target.storeDocBody !== "function") {
23374
23652
  bodyNote = "\n\n_Body not stored \u2014 this adapter does not support body storage._";
23375
23653
  } else {
23376
- const decision = await checkDocStorageCap(adapter2, Buffer.byteLength(body, "utf8"));
23654
+ const decision = await checkDocStorageCap(target, Buffer.byteLength(body, "utf8"));
23377
23655
  if (!decision.storeBody) {
23378
23656
  bodyNote = `
23379
23657
 
23380
23658
  ${decision.message}`;
23381
23659
  } else {
23382
- const result = await adapter2.storeDocBody({
23660
+ const result = await target.storeDocBody({
23383
23661
  docId: entry.id,
23384
23662
  body,
23385
23663
  // Resolved by resolveDocVisibility above — the SAME resolution the
@@ -23403,7 +23681,7 @@ ${decision.message}`;
23403
23681
  durability = "";
23404
23682
  }
23405
23683
  return textResponse(
23406
- `**Registered:** ${entry.title}
23684
+ `**Registered:** ${entry.title}${overrideNote}
23407
23685
  - **Path:** ${entry.path}
23408
23686
  - **Type:** ${entry.type} | **Status:** ${entry.status}
23409
23687
  - **Visibility:** ${visibilityLabel}
@@ -23967,7 +24245,7 @@ var buildExecuteTool = {
23967
24245
  },
23968
24246
  fixed_issues: {
23969
24247
  type: "array",
23970
- description: `cycle_learnings UUIDs of discovered issues this build FIXED. Stamps resolved_at (via the existing discovered_issue_resolve path) so the hub's "What PAPI caught" surface counts them as fixed on the caught\u2192fixed ledger \u2014 triage-and-fix at build time, no separate tool call. Distinct from resolves_learnings, which only LINKS a learning to this task without closing it. Best-effort and idempotent.`,
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.`,
23971
24249
  items: { type: "string" }
23972
24250
  },
23973
24251
  production_verification: {
@@ -24325,7 +24603,7 @@ ${entries}`;
24325
24603
 
24326
24604
  **OPEN DISCOVERED ISSUES** (${top.length} shown):
24327
24605
  ${rows}
24328
- If this build fixes any of these, pass their UUIDs in \`fixed_issues\` on complete \u2014 that stamps them FIXED on the caught\u2192fixed ledger. Do not fix out-of-scope issues just to clear the list.`;
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.`;
24329
24607
  }
24330
24608
  }
24331
24609
  } catch {
@@ -24572,8 +24850,25 @@ Your report was NOT discarded and the task is NOT yet Done \u2014 re-send with \
24572
24850
  fixedNote = `
24573
24851
 
24574
24852
  \u2705 Marked ${fixedResolvedCount} discovered issue(s) FIXED \u2014 resolved_at stamped, now counted as fixed on the hub's caught\u2192fixed ledger.`;
24575
- } else if (discoveredIssues && discoveredIssues.trim() !== "" && !/^none\b/i.test(discoveredIssues.trim())) {
24576
- fixedNote = "\n\n\u2139\uFE0F This build filed discovered issues but passed no `fixed_issues`. When a future build fixes one, pass its UUID in `fixed_issues` so it counts as FIXED (not just auto-cleared) on the hub ledger.";
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
+ }
24577
24872
  }
24578
24873
  return textResponse(formatCompleteResult(result) + fixedNote + docsNote + batchRollupNote);
24579
24874
  } catch (err) {
@@ -25603,6 +25898,13 @@ init_git();
25603
25898
 
25604
25899
  // src/services/ad-hoc.ts
25605
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
+ }
25606
25908
  function resolveAdHocCycle(cycle, latest, latestComplete) {
25607
25909
  if (cycle === void 0) return null;
25608
25910
  if (typeof cycle === "number") return cycle;
@@ -25657,8 +25959,32 @@ async function recordAdHoc(adapter2, input) {
25657
25959
  ...targetCycle !== null ? { cycle: targetCycle } : {},
25658
25960
  notes: input.notes ? `[ad-hoc] ${input.notes}` : "[ad-hoc]",
25659
25961
  taskType: input.taskType || "task",
25660
- 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
+ })()
25661
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
+ }
25662
25988
  }
25663
25989
  const report = {
25664
25990
  uuid: randomUUID15(),
@@ -25748,6 +26074,10 @@ var adHocTool = {
25748
26074
  hold: {
25749
26075
  type: "boolean",
25750
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."
25751
26081
  }
25752
26082
  },
25753
26083
  required: []
@@ -25784,7 +26114,18 @@ async function handleAdHoc(adapter2, config2, args) {
25784
26114
  else if (rawCycle === "current" || rawCycle === "next-if-plan-not-run") cycleArg = rawCycle;
25785
26115
  const stageArg = args.stage === "release" ? "release" : void 0;
25786
26116
  const holdArg = args.hold === true;
25787
- 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, {
25788
26129
  title: title || "",
25789
26130
  taskId,
25790
26131
  notes: rawNotes,
@@ -25797,9 +26138,11 @@ async function handleAdHoc(adapter2, config2, args) {
25797
26138
  owner: config2.projectOwner,
25798
26139
  cycle: cycleArg,
25799
26140
  stage: stageArg,
25800
- hold: holdArg
26141
+ hold: holdArg,
26142
+ currentBranch,
26143
+ baseBranch
25801
26144
  });
25802
- if (!holdArg && isGitAvailable() && isGitRepo(config2.projectRoot)) {
26145
+ if (!holdArg && gitUsable) {
25803
26146
  try {
25804
26147
  stageDirAndCommit(
25805
26148
  config2.projectRoot,
@@ -25817,7 +26160,7 @@ async function handleAdHoc(adapter2, config2, args) {
25817
26160
  const branch = `feat/${result.task.id}`;
25818
26161
  let collisionBlock = "";
25819
26162
  try {
25820
- const board = await adapter2.queryBoard({ status: ["In Progress"] });
26163
+ const board = await target.queryBoard({ status: ["In Progress"] });
25821
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);
25822
26165
  const collision = detectWorktreeCollision({
25823
26166
  taskId: result.task.id,
@@ -25841,7 +26184,7 @@ async function handleAdHoc(adapter2, config2, args) {
25841
26184
  } catch {
25842
26185
  }
25843
26186
  return textResponse(
25844
- `**${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.
25845
26188
 
25846
26189
  ## Held for the next cycle \u2014 branch + commit, do NOT merge
25847
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.
@@ -25859,7 +26202,7 @@ _To correct: board_edit ${result.task.id} with updated fields._`
25859
26202
  );
25860
26203
  }
25861
26204
  return textResponse(
25862
- `**${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.
25863
26206
  _To correct: board_edit ${result.task.id} with updated fields._`
25864
26207
  );
25865
26208
  }
@@ -29129,18 +29472,7 @@ async function getHierarchyPosition(adapter2, projectId) {
29129
29472
  return void 0;
29130
29473
  }
29131
29474
  }
29132
- async function getLatestGitTag(projectRoot) {
29133
- try {
29134
- const { stdout } = await execFileAsync2("git", ["describe", "--tags", "--abbrev=0"], {
29135
- encoding: "utf-8",
29136
- cwd: projectRoot,
29137
- timeout: 2e3
29138
- });
29139
- return stdout.trim() || null;
29140
- } catch {
29141
- return null;
29142
- }
29143
- }
29475
+ var GIT_TAG_TIMEOUT_MS = 2e3;
29144
29476
  async function checkNpmVersionDrift() {
29145
29477
  try {
29146
29478
  const pkgPath = join17(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
@@ -29486,7 +29818,7 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
29486
29818
  // Latest git tag + npm version drift (both exec calls with own timeouts).
29487
29819
  // task-2172: git-tag stays (the latest tag is part of the core summary);
29488
29820
  // version-drift is enrichment, gated behind `full`/deep_housekeeping.
29489
- tracked("git-tag", () => getLatestGitTag(config2.projectRoot)),
29821
+ tracked("git-tag", async () => getLatestTag(config2.projectRoot, GIT_TAG_TIMEOUT_MS)),
29490
29822
  tracked("npm-version-drift", async () => fullEnrichment ? checkNpmVersionDrift() : void 0),
29491
29823
  // Research Signals — research docs with pending actions since last strategy review.
29492
29824
  // task-2172: heavy (doc search + AD cross-reference) and rarely actioned
@@ -32045,6 +32377,7 @@ If this is legitimate, reach out at https://getpapi.ai and we'll lift it \u2014
32045
32377
  }
32046
32378
 
32047
32379
  // src/server.ts
32380
+ var mdModeWarned = false;
32048
32381
  var DEFAULT_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_TOOL_TIMEOUT_MS ?? "30000", 10);
32049
32382
  var LONG_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_LONG_TOOL_TIMEOUT_MS ?? "180000", 10);
32050
32383
  var WEDGE_PENDING_FRACTION = Math.min(1, Math.max(0, parseFloat(process.env.PAPI_WEDGE_PENDING_FRACTION ?? "0.6")));
@@ -32221,7 +32554,8 @@ function createServer(adapter2, config2) {
32221
32554
  // task-1801: `resources` capability for the PAPI read surface exposed as MCP resources.
32222
32555
  { capabilities: { tools: {}, prompts: {}, resources: {} }, instructions: UNIVERSAL_FRAME }
32223
32556
  );
32224
- if (config2.adapterType === "md") {
32557
+ if (config2.adapterType === "md" && !mdModeWarned) {
32558
+ mdModeWarned = true;
32225
32559
  process.stderr.write(
32226
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"
32227
32561
  );
@@ -32548,6 +32882,7 @@ ${usageLine(decision.usage)}`;
32548
32882
  // src/transport-http.ts
32549
32883
  init_proxy_adapter();
32550
32884
  import { createServer as createHttpServer } from "http";
32885
+ import { createHash as createHash7 } from "crypto";
32551
32886
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
32552
32887
  var BEARER_PREFIX = "papi_";
32553
32888
  var BEARER_REGEX = /^(papi_|papi_oauth_)[a-f0-9]{64}$/;
@@ -32577,6 +32912,7 @@ var FRIENDLY_GET_HTML = `<!doctype html>
32577
32912
  <code>https://mcp.getpapi.ai/mcp</code>.</p>
32578
32913
  <a class="btn" href="https://getpapi.ai/docs/install">See the install guide \u2192</a>
32579
32914
  </div></body></html>`;
32915
+ var KNOWN_INSTALL_CLIENTS = /* @__PURE__ */ new Set(["claude-code-plugin"]);
32580
32916
  var MAX_BODY_BYTES = 1 * 1024 * 1024;
32581
32917
  var IP_RATE_WINDOW_MS = 6e4;
32582
32918
  var IP_RATE_MAX = 60;
@@ -32615,7 +32951,7 @@ function corsHeaders(origin) {
32615
32951
  return {
32616
32952
  "Access-Control-Allow-Origin": origin,
32617
32953
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
32618
- "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",
32619
32955
  "Vary": "Origin"
32620
32956
  };
32621
32957
  }
@@ -32657,6 +32993,48 @@ function sendError(res, err, extraHeaders = {}) {
32657
32993
  });
32658
32994
  res.end(JSON.stringify(err.body));
32659
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
+ }
32660
33038
  function startHttpTransport(opts) {
32661
33039
  const { port, host, baseConfig, pkgVersion: pkgVersion2, dataEndpoint } = opts;
32662
33040
  const httpServer = createHttpServer((req, res) => {
@@ -32758,21 +33136,11 @@ function startHttpTransport(opts) {
32758
33136
  ip,
32759
33137
  status: 401
32760
33138
  });
32761
- sendError(
32762
- res,
32763
- {
32764
- status: 401,
32765
- body: {
32766
- error: "Unauthorized",
32767
- reason: hasHeader ? "malformed_bearer" : "missing_bearer"
32768
- }
32769
- },
32770
- {
32771
- "WWW-Authenticate": `Bearer realm="papi", resource_metadata="${RESOURCE_METADATA_URL}"`
32772
- }
32773
- );
33139
+ sendUnauthorized(res, hasHeader ? "malformed_bearer" : "missing_bearer");
32774
33140
  return;
32775
33141
  }
33142
+ const clientHeader = req.headers["x-papi-client"];
33143
+ const installClient = typeof clientHeader === "string" && KNOWN_INSTALL_CLIENTS.has(clientHeader) ? clientHeader : "direct";
32776
33144
  const projectIdHeader = req.headers["x-papi-project-id"];
32777
33145
  const projectId = typeof projectIdHeader === "string" && projectIdHeader.length > 0 ? projectIdHeader : void 0;
32778
33146
  if (req.method !== "POST" && req.method !== "GET") {
@@ -32815,6 +33183,13 @@ function startHttpTransport(opts) {
32815
33183
  return;
32816
33184
  }
32817
33185
  }
33186
+ logEvent({
33187
+ level: "info",
33188
+ msg: "mcp_request",
33189
+ ip,
33190
+ bearer_prefix: bearerPrefix(bearer),
33191
+ install_client: installClient
33192
+ });
32818
33193
  void dispatchRequest({
32819
33194
  req,
32820
33195
  res,
@@ -32895,6 +33270,18 @@ Example: add \`project="${projects[0].slug}"\` to the tool arguments.`;
32895
33270
  res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) });
32896
33271
  res.end(JSON.stringify(payload));
32897
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
+ }
32898
33285
  function resolveEffectiveProjectId(body, headerProjectId) {
32899
33286
  const explicitProject = extractProjectOverride(body);
32900
33287
  try {
@@ -32904,17 +33291,59 @@ function resolveEffectiveProjectId(body, headerProjectId) {
32904
33291
  throw err;
32905
33292
  }
32906
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
+ }
32907
33302
  async function dispatchRequest(args) {
32908
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
+ }
32909
33322
  let effectiveProjectId = resolveEffectiveProjectId(body, projectId);
32910
33323
  if (effectiveProjectId === void 0) {
32911
- const toolName = extractToolName(body);
33324
+ const toolName = calledTool;
32912
33325
  if (toolName && !PROJECT_OPTIONAL_TOOLS.has(toolName)) {
32913
33326
  let projects = [];
33327
+ let probeFailed = false;
32914
33328
  try {
32915
- 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
+ });
32916
33334
  projects = await probe.listUserProjects();
32917
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;
32918
33347
  }
32919
33348
  if (projects.length === 1) {
32920
33349
  effectiveProjectId = projects[0].id;
@@ -32937,10 +33366,14 @@ async function dispatchRequest(args) {
32937
33366
  }
32938
33367
  }
32939
33368
  }
32940
- const adapter2 = new ProxyPapiAdapter({
33369
+ const adapter2 = createProxyAdapter({
32941
33370
  endpoint: dataEndpoint,
32942
33371
  apiKey: bearer,
32943
- 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")
32944
33377
  });
32945
33378
  const requestConfig = {
32946
33379
  ...baseConfig,