@papi-ai/server 0.7.104 → 0.7.106

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.
@@ -201,6 +201,7 @@ __export(git_exports, {
201
201
  ensureLatestDevelop: () => ensureLatestDevelop,
202
202
  ensureTagAtHead: () => ensureTagAtHead,
203
203
  fetchBaseBranch: () => fetchBaseBranch,
204
+ findCollidingWork: () => findCollidingWork,
204
205
  findContributorReleasePullRequests: () => findContributorReleasePullRequests,
205
206
  findTaskCommitsOnBase: () => findTaskCommitsOnBase,
206
207
  getBaseDivergence: () => getBaseDivergence,
@@ -1493,6 +1494,23 @@ function getRemoteBranchFiles(cwd, branch, baseBranch) {
1493
1494
  return [];
1494
1495
  }
1495
1496
  }
1497
+ function findCollidingWork(cwd, baseBranch, filesLikelyTouched) {
1498
+ if (filesLikelyTouched.length === 0) return null;
1499
+ const prs = listOpenPullRequests(cwd);
1500
+ if (!prs || prs.length === 0) return null;
1501
+ const wanted = new Set(filesLikelyTouched);
1502
+ for (const pr of prs) {
1503
+ const branchFiles = getRemoteBranchFiles(cwd, pr.headRefName, baseBranch);
1504
+ const overlap = branchFiles.filter((f) => wanted.has(f));
1505
+ if (overlap.length > 0) {
1506
+ return {
1507
+ label: `PR #${pr.number} "${pr.title}" (branch \`${pr.headRefName}\`, opened by ${pr.author})`,
1508
+ files: overlap
1509
+ };
1510
+ }
1511
+ }
1512
+ return null;
1513
+ }
1496
1514
  var AUTO_WRITTEN_PATHS, GIT_NETWORK_TIMEOUT_MS, MERGE_RETRY_DELAY_MS, MERGE_MAX_RETRIES, GIT_FETCH_TIMEOUT_MS;
1497
1515
  var init_git = __esm({
1498
1516
  "src/lib/git.ts"() {
@@ -1684,7 +1702,6 @@ var init_proxy_adapter = __esm({
1684
1702
  "getBuildReportsSince",
1685
1703
  "getContextHashes",
1686
1704
  "getContextUtilisation",
1687
- "getCostSnapshots",
1688
1705
  "getCostSummary",
1689
1706
  "getCurrentNorthStar",
1690
1707
  "getCycleHealth",
@@ -1891,11 +1908,13 @@ var init_proxy_adapter = __esm({
1891
1908
  endpoint;
1892
1909
  apiKey;
1893
1910
  projectId;
1911
+ papiDir;
1894
1912
  onAuthRejected;
1895
1913
  constructor(config) {
1896
1914
  this.endpoint = config.endpoint.replace(/\/$/, "");
1897
1915
  this.apiKey = config.apiKey;
1898
1916
  this.projectId = config.projectId ?? "";
1917
+ this.papiDir = config.papiDir ?? null;
1899
1918
  this.onAuthRejected = config.onAuthRejected;
1900
1919
  }
1901
1920
  /**
@@ -1971,6 +1990,7 @@ var init_proxy_adapter = __esm({
1971
1990
  endpoint: this.endpoint,
1972
1991
  apiKey: this.apiKey,
1973
1992
  projectId,
1993
+ papiDir: this.papiDir,
1974
1994
  onAuthRejected: this.onAuthRejected
1975
1995
  }));
1976
1996
  }
@@ -2338,9 +2358,8 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
2338
2358
  getCostSummary(cycleNumber) {
2339
2359
  return this.invoke("getCostSummary", [cycleNumber]);
2340
2360
  }
2341
- getCostSnapshots() {
2342
- return this.invoke("getCostSnapshots");
2343
- }
2361
+ // task-3332: getCostSnapshots retired — see the removal note on CostSnapshot
2362
+ // in packages/shared/src/entities.ts.
2344
2363
  appendCycleMetrics(snapshot) {
2345
2364
  return this.invoke("appendCycleMetrics", [snapshot]);
2346
2365
  }
@@ -2604,7 +2623,7 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
2604
2623
  }
2605
2624
  // --- Project metadata (path-identity guardrail) ---
2606
2625
  async getProjectInfo() {
2607
- const raw = await this.invoke("getProjectInfo", []);
2626
+ const raw = await this.invoke("getProjectInfo", [this.papiDir]);
2608
2627
  if (!raw) return null;
2609
2628
  return {
2610
2629
  name: raw.name,
@@ -2691,7 +2710,9 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
2691
2710
  };
2692
2711
  }
2693
2712
  async listUserProjects() {
2694
- const body = await this.postRoute("project-list", {});
2713
+ const body = await this.postRoute("project-list", {
2714
+ ...this.papiDir ? { papiDir: this.papiDir } : {}
2715
+ });
2695
2716
  return body.projects ?? [];
2696
2717
  }
2697
2718
  async createUserProject(input) {
@@ -2950,6 +2971,7 @@ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the
2950
2971
  );
2951
2972
  }
2952
2973
  const pgAdapter = new PgAdapter(config);
2974
+ let projectCreated = false;
2953
2975
  try {
2954
2976
  const existing = await pgAdapter.getProject(projectId);
2955
2977
  if (!existing) {
@@ -2981,12 +3003,12 @@ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the
2981
3003
  id: projectId,
2982
3004
  slug,
2983
3005
  name: slug,
2984
- papi_dir: papiDir,
2985
3006
  user_id: userId,
2986
3007
  root_commit_hash: rootHash ?? void 0,
2987
3008
  repo_url: originUrl ?? void 0,
2988
3009
  resolution_method: rootHash ? "root_hash" : "manual"
2989
3010
  });
3011
+ projectCreated = true;
2990
3012
  }
2991
3013
  } else {
2992
3014
  if (!existing.root_commit_hash && rootHash || !existing.repo_url && originUrl) {
@@ -3007,25 +3029,6 @@ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the
3007
3029
  );
3008
3030
  }
3009
3031
  }
3010
- if (!_pathIdentityChecked) {
3011
- _pathIdentityChecked = true;
3012
- const cwd = options.projectRoot ?? process.env["PAPI_PROJECT_DIR"] ?? process.cwd();
3013
- const projectName = existing.name ?? existing.slug ?? projectId;
3014
- const result = assertWorkspaceMatch({
3015
- storedPapiDir: existing.papi_dir,
3016
- projectName,
3017
- workspacePath: cwd
3018
- });
3019
- if (result && (result.action === "backfill" || result.action === "migrate")) {
3020
- try {
3021
- await pgAdapter.updateProject(projectId, { papi_dir: result.newPapiDir });
3022
- } catch (err) {
3023
- console.error(
3024
- `[papi] Failed to persist papi_dir update: ${err instanceof Error ? err.message : String(err)}`
3025
- );
3026
- }
3027
- }
3028
- }
3029
3032
  if (existing.user_id) {
3030
3033
  const configuredUserId = process.env["PAPI_USER_ID"] ?? detectUserId();
3031
3034
  if (configuredUserId && existing.user_id !== configuredUserId) {
@@ -3044,11 +3047,29 @@ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the
3044
3047
  } catch {
3045
3048
  }
3046
3049
  }
3047
- const adapter = new PgPapiAdapter({ ...config, userId: resolveUserId }, projectId);
3050
+ const adapter = new PgPapiAdapter({ ...config, userId: resolveUserId, papiDir }, projectId);
3048
3051
  try {
3049
3052
  await adapter.initRls();
3050
3053
  } catch {
3051
3054
  }
3055
+ if (projectCreated && papiDir && adapter.setProjectPapiDir) {
3056
+ await adapter.setProjectPapiDir(papiDir);
3057
+ }
3058
+ if (!_pathIdentityChecked && adapter.getProjectInfo && adapter.setProjectPapiDir) {
3059
+ _pathIdentityChecked = true;
3060
+ const info = await adapter.getProjectInfo();
3061
+ if (info) {
3062
+ const cwd = options.projectRoot ?? process.env["PAPI_PROJECT_DIR"] ?? process.cwd();
3063
+ const result = assertWorkspaceMatch({
3064
+ storedPapiDir: info.papi_dir,
3065
+ projectName: info.name || info.slug || projectId,
3066
+ workspacePath: cwd
3067
+ });
3068
+ if (result && (result.action === "backfill" || result.action === "migrate")) {
3069
+ await adapter.setProjectPapiDir(result.newPapiDir);
3070
+ }
3071
+ }
3072
+ }
3052
3073
  const connected = await adapter.probeConnection();
3053
3074
  if (connected) {
3054
3075
  _connectionStatus = "connected";
@@ -3087,7 +3108,8 @@ Already have an account? Make sure PAPI_DATA_API_KEY is set in your .mcp.json en
3087
3108
  const adapter = new ProxyPapiAdapter2({
3088
3109
  endpoint: dataEndpoint,
3089
3110
  apiKey: dataApiKey,
3090
- projectId: projectId || void 0
3111
+ projectId: projectId || void 0,
3112
+ papiDir
3091
3113
  });
3092
3114
  const connected = await adapter.probeConnection();
3093
3115
  if (!connected) {
package/dist/index.js CHANGED
@@ -971,6 +971,7 @@ __export(git_exports, {
971
971
  ensureLatestDevelop: () => ensureLatestDevelop,
972
972
  ensureTagAtHead: () => ensureTagAtHead,
973
973
  fetchBaseBranch: () => fetchBaseBranch,
974
+ findCollidingWork: () => findCollidingWork,
974
975
  findContributorReleasePullRequests: () => findContributorReleasePullRequests,
975
976
  findTaskCommitsOnBase: () => findTaskCommitsOnBase,
976
977
  getBaseDivergence: () => getBaseDivergence,
@@ -2263,6 +2264,23 @@ function getRemoteBranchFiles(cwd, branch, baseBranch) {
2263
2264
  return [];
2264
2265
  }
2265
2266
  }
2267
+ function findCollidingWork(cwd, baseBranch, filesLikelyTouched) {
2268
+ if (filesLikelyTouched.length === 0) return null;
2269
+ const prs = listOpenPullRequests(cwd);
2270
+ if (!prs || prs.length === 0) return null;
2271
+ const wanted = new Set(filesLikelyTouched);
2272
+ for (const pr of prs) {
2273
+ const branchFiles = getRemoteBranchFiles(cwd, pr.headRefName, baseBranch);
2274
+ const overlap = branchFiles.filter((f) => wanted.has(f));
2275
+ if (overlap.length > 0) {
2276
+ return {
2277
+ label: `PR #${pr.number} "${pr.title}" (branch \`${pr.headRefName}\`, opened by ${pr.author})`,
2278
+ files: overlap
2279
+ };
2280
+ }
2281
+ }
2282
+ return null;
2283
+ }
2266
2284
  var AUTO_WRITTEN_PATHS, GIT_NETWORK_TIMEOUT_MS, MERGE_RETRY_DELAY_MS, MERGE_MAX_RETRIES, GIT_FETCH_TIMEOUT_MS;
2267
2285
  var init_git = __esm({
2268
2286
  "src/lib/git.ts"() {
@@ -2520,7 +2538,6 @@ var init_proxy_adapter = __esm({
2520
2538
  "getBuildReportsSince",
2521
2539
  "getContextHashes",
2522
2540
  "getContextUtilisation",
2523
- "getCostSnapshots",
2524
2541
  "getCostSummary",
2525
2542
  "getCurrentNorthStar",
2526
2543
  "getCycleHealth",
@@ -2727,11 +2744,13 @@ var init_proxy_adapter = __esm({
2727
2744
  endpoint;
2728
2745
  apiKey;
2729
2746
  projectId;
2747
+ papiDir;
2730
2748
  onAuthRejected;
2731
2749
  constructor(config2) {
2732
2750
  this.endpoint = config2.endpoint.replace(/\/$/, "");
2733
2751
  this.apiKey = config2.apiKey;
2734
2752
  this.projectId = config2.projectId ?? "";
2753
+ this.papiDir = config2.papiDir ?? null;
2735
2754
  this.onAuthRejected = config2.onAuthRejected;
2736
2755
  }
2737
2756
  /**
@@ -2807,6 +2826,7 @@ var init_proxy_adapter = __esm({
2807
2826
  endpoint: this.endpoint,
2808
2827
  apiKey: this.apiKey,
2809
2828
  projectId,
2829
+ papiDir: this.papiDir,
2810
2830
  onAuthRejected: this.onAuthRejected
2811
2831
  }));
2812
2832
  }
@@ -3174,9 +3194,8 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
3174
3194
  getCostSummary(cycleNumber) {
3175
3195
  return this.invoke("getCostSummary", [cycleNumber]);
3176
3196
  }
3177
- getCostSnapshots() {
3178
- return this.invoke("getCostSnapshots");
3179
- }
3197
+ // task-3332: getCostSnapshots retired — see the removal note on CostSnapshot
3198
+ // in packages/shared/src/entities.ts.
3180
3199
  appendCycleMetrics(snapshot) {
3181
3200
  return this.invoke("appendCycleMetrics", [snapshot]);
3182
3201
  }
@@ -3440,7 +3459,7 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
3440
3459
  }
3441
3460
  // --- Project metadata (path-identity guardrail) ---
3442
3461
  async getProjectInfo() {
3443
- const raw = await this.invoke("getProjectInfo", []);
3462
+ const raw = await this.invoke("getProjectInfo", [this.papiDir]);
3444
3463
  if (!raw) return null;
3445
3464
  return {
3446
3465
  name: raw.name,
@@ -3527,7 +3546,9 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
3527
3546
  };
3528
3547
  }
3529
3548
  async listUserProjects() {
3530
- const body = await this.postRoute("project-list", {});
3549
+ const body = await this.postRoute("project-list", {
3550
+ ...this.papiDir ? { papiDir: this.papiDir } : {}
3551
+ });
3531
3552
  return body.projects ?? [];
3532
3553
  }
3533
3554
  async createUserProject(input) {
@@ -7274,6 +7295,7 @@ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the
7274
7295
  );
7275
7296
  }
7276
7297
  const pgAdapter = new PgAdapter(config2);
7298
+ let projectCreated = false;
7277
7299
  try {
7278
7300
  const existing = await pgAdapter.getProject(projectId);
7279
7301
  if (!existing) {
@@ -7305,12 +7327,12 @@ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the
7305
7327
  id: projectId,
7306
7328
  slug,
7307
7329
  name: slug,
7308
- papi_dir: papiDir,
7309
7330
  user_id: userId,
7310
7331
  root_commit_hash: rootHash ?? void 0,
7311
7332
  repo_url: originUrl ?? void 0,
7312
7333
  resolution_method: rootHash ? "root_hash" : "manual"
7313
7334
  });
7335
+ projectCreated = true;
7314
7336
  }
7315
7337
  } else {
7316
7338
  if (!existing.root_commit_hash && rootHash || !existing.repo_url && originUrl) {
@@ -7331,25 +7353,6 @@ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the
7331
7353
  );
7332
7354
  }
7333
7355
  }
7334
- if (!_pathIdentityChecked) {
7335
- _pathIdentityChecked = true;
7336
- const cwd = options.projectRoot ?? process.env["PAPI_PROJECT_DIR"] ?? process.cwd();
7337
- const projectName = existing.name ?? existing.slug ?? projectId;
7338
- const result = assertWorkspaceMatch({
7339
- storedPapiDir: existing.papi_dir,
7340
- projectName,
7341
- workspacePath: cwd
7342
- });
7343
- if (result && (result.action === "backfill" || result.action === "migrate")) {
7344
- try {
7345
- await pgAdapter.updateProject(projectId, { papi_dir: result.newPapiDir });
7346
- } catch (err) {
7347
- console.error(
7348
- `[papi] Failed to persist papi_dir update: ${err instanceof Error ? err.message : String(err)}`
7349
- );
7350
- }
7351
- }
7352
- }
7353
7356
  if (existing.user_id) {
7354
7357
  const configuredUserId = process.env["PAPI_USER_ID"] ?? detectUserId();
7355
7358
  if (configuredUserId && existing.user_id !== configuredUserId) {
@@ -7368,11 +7371,29 @@ Fix the PAPI_PROJECT_ID in your .mcp.json (or unset it and let PAPI resolve the
7368
7371
  } catch {
7369
7372
  }
7370
7373
  }
7371
- const adapter2 = new PgPapiAdapter({ ...config2, userId: resolveUserId }, projectId);
7374
+ const adapter2 = new PgPapiAdapter({ ...config2, userId: resolveUserId, papiDir }, projectId);
7372
7375
  try {
7373
7376
  await adapter2.initRls();
7374
7377
  } catch {
7375
7378
  }
7379
+ if (projectCreated && papiDir && adapter2.setProjectPapiDir) {
7380
+ await adapter2.setProjectPapiDir(papiDir);
7381
+ }
7382
+ if (!_pathIdentityChecked && adapter2.getProjectInfo && adapter2.setProjectPapiDir) {
7383
+ _pathIdentityChecked = true;
7384
+ const info = await adapter2.getProjectInfo();
7385
+ if (info) {
7386
+ const cwd = options.projectRoot ?? process.env["PAPI_PROJECT_DIR"] ?? process.cwd();
7387
+ const result = assertWorkspaceMatch({
7388
+ storedPapiDir: info.papi_dir,
7389
+ projectName: info.name || info.slug || projectId,
7390
+ workspacePath: cwd
7391
+ });
7392
+ if (result && (result.action === "backfill" || result.action === "migrate")) {
7393
+ await adapter2.setProjectPapiDir(result.newPapiDir);
7394
+ }
7395
+ }
7396
+ }
7376
7397
  const connected = await adapter2.probeConnection();
7377
7398
  if (connected) {
7378
7399
  _connectionStatus = "connected";
@@ -7411,7 +7432,8 @@ Already have an account? Make sure PAPI_DATA_API_KEY is set in your .mcp.json en
7411
7432
  const adapter2 = new ProxyPapiAdapter2({
7412
7433
  endpoint: dataEndpoint,
7413
7434
  apiKey: dataApiKey,
7414
- projectId: projectId || void 0
7435
+ projectId: projectId || void 0,
7436
+ papiDir
7415
7437
  });
7416
7438
  const connected = await adapter2.probeConnection();
7417
7439
  if (!connected) {
@@ -7540,21 +7562,27 @@ import {
7540
7562
  // src/universal-frame.ts
7541
7563
  var UNIVERSAL_FRAME = `PAPI gives this project a structured plan \u2192 build \u2192 review cycle, persisted across sessions. Follow it:
7542
7564
 
7543
- 0. NEW HERE? If PAPI has never been set up for this project (no cycles yet, or \`orient\` says the board is empty), call \`setup\` FIRST \u2014 it generates the Product Brief and scaffolds the workflow. Then run \`plan\` to create the first cycle. This is the required first step for a brand-new project; everything below assumes setup has already run.
7565
+ 0a. MCHAT REALITY. Most hosts load MCP tools at CONVERSATION START \u2014 tools added or authenticated mid-chat are invisible in that chat. If the user says PAPI "is not working" or "failed to connect" but your tool list has no papi tools, do NOT retry the install or re-run OAuth: the link almost certainly succeeded already. Tell them plainly: "PAPI connected fine \u2014 tools only load in a NEW conversation. Start a fresh chat and run \`papi\` there." Never claim the connection failed when you simply cannot see the tools.
7566
+
7567
+ 0b. ONE CHAT, ONE PROJECT. Each conversation binds to ONE PAPI project at a time. If the user has several projects, orient names which one is connected \u2014 confirm it matches the repo the user is working in before writing anything. Wrong project? Run \`project_switch\` (or pass \`project="<slug>"\` per call), and say a fresh conversation is the cleanest switch.
7568
+
7569
+ 1. NEW HERE? If PAPI has never been set up for this project (no cycles yet, or \`orient\` says the board is empty), call \`setup\` FIRST \u2014 it generates the Product Brief and scaffolds the workflow. Then run \`plan\` to create the first cycle. This is the required first step for a brand-new project; everything below assumes setup has already run.
7544
7570
 
7545
- 1. ORIENT FIRST (once set up). At the start of every session, call \`orient\` (or \`papi\`) before anything else \u2014 it returns the current cycle, what's in flight, and the recommended next action. Re-run it after any context compression.
7571
+ 2. ORIENT FIRST (once set up). At the start of every session, call \`orient\` (or \`papi\`) before anything else \u2014 it returns the current cycle, what's in flight, and the recommended next action. Re-run it after any context compression.
7546
7572
 
7547
- 2. THE CYCLE, IN ORDER: \`plan\` (once per cycle) \u2192 \`build_list\` (pick a task) \u2192 \`build_execute <task>\` to start \u2192 implement the task from its BUILD HANDOFF \u2192 \`build_execute\` again to complete with a build report \u2192 \`review_submit\` \u2192 \`release\` when every cycle task is done.
7573
+ 3. THE CYCLE, IN ORDER: \`plan\` (once per cycle) \u2192 \`build_list\` (pick a task) \u2192 \`build_execute <task>\` to start \u2192 implement the task from its BUILD HANDOFF \u2192 \`build_execute\` again to complete with a build report \u2192 \`review_submit\` \u2192 \`release\` when every cycle task is done.
7548
7574
 
7549
- 3. STATUS DISCIPLINE. Check a task's status before acting. "In Review" = already built \u2014 never re-build it; submit a review instead. "In Progress" = a build started \u2014 check the existing branch before writing new code. "Backlog" = not started, but check for an existing feature branch first.
7575
+ 4. STATUS DISCIPLINE. Check a task's status before acting. "In Review" = already built \u2014 never re-build it; submit a review instead. "In Progress" = a build started \u2014 check the existing branch before writing new code. "Backlog" = not started, but check for an existing feature branch first.
7550
7576
 
7551
- 4. STAY IN SCOPE. Build exactly what the BUILD HANDOFF specifies. Mechanical steps (branch creation, commits, status updates) are automatic \u2014 only "what to build" needs the user's confirmation.
7577
+ 5. STAY IN SCOPE. Build exactly what the BUILD HANDOFF specifies. Mechanical steps (branch creation, commits, status updates) are automatic \u2014 only "what to build" needs the user's confirmation.
7552
7578
 
7553
- 5. VERIFY BEFORE DONE. Test the change and confirm it works before reporting a task complete. Report failures honestly, with the output.
7579
+ 6. VERIFY BEFORE DONE. Test the change and confirm it works before reporting a task complete. Report failures honestly, with the output.
7554
7580
 
7555
- 6. NAME THE PROJECT WHEN YOU KNOW IT. If you know which repo this session is working in, pass \`project="<slug>"\` on the call rather than relying on whatever the connection defaults to. An account with more than one project cannot be resolved from a connection with no project bound \u2014 PAPI will stop and ask which one rather than guess, and answering costs a round trip. If PAPI asks, put the list to the user, then re-call with their choice; run \`project_switch\` to make it stick.
7581
+ 7. NAME THE PROJECT WHEN YOU KNOW IT. If you know which repo this session is working in, pass \`project="<slug>"\` on the call rather than relying on whatever the connection defaults to. An account with more than one project cannot be resolved from a connection with no project bound \u2014 PAPI will stop and ask which one rather than guess, and answering costs a round trip. If PAPI asks, put the list to the user, then re-call with their choice; run \`project_switch\` to make it stick.
7556
7582
 
7557
- PAPI reads and writes all project state through these tools \u2014 they are the source of truth, not local files.`;
7583
+ PAPI reads and writes all project state through these tools \u2014 they are the source of truth, not local files.
7584
+
7585
+ DATA HANDLING: PAPI stores structured project metadata only (never your source code), runs no AI calls from its servers \u2014 https://getpapi.ai/trust states exactly who can read what.`;
7558
7586
 
7559
7587
  // src/lib/response.ts
7560
7588
  function textResponse(text, usage) {
@@ -12789,6 +12817,13 @@ async function resolveCallerLatestCycle(adapter2, callerUserId) {
12789
12817
  }
12790
12818
  return newest;
12791
12819
  }
12820
+ function unownedCycleNumbers(cycles) {
12821
+ const unowned = /* @__PURE__ */ new Set();
12822
+ for (const c of cycles) {
12823
+ if (c.userId == null) unowned.add(c.number);
12824
+ }
12825
+ return unowned;
12826
+ }
12792
12827
  async function validateAndPrepare(adapter2, force, callerUserId, adapterType) {
12793
12828
  let mode;
12794
12829
  let cycleNumber;
@@ -12813,23 +12848,37 @@ Run \`release\` first, or pass \`force: true\` to bypass this block.`
12813
12848
  );
12814
12849
  }
12815
12850
  if (!force) {
12851
+ const callerCycleNumbers = callerUserId ? (await adapter2.readCycles()).filter((c) => c.userId === callerUserId).map((c) => c.number) : null;
12816
12852
  const inReviewTasks = await adapter2.queryBoard({
12817
12853
  status: ["In Review"],
12818
12854
  compact: true
12819
12855
  });
12820
- const staleTasks = inReviewTasks.filter(
12821
- (t) => t.cycle !== void 0 && t.cycle <= cycleNumber - 2
12856
+ const staleMine = inReviewTasks.filter(
12857
+ (t) => t.cycle !== void 0 && t.cycle <= cycleNumber - 2 && (callerCycleNumbers === null || callerCycleNumbers.includes(t.cycle))
12822
12858
  );
12823
- if (staleTasks.length > 0) {
12824
- const taskList = staleTasks.map((t) => `- ${t.id} (Cycle ${t.cycle}): ${t.title}`).join("\n");
12859
+ if (staleMine.length > 0) {
12860
+ const taskList = staleMine.map((t) => `- ${t.id} (Cycle ${t.cycle}): ${t.title}`).join("\n");
12825
12861
  throw new Error(
12826
- `Stale reviews detected \u2014 ${staleTasks.length} task(s) have been In Review for 2+ cycles:
12862
+ `Stale reviews detected \u2014 ${staleMine.length} task(s) have been In Review for 2+ cycles:
12827
12863
 
12828
12864
  ${taskList}
12829
12865
 
12830
12866
  Run \`review_submit\` to clear them, or pass \`force: true\` to bypass this block.`
12831
12867
  );
12832
12868
  }
12869
+ if (callerCycleNumbers !== null) {
12870
+ const unowned = unownedCycleNumbers(await adapter2.readCycles());
12871
+ const staleTheirs = inReviewTasks.filter(
12872
+ (t) => t.cycle !== void 0 && t.cycle <= cycleNumber - 2 && !callerCycleNumbers.includes(t.cycle) && // A task in a cycle with no known owner is unowned legacy data —
12873
+ // do not attribute it to a teammate either.
12874
+ !unowned.has(t.cycle)
12875
+ );
12876
+ if (staleTheirs.length > 0) {
12877
+ strategyReviewWarning += `> \u2139\uFE0F ${staleTheirs.length} task(s) are In Review from other members' cycles (2+ cycles stale). They do not block your planning \u2014 but the review queue may need attention.
12878
+
12879
+ `;
12880
+ }
12881
+ }
12833
12882
  }
12834
12883
  const autoCompleted = await assertSingleActiveCycle(adapter2, { autoComplete: force === true, userId: callerUserId ?? void 0 });
12835
12884
  for (const note of autoCompleted) {
@@ -27762,7 +27811,7 @@ function resolveAdHocCycle(cycle, latest, latestComplete) {
27762
27811
  if (cycle === "current") return latest;
27763
27812
  return latestComplete ? latest + 1 : latest;
27764
27813
  }
27765
- async function recordAdHoc(adapter2, input) {
27814
+ async function recordAdHoc(adapter2, config2, input) {
27766
27815
  const [health, phases] = await Promise.all([
27767
27816
  adapter2.getCycleHealth(),
27768
27817
  adapter2.readPhases()
@@ -27784,9 +27833,16 @@ async function recordAdHoc(adapter2, input) {
27784
27833
  if (!existing) {
27785
27834
  throw new Error(`Task "${input.taskId}" not found on the board. Check the task ID and try again.`);
27786
27835
  }
27787
- if (!existing.assigneeId) {
27836
+ if (existing.assigneeId) {
27837
+ const gate = await resolveOwnerGate(adapter2, config2);
27838
+ if (gate.enforced && (!gate.callerUserId || gate.callerUserId !== existing.assigneeId)) {
27839
+ throw new Error(
27840
+ `Task "${input.taskId}" (${existing.title}) is claimed by another member \u2014 ad-hoc work cannot be recorded against it. Have the claimer record it, or run \`task_unclaim\` to release it first.`
27841
+ );
27842
+ }
27843
+ } else {
27788
27844
  warnings.push(
27789
- `\u2139\uFE0F ${input.taskId} has no assignee \u2014 recording ad-hoc work against an unclaimed pool task. If someone else might be building it too, check for an open PR/branch on the same files before merging.`
27845
+ `\u2139\uFE0F ${input.taskId} has no assignee \u2014 recording ad-hoc work against an unclaimed pool task. ` + (input.collidingWorkNote ?? `If someone else might be building it too, check for an open PR/branch on the same files before merging.`)
27790
27846
  );
27791
27847
  }
27792
27848
  const updatePayload = {
@@ -28043,7 +28099,22 @@ async function handleAdHoc(adapter2, config2, args) {
28043
28099
  const gitUsable = !overrideNote && isGitAvailable() && isGitRepo(config2.projectRoot);
28044
28100
  const currentBranch = gitUsable ? getCurrentBranch(config2.projectRoot) : null;
28045
28101
  const baseBranch = gitUsable ? resolveBaseBranch(config2.projectRoot, config2.baseBranch) : null;
28046
- const result = await recordAdHoc(target, {
28102
+ let collidingWorkNote;
28103
+ if (taskId && gitUsable && baseBranch) {
28104
+ try {
28105
+ const existingTask = await target.getTask(taskId);
28106
+ const filesLikelyTouched = existingTask?.buildHandoff?.filesLikelyTouched ?? [];
28107
+ if (existingTask && !existingTask.assigneeId && filesLikelyTouched.length > 0) {
28108
+ const collision = findCollidingWork(config2.projectRoot, baseBranch, filesLikelyTouched);
28109
+ if (collision) {
28110
+ const fileList = collision.files.slice(0, 3).join(", ") + (collision.files.length > 3 ? ", \u2026" : "");
28111
+ collidingWorkNote = `${collision.label} already touches ${collision.files.length === 1 ? "the same file" : `${collision.files.length} of the same files`} (${fileList}) \u2014 check it before merging.`;
28112
+ }
28113
+ }
28114
+ } catch {
28115
+ }
28116
+ }
28117
+ const result = await recordAdHoc(target, config2, {
28047
28118
  title: title || "",
28048
28119
  taskId,
28049
28120
  notes: rawNotes,
@@ -28058,7 +28129,8 @@ async function handleAdHoc(adapter2, config2, args) {
28058
28129
  stage: stageArg,
28059
28130
  hold: holdArg,
28060
28131
  currentBranch,
28061
- baseBranch
28132
+ baseBranch,
28133
+ collidingWorkNote
28062
28134
  });
28063
28135
  if (!holdArg && gitUsable) {
28064
28136
  try {
@@ -29090,6 +29162,27 @@ var reviewSubmitTool = {
29090
29162
  }
29091
29163
  },
29092
29164
  required: ["verdict", "summary", "findings"]
29165
+ },
29166
+ proposal: {
29167
+ type: "object",
29168
+ description: 'task-3388, OPTIONAL (build-acceptance + verdict:"accept" only): propose a Decision or a Convention the build or the review itself settled. Same mechanism as build_execute\u2019s `proposal` (task-3273) \u2014 PAPI never mints one for you. A proposal that passes all four admission tests is QUEUED for the owner behind a decision gate, and nothing is written as an Active Decision. YOU apply the four tests (they are stated in the planning prompts); the server routes on your answers and makes no judgement about the content. Fails "arguable today" only and it is recorded as a Convention instead, riding every future build. Fails "constrains future work" and it is returned to you with the reason, unrecorded.',
29169
+ properties: {
29170
+ title: { type: "string", description: "One line stating the stance or rule." },
29171
+ body: { type: "string", description: "What was decided or settled, and why." },
29172
+ module: { type: "string", description: "Optional module scope. Only used when this lands as a Convention." },
29173
+ tests: {
29174
+ type: "object",
29175
+ description: "The four admission tests, each answered explicitly. An omitted test is NOT a failed test \u2014 the proposal is refused rather than routed.",
29176
+ properties: {
29177
+ alternativesWereReal: { type: "boolean", description: "(a) Something else could genuinely have been chosen." },
29178
+ constrainsFutureWork: { type: "boolean", description: "(b) It changes what a task nobody has written yet will do." },
29179
+ arguableToday: { type: "boolean", description: "(c) A competent person could argue the other side right now." },
29180
+ reversalCostsMore: { type: "boolean", description: "(d) Reversing it costs more than making it did." }
29181
+ },
29182
+ required: ["alternativesWereReal", "constrainsFutureWork", "arguableToday", "reversalCostsMore"]
29183
+ }
29184
+ },
29185
+ required: ["title", "body", "tests"]
29093
29186
  }
29094
29187
  },
29095
29188
  required: ["task_id", "stage", "verdict", "comments"],
@@ -29705,6 +29798,30 @@ Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
29705
29798
  }
29706
29799
  } catch {
29707
29800
  }
29801
+ let proposalNote = "";
29802
+ if (args.proposal !== void 0) {
29803
+ if (!(stage === "build-acceptance" && verdict === "accept")) {
29804
+ proposalNote = "\n\n---\n\n**Proposal not recorded.** `proposal` is only accepted on a build-acceptance `accept` \u2014 nothing has settled on a handoff-review or a non-accept verdict.\n\nThe review itself is unaffected \u2014 only the proposal was skipped.";
29805
+ } else {
29806
+ const validated = validateProposal(args.proposal);
29807
+ if ("error" in validated) {
29808
+ proposalNote = `
29809
+
29810
+ ---
29811
+
29812
+ **Proposal not recorded.** ${validated.error}
29813
+
29814
+ The review itself is unaffected \u2014 only the proposal failed.`;
29815
+ } else {
29816
+ const routing = routeProposal(validated.proposal);
29817
+ const applied = await applyProposal(adapter2, routing, validated.proposal, {
29818
+ cycleNumber: result.currentCycle,
29819
+ sourceTaskId: result.taskId
29820
+ });
29821
+ proposalNote = formatProposalOutcome(routing, validated.proposal, applied);
29822
+ }
29823
+ }
29824
+ }
29708
29825
  tracker.mark("format-response");
29709
29826
  return textResponse(
29710
29827
  `**${result.stageLabel}** recorded for ${result.taskId}.
@@ -29712,7 +29829,7 @@ Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
29712
29829
  - **Verdict:** ${result.verdict}
29713
29830
  - **Comments:** ${trimForEcho(result.comments)}
29714
29831
 
29715
- ${statusNote}${capabilityReviewSkippedNote}${autoReviewNote}${securityNote}${adConflictNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
29832
+ ${statusNote}${capabilityReviewSkippedNote}${autoReviewNote}${securityNote}${adConflictNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}${proposalNote}`
29716
29833
  );
29717
29834
  } catch (err) {
29718
29835
  const message = err instanceof Error ? err.message : String(err);
@@ -30746,6 +30863,41 @@ function formatUnblockSection(candidates) {
30746
30863
  return lines.join("\n");
30747
30864
  }
30748
30865
 
30866
+ // src/lib/decision-drafts.ts
30867
+ async function findPendingDecisionDrafts(adapter2) {
30868
+ let blockedProbe;
30869
+ try {
30870
+ blockedProbe = await adapter2.queryBoard({ status: ["Blocked"], compact: true });
30871
+ } catch {
30872
+ return [];
30873
+ }
30874
+ if (blockedProbe.length === 0) return [];
30875
+ const drafts = [];
30876
+ for (const task of blockedProbe) {
30877
+ const blocker = task.blocker;
30878
+ if (!blocker || blocker.type !== "decision-gate" || blocker.ref !== PROPOSED_DECISION_REF) continue;
30879
+ const evidence = (task.notes ?? "").split("\n")[0]?.trim() ?? "";
30880
+ drafts.push({
30881
+ taskId: task.displayId ?? task.id,
30882
+ taskTitle: task.title,
30883
+ evidence
30884
+ });
30885
+ }
30886
+ return drafts;
30887
+ }
30888
+ function formatPendingDecisionDrafts(drafts) {
30889
+ if (drafts.length === 0) return "";
30890
+ const lines = ["## Decisions Awaiting Your Call"];
30891
+ lines.push(
30892
+ `${drafts.length} decision${drafts.length === 1 ? "" : "s"} proposed during a build, waiting on you \u2014 mint with \`strategy_change\` to make it a real Active Decision, or \`board_edit <task> status:Cancelled\` to dismiss it. Neither is automatic.`
30893
+ );
30894
+ lines.push("");
30895
+ for (const d of drafts) {
30896
+ lines.push(`- **${formatRef(d.taskId, d.taskTitle)}**${d.evidence ? ` \u2014 ${d.evidence}` : ""}`);
30897
+ }
30898
+ return lines.join("\n");
30899
+ }
30900
+
30749
30901
  // src/lib/deferred-gate.ts
30750
30902
  var GATE_PHRASES = [
30751
30903
  "depends on",
@@ -31986,10 +32138,18 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
31986
32138
  const handshake = buildHandshakeResult(proxyVersion);
31987
32139
  return handshake.mismatch && handshake.warning ? handshake.warning : void 0;
31988
32140
  }),
31989
- // P1 stall warning: P1 High tasks in Backlog that haven't moved in 3+ cycles
32141
+ // P1 stall warning: P1 High tasks in Backlog that haven't moved in 3+ cycles.
32142
+ // task-3736: the read is scoped to the CALLER (unassigned-or-mine, the same
32143
+ // predicate task-3397 pinned). Unscoped, a teammate's Backlog P1s were aged
32144
+ // against the CALLER's cycle count — a member at cycle 372 reading a
32145
+ // teammate's cycle-5 task as "367+ cycles" stalled. Cycle identity is
32146
+ // (project_id, user_id, number) since task-3335; cross-member cycle-age
32147
+ // arithmetic is meaningless. The caller's own unclaimed pool P1s still
32148
+ // count — unclaimed work is everyone's to claim, and its createdCycle is
32149
+ // the cycle that planned it.
31990
32150
  tracked("p1-stall", async () => {
31991
32151
  const p1BacklogTasks = await adapter2.queryBoard({ status: ["Backlog"], priority: ["P1 High"] });
31992
- const stalledP1 = p1BacklogTasks.filter(
32152
+ const stalledP1 = scopeTasksToCaller(p1BacklogTasks, callerUserId).filter(
31993
32153
  (t) => t.createdCycle != null && currentCycle2 - t.createdCycle >= 3
31994
32154
  );
31995
32155
  if (stalledP1.length === 0) return void 0;
@@ -32288,7 +32448,8 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
32288
32448
  }
32289
32449
  let unrecordedNote2 = "";
32290
32450
  try {
32291
- const unrecorded = detectUnrecordedCommits(config2.projectRoot, config2.baseBranch);
32451
+ const { compareRef } = getBaseDivergence(config2.projectRoot, config2.baseBranch, { timeoutMs: ORIGIN_FETCH_TIMEOUT_MS });
32452
+ const unrecorded = detectUnrecordedCommits(config2.projectRoot, compareRef);
32292
32453
  if (unrecorded.length > 0) {
32293
32454
  const doneTasks = await adapter2.queryBoard({ status: ["Done"] });
32294
32455
  const adHocDoneTasks = doneTasks.filter((t) => t.cycle == null);
@@ -32417,8 +32578,14 @@ ${versionDrift}` : "";
32417
32578
  }
32418
32579
  tracker.mark("parallel-tail");
32419
32580
  const sharedContributorsPromise = adapterSupports(adapter2, "listContributors") ? adapter2.listContributors().catch(() => []) : Promise.resolve([]);
32420
- const [unblockCandidates, subAgents, teamSummaryLine, releaseHistoryLine, cohortVisibilityLine, carryForwardRefs] = await Promise.all([
32581
+ const [unblockCandidates, pendingDecisionDrafts, subAgents, teamSummaryLine, releaseHistoryLine, cohortVisibilityLine, carryForwardRefs] = await Promise.all([
32421
32582
  tracked("unblock-candidates", () => findUnblockCandidates(adapter2, currentCycle2))().catch(() => []),
32583
+ // task-3388: pending decision-proposal drafts (build_execute/review_submit's
32584
+ // `proposal` param, task-3273/3388) — mandatory reader, deliberately
32585
+ // uncapped and separate from the housekeeping-suggestion unblock list above
32586
+ // (see lib/decision-drafts.ts for why). Runs on every orient call, not
32587
+ // gated behind deep_housekeeping — same posture as unblock-candidates.
32588
+ tracked("pending-decision-drafts", () => findPendingDecisionDrafts(adapter2))().catch(() => []),
32422
32589
  // task-1866: discover project sub-agents for the orient surface (read-only, never throws).
32423
32590
  listAgents(config2.projectRoot),
32424
32591
  // task-2071 (MU-3) + task-2072 (MU-5): team summary + release-history — both
@@ -32437,6 +32604,10 @@ ${versionDrift}` : "";
32437
32604
  const unblockNote = unblockSection ? `
32438
32605
 
32439
32606
  ${unblockSection}` : "";
32607
+ const decisionDraftsSection = formatPendingDecisionDrafts(pendingDecisionDrafts);
32608
+ const decisionDraftsNote = decisionDraftsSection ? `
32609
+
32610
+ ${decisionDraftsSection}` : "";
32440
32611
  const teamSummary = [teamSummaryLine, releaseHistoryLine, cohortVisibilityLine].filter(Boolean).join("\n") || void 0;
32441
32612
  let deferredGateNote = "";
32442
32613
  if (deepHousekeeping) {
@@ -32472,7 +32643,7 @@ ${formatRuntimeIdentity(getRuntimeIdentity(config2, serverVersion))}`;
32472
32643
  let effStaleSkillsNote = staleSkillsNote;
32473
32644
  let effResearchSignalsNote = researchSignalsNote;
32474
32645
  const droppedNotes = [];
32475
- const assembleOrientOutput = () => projectOverrideLine + projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary, clientName, carryForwardRefs) + runtimeIdentityNote + unblockNote + deferredGateNote + feedbackResolvedNote + alertsNote + ttfvNote + reconciliationNote + mergedInProgressNote + unrecordedNote + unregisteredDocsNote + effStaleSkillsNote + effResearchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + effOnboardingCoachingNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + effDeepHint + enrichmentFilesSection;
32646
+ const assembleOrientOutput = () => projectOverrideLine + projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary, clientName, carryForwardRefs) + runtimeIdentityNote + unblockNote + decisionDraftsNote + deferredGateNote + feedbackResolvedNote + alertsNote + ttfvNote + reconciliationNote + mergedInProgressNote + unrecordedNote + unregisteredDocsNote + effStaleSkillsNote + effResearchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + effOnboardingCoachingNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + effDeepHint + enrichmentFilesSection;
32476
32647
  const orientBudgetSoft = Number(process.env.PAPI_ORIENT_CONTEXT_BUDGET) || 6e4;
32477
32648
  let assembled = assembleOrientOutput();
32478
32649
  if (Buffer.byteLength(assembled, "utf-8") > orientBudgetSoft && effDeepHint) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.104",
3
+ "version": "0.7.106",
4
4
  "description": "PAPI MCP server — AI-powered sprint planning, build execution, and strategy review for software projects",
5
5
  "license": "Elastic-2.0",
6
6
  "mcpName": "io.github.getpapi/papi",