@papi-ai/server 0.7.34 → 0.7.35

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
  }
@@ -5163,7 +5184,10 @@ function toCycleTask(raw) {
5163
5184
  closureReason: raw.closure_reason || void 0,
5164
5185
  buildHandoff: raw.build_handoff ? parseBuildHandoff(raw.build_handoff) ?? void 0 : void 0,
5165
5186
  buildReport: raw.build_report || void 0,
5166
- scopeClass: raw.scope_class === "brief" ? "brief" : "task"
5187
+ scopeClass: raw.scope_class === "brief" ? "brief" : "task",
5188
+ assigneeId: raw.assignee_id || void 0,
5189
+ claimSource: raw.claim_source === "pool" || raw.claim_source === "self_generated" ? raw.claim_source : void 0,
5190
+ reviewerId: raw.reviewer_id || void 0
5167
5191
  };
5168
5192
  }
5169
5193
  function sanitizeDelimiters(value) {
@@ -5196,6 +5220,9 @@ function fromCycleTask(task) {
5196
5220
  if (task.buildHandoff) raw.build_handoff = sanitizeDelimiters(serializeBuildHandoff(task.buildHandoff));
5197
5221
  if (task.buildReport) raw.build_report = sanitizeDelimiters(task.buildReport);
5198
5222
  if (task.scopeClass && task.scopeClass !== "task") raw.scope_class = task.scopeClass;
5223
+ if (task.assigneeId) raw.assignee_id = task.assigneeId;
5224
+ if (task.claimSource) raw.claim_source = task.claimSource;
5225
+ if (task.reviewerId) raw.reviewer_id = task.reviewerId;
5199
5226
  return raw;
5200
5227
  }
5201
5228
  function mergeConflictHint(content) {
@@ -5262,6 +5289,7 @@ function filterTasks(tasks, options) {
5262
5289
  if (options.reviewed !== void 0 && task.reviewed !== options.reviewed) return false;
5263
5290
  if (options.module && task.module !== options.module) return false;
5264
5291
  if (options.epic && task.epic !== options.epic) return false;
5292
+ if (options.assigneeId && task.assigneeId !== options.assigneeId) return false;
5265
5293
  return true;
5266
5294
  });
5267
5295
  }
@@ -6043,6 +6071,7 @@ function toCycle(raw) {
6043
6071
  taskIds: raw.task_ids ?? []
6044
6072
  };
6045
6073
  if (raw.end_date) cycle.endDate = raw.end_date;
6074
+ if (raw.user_id) cycle.userId = raw.user_id;
6046
6075
  return cycle;
6047
6076
  }
6048
6077
  function fromCycle(cycle) {
@@ -6056,6 +6085,7 @@ function fromCycle(cycle) {
6056
6085
  task_ids: cycle.taskIds
6057
6086
  };
6058
6087
  if (cycle.endDate) raw.end_date = cycle.endDate;
6088
+ if (cycle.userId) raw.user_id = cycle.userId;
6059
6089
  return raw;
6060
6090
  }
6061
6091
  function extractYamlBlock2(content) {
@@ -6378,6 +6408,66 @@ var MdFileAdapter = class {
6378
6408
  async updateTaskStatus(id, status) {
6379
6409
  return this.updateTask(id, { status });
6380
6410
  }
6411
+ /**
6412
+ * task-1763 (C293): atomic compare-and-swap task claim. First-claim-wins —
6413
+ * sets assigneeId only if the task is currently unclaimed. The markdown adapter
6414
+ * is single-process, so the read-check-write is trivially atomic here; the real
6415
+ * concurrency guarantee lives in the pg adapter's RETURNING CAS. Returns the
6416
+ * claimed task, or null if it was already claimed or does not exist.
6417
+ *
6418
+ * task-2071 (MU-3): the pooled-task invariant is `assigneeId == null && cycle
6419
+ * == null` — a task already pulled into someone's cycle is NOT in the pool and
6420
+ * cannot be claimed. Sets claimSource='pool' on success.
6421
+ */
6422
+ async claimTask(taskId, assigneeId) {
6423
+ const content = await this.read("SPRINT_BOARD.md");
6424
+ const tasks = parseBoard(content);
6425
+ const idx = tasks.findIndex((t) => t.id === taskId);
6426
+ if (idx === -1) return null;
6427
+ if (tasks[idx].assigneeId || tasks[idx].cycle != null) return null;
6428
+ tasks[idx] = { ...tasks[idx], assigneeId, claimSource: "pool" };
6429
+ await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
6430
+ return tasks[idx];
6431
+ }
6432
+ /**
6433
+ * task-2071 (C293, MU-3): claimer-only release. Clears assigneeId + claimSource
6434
+ * only if the task is currently assigned to `assigneeId` and has not entered
6435
+ * review. Returns the unclaimed task, or null if the caller is not the claimer,
6436
+ * the task has progressed, or it does not exist.
6437
+ */
6438
+ async unclaimTask(taskId, assigneeId) {
6439
+ const content = await this.read("SPRINT_BOARD.md");
6440
+ const tasks = parseBoard(content);
6441
+ const idx = tasks.findIndex((t2) => t2.id === taskId);
6442
+ if (idx === -1) return null;
6443
+ const t = tasks[idx];
6444
+ if (t.assigneeId !== assigneeId) return null;
6445
+ if (t.status === "In Review" || t.status === "Done") return null;
6446
+ const next = { ...t };
6447
+ delete next.assigneeId;
6448
+ delete next.claimSource;
6449
+ tasks[idx] = next;
6450
+ await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
6451
+ return tasks[idx];
6452
+ }
6453
+ /**
6454
+ * task-2072 (C293, MU-4): atomic review claim. Sets reviewerId only if the task
6455
+ * is In Review and not yet claimed for review (reviewerId == null). First-claim-
6456
+ * wins. Returns the claimed task, or null if already review-claimed / not In
6457
+ * Review / missing.
6458
+ */
6459
+ async claimReview(taskId, reviewerId) {
6460
+ const content = await this.read("SPRINT_BOARD.md");
6461
+ const tasks = parseBoard(content);
6462
+ const idx = tasks.findIndex((t2) => t2.id === taskId);
6463
+ if (idx === -1) return null;
6464
+ const t = tasks[idx];
6465
+ if (t.status !== "In Review") return null;
6466
+ if (t.reviewerId) return null;
6467
+ tasks[idx] = { ...t, reviewerId };
6468
+ await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
6469
+ return tasks[idx];
6470
+ }
6381
6471
  async recordTransition(_taskId, _fromStatus, _toStatus, _changedBy) {
6382
6472
  }
6383
6473
  // --- Build Reports ---
@@ -6817,6 +6907,8 @@ ${footer}`);
6817
6907
  - **source:** ${full.source}
6818
6908
  ` + (full.sourceRef ? `- **sourceRef:** ${full.sourceRef}
6819
6909
  ` : "") + (full.detail ? `- **detail:** ${full.detail}
6910
+ ` : "") + (full.evidenceRef ? `- **evidenceRef:** ${full.evidenceRef}
6911
+ ` : "") + (full.metricDelta ? `- **metricDelta:** ${JSON.stringify(full.metricDelta)}
6820
6912
  ` : "") + `- **createdAt:** ${full.createdAt}
6821
6913
 
6822
6914
  ---
@@ -6850,8 +6942,18 @@ ${footer}`);
6850
6942
  const sourceMatch = block.match(/\*\*source:\*\*\s+(.+)/);
6851
6943
  const sourceRefMatch = block.match(/\*\*sourceRef:\*\*\s+(.+)/);
6852
6944
  const detailMatch = block.match(/\*\*detail:\*\*\s+(.+)/);
6945
+ const evidenceRefMatch = block.match(/\*\*evidenceRef:\*\*\s+(.+)/);
6946
+ const metricDeltaMatch = block.match(/\*\*metricDelta:\*\*\s+(.+)/);
6853
6947
  const createdAtMatch = block.match(/\*\*createdAt:\*\*\s+(.+)/);
6854
6948
  if (!idMatch || !sourceMatch || !createdAtMatch) continue;
6949
+ let metricDelta;
6950
+ if (metricDeltaMatch?.[1]) {
6951
+ try {
6952
+ metricDelta = JSON.parse(metricDeltaMatch[1].trim());
6953
+ } catch {
6954
+ metricDelta = null;
6955
+ }
6956
+ }
6855
6957
  events.push({
6856
6958
  id: idMatch[1].trim(),
6857
6959
  decisionId: headingMatch[1],
@@ -6860,6 +6962,8 @@ ${footer}`);
6860
6962
  source: sourceMatch[1].trim(),
6861
6963
  sourceRef: sourceRefMatch?.[1]?.trim(),
6862
6964
  detail: detailMatch?.[1]?.trim(),
6965
+ evidenceRef: evidenceRefMatch?.[1]?.trim(),
6966
+ metricDelta,
6863
6967
  createdAt: createdAtMatch[1].trim()
6864
6968
  });
6865
6969
  }
@@ -7667,8 +7771,18 @@ function formatDetailedTask(t) {
7667
7771
  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
7772
  Notes: ${notes}` : ""}`;
7669
7773
  }
7670
- function formatBoardForPlan(tasks, filters, currentCycle) {
7671
- if (tasks.length === 0) return "No tasks on the board.";
7774
+ var PRIORITY_RANK = { "P0 Critical": 0, "P1 High": 1, "P2 Medium": 2, "P3 Low": 3 };
7775
+ function formatDeferredForPlan(deferred) {
7776
+ if (deferred.length === 0) return "";
7777
+ const sorted = [...deferred].sort(
7778
+ (a, b2) => (PRIORITY_RANK[a.priority] ?? 9) - (PRIORITY_RANK[b2.priority] ?? 9)
7779
+ );
7780
+ return `**Deferred (${deferred.length} parked \u2014 re-triageable, NOT active candidates):**
7781
+ 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.
7782
+ ` + sorted.map(formatCompactTask).join("\n");
7783
+ }
7784
+ function formatBoardForPlan(tasks, filters, currentCycle, deferredTasks = []) {
7785
+ if (tasks.length === 0 && deferredTasks.length === 0) return "No tasks on the board.";
7672
7786
  let active = tasks.filter((t) => !PLAN_EXCLUDED_STATUSES.has(t.status));
7673
7787
  const excludedCount = tasks.length - active.length;
7674
7788
  if (filters) {
@@ -7677,13 +7791,24 @@ function formatBoardForPlan(tasks, filters, currentCycle) {
7677
7791
  if (filters.epic) active = active.filter((t) => t.epic === filters.epic);
7678
7792
  if (filters.priority) active = active.filter((t) => t.priority === filters.priority);
7679
7793
  }
7794
+ let deferred = deferredTasks.filter((t) => t.status === "Deferred");
7795
+ if (filters) {
7796
+ if (filters.phase) deferred = deferred.filter((t) => t.phase === filters.phase);
7797
+ if (filters.module) deferred = deferred.filter((t) => t.module === filters.module);
7798
+ if (filters.epic) deferred = deferred.filter((t) => t.epic === filters.epic);
7799
+ if (filters.priority) deferred = deferred.filter((t) => t.priority === filters.priority);
7800
+ }
7801
+ const deferredSection = formatDeferredForPlan(deferred);
7680
7802
  const userFilteredCount = tasks.length - excludedCount - active.length;
7681
7803
  const filterParts = [];
7682
7804
  if (excludedCount > 0) filterParts.push(`${excludedCount} completed filtered`);
7683
7805
  if (userFilteredCount > 0) filterParts.push(`${userFilteredCount} excluded by filters`);
7684
7806
  const filterSuffix = filterParts.length > 0 ? filterParts.join(", ") : "";
7685
7807
  if (active.length === 0) {
7686
- return `No active tasks on the board (${filterSuffix || "all filtered"}).`;
7808
+ const base = `No active tasks on the board (${filterSuffix || "all filtered"}).`;
7809
+ return deferredSection ? `${base}
7810
+
7811
+ ${deferredSection}` : base;
7687
7812
  }
7688
7813
  const byCounts = (statuses) => statuses.map((s) => {
7689
7814
  const n = active.filter((t) => t.status === s).length;
@@ -7692,9 +7817,12 @@ function formatBoardForPlan(tasks, filters, currentCycle) {
7692
7817
  const summary = `Board: ${active.length} active tasks (${byCounts(["Backlog", "In Cycle", "Ready", "In Progress", "In Review", "Blocked"])})` + (filterSuffix ? ` \u2014 ${filterSuffix}` : "");
7693
7818
  if (currentCycle === void 0) {
7694
7819
  const formatted = active.map(formatDetailedTask).join("\n\n");
7695
- return `${summary}
7820
+ const body = `${summary}
7696
7821
 
7697
7822
  ${formatted}`;
7823
+ return deferredSection ? `${body}
7824
+
7825
+ ${deferredSection}` : body;
7698
7826
  }
7699
7827
  const recent = [];
7700
7828
  const stable = [];
@@ -7715,6 +7843,7 @@ ${formatted}`;
7715
7843
  ` + stable.map(formatCompactTask).join("\n")
7716
7844
  );
7717
7845
  }
7846
+ if (deferredSection) sections.push(deferredSection);
7718
7847
  return sections.join("\n\n");
7719
7848
  }
7720
7849
  function formatCandidateTaskFullNotes(tasks) {
@@ -8166,11 +8295,19 @@ This is Cycle 0 \u2014 the first planning cycle for a brand-new project.
8166
8295
 
8167
8296
  2. **North Star** \u2014 Propose a one-sentence North Star statement, a success metric, and a key metric.
8168
8297
 
8169
- 3. **Initial Board** \u2014 Generate 3-5 tasks based on the project's actual tech stack and goals:
8298
+ 3. **Initial Board** \u2014 Generate 3-5 tasks based on the project's actual tech stack and goals.
8299
+
8300
+ **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:
8301
+ 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.
8302
+ 2. **Persistence / real data model** \u2014 only after the slice exists.
8303
+ 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.
8304
+ 4. **RLS / cloud hardening / multi-tenant security** \u2014 last. These are not cycle-1 work for a new project.
8305
+ 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.
8306
+
8170
8307
  - 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
8308
+ - 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.
8309
+ - Task 2: The next slice, or the minimal persistence the core loop needs \u2014 only once the slice works.
8310
+ - 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
8311
  - Do NOT assume web-app patterns (routes, pages, components) unless the brief explicitly describes a web application
8175
8312
  - All tasks: status Backlog, priority P1-P2, reviewed true, phase "Phase 1"
8176
8313
 
@@ -8333,7 +8470,7 @@ Standard planning cycle with full board review.
8333
8470
  }
8334
8471
  parts.push(`
8335
8472
  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.
8473
+ - **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
8474
  - **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
8475
  - **Dependency chain:** If a task's \`dependsOn\` references incomplete tasks, it cannot be scheduled regardless of priority.
8339
8476
  - **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.
@@ -8407,7 +8544,7 @@ ${AD_REJECTION_RULES}
8407
8544
  - **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
8545
  - **Strategy gaps:** If an Active Decision has no board tasks supporting it, propose one.
8409
8546
  - **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.
8547
+ 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
8548
  **\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
8549
  **\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
8550
  parts.push(PLAN_FRAGMENT_PRODUCT_BRIEF);
@@ -8485,6 +8622,9 @@ function buildPlanUserMessage(ctx) {
8485
8622
  ""
8486
8623
  );
8487
8624
  }
8625
+ if (ctx.siblingRepoWarning) {
8626
+ parts.push("", ctx.siblingRepoWarning, "");
8627
+ }
8488
8628
  parts.push("", "---", "", "## PROJECT CONTEXT", "");
8489
8629
  if (ctx.contextTier) {
8490
8630
  parts.push(`**Context tier:** ${ctx.contextTier}`, "");
@@ -8840,7 +8980,9 @@ After your natural language output, include this EXACT format on its own line:
8840
8980
  {
8841
8981
  "id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
8842
8982
  "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)"
8983
+ "body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
8984
+ "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.",
8985
+ "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
8986
  }
8845
8987
  ],
8846
8988
  "decisionScores": [
@@ -8900,6 +9042,8 @@ The JSON must be valid. Use null for optional fields that don't apply.
8900
9042
  For activeDecisionUpdates, the body field must be the COMPLETE replacement text for the AD block (including the ### heading line).
8901
9043
  Only include ADs that need changes \u2014 omit unchanged ADs.${compressionNote}
8902
9044
 
9045
+ **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.
9046
+
8903
9047
  ## PERSISTENCE RULES \u2014 READ THIS CAREFULLY
8904
9048
 
8905
9049
  Everything in Part 1 (natural language) is **display-only**. Part 2 (structured JSON) is what gets written to files.
@@ -9065,7 +9209,9 @@ After your natural language output, include this EXACT format on its own line:
9065
9209
  {
9066
9210
  "id": "string \u2014 AD-N (existing) or new AD-N (for new decisions)",
9067
9211
  "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)"
9212
+ "body": "string \u2014 full AD block including ### heading, confidence tag, and body text (empty string for delete)",
9213
+ "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.",
9214
+ "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
9215
  }
9070
9216
  ],
9071
9217
  "phaseUpdates": [
@@ -9382,6 +9528,37 @@ Return a JSON array of 3-10 tasks. Each task must have:
9382
9528
  - Use the full complexity range: XS (config/one-liner), Small (one file), Medium (2-5 files), Large (cross-module), XL (architectural)
9383
9529
  - Tasks should be specific enough to execute without further investigation
9384
9530
  - Maximum 10 tasks \u2014 fewer is better if the codebase is well-maintained`;
9531
+ 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.
9532
+
9533
+ IMPORTANT: You are running as a non-interactive API call. Do NOT ask questions. Produce tasks directly.
9534
+
9535
+ ## OUTPUT FORMAT
9536
+
9537
+ Return a JSON array of 15-20 tasks. Each task must have:
9538
+ - "title": Clear, actionable task title (start with a verb)
9539
+ - "priority": "P0 Critical", "P1 High", "P2 Medium", or "P3 Low"
9540
+ - "complexity": "XS", "Small", "Medium", "Large", or "XL"
9541
+ - "module": A module name inferred from the vision (e.g. "Core", "Auth", "Frontend", "API", "Payments")
9542
+ - "phase": A phase name ("Phase 1" for the first shippable slice, "Phase 2" for what follows, etc.)
9543
+ - "notes": 1-2 sentences tying this task to the user's stated vision
9544
+
9545
+ ## GUIDELINES
9546
+
9547
+ - Cover the VISION, not a generic app skeleton. Every task must trace to something the user actually described.
9548
+ - 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.
9549
+ - Mix: the first shippable feature slice (Phase 1), then the next features, then supporting/foundational work behind them.
9550
+ - 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.
9551
+ - Use the full complexity range. Keep titles concrete enough to execute without re-deriving the vision.
9552
+ - Do NOT add PAPI-setup tasks \u2014 those are handled by the setup flow.`;
9553
+ function buildVisionTasksPrompt(inputs) {
9554
+ const line = (label, val) => val?.trim() ? `**${label}:** ${val.trim()}
9555
+ ` : "";
9556
+ return `Turn this project vision into a 15-20 item starter backlog.
9557
+
9558
+ **Project:** ${inputs.projectName}
9559
+ ${line("What it is", inputs.description)}${line("Target users", inputs.targetUsers)}${line("Problems it solves", inputs.problems)}${line("Project type", inputs.projectType)}
9560
+ 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.`;
9561
+ }
9385
9562
  function buildInitialTasksPrompt(inputs) {
9386
9563
  const description = inputs.description?.trim() ? `**Description:** ${inputs.description}
9387
9564
  ` : `**Description:** (not provided \u2014 derive from the codebase analysis below)
@@ -9533,8 +9710,8 @@ function oneLine(ad) {
9533
9710
  }
9534
9711
  function formatHierarchy(horizons, stages) {
9535
9712
  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];
9713
+ const activeHorizon = horizons.find((h) => h.status === "In Progress") ?? horizons[0];
9714
+ const activeStage = stages.find((s) => s.status === "In Progress") ?? stages[0];
9538
9715
  const parts = [];
9539
9716
  if (activeHorizon) parts.push(activeHorizon.slug || activeHorizon.label);
9540
9717
  if (activeStage) parts.push(activeStage.slug || activeStage.label);
@@ -9557,7 +9734,7 @@ function validateHandoffScope(handoff) {
9557
9734
  if (!isMeaningful(handoff.scopeBoundary)) invalid.push("scopeBoundary");
9558
9735
  return invalid;
9559
9736
  }
9560
- async function prepareHandoffs(adapter2, _config, taskIds) {
9737
+ async function prepareHandoffs(adapter2, _config, taskIds, force = false) {
9561
9738
  const timer2 = startTimer();
9562
9739
  const cycles = await adapter2.readCycles();
9563
9740
  const activeCycle = cycles.find((c) => c.status === "active");
@@ -9571,10 +9748,10 @@ async function prepareHandoffs(adapter2, _config, taskIds) {
9571
9748
  const idSet = new Set(taskIds);
9572
9749
  cycleTasks = cycleTasks.filter((t) => idSet.has(t.id));
9573
9750
  }
9574
- const tasksNeedingHandoffs = cycleTasks.filter((t) => !t.buildHandoff);
9751
+ const tasksNeedingHandoffs = force ? cycleTasks : cycleTasks.filter((t) => !t.buildHandoff);
9575
9752
  if (tasksNeedingHandoffs.length === 0) {
9576
9753
  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.`
9754
+ 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
9755
  );
9579
9756
  }
9580
9757
  const [decisions, reports, brief] = await Promise.all([
@@ -9605,7 +9782,7 @@ async function prepareHandoffs(adapter2, _config, taskIds) {
9605
9782
  taskCount: tasksNeedingHandoffs.length
9606
9783
  };
9607
9784
  }
9608
- async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber) {
9785
+ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false) {
9609
9786
  const timer2 = startTimer();
9610
9787
  const { data } = parseStructuredOutput(rawLlmOutput);
9611
9788
  if (!data) {
@@ -9629,7 +9806,7 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber) {
9629
9806
  const warnings = [];
9630
9807
  for (const handoff of handoffs) {
9631
9808
  try {
9632
- if (existingHandoffSet.has(handoff.taskId)) {
9809
+ if (!force && existingHandoffSet.has(handoff.taskId)) {
9633
9810
  console.error(`[handoff] skipping ${handoff.taskId} \u2014 already has handoff`);
9634
9811
  skipped++;
9635
9812
  continue;
@@ -10005,7 +10182,72 @@ async function resolveVisibilityFromDocs(adapter2, docRefs) {
10005
10182
  };
10006
10183
  }
10007
10184
 
10185
+ // src/lib/owner-identity.ts
10186
+ function isProjectOwner(callerUserId, ownerUserId) {
10187
+ if (!callerUserId || !ownerUserId) return false;
10188
+ const caller = callerUserId.trim().toLowerCase();
10189
+ const owner = ownerUserId.trim().toLowerCase();
10190
+ if (caller.length === 0 || owner.length === 0) return false;
10191
+ return caller === owner;
10192
+ }
10193
+ async function resolveOwnerGate(adapter2, config2) {
10194
+ if (typeof adapter2.getOwnerIdentity === "function") {
10195
+ try {
10196
+ const identity = await adapter2.getOwnerIdentity();
10197
+ const callerUserId = identity.callerUserId ?? config2.userId ?? null;
10198
+ return {
10199
+ enforced: true,
10200
+ callerIsOwner: isProjectOwner(callerUserId, identity.ownerUserId),
10201
+ callerUserId,
10202
+ ownerUserId: identity.ownerUserId
10203
+ };
10204
+ } catch (err) {
10205
+ return {
10206
+ enforced: true,
10207
+ callerIsOwner: false,
10208
+ callerUserId: config2.userId ?? null,
10209
+ ownerUserId: null,
10210
+ resolutionError: err instanceof Error ? err.message : String(err)
10211
+ };
10212
+ }
10213
+ }
10214
+ if (typeof adapter2.getProjectOwnerUserId === "function") {
10215
+ let ownerUserId = null;
10216
+ let resolutionError;
10217
+ try {
10218
+ ownerUserId = await adapter2.getProjectOwnerUserId();
10219
+ } catch (err) {
10220
+ resolutionError = err instanceof Error ? err.message : String(err);
10221
+ }
10222
+ const callerUserId = config2.userId ?? null;
10223
+ return {
10224
+ enforced: true,
10225
+ callerIsOwner: isProjectOwner(callerUserId, ownerUserId),
10226
+ callerUserId,
10227
+ ownerUserId,
10228
+ ...resolutionError !== void 0 ? { resolutionError } : {}
10229
+ };
10230
+ }
10231
+ return {
10232
+ enforced: false,
10233
+ callerIsOwner: true,
10234
+ callerUserId: config2.userId ?? null,
10235
+ ownerUserId: null
10236
+ };
10237
+ }
10238
+
10008
10239
  // src/services/plan.ts
10240
+ async function resolvePlanScope(adapter2, config2) {
10241
+ const gate = await resolveOwnerGate(adapter2, config2);
10242
+ return { callerUserId: gate.callerUserId, ownerUserId: gate.ownerUserId, callerIsOwner: gate.callerIsOwner };
10243
+ }
10244
+ function filterToPersonalBacklog(tasks, scope) {
10245
+ if (!scope.callerUserId) return tasks;
10246
+ if (scope.callerIsOwner) {
10247
+ return tasks.filter((t) => !t.assigneeId || t.assigneeId === scope.callerUserId);
10248
+ }
10249
+ return tasks.filter((t) => t.assigneeId === scope.callerUserId);
10250
+ }
10009
10251
  function determineContextTier(cycleCount) {
10010
10252
  if (cycleCount <= 5) return 1;
10011
10253
  if (cycleCount <= 20) return 2;
@@ -10294,8 +10536,8 @@ var BRIEF_SECTIONS = [
10294
10536
  }
10295
10537
  ];
10296
10538
  function assessBriefThinness(brief) {
10297
- const TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
10298
- const briefWithoutTemplate = brief.replace(TEMPLATE_MARKER, "");
10539
+ const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
10540
+ const briefWithoutTemplate = brief.replace(TEMPLATE_MARKER2, "");
10299
10541
  const populated = [];
10300
10542
  const missing = [];
10301
10543
  for (const section of BRIEF_SECTIONS) {
@@ -10434,12 +10676,34 @@ async function readDogfoodEntries(projectRoot, count = 5, adapter2) {
10434
10676
  return void 0;
10435
10677
  }
10436
10678
  }
10679
+ function formatSiblingRepoWarning(siblings) {
10680
+ const lines = siblings.slice(0, 20).map(
10681
+ (s) => `- ${s.displayId} [${s.sourceProjectName}] (${s.status}${s.epic ? `, ${s.epic}` : ""}): ${s.title}`
10682
+ );
10683
+ const more = siblings.length > 20 ? `
10684
+ \u2026and ${siblings.length - 20} more` : "";
10685
+ return [
10686
+ "## \u26A0\uFE0F SHARED-REPO SIBLING WORK \u2014 CHECK BEFORE SCHEDULING",
10687
+ "",
10688
+ "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.)",
10689
+ "",
10690
+ ...lines,
10691
+ more
10692
+ ].filter(Boolean).join("\n");
10693
+ }
10437
10694
  async function assembleContext(adapter2, mode, _config, filters, focus) {
10438
10695
  const totalTimer = startTimer();
10439
10696
  const timings = {};
10697
+ const planScope = await resolvePlanScope(adapter2, _config);
10440
10698
  let t = startTimer();
10441
10699
  const health = await adapter2.getCycleHealth();
10442
10700
  timings["getCycleHealth"] = t();
10701
+ let siblingRepoWarning;
10702
+ try {
10703
+ const siblings = await adapter2.getSiblingRepoTasks?.() ?? [];
10704
+ if (siblings.length > 0) siblingRepoWarning = formatSiblingRepoWarning(siblings);
10705
+ } catch {
10706
+ }
10443
10707
  t = startTimer();
10444
10708
  const [rawProductBrief, currentNorthStar] = await Promise.all([
10445
10709
  adapter2.readProductBrief(),
@@ -10564,9 +10828,10 @@ ${lines.join("\n")}`;
10564
10828
  const doneIds = doneTasksResult.status === "fulfilled" ? new Set(doneTasksResult.value.map((t2) => t2.displayId)) : void 0;
10565
10829
  carryForwardStalenessLean = computeCarryForwardStaleness(cycleLogResult.value, doneIds);
10566
10830
  }
10831
+ const candidateTasksLean = preAssignedResult.status === "fulfilled" ? filterToPersonalBacklog(preAssignedResult.value, planScope) : [];
10567
10832
  let preAssignedTextLean;
10568
10833
  if (preAssignedResult.status === "fulfilled") {
10569
- const preAssigned2 = preAssignedResult.value.filter((t2) => t2.cycle === leanTargetCycle);
10834
+ const preAssigned2 = candidateTasksLean.filter((t2) => t2.cycle === leanTargetCycle);
10570
10835
  preAssignedTextLean = formatPreAssignedTasks(preAssigned2, leanTargetCycle);
10571
10836
  }
10572
10837
  let recentlyShippedLean;
@@ -10575,7 +10840,7 @@ ${lines.join("\n")}`;
10575
10840
  }
10576
10841
  let candidateTaskFullNotesLean;
10577
10842
  if (preAssignedResult.status === "fulfilled") {
10578
- candidateTaskFullNotesLean = formatCandidateTaskFullNotes(preAssignedResult.value);
10843
+ candidateTaskFullNotesLean = formatCandidateTaskFullNotes(candidateTasksLean);
10579
10844
  }
10580
10845
  logDataSourceSummary("plan (lean)", [
10581
10846
  { label: "cycleHealth", hasData: !!health },
@@ -10617,7 +10882,8 @@ ${lines.join("\n")}`;
10617
10882
  recentlyShippedCapabilities: recentlyShippedLean,
10618
10883
  strategyReviewCadence,
10619
10884
  candidateTaskFullNotes: candidateTaskFullNotesLean,
10620
- foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief)
10885
+ foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief),
10886
+ siblingRepoWarning
10621
10887
  };
10622
10888
  const { label: leanTierLabel } = applyContextTier(ctx2, health.totalCycles);
10623
10889
  ctx2.contextTier = leanTierLabel;
@@ -10633,7 +10899,7 @@ ${lines.join("\n")}`;
10633
10899
  return { context: ctx2, contextHashes: newHashes2 };
10634
10900
  }
10635
10901
  t = startTimer();
10636
- const [decisions, reportsSinceCycle, log2, tasks, rawMetricsSnapshots, reviews, phases, dogfoodEntries, allBuildReports] = await Promise.all([
10902
+ const [decisions, reportsSinceCycle, log2, tasks, rawMetricsSnapshots, reviews, phases, dogfoodEntries, allBuildReports, deferredRaw] = await Promise.all([
10637
10903
  adapter2.getActiveDecisions(),
10638
10904
  adapter2.getBuildReportsSince(health.totalCycles ?? 0),
10639
10905
  adapter2.getCycleLog(3),
@@ -10642,7 +10908,10 @@ ${lines.join("\n")}`;
10642
10908
  adapter2.getRecentReviews(5),
10643
10909
  adapter2.readPhases(),
10644
10910
  readDogfoodEntries(_config.projectRoot, 5, adapter2),
10645
- adapter2.getRecentBuildReports(50)
10911
+ adapter2.getRecentBuildReports(50),
10912
+ // task-2198: Deferred tasks queried SEPARATELY so they reach the planner as a
10913
+ // re-triageable set without ever entering the selectable candidate pool.
10914
+ adapter2.queryBoard({ status: ["Deferred"], contextTier: 2, compact: true })
10646
10915
  ]);
10647
10916
  timings["fullQueries"] = t();
10648
10917
  const reports = reportsSinceCycle.length > 0 ? reportsSinceCycle : allBuildReports.slice(0, 5);
@@ -10699,9 +10968,13 @@ ${lines.join("\n")}`;
10699
10968
  const briefExcluded = strippedTasks.filter(
10700
10969
  (t2) => t2.scopeClass === "brief" && !ACTIVE_STATUSES2.has(t2.status)
10701
10970
  );
10702
- const plannerTasks = strippedTasks.filter(
10703
- (t2) => (t2.priority !== "P3 Low" || ACTIVE_STATUSES2.has(t2.status)) && (t2.scopeClass !== "brief" || ACTIVE_STATUSES2.has(t2.status))
10971
+ const plannerTasks = filterToPersonalBacklog(
10972
+ strippedTasks.filter(
10973
+ (t2) => (t2.priority !== "P3 Low" || ACTIVE_STATUSES2.has(t2.status)) && (t2.scopeClass !== "brief" || ACTIVE_STATUSES2.has(t2.status))
10974
+ ),
10975
+ planScope
10704
10976
  );
10977
+ const deferredTasks = filterToPersonalBacklog(stripTasksForPlan(deferredRaw), planScope);
10705
10978
  if (p3Excluded.length > 0) {
10706
10979
  console.error(`[plan-perf] board tiering: excluded ${p3Excluded.length} P3 Low tasks from planner context`);
10707
10980
  }
@@ -10771,7 +11044,7 @@ ${logLines}`);
10771
11044
  activeDecisions: formatActiveDecisionsForPlan(decisions),
10772
11045
  recentBuildReports: formatBuildReports(cappedReports),
10773
11046
  cycleLog: formatCycleLog(log2),
10774
- board: formatBoardForPlan(plannerTasks, filters, health.totalCycles),
11047
+ board: formatBoardForPlan(plannerTasks, filters, health.totalCycles, deferredTasks),
10775
11048
  northStar,
10776
11049
  methodologyMetrics: formatCycleMetrics(metricsSnapshots),
10777
11050
  recentReviews: formatReviews(reviews),
@@ -10794,7 +11067,8 @@ ${logLines}`);
10794
11067
  recentlyShippedCapabilities: formatRecentlyShippedCapabilities(reports),
10795
11068
  strategyReviewCadence: strategyReviewCadenceFull,
10796
11069
  candidateTaskFullNotes: formatCandidateTaskFullNotes(plannerTasks),
10797
- foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief)
11070
+ foundationalTasksGuidance: computeFoundationalTasksGuidance(health.totalCycles, productBrief),
11071
+ siblingRepoWarning
10798
11072
  };
10799
11073
  const { label: fullTierLabel } = applyContextTier(ctx, health.totalCycles);
10800
11074
  ctx.contextTier = fullTierLabel;
@@ -10860,7 +11134,10 @@ ${cleanContent}`;
10860
11134
  goals: data.cycleLogTitle ? [data.cycleLogTitle] : [],
10861
11135
  boardHealth: data.boardHealth || "",
10862
11136
  taskIds: cycleTaskIds,
10863
- contextHashes
11137
+ contextHashes,
11138
+ // task-2071 (MU-3): own the cycle by the planning member (pg defaults to the
11139
+ // project owner when unset, so owner-operated plans stay correct).
11140
+ ...options.ownerUserId ? { userId: options.ownerUserId } : {}
10864
11141
  };
10865
11142
  let dedupedNewTasks = data.newTasks ?? [];
10866
11143
  if (dedupedNewTasks.length > 0) {
@@ -10945,25 +11222,12 @@ ${cleanContent}`;
10945
11222
  const writeBackMs = writeBackTimer();
10946
11223
  console.error(`[plan-perf] transactionalWriteBack: total=${writeBackMs}ms`);
10947
11224
  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");
11225
+ const expectedNewTasks = dedupedNewTasks.length;
11226
+ const actualNewTasks = result.newTaskIdMap.size;
11227
+ if (expectedNewTasks > 0 && actualNewTasks < expectedNewTasks) {
11228
+ verifyWarnings.push(
11229
+ `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.)`
11230
+ );
10967
11231
  }
10968
11232
  const allWarnings = [...result.warnings, ...verifyWarnings];
10969
11233
  const handoffCount = data.cycleHandoffs?.length ?? 0;
@@ -11258,7 +11522,9 @@ ${cleanContent}`;
11258
11522
  goals: data.cycleLogTitle ? [data.cycleLogTitle] : [],
11259
11523
  boardHealth: data.boardHealth || "",
11260
11524
  taskIds: cycleTaskIds,
11261
- contextHashes
11525
+ contextHashes,
11526
+ // task-2071 (MU-3): own the cycle by the planning member.
11527
+ ...options.ownerUserId ? { userId: options.ownerUserId } : {}
11262
11528
  };
11263
11529
  await adapter2.createCycle(cycle);
11264
11530
  } catch (err) {
@@ -11300,7 +11566,7 @@ async function assertSingleActiveCycle(adapter2, opts = {}) {
11300
11566
  for (const c of cycles) {
11301
11567
  if (!newestByNumber.has(c.number)) newestByNumber.set(c.number, c);
11302
11568
  }
11303
- const blocking = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber);
11569
+ const blocking = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber).filter((c) => opts.userId == null || c.userId === opts.userId);
11304
11570
  if (blocking.length === 0) return [];
11305
11571
  if (!opts.autoComplete) {
11306
11572
  const nums = blocking.map((c) => c.number).join(", ");
@@ -11319,7 +11585,7 @@ async function assertSingleActiveCycle(adapter2, opts = {}) {
11319
11585
  }
11320
11586
  return notes;
11321
11587
  }
11322
- async function validateAndPrepare(adapter2, force) {
11588
+ async function validateAndPrepare(adapter2, force, callerUserId) {
11323
11589
  let mode;
11324
11590
  let cycleNumber;
11325
11591
  let strategyReviewWarning = "";
@@ -11358,7 +11624,7 @@ Run \`review_submit\` to clear them, or pass \`force: true\` to bypass this bloc
11358
11624
  );
11359
11625
  }
11360
11626
  }
11361
- const autoCompleted = await assertSingleActiveCycle(adapter2, { autoComplete: force === true });
11627
+ const autoCompleted = await assertSingleActiveCycle(adapter2, { autoComplete: force === true, userId: callerUserId ?? void 0 });
11362
11628
  for (const note of autoCompleted) {
11363
11629
  console.error(`[plan] ${note}`);
11364
11630
  }
@@ -11390,7 +11656,8 @@ Run \`strategy_review\` first, or pass \`force: true\` to bypass this gate.`
11390
11656
  }
11391
11657
  async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber, contextHashes, planRunMeta) {
11392
11658
  const applyStartMs = Date.now();
11393
- await assertSingleActiveCycle(adapter2, { allowNumber: cycleNumber + 1 });
11659
+ const applyScope = await resolvePlanScope(adapter2, config2);
11660
+ await assertSingleActiveCycle(adapter2, { allowNumber: cycleNumber + 1, userId: applyScope.callerUserId ?? void 0 });
11394
11661
  const { displayText, data } = parseStructuredOutput(rawOutput);
11395
11662
  let resolvedDisplayText = displayText;
11396
11663
  let autoCommitNote = "";
@@ -11409,7 +11676,7 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
11409
11676
  cycleNumber,
11410
11677
  data,
11411
11678
  contextHashes,
11412
- { confirmCancellations: planRunMeta?.confirmCancellations === true }
11679
+ { confirmCancellations: planRunMeta?.confirmCancellations === true, ownerUserId: applyScope.callerUserId ?? void 0 }
11413
11680
  );
11414
11681
  if (wbWarnings.length > 0) {
11415
11682
  writeBackWarnings = wbWarnings;
@@ -11484,7 +11751,8 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
11484
11751
  const prepareTimer = startTimer();
11485
11752
  tracker?.mark("validate_and_prepare");
11486
11753
  let t = startTimer();
11487
- const { mode, cycleNumber, strategyReviewWarning } = await validateAndPrepare(adapter2, force);
11754
+ const prepareScope = await resolvePlanScope(adapter2, config2);
11755
+ const { mode, cycleNumber, strategyReviewWarning } = await validateAndPrepare(adapter2, force, prepareScope.callerUserId);
11488
11756
  const validateMs = t();
11489
11757
  if (handoffsOnly) {
11490
11758
  tracker?.mark("handoffs_only_assemble");
@@ -11530,8 +11798,8 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
11530
11798
  t = startTimer();
11531
11799
  const { context, contextHashes } = await assembleContext(adapter2, mode, config2, filters, focus);
11532
11800
  const assembleMs = t();
11533
- const TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
11534
- if (mode !== "bootstrap" && context.productBrief.includes(TEMPLATE_MARKER)) {
11801
+ const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
11802
+ if (mode !== "bootstrap" && context.productBrief.includes(TEMPLATE_MARKER2)) {
11535
11803
  throw new Error("TEMPLATE_BRIEF");
11536
11804
  }
11537
11805
  if (skipHandoffs) context.skipHandoffs = true;
@@ -11755,9 +12023,9 @@ function defaultHint(tool) {
11755
12023
  "release"
11756
12024
  ]);
11757
12025
  if (knownTools.has(tool)) {
11758
- return `Re-run \`${tool}\`. If the crash repeats, share the diagnostic JSON above with PAPI support.`;
12026
+ 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
12027
  }
11760
- return "Re-run the tool. If the crash repeats, share the diagnostic JSON above with PAPI support.";
12028
+ 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
12029
  }
11762
12030
 
11763
12031
  // src/lib/subagent-dispatch.ts
@@ -11899,12 +12167,174 @@ async function resolveLlmResponse(inlineResponse, filePath) {
11899
12167
  return { ok: true, llmResponse: contents };
11900
12168
  }
11901
12169
 
12170
+ // src/services/session-guidance.ts
12171
+ var DEFAULT_CALLER_KEY = "__default__";
12172
+ var sessionStates = /* @__PURE__ */ new Map();
12173
+ var MAX_SESSION_STATES = 1e3;
12174
+ function newSessionState(now) {
12175
+ return {
12176
+ toolCallCount: 0,
12177
+ lastOrientAt: null,
12178
+ releaseSinceLastOrient: false,
12179
+ sessionStartedAt: now,
12180
+ lastReviewListAt: null,
12181
+ failureTimestamps: [],
12182
+ consecutiveFailures: 0,
12183
+ lastTouchedAt: now
12184
+ };
12185
+ }
12186
+ function evictIfNeeded() {
12187
+ if (sessionStates.size <= MAX_SESSION_STATES) return;
12188
+ const entries = [...sessionStates.entries()].sort(
12189
+ (a, b2) => a[1].lastTouchedAt - b2[1].lastTouchedAt
12190
+ );
12191
+ const overflow = sessionStates.size - MAX_SESSION_STATES;
12192
+ for (let i = 0; i < overflow; i++) {
12193
+ sessionStates.delete(entries[i][0]);
12194
+ }
12195
+ }
12196
+ function getState(callerKey) {
12197
+ const key = callerKey ?? DEFAULT_CALLER_KEY;
12198
+ const now = Date.now();
12199
+ let s = sessionStates.get(key);
12200
+ if (!s) {
12201
+ s = newSessionState(now);
12202
+ sessionStates.set(key, s);
12203
+ evictIfNeeded();
12204
+ } else {
12205
+ s.lastTouchedAt = now;
12206
+ }
12207
+ return s;
12208
+ }
12209
+ function callerKeyFromConfig(config2) {
12210
+ return config2.projectId ?? config2.userId ?? void 0;
12211
+ }
12212
+ var CONTEXT_BLOAT_CALL_THRESHOLD = 40;
12213
+ var ORIENT_GAP_MS = 3 * 60 * 60 * 1e3;
12214
+ var REVIEW_LIST_GUARD_WINDOW_MS = 15 * 60 * 1e3;
12215
+ var FAILURE_WINDOW_MS = 30 * 60 * 1e3;
12216
+ var FAILURE_RATE_THRESHOLD = 8;
12217
+ var CONSECUTIVE_FAILURE_THRESHOLD = 4;
12218
+ function recordToolCall(name, callerKey) {
12219
+ const state = getState(callerKey);
12220
+ state.toolCallCount++;
12221
+ if (name === "release") state.releaseSinceLastOrient = true;
12222
+ if (name === "review_list") state.lastReviewListAt = Date.now();
12223
+ }
12224
+ function recordToolOutcome(success, callerKey) {
12225
+ const state = getState(callerKey);
12226
+ if (success) {
12227
+ state.consecutiveFailures = 0;
12228
+ return;
12229
+ }
12230
+ const now = Date.now();
12231
+ state.consecutiveFailures++;
12232
+ state.failureTimestamps.push(now);
12233
+ const cutoff = now - FAILURE_WINDOW_MS;
12234
+ state.failureTimestamps = state.failureTimestamps.filter((t) => t >= cutoff);
12235
+ }
12236
+ function wasReviewListSeenRecently(windowMs = REVIEW_LIST_GUARD_WINDOW_MS, callerKey) {
12237
+ const state = getState(callerKey);
12238
+ if (state.lastReviewListAt == null) return false;
12239
+ return Date.now() - state.lastReviewListAt <= windowMs;
12240
+ }
12241
+ function markOrient(callerKey) {
12242
+ const state = getState(callerKey);
12243
+ state.lastOrientAt = Date.now();
12244
+ state.releaseSinceLastOrient = false;
12245
+ }
12246
+ function getProjectConnectionBanner(projectName, projectSlug) {
12247
+ if (!projectName || !projectSlug) return null;
12248
+ 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.`;
12249
+ }
12250
+ function detectContextDegradation(now = Date.now(), callerKey) {
12251
+ const state = getState(callerKey);
12252
+ if (state.consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD) {
12253
+ 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.`;
12254
+ }
12255
+ const cutoff = now - FAILURE_WINDOW_MS;
12256
+ const recentFailures = state.failureTimestamps.filter((t) => t >= cutoff).length;
12257
+ if (recentFailures >= FAILURE_RATE_THRESHOLD) {
12258
+ const mins = Math.round(FAILURE_WINDOW_MS / 6e4);
12259
+ return `Context degradation (distraction/confusion): ${recentFailures} tool failures in the last ${mins}min. A fresh window often clears this faster than pushing on.`;
12260
+ }
12261
+ return null;
12262
+ }
12263
+ async function buildSessionGuidance(callerKey) {
12264
+ const state = getState(callerKey);
12265
+ const signals = [];
12266
+ const degradation = detectContextDegradation(Date.now(), callerKey);
12267
+ if (degradation) signals.push(degradation);
12268
+ if (state.toolCallCount > CONTEXT_BLOAT_CALL_THRESHOLD) {
12269
+ signals.push(
12270
+ `${state.toolCallCount} tool calls this session \u2014 context may be bloated. Consider starting a fresh window.`
12271
+ );
12272
+ }
12273
+ if (state.lastOrientAt && Date.now() - state.lastOrientAt > ORIENT_GAP_MS) {
12274
+ const hours = Math.round((Date.now() - state.lastOrientAt) / (60 * 60 * 1e3));
12275
+ signals.push(
12276
+ `${hours}h since last orient \u2014 session may be stale. Consider a fresh window for best results.`
12277
+ );
12278
+ }
12279
+ if (state.releaseSinceLastOrient) {
12280
+ signals.push(
12281
+ "Release just ran \u2014 start a fresh session before the next `plan` to keep planning context clean."
12282
+ );
12283
+ }
12284
+ return signals.slice(0, 3);
12285
+ }
12286
+
12287
+ // src/lib/per-caller-cache.ts
12288
+ var DEFAULT_CALLER_KEY2 = "__default__";
12289
+ var MAX_PER_CALLER_ENTRIES = 1e3;
12290
+ var PerCallerCache = class {
12291
+ constructor(max = MAX_PER_CALLER_ENTRIES) {
12292
+ this.max = max;
12293
+ }
12294
+ store = /* @__PURE__ */ new Map();
12295
+ /** Stash the prepare-phase value for this caller (undefined caller → default bucket). */
12296
+ set(callerKey, value) {
12297
+ this.store.set(callerKey ?? DEFAULT_CALLER_KEY2, { value, touchedAt: this.now() });
12298
+ this.evictIfNeeded();
12299
+ }
12300
+ /**
12301
+ * Read the caller's prepare-phase value WITHOUT removing it — used to peek the
12302
+ * expected value during apply-validation (the prior singletons were read then
12303
+ * cleared, so callers that need the clear-on-read semantics use `take`).
12304
+ */
12305
+ peek(callerKey) {
12306
+ return this.store.get(callerKey ?? DEFAULT_CALLER_KEY2)?.value;
12307
+ }
12308
+ /** Read AND remove this caller's value — the prepare→apply handshake consumes it once. */
12309
+ take(callerKey) {
12310
+ const key = callerKey ?? DEFAULT_CALLER_KEY2;
12311
+ const entry = this.store.get(key);
12312
+ if (entry) this.store.delete(key);
12313
+ return entry?.value;
12314
+ }
12315
+ /** Drop this caller's value without reading it. */
12316
+ clear(callerKey) {
12317
+ this.store.delete(callerKey ?? DEFAULT_CALLER_KEY2);
12318
+ }
12319
+ /** Test/inspection helper — number of live caller buckets. */
12320
+ size() {
12321
+ return this.store.size;
12322
+ }
12323
+ now() {
12324
+ return Date.now();
12325
+ }
12326
+ evictIfNeeded() {
12327
+ if (this.store.size <= this.max) return;
12328
+ const entries = [...this.store.entries()].sort((a, b2) => a[1].touchedAt - b2[1].touchedAt);
12329
+ const overflow = this.store.size - this.max;
12330
+ for (let i = 0; i < overflow; i++) {
12331
+ this.store.delete(entries[i][0]);
12332
+ }
12333
+ }
12334
+ };
12335
+
11902
12336
  // src/tools/plan.ts
11903
- var lastPrepareContextHashes;
11904
- var lastPrepareUserMessage;
11905
- var lastPrepareContextBytes;
11906
- var lastPrepareCycleNumber;
11907
- var lastPrepareSkipHandoffs;
12337
+ var planPrepareCache = new PerCallerCache();
11908
12338
  var planTool = {
11909
12339
  name: "plan",
11910
12340
  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 +12476,7 @@ function formatPlanResult(result) {
12046
12476
  }
12047
12477
  async function handlePlan(adapter2, config2, args) {
12048
12478
  const toolMode = args.mode;
12479
+ const callerKey = callerKeyFromConfig(config2);
12049
12480
  const filters = {};
12050
12481
  if (typeof args.phase === "string") filters.phase = args.phase;
12051
12482
  if (typeof args.module === "string") filters.module = args.module;
@@ -12068,11 +12499,12 @@ async function handlePlan(adapter2, config2, args) {
12068
12499
  const planMode = args.plan_mode || "full";
12069
12500
  const rawCycleNumber = args.cycle_number != null ? Number(args.cycle_number) : NaN;
12070
12501
  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;
12502
+ const prep = planPrepareCache.peek(callerKey);
12503
+ const contextHashes = prep?.contextHashes;
12504
+ const inputContext = prep?.userMessage;
12505
+ const contextBytes = prep?.contextBytes;
12506
+ let expectedCycleNumber = prep?.cycleNumber;
12507
+ const skipHandoffsCached = prep?.skipHandoffs;
12076
12508
  const skipHandoffs = args.skip_handoffs === true || skipHandoffsCached === true;
12077
12509
  if (expectedCycleNumber === void 0 && process.env.PAPI_GUARD_RESTART === "true") {
12078
12510
  try {
@@ -12094,11 +12526,7 @@ async function handlePlan(adapter2, config2, args) {
12094
12526
  );
12095
12527
  }
12096
12528
  const cycleNumber = newCycleNumber - 1;
12097
- lastPrepareContextHashes = void 0;
12098
- lastPrepareUserMessage = void 0;
12099
- lastPrepareContextBytes = void 0;
12100
- lastPrepareCycleNumber = void 0;
12101
- lastPrepareSkipHandoffs = void 0;
12529
+ planPrepareCache.clear(callerKey);
12102
12530
  let utilisation;
12103
12531
  if (inputContext) {
12104
12532
  try {
@@ -12127,11 +12555,13 @@ async function handlePlan(adapter2, config2, args) {
12127
12555
  }
12128
12556
  const skipHandoffs = args.skip_handoffs === true;
12129
12557
  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;
12558
+ planPrepareCache.set(callerKey, {
12559
+ contextHashes: result.contextHashes,
12560
+ userMessage: result.userMessage,
12561
+ contextBytes: result.contextBytes,
12562
+ cycleNumber: result.cycleNumber,
12563
+ skipHandoffs: skipHandoffs || void 0
12564
+ });
12135
12565
  const autoDispatchEnabled = process.env.PAPI_AUTO_DISPATCH !== "false";
12136
12566
  const autoDispatchThreshold = 50 * 1024;
12137
12567
  let dispatch;
@@ -13406,7 +13836,18 @@ ${lines.join("\n")}`;
13406
13836
  console.error(`[strategy_review] Context size: ${contextSize.toLocaleString()} chars (~${estimatedTokens.toLocaleString()} tokens)`);
13407
13837
  return context;
13408
13838
  }
13409
- async function writeBack2(adapter2, cycleNumber, data, fullAnalysis) {
13839
+ function extractDecisionEvidence(ad, eventType, warnings) {
13840
+ const isDeliberate = eventType === "validated" || eventType === "invalidated" || eventType === "modified";
13841
+ const evidenceRef = ad.evidenceRef?.trim() || void 0;
13842
+ const metricDelta = ad.metricDelta ?? null;
13843
+ if (isDeliberate && !evidenceRef && !metricDelta?.metric) {
13844
+ warnings?.push(
13845
+ `${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.`
13846
+ );
13847
+ }
13848
+ return { evidenceRef, metricDelta };
13849
+ }
13850
+ async function writeBack2(adapter2, cycleNumber, data, fullAnalysis, warnings) {
13410
13851
  const cleanTitle = data.sessionLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
13411
13852
  const cleanContent = data.sessionLogContent.replace(/^#{1,3}\s+(?:Cycle|Session)\s+\d+\s*—[^\n]*\n*/i, "").trim();
13412
13853
  const lastReviewCycle = await adapter2.getLastStrategyReviewCycle();
@@ -13460,6 +13901,7 @@ ${cleanContent}`;
13460
13901
  await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, ad.action);
13461
13902
  }
13462
13903
  const eventType = ad.action === "delete" ? "invalidated" : ad.action === "confidence_change" ? "confidence_changed" : ad.action === "supersede" ? "superseded" : ad.action === "new" ? "created" : "modified";
13904
+ const evidence = extractDecisionEvidence(ad, eventType, warnings);
13463
13905
  try {
13464
13906
  await adapter2.appendDecisionEvent({
13465
13907
  decisionId: ad.id,
@@ -13467,7 +13909,9 @@ ${cleanContent}`;
13467
13909
  cycle: cycleNumber,
13468
13910
  source: "strategy_review",
13469
13911
  sourceRef: `cycle-${cycleNumber}-review`,
13470
- detail: `Action: ${ad.action}`
13912
+ detail: `Action: ${ad.action}`,
13913
+ evidenceRef: evidence.evidenceRef,
13914
+ metricDelta: evidence.metricDelta
13471
13915
  });
13472
13916
  } catch {
13473
13917
  }
@@ -13626,6 +14070,7 @@ async function processReviewOutput(adapter2, rawOutput, cycleNumber) {
13626
14070
  let slackWarning;
13627
14071
  let writeBackFailed;
13628
14072
  let phaseChanges;
14073
+ const evidenceWarnings = [];
13629
14074
  if (!data) {
13630
14075
  const marker = "<!-- PAPI_STRUCTURED_OUTPUT -->";
13631
14076
  const hasMarker = rawOutput.includes(marker);
@@ -13639,7 +14084,7 @@ async function processReviewOutput(adapter2, rawOutput, cycleNumber) {
13639
14084
  }
13640
14085
  if (data) {
13641
14086
  try {
13642
- phaseChanges = await writeBack2(adapter2, cycleNumber, data, displayText);
14087
+ phaseChanges = await writeBack2(adapter2, cycleNumber, data, displayText, evidenceWarnings);
13643
14088
  } catch (err) {
13644
14089
  writeBackFailed = err instanceof Error ? err.message : String(err);
13645
14090
  try {
@@ -13687,9 +14132,15 @@ ${report}`;
13687
14132
  **Discovery Canvas Auto-Populated:** ${populated.join(", ")}`;
13688
14133
  }
13689
14134
  }
14135
+ const evidenceWarningSection = evidenceWarnings.length ? `
14136
+
14137
+ ---
14138
+
14139
+ **Decision-ledger evidence warnings (${evidenceWarnings.length}):**
14140
+ ${evidenceWarnings.map((w) => `- ${w}`).join("\n")}` : "";
13690
14141
  const fullText = phaseChanges?.length ? `${displayText}
13691
14142
 
13692
- ${formatPhaseChanges(phaseChanges)}${valueReportSection}${canvasSection}` : `${displayText}${valueReportSection}${canvasSection}`;
14143
+ ${formatPhaseChanges(phaseChanges)}${valueReportSection}${canvasSection}${evidenceWarningSection}` : `${displayText}${valueReportSection}${canvasSection}${evidenceWarningSection}`;
13693
14144
  return {
13694
14145
  cycleNumber,
13695
14146
  displayText: fullText,
@@ -13996,6 +14447,7 @@ function buildStrategyChangeUserMessage(cycleNumber, text, productBrief, activeD
13996
14447
  async function processStrategyChangeOutput(adapter2, rawOutput, cycleNumber) {
13997
14448
  const { displayText, data } = parseStrategyChangeOutput(rawOutput);
13998
14449
  let writeBackFailed;
14450
+ const evidenceWarnings = [];
13999
14451
  if (data) {
14000
14452
  try {
14001
14453
  const cleanTitle = data.cycleLogTitle.replace(/^(?:Cycle|Session)\s+\d+\s*—\s*/i, "").trim();
@@ -14023,6 +14475,7 @@ ${cleanContent}`;
14023
14475
  await adapter2.updateActiveDecision(ad.id, ad.body, cycleNumber, ad.action);
14024
14476
  }
14025
14477
  const eventType = ad.action === "delete" ? "invalidated" : ad.action === "confidence_change" ? "confidence_changed" : ad.action === "supersede" ? "superseded" : ad.action === "new" ? "created" : "modified";
14478
+ const evidence = extractDecisionEvidence(ad, eventType, evidenceWarnings);
14026
14479
  try {
14027
14480
  await adapter2.appendDecisionEvent({
14028
14481
  decisionId: ad.id,
@@ -14030,7 +14483,9 @@ ${cleanContent}`;
14030
14483
  cycle: cycleNumber,
14031
14484
  source: "strategy_change",
14032
14485
  sourceRef: `cycle-${cycleNumber}-change`,
14033
- detail: `Action: ${ad.action}`
14486
+ detail: `Action: ${ad.action}`,
14487
+ evidenceRef: evidence.evidenceRef,
14488
+ metricDelta: evidence.metricDelta
14034
14489
  });
14035
14490
  } catch {
14036
14491
  }
@@ -14050,7 +14505,13 @@ ${cleanContent}`;
14050
14505
  writeBackFailed = err instanceof Error ? err.message : String(err);
14051
14506
  }
14052
14507
  }
14053
- return { cycleNumber, displayText, writeBackFailed };
14508
+ const fullText = evidenceWarnings.length ? `${displayText}
14509
+
14510
+ ---
14511
+
14512
+ **Decision-ledger evidence warnings (${evidenceWarnings.length}):**
14513
+ ${evidenceWarnings.map((w) => `- ${w}`).join("\n")}` : displayText;
14514
+ return { cycleNumber, displayText: fullText, writeBackFailed };
14054
14515
  }
14055
14516
  async function prepareStrategyChange(adapter2, text) {
14056
14517
  let cycleNumber;
@@ -14189,11 +14650,10 @@ Confidence: ${input.confidence}. Captured mid-conversation via strategy_change c
14189
14650
  }
14190
14651
 
14191
14652
  // src/tools/strategy.ts
14192
- var lastReviewUserMessage;
14193
- var lastReviewContextBytes;
14653
+ var reviewPrepareCache = new PerCallerCache();
14194
14654
  var strategyReviewTool = {
14195
14655
  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.',
14656
+ 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
14657
  annotations: { readOnlyHint: false, destructiveHint: false },
14198
14658
  inputSchema: {
14199
14659
  type: "object",
@@ -14258,7 +14718,7 @@ var strategyAgendaTool = {
14258
14718
  };
14259
14719
  var strategyChangeTool = {
14260
14720
  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.',
14721
+ 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
14722
  annotations: { readOnlyHint: false, destructiveHint: false },
14263
14723
  inputSchema: {
14264
14724
  type: "object",
@@ -14296,6 +14756,10 @@ var strategyChangeTool = {
14296
14756
  confidence_only: {
14297
14757
  type: "boolean",
14298
14758
  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.`
14759
+ },
14760
+ north_star: {
14761
+ type: "string",
14762
+ 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
14763
  }
14300
14764
  },
14301
14765
  required: []
@@ -14304,6 +14768,7 @@ var strategyChangeTool = {
14304
14768
  async function handleStrategyReview(adapter2, config2, args) {
14305
14769
  const toolMode = args.mode;
14306
14770
  const force = args.force === true;
14771
+ const callerKey = callerKeyFromConfig(config2);
14307
14772
  const tracker = new ProgressTracker(toolMode === "apply" ? "apply_validate" : "prepare_validate");
14308
14773
  try {
14309
14774
  if (toolMode === "apply") {
@@ -14316,10 +14781,9 @@ async function handleStrategyReview(adapter2, config2, args) {
14316
14781
  }
14317
14782
  const llmResponse = resolved.llmResponse;
14318
14783
  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;
14784
+ const prep = reviewPrepareCache.take(callerKey);
14785
+ const inputContext = prep?.userMessage;
14786
+ const contextBytes = prep?.contextBytes;
14323
14787
  const result = await applyStrategyReviewOutput(adapter2, llmResponse, cycleNumber, tracker);
14324
14788
  let utilisation;
14325
14789
  if (inputContext) {
@@ -14384,14 +14848,17 @@ ${recLines.join("\n")}
14384
14848
  if ("notDue" in result) {
14385
14849
  return textResponse(result.message);
14386
14850
  }
14387
- lastReviewUserMessage = result.userMessage;
14388
- lastReviewContextBytes = Buffer.byteLength(result.userMessage, "utf-8");
14851
+ const reviewContextBytes = Buffer.byteLength(result.userMessage, "utf-8");
14852
+ reviewPrepareCache.set(callerKey, {
14853
+ userMessage: result.userMessage,
14854
+ contextBytes: reviewContextBytes
14855
+ });
14389
14856
  const autoDispatchEnabled = process.env.PAPI_AUTO_DISPATCH !== "false";
14390
14857
  const autoDispatchThreshold = 50 * 1024;
14391
14858
  let dispatch;
14392
14859
  if (args.dispatch === "inline" || args.dispatch === "subagent") {
14393
14860
  dispatch = args.dispatch;
14394
- } else if (autoDispatchEnabled && lastReviewContextBytes > autoDispatchThreshold) {
14861
+ } else if (autoDispatchEnabled && reviewContextBytes > autoDispatchThreshold) {
14395
14862
  dispatch = "subagent";
14396
14863
  } else {
14397
14864
  dispatch = "inline";
@@ -14402,7 +14869,7 @@ ${recLines.join("\n")}
14402
14869
  cycleNumber: result.cycleNumber,
14403
14870
  systemPrompt: result.systemPrompt,
14404
14871
  userMessage: result.userMessage,
14405
- contextBytes: lastReviewContextBytes
14872
+ contextBytes: reviewContextBytes
14406
14873
  });
14407
14874
  return textResponse(dispatchPrompt);
14408
14875
  }
@@ -14499,9 +14966,25 @@ async function handleStrategyChange(adapter2, _config, args) {
14499
14966
  const toolMode = args.mode;
14500
14967
  try {
14501
14968
  if (toolMode === "capture") {
14969
+ const northStar = args.north_star;
14970
+ if (northStar && northStar.trim()) {
14971
+ if (!adapter2.upsertNorthStar) {
14972
+ return errorResponse("North Star write is not available on this adapter (requires the pg/proxy or md adapter).");
14973
+ }
14974
+ const health = await adapter2.getCycleHealth();
14975
+ const cycleNumber = health.totalCycles;
14976
+ await adapter2.upsertNorthStar(northStar.trim(), cycleNumber);
14977
+ return textResponse(
14978
+ `**North Star set** (Cycle ${cycleNumber})
14979
+
14980
+ ${northStar.trim()}
14981
+
14982
+ orient and the project foundation will read this value.`
14983
+ );
14984
+ }
14502
14985
  const text2 = args.text;
14503
14986
  if (!text2 || !text2.trim()) {
14504
- return errorResponse("text is required for capture mode. Describe the decision.");
14987
+ return errorResponse("text is required for capture mode. Describe the decision (or pass north_star to set the project North Star).");
14505
14988
  }
14506
14989
  const adId = args.ad_id;
14507
14990
  const confidence = args.confidence ?? "MEDIUM";
@@ -14612,7 +15095,17 @@ async function viewBoard(adapter2, phaseFilter, options) {
14612
15095
  const allTasks = await adapter2.queryBoard(
14613
15096
  Object.keys(queryOptions).length > 0 ? queryOptions : void 0
14614
15097
  );
14615
- allTasks.sort((a, b2) => {
15098
+ let filtered = allTasks;
15099
+ if (options?.cycle != null) {
15100
+ filtered = filtered.filter((t) => t.cycle === options.cycle);
15101
+ }
15102
+ if (options?.query && options.query.trim()) {
15103
+ const q = options.query.trim().toLowerCase();
15104
+ filtered = filtered.filter(
15105
+ (t) => t.title.toLowerCase().includes(q) || (t.notes?.toLowerCase().includes(q) ?? false)
15106
+ );
15107
+ }
15108
+ filtered.sort((a, b2) => {
14616
15109
  const aTier = STATUS_TIER[a.status] ?? 4;
14617
15110
  const bTier = STATUS_TIER[b2.status] ?? 4;
14618
15111
  if (aTier !== bTier) return aTier - bTier;
@@ -14624,10 +15117,10 @@ async function viewBoard(adapter2, phaseFilter, options) {
14624
15117
  const bDate = b2.createdAt ? String(b2.createdAt) : "";
14625
15118
  return bDate.localeCompare(aDate);
14626
15119
  });
14627
- const total = allTasks.length;
15120
+ const total = filtered.length;
14628
15121
  const offset = options?.offset ?? 0;
14629
15122
  const limit = options?.limit ?? 50;
14630
- const paged = allTasks.slice(offset, offset + limit);
15123
+ const paged = filtered.slice(offset, offset + limit);
14631
15124
  return {
14632
15125
  tasks: paged,
14633
15126
  total,
@@ -14684,11 +15177,23 @@ async function archiveTasks(adapter2, phases, statuses) {
14684
15177
  // src/tools/board.ts
14685
15178
  var boardViewTool = {
14686
15179
  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.',
15180
+ 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
15181
  annotations: { readOnlyHint: true, destructiveHint: false },
14689
15182
  inputSchema: {
14690
15183
  type: "object",
14691
15184
  properties: {
15185
+ task_id: {
15186
+ type: "string",
15187
+ 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.'
15188
+ },
15189
+ query: {
15190
+ type: "string",
15191
+ 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."
15192
+ },
15193
+ cycle: {
15194
+ type: "number",
15195
+ description: "Filter to tasks assigned to this cycle number."
15196
+ },
14692
15197
  phase: {
14693
15198
  type: "string",
14694
15199
  description: 'Filter to tasks in this phase (e.g. "Phase 5").'
@@ -14828,14 +15333,45 @@ var boardEditTool = {
14828
15333
  cycle: {
14829
15334
  type: ["number", "null"],
14830
15335
  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."
14831
- }
14832
- },
14833
- required: ["task_id"]
14834
- }
14835
- };
15336
+ },
15337
+ estimated_effort: {
15338
+ type: "string",
15339
+ enum: ["XS", "S", "M", "L", "XL"],
15340
+ 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."
15341
+ },
15342
+ actual_effort: {
15343
+ type: "string",
15344
+ enum: ["XS", "S", "M", "L", "XL"],
15345
+ description: "task-2182: correct the actual effort on this task's LATEST build report (fixes a mis-recorded actual)."
15346
+ }
15347
+ },
15348
+ required: ["task_id"]
15349
+ }
15350
+ };
14836
15351
  function pad(value, width) {
14837
15352
  return value.length >= width ? value : value + " ".repeat(width - value.length);
14838
15353
  }
15354
+ var TITLE_MAX = 80;
15355
+ function truncateTitle(title) {
15356
+ return title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1)}\u2026` : title;
15357
+ }
15358
+ function formatSingleTask(t) {
15359
+ const lines = [
15360
+ `**${t.id} \u2014 ${t.title}**`,
15361
+ "",
15362
+ `- **Status:** ${t.status}`,
15363
+ `- **Priority:** ${t.priority}`,
15364
+ `- **Cycle:** ${t.cycle != null ? t.cycle : "-"}`,
15365
+ `- **Phase:** ${t.phase ?? "-"}`,
15366
+ `- **Module:** ${t.module ?? "-"}`,
15367
+ `- **Epic:** ${t.epic ?? "-"}`,
15368
+ `- **Effort:** ${t.complexity ?? "-"}`,
15369
+ `- **Source:** ${t.source ?? "-"}`
15370
+ ];
15371
+ if (t.notes?.trim()) lines.push(`- **Notes:** ${t.notes.trim()}`);
15372
+ if (t.why?.trim()) lines.push(`- **Why:** ${t.why.trim()}`);
15373
+ return lines.join("\n");
15374
+ }
14839
15375
  function formatBoard(result) {
14840
15376
  if (result.tasks.length === 0) {
14841
15377
  return "No tasks found.";
@@ -14844,7 +15380,7 @@ function formatBoard(result) {
14844
15380
  const rows = result.tasks.map((t) => [
14845
15381
  t.priority,
14846
15382
  t.id,
14847
- t.title,
15383
+ truncateTitle(t.title),
14848
15384
  t.status,
14849
15385
  t.cycle != null ? String(t.cycle) : "-",
14850
15386
  t.phase ?? "-",
@@ -14896,11 +15432,19 @@ async function handleBoardView(adapter2, args) {
14896
15432
  const summary = await viewBoardSummary(adapter2);
14897
15433
  return textResponse(formatSummary(summary));
14898
15434
  }
15435
+ const taskIdArg = args.task_id ?? args.display_id;
15436
+ if (taskIdArg) {
15437
+ const task = await adapter2.getTask(taskIdArg);
15438
+ if (!task) return errorResponse(`Task ${taskIdArg} not found.`);
15439
+ return textResponse(formatSingleTask(task));
15440
+ }
14899
15441
  const result = await viewBoard(adapter2, void 0, {
14900
15442
  phase: args.phase,
14901
15443
  status: args.status,
14902
15444
  limit: args.limit,
14903
- offset: args.offset
15445
+ offset: args.offset,
15446
+ query: args.query,
15447
+ cycle: args.cycle
14904
15448
  });
14905
15449
  let output = formatBoard(result);
14906
15450
  try {
@@ -15022,6 +15566,15 @@ async function handleBoardEdit(adapter2, args) {
15022
15566
  changes.push(field);
15023
15567
  }
15024
15568
  }
15569
+ const effortCorrection = {};
15570
+ if (typeof args.estimated_effort === "string") {
15571
+ effortCorrection.estimatedEffort = args.estimated_effort;
15572
+ changes.push("estimated_effort");
15573
+ }
15574
+ if (typeof args.actual_effort === "string") {
15575
+ effortCorrection.actualEffort = args.actual_effort;
15576
+ changes.push("actual_effort");
15577
+ }
15025
15578
  const notesMode = args.notes_mode;
15026
15579
  if (notesMode === "clear" && !changes.includes("notes")) {
15027
15580
  changes.push("notes");
@@ -15106,7 +15659,15 @@ ${existing}` : entry;
15106
15659
  if (updates.status === "Cancelled" && !updates.cancelledBy) {
15107
15660
  updates.cancelledBy = "user";
15108
15661
  }
15109
- await adapter2.updateTask(taskId, updates);
15662
+ if (effortCorrection.estimatedEffort || effortCorrection.actualEffort) {
15663
+ if (!adapter2.correctLatestBuildReportEffort) {
15664
+ return errorResponse("Correcting build-report effort requires a database adapter (pg). The md adapter does not support it.");
15665
+ }
15666
+ await adapter2.correctLatestBuildReportEffort(taskId, effortCorrection);
15667
+ }
15668
+ if (Object.keys(updates).length > 0) {
15669
+ await adapter2.updateTask(taskId, updates);
15670
+ }
15110
15671
  if ((updates.status === "Done" || updates.status === "Cancelled") && adapter2.updateDogfoodEntryStatus) {
15111
15672
  try {
15112
15673
  const dogfoodLog = await adapter2.getDogfoodLog?.(50) ?? [];
@@ -15383,7 +15944,7 @@ This is a one-time check at the start of the cycle, not per-task. It catches sco
15383
15944
  Every 5 cycles, PAPI offers a strategy review \u2014 a deep analysis of velocity, estimation accuracy, active decisions, and project direction.
15384
15945
 
15385
15946
  - **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.
15947
+ - 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
15948
  - Reviews produce recommendations that feed into the next plan.
15388
15949
  - If the review recommends AD changes, use \`strategy_change\` to apply them.
15389
15950
 
@@ -15781,6 +16342,9 @@ async function ensurePapiPermission(projectRoot) {
15781
16342
  } catch {
15782
16343
  }
15783
16344
  }
16345
+ var TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
16346
+ var CONVENTIONS_SENTINEL = "<!-- PAPI_CONVENTIONS -->";
16347
+ var CONVENTIONS_HEADING = "## Code Style Conventions";
15784
16348
  async function applySetupOutputs(adapter2, config2, input, collector, briefText, adSeedText, conventionsText) {
15785
16349
  const warnings = [];
15786
16350
  await adapter2.updateProductBrief(briefText);
@@ -15818,13 +16382,21 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
15818
16382
  }
15819
16383
  }
15820
16384
  let seededAds = 0;
16385
+ let skippedAds = 0;
15821
16386
  if (adSeedText) {
15822
16387
  try {
15823
16388
  const cleaned = adSeedText.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
15824
16389
  const ads = JSON.parse(cleaned);
15825
16390
  if (Array.isArray(ads)) {
16391
+ const existingAdIds = adapter2.getActiveDecisions ? new Set(
16392
+ (await adapter2.getActiveDecisions({ includeRetired: true }).catch(() => [])).map((a) => a.displayId)
16393
+ ) : /* @__PURE__ */ new Set();
15826
16394
  for (const ad of ads) {
15827
16395
  if (ad.id && ad.body) {
16396
+ if (existingAdIds.has(ad.id) && !input.force) {
16397
+ skippedAds++;
16398
+ continue;
16399
+ }
15828
16400
  if (adapter2.upsertActiveDecision) {
15829
16401
  const title = ad.title || ad.body.split("\n")[0].replace(/^#+\s*/, "").slice(0, 120);
15830
16402
  await adapter2.upsertActiveDecision(ad.id, ad.body, title, ad.confidence || "MEDIUM", 0);
@@ -15842,7 +16414,11 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
15842
16414
  );
15843
16415
  seededAds = 0;
15844
16416
  }
15845
- if (seededAds === 0 && adSeedText) {
16417
+ if (skippedAds > 0) {
16418
+ warnings.push(
16419
+ `Active Decisions: detected ${skippedAds} existing AD(s) and left them untouched${seededAds > 0 ? `; created ${seededAds} new one(s)` : " (none new to create)"}.`
16420
+ );
16421
+ } else if (seededAds === 0 && adSeedText) {
15846
16422
  if (!warnings.some((w) => w.startsWith("AD seeding failed"))) {
15847
16423
  warnings.push(
15848
16424
  "AD seeding produced 0 active decisions \u2014 the JSON may be valid but empty or missing required `id` and `body` fields."
@@ -15851,17 +16427,29 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
15851
16427
  }
15852
16428
  }
15853
16429
  if (conventionsText?.trim()) {
16430
+ const conventionsBlock = `${CONVENTIONS_SENTINEL}
16431
+ ${conventionsText.trim()}
16432
+ `;
15854
16433
  if (config2.adapterType === "proxy") {
15855
16434
  collector.add({
15856
16435
  path: "CLAUDE.md",
15857
- content: "\n" + conventionsText.trim() + "\n",
16436
+ content: "\n" + conventionsBlock,
15858
16437
  mode: "append"
15859
16438
  });
16439
+ warnings.push(
16440
+ `Conventions: append the block to CLAUDE.md only if it does not already contain \`${CONVENTIONS_SENTINEL}\` or a "${CONVENTIONS_HEADING}" section.`
16441
+ );
15860
16442
  } else {
15861
16443
  try {
15862
16444
  const claudeMdPath = join5(config2.projectRoot, "CLAUDE.md");
15863
16445
  const existing = await readFile4(claudeMdPath, "utf-8");
15864
- await writeFile2(claudeMdPath, existing + "\n" + conventionsText.trim() + "\n", "utf-8");
16446
+ if (existing.includes(CONVENTIONS_SENTINEL) || existing.includes(CONVENTIONS_HEADING)) {
16447
+ warnings.push(
16448
+ "Conventions: CLAUDE.md already contains a conventions block \u2014 skipped append to avoid duplication."
16449
+ );
16450
+ } else {
16451
+ await writeFile2(claudeMdPath, existing + "\n" + conventionsBlock, "utf-8");
16452
+ }
15865
16453
  } catch {
15866
16454
  }
15867
16455
  }
@@ -16048,8 +16636,8 @@ async function prepareSetup(adapter2, config2, input) {
16048
16636
  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
16637
  );
16050
16638
  }
16051
- const TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
16052
- const briefHasRealContent = existingBrief.trim().length > 0 && !existingBrief.includes(TEMPLATE_MARKER);
16639
+ const TEMPLATE_MARKER2 = "*Describe your project's core value proposition here.*";
16640
+ const briefHasRealContent = existingBrief.trim().length > 0 && !existingBrief.includes(TEMPLATE_MARKER2);
16053
16641
  const briefAlreadyExists = briefHasRealContent && !input.force;
16054
16642
  const canScanFilesystem = hasLocalWorkspace();
16055
16643
  const warnings = [];
@@ -16160,6 +16748,15 @@ async function prepareSetup(adapter2, config2, input) {
16160
16748
  targetUsers: input.targetUsers,
16161
16749
  codebaseContext: codebaseSummary
16162
16750
  })
16751
+ } : input.description?.trim() ? {
16752
+ system: VISION_TASKS_SYSTEM,
16753
+ user: buildVisionTasksPrompt({
16754
+ projectName: input.projectName,
16755
+ description: input.description,
16756
+ targetUsers: input.targetUsers,
16757
+ problems: input.problems,
16758
+ projectType: input.projectType
16759
+ })
16163
16760
  } : void 0;
16164
16761
  return {
16165
16762
  createdProject,
@@ -16181,7 +16778,6 @@ async function prepareSetup(adapter2, config2, input) {
16181
16778
  async function applySetup(adapter2, config2, input, briefText, adSeedText, conventionsText, initialTasksText) {
16182
16779
  const collector = new FileWriteCollector();
16183
16780
  const createdProject = await scaffoldPapiDir(adapter2, config2, input, collector);
16184
- const TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
16185
16781
  let effectiveBriefText = briefText;
16186
16782
  let briefRegenerated = false;
16187
16783
  if (!effectiveBriefText.trim()) {
@@ -16240,7 +16836,7 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
16240
16836
  `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
16837
  );
16242
16838
  }
16243
- const MAX_INITIAL_TASKS = 10;
16839
+ const MAX_INITIAL_TASKS = 20;
16244
16840
  const failedTasks = [];
16245
16841
  for (const task of toCreate.slice(0, MAX_INITIAL_TASKS)) {
16246
16842
  try {
@@ -16516,10 +17112,11 @@ ${result.seededAds} Active Decision${result.seededAds > 1 ? "s" : ""} seeded bas
16516
17112
  const taskNote = result.createdTasks > 0 || (result.tasksSkipped ?? 0) > 0 ? (() => {
16517
17113
  const created = result.createdTasks > 0 ? `${result.createdTasks} initial backlog task${result.createdTasks > 1 ? "s" : ""} created` : "";
16518
17114
  const skipped = (result.tasksSkipped ?? 0) > 0 ? `${result.tasksSkipped} duplicate${(result.tasksSkipped ?? 0) > 1 ? "s" : ""} skipped` : "";
17115
+ 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
17116
  return `
16520
17117
 
16521
- ${[created, skipped].filter(Boolean).join(", ")}.`;
16522
- })() : "";
17118
+ ${[created, skipped].filter(Boolean).join(", ")}.${idea}`;
17119
+ })() : '\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
17120
  const constraintsHint = !constraints ? '\n\nTip: consider adding `constraints` (e.g. "must use PostgreSQL", "HIPAA compliant", "offline-first") to improve Active Decision seeding.' : "";
16524
17121
  const editorNote = result.cursorScaffolded ? "\n\nCursor detected \u2014 `.cursor/rules/papi.mdc` scaffolded alongside CLAUDE.md." : "";
16525
17122
  const gitignoreNote = result.gitignoreNote ? `
@@ -16619,6 +17216,17 @@ PAPI needs the project name. Description and target users are optional \u2014 th
16619
17216
  ""
16620
17217
  );
16621
17218
  }
17219
+ if (result.briefPrompt && !result.briefAlreadyExists) {
17220
+ sections.push(
17221
+ `**\u{1F4C4} Before you write the brief \u2014 does the user already have a PRD, brief, spec, or design doc?**`,
17222
+ `A brief generated from the user's real spec produces a far better first plan than a generic scaffold.`,
17223
+ `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."`,
17224
+ `2. **Scan the working directory yourself** for likely files and offer them: \`PRD*\`, \`README*\`, \`SPEC*\`/\`spec*\`, design docs, and \`*.md\` under \`docs/\`.`,
17225
+ `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.`,
17226
+ `If there's no existing doc, skip this and generate the brief from what you know \u2014 the zero-doc path is fully supported.`,
17227
+ ""
17228
+ );
17229
+ }
16622
17230
  sections.push(
16623
17231
  `Generate the outputs below, then call \`setup\` again with:`,
16624
17232
  `- \`mode\`: "apply"`,
@@ -17000,8 +17608,16 @@ function inferCycleFromVersion(version) {
17000
17608
  const m = version.match(/^v0\.(\d+)\./);
17001
17609
  return m ? parseInt(m[1], 10) : 0;
17002
17610
  }
17003
- async function resolveCycleToClose(adapter2, version) {
17611
+ async function resolveCycleToClose(adapter2, version, callerUserId) {
17004
17612
  if (adapter2) {
17613
+ if (callerUserId) {
17614
+ try {
17615
+ const mine = callerUserId.trim().toLowerCase();
17616
+ const owned = (await adapter2.readCycles()).filter((c) => c.status === "active" && c.userId?.trim().toLowerCase() === mine).map((c) => c.number);
17617
+ if (owned.length > 0) return Math.max(...owned);
17618
+ } catch {
17619
+ }
17620
+ }
17005
17621
  try {
17006
17622
  const health = await adapter2.getCycleHealth();
17007
17623
  if (health.totalCycles > 0) return health.totalCycles;
@@ -17010,21 +17626,11 @@ async function resolveCycleToClose(adapter2, version) {
17010
17626
  }
17011
17627
  return inferCycleFromVersion(version);
17012
17628
  }
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
- }
17629
+ async function closeCycleState(config2, adapter2, version, cycleNum, options) {
17024
17630
  const warnings = [];
17025
17631
  const force = options?.force ?? false;
17026
17632
  const skipVersion = options?.skipVersion ?? false;
17027
- const resolvedCycleNum = cycleNum && cycleNum > 0 ? cycleNum : await resolveCycleToClose(adapter2, version);
17633
+ const resolvedCycleNum = cycleNum && cycleNum > 0 ? cycleNum : await resolveCycleToClose(adapter2, version, options?.callerUserId);
17028
17634
  const versionRegexCycle = inferCycleFromVersion(version);
17029
17635
  if (resolvedCycleNum > 0 && versionRegexCycle > 0 && versionRegexCycle !== resolvedCycleNum) {
17030
17636
  warnings.push(
@@ -17068,30 +17674,6 @@ To override, pass force=true (emits a telemetry warning).`
17068
17674
  warnings.push(`Release-readiness check failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
17069
17675
  }
17070
17676
  }
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
17677
  if (adapter2) {
17096
17678
  const currentCycle = resolvedCycleNum;
17097
17679
  if (currentCycle > 0) {
@@ -17115,9 +17697,64 @@ To override, pass force=true (emits a telemetry warning).`
17115
17697
  const msg = `createCycle (mark complete) failed for cycle ${currentCycle}: ${err instanceof Error ? err.message : String(err)}`;
17116
17698
  console.error(`[release] ${msg}`);
17117
17699
  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\`.`
17700
+ `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\`.`
17119
17701
  );
17120
17702
  }
17703
+ if (adapter2.appendCycleMetrics && adapter2.getBuildReportsSince) {
17704
+ try {
17705
+ const cycleReports = (await adapter2.getBuildReportsSince(currentCycle)).filter((r) => r.cycle === currentCycle);
17706
+ const [snapshot] = computeSnapshotsFromBuildReports(cycleReports);
17707
+ if (snapshot) await adapter2.appendCycleMetrics(snapshot);
17708
+ } catch (err) {
17709
+ console.error(
17710
+ `[release] cycle-metrics snapshot write failed for cycle ${currentCycle} (non-blocking): ${err instanceof Error ? err.message : String(err)}`
17711
+ );
17712
+ }
17713
+ }
17714
+ }
17715
+ }
17716
+ return { resolvedCycleNum, warnings, force, skipVersion };
17717
+ }
17718
+ async function createRelease(config2, branch, version, adapter2, cycleNum, options) {
17719
+ const collector = new FileWriteCollector();
17720
+ if (!isGitAvailable()) {
17721
+ throw new Error("git is not available.");
17722
+ }
17723
+ if (!isGitRepo(config2.projectRoot)) {
17724
+ throw new Error("not a git repository.");
17725
+ }
17726
+ if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
17727
+ throw new Error("working directory has uncommitted changes. Commit or stash them before releasing.");
17728
+ }
17729
+ const { resolvedCycleNum, warnings, skipVersion } = await closeCycleState(
17730
+ config2,
17731
+ adapter2,
17732
+ version,
17733
+ cycleNum,
17734
+ options
17735
+ );
17736
+ if (adapter2) {
17737
+ try {
17738
+ const resolvedBase = resolveBaseBranch(config2.projectRoot, branch);
17739
+ const orphans = listOrphanFeatBranches(config2.projectRoot, resolvedBase);
17740
+ const allDoneOrphans = [];
17741
+ for (const orphanBranch of orphans) {
17742
+ const taskIds = getTaskIdsOnBranch(config2.projectRoot, orphanBranch, resolvedBase);
17743
+ if (taskIds.length === 0) continue;
17744
+ const tasks = await adapter2.getTasks(taskIds);
17745
+ if (tasks.length === 0) continue;
17746
+ const allDone = tasks.every((t) => t.status === "Done");
17747
+ if (allDone) {
17748
+ allDoneOrphans.push(`${orphanBranch} (${taskIds.join(", ")})`);
17749
+ }
17750
+ }
17751
+ if (allDoneOrphans.length > 0) {
17752
+ warnings.push(
17753
+ `\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("; ")}`
17754
+ );
17755
+ }
17756
+ } catch (err) {
17757
+ warnings.push(`Orphan-branch detection failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
17121
17758
  }
17122
17759
  }
17123
17760
  const checkout = checkoutBranch(config2.projectRoot, branch);
@@ -17148,6 +17785,21 @@ To override, pass force=true (emits a telemetry warning).`
17148
17785
  }
17149
17786
  }
17150
17787
  }
17788
+ if (adapter2 && resolvedCycleNum > 0) {
17789
+ try {
17790
+ const stampedAt = (/* @__PURE__ */ new Date()).toISOString();
17791
+ const doneCycleTasks = (await adapter2.queryBoard()).filter(
17792
+ (t) => t.cycle === resolvedCycleNum && t.status === "Done"
17793
+ );
17794
+ for (const t of doneCycleTasks) {
17795
+ try {
17796
+ await adapter2.updateTask(t.id, { mergedAt: stampedAt });
17797
+ } catch {
17798
+ }
17799
+ }
17800
+ } catch {
17801
+ }
17802
+ }
17151
17803
  const latestTag = getLatestTag(config2.projectRoot);
17152
17804
  const changelogPath = join7(config2.projectRoot, "CHANGELOG.md");
17153
17805
  if (!latestTag) {
@@ -17207,60 +17859,6 @@ To override, pass force=true (emits a telemetry warning).`
17207
17859
  // src/tools/release.ts
17208
17860
  init_git();
17209
17861
 
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
17862
  // src/lib/registry-updates.ts
17265
17863
  import { execFile } from "child_process";
17266
17864
  import { promisify } from "util";
@@ -17371,21 +17969,32 @@ function parseGithubOwner(input) {
17371
17969
  function sanitiseBranchSuffix(branch) {
17372
17970
  return branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
17373
17971
  }
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
- }
17972
+ var CYCLE_UPDATES_CHANNEL_ENV = "DISCORD_CYCLE_UPDATES_CHANNEL_ID";
17973
+ function buildCycleUpdateCurationDirective(version, cycleClosed) {
17974
+ const channelId = process.env[CYCLE_UPDATES_CHANNEL_ENV]?.trim();
17975
+ if (!channelId) return null;
17976
+ const cyclePart = cycleClosed > 0 ? `Cycle ${cycleClosed}` : "New release";
17977
+ return [
17978
+ "",
17979
+ "---",
17980
+ "## \u{1F4E3} Post a curated cycle update to Discord #papi-cycle-updates",
17981
+ "",
17982
+ `**${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\`.`,
17983
+ "",
17984
+ "**LOCKED TEMPLATE \u2014 every cycle update uses this exact shape so they stay consistent (do NOT improvise the structure):**",
17985
+ `- \`title\`: \`${cyclePart} shipped \u2014 ${version}\``,
17986
+ "- `url`: `https://getpapi.ai/changelog` (makes the title link to the changelog)",
17987
+ "- `color`: `#5a4b8a` (Papi plum)",
17988
+ "- `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.",
17989
+ `- \`footer\`: \`Built with PAPI \xB7 <N> tasks \xB7 getpapi.ai\` where <N> = the count of THIS cycle's completed tasks (${cyclePart}).`,
17990
+ "",
17991
+ "Then:",
17992
+ "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.)",
17993
+ "2. Show the draft embed for a quick approval BEFORE posting.",
17994
+ `3. On approval, send the embed to channel \`${channelId}\`.`,
17995
+ "",
17996
+ "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."
17997
+ ].join("\n");
17389
17998
  }
17390
17999
  async function postReleaseToX(version, cycleClosed) {
17391
18000
  const url = process.env["X_RELEASE_WEBHOOK_URL"]?.trim();
@@ -17469,14 +18078,48 @@ async function handleRelease(adapter2, config2, args) {
17469
18078
  version = `${version}-${suffix}`;
17470
18079
  }
17471
18080
  }
17472
- if (isHostedTransport()) {
17473
- const tagAnnotation = `Release ${version}`;
17474
- return errorResponse(
17475
- `Release is not available on the hosted remote MCP transport.
18081
+ const tracker = new ProgressTracker("validate-args");
18082
+ try {
18083
+ tracker.mark("owner-identity-guard");
18084
+ const gate = await resolveOwnerGate(adapter2, config2);
18085
+ const productionBaseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
18086
+ if (branch === productionBaseBranch) {
18087
+ if (gate.enforced && !gate.callerIsOwner) {
18088
+ const resolutionNote = gate.resolutionError ? `
18089
+
18090
+ Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.` : "";
18091
+ return errorResponse(
18092
+ `Release to ${productionBaseBranch} is restricted to the project owner.
18093
+
18094
+ 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
18095
+ );
18096
+ }
18097
+ }
18098
+ if (isHostedTransport()) {
18099
+ tracker.mark("hosted-close-cycle");
18100
+ let closed;
18101
+ try {
18102
+ closed = await closeCycleState(config2, adapter2, version, void 0, {
18103
+ force: force ?? false,
18104
+ callerUserId: gate.callerUserId
18105
+ });
18106
+ } catch (err) {
18107
+ const message = err instanceof Error ? err.message : String(err);
18108
+ return errorResponse(message);
18109
+ }
18110
+ const tagAnnotation = `Release ${version}`;
18111
+ const cyclePart = closed.resolvedCycleNum > 0 ? `Cycle ${closed.resolvedCycleNum}` : "The cycle";
18112
+ const warningsBlock = closed.warnings.length > 0 ? `
18113
+ \u26A0\uFE0F Warnings: ${closed.warnings.join("; ")}
18114
+ ` : "";
18115
+ return textResponse(
18116
+ `## Release ${version} \u2014 cycle closed in the DB
17476
18117
 
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.
18118
+ ${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
18119
+ ` + warningsBlock + `
18120
+ 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
18121
 
17479
- To close this cycle locally, on your own machine:
18122
+ Finish the release locally:
17480
18123
  \`\`\`
17481
18124
  git checkout ${branch}
17482
18125
  git pull
@@ -17490,11 +18133,9 @@ If git reports "Author identity unknown" or "Committer identity unknown" (no glo
17490
18133
  git -c user.name="Your Name" -c user.email="you@example.com" tag -a ${version} -m "${tagAnnotation}"
17491
18134
  \`\`\`
17492
18135
 
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 {
18136
+ Next: cycle closed! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`
18137
+ );
18138
+ }
17498
18139
  tracker.mark("remote-project-guard");
17499
18140
  if (adapter2.getProjectInfo) {
17500
18141
  try {
@@ -17516,21 +18157,6 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
17516
18157
  console.error("[release] remote-project guard: skipped due to error \u2014 " + (err instanceof Error ? err.message : String(err)));
17517
18158
  }
17518
18159
  }
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
18160
  tracker.mark("preflight-grouped-branches");
17535
18161
  const cycleMatch = version.match(/^v0\.(\d+)\./);
17536
18162
  const cycleNumForPreflight = cycleMatch ? parseInt(cycleMatch[1], 10) : void 0;
@@ -17542,7 +18168,11 @@ Your identity does not match this project's owner. If you are a contributor, pus
17542
18168
  }
17543
18169
  }
17544
18170
  tracker.mark("create-release");
17545
- const result = await createRelease(config2, branch, version, adapter2, void 0, { force: force ?? false, skipVersion: skipVersion ?? false });
18171
+ const result = await createRelease(config2, branch, version, adapter2, void 0, {
18172
+ force: force ?? false,
18173
+ skipVersion: skipVersion ?? false,
18174
+ callerUserId: gate.callerUserId
18175
+ });
17546
18176
  const lines = [
17547
18177
  `## Release ${result.version}${skipVersion ? " (skip version)" : ""}`,
17548
18178
  "",
@@ -17594,11 +18224,8 @@ Your identity does not match this project's owner. If you are a contributor, pus
17594
18224
  lines.push("", "\u26A0\uFE0F Dogfood observations could not be saved to DB \u2014 log them manually in DOGFOOD_LOG.md.");
17595
18225
  }
17596
18226
  }
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
- }
18227
+ const cycleUpdateDirective = buildCycleUpdateCurationDirective(result.version, result.cycleClosed ?? 0);
18228
+ if (cycleUpdateDirective) lines.push(cycleUpdateDirective);
17602
18229
  try {
17603
18230
  const xPosted = await postReleaseToX(result.version, result.cycleClosed ?? 0);
17604
18231
  if (xPosted) lines.push("", "Posted release announcement to X.");
@@ -17618,7 +18245,7 @@ Your identity does not match this project's owner. If you are a contributor, pus
17618
18245
  }
17619
18246
  } catch {
17620
18247
  }
17621
- lines.push("", `Next: cycle released! Run \`plan\` to start your next planning cycle.`);
18248
+ lines.push("", `Next: cycle released! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`);
17622
18249
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
17623
18250
  return textResponse(lines.join("\n") + filesToWriteSection);
17624
18251
  } catch (err) {
@@ -17746,6 +18373,16 @@ function selectAutostashPaths(modified, untracked) {
17746
18373
  const stashableUntracked = untracked.filter((p) => !isDocsPath(p));
17747
18374
  return { toStash: [...trackedDirty, ...stashableUntracked], preservedDocs };
17748
18375
  }
18376
+ function resolveRelatedDecisions(provided, buildHandoff) {
18377
+ const fromInput = (provided ?? "").split(",").map((s) => s.trim()).filter(Boolean);
18378
+ if (fromInput.length > 0) return { adIds: Array.from(new Set(fromInput)), inferred: false };
18379
+ if (buildHandoff) {
18380
+ const text = typeof buildHandoff === "string" ? buildHandoff : JSON.stringify(buildHandoff);
18381
+ const matches = text.match(/\bAD-\d+\b/g);
18382
+ if (matches && matches.length > 0) return { adIds: Array.from(new Set(matches)), inferred: true };
18383
+ }
18384
+ return { adIds: [], inferred: false };
18385
+ }
17749
18386
  var buildStartTimes = /* @__PURE__ */ new Map();
17750
18387
  var taskBranchMap = /* @__PURE__ */ new Map();
17751
18388
  var taskStartShaMap = /* @__PURE__ */ new Map();
@@ -17761,16 +18398,36 @@ function sanitisePredictedFiles(raw) {
17761
18398
  const out = [];
17762
18399
  for (const entry of raw) {
17763
18400
  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());
18401
+ for (const segment of entry.split(";")) {
18402
+ const altMatches = segment.match(/\(\s*or\s+`([^`]+)`\s*\)/gi) ?? [];
18403
+ for (const alt of altMatches) {
18404
+ const inner = alt.match(/`([^`]+)`/);
18405
+ if (inner) out.push(inner[1].trim());
18406
+ }
18407
+ const cleaned = segment.replace(/`/g, "").replace(/\s*\(.*$/, "").trim();
18408
+ if (cleaned) out.push(cleaned);
17768
18409
  }
17769
- const cleaned = entry.replace(/`/g, "").replace(/\s*\(.*$/, "").trim();
17770
- if (cleaned) out.push(cleaned);
17771
18410
  }
17772
18411
  return Array.from(new Set(out));
17773
18412
  }
18413
+ function pathBasename(p) {
18414
+ const parts = p.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
18415
+ return parts[parts.length - 1] ?? p;
18416
+ }
18417
+ function isPathInPredictedScope(changedPath, predicted) {
18418
+ const changedLower = changedPath.replace(/\\/g, "/").toLowerCase();
18419
+ const changedBase = pathBasename(changedPath);
18420
+ for (const raw of predicted) {
18421
+ const entry = raw.replace(/\\/g, "/").replace(/\/+$/, "").trim();
18422
+ if (!entry) continue;
18423
+ if (pathBasename(entry) === changedBase) return true;
18424
+ const entryLower = entry.toLowerCase();
18425
+ if (changedLower === entryLower || changedLower.startsWith(`${entryLower}/`)) {
18426
+ return true;
18427
+ }
18428
+ }
18429
+ return false;
18430
+ }
17774
18431
  function autoCommit(config2, taskId, taskTitle, predictedFiles) {
17775
18432
  const cwd = config2.projectRoot;
17776
18433
  const message = `feat(${taskId}): ${taskTitle}`;
@@ -17800,17 +18457,12 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
17800
18457
  if (modified.length === 0) {
17801
18458
  return "Auto-commit: skipped (no working-tree changes).";
17802
18459
  }
17803
- const basename2 = (p) => {
17804
- const parts = p.split(/[\\/]/);
17805
- return parts[parts.length - 1] ?? p;
17806
- };
17807
18460
  const dirname6 = (p) => {
17808
18461
  const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
17809
18462
  return idx > 0 ? p.slice(0, idx) : "";
17810
18463
  };
17811
18464
  const cleanedPredicted = sanitisePredictedFiles(predictedFiles);
17812
- const predictedNames = new Set(cleanedPredicted.map(basename2).filter(Boolean));
17813
- const scoped = modified.filter((p) => predictedNames.has(basename2(p)));
18465
+ const scoped = modified.filter((p) => isPathInPredictedScope(p, cleanedPredicted));
17814
18466
  if (scoped.length === 0) {
17815
18467
  const modSample = modified.slice(0, 5).join(", ");
17816
18468
  const predSample = cleanedPredicted.slice(0, 5).join(", ");
@@ -17938,12 +18590,8 @@ function computeScopeDriftSignal(predicted, changed) {
17938
18590
  if (!predicted || predicted.length === 0) return null;
17939
18591
  const filtered = changed.filter((c) => !isAmbientPath(c));
17940
18592
  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)));
18593
+ const cleanedPredicted = sanitisePredictedFiles(predicted);
18594
+ const unexpected = filtered.filter((c) => !isPathInPredictedScope(c, cleanedPredicted));
17947
18595
  const fraction = unexpected.length / filtered.length;
17948
18596
  const triggered = fraction > 0.5 || unexpected.length > 5;
17949
18597
  if (!triggered) return null;
@@ -18020,6 +18668,14 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
18020
18668
  if (!task) {
18021
18669
  throw new Error(`Task "${taskId}" not found on the Cycle Board.`);
18022
18670
  }
18671
+ if (task.assigneeId) {
18672
+ const gate = await resolveOwnerGate(adapter2, config2);
18673
+ if (!gate.callerUserId || gate.callerUserId !== task.assigneeId) {
18674
+ throw new Error(
18675
+ `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.`
18676
+ );
18677
+ }
18678
+ }
18023
18679
  if (task.status === "Done" || task.status === "Archived") {
18024
18680
  throw new Error(`Task "${taskId}" (${task.title}) is already ${task.status}. Cannot execute a completed task.`);
18025
18681
  }
@@ -18361,10 +19017,14 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
18361
19017
  };
18362
19018
  buildStartTimes.delete(taskId);
18363
19019
  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
- }
19020
+ const { adIds: relatedAdIds, inferred: relatedInferred } = resolveRelatedDecisions(
19021
+ input.relatedDecisions,
19022
+ task.buildHandoff
19023
+ );
19024
+ if (relatedAdIds.length > 0) report.relatedDecisions = relatedAdIds;
19025
+ console.error(
19026
+ `[build] task ${taskId} related_decisions: ${relatedAdIds.length} AD(s) (${relatedAdIds.length === 0 ? "none" : relatedInferred ? "inferred from handoff" : "builder-provided"}).`
19027
+ );
18368
19028
  if (report.startedAt && report.completedAt && typeof adapter2.getToolCallCount === "function") {
18369
19029
  try {
18370
19030
  const count = await adapter2.getToolCallCount(report.startedAt, report.completedAt);
@@ -18863,7 +19523,7 @@ var buildExecuteTool = {
18863
19523
  },
18864
19524
  related_decisions: {
18865
19525
  type: "string",
18866
- description: 'Comma-separated AD IDs that this build validated or challenged (e.g. "AD-5,AD-12"). Optional.'
19526
+ 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
19527
  },
18868
19528
  handoff_accuracy: {
18869
19529
  type: "object",
@@ -19592,7 +20252,7 @@ function collectDiagnostics(config2) {
19592
20252
  }
19593
20253
  var bugTool = {
19594
20254
  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.",
20255
+ description: "Report a bug OR submit an idea to PAPI. Two modes: (1) Default \u2014 creates a Backlog task with severity-based priority for the project board. (2) With report=true \u2014 submits a bug or idea (set `type`) upstream to PAPI maintainers with diagnostics, optionally recording notify-when-fixed and contact-ok consent. Use report=true at a friction moment \u2014 e.g. when a PAPI tool returns a workflow-blocking error. Does not call the Anthropic API.",
19596
20256
  annotations: { readOnlyHint: false, destructiveHint: false },
19597
20257
  inputSchema: {
19598
20258
  type: "object",
@@ -19626,6 +20286,19 @@ var bugTool = {
19626
20286
  type: "boolean",
19627
20287
  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."
19628
20288
  },
20289
+ type: {
20290
+ type: "string",
20291
+ enum: ["bug", "idea"],
20292
+ description: 'Report mode only: submission kind \u2014 "bug" (default) or "idea" (feature request / suggestion). Both route to the same upstream PAPI triage.'
20293
+ },
20294
+ notify_when_fixed: {
20295
+ type: "boolean",
20296
+ 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)."
20297
+ },
20298
+ contact_ok: {
20299
+ type: "boolean",
20300
+ description: "Report mode only: set true if the user consents to the PAPI team reaching out about this submission."
20301
+ },
19629
20302
  project: {
19630
20303
  type: "string",
19631
20304
  description: "Project id (UUID) or slug to file this bug under, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. Use project_switch to change the session default."
@@ -19643,6 +20316,9 @@ async function handleBug(adapter2, config2, args) {
19643
20316
  if (!adapter2.submitBugReport) {
19644
20317
  return errorResponse("Bug reports require a database adapter (pg or proxy). The md adapter does not support bug reports.");
19645
20318
  }
20319
+ const type = args.type === "idea" ? "idea" : "bug";
20320
+ const notifyRequested = args.notify_when_fixed === true;
20321
+ const contactOk = args.contact_ok === true;
19646
20322
  const diagnostics = collectDiagnostics(config2);
19647
20323
  const notes = args.notes?.trim();
19648
20324
  if (notes) {
@@ -19658,17 +20334,27 @@ async function handleBug(adapter2, config2, args) {
19658
20334
  }
19659
20335
  const report = await adapter2.submitBugReport({
19660
20336
  projectId,
20337
+ type,
19661
20338
  description: text,
19662
20339
  diagnostics,
19663
- status: "open"
20340
+ status: "open",
20341
+ notifyRequested,
20342
+ contactOk
19664
20343
  });
20344
+ const kindLabel = type === "idea" ? "Idea" : "Bug report";
20345
+ const followUps = [];
20346
+ if (notifyRequested) followUps.push("you'll be notified when it's resolved");
20347
+ if (contactOk) followUps.push("the PAPI team may reach out");
20348
+ const followUpLine = followUps.length ? `
20349
+
20350
+ Noted: ${followUps.join("; ")}.` : "";
19665
20351
  return textResponse(
19666
- `**Bug report submitted** \u2014 ID: \`${report.id}\`
20352
+ `**${kindLabel} submitted** \u2014 ID: \`${report.id}\`
19667
20353
 
19668
- Description: ${text}
20354
+ ${type === "idea" ? "Idea" : "Description"}: ${text}
19669
20355
  Diagnostics collected: Node ${diagnostics.nodeVersion}, ${diagnostics.platform}/${diagnostics.arch}, adapter=${diagnostics.adapterType}
19670
20356
 
19671
- This report is visible to PAPI maintainers. No further action needed from you.`
20357
+ This ${type} is visible to PAPI maintainers.${followUpLine}`
19672
20358
  );
19673
20359
  }
19674
20360
  let target = adapter2;
@@ -20045,7 +20731,7 @@ async function prepareReconcile(adapter2, projectRoot) {
20045
20731
  const misaligned = allTasks.filter((t) => {
20046
20732
  const mapping = phaseStageMap.get(t.phase ?? "");
20047
20733
  if (!mapping) return false;
20048
- return mapping.stage.status === "deferred" || mapping.horizon.status === "deferred";
20734
+ return mapping.stage.status === "Deferred" || mapping.horizon.status === "Deferred";
20049
20735
  });
20050
20736
  if (misaligned.length > 0) {
20051
20737
  lines.push("### Hierarchy Misalignment (tasks in deferred stages/horizons)");
@@ -20643,108 +21329,29 @@ Re-run build_execute complete with a production_verification field, then re-subm
20643
21329
  const handoffRegenerated = false;
20644
21330
  let handoffRegenPrompt;
20645
21331
  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
- );
21332
+ handoffRegenPrompt = await prepareHandoffRegen(task, input.comments, adapter2);
20741
21333
  }
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
- );
21334
+ const stageLabel = input.stage === "handoff-review" ? "Handoff Review" : "Build Acceptance";
21335
+ let phaseChanges = [];
21336
+ if (newStatus) {
21337
+ try {
21338
+ phaseChanges = await propagatePhaseStatus(adapter2);
21339
+ } catch {
21340
+ }
20746
21341
  }
20747
- return signals.slice(0, 3);
21342
+ return {
21343
+ stageLabel,
21344
+ taskId: input.taskId,
21345
+ verdict: input.verdict,
21346
+ comments: input.comments.trim(),
21347
+ newStatus,
21348
+ unblockedTasks,
21349
+ handoffRegenerated,
21350
+ handoffRegenPrompt,
21351
+ currentCycle: cycle,
21352
+ phaseChanges,
21353
+ closedDocActions
21354
+ };
20748
21355
  }
20749
21356
 
20750
21357
  // src/tools/review.ts
@@ -20893,6 +21500,11 @@ function formatReviewList(pendingBuilds) {
20893
21500
  for (const t of pendingBuilds) {
20894
21501
  lines.push(`- **${t.id}:** ${t.title}`);
20895
21502
  lines.push(` Status: ${t.status} | Priority: ${t.priority} | Complexity: ${t.complexity}`);
21503
+ if (t.assigneeId || t.reviewerId) {
21504
+ const built = t.assigneeId ? `built by ${t.assigneeId}` : "unattributed build";
21505
+ const review = t.reviewerId ? `claimed by reviewer ${t.reviewerId}` : "unclaimed \u2014 `review_claim` to take it";
21506
+ lines.push(` ${built} \xB7 ${review}`);
21507
+ }
20896
21508
  }
20897
21509
  lines.push("");
20898
21510
  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 +21558,10 @@ function mergeAfterAccept(config2, taskId) {
20946
21558
  }
20947
21559
  const merge = mergePullRequest(config2.projectRoot, featureBranch);
20948
21560
  if (!merge.success) {
21561
+ if (isBranchMergedInto(config2.projectRoot, featureBranch, baseBranch)) {
21562
+ details.push(`Branch '${featureBranch}' is already merged into '${baseBranch}'.`);
21563
+ return { merged: true, skipped: false, message: `Already merged: \`${featureBranch}\` \u2192 \`${baseBranch}\`.`, details };
21564
+ }
20949
21565
  return { merged: false, skipped: false, message: `PR merge failed: ${merge.message}`, details };
20950
21566
  }
20951
21567
  details.push(merge.message);
@@ -21070,12 +21686,28 @@ async function handleReviewSubmit(adapter2, config2, args) {
21070
21686
  }
21071
21687
  if (stage === "build-acceptance" && verdict === "accept") {
21072
21688
  const reviewerConfirmed = args.reviewer_confirmed === true;
21073
- if (!reviewerConfirmed && !wasReviewListSeenRecently()) {
21689
+ if (!reviewerConfirmed && !wasReviewListSeenRecently(void 0, callerKeyFromConfig(config2))) {
21074
21690
  return errorResponse(
21075
21691
  "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
21692
  );
21077
21693
  }
21078
21694
  }
21695
+ if (stage === "build-acceptance") {
21696
+ const gate = await resolveOwnerGate(adapter2, config2);
21697
+ const caller = gate.callerUserId;
21698
+ const reviewTask = await adapter2.getTask(taskId);
21699
+ if (reviewTask?.reviewerId && caller && reviewTask.reviewerId !== caller && !gate.callerIsOwner) {
21700
+ return errorResponse(
21701
+ `Task "${taskId}" is claimed for review by another member (${reviewTask.reviewerId}). Only the claiming reviewer (or the project owner) can submit its verdict.`
21702
+ );
21703
+ }
21704
+ if (reviewTask && !reviewTask.reviewerId && caller) {
21705
+ try {
21706
+ await adapter2.updateTask(taskId, { reviewerId: caller });
21707
+ } catch {
21708
+ }
21709
+ }
21710
+ }
21079
21711
  const tracker = new ProgressTracker("validate-args");
21080
21712
  const handoffRegenResponse = args.handoff_regen_response;
21081
21713
  if (handoffRegenResponse?.trim()) {
@@ -21123,6 +21755,7 @@ ${result.handoffRegenPrompt.userMessage}
21123
21755
  }
21124
21756
  let mergeNote = "";
21125
21757
  let overlapNote = "";
21758
+ let mergeFailed = false;
21126
21759
  if (stage === "build-acceptance" && verdict === "accept") {
21127
21760
  const mergeResult = mergeAfterAccept(config2, taskId);
21128
21761
  if (mergeResult.skipped) {
@@ -21132,14 +21765,24 @@ ${result.handoffRegenPrompt.userMessage}
21132
21765
  > ${mergeResult.message}`;
21133
21766
  }
21134
21767
  } else if (!mergeResult.merged) {
21768
+ mergeFailed = true;
21769
+ let revertNote = "";
21770
+ try {
21771
+ await adapter2.updateTask(taskId, { status: "In Review" });
21772
+ revertNote = ` Task rolled back to **In Review** \u2014 re-run \`review_submit ${taskId} build-acceptance accept\` once the merge succeeds.`;
21773
+ } catch (err) {
21774
+ 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.`;
21775
+ }
21135
21776
  mergeNote = `
21136
21777
 
21137
- \u26A0\uFE0F **PR merge failed** \u2014 task is Done but the branch was NOT merged.
21138
-
21139
- ${mergeResult.message}
21778
+ \u26A0\uFE0F **PR merge failed \u2014 task NOT marked Done.**${revertNote}
21140
21779
 
21141
- Resolve this before running \`release\`.`;
21780
+ ${mergeResult.message}`;
21142
21781
  } else {
21782
+ try {
21783
+ await adapter2.updateTask(taskId, { mergedAt: (/* @__PURE__ */ new Date()).toISOString() });
21784
+ } catch {
21785
+ }
21143
21786
  const detailLines = mergeResult.details.map((l) => `> ${l}`).join("\n");
21144
21787
  mergeNote = `
21145
21788
 
@@ -21159,7 +21802,7 @@ ${overlap}`;
21159
21802
  }
21160
21803
  let autoReleaseNote = "";
21161
21804
  let batchSummaryNote = "";
21162
- if (stage === "build-acceptance" && verdict === "accept" && result.newStatus === "Done" && result.currentCycle > 0) {
21805
+ if (stage === "build-acceptance" && verdict === "accept" && result.newStatus === "Done" && result.currentCycle > 0 && !mergeFailed) {
21163
21806
  try {
21164
21807
  const allTasks = await adapter2.queryBoard();
21165
21808
  const cycleTasks = allTasks.filter((t) => t.cycle === result.currentCycle);
@@ -21252,9 +21895,28 @@ Resolve the issue above, then run \`release\` manually.`;
21252
21895
  }
21253
21896
  }
21254
21897
  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 ? `
21898
+ let autoReviewNote = "";
21899
+ if (autoReview) {
21900
+ const findingLines = autoReview.findings.slice(0, 10).map((f) => {
21901
+ const loc = f.file ? ` (${f.file}${f.line ? `:${f.line}` : ""})` : "";
21902
+ return ` - [${f.severity}]${loc} ${f.message}`;
21903
+ }).join("\n");
21904
+ const more = autoReview.findings.length > 10 ? `
21905
+ \u2026and ${autoReview.findings.length - 10} more` : "";
21906
+ autoReviewNote = `
21907
+
21908
+ **Quality Gate (auto-review): ${autoReview.verdict}** \u2014 ${autoReview.summary}` + (autoReview.findings.length > 0 ? `
21909
+ ${findingLines}${more}` : "");
21910
+ if (stage === "build-acceptance" && verdict === "accept" && autoReview.verdict !== "pass") {
21911
+ autoReviewNote += `
21256
21912
 
21257
- **Auto-Review:** ${autoReview.verdict} \u2014 ${autoReview.summary}` + (autoReview.findings.length > 0 ? ` (${autoReview.findings.length} finding${autoReview.findings.length === 1 ? "" : "s"})` : "") : "";
21913
+ \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.`;
21914
+ }
21915
+ } else if (stage === "build-acceptance" && verdict === "accept") {
21916
+ autoReviewNote = `
21917
+
21918
+ **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.`;
21919
+ }
21258
21920
  let nextStepNote = "";
21259
21921
  if (!autoReleaseNote && stage === "build-acceptance") {
21260
21922
  if (verdict === "accept") {
@@ -21290,6 +21952,66 @@ ${statusNote}${autoReviewNote}${unblockNote}${docClosureNote}${regenNote}${merge
21290
21952
  }));
21291
21953
  }
21292
21954
  }
21955
+ var reviewClaimTool = {
21956
+ name: "review_claim",
21957
+ 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.",
21958
+ annotations: { readOnlyHint: false, destructiveHint: false },
21959
+ inputSchema: {
21960
+ type: "object",
21961
+ properties: {
21962
+ task_id: { type: "string", description: 'The In Review task to claim for review, e.g. "task-2072".' }
21963
+ },
21964
+ required: ["task_id"]
21965
+ }
21966
+ };
21967
+ async function resolveReviewerIdentity(adapter2, config2) {
21968
+ const gate = await resolveOwnerGate(adapter2, config2);
21969
+ const callerUserId = gate.callerUserId;
21970
+ if (!callerUserId) {
21971
+ const note = gate.resolutionError ? ` (${gate.resolutionError})` : "";
21972
+ 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." };
21973
+ }
21974
+ if (!gate.callerIsOwner && typeof adapter2.listContributors === "function") {
21975
+ try {
21976
+ const members = await adapter2.listContributors();
21977
+ if (!members.some((m) => m.userId === callerUserId)) {
21978
+ return { error: "Only the project owner or an active contributor can claim reviews on this project." };
21979
+ }
21980
+ } catch (err) {
21981
+ return { error: `Could not verify membership: ${err instanceof Error ? err.message : String(err)}` };
21982
+ }
21983
+ }
21984
+ return { callerUserId, callerIsOwner: gate.callerIsOwner };
21985
+ }
21986
+ async function handleReviewClaim(adapter2, config2, args) {
21987
+ const taskId = typeof args.task_id === "string" ? args.task_id.trim() : "";
21988
+ if (!taskId) return errorResponse('A task_id is required. Example: review_claim task_id="task-2072"');
21989
+ if (typeof adapter2.claimReview !== "function") {
21990
+ return errorResponse("The shared review queue is not available on this adapter.");
21991
+ }
21992
+ const identity = await resolveReviewerIdentity(adapter2, config2);
21993
+ if ("error" in identity) return errorResponse(identity.error);
21994
+ const me = identity.callerUserId;
21995
+ const claimed = await adapter2.claimReview(taskId, me);
21996
+ if (!claimed) {
21997
+ const task = await adapter2.getTask(taskId);
21998
+ if (!task) return errorResponse(`Task "${taskId}" not found on the board.`);
21999
+ if (task.status !== "In Review") {
22000
+ return errorResponse(`Task "${taskId}" is ${task.status}, not In Review \u2014 there is no pending review to claim.`);
22001
+ }
22002
+ if (task.reviewerId && task.reviewerId !== me) {
22003
+ return errorResponse(`Task "${taskId}" is already being reviewed by another member (${task.reviewerId}).`);
22004
+ }
22005
+ if (task.reviewerId === me) {
22006
+ return textResponse(`You have already claimed the review for **${taskId}** \u2014 run \`review_submit\` to record your verdict.`);
22007
+ }
22008
+ return errorResponse(`Could not claim the review for "${taskId}".`);
22009
+ }
22010
+ const builtBy = claimed.assigneeId ? ` (built by ${claimed.assigneeId})` : "";
22011
+ return textResponse(
22012
+ `\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.`
22013
+ );
22014
+ }
21293
22015
 
21294
22016
  // src/tools/init.ts
21295
22017
  import { randomUUID as randomUUID15 } from "crypto";
@@ -22023,7 +22745,7 @@ ${lines.join("\n")}`;
22023
22745
 
22024
22746
  // src/lib/version-handshake.ts
22025
22747
  var MIN_PROXY_VERSION = "1.0.0";
22026
- var SERVER_VERSION = "0.7.15";
22748
+ var SERVER_VERSION = "0.7.35";
22027
22749
  function meetsMinVersion(actual, required) {
22028
22750
  const actualParts = actual.split(".").map((n) => parseInt(n, 10));
22029
22751
  const requiredParts = required.split(".").map((n) => parseInt(n, 10));
@@ -22405,13 +23127,29 @@ function normalizeDocPath(rawPath, projectRoot) {
22405
23127
  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
23128
  };
22407
23129
  }
23130
+ function docRegisterSoftFail(adapterType, lastStep, error, hint) {
23131
+ const payload = { tool: "doc_register", adapter: adapterType, lastStep, error, hint };
23132
+ return textResponse(
23133
+ `\u26A0\uFE0F doc_register skipped (non-blocking) at step "${lastStep}" \u2014 your build/plan/review flow can continue safely; document registration is advisory.
23134
+
23135
+ Diagnostic JSON:
23136
+ ${JSON.stringify(payload, null, 2)}`
23137
+ );
23138
+ }
22408
23139
  async function handleDocRegister(adapter2, args, config2) {
23140
+ const adapterType = config2?.adapterType ?? "unknown";
23141
+ 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
23142
  if (!adapter2.registerDoc) {
22410
- return errorResponse("Doc registry not available \u2014 requires pg adapter.");
23143
+ return docRegisterSoftFail(
23144
+ adapterType,
23145
+ "adapter-capability-check",
23146
+ "Doc registry not available \u2014 requires the pg/proxy adapter.",
23147
+ "On the md adapter doc_register is a no-op. Continue without it \u2014 nothing is blocked."
23148
+ );
22411
23149
  }
22412
23150
  const normalized = normalizeDocPath(args.path, config2?.projectRoot);
22413
23151
  if ("error" in normalized) {
22414
- return errorResponse(normalized.error);
23152
+ return docRegisterSoftFail(adapterType, "path-normalization", normalized.error, continueHint);
22415
23153
  }
22416
23154
  const path7 = normalized.path;
22417
23155
  const title = args.title;
@@ -22424,39 +23162,49 @@ async function handleDocRegister(adapter2, args, config2) {
22424
23162
  const supersededByPath = args.superseded_by_path;
22425
23163
  const visibility = resolveDocVisibility(args.visibility, path7);
22426
23164
  if (!path7 || !title || !type || !summary || !cycle) {
22427
- return errorResponse("Required fields: path, title, type, summary, cycle.");
23165
+ return docRegisterSoftFail(
23166
+ adapterType,
23167
+ "field-validation",
23168
+ "Required fields: path, title, type, summary, cycle.",
23169
+ continueHint
23170
+ );
22428
23171
  }
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);
23172
+ try {
23173
+ let supersededBy;
23174
+ if (supersededByPath) {
23175
+ const existing = await adapter2.getDoc?.(supersededByPath);
23176
+ if (existing) {
23177
+ supersededBy = existing.id;
23178
+ await adapter2.updateDocStatus?.(existing.id, "superseded", void 0);
23179
+ }
22435
23180
  }
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}
23181
+ const entry = await adapter2.registerDoc({
23182
+ title,
23183
+ type,
23184
+ path: path7,
23185
+ status,
23186
+ summary,
23187
+ tags,
23188
+ cycleCreated: cycle,
23189
+ cycleUpdated: cycle,
23190
+ supersededBy,
23191
+ actions,
23192
+ visibility
23193
+ });
23194
+ const visibilityLabel = entry.visibility === "contributors" ? "team member" : entry.visibility ?? "private";
23195
+ return textResponse(
23196
+ `**Registered:** ${entry.title}
22453
23197
  - **Path:** ${entry.path}
22454
23198
  - **Type:** ${entry.type} | **Status:** ${entry.status}
22455
23199
  - **Visibility:** ${visibilityLabel}
22456
23200
  - **Tags:** ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}
22457
23201
  - **Actions:** ${actions?.length ?? 0} items
22458
23202
  - **ID:** ${entry.id}`
22459
- );
23203
+ );
23204
+ } catch (err) {
23205
+ const message = err instanceof Error ? err.message : String(err);
23206
+ return docRegisterSoftFail(adapterType, "registry-write", message, continueHint);
23207
+ }
22460
23208
  }
22461
23209
  async function handleDocSearch(adapter2, args, config2) {
22462
23210
  if (!adapter2.searchDocs) {
@@ -22807,6 +23555,10 @@ var orientTool = {
22807
23555
  deep_housekeeping: {
22808
23556
  type: "boolean",
22809
23557
  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."
23558
+ },
23559
+ full: {
23560
+ type: "boolean",
23561
+ 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
23562
  }
22811
23563
  },
22812
23564
  required: []
@@ -22837,7 +23589,7 @@ function formatSynthesisParagraph(opts) {
22837
23589
  parts.push(`Next: ${cleanMode}`);
22838
23590
  return parts.join(" ");
22839
23591
  }
22840
- function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoot, environment = "unknown", subAgents = [], projectName) {
23592
+ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoot, environment = "unknown", subAgents = [], projectName, teamSummary) {
22841
23593
  const lines = [];
22842
23594
  const cycleIsComplete = health.latestCycleStatus === "complete";
22843
23595
  const tagSuffix = latestTag ? ` \u2014 ${latestTag}` : "";
@@ -22904,6 +23656,10 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
22904
23656
  lines.push("## Board");
22905
23657
  lines.push(health.boardSummary);
22906
23658
  lines.push("");
23659
+ if (teamSummary) {
23660
+ lines.push(teamSummary);
23661
+ lines.push("");
23662
+ }
22907
23663
  if (subAgents.length > 0) {
22908
23664
  lines.push(`**Sub-agents (${subAgents.length}):** ` + subAgents.map((a) => {
22909
23665
  const desc = a.description ? ` (${a.description.slice(0, 60)})` : "";
@@ -23108,9 +23864,56 @@ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
23108
23864
  }
23109
23865
  return { alertsNote, unactionedIssuesNote };
23110
23866
  }
23867
+ async function computeTeamSummary(adapter2) {
23868
+ if (typeof adapter2.listContributors !== "function") return void 0;
23869
+ let members;
23870
+ try {
23871
+ members = (await adapter2.listContributors()).length;
23872
+ } catch {
23873
+ return void 0;
23874
+ }
23875
+ if (members <= 1) return void 0;
23876
+ let tasks;
23877
+ try {
23878
+ tasks = await adapter2.queryBoard({ compact: true });
23879
+ } catch {
23880
+ return void 0;
23881
+ }
23882
+ const isOpen = (t) => t.status !== "Done" && t.status !== "Cancelled" && t.status !== "Archived";
23883
+ const pool = tasks.filter((t) => isOpen(t) && !t.assigneeId && t.cycle == null).length;
23884
+ const inFlight = tasks.filter((t) => t.status === "In Progress").length;
23885
+ const reviewQueue = tasks.filter((t) => t.status === "In Review").length;
23886
+ return `**Team:** ${members} members \xB7 ${pool} in pool \xB7 ${inFlight} in flight \xB7 ${reviewQueue} in review`;
23887
+ }
23888
+ async function computeReleaseHistory(adapter2) {
23889
+ if (typeof adapter2.listContributors !== "function") return void 0;
23890
+ let contributors;
23891
+ try {
23892
+ contributors = await adapter2.listContributors();
23893
+ } catch {
23894
+ return void 0;
23895
+ }
23896
+ if (contributors.length <= 1) return void 0;
23897
+ let cycles;
23898
+ try {
23899
+ cycles = await adapter2.readCycles();
23900
+ } catch {
23901
+ return void 0;
23902
+ }
23903
+ const nameOf = new Map(
23904
+ contributors.map((c) => [c.userId, c.displayName || c.email || c.userId.slice(0, 8)])
23905
+ );
23906
+ const released = cycles.filter((c) => c.status === "complete").sort((a, b2) => b2.number - a.number).slice(0, 5).map((c) => {
23907
+ const who = c.userId ? nameOf.get(c.userId) ?? c.userId.slice(0, 8) : null;
23908
+ return `Cycle ${c.number}${who ? ` (${who})` : ""}`;
23909
+ });
23910
+ if (released.length === 0) return void 0;
23911
+ return `**Recent releases:** ${released.join(" \xB7 ")}`;
23912
+ }
23111
23913
  async function handleOrient(adapter2, config2, args = {}) {
23112
23914
  const environment = normaliseEnvironment(args.environment);
23113
23915
  const deepHousekeeping = args.deep_housekeeping === true;
23916
+ const fullEnrichment = args.full === true || deepHousekeeping;
23114
23917
  const tracker = new ProgressTracker("start");
23115
23918
  try {
23116
23919
  tracker.mark("propagate-phase-status");
@@ -23157,6 +23960,7 @@ async function handleOrient(adapter2, config2, args = {}) {
23157
23960
  ownerActionsOutcome,
23158
23961
  ownerActionsNudgedOutcome,
23159
23962
  ownerActionsBlockingOutcome,
23963
+ reviewChangesOutcome,
23160
23964
  ttfvOutcome,
23161
23965
  latestTagOutcome,
23162
23966
  versionDriftOutcome,
@@ -23247,6 +24051,37 @@ async function handleOrient(adapter2, config2, args = {}) {
23247
24051
  return void 0;
23248
24052
  }
23249
24053
  }),
24054
+ // task-2105 (C296): review-verdict notification. When a build was bounced
24055
+ // back (request-changes/reject → status In Progress) and not yet resubmitted,
24056
+ // surface it in orient so the builder gets a signal — submitReview itself has
24057
+ // no notification channel. Derived from existing data (no new schema): latest
24058
+ // review per task + current status. Within-project only (board visibility
24059
+ // already scopes the caller); per-assignee targeting is a future refinement.
24060
+ tracked("review-changes-requested", async () => {
24061
+ if (!adapter2.getRecentReviews || !adapter2.getTask) return void 0;
24062
+ try {
24063
+ const reviews = await adapter2.getRecentReviews(100);
24064
+ const latestByTask = /* @__PURE__ */ new Map();
24065
+ for (const r of reviews) {
24066
+ if (!latestByTask.has(r.taskId)) latestByTask.set(r.taskId, { verdict: r.verdict, comments: r.comments });
24067
+ }
24068
+ const bounced = [];
24069
+ for (const [taskId, rev] of latestByTask) {
24070
+ if (rev.verdict !== "request-changes" && rev.verdict !== "reject") continue;
24071
+ const task = await adapter2.getTask(taskId);
24072
+ if (task && task.status === "In Progress") {
24073
+ bounced.push({ id: task.displayId ?? taskId, comment: rev.comments ?? "" });
24074
+ }
24075
+ }
24076
+ if (bounced.length === 0) return void 0;
24077
+ const list = bounced.slice(0, 5).map((b2) => b2.id).join(", ");
24078
+ const c = bounced[0].comment.trim();
24079
+ const snippet = c ? ` \u2014 ${bounced[0].id}: "${c.length > 80 ? `${c.slice(0, 80)}\u2026` : c}"` : "";
24080
+ 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.`;
24081
+ } catch {
24082
+ return void 0;
24083
+ }
24084
+ }),
23250
24085
  // Time-to-first-value: setup/plan milestones (only for early projects).
23251
24086
  // PERF: hasToolMilestone is a cheap EXISTS lookup; avoids 5000-row pull.
23252
24087
  tracked("ttfv", async () => {
@@ -23265,11 +24100,16 @@ async function handleOrient(adapter2, config2, args = {}) {
23265
24100
  return `
23266
24101
  **Time to first plan:** ${deltaMinutes} minutes (setup \u2192 first plan)`;
23267
24102
  }),
23268
- // Latest git tag + npm version drift (both exec calls with own timeouts)
24103
+ // Latest git tag + npm version drift (both exec calls with own timeouts).
24104
+ // task-2172: git-tag stays (the latest tag is part of the core summary);
24105
+ // version-drift is enrichment, gated behind `full`/deep_housekeeping.
23269
24106
  tracked("git-tag", () => getLatestGitTag(config2.projectRoot)),
23270
- tracked("npm-version-drift", () => checkNpmVersionDrift()),
23271
- // Research Signals — research docs with pending actions since last strategy review
24107
+ tracked("npm-version-drift", async () => fullEnrichment ? checkNpmVersionDrift() : void 0),
24108
+ // Research Signals — research docs with pending actions since last strategy review.
24109
+ // task-2172: heavy (doc search + AD cross-reference) and rarely actioned
24110
+ // mid-session — gated behind `full`/deep_housekeeping to keep the default lean.
23272
24111
  tracked("research-signals", async () => {
24112
+ if (!fullEnrichment) return "";
23273
24113
  if (!adapter2.searchDocs) return "";
23274
24114
  const cycleHealth = await adapter2.getCycleHealth();
23275
24115
  const lastReviewCycle = Math.max(0, cycleHealth.totalCycles - cycleHealth.cyclesSinceLastStrategyReview);
@@ -23370,8 +24210,9 @@ async function handleOrient(adapter2, config2, args = {}) {
23370
24210
  }),
23371
24211
  // Session guidance — proactive nudges (doc_register, context bloat, mode switch)
23372
24212
  tracked("session-guidance", async () => {
23373
- const signals = await buildSessionGuidance();
23374
- markOrient();
24213
+ const callerKey = callerKeyFromConfig(config2);
24214
+ const signals = await buildSessionGuidance(callerKey);
24215
+ markOrient(callerKey);
23375
24216
  if (signals.length === 0) return "";
23376
24217
  const lines = ["\n\n## Session Guidance"];
23377
24218
  for (const s of signals) lines.push(`- ${s}`);
@@ -23468,6 +24309,8 @@ async function handleOrient(adapter2, config2, args = {}) {
23468
24309
  if (ownerActionsNudgedWarning) buildResult.warnings.push(ownerActionsNudgedWarning);
23469
24310
  const ownerActionsBlockingWarning = ownerActionsBlockingOutcome.status === "fulfilled" ? ownerActionsBlockingOutcome.value : void 0;
23470
24311
  if (ownerActionsBlockingWarning) buildResult.warnings.push(ownerActionsBlockingWarning);
24312
+ const reviewChangesWarning = reviewChangesOutcome.status === "fulfilled" ? reviewChangesOutcome.value : void 0;
24313
+ if (reviewChangesWarning) buildResult.warnings.push(reviewChangesWarning);
23471
24314
  try {
23472
24315
  const verifyWarnings = await verifyProject(adapter2);
23473
24316
  for (const w of verifyWarnings) buildResult.warnings.push(w);
@@ -23531,8 +24374,13 @@ ${section}`;
23531
24374
  }
23532
24375
  tracker.mark("format-summary");
23533
24376
  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);
24377
+ const [teamSummaryLine, releaseHistoryLine] = await Promise.all([
24378
+ tracked("team-summary", () => computeTeamSummary(adapter2))().catch(() => void 0),
24379
+ tracked("release-history", () => computeReleaseHistory(adapter2))().catch(() => void 0)
24380
+ ]);
24381
+ const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
24382
+ 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`).*";
24383
+ 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
24384
  } catch (err) {
23537
24385
  const message = err instanceof Error ? err.message : String(err);
23538
24386
  const isKnownFriendly = /^(Orient failed|Project not found|No project|Setup required)/i.test(message);
@@ -23806,6 +24654,18 @@ async function assembleZoomOutContext(adapter2, cycleNumber, projectRoot) {
23806
24654
  const lines = [];
23807
24655
  for (const [adId, adEvents] of byDecision) {
23808
24656
  lines.push(`**${adId}:** ${adEvents.length} events \u2014 ${adEvents.map((e) => `${e.eventType} (S${e.cycle})`).join(", ")}`);
24657
+ for (const e of adEvents) {
24658
+ const parts = [];
24659
+ if (e.evidenceRef) parts.push(`evidence: ${e.evidenceRef}`);
24660
+ if (e.metricDelta?.metric) {
24661
+ const d = e.metricDelta;
24662
+ const movement = d.before !== void 0 && d.after !== void 0 ? `${d.before}\u2192${d.after}` : d.delta !== void 0 ? `\u0394${d.delta}` : "";
24663
+ parts.push(`metric: ${d.metric}${movement ? ` (${movement})` : ""}`);
24664
+ }
24665
+ if (parts.length > 0) {
24666
+ lines.push(` - ${e.eventType} S${e.cycle}: ${parts.join("; ")}`);
24667
+ }
24668
+ }
23809
24669
  }
23810
24670
  adLifecycleText = lines.join("\n");
23811
24671
  }
@@ -24136,8 +24996,7 @@ Check that the project IDs are correct and exist in the same Supabase instance.`
24136
24996
  }
24137
24997
 
24138
24998
  // src/tools/handoff.ts
24139
- var lastPrepareCycleNumber2;
24140
- var lastPrepareContextBytes2;
24999
+ var handoffPrepareCache = new PerCallerCache();
24141
25000
  var handoffGenerateTool = {
24142
25001
  name: "handoff_generate",
24143
25002
  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 +25025,10 @@ var handoffGenerateTool = {
24166
25025
  cycle_number: {
24167
25026
  type: "number",
24168
25027
  description: 'The cycle number returned from prepare phase (mode "apply" only).'
25028
+ },
25029
+ force: {
25030
+ type: "boolean",
25031
+ 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
25032
  }
24170
25033
  },
24171
25034
  required: []
@@ -24173,6 +25036,7 @@ var handoffGenerateTool = {
24173
25036
  };
24174
25037
  async function handleHandoffGenerate(adapter2, config2, args) {
24175
25038
  const toolMode = args.mode;
25039
+ const callerKey = callerKeyFromConfig(config2);
24176
25040
  try {
24177
25041
  if (toolMode === "apply") {
24178
25042
  const resolved = await resolveLlmResponse(
@@ -24181,20 +25045,20 @@ async function handleHandoffGenerate(adapter2, config2, args) {
24181
25045
  );
24182
25046
  if (!resolved.ok) return errorResponse(resolved.error);
24183
25047
  const llmResponse = resolved.llmResponse;
24184
- const cycleNumber = typeof args.cycle_number === "number" ? args.cycle_number : lastPrepareCycleNumber2;
25048
+ const prep = handoffPrepareCache.take(callerKey);
25049
+ const expectedCycleNumber = prep?.cycleNumber;
25050
+ const contextBytes = prep?.contextBytes;
25051
+ const cycleNumber = typeof args.cycle_number === "number" ? args.cycle_number : expectedCycleNumber;
24185
25052
  if (cycleNumber === void 0) {
24186
25053
  return errorResponse('cycle_number is required for mode "apply". Pass the cycle number from the prepare phase.');
24187
25054
  }
24188
- const expectedCycleNumber = lastPrepareCycleNumber2;
24189
- const contextBytes = lastPrepareContextBytes2;
24190
- lastPrepareCycleNumber2 = void 0;
24191
- lastPrepareContextBytes2 = void 0;
25055
+ const force = args.force === true || prep?.force === true;
24192
25056
  if (expectedCycleNumber !== void 0 && cycleNumber !== expectedCycleNumber) {
24193
25057
  return errorResponse(
24194
25058
  `cycle_number mismatch: prepare phase returned cycle ${expectedCycleNumber} but apply received ${cycleNumber}.`
24195
25059
  );
24196
25060
  }
24197
- const result = await applyHandoffs(adapter2, llmResponse, cycleNumber);
25061
+ const result = await applyHandoffs(adapter2, llmResponse, cycleNumber, force);
24198
25062
  const lines = [];
24199
25063
  lines.push(`**Handoff Generation \u2014 Cycle ${result.cycleNumber}**`);
24200
25064
  lines.push(`${result.handoffsWritten} handoff(s) written: ${result.taskIds.join(", ")}`);
@@ -24209,9 +25073,13 @@ async function handleHandoffGenerate(adapter2, config2, args) {
24209
25073
  }
24210
25074
  {
24211
25075
  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;
25076
+ const force = args.force === true;
25077
+ const result = await prepareHandoffs(adapter2, config2, taskIds, force);
25078
+ handoffPrepareCache.set(callerKey, {
25079
+ cycleNumber: result.cycleNumber,
25080
+ contextBytes: result.contextBytes,
25081
+ force
25082
+ });
24215
25083
  return textResponse(
24216
25084
  `## PAPI Handoff Generation \u2014 Prepare Phase (Cycle ${result.cycleNumber})
24217
25085
 
@@ -24617,6 +25485,60 @@ async function handleDiscoveredIssueResolve(adapter2, args) {
24617
25485
 
24618
25486
  // src/tools/project.ts
24619
25487
  import path6 from "path";
25488
+
25489
+ // src/services/entitlements.ts
25490
+ var FREE_PROJECT_CAP = 3;
25491
+ var PAID_TIERS = /* @__PURE__ */ new Set(["pro", "team"]);
25492
+ var PRICING_URL = "https://getpapi.ai/pricing";
25493
+ async function resolveTier(adapter2) {
25494
+ if (typeof adapter2.getMeteredUsage !== "function") return null;
25495
+ try {
25496
+ const usage = await adapter2.getMeteredUsage();
25497
+ return usage?.tier ?? null;
25498
+ } catch {
25499
+ return null;
25500
+ }
25501
+ }
25502
+ function isPaidTier(tier) {
25503
+ return tier !== null && PAID_TIERS.has(tier);
25504
+ }
25505
+ async function enforceProjectCap(adapter2, target) {
25506
+ const tier = await resolveTier(adapter2);
25507
+ if (tier === null || isPaidTier(tier)) return null;
25508
+ if (typeof adapter2.listUserProjects !== "function") return null;
25509
+ let projects;
25510
+ try {
25511
+ projects = await adapter2.listUserProjects();
25512
+ } catch {
25513
+ return null;
25514
+ }
25515
+ const matchesExisting = projects.some(
25516
+ (p) => target.papiDir && p.papi_dir && p.papi_dir === target.papiDir || target.name && p.name && p.name.trim().toLowerCase() === target.name.trim().toLowerCase()
25517
+ );
25518
+ if (matchesExisting) return null;
25519
+ if (projects.length >= FREE_PROJECT_CAP) return projectCapMessage(projects.length);
25520
+ return null;
25521
+ }
25522
+ async function enforceContributorGate(adapter2) {
25523
+ const tier = await resolveTier(adapter2);
25524
+ if (tier === null) return null;
25525
+ if (tier === "team") return null;
25526
+ return contributorTeamMessage(tier);
25527
+ }
25528
+ function projectCapMessage(currentCount) {
25529
+ return `**You're on the Free plan (${currentCount} of ${FREE_PROJECT_CAP} projects).**
25530
+
25531
+ 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}
25532
+
25533
+ Your existing projects are untouched, and you can keep working in any of them.`;
25534
+ }
25535
+ function contributorTeamMessage(tier) {
25536
+ return `**Adding people to a project is a Team feature.**
25537
+
25538
+ 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}`;
25539
+ }
25540
+
25541
+ // src/tools/project.ts
24620
25542
  function workspacePapiDir(config2) {
24621
25543
  if (!hasLocalWorkspace()) return void 0;
24622
25544
  return config2.papiDir;
@@ -24649,6 +25571,8 @@ async function handleProjectCreate(adapter2, config2, args) {
24649
25571
  if (papiDir) name = path6.basename(config2.projectRoot) || "My Project";
24650
25572
  else return errorResponse("name is required \u2014 there is no local workspace to derive it from on the remote transport.");
24651
25573
  }
25574
+ const capDenied = await enforceProjectCap(adapter2, { papiDir, name });
25575
+ if (capDenied) return errorResponse(capDenied);
24652
25576
  const result = await adapter2.createUserProject({
24653
25577
  name,
24654
25578
  papiDir,
@@ -24790,6 +25714,8 @@ function requireEmail(args) {
24790
25714
  async function handleContributorAdd(adapter2, config2, args) {
24791
25715
  const denied = await denyUnlessOwner(adapter2, config2);
24792
25716
  if (denied) return errorResponse(denied);
25717
+ const tierDenied = await enforceContributorGate(adapter2);
25718
+ if (tierDenied) return errorResponse(tierDenied);
24793
25719
  const email = requireEmail(args);
24794
25720
  if (!email) return errorResponse('A valid email is required. Example: contributor_add email="wes@example.com"');
24795
25721
  try {
@@ -24843,6 +25769,155 @@ async function handleContributorList(adapter2, config2, _args) {
24843
25769
  }
24844
25770
  }
24845
25771
 
25772
+ // src/tools/task-claim.ts
25773
+ var taskClaimTool = {
25774
+ name: "task_claim",
25775
+ 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.",
25776
+ annotations: { readOnlyHint: false, destructiveHint: false },
25777
+ inputSchema: {
25778
+ type: "object",
25779
+ properties: {
25780
+ task_id: { type: "string", description: 'The task to claim, e.g. "task-2071".' }
25781
+ },
25782
+ required: ["task_id"]
25783
+ }
25784
+ };
25785
+ var taskUnclaimTool = {
25786
+ name: "task_unclaim",
25787
+ 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.",
25788
+ annotations: { readOnlyHint: false, destructiveHint: true },
25789
+ inputSchema: {
25790
+ type: "object",
25791
+ properties: {
25792
+ task_id: { type: "string", description: 'The task to unclaim, e.g. "task-2071".' }
25793
+ },
25794
+ required: ["task_id"]
25795
+ }
25796
+ };
25797
+ var DONE_STATUSES = /* @__PURE__ */ new Set(["Done", "Cancelled", "Archived"]);
25798
+ function requireTaskId(args) {
25799
+ const id = typeof args.task_id === "string" ? args.task_id.trim() : "";
25800
+ return id.length > 0 ? id : null;
25801
+ }
25802
+ async function resolveClaimerIdentity(adapter2, config2) {
25803
+ if (typeof adapter2.claimTask !== "function") {
25804
+ return { error: "Claiming is not available on this adapter." };
25805
+ }
25806
+ const gate = await resolveOwnerGate(adapter2, config2);
25807
+ const callerUserId = gate.callerUserId;
25808
+ if (!callerUserId) {
25809
+ const note = gate.resolutionError ? ` (${gate.resolutionError})` : "";
25810
+ return {
25811
+ 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."
25812
+ };
25813
+ }
25814
+ if (!gate.callerIsOwner && typeof adapter2.listContributors === "function") {
25815
+ try {
25816
+ const members = await adapter2.listContributors();
25817
+ if (!members.some((m) => m.userId === callerUserId)) {
25818
+ return { error: "Only the project owner or an active contributor can claim tasks on this project." };
25819
+ }
25820
+ } catch (err) {
25821
+ return { error: `Could not verify membership: ${err instanceof Error ? err.message : String(err)}` };
25822
+ }
25823
+ }
25824
+ return { callerUserId };
25825
+ }
25826
+ async function collectClaimChain(adapter2, root) {
25827
+ const byId = /* @__PURE__ */ new Map([[root.id, root]]);
25828
+ const queue = [root];
25829
+ while (queue.length > 0) {
25830
+ const t = queue.shift();
25831
+ if (!t.dependsOn) continue;
25832
+ const depIds = t.dependsOn.split(",").map((d) => d.trim()).filter(Boolean).filter((d) => !byId.has(d));
25833
+ if (depIds.length === 0) continue;
25834
+ const deps = await adapter2.getTasks(depIds);
25835
+ for (const dep of deps) {
25836
+ if (byId.has(dep.id)) continue;
25837
+ byId.set(dep.id, dep);
25838
+ if (!DONE_STATUSES.has(dep.status)) queue.push(dep);
25839
+ }
25840
+ }
25841
+ return [...byId.values()].filter((t) => !DONE_STATUSES.has(t.status));
25842
+ }
25843
+ async function handleTaskClaim(adapter2, config2, args) {
25844
+ const taskId = requireTaskId(args);
25845
+ if (!taskId) return errorResponse('A task_id is required. Example: task_claim task_id="task-2071"');
25846
+ const identity = await resolveClaimerIdentity(adapter2, config2);
25847
+ if ("error" in identity) return errorResponse(identity.error);
25848
+ const me = identity.callerUserId;
25849
+ const root = await adapter2.getTask(taskId);
25850
+ if (!root) return errorResponse(`Task "${taskId}" not found on the board.`);
25851
+ if (DONE_STATUSES.has(root.status)) {
25852
+ return errorResponse(`Task "${taskId}" is ${root.status} \u2014 nothing to claim.`);
25853
+ }
25854
+ const chain = await collectClaimChain(adapter2, root);
25855
+ const blockedByOther = chain.filter((t) => t.assigneeId && t.assigneeId !== me);
25856
+ const inOthersCycle = chain.filter((t) => !t.assigneeId && t.cycle != null);
25857
+ if (blockedByOther.length > 0 || inOthersCycle.length > 0) {
25858
+ const lines = [];
25859
+ for (const t of blockedByOther) lines.push(`- ${t.id} (${t.title}) \u2014 already claimed by another member`);
25860
+ for (const t of inOthersCycle) lines.push(`- ${t.id} (${t.title}) \u2014 already pulled into a cycle (not in the pool)`);
25861
+ return errorResponse(
25862
+ `Cannot claim "${taskId}" \u2014 it shares a DEPENDS ON build unit with task(s) you cannot take:
25863
+ ${lines.join("\n")}
25864
+
25865
+ A build unit is never split across owners. Ask the current owner to unclaim, or pick a different task.`
25866
+ );
25867
+ }
25868
+ const alreadyMine = chain.filter((t) => t.assigneeId === me);
25869
+ const toClaim = chain.filter((t) => !t.assigneeId && t.cycle == null);
25870
+ const claimed = [];
25871
+ const raced = [];
25872
+ for (const t of toClaim) {
25873
+ const result = await adapter2.claimTask(t.id, me);
25874
+ if (result) claimed.push(result);
25875
+ else raced.push(t.id);
25876
+ }
25877
+ if (claimed.length === 0 && alreadyMine.length > 0 && raced.length === 0) {
25878
+ return textResponse(`Task "${taskId}" and its prerequisites are already in your backlog \u2014 nothing to do.`);
25879
+ }
25880
+ const parts = [];
25881
+ const claimedList = claimed.map((t) => `- ${t.id} (${t.title})`).join("\n");
25882
+ parts.push(
25883
+ `\u2705 Claimed **${claimed.length}** task(s) into your backlog (assignee = you, not yet in a cycle):
25884
+ ${claimedList}`
25885
+ );
25886
+ if (alreadyMine.length > 0) {
25887
+ parts.push(`Already yours: ${alreadyMine.map((t) => t.id).join(", ")}.`);
25888
+ }
25889
+ if (raced.length > 0) {
25890
+ parts.push(
25891
+ `\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.`
25892
+ );
25893
+ }
25894
+ parts.push("Next: run `plan` to draw a cycle from your backlog, or `task_unclaim` to release.");
25895
+ return textResponse(parts.join("\n\n"));
25896
+ }
25897
+ async function handleTaskUnclaim(adapter2, config2, args) {
25898
+ const taskId = requireTaskId(args);
25899
+ if (!taskId) return errorResponse('A task_id is required. Example: task_unclaim task_id="task-2071"');
25900
+ if (typeof adapter2.unclaimTask !== "function") {
25901
+ return errorResponse("Unclaiming is not available on this adapter.");
25902
+ }
25903
+ const identity = await resolveClaimerIdentity(adapter2, config2);
25904
+ if ("error" in identity) return errorResponse(identity.error);
25905
+ const me = identity.callerUserId;
25906
+ const result = await adapter2.unclaimTask(taskId, me);
25907
+ if (!result) {
25908
+ const task = await adapter2.getTask(taskId);
25909
+ if (!task) return errorResponse(`Task "${taskId}" not found on the board.`);
25910
+ if (task.assigneeId && task.assigneeId !== me) {
25911
+ return errorResponse(`Task "${taskId}" is claimed by another member \u2014 only the claimer can unclaim it.`);
25912
+ }
25913
+ if (task.status === "In Review" || task.status === "Done") {
25914
+ return errorResponse(`Task "${taskId}" is ${task.status} \u2014 it has passed the pre-review window and cannot be unclaimed.`);
25915
+ }
25916
+ return errorResponse(`Task "${taskId}" is not currently claimed by you \u2014 nothing to unclaim.`);
25917
+ }
25918
+ return textResponse(`\u2705 Released **${result.id}** (${result.title}) back to the shared Pool.`);
25919
+ }
25920
+
24846
25921
  // src/services/harness-inventory.ts
24847
25922
  import { readdir as readdir3, readFile as readFile8, stat as stat3 } from "fs/promises";
24848
25923
  import { join as join16 } from "path";
@@ -24944,8 +26019,8 @@ async function syncHarnessInventory(adapter2, config2, opts) {
24944
26019
  }
24945
26020
  const fingerprint = await computeFingerprint(config2.projectRoot);
24946
26021
  if (!opts?.force) {
24947
- const state2 = await adapter2.getHarnessState();
24948
- if (state2 && state2.fingerprint === fingerprint) return { changed: false };
26022
+ const state = await adapter2.getHarnessState();
26023
+ if (state && state.fingerprint === fingerprint) return { changed: false };
24949
26024
  }
24950
26025
  const entries = await scanInventory(config2.projectRoot, opts?.toolDefs);
24951
26026
  await adapter2.replaceHarnessInventory(entries);
@@ -25077,6 +26152,7 @@ If this is legitimate, reach out at https://getpapi.ai and we'll lift it \u2014
25077
26152
  // src/server.ts
25078
26153
  var DEFAULT_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_TOOL_TIMEOUT_MS ?? "30000", 10);
25079
26154
  var LONG_TOOL_TIMEOUT_MS = parseInt(process.env.PAPI_LONG_TOOL_TIMEOUT_MS ?? "180000", 10);
26155
+ var WEDGE_PENDING_FRACTION = Math.min(1, Math.max(0, parseFloat(process.env.PAPI_WEDGE_PENDING_FRACTION ?? "0.6")));
25080
26156
  var LONG_RUNNING_TOOLS = /* @__PURE__ */ new Set([
25081
26157
  "plan",
25082
26158
  "strategy_review",
@@ -25093,6 +26169,31 @@ function toolTimeoutMs(name) {
25093
26169
  }
25094
26170
  var wedgeRecoveryStats = { count: 0 };
25095
26171
  var inflightWork = /* @__PURE__ */ new Map();
26172
+ var ToolTimeoutError = class extends Error {
26173
+ constructor(toolName, expectedMs, actualMs, wedged, pendingNote) {
26174
+ super(
26175
+ 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\`).`
26176
+ );
26177
+ this.toolName = toolName;
26178
+ this.expectedMs = expectedMs;
26179
+ this.actualMs = actualMs;
26180
+ this.wedged = wedged;
26181
+ this.pendingNote = pendingNote;
26182
+ this.name = "ToolTimeoutError";
26183
+ }
26184
+ };
26185
+ function classifyTimeout(start, actualMs, budgetMs) {
26186
+ const now = start + actualMs;
26187
+ let oldestPendingMs = 0;
26188
+ const parts = Array.from(inflightWork.entries()).map(([slot, slotStart]) => {
26189
+ const age = now - slotStart;
26190
+ if (age > oldestPendingMs) oldestPendingMs = age;
26191
+ return `${slot}(${age}ms)`;
26192
+ });
26193
+ const pendingNote = parts.length ? ` Pending work at timeout: ${parts.join(", ")}.` : " No pending work registered.";
26194
+ const wedged = oldestPendingMs >= budgetMs * WEDGE_PENDING_FRACTION;
26195
+ return { wedged, pendingNote };
26196
+ }
25096
26197
  async function withToolTimeout(name, fn) {
25097
26198
  const ms = toolTimeoutMs(name);
25098
26199
  const start = Date.now();
@@ -25100,15 +26201,18 @@ async function withToolTimeout(name, fn) {
25100
26201
  const timeoutPromise = new Promise((_, reject) => {
25101
26202
  timer2 = setTimeout(() => {
25102
26203
  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
- ));
26204
+ const { wedged, pendingNote } = classifyTimeout(start, actualMs, ms);
26205
+ if (wedged) {
26206
+ wedgeRecoveryStats.count += 1;
26207
+ console.error(
26208
+ `[papi] Wedge-recovery #${wedgeRecoveryStats.count}: tool '${name}' timed out with a stuck operation (expected=${ms}ms, actual=${actualMs}ms) (SUP-2026-012).${pendingNote}`
26209
+ );
26210
+ } else {
26211
+ console.error(
26212
+ `[papi] Slow timeout (DB healthy, not a wedge): tool '${name}' exceeded its ${ms}ms budget (actual=${actualMs}ms) via cumulative load.${pendingNote}`
26213
+ );
26214
+ }
26215
+ reject(new ToolTimeoutError(name, ms, actualMs, wedged, pendingNote));
25112
26216
  }, ms);
25113
26217
  timer2.unref();
25114
26218
  });
@@ -25166,6 +26270,7 @@ var PAPI_TOOLS = [
25166
26270
  releaseTool,
25167
26271
  reviewListTool,
25168
26272
  reviewSubmitTool,
26273
+ reviewClaimTool,
25169
26274
  initTool,
25170
26275
  orientTool,
25171
26276
  hierarchyUpdateTool,
@@ -25187,6 +26292,8 @@ var PAPI_TOOLS = [
25187
26292
  contributorAddTool,
25188
26293
  contributorRemoveTool,
25189
26294
  contributorListTool,
26295
+ taskClaimTool,
26296
+ taskUnclaimTool,
25190
26297
  inventorySyncTool
25191
26298
  ];
25192
26299
  function getToolMetadata() {
@@ -25290,7 +26397,8 @@ function createServer(adapter2, config2) {
25290
26397
  }
25291
26398
  }
25292
26399
  const timer2 = startTimer();
25293
- recordToolCall(name);
26400
+ const callerKey = callerKeyFromConfig(config2);
26401
+ recordToolCall(name, callerKey);
25294
26402
  const runHandler = async () => {
25295
26403
  switch (name) {
25296
26404
  case "plan":
@@ -25333,6 +26441,8 @@ function createServer(adapter2, config2) {
25333
26441
  return handleReviewList(adapter2, config2);
25334
26442
  case "review_submit":
25335
26443
  return handleReviewSubmit(adapter2, config2, safeArgs);
26444
+ case "review_claim":
26445
+ return handleReviewClaim(adapter2, config2, safeArgs);
25336
26446
  case "init":
25337
26447
  return handleInit(config2, safeArgs);
25338
26448
  case "orient":
@@ -25375,6 +26485,10 @@ function createServer(adapter2, config2) {
25375
26485
  return handleContributorRemove(adapter2, config2, safeArgs);
25376
26486
  case "contributor_list":
25377
26487
  return handleContributorList(adapter2, config2, safeArgs);
26488
+ case "task_claim":
26489
+ return handleTaskClaim(adapter2, config2, safeArgs);
26490
+ case "task_unclaim":
26491
+ return handleTaskUnclaim(adapter2, config2, safeArgs);
25378
26492
  case "inventory_sync":
25379
26493
  return handleInventorySync(adapter2, config2, safeArgs, getToolMetadata());
25380
26494
  default:
@@ -25383,7 +26497,7 @@ function createServer(adapter2, config2) {
25383
26497
  };
25384
26498
  const meterDecision = await checkMeter(adapter2, name, config2.projectId ?? "session");
25385
26499
  if (meterDecision.blocked && meterDecision.usage) {
25386
- recordToolOutcome(true);
26500
+ recordToolOutcome(true, callerKey);
25387
26501
  return {
25388
26502
  content: [{ type: "text", text: allowanceExceededMessage(name, meterDecision.usage) }]
25389
26503
  };
@@ -25392,14 +26506,15 @@ function createServer(adapter2, config2) {
25392
26506
  try {
25393
26507
  result = await withToolTimeout(name, runHandler);
25394
26508
  } 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}` }] };
26509
+ if (err instanceof ToolTimeoutError) {
26510
+ recordToolOutcome(false, callerKey);
26511
+ if (err.wedged) {
26512
+ setImmediate(() => {
26513
+ console.error(`[papi] Exiting after '${name}' wedge timeout (wedge-recovery #${wedgeRecoveryStats.count}); user must reconnect with /mcp before the next call.`);
26514
+ process.exit(2);
26515
+ });
26516
+ }
26517
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
25403
26518
  }
25404
26519
  throw err;
25405
26520
  }
@@ -25425,7 +26540,7 @@ ${usageLine(decision.usage)}`;
25425
26540
  delete result._contextBytes;
25426
26541
  delete result._contextUtilisation;
25427
26542
  const isError = result.content.some((c) => c.text.startsWith("Error:") || c.text.startsWith("\u274C"));
25428
- recordToolOutcome(!isError);
26543
+ recordToolOutcome(!isError, callerKey);
25429
26544
  try {
25430
26545
  const clientName = server2.getClientVersion()?.name;
25431
26546
  const metric = buildMetric(name, elapsed, usage, void 0, contextBytes, contextUtilisation, clientName);
@@ -25473,6 +26588,28 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
25473
26588
  var BEARER_PREFIX = "papi_";
25474
26589
  var BEARER_REGEX = /^(papi_|papi_oauth_)[a-f0-9]{64}$/;
25475
26590
  var RESOURCE_METADATA_URL = process.env["PAPI_RESOURCE_METADATA_URL"] ?? "https://getpapi.ai/.well-known/oauth-protected-resource";
26591
+ var FRIENDLY_GET_HTML = `<!doctype html>
26592
+ <html lang="en"><head><meta charset="utf-8">
26593
+ <meta name="viewport" content="width=device-width, initial-scale=1">
26594
+ <title>PAPI MCP server</title>
26595
+ <style>
26596
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
26597
+ background:#faf6f0;color:#15171c;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
26598
+ .card{max-width:520px;padding:40px 36px;text-align:center}
26599
+ h1{font-size:22px;margin:0 0 12px;letter-spacing:.2px}
26600
+ p{margin:10px 0;color:#394048}
26601
+ a.btn{display:inline-block;margin-top:18px;padding:11px 20px;border-radius:10px;
26602
+ background:#15171c;color:#faf6f0;text-decoration:none;font-weight:600}
26603
+ code{background:#efe9e0;padding:2px 6px;border-radius:5px;font-size:13px}
26604
+ </style></head>
26605
+ <body><div class="card">
26606
+ <h1>This is the PAPI MCP server \u{1F44B}</h1>
26607
+ <p>You've reached it in a browser \u2014 that's expected to look empty. This endpoint
26608
+ is meant to be added to your AI tool as a connector, not opened directly.</p>
26609
+ <p>Add it in Claude, Cursor, or any MCP client as a custom connector pointing at
26610
+ <code>https://mcp.getpapi.ai/mcp</code>.</p>
26611
+ <a class="btn" href="https://getpapi.ai/docs/install">See the install guide \u2192</a>
26612
+ </div></body></html>`;
25476
26613
  var MAX_BODY_BYTES = 1 * 1024 * 1024;
25477
26614
  var IP_RATE_WINDOW_MS = 6e4;
25478
26615
  var IP_RATE_MAX = 60;
@@ -25594,6 +26731,13 @@ function startHttpTransport(opts) {
25594
26731
  }
25595
26732
  const bearer = extractBearer(req.headers.authorization);
25596
26733
  if (!bearer) {
26734
+ const accept = req.headers["accept"] ?? "";
26735
+ if (req.method === "GET" && accept.includes("text/html")) {
26736
+ logEvent({ level: "info", msg: "friendly_get", ip, status: 200 });
26737
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", ...cors });
26738
+ res.end(FRIENDLY_GET_HTML);
26739
+ return;
26740
+ }
25597
26741
  const hasHeader = Boolean(req.headers.authorization);
25598
26742
  logEvent({
25599
26743
  level: "warn",
@@ -25707,6 +26851,37 @@ function extractProjectOverride(body) {
25707
26851
  const p = args.project;
25708
26852
  return typeof p === "string" && p.trim().length > 0 ? p.trim() : void 0;
25709
26853
  }
26854
+ var PROJECT_OPTIONAL_TOOLS = /* @__PURE__ */ new Set([
26855
+ "init",
26856
+ "setup",
26857
+ "project_list",
26858
+ "project_create",
26859
+ "project_switch"
26860
+ ]);
26861
+ function extractToolName(body) {
26862
+ if (!body || typeof body !== "object") return void 0;
26863
+ const b2 = body;
26864
+ if (b2.method !== "tools/call") return void 0;
26865
+ const name = b2.params?.name;
26866
+ return typeof name === "string" && name.length > 0 ? name : void 0;
26867
+ }
26868
+ function sendProjectChoice(res, origin, body, projects) {
26869
+ if (res.headersSent) return;
26870
+ const id = (body && typeof body === "object" ? body.id : null) ?? null;
26871
+ const list = projects.map((p) => ` \u2022 ${p.slug}${p.name ? ` \u2014 ${p.name}` : ""}`).join("\n");
26872
+ 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):
26873
+
26874
+ ${list}
26875
+
26876
+ Example: add \`project="${projects[0].slug}"\` to the tool arguments.`;
26877
+ const payload = {
26878
+ jsonrpc: "2.0",
26879
+ id,
26880
+ result: { content: [{ type: "text", text }], isError: true }
26881
+ };
26882
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) });
26883
+ res.end(JSON.stringify(payload));
26884
+ }
25710
26885
  function resolveEffectiveProjectId(body, headerProjectId) {
25711
26886
  const explicitProject = extractProjectOverride(body);
25712
26887
  try {
@@ -25718,7 +26893,37 @@ function resolveEffectiveProjectId(body, headerProjectId) {
25718
26893
  }
25719
26894
  async function dispatchRequest(args) {
25720
26895
  const { req, res, body, bearer, projectId, ip, baseConfig, dataEndpoint } = args;
25721
- const effectiveProjectId = resolveEffectiveProjectId(body, projectId);
26896
+ let effectiveProjectId = resolveEffectiveProjectId(body, projectId);
26897
+ if (effectiveProjectId === void 0) {
26898
+ const toolName = extractToolName(body);
26899
+ if (toolName && !PROJECT_OPTIONAL_TOOLS.has(toolName)) {
26900
+ let projects = [];
26901
+ try {
26902
+ const probe = new ProxyPapiAdapter({ endpoint: dataEndpoint, apiKey: bearer });
26903
+ projects = await probe.listUserProjects();
26904
+ } catch {
26905
+ }
26906
+ if (projects.length === 1) {
26907
+ effectiveProjectId = projects[0].id;
26908
+ logEvent({
26909
+ level: "info",
26910
+ msg: "project_autobound",
26911
+ ip,
26912
+ bearer_prefix: bearerPrefix(bearer),
26913
+ project_id: effectiveProjectId
26914
+ });
26915
+ } else if (projects.length > 1) {
26916
+ logEvent({
26917
+ level: "info",
26918
+ msg: "project_choice_returned",
26919
+ ip,
26920
+ bearer_prefix: bearerPrefix(bearer)
26921
+ });
26922
+ sendProjectChoice(res, req.headers.origin, body, projects);
26923
+ return;
26924
+ }
26925
+ }
26926
+ }
25722
26927
  const adapter2 = new ProxyPapiAdapter({
25723
26928
  endpoint: dataEndpoint,
25724
26929
  apiKey: bearer,