@papi-ai/server 0.7.67 → 0.7.69

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
@@ -5210,6 +5210,7 @@ function loadConfig() {
5210
5210
  const baseBranch = process.env.PAPI_BASE_BRANCH ?? "main";
5211
5211
  const autoPR = process.env.PAPI_AUTO_PR !== "false";
5212
5212
  const lightMode = process.env.PAPI_LIGHT_MODE === "true";
5213
+ const autoWorktree = process.env.PAPI_AUTO_WORKTREE === "true";
5213
5214
  const projectOwner = process.env.PAPI_OWNER ?? "Cathal";
5214
5215
  const skipProjectSpecificRules = process.env.PAPI_SKIP_PROJECT_RULES === "true";
5215
5216
  const userId = process.env.PAPI_USER_ID || void 0;
@@ -5283,8 +5284,13 @@ Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env con
5283
5284
  baseBranch,
5284
5285
  autoPR,
5285
5286
  adapterType,
5287
+ // Local stdio boot: the server runs on the user's machine, so the client
5288
+ // shares its filesystem. The HTTP transport overrides this to false per
5289
+ // request (task-2906).
5290
+ localFilesystem: true,
5286
5291
  papiEndpoint,
5287
5292
  lightMode,
5293
+ autoWorktree,
5288
5294
  projectOwner,
5289
5295
  skipProjectSpecificRules,
5290
5296
  userId,
@@ -9475,11 +9481,16 @@ function buildPlanUserMessage(ctx) {
9475
9481
  ""
9476
9482
  );
9477
9483
  }
9478
- if (ctx.buildPatterns) {
9479
- parts.push("### Build Patterns", "", ctx.buildPatterns, "");
9484
+ const enrichment = [];
9485
+ const addEnrichment = (label, ...lines) => {
9486
+ enrichment.push({ label, block: lines.join("\n") });
9487
+ };
9488
+ if (ctx.strategyRecommendations) {
9489
+ addEnrichment("Strategy Recommendations (Pending)", "### Strategy Recommendations (Pending)", "", ctx.strategyRecommendations, "");
9480
9490
  }
9481
9491
  if (ctx.learningPatterns) {
9482
- parts.push(
9492
+ addEnrichment(
9493
+ "Recurring Learning Patterns",
9483
9494
  "### Recurring Learning Patterns (cross-cycle)",
9484
9495
  "",
9485
9496
  "Tags from cycle learnings (surprises, issues, dead-ends, estimation misses) that keep recurring. Treat a module/theme that recurs across many cycles as a signal: size its tasks up and write a sharper pre-mortem.",
@@ -9489,7 +9500,8 @@ function buildPlanUserMessage(ctx) {
9489
9500
  );
9490
9501
  }
9491
9502
  if (ctx.decisionScorePatterns) {
9492
- parts.push(
9503
+ addEnrichment(
9504
+ "Decision Risk Scores",
9493
9505
  "### Decision Risk Scores (high-risk + movement)",
9494
9506
  "",
9495
9507
  "Per-decision risk scores (effort+risk+reversibility+scaleCost+lockIn, /25; lower = safer). Decisions flagged \u26A0 high-risk (>15/25) or whose score moved since the last scoring. When a task depends on or advances a \u26A0 high-risk or \u2191 riskier decision, name that decision in the BUILD HANDOFF pre-mortem and lean toward the larger estimate. Do NOT re-score decisions here \u2014 this is read-only input.",
@@ -9498,38 +9510,39 @@ function buildPlanUserMessage(ctx) {
9498
9510
  ""
9499
9511
  );
9500
9512
  }
9513
+ if (ctx.estimationCalibration) {
9514
+ addEnrichment("Estimation Calibration", "### Estimation Calibration (Historical)", "", ctx.estimationCalibration, "");
9515
+ }
9501
9516
  if (ctx.reviewPatterns) {
9502
- parts.push("### Review Patterns", "", ctx.reviewPatterns, "");
9517
+ addEnrichment("Review Patterns", "### Review Patterns", "", ctx.reviewPatterns, "");
9503
9518
  }
9504
9519
  if (ctx.methodologyMetrics) {
9505
- parts.push("### Methodology Trends", "", ctx.methodologyMetrics, "");
9506
- }
9507
- if (ctx.estimationCalibration) {
9508
- parts.push("### Estimation Calibration (Historical)", "", ctx.estimationCalibration, "");
9520
+ addEnrichment("Methodology Trends", "### Methodology Trends", "", ctx.methodologyMetrics, "");
9509
9521
  }
9510
9522
  if (ctx.horizonContext) {
9511
- parts.push("### Forward Horizon", "", ctx.horizonContext, "");
9523
+ addEnrichment("Forward Horizon", "### Forward Horizon", "", ctx.horizonContext, "");
9512
9524
  }
9513
- if (ctx.strategyRecommendations) {
9514
- parts.push("### Strategy Recommendations (Pending)", "", ctx.strategyRecommendations, "");
9515
- }
9516
- if (ctx.dogfoodEntries) {
9517
- parts.push("### Dogfood Observations (Recent)", "", ctx.dogfoodEntries, "");
9525
+ if (ctx.buildPatterns) {
9526
+ addEnrichment("Build Patterns", "### Build Patterns", "", ctx.buildPatterns, "");
9518
9527
  }
9519
9528
  if (ctx.taskComments) {
9520
- parts.push("### Task Discussion Threads", "", ctx.taskComments, "");
9529
+ addEnrichment("Task Discussion Threads", "### Task Discussion Threads", "", ctx.taskComments, "");
9521
9530
  }
9522
9531
  if (ctx.recentReviews) {
9523
- parts.push("### Human Reviews", "", ctx.recentReviews, "");
9532
+ addEnrichment("Human Reviews", "### Human Reviews", "", ctx.recentReviews, "");
9533
+ }
9534
+ if (ctx.dogfoodEntries) {
9535
+ addEnrichment("Dogfood Observations", "### Dogfood Observations (Recent)", "", ctx.dogfoodEntries, "");
9524
9536
  }
9525
9537
  if (ctx.discoveryCanvas) {
9526
- parts.push("### Discovery Canvas", "", ctx.discoveryCanvas, "");
9538
+ addEnrichment("Discovery Canvas", "### Discovery Canvas", "", ctx.discoveryCanvas, "");
9527
9539
  }
9528
9540
  if (ctx.registeredDocs) {
9529
- parts.push("### Relevant Research Docs", "", ctx.registeredDocs, "");
9541
+ addEnrichment("Relevant Research Docs", "### Relevant Research Docs", "", ctx.registeredDocs, "");
9530
9542
  }
9531
9543
  if (ctx.unactionedDocs) {
9532
- parts.push(
9544
+ addEnrichment(
9545
+ "Unactioned Research",
9533
9546
  "### Unactioned Research (soft-warn)",
9534
9547
  "",
9535
9548
  "These registered docs still have pending actions. Before adding net-new tasks, consider whether one of these unactioned items should be promoted into this cycle via `doc_action_promote`. Surface this in your plan output so the user can decide.",
@@ -9539,7 +9552,31 @@ function buildPlanUserMessage(ctx) {
9539
9552
  );
9540
9553
  }
9541
9554
  if (ctx.carryForwardStaleness) {
9542
- parts.push("### Carry-Forward Staleness", "", ctx.carryForwardStaleness, "");
9555
+ addEnrichment("Carry-Forward Staleness", "### Carry-Forward Staleness", "", ctx.carryForwardStaleness, "");
9556
+ }
9557
+ const contextBudget = Number(process.env.PAPI_PLAN_CONTEXT_BUDGET) || 9e4;
9558
+ const coreBytes = Buffer.byteLength(parts.join("\n"), "utf-8");
9559
+ let enrichmentSpent = 0;
9560
+ const elidedLabels = [];
9561
+ for (const { label, block } of enrichment) {
9562
+ const cost = Buffer.byteLength(`
9563
+ ${block}`, "utf-8");
9564
+ if (coreBytes + enrichmentSpent + cost > contextBudget) {
9565
+ elidedLabels.push(label);
9566
+ continue;
9567
+ }
9568
+ parts.push(block);
9569
+ enrichmentSpent += cost;
9570
+ }
9571
+ if (elidedLabels.length > 0) {
9572
+ parts.push(
9573
+ "### Context Budget \u2014 enrichment sections trimmed",
9574
+ "",
9575
+ `${elidedLabels.length} non-essential context section(s) were omitted to keep this plan payload under ${Math.round(contextBudget / 1024)} KB \u2014 a mature board otherwise overflows the client's tool-result ceiling and the plan output is spilled to a file. Trimmed (lowest-value first): ${elidedLabels.join(", ")}.`,
9576
+ "",
9577
+ "These are cross-cycle analytics/enrichment, NOT task-selection inputs \u2014 the Board, Active Decisions, Product Brief, Full Notes, and Recent Build Reports above are complete and untrimmed. Plan normally. If you specifically need one of the trimmed sections, raise `PAPI_PLAN_CONTEXT_BUDGET` in the MCP server env and re-run.",
9578
+ ""
9579
+ );
9543
9580
  }
9544
9581
  }
9545
9582
  return parts.join("\n");
@@ -9928,6 +9965,19 @@ function buildReviewUserMessage(ctx) {
9928
9965
  } else {
9929
9966
  parts.push("(Showing build reports and cycle log entries since last strategy review.)");
9930
9967
  }
9968
+ if (ctx.earnedPushback) {
9969
+ parts.push(
9970
+ "",
9971
+ "---",
9972
+ "",
9973
+ "## \u26A0\uFE0F EARNED ADVERSARIAL PUSHBACK",
9974
+ "",
9975
+ "This section appears ONLY because concrete signals crossed a threshold this window. Address each item in your review with a clearly-labelled pushback callout: verify the claim against the actual build reports / code / decisions before accepting it, and push back proportionally to the stakes. If verification shows a signal is benign, say so and move on. Do NOT manufacture additional pushback beyond what these signals support.",
9976
+ "",
9977
+ ctx.earnedPushback,
9978
+ ""
9979
+ );
9980
+ }
9931
9981
  parts.push(
9932
9982
  "",
9933
9983
  "---",
@@ -12794,11 +12844,24 @@ Run \`strategy_review\` first, or pass \`force: true\` to bypass this gate.`
12794
12844
  }
12795
12845
  return { mode, cycleNumber, strategyReviewWarning };
12796
12846
  }
12847
+ function assertApplyPayloadNonEmpty(data, cycleNumber) {
12848
+ const handoffCount = data.cycleHandoffs?.length ?? 0;
12849
+ const taskIdCount = data.cycleTaskIds?.length ?? 0;
12850
+ const newTaskCount = data.newTasks?.length ?? 0;
12851
+ if (handoffCount + taskIdCount + newTaskCount === 0) {
12852
+ throw new Error(
12853
+ `Plan apply rejected: trimmed/partial apply payload \u2014 nothing persisted. The parsed plan for Cycle ${cycleNumber + 1} assigns NO tasks to the cycle (cycleHandoffs, cycleTaskIds and newTasks are all empty). Writing it would create a cycle row with zero task membership and no handoffs \u2014 the phantom-empty-cycle failure mode that renders an empty Team view. The most likely cause is a truncated apply JSON: the prepare payload overflowed the client tool-result ceiling (see task-2905/2906). Re-run the plan and resend the COMPLETE structured output \u2014 ensure the JSON after the <!-- PAPI_STRUCTURED_OUTPUT --> marker includes the full cycleHandoffs array (or cycleTaskIds when using skip_handoffs).`
12854
+ );
12855
+ }
12856
+ }
12797
12857
  async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber, contextHashes, planRunMeta) {
12798
12858
  const applyStartMs = Date.now();
12799
12859
  const applyScope = await resolvePlanScope(adapter2, config2);
12800
12860
  await assertSingleActiveCycle(adapter2, { allowNumber: cycleNumber + 1, userId: applyScope.callerUserId ?? void 0 });
12801
12861
  const { displayText, data } = parseStructuredOutput(rawOutput);
12862
+ if (data) {
12863
+ assertApplyPayloadNonEmpty(data, cycleNumber);
12864
+ }
12802
12865
  let resolvedDisplayText = displayText;
12803
12866
  let autoCommitNote = "";
12804
12867
  let priorityLockNote = "";
@@ -13276,7 +13339,15 @@ function buildSubagentDispatchPrompt(input) {
13276
13339
  - "pass" = no blocking issues; "warn" = minor/non-blocking nits; "fail" = blocking issues that should send the build back. \`file\`/\`line\` are optional per finding. Empty \`findings\` is valid when clean.` : `- Read the system prompt and context exactly as the main agent would.
13277
13340
  - Produce the full structured output the system prompt requires (Part 1 markdown + Part 2 JSON after \`<!-- PAPI_STRUCTURED_OUTPUT -->\`, or whatever the system prompt specifies).
13278
13341
  - Return ONLY that output \u2014 no preamble, no commentary, no closing summary. The dispatching agent will pass your reply verbatim to the apply call.`;
13279
- const contextBlock = isReview ? `<review_rubric>
13342
+ const contextBlock = input.contextFilePath ? isReview ? `The review rubric and the full build-under-review context have been written to a local file:
13343
+
13344
+ ${input.contextFilePath}
13345
+
13346
+ Read that file IN FULL (it contains the rubric and the build report + diff) before you start. Do not skip it \u2014 it is your only source of context.` : `The system prompt and full planning context have been written to a local file:
13347
+
13348
+ ${input.contextFilePath}
13349
+
13350
+ Read that file IN FULL before you produce anything \u2014 it contains the system prompt and all context you need. Do not skip it; it is your only source of context.` : isReview ? `<review_rubric>
13280
13351
  ${systemPrompt}
13281
13352
  </review_rubric>
13282
13353
 
@@ -13682,6 +13753,15 @@ function clearPrepareSpill(projectId, callerKey) {
13682
13753
  } catch {
13683
13754
  }
13684
13755
  }
13756
+ function contextPath(projectId, callerKey) {
13757
+ const id = createHash2("sha256").update(`${projectId ?? "no-project"}|${callerKey ?? DEFAULT_CALLER_KEY3}`).digest("hex").slice(0, 16);
13758
+ return join3(tmpdir(), `papi-plan-context-${id}.md`);
13759
+ }
13760
+ function savePrepareContextFile(projectId, callerKey, content) {
13761
+ const path7 = contextPath(projectId, callerKey);
13762
+ writeFileSync2(path7, content, { mode: 384 });
13763
+ return path7;
13764
+ }
13685
13765
 
13686
13766
  // src/tools/plan.ts
13687
13767
  var planPrepareCache = new PerCallerCache();
@@ -13690,6 +13770,7 @@ var planTool = {
13690
13770
  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`.',
13691
13771
  annotations: { title: "Plan Cycle", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
13692
13772
  inputSchema: {
13773
+ $schema: "https://json-schema.org/draft/2020-12/schema",
13693
13774
  type: "object",
13694
13775
  properties: {
13695
13776
  mode: {
@@ -13760,7 +13841,16 @@ var planTool = {
13760
13841
  description: 'Apply-mode confirmation guard for planner-initiated cancellations (task-1727). When false (default), any boardCorrection with status="Cancelled" is SKIPPED and listed in the response so the user can confirm before destructive writes. Set true to apply the cancellations. board_edit-direct cancellations are unaffected \u2014 this guard is plan-only.'
13761
13842
  }
13762
13843
  },
13763
- required: []
13844
+ required: [],
13845
+ // task-2802: mirror resolveLlmResponse — llm_response and llm_response_file are
13846
+ // mutually exclusive, and an apply call must carry exactly one of them.
13847
+ not: { required: ["llm_response", "llm_response_file"] },
13848
+ allOf: [
13849
+ {
13850
+ if: { properties: { mode: { const: "apply" } }, required: ["mode"] },
13851
+ then: { anyOf: [{ required: ["llm_response"] }, { required: ["llm_response_file"] }] }
13852
+ }
13853
+ ]
13764
13854
  }
13765
13855
  };
13766
13856
  function formatPlanResult(result) {
@@ -13929,6 +14019,27 @@ async function handlePlan(adapter2, config2, args) {
13929
14019
  } else {
13930
14020
  dispatch = "inline";
13931
14021
  }
14022
+ const modeLabel = result.mode === "bootstrap" ? "Bootstrap" : "Full";
14023
+ const header = result.strategyReviewWarning ? `${result.strategyReviewWarning}
14024
+ ` : "";
14025
+ let contextFilePath;
14026
+ if (config2.localFilesystem) {
14027
+ const contextDoc = `### System Prompt
14028
+
14029
+ ${result.systemPrompt}
14030
+
14031
+ ---
14032
+
14033
+ ### Context
14034
+
14035
+ ${result.userMessage}
14036
+ `;
14037
+ try {
14038
+ contextFilePath = savePrepareContextFile(adapter2.getProjectId?.(), callerKey, contextDoc);
14039
+ } catch {
14040
+ contextFilePath = void 0;
14041
+ }
14042
+ }
13932
14043
  if (dispatch === "subagent") {
13933
14044
  const dispatchPrompt = buildSubagentDispatchPrompt({
13934
14045
  tool: "plan",
@@ -13937,16 +14048,34 @@ async function handlePlan(adapter2, config2, args) {
13937
14048
  strategyReviewWarning: result.strategyReviewWarning,
13938
14049
  systemPrompt: result.systemPrompt,
13939
14050
  userMessage: result.userMessage,
13940
- contextBytes: result.contextBytes
14051
+ contextBytes: result.contextBytes,
14052
+ contextFilePath
13941
14053
  });
13942
- const header2 = result.strategyReviewWarning ? `${result.strategyReviewWarning}
14054
+ const dispatchHeader = result.strategyReviewWarning ? `${result.strategyReviewWarning}
13943
14055
 
13944
14056
  ` : "";
13945
- return { ...textResponse(`${header2}${dispatchPrompt}`), _contextBytes: result.contextBytes };
14057
+ return { ...textResponse(`${dispatchHeader}${dispatchPrompt}`), _contextBytes: result.contextBytes };
14058
+ }
14059
+ if (contextFilePath) {
14060
+ const kb = result.contextBytes !== void 0 ? ` (~${(result.contextBytes / 1024).toFixed(0)} KB)` : "";
14061
+ const pathResponse = textResponse(
14062
+ `${header}## PAPI Cycle Plan \u2014 Prepare Phase (${modeLabel} Mode, Cycle ${result.cycleNumber + 1})
14063
+
14064
+ The full planning brief \u2014 system prompt + all context${kb} \u2014 has been written to a local file to keep it off the tool-result channel (it overflows the client ceiling on a mature board):
14065
+
14066
+ \`${contextFilePath}\`
14067
+
14068
+ **Do this:**
14069
+ 1. **Read that file in full** \u2014 it is the system prompt and the entire planning context.
14070
+ 2. Produce the complete plan output in TWO parts: Part 1 markdown with BUILD HANDOFF blocks, then after \`<!-- PAPI_STRUCTURED_OUTPUT -->\` a Part 2 JSON block.
14071
+ 3. **Write your output to a local file**, then call \`plan\` again with:
14072
+ - \`mode\`: "apply"
14073
+ - \`llm_response_file\`: the absolute path to YOUR output file
14074
+ - \`cycle_number\`: ${result.cycleNumber + 1}
14075
+ - \`strategy_review_warning\`: "${result.strategyReviewWarning.replace(/"/g, '\\"')}"`
14076
+ );
14077
+ return { ...pathResponse, _contextBytes: result.contextBytes };
13946
14078
  }
13947
- const modeLabel = result.mode === "bootstrap" ? "Bootstrap" : "Full";
13948
- const header = result.strategyReviewWarning ? `${result.strategyReviewWarning}
13949
- ` : "";
13950
14079
  const response = textResponse(
13951
14080
  `${header}## PAPI Cycle Plan \u2014 Prepare Phase (${modeLabel} Mode, Cycle ${result.cycleNumber + 1})
13952
14081
 
@@ -14564,6 +14693,55 @@ function generateValueReport(snapshots) {
14564
14693
  return lines.join("\n");
14565
14694
  }
14566
14695
 
14696
+ // src/lib/earned-pushback.ts
14697
+ var SIGNAL_MIN_COUNT = 3;
14698
+ var EFFORT_RANK = { XS: 0, S: 1, M: 2, L: 3, XL: 4 };
14699
+ function rankEffort(size2) {
14700
+ if (!size2) return void 0;
14701
+ return EFFORT_RANK[size2];
14702
+ }
14703
+ function computeEarnedPushback(inputs) {
14704
+ const { reports, log: log2, doneTaskIds } = inputs;
14705
+ const drifted = reports.filter(
14706
+ (r) => r.scopeAccuracy && r.scopeAccuracy !== "accurate" || !!r.scopeDriftSignal
14707
+ );
14708
+ const crept = reports.filter((r) => {
14709
+ const actual = rankEffort(r.actualEffort);
14710
+ const estimated = rankEffort(r.estimatedEffort);
14711
+ return actual !== void 0 && estimated !== void 0 && actual > estimated;
14712
+ });
14713
+ const staleness = computeCarryForwardStaleness(log2, doneTaskIds);
14714
+ const driftFires = drifted.length >= SIGNAL_MIN_COUNT;
14715
+ const creepFires = crept.length >= SIGNAL_MIN_COUNT;
14716
+ const deferralFires = staleness !== void 0;
14717
+ if (!driftFires && !creepFires && !deferralFires) return void 0;
14718
+ const sections = [
14719
+ "The following signals crossed a threshold this window and warrant adversarial scrutiny. For each, verify the claim against the actual reports/code before accepting it, and push back proportionally to the stakes \u2014 do NOT wave it through, and do NOT invent pushback beyond what these signals support."
14720
+ ];
14721
+ if (driftFires) {
14722
+ const named = drifted.slice(0, 8).map((r) => {
14723
+ const why = r.scopeDriftSignal ? `files diverged from handoff` : `scope ${r.scopeAccuracy}`;
14724
+ return ` - **${r.displayId ?? r.taskId}** (${r.taskName}) \u2014 ${why}`;
14725
+ }).join("\n");
14726
+ sections.push(
14727
+ `**Scope drift (${drifted.length} build${drifted.length === 1 ? "" : "s"}):** handoff scope did not match what was actually built. Verify whether the plan or the execution was wrong, and whether an Active Decision or planning heuristic needs to change.
14728
+ ${named}`
14729
+ );
14730
+ }
14731
+ if (creepFires) {
14732
+ const named = crept.slice(0, 8).map((r) => ` - **${r.displayId ?? r.taskId}** (${r.taskName}) \u2014 estimated ${r.estimatedEffort}, actual ${r.actualEffort}`).join("\n");
14733
+ sections.push(
14734
+ `**Scope creep (${crept.length} task${crept.length === 1 ? "" : "s"}):** actual effort exceeded the estimate. Check whether these were under-scoped at plan time or grew mid-build, and whether cycle sizing is drifting.
14735
+ ${named}`
14736
+ );
14737
+ }
14738
+ if (deferralFires) {
14739
+ sections.push(`**Repeat deferral (3+ consecutive cycles):**
14740
+ ${staleness}`);
14741
+ }
14742
+ return sections.join("\n\n");
14743
+ }
14744
+
14567
14745
  // src/services/strategy.ts
14568
14746
  var STRATEGY_DUPE_COVERAGE_THRESHOLD = 0.6;
14569
14747
  function taskStatusLabel(task) {
@@ -15071,10 +15249,10 @@ ${unregistered.slice(0, 10).map((f) => `- ${f}`).join("\n")}`;
15071
15249
  try {
15072
15250
  const comments = await adapter2.getRecentTaskComments?.(50);
15073
15251
  if (comments && comments.length > 0) {
15074
- const doneTaskIds = new Set(recentDoneTasks.map((t) => t.id));
15252
+ const doneTaskIds2 = new Set(recentDoneTasks.map((t) => t.id));
15075
15253
  const inReviewTasks = activeTasks.filter((t) => t.status === "In Review");
15076
15254
  const reviewWindowTaskIds = /* @__PURE__ */ new Set([
15077
- ...doneTaskIds,
15255
+ ...doneTaskIds2,
15078
15256
  ...inReviewTasks.map((t) => t.id)
15079
15257
  ]);
15080
15258
  const filtered = comments.filter((c) => reviewWindowTaskIds.has(c.taskId));
@@ -15104,7 +15282,7 @@ ${unregistered.slice(0, 10).map((f) => `- ${f}`).join("\n")}`;
15104
15282
  try {
15105
15283
  if (docsWithPendingActions && docsWithPendingActions.length > 0) {
15106
15284
  const STALE_THRESHOLD = 20;
15107
- const doneTaskIds = new Set(recentDoneTasks.map((t) => t.displayId ?? t.id));
15285
+ const doneTaskIds2 = new Set(recentDoneTasks.map((t) => t.displayId ?? t.id));
15108
15286
  const completed = [];
15109
15287
  const deferred = [];
15110
15288
  const stale = [];
@@ -15114,7 +15292,7 @@ ${unregistered.slice(0, 10).map((f) => `- ${f}`).join("\n")}`;
15114
15292
  const ageInCycles = cycleNumber - (doc.cycleCreated ?? cycleNumber);
15115
15293
  for (const action of pendingActions) {
15116
15294
  const line = ` - **${doc.title}** (C${doc.cycleCreated ?? "?"}): ${action.description}${action.linkedTaskId ? ` [\u2192${action.linkedTaskId}]` : ""}`;
15117
- if (action.linkedTaskId && doneTaskIds.has(action.linkedTaskId)) {
15295
+ if (action.linkedTaskId && doneTaskIds2.has(action.linkedTaskId)) {
15118
15296
  completed.push(line);
15119
15297
  } else if (ageInCycles > STALE_THRESHOLD) {
15120
15298
  stale.push(line);
@@ -15161,6 +15339,10 @@ ${lines.join("\n")}`;
15161
15339
  { label: "taskComments", hasData: taskCommentsText !== void 0 },
15162
15340
  { label: "docActionStaleness", hasData: docActionStalenessText !== void 0 }
15163
15341
  ]);
15342
+ const doneTaskIds = new Set(
15343
+ recentDoneTasks.map((t) => t.displayId ?? t.id).filter((id) => !!id)
15344
+ );
15345
+ const earnedPushback = computeEarnedPushback({ reports, log: recentLog, doneTaskIds });
15164
15346
  const context = {
15165
15347
  sessionNumber: cycleNumber,
15166
15348
  lastReviewCycle: lastReviewCycleNum,
@@ -15169,6 +15351,7 @@ ${lines.join("\n")}`;
15169
15351
  allBuildReports: buildReportsText,
15170
15352
  sessionLog: formatCycleLog(recentLog),
15171
15353
  board: smartBoard,
15354
+ earnedPushback,
15172
15355
  humanReviews: formatReviews(reviews),
15173
15356
  buildPatterns: buildPatternsText,
15174
15357
  reviewPatterns: reviewPatternsText,
@@ -16644,6 +16827,7 @@ var boardViewTool = {
16644
16827
  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.',
16645
16828
  annotations: { title: "View Board", readOnlyHint: true, destructiveHint: false, openWorldHint: false },
16646
16829
  inputSchema: {
16830
+ $schema: "https://json-schema.org/draft/2020-12/schema",
16647
16831
  type: "object",
16648
16832
  properties: {
16649
16833
  task_id: {
@@ -16668,10 +16852,14 @@ var boardViewTool = {
16668
16852
  },
16669
16853
  limit: {
16670
16854
  type: "number",
16855
+ minimum: 1,
16856
+ default: 50,
16671
16857
  description: "Max tasks to return (default: 50)."
16672
16858
  },
16673
16859
  offset: {
16674
16860
  type: "number",
16861
+ minimum: 0,
16862
+ default: 0,
16675
16863
  description: "Skip first N tasks for pagination (default: 0)."
16676
16864
  },
16677
16865
  mode: {
@@ -16688,6 +16876,7 @@ var boardDeprioritiseTool = {
16688
16876
  description: `Remove a task from the current cycle. Four actions: "backlog" (not now, maybe later \u2014 preserves handoff), "defer" (valid but premature \u2014 hidden from planner), "block" (waiting on external dependency \u2014 visible on board but skipped by planner), "cancel" (don't want this \u2014 permanently closed with reason). When a user rejects a task, ALWAYS ask which action they want. Does not call the Anthropic API.`,
16689
16877
  annotations: { title: "Deprioritise Task", readOnlyHint: false, destructiveHint: true, openWorldHint: false },
16690
16878
  inputSchema: {
16879
+ $schema: "https://json-schema.org/draft/2020-12/schema",
16691
16880
  type: "object",
16692
16881
  properties: {
16693
16882
  task_id: {
@@ -16725,7 +16914,21 @@ var boardDeprioritiseTool = {
16725
16914
  description: 'Optional new phase (only applies to "backlog" and "defer" actions).'
16726
16915
  }
16727
16916
  },
16728
- required: ["task_id"]
16917
+ required: ["task_id"],
16918
+ // task-2802: mirror the handler's guards so the agent sees the dependency up
16919
+ // front. handleBoardDeprioritise requires `reason` for both block and cancel,
16920
+ // and requires `blocker_ref` whenever `blocker_type` is set. Keyed on explicit
16921
+ // values — an omitted action defaults to "backlog" and triggers neither.
16922
+ allOf: [
16923
+ {
16924
+ if: { properties: { action: { enum: ["block", "cancel"] } }, required: ["action"] },
16925
+ then: { required: ["reason"] }
16926
+ },
16927
+ {
16928
+ if: { required: ["blocker_type"] },
16929
+ then: { required: ["blocker_ref"] }
16930
+ }
16931
+ ]
16729
16932
  }
16730
16933
  };
16731
16934
  var boardArchiveTool = {
@@ -16752,7 +16955,15 @@ var boardEditTool = {
16752
16955
  description: "Edit fields on an existing task. Supports title, priority, complexity, module, epic, phase, notes (with notes_mode for append/replace/clear), status, maturity, and cycle (number or null). Pass task_id plus any fields to update. Does not call the Anthropic API.",
16753
16956
  annotations: { title: "Edit Task", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
16754
16957
  inputSchema: {
16958
+ $schema: "https://json-schema.org/draft/2020-12/schema",
16755
16959
  type: "object",
16960
+ // task-2802: shared effort-size enum referenced by estimated_effort/actual_effort.
16961
+ $defs: {
16962
+ effortSize: {
16963
+ type: "string",
16964
+ enum: ["XS", "S", "M", "L", "XL"]
16965
+ }
16966
+ },
16756
16967
  properties: {
16757
16968
  task_id: {
16758
16969
  type: "string",
@@ -16808,13 +17019,11 @@ var boardEditTool = {
16808
17019
  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."
16809
17020
  },
16810
17021
  estimated_effort: {
16811
- type: "string",
16812
- enum: ["XS", "S", "M", "L", "XL"],
17022
+ $ref: "#/$defs/effortSize",
16813
17023
  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."
16814
17024
  },
16815
17025
  actual_effort: {
16816
- type: "string",
16817
- enum: ["XS", "S", "M", "L", "XL"],
17026
+ $ref: "#/$defs/effortSize",
16818
17027
  description: "task-2182: correct the actual effort on this task's LATEST build report (fixes a mis-recorded actual)."
16819
17028
  }
16820
17029
  },
@@ -17246,6 +17455,9 @@ var PROJECT_BUNDLE_REL = join6(".agents", "skills", "papi-cycle");
17246
17455
  function bundleDestRel(rel) {
17247
17456
  return rel === "AGENTS.md" ? "AGENTS.md" : join6(PROJECT_BUNDLE_REL, rel);
17248
17457
  }
17458
+ function claudeSkillDestRel(rel) {
17459
+ return rel === "AGENTS.md" ? void 0 : join6(".claude", "skills", rel);
17460
+ }
17249
17461
  function resolveBundleDir() {
17250
17462
  let dir = dirname(fileURLToPath(import.meta.url));
17251
17463
  for (let i = 0; i < 5; i++) {
@@ -17274,10 +17486,13 @@ function readBundleFiles(bundleDir = resolveBundleDir()) {
17274
17486
  function planBundleInstall(projectRoot, projectName, opts = {}) {
17275
17487
  const out = {};
17276
17488
  for (const f of readBundleFiles()) {
17277
- const dest = join6(projectRoot, bundleDestRel(f.rel));
17278
- if (opts.skipExisting && existsSync4(dest) && statSync4(dest).isFile()) continue;
17279
17489
  const content = f.rel === "AGENTS.md" ? f.content.replace(/\{\{project_name\}\}/g, projectName) : f.content;
17280
- out[dest] = content;
17490
+ for (const rel of [bundleDestRel(f.rel), claudeSkillDestRel(f.rel)]) {
17491
+ if (!rel) continue;
17492
+ const dest = join6(projectRoot, rel);
17493
+ if (opts.skipExisting && existsSync4(dest) && statSync4(dest).isFile()) continue;
17494
+ out[dest] = content;
17495
+ }
17281
17496
  }
17282
17497
  return out;
17283
17498
  }
@@ -17565,7 +17780,7 @@ var CLAUDE_MD_ENRICHMENT_SENTINEL_T1 = "<!-- PAPI_ENRICHMENT_TIER_1 -->";
17565
17780
  var CLAUDE_MD_ENRICHMENT_SENTINEL_T2 = "<!-- PAPI_ENRICHMENT_TIER_2 -->";
17566
17781
  var CLAUDE_MD_STUB = `# {{project_name}}
17567
17782
 
17568
- This project is managed with **PAPI**. The agent harness \u2014 session workflow, the plan \u2192 build \u2192 review cycle, branching, and conventions \u2014 lives in **\`AGENTS.md\`** (always loaded) plus lazy-loaded phase skills under \`.agents/skills/papi-cycle/\`.
17783
+ This project is managed with **PAPI**. The agent harness \u2014 session workflow, the plan \u2192 build \u2192 review cycle, branching, and conventions \u2014 lives in **\`AGENTS.md\`** (always loaded) plus lazy-loaded phase skills under \`.agents/skills/papi-cycle/\` (Claude Code loads the same phase skills natively from \`.claude/skills/papi-*\`).
17569
17784
 
17570
17785
  **At session start: read \`AGENTS.md\`, then run \`orient\`.** Phase mechanics (planning, building, strategy, ideas) load on demand from the skills bundle. Add project-specific notes below this line \u2014 they will not be overwritten.
17571
17786
 
@@ -20618,6 +20833,32 @@ async function ownsLocalWorkspace(adapter2, cwd) {
20618
20833
  return storedOwner === remoteOwner;
20619
20834
  }
20620
20835
 
20836
+ // src/lib/worktree-collision.ts
20837
+ var WORKTREE_DIR = ".claude/worktrees";
20838
+ function detectWorktreeCollision(input) {
20839
+ const target = input.targetBranch.trim();
20840
+ if (!target) return null;
20841
+ const conflicts = input.inProgress.filter((b2) => {
20842
+ const branch = b2.branch?.trim();
20843
+ return !!branch && branch !== target && b2.taskId !== input.taskId;
20844
+ });
20845
+ if (conflicts.length === 0) return null;
20846
+ const worktreePath = `${WORKTREE_DIR}/${input.taskId}`;
20847
+ const worktreeCommand = `git worktree add ${worktreePath} ${target}`;
20848
+ const others = conflicts.map((c) => `${c.taskId} (on '${c.branch.trim()}')`).join(", ");
20849
+ const isAre = conflicts.length === 1 ? "is" : "are";
20850
+ const warning = `\u26A0\uFE0F Concurrent build detected: ${others} ${isAre} In Progress on a different branch, but this build targets '${target}'. Switching HEAD on the shared checkout would revert that session's working tree (the task-2346 data-loss incident). Isolate this build into its own worktree instead of switching branches here.`;
20851
+ const setupHint = `Then run \`npm run worktree:setup\` inside ${worktreePath} \u2014 node_modules only exists in the main worktree, so the symlink step is required before tsup/builds work there (task-1419).`;
20852
+ return {
20853
+ warning,
20854
+ worktreeCommand,
20855
+ setupHint,
20856
+ worktreePath,
20857
+ auto: input.autoWorktree,
20858
+ conflicts
20859
+ };
20860
+ }
20861
+
20621
20862
  // src/services/build.ts
20622
20863
  init_git();
20623
20864
 
@@ -21221,7 +21462,46 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21221
21462
  }
21222
21463
  taskBranchMap.set(taskId, featureBranch);
21223
21464
  const currentBranch = getCurrentBranch(config2.projectRoot);
21224
- if (currentBranch === featureBranch) {
21465
+ let isolatedIntoWorktree = false;
21466
+ if (currentBranch !== featureBranch) {
21467
+ const otherInProgress = allTasks.filter((t) => t.status === "In Progress" && t.id !== taskId && t.displayId !== taskId).map((t) => ({
21468
+ taskId: t.displayId || t.id,
21469
+ branch: (t.branchName ?? taskBranchMap.get(t.id) ?? taskBranchMap.get(t.displayId) ?? "").trim()
21470
+ })).filter((t) => t.branch.length > 0);
21471
+ const collision = detectWorktreeCollision({
21472
+ taskId,
21473
+ targetBranch: featureBranch,
21474
+ inProgress: otherInProgress,
21475
+ autoWorktree: config2.autoWorktree
21476
+ });
21477
+ if (collision) {
21478
+ branchLines.push(collision.warning);
21479
+ if (collision.auto) {
21480
+ try {
21481
+ const { execFileSync: execFileSync7 } = await import("child_process");
21482
+ const baseForWorktree = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
21483
+ const wtArgs = branchExists(config2.projectRoot, featureBranch) ? ["worktree", "add", collision.worktreePath, featureBranch] : ["worktree", "add", "-b", featureBranch, collision.worktreePath, baseForWorktree];
21484
+ execFileSync7("git", wtArgs, { cwd: config2.projectRoot, encoding: "utf-8" });
21485
+ isolatedIntoWorktree = true;
21486
+ branchLines.push(
21487
+ `Auto-isolated (PAPI_AUTO_WORKTREE): created worktree at ${collision.worktreePath} on '${featureBranch}'. The shared checkout stays on '${currentBranch}' \u2014 continue this build from inside the worktree.`
21488
+ );
21489
+ branchLines.push(collision.setupHint);
21490
+ } catch (err) {
21491
+ branchLines.push(
21492
+ `Auto-worktree attempt failed (${err instanceof Error ? err.message : String(err)}) \u2014 isolate manually instead:`
21493
+ );
21494
+ branchLines.push(` ${collision.worktreeCommand}`);
21495
+ branchLines.push(collision.setupHint);
21496
+ }
21497
+ } else {
21498
+ branchLines.push(` ${collision.worktreeCommand}`);
21499
+ branchLines.push(collision.setupHint);
21500
+ }
21501
+ }
21502
+ }
21503
+ if (isolatedIntoWorktree) {
21504
+ } else if (currentBranch === featureBranch) {
21225
21505
  branchLines.push(`Already on branch '${featureBranch}'.`);
21226
21506
  if (useSharedBranch) {
21227
21507
  branchLines.push(`Reusing shared cycle branch for ${task.complexity} ${task.module} task.`);
@@ -22561,7 +22841,17 @@ var buildExecuteTool = {
22561
22841
  description: "Start or complete a build task. Call with just task_id to start (returns BUILD HANDOFF, creates feature branch, marks In Progress). After implementing the task, you MUST call build_execute again with all report fields (completed, effort, estimated_effort, surprises, discovered_issues, architecture_notes) to finish \u2014 do not wait for user confirmation between start and complete. Never call on tasks that are already In Review or Done. Does not call the Anthropic API. Set light=true to skip branch/PR creation (commits to current branch). Set PAPI_LIGHT_MODE=true in env to default all builds to light mode.",
22562
22842
  annotations: { title: "Run Build", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
22563
22843
  inputSchema: {
22844
+ $schema: "https://json-schema.org/draft/2020-12/schema",
22564
22845
  type: "object",
22846
+ // task-2802: JSON Schema 2020-12. Shared effort-size enum lives in $defs and is
22847
+ // referenced from effort/estimated_effort so the two stay in lockstep; the
22848
+ // start-vs-complete contract is expressed as a conditional (see allOf below).
22849
+ $defs: {
22850
+ effortSize: {
22851
+ type: "string",
22852
+ enum: ["XS", "S", "M", "L", "XL"]
22853
+ }
22854
+ },
22565
22855
  properties: {
22566
22856
  task_id: {
22567
22857
  type: "string",
@@ -22581,13 +22871,11 @@ var buildExecuteTool = {
22581
22871
  description: `task-2833: set true to assert every acceptance criterion in the task's BUILD HANDOFF was met. Required to record a completed:"yes" build when the handoff lists acceptance criteria \u2014 without it, build_execute returns the criteria checklist and does NOT mark the task Done (the report is not discarded; re-send with acceptance_confirmed:true). Tasks with no acceptance criteria, and completed:"partial"/"no", are unaffected.`
22582
22872
  },
22583
22873
  effort: {
22584
- type: "string",
22585
- enum: ["XS", "S", "M", "L", "XL"],
22874
+ $ref: "#/$defs/effortSize",
22586
22875
  description: "Actual effort: XS, S, M, L, or XL. Required for complete."
22587
22876
  },
22588
22877
  estimated_effort: {
22589
- type: "string",
22590
- enum: ["XS", "S", "M", "L", "XL"],
22878
+ $ref: "#/$defs/effortSize",
22591
22879
  description: "Estimated effort from the BUILD HANDOFF. Required for complete."
22592
22880
  },
22593
22881
  model: {
@@ -22631,6 +22919,8 @@ var buildExecuteTool = {
22631
22919
  },
22632
22920
  corrections_count: {
22633
22921
  type: "integer",
22922
+ minimum: 0,
22923
+ default: 0,
22634
22924
  description: "Number of times the user corrected or redirected the build during implementation. Captures informal pushback that bypasses review_submit. Default 0."
22635
22925
  },
22636
22926
  dead_ends: {
@@ -22681,7 +22971,29 @@ var buildExecuteTool = {
22681
22971
  }
22682
22972
  }
22683
22973
  },
22684
- required: ["task_id"]
22974
+ required: ["task_id"],
22975
+ // task-2802: a COMPLETE call is any call carrying report data (mirrors the
22976
+ // handler's isCompleteCall at build.ts). When any of the six report fields is
22977
+ // present, all six are required — exactly what completeBuild enforces, so this
22978
+ // never rejects a call the handler would accept; it just steers the agent to
22979
+ // send the full report in one shot instead of round-tripping on a missing field.
22980
+ allOf: [
22981
+ {
22982
+ if: {
22983
+ anyOf: [
22984
+ { required: ["completed"] },
22985
+ { required: ["effort"] },
22986
+ { required: ["estimated_effort"] },
22987
+ { required: ["surprises"] },
22988
+ { required: ["discovered_issues"] },
22989
+ { required: ["architecture_notes"] }
22990
+ ]
22991
+ },
22992
+ then: {
22993
+ required: ["completed", "effort", "estimated_effort", "surprises", "discovered_issues", "architecture_notes"]
22994
+ }
22995
+ }
22996
+ ]
22685
22997
  }
22686
22998
  };
22687
22999
  var buildCancelTool = {
@@ -23456,12 +23768,460 @@ Re-submit with \`notes: "... Reference: <path>"\` to link one, or ignore if none
23456
23768
  return textResponse(`${result.message}${overrideNote}`);
23457
23769
  }
23458
23770
 
23771
+ // src/services/import.ts
23772
+ import { randomUUID as randomUUID13 } from "crypto";
23773
+ var IMPLEMENTED_SOURCES = ["csv", "markdown", "linear"];
23774
+ var MAX_TITLE_LEN = 200;
23775
+ var MAX_NOTES_LEN = 2e3;
23776
+ function sanitiseText(value, maxLen, keepNewlines = false) {
23777
+ let str;
23778
+ if (typeof value === "string") str = value;
23779
+ else if (typeof value === "number" || typeof value === "boolean") str = String(value);
23780
+ else if (value == null) str = "";
23781
+ else {
23782
+ try {
23783
+ str = String(value);
23784
+ } catch {
23785
+ str = "";
23786
+ }
23787
+ }
23788
+ if (keepNewlines) {
23789
+ str = str.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, " ");
23790
+ str = str.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n");
23791
+ } else {
23792
+ str = str.replace(/[\x00-\x1F\x7F-\x9F]/g, " ");
23793
+ str = str.replace(/\s+/g, " ");
23794
+ }
23795
+ str = str.trim();
23796
+ if (str.length > maxLen) str = str.slice(0, maxLen).trim();
23797
+ return str;
23798
+ }
23799
+ function mapPriorityString(value) {
23800
+ const v = String(value ?? "").trim().toLowerCase();
23801
+ if (/\b(p0|urgent|critical|highest|blocker)\b/.test(v)) return "P0 Critical";
23802
+ if (/\b(p1|high)\b/.test(v)) return "P1 High";
23803
+ if (/\b(p3|low|lowest|minor)\b/.test(v)) return "P3 Low";
23804
+ return "P2 Medium";
23805
+ }
23806
+ function mapPriorityNumber(value) {
23807
+ const n = typeof value === "number" ? value : Number(value);
23808
+ switch (n) {
23809
+ case 1:
23810
+ return "P0 Critical";
23811
+ case 2:
23812
+ return "P1 High";
23813
+ case 4:
23814
+ return "P3 Low";
23815
+ case 3:
23816
+ case 0:
23817
+ default:
23818
+ return "P2 Medium";
23819
+ }
23820
+ }
23821
+ function mapStatusString(value) {
23822
+ const v = String(value ?? "").trim().toLowerCase();
23823
+ if (/\b(done|complete|completed|closed|merged|shipped|resolved)\b/.test(v)) return "Done";
23824
+ if (/\b(cancel|cancelled|canceled|wont.?fix|duplicate|abandoned)\b/.test(v)) return "Cancelled";
23825
+ if (/\b(in.?review|review|qa|verifying)\b/.test(v)) return "In Review";
23826
+ if (/\b(in.?progress|started|doing|active|wip)\b/.test(v)) return "In Progress";
23827
+ if (/\b(blocked|waiting|on.?hold)\b/.test(v)) return "Blocked";
23828
+ return "Backlog";
23829
+ }
23830
+ function estimateToComplexity(value) {
23831
+ const n = typeof value === "number" ? value : Number(value);
23832
+ if (!Number.isFinite(n) || n <= 0) return "Small";
23833
+ if (n <= 1) return "XS";
23834
+ if (n <= 2) return "Small";
23835
+ if (n <= 3) return "Medium";
23836
+ if (n <= 5) return "Large";
23837
+ return "XL";
23838
+ }
23839
+ function mapComplexityField(value) {
23840
+ const v = String(value ?? "").trim();
23841
+ if (v !== "" && /^\d+(\.\d+)?$/.test(v)) return estimateToComplexity(v);
23842
+ return normalizeComplexity(v);
23843
+ }
23844
+ function parseCsv(text) {
23845
+ const rows = [];
23846
+ let field = "";
23847
+ let row = [];
23848
+ let inQuotes = false;
23849
+ for (let i = 0; i < text.length; i++) {
23850
+ const c = text[i];
23851
+ if (inQuotes) {
23852
+ if (c === '"') {
23853
+ if (text[i + 1] === '"') {
23854
+ field += '"';
23855
+ i++;
23856
+ } else {
23857
+ inQuotes = false;
23858
+ }
23859
+ } else {
23860
+ field += c;
23861
+ }
23862
+ continue;
23863
+ }
23864
+ if (c === '"') {
23865
+ inQuotes = true;
23866
+ } else if (c === ",") {
23867
+ row.push(field);
23868
+ field = "";
23869
+ } else if (c === "\n" || c === "\r") {
23870
+ if (c === "\r" && text[i + 1] === "\n") i++;
23871
+ row.push(field);
23872
+ field = "";
23873
+ if (row.some((f) => f.trim() !== "")) rows.push(row);
23874
+ row = [];
23875
+ } else {
23876
+ field += c;
23877
+ }
23878
+ }
23879
+ if (field !== "" || row.length > 0) {
23880
+ row.push(field);
23881
+ if (row.some((f) => f.trim() !== "")) rows.push(row);
23882
+ }
23883
+ return rows;
23884
+ }
23885
+ function findColumn(header, candidates) {
23886
+ const lower = header.map((h) => h.trim().toLowerCase());
23887
+ for (const cand of candidates) {
23888
+ const idx = lower.indexOf(cand);
23889
+ if (idx !== -1) return idx;
23890
+ }
23891
+ return -1;
23892
+ }
23893
+ var csvNormaliser = ({ raw }) => {
23894
+ if (!raw || !raw.trim()) return [];
23895
+ const rows = parseCsv(raw);
23896
+ if (rows.length < 2) return [];
23897
+ const header = rows[0];
23898
+ const titleCol = findColumn(header, ["title", "name", "task", "summary", "subject"]);
23899
+ if (titleCol === -1) {
23900
+ throw new Error(
23901
+ "CSV import: no recognisable title column. Expected a header row with one of: title, name, task, summary, subject."
23902
+ );
23903
+ }
23904
+ const notesCol = findColumn(header, ["notes", "description", "details", "body"]);
23905
+ const statusCol = findColumn(header, ["status", "state"]);
23906
+ const priorityCol = findColumn(header, ["priority", "importance"]);
23907
+ const complexityCol = findColumn(header, ["complexity", "estimate", "size", "points", "effort"]);
23908
+ const idCol = findColumn(header, ["id", "key", "identifier", "ref"]);
23909
+ const out = [];
23910
+ for (let r = 1; r < rows.length; r++) {
23911
+ const cells = rows[r];
23912
+ const title = sanitiseText(cells[titleCol], MAX_TITLE_LEN);
23913
+ if (!title) continue;
23914
+ out.push({
23915
+ title,
23916
+ notes: notesCol === -1 ? "" : sanitiseText(cells[notesCol], MAX_NOTES_LEN, true),
23917
+ status: statusCol === -1 ? "Backlog" : mapStatusString(cells[statusCol]),
23918
+ priority: priorityCol === -1 ? "P2 Medium" : mapPriorityString(cells[priorityCol]),
23919
+ complexity: complexityCol === -1 ? "Small" : mapComplexityField(cells[complexityCol]),
23920
+ sourceId: idCol === -1 ? void 0 : sanitiseText(cells[idCol], 80) || void 0
23921
+ });
23922
+ }
23923
+ return out;
23924
+ };
23925
+ var markdownNormaliser = ({ raw }) => {
23926
+ if (!raw || !raw.trim()) return [];
23927
+ const lines = raw.split(/\r?\n/);
23928
+ const tableHeaderIdx = lines.findIndex(
23929
+ (l, i) => /\|/.test(l) && lines[i + 1] !== void 0 && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && /-/.test(lines[i + 1])
23930
+ );
23931
+ if (tableHeaderIdx !== -1) {
23932
+ const splitRow = (l) => l.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map((c) => c.trim());
23933
+ const header = splitRow(lines[tableHeaderIdx]);
23934
+ const titleCol = findColumn(header, ["title", "name", "task", "summary", "subject"]);
23935
+ if (titleCol === -1) {
23936
+ throw new Error(
23937
+ "Markdown table import: no recognisable title column. Expected a header cell of: title, name, task, summary, subject."
23938
+ );
23939
+ }
23940
+ const notesCol = findColumn(header, ["notes", "description", "details", "body"]);
23941
+ const statusCol = findColumn(header, ["status", "state"]);
23942
+ const priorityCol = findColumn(header, ["priority", "importance"]);
23943
+ const complexityCol = findColumn(header, ["complexity", "estimate", "size", "points", "effort"]);
23944
+ const idCol = findColumn(header, ["id", "key", "identifier", "ref"]);
23945
+ const out2 = [];
23946
+ for (let i = tableHeaderIdx + 2; i < lines.length; i++) {
23947
+ const l = lines[i];
23948
+ if (!/\|/.test(l) || l.trim() === "") break;
23949
+ const cells = splitRow(l);
23950
+ const title = sanitiseText(cells[titleCol], MAX_TITLE_LEN);
23951
+ if (!title) continue;
23952
+ out2.push({
23953
+ title,
23954
+ notes: notesCol === -1 ? "" : sanitiseText(cells[notesCol], MAX_NOTES_LEN, true),
23955
+ status: statusCol === -1 ? "Backlog" : mapStatusString(cells[statusCol]),
23956
+ priority: priorityCol === -1 ? "P2 Medium" : mapPriorityString(cells[priorityCol]),
23957
+ complexity: complexityCol === -1 ? "Small" : mapComplexityField(cells[complexityCol]),
23958
+ sourceId: idCol === -1 ? void 0 : sanitiseText(cells[idCol], 80) || void 0
23959
+ });
23960
+ }
23961
+ return out2;
23962
+ }
23963
+ const out = [];
23964
+ const checklistRe = /^\s*(?:[-*+]|\d+\.)\s+(?:\[( |x|X)\]\s+)?(.*\S)\s*$/;
23965
+ for (const line of lines) {
23966
+ const m = line.match(checklistRe);
23967
+ if (!m) continue;
23968
+ const checked = m[1];
23969
+ const title = sanitiseText(m[2], MAX_TITLE_LEN);
23970
+ if (!title) continue;
23971
+ out.push({
23972
+ title,
23973
+ notes: "",
23974
+ status: checked && checked.toLowerCase() === "x" ? "Done" : "Backlog",
23975
+ priority: "P2 Medium",
23976
+ complexity: "Small"
23977
+ });
23978
+ }
23979
+ return out;
23980
+ };
23981
+ function isRecord(v) {
23982
+ return typeof v === "object" && v !== null;
23983
+ }
23984
+ var linearNormaliser = ({ rows }) => {
23985
+ if (!rows || rows.length === 0) return [];
23986
+ const out = [];
23987
+ for (const row of rows) {
23988
+ if (!isRecord(row)) continue;
23989
+ const issue = row;
23990
+ const title = sanitiseText(issue.title, MAX_TITLE_LEN);
23991
+ if (!title) continue;
23992
+ const stateName = isRecord(issue.state) ? issue.state.name ?? issue.state.type : issue.state;
23993
+ const identifier = sanitiseText(issue.identifier, 80) || sanitiseText(issue.id, 80) || void 0;
23994
+ const url = sanitiseText(issue.url, 300);
23995
+ const description = sanitiseText(issue.description, MAX_NOTES_LEN, true);
23996
+ const notesParts = [];
23997
+ if (description) notesParts.push(description);
23998
+ if (url) notesParts.push(`Reference: ${url}`);
23999
+ out.push({
24000
+ title,
24001
+ notes: sanitiseText(notesParts.join("\n"), MAX_NOTES_LEN, true),
24002
+ status: mapStatusString(stateName),
24003
+ priority: mapPriorityNumber(issue.priority),
24004
+ complexity: issue.estimate == null ? "Small" : estimateToComplexity(issue.estimate),
24005
+ sourceId: identifier
24006
+ });
24007
+ }
24008
+ return out;
24009
+ };
24010
+ function notImplemented(source) {
24011
+ return () => {
24012
+ throw new Error(
24013
+ `Import source "${source}" is not implemented yet. Shipped sources: ${IMPLEMENTED_SOURCES.join(", ")}. To add "${source}", implement a normaliser in packages/server/src/services/import.ts (see the "EXTENDING THE IMPORTER" header comment) \u2014 the host reads "${source}" via its own MCP tool and passes rows here; PAPI never connects to it directly.`
24014
+ );
24015
+ };
24016
+ }
24017
+ var SOURCE_NORMALISERS = {
24018
+ csv: csvNormaliser,
24019
+ markdown: markdownNormaliser,
24020
+ linear: linearNormaliser,
24021
+ // Extension points — build these by replacing notImplemented() with a real normaliser.
24022
+ trello: notImplemented("trello"),
24023
+ todoist: notImplemented("todoist"),
24024
+ notion: notImplemented("notion")
24025
+ };
24026
+ function importMarker(source, sourceId) {
24027
+ return `[papi-import:${source}:${sourceId}]`;
24028
+ }
24029
+ function dedupTitleKey(title) {
24030
+ return title.toLowerCase().replace(/[^a-z0-9\s]/g, "").replace(/\s+/g, " ").trim();
24031
+ }
24032
+ async function importBacklog(adapter2, input) {
24033
+ const normaliser = SOURCE_NORMALISERS[input.source];
24034
+ if (!normaliser) {
24035
+ throw new Error(
24036
+ `Unknown import source "${input.source}". Valid sources: ${Object.keys(SOURCE_NORMALISERS).join(", ")}.`
24037
+ );
24038
+ }
24039
+ const normalised = normaliser({ raw: input.raw, rows: input.rows });
24040
+ const existing = await adapter2.queryBoard({});
24041
+ const existingTitleKeys = /* @__PURE__ */ new Set();
24042
+ const existingMarkers = /* @__PURE__ */ new Set();
24043
+ for (const t of existing) {
24044
+ existingTitleKeys.add(dedupTitleKey(t.title));
24045
+ const notes = t.notes ?? "";
24046
+ const markerMatches = notes.match(/\[papi-import:[^\]]+\]/g);
24047
+ if (markerMatches) for (const mk of markerMatches) existingMarkers.add(mk);
24048
+ }
24049
+ const health = await adapter2.getCycleHealth();
24050
+ warnIfEmpty("getCycleHealth (import)", health);
24051
+ const createdCycle = health.totalCycles;
24052
+ const targetModule = input.module && input.module.trim() || "Core";
24053
+ const createdTasks = [];
24054
+ const skippedTitles = [];
24055
+ const seenThisBatch = /* @__PURE__ */ new Set();
24056
+ for (const task of normalised) {
24057
+ const titleKey = dedupTitleKey(task.title);
24058
+ const marker = task.sourceId ? importMarker(input.source, task.sourceId) : null;
24059
+ const isDup = marker !== null && existingMarkers.has(marker) || existingTitleKeys.has(titleKey) || seenThisBatch.has(marker ?? titleKey);
24060
+ if (isDup) {
24061
+ skippedTitles.push(task.title);
24062
+ continue;
24063
+ }
24064
+ seenThisBatch.add(marker ?? titleKey);
24065
+ if (input.dryRun) {
24066
+ createdTasks.push({ id: "(dry-run)", title: task.title });
24067
+ continue;
24068
+ }
24069
+ const notes = marker ? `${task.notes ? `${task.notes}
24070
+
24071
+ ` : ""}${marker}`.trim() : task.notes;
24072
+ const created = await adapter2.createTask({
24073
+ uuid: randomUUID13(),
24074
+ displayId: "",
24075
+ title: task.title,
24076
+ status: task.status,
24077
+ priority: task.priority,
24078
+ complexity: task.complexity,
24079
+ module: targetModule,
24080
+ epic: "Platform",
24081
+ phase: "Unscoped",
24082
+ owner: "TBD",
24083
+ reviewed: false,
24084
+ createdCycle,
24085
+ notes,
24086
+ taskType: "task",
24087
+ maturity: "raw",
24088
+ source: `import:${input.source}`
24089
+ });
24090
+ createdTasks.push({ id: created.id, title: created.title });
24091
+ existingTitleKeys.add(titleKey);
24092
+ if (marker) existingMarkers.add(marker);
24093
+ }
24094
+ return {
24095
+ source: input.source,
24096
+ normalised: normalised.length,
24097
+ imported: input.dryRun ? 0 : createdTasks.length,
24098
+ skipped: skippedTitles.length,
24099
+ skippedTitles,
24100
+ createdTasks,
24101
+ dryRun: input.dryRun === true
24102
+ };
24103
+ }
24104
+
24105
+ // src/tools/import.ts
24106
+ var ALL_SOURCES = ["csv", "markdown", "linear", "trello", "todoist", "notion"];
24107
+ var backlogImportTool = {
24108
+ name: "backlog_import",
24109
+ annotations: { title: "Import Backlog", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
24110
+ description: `Import an existing backlog from another tool into PAPI as Backlog tasks, removing the "start from an empty board" wall. PAPI never connects to the source tool \u2014 YOU (the host) read the export and pass it here. Flat-file sources (csv, markdown) take a \`raw\` string; structured sources (linear) take a \`rows\` array you read via that tool's MCP. Import is one-way and idempotent: re-running the same import does NOT create duplicates. Shipped sources: ${IMPLEMENTED_SOURCES.join(", ")}. trello/todoist/notion are reserved but not implemented yet. Does not call the Anthropic API.`,
24111
+ inputSchema: {
24112
+ type: "object",
24113
+ properties: {
24114
+ source: {
24115
+ type: "string",
24116
+ enum: ALL_SOURCES,
24117
+ description: "The tool the backlog came from. csv/markdown read a `raw` string; linear reads a `rows` array of issues. trello/todoist/notion are reserved extension points (not implemented yet)."
24118
+ },
24119
+ raw: {
24120
+ type: "string",
24121
+ description: "Raw export text for flat-file sources. CSV: a header row + data rows (title/name column required; optional notes/description, status/state, priority, complexity/estimate, id). Markdown: a checklist (`- [ ] Task`) or a GitHub-style table."
24122
+ },
24123
+ rows: {
24124
+ type: "array",
24125
+ description: "Pre-read rows for structured sources. For linear: an array of issues you read via the Linear MCP, each like { identifier, title, description, state: { name }, priority (0-4), estimate, url }.",
24126
+ items: { type: "object" }
24127
+ },
24128
+ module: {
24129
+ type: "string",
24130
+ description: 'Module to file imported tasks under (default "Core").'
24131
+ },
24132
+ dry_run: {
24133
+ type: "boolean",
24134
+ description: "When true, normalise and dedup-check without writing anything. Use this to preview what would be imported. Default: false."
24135
+ },
24136
+ project: {
24137
+ type: "string",
24138
+ description: "Project id (UUID) or slug to import into, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise."
24139
+ }
24140
+ },
24141
+ required: ["source"]
24142
+ }
24143
+ };
24144
+ function diagnostic(lastStep, error, hint) {
24145
+ return errorResponse(JSON.stringify({ tool: "backlog_import", lastStep, error, hint }, null, 2));
24146
+ }
24147
+ function formatResult(result, overrideNote) {
24148
+ const lines = [];
24149
+ const verb = result.dryRun ? "Would import" : "Imported";
24150
+ lines.push(
24151
+ `${verb} ${result.dryRun ? result.normalised - result.skipped : result.imported} task(s) from ${result.source}${overrideNote}.`
24152
+ );
24153
+ lines.push(` normalised: ${result.normalised} \xB7 ${result.dryRun ? "would-write" : "written"}: ${result.dryRun ? result.normalised - result.skipped : result.imported} \xB7 skipped (duplicates): ${result.skipped}`);
24154
+ if (result.createdTasks.length > 0 && !result.dryRun) {
24155
+ const preview = result.createdTasks.slice(0, 10).map((t) => ` - ${t.id}: ${t.title}`);
24156
+ lines.push("Created:");
24157
+ lines.push(...preview);
24158
+ if (result.createdTasks.length > 10) lines.push(` \u2026and ${result.createdTasks.length - 10} more`);
24159
+ }
24160
+ if (result.skipped > 0) {
24161
+ const dupPreview = result.skippedTitles.slice(0, 5).map((t) => ` - ${t}`);
24162
+ lines.push(`Skipped ${result.skipped} duplicate(s) (already on the board or repeated in this import):`);
24163
+ lines.push(...dupPreview);
24164
+ if (result.skippedTitles.length > 5) lines.push(` \u2026and ${result.skippedTitles.length - 5} more`);
24165
+ }
24166
+ if (!result.dryRun && result.imported > 0) {
24167
+ lines.push("\nRun `plan` to triage and scope the imported backlog into a cycle.");
24168
+ }
24169
+ return lines.join("\n");
24170
+ }
24171
+ async function handleBacklogImport(adapter2, args) {
24172
+ const source = args.source?.trim();
24173
+ if (!source) {
24174
+ return errorResponse(`source is required \u2014 one of: ${ALL_SOURCES.join(", ")}.`);
24175
+ }
24176
+ if (!ALL_SOURCES.includes(source)) {
24177
+ return errorResponse(`Unknown source "${source}". Valid: ${ALL_SOURCES.join(", ")}.`);
24178
+ }
24179
+ const raw = args.raw;
24180
+ const rawRows = args.rows;
24181
+ const rows = Array.isArray(rawRows) ? rawRows : void 0;
24182
+ const isFlatFile = source === "csv" || source === "markdown";
24183
+ if (isFlatFile && (!raw || !raw.trim())) {
24184
+ return errorResponse(`Source "${source}" needs a \`raw\` string (the exported ${source} text).`);
24185
+ }
24186
+ if (!isFlatFile && IMPLEMENTED_SOURCES.includes(source) && (!rows || rows.length === 0)) {
24187
+ return errorResponse(`Source "${source}" needs a \`rows\` array of issues you read via that tool's MCP.`);
24188
+ }
24189
+ let target = adapter2;
24190
+ let overrideNote = "";
24191
+ try {
24192
+ ({ adapter: target, overrideNote } = await resolvePerCallProjectAdapter(adapter2, args));
24193
+ } catch (err) {
24194
+ if (err instanceof ProjectResolutionError) return errorResponse(err.message);
24195
+ throw err;
24196
+ }
24197
+ try {
24198
+ const result = await importBacklog(target, {
24199
+ source,
24200
+ raw,
24201
+ rows,
24202
+ module: args.module,
24203
+ dryRun: args.dry_run === true
24204
+ });
24205
+ return textResponse(formatResult(result, overrideNote));
24206
+ } catch (err) {
24207
+ const message = err instanceof Error ? err.message : String(err);
24208
+ if (/not implemented yet|no recognisable title column/i.test(message)) {
24209
+ return errorResponse(message);
24210
+ }
24211
+ return diagnostic(
24212
+ "importBacklog",
24213
+ message,
24214
+ "Check the export shape matches the source (csv/markdown need `raw`; linear needs `rows`). Re-run with dry_run:true to preview."
24215
+ );
24216
+ }
24217
+ }
24218
+
23459
24219
  // src/tools/bug.ts
23460
24220
  import os from "os";
23461
24221
  init_git();
23462
24222
 
23463
24223
  // src/services/bug.ts
23464
- import { randomUUID as randomUUID13 } from "crypto";
24224
+ import { randomUUID as randomUUID14 } from "crypto";
23465
24225
  function resolveCurrentPhase2(phases) {
23466
24226
  if (phases.length === 0) return "Unscoped";
23467
24227
  const inProgress = phases.find((p) => p.status === "In Progress");
@@ -23487,7 +24247,7 @@ async function captureBug(adapter2, input) {
23487
24247
  warnIfEmpty("getCycleHealth (bug)", health);
23488
24248
  const phase = input.phase || resolveCurrentPhase2(phases);
23489
24249
  return adapter2.createTask({
23490
- uuid: randomUUID13(),
24250
+ uuid: randomUUID14(),
23491
24251
  displayId: "",
23492
24252
  title: input.text,
23493
24253
  status: "Backlog",
@@ -23741,7 +24501,7 @@ ${lines.join("\n")}`
23741
24501
  init_git();
23742
24502
 
23743
24503
  // src/services/ad-hoc.ts
23744
- import { randomUUID as randomUUID14 } from "crypto";
24504
+ import { randomUUID as randomUUID15 } from "crypto";
23745
24505
  function resolveAdHocCycle(cycle, latest, latestComplete) {
23746
24506
  if (cycle === void 0) return null;
23747
24507
  if (typeof cycle === "number") return cycle;
@@ -23781,7 +24541,7 @@ async function recordAdHoc(adapter2, input) {
23781
24541
  } else {
23782
24542
  const phase = resolveCurrentPhase(phases);
23783
24543
  task = await adapter2.createTask({
23784
- uuid: randomUUID14(),
24544
+ uuid: randomUUID15(),
23785
24545
  displayId: "",
23786
24546
  title: input.title,
23787
24547
  status: landInReview ? "In Review" : "Done",
@@ -23800,7 +24560,7 @@ async function recordAdHoc(adapter2, input) {
23800
24560
  });
23801
24561
  }
23802
24562
  const report = {
23803
- uuid: randomUUID14(),
24563
+ uuid: randomUUID15(),
23804
24564
  createdAt: now.toISOString(),
23805
24565
  taskId: task.id,
23806
24566
  taskName: task.title ?? task.displayId ?? "untitled",
@@ -23954,13 +24714,38 @@ async function handleAdHoc(adapter2, config2, args) {
23954
24714
  const promoNote = result.task.cycle != null ? ` Promoted into Cycle ${result.task.cycle} as injected work${result.task.status === "In Review" ? " (In Review \u2014 will release with the cycle)" : ""}.` : "";
23955
24715
  if (holdArg) {
23956
24716
  const branch = `feat/${result.task.id}`;
24717
+ let collisionBlock = "";
24718
+ try {
24719
+ const board = await adapter2.queryBoard({ status: ["In Progress"] });
24720
+ const otherInProgress = board.filter((t) => t.id !== result.task.id && t.displayId !== result.task.id).map((t) => ({ taskId: t.displayId || t.id, branch: (t.branchName ?? "").trim() })).filter((t) => t.branch.length > 0);
24721
+ const collision = detectWorktreeCollision({
24722
+ taskId: result.task.id,
24723
+ targetBranch: branch,
24724
+ inProgress: otherInProgress,
24725
+ // Held work never runs git server-side — treat as suggest-only.
24726
+ autoWorktree: false
24727
+ });
24728
+ if (collision) {
24729
+ collisionBlock = `
24730
+ > ${collision.warning}
24731
+ > Instead of \`git switch -c\`, isolate into a worktree:
24732
+ > \`\`\`
24733
+ > git worktree add -b ${branch} ${collision.worktreePath}
24734
+ > cd ${collision.worktreePath} && npm run worktree:setup
24735
+ > \`\`\`
24736
+ > ${collision.setupHint}
24737
+
24738
+ `;
24739
+ }
24740
+ } catch {
24741
+ }
23957
24742
  return textResponse(
23958
24743
  `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning}${promoNote} Build report attached.
23959
24744
 
23960
24745
  ## Held for the next cycle \u2014 branch + commit, do NOT merge
23961
24746
  The task is recorded **In Review** and pinned to **Cycle ${result.task.cycle}**, so the planner won't re-plan it and it rides that cycle's review \u2192 release bundled with planned work.
23962
24747
 
23963
- 1. Create a branch and commit your code there (never \`main\`):
24748
+ ` + collisionBlock + `1. Create a branch and commit your code there (never \`main\`):
23964
24749
  \`\`\`
23965
24750
  git switch -c ${branch}
23966
24751
  git add -- <your changed files>
@@ -24568,7 +25353,7 @@ import { join as join15 } from "path";
24568
25353
  init_git();
24569
25354
 
24570
25355
  // src/services/review.ts
24571
- import { randomUUID as randomUUID15 } from "crypto";
25356
+ import { randomUUID as randomUUID16 } from "crypto";
24572
25357
  function isValidVerdict(stage, verdict) {
24573
25358
  if (stage === "handoff-review") {
24574
25359
  return verdict === "approve" || verdict === "request-changes" || verdict === "reject";
@@ -24625,7 +25410,7 @@ async function submitReview(adapter2, input) {
24625
25410
  }
24626
25411
  const date = (/* @__PURE__ */ new Date()).toISOString();
24627
25412
  const review = {
24628
- uuid: randomUUID15(),
25413
+ uuid: randomUUID16(),
24629
25414
  taskId: input.taskId,
24630
25415
  stage: input.stage,
24631
25416
  reviewer: input.reviewer,
@@ -24840,6 +25625,7 @@ var reviewSubmitTool = {
24840
25625
  description: "Record a review verdict on a completed build (build-acceptance) or task plan (handoff-review). ALWAYS ask the human for their verdict before calling \u2014 never auto-submit without human input. Accept moves the task to Done, request-changes sends it back for rework, reject discards the build. Updates task status based on the verdict. On handoff-review with suggested changes, returns a prompt to revise the BUILD HANDOFF.\n\nDO NOT use this tool as a substitute for review_list. If you need to see what is pending review, call review_list first. If review_list is unavailable in your tool set, STOP and tell the human their MCP integration is incomplete rather than guessing at the next pending task. (SUP-2026-010.)",
24841
25626
  annotations: { title: "Submit Review", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
24842
25627
  inputSchema: {
25628
+ $schema: "https://json-schema.org/draft/2020-12/schema",
24843
25629
  type: "object",
24844
25630
  properties: {
24845
25631
  task_id: {
@@ -24906,7 +25692,21 @@ var reviewSubmitTool = {
24906
25692
  required: ["verdict", "summary", "findings"]
24907
25693
  }
24908
25694
  },
24909
- required: ["task_id", "stage", "verdict", "comments"]
25695
+ required: ["task_id", "stage", "verdict", "comments"],
25696
+ // task-2802: mirror isValidVerdict — the legal verdict set is stage-dependent.
25697
+ // handoff-review takes approve/request-changes/reject; build-acceptance takes
25698
+ // accept/request-changes/reject. The flat enum above lists the union; these
25699
+ // conditionals narrow it per stage so the agent can't pair an impossible verdict.
25700
+ allOf: [
25701
+ {
25702
+ if: { properties: { stage: { const: "handoff-review" } }, required: ["stage"] },
25703
+ then: { properties: { verdict: { enum: ["approve", "request-changes", "reject"] } } }
25704
+ },
25705
+ {
25706
+ if: { properties: { stage: { const: "build-acceptance" } }, required: ["stage"] },
25707
+ then: { properties: { verdict: { enum: ["accept", "request-changes", "reject"] } } }
25708
+ }
25709
+ ]
24910
25710
  }
24911
25711
  };
24912
25712
  function formatReviewList(pendingBuilds) {
@@ -25525,7 +26325,7 @@ async function handleReviewClaim(adapter2, config2, args) {
25525
26325
  }
25526
26326
 
25527
26327
  // src/tools/init.ts
25528
- import { randomUUID as randomUUID16 } from "crypto";
26328
+ import { randomUUID as randomUUID17 } from "crypto";
25529
26329
  import { access as access3, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
25530
26330
  import path5 from "path";
25531
26331
  var initTool = {
@@ -25958,7 +26758,7 @@ ${writeNote}
25958
26758
  );
25959
26759
  }
25960
26760
  if (isDatabaseUser) {
25961
- const projectId = randomUUID16();
26761
+ const projectId = randomUUID17();
25962
26762
  const envVars = {
25963
26763
  PAPI_PROJECT_DIR: projectRoot,
25964
26764
  PAPI_ADAPTER: "pg",
@@ -26761,6 +27561,7 @@ var orientTool = {
26761
27561
  description: "Session orientation \u2014 run this FIRST at session start before any other tool. Single call that replaces build_list + health. Returns: cycle number, task counts by status, in-progress/in-review tasks, strategy review cadence, velocity snapshot, recommended next action, and a release reminder when all cycle tasks are Done but release has not run. Read-only, does not modify any files. PAPI detects build capability from the connecting harness (clientInfo); pass `environment` only to override that detection for git-dependent recommendations (build_execute, release, review_submit).",
26762
27562
  annotations: { title: "Orient Session", readOnlyHint: true, destructiveHint: false, openWorldHint: false },
26763
27563
  inputSchema: {
27564
+ $schema: "https://json-schema.org/draft/2020-12/schema",
26764
27565
  type: "object",
26765
27566
  properties: {
26766
27567
  environment: {
@@ -29906,6 +30707,7 @@ var PAPI_TOOLS = [
29906
30707
  buildExecuteTool,
29907
30708
  buildCancelTool,
29908
30709
  ideaTool,
30710
+ backlogImportTool,
29909
30711
  bugTool,
29910
30712
  bugListTool,
29911
30713
  adHocTool,
@@ -30088,6 +30890,8 @@ function createServer(adapter2, config2) {
30088
30890
  return handleBuildCancel(adapter2, safeArgs);
30089
30891
  case "idea":
30090
30892
  return handleIdea(adapter2, config2, safeArgs);
30893
+ case "backlog_import":
30894
+ return handleBacklogImport(adapter2, safeArgs);
30091
30895
  case "bug":
30092
30896
  return handleBug(adapter2, config2, safeArgs);
30093
30897
  case "bug_list":
@@ -30664,7 +31468,11 @@ async function dispatchRequest(args) {
30664
31468
  const requestConfig = {
30665
31469
  ...baseConfig,
30666
31470
  adapterType: "proxy",
30667
- projectId: effectiveProjectId
31471
+ projectId: effectiveProjectId,
31472
+ // Remote transport: the client cannot read files this server writes, so
31473
+ // prepare must keep returning inline blobs (2905's budget bounds them),
31474
+ // never a server-side file path (task-2906).
31475
+ localFilesystem: false
30668
31476
  };
30669
31477
  const server2 = createServer(adapter2, requestConfig);
30670
31478
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });