@papi-ai/server 0.7.104 → 0.7.105

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",
@@ -2338,9 +2355,8 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
2338
2355
  getCostSummary(cycleNumber) {
2339
2356
  return this.invoke("getCostSummary", [cycleNumber]);
2340
2357
  }
2341
- getCostSnapshots() {
2342
- return this.invoke("getCostSnapshots");
2343
- }
2358
+ // task-3332: getCostSnapshots retired — see the removal note on CostSnapshot
2359
+ // in packages/shared/src/entities.ts.
2344
2360
  appendCycleMetrics(snapshot) {
2345
2361
  return this.invoke("appendCycleMetrics", [snapshot]);
2346
2362
  }
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",
@@ -3174,9 +3191,8 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
3174
3191
  getCostSummary(cycleNumber) {
3175
3192
  return this.invoke("getCostSummary", [cycleNumber]);
3176
3193
  }
3177
- getCostSnapshots() {
3178
- return this.invoke("getCostSnapshots");
3179
- }
3194
+ // task-3332: getCostSnapshots retired — see the removal note on CostSnapshot
3195
+ // in packages/shared/src/entities.ts.
3180
3196
  appendCycleMetrics(snapshot) {
3181
3197
  return this.invoke("appendCycleMetrics", [snapshot]);
3182
3198
  }
@@ -27762,7 +27778,7 @@ function resolveAdHocCycle(cycle, latest, latestComplete) {
27762
27778
  if (cycle === "current") return latest;
27763
27779
  return latestComplete ? latest + 1 : latest;
27764
27780
  }
27765
- async function recordAdHoc(adapter2, input) {
27781
+ async function recordAdHoc(adapter2, config2, input) {
27766
27782
  const [health, phases] = await Promise.all([
27767
27783
  adapter2.getCycleHealth(),
27768
27784
  adapter2.readPhases()
@@ -27784,9 +27800,16 @@ async function recordAdHoc(adapter2, input) {
27784
27800
  if (!existing) {
27785
27801
  throw new Error(`Task "${input.taskId}" not found on the board. Check the task ID and try again.`);
27786
27802
  }
27787
- if (!existing.assigneeId) {
27803
+ if (existing.assigneeId) {
27804
+ const gate = await resolveOwnerGate(adapter2, config2);
27805
+ if (gate.enforced && (!gate.callerUserId || gate.callerUserId !== existing.assigneeId)) {
27806
+ throw new Error(
27807
+ `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.`
27808
+ );
27809
+ }
27810
+ } else {
27788
27811
  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.`
27812
+ `\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
27813
  );
27791
27814
  }
27792
27815
  const updatePayload = {
@@ -28043,7 +28066,22 @@ async function handleAdHoc(adapter2, config2, args) {
28043
28066
  const gitUsable = !overrideNote && isGitAvailable() && isGitRepo(config2.projectRoot);
28044
28067
  const currentBranch = gitUsable ? getCurrentBranch(config2.projectRoot) : null;
28045
28068
  const baseBranch = gitUsable ? resolveBaseBranch(config2.projectRoot, config2.baseBranch) : null;
28046
- const result = await recordAdHoc(target, {
28069
+ let collidingWorkNote;
28070
+ if (taskId && gitUsable && baseBranch) {
28071
+ try {
28072
+ const existingTask = await target.getTask(taskId);
28073
+ const filesLikelyTouched = existingTask?.buildHandoff?.filesLikelyTouched ?? [];
28074
+ if (existingTask && !existingTask.assigneeId && filesLikelyTouched.length > 0) {
28075
+ const collision = findCollidingWork(config2.projectRoot, baseBranch, filesLikelyTouched);
28076
+ if (collision) {
28077
+ const fileList = collision.files.slice(0, 3).join(", ") + (collision.files.length > 3 ? ", \u2026" : "");
28078
+ 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.`;
28079
+ }
28080
+ }
28081
+ } catch {
28082
+ }
28083
+ }
28084
+ const result = await recordAdHoc(target, config2, {
28047
28085
  title: title || "",
28048
28086
  taskId,
28049
28087
  notes: rawNotes,
@@ -28058,7 +28096,8 @@ async function handleAdHoc(adapter2, config2, args) {
28058
28096
  stage: stageArg,
28059
28097
  hold: holdArg,
28060
28098
  currentBranch,
28061
- baseBranch
28099
+ baseBranch,
28100
+ collidingWorkNote
28062
28101
  });
28063
28102
  if (!holdArg && gitUsable) {
28064
28103
  try {
@@ -29090,6 +29129,27 @@ var reviewSubmitTool = {
29090
29129
  }
29091
29130
  },
29092
29131
  required: ["verdict", "summary", "findings"]
29132
+ },
29133
+ proposal: {
29134
+ type: "object",
29135
+ 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.',
29136
+ properties: {
29137
+ title: { type: "string", description: "One line stating the stance or rule." },
29138
+ body: { type: "string", description: "What was decided or settled, and why." },
29139
+ module: { type: "string", description: "Optional module scope. Only used when this lands as a Convention." },
29140
+ tests: {
29141
+ type: "object",
29142
+ description: "The four admission tests, each answered explicitly. An omitted test is NOT a failed test \u2014 the proposal is refused rather than routed.",
29143
+ properties: {
29144
+ alternativesWereReal: { type: "boolean", description: "(a) Something else could genuinely have been chosen." },
29145
+ constrainsFutureWork: { type: "boolean", description: "(b) It changes what a task nobody has written yet will do." },
29146
+ arguableToday: { type: "boolean", description: "(c) A competent person could argue the other side right now." },
29147
+ reversalCostsMore: { type: "boolean", description: "(d) Reversing it costs more than making it did." }
29148
+ },
29149
+ required: ["alternativesWereReal", "constrainsFutureWork", "arguableToday", "reversalCostsMore"]
29150
+ }
29151
+ },
29152
+ required: ["title", "body", "tests"]
29093
29153
  }
29094
29154
  },
29095
29155
  required: ["task_id", "stage", "verdict", "comments"],
@@ -29705,6 +29765,30 @@ Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
29705
29765
  }
29706
29766
  } catch {
29707
29767
  }
29768
+ let proposalNote = "";
29769
+ if (args.proposal !== void 0) {
29770
+ if (!(stage === "build-acceptance" && verdict === "accept")) {
29771
+ 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.";
29772
+ } else {
29773
+ const validated = validateProposal(args.proposal);
29774
+ if ("error" in validated) {
29775
+ proposalNote = `
29776
+
29777
+ ---
29778
+
29779
+ **Proposal not recorded.** ${validated.error}
29780
+
29781
+ The review itself is unaffected \u2014 only the proposal failed.`;
29782
+ } else {
29783
+ const routing = routeProposal(validated.proposal);
29784
+ const applied = await applyProposal(adapter2, routing, validated.proposal, {
29785
+ cycleNumber: result.currentCycle,
29786
+ sourceTaskId: result.taskId
29787
+ });
29788
+ proposalNote = formatProposalOutcome(routing, validated.proposal, applied);
29789
+ }
29790
+ }
29791
+ }
29708
29792
  tracker.mark("format-response");
29709
29793
  return textResponse(
29710
29794
  `**${result.stageLabel}** recorded for ${result.taskId}.
@@ -29712,7 +29796,7 @@ Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
29712
29796
  - **Verdict:** ${result.verdict}
29713
29797
  - **Comments:** ${trimForEcho(result.comments)}
29714
29798
 
29715
- ${statusNote}${capabilityReviewSkippedNote}${autoReviewNote}${securityNote}${adConflictNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
29799
+ ${statusNote}${capabilityReviewSkippedNote}${autoReviewNote}${securityNote}${adConflictNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}${proposalNote}`
29716
29800
  );
29717
29801
  } catch (err) {
29718
29802
  const message = err instanceof Error ? err.message : String(err);
@@ -30746,6 +30830,41 @@ function formatUnblockSection(candidates) {
30746
30830
  return lines.join("\n");
30747
30831
  }
30748
30832
 
30833
+ // src/lib/decision-drafts.ts
30834
+ async function findPendingDecisionDrafts(adapter2) {
30835
+ let blockedProbe;
30836
+ try {
30837
+ blockedProbe = await adapter2.queryBoard({ status: ["Blocked"], compact: true });
30838
+ } catch {
30839
+ return [];
30840
+ }
30841
+ if (blockedProbe.length === 0) return [];
30842
+ const drafts = [];
30843
+ for (const task of blockedProbe) {
30844
+ const blocker = task.blocker;
30845
+ if (!blocker || blocker.type !== "decision-gate" || blocker.ref !== PROPOSED_DECISION_REF) continue;
30846
+ const evidence = (task.notes ?? "").split("\n")[0]?.trim() ?? "";
30847
+ drafts.push({
30848
+ taskId: task.displayId ?? task.id,
30849
+ taskTitle: task.title,
30850
+ evidence
30851
+ });
30852
+ }
30853
+ return drafts;
30854
+ }
30855
+ function formatPendingDecisionDrafts(drafts) {
30856
+ if (drafts.length === 0) return "";
30857
+ const lines = ["## Decisions Awaiting Your Call"];
30858
+ lines.push(
30859
+ `${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.`
30860
+ );
30861
+ lines.push("");
30862
+ for (const d of drafts) {
30863
+ lines.push(`- **${formatRef(d.taskId, d.taskTitle)}**${d.evidence ? ` \u2014 ${d.evidence}` : ""}`);
30864
+ }
30865
+ return lines.join("\n");
30866
+ }
30867
+
30749
30868
  // src/lib/deferred-gate.ts
30750
30869
  var GATE_PHRASES = [
30751
30870
  "depends on",
@@ -32288,7 +32407,8 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
32288
32407
  }
32289
32408
  let unrecordedNote2 = "";
32290
32409
  try {
32291
- const unrecorded = detectUnrecordedCommits(config2.projectRoot, config2.baseBranch);
32410
+ const { compareRef } = getBaseDivergence(config2.projectRoot, config2.baseBranch, { timeoutMs: ORIGIN_FETCH_TIMEOUT_MS });
32411
+ const unrecorded = detectUnrecordedCommits(config2.projectRoot, compareRef);
32292
32412
  if (unrecorded.length > 0) {
32293
32413
  const doneTasks = await adapter2.queryBoard({ status: ["Done"] });
32294
32414
  const adHocDoneTasks = doneTasks.filter((t) => t.cycle == null);
@@ -32417,8 +32537,14 @@ ${versionDrift}` : "";
32417
32537
  }
32418
32538
  tracker.mark("parallel-tail");
32419
32539
  const sharedContributorsPromise = adapterSupports(adapter2, "listContributors") ? adapter2.listContributors().catch(() => []) : Promise.resolve([]);
32420
- const [unblockCandidates, subAgents, teamSummaryLine, releaseHistoryLine, cohortVisibilityLine, carryForwardRefs] = await Promise.all([
32540
+ const [unblockCandidates, pendingDecisionDrafts, subAgents, teamSummaryLine, releaseHistoryLine, cohortVisibilityLine, carryForwardRefs] = await Promise.all([
32421
32541
  tracked("unblock-candidates", () => findUnblockCandidates(adapter2, currentCycle2))().catch(() => []),
32542
+ // task-3388: pending decision-proposal drafts (build_execute/review_submit's
32543
+ // `proposal` param, task-3273/3388) — mandatory reader, deliberately
32544
+ // uncapped and separate from the housekeeping-suggestion unblock list above
32545
+ // (see lib/decision-drafts.ts for why). Runs on every orient call, not
32546
+ // gated behind deep_housekeeping — same posture as unblock-candidates.
32547
+ tracked("pending-decision-drafts", () => findPendingDecisionDrafts(adapter2))().catch(() => []),
32422
32548
  // task-1866: discover project sub-agents for the orient surface (read-only, never throws).
32423
32549
  listAgents(config2.projectRoot),
32424
32550
  // task-2071 (MU-3) + task-2072 (MU-5): team summary + release-history — both
@@ -32437,6 +32563,10 @@ ${versionDrift}` : "";
32437
32563
  const unblockNote = unblockSection ? `
32438
32564
 
32439
32565
  ${unblockSection}` : "";
32566
+ const decisionDraftsSection = formatPendingDecisionDrafts(pendingDecisionDrafts);
32567
+ const decisionDraftsNote = decisionDraftsSection ? `
32568
+
32569
+ ${decisionDraftsSection}` : "";
32440
32570
  const teamSummary = [teamSummaryLine, releaseHistoryLine, cohortVisibilityLine].filter(Boolean).join("\n") || void 0;
32441
32571
  let deferredGateNote = "";
32442
32572
  if (deepHousekeeping) {
@@ -32472,7 +32602,7 @@ ${formatRuntimeIdentity(getRuntimeIdentity(config2, serverVersion))}`;
32472
32602
  let effStaleSkillsNote = staleSkillsNote;
32473
32603
  let effResearchSignalsNote = researchSignalsNote;
32474
32604
  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;
32605
+ 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
32606
  const orientBudgetSoft = Number(process.env.PAPI_ORIENT_CONTEXT_BUDGET) || 6e4;
32477
32607
  let assembled = assembleOrientOutput();
32478
32608
  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.105",
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",