@papi-ai/server 0.7.34 → 0.7.36

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
@@ -51,6 +51,7 @@ __export(git_exports, {
51
51
  hasRemote: () => hasRemote,
52
52
  hasUncommittedChanges: () => hasUncommittedChanges,
53
53
  hasUnpushedCommits: () => hasUnpushedCommits,
54
+ isBranchMergedInto: () => isBranchMergedInto,
54
55
  isGhAvailable: () => isGhAvailable,
55
56
  isGitAvailable: () => isGitAvailable,
56
57
  isGitRepo: () => isGitRepo,
@@ -798,6 +799,15 @@ function listGroupedCycleBranches(cwd, cycleNum, baseBranch) {
798
799
  }
799
800
  return [...seen];
800
801
  }
802
+ function isBranchMergedInto(cwd, branch, baseBranch) {
803
+ try {
804
+ const tip = execFileSync("git", ["rev-parse", branch], { cwd, encoding: "utf-8" }).trim();
805
+ execFileSync("git", ["merge-base", "--is-ancestor", tip, baseBranch], { cwd, stdio: "ignore" });
806
+ return true;
807
+ } catch {
808
+ return false;
809
+ }
810
+ }
801
811
  function pickModuleCycleBranch(candidates, cycleNumber, module) {
802
812
  if (candidates.length === 0) return void 0;
803
813
  const expected = cycleBranchName(cycleNumber, module);
@@ -1199,6 +1209,17 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
1199
1209
  updateTaskStatus(id, status) {
1200
1210
  return this.invoke("updateTaskStatus", [id, status]);
1201
1211
  }
1212
+ // task-2155 (MU-3 task B): hosted/proxy claim write-path. The data-proxy gates
1213
+ // these via WRITE_METHODS (active editor may write, viewer cannot) and binds the
1214
+ // assignee to the bearer-derived caller server-side — the assigneeId passed here
1215
+ // is ignored by the edge function, so it cannot be spoofed to claim on another's
1216
+ // behalf. Mirrors pg-papi-adapter.claimTask/unclaimTask (proxy-parity enforced).
1217
+ claimTask(taskId, assigneeId) {
1218
+ return this.invoke("claimTask", [taskId, assigneeId]);
1219
+ }
1220
+ unclaimTask(taskId, assigneeId) {
1221
+ return this.invoke("unclaimTask", [taskId, assigneeId]);
1222
+ }
1202
1223
  recordTransition(taskId, fromStatus, toStatus, changedBy) {
1203
1224
  return this.invoke("recordTransition", [taskId, fromStatus, toStatus, changedBy]);
1204
1225
  }
@@ -3075,7 +3096,7 @@ var init_connection = __esm({
3075
3096
 
3076
3097
  // ../../node_modules/postgres/src/subscribe.js
3077
3098
  function Subscribe(postgres2, options) {
3078
- const subscribers = /* @__PURE__ */ new Map(), slot = "postgresjs_" + Math.random().toString(36).slice(2), state2 = {};
3099
+ const subscribers = /* @__PURE__ */ new Map(), slot = "postgresjs_" + Math.random().toString(36).slice(2), state = {};
3079
3100
  let connection2, stream, ended = false;
3080
3101
  const sql = subscribe.sql = postgres2({
3081
3102
  ...options,
@@ -3092,7 +3113,7 @@ function Subscribe(postgres2, options) {
3092
3113
  if (ended)
3093
3114
  return;
3094
3115
  stream = null;
3095
- state2.pid = state2.secret = void 0;
3116
+ state.pid = state.secret = void 0;
3096
3117
  connected(await init(sql, slot, options.publications));
3097
3118
  subscribers.forEach((event) => event.forEach(({ onsubscribe }) => onsubscribe()));
3098
3119
  },
@@ -3123,13 +3144,13 @@ function Subscribe(postgres2, options) {
3123
3144
  connected(x);
3124
3145
  onsubscribe();
3125
3146
  stream && stream.on("error", onerror);
3126
- return { unsubscribe, state: state2, sql };
3147
+ return { unsubscribe, state, sql };
3127
3148
  });
3128
3149
  }
3129
3150
  function connected(x) {
3130
3151
  stream = x.stream;
3131
- state2.pid = x.state.pid;
3132
- state2.secret = x.state.secret;
3152
+ state.pid = x.state.pid;
3153
+ state.secret = x.state.secret;
3133
3154
  }
3134
3155
  async function init(sql2, slot2, publications) {
3135
3156
  if (!publications)
@@ -3141,7 +3162,7 @@ function Subscribe(postgres2, options) {
3141
3162
  const stream2 = await sql2.unsafe(
3142
3163
  `START_REPLICATION SLOT ${slot2} LOGICAL ${x.consistent_point} (proto_version '1', publication_names '${publications}')`
3143
3164
  ).writable();
3144
- const state3 = {
3165
+ const state2 = {
3145
3166
  lsn: Buffer.concat(x.consistent_point.split("/").map((x2) => Buffer.from(("00000000" + x2).slice(-8), "hex")))
3146
3167
  };
3147
3168
  stream2.on("data", data);
@@ -3153,9 +3174,9 @@ function Subscribe(postgres2, options) {
3153
3174
  }
3154
3175
  function data(x2) {
3155
3176
  if (x2[0] === 119) {
3156
- parse(x2.subarray(25), state3, sql2.options.parsers, handle, options.transform);
3177
+ parse(x2.subarray(25), state2, sql2.options.parsers, handle, options.transform);
3157
3178
  } else if (x2[0] === 107 && x2[17]) {
3158
- state3.lsn = x2.subarray(1, 9);
3179
+ state2.lsn = x2.subarray(1, 9);
3159
3180
  pong();
3160
3181
  }
3161
3182
  }
@@ -3171,7 +3192,7 @@ function Subscribe(postgres2, options) {
3171
3192
  function pong() {
3172
3193
  const x2 = Buffer.alloc(34);
3173
3194
  x2[0] = "r".charCodeAt(0);
3174
- x2.fill(state3.lsn, 1);
3195
+ x2.fill(state2.lsn, 1);
3175
3196
  x2.writeBigInt64BE(BigInt(Date.now() - Date.UTC(2e3, 0, 1)) * BigInt(1e3), 25);
3176
3197
  stream2.write(x2);
3177
3198
  }
@@ -3183,12 +3204,12 @@ function Subscribe(postgres2, options) {
3183
3204
  function Time(x) {
3184
3205
  return new Date(Date.UTC(2e3, 0, 1) + Number(x / BigInt(1e3)));
3185
3206
  }
3186
- function parse(x, state2, parsers2, handle, transform) {
3207
+ function parse(x, state, parsers2, handle, transform) {
3187
3208
  const char = (acc, [k, v]) => (acc[k.charCodeAt(0)] = v, acc);
3188
3209
  Object.entries({
3189
3210
  R: (x2) => {
3190
3211
  let i = 1;
3191
- const r = state2[x2.readUInt32BE(i)] = {
3212
+ const r = state[x2.readUInt32BE(i)] = {
3192
3213
  schema: x2.toString("utf8", i += 4, i = x2.indexOf(0, i)) || "pg_catalog",
3193
3214
  table: x2.toString("utf8", i + 1, i = x2.indexOf(0, i + 1)),
3194
3215
  columns: Array(x2.readUInt16BE(i += 2)),
@@ -3215,12 +3236,12 @@ function parse(x, state2, parsers2, handle, transform) {
3215
3236
  },
3216
3237
  // Origin
3217
3238
  B: (x2) => {
3218
- state2.date = Time(x2.readBigInt64BE(9));
3219
- state2.lsn = x2.subarray(1, 9);
3239
+ state.date = Time(x2.readBigInt64BE(9));
3240
+ state.lsn = x2.subarray(1, 9);
3220
3241
  },
3221
3242
  I: (x2) => {
3222
3243
  let i = 1;
3223
- const relation = state2[x2.readUInt32BE(i)];
3244
+ const relation = state[x2.readUInt32BE(i)];
3224
3245
  const { row } = tuples(x2, relation.columns, i += 7, transform);
3225
3246
  handle(row, {
3226
3247
  command: "insert",
@@ -3229,7 +3250,7 @@ function parse(x, state2, parsers2, handle, transform) {
3229
3250
  },
3230
3251
  D: (x2) => {
3231
3252
  let i = 1;
3232
- const relation = state2[x2.readUInt32BE(i)];
3253
+ const relation = state[x2.readUInt32BE(i)];
3233
3254
  i += 4;
3234
3255
  const key = x2[i] === 75;
3235
3256
  handle(
@@ -3243,7 +3264,7 @@ function parse(x, state2, parsers2, handle, transform) {
3243
3264
  },
3244
3265
  U: (x2) => {
3245
3266
  let i = 1;
3246
- const relation = state2[x2.readUInt32BE(i)];
3267
+ const relation = state[x2.readUInt32BE(i)];
3247
3268
  i += 4;
3248
3269
  const key = x2[i] === 75;
3249
3270
  const xs = key || x2[i] === 79 ? tuples(x2, relation.columns, i += 3, transform) : null;
@@ -3954,12 +3975,12 @@ function resolveDatabaseUrl() {
3954
3975
  function classifyWedged(rows) {
3955
3976
  const wedged = [];
3956
3977
  for (const r of rows) {
3957
- const state2 = (r.state ?? "").toLowerCase();
3978
+ const state = (r.state ?? "").toLowerCase();
3958
3979
  const stateAge = Number(r.state_age ?? 0);
3959
3980
  const queryAge = Number(r.query_age ?? 0);
3960
- if (state2.startsWith("idle in transaction") && stateAge > WEDGED_IDLE_TX_SECONDS) {
3981
+ if (state.startsWith("idle in transaction") && stateAge > WEDGED_IDLE_TX_SECONDS) {
3961
3982
  wedged.push({ pid: r.pid, state: r.state ?? "unknown", ageSeconds: Math.round(stateAge), reason: "idle-in-transaction" });
3962
- } else if (state2 === "active" && queryAge > WEDGED_ACTIVE_SECONDS) {
3983
+ } else if (state === "active" && queryAge > WEDGED_ACTIVE_SECONDS) {
3963
3984
  wedged.push({ pid: r.pid, state: r.state ?? "unknown", ageSeconds: Math.round(queryAge), reason: "long-active" });
3964
3985
  }
3965
3986
  }
@@ -4618,13 +4639,35 @@ import {
4618
4639
 
4619
4640
  // src/config.ts
4620
4641
  import path from "path";
4642
+
4643
+ // src/lib/help-text.ts
4644
+ var PAPI_DISCORD = "https://discord.gg/papi";
4645
+ var PAPI_DOCS = "https://getpapi.ai/docs/install";
4646
+ var HELP_FOOTER = `
4647
+ Need a hand?
4648
+ \u2022 Make sure you're in your project's root folder (where your code lives), then say "run setup".
4649
+ \u2022 Diagnose your setup: npx @papi-ai/server doctor
4650
+ \u2022 Hit a PAPI bug or have an idea? Say "report a bug to PAPI" \u2014 it goes straight to the
4651
+ PAPI team (not your project backlog), with diagnostics attached.
4652
+ \u2022 Get help: ${PAPI_DISCORD} \u2022 Docs: ${PAPI_DOCS}
4653
+ `;
4654
+ var HELP_FOOTER_MD = `
4655
+
4656
+ ## Need a hand?
4657
+ - Make sure you're in your project's **root folder** (where your code lives), then say "run setup".
4658
+ - Diagnose your setup: \`npx @papi-ai/server doctor\`
4659
+ - Hit a PAPI bug or have an idea? Say **"report a bug to PAPI"** \u2014 it goes straight to the PAPI team (not your project backlog), with diagnostics attached.
4660
+ - Get help: [Discord](${PAPI_DISCORD}) \xB7 [Docs](${PAPI_DOCS})
4661
+ `;
4662
+
4663
+ // src/config.ts
4621
4664
  function loadConfig() {
4622
4665
  const projectArgIdx = process.argv.indexOf("--project");
4623
4666
  const configuredRoot = projectArgIdx !== -1 ? process.argv[projectArgIdx + 1] : process.env.PAPI_PROJECT_DIR;
4624
4667
  const projectRoot = configuredRoot ?? process.cwd();
4625
4668
  if (!configuredRoot) {
4626
4669
  process.stderr.write(
4627
- '\nPAPI is running but no project is configured.\nSay "run setup" to get started.\n\n'
4670
+ "\nPAPI is running, but no project is configured here.\n" + HELP_FOOTER + "\n"
4628
4671
  );
4629
4672
  }
4630
4673
  const anthropicApiKey = process.env.PAPI_API_KEY ?? "";
@@ -4672,7 +4715,7 @@ If you intentionally self-host, set PAPI_SELF_HOST=1 in your environment to bypa
4672
4715
  }
4673
4716
  if (databaseUrl && !papiEndpoint && !explicitAdapter && !selfHostOptIn && !dataApiKey) {
4674
4717
  throw new Error(
4675
- 'DATABASE_URL is set but PAPI_ADAPTER is not specified.\n\nIf you want to self-host PAPI against your own database, add to your .mcp.json env:\n "PAPI_ADAPTER": "pg"\n\nIf you are an external user with a PAPI account, remove DATABASE_URL from your config.\nExternal users authenticate via PAPI_DATA_API_KEY \u2014 no DATABASE_URL needed.\n\nInstall guide: https://getpapi.ai/docs/install'
4718
+ 'DATABASE_URL is set but PAPI_ADAPTER is not specified.\n\nIf you want to self-host PAPI against your own database, add to your .mcp.json env:\n "PAPI_ADAPTER": "pg"\n\nIf you are an external user with a PAPI account, remove DATABASE_URL from your config.\nExternal users authenticate via PAPI_DATA_API_KEY \u2014 no DATABASE_URL needed.\n' + HELP_FOOTER
4676
4719
  );
4677
4720
  }
4678
4721
  let adapterType = papiEndpoint ? "pg" : databaseUrl && (explicitAdapter === "pg" || selfHostOptIn) ? "pg" : dataEndpoint ? "proxy" : explicitAdapter ? explicitAdapter : "proxy";
@@ -4691,7 +4734,8 @@ and unlocks dashboard features when you're ready.
4691
4734
  After signing up, add this to your .mcp.json env config:
4692
4735
  "PAPI_USER_ID": "your-email@example.com"
4693
4736
 
4694
- Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env config.`
4737
+ Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env config.
4738
+ ` + HELP_FOOTER
4695
4739
  );
4696
4740
  }
4697
4741
  return {
@@ -5163,7 +5207,10 @@ function toCycleTask(raw) {
5163
5207
  closureReason: raw.closure_reason || void 0,
5164
5208
  buildHandoff: raw.build_handoff ? parseBuildHandoff(raw.build_handoff) ?? void 0 : void 0,
5165
5209
  buildReport: raw.build_report || void 0,
5166
- scopeClass: raw.scope_class === "brief" ? "brief" : "task"
5210
+ scopeClass: raw.scope_class === "brief" ? "brief" : "task",
5211
+ assigneeId: raw.assignee_id || void 0,
5212
+ claimSource: raw.claim_source === "pool" || raw.claim_source === "self_generated" ? raw.claim_source : void 0,
5213
+ reviewerId: raw.reviewer_id || void 0
5167
5214
  };
5168
5215
  }
5169
5216
  function sanitizeDelimiters(value) {
@@ -5196,6 +5243,9 @@ function fromCycleTask(task) {
5196
5243
  if (task.buildHandoff) raw.build_handoff = sanitizeDelimiters(serializeBuildHandoff(task.buildHandoff));
5197
5244
  if (task.buildReport) raw.build_report = sanitizeDelimiters(task.buildReport);
5198
5245
  if (task.scopeClass && task.scopeClass !== "task") raw.scope_class = task.scopeClass;
5246
+ if (task.assigneeId) raw.assignee_id = task.assigneeId;
5247
+ if (task.claimSource) raw.claim_source = task.claimSource;
5248
+ if (task.reviewerId) raw.reviewer_id = task.reviewerId;
5199
5249
  return raw;
5200
5250
  }
5201
5251
  function mergeConflictHint(content) {
@@ -5262,6 +5312,7 @@ function filterTasks(tasks, options) {
5262
5312
  if (options.reviewed !== void 0 && task.reviewed !== options.reviewed) return false;
5263
5313
  if (options.module && task.module !== options.module) return false;
5264
5314
  if (options.epic && task.epic !== options.epic) return false;
5315
+ if (options.assigneeId && task.assigneeId !== options.assigneeId) return false;
5265
5316
  return true;
5266
5317
  });
5267
5318
  }
@@ -6043,6 +6094,7 @@ function toCycle(raw) {
6043
6094
  taskIds: raw.task_ids ?? []
6044
6095
  };
6045
6096
  if (raw.end_date) cycle.endDate = raw.end_date;
6097
+ if (raw.user_id) cycle.userId = raw.user_id;
6046
6098
  return cycle;
6047
6099
  }
6048
6100
  function fromCycle(cycle) {
@@ -6056,6 +6108,7 @@ function fromCycle(cycle) {
6056
6108
  task_ids: cycle.taskIds
6057
6109
  };
6058
6110
  if (cycle.endDate) raw.end_date = cycle.endDate;
6111
+ if (cycle.userId) raw.user_id = cycle.userId;
6059
6112
  return raw;
6060
6113
  }
6061
6114
  function extractYamlBlock2(content) {
@@ -6378,6 +6431,66 @@ var MdFileAdapter = class {
6378
6431
  async updateTaskStatus(id, status) {
6379
6432
  return this.updateTask(id, { status });
6380
6433
  }
6434
+ /**
6435
+ * task-1763 (C293): atomic compare-and-swap task claim. First-claim-wins —
6436
+ * sets assigneeId only if the task is currently unclaimed. The markdown adapter
6437
+ * is single-process, so the read-check-write is trivially atomic here; the real
6438
+ * concurrency guarantee lives in the pg adapter's RETURNING CAS. Returns the
6439
+ * claimed task, or null if it was already claimed or does not exist.
6440
+ *
6441
+ * task-2071 (MU-3): the pooled-task invariant is `assigneeId == null && cycle
6442
+ * == null` — a task already pulled into someone's cycle is NOT in the pool and
6443
+ * cannot be claimed. Sets claimSource='pool' on success.
6444
+ */
6445
+ async claimTask(taskId, assigneeId) {
6446
+ const content = await this.read("SPRINT_BOARD.md");
6447
+ const tasks = parseBoard(content);
6448
+ const idx = tasks.findIndex((t) => t.id === taskId);
6449
+ if (idx === -1) return null;
6450
+ if (tasks[idx].assigneeId || tasks[idx].cycle != null) return null;
6451
+ tasks[idx] = { ...tasks[idx], assigneeId, claimSource: "pool" };
6452
+ await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
6453
+ return tasks[idx];
6454
+ }
6455
+ /**
6456
+ * task-2071 (C293, MU-3): claimer-only release. Clears assigneeId + claimSource
6457
+ * only if the task is currently assigned to `assigneeId` and has not entered
6458
+ * review. Returns the unclaimed task, or null if the caller is not the claimer,
6459
+ * the task has progressed, or it does not exist.
6460
+ */
6461
+ async unclaimTask(taskId, assigneeId) {
6462
+ const content = await this.read("SPRINT_BOARD.md");
6463
+ const tasks = parseBoard(content);
6464
+ const idx = tasks.findIndex((t2) => t2.id === taskId);
6465
+ if (idx === -1) return null;
6466
+ const t = tasks[idx];
6467
+ if (t.assigneeId !== assigneeId) return null;
6468
+ if (t.status === "In Review" || t.status === "Done") return null;
6469
+ const next = { ...t };
6470
+ delete next.assigneeId;
6471
+ delete next.claimSource;
6472
+ tasks[idx] = next;
6473
+ await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
6474
+ return tasks[idx];
6475
+ }
6476
+ /**
6477
+ * task-2072 (C293, MU-4): atomic review claim. Sets reviewerId only if the task
6478
+ * is In Review and not yet claimed for review (reviewerId == null). First-claim-
6479
+ * wins. Returns the claimed task, or null if already review-claimed / not In
6480
+ * Review / missing.
6481
+ */
6482
+ async claimReview(taskId, reviewerId) {
6483
+ const content = await this.read("SPRINT_BOARD.md");
6484
+ const tasks = parseBoard(content);
6485
+ const idx = tasks.findIndex((t2) => t2.id === taskId);
6486
+ if (idx === -1) return null;
6487
+ const t = tasks[idx];
6488
+ if (t.status !== "In Review") return null;
6489
+ if (t.reviewerId) return null;
6490
+ tasks[idx] = { ...t, reviewerId };
6491
+ await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
6492
+ return tasks[idx];
6493
+ }
6381
6494
  async recordTransition(_taskId, _fromStatus, _toStatus, _changedBy) {
6382
6495
  }
6383
6496
  // --- Build Reports ---
@@ -6817,6 +6930,8 @@ ${footer}`);
6817
6930
  - **source:** ${full.source}
6818
6931
  ` + (full.sourceRef ? `- **sourceRef:** ${full.sourceRef}
6819
6932
  ` : "") + (full.detail ? `- **detail:** ${full.detail}
6933
+ ` : "") + (full.evidenceRef ? `- **evidenceRef:** ${full.evidenceRef}
6934
+ ` : "") + (full.metricDelta ? `- **metricDelta:** ${JSON.stringify(full.metricDelta)}
6820
6935
  ` : "") + `- **createdAt:** ${full.createdAt}
6821
6936
 
6822
6937
  ---
@@ -6850,8 +6965,18 @@ ${footer}`);
6850
6965
  const sourceMatch = block.match(/\*\*source:\*\*\s+(.+)/);
6851
6966
  const sourceRefMatch = block.match(/\*\*sourceRef:\*\*\s+(.+)/);
6852
6967
  const detailMatch = block.match(/\*\*detail:\*\*\s+(.+)/);
6968
+ const evidenceRefMatch = block.match(/\*\*evidenceRef:\*\*\s+(.+)/);
6969
+ const metricDeltaMatch = block.match(/\*\*metricDelta:\*\*\s+(.+)/);
6853
6970
  const createdAtMatch = block.match(/\*\*createdAt:\*\*\s+(.+)/);
6854
6971
  if (!idMatch || !sourceMatch || !createdAtMatch) continue;
6972
+ let metricDelta;
6973
+ if (metricDeltaMatch?.[1]) {
6974
+ try {
6975
+ metricDelta = JSON.parse(metricDeltaMatch[1].trim());
6976
+ } catch {
6977
+ metricDelta = null;
6978
+ }
6979
+ }
6855
6980
  events.push({
6856
6981
  id: idMatch[1].trim(),
6857
6982
  decisionId: headingMatch[1],
@@ -6860,6 +6985,8 @@ ${footer}`);
6860
6985
  source: sourceMatch[1].trim(),
6861
6986
  sourceRef: sourceRefMatch?.[1]?.trim(),
6862
6987
  detail: detailMatch?.[1]?.trim(),
6988
+ evidenceRef: evidenceRefMatch?.[1]?.trim(),
6989
+ metricDelta,
6863
6990
  createdAt: createdAtMatch[1].trim()
6864
6991
  });
6865
6992
  }
@@ -7667,8 +7794,18 @@ function formatDetailedTask(t) {
7667
7794
  Reviewed: ${t.reviewed}${t.dependsOn ? ` | Depends on: ${t.dependsOn}` : ""}${hasHandoff ? " | Has BUILD HANDOFF: yes" : ""}${t.docRef ? ` | Doc ref: ${t.docRef}` : ""}${t.opportunity ? ` | Opportunity: ${t.opportunity}` : ""}${notes ? `
7668
7795
  Notes: ${notes}` : ""}`;
7669
7796
  }
7670
- function formatBoardForPlan(tasks, filters, currentCycle) {
7671
- if (tasks.length === 0) return "No tasks on the board.";
7797
+ var PRIORITY_RANK = { "P0 Critical": 0, "P1 High": 1, "P2 Medium": 2, "P3 Low": 3 };
7798
+ function formatDeferredForPlan(deferred) {
7799
+ if (deferred.length === 0) return "";
7800
+ const sorted = [...deferred].sort(
7801
+ (a, b2) => (PRIORITY_RANK[a.priority] ?? 9) - (PRIORITY_RANK[b2.priority] ?? 9)
7802
+ );
7803
+ return `**Deferred (${deferred.length} parked \u2014 re-triageable, NOT active candidates):**
7804
+ These were parked, not deleted. Do NOT auto-schedule them. If a task's blocker has cleared or it now fits this cycle's strategic theme, un-defer it with a \`boardCorrections\` "promote" entry; otherwise leave it parked.
7805
+ ` + sorted.map(formatCompactTask).join("\n");
7806
+ }
7807
+ function formatBoardForPlan(tasks, filters, currentCycle, deferredTasks = []) {
7808
+ if (tasks.length === 0 && deferredTasks.length === 0) return "No tasks on the board.";
7672
7809
  let active = tasks.filter((t) => !PLAN_EXCLUDED_STATUSES.has(t.status));
7673
7810
  const excludedCount = tasks.length - active.length;
7674
7811
  if (filters) {
@@ -7677,13 +7814,24 @@ function formatBoardForPlan(tasks, filters, currentCycle) {
7677
7814
  if (filters.epic) active = active.filter((t) => t.epic === filters.epic);
7678
7815
  if (filters.priority) active = active.filter((t) => t.priority === filters.priority);
7679
7816
  }
7817
+ let deferred = deferredTasks.filter((t) => t.status === "Deferred");
7818
+ if (filters) {
7819
+ if (filters.phase) deferred = deferred.filter((t) => t.phase === filters.phase);
7820
+ if (filters.module) deferred = deferred.filter((t) => t.module === filters.module);
7821
+ if (filters.epic) deferred = deferred.filter((t) => t.epic === filters.epic);
7822
+ if (filters.priority) deferred = deferred.filter((t) => t.priority === filters.priority);
7823
+ }
7824
+ const deferredSection = formatDeferredForPlan(deferred);
7680
7825
  const userFilteredCount = tasks.length - excludedCount - active.length;
7681
7826
  const filterParts = [];
7682
7827
  if (excludedCount > 0) filterParts.push(`${excludedCount} completed filtered`);
7683
7828
  if (userFilteredCount > 0) filterParts.push(`${userFilteredCount} excluded by filters`);
7684
7829
  const filterSuffix = filterParts.length > 0 ? filterParts.join(", ") : "";
7685
7830
  if (active.length === 0) {
7686
- return `No active tasks on the board (${filterSuffix || "all filtered"}).`;
7831
+ const base = `No active tasks on the board (${filterSuffix || "all filtered"}).`;
7832
+ return deferredSection ? `${base}
7833
+
7834
+ ${deferredSection}` : base;
7687
7835
  }
7688
7836
  const byCounts = (statuses) => statuses.map((s) => {
7689
7837
  const n = active.filter((t) => t.status === s).length;
@@ -7692,9 +7840,12 @@ function formatBoardForPlan(tasks, filters, currentCycle) {
7692
7840
  const summary = `Board: ${active.length} active tasks (${byCounts(["Backlog", "In Cycle", "Ready", "In Progress", "In Review", "Blocked"])})` + (filterSuffix ? ` \u2014 ${filterSuffix}` : "");
7693
7841
  if (currentCycle === void 0) {
7694
7842
  const formatted = active.map(formatDetailedTask).join("\n\n");
7695
- return `${summary}
7843
+ const body = `${summary}
7696
7844
 
7697
7845
  ${formatted}`;
7846
+ return deferredSection ? `${body}
7847
+
7848
+ ${deferredSection}` : body;
7698
7849
  }
7699
7850
  const recent = [];
7700
7851
  const stable = [];
@@ -7715,6 +7866,7 @@ ${formatted}`;
7715
7866
  ` + stable.map(formatCompactTask).join("\n")
7716
7867
  );
7717
7868
  }
7869
+ if (deferredSection) sections.push(deferredSection);
7718
7870
  return sections.join("\n\n");
7719
7871
  }
7720
7872
  function formatCandidateTaskFullNotes(tasks) {
@@ -8166,11 +8318,19 @@ This is Cycle 0 \u2014 the first planning cycle for a brand-new project.
8166
8318
 
8167
8319
  2. **North Star** \u2014 Propose a one-sentence North Star statement, a success metric, and a key metric.
8168
8320
 
8169
- 3. **Initial Board** \u2014 Generate 3-5 tasks based on the project's actual tech stack and goals:
8321
+ 3. **Initial Board** \u2014 Generate 3-5 tasks based on the project's actual tech stack and goals.
8322
+
8323
+ **Sequencing principle \u2014 local-first / thinnest-vertical-slice-first (AD-51).** Plan the way the everyday builder gets value, NOT the way a backend team scaffolds. Order the first cycle so the user SEES the core loop working as fast as possible:
8324
+ 1. **Thinnest visible vertical slice first** \u2014 the core loop on screen, running on local / mock / hardcoded data. Cycle 1 should make the thing actually work in front of the user.
8325
+ 2. **Persistence / real data model** \u2014 only after the slice exists.
8326
+ 3. **Auth** \u2014 later, and **NEVER third-party OAuth (Google/GitHub/etc.) as an opening task.** Start with no-auth or the simplest local auth.
8327
+ 4. **RLS / cloud hardening / multi-tenant security** \u2014 last. These are not cycle-1 work for a new project.
8328
+ Defer infrastructure (cloud DB schema, RLS, OAuth providers, large data seeds) until a working slice exists. A first cycle that opens with "Supabase schema + RLS + third-party OAuth + a big data seed" is the exact anti-pattern this rule exists to prevent.
8329
+
8170
8330
  - Infer the project type from the brief/description (CLI, web app, mobile app, API, library, game, data pipeline, etc.)
8171
- - Task 1: Project-appropriate setup (toolchain, dependencies, config \u2014 NOT "scaffolding" if the project already has code)
8172
- - Task 2: Core functionality that proves the concept works (data model, main loop, core algorithm \u2014 whatever the project needs first)
8173
- - Tasks 3-5: First deliverables that demonstrate value, broken into small steps appropriate for the project type
8331
+ - Task 1: The thinnest vertical slice that puts the core loop in front of the user (local/mock data is fine \u2014 and preferred \u2014 at this stage). NOT infra scaffolding unless the project literally has no runnable surface without it.
8332
+ - Task 2: The next slice, or the minimal persistence the core loop needs \u2014 only once the slice works.
8333
+ - Tasks 3-5: Further value-demonstrating slices, broken into small steps appropriate for the project type. Keep auth, cloud, and hardening OUT of the opening cycle unless the brief makes them the literal core product.
8174
8334
  - Do NOT assume web-app patterns (routes, pages, components) unless the brief explicitly describes a web application
8175
8335
  - All tasks: status Backlog, priority P1-P2, reviewed true, phase "Phase 1"
8176
8336
 
@@ -8333,7 +8493,7 @@ Standard planning cycle with full board review.
8333
8493
  }
8334
8494
  parts.push(`
8335
8495
  6. **Maturity Gate** \u2014 Before scheduling any task, check whether the project is ready for it:
8336
- - **Cycle number as signal:** A Cycle 3 project should not be scheduling OAuth, billing, or analytics tasks. Early cycles focus on core functionality and proving the concept works.
8496
+ - **Cycle number as signal (local-first sequencing, AD-51):** A Cycle 1-3 project should not be scheduling OAuth, billing, analytics, or RLS/cloud-hardening tasks. Early cycles sequence local-first / thinnest-vertical-slice-first: get the core loop working on local/mock data, THEN persistence, THEN auth (never third-party OAuth as an opening task), and RLS/cloud hardening LAST. Defer infrastructure until a working slice exists \u2014 plan the way the everyday builder gets value, not the way a backend team scaffolds.
8337
8497
  - **Phase prerequisites:** If the board has phases, tasks from later phases should only be scheduled when earlier phases have completed tasks (check Done count per phase). A task in "Phase 4: Monetisation" is premature if Phase 2 tasks are still in Backlog.
8338
8498
  - **Dependency chain:** If a task's \`dependsOn\` references incomplete tasks, it cannot be scheduled regardless of priority.
8339
8499
  - **Task maturity:** Tasks with \`maturity: "raw"\` are unscoped ideas from the idea tool. The planner IS the scoping mechanism \u2014 scope them as part of planning. For raw tasks selected for a cycle: (a) derive clear scope, acceptance criteria, and effort from the title, notes, and project context, (b) upgrade them to \`maturity: "investigated"\` via a \`boardCorrections\` entry, and (c) generate a BUILD HANDOFF as normal. For research-type raw tasks, scope the handoff as an investigation task \u2014 the deliverable is findings + follow-up backlog tasks, not code. Only leave a raw task unscheduled if you genuinely cannot derive scope from the available context \u2014 note why in the cycle log. Tasks with \`maturity: "ready"\` or no maturity field are considered cycle-ready. Tasks with \`maturity: "investigated"\` have been scoped but may still need refinement \u2014 schedule them if priority warrants it.
@@ -8347,7 +8507,8 @@ Standard planning cycle with full board review.
8347
8507
  - **P1 High** \u2014 Strategically aligned: directly advances the current horizon, phase, or Active Decision goals.
8348
8508
  - **P2 Medium** \u2014 Valuable but not strategically urgent: quality improvements, efficiency, polish, infra.
8349
8509
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
8350
- Within the same priority level, prefer tasks with the highest **impact-to-effort ratio**. Impact is measured by: (a) strategic alignment \u2014 does it advance the current horizon/phase? (b) unlocks other work \u2014 are tasks blocked by this? (c) user-facing \u2014 does it change what users see? (d) compounds over time \u2014 does it make future cycles faster? A high-impact Medium task beats a low-impact Small task at the same priority level. Justify in 2-3 sentences.
8510
+ Within the same priority level, prefer tasks with the highest **impact-to-effort ratio**. Impact is measured by: (a) strategic alignment \u2014 does it advance the current horizon/phase? (b) unlocks other work \u2014 are tasks blocked by this? (c) user-facing \u2014 does it change what users see? (d) compounds over time \u2014 does it make future cycles faster? A high-impact Medium task beats a low-impact Small task at the same priority level. **Capacity-fit (smaller effort) breaks ties between comparable-impact tasks \u2014 it does NOT outrank impact.** Do NOT let a task's smaller size be the deciding factor that repeatedly defers a higher-impact larger task; that is the exact failure mode this step guards against. Justify in 2-3 sentences.
8511
+ **\u26A0\uFE0F Large-task inclusion guarantee (task-2227, structural rule \u2014 not advisory):** Impact weighting alone has historically lost to capacity-fit \u2014 the planner repeatedly filled cycles with XS/S work and deferred high-impact L/XL P1s (9 P1s stalled 6-21 cycles as of C299; C297 named this the "sequencing inversion"). Enforce a HARD rule: **every cycle MUST contain at least one Large or XL strategically-aligned P1 task, UNLESS no unblocked L/XL P1 exists on the board.** Before finalising the selection, scan the candidate pool: if the selected cycle contains zero L/XL tasks AND \u22651 unblocked L/XL P1 exists in the backlog, you MUST force-include the highest-impact such task \u2014 displacing the lowest-impact small task if the budget requires it (respect the single-theme vs mixed-theme budget from the Cycle-sizing rule below rather than always adding on top of an already-full cycle). Record the outcome in a "Large-task guarantee:" line in \`cycleLogNotes\`: name the task the guarantee pulled in, OR "satisfied \u2014 cycle already contains an L/XL task", OR "no unblocked L/XL P1 on the board". Do NOT skip this because the small tasks look individually attractive \u2014 that attractiveness IS the bias.
8351
8512
  **Blocked tasks:** Tasks with status "Blocked" MUST be skipped during task selection \u2014 they are waiting on external dependencies or gates and cannot be built. Do NOT generate BUILD HANDOFFs for blocked tasks. Do NOT recommend blocked tasks. If a blocked task's gate has been resolved (check the notes and recent build reports), emit a \`boardCorrections\` entry to move it back to Backlog. Report blocked task count in the cycle log.
8352
8513
  **Cycle sizing:** Size the cycle based on what the selected tasks actually require \u2014 not a fixed budget. Select the highest-priority unblocked tasks, estimate each one's effort from its scope, and let the total emerge. The historical average effort from Methodology Trends is a reference point for calibration, not a target or floor. A healthy cycle has 6-10 tasks. Cycles with fewer than 5 tasks require explicit justification in the cycle log \u2014 explain why more tasks could not be included. When the backlog has 10+ tasks, the cycle SHOULD have 6+ tasks \u2014 undersized cycles waste planning overhead relative to the available work. If fewer than 5 tasks qualify after filtering (blocked, deferred, raw), check Deferred tasks \u2014 some may be ready to un-defer via a \`boardCorrections\` entry. A 1-task cycle is almost never correct. Prefer grouping tasks by module or similarity \u2014 reduces context switching and enables shared branches during the build phase.
8353
8514
  **Theme-driven sizing:** Single-theme cycles (all tasks in the same module or epic) can absorb 25-30 effort points because builders maintain context across tasks. Mixed-theme cycles should stay at 15-20 effort points to limit context switching. Use the theme to determine the budget, not a fixed number.
@@ -8407,7 +8568,7 @@ ${AD_REJECTION_RULES}
8407
8568
  - **Architecture Notes:** If a pattern was established that needs follow-up (e.g. "shared service layer created, MCP migration needed"), propose the follow-up.
8408
8569
  - **Strategy gaps:** If an Active Decision has no board tasks supporting it, propose one.
8409
8570
  - **Dogfood observations:** Unactioned dogfood entries span FOUR categories (friction, methodology, signal, commercial) \u2014 consider ALL of them, not just friction. If an entry (with ID) maps to no existing task, propose one, matching task type to category: friction/signal \u2192 fix or improvement; methodology \u2192 process/tooling change; commercial \u2192 GTM/positioning. **CRITICAL: Include \`dogfood:<uuid>\` in the new task's \`notes\` field** (e.g. \`"notes": "dogfood:abc12345-..."\`). This links the task to the observation so the pipeline can track what was actioned. Without this annotation, the observation stays unactioned forever.
8410
- Create new tasks via the \`newTasks\` array in Part 2. Use \`new-N\` IDs in \`cycleHandoffs\` to reference them. **Limit: 3 new tasks per cycle** to prevent backlog bloat.
8571
+ Create new tasks via the \`newTasks\` array in Part 2. Use \`new-N\` IDs in \`cycleHandoffs\` to reference them. **New-task cap \u2014 scale it to backlog depth, do NOT hard-cap at 3:** when 5+ unblocked backlog candidates already exist, limit new tasks to 3 (there is plenty to select from, so prevent backlog bloat). When the backlog is thin (fewer than 5 unblocked candidates), create as many as needed to bring the cycle into the healthy 5-8 range \u2014 roughly \`6 \u2212 unblocked_candidates\` additional new tasks, derived from the brief, roadmap phases, and recent build reports \u2014 up to an absolute ceiling of **8 new tasks**. This removes the contradiction with the Cycle-sizing rule above (a healthy cycle is 6-10 tasks; <5 needs justification), which the old flat cap of 3 violated whenever the backlog was thin. Never invent filler to hit a number: every new task must trace to a real discovered issue, dogfood entry, roadmap phase, or brief item.
8411
8572
  **\u26A0\uFE0F DUPLICATE CHECK:** Before adding a task to \`newTasks\`, scan the Cycle Board above for any existing task with the same or very similar title/scope. If a matching task already exists (even with slightly different wording), do NOT create a duplicate \u2014 reference the existing task ID instead. The board already contains all active tasks; re-creating them wastes IDs and bloats the board.
8412
8573
  **\u26A0\uFE0F ALREADY-BUILT CHECK:** Before creating a task, check the recent build reports and cycle log for evidence that this capability was already shipped. If a recent build report shows this feature was completed (even under a different task name), do NOT create a new task for it. This is especially important for UI features, data models, and integrations that may already exist.`);
8413
8574
  parts.push(PLAN_FRAGMENT_PRODUCT_BRIEF);
@@ -8485,6 +8646,9 @@ function buildPlanUserMessage(ctx) {
8485
8646
  ""
8486
8647
  );
8487
8648
  }
8649
+ if (ctx.siblingRepoWarning) {
8650
+ parts.push("", ctx.siblingRepoWarning, "");
8651
+ }
8488
8652
  parts.push("", "---", "", "## PROJECT CONTEXT", "");
8489
8653
  if (ctx.contextTier) {
8490
8654
  parts.push(`**Context tier:** ${ctx.contextTier}`, "");
@@ -8840,7 +9004,9 @@ After your natural language output, include this EXACT format on its own line:
8840
9004
  {
8841
9005
  "id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
8842
9006
  "action": "confidence_change | modify | resolve | supersede | new | delete",
8843
- "body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)"
9007
+ "body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
9008
+ "evidenceRef": "string (optional) \u2014 for DELIBERATE decisions (modify / resolve / delete = validate/modify/invalidate), a pointer to the evidence that justified the change: a doc path (docs/research/foo.md), a build-report id, or a metric name. Builds the decision->outcome ledger. Omit if no concrete evidence.",
9009
+ "metricDelta": "object (optional) \u2014 { "metric": string, "before": number, "after": number, "delta": number } \u2014 which metric moved and by how much. Use a REAL metric name from cycle_metrics_snapshots where possible (e.g. scope_accuracy, velocity, est_actual_drift). Omit if no metric moved."
8844
9010
  }
8845
9011
  ],
8846
9012
  "decisionScores": [
@@ -8900,6 +9066,8 @@ The JSON must be valid. Use null for optional fields that don't apply.
8900
9066
  For activeDecisionUpdates, the body field must be the COMPLETE replacement text for the AD block (including the ### heading line).
8901
9067
  Only include ADs that need changes \u2014 omit unchanged ADs.${compressionNote}
8902
9068
 
9069
+ **Decision-outcome ledger (task-2168):** For DELIBERATE decisions \u2014 \`modify\`, \`resolve\`, or \`delete\` (i.e. you validated, modified, or invalidated an AD based on what actually happened) \u2014 include \`evidenceRef\` and/or \`metricDelta\` so the decision becomes a queryable ledger entry rather than freetext. \`evidenceRef\` points at WHAT justified the change (a doc path, a build-report id, or a metric name). \`metricDelta\` records WHICH metric moved (prefer a real metric from cycle_metrics_snapshots) and its before->after. This is **guidance, not a hard requirement** \u2014 if a deliberate change genuinely has no concrete evidence, omit both and proceed; the apply will record the event with a non-blocking warning.
9070
+
8903
9071
  ## PERSISTENCE RULES \u2014 READ THIS CAREFULLY
8904
9072
 
8905
9073
  Everything in Part 1 (natural language) is **display-only**. Part 2 (structured JSON) is what gets written to files.
@@ -9065,7 +9233,9 @@ After your natural language output, include this EXACT format on its own line:
9065
9233
  {
9066
9234
  "id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
9067
9235
  "action": "confidence_change | modify | resolve | supersede | new | delete",
9068
- "body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)"
9236
+ "body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
9237
+ "evidenceRef": "string (optional) \u2014 for DELIBERATE changes (modify / resolve / delete), a pointer to the justifying evidence: a doc path, a build-report id, or a metric name. Builds the decision->outcome ledger. Omit if none.",
9238
+ "metricDelta": "object (optional) \u2014 { "metric": string, "before": number, "after": number, "delta": number } \u2014 which metric moved. Prefer a real metric name from cycle_metrics_snapshots. Omit if none."
9069
9239
  }
9070
9240
  ],
9071
9241
  "phaseUpdates": [
@@ -9382,6 +9552,37 @@ Return a JSON array of 3-10 tasks. Each task must have:
9382
9552
  - Use the full complexity range: XS (config/one-liner), Small (one file), Medium (2-5 files), Large (cross-module), XL (architectural)
9383
9553
  - Tasks should be specific enough to execute without further investigation
9384
9554
  - Maximum 10 tasks \u2014 fewer is better if the codebase is well-maintained`;
9555
+ var VISION_TASKS_SYSTEM = `You are a product engineer turning a project VISION into a starter backlog for a brand-new project (no code exists yet). The user has described what they want to build; your job is to make that vision legible as concrete, buildable tasks.
9556
+
9557
+ IMPORTANT: You are running as a non-interactive API call. Do NOT ask questions. Produce tasks directly.
9558
+
9559
+ ## OUTPUT FORMAT
9560
+
9561
+ Return a JSON array of 15-20 tasks. Each task must have:
9562
+ - "title": Clear, actionable task title (start with a verb)
9563
+ - "priority": "P0 Critical", "P1 High", "P2 Medium", or "P3 Low"
9564
+ - "complexity": "XS", "Small", "Medium", "Large", or "XL"
9565
+ - "module": A module name inferred from the vision (e.g. "Core", "Auth", "Frontend", "API", "Payments")
9566
+ - "phase": A phase name ("Phase 1" for the first shippable slice, "Phase 2" for what follows, etc.)
9567
+ - "notes": 1-2 sentences tying this task to the user's stated vision
9568
+
9569
+ ## GUIDELINES
9570
+
9571
+ - Cover the VISION, not a generic app skeleton. Every task must trace to something the user actually described.
9572
+ - Sequence local-first / thinnest-vertical-slice-first (AD-51): the first few P0/P1 tasks should be the smallest path to a visibly working thing, NOT infrastructure scaffolding (no "set up CI", "configure auth provider", "design database schema" as opening tasks). Plan the way the user gets value, not the way a backend team scaffolds.
9573
+ - Mix: the first shippable feature slice (Phase 1), then the next features, then supporting/foundational work behind them.
9574
+ - 15-20 is a TARGET BAND for good vision coverage, NOT a quota. If the vision is genuinely small, produce fewer high-quality tasks. NEVER pad with filler ("add tests", "write docs", "refactor") to hit a number.
9575
+ - Use the full complexity range. Keep titles concrete enough to execute without re-deriving the vision.
9576
+ - Do NOT add PAPI-setup tasks \u2014 those are handled by the setup flow.`;
9577
+ function buildVisionTasksPrompt(inputs) {
9578
+ const line = (label, val) => val?.trim() ? `**${label}:** ${val.trim()}
9579
+ ` : "";
9580
+ return `Turn this project vision into a 15-20 item starter backlog.
9581
+
9582
+ **Project:** ${inputs.projectName}
9583
+ ${line("What it is", inputs.description)}${line("Target users", inputs.targetUsers)}${line("Problems it solves", inputs.problems)}${line("Project type", inputs.projectType)}
9584
+ You are ALSO generating this project's Product Brief in this same setup round \u2014 use that fuller vision as your primary source. Return a JSON array of 15-20 tasks (coverage over count; do not pad) that make this vision a buildable backlog, thinnest-shippable-slice first.`;
9585
+ }
9385
9586
  function buildInitialTasksPrompt(inputs) {
9386
9587
  const description = inputs.description?.trim() ? `**Description:** ${inputs.description}
9387
9588
  ` : `**Description:** (not provided \u2014 derive from the codebase analysis below)
@@ -9533,8 +9734,8 @@ function oneLine(ad) {
9533
9734
  }
9534
9735
  function formatHierarchy(horizons, stages) {
9535
9736
  if (!horizons.length && !stages.length) return "";
9536
- const activeHorizon = horizons.find((h) => h.status === "active") ?? horizons[0];
9537
- const activeStage = stages.find((s) => s.status === "active") ?? stages[0];
9737
+ const activeHorizon = horizons.find((h) => h.status === "In Progress") ?? horizons[0];
9738
+ const activeStage = stages.find((s) => s.status === "In Progress") ?? stages[0];
9538
9739
  const parts = [];
9539
9740
  if (activeHorizon) parts.push(activeHorizon.slug || activeHorizon.label);
9540
9741
  if (activeStage) parts.push(activeStage.slug || activeStage.label);
@@ -9557,7 +9758,7 @@ function validateHandoffScope(handoff) {
9557
9758
  if (!isMeaningful(handoff.scopeBoundary)) invalid.push("scopeBoundary");
9558
9759
  return invalid;
9559
9760
  }
9560
- async function prepareHandoffs(adapter2, _config, taskIds) {
9761
+ async function prepareHandoffs(adapter2, _config, taskIds, force = false) {
9561
9762
  const timer2 = startTimer();
9562
9763
  const cycles = await adapter2.readCycles();
9563
9764
  const activeCycle = cycles.find((c) => c.status === "active");
@@ -9571,10 +9772,10 @@ async function prepareHandoffs(adapter2, _config, taskIds) {
9571
9772
  const idSet = new Set(taskIds);
9572
9773
  cycleTasks = cycleTasks.filter((t) => idSet.has(t.id));
9573
9774
  }
9574
- const tasksNeedingHandoffs = cycleTasks.filter((t) => !t.buildHandoff);
9775
+ const tasksNeedingHandoffs = force ? cycleTasks : cycleTasks.filter((t) => !t.buildHandoff);
9575
9776
  if (tasksNeedingHandoffs.length === 0) {
9576
9777
  throw new Error(
9577
- taskIds?.length ? `All specified tasks already have BUILD HANDOFFs. Nothing to generate.` : `All ${cycleTasks.length} cycle task(s) already have BUILD HANDOFFs. Nothing to generate.`
9778
+ force ? taskIds?.length ? `No matching cycle task(s) found to regenerate. Check the task IDs are in the active cycle.` : `No cycle task(s) found to regenerate.` : taskIds?.length ? `All specified tasks already have BUILD HANDOFFs. Nothing to generate. Pass force: true to regenerate.` : `All ${cycleTasks.length} cycle task(s) already have BUILD HANDOFFs. Nothing to generate. Pass force: true to regenerate.`
9578
9779
  );
9579
9780
  }
9580
9781
  const [decisions, reports, brief] = await Promise.all([
@@ -9605,7 +9806,7 @@ async function prepareHandoffs(adapter2, _config, taskIds) {
9605
9806
  taskCount: tasksNeedingHandoffs.length
9606
9807
  };
9607
9808
  }
9608
- async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber) {
9809
+ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false) {
9609
9810
  const timer2 = startTimer();
9610
9811
  const { data } = parseStructuredOutput(rawLlmOutput);
9611
9812
  if (!data) {
@@ -9629,7 +9830,7 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber) {
9629
9830
  const warnings = [];
9630
9831
  for (const handoff of handoffs) {
9631
9832
  try {
9632
- if (existingHandoffSet.has(handoff.taskId)) {
9833
+ if (!force && existingHandoffSet.has(handoff.taskId)) {
9633
9834
  console.error(`[handoff] skipping ${handoff.taskId} \u2014 already has handoff`);
9634
9835
  skipped++;
9635
9836
  continue;
@@ -10005,7 +10206,72 @@ async function resolveVisibilityFromDocs(adapter2, docRefs) {
10005
10206
  };
10006
10207
  }
10007
10208
 
10209
+ // src/lib/owner-identity.ts
10210
+ function isProjectOwner(callerUserId, ownerUserId) {
10211
+ if (!callerUserId || !ownerUserId) return false;
10212
+ const caller = callerUserId.trim().toLowerCase();
10213
+ const owner = ownerUserId.trim().toLowerCase();
10214
+ if (caller.length === 0 || owner.length === 0) return false;
10215
+ return caller === owner;
10216
+ }
10217
+ async function resolveOwnerGate(adapter2, config2) {
10218
+ if (typeof adapter2.getOwnerIdentity === "function") {
10219
+ try {
10220
+ const identity = await adapter2.getOwnerIdentity();
10221
+ const callerUserId = identity.callerUserId ?? config2.userId ?? null;
10222
+ return {
10223
+ enforced: true,
10224
+ callerIsOwner: isProjectOwner(callerUserId, identity.ownerUserId),
10225
+ callerUserId,
10226
+ ownerUserId: identity.ownerUserId
10227
+ };
10228
+ } catch (err) {
10229
+ return {
10230
+ enforced: true,
10231
+ callerIsOwner: false,
10232
+ callerUserId: config2.userId ?? null,
10233
+ ownerUserId: null,
10234
+ resolutionError: err instanceof Error ? err.message : String(err)
10235
+ };
10236
+ }
10237
+ }
10238
+ if (typeof adapter2.getProjectOwnerUserId === "function") {
10239
+ let ownerUserId = null;
10240
+ let resolutionError;
10241
+ try {
10242
+ ownerUserId = await adapter2.getProjectOwnerUserId();
10243
+ } catch (err) {
10244
+ resolutionError = err instanceof Error ? err.message : String(err);
10245
+ }
10246
+ const callerUserId = config2.userId ?? null;
10247
+ return {
10248
+ enforced: true,
10249
+ callerIsOwner: isProjectOwner(callerUserId, ownerUserId),
10250
+ callerUserId,
10251
+ ownerUserId,
10252
+ ...resolutionError !== void 0 ? { resolutionError } : {}
10253
+ };
10254
+ }
10255
+ return {
10256
+ enforced: false,
10257
+ callerIsOwner: true,
10258
+ callerUserId: config2.userId ?? null,
10259
+ ownerUserId: null
10260
+ };
10261
+ }
10262
+
10008
10263
  // src/services/plan.ts
10264
+ async function resolvePlanScope(adapter2, config2) {
10265
+ const gate = await resolveOwnerGate(adapter2, config2);
10266
+ return { callerUserId: gate.callerUserId, ownerUserId: gate.ownerUserId, callerIsOwner: gate.callerIsOwner };
10267
+ }
10268
+ function filterToPersonalBacklog(tasks, scope) {
10269
+ if (!scope.callerUserId) return tasks;
10270
+ if (scope.callerIsOwner) {
10271
+ return tasks.filter((t) => !t.assigneeId || t.assigneeId === scope.callerUserId);
10272
+ }
10273
+ return tasks.filter((t) => t.assigneeId === scope.callerUserId);
10274
+ }
10009
10275
  function determineContextTier(cycleCount) {
10010
10276
  if (cycleCount <= 5) return 1;
10011
10277
  if (cycleCount <= 20) return 2;
@@ -10294,8 +10560,8 @@ var BRIEF_SECTIONS = [
10294
10560
  }
10295
10561
  ];
10296
10562
  function assessBriefThinness(brief) {
10297
- const TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
10298
- const briefWithoutTemplate = brief.replace(TEMPLATE_MARKER, "");
10563
+ const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
10564
+ const briefWithoutTemplate = brief.replace(TEMPLATE_MARKER2, "");
10299
10565
  const populated = [];
10300
10566
  const missing = [];
10301
10567
  for (const section of BRIEF_SECTIONS) {
@@ -10434,12 +10700,34 @@ async function readDogfoodEntries(projectRoot, count = 5, adapter2) {
10434
10700
  return void 0;
10435
10701
  }
10436
10702
  }
10703
+ function formatSiblingRepoWarning(siblings) {
10704
+ const lines = siblings.slice(0, 20).map(
10705
+ (s) => `- ${s.displayId} [${s.sourceProjectName}] (${s.status}${s.epic ? `, ${s.epic}` : ""}): ${s.title}`
10706
+ );
10707
+ const more = siblings.length > 20 ? `
10708
+ \u2026and ${siblings.length - 20} more` : "";
10709
+ return [
10710
+ "## \u26A0\uFE0F SHARED-REPO SIBLING WORK \u2014 CHECK BEFORE SCHEDULING",
10711
+ "",
10712
+ "Other PAPI projects share THIS repository and have the tasks below IN FLIGHT (backlog / in progress / in review). Shared-repo work (multi-user, RLS, auth, common codebase) may already be built or underway in a sibling project. Do NOT schedule a task that duplicates one below \u2014 skip it or note the overlap in cycleLogNotes instead of re-building. (Root cause of the C292 incident: the planner re-scheduled already-merged multi-user work, costing a build + a prod DB revert.)",
10713
+ "",
10714
+ ...lines,
10715
+ more
10716
+ ].filter(Boolean).join("\n");
10717
+ }
10437
10718
  async function assembleContext(adapter2, mode, _config, filters, focus) {
10438
10719
  const totalTimer = startTimer();
10439
10720
  const timings = {};
10721
+ const planScope = await resolvePlanScope(adapter2, _config);
10440
10722
  let t = startTimer();
10441
10723
  const health = await adapter2.getCycleHealth();
10442
10724
  timings["getCycleHealth"] = t();
10725
+ let siblingRepoWarning;
10726
+ try {
10727
+ const siblings = await adapter2.getSiblingRepoTasks?.() ?? [];
10728
+ if (siblings.length > 0) siblingRepoWarning = formatSiblingRepoWarning(siblings);
10729
+ } catch {
10730
+ }
10443
10731
  t = startTimer();
10444
10732
  const [rawProductBrief, currentNorthStar] = await Promise.all([
10445
10733
  adapter2.readProductBrief(),
@@ -10564,9 +10852,10 @@ ${lines.join("\n")}`;
10564
10852
  const doneIds = doneTasksResult.status === "fulfilled" ? new Set(doneTasksResult.value.map((t2) => t2.displayId)) : void 0;
10565
10853
  carryForwardStalenessLean = computeCarryForwardStaleness(cycleLogResult.value, doneIds);
10566
10854
  }
10855
+ const candidateTasksLean = preAssignedResult.status === "fulfilled" ? filterToPersonalBacklog(preAssignedResult.value, planScope) : [];
10567
10856
  let preAssignedTextLean;
10568
10857
  if (preAssignedResult.status === "fulfilled") {
10569
- const preAssigned2 = preAssignedResult.value.filter((t2) => t2.cycle === leanTargetCycle);
10858
+ const preAssigned2 = candidateTasksLean.filter((t2) => t2.cycle === leanTargetCycle);
10570
10859
  preAssignedTextLean = formatPreAssignedTasks(preAssigned2, leanTargetCycle);
10571
10860
  }
10572
10861
  let recentlyShippedLean;
@@ -10575,7 +10864,7 @@ ${lines.join("\n")}`;
10575
10864
  }
10576
10865
  let candidateTaskFullNotesLean;
10577
10866
  if (preAssignedResult.status === "fulfilled") {
10578
- candidateTaskFullNotesLean = formatCandidateTaskFullNotes(preAssignedResult.value);
10867
+ candidateTaskFullNotesLean = formatCandidateTaskFullNotes(candidateTasksLean);
10579
10868
  }
10580
10869
  logDataSourceSummary("plan (lean)", [
10581
10870
  { label: "cycleHealth", hasData: !!health },
@@ -10617,7 +10906,8 @@ ${lines.join("\n")}`;
10617
10906
  recentlyShippedCapabilities: recentlyShippedLean,
10618
10907
  strategyReviewCadence,
10619
10908
  candidateTaskFullNotes: candidateTaskFullNotesLean,
10620
- foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief)
10909
+ foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief),
10910
+ siblingRepoWarning
10621
10911
  };
10622
10912
  const { label: leanTierLabel } = applyContextTier(ctx2, health.totalCycles);
10623
10913
  ctx2.contextTier = leanTierLabel;
@@ -10633,7 +10923,7 @@ ${lines.join("\n")}`;
10633
10923
  return { context: ctx2, contextHashes: newHashes2 };
10634
10924
  }
10635
10925
  t = startTimer();
10636
- const [decisions, reportsSinceCycle, log2, tasks, rawMetricsSnapshots, reviews, phases, dogfoodEntries, allBuildReports] = await Promise.all([
10926
+ const [decisions, reportsSinceCycle, log2, tasks, rawMetricsSnapshots, reviews, phases, dogfoodEntries, allBuildReports, deferredRaw] = await Promise.all([
10637
10927
  adapter2.getActiveDecisions(),
10638
10928
  adapter2.getBuildReportsSince(health.totalCycles ?? 0),
10639
10929
  adapter2.getCycleLog(3),
@@ -10642,7 +10932,10 @@ ${lines.join("\n")}`;
10642
10932
  adapter2.getRecentReviews(5),
10643
10933
  adapter2.readPhases(),
10644
10934
  readDogfoodEntries(_config.projectRoot, 5, adapter2),
10645
- adapter2.getRecentBuildReports(50)
10935
+ adapter2.getRecentBuildReports(50),
10936
+ // task-2198: Deferred tasks queried SEPARATELY so they reach the planner as a
10937
+ // re-triageable set without ever entering the selectable candidate pool.
10938
+ adapter2.queryBoard({ status: ["Deferred"], contextTier: 2, compact: true })
10646
10939
  ]);
10647
10940
  timings["fullQueries"] = t();
10648
10941
  const reports = reportsSinceCycle.length > 0 ? reportsSinceCycle : allBuildReports.slice(0, 5);
@@ -10699,9 +10992,13 @@ ${lines.join("\n")}`;
10699
10992
  const briefExcluded = strippedTasks.filter(
10700
10993
  (t2) => t2.scopeClass === "brief" && !ACTIVE_STATUSES2.has(t2.status)
10701
10994
  );
10702
- const plannerTasks = strippedTasks.filter(
10703
- (t2) => (t2.priority !== "P3 Low" || ACTIVE_STATUSES2.has(t2.status)) && (t2.scopeClass !== "brief" || ACTIVE_STATUSES2.has(t2.status))
10995
+ const plannerTasks = filterToPersonalBacklog(
10996
+ strippedTasks.filter(
10997
+ (t2) => (t2.priority !== "P3 Low" || ACTIVE_STATUSES2.has(t2.status)) && (t2.scopeClass !== "brief" || ACTIVE_STATUSES2.has(t2.status))
10998
+ ),
10999
+ planScope
10704
11000
  );
11001
+ const deferredTasks = filterToPersonalBacklog(stripTasksForPlan(deferredRaw), planScope);
10705
11002
  if (p3Excluded.length > 0) {
10706
11003
  console.error(`[plan-perf] board tiering: excluded ${p3Excluded.length} P3 Low tasks from planner context`);
10707
11004
  }
@@ -10771,7 +11068,7 @@ ${logLines}`);
10771
11068
  activeDecisions: formatActiveDecisionsForPlan(decisions),
10772
11069
  recentBuildReports: formatBuildReports(cappedReports),
10773
11070
  cycleLog: formatCycleLog(log2),
10774
- board: formatBoardForPlan(plannerTasks, filters, health.totalCycles),
11071
+ board: formatBoardForPlan(plannerTasks, filters, health.totalCycles, deferredTasks),
10775
11072
  northStar,
10776
11073
  methodologyMetrics: formatCycleMetrics(metricsSnapshots),
10777
11074
  recentReviews: formatReviews(reviews),
@@ -10794,7 +11091,8 @@ ${logLines}`);
10794
11091
  recentlyShippedCapabilities: formatRecentlyShippedCapabilities(reports),
10795
11092
  strategyReviewCadence: strategyReviewCadenceFull,
10796
11093
  candidateTaskFullNotes: formatCandidateTaskFullNotes(plannerTasks),
10797
- foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief)
11094
+ foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief),
11095
+ siblingRepoWarning
10798
11096
  };
10799
11097
  const { label: fullTierLabel } = applyContextTier(ctx, health.totalCycles);
10800
11098
  ctx.contextTier = fullTierLabel;
@@ -10860,7 +11158,10 @@ ${cleanContent}`;
10860
11158
  goals: data.cycleLogTitle ? [data.cycleLogTitle] : [],
10861
11159
  boardHealth: data.boardHealth || "",
10862
11160
  taskIds: cycleTaskIds,
10863
- contextHashes
11161
+ contextHashes,
11162
+ // task-2071 (MU-3): own the cycle by the planning member (pg defaults to the
11163
+ // project owner when unset, so owner-operated plans stay correct).
11164
+ ...options.ownerUserId ? { userId: options.ownerUserId } : {}
10864
11165
  };
10865
11166
  let dedupedNewTasks = data.newTasks ?? [];
10866
11167
  if (dedupedNewTasks.length > 0) {
@@ -10945,25 +11246,12 @@ ${cleanContent}`;
10945
11246
  const writeBackMs = writeBackTimer();
10946
11247
  console.error(`[plan-perf] transactionalWriteBack: total=${writeBackMs}ms`);
10947
11248
  const verifyWarnings = [];
10948
- try {
10949
- const [cycles, boardTasks] = await Promise.all([
10950
- adapter2.readCycles(),
10951
- adapter2.queryBoard({ status: ["In Cycle", "Backlog", "In Progress", "In Review"], compact: true })
10952
- ]);
10953
- const newCycle = cycles.find((s) => s.number === newCycleNumber);
10954
- if (!newCycle) {
10955
- verifyWarnings.push(`Post-write verification FAILED: cycle ${newCycleNumber} entity not found after commit \u2014 data may not have persisted`);
10956
- } else {
10957
- const expectedTaskCount = data.cycleTaskIds?.length ?? data.cycleHandoffs?.length ?? 0;
10958
- const actualCycleTasks = boardTasks.filter((t) => t.cycle === newCycleNumber).length;
10959
- if (expectedTaskCount > 0 && actualCycleTasks === 0) {
10960
- verifyWarnings.push(`Post-write verification FAILED: cycle ${newCycleNumber} exists but has 0 tasks assigned (expected ${expectedTaskCount}) \u2014 task cycle assignment may have failed`);
10961
- } else if (expectedTaskCount > 0 && actualCycleTasks < expectedTaskCount) {
10962
- verifyWarnings.push(`Post-write verification WARNING: cycle ${newCycleNumber} has ${actualCycleTasks} tasks but expected ${expectedTaskCount} \u2014 some task assignments may have failed`);
10963
- }
10964
- }
10965
- } catch {
10966
- verifyWarnings.push("Post-write verification: could not read cycles/tasks tables");
11249
+ const expectedNewTasks = dedupedNewTasks.length;
11250
+ const actualNewTasks = result.newTaskIdMap.size;
11251
+ if (expectedNewTasks > 0 && actualNewTasks < expectedNewTasks) {
11252
+ verifyWarnings.push(
11253
+ `Plan write-back created ${actualNewTasks} of ${expectedNewTasks} new tasks the transaction was asked to create \u2014 investigate the planWriteBack handler. (This count is returned by the committed transaction, not a board re-read, so it is not a hosted read-lag artifact.)`
11254
+ );
10967
11255
  }
10968
11256
  const allWarnings = [...result.warnings, ...verifyWarnings];
10969
11257
  const handoffCount = data.cycleHandoffs?.length ?? 0;
@@ -11258,7 +11546,9 @@ ${cleanContent}`;
11258
11546
  goals: data.cycleLogTitle ? [data.cycleLogTitle] : [],
11259
11547
  boardHealth: data.boardHealth || "",
11260
11548
  taskIds: cycleTaskIds,
11261
- contextHashes
11549
+ contextHashes,
11550
+ // task-2071 (MU-3): own the cycle by the planning member.
11551
+ ...options.ownerUserId ? { userId: options.ownerUserId } : {}
11262
11552
  };
11263
11553
  await adapter2.createCycle(cycle);
11264
11554
  } catch (err) {
@@ -11300,7 +11590,7 @@ async function assertSingleActiveCycle(adapter2, opts = {}) {
11300
11590
  for (const c of cycles) {
11301
11591
  if (!newestByNumber.has(c.number)) newestByNumber.set(c.number, c);
11302
11592
  }
11303
- const blocking = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber);
11593
+ const blocking = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber).filter((c) => opts.userId == null || c.userId === opts.userId);
11304
11594
  if (blocking.length === 0) return [];
11305
11595
  if (!opts.autoComplete) {
11306
11596
  const nums = blocking.map((c) => c.number).join(", ");
@@ -11319,7 +11609,7 @@ async function assertSingleActiveCycle(adapter2, opts = {}) {
11319
11609
  }
11320
11610
  return notes;
11321
11611
  }
11322
- async function validateAndPrepare(adapter2, force) {
11612
+ async function validateAndPrepare(adapter2, force, callerUserId) {
11323
11613
  let mode;
11324
11614
  let cycleNumber;
11325
11615
  let strategyReviewWarning = "";
@@ -11358,7 +11648,7 @@ Run \`review_submit\` to clear them, or pass \`force: true\` to bypass this bloc
11358
11648
  );
11359
11649
  }
11360
11650
  }
11361
- const autoCompleted = await assertSingleActiveCycle(adapter2, { autoComplete: force === true });
11651
+ const autoCompleted = await assertSingleActiveCycle(adapter2, { autoComplete: force === true, userId: callerUserId ?? void 0 });
11362
11652
  for (const note of autoCompleted) {
11363
11653
  console.error(`[plan] ${note}`);
11364
11654
  }
@@ -11390,7 +11680,8 @@ Run \`strategy_review\` first, or pass \`force: true\` to bypass this gate.`
11390
11680
  }
11391
11681
  async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber, contextHashes, planRunMeta) {
11392
11682
  const applyStartMs = Date.now();
11393
- await assertSingleActiveCycle(adapter2, { allowNumber: cycleNumber + 1 });
11683
+ const applyScope = await resolvePlanScope(adapter2, config2);
11684
+ await assertSingleActiveCycle(adapter2, { allowNumber: cycleNumber + 1, userId: applyScope.callerUserId ?? void 0 });
11394
11685
  const { displayText, data } = parseStructuredOutput(rawOutput);
11395
11686
  let resolvedDisplayText = displayText;
11396
11687
  let autoCommitNote = "";
@@ -11409,7 +11700,7 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
11409
11700
  cycleNumber,
11410
11701
  data,
11411
11702
  contextHashes,
11412
- { confirmCancellations: planRunMeta?.confirmCancellations === true }
11703
+ { confirmCancellations: planRunMeta?.confirmCancellations === true, ownerUserId: applyScope.callerUserId ?? void 0 }
11413
11704
  );
11414
11705
  if (wbWarnings.length > 0) {
11415
11706
  writeBackWarnings = wbWarnings;
@@ -11484,7 +11775,8 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
11484
11775
  const prepareTimer = startTimer();
11485
11776
  tracker?.mark("validate_and_prepare");
11486
11777
  let t = startTimer();
11487
- const { mode, cycleNumber, strategyReviewWarning } = await validateAndPrepare(adapter2, force);
11778
+ const prepareScope = await resolvePlanScope(adapter2, config2);
11779
+ const { mode, cycleNumber, strategyReviewWarning } = await validateAndPrepare(adapter2, force, prepareScope.callerUserId);
11488
11780
  const validateMs = t();
11489
11781
  if (handoffsOnly) {
11490
11782
  tracker?.mark("handoffs_only_assemble");
@@ -11530,8 +11822,8 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
11530
11822
  t = startTimer();
11531
11823
  const { context, contextHashes } = await assembleContext(adapter2, mode, config2, filters, focus);
11532
11824
  const assembleMs = t();
11533
- const TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
11534
- if (mode !== "bootstrap" && context.productBrief.includes(TEMPLATE_MARKER)) {
11825
+ const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
11826
+ if (mode !== "bootstrap" && context.productBrief.includes(TEMPLATE_MARKER2)) {
11535
11827
  throw new Error("TEMPLATE_BRIEF");
11536
11828
  }
11537
11829
  if (skipHandoffs) context.skipHandoffs = true;
@@ -11748,6 +12040,7 @@ function defaultHint(tool) {
11748
12040
  "plan",
11749
12041
  "strategy_review",
11750
12042
  "orient",
12043
+ "papi",
11751
12044
  "build_execute",
11752
12045
  "review_list",
11753
12046
  "review_submit",
@@ -11755,9 +12048,9 @@ function defaultHint(tool) {
11755
12048
  "release"
11756
12049
  ]);
11757
12050
  if (knownTools.has(tool)) {
11758
- return `Re-run \`${tool}\`. If the crash repeats, share the diagnostic JSON above with PAPI support.`;
12051
+ return `Re-run \`${tool}\`. If the crash repeats, offer to submit it upstream \u2014 \`bug\` with report=true \u2014 and ask the user (a) notify when fixed? (b) OK for the PAPI team to reach out?`;
11759
12052
  }
11760
- return "Re-run the tool. If the crash repeats, share the diagnostic JSON above with PAPI support.";
12053
+ return "Re-run the tool. If the crash repeats, offer to submit it upstream \u2014 `bug` with report=true \u2014 and ask the user (a) notify when fixed? (b) OK for the PAPI team to reach out?";
11761
12054
  }
11762
12055
 
11763
12056
  // src/lib/subagent-dispatch.ts
@@ -11899,12 +12192,174 @@ async function resolveLlmResponse(inlineResponse, filePath) {
11899
12192
  return { ok: true, llmResponse: contents };
11900
12193
  }
11901
12194
 
12195
+ // src/services/session-guidance.ts
12196
+ var DEFAULT_CALLER_KEY = "__default__";
12197
+ var sessionStates = /* @__PURE__ */ new Map();
12198
+ var MAX_SESSION_STATES = 1e3;
12199
+ function newSessionState(now) {
12200
+ return {
12201
+ toolCallCount: 0,
12202
+ lastOrientAt: null,
12203
+ releaseSinceLastOrient: false,
12204
+ sessionStartedAt: now,
12205
+ lastReviewListAt: null,
12206
+ failureTimestamps: [],
12207
+ consecutiveFailures: 0,
12208
+ lastTouchedAt: now
12209
+ };
12210
+ }
12211
+ function evictIfNeeded() {
12212
+ if (sessionStates.size <= MAX_SESSION_STATES) return;
12213
+ const entries = [...sessionStates.entries()].sort(
12214
+ (a, b2) => a[1].lastTouchedAt - b2[1].lastTouchedAt
12215
+ );
12216
+ const overflow = sessionStates.size - MAX_SESSION_STATES;
12217
+ for (let i = 0; i < overflow; i++) {
12218
+ sessionStates.delete(entries[i][0]);
12219
+ }
12220
+ }
12221
+ function getState(callerKey) {
12222
+ const key = callerKey ?? DEFAULT_CALLER_KEY;
12223
+ const now = Date.now();
12224
+ let s = sessionStates.get(key);
12225
+ if (!s) {
12226
+ s = newSessionState(now);
12227
+ sessionStates.set(key, s);
12228
+ evictIfNeeded();
12229
+ } else {
12230
+ s.lastTouchedAt = now;
12231
+ }
12232
+ return s;
12233
+ }
12234
+ function callerKeyFromConfig(config2) {
12235
+ return config2.projectId ?? config2.userId ?? void 0;
12236
+ }
12237
+ var CONTEXT_BLOAT_CALL_THRESHOLD = 40;
12238
+ var ORIENT_GAP_MS = 3 * 60 * 60 * 1e3;
12239
+ var REVIEW_LIST_GUARD_WINDOW_MS = 15 * 60 * 1e3;
12240
+ var FAILURE_WINDOW_MS = 30 * 60 * 1e3;
12241
+ var FAILURE_RATE_THRESHOLD = 8;
12242
+ var CONSECUTIVE_FAILURE_THRESHOLD = 4;
12243
+ function recordToolCall(name, callerKey) {
12244
+ const state = getState(callerKey);
12245
+ state.toolCallCount++;
12246
+ if (name === "release") state.releaseSinceLastOrient = true;
12247
+ if (name === "review_list") state.lastReviewListAt = Date.now();
12248
+ }
12249
+ function recordToolOutcome(success, callerKey) {
12250
+ const state = getState(callerKey);
12251
+ if (success) {
12252
+ state.consecutiveFailures = 0;
12253
+ return;
12254
+ }
12255
+ const now = Date.now();
12256
+ state.consecutiveFailures++;
12257
+ state.failureTimestamps.push(now);
12258
+ const cutoff = now - FAILURE_WINDOW_MS;
12259
+ state.failureTimestamps = state.failureTimestamps.filter((t) => t >= cutoff);
12260
+ }
12261
+ function wasReviewListSeenRecently(windowMs = REVIEW_LIST_GUARD_WINDOW_MS, callerKey) {
12262
+ const state = getState(callerKey);
12263
+ if (state.lastReviewListAt == null) return false;
12264
+ return Date.now() - state.lastReviewListAt <= windowMs;
12265
+ }
12266
+ function markOrient(callerKey) {
12267
+ const state = getState(callerKey);
12268
+ state.lastOrientAt = Date.now();
12269
+ state.releaseSinceLastOrient = false;
12270
+ }
12271
+ function getProjectConnectionBanner(projectName, projectSlug) {
12272
+ if (!projectName || !projectSlug) return null;
12273
+ return `[Connected: ${projectName} (${projectSlug})] \u2014 confirm this is the project you mean before I write to it. If it's wrong, don't proceed: pass \`project=<id>\` on the call to target a different project, or fix the project id in your MCP config (PAPI_PROJECT_ID for local, x-papi-project-id header for remote) and reconnect.`;
12274
+ }
12275
+ function detectContextDegradation(now = Date.now(), callerKey) {
12276
+ const state = getState(callerKey);
12277
+ if (state.consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD) {
12278
+ return `Context degradation (clash): ${state.consecutiveFailures} tool calls failed in a row \u2014 the session may be stuck on a contradiction. Consider a fresh window after this task.`;
12279
+ }
12280
+ const cutoff = now - FAILURE_WINDOW_MS;
12281
+ const recentFailures = state.failureTimestamps.filter((t) => t >= cutoff).length;
12282
+ if (recentFailures >= FAILURE_RATE_THRESHOLD) {
12283
+ const mins = Math.round(FAILURE_WINDOW_MS / 6e4);
12284
+ return `Context degradation (distraction/confusion): ${recentFailures} tool failures in the last ${mins}min. A fresh window often clears this faster than pushing on.`;
12285
+ }
12286
+ return null;
12287
+ }
12288
+ async function buildSessionGuidance(callerKey) {
12289
+ const state = getState(callerKey);
12290
+ const signals = [];
12291
+ const degradation = detectContextDegradation(Date.now(), callerKey);
12292
+ if (degradation) signals.push(degradation);
12293
+ if (state.toolCallCount > CONTEXT_BLOAT_CALL_THRESHOLD) {
12294
+ signals.push(
12295
+ `${state.toolCallCount} tool calls this session \u2014 context may be bloated. Consider starting a fresh window.`
12296
+ );
12297
+ }
12298
+ if (state.lastOrientAt && Date.now() - state.lastOrientAt > ORIENT_GAP_MS) {
12299
+ const hours = Math.round((Date.now() - state.lastOrientAt) / (60 * 60 * 1e3));
12300
+ signals.push(
12301
+ `${hours}h since last orient \u2014 session may be stale. Consider a fresh window for best results.`
12302
+ );
12303
+ }
12304
+ if (state.releaseSinceLastOrient) {
12305
+ signals.push(
12306
+ "Release just ran \u2014 start a fresh session before the next `plan` to keep planning context clean."
12307
+ );
12308
+ }
12309
+ return signals.slice(0, 3);
12310
+ }
12311
+
12312
+ // src/lib/per-caller-cache.ts
12313
+ var DEFAULT_CALLER_KEY2 = "__default__";
12314
+ var MAX_PER_CALLER_ENTRIES = 1e3;
12315
+ var PerCallerCache = class {
12316
+ constructor(max = MAX_PER_CALLER_ENTRIES) {
12317
+ this.max = max;
12318
+ }
12319
+ store = /* @__PURE__ */ new Map();
12320
+ /** Stash the prepare-phase value for this caller (undefined caller → default bucket). */
12321
+ set(callerKey, value) {
12322
+ this.store.set(callerKey ?? DEFAULT_CALLER_KEY2, { value, touchedAt: this.now() });
12323
+ this.evictIfNeeded();
12324
+ }
12325
+ /**
12326
+ * Read the caller's prepare-phase value WITHOUT removing it — used to peek the
12327
+ * expected value during apply-validation (the prior singletons were read then
12328
+ * cleared, so callers that need the clear-on-read semantics use `take`).
12329
+ */
12330
+ peek(callerKey) {
12331
+ return this.store.get(callerKey ?? DEFAULT_CALLER_KEY2)?.value;
12332
+ }
12333
+ /** Read AND remove this caller's value — the prepare→apply handshake consumes it once. */
12334
+ take(callerKey) {
12335
+ const key = callerKey ?? DEFAULT_CALLER_KEY2;
12336
+ const entry = this.store.get(key);
12337
+ if (entry) this.store.delete(key);
12338
+ return entry?.value;
12339
+ }
12340
+ /** Drop this caller's value without reading it. */
12341
+ clear(callerKey) {
12342
+ this.store.delete(callerKey ?? DEFAULT_CALLER_KEY2);
12343
+ }
12344
+ /** Test/inspection helper — number of live caller buckets. */
12345
+ size() {
12346
+ return this.store.size;
12347
+ }
12348
+ now() {
12349
+ return Date.now();
12350
+ }
12351
+ evictIfNeeded() {
12352
+ if (this.store.size <= this.max) return;
12353
+ const entries = [...this.store.entries()].sort((a, b2) => a[1].touchedAt - b2[1].touchedAt);
12354
+ const overflow = this.store.size - this.max;
12355
+ for (let i = 0; i < overflow; i++) {
12356
+ this.store.delete(entries[i][0]);
12357
+ }
12358
+ }
12359
+ };
12360
+
11902
12361
  // src/tools/plan.ts
11903
- var lastPrepareContextHashes;
11904
- var lastPrepareUserMessage;
11905
- var lastPrepareContextBytes;
11906
- var lastPrepareCycleNumber;
11907
- var lastPrepareSkipHandoffs;
12362
+ var planPrepareCache = new PerCallerCache();
11908
12363
  var planTool = {
11909
12364
  name: "plan",
11910
12365
  description: 'Run once per cycle to select tasks and generate BUILD HANDOFFs. Call after setup (first time) or after completing all builds AND running release for the previous cycle. Returns prioritised task recommendations with detailed implementation specs. NEVER call when unbuilt cycle tasks exist \u2014 build and release first. First call returns a planning prompt for you to execute (prepare phase). Then call again with mode "apply" and your output to write results. Use skip_handoffs=true for large backlogs \u2014 handoffs are then generated separately via `handoff_generate`.',
@@ -12046,6 +12501,7 @@ function formatPlanResult(result) {
12046
12501
  }
12047
12502
  async function handlePlan(adapter2, config2, args) {
12048
12503
  const toolMode = args.mode;
12504
+ const callerKey = callerKeyFromConfig(config2);
12049
12505
  const filters = {};
12050
12506
  if (typeof args.phase === "string") filters.phase = args.phase;
12051
12507
  if (typeof args.module === "string") filters.module = args.module;
@@ -12068,11 +12524,12 @@ async function handlePlan(adapter2, config2, args) {
12068
12524
  const planMode = args.plan_mode || "full";
12069
12525
  const rawCycleNumber = args.cycle_number != null ? Number(args.cycle_number) : NaN;
12070
12526
  const strategyReviewWarning = args.strategy_review_warning || "";
12071
- const contextHashes = lastPrepareContextHashes;
12072
- const inputContext = lastPrepareUserMessage;
12073
- const contextBytes = lastPrepareContextBytes;
12074
- let expectedCycleNumber = lastPrepareCycleNumber;
12075
- const skipHandoffsCached = lastPrepareSkipHandoffs;
12527
+ const prep = planPrepareCache.peek(callerKey);
12528
+ const contextHashes = prep?.contextHashes;
12529
+ const inputContext = prep?.userMessage;
12530
+ const contextBytes = prep?.contextBytes;
12531
+ let expectedCycleNumber = prep?.cycleNumber;
12532
+ const skipHandoffsCached = prep?.skipHandoffs;
12076
12533
  const skipHandoffs = args.skip_handoffs === true || skipHandoffsCached === true;
12077
12534
  if (expectedCycleNumber === void 0 && process.env.PAPI_GUARD_RESTART === "true") {
12078
12535
  try {
@@ -12094,11 +12551,7 @@ async function handlePlan(adapter2, config2, args) {
12094
12551
  );
12095
12552
  }
12096
12553
  const cycleNumber = newCycleNumber - 1;
12097
- lastPrepareContextHashes = void 0;
12098
- lastPrepareUserMessage = void 0;
12099
- lastPrepareContextBytes = void 0;
12100
- lastPrepareCycleNumber = void 0;
12101
- lastPrepareSkipHandoffs = void 0;
12554
+ planPrepareCache.clear(callerKey);
12102
12555
  let utilisation;
12103
12556
  if (inputContext) {
12104
12557
  try {
@@ -12127,11 +12580,13 @@ async function handlePlan(adapter2, config2, args) {
12127
12580
  }
12128
12581
  const skipHandoffs = args.skip_handoffs === true;
12129
12582
  const result = await preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker);
12130
- lastPrepareContextHashes = result.contextHashes;
12131
- lastPrepareUserMessage = result.userMessage;
12132
- lastPrepareContextBytes = result.contextBytes;
12133
- lastPrepareCycleNumber = result.cycleNumber;
12134
- lastPrepareSkipHandoffs = skipHandoffs || void 0;
12583
+ planPrepareCache.set(callerKey, {
12584
+ contextHashes: result.contextHashes,
12585
+ userMessage: result.userMessage,
12586
+ contextBytes: result.contextBytes,
12587
+ cycleNumber: result.cycleNumber,
12588
+ skipHandoffs: skipHandoffs || void 0
12589
+ });
12135
12590
  const autoDispatchEnabled = process.env.PAPI_AUTO_DISPATCH !== "false";
12136
12591
  const autoDispatchThreshold = 50 * 1024;
12137
12592
  let dispatch;
@@ -13406,7 +13861,18 @@ ${lines.join("\n")}`;
13406
13861
  console.error(`[strategy_review] Context size: ${contextSize.toLocaleString()} chars (~${estimatedTokens.toLocaleString()} tokens)`);
13407
13862
  return context;
13408
13863
  }
13409
- async function writeBack2(adapter2, cycleNumber, data, fullAnalysis) {
13864
+ function extractDecisionEvidence(ad, eventType, warnings) {
13865
+ const isDeliberate = eventType === "validated" || eventType === "invalidated" || eventType === "modified";
13866
+ const evidenceRef = ad.evidenceRef?.trim() || void 0;
13867
+ const metricDelta = ad.metricDelta ?? null;
13868
+ if (isDeliberate && !evidenceRef && !metricDelta?.metric) {
13869
+ warnings?.push(
13870
+ `${ad.id}: ${eventType} decision recorded without evidence_ref or metric_delta \u2014 ledger entry has no outcome pointer. Add evidence in the strategy output to make this decision queryable.`
13871
+ );
13872
+ }
13873
+ return { evidenceRef, metricDelta };
13874
+ }
13875
+ async function writeBack2(adapter2, cycleNumber, data, fullAnalysis, warnings) {
13410
13876
  const cleanTitle = data.sessionLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
13411
13877
  const cleanContent = data.sessionLogContent.replace(/^#{1,3}\s+(?:Cycle|Session)\s+\d+\s*—[^\n]*\n*/i, "").trim();
13412
13878
  const lastReviewCycle = await adapter2.getLastStrategyReviewCycle();
@@ -13460,6 +13926,7 @@ ${cleanContent}`;
13460
13926
  await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, ad.action);
13461
13927
  }
13462
13928
  const eventType = ad.action === "delete" ? "invalidated" : ad.action === "confidence_change" ? "confidence_changed" : ad.action === "supersede" ? "superseded" : ad.action === "new" ? "created" : "modified";
13929
+ const evidence = extractDecisionEvidence(ad, eventType, warnings);
13463
13930
  try {
13464
13931
  await adapter2.appendDecisionEvent({
13465
13932
  decisionId: ad.id,
@@ -13467,7 +13934,9 @@ ${cleanContent}`;
13467
13934
  cycle: cycleNumber,
13468
13935
  source: "strategy_review",
13469
13936
  sourceRef: `cycle-${cycleNumber}-review`,
13470
- detail: `Action: ${ad.action}`
13937
+ detail: `Action: ${ad.action}`,
13938
+ evidenceRef: evidence.evidenceRef,
13939
+ metricDelta: evidence.metricDelta
13471
13940
  });
13472
13941
  } catch {
13473
13942
  }
@@ -13626,6 +14095,7 @@ async function processReviewOutput(adapter2, rawOutput, cycleNumber) {
13626
14095
  let slackWarning;
13627
14096
  let writeBackFailed;
13628
14097
  let phaseChanges;
14098
+ const evidenceWarnings = [];
13629
14099
  if (!data) {
13630
14100
  const marker = "<!-- PAPI_STRUCTURED_OUTPUT -->";
13631
14101
  const hasMarker = rawOutput.includes(marker);
@@ -13639,7 +14109,7 @@ async function processReviewOutput(adapter2, rawOutput, cycleNumber) {
13639
14109
  }
13640
14110
  if (data) {
13641
14111
  try {
13642
- phaseChanges = await writeBack2(adapter2, cycleNumber, data, displayText);
14112
+ phaseChanges = await writeBack2(adapter2, cycleNumber, data, displayText, evidenceWarnings);
13643
14113
  } catch (err) {
13644
14114
  writeBackFailed = err instanceof Error ? err.message : String(err);
13645
14115
  try {
@@ -13687,9 +14157,15 @@ ${report}`;
13687
14157
  **Discovery Canvas Auto-Populated:** ${populated.join(", ")}`;
13688
14158
  }
13689
14159
  }
14160
+ const evidenceWarningSection = evidenceWarnings.length ? `
14161
+
14162
+ ---
14163
+
14164
+ **Decision-ledger evidence warnings (${evidenceWarnings.length}):**
14165
+ ${evidenceWarnings.map((w) => `- ${w}`).join("\n")}` : "";
13690
14166
  const fullText = phaseChanges?.length ? `${displayText}
13691
14167
 
13692
- ${formatPhaseChanges(phaseChanges)}${valueReportSection}${canvasSection}` : `${displayText}${valueReportSection}${canvasSection}`;
14168
+ ${formatPhaseChanges(phaseChanges)}${valueReportSection}${canvasSection}${evidenceWarningSection}` : `${displayText}${valueReportSection}${canvasSection}${evidenceWarningSection}`;
13693
14169
  return {
13694
14170
  cycleNumber,
13695
14171
  displayText: fullText,
@@ -13996,6 +14472,7 @@ function buildStrategyChangeUserMessage(cycleNumber, text, productBrief, activeD
13996
14472
  async function processStrategyChangeOutput(adapter2, rawOutput, cycleNumber) {
13997
14473
  const { displayText, data } = parseStrategyChangeOutput(rawOutput);
13998
14474
  let writeBackFailed;
14475
+ const evidenceWarnings = [];
13999
14476
  if (data) {
14000
14477
  try {
14001
14478
  const cleanTitle = data.cycleLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
@@ -14023,6 +14500,7 @@ ${cleanContent}`;
14023
14500
  await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, ad.action);
14024
14501
  }
14025
14502
  const eventType = ad.action === "delete" ? "invalidated" : ad.action === "confidence_change" ? "confidence_changed" : ad.action === "supersede" ? "superseded" : ad.action === "new" ? "created" : "modified";
14503
+ const evidence = extractDecisionEvidence(ad, eventType, evidenceWarnings);
14026
14504
  try {
14027
14505
  await adapter2.appendDecisionEvent({
14028
14506
  decisionId: ad.id,
@@ -14030,7 +14508,9 @@ ${cleanContent}`;
14030
14508
  cycle: cycleNumber,
14031
14509
  source: "strategy_change",
14032
14510
  sourceRef: `cycle-${cycleNumber}-change`,
14033
- detail: `Action: ${ad.action}`
14511
+ detail: `Action: ${ad.action}`,
14512
+ evidenceRef: evidence.evidenceRef,
14513
+ metricDelta: evidence.metricDelta
14034
14514
  });
14035
14515
  } catch {
14036
14516
  }
@@ -14050,7 +14530,13 @@ ${cleanContent}`;
14050
14530
  writeBackFailed = err instanceof Error ? err.message : String(err);
14051
14531
  }
14052
14532
  }
14053
- return { cycleNumber, displayText, writeBackFailed };
14533
+ const fullText = evidenceWarnings.length ? `${displayText}
14534
+
14535
+ ---
14536
+
14537
+ **Decision-ledger evidence warnings (${evidenceWarnings.length}):**
14538
+ ${evidenceWarnings.map((w) => `- ${w}`).join("\n")}` : displayText;
14539
+ return { cycleNumber, displayText: fullText, writeBackFailed };
14054
14540
  }
14055
14541
  async function prepareStrategyChange(adapter2, text) {
14056
14542
  let cycleNumber;
@@ -14189,11 +14675,10 @@ Confidence: ${input.confidence}. Captured mid-conversation via strategy_change c
14189
14675
  }
14190
14676
 
14191
14677
  // src/tools/strategy.ts
14192
- var lastReviewUserMessage;
14193
- var lastReviewContextBytes;
14678
+ var reviewPrepareCache = new PerCallerCache();
14194
14679
  var strategyReviewTool = {
14195
14680
  name: "strategy_review",
14196
- description: 'Run a Strategy Review \u2014 assesses project direction, velocity, and Active Decisions. Produces recommendations and potential AD updates that feed into the next plan. Offered every 5 cycles; hard-blocked at 7+ overdue cycles. Run in its own dedicated session \u2014 do not mix with building. First call returns a review prompt for you to execute (prepare phase). Then call again with mode "apply" and your output. Pass `force: true` to run before the cadence gate.',
14681
+ description: 'Run a Strategy Review \u2014 assesses project direction, velocity, and Active Decisions. Produces recommendations and potential AD updates that feed into the next plan. Offered every 5 cycles; hard-blocked at 7+ overdue cycles. If your current session is already heavy with build context, running the review in a fresh session gives cleaner output \u2014 but a genuinely fresh session needs no restart, just run it. First call returns a review prompt for you to execute (prepare phase). Then call again with mode "apply" and your output. Pass `force: true` to run before the cadence gate.',
14197
14682
  annotations: { readOnlyHint: false, destructiveHint: false },
14198
14683
  inputSchema: {
14199
14684
  type: "object",
@@ -14258,7 +14743,7 @@ var strategyAgendaTool = {
14258
14743
  };
14259
14744
  var strategyChangeTool = {
14260
14745
  name: "strategy_change",
14261
- description: 'Apply a strategic shift to the project. Three modes: "capture" for lightweight mid-conversation decision capture (no LLM round-trip), "prepare" to get a change prompt for full analysis, "apply" to persist analysis output. Use "capture" when you detect a strategic decision in conversation and want to persist it quickly without disrupting the build flow.',
14746
+ description: 'Apply a strategic shift to the project. Three modes: "capture" for lightweight mid-conversation decision capture (no LLM round-trip), "prepare" to get a change prompt for full analysis, "apply" to persist analysis output. Use "capture" when you detect a strategic decision in conversation and want to persist it quickly without disrupting the build flow. In "capture" mode, pass north_star to directly set/update the project North Star (no decision text needed).',
14262
14747
  annotations: { readOnlyHint: false, destructiveHint: false },
14263
14748
  inputSchema: {
14264
14749
  type: "object",
@@ -14296,6 +14781,10 @@ var strategyChangeTool = {
14296
14781
  confidence_only: {
14297
14782
  type: "boolean",
14298
14783
  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.`
14784
+ },
14785
+ north_star: {
14786
+ type: "string",
14787
+ 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.'
14299
14788
  }
14300
14789
  },
14301
14790
  required: []
@@ -14304,6 +14793,7 @@ var strategyChangeTool = {
14304
14793
  async function handleStrategyReview(adapter2, config2, args) {
14305
14794
  const toolMode = args.mode;
14306
14795
  const force = args.force === true;
14796
+ const callerKey = callerKeyFromConfig(config2);
14307
14797
  const tracker = new ProgressTracker(toolMode === "apply" ? "apply_validate" : "prepare_validate");
14308
14798
  try {
14309
14799
  if (toolMode === "apply") {
@@ -14316,10 +14806,9 @@ async function handleStrategyReview(adapter2, config2, args) {
14316
14806
  }
14317
14807
  const llmResponse = resolved.llmResponse;
14318
14808
  const cycleNumber = typeof args.cycle_number === "number" ? args.cycle_number : 0;
14319
- const inputContext = lastReviewUserMessage;
14320
- const contextBytes = lastReviewContextBytes;
14321
- lastReviewUserMessage = void 0;
14322
- lastReviewContextBytes = void 0;
14809
+ const prep = reviewPrepareCache.take(callerKey);
14810
+ const inputContext = prep?.userMessage;
14811
+ const contextBytes = prep?.contextBytes;
14323
14812
  const result = await applyStrategyReviewOutput(adapter2, llmResponse, cycleNumber, tracker);
14324
14813
  let utilisation;
14325
14814
  if (inputContext) {
@@ -14384,14 +14873,17 @@ ${recLines.join("\n")}
14384
14873
  if ("notDue" in result) {
14385
14874
  return textResponse(result.message);
14386
14875
  }
14387
- lastReviewUserMessage = result.userMessage;
14388
- lastReviewContextBytes = Buffer.byteLength(result.userMessage, "utf-8");
14876
+ const reviewContextBytes = Buffer.byteLength(result.userMessage, "utf-8");
14877
+ reviewPrepareCache.set(callerKey, {
14878
+ userMessage: result.userMessage,
14879
+ contextBytes: reviewContextBytes
14880
+ });
14389
14881
  const autoDispatchEnabled = process.env.PAPI_AUTO_DISPATCH !== "false";
14390
14882
  const autoDispatchThreshold = 50 * 1024;
14391
14883
  let dispatch;
14392
14884
  if (args.dispatch === "inline" || args.dispatch === "subagent") {
14393
14885
  dispatch = args.dispatch;
14394
- } else if (autoDispatchEnabled && lastReviewContextBytes > autoDispatchThreshold) {
14886
+ } else if (autoDispatchEnabled && reviewContextBytes > autoDispatchThreshold) {
14395
14887
  dispatch = "subagent";
14396
14888
  } else {
14397
14889
  dispatch = "inline";
@@ -14402,7 +14894,7 @@ ${recLines.join("\n")}
14402
14894
  cycleNumber: result.cycleNumber,
14403
14895
  systemPrompt: result.systemPrompt,
14404
14896
  userMessage: result.userMessage,
14405
- contextBytes: lastReviewContextBytes
14897
+ contextBytes: reviewContextBytes
14406
14898
  });
14407
14899
  return textResponse(dispatchPrompt);
14408
14900
  }
@@ -14499,9 +14991,25 @@ async function handleStrategyChange(adapter2, _config, args) {
14499
14991
  const toolMode = args.mode;
14500
14992
  try {
14501
14993
  if (toolMode === "capture") {
14994
+ const northStar = args.north_star;
14995
+ if (northStar && northStar.trim()) {
14996
+ if (!adapter2.upsertNorthStar) {
14997
+ return errorResponse("North Star write is not available on this adapter (requires the pg/proxy or md adapter).");
14998
+ }
14999
+ const health = await adapter2.getCycleHealth();
15000
+ const cycleNumber = health.totalCycles;
15001
+ await adapter2.upsertNorthStar(northStar.trim(), cycleNumber);
15002
+ return textResponse(
15003
+ `**North Star set** (Cycle ${cycleNumber})
15004
+
15005
+ ${northStar.trim()}
15006
+
15007
+ orient and the project foundation will read this value.`
15008
+ );
15009
+ }
14502
15010
  const text2 = args.text;
14503
15011
  if (!text2 || !text2.trim()) {
14504
- return errorResponse("text is required for capture mode. Describe the decision.");
15012
+ return errorResponse("text is required for capture mode. Describe the decision (or pass north_star to set the project North Star).");
14505
15013
  }
14506
15014
  const adId = args.ad_id;
14507
15015
  const confidence = args.confidence ?? "MEDIUM";
@@ -14612,7 +15120,17 @@ async function viewBoard(adapter2, phaseFilter, options) {
14612
15120
  const allTasks = await adapter2.queryBoard(
14613
15121
  Object.keys(queryOptions).length > 0 ? queryOptions : void 0
14614
15122
  );
14615
- allTasks.sort((a, b2) => {
15123
+ let filtered = allTasks;
15124
+ if (options?.cycle != null) {
15125
+ filtered = filtered.filter((t) => t.cycle === options.cycle);
15126
+ }
15127
+ if (options?.query && options.query.trim()) {
15128
+ const q = options.query.trim().toLowerCase();
15129
+ filtered = filtered.filter(
15130
+ (t) => t.title.toLowerCase().includes(q) || (t.notes?.toLowerCase().includes(q) ?? false)
15131
+ );
15132
+ }
15133
+ filtered.sort((a, b2) => {
14616
15134
  const aTier = STATUS_TIER[a.status] ?? 4;
14617
15135
  const bTier = STATUS_TIER[b2.status] ?? 4;
14618
15136
  if (aTier !== bTier) return aTier - bTier;
@@ -14624,10 +15142,10 @@ async function viewBoard(adapter2, phaseFilter, options) {
14624
15142
  const bDate = b2.createdAt ? String(b2.createdAt) : "";
14625
15143
  return bDate.localeCompare(aDate);
14626
15144
  });
14627
- const total = allTasks.length;
15145
+ const total = filtered.length;
14628
15146
  const offset = options?.offset ?? 0;
14629
15147
  const limit = options?.limit ?? 50;
14630
- const paged = allTasks.slice(offset, offset + limit);
15148
+ const paged = filtered.slice(offset, offset + limit);
14631
15149
  return {
14632
15150
  tasks: paged,
14633
15151
  total,
@@ -14684,20 +15202,32 @@ async function archiveTasks(adapter2, phases, statuses) {
14684
15202
  // src/tools/board.ts
14685
15203
  var boardViewTool = {
14686
15204
  name: "board_view",
14687
- description: 'View the Board. By default shows active tasks only (excludes Done/Cancelled), sorted by priority, limited to 50. Use status="all" to see everything. Use mode="summary" for counts only (no task details). Does not call the Anthropic API.',
15205
+ description: 'View the Board. To find a SPECIFIC task or subset, FILTER FIRST \u2014 do not dump the whole board: pass task_id for one task (full detail), query="<text>" for a title/notes substring match, or cycle=<n> for one cycle. Combine with status/phase. By default shows active tasks only (excludes Done/Cancelled), sorted by priority, limited to 50; titles are truncated in the table (single-task lookup shows the full title). Use status="all" to see everything, mode="summary" for counts only. Does not call the Anthropic API.',
14688
15206
  annotations: { readOnlyHint: true, destructiveHint: false },
14689
15207
  inputSchema: {
14690
15208
  type: "object",
14691
15209
  properties: {
14692
- phase: {
15210
+ task_id: {
14693
15211
  type: "string",
14694
- description: 'Filter to tasks in this phase (e.g. "Phase 5").'
15212
+ description: 'Direct lookup of a single task by its display_id (e.g. "task-2154"). Returns full untruncated detail for just that task \u2014 no board scan. Takes precedence over the list filters.'
14695
15213
  },
14696
- status: {
15214
+ query: {
14697
15215
  type: "string",
14698
- description: 'Status filter: omit for active tasks only, "all" for everything, or comma-separated statuses (e.g. "In Progress,Backlog").'
15216
+ description: "Case-insensitive substring match on task title OR notes. Returns only matching tasks \u2014 use this instead of dumping the whole board to find something."
14699
15217
  },
14700
- limit: {
15218
+ cycle: {
15219
+ type: "number",
15220
+ description: "Filter to tasks assigned to this cycle number."
15221
+ },
15222
+ phase: {
15223
+ type: "string",
15224
+ description: 'Filter to tasks in this phase (e.g. "Phase 5").'
15225
+ },
15226
+ status: {
15227
+ type: "string",
15228
+ description: 'Status filter: omit for active tasks only, "all" for everything, or comma-separated statuses (e.g. "In Progress,Backlog").'
15229
+ },
15230
+ limit: {
14701
15231
  type: "number",
14702
15232
  description: "Max tasks to return (default: 50)."
14703
15233
  },
@@ -14828,6 +15358,16 @@ var boardEditTool = {
14828
15358
  cycle: {
14829
15359
  type: ["number", "null"],
14830
15360
  description: "Cycle assignment. Pass a cycle number to assign, or null to remove from any cycle. Validated against existing cycles. Replaces the prior workaround of editing cycle_tasks.cycle directly via SQL."
15361
+ },
15362
+ estimated_effort: {
15363
+ type: "string",
15364
+ enum: ["XS", "S", "M", "L", "XL"],
15365
+ description: "task-2182: correct the estimated effort on this task's LATEST build report (fixes a mis-recorded estimate). Updates the build_reports row, not the task \u2014 feeds the next estimation-accuracy recompute."
15366
+ },
15367
+ actual_effort: {
15368
+ type: "string",
15369
+ enum: ["XS", "S", "M", "L", "XL"],
15370
+ description: "task-2182: correct the actual effort on this task's LATEST build report (fixes a mis-recorded actual)."
14831
15371
  }
14832
15372
  },
14833
15373
  required: ["task_id"]
@@ -14836,6 +15376,27 @@ var boardEditTool = {
14836
15376
  function pad(value, width) {
14837
15377
  return value.length >= width ? value : value + " ".repeat(width - value.length);
14838
15378
  }
15379
+ var TITLE_MAX = 80;
15380
+ function truncateTitle(title) {
15381
+ return title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1)}\u2026` : title;
15382
+ }
15383
+ function formatSingleTask(t) {
15384
+ const lines = [
15385
+ `**${t.id} \u2014 ${t.title}**`,
15386
+ "",
15387
+ `- **Status:** ${t.status}`,
15388
+ `- **Priority:** ${t.priority}`,
15389
+ `- **Cycle:** ${t.cycle != null ? t.cycle : "-"}`,
15390
+ `- **Phase:** ${t.phase ?? "-"}`,
15391
+ `- **Module:** ${t.module ?? "-"}`,
15392
+ `- **Epic:** ${t.epic ?? "-"}`,
15393
+ `- **Effort:** ${t.complexity ?? "-"}`,
15394
+ `- **Source:** ${t.source ?? "-"}`
15395
+ ];
15396
+ if (t.notes?.trim()) lines.push(`- **Notes:** ${t.notes.trim()}`);
15397
+ if (t.why?.trim()) lines.push(`- **Why:** ${t.why.trim()}`);
15398
+ return lines.join("\n");
15399
+ }
14839
15400
  function formatBoard(result) {
14840
15401
  if (result.tasks.length === 0) {
14841
15402
  return "No tasks found.";
@@ -14844,7 +15405,7 @@ function formatBoard(result) {
14844
15405
  const rows = result.tasks.map((t) => [
14845
15406
  t.priority,
14846
15407
  t.id,
14847
- t.title,
15408
+ truncateTitle(t.title),
14848
15409
  t.status,
14849
15410
  t.cycle != null ? String(t.cycle) : "-",
14850
15411
  t.phase ?? "-",
@@ -14896,11 +15457,19 @@ async function handleBoardView(adapter2, args) {
14896
15457
  const summary = await viewBoardSummary(adapter2);
14897
15458
  return textResponse(formatSummary(summary));
14898
15459
  }
15460
+ const taskIdArg = args.task_id ?? args.display_id;
15461
+ if (taskIdArg) {
15462
+ const task = await adapter2.getTask(taskIdArg);
15463
+ if (!task) return errorResponse(`Task ${taskIdArg} not found.`);
15464
+ return textResponse(formatSingleTask(task));
15465
+ }
14899
15466
  const result = await viewBoard(adapter2, void 0, {
14900
15467
  phase: args.phase,
14901
15468
  status: args.status,
14902
15469
  limit: args.limit,
14903
- offset: args.offset
15470
+ offset: args.offset,
15471
+ query: args.query,
15472
+ cycle: args.cycle
14904
15473
  });
14905
15474
  let output = formatBoard(result);
14906
15475
  try {
@@ -15022,6 +15591,15 @@ async function handleBoardEdit(adapter2, args) {
15022
15591
  changes.push(field);
15023
15592
  }
15024
15593
  }
15594
+ const effortCorrection = {};
15595
+ if (typeof args.estimated_effort === "string") {
15596
+ effortCorrection.estimatedEffort = args.estimated_effort;
15597
+ changes.push("estimated_effort");
15598
+ }
15599
+ if (typeof args.actual_effort === "string") {
15600
+ effortCorrection.actualEffort = args.actual_effort;
15601
+ changes.push("actual_effort");
15602
+ }
15025
15603
  const notesMode = args.notes_mode;
15026
15604
  if (notesMode === "clear" && !changes.includes("notes")) {
15027
15605
  changes.push("notes");
@@ -15106,7 +15684,15 @@ ${existing}` : entry;
15106
15684
  if (updates.status === "Cancelled" && !updates.cancelledBy) {
15107
15685
  updates.cancelledBy = "user";
15108
15686
  }
15109
- await adapter2.updateTask(taskId, updates);
15687
+ if (effortCorrection.estimatedEffort || effortCorrection.actualEffort) {
15688
+ if (!adapter2.correctLatestBuildReportEffort) {
15689
+ return errorResponse("Correcting build-report effort requires a database adapter (pg). The md adapter does not support it.");
15690
+ }
15691
+ await adapter2.correctLatestBuildReportEffort(taskId, effortCorrection);
15692
+ }
15693
+ if (Object.keys(updates).length > 0) {
15694
+ await adapter2.updateTask(taskId, updates);
15695
+ }
15110
15696
  if ((updates.status === "Done" || updates.status === "Cancelled") && adapter2.updateDogfoodEntryStatus) {
15111
15697
  try {
15112
15698
  const dogfoodLog = await adapter2.getDogfoodLog?.(50) ?? [];
@@ -15383,7 +15969,7 @@ This is a one-time check at the start of the cycle, not per-task. It catches sco
15383
15969
  Every 5 cycles, PAPI offers a strategy review \u2014 a deep analysis of velocity, estimation accuracy, active decisions, and project direction.
15384
15970
 
15385
15971
  - **Don't skip them.** They're where compounding value comes from.
15386
- - Strategy reviews run in their own session \u2014 don't mix with building.
15972
+ - If your session is already heavy with build context, run the review fresh for cleaner output \u2014 a genuinely fresh session needs no restart.
15387
15973
  - Reviews produce recommendations that feed into the next plan.
15388
15974
  - If the review recommends AD changes, use \`strategy_change\` to apply them.
15389
15975
 
@@ -15781,6 +16367,9 @@ async function ensurePapiPermission(projectRoot) {
15781
16367
  } catch {
15782
16368
  }
15783
16369
  }
16370
+ var TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
16371
+ var CONVENTIONS_SENTINEL = "<!-- PAPI_CONVENTIONS -->";
16372
+ var CONVENTIONS_HEADING = "## Code Style Conventions";
15784
16373
  async function applySetupOutputs(adapter2, config2, input, collector, briefText, adSeedText, conventionsText) {
15785
16374
  const warnings = [];
15786
16375
  await adapter2.updateProductBrief(briefText);
@@ -15818,13 +16407,21 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
15818
16407
  }
15819
16408
  }
15820
16409
  let seededAds = 0;
16410
+ let skippedAds = 0;
15821
16411
  if (adSeedText) {
15822
16412
  try {
15823
16413
  const cleaned = adSeedText.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
15824
16414
  const ads = JSON.parse(cleaned);
15825
16415
  if (Array.isArray(ads)) {
16416
+ const existingAdIds = adapter2.getActiveDecisions ? new Set(
16417
+ (await adapter2.getActiveDecisions({ includeRetired: true }).catch(() => [])).map((a) => a.displayId)
16418
+ ) : /* @__PURE__ */ new Set();
15826
16419
  for (const ad of ads) {
15827
16420
  if (ad.id && ad.body) {
16421
+ if (existingAdIds.has(ad.id) && !input.force) {
16422
+ skippedAds++;
16423
+ continue;
16424
+ }
15828
16425
  if (adapter2.upsertActiveDecision) {
15829
16426
  const title = ad.title || ad.body.split("\n")[0].replace(/^#+\s*/, "").slice(0, 120);
15830
16427
  await adapter2.upsertActiveDecision(ad.id, ad.body, title, ad.confidence || "MEDIUM", 0);
@@ -15842,7 +16439,11 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
15842
16439
  );
15843
16440
  seededAds = 0;
15844
16441
  }
15845
- if (seededAds === 0 && adSeedText) {
16442
+ if (skippedAds > 0) {
16443
+ warnings.push(
16444
+ `Active Decisions: detected ${skippedAds} existing AD(s) and left them untouched${seededAds > 0 ? `; created ${seededAds} new one(s)` : " (none new to create)"}.`
16445
+ );
16446
+ } else if (seededAds === 0 && adSeedText) {
15846
16447
  if (!warnings.some((w) => w.startsWith("AD seeding failed"))) {
15847
16448
  warnings.push(
15848
16449
  "AD seeding produced 0 active decisions \u2014 the JSON may be valid but empty or missing required `id` and `body` fields."
@@ -15851,17 +16452,29 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
15851
16452
  }
15852
16453
  }
15853
16454
  if (conventionsText?.trim()) {
16455
+ const conventionsBlock = `${CONVENTIONS_SENTINEL}
16456
+ ${conventionsText.trim()}
16457
+ `;
15854
16458
  if (config2.adapterType === "proxy") {
15855
16459
  collector.add({
15856
16460
  path: "CLAUDE.md",
15857
- content: "\n" + conventionsText.trim() + "\n",
16461
+ content: "\n" + conventionsBlock,
15858
16462
  mode: "append"
15859
16463
  });
16464
+ warnings.push(
16465
+ `Conventions: append the block to CLAUDE.md only if it does not already contain \`${CONVENTIONS_SENTINEL}\` or a "${CONVENTIONS_HEADING}" section.`
16466
+ );
15860
16467
  } else {
15861
16468
  try {
15862
16469
  const claudeMdPath = join5(config2.projectRoot, "CLAUDE.md");
15863
16470
  const existing = await readFile4(claudeMdPath, "utf-8");
15864
- await writeFile2(claudeMdPath, existing + "\n" + conventionsText.trim() + "\n", "utf-8");
16471
+ if (existing.includes(CONVENTIONS_SENTINEL) || existing.includes(CONVENTIONS_HEADING)) {
16472
+ warnings.push(
16473
+ "Conventions: CLAUDE.md already contains a conventions block \u2014 skipped append to avoid duplication."
16474
+ );
16475
+ } else {
16476
+ await writeFile2(claudeMdPath, existing + "\n" + conventionsBlock, "utf-8");
16477
+ }
15865
16478
  } catch {
15866
16479
  }
15867
16480
  }
@@ -16048,8 +16661,8 @@ async function prepareSetup(adapter2, config2, input) {
16048
16661
  config2.adapterType === "proxy" ? "Could not read Product Brief from PAPI. Check your PAPI connection (OAuth sign-in, bearer token, or PAPI_DATA_API_KEY) and that PAPI_PROJECT_ID matches the project you want to scaffold." : config2.adapterType === "pg" ? "Could not read Product Brief from database. Check DATABASE_URL and PAPI_PROJECT_ID are set correctly in your MCP config." : "Could not read PRODUCT_BRIEF.md. Ensure .papi/ directory exists."
16049
16662
  );
16050
16663
  }
16051
- const TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
16052
- const briefHasRealContent = existingBrief.trim().length > 0 && !existingBrief.includes(TEMPLATE_MARKER);
16664
+ const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
16665
+ const briefHasRealContent = existingBrief.trim().length > 0 && !existingBrief.includes(TEMPLATE_MARKER2);
16053
16666
  const briefAlreadyExists = briefHasRealContent && !input.force;
16054
16667
  const canScanFilesystem = hasLocalWorkspace();
16055
16668
  const warnings = [];
@@ -16160,6 +16773,15 @@ async function prepareSetup(adapter2, config2, input) {
16160
16773
  targetUsers: input.targetUsers,
16161
16774
  codebaseContext: codebaseSummary
16162
16775
  })
16776
+ } : input.description?.trim() ? {
16777
+ system: VISION_TASKS_SYSTEM,
16778
+ user: buildVisionTasksPrompt({
16779
+ projectName: input.projectName,
16780
+ description: input.description,
16781
+ targetUsers: input.targetUsers,
16782
+ problems: input.problems,
16783
+ projectType: input.projectType
16784
+ })
16163
16785
  } : void 0;
16164
16786
  return {
16165
16787
  createdProject,
@@ -16181,7 +16803,6 @@ async function prepareSetup(adapter2, config2, input) {
16181
16803
  async function applySetup(adapter2, config2, input, briefText, adSeedText, conventionsText, initialTasksText) {
16182
16804
  const collector = new FileWriteCollector();
16183
16805
  const createdProject = await scaffoldPapiDir(adapter2, config2, input, collector);
16184
- const TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
16185
16806
  let effectiveBriefText = briefText;
16186
16807
  let briefRegenerated = false;
16187
16808
  if (!effectiveBriefText.trim()) {
@@ -16240,7 +16861,7 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
16240
16861
  `100% overlap \u2014 all ${validTasks.length} initial task(s) already exist in the board (matched by title). Re-run without initial_tasks_response, or add force: true to skip the overlap guard.`
16241
16862
  );
16242
16863
  }
16243
- const MAX_INITIAL_TASKS = 10;
16864
+ const MAX_INITIAL_TASKS = 20;
16244
16865
  const failedTasks = [];
16245
16866
  for (const task of toCreate.slice(0, MAX_INITIAL_TASKS)) {
16246
16867
  try {
@@ -16516,10 +17137,11 @@ ${result.seededAds} Active Decision${result.seededAds > 1 ? "s" : ""} seeded bas
16516
17137
  const taskNote = result.createdTasks > 0 || (result.tasksSkipped ?? 0) > 0 ? (() => {
16517
17138
  const created = result.createdTasks > 0 ? `${result.createdTasks} initial backlog task${result.createdTasks > 1 ? "s" : ""} created` : "";
16518
17139
  const skipped = (result.tasksSkipped ?? 0) > 0 ? `${result.tasksSkipped} duplicate${(result.tasksSkipped ?? 0) > 1 ? "s" : ""} skipped` : "";
17140
+ const idea = result.createdTasks < 8 ? ` Your backlog is light \u2014 add more with \`idea "<what you want to build>"\` so your next plan has fuel.` : ` Capture new ideas any time with \`idea "<...>"\` \u2014 mid-build or after a cycle, it all feeds the next plan.`;
16519
17141
  return `
16520
17142
 
16521
- ${[created, skipped].filter(Boolean).join(", ")}.`;
16522
- })() : "";
17143
+ ${[created, skipped].filter(Boolean).join(", ")}.${idea}`;
17144
+ })() : '\n\nNo starter tasks yet. Describe what you want to build, or seed the board with `idea "<what you want to build>"`, so your first `plan` has something to work from.';
16523
17145
  const constraintsHint = !constraints ? '\n\nTip: consider adding `constraints` (e.g. "must use PostgreSQL", "HIPAA compliant", "offline-first") to improve Active Decision seeding.' : "";
16524
17146
  const editorNote = result.cursorScaffolded ? "\n\nCursor detected \u2014 `.cursor/rules/papi.mdc` scaffolded alongside CLAUDE.md." : "";
16525
17147
  const gitignoreNote = result.gitignoreNote ? `
@@ -16619,6 +17241,17 @@ PAPI needs the project name. Description and target users are optional \u2014 th
16619
17241
  ""
16620
17242
  );
16621
17243
  }
17244
+ if (result.briefPrompt && !result.briefAlreadyExists) {
17245
+ sections.push(
17246
+ `**\u{1F4C4} Before you write the brief \u2014 does the user already have a PRD, brief, spec, or design doc?**`,
17247
+ `A brief generated from the user's real spec produces a far better first plan than a generic scaffold.`,
17248
+ `1. **Ask:** "Do you already have a PRD, brief, spec, or design doc for this \u2014 in this folder or anywhere else (even another directory)? Point me at it or paste it in."`,
17249
+ `2. **Scan the working directory yourself** for likely files and offer them: \`PRD*\`, \`README*\`, \`SPEC*\`/\`spec*\`, design docs, and \`*.md\` under \`docs/\`.`,
17250
+ `3. If the user supplies or approves any, **re-run \`setup\` with \`sources: "<comma-separated file paths>"\`** (local stdio install) so the brief is generated FROM the real doc. Over a hosted/remote connector PAPI can't read local paths \u2014 paste the content into \`description\` instead.`,
17251
+ `If there's no existing doc, skip this and generate the brief from what you know \u2014 the zero-doc path is fully supported.`,
17252
+ ""
17253
+ );
17254
+ }
16622
17255
  sections.push(
16623
17256
  `Generate the outputs below, then call \`setup\` again with:`,
16624
17257
  `- \`mode\`: "apply"`,
@@ -17000,8 +17633,16 @@ function inferCycleFromVersion(version) {
17000
17633
  const m = version.match(/^v0\.(\d+)\./);
17001
17634
  return m ? parseInt(m[1], 10) : 0;
17002
17635
  }
17003
- async function resolveCycleToClose(adapter2, version) {
17636
+ async function resolveCycleToClose(adapter2, version, callerUserId) {
17004
17637
  if (adapter2) {
17638
+ if (callerUserId) {
17639
+ try {
17640
+ const mine = callerUserId.trim().toLowerCase();
17641
+ const owned = (await adapter2.readCycles()).filter((c) => c.status === "active" && c.userId?.trim().toLowerCase() === mine).map((c) => c.number);
17642
+ if (owned.length > 0) return Math.max(...owned);
17643
+ } catch {
17644
+ }
17645
+ }
17005
17646
  try {
17006
17647
  const health = await adapter2.getCycleHealth();
17007
17648
  if (health.totalCycles > 0) return health.totalCycles;
@@ -17010,21 +17651,11 @@ async function resolveCycleToClose(adapter2, version) {
17010
17651
  }
17011
17652
  return inferCycleFromVersion(version);
17012
17653
  }
17013
- async function createRelease(config2, branch, version, adapter2, cycleNum, options) {
17014
- const collector = new FileWriteCollector();
17015
- if (!isGitAvailable()) {
17016
- throw new Error("git is not available.");
17017
- }
17018
- if (!isGitRepo(config2.projectRoot)) {
17019
- throw new Error("not a git repository.");
17020
- }
17021
- if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
17022
- throw new Error("working directory has uncommitted changes. Commit or stash them before releasing.");
17023
- }
17654
+ async function closeCycleState(config2, adapter2, version, cycleNum, options) {
17024
17655
  const warnings = [];
17025
17656
  const force = options?.force ?? false;
17026
17657
  const skipVersion = options?.skipVersion ?? false;
17027
- const resolvedCycleNum = cycleNum && cycleNum > 0 ? cycleNum : await resolveCycleToClose(adapter2, version);
17658
+ const resolvedCycleNum = cycleNum && cycleNum > 0 ? cycleNum : await resolveCycleToClose(adapter2, version, options?.callerUserId);
17028
17659
  const versionRegexCycle = inferCycleFromVersion(version);
17029
17660
  if (resolvedCycleNum > 0 && versionRegexCycle > 0 && versionRegexCycle !== resolvedCycleNum) {
17030
17661
  warnings.push(
@@ -17068,30 +17699,6 @@ To override, pass force=true (emits a telemetry warning).`
17068
17699
  warnings.push(`Release-readiness check failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
17069
17700
  }
17070
17701
  }
17071
- if (adapter2) {
17072
- try {
17073
- const resolvedBase = resolveBaseBranch(config2.projectRoot, branch);
17074
- const orphans = listOrphanFeatBranches(config2.projectRoot, resolvedBase);
17075
- const allDoneOrphans = [];
17076
- for (const orphanBranch of orphans) {
17077
- const taskIds = getTaskIdsOnBranch(config2.projectRoot, orphanBranch, resolvedBase);
17078
- if (taskIds.length === 0) continue;
17079
- const tasks = await adapter2.getTasks(taskIds);
17080
- if (tasks.length === 0) continue;
17081
- const allDone = tasks.every((t) => t.status === "Done");
17082
- if (allDone) {
17083
- allDoneOrphans.push(`${orphanBranch} (${taskIds.join(", ")})`);
17084
- }
17085
- }
17086
- if (allDoneOrphans.length > 0) {
17087
- warnings.push(
17088
- `Orphan branches detected \u2014 ${allDoneOrphans.length} feature branch(es) have all tasks Done but are not merged into ${resolvedBase}. Merge or delete before next release: ${allDoneOrphans.join("; ")}`
17089
- );
17090
- }
17091
- } catch (err) {
17092
- warnings.push(`Orphan-branch detection failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
17093
- }
17094
- }
17095
17702
  if (adapter2) {
17096
17703
  const currentCycle = resolvedCycleNum;
17097
17704
  if (currentCycle > 0) {
@@ -17115,9 +17722,64 @@ To override, pass force=true (emits a telemetry warning).`
17115
17722
  const msg = `createCycle (mark complete) failed for cycle ${currentCycle}: ${err instanceof Error ? err.message : String(err)}`;
17116
17723
  console.error(`[release] ${msg}`);
17117
17724
  throw new Error(
17118
- `Release blocked \u2014 could not mark cycle ${currentCycle} complete in the DB before tagging. ${err instanceof Error ? err.message : String(err)}. Resolve the DB error and re-run \`release\`.`
17725
+ `Release blocked \u2014 could not mark cycle ${currentCycle} complete in the DB. ${err instanceof Error ? err.message : String(err)}. Resolve the DB error and re-run \`release\`.`
17726
+ );
17727
+ }
17728
+ if (adapter2.appendCycleMetrics && adapter2.getBuildReportsSince) {
17729
+ try {
17730
+ const cycleReports = (await adapter2.getBuildReportsSince(currentCycle)).filter((r) => r.cycle === currentCycle);
17731
+ const [snapshot] = computeSnapshotsFromBuildReports(cycleReports);
17732
+ if (snapshot) await adapter2.appendCycleMetrics(snapshot);
17733
+ } catch (err) {
17734
+ console.error(
17735
+ `[release] cycle-metrics snapshot write failed for cycle ${currentCycle} (non-blocking): ${err instanceof Error ? err.message : String(err)}`
17736
+ );
17737
+ }
17738
+ }
17739
+ }
17740
+ }
17741
+ return { resolvedCycleNum, warnings, force, skipVersion };
17742
+ }
17743
+ async function createRelease(config2, branch, version, adapter2, cycleNum, options) {
17744
+ const collector = new FileWriteCollector();
17745
+ if (!isGitAvailable()) {
17746
+ throw new Error("git is not available.");
17747
+ }
17748
+ if (!isGitRepo(config2.projectRoot)) {
17749
+ throw new Error("not a git repository.");
17750
+ }
17751
+ if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
17752
+ throw new Error("working directory has uncommitted changes. Commit or stash them before releasing.");
17753
+ }
17754
+ const { resolvedCycleNum, warnings, skipVersion } = await closeCycleState(
17755
+ config2,
17756
+ adapter2,
17757
+ version,
17758
+ cycleNum,
17759
+ options
17760
+ );
17761
+ if (adapter2) {
17762
+ try {
17763
+ const resolvedBase = resolveBaseBranch(config2.projectRoot, branch);
17764
+ const orphans = listOrphanFeatBranches(config2.projectRoot, resolvedBase);
17765
+ const allDoneOrphans = [];
17766
+ for (const orphanBranch of orphans) {
17767
+ const taskIds = getTaskIdsOnBranch(config2.projectRoot, orphanBranch, resolvedBase);
17768
+ if (taskIds.length === 0) continue;
17769
+ const tasks = await adapter2.getTasks(taskIds);
17770
+ if (tasks.length === 0) continue;
17771
+ const allDone = tasks.every((t) => t.status === "Done");
17772
+ if (allDone) {
17773
+ allDoneOrphans.push(`${orphanBranch} (${taskIds.join(", ")})`);
17774
+ }
17775
+ }
17776
+ if (allDoneOrphans.length > 0) {
17777
+ warnings.push(
17778
+ `\u26A0\uFE0F DATA-INTEGRITY \u2014 ${allDoneOrphans.length} branch(es) have all tasks marked **Done** but were NEVER merged into ${resolvedBase}: their Done status is not backed by git. Merge or delete before next release: ${allDoneOrphans.join("; ")}`
17119
17779
  );
17120
17780
  }
17781
+ } catch (err) {
17782
+ warnings.push(`Orphan-branch detection failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
17121
17783
  }
17122
17784
  }
17123
17785
  const checkout = checkoutBranch(config2.projectRoot, branch);
@@ -17148,6 +17810,21 @@ To override, pass force=true (emits a telemetry warning).`
17148
17810
  }
17149
17811
  }
17150
17812
  }
17813
+ if (adapter2 && resolvedCycleNum > 0) {
17814
+ try {
17815
+ const stampedAt = (/* @__PURE__ */ new Date()).toISOString();
17816
+ const doneCycleTasks = (await adapter2.queryBoard()).filter(
17817
+ (t) => t.cycle === resolvedCycleNum && t.status === "Done"
17818
+ );
17819
+ for (const t of doneCycleTasks) {
17820
+ try {
17821
+ await adapter2.updateTask(t.id, { mergedAt: stampedAt });
17822
+ } catch {
17823
+ }
17824
+ }
17825
+ } catch {
17826
+ }
17827
+ }
17151
17828
  const latestTag = getLatestTag(config2.projectRoot);
17152
17829
  const changelogPath = join7(config2.projectRoot, "CHANGELOG.md");
17153
17830
  if (!latestTag) {
@@ -17207,60 +17884,6 @@ To override, pass force=true (emits a telemetry warning).`
17207
17884
  // src/tools/release.ts
17208
17885
  init_git();
17209
17886
 
17210
- // src/lib/owner-identity.ts
17211
- function isProjectOwner(callerUserId, ownerUserId) {
17212
- if (!callerUserId || !ownerUserId) return false;
17213
- const caller = callerUserId.trim().toLowerCase();
17214
- const owner = ownerUserId.trim().toLowerCase();
17215
- if (caller.length === 0 || owner.length === 0) return false;
17216
- return caller === owner;
17217
- }
17218
- async function resolveOwnerGate(adapter2, config2) {
17219
- if (typeof adapter2.getOwnerIdentity === "function") {
17220
- try {
17221
- const identity = await adapter2.getOwnerIdentity();
17222
- const callerUserId = identity.callerUserId ?? config2.userId ?? null;
17223
- return {
17224
- enforced: true,
17225
- callerIsOwner: isProjectOwner(callerUserId, identity.ownerUserId),
17226
- callerUserId,
17227
- ownerUserId: identity.ownerUserId
17228
- };
17229
- } catch (err) {
17230
- return {
17231
- enforced: true,
17232
- callerIsOwner: false,
17233
- callerUserId: config2.userId ?? null,
17234
- ownerUserId: null,
17235
- resolutionError: err instanceof Error ? err.message : String(err)
17236
- };
17237
- }
17238
- }
17239
- if (typeof adapter2.getProjectOwnerUserId === "function") {
17240
- let ownerUserId = null;
17241
- let resolutionError;
17242
- try {
17243
- ownerUserId = await adapter2.getProjectOwnerUserId();
17244
- } catch (err) {
17245
- resolutionError = err instanceof Error ? err.message : String(err);
17246
- }
17247
- const callerUserId = config2.userId ?? null;
17248
- return {
17249
- enforced: true,
17250
- callerIsOwner: isProjectOwner(callerUserId, ownerUserId),
17251
- callerUserId,
17252
- ownerUserId,
17253
- ...resolutionError !== void 0 ? { resolutionError } : {}
17254
- };
17255
- }
17256
- return {
17257
- enforced: false,
17258
- callerIsOwner: true,
17259
- callerUserId: config2.userId ?? null,
17260
- ownerUserId: null
17261
- };
17262
- }
17263
-
17264
17887
  // src/lib/registry-updates.ts
17265
17888
  import { execFile } from "child_process";
17266
17889
  import { promisify } from "util";
@@ -17371,21 +17994,32 @@ function parseGithubOwner(input) {
17371
17994
  function sanitiseBranchSuffix(branch) {
17372
17995
  return branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
17373
17996
  }
17374
- async function postReleaseToDiscord(version, cycleClosed) {
17375
- const url = process.env["DISCORD_CHANGELOG_WEBHOOK"]?.trim();
17376
- if (!url) return false;
17377
- const cyclePart = cycleClosed > 0 ? `Cycle ${cycleClosed}` : "New cycle";
17378
- const content = `${cyclePart} released: ${version}. https://getpapi.ai/changelog`;
17379
- try {
17380
- const res = await fetch(url, {
17381
- method: "POST",
17382
- headers: { "Content-Type": "application/json" },
17383
- body: JSON.stringify({ content })
17384
- });
17385
- return res.ok;
17386
- } catch {
17387
- return false;
17388
- }
17997
+ var CYCLE_UPDATES_CHANNEL_ENV = "DISCORD_CYCLE_UPDATES_CHANNEL_ID";
17998
+ function buildCycleUpdateCurationDirective(version, cycleClosed) {
17999
+ const channelId = process.env[CYCLE_UPDATES_CHANNEL_ENV]?.trim();
18000
+ if (!channelId) return null;
18001
+ const cyclePart = cycleClosed > 0 ? `Cycle ${cycleClosed}` : "New release";
18002
+ return [
18003
+ "",
18004
+ "---",
18005
+ "## \u{1F4E3} Post a curated cycle update to Discord #papi-cycle-updates",
18006
+ "",
18007
+ `**${cyclePart} \u2014 ${version}** just shipped. Post a curated, safety-gated **rich embed** (NOT plain content, so the link does not unfurl) to the private #papi-cycle-updates channel (id \`${channelId}\`) via \`mcp__discord__send_embed\`.`,
18008
+ "",
18009
+ "**LOCKED TEMPLATE \u2014 every cycle update uses this exact shape so they stay consistent (do NOT improvise the structure):**",
18010
+ `- \`title\`: \`${cyclePart} shipped \u2014 ${version}\``,
18011
+ "- `url`: `https://getpapi.ai/changelog` (makes the title link to the changelog)",
18012
+ "- `color`: `#5a4b8a` (Papi plum)",
18013
+ "- `description`: 4-6 PLAIN one-line bullets (`- ` prefix). No emoji-headed sections, no theme intro paragraph, no sub-headings. Each bullet = one user-facing outcome in the `/patch-notes` voice (warm, plain, outcome-framed). Strip task-IDs and internal framing. Only genuinely user-facing changes \u2014 internal/owner-only/site-plumbing tasks do NOT get a bullet.",
18014
+ `- \`footer\`: \`Built with PAPI \xB7 <N> tasks \xB7 getpapi.ai\` where <N> = the count of THIS cycle's completed tasks (${cyclePart}).`,
18015
+ "",
18016
+ "Then:",
18017
+ "1. Run the build-in-public safety gate: NO external usernames, NO contributor/private work, NO owner cost/commercial data. (#papi-cycle-updates is private \u2014 internal detail is OK \u2014 but keep the gate habit so anything later promoted to public is already clean.)",
18018
+ "2. Show the draft embed for a quick approval BEFORE posting.",
18019
+ `3. On approval, send the embed to channel \`${channelId}\`.`,
18020
+ "",
18021
+ "Skip entirely if nothing user-facing is worth sharing this cycle. Do NOT post to public #changelog per-cycle \u2014 that now happens at strategy-review cadence via /patch-notes."
18022
+ ].join("\n");
17389
18023
  }
17390
18024
  async function postReleaseToX(version, cycleClosed) {
17391
18025
  const url = process.env["X_RELEASE_WEBHOOK_URL"]?.trim();
@@ -17469,14 +18103,48 @@ async function handleRelease(adapter2, config2, args) {
17469
18103
  version = `${version}-${suffix}`;
17470
18104
  }
17471
18105
  }
17472
- if (isHostedTransport()) {
17473
- const tagAnnotation = `Release ${version}`;
17474
- return errorResponse(
17475
- `Release is not available on the hosted remote MCP transport.
18106
+ const tracker = new ProgressTracker("validate-args");
18107
+ try {
18108
+ tracker.mark("owner-identity-guard");
18109
+ const gate = await resolveOwnerGate(adapter2, config2);
18110
+ const productionBaseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
18111
+ if (branch === productionBaseBranch) {
18112
+ if (gate.enforced && !gate.callerIsOwner) {
18113
+ const resolutionNote = gate.resolutionError ? `
18114
+
18115
+ Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.` : "";
18116
+ return errorResponse(
18117
+ `Release to ${productionBaseBranch} is restricted to the project owner.
18118
+
18119
+ Your identity does not match this project's owner. If you are a contributor, push your branch and open a PR for the owner to release. If you ARE the owner on a local (pg) setup, set PAPI_USER_ID to your account UUID in .mcp.json; on the hosted/proxy setup your identity comes from your API key \u2014 check you are using YOUR key for YOUR project. Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
18120
+ );
18121
+ }
18122
+ }
18123
+ if (isHostedTransport()) {
18124
+ tracker.mark("hosted-close-cycle");
18125
+ let closed;
18126
+ try {
18127
+ closed = await closeCycleState(config2, adapter2, version, void 0, {
18128
+ force: force ?? false,
18129
+ callerUserId: gate.callerUserId
18130
+ });
18131
+ } catch (err) {
18132
+ const message = err instanceof Error ? err.message : String(err);
18133
+ return errorResponse(message);
18134
+ }
18135
+ const tagAnnotation = `Release ${version}`;
18136
+ const cyclePart = closed.resolvedCycleNum > 0 ? `Cycle ${closed.resolvedCycleNum}` : "The cycle";
18137
+ const warningsBlock = closed.warnings.length > 0 ? `
18138
+ \u26A0\uFE0F Warnings: ${closed.warnings.join("; ")}
18139
+ ` : "";
18140
+ return textResponse(
18141
+ `## Release ${version} \u2014 cycle closed in the DB
17476
18142
 
17477
- The release tool runs git (tag, push) and generates CHANGELOG.md on the server's filesystem. The hosted server (mcp.getpapi.ai) has no checkout of your project and no git binary, so these steps can't run remotely.
18143
+ ${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
18144
+ ` + warningsBlock + `
18145
+ The hosted remote MCP transport (mcp.getpapi.ai) has no checkout of your project and no git binary, so the git half of the release (tag, push, CHANGELOG.md) must run on your own machine.
17478
18146
 
17479
- To close this cycle locally, on your own machine:
18147
+ Finish the release locally:
17480
18148
  \`\`\`
17481
18149
  git checkout ${branch}
17482
18150
  git pull
@@ -17490,11 +18158,9 @@ If git reports "Author identity unknown" or "Committer identity unknown" (no glo
17490
18158
  git -c user.name="Your Name" -c user.email="you@example.com" tag -a ${version} -m "${tagAnnotation}"
17491
18159
  \`\`\`
17492
18160
 
17493
- Known limitation: PAPI's internal "cycle marked complete" state is updated by the release tool itself, so until you can run release from a local PAPI install, \`orient\` will continue to flag "Release has not been run." Tracked as a follow-up under SUP-2026-016.`
17494
- );
17495
- }
17496
- const tracker = new ProgressTracker("validate-args");
17497
- try {
18161
+ Next: cycle closed! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`
18162
+ );
18163
+ }
17498
18164
  tracker.mark("remote-project-guard");
17499
18165
  if (adapter2.getProjectInfo) {
17500
18166
  try {
@@ -17516,21 +18182,6 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
17516
18182
  console.error("[release] remote-project guard: skipped due to error \u2014 " + (err instanceof Error ? err.message : String(err)));
17517
18183
  }
17518
18184
  }
17519
- tracker.mark("owner-identity-guard");
17520
- const productionBaseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
17521
- if (branch === productionBaseBranch) {
17522
- const gate = await resolveOwnerGate(adapter2, config2);
17523
- if (gate.enforced && !gate.callerIsOwner) {
17524
- const resolutionNote = gate.resolutionError ? `
17525
-
17526
- Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.` : "";
17527
- return errorResponse(
17528
- `Release to ${productionBaseBranch} is restricted to the project owner.
17529
-
17530
- Your identity does not match this project's owner. If you are a contributor, push your branch and open a PR for the owner to release. If you ARE the owner on a local (pg) setup, set PAPI_USER_ID to your account UUID in .mcp.json; on the hosted/proxy setup your identity comes from your API key \u2014 check you are using YOUR key for YOUR project. Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
17531
- );
17532
- }
17533
- }
17534
18185
  tracker.mark("preflight-grouped-branches");
17535
18186
  const cycleMatch = version.match(/^v0\.(\d+)\./);
17536
18187
  const cycleNumForPreflight = cycleMatch ? parseInt(cycleMatch[1], 10) : void 0;
@@ -17542,7 +18193,11 @@ Your identity does not match this project's owner. If you are a contributor, pus
17542
18193
  }
17543
18194
  }
17544
18195
  tracker.mark("create-release");
17545
- const result = await createRelease(config2, branch, version, adapter2, void 0, { force: force ?? false, skipVersion: skipVersion ?? false });
18196
+ const result = await createRelease(config2, branch, version, adapter2, void 0, {
18197
+ force: force ?? false,
18198
+ skipVersion: skipVersion ?? false,
18199
+ callerUserId: gate.callerUserId
18200
+ });
17546
18201
  const lines = [
17547
18202
  `## Release ${result.version}${skipVersion ? " (skip version)" : ""}`,
17548
18203
  "",
@@ -17594,11 +18249,8 @@ Your identity does not match this project's owner. If you are a contributor, pus
17594
18249
  lines.push("", "\u26A0\uFE0F Dogfood observations could not be saved to DB \u2014 log them manually in DOGFOOD_LOG.md.");
17595
18250
  }
17596
18251
  }
17597
- try {
17598
- const posted = await postReleaseToDiscord(result.version, result.cycleClosed ?? 0);
17599
- if (posted) lines.push("", "Posted release announcement to Discord #changelog.");
17600
- } catch {
17601
- }
18252
+ const cycleUpdateDirective = buildCycleUpdateCurationDirective(result.version, result.cycleClosed ?? 0);
18253
+ if (cycleUpdateDirective) lines.push(cycleUpdateDirective);
17602
18254
  try {
17603
18255
  const xPosted = await postReleaseToX(result.version, result.cycleClosed ?? 0);
17604
18256
  if (xPosted) lines.push("", "Posted release announcement to X.");
@@ -17618,7 +18270,7 @@ Your identity does not match this project's owner. If you are a contributor, pus
17618
18270
  }
17619
18271
  } catch {
17620
18272
  }
17621
- lines.push("", `Next: cycle released! Run \`plan\` to start your next planning cycle.`);
18273
+ lines.push("", `Next: cycle released! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`);
17622
18274
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
17623
18275
  return textResponse(lines.join("\n") + filesToWriteSection);
17624
18276
  } catch (err) {
@@ -17746,6 +18398,16 @@ function selectAutostashPaths(modified, untracked) {
17746
18398
  const stashableUntracked = untracked.filter((p) => !isDocsPath(p));
17747
18399
  return { toStash: [...trackedDirty, ...stashableUntracked], preservedDocs };
17748
18400
  }
18401
+ function resolveRelatedDecisions(provided, buildHandoff) {
18402
+ const fromInput = (provided ?? "").split(",").map((s) => s.trim()).filter(Boolean);
18403
+ if (fromInput.length > 0) return { adIds: Array.from(new Set(fromInput)), inferred: false };
18404
+ if (buildHandoff) {
18405
+ const text = typeof buildHandoff === "string" ? buildHandoff : JSON.stringify(buildHandoff);
18406
+ const matches = text.match(/\bAD-\d+\b/g);
18407
+ if (matches && matches.length > 0) return { adIds: Array.from(new Set(matches)), inferred: true };
18408
+ }
18409
+ return { adIds: [], inferred: false };
18410
+ }
17749
18411
  var buildStartTimes = /* @__PURE__ */ new Map();
17750
18412
  var taskBranchMap = /* @__PURE__ */ new Map();
17751
18413
  var taskStartShaMap = /* @__PURE__ */ new Map();
@@ -17761,16 +18423,36 @@ function sanitisePredictedFiles(raw) {
17761
18423
  const out = [];
17762
18424
  for (const entry of raw) {
17763
18425
  if (typeof entry !== "string") continue;
17764
- const altMatches = entry.match(/\(\s*or\s+`([^`]+)`\s*\)/gi) ?? [];
17765
- for (const alt of altMatches) {
17766
- const inner = alt.match(/`([^`]+)`/);
17767
- if (inner) out.push(inner[1].trim());
18426
+ for (const segment of entry.split(";")) {
18427
+ const altMatches = segment.match(/\(\s*or\s+`([^`]+)`\s*\)/gi) ?? [];
18428
+ for (const alt of altMatches) {
18429
+ const inner = alt.match(/`([^`]+)`/);
18430
+ if (inner) out.push(inner[1].trim());
18431
+ }
18432
+ const cleaned = segment.replace(/`/g, "").replace(/\s*\(.*$/, "").trim();
18433
+ if (cleaned) out.push(cleaned);
17768
18434
  }
17769
- const cleaned = entry.replace(/`/g, "").replace(/\s*\(.*$/, "").trim();
17770
- if (cleaned) out.push(cleaned);
17771
18435
  }
17772
18436
  return Array.from(new Set(out));
17773
18437
  }
18438
+ function pathBasename(p) {
18439
+ const parts = p.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
18440
+ return parts[parts.length - 1] ?? p;
18441
+ }
18442
+ function isPathInPredictedScope(changedPath, predicted) {
18443
+ const changedLower = changedPath.replace(/\\/g, "/").toLowerCase();
18444
+ const changedBase = pathBasename(changedPath);
18445
+ for (const raw of predicted) {
18446
+ const entry = raw.replace(/\\/g, "/").replace(/\/+$/, "").trim();
18447
+ if (!entry) continue;
18448
+ if (pathBasename(entry) === changedBase) return true;
18449
+ const entryLower = entry.toLowerCase();
18450
+ if (changedLower === entryLower || changedLower.startsWith(`${entryLower}/`)) {
18451
+ return true;
18452
+ }
18453
+ }
18454
+ return false;
18455
+ }
17774
18456
  function autoCommit(config2, taskId, taskTitle, predictedFiles) {
17775
18457
  const cwd = config2.projectRoot;
17776
18458
  const message = `feat(${taskId}): ${taskTitle}`;
@@ -17800,17 +18482,12 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
17800
18482
  if (modified.length === 0) {
17801
18483
  return "Auto-commit: skipped (no working-tree changes).";
17802
18484
  }
17803
- const basename2 = (p) => {
17804
- const parts = p.split(/[\\/]/);
17805
- return parts[parts.length - 1] ?? p;
17806
- };
17807
18485
  const dirname6 = (p) => {
17808
18486
  const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
17809
18487
  return idx > 0 ? p.slice(0, idx) : "";
17810
18488
  };
17811
18489
  const cleanedPredicted = sanitisePredictedFiles(predictedFiles);
17812
- const predictedNames = new Set(cleanedPredicted.map(basename2).filter(Boolean));
17813
- const scoped = modified.filter((p) => predictedNames.has(basename2(p)));
18490
+ const scoped = modified.filter((p) => isPathInPredictedScope(p, cleanedPredicted));
17814
18491
  if (scoped.length === 0) {
17815
18492
  const modSample = modified.slice(0, 5).join(", ");
17816
18493
  const predSample = cleanedPredicted.slice(0, 5).join(", ");
@@ -17938,12 +18615,8 @@ function computeScopeDriftSignal(predicted, changed) {
17938
18615
  if (!predicted || predicted.length === 0) return null;
17939
18616
  const filtered = changed.filter((c) => !isAmbientPath(c));
17940
18617
  if (filtered.length === 0) return null;
17941
- const basename2 = (p) => {
17942
- const parts = p.split(/[\\/]/);
17943
- return parts[parts.length - 1] ?? p;
17944
- };
17945
- const predictedNames = new Set(predicted.map(basename2).filter(Boolean));
17946
- const unexpected = filtered.filter((c) => !predictedNames.has(basename2(c)));
18618
+ const cleanedPredicted = sanitisePredictedFiles(predicted);
18619
+ const unexpected = filtered.filter((c) => !isPathInPredictedScope(c, cleanedPredicted));
17947
18620
  const fraction = unexpected.length / filtered.length;
17948
18621
  const triggered = fraction > 0.5 || unexpected.length > 5;
17949
18622
  if (!triggered) return null;
@@ -18020,6 +18693,14 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
18020
18693
  if (!task) {
18021
18694
  throw new Error(`Task "${taskId}" not found on the Cycle Board.`);
18022
18695
  }
18696
+ if (task.assigneeId) {
18697
+ const gate = await resolveOwnerGate(adapter2, config2);
18698
+ if (!gate.callerUserId || gate.callerUserId !== task.assigneeId) {
18699
+ throw new Error(
18700
+ `Task "${taskId}" (${task.title}) is claimed by another member \u2014 only its assignee can build it. Have the claimer build it, or run \`task_unclaim\` to release it first.`
18701
+ );
18702
+ }
18703
+ }
18023
18704
  if (task.status === "Done" || task.status === "Archived") {
18024
18705
  throw new Error(`Task "${taskId}" (${task.title}) is already ${task.status}. Cannot execute a completed task.`);
18025
18706
  }
@@ -18361,10 +19042,14 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
18361
19042
  };
18362
19043
  buildStartTimes.delete(taskId);
18363
19044
  taskBranchMap.delete(taskId);
18364
- if (input.relatedDecisions) {
18365
- const adIds = input.relatedDecisions.split(",").map((s) => s.trim()).filter(Boolean);
18366
- if (adIds.length > 0) report.relatedDecisions = adIds;
18367
- }
19045
+ const { adIds: relatedAdIds, inferred: relatedInferred } = resolveRelatedDecisions(
19046
+ input.relatedDecisions,
19047
+ task.buildHandoff
19048
+ );
19049
+ if (relatedAdIds.length > 0) report.relatedDecisions = relatedAdIds;
19050
+ console.error(
19051
+ `[build] task ${taskId} related_decisions: ${relatedAdIds.length} AD(s) (${relatedAdIds.length === 0 ? "none" : relatedInferred ? "inferred from handoff" : "builder-provided"}).`
19052
+ );
18368
19053
  if (report.startedAt && report.completedAt && typeof adapter2.getToolCallCount === "function") {
18369
19054
  try {
18370
19055
  const count = await adapter2.getToolCallCount(report.startedAt, report.completedAt);
@@ -18863,7 +19548,7 @@ var buildExecuteTool = {
18863
19548
  },
18864
19549
  related_decisions: {
18865
19550
  type: "string",
18866
- description: 'Comma-separated AD IDs that this build validated or challenged (e.g. "AD-5,AD-12"). Optional.'
19551
+ description: 'Comma-separated AD IDs this build validated or challenged (e.g. "AD-5,AD-12"). Optional but high-value: name any Active Decision your work CONFIRMED, CONTRADICTED, or DEPENDED ON \u2014 this is the primary signal feeding the decision-intelligence graph (validated-decision events). Check the BUILD HANDOFF for AD references. If you leave this empty, AD IDs found in the handoff are auto-captured as a fallback.'
18867
19552
  },
18868
19553
  handoff_accuracy: {
18869
19554
  type: "object",
@@ -19579,6 +20264,20 @@ async function captureBug(adapter2, input) {
19579
20264
  }
19580
20265
 
19581
20266
  // src/tools/bug.ts
20267
+ var PAPI_SIGNAL_PATTERNS = [
20268
+ /\bpapi\b/i,
20269
+ /\bmcp\b/i,
20270
+ // Distinctive PAPI MCP tool names (whole-word) — these don't appear in an
20271
+ // everyday project-domain bug report.
20272
+ /\b(orient|build_execute|build_list|build_describe|build_cancel|review_submit|review_list|strategy_review|strategy_change|board_view|board_edit|board_deprioritise|board_reconcile|handoff_generate|doc_register|doc_search|ad_hoc|inventory_sync|scope_brief)\b/i,
20273
+ /\bbuild handoff\b/i,
20274
+ /\bremote connector\b/i
20275
+ ];
20276
+ function looksLikePapiBug(text, notes) {
20277
+ const haystack = `${text}
20278
+ ${notes ?? ""}`;
20279
+ return PAPI_SIGNAL_PATTERNS.some((re) => re.test(haystack));
20280
+ }
19582
20281
  function collectDiagnostics(config2) {
19583
20282
  return {
19584
20283
  nodeVersion: process.version,
@@ -19592,7 +20291,7 @@ function collectDiagnostics(config2) {
19592
20291
  }
19593
20292
  var bugTool = {
19594
20293
  name: "bug",
19595
- description: "Report a bug. Two modes: (1) Default \u2014 creates a Backlog task with severity-based priority for the project board. (2) With report=true \u2014 submits a diagnostic bug report with system info for cross-project visibility (external user issue reporting). Does not call the Anthropic API.",
20294
+ description: 'Report a bug OR submit an idea. Routing: a bug about PAPI itself (a PAPI tool/MCP error, the connector, a handoff/cycle problem) auto-submits UPSTREAM to PAPI maintainers with diagnostics \u2014 you do NOT need to set report=true for these. A bug in the user\'s OWN project auto-files as a Backlog task on their board. Override the routing explicitly: report=true forces upstream, report=false forces the user\'s own board. Set `type` ("bug"/"idea") and optional notify-when-fixed / contact-ok consent for upstream submissions. Does not call the Anthropic API.',
19596
20295
  annotations: { readOnlyHint: false, destructiveHint: false },
19597
20296
  inputSchema: {
19598
20297
  type: "object",
@@ -19624,7 +20323,20 @@ var bugTool = {
19624
20323
  },
19625
20324
  report: {
19626
20325
  type: "boolean",
19627
- description: "When true, submits a diagnostic bug report with system info instead of creating a board task. Use this to report issues to PAPI maintainers."
20326
+ description: "Routing override. Leave UNSET to auto-route: PAPI-product bugs go upstream to maintainers, project-domain bugs go to the user's board. Set true to force an upstream diagnostic submission; set false to force a task on the user's own board (use false when an auto-detected PAPI bug is actually about the user's project)."
20327
+ },
20328
+ type: {
20329
+ type: "string",
20330
+ enum: ["bug", "idea"],
20331
+ description: 'Report mode only: submission kind \u2014 "bug" (default) or "idea" (feature request / suggestion). Both route to the same upstream PAPI triage.'
20332
+ },
20333
+ notify_when_fixed: {
20334
+ type: "boolean",
20335
+ description: "Report mode only: set true if the user wants to be notified when this submission is resolved (surfaced in a later orient/MCP call and on their dashboard)."
20336
+ },
20337
+ contact_ok: {
20338
+ type: "boolean",
20339
+ description: "Report mode only: set true if the user consents to the PAPI team reaching out about this submission."
19628
20340
  },
19629
20341
  project: {
19630
20342
  type: "string",
@@ -19639,10 +20351,23 @@ async function handleBug(adapter2, config2, args) {
19639
20351
  if (!text) {
19640
20352
  return errorResponse("text is required \u2014 describe the bug you want to report.");
19641
20353
  }
19642
- if (args.report === true) {
20354
+ const notesArg = args.notes;
20355
+ const explicitReport = typeof args.report === "boolean" ? args.report : void 0;
20356
+ const papiBug = looksLikePapiBug(text, notesArg);
20357
+ const autoRoutedUpstream = explicitReport === void 0 && papiBug;
20358
+ const shouldReportUpstream = explicitReport === true || autoRoutedUpstream;
20359
+ if (shouldReportUpstream) {
19643
20360
  if (!adapter2.submitBugReport) {
20361
+ if (autoRoutedUpstream) {
20362
+ return errorResponse(
20363
+ `This looks like a PAPI bug, which belongs upstream with the PAPI maintainers \u2014 but upstream reporting needs a database adapter (pg or proxy) and this session uses the md adapter, which can't submit it. Two options: (1) switch to the hosted/pg setup to report it upstream, or (2) re-run with report=false to file it on your own project board instead.`
20364
+ );
20365
+ }
19644
20366
  return errorResponse("Bug reports require a database adapter (pg or proxy). The md adapter does not support bug reports.");
19645
20367
  }
20368
+ const type = args.type === "idea" ? "idea" : "bug";
20369
+ const notifyRequested = args.notify_when_fixed === true;
20370
+ const contactOk = args.contact_ok === true;
19646
20371
  const diagnostics = collectDiagnostics(config2);
19647
20372
  const notes = args.notes?.trim();
19648
20373
  if (notes) {
@@ -19658,17 +20383,30 @@ async function handleBug(adapter2, config2, args) {
19658
20383
  }
19659
20384
  const report = await adapter2.submitBugReport({
19660
20385
  projectId,
20386
+ type,
19661
20387
  description: text,
19662
20388
  diagnostics,
19663
- status: "open"
20389
+ status: "open",
20390
+ notifyRequested,
20391
+ contactOk
19664
20392
  });
20393
+ const kindLabel = type === "idea" ? "Idea" : "Bug report";
20394
+ const followUps = [];
20395
+ if (notifyRequested) followUps.push("you'll be notified when it's resolved");
20396
+ if (contactOk) followUps.push("the PAPI team may reach out");
20397
+ const followUpLine = followUps.length ? `
20398
+
20399
+ Noted: ${followUps.join("; ")}.` : "";
20400
+ const autoRouteLine = autoRoutedUpstream ? `
20401
+
20402
+ _Detected this as a PAPI bug and sent it upstream rather than adding it to your own board. If it's actually about your project, re-run with \`report=false\` to file it on your board instead._` : "";
19665
20403
  return textResponse(
19666
- `**Bug report submitted** \u2014 ID: \`${report.id}\`
20404
+ `**${kindLabel} submitted** \u2014 ID: \`${report.id}\`
19667
20405
 
19668
- Description: ${text}
20406
+ ${type === "idea" ? "Idea" : "Description"}: ${text}
19669
20407
  Diagnostics collected: Node ${diagnostics.nodeVersion}, ${diagnostics.platform}/${diagnostics.arch}, adapter=${diagnostics.adapterType}
19670
20408
 
19671
- This report is visible to PAPI maintainers. No further action needed from you.`
20409
+ This ${type} is visible to PAPI maintainers.${followUpLine}${autoRouteLine}`
19672
20410
  );
19673
20411
  }
19674
20412
  let target = adapter2;
@@ -20045,7 +20783,7 @@ async function prepareReconcile(adapter2, projectRoot) {
20045
20783
  const misaligned = allTasks.filter((t) => {
20046
20784
  const mapping = phaseStageMap.get(t.phase ?? "");
20047
20785
  if (!mapping) return false;
20048
- return mapping.stage.status === "deferred" || mapping.horizon.status === "deferred";
20786
+ return mapping.stage.status === "Deferred" || mapping.horizon.status === "Deferred";
20049
20787
  });
20050
20788
  if (misaligned.length > 0) {
20051
20789
  lines.push("### Hierarchy Misalignment (tasks in deferred stages/horizons)");
@@ -20642,109 +21380,30 @@ Re-run build_execute complete with a production_verification field, then re-subm
20642
21380
  }
20643
21381
  const handoffRegenerated = false;
20644
21382
  let handoffRegenPrompt;
20645
- if (input.stage === "handoff-review" && input.verdict === "request-changes" && task.buildHandoff) {
20646
- handoffRegenPrompt = await prepareHandoffRegen(task, input.comments, adapter2);
20647
- }
20648
- const stageLabel = input.stage === "handoff-review" ? "Handoff Review" : "Build Acceptance";
20649
- let phaseChanges = [];
20650
- if (newStatus) {
20651
- try {
20652
- phaseChanges = await propagatePhaseStatus(adapter2);
20653
- } catch {
20654
- }
20655
- }
20656
- return {
20657
- stageLabel,
20658
- taskId: input.taskId,
20659
- verdict: input.verdict,
20660
- comments: input.comments.trim(),
20661
- newStatus,
20662
- unblockedTasks,
20663
- handoffRegenerated,
20664
- handoffRegenPrompt,
20665
- currentCycle: cycle,
20666
- phaseChanges,
20667
- closedDocActions
20668
- };
20669
- }
20670
-
20671
- // src/services/session-guidance.ts
20672
- var state = {
20673
- toolCallCount: 0,
20674
- lastOrientAt: null,
20675
- releaseSinceLastOrient: false,
20676
- sessionStartedAt: Date.now(),
20677
- lastReviewListAt: null,
20678
- failureTimestamps: [],
20679
- consecutiveFailures: 0
20680
- };
20681
- var CONTEXT_BLOAT_CALL_THRESHOLD = 40;
20682
- var ORIENT_GAP_MS = 3 * 60 * 60 * 1e3;
20683
- var REVIEW_LIST_GUARD_WINDOW_MS = 15 * 60 * 1e3;
20684
- var FAILURE_WINDOW_MS = 30 * 60 * 1e3;
20685
- var FAILURE_RATE_THRESHOLD = 8;
20686
- var CONSECUTIVE_FAILURE_THRESHOLD = 4;
20687
- function recordToolCall(name) {
20688
- state.toolCallCount++;
20689
- if (name === "release") state.releaseSinceLastOrient = true;
20690
- if (name === "review_list") state.lastReviewListAt = Date.now();
20691
- }
20692
- function recordToolOutcome(success) {
20693
- if (success) {
20694
- state.consecutiveFailures = 0;
20695
- return;
20696
- }
20697
- const now = Date.now();
20698
- state.consecutiveFailures++;
20699
- state.failureTimestamps.push(now);
20700
- const cutoff = now - FAILURE_WINDOW_MS;
20701
- state.failureTimestamps = state.failureTimestamps.filter((t) => t >= cutoff);
20702
- }
20703
- function wasReviewListSeenRecently(windowMs = REVIEW_LIST_GUARD_WINDOW_MS) {
20704
- if (state.lastReviewListAt == null) return false;
20705
- return Date.now() - state.lastReviewListAt <= windowMs;
20706
- }
20707
- function markOrient() {
20708
- state.lastOrientAt = Date.now();
20709
- state.releaseSinceLastOrient = false;
20710
- }
20711
- function getProjectConnectionBanner(projectName, projectSlug) {
20712
- if (!projectName || !projectSlug) return null;
20713
- return `[Connected: ${projectName} (${projectSlug})] \u2014 confirm this is the project you mean before I write to it. If it's wrong, don't proceed: pass \`project=<id>\` on the call to target a different project, or fix the project id in your MCP config (PAPI_PROJECT_ID for local, x-papi-project-id header for remote) and reconnect.`;
20714
- }
20715
- function detectContextDegradation(now = Date.now()) {
20716
- if (state.consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD) {
20717
- return `Context degradation (clash): ${state.consecutiveFailures} tool calls failed in a row \u2014 the session may be stuck on a contradiction. Consider a fresh window after this task.`;
20718
- }
20719
- const cutoff = now - FAILURE_WINDOW_MS;
20720
- const recentFailures = state.failureTimestamps.filter((t) => t >= cutoff).length;
20721
- if (recentFailures >= FAILURE_RATE_THRESHOLD) {
20722
- const mins = Math.round(FAILURE_WINDOW_MS / 6e4);
20723
- return `Context degradation (distraction/confusion): ${recentFailures} tool failures in the last ${mins}min. A fresh window often clears this faster than pushing on.`;
20724
- }
20725
- return null;
20726
- }
20727
- async function buildSessionGuidance() {
20728
- const signals = [];
20729
- const degradation = detectContextDegradation();
20730
- if (degradation) signals.push(degradation);
20731
- if (state.toolCallCount > CONTEXT_BLOAT_CALL_THRESHOLD) {
20732
- signals.push(
20733
- `${state.toolCallCount} tool calls this session \u2014 context may be bloated. Consider starting a fresh window.`
20734
- );
20735
- }
20736
- if (state.lastOrientAt && Date.now() - state.lastOrientAt > ORIENT_GAP_MS) {
20737
- const hours = Math.round((Date.now() - state.lastOrientAt) / (60 * 60 * 1e3));
20738
- signals.push(
20739
- `${hours}h since last orient \u2014 session may be stale. Consider a fresh window for best results.`
20740
- );
20741
- }
20742
- if (state.releaseSinceLastOrient) {
20743
- signals.push(
20744
- "Release just ran \u2014 start a fresh session before the next `plan` to keep planning context clean."
20745
- );
21383
+ if (input.stage === "handoff-review" && input.verdict === "request-changes" && task.buildHandoff) {
21384
+ handoffRegenPrompt = await prepareHandoffRegen(task, input.comments, adapter2);
20746
21385
  }
20747
- return signals.slice(0, 3);
21386
+ const stageLabel = input.stage === "handoff-review" ? "Handoff Review" : "Build Acceptance";
21387
+ let phaseChanges = [];
21388
+ if (newStatus) {
21389
+ try {
21390
+ phaseChanges = await propagatePhaseStatus(adapter2);
21391
+ } catch {
21392
+ }
21393
+ }
21394
+ return {
21395
+ stageLabel,
21396
+ taskId: input.taskId,
21397
+ verdict: input.verdict,
21398
+ comments: input.comments.trim(),
21399
+ newStatus,
21400
+ unblockedTasks,
21401
+ handoffRegenerated,
21402
+ handoffRegenPrompt,
21403
+ currentCycle: cycle,
21404
+ phaseChanges,
21405
+ closedDocActions
21406
+ };
20748
21407
  }
20749
21408
 
20750
21409
  // src/tools/review.ts
@@ -20893,6 +21552,11 @@ function formatReviewList(pendingBuilds) {
20893
21552
  for (const t of pendingBuilds) {
20894
21553
  lines.push(`- **${t.id}:** ${t.title}`);
20895
21554
  lines.push(` Status: ${t.status} | Priority: ${t.priority} | Complexity: ${t.complexity}`);
21555
+ if (t.assigneeId || t.reviewerId) {
21556
+ const built = t.assigneeId ? `built by ${t.assigneeId}` : "unattributed build";
21557
+ const review = t.reviewerId ? `claimed by reviewer ${t.reviewerId}` : "unclaimed \u2014 `review_claim` to take it";
21558
+ lines.push(` ${built} \xB7 ${review}`);
21559
+ }
20896
21560
  }
20897
21561
  lines.push("");
20898
21562
  lines.push('Use `review_submit` with task_id, stage "build-acceptance", verdict, and comments \u2014 are you happy with the build, or would you like changes?');
@@ -20946,6 +21610,10 @@ function mergeAfterAccept(config2, taskId) {
20946
21610
  }
20947
21611
  const merge = mergePullRequest(config2.projectRoot, featureBranch);
20948
21612
  if (!merge.success) {
21613
+ if (isBranchMergedInto(config2.projectRoot, featureBranch, baseBranch)) {
21614
+ details.push(`Branch '${featureBranch}' is already merged into '${baseBranch}'.`);
21615
+ return { merged: true, skipped: false, message: `Already merged: \`${featureBranch}\` \u2192 \`${baseBranch}\`.`, details };
21616
+ }
20949
21617
  return { merged: false, skipped: false, message: `PR merge failed: ${merge.message}`, details };
20950
21618
  }
20951
21619
  details.push(merge.message);
@@ -21070,12 +21738,28 @@ async function handleReviewSubmit(adapter2, config2, args) {
21070
21738
  }
21071
21739
  if (stage === "build-acceptance" && verdict === "accept") {
21072
21740
  const reviewerConfirmed = args.reviewer_confirmed === true;
21073
- if (!reviewerConfirmed && !wasReviewListSeenRecently()) {
21741
+ if (!reviewerConfirmed && !wasReviewListSeenRecently(void 0, callerKeyFromConfig(config2))) {
21074
21742
  return errorResponse(
21075
21743
  "REVIEW_LIST_REQUIRED \u2014 call review_list first to see what is pending, then submit review_submit with reviewer_confirmed: true after reviewing the build report. (Guards against accepting work without inspecting it \u2014 see SUP-2026-010.)"
21076
21744
  );
21077
21745
  }
21078
21746
  }
21747
+ if (stage === "build-acceptance") {
21748
+ const gate = await resolveOwnerGate(adapter2, config2);
21749
+ const caller = gate.callerUserId;
21750
+ const reviewTask = await adapter2.getTask(taskId);
21751
+ if (reviewTask?.reviewerId && caller && reviewTask.reviewerId !== caller && !gate.callerIsOwner) {
21752
+ return errorResponse(
21753
+ `Task "${taskId}" is claimed for review by another member (${reviewTask.reviewerId}). Only the claiming reviewer (or the project owner) can submit its verdict.`
21754
+ );
21755
+ }
21756
+ if (reviewTask && !reviewTask.reviewerId && caller) {
21757
+ try {
21758
+ await adapter2.updateTask(taskId, { reviewerId: caller });
21759
+ } catch {
21760
+ }
21761
+ }
21762
+ }
21079
21763
  const tracker = new ProgressTracker("validate-args");
21080
21764
  const handoffRegenResponse = args.handoff_regen_response;
21081
21765
  if (handoffRegenResponse?.trim()) {
@@ -21123,6 +21807,7 @@ ${result.handoffRegenPrompt.userMessage}
21123
21807
  }
21124
21808
  let mergeNote = "";
21125
21809
  let overlapNote = "";
21810
+ let mergeFailed = false;
21126
21811
  if (stage === "build-acceptance" && verdict === "accept") {
21127
21812
  const mergeResult = mergeAfterAccept(config2, taskId);
21128
21813
  if (mergeResult.skipped) {
@@ -21132,14 +21817,24 @@ ${result.handoffRegenPrompt.userMessage}
21132
21817
  > ${mergeResult.message}`;
21133
21818
  }
21134
21819
  } else if (!mergeResult.merged) {
21820
+ mergeFailed = true;
21821
+ let revertNote = "";
21822
+ try {
21823
+ await adapter2.updateTask(taskId, { status: "In Review" });
21824
+ revertNote = ` Task rolled back to **In Review** \u2014 re-run \`review_submit ${taskId} build-acceptance accept\` once the merge succeeds.`;
21825
+ } catch (err) {
21826
+ revertNote = ` \u26A0\uFE0F Could not auto-revert the status (${err instanceof Error ? err.message : String(err)}) \u2014 set ${taskId} back to In Review manually so the board does not show it as Done.`;
21827
+ }
21135
21828
  mergeNote = `
21136
21829
 
21137
- \u26A0\uFE0F **PR merge failed** \u2014 task is Done but the branch was NOT merged.
21138
-
21139
- ${mergeResult.message}
21830
+ \u26A0\uFE0F **PR merge failed \u2014 task NOT marked Done.**${revertNote}
21140
21831
 
21141
- Resolve this before running \`release\`.`;
21832
+ ${mergeResult.message}`;
21142
21833
  } else {
21834
+ try {
21835
+ await adapter2.updateTask(taskId, { mergedAt: (/* @__PURE__ */ new Date()).toISOString() });
21836
+ } catch {
21837
+ }
21143
21838
  const detailLines = mergeResult.details.map((l) => `> ${l}`).join("\n");
21144
21839
  mergeNote = `
21145
21840
 
@@ -21159,7 +21854,7 @@ ${overlap}`;
21159
21854
  }
21160
21855
  let autoReleaseNote = "";
21161
21856
  let batchSummaryNote = "";
21162
- if (stage === "build-acceptance" && verdict === "accept" && result.newStatus === "Done" && result.currentCycle > 0) {
21857
+ if (stage === "build-acceptance" && verdict === "accept" && result.newStatus === "Done" && result.currentCycle > 0 && !mergeFailed) {
21163
21858
  try {
21164
21859
  const allTasks = await adapter2.queryBoard();
21165
21860
  const cycleTasks = allTasks.filter((t) => t.cycle === result.currentCycle);
@@ -21252,9 +21947,28 @@ Resolve the issue above, then run \`release\` manually.`;
21252
21947
  }
21253
21948
  }
21254
21949
  const phaseNote = result.phaseChanges.length > 0 ? "\n\n" + result.phaseChanges.map((c) => `Phase auto-updated: ${c.phaseId} ${c.oldStatus} \u2192 ${c.newStatus}`).join("\n") : "";
21255
- const autoReviewNote = autoReview ? `
21950
+ let autoReviewNote = "";
21951
+ if (autoReview) {
21952
+ const findingLines = autoReview.findings.slice(0, 10).map((f) => {
21953
+ const loc = f.file ? ` (${f.file}${f.line ? `:${f.line}` : ""})` : "";
21954
+ return ` - [${f.severity}]${loc} ${f.message}`;
21955
+ }).join("\n");
21956
+ const more = autoReview.findings.length > 10 ? `
21957
+ \u2026and ${autoReview.findings.length - 10} more` : "";
21958
+ autoReviewNote = `
21959
+
21960
+ **Quality Gate (auto-review): ${autoReview.verdict}** \u2014 ${autoReview.summary}` + (autoReview.findings.length > 0 ? `
21961
+ ${findingLines}${more}` : "");
21962
+ if (stage === "build-acceptance" && verdict === "accept" && autoReview.verdict !== "pass") {
21963
+ autoReviewNote += `
21964
+
21965
+ \u26A0\uFE0F **Override recorded:** ${reviewer} accepted past a ${autoReview.verdict} quality gate (${autoReview.findings.length} finding${autoReview.findings.length === 1 ? "" : "s"}). The verdict + findings are stored on this review for the audit trail.`;
21966
+ }
21967
+ } else if (stage === "build-acceptance" && verdict === "accept") {
21968
+ autoReviewNote = `
21256
21969
 
21257
- **Auto-Review:** ${autoReview.verdict} \u2014 ${autoReview.summary}` + (autoReview.findings.length > 0 ? ` (${autoReview.findings.length} finding${autoReview.findings.length === 1 ? "" : "s"})` : "") : "";
21970
+ **Quality Gate:** no auto-review attached. PAPI's standard pre-accept step is a code review of the branch diff \u2014 run \`review_submit\` with \`dispatch:"subagent"\` to auto-review, or attach \`auto_review\` findings. Risk-tier work (auth, data, migrations, CI) should always carry one.`;
21971
+ }
21258
21972
  let nextStepNote = "";
21259
21973
  if (!autoReleaseNote && stage === "build-acceptance") {
21260
21974
  if (verdict === "accept") {
@@ -21290,6 +22004,66 @@ ${statusNote}${autoReviewNote}${unblockNote}${docClosureNote}${regenNote}${merge
21290
22004
  }));
21291
22005
  }
21292
22006
  }
22007
+ var reviewClaimTool = {
22008
+ name: "review_claim",
22009
+ description: "Claim a Pending Review from the shared cross-user review queue so you are the one reviewing it. Atomic first-claim-wins \u2014 two reviewers cannot grab the same build. After claiming, run review_submit to record your verdict. Owner-or-active-member only. Does not call the Anthropic API.",
22010
+ annotations: { readOnlyHint: false, destructiveHint: false },
22011
+ inputSchema: {
22012
+ type: "object",
22013
+ properties: {
22014
+ task_id: { type: "string", description: 'The In Review task to claim for review, e.g. "task-2072".' }
22015
+ },
22016
+ required: ["task_id"]
22017
+ }
22018
+ };
22019
+ async function resolveReviewerIdentity(adapter2, config2) {
22020
+ const gate = await resolveOwnerGate(adapter2, config2);
22021
+ const callerUserId = gate.callerUserId;
22022
+ if (!callerUserId) {
22023
+ const note = gate.resolutionError ? ` (${gate.resolutionError})` : "";
22024
+ return { error: "Reviewing requires a resolvable user identity, but none was found" + note + ". Set PAPI_USER_ID to your account UUID (local) \u2014 hosted sessions derive it from the bearer token." };
22025
+ }
22026
+ if (!gate.callerIsOwner && typeof adapter2.listContributors === "function") {
22027
+ try {
22028
+ const members = await adapter2.listContributors();
22029
+ if (!members.some((m) => m.userId === callerUserId)) {
22030
+ return { error: "Only the project owner or an active contributor can claim reviews on this project." };
22031
+ }
22032
+ } catch (err) {
22033
+ return { error: `Could not verify membership: ${err instanceof Error ? err.message : String(err)}` };
22034
+ }
22035
+ }
22036
+ return { callerUserId, callerIsOwner: gate.callerIsOwner };
22037
+ }
22038
+ async function handleReviewClaim(adapter2, config2, args) {
22039
+ const taskId = typeof args.task_id === "string" ? args.task_id.trim() : "";
22040
+ if (!taskId) return errorResponse('A task_id is required. Example: review_claim task_id="task-2072"');
22041
+ if (typeof adapter2.claimReview !== "function") {
22042
+ return errorResponse("The shared review queue is not available on this adapter.");
22043
+ }
22044
+ const identity = await resolveReviewerIdentity(adapter2, config2);
22045
+ if ("error" in identity) return errorResponse(identity.error);
22046
+ const me = identity.callerUserId;
22047
+ const claimed = await adapter2.claimReview(taskId, me);
22048
+ if (!claimed) {
22049
+ const task = await adapter2.getTask(taskId);
22050
+ if (!task) return errorResponse(`Task "${taskId}" not found on the board.`);
22051
+ if (task.status !== "In Review") {
22052
+ return errorResponse(`Task "${taskId}" is ${task.status}, not In Review \u2014 there is no pending review to claim.`);
22053
+ }
22054
+ if (task.reviewerId && task.reviewerId !== me) {
22055
+ return errorResponse(`Task "${taskId}" is already being reviewed by another member (${task.reviewerId}).`);
22056
+ }
22057
+ if (task.reviewerId === me) {
22058
+ return textResponse(`You have already claimed the review for **${taskId}** \u2014 run \`review_submit\` to record your verdict.`);
22059
+ }
22060
+ return errorResponse(`Could not claim the review for "${taskId}".`);
22061
+ }
22062
+ const builtBy = claimed.assigneeId ? ` (built by ${claimed.assigneeId})` : "";
22063
+ return textResponse(
22064
+ `\u2705 Claimed the review for **${claimed.id}** (${claimed.title})${builtBy}. You are now the reviewer. Read the build report, then run \`review_submit\` with your verdict.`
22065
+ );
22066
+ }
21293
22067
 
21294
22068
  // src/tools/init.ts
21295
22069
  import { randomUUID as randomUUID15 } from "crypto";
@@ -22023,7 +22797,7 @@ ${lines.join("\n")}`;
22023
22797
 
22024
22798
  // src/lib/version-handshake.ts
22025
22799
  var MIN_PROXY_VERSION = "1.0.0";
22026
- var SERVER_VERSION = "0.7.15";
22800
+ var SERVER_VERSION = "0.7.36";
22027
22801
  function meetsMinVersion(actual, required) {
22028
22802
  const actualParts = actual.split(".").map((n) => parseInt(n, 10));
22029
22803
  const requiredParts = required.split(".").map((n) => parseInt(n, 10));
@@ -22405,13 +23179,29 @@ function normalizeDocPath(rawPath, projectRoot) {
22405
23179
  error: `doc_register received an absolute path: \`${input}\`. Pass a path relative to your project root instead (e.g. \`docs/research/x.md\`). Absolute paths break visibility inference and on-disk lookups.`
22406
23180
  };
22407
23181
  }
23182
+ function docRegisterSoftFail(adapterType, lastStep, error, hint) {
23183
+ const payload = { tool: "doc_register", adapter: adapterType, lastStep, error, hint };
23184
+ return textResponse(
23185
+ `\u26A0\uFE0F doc_register skipped (non-blocking) at step "${lastStep}" \u2014 your build/plan/review flow can continue safely; document registration is advisory.
23186
+
23187
+ Diagnostic JSON:
23188
+ ${JSON.stringify(payload, null, 2)}`
23189
+ );
23190
+ }
22408
23191
  async function handleDocRegister(adapter2, args, config2) {
23192
+ const adapterType = config2?.adapterType ?? "unknown";
23193
+ const continueHint = "doc_register is advisory \u2014 your build/plan/review flow is unaffected. Fix the input (or wait for the registry to recover) and re-run doc_register; or just continue without it.";
22409
23194
  if (!adapter2.registerDoc) {
22410
- return errorResponse("Doc registry not available \u2014 requires pg adapter.");
23195
+ return docRegisterSoftFail(
23196
+ adapterType,
23197
+ "adapter-capability-check",
23198
+ "Doc registry not available \u2014 requires the pg/proxy adapter.",
23199
+ "On the md adapter doc_register is a no-op. Continue without it \u2014 nothing is blocked."
23200
+ );
22411
23201
  }
22412
23202
  const normalized = normalizeDocPath(args.path, config2?.projectRoot);
22413
23203
  if ("error" in normalized) {
22414
- return errorResponse(normalized.error);
23204
+ return docRegisterSoftFail(adapterType, "path-normalization", normalized.error, continueHint);
22415
23205
  }
22416
23206
  const path7 = normalized.path;
22417
23207
  const title = args.title;
@@ -22424,39 +23214,49 @@ async function handleDocRegister(adapter2, args, config2) {
22424
23214
  const supersededByPath = args.superseded_by_path;
22425
23215
  const visibility = resolveDocVisibility(args.visibility, path7);
22426
23216
  if (!path7 || !title || !type || !summary || !cycle) {
22427
- return errorResponse("Required fields: path, title, type, summary, cycle.");
23217
+ return docRegisterSoftFail(
23218
+ adapterType,
23219
+ "field-validation",
23220
+ "Required fields: path, title, type, summary, cycle.",
23221
+ continueHint
23222
+ );
22428
23223
  }
22429
- let supersededBy;
22430
- if (supersededByPath) {
22431
- const existing = await adapter2.getDoc?.(supersededByPath);
22432
- if (existing) {
22433
- supersededBy = existing.id;
22434
- await adapter2.updateDocStatus?.(existing.id, "superseded", void 0);
23224
+ try {
23225
+ let supersededBy;
23226
+ if (supersededByPath) {
23227
+ const existing = await adapter2.getDoc?.(supersededByPath);
23228
+ if (existing) {
23229
+ supersededBy = existing.id;
23230
+ await adapter2.updateDocStatus?.(existing.id, "superseded", void 0);
23231
+ }
22435
23232
  }
22436
- }
22437
- const entry = await adapter2.registerDoc({
22438
- title,
22439
- type,
22440
- path: path7,
22441
- status: supersededByPath ? "superseded" : status,
22442
- summary,
22443
- tags,
22444
- cycleCreated: cycle,
22445
- cycleUpdated: cycle,
22446
- supersededBy,
22447
- actions,
22448
- visibility
22449
- });
22450
- const visibilityLabel = entry.visibility === "contributors" ? "team member" : entry.visibility ?? "private";
22451
- return textResponse(
22452
- `**Registered:** ${entry.title}
23233
+ const entry = await adapter2.registerDoc({
23234
+ title,
23235
+ type,
23236
+ path: path7,
23237
+ status,
23238
+ summary,
23239
+ tags,
23240
+ cycleCreated: cycle,
23241
+ cycleUpdated: cycle,
23242
+ supersededBy,
23243
+ actions,
23244
+ visibility
23245
+ });
23246
+ const visibilityLabel = entry.visibility === "contributors" ? "team member" : entry.visibility ?? "private";
23247
+ return textResponse(
23248
+ `**Registered:** ${entry.title}
22453
23249
  - **Path:** ${entry.path}
22454
23250
  - **Type:** ${entry.type} | **Status:** ${entry.status}
22455
23251
  - **Visibility:** ${visibilityLabel}
22456
23252
  - **Tags:** ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}
22457
23253
  - **Actions:** ${actions?.length ?? 0} items
22458
23254
  - **ID:** ${entry.id}`
22459
- );
23255
+ );
23256
+ } catch (err) {
23257
+ const message = err instanceof Error ? err.message : String(err);
23258
+ return docRegisterSoftFail(adapterType, "registry-write", message, continueHint);
23259
+ }
22460
23260
  }
22461
23261
  async function handleDocSearch(adapter2, args, config2) {
22462
23262
  if (!adapter2.searchDocs) {
@@ -22807,11 +23607,20 @@ var orientTool = {
22807
23607
  deep_housekeeping: {
22808
23608
  type: "boolean",
22809
23609
  description: "Run expensive cross-referencing checks: board-vs-branch reconciliation, unrecorded commit detection, unregistered doc scan. Default false \u2014 orient stays fast and noise-light at session start. Pass true when you specifically want a sweep (e.g. before release or after a long break). One-liner in default output points users at this flag when they need it."
23610
+ },
23611
+ full: {
23612
+ type: "boolean",
23613
+ description: "Run the heavy enrichment blocks that the lean default path skips: Research Signals (doc search) and npm version-drift. Default false \u2014 the lean path keeps the per-session query count down so orient stays well under the tool timeout on large projects. Implied automatically when deep_housekeeping is true."
22810
23614
  }
22811
23615
  },
22812
23616
  required: []
22813
23617
  }
22814
23618
  };
23619
+ var papiTool = {
23620
+ ...orientTool,
23621
+ name: "papi",
23622
+ description: 'Say "papi" to check in with Papi \u2014 an alias for `orient`. Run this FIRST at session start: it returns your cycle number, task counts, in-progress/in-review work, strategy-review cadence, a velocity snapshot, and the recommended next action. Identical to `orient` (same inputs, same output); use whichever name you prefer. Read-only.'
23623
+ };
22815
23624
  function countStalledP1(warnings) {
22816
23625
  const stallLine = warnings.find((w) => w.includes("P1 tasks stalled"));
22817
23626
  if (!stallLine) return 0;
@@ -22837,7 +23646,7 @@ function formatSynthesisParagraph(opts) {
22837
23646
  parts.push(`Next: ${cleanMode}`);
22838
23647
  return parts.join(" ");
22839
23648
  }
22840
- function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoot, environment = "unknown", subAgents = [], projectName) {
23649
+ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoot, environment = "unknown", subAgents = [], projectName, teamSummary) {
22841
23650
  const lines = [];
22842
23651
  const cycleIsComplete = health.latestCycleStatus === "complete";
22843
23652
  const tagSuffix = latestTag ? ` \u2014 ${latestTag}` : "";
@@ -22904,6 +23713,10 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
22904
23713
  lines.push("## Board");
22905
23714
  lines.push(health.boardSummary);
22906
23715
  lines.push("");
23716
+ if (teamSummary) {
23717
+ lines.push(teamSummary);
23718
+ lines.push("");
23719
+ }
22907
23720
  if (subAgents.length > 0) {
22908
23721
  lines.push(`**Sub-agents (${subAgents.length}):** ` + subAgents.map((a) => {
22909
23722
  const desc = a.description ? ` (${a.description.slice(0, 60)})` : "";
@@ -23108,9 +23921,56 @@ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
23108
23921
  }
23109
23922
  return { alertsNote, unactionedIssuesNote };
23110
23923
  }
23924
+ async function computeTeamSummary(adapter2) {
23925
+ if (typeof adapter2.listContributors !== "function") return void 0;
23926
+ let members;
23927
+ try {
23928
+ members = (await adapter2.listContributors()).length;
23929
+ } catch {
23930
+ return void 0;
23931
+ }
23932
+ if (members <= 1) return void 0;
23933
+ let tasks;
23934
+ try {
23935
+ tasks = await adapter2.queryBoard({ compact: true });
23936
+ } catch {
23937
+ return void 0;
23938
+ }
23939
+ const isOpen = (t) => t.status !== "Done" && t.status !== "Cancelled" && t.status !== "Archived";
23940
+ const pool = tasks.filter((t) => isOpen(t) && !t.assigneeId && t.cycle == null).length;
23941
+ const inFlight = tasks.filter((t) => t.status === "In Progress").length;
23942
+ const reviewQueue = tasks.filter((t) => t.status === "In Review").length;
23943
+ return `**Team:** ${members} members \xB7 ${pool} in pool \xB7 ${inFlight} in flight \xB7 ${reviewQueue} in review`;
23944
+ }
23945
+ async function computeReleaseHistory(adapter2) {
23946
+ if (typeof adapter2.listContributors !== "function") return void 0;
23947
+ let contributors;
23948
+ try {
23949
+ contributors = await adapter2.listContributors();
23950
+ } catch {
23951
+ return void 0;
23952
+ }
23953
+ if (contributors.length <= 1) return void 0;
23954
+ let cycles;
23955
+ try {
23956
+ cycles = await adapter2.readCycles();
23957
+ } catch {
23958
+ return void 0;
23959
+ }
23960
+ const nameOf = new Map(
23961
+ contributors.map((c) => [c.userId, c.displayName || c.email || c.userId.slice(0, 8)])
23962
+ );
23963
+ const released = cycles.filter((c) => c.status === "complete").sort((a, b2) => b2.number - a.number).slice(0, 5).map((c) => {
23964
+ const who = c.userId ? nameOf.get(c.userId) ?? c.userId.slice(0, 8) : null;
23965
+ return `Cycle ${c.number}${who ? ` (${who})` : ""}`;
23966
+ });
23967
+ if (released.length === 0) return void 0;
23968
+ return `**Recent releases:** ${released.join(" \xB7 ")}`;
23969
+ }
23111
23970
  async function handleOrient(adapter2, config2, args = {}) {
23112
23971
  const environment = normaliseEnvironment(args.environment);
23113
23972
  const deepHousekeeping = args.deep_housekeeping === true;
23973
+ const fullEnrichment = args.full === true || deepHousekeeping;
23114
23974
  const tracker = new ProgressTracker("start");
23115
23975
  try {
23116
23976
  tracker.mark("propagate-phase-status");
@@ -23157,6 +24017,7 @@ async function handleOrient(adapter2, config2, args = {}) {
23157
24017
  ownerActionsOutcome,
23158
24018
  ownerActionsNudgedOutcome,
23159
24019
  ownerActionsBlockingOutcome,
24020
+ reviewChangesOutcome,
23160
24021
  ttfvOutcome,
23161
24022
  latestTagOutcome,
23162
24023
  versionDriftOutcome,
@@ -23247,6 +24108,37 @@ async function handleOrient(adapter2, config2, args = {}) {
23247
24108
  return void 0;
23248
24109
  }
23249
24110
  }),
24111
+ // task-2105 (C296): review-verdict notification. When a build was bounced
24112
+ // back (request-changes/reject → status In Progress) and not yet resubmitted,
24113
+ // surface it in orient so the builder gets a signal — submitReview itself has
24114
+ // no notification channel. Derived from existing data (no new schema): latest
24115
+ // review per task + current status. Within-project only (board visibility
24116
+ // already scopes the caller); per-assignee targeting is a future refinement.
24117
+ tracked("review-changes-requested", async () => {
24118
+ if (!adapter2.getRecentReviews || !adapter2.getTask) return void 0;
24119
+ try {
24120
+ const reviews = await adapter2.getRecentReviews(100);
24121
+ const latestByTask = /* @__PURE__ */ new Map();
24122
+ for (const r of reviews) {
24123
+ if (!latestByTask.has(r.taskId)) latestByTask.set(r.taskId, { verdict: r.verdict, comments: r.comments });
24124
+ }
24125
+ const bounced = [];
24126
+ for (const [taskId, rev] of latestByTask) {
24127
+ if (rev.verdict !== "request-changes" && rev.verdict !== "reject") continue;
24128
+ const task = await adapter2.getTask(taskId);
24129
+ if (task && task.status === "In Progress") {
24130
+ bounced.push({ id: task.displayId ?? taskId, comment: rev.comments ?? "" });
24131
+ }
24132
+ }
24133
+ if (bounced.length === 0) return void 0;
24134
+ const list = bounced.slice(0, 5).map((b2) => b2.id).join(", ");
24135
+ const c = bounced[0].comment.trim();
24136
+ const snippet = c ? ` \u2014 ${bounced[0].id}: "${c.length > 80 ? `${c.slice(0, 80)}\u2026` : c}"` : "";
24137
+ return `\u26A0\uFE0F ${bounced.length} task${bounced.length === 1 ? "" : "s"} bounced back with changes requested: ${list}${snippet}. Address the feedback, then build_execute to resubmit.`;
24138
+ } catch {
24139
+ return void 0;
24140
+ }
24141
+ }),
23250
24142
  // Time-to-first-value: setup/plan milestones (only for early projects).
23251
24143
  // PERF: hasToolMilestone is a cheap EXISTS lookup; avoids 5000-row pull.
23252
24144
  tracked("ttfv", async () => {
@@ -23265,11 +24157,16 @@ async function handleOrient(adapter2, config2, args = {}) {
23265
24157
  return `
23266
24158
  **Time to first plan:** ${deltaMinutes} minutes (setup \u2192 first plan)`;
23267
24159
  }),
23268
- // Latest git tag + npm version drift (both exec calls with own timeouts)
24160
+ // Latest git tag + npm version drift (both exec calls with own timeouts).
24161
+ // task-2172: git-tag stays (the latest tag is part of the core summary);
24162
+ // version-drift is enrichment, gated behind `full`/deep_housekeeping.
23269
24163
  tracked("git-tag", () => getLatestGitTag(config2.projectRoot)),
23270
- tracked("npm-version-drift", () => checkNpmVersionDrift()),
23271
- // Research Signals — research docs with pending actions since last strategy review
24164
+ tracked("npm-version-drift", async () => fullEnrichment ? checkNpmVersionDrift() : void 0),
24165
+ // Research Signals — research docs with pending actions since last strategy review.
24166
+ // task-2172: heavy (doc search + AD cross-reference) and rarely actioned
24167
+ // mid-session — gated behind `full`/deep_housekeeping to keep the default lean.
23272
24168
  tracked("research-signals", async () => {
24169
+ if (!fullEnrichment) return "";
23273
24170
  if (!adapter2.searchDocs) return "";
23274
24171
  const cycleHealth = await adapter2.getCycleHealth();
23275
24172
  const lastReviewCycle = Math.max(0, cycleHealth.totalCycles - cycleHealth.cyclesSinceLastStrategyReview);
@@ -23370,8 +24267,9 @@ async function handleOrient(adapter2, config2, args = {}) {
23370
24267
  }),
23371
24268
  // Session guidance — proactive nudges (doc_register, context bloat, mode switch)
23372
24269
  tracked("session-guidance", async () => {
23373
- const signals = await buildSessionGuidance();
23374
- markOrient();
24270
+ const callerKey = callerKeyFromConfig(config2);
24271
+ const signals = await buildSessionGuidance(callerKey);
24272
+ markOrient(callerKey);
23375
24273
  if (signals.length === 0) return "";
23376
24274
  const lines = ["\n\n## Session Guidance"];
23377
24275
  for (const s of signals) lines.push(`- ${s}`);
@@ -23468,6 +24366,8 @@ async function handleOrient(adapter2, config2, args = {}) {
23468
24366
  if (ownerActionsNudgedWarning) buildResult.warnings.push(ownerActionsNudgedWarning);
23469
24367
  const ownerActionsBlockingWarning = ownerActionsBlockingOutcome.status === "fulfilled" ? ownerActionsBlockingOutcome.value : void 0;
23470
24368
  if (ownerActionsBlockingWarning) buildResult.warnings.push(ownerActionsBlockingWarning);
24369
+ const reviewChangesWarning = reviewChangesOutcome.status === "fulfilled" ? reviewChangesOutcome.value : void 0;
24370
+ if (reviewChangesWarning) buildResult.warnings.push(reviewChangesWarning);
23471
24371
  try {
23472
24372
  const verifyWarnings = await verifyProject(adapter2);
23473
24373
  for (const w of verifyWarnings) buildResult.warnings.push(w);
@@ -23531,8 +24431,13 @@ ${section}`;
23531
24431
  }
23532
24432
  tracker.mark("format-summary");
23533
24433
  const subAgents = await listAgents(config2.projectRoot);
23534
- const deepHint = deepHousekeeping ? "" : "\n\n*Tip: pass `deep_housekeeping: true` to also check orphaned branches, unrecorded commits, unregistered docs, and stale skill forks.*";
23535
- return textResponse(projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName) + unblockNote + alertsNote + ttfvNote + reconciliationNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection);
24434
+ const [teamSummaryLine, releaseHistoryLine] = await Promise.all([
24435
+ tracked("team-summary", () => computeTeamSummary(adapter2))().catch(() => void 0),
24436
+ tracked("release-history", () => computeReleaseHistory(adapter2))().catch(() => void 0)
24437
+ ]);
24438
+ const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
24439
+ const deepHint = deepHousekeeping ? "" : "\n\n*Tip: pass `full: true` for Research Signals + version-drift, or `deep_housekeeping: true` to also check orphaned branches, unrecorded commits, unregistered docs, and stale skill forks (implies `full`).*";
24440
+ return textResponse(projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary) + unblockNote + alertsNote + ttfvNote + reconciliationNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection);
23536
24441
  } catch (err) {
23537
24442
  const message = err instanceof Error ? err.message : String(err);
23538
24443
  const isKnownFriendly = /^(Orient failed|Project not found|No project|Setup required)/i.test(message);
@@ -23806,6 +24711,18 @@ async function assembleZoomOutContext(adapter2, cycleNumber, projectRoot) {
23806
24711
  const lines = [];
23807
24712
  for (const [adId, adEvents] of byDecision) {
23808
24713
  lines.push(`**${adId}:** ${adEvents.length} events \u2014 ${adEvents.map((e) => `${e.eventType} (S${e.cycle})`).join(", ")}`);
24714
+ for (const e of adEvents) {
24715
+ const parts = [];
24716
+ if (e.evidenceRef) parts.push(`evidence: ${e.evidenceRef}`);
24717
+ if (e.metricDelta?.metric) {
24718
+ const d = e.metricDelta;
24719
+ const movement = d.before !== void 0 && d.after !== void 0 ? `${d.before}\u2192${d.after}` : d.delta !== void 0 ? `\u0394${d.delta}` : "";
24720
+ parts.push(`metric: ${d.metric}${movement ? ` (${movement})` : ""}`);
24721
+ }
24722
+ if (parts.length > 0) {
24723
+ lines.push(` - ${e.eventType} S${e.cycle}: ${parts.join("; ")}`);
24724
+ }
24725
+ }
23809
24726
  }
23810
24727
  adLifecycleText = lines.join("\n");
23811
24728
  }
@@ -24136,8 +25053,7 @@ Check that the project IDs are correct and exist in the same Supabase instance.`
24136
25053
  }
24137
25054
 
24138
25055
  // src/tools/handoff.ts
24139
- var lastPrepareCycleNumber2;
24140
- var lastPrepareContextBytes2;
25056
+ var handoffPrepareCache = new PerCallerCache();
24141
25057
  var handoffGenerateTool = {
24142
25058
  name: "handoff_generate",
24143
25059
  description: "Generate BUILD HANDOFFs for cycle tasks that don't have one yet. Run after `plan` (with skip_handoffs=true) or to regenerate stale handoffs. Uses the prepare/apply pattern \u2014 first call returns a prompt, second call persists results.",
@@ -24166,6 +25082,10 @@ var handoffGenerateTool = {
24166
25082
  cycle_number: {
24167
25083
  type: "number",
24168
25084
  description: 'The cycle number returned from prepare phase (mode "apply" only).'
25085
+ },
25086
+ force: {
25087
+ type: "boolean",
25088
+ description: "Regenerate handoffs for tasks that ALREADY have one, overwriting the stored handoff (default false = only fill in missing handoffs). Use to propagate a changed Active Decision or dependency into an in-flight task. Pass on the prepare call (it is remembered through apply); combine with task_ids to target specific tasks."
24169
25089
  }
24170
25090
  },
24171
25091
  required: []
@@ -24173,6 +25093,7 @@ var handoffGenerateTool = {
24173
25093
  };
24174
25094
  async function handleHandoffGenerate(adapter2, config2, args) {
24175
25095
  const toolMode = args.mode;
25096
+ const callerKey = callerKeyFromConfig(config2);
24176
25097
  try {
24177
25098
  if (toolMode === "apply") {
24178
25099
  const resolved = await resolveLlmResponse(
@@ -24181,20 +25102,20 @@ async function handleHandoffGenerate(adapter2, config2, args) {
24181
25102
  );
24182
25103
  if (!resolved.ok) return errorResponse(resolved.error);
24183
25104
  const llmResponse = resolved.llmResponse;
24184
- const cycleNumber = typeof args.cycle_number === "number" ? args.cycle_number : lastPrepareCycleNumber2;
25105
+ const prep = handoffPrepareCache.take(callerKey);
25106
+ const expectedCycleNumber = prep?.cycleNumber;
25107
+ const contextBytes = prep?.contextBytes;
25108
+ const cycleNumber = typeof args.cycle_number === "number" ? args.cycle_number : expectedCycleNumber;
24185
25109
  if (cycleNumber === void 0) {
24186
25110
  return errorResponse('cycle_number is required for mode "apply". Pass the cycle number from the prepare phase.');
24187
25111
  }
24188
- const expectedCycleNumber = lastPrepareCycleNumber2;
24189
- const contextBytes = lastPrepareContextBytes2;
24190
- lastPrepareCycleNumber2 = void 0;
24191
- lastPrepareContextBytes2 = void 0;
25112
+ const force = args.force === true || prep?.force === true;
24192
25113
  if (expectedCycleNumber !== void 0 && cycleNumber !== expectedCycleNumber) {
24193
25114
  return errorResponse(
24194
25115
  `cycle_number mismatch: prepare phase returned cycle ${expectedCycleNumber} but apply received ${cycleNumber}.`
24195
25116
  );
24196
25117
  }
24197
- const result = await applyHandoffs(adapter2, llmResponse, cycleNumber);
25118
+ const result = await applyHandoffs(adapter2, llmResponse, cycleNumber, force);
24198
25119
  const lines = [];
24199
25120
  lines.push(`**Handoff Generation \u2014 Cycle ${result.cycleNumber}**`);
24200
25121
  lines.push(`${result.handoffsWritten} handoff(s) written: ${result.taskIds.join(", ")}`);
@@ -24209,9 +25130,13 @@ async function handleHandoffGenerate(adapter2, config2, args) {
24209
25130
  }
24210
25131
  {
24211
25132
  const taskIds = Array.isArray(args.task_ids) ? args.task_ids.filter((id) => typeof id === "string") : void 0;
24212
- const result = await prepareHandoffs(adapter2, config2, taskIds);
24213
- lastPrepareCycleNumber2 = result.cycleNumber;
24214
- lastPrepareContextBytes2 = result.contextBytes;
25133
+ const force = args.force === true;
25134
+ const result = await prepareHandoffs(adapter2, config2, taskIds, force);
25135
+ handoffPrepareCache.set(callerKey, {
25136
+ cycleNumber: result.cycleNumber,
25137
+ contextBytes: result.contextBytes,
25138
+ force
25139
+ });
24215
25140
  return textResponse(
24216
25141
  `## PAPI Handoff Generation \u2014 Prepare Phase (Cycle ${result.cycleNumber})
24217
25142
 
@@ -24617,6 +25542,60 @@ async function handleDiscoveredIssueResolve(adapter2, args) {
24617
25542
 
24618
25543
  // src/tools/project.ts
24619
25544
  import path6 from "path";
25545
+
25546
+ // src/services/entitlements.ts
25547
+ var FREE_PROJECT_CAP = 3;
25548
+ var PAID_TIERS = /* @__PURE__ */ new Set(["pro", "team"]);
25549
+ var PRICING_URL = "https://getpapi.ai/pricing";
25550
+ async function resolveTier(adapter2) {
25551
+ if (typeof adapter2.getMeteredUsage !== "function") return null;
25552
+ try {
25553
+ const usage = await adapter2.getMeteredUsage();
25554
+ return usage?.tier ?? null;
25555
+ } catch {
25556
+ return null;
25557
+ }
25558
+ }
25559
+ function isPaidTier(tier) {
25560
+ return tier !== null && PAID_TIERS.has(tier);
25561
+ }
25562
+ async function enforceProjectCap(adapter2, target) {
25563
+ const tier = await resolveTier(adapter2);
25564
+ if (tier === null || isPaidTier(tier)) return null;
25565
+ if (typeof adapter2.listUserProjects !== "function") return null;
25566
+ let projects;
25567
+ try {
25568
+ projects = await adapter2.listUserProjects();
25569
+ } catch {
25570
+ return null;
25571
+ }
25572
+ const matchesExisting = projects.some(
25573
+ (p) => target.papiDir && p.papi_dir && p.papi_dir === target.papiDir || target.name && p.name && p.name.trim().toLowerCase() === target.name.trim().toLowerCase()
25574
+ );
25575
+ if (matchesExisting) return null;
25576
+ if (projects.length >= FREE_PROJECT_CAP) return projectCapMessage(projects.length);
25577
+ return null;
25578
+ }
25579
+ async function enforceContributorGate(adapter2) {
25580
+ const tier = await resolveTier(adapter2);
25581
+ if (tier === null) return null;
25582
+ if (tier === "team") return null;
25583
+ return contributorTeamMessage(tier);
25584
+ }
25585
+ function projectCapMessage(currentCount) {
25586
+ return `**You're on the Free plan (${currentCount} of ${FREE_PROJECT_CAP} projects).**
25587
+
25588
+ Free covers up to ${FREE_PROJECT_CAP} projects. To run more, upgrade to Pro for unlimited projects at a flat monthly price (no usage bills): ${PRICING_URL}
25589
+
25590
+ Your existing projects are untouched, and you can keep working in any of them.`;
25591
+ }
25592
+ function contributorTeamMessage(tier) {
25593
+ return `**Adding people to a project is a Team feature.**
25594
+
25595
+ You're on the ${tier} plan. Shared projects, roles, and the Quality Gate come with Team. Read-only viewer seats are free, so you only pay for who is actually building: ${PRICING_URL}`;
25596
+ }
25597
+
25598
+ // src/tools/project.ts
24620
25599
  function workspacePapiDir(config2) {
24621
25600
  if (!hasLocalWorkspace()) return void 0;
24622
25601
  return config2.papiDir;
@@ -24649,6 +25628,8 @@ async function handleProjectCreate(adapter2, config2, args) {
24649
25628
  if (papiDir) name = path6.basename(config2.projectRoot) || "My Project";
24650
25629
  else return errorResponse("name is required \u2014 there is no local workspace to derive it from on the remote transport.");
24651
25630
  }
25631
+ const capDenied = await enforceProjectCap(adapter2, { papiDir, name });
25632
+ if (capDenied) return errorResponse(capDenied);
24652
25633
  const result = await adapter2.createUserProject({
24653
25634
  name,
24654
25635
  papiDir,
@@ -24790,6 +25771,8 @@ function requireEmail(args) {
24790
25771
  async function handleContributorAdd(adapter2, config2, args) {
24791
25772
  const denied = await denyUnlessOwner(adapter2, config2);
24792
25773
  if (denied) return errorResponse(denied);
25774
+ const tierDenied = await enforceContributorGate(adapter2);
25775
+ if (tierDenied) return errorResponse(tierDenied);
24793
25776
  const email = requireEmail(args);
24794
25777
  if (!email) return errorResponse('A valid email is required. Example: contributor_add email="wes@example.com"');
24795
25778
  try {
@@ -24843,6 +25826,155 @@ async function handleContributorList(adapter2, config2, _args) {
24843
25826
  }
24844
25827
  }
24845
25828
 
25829
+ // src/tools/task-claim.ts
25830
+ var taskClaimTool = {
25831
+ name: "task_claim",
25832
+ description: "Claim a task from the shared org Pool into your personal backlog (assignee = you). Atomic first-claim-wins \u2014 a concurrent double-claim is impossible. Cascades the DEPENDS ON chain: claiming a task also claims its not-Done prerequisites; if any prerequisite is already claimed by another member the whole claim is refused (a build unit is never split across owners). Does not call the Anthropic API.",
25833
+ annotations: { readOnlyHint: false, destructiveHint: false },
25834
+ inputSchema: {
25835
+ type: "object",
25836
+ properties: {
25837
+ task_id: { type: "string", description: 'The task to claim, e.g. "task-2071".' }
25838
+ },
25839
+ required: ["task_id"]
25840
+ }
25841
+ };
25842
+ var taskUnclaimTool = {
25843
+ name: "task_unclaim",
25844
+ description: "Release a task you claimed back to the shared Pool (clears assignee). Claimer-only and pre-review \u2014 you cannot unclaim another member's task or one that has reached In Review/Done. Does not cascade. Does not call the Anthropic API.",
25845
+ annotations: { readOnlyHint: false, destructiveHint: true },
25846
+ inputSchema: {
25847
+ type: "object",
25848
+ properties: {
25849
+ task_id: { type: "string", description: 'The task to unclaim, e.g. "task-2071".' }
25850
+ },
25851
+ required: ["task_id"]
25852
+ }
25853
+ };
25854
+ var DONE_STATUSES = /* @__PURE__ */ new Set(["Done", "Cancelled", "Archived"]);
25855
+ function requireTaskId(args) {
25856
+ const id = typeof args.task_id === "string" ? args.task_id.trim() : "";
25857
+ return id.length > 0 ? id : null;
25858
+ }
25859
+ async function resolveClaimerIdentity(adapter2, config2) {
25860
+ if (typeof adapter2.claimTask !== "function") {
25861
+ return { error: "Claiming is not available on this adapter." };
25862
+ }
25863
+ const gate = await resolveOwnerGate(adapter2, config2);
25864
+ const callerUserId = gate.callerUserId;
25865
+ if (!callerUserId) {
25866
+ const note = gate.resolutionError ? ` (${gate.resolutionError})` : "";
25867
+ return {
25868
+ error: "Claiming requires a resolvable user identity, but none was found" + note + ". Set PAPI_USER_ID to your account UUID (local) \u2014 hosted sessions derive it from your bearer token."
25869
+ };
25870
+ }
25871
+ if (!gate.callerIsOwner && typeof adapter2.listContributors === "function") {
25872
+ try {
25873
+ const members = await adapter2.listContributors();
25874
+ if (!members.some((m) => m.userId === callerUserId)) {
25875
+ return { error: "Only the project owner or an active contributor can claim tasks on this project." };
25876
+ }
25877
+ } catch (err) {
25878
+ return { error: `Could not verify membership: ${err instanceof Error ? err.message : String(err)}` };
25879
+ }
25880
+ }
25881
+ return { callerUserId };
25882
+ }
25883
+ async function collectClaimChain(adapter2, root) {
25884
+ const byId = /* @__PURE__ */ new Map([[root.id, root]]);
25885
+ const queue = [root];
25886
+ while (queue.length > 0) {
25887
+ const t = queue.shift();
25888
+ if (!t.dependsOn) continue;
25889
+ const depIds = t.dependsOn.split(",").map((d) => d.trim()).filter(Boolean).filter((d) => !byId.has(d));
25890
+ if (depIds.length === 0) continue;
25891
+ const deps = await adapter2.getTasks(depIds);
25892
+ for (const dep of deps) {
25893
+ if (byId.has(dep.id)) continue;
25894
+ byId.set(dep.id, dep);
25895
+ if (!DONE_STATUSES.has(dep.status)) queue.push(dep);
25896
+ }
25897
+ }
25898
+ return [...byId.values()].filter((t) => !DONE_STATUSES.has(t.status));
25899
+ }
25900
+ async function handleTaskClaim(adapter2, config2, args) {
25901
+ const taskId = requireTaskId(args);
25902
+ if (!taskId) return errorResponse('A task_id is required. Example: task_claim task_id="task-2071"');
25903
+ const identity = await resolveClaimerIdentity(adapter2, config2);
25904
+ if ("error" in identity) return errorResponse(identity.error);
25905
+ const me = identity.callerUserId;
25906
+ const root = await adapter2.getTask(taskId);
25907
+ if (!root) return errorResponse(`Task "${taskId}" not found on the board.`);
25908
+ if (DONE_STATUSES.has(root.status)) {
25909
+ return errorResponse(`Task "${taskId}" is ${root.status} \u2014 nothing to claim.`);
25910
+ }
25911
+ const chain = await collectClaimChain(adapter2, root);
25912
+ const blockedByOther = chain.filter((t) => t.assigneeId && t.assigneeId !== me);
25913
+ const inOthersCycle = chain.filter((t) => !t.assigneeId && t.cycle != null);
25914
+ if (blockedByOther.length > 0 || inOthersCycle.length > 0) {
25915
+ const lines = [];
25916
+ for (const t of blockedByOther) lines.push(`- ${t.id} (${t.title}) \u2014 already claimed by another member`);
25917
+ for (const t of inOthersCycle) lines.push(`- ${t.id} (${t.title}) \u2014 already pulled into a cycle (not in the pool)`);
25918
+ return errorResponse(
25919
+ `Cannot claim "${taskId}" \u2014 it shares a DEPENDS ON build unit with task(s) you cannot take:
25920
+ ${lines.join("\n")}
25921
+
25922
+ A build unit is never split across owners. Ask the current owner to unclaim, or pick a different task.`
25923
+ );
25924
+ }
25925
+ const alreadyMine = chain.filter((t) => t.assigneeId === me);
25926
+ const toClaim = chain.filter((t) => !t.assigneeId && t.cycle == null);
25927
+ const claimed = [];
25928
+ const raced = [];
25929
+ for (const t of toClaim) {
25930
+ const result = await adapter2.claimTask(t.id, me);
25931
+ if (result) claimed.push(result);
25932
+ else raced.push(t.id);
25933
+ }
25934
+ if (claimed.length === 0 && alreadyMine.length > 0 && raced.length === 0) {
25935
+ return textResponse(`Task "${taskId}" and its prerequisites are already in your backlog \u2014 nothing to do.`);
25936
+ }
25937
+ const parts = [];
25938
+ const claimedList = claimed.map((t) => `- ${t.id} (${t.title})`).join("\n");
25939
+ parts.push(
25940
+ `\u2705 Claimed **${claimed.length}** task(s) into your backlog (assignee = you, not yet in a cycle):
25941
+ ${claimedList}`
25942
+ );
25943
+ if (alreadyMine.length > 0) {
25944
+ parts.push(`Already yours: ${alreadyMine.map((t) => t.id).join(", ")}.`);
25945
+ }
25946
+ if (raced.length > 0) {
25947
+ parts.push(
25948
+ `\u26A0\uFE0F ${raced.length} task(s) were claimed by someone else just now and were skipped: ${raced.join(", ")}. Re-run task_claim to see the current state.`
25949
+ );
25950
+ }
25951
+ parts.push("Next: run `plan` to draw a cycle from your backlog, or `task_unclaim` to release.");
25952
+ return textResponse(parts.join("\n\n"));
25953
+ }
25954
+ async function handleTaskUnclaim(adapter2, config2, args) {
25955
+ const taskId = requireTaskId(args);
25956
+ if (!taskId) return errorResponse('A task_id is required. Example: task_unclaim task_id="task-2071"');
25957
+ if (typeof adapter2.unclaimTask !== "function") {
25958
+ return errorResponse("Unclaiming is not available on this adapter.");
25959
+ }
25960
+ const identity = await resolveClaimerIdentity(adapter2, config2);
25961
+ if ("error" in identity) return errorResponse(identity.error);
25962
+ const me = identity.callerUserId;
25963
+ const result = await adapter2.unclaimTask(taskId, me);
25964
+ if (!result) {
25965
+ const task = await adapter2.getTask(taskId);
25966
+ if (!task) return errorResponse(`Task "${taskId}" not found on the board.`);
25967
+ if (task.assigneeId && task.assigneeId !== me) {
25968
+ return errorResponse(`Task "${taskId}" is claimed by another member \u2014 only the claimer can unclaim it.`);
25969
+ }
25970
+ if (task.status === "In Review" || task.status === "Done") {
25971
+ return errorResponse(`Task "${taskId}" is ${task.status} \u2014 it has passed the pre-review window and cannot be unclaimed.`);
25972
+ }
25973
+ return errorResponse(`Task "${taskId}" is not currently claimed by you \u2014 nothing to unclaim.`);
25974
+ }
25975
+ return textResponse(`\u2705 Released **${result.id}** (${result.title}) back to the shared Pool.`);
25976
+ }
25977
+
24846
25978
  // src/services/harness-inventory.ts
24847
25979
  import { readdir as readdir3, readFile as readFile8, stat as stat3 } from "fs/promises";
24848
25980
  import { join as join16 } from "path";
@@ -24944,8 +26076,8 @@ async function syncHarnessInventory(adapter2, config2, opts) {
24944
26076
  }
24945
26077
  const fingerprint = await computeFingerprint(config2.projectRoot);
24946
26078
  if (!opts?.force) {
24947
- const state2 = await adapter2.getHarnessState();
24948
- if (state2 && state2.fingerprint === fingerprint) return { changed: false };
26079
+ const state = await adapter2.getHarnessState();
26080
+ if (state && state.fingerprint === fingerprint) return { changed: false };
24949
26081
  }
24950
26082
  const entries = await scanInventory(config2.projectRoot, opts?.toolDefs);
24951
26083
  await adapter2.replaceHarnessInventory(entries);
@@ -25018,6 +26150,8 @@ var ABUSE_CEILINGS = {
25018
26150
  var DEFAULT_CEILING = ABUSE_CEILINGS.free;
25019
26151
  var ALWAYS_FREE_TOOLS = /* @__PURE__ */ new Set([
25020
26152
  "orient",
26153
+ "papi",
26154
+ // alias for orient (task-2223) — must share its free treatment
25021
26155
  "board_view",
25022
26156
  "build_list",
25023
26157
  "build_describe",
@@ -25077,6 +26211,7 @@ If this is legitimate, reach out at https://getpapi.ai and we'll lift it \u2014
25077
26211
  // src/server.ts
25078
26212
  var DEFAULT_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_TOOL_TIMEOUT_MS ?? "30000", 10);
25079
26213
  var LONG_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_LONG_TOOL_TIMEOUT_MS ?? "180000", 10);
26214
+ var WEDGE_PENDING_FRACTION = Math.min(1, Math.max(0, parseFloat(process.env.PAPI_WEDGE_PENDING_FRACTION ?? "0.6")));
25080
26215
  var LONG_RUNNING_TOOLS = /* @__PURE__ */ new Set([
25081
26216
  "plan",
25082
26217
  "strategy_review",
@@ -25093,6 +26228,31 @@ function toolTimeoutMs(name) {
25093
26228
  }
25094
26229
  var wedgeRecoveryStats = { count: 0 };
25095
26230
  var inflightWork = /* @__PURE__ */ new Map();
26231
+ var ToolTimeoutError = class extends Error {
26232
+ constructor(toolName, expectedMs, actualMs, wedged, pendingNote) {
26233
+ super(
26234
+ wedged ? `Tool '${toolName}' timed out after ${actualMs}ms (budget ${expectedMs}ms). A database operation ran the entire time without returning \u2014 the connection is likely wedged or contended (SUP-2026-012); a second PAPI session open against the same database is a common cause.${pendingNote} The MCP server is restarting; reconnect with /mcp in Claude Code (or restart your MCP host), and close any other open PAPI session, before the next call.` : `Tool '${toolName}' timed out after ${actualMs}ms (budget ${expectedMs}ms). No single operation was stuck \u2014 this is cumulative query load exceeding the budget, not a wedged pool, and the database is healthy.${pendingNote} Retry the call; \`orient\` runs a lean path by default (heavy enrichment is opt-in via \`full: true\`).`
26235
+ );
26236
+ this.toolName = toolName;
26237
+ this.expectedMs = expectedMs;
26238
+ this.actualMs = actualMs;
26239
+ this.wedged = wedged;
26240
+ this.pendingNote = pendingNote;
26241
+ this.name = "ToolTimeoutError";
26242
+ }
26243
+ };
26244
+ function classifyTimeout(start, actualMs, budgetMs) {
26245
+ const now = start + actualMs;
26246
+ let oldestPendingMs = 0;
26247
+ const parts = Array.from(inflightWork.entries()).map(([slot, slotStart]) => {
26248
+ const age = now - slotStart;
26249
+ if (age > oldestPendingMs) oldestPendingMs = age;
26250
+ return `${slot}(${age}ms)`;
26251
+ });
26252
+ const pendingNote = parts.length ? ` Pending work at timeout: ${parts.join(", ")}.` : " No pending work registered.";
26253
+ const wedged = oldestPendingMs >= budgetMs * WEDGE_PENDING_FRACTION;
26254
+ return { wedged, pendingNote };
26255
+ }
25096
26256
  async function withToolTimeout(name, fn) {
25097
26257
  const ms = toolTimeoutMs(name);
25098
26258
  const start = Date.now();
@@ -25100,15 +26260,18 @@ async function withToolTimeout(name, fn) {
25100
26260
  const timeoutPromise = new Promise((_, reject) => {
25101
26261
  timer2 = setTimeout(() => {
25102
26262
  const actualMs = Date.now() - start;
25103
- wedgeRecoveryStats.count += 1;
25104
- const pending = Array.from(inflightWork.entries()).map(([slot, slotStart]) => `${slot}(${actualMs - (slotStart - start)}ms)`).join(", ");
25105
- const pendingNote = pending ? ` Pending work at timeout: ${pending}.` : " No pending work registered.";
25106
- console.error(
25107
- `[papi] Wedge-recovery #${wedgeRecoveryStats.count}: tool '${name}' fired (expected=${ms}ms, actual=${actualMs}ms) (SUP-2026-012).${pendingNote}`
25108
- );
25109
- reject(new Error(
25110
- `Tool '${name}' timed out after ${actualMs}ms (timer set for ${ms}ms). The database pool likely wedged after an interrupted prior call (SUP-2026-012).${pendingNote} The MCP server is exiting now; reconnect with /mcp in Claude Code (or restart your MCP host) before the next call.`
25111
- ));
26263
+ const { wedged, pendingNote } = classifyTimeout(start, actualMs, ms);
26264
+ if (wedged) {
26265
+ wedgeRecoveryStats.count += 1;
26266
+ console.error(
26267
+ `[papi] Wedge-recovery #${wedgeRecoveryStats.count}: tool '${name}' timed out with a stuck operation (expected=${ms}ms, actual=${actualMs}ms) (SUP-2026-012).${pendingNote}`
26268
+ );
26269
+ } else {
26270
+ console.error(
26271
+ `[papi] Slow timeout (DB healthy, not a wedge): tool '${name}' exceeded its ${ms}ms budget (actual=${actualMs}ms) via cumulative load.${pendingNote}`
26272
+ );
26273
+ }
26274
+ reject(new ToolTimeoutError(name, ms, actualMs, wedged, pendingNote));
25112
26275
  }, ms);
25113
26276
  timer2.unref();
25114
26277
  });
@@ -25138,6 +26301,7 @@ var TOOLS_REQUIRING_PAPI = /* @__PURE__ */ new Set([
25138
26301
  "review_list",
25139
26302
  "review_submit",
25140
26303
  "orient",
26304
+ "papi",
25141
26305
  "hierarchy_update",
25142
26306
  "zoom_out",
25143
26307
  "handoff_generate",
@@ -25166,8 +26330,10 @@ var PAPI_TOOLS = [
25166
26330
  releaseTool,
25167
26331
  reviewListTool,
25168
26332
  reviewSubmitTool,
26333
+ reviewClaimTool,
25169
26334
  initTool,
25170
26335
  orientTool,
26336
+ papiTool,
25171
26337
  hierarchyUpdateTool,
25172
26338
  zoomOutTool,
25173
26339
  docRegisterTool,
@@ -25187,6 +26353,8 @@ var PAPI_TOOLS = [
25187
26353
  contributorAddTool,
25188
26354
  contributorRemoveTool,
25189
26355
  contributorListTool,
26356
+ taskClaimTool,
26357
+ taskUnclaimTool,
25190
26358
  inventorySyncTool
25191
26359
  ];
25192
26360
  function getToolMetadata() {
@@ -25290,7 +26458,8 @@ function createServer(adapter2, config2) {
25290
26458
  }
25291
26459
  }
25292
26460
  const timer2 = startTimer();
25293
- recordToolCall(name);
26461
+ const callerKey = callerKeyFromConfig(config2);
26462
+ recordToolCall(name, callerKey);
25294
26463
  const runHandler = async () => {
25295
26464
  switch (name) {
25296
26465
  case "plan":
@@ -25333,10 +26502,15 @@ function createServer(adapter2, config2) {
25333
26502
  return handleReviewList(adapter2, config2);
25334
26503
  case "review_submit":
25335
26504
  return handleReviewSubmit(adapter2, config2, safeArgs);
26505
+ case "review_claim":
26506
+ return handleReviewClaim(adapter2, config2, safeArgs);
25336
26507
  case "init":
25337
26508
  return handleInit(config2, safeArgs);
25338
26509
  case "orient":
25339
26510
  return handleOrient(adapter2, config2, safeArgs);
26511
+ case "papi":
26512
+ return handleOrient(adapter2, config2, safeArgs);
26513
+ // alias (task-2223)
25340
26514
  case "hierarchy_update":
25341
26515
  return handleHierarchyUpdate(adapter2, safeArgs);
25342
26516
  case "zoom_out":
@@ -25375,6 +26549,10 @@ function createServer(adapter2, config2) {
25375
26549
  return handleContributorRemove(adapter2, config2, safeArgs);
25376
26550
  case "contributor_list":
25377
26551
  return handleContributorList(adapter2, config2, safeArgs);
26552
+ case "task_claim":
26553
+ return handleTaskClaim(adapter2, config2, safeArgs);
26554
+ case "task_unclaim":
26555
+ return handleTaskUnclaim(adapter2, config2, safeArgs);
25378
26556
  case "inventory_sync":
25379
26557
  return handleInventorySync(adapter2, config2, safeArgs, getToolMetadata());
25380
26558
  default:
@@ -25383,7 +26561,7 @@ function createServer(adapter2, config2) {
25383
26561
  };
25384
26562
  const meterDecision = await checkMeter(adapter2, name, config2.projectId ?? "session");
25385
26563
  if (meterDecision.blocked && meterDecision.usage) {
25386
- recordToolOutcome(true);
26564
+ recordToolOutcome(true, callerKey);
25387
26565
  return {
25388
26566
  content: [{ type: "text", text: allowanceExceededMessage(name, meterDecision.usage) }]
25389
26567
  };
@@ -25392,18 +26570,19 @@ function createServer(adapter2, config2) {
25392
26570
  try {
25393
26571
  result = await withToolTimeout(name, runHandler);
25394
26572
  } catch (err) {
25395
- const msg = err instanceof Error ? err.message : String(err);
25396
- if (msg.includes("timed out after")) {
25397
- setImmediate(() => {
25398
- console.error(`[papi] Exiting after '${name}' timeout (wedge-recovery #${wedgeRecoveryStats.count}); user must reconnect with /mcp before the next call.`);
25399
- process.exit(2);
25400
- });
25401
- recordToolOutcome(false);
25402
- return { content: [{ type: "text", text: `Error: ${msg}` }] };
26573
+ if (err instanceof ToolTimeoutError) {
26574
+ recordToolOutcome(false, callerKey);
26575
+ if (err.wedged) {
26576
+ setImmediate(() => {
26577
+ console.error(`[papi] Exiting after '${name}' wedge timeout (wedge-recovery #${wedgeRecoveryStats.count}); user must reconnect with /mcp before the next call.`);
26578
+ process.exit(2);
26579
+ });
26580
+ }
26581
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
25403
26582
  }
25404
26583
  throw err;
25405
26584
  }
25406
- if (name === "orient" && adapter2.getMeteredUsage && !result.content.some((c) => c.text.startsWith("Error:"))) {
26585
+ if ((name === "orient" || name === "papi") && adapter2.getMeteredUsage && !result.content.some((c) => c.text.startsWith("Error:"))) {
25407
26586
  try {
25408
26587
  const decision = await checkMeter(adapter2, "plan", config2.projectId ?? "session");
25409
26588
  if (decision.usage) {
@@ -25425,7 +26604,7 @@ ${usageLine(decision.usage)}`;
25425
26604
  delete result._contextBytes;
25426
26605
  delete result._contextUtilisation;
25427
26606
  const isError = result.content.some((c) => c.text.startsWith("Error:") || c.text.startsWith("\u274C"));
25428
- recordToolOutcome(!isError);
26607
+ recordToolOutcome(!isError, callerKey);
25429
26608
  try {
25430
26609
  const clientName = server2.getClientVersion()?.name;
25431
26610
  const metric = buildMetric(name, elapsed, usage, void 0, contextBytes, contextUtilisation, clientName);
@@ -25473,6 +26652,28 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
25473
26652
  var BEARER_PREFIX = "papi_";
25474
26653
  var BEARER_REGEX = /^(papi_|papi_oauth_)[a-f0-9]{64}$/;
25475
26654
  var RESOURCE_METADATA_URL = process.env["PAPI_RESOURCE_METADATA_URL"] ?? "https://getpapi.ai/.well-known/oauth-protected-resource";
26655
+ var FRIENDLY_GET_HTML = `<!doctype html>
26656
+ <html lang="en"><head><meta charset="utf-8">
26657
+ <meta name="viewport" content="width=device-width, initial-scale=1">
26658
+ <title>PAPI MCP server</title>
26659
+ <style>
26660
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
26661
+ background:#faf6f0;color:#15171c;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
26662
+ .card{max-width:520px;padding:40px 36px;text-align:center}
26663
+ h1{font-size:22px;margin:0 0 12px;letter-spacing:.2px}
26664
+ p{margin:10px 0;color:#394048}
26665
+ a.btn{display:inline-block;margin-top:18px;padding:11px 20px;border-radius:10px;
26666
+ background:#15171c;color:#faf6f0;text-decoration:none;font-weight:600}
26667
+ code{background:#efe9e0;padding:2px 6px;border-radius:5px;font-size:13px}
26668
+ </style></head>
26669
+ <body><div class="card">
26670
+ <h1>This is the PAPI MCP server \u{1F44B}</h1>
26671
+ <p>You've reached it in a browser \u2014 that's expected to look empty. This endpoint
26672
+ is meant to be added to your AI tool as a connector, not opened directly.</p>
26673
+ <p>Add it in Claude, Cursor, or any MCP client as a custom connector pointing at
26674
+ <code>https://mcp.getpapi.ai/mcp</code>.</p>
26675
+ <a class="btn" href="https://getpapi.ai/docs/install">See the install guide \u2192</a>
26676
+ </div></body></html>`;
25476
26677
  var MAX_BODY_BYTES = 1 * 1024 * 1024;
25477
26678
  var IP_RATE_WINDOW_MS = 6e4;
25478
26679
  var IP_RATE_MAX = 60;
@@ -25594,6 +26795,13 @@ function startHttpTransport(opts) {
25594
26795
  }
25595
26796
  const bearer = extractBearer(req.headers.authorization);
25596
26797
  if (!bearer) {
26798
+ const accept = req.headers["accept"] ?? "";
26799
+ if (req.method === "GET" && accept.includes("text/html")) {
26800
+ logEvent({ level: "info", msg: "friendly_get", ip, status: 200 });
26801
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", ...cors });
26802
+ res.end(FRIENDLY_GET_HTML);
26803
+ return;
26804
+ }
25597
26805
  const hasHeader = Boolean(req.headers.authorization);
25598
26806
  logEvent({
25599
26807
  level: "warn",
@@ -25707,6 +26915,37 @@ function extractProjectOverride(body) {
25707
26915
  const p = args.project;
25708
26916
  return typeof p === "string" && p.trim().length > 0 ? p.trim() : void 0;
25709
26917
  }
26918
+ var PROJECT_OPTIONAL_TOOLS = /* @__PURE__ */ new Set([
26919
+ "init",
26920
+ "setup",
26921
+ "project_list",
26922
+ "project_create",
26923
+ "project_switch"
26924
+ ]);
26925
+ function extractToolName(body) {
26926
+ if (!body || typeof body !== "object") return void 0;
26927
+ const b2 = body;
26928
+ if (b2.method !== "tools/call") return void 0;
26929
+ const name = b2.params?.name;
26930
+ return typeof name === "string" && name.length > 0 ? name : void 0;
26931
+ }
26932
+ function sendProjectChoice(res, origin, body, projects) {
26933
+ if (res.headersSent) return;
26934
+ const id = (body && typeof body === "object" ? body.id : null) ?? null;
26935
+ const list = projects.map((p) => ` \u2022 ${p.slug}${p.name ? ` \u2014 ${p.name}` : ""}`).join("\n");
26936
+ const text = `You have ${projects.length} PAPI projects, so PAPI can't tell which one this call is for. Pick one and call again with \`project="<slug>"\` (or set the x-papi-project-id header):
26937
+
26938
+ ${list}
26939
+
26940
+ Example: add \`project="${projects[0].slug}"\` to the tool arguments.`;
26941
+ const payload = {
26942
+ jsonrpc: "2.0",
26943
+ id,
26944
+ result: { content: [{ type: "text", text }], isError: true }
26945
+ };
26946
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) });
26947
+ res.end(JSON.stringify(payload));
26948
+ }
25710
26949
  function resolveEffectiveProjectId(body, headerProjectId) {
25711
26950
  const explicitProject = extractProjectOverride(body);
25712
26951
  try {
@@ -25718,7 +26957,37 @@ function resolveEffectiveProjectId(body, headerProjectId) {
25718
26957
  }
25719
26958
  async function dispatchRequest(args) {
25720
26959
  const { req, res, body, bearer, projectId, ip, baseConfig, dataEndpoint } = args;
25721
- const effectiveProjectId = resolveEffectiveProjectId(body, projectId);
26960
+ let effectiveProjectId = resolveEffectiveProjectId(body, projectId);
26961
+ if (effectiveProjectId === void 0) {
26962
+ const toolName = extractToolName(body);
26963
+ if (toolName && !PROJECT_OPTIONAL_TOOLS.has(toolName)) {
26964
+ let projects = [];
26965
+ try {
26966
+ const probe = new ProxyPapiAdapter({ endpoint: dataEndpoint, apiKey: bearer });
26967
+ projects = await probe.listUserProjects();
26968
+ } catch {
26969
+ }
26970
+ if (projects.length === 1) {
26971
+ effectiveProjectId = projects[0].id;
26972
+ logEvent({
26973
+ level: "info",
26974
+ msg: "project_autobound",
26975
+ ip,
26976
+ bearer_prefix: bearerPrefix(bearer),
26977
+ project_id: effectiveProjectId
26978
+ });
26979
+ } else if (projects.length > 1) {
26980
+ logEvent({
26981
+ level: "info",
26982
+ msg: "project_choice_returned",
26983
+ ip,
26984
+ bearer_prefix: bearerPrefix(bearer)
26985
+ });
26986
+ sendProjectChoice(res, req.headers.origin, body, projects);
26987
+ return;
26988
+ }
26989
+ }
26990
+ }
25722
26991
  const adapter2 = new ProxyPapiAdapter({
25723
26992
  endpoint: dataEndpoint,
25724
26993
  apiKey: bearer,
@@ -25903,7 +27172,7 @@ If you haven't set up PAPI yet:
25903
27172
  2. Complete the onboarding wizard \u2014 it generates your config
25904
27173
  3. Copy the config to your project and restart your AI tool
25905
27174
 
25906
- If you already have an account, check that both **PAPI_PROJECT_ID** and **PAPI_DATA_API_KEY** are set in your .mcp.json env config.`
27175
+ If you already have an account, check that both **PAPI_PROJECT_ID** and **PAPI_DATA_API_KEY** are set in your .mcp.json env config.` + HELP_FOOTER_MD
25907
27176
  }]
25908
27177
  }));
25909
27178
  }