@papi-ai/server 0.7.67 → 0.7.70

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.
Files changed (3) hide show
  1. package/dist/index.js +1486 -428
  2. package/dist/prompts.js +65 -21
  3. package/package.json +1 -1
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, "");
9512
- }
9513
- if (ctx.strategyRecommendations) {
9514
- parts.push("### Strategy Recommendations (Pending)", "", ctx.strategyRecommendations, "");
9523
+ addEnrichment("Forward Horizon", "### Forward Horizon", "", ctx.horizonContext, "");
9515
9524
  }
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
  "---",
@@ -10679,6 +10729,14 @@ function validateHandoffScope(handoff) {
10679
10729
  if (!isMeaningful(handoff.scopeBoundary)) invalid.push("scopeBoundary");
10680
10730
  return invalid;
10681
10731
  }
10732
+ function assertHandoffApplyPayloadNonEmpty(data, cycleNumber) {
10733
+ const handoffCount = data.cycleHandoffs?.length ?? 0;
10734
+ if (handoffCount === 0) {
10735
+ throw new Error(
10736
+ `Handoff apply rejected: trimmed/empty apply payload \u2014 nothing persisted. The parsed output for Cycle ${cycleNumber} carries NO handoffs (cycleHandoffs is empty), but handoff prepare only runs when at least one task needs a handoff \u2014 so the handoffs were dropped. The most likely cause is a truncated apply JSON: the output overflowed the client tool-result ceiling (see task-2905/2906). Re-run handoff_generate and resend the COMPLETE structured output \u2014 ensure the JSON after the <!-- PAPI_STRUCTURED_OUTPUT --> marker includes the full cycleHandoffs array.`
10737
+ );
10738
+ }
10739
+ }
10682
10740
  async function prepareHandoffs(adapter2, _config, taskIds, force = false) {
10683
10741
  const timer2 = startTimer();
10684
10742
  const cycles = await adapter2.readCycles();
@@ -10735,10 +10793,8 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false)
10735
10793
  if (!data) {
10736
10794
  throw new Error("Could not parse structured output. Ensure your output includes <!-- PAPI_STRUCTURED_OUTPUT --> with valid JSON.");
10737
10795
  }
10796
+ assertHandoffApplyPayloadNonEmpty(data, cycleNumber);
10738
10797
  const handoffs = data.cycleHandoffs ?? [];
10739
- if (handoffs.length === 0) {
10740
- throw new Error("No cycleHandoffs found in structured output. Ensure your output includes handoffs in the cycleHandoffs array.");
10741
- }
10742
10798
  const taskIdsToWrite = handoffs.map((h) => h.taskId);
10743
10799
  const existingHandoffSet = /* @__PURE__ */ new Set();
10744
10800
  try {
@@ -12794,11 +12850,24 @@ Run \`strategy_review\` first, or pass \`force: true\` to bypass this gate.`
12794
12850
  }
12795
12851
  return { mode, cycleNumber, strategyReviewWarning };
12796
12852
  }
12853
+ function assertApplyPayloadNonEmpty(data, cycleNumber) {
12854
+ const handoffCount = data.cycleHandoffs?.length ?? 0;
12855
+ const taskIdCount = data.cycleTaskIds?.length ?? 0;
12856
+ const newTaskCount = data.newTasks?.length ?? 0;
12857
+ if (handoffCount + taskIdCount + newTaskCount === 0) {
12858
+ throw new Error(
12859
+ `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).`
12860
+ );
12861
+ }
12862
+ }
12797
12863
  async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber, contextHashes, planRunMeta) {
12798
12864
  const applyStartMs = Date.now();
12799
12865
  const applyScope = await resolvePlanScope(adapter2, config2);
12800
12866
  await assertSingleActiveCycle(adapter2, { allowNumber: cycleNumber + 1, userId: applyScope.callerUserId ?? void 0 });
12801
12867
  const { displayText, data } = parseStructuredOutput(rawOutput);
12868
+ if (data) {
12869
+ assertApplyPayloadNonEmpty(data, cycleNumber);
12870
+ }
12802
12871
  let resolvedDisplayText = displayText;
12803
12872
  let autoCommitNote = "";
12804
12873
  let priorityLockNote = "";
@@ -13276,7 +13345,15 @@ function buildSubagentDispatchPrompt(input) {
13276
13345
  - "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
13346
  - 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
13347
  - 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>
13348
+ const contextBlock = input.contextFilePath ? isReview ? `The review rubric and the full build-under-review context have been written to a local file:
13349
+
13350
+ ${input.contextFilePath}
13351
+
13352
+ 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:
13353
+
13354
+ ${input.contextFilePath}
13355
+
13356
+ 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
13357
  ${systemPrompt}
13281
13358
  </review_rubric>
13282
13359
 
@@ -13591,6 +13668,58 @@ async function buildSessionGuidance(callerKey) {
13591
13668
  return signals.slice(0, 3);
13592
13669
  }
13593
13670
 
13671
+ // src/services/onboarding-coaching.ts
13672
+ var ONBOARDING_EARLY_CYCLE_MAX = 2;
13673
+ var MAX_COACHING_LINES = 4;
13674
+ var COACH_CONNECT_REPO = "No repository is linked to this project yet. Link your repo from the dashboard Settings (or capture it during `setup`) so builds, reviews, and releases attach to the right codebase.";
13675
+ var COACH_ROOT_DIR = "Before building, confirm this session is running from your project root directory, so commits and builds land against the right files.";
13676
+ var COACH_CLICKABLE_TASKS = "Task cards on your dashboard expand on click. Open one to read its full build handoff, comments, and history.";
13677
+ var COACH_OFF_CYCLE = 'When a request falls outside the current cycle, keep it in the loop: `idea "<what you want>"` parks it in the backlog, or promote it into the cycle and generate a build handoff before you build.';
13678
+ var COACH_SESSION_START = 'Start of a session: skim your Active Decisions (the AD summary from `orient`, or `strategy_review`) to reload your project direction, and capture anything new with `idea "<what you want>"`.';
13679
+ var COACH_NO_DECISIONS = 'No Active Decisions recorded yet. Capture your first with `idea "<a direction or constraint>"` so your project starts steering itself.';
13680
+ var COACH_BACKLOG_IMPORT = "Already tracking a backlog elsewhere (Linear, a CSV, a markdown checklist)? Bring it over in one pass with `backlog_import` instead of retyping it.";
13681
+ var ONBOARDING_COACHING_HEADING = "## Getting Started";
13682
+ function buildOnboardingCoaching(state) {
13683
+ const lines = [];
13684
+ const isEarly = state.cycleNumber <= ONBOARDING_EARLY_CYCLE_MAX;
13685
+ const { surface } = state;
13686
+ if (state.repoConnected === false && (surface === "orient" || surface === "plan")) {
13687
+ lines.push(COACH_CONNECT_REPO);
13688
+ }
13689
+ if (state.hasLocalWorkspace && (surface === "setup" || surface === "orient" && isEarly)) {
13690
+ lines.push(COACH_ROOT_DIR);
13691
+ }
13692
+ if ((surface === "orient" || surface === "setup") && isEarly) {
13693
+ lines.push(COACH_CLICKABLE_TASKS);
13694
+ }
13695
+ if ((surface === "orient" || surface === "plan") && state.hasActiveCycle && isEarly) {
13696
+ lines.push(COACH_OFF_CYCLE);
13697
+ }
13698
+ if (surface === "orient" && isEarly) {
13699
+ lines.push(state.hasActiveDecisions === false ? COACH_NO_DECISIONS : COACH_SESSION_START);
13700
+ }
13701
+ if (surface === "plan" && state.bootstrapPlan === true) {
13702
+ lines.push(COACH_BACKLOG_IMPORT);
13703
+ }
13704
+ return lines.slice(0, MAX_COACHING_LINES);
13705
+ }
13706
+ function formatOnboardingCoachingBlock(state) {
13707
+ const lines = buildOnboardingCoaching(state);
13708
+ if (lines.length === 0) return "";
13709
+ return `
13710
+
13711
+ ${ONBOARDING_COACHING_HEADING}
13712
+ ${lines.map((l) => `- ${l}`).join("\n")}`;
13713
+ }
13714
+
13715
+ // src/lib/hosted-mode.ts
13716
+ function isHostedTransport() {
13717
+ return Boolean(process.env.PORT || process.env.PAPI_HTTP_PORT);
13718
+ }
13719
+ function hasLocalWorkspace() {
13720
+ return !isHostedTransport();
13721
+ }
13722
+
13594
13723
  // src/lib/per-caller-cache.ts
13595
13724
  var DEFAULT_CALLER_KEY2 = "__default__";
13596
13725
  var MAX_PER_CALLER_ENTRIES = 1e3;
@@ -13682,6 +13811,15 @@ function clearPrepareSpill(projectId, callerKey) {
13682
13811
  } catch {
13683
13812
  }
13684
13813
  }
13814
+ function contextPath(projectId, callerKey) {
13815
+ const id = createHash2("sha256").update(`${projectId ?? "no-project"}|${callerKey ?? DEFAULT_CALLER_KEY3}`).digest("hex").slice(0, 16);
13816
+ return join3(tmpdir(), `papi-plan-context-${id}.md`);
13817
+ }
13818
+ function savePrepareContextFile(projectId, callerKey, content) {
13819
+ const path7 = contextPath(projectId, callerKey);
13820
+ writeFileSync2(path7, content, { mode: 384 });
13821
+ return path7;
13822
+ }
13685
13823
 
13686
13824
  // src/tools/plan.ts
13687
13825
  var planPrepareCache = new PerCallerCache();
@@ -13690,6 +13828,7 @@ var planTool = {
13690
13828
  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
13829
  annotations: { title: "Plan Cycle", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
13692
13830
  inputSchema: {
13831
+ $schema: "https://json-schema.org/draft/2020-12/schema",
13693
13832
  type: "object",
13694
13833
  properties: {
13695
13834
  mode: {
@@ -13760,7 +13899,16 @@ var planTool = {
13760
13899
  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
13900
  }
13762
13901
  },
13763
- required: []
13902
+ required: [],
13903
+ // task-2802: mirror resolveLlmResponse — llm_response and llm_response_file are
13904
+ // mutually exclusive, and an apply call must carry exactly one of them.
13905
+ not: { required: ["llm_response", "llm_response_file"] },
13906
+ allOf: [
13907
+ {
13908
+ if: { properties: { mode: { const: "apply" } }, required: ["mode"] },
13909
+ then: { anyOf: [{ required: ["llm_response"] }, { required: ["llm_response_file"] }] }
13910
+ }
13911
+ ]
13764
13912
  }
13765
13913
  };
13766
13914
  function formatPlanResult(result) {
@@ -13815,6 +13963,10 @@ function formatPlanResult(result) {
13815
13963
  } else {
13816
13964
  lines.push("", `Next: run \`build_list\` to see your cycle tasks, then \`build_execute <task_id>\` to start building.`);
13817
13965
  }
13966
+ if (result.onboardingCoaching && result.onboardingCoaching.length > 0) {
13967
+ lines.push("", ONBOARDING_COACHING_HEADING);
13968
+ for (const c of result.onboardingCoaching) lines.push(`- ${c}`);
13969
+ }
13818
13970
  if (result.contextBytes !== void 0) {
13819
13971
  const kb = (result.contextBytes / 1024).toFixed(1);
13820
13972
  lines.push(`---`, `Context: ${kb}KB`);
@@ -13896,7 +14048,15 @@ async function handlePlan(adapter2, config2, args) {
13896
14048
  }, tracker);
13897
14049
  const planProjectInfo = adapter2.getProjectInfo ? await adapter2.getProjectInfo().catch(() => null) : null;
13898
14050
  const projectBanner = planProjectInfo ? getProjectConnectionBanner(planProjectInfo.name, planProjectInfo.slug) ?? void 0 : void 0;
13899
- const response = formatPlanResult({ ...result, contextUtilisation: utilisation, contextBytes, skipHandoffs, projectBanner });
14051
+ const onboardingCoaching = buildOnboardingCoaching({
14052
+ surface: "plan",
14053
+ repoConnected: planProjectInfo ? !!planProjectInfo.repo_url : void 0,
14054
+ hasLocalWorkspace: hasLocalWorkspace(),
14055
+ cycleNumber: result.cycleNumber + 1,
14056
+ hasActiveCycle: true,
14057
+ bootstrapPlan: result.mode === "bootstrap"
14058
+ });
14059
+ const response = formatPlanResult({ ...result, contextUtilisation: utilisation, contextBytes, skipHandoffs, projectBanner, onboardingCoaching });
13900
14060
  return {
13901
14061
  ...response,
13902
14062
  ...contextBytes !== void 0 ? { _contextBytes: contextBytes } : {},
@@ -13929,6 +14089,27 @@ async function handlePlan(adapter2, config2, args) {
13929
14089
  } else {
13930
14090
  dispatch = "inline";
13931
14091
  }
14092
+ const modeLabel = result.mode === "bootstrap" ? "Bootstrap" : "Full";
14093
+ const header = result.strategyReviewWarning ? `${result.strategyReviewWarning}
14094
+ ` : "";
14095
+ let contextFilePath;
14096
+ if (config2.localFilesystem) {
14097
+ const contextDoc = `### System Prompt
14098
+
14099
+ ${result.systemPrompt}
14100
+
14101
+ ---
14102
+
14103
+ ### Context
14104
+
14105
+ ${result.userMessage}
14106
+ `;
14107
+ try {
14108
+ contextFilePath = savePrepareContextFile(adapter2.getProjectId?.(), callerKey, contextDoc);
14109
+ } catch {
14110
+ contextFilePath = void 0;
14111
+ }
14112
+ }
13932
14113
  if (dispatch === "subagent") {
13933
14114
  const dispatchPrompt = buildSubagentDispatchPrompt({
13934
14115
  tool: "plan",
@@ -13937,16 +14118,34 @@ async function handlePlan(adapter2, config2, args) {
13937
14118
  strategyReviewWarning: result.strategyReviewWarning,
13938
14119
  systemPrompt: result.systemPrompt,
13939
14120
  userMessage: result.userMessage,
13940
- contextBytes: result.contextBytes
14121
+ contextBytes: result.contextBytes,
14122
+ contextFilePath
13941
14123
  });
13942
- const header2 = result.strategyReviewWarning ? `${result.strategyReviewWarning}
14124
+ const dispatchHeader = result.strategyReviewWarning ? `${result.strategyReviewWarning}
13943
14125
 
13944
14126
  ` : "";
13945
- return { ...textResponse(`${header2}${dispatchPrompt}`), _contextBytes: result.contextBytes };
14127
+ return { ...textResponse(`${dispatchHeader}${dispatchPrompt}`), _contextBytes: result.contextBytes };
14128
+ }
14129
+ if (contextFilePath) {
14130
+ const kb = result.contextBytes !== void 0 ? ` (~${(result.contextBytes / 1024).toFixed(0)} KB)` : "";
14131
+ const pathResponse = textResponse(
14132
+ `${header}## PAPI Cycle Plan \u2014 Prepare Phase (${modeLabel} Mode, Cycle ${result.cycleNumber + 1})
14133
+
14134
+ 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):
14135
+
14136
+ \`${contextFilePath}\`
14137
+
14138
+ **Do this:**
14139
+ 1. **Read that file in full** \u2014 it is the system prompt and the entire planning context.
14140
+ 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.
14141
+ 3. **Write your output to a local file**, then call \`plan\` again with:
14142
+ - \`mode\`: "apply"
14143
+ - \`llm_response_file\`: the absolute path to YOUR output file
14144
+ - \`cycle_number\`: ${result.cycleNumber + 1}
14145
+ - \`strategy_review_warning\`: "${result.strategyReviewWarning.replace(/"/g, '\\"')}"`
14146
+ );
14147
+ return { ...pathResponse, _contextBytes: result.contextBytes };
13946
14148
  }
13947
- const modeLabel = result.mode === "bootstrap" ? "Bootstrap" : "Full";
13948
- const header = result.strategyReviewWarning ? `${result.strategyReviewWarning}
13949
- ` : "";
13950
14149
  const response = textResponse(
13951
14150
  `${header}## PAPI Cycle Plan \u2014 Prepare Phase (${modeLabel} Mode, Cycle ${result.cycleNumber + 1})
13952
14151
 
@@ -14009,14 +14208,6 @@ import { existsSync as existsSync2, readdirSync, statSync as statSync2 } from "f
14009
14208
  import { join as join4 } from "path";
14010
14209
  import { homedir as homedir2 } from "os";
14011
14210
 
14012
- // src/lib/hosted-mode.ts
14013
- function isHostedTransport() {
14014
- return Boolean(process.env.PORT || process.env.PAPI_HTTP_PORT);
14015
- }
14016
- function hasLocalWorkspace() {
14017
- return !isHostedTransport();
14018
- }
14019
-
14020
14211
  // src/services/idea.ts
14021
14212
  import { randomUUID as randomUUID9 } from "crypto";
14022
14213
  var OWNER_ACTION_PATTERNS = [
@@ -14564,6 +14755,55 @@ function generateValueReport(snapshots) {
14564
14755
  return lines.join("\n");
14565
14756
  }
14566
14757
 
14758
+ // src/lib/earned-pushback.ts
14759
+ var SIGNAL_MIN_COUNT = 3;
14760
+ var EFFORT_RANK = { XS: 0, S: 1, M: 2, L: 3, XL: 4 };
14761
+ function rankEffort(size2) {
14762
+ if (!size2) return void 0;
14763
+ return EFFORT_RANK[size2];
14764
+ }
14765
+ function computeEarnedPushback(inputs) {
14766
+ const { reports, log: log2, doneTaskIds } = inputs;
14767
+ const drifted = reports.filter(
14768
+ (r) => r.scopeAccuracy && r.scopeAccuracy !== "accurate" || !!r.scopeDriftSignal
14769
+ );
14770
+ const crept = reports.filter((r) => {
14771
+ const actual = rankEffort(r.actualEffort);
14772
+ const estimated = rankEffort(r.estimatedEffort);
14773
+ return actual !== void 0 && estimated !== void 0 && actual > estimated;
14774
+ });
14775
+ const staleness = computeCarryForwardStaleness(log2, doneTaskIds);
14776
+ const driftFires = drifted.length >= SIGNAL_MIN_COUNT;
14777
+ const creepFires = crept.length >= SIGNAL_MIN_COUNT;
14778
+ const deferralFires = staleness !== void 0;
14779
+ if (!driftFires && !creepFires && !deferralFires) return void 0;
14780
+ const sections = [
14781
+ "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."
14782
+ ];
14783
+ if (driftFires) {
14784
+ const named = drifted.slice(0, 8).map((r) => {
14785
+ const why = r.scopeDriftSignal ? `files diverged from handoff` : `scope ${r.scopeAccuracy}`;
14786
+ return ` - **${r.displayId ?? r.taskId}** (${r.taskName}) \u2014 ${why}`;
14787
+ }).join("\n");
14788
+ sections.push(
14789
+ `**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.
14790
+ ${named}`
14791
+ );
14792
+ }
14793
+ if (creepFires) {
14794
+ const named = crept.slice(0, 8).map((r) => ` - **${r.displayId ?? r.taskId}** (${r.taskName}) \u2014 estimated ${r.estimatedEffort}, actual ${r.actualEffort}`).join("\n");
14795
+ sections.push(
14796
+ `**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.
14797
+ ${named}`
14798
+ );
14799
+ }
14800
+ if (deferralFires) {
14801
+ sections.push(`**Repeat deferral (3+ consecutive cycles):**
14802
+ ${staleness}`);
14803
+ }
14804
+ return sections.join("\n\n");
14805
+ }
14806
+
14567
14807
  // src/services/strategy.ts
14568
14808
  var STRATEGY_DUPE_COVERAGE_THRESHOLD = 0.6;
14569
14809
  function taskStatusLabel(task) {
@@ -15071,10 +15311,10 @@ ${unregistered.slice(0, 10).map((f) => `- ${f}`).join("\n")}`;
15071
15311
  try {
15072
15312
  const comments = await adapter2.getRecentTaskComments?.(50);
15073
15313
  if (comments && comments.length > 0) {
15074
- const doneTaskIds = new Set(recentDoneTasks.map((t) => t.id));
15314
+ const doneTaskIds2 = new Set(recentDoneTasks.map((t) => t.id));
15075
15315
  const inReviewTasks = activeTasks.filter((t) => t.status === "In Review");
15076
15316
  const reviewWindowTaskIds = /* @__PURE__ */ new Set([
15077
- ...doneTaskIds,
15317
+ ...doneTaskIds2,
15078
15318
  ...inReviewTasks.map((t) => t.id)
15079
15319
  ]);
15080
15320
  const filtered = comments.filter((c) => reviewWindowTaskIds.has(c.taskId));
@@ -15104,7 +15344,7 @@ ${unregistered.slice(0, 10).map((f) => `- ${f}`).join("\n")}`;
15104
15344
  try {
15105
15345
  if (docsWithPendingActions && docsWithPendingActions.length > 0) {
15106
15346
  const STALE_THRESHOLD = 20;
15107
- const doneTaskIds = new Set(recentDoneTasks.map((t) => t.displayId ?? t.id));
15347
+ const doneTaskIds2 = new Set(recentDoneTasks.map((t) => t.displayId ?? t.id));
15108
15348
  const completed = [];
15109
15349
  const deferred = [];
15110
15350
  const stale = [];
@@ -15114,7 +15354,7 @@ ${unregistered.slice(0, 10).map((f) => `- ${f}`).join("\n")}`;
15114
15354
  const ageInCycles = cycleNumber - (doc.cycleCreated ?? cycleNumber);
15115
15355
  for (const action of pendingActions) {
15116
15356
  const line = ` - **${doc.title}** (C${doc.cycleCreated ?? "?"}): ${action.description}${action.linkedTaskId ? ` [\u2192${action.linkedTaskId}]` : ""}`;
15117
- if (action.linkedTaskId && doneTaskIds.has(action.linkedTaskId)) {
15357
+ if (action.linkedTaskId && doneTaskIds2.has(action.linkedTaskId)) {
15118
15358
  completed.push(line);
15119
15359
  } else if (ageInCycles > STALE_THRESHOLD) {
15120
15360
  stale.push(line);
@@ -15161,6 +15401,10 @@ ${lines.join("\n")}`;
15161
15401
  { label: "taskComments", hasData: taskCommentsText !== void 0 },
15162
15402
  { label: "docActionStaleness", hasData: docActionStalenessText !== void 0 }
15163
15403
  ]);
15404
+ const doneTaskIds = new Set(
15405
+ recentDoneTasks.map((t) => t.displayId ?? t.id).filter((id) => !!id)
15406
+ );
15407
+ const earnedPushback = computeEarnedPushback({ reports, log: recentLog, doneTaskIds });
15164
15408
  const context = {
15165
15409
  sessionNumber: cycleNumber,
15166
15410
  lastReviewCycle: lastReviewCycleNum,
@@ -15169,6 +15413,7 @@ ${lines.join("\n")}`;
15169
15413
  allBuildReports: buildReportsText,
15170
15414
  sessionLog: formatCycleLog(recentLog),
15171
15415
  board: smartBoard,
15416
+ earnedPushback,
15172
15417
  humanReviews: formatReviews(reviews),
15173
15418
  buildPatterns: buildPatternsText,
15174
15419
  reviewPatterns: reviewPatternsText,
@@ -15419,6 +15664,13 @@ ${cleanContent}`;
15419
15664
  } catch {
15420
15665
  }
15421
15666
  }
15667
+ if (data.hierarchyUpdates && data.hierarchyUpdates.length > 0) {
15668
+ try {
15669
+ await applyHierarchyUpdates(adapter2, data.hierarchyUpdates);
15670
+ } catch (err) {
15671
+ console.error("[strategy] applyHierarchyUpdates failed:", err instanceof Error ? err.message : String(err));
15672
+ }
15673
+ }
15422
15674
  const compressionThreshold = cycleNumber - 5;
15423
15675
  if (compressionThreshold > 0 && data.sessionLogCompressionSummary) {
15424
15676
  await adapter2.compressCycleLog(compressionThreshold, data.sessionLogCompressionSummary);
@@ -15696,6 +15948,79 @@ function buildPhaseLabel(phase) {
15696
15948
  if (!numMatch) return phase.label;
15697
15949
  return `Phase ${numMatch[1]}: ${phase.label}`;
15698
15950
  }
15951
+ function nextHierarchySort(existing) {
15952
+ return existing.length === 0 ? 10 : Math.max(...existing.map((e) => e.sortOrder)) + 10;
15953
+ }
15954
+ async function applyHierarchyUpdates(adapter2, updates) {
15955
+ if (!adapter2.readStages || !adapter2.readHorizons) return;
15956
+ const findHorizon = async (ref) => (await adapter2.readHorizons()).find(
15957
+ (h) => h.slug === ref || h.id === ref || h.label.toLowerCase() === ref.toLowerCase()
15958
+ );
15959
+ const findStage = async (ref) => (await adapter2.readStages()).find(
15960
+ (s) => s.slug === ref || s.id === ref || s.label.toLowerCase() === ref.toLowerCase()
15961
+ );
15962
+ for (const u of updates) {
15963
+ if (u.level === "horizon") {
15964
+ if (u.action === "create") {
15965
+ if (!await findHorizon(u.slug) && adapter2.createHorizon) {
15966
+ const horizons = await adapter2.readHorizons();
15967
+ await adapter2.createHorizon({
15968
+ slug: u.slug,
15969
+ label: u.label ?? u.slug,
15970
+ status: u.status ?? "In Progress",
15971
+ sortOrder: u.sortOrder ?? nextHierarchySort(horizons)
15972
+ });
15973
+ }
15974
+ } else if (u.action === "update_status" && u.status && adapter2.updateHorizonStatus) {
15975
+ const h = await findHorizon(u.slug);
15976
+ if (h) await adapter2.updateHorizonStatus(h.id, u.status);
15977
+ }
15978
+ continue;
15979
+ }
15980
+ if (u.action === "update_criterion" && u.criterionId && typeof u.met === "boolean" && adapter2.setCriterionMet) {
15981
+ const s = await findStage(u.slug);
15982
+ if (s) await adapter2.setCriterionMet(s.id, u.criterionId, u.met, u.evidence ?? null);
15983
+ } else if (u.action === "update_status" && u.status && adapter2.updateStageStatus) {
15984
+ const s = await findStage(u.slug);
15985
+ if (s) await adapter2.updateStageStatus(s.id, u.status);
15986
+ } else if (u.action === "create") {
15987
+ await createStageFromUpdate(adapter2, u, findStage, findHorizon);
15988
+ } else if (u.action === "advance" && adapter2.updateStageStatus) {
15989
+ const current = await findStage(u.slug);
15990
+ if (current) await adapter2.updateStageStatus(current.id, "Done");
15991
+ const newRef = u.newStageSlug ?? u.slug;
15992
+ const existingNext = await findStage(newRef);
15993
+ if (existingNext) {
15994
+ await adapter2.updateStageStatus(existingNext.id, "In Progress");
15995
+ } else if (u.newStageLabel) {
15996
+ await createStageFromUpdate(
15997
+ adapter2,
15998
+ { ...u, slug: newRef, label: u.newStageLabel, status: "In Progress", horizon: u.horizon ?? current?.horizonId },
15999
+ findStage,
16000
+ findHorizon
16001
+ );
16002
+ }
16003
+ }
16004
+ }
16005
+ }
16006
+ async function createStageFromUpdate(adapter2, u, findStage, findHorizon) {
16007
+ if (!adapter2.createStage || !adapter2.readStages || !adapter2.readHorizons) return;
16008
+ if (await findStage(u.slug)) return;
16009
+ const horizons = await adapter2.readHorizons();
16010
+ const parent = u.horizon ? await findHorizon(u.horizon) : horizons.length === 1 ? horizons[0] : horizons.find((h) => h.status === "In Progress");
16011
+ if (!parent) return;
16012
+ const stagesInHorizon = (await adapter2.readStages()).filter((s) => s.horizonId === parent.id);
16013
+ const id = await adapter2.createStage({
16014
+ slug: u.slug,
16015
+ label: u.label ?? u.slug,
16016
+ status: u.status ?? "Not Started",
16017
+ sortOrder: u.sortOrder ?? nextHierarchySort(stagesInHorizon),
16018
+ horizonId: parent.id
16019
+ });
16020
+ if (id && u.exitCriteria?.length && adapter2.updateStageExitCriteria) {
16021
+ await adapter2.updateStageExitCriteria(id, u.exitCriteria);
16022
+ }
16023
+ }
15699
16024
  async function applyPhaseUpdates(adapter2, currentPhases, updates) {
15700
16025
  const phasesById = new Map(currentPhases.map((p) => [p.id, p]));
15701
16026
  const labelMigrations = [];
@@ -16644,6 +16969,7 @@ var boardViewTool = {
16644
16969
  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
16970
  annotations: { title: "View Board", readOnlyHint: true, destructiveHint: false, openWorldHint: false },
16646
16971
  inputSchema: {
16972
+ $schema: "https://json-schema.org/draft/2020-12/schema",
16647
16973
  type: "object",
16648
16974
  properties: {
16649
16975
  task_id: {
@@ -16668,10 +16994,14 @@ var boardViewTool = {
16668
16994
  },
16669
16995
  limit: {
16670
16996
  type: "number",
16997
+ minimum: 1,
16998
+ default: 50,
16671
16999
  description: "Max tasks to return (default: 50)."
16672
17000
  },
16673
17001
  offset: {
16674
17002
  type: "number",
17003
+ minimum: 0,
17004
+ default: 0,
16675
17005
  description: "Skip first N tasks for pagination (default: 0)."
16676
17006
  },
16677
17007
  mode: {
@@ -16688,6 +17018,7 @@ var boardDeprioritiseTool = {
16688
17018
  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
17019
  annotations: { title: "Deprioritise Task", readOnlyHint: false, destructiveHint: true, openWorldHint: false },
16690
17020
  inputSchema: {
17021
+ $schema: "https://json-schema.org/draft/2020-12/schema",
16691
17022
  type: "object",
16692
17023
  properties: {
16693
17024
  task_id: {
@@ -16725,7 +17056,21 @@ var boardDeprioritiseTool = {
16725
17056
  description: 'Optional new phase (only applies to "backlog" and "defer" actions).'
16726
17057
  }
16727
17058
  },
16728
- required: ["task_id"]
17059
+ required: ["task_id"],
17060
+ // task-2802: mirror the handler's guards so the agent sees the dependency up
17061
+ // front. handleBoardDeprioritise requires `reason` for both block and cancel,
17062
+ // and requires `blocker_ref` whenever `blocker_type` is set. Keyed on explicit
17063
+ // values — an omitted action defaults to "backlog" and triggers neither.
17064
+ allOf: [
17065
+ {
17066
+ if: { properties: { action: { enum: ["block", "cancel"] } }, required: ["action"] },
17067
+ then: { required: ["reason"] }
17068
+ },
17069
+ {
17070
+ if: { required: ["blocker_type"] },
17071
+ then: { required: ["blocker_ref"] }
17072
+ }
17073
+ ]
16729
17074
  }
16730
17075
  };
16731
17076
  var boardArchiveTool = {
@@ -16752,7 +17097,15 @@ var boardEditTool = {
16752
17097
  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
17098
  annotations: { title: "Edit Task", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
16754
17099
  inputSchema: {
17100
+ $schema: "https://json-schema.org/draft/2020-12/schema",
16755
17101
  type: "object",
17102
+ // task-2802: shared effort-size enum referenced by estimated_effort/actual_effort.
17103
+ $defs: {
17104
+ effortSize: {
17105
+ type: "string",
17106
+ enum: ["XS", "S", "M", "L", "XL"]
17107
+ }
17108
+ },
16756
17109
  properties: {
16757
17110
  task_id: {
16758
17111
  type: "string",
@@ -16808,13 +17161,11 @@ var boardEditTool = {
16808
17161
  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
17162
  },
16810
17163
  estimated_effort: {
16811
- type: "string",
16812
- enum: ["XS", "S", "M", "L", "XL"],
17164
+ $ref: "#/$defs/effortSize",
16813
17165
  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
17166
  },
16815
17167
  actual_effort: {
16816
- type: "string",
16817
- enum: ["XS", "S", "M", "L", "XL"],
17168
+ $ref: "#/$defs/effortSize",
16818
17169
  description: "task-2182: correct the actual effort on this task's LATEST build report (fixes a mis-recorded actual)."
16819
17170
  }
16820
17171
  },
@@ -17246,6 +17597,9 @@ var PROJECT_BUNDLE_REL = join6(".agents", "skills", "papi-cycle");
17246
17597
  function bundleDestRel(rel) {
17247
17598
  return rel === "AGENTS.md" ? "AGENTS.md" : join6(PROJECT_BUNDLE_REL, rel);
17248
17599
  }
17600
+ function claudeSkillDestRel(rel) {
17601
+ return rel === "AGENTS.md" ? void 0 : join6(".claude", "skills", rel);
17602
+ }
17249
17603
  function resolveBundleDir() {
17250
17604
  let dir = dirname(fileURLToPath(import.meta.url));
17251
17605
  for (let i = 0; i < 5; i++) {
@@ -17274,10 +17628,13 @@ function readBundleFiles(bundleDir = resolveBundleDir()) {
17274
17628
  function planBundleInstall(projectRoot, projectName, opts = {}) {
17275
17629
  const out = {};
17276
17630
  for (const f of readBundleFiles()) {
17277
- const dest = join6(projectRoot, bundleDestRel(f.rel));
17278
- if (opts.skipExisting && existsSync4(dest) && statSync4(dest).isFile()) continue;
17279
17631
  const content = f.rel === "AGENTS.md" ? f.content.replace(/\{\{project_name\}\}/g, projectName) : f.content;
17280
- out[dest] = content;
17632
+ for (const rel of [bundleDestRel(f.rel), claudeSkillDestRel(f.rel)]) {
17633
+ if (!rel) continue;
17634
+ const dest = join6(projectRoot, rel);
17635
+ if (opts.skipExisting && existsSync4(dest) && statSync4(dest).isFile()) continue;
17636
+ out[dest] = content;
17637
+ }
17281
17638
  }
17282
17639
  return out;
17283
17640
  }
@@ -17565,7 +17922,7 @@ var CLAUDE_MD_ENRICHMENT_SENTINEL_T1 = "<!-- PAPI_ENRICHMENT_TIER_1 -->";
17565
17922
  var CLAUDE_MD_ENRICHMENT_SENTINEL_T2 = "<!-- PAPI_ENRICHMENT_TIER_2 -->";
17566
17923
  var CLAUDE_MD_STUB = `# {{project_name}}
17567
17924
 
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/\`.
17925
+ 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
17926
 
17570
17927
  **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
17928
 
@@ -18896,8 +19253,14 @@ ${result.warnings.map((w) => `- ${w}`).join("\n")}` : "";
18896
19253
  **Important:** This is a remote PAPI connection, so the server could not write to your project directory. Setup prepared your files (${harnessFiles}, .claude/settings.json, docs/) and returned them in the scaffolding section below \u2014 **write each one to disk, then commit** before running \`build_execute\` (it requires a clean working directory).` : `
18897
19254
 
18898
19255
  **Important:** Setup created/modified files (${harnessFiles}, .claude/settings.json, docs/). Commit these changes before running \`build_execute\` \u2014 it requires a clean working directory.`;
19256
+ const coachingNote = formatOnboardingCoachingBlock({
19257
+ surface: "setup",
19258
+ hasLocalWorkspace: hasLocalWorkspace(),
19259
+ cycleNumber: 0,
19260
+ hasActiveCycle: false
19261
+ });
18899
19262
  return textResponse(
18900
- `${prefix}Product Brief generated and saved.${briefRegenNote}${adNote}${northStarNote}${taskNote}${constraintsHint}${editorNote}${gitignoreNote}${warningsNote}${filesNote}
19263
+ `${prefix}Product Brief generated and saved.${briefRegenNote}${adNote}${northStarNote}${taskNote}${constraintsHint}${editorNote}${gitignoreNote}${warningsNote}${filesNote}${coachingNote}
18901
19264
 
18902
19265
  Tip: See \`docs/templates/example-project-brief.md\` for an example of a well-written brief.
18903
19266
 
@@ -19268,8 +19631,8 @@ function buildPapiMetaFramingDirective(caps, inner) {
19268
19631
 
19269
19632
  // src/services/build.ts
19270
19633
  import { randomUUID as randomUUID11 } from "crypto";
19271
- import { readdirSync as readdirSync5, existsSync as existsSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2, mkdirSync as mkdirSync2 } from "fs";
19272
- import { join as join11 } from "path";
19634
+ import { readdirSync as readdirSync5, existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, mkdirSync as mkdirSync3 } from "fs";
19635
+ import { join as join12 } from "path";
19273
19636
 
19274
19637
  // src/lib/db-only-notices.ts
19275
19638
  var DB_ONLY_START_NOTICE = "No git repo detected \u2014 running this cycle in your project database only. Your work stays in the working tree; run `git init` (and add a remote) to enable branches, commits and PR review.";
@@ -20187,6 +20550,38 @@ async function postReleaseToX(version, cycleClosed) {
20187
20550
  return false;
20188
20551
  }
20189
20552
  }
20553
+ function buildHostedReleaseOutput(params) {
20554
+ const { version, branch, cyclePart, warningsBlock, skipVersion } = params;
20555
+ const tagAnnotation = `Release ${version}`;
20556
+ const titleSuffix = skipVersion ? " (skip version)" : "";
20557
+ const gitHalf = skipVersion ? "the git half of the release (the CHANGELOG.md commit and the branch push) must run on your own machine" : "the git half of the release (tag, push, CHANGELOG.md) must run on your own machine";
20558
+ const commands = skipVersion ? `git checkout ${branch}
20559
+ git pull
20560
+ git push origin ${branch}
20561
+ ` : `git checkout ${branch}
20562
+ git pull
20563
+ git tag -a ${version} -m "${tagAnnotation}"
20564
+ git push origin ${branch}
20565
+ git push origin ${version}
20566
+ `;
20567
+ const identityBlock = skipVersion ? "" : `If git reports "Author identity unknown" or "Committer identity unknown" (no global git identity configured), pass your identity inline instead of writing global config:
20568
+ \`\`\`
20569
+ git -c user.name="Your Name" -c user.email="you@example.com" tag -a ${version} -m "${tagAnnotation}"
20570
+ \`\`\`
20571
+
20572
+ `;
20573
+ return `## Release ${version}${titleSuffix} \u2014 cycle closed in the DB
20574
+
20575
+ ${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
20576
+ ` + warningsBlock + `
20577
+ The hosted remote MCP transport (mcp.getpapi.ai) has no checkout of your project and no git binary, so ${gitHalf}.
20578
+
20579
+ Finish the release locally:
20580
+ \`\`\`
20581
+ ` + commands + `\`\`\`
20582
+
20583
+ ` + identityBlock + `Next: cycle closed! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`;
20584
+ }
20190
20585
  var releaseTool = {
20191
20586
  name: "release",
20192
20587
  description: "Cut a versioned release \u2014 creates a git tag, generates CHANGELOG.md, and pushes to remote. Pass skipVersion=true to update CHANGELOG and close the cycle without creating a tag or bumping version numbers.",
@@ -20364,33 +20759,12 @@ Next: run \`plan\` to start your next cycle.`
20364
20759
  branchMerges: [],
20365
20760
  changelogEmitted: false
20366
20761
  });
20367
- const tagAnnotation = `Release ${version}`;
20368
20762
  const cyclePart = closed.resolvedCycleNum > 0 ? `Cycle ${closed.resolvedCycleNum}` : "The cycle";
20369
20763
  const warningsBlock = closed.warnings.length > 0 ? `
20370
20764
  \u26A0\uFE0F Warnings: ${closed.warnings.join("; ")}
20371
20765
  ` : "";
20372
20766
  return textResponse(
20373
- `## Release ${version} \u2014 cycle closed in the DB
20374
-
20375
- ${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
20376
- ` + warningsBlock + `
20377
- The hosted remote MCP transport (mcp.getpapi.ai) has no checkout of your project and no git binary, so the git half of the release (tag, push, CHANGELOG.md) must run on your own machine.
20378
-
20379
- Finish the release locally:
20380
- \`\`\`
20381
- git checkout ${branch}
20382
- git pull
20383
- git tag -a ${version} -m "${tagAnnotation}"
20384
- git push origin ${branch}
20385
- git push origin ${version}
20386
- \`\`\`
20387
-
20388
- If git reports "Author identity unknown" or "Committer identity unknown" (no global git identity configured), pass your identity inline instead of writing global config:
20389
- \`\`\`
20390
- git -c user.name="Your Name" -c user.email="you@example.com" tag -a ${version} -m "${tagAnnotation}"
20391
- \`\`\`
20392
-
20393
- Next: cycle closed! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`
20767
+ buildHostedReleaseOutput({ version, branch, cyclePart, warningsBlock, skipVersion: skipVersion ?? false })
20394
20768
  );
20395
20769
  }
20396
20770
  tracker.mark("remote-project-guard");
@@ -20618,59 +20992,174 @@ async function ownsLocalWorkspace(adapter2, cwd) {
20618
20992
  return storedOwner === remoteOwner;
20619
20993
  }
20620
20994
 
20995
+ // src/lib/worktree-collision.ts
20996
+ var WORKTREE_DIR = ".claude/worktrees";
20997
+ function detectWorktreeCollision(input) {
20998
+ const target = input.targetBranch.trim();
20999
+ if (!target) return null;
21000
+ const conflicts = input.inProgress.filter((b2) => {
21001
+ const branch = b2.branch?.trim();
21002
+ return !!branch && branch !== target && b2.taskId !== input.taskId;
21003
+ });
21004
+ if (conflicts.length === 0) return null;
21005
+ const worktreePath = `${WORKTREE_DIR}/${input.taskId}`;
21006
+ const worktreeCommand = `git worktree add ${worktreePath} ${target}`;
21007
+ const others = conflicts.map((c) => `${c.taskId} (on '${c.branch.trim()}')`).join(", ");
21008
+ const isAre = conflicts.length === 1 ? "is" : "are";
21009
+ 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.`;
21010
+ 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).`;
21011
+ return {
21012
+ warning,
21013
+ worktreeCommand,
21014
+ setupHint,
21015
+ worktreePath,
21016
+ auto: input.autoWorktree,
21017
+ conflicts
21018
+ };
21019
+ }
21020
+
20621
21021
  // src/services/build.ts
20622
21022
  init_git();
20623
21023
 
20624
- // src/lib/diff-inspector.ts
20625
- import { execFileSync as execFileSync5 } from "child_process";
20626
- var TRIGGER_SURFACE_GLOBS = [
20627
- "lib/install-snippets.ts",
20628
- "packages/server/src/transport-http.ts",
20629
- "packages/server/src/index.ts",
20630
- "app/well-known/**",
20631
- "app/api/auth/oauth/**",
20632
- "app/proxy.ts",
20633
- "app/middleware.ts",
20634
- "lib/auth*",
20635
- "next.config.ts",
20636
- "vercel.json",
20637
- "**/railway.toml",
20638
- "**/Dockerfile",
20639
- "**/Procfile",
20640
- "supabase/functions/**",
20641
- "lib/env*.ts"
20642
- ];
20643
- function globToRegex(glob) {
20644
- let out = "";
20645
- let i = 0;
20646
- while (i < glob.length) {
20647
- const ch = glob[i];
20648
- if (ch === "*") {
20649
- if (glob[i + 1] === "*") {
20650
- if (glob[i + 2] === "/") {
20651
- out += "(?:.*/)?";
20652
- i += 3;
20653
- } else {
20654
- out += ".*";
20655
- i += 2;
20656
- }
20657
- } else {
20658
- out += "[^/]*";
20659
- i += 1;
20660
- }
20661
- } else if (ch === "." || ch === "+" || ch === "?" || ch === "(" || ch === ")" || ch === "|" || ch === "[" || ch === "]" || ch === "{" || ch === "}" || ch === "^" || ch === "$" || ch === "\\") {
20662
- out += "\\" + ch;
20663
- i += 1;
20664
- } else {
20665
- out += ch;
20666
- i += 1;
20667
- }
20668
- }
20669
- return new RegExp("^" + out + "$");
20670
- }
20671
- function isTriggerSurfacePath(path7, globs = TRIGGER_SURFACE_GLOBS) {
20672
- for (const g of globs) {
20673
- if (globToRegex(g).test(path7)) return true;
21024
+ // src/lib/build-checkpoint.ts
21025
+ import { createHash as createHash4 } from "crypto";
21026
+ import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync6, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
21027
+ import { join as join11 } from "path";
21028
+ var BUILD_CHECKPOINT_VERSION = 1;
21029
+ function cwdHash(cwd) {
21030
+ return createHash4("sha256").update(realpathOrSelf(cwd)).digest("hex").slice(0, 12);
21031
+ }
21032
+ function safeTaskId(taskId) {
21033
+ return taskId.replace(/[^a-zA-Z0-9._-]/g, "_");
21034
+ }
21035
+ function checkpointDir(cwd) {
21036
+ return join11(cwd, ".papi", "state");
21037
+ }
21038
+ function checkpointPath(cwd, taskId) {
21039
+ return join11(checkpointDir(cwd), `build-${safeTaskId(taskId)}.${cwdHash(cwd)}.json`);
21040
+ }
21041
+ function writeBuildCheckpoint(input) {
21042
+ try {
21043
+ const dir = checkpointDir(input.cwd);
21044
+ if (!existsSync7(dir)) {
21045
+ mkdirSync2(dir, { recursive: true });
21046
+ }
21047
+ const checkpoint = {
21048
+ version: BUILD_CHECKPOINT_VERSION,
21049
+ taskId: input.taskId,
21050
+ branch: input.branch,
21051
+ step: input.step,
21052
+ lastCommitSha: input.lastCommitSha,
21053
+ modifiedFiles: input.modifiedFiles,
21054
+ cwd: realpathOrSelf(input.cwd),
21055
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
21056
+ };
21057
+ writeFileSync3(checkpointPath(input.cwd, input.taskId), JSON.stringify(checkpoint, null, 2) + "\n", "utf-8");
21058
+ } catch {
21059
+ }
21060
+ }
21061
+ function readBuildCheckpoint(key) {
21062
+ try {
21063
+ const path7 = checkpointPath(key.cwd, key.taskId);
21064
+ if (!existsSync7(path7)) return null;
21065
+ const parsed = JSON.parse(readFileSync6(path7, "utf-8"));
21066
+ if (!parsed || parsed.version !== BUILD_CHECKPOINT_VERSION || parsed.taskId !== key.taskId) {
21067
+ return null;
21068
+ }
21069
+ return {
21070
+ version: BUILD_CHECKPOINT_VERSION,
21071
+ taskId: parsed.taskId,
21072
+ branch: parsed.branch ?? null,
21073
+ step: "branch_ready",
21074
+ lastCommitSha: parsed.lastCommitSha ?? null,
21075
+ modifiedFiles: Array.isArray(parsed.modifiedFiles) ? parsed.modifiedFiles : [],
21076
+ cwd: typeof parsed.cwd === "string" ? parsed.cwd : realpathOrSelf(key.cwd),
21077
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : ""
21078
+ };
21079
+ } catch {
21080
+ return null;
21081
+ }
21082
+ }
21083
+ function clearBuildCheckpoint(key) {
21084
+ try {
21085
+ const path7 = checkpointPath(key.cwd, key.taskId);
21086
+ if (existsSync7(path7)) {
21087
+ unlinkSync2(path7);
21088
+ }
21089
+ } catch {
21090
+ }
21091
+ }
21092
+ function formatResumeNote(cp) {
21093
+ const files = cp.modifiedFiles.filter((f) => f && f.trim());
21094
+ const fileList = files.length > 0 ? files.slice(0, 12).map((f) => ` - ${f}`).join("\n") + (files.length > 12 ? `
21095
+ - \u2026+${files.length - 12} more` : "") : " _(none recorded)_";
21096
+ const lines = [
21097
+ "> **\u21BB Resuming from checkpoint** \u2014 this task is already In Progress; you are NOT starting clean.",
21098
+ `> - Branch: \`${cp.branch ?? "unknown"}\``,
21099
+ "> - Last step: branch ready",
21100
+ `> - Last commit: ${cp.lastCommitSha ? cp.lastCommitSha.slice(0, 8) : "none"}`,
21101
+ `> - Checkpoint saved: ${cp.updatedAt || "unknown"}`,
21102
+ ">",
21103
+ "> Modified files at last checkpoint:",
21104
+ ...fileList.split("\n").map((l) => `> ${l}`),
21105
+ ">",
21106
+ "> Review the existing branch and changes above before writing new code \u2014 do not re-do work already committed.",
21107
+ "",
21108
+ ""
21109
+ ];
21110
+ return lines.join("\n");
21111
+ }
21112
+
21113
+ // src/lib/diff-inspector.ts
21114
+ import { execFileSync as execFileSync5 } from "child_process";
21115
+ var TRIGGER_SURFACE_GLOBS = [
21116
+ "lib/install-snippets.ts",
21117
+ "packages/server/src/transport-http.ts",
21118
+ "packages/server/src/index.ts",
21119
+ "app/well-known/**",
21120
+ "app/api/auth/oauth/**",
21121
+ "app/proxy.ts",
21122
+ "app/middleware.ts",
21123
+ "lib/auth*",
21124
+ "next.config.ts",
21125
+ "vercel.json",
21126
+ "**/railway.toml",
21127
+ "**/Dockerfile",
21128
+ "**/Procfile",
21129
+ "supabase/functions/**",
21130
+ "lib/env*.ts"
21131
+ ];
21132
+ function globToRegex(glob) {
21133
+ let out = "";
21134
+ let i = 0;
21135
+ while (i < glob.length) {
21136
+ const ch = glob[i];
21137
+ if (ch === "*") {
21138
+ if (glob[i + 1] === "*") {
21139
+ if (glob[i + 2] === "/") {
21140
+ out += "(?:.*/)?";
21141
+ i += 3;
21142
+ } else {
21143
+ out += ".*";
21144
+ i += 2;
21145
+ }
21146
+ } else {
21147
+ out += "[^/]*";
21148
+ i += 1;
21149
+ }
21150
+ } else if (ch === "." || ch === "+" || ch === "?" || ch === "(" || ch === ")" || ch === "|" || ch === "[" || ch === "]" || ch === "{" || ch === "}" || ch === "^" || ch === "$" || ch === "\\") {
21151
+ out += "\\" + ch;
21152
+ i += 1;
21153
+ } else {
21154
+ out += ch;
21155
+ i += 1;
21156
+ }
21157
+ }
21158
+ return new RegExp("^" + out + "$");
21159
+ }
21160
+ function isTriggerSurfacePath(path7, globs = TRIGGER_SURFACE_GLOBS) {
21161
+ for (const g of globs) {
21162
+ if (globToRegex(g).test(path7)) return true;
20674
21163
  }
20675
21164
  return false;
20676
21165
  }
@@ -20856,51 +21345,22 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
20856
21345
  if (staged.length > 0) {
20857
21346
  return safeRun(() => commitStagedOnly(cwd, message)) + ` (selective staging respected: ${staged.length} file(s)).`;
20858
21347
  }
21348
+ const modified = getModifiedFiles(cwd);
21349
+ if (modified.length === 0) {
21350
+ return "Auto-commit: skipped (no working-tree changes).";
21351
+ }
21352
+ const commitResult = safeRun(() => stageAllAndCommit(cwd, message));
20859
21353
  if (predictedFiles && predictedFiles.length > 0) {
20860
- const modified = getModifiedFiles(cwd);
20861
- if (modified.length === 0) {
20862
- return "Auto-commit: skipped (no working-tree changes).";
20863
- }
20864
- const dirname7 = (p) => {
20865
- const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
20866
- return idx > 0 ? p.slice(0, idx) : "";
20867
- };
20868
21354
  const cleanedPredicted = sanitisePredictedFiles(predictedFiles);
20869
- const scoped = modified.filter((p) => isPathInPredictedScope(p, cleanedPredicted));
20870
- const untracked = getUntrackedFiles(cwd);
20871
- const scopedSet = new Set(scoped);
20872
- const untrackedInScope = untracked.filter(
20873
- (p) => !scopedSet.has(p) && isPathInPredictedScope(p, cleanedPredicted)
20874
- );
20875
- if (scoped.length === 0 && untrackedInScope.length === 0) {
20876
- const modSample = modified.slice(0, 5).join(", ");
20877
- const predSample = cleanedPredicted.slice(0, 5).join(", ");
20878
- return `Auto-commit: refused \u2014 none of the ${modified.length} modified file(s) intersect FILES LIKELY TOUCHED. Modified: ${modSample}. Expected: ${predSample}. Stage the intended files manually (\`git add <paths>\`) then re-run, or set PAPI_AUTO_COMMIT=false.`;
20879
- }
20880
- const scopedDirs = [...new Set(scoped.map(dirname7).filter((d) => d.length > 0))];
20881
- const isUnderScopedDir = (p) => scopedDirs.some((d) => p === d || p.startsWith(`${d}/`) || p.startsWith(`${d}\\`));
20882
- const inScopeSet = /* @__PURE__ */ new Set([...scoped, ...untrackedInScope]);
20883
- const adjacentUntracked = untracked.filter(
20884
- (p) => !inScopeSet.has(p) && isUnderScopedDir(p)
20885
- );
20886
- const toStage = [...scoped, ...untrackedInScope, ...adjacentUntracked];
20887
- const toStageSet = new Set(toStage);
20888
- const droppedUntracked = untracked.filter((p) => !toStageSet.has(p));
20889
- const droppedModified = modified.filter((p) => !scopedSet.has(p));
20890
- let line = safeRun(() => stagePathsAndCommit(cwd, toStage, message)) + ` (scoped to ${scoped.length}/${modified.length} files via FILES LIKELY TOUCHED` + (untrackedInScope.length > 0 ? ` + ${untrackedInScope.length} new file(s) named in the handoff` : "") + (adjacentUntracked.length > 0 ? ` + ${adjacentUntracked.length} untracked under scoped dir(s)` : "") + `).`;
20891
- if (droppedModified.length > 0) {
20892
- const sample = droppedModified.slice(0, 10).join(", ");
20893
- const more = droppedModified.length > 10 ? ` (+${droppedModified.length - 10} more)` : "";
20894
- line += ` \u26A0\uFE0F ${droppedModified.length} modified file(s) outside FILES LIKELY TOUCHED were NOT staged: ${sample}${more}. If they belong to this task, stage them manually (\`git add <paths>\`) and re-run, or set PAPI_AUTO_COMMIT=false.`;
20895
- }
20896
- if (droppedUntracked.length > 0) {
20897
- const sample = droppedUntracked.slice(0, 10).join(", ");
20898
- const more = droppedUntracked.length > 10 ? ` (+${droppedUntracked.length - 10} more)` : "";
20899
- line += ` \u26A0\uFE0F ${droppedUntracked.length} untracked file(s) were NOT committed: ${sample}${more}. If they belong to this task, run \`git add <paths> && git commit --amend --no-edit\` before pushing \u2014 otherwise the committed tree may not build on checkout/CI.`;
21355
+ const outOfScope = modified.filter((p) => !isPathInPredictedScope(p, cleanedPredicted));
21356
+ if (outOfScope.length > 0) {
21357
+ const sample = outOfScope.slice(0, 10).join(", ");
21358
+ const more = outOfScope.length > 10 ? ` (+${outOfScope.length - 10} more)` : "";
21359
+ return `${commitResult} (staged all ${modified.length} changed file(s)). \u2139\uFE0F Scope drift: ${outOfScope.length} committed file(s) were outside the handoff's FILES LIKELY TOUCHED \u2014 handoff under-predicted: ${sample}${more}.`;
20900
21360
  }
20901
- return line;
21361
+ return `${commitResult} (staged all ${modified.length} changed file(s), all within FILES LIKELY TOUCHED).`;
20902
21362
  }
20903
- return safeRun(() => stageAllAndCommit(cwd, message));
21363
+ return `${commitResult} (staged all ${modified.length} changed file(s)).`;
20904
21364
  }
20905
21365
  function pushAndCreatePR(config2, taskId, taskTitle, clientName, module, cycleNumber) {
20906
21366
  const lines = [];
@@ -21221,7 +21681,46 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21221
21681
  }
21222
21682
  taskBranchMap.set(taskId, featureBranch);
21223
21683
  const currentBranch = getCurrentBranch(config2.projectRoot);
21224
- if (currentBranch === featureBranch) {
21684
+ let isolatedIntoWorktree = false;
21685
+ if (currentBranch !== featureBranch) {
21686
+ const otherInProgress = allTasks.filter((t) => t.status === "In Progress" && t.id !== taskId && t.displayId !== taskId).map((t) => ({
21687
+ taskId: t.displayId || t.id,
21688
+ branch: (t.branchName ?? taskBranchMap.get(t.id) ?? taskBranchMap.get(t.displayId) ?? "").trim()
21689
+ })).filter((t) => t.branch.length > 0);
21690
+ const collision = detectWorktreeCollision({
21691
+ taskId,
21692
+ targetBranch: featureBranch,
21693
+ inProgress: otherInProgress,
21694
+ autoWorktree: config2.autoWorktree
21695
+ });
21696
+ if (collision) {
21697
+ branchLines.push(collision.warning);
21698
+ if (collision.auto) {
21699
+ try {
21700
+ const { execFileSync: execFileSync7 } = await import("child_process");
21701
+ const baseForWorktree = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
21702
+ const wtArgs = branchExists(config2.projectRoot, featureBranch) ? ["worktree", "add", collision.worktreePath, featureBranch] : ["worktree", "add", "-b", featureBranch, collision.worktreePath, baseForWorktree];
21703
+ execFileSync7("git", wtArgs, { cwd: config2.projectRoot, encoding: "utf-8" });
21704
+ isolatedIntoWorktree = true;
21705
+ branchLines.push(
21706
+ `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.`
21707
+ );
21708
+ branchLines.push(collision.setupHint);
21709
+ } catch (err) {
21710
+ branchLines.push(
21711
+ `Auto-worktree attempt failed (${err instanceof Error ? err.message : String(err)}) \u2014 isolate manually instead:`
21712
+ );
21713
+ branchLines.push(` ${collision.worktreeCommand}`);
21714
+ branchLines.push(collision.setupHint);
21715
+ }
21716
+ } else {
21717
+ branchLines.push(` ${collision.worktreeCommand}`);
21718
+ branchLines.push(collision.setupHint);
21719
+ }
21720
+ }
21721
+ }
21722
+ if (isolatedIntoWorktree) {
21723
+ } else if (currentBranch === featureBranch) {
21225
21724
  branchLines.push(`Already on branch '${featureBranch}'.`);
21226
21725
  if (useSharedBranch) {
21227
21726
  branchLines.push(`Reusing shared cycle branch for ${task.complexity} ${task.module} task.`);
@@ -21338,8 +21837,9 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21338
21837
  await persistBranchName(adapter2, taskId, startedBranch);
21339
21838
  }
21340
21839
  buildStartTimes.set(taskId, (/* @__PURE__ */ new Date()).toISOString());
21840
+ let startSha = null;
21341
21841
  if (isGitAvailable() && isGitRepo(config2.projectRoot)) {
21342
- const startSha = getHeadCommitSha(config2.projectRoot);
21842
+ startSha = getHeadCommitSha(config2.projectRoot);
21343
21843
  if (startSha) taskStartShaMap.set(taskId, startSha);
21344
21844
  }
21345
21845
  let phaseChanges = [];
@@ -21358,6 +21858,14 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21358
21858
  );
21359
21859
  } catch {
21360
21860
  }
21861
+ writeBuildCheckpoint({
21862
+ cwd: config2.projectRoot,
21863
+ taskId,
21864
+ branch: getCurrentBranch(config2.projectRoot),
21865
+ step: "branch_ready",
21866
+ lastCommitSha: startSha,
21867
+ modifiedFiles: getModifiedFiles(config2.projectRoot)
21868
+ });
21361
21869
  return {
21362
21870
  task,
21363
21871
  branchLines,
@@ -21372,17 +21880,17 @@ function writeActiveTaskScope(projectRoot, taskId, filesLikelyTouched, adapterTy
21372
21880
  collector.add({ path: ".papi/active-task-scope.txt", content, mode: "overwrite" });
21373
21881
  return;
21374
21882
  }
21375
- const papiDir = join11(projectRoot, ".papi");
21376
- if (!existsSync7(papiDir)) {
21377
- mkdirSync2(papiDir, { recursive: true });
21883
+ const papiDir = join12(projectRoot, ".papi");
21884
+ if (!existsSync8(papiDir)) {
21885
+ mkdirSync3(papiDir, { recursive: true });
21378
21886
  }
21379
- const scopePath = join11(papiDir, "active-task-scope.txt");
21380
- writeFileSync3(scopePath, content, "utf-8");
21887
+ const scopePath = join12(papiDir, "active-task-scope.txt");
21888
+ writeFileSync4(scopePath, content, "utf-8");
21381
21889
  }
21382
21890
  function clearActiveTaskScope(projectRoot) {
21383
- const scopePath = join11(projectRoot, ".papi", "active-task-scope.txt");
21384
- if (existsSync7(scopePath)) {
21385
- unlinkSync2(scopePath);
21891
+ const scopePath = join12(projectRoot, ".papi", "active-task-scope.txt");
21892
+ if (existsSync8(scopePath)) {
21893
+ unlinkSync3(scopePath);
21386
21894
  }
21387
21895
  }
21388
21896
  function sanitiseResponseExcerpt(raw) {
@@ -21401,7 +21909,7 @@ function extractDocMeta(absolutePath, relativePath, cycleNumber) {
21401
21909
  else if (relativePath.startsWith("docs/architecture/")) type = "architecture";
21402
21910
  else if (relativePath.startsWith("docs/audits/")) type = "audit";
21403
21911
  try {
21404
- const content = readFileSync6(absolutePath, "utf-8").slice(0, 2e3);
21912
+ const content = readFileSync7(absolutePath, "utf-8").slice(0, 2e3);
21405
21913
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
21406
21914
  if (fmMatch) {
21407
21915
  const fm = fmMatch[1];
@@ -21798,14 +22306,14 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
21798
22306
  let docWarning;
21799
22307
  try {
21800
22308
  if (adapter2.searchDocs && hasLocalWorkspace() && await ownsLocalWorkspace(adapter2, config2.projectRoot)) {
21801
- const docsDir = join11(config2.projectRoot, "docs");
21802
- if (existsSync7(docsDir)) {
22309
+ const docsDir = join12(config2.projectRoot, "docs");
22310
+ if (existsSync8(docsDir)) {
21803
22311
  const scanDir = (dir, depth = 0) => {
21804
22312
  if (depth > 8) return [];
21805
22313
  const entries = readdirSync5(dir, { withFileTypes: true });
21806
22314
  const files = [];
21807
22315
  for (const e of entries) {
21808
- const full = join11(dir, e.name);
22316
+ const full = join12(dir, e.name);
21809
22317
  if (e.isDirectory() && !e.isSymbolicLink()) files.push(...scanDir(full, depth + 1));
21810
22318
  else if (e.name.endsWith(".md")) files.push(full.replace(config2.projectRoot + "/", ""));
21811
22319
  }
@@ -21820,7 +22328,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
21820
22328
  const failed = [];
21821
22329
  for (const docPath of unregistered) {
21822
22330
  try {
21823
- const meta = extractDocMeta(join11(config2.projectRoot, docPath), docPath, cycleNumber);
22331
+ const meta = extractDocMeta(join12(config2.projectRoot, docPath), docPath, cycleNumber);
21824
22332
  await adapter2.registerDoc({
21825
22333
  title: meta.title,
21826
22334
  type: meta.type,
@@ -21858,6 +22366,10 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
21858
22366
  clearActiveTaskScope(config2.projectRoot);
21859
22367
  } catch {
21860
22368
  }
22369
+ try {
22370
+ clearBuildCheckpoint({ cwd: config2.projectRoot, taskId });
22371
+ } catch {
22372
+ }
21861
22373
  return {
21862
22374
  task,
21863
22375
  report,
@@ -21972,8 +22484,8 @@ ${instructions}`;
21972
22484
  }
21973
22485
 
21974
22486
  // src/tools/doc-registry.ts
21975
- import { readdirSync as readdirSync6, existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
21976
- import { join as join12, relative } from "path";
22487
+ import { readdirSync as readdirSync6, existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
22488
+ import { join as join13, relative } from "path";
21977
22489
  import { homedir as homedir3 } from "os";
21978
22490
  import { randomUUID as randomUUID12 } from "crypto";
21979
22491
  import { docDeletionBlockMessage } from "@papi-ai/shared";
@@ -22171,7 +22683,7 @@ async function handleDocSearch(adapter2, args, config2) {
22171
22683
  const lines = docs.map((d) => {
22172
22684
  const actionCount = d.actions?.filter((a) => a.status === "pending").length ?? 0;
22173
22685
  const actionNote = actionCount > 0 ? ` | ${actionCount} pending action(s)` : "";
22174
- const missingNote = root && d.path && !existsSync8(join12(root, d.path)) ? `
22686
+ const missingNote = root && d.path && !existsSync9(join13(root, d.path)) ? `
22175
22687
  > \u26A0\uFE0F **File missing on disk** \u2014 the registry points at \`${d.path}\` but nothing is there. Check \`git stash list\` for a papi-autostash entry, or re-create/deregister the doc.` : "";
22176
22688
  return `### ${d.title}
22177
22689
  **Type:** ${d.type} | **Status:** ${d.status} | **Cycle:** ${d.cycleCreated}${d.cycleUpdated ? `\u2192${d.cycleUpdated}` : ""}${actionNote}
@@ -22185,12 +22697,12 @@ ${d.summary}
22185
22697
  ${lines.join("\n---\n\n")}`);
22186
22698
  }
22187
22699
  function scanMdFiles(dir, rootDir) {
22188
- if (!existsSync8(dir)) return [];
22700
+ if (!existsSync9(dir)) return [];
22189
22701
  const files = [];
22190
22702
  try {
22191
22703
  const entries = readdirSync6(dir, { withFileTypes: true });
22192
22704
  for (const entry of entries) {
22193
- const full = join12(dir, entry.name);
22705
+ const full = join13(dir, entry.name);
22194
22706
  if (entry.isDirectory()) {
22195
22707
  files.push(...scanMdFiles(full, rootDir));
22196
22708
  } else if (entry.name.endsWith(".md")) {
@@ -22203,7 +22715,7 @@ function scanMdFiles(dir, rootDir) {
22203
22715
  }
22204
22716
  function extractTitle(filePath) {
22205
22717
  try {
22206
- const content = readFileSync7(filePath, "utf-8").slice(0, 1e3);
22718
+ const content = readFileSync8(filePath, "utf-8").slice(0, 1e3);
22207
22719
  const fmMatch = content.match(/^---[\s\S]*?title:\s*(.+?)$/m);
22208
22720
  if (fmMatch) return fmMatch[1].trim().replace(/^["']|["']$/g, "");
22209
22721
  const headingMatch = content.match(/^#+\s+(.+)$/m);
@@ -22215,7 +22727,7 @@ function extractTitle(filePath) {
22215
22727
  async function detectUnregisteredDocsNote(adapter2, config2) {
22216
22728
  try {
22217
22729
  if (!adapter2.searchDocs || !hasLocalWorkspace()) return "";
22218
- const docsDir = join12(config2.projectRoot, "docs");
22730
+ const docsDir = join13(config2.projectRoot, "docs");
22219
22731
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
22220
22732
  if (docsFiles.length === 0) return "";
22221
22733
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
@@ -22241,17 +22753,17 @@ async function handleDocScan(adapter2, config2, args) {
22241
22753
  const includePlans = args.include_plans ?? false;
22242
22754
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
22243
22755
  const registeredPaths = new Set(registered.map((d) => d.path));
22244
- const docsDir = join12(config2.projectRoot, "docs");
22756
+ const docsDir = join13(config2.projectRoot, "docs");
22245
22757
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
22246
22758
  const unregisteredDocs = docsFiles.filter((f) => !registeredPaths.has(f));
22247
22759
  let unregisteredPlans = [];
22248
22760
  if (includePlans) {
22249
- const plansDir = join12(homedir3(), ".claude", "plans");
22250
- if (existsSync8(plansDir)) {
22761
+ const plansDir = join13(homedir3(), ".claude", "plans");
22762
+ if (existsSync9(plansDir)) {
22251
22763
  const planFiles = scanMdFiles(plansDir, plansDir);
22252
22764
  unregisteredPlans = planFiles.map((f) => `plans/${f}`).filter((f) => !registeredPaths.has(f)).map((f) => ({
22253
22765
  path: f,
22254
- title: extractTitle(join12(plansDir, f.replace("plans/", "")))
22766
+ title: extractTitle(join13(plansDir, f.replace("plans/", "")))
22255
22767
  }));
22256
22768
  }
22257
22769
  }
@@ -22262,7 +22774,7 @@ async function handleDocScan(adapter2, config2, args) {
22262
22774
  if (unregisteredDocs.length > 0) {
22263
22775
  lines.push(`## Unregistered Docs (${unregisteredDocs.length})`);
22264
22776
  for (const f of unregisteredDocs) {
22265
- const title = extractTitle(join12(config2.projectRoot, f));
22777
+ const title = extractTitle(join13(config2.projectRoot, f));
22266
22778
  lines.push(`- \`${f}\`${title ? ` \u2014 ${title}` : ""}`);
22267
22779
  }
22268
22780
  }
@@ -22435,97 +22947,6 @@ async function handleDocReorder(adapter2, args) {
22435
22947
 
22436
22948
  // src/tools/build.ts
22437
22949
  init_git();
22438
-
22439
- // src/lib/build-checkpoint.ts
22440
- import { createHash as createHash4 } from "crypto";
22441
- import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync8, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4 } from "fs";
22442
- import { join as join13 } from "path";
22443
- var BUILD_CHECKPOINT_VERSION = 1;
22444
- function cwdHash(cwd) {
22445
- return createHash4("sha256").update(realpathOrSelf(cwd)).digest("hex").slice(0, 12);
22446
- }
22447
- function safeTaskId(taskId) {
22448
- return taskId.replace(/[^a-zA-Z0-9._-]/g, "_");
22449
- }
22450
- function checkpointDir(cwd) {
22451
- return join13(cwd, ".papi", "state");
22452
- }
22453
- function checkpointPath(cwd, taskId) {
22454
- return join13(checkpointDir(cwd), `build-${safeTaskId(taskId)}.${cwdHash(cwd)}.json`);
22455
- }
22456
- function writeBuildCheckpoint(input) {
22457
- try {
22458
- const dir = checkpointDir(input.cwd);
22459
- if (!existsSync9(dir)) {
22460
- mkdirSync3(dir, { recursive: true });
22461
- }
22462
- const checkpoint = {
22463
- version: BUILD_CHECKPOINT_VERSION,
22464
- taskId: input.taskId,
22465
- branch: input.branch,
22466
- step: input.step,
22467
- lastCommitSha: input.lastCommitSha,
22468
- modifiedFiles: input.modifiedFiles,
22469
- cwd: realpathOrSelf(input.cwd),
22470
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22471
- };
22472
- writeFileSync4(checkpointPath(input.cwd, input.taskId), JSON.stringify(checkpoint, null, 2) + "\n", "utf-8");
22473
- } catch {
22474
- }
22475
- }
22476
- function readBuildCheckpoint(key) {
22477
- try {
22478
- const path7 = checkpointPath(key.cwd, key.taskId);
22479
- if (!existsSync9(path7)) return null;
22480
- const parsed = JSON.parse(readFileSync8(path7, "utf-8"));
22481
- if (!parsed || parsed.version !== BUILD_CHECKPOINT_VERSION || parsed.taskId !== key.taskId) {
22482
- return null;
22483
- }
22484
- return {
22485
- version: BUILD_CHECKPOINT_VERSION,
22486
- taskId: parsed.taskId,
22487
- branch: parsed.branch ?? null,
22488
- step: "branch_ready",
22489
- lastCommitSha: parsed.lastCommitSha ?? null,
22490
- modifiedFiles: Array.isArray(parsed.modifiedFiles) ? parsed.modifiedFiles : [],
22491
- cwd: typeof parsed.cwd === "string" ? parsed.cwd : realpathOrSelf(key.cwd),
22492
- updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : ""
22493
- };
22494
- } catch {
22495
- return null;
22496
- }
22497
- }
22498
- function clearBuildCheckpoint(key) {
22499
- try {
22500
- const path7 = checkpointPath(key.cwd, key.taskId);
22501
- if (existsSync9(path7)) {
22502
- unlinkSync3(path7);
22503
- }
22504
- } catch {
22505
- }
22506
- }
22507
- function formatResumeNote(cp) {
22508
- const files = cp.modifiedFiles.filter((f) => f && f.trim());
22509
- const fileList = files.length > 0 ? files.slice(0, 12).map((f) => ` - ${f}`).join("\n") + (files.length > 12 ? `
22510
- - \u2026+${files.length - 12} more` : "") : " _(none recorded)_";
22511
- const lines = [
22512
- "> **\u21BB Resuming from checkpoint** \u2014 this task is already In Progress; you are NOT starting clean.",
22513
- `> - Branch: \`${cp.branch ?? "unknown"}\``,
22514
- "> - Last step: branch ready",
22515
- `> - Last commit: ${cp.lastCommitSha ? cp.lastCommitSha.slice(0, 8) : "none"}`,
22516
- `> - Checkpoint saved: ${cp.updatedAt || "unknown"}`,
22517
- ">",
22518
- "> Modified files at last checkpoint:",
22519
- ...fileList.split("\n").map((l) => `> ${l}`),
22520
- ">",
22521
- "> Review the existing branch and changes above before writing new code \u2014 do not re-do work already committed.",
22522
- "",
22523
- ""
22524
- ];
22525
- return lines.join("\n");
22526
- }
22527
-
22528
- // src/tools/build.ts
22529
22950
  var buildListTool = {
22530
22951
  name: "build_list",
22531
22952
  description: "List cycle tasks that have BUILD HANDOFFs ready for execution. Shows task ID, title, status, priority, and complexity. In Progress tasks appear first, then Backlog. Does not call the Anthropic API.",
@@ -22561,7 +22982,17 @@ var buildExecuteTool = {
22561
22982
  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
22983
  annotations: { title: "Run Build", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
22563
22984
  inputSchema: {
22985
+ $schema: "https://json-schema.org/draft/2020-12/schema",
22564
22986
  type: "object",
22987
+ // task-2802: JSON Schema 2020-12. Shared effort-size enum lives in $defs and is
22988
+ // referenced from effort/estimated_effort so the two stay in lockstep; the
22989
+ // start-vs-complete contract is expressed as a conditional (see allOf below).
22990
+ $defs: {
22991
+ effortSize: {
22992
+ type: "string",
22993
+ enum: ["XS", "S", "M", "L", "XL"]
22994
+ }
22995
+ },
22565
22996
  properties: {
22566
22997
  task_id: {
22567
22998
  type: "string",
@@ -22581,13 +23012,11 @@ var buildExecuteTool = {
22581
23012
  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
23013
  },
22583
23014
  effort: {
22584
- type: "string",
22585
- enum: ["XS", "S", "M", "L", "XL"],
23015
+ $ref: "#/$defs/effortSize",
22586
23016
  description: "Actual effort: XS, S, M, L, or XL. Required for complete."
22587
23017
  },
22588
23018
  estimated_effort: {
22589
- type: "string",
22590
- enum: ["XS", "S", "M", "L", "XL"],
23019
+ $ref: "#/$defs/effortSize",
22591
23020
  description: "Estimated effort from the BUILD HANDOFF. Required for complete."
22592
23021
  },
22593
23022
  model: {
@@ -22631,6 +23060,8 @@ var buildExecuteTool = {
22631
23060
  },
22632
23061
  corrections_count: {
22633
23062
  type: "integer",
23063
+ minimum: 0,
23064
+ default: 0,
22634
23065
  description: "Number of times the user corrected or redirected the build during implementation. Captures informal pushback that bypasses review_submit. Default 0."
22635
23066
  },
22636
23067
  dead_ends: {
@@ -22681,7 +23112,29 @@ var buildExecuteTool = {
22681
23112
  }
22682
23113
  }
22683
23114
  },
22684
- required: ["task_id"]
23115
+ required: ["task_id"],
23116
+ // task-2802: a COMPLETE call is any call carrying report data (mirrors the
23117
+ // handler's isCompleteCall at build.ts). When any of the six report fields is
23118
+ // present, all six are required — exactly what completeBuild enforces, so this
23119
+ // never rejects a call the handler would accept; it just steers the agent to
23120
+ // send the full report in one shot instead of round-tripping on a missing field.
23121
+ allOf: [
23122
+ {
23123
+ if: {
23124
+ anyOf: [
23125
+ { required: ["completed"] },
23126
+ { required: ["effort"] },
23127
+ { required: ["estimated_effort"] },
23128
+ { required: ["surprises"] },
23129
+ { required: ["discovered_issues"] },
23130
+ { required: ["architecture_notes"] }
23131
+ ]
23132
+ },
23133
+ then: {
23134
+ required: ["completed", "effort", "estimated_effort", "surprises", "discovered_issues", "architecture_notes"]
23135
+ }
23136
+ }
23137
+ ]
22685
23138
  }
22686
23139
  };
22687
23140
  var buildCancelTool = {
@@ -22872,14 +23325,6 @@ async function handleBuildExecute(adapter2, config2, args, clientName) {
22872
23325
  const result = await startBuild(adapter2, config2, taskId, { light }, clientName);
22873
23326
  tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.task.cycle ?? null });
22874
23327
  await tracker.recordStep("branch_ready");
22875
- writeBuildCheckpoint({
22876
- cwd: config2.projectRoot,
22877
- taskId,
22878
- branch: getCurrentBranch(config2.projectRoot),
22879
- step: "branch_ready",
22880
- lastCommitSha: getHeadCommitSha(config2.projectRoot),
22881
- modifiedFiles: getModifiedFiles(config2.projectRoot)
22882
- });
22883
23328
  tracker.mark("start_decorate_handoff");
22884
23329
  const branchInfo = result.branchLines.length > 0 ? result.branchLines.map((l) => `> ${l}`).join("\n") + "\n\n" : "";
22885
23330
  const phaseNote = result.phaseChanges.length > 0 ? "\n\n" + result.phaseChanges.map((c) => `Phase auto-updated: ${c.phaseId} ${c.oldStatus} \u2192 ${c.newStatus}`).join("\n") : "";
@@ -23098,7 +23543,6 @@ Your report was NOT discarded and the task is NOT yet Done \u2014 re-send with \
23098
23543
  preview
23099
23544
  }, { light }, clientName);
23100
23545
  tracker.mark("complete_format");
23101
- clearBuildCheckpoint({ cwd: config2.projectRoot, taskId });
23102
23546
  tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.cycleNumber });
23103
23547
  await tracker.recordStep("report_written");
23104
23548
  if ((result.autoTriagedCount ?? 0) > 0) {
@@ -23456,12 +23900,460 @@ Re-submit with \`notes: "... Reference: <path>"\` to link one, or ignore if none
23456
23900
  return textResponse(`${result.message}${overrideNote}`);
23457
23901
  }
23458
23902
 
23903
+ // src/services/import.ts
23904
+ import { randomUUID as randomUUID13 } from "crypto";
23905
+ var IMPLEMENTED_SOURCES = ["csv", "markdown", "linear"];
23906
+ var MAX_TITLE_LEN = 200;
23907
+ var MAX_NOTES_LEN = 2e3;
23908
+ function sanitiseText(value, maxLen, keepNewlines = false) {
23909
+ let str;
23910
+ if (typeof value === "string") str = value;
23911
+ else if (typeof value === "number" || typeof value === "boolean") str = String(value);
23912
+ else if (value == null) str = "";
23913
+ else {
23914
+ try {
23915
+ str = String(value);
23916
+ } catch {
23917
+ str = "";
23918
+ }
23919
+ }
23920
+ if (keepNewlines) {
23921
+ str = str.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, " ");
23922
+ str = str.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n");
23923
+ } else {
23924
+ str = str.replace(/[\x00-\x1F\x7F-\x9F]/g, " ");
23925
+ str = str.replace(/\s+/g, " ");
23926
+ }
23927
+ str = str.trim();
23928
+ if (str.length > maxLen) str = str.slice(0, maxLen).trim();
23929
+ return str;
23930
+ }
23931
+ function mapPriorityString(value) {
23932
+ const v = String(value ?? "").trim().toLowerCase();
23933
+ if (/\b(p0|urgent|critical|highest|blocker)\b/.test(v)) return "P0 Critical";
23934
+ if (/\b(p1|high)\b/.test(v)) return "P1 High";
23935
+ if (/\b(p3|low|lowest|minor)\b/.test(v)) return "P3 Low";
23936
+ return "P2 Medium";
23937
+ }
23938
+ function mapPriorityNumber(value) {
23939
+ const n = typeof value === "number" ? value : Number(value);
23940
+ switch (n) {
23941
+ case 1:
23942
+ return "P0 Critical";
23943
+ case 2:
23944
+ return "P1 High";
23945
+ case 4:
23946
+ return "P3 Low";
23947
+ case 3:
23948
+ case 0:
23949
+ default:
23950
+ return "P2 Medium";
23951
+ }
23952
+ }
23953
+ function mapStatusString(value) {
23954
+ const v = String(value ?? "").trim().toLowerCase();
23955
+ if (/\b(done|complete|completed|closed|merged|shipped|resolved)\b/.test(v)) return "Done";
23956
+ if (/\b(cancel|cancelled|canceled|wont.?fix|duplicate|abandoned)\b/.test(v)) return "Cancelled";
23957
+ if (/\b(in.?review|review|qa|verifying)\b/.test(v)) return "In Review";
23958
+ if (/\b(in.?progress|started|doing|active|wip)\b/.test(v)) return "In Progress";
23959
+ if (/\b(blocked|waiting|on.?hold)\b/.test(v)) return "Blocked";
23960
+ return "Backlog";
23961
+ }
23962
+ function estimateToComplexity(value) {
23963
+ const n = typeof value === "number" ? value : Number(value);
23964
+ if (!Number.isFinite(n) || n <= 0) return "Small";
23965
+ if (n <= 1) return "XS";
23966
+ if (n <= 2) return "Small";
23967
+ if (n <= 3) return "Medium";
23968
+ if (n <= 5) return "Large";
23969
+ return "XL";
23970
+ }
23971
+ function mapComplexityField(value) {
23972
+ const v = String(value ?? "").trim();
23973
+ if (v !== "" && /^\d+(\.\d+)?$/.test(v)) return estimateToComplexity(v);
23974
+ return normalizeComplexity(v);
23975
+ }
23976
+ function parseCsv(text) {
23977
+ const rows = [];
23978
+ let field = "";
23979
+ let row = [];
23980
+ let inQuotes = false;
23981
+ for (let i = 0; i < text.length; i++) {
23982
+ const c = text[i];
23983
+ if (inQuotes) {
23984
+ if (c === '"') {
23985
+ if (text[i + 1] === '"') {
23986
+ field += '"';
23987
+ i++;
23988
+ } else {
23989
+ inQuotes = false;
23990
+ }
23991
+ } else {
23992
+ field += c;
23993
+ }
23994
+ continue;
23995
+ }
23996
+ if (c === '"') {
23997
+ inQuotes = true;
23998
+ } else if (c === ",") {
23999
+ row.push(field);
24000
+ field = "";
24001
+ } else if (c === "\n" || c === "\r") {
24002
+ if (c === "\r" && text[i + 1] === "\n") i++;
24003
+ row.push(field);
24004
+ field = "";
24005
+ if (row.some((f) => f.trim() !== "")) rows.push(row);
24006
+ row = [];
24007
+ } else {
24008
+ field += c;
24009
+ }
24010
+ }
24011
+ if (field !== "" || row.length > 0) {
24012
+ row.push(field);
24013
+ if (row.some((f) => f.trim() !== "")) rows.push(row);
24014
+ }
24015
+ return rows;
24016
+ }
24017
+ function findColumn(header, candidates) {
24018
+ const lower = header.map((h) => h.trim().toLowerCase());
24019
+ for (const cand of candidates) {
24020
+ const idx = lower.indexOf(cand);
24021
+ if (idx !== -1) return idx;
24022
+ }
24023
+ return -1;
24024
+ }
24025
+ var csvNormaliser = ({ raw }) => {
24026
+ if (!raw || !raw.trim()) return [];
24027
+ const rows = parseCsv(raw);
24028
+ if (rows.length < 2) return [];
24029
+ const header = rows[0];
24030
+ const titleCol = findColumn(header, ["title", "name", "task", "summary", "subject"]);
24031
+ if (titleCol === -1) {
24032
+ throw new Error(
24033
+ "CSV import: no recognisable title column. Expected a header row with one of: title, name, task, summary, subject."
24034
+ );
24035
+ }
24036
+ const notesCol = findColumn(header, ["notes", "description", "details", "body"]);
24037
+ const statusCol = findColumn(header, ["status", "state"]);
24038
+ const priorityCol = findColumn(header, ["priority", "importance"]);
24039
+ const complexityCol = findColumn(header, ["complexity", "estimate", "size", "points", "effort"]);
24040
+ const idCol = findColumn(header, ["id", "key", "identifier", "ref"]);
24041
+ const out = [];
24042
+ for (let r = 1; r < rows.length; r++) {
24043
+ const cells = rows[r];
24044
+ const title = sanitiseText(cells[titleCol], MAX_TITLE_LEN);
24045
+ if (!title) continue;
24046
+ out.push({
24047
+ title,
24048
+ notes: notesCol === -1 ? "" : sanitiseText(cells[notesCol], MAX_NOTES_LEN, true),
24049
+ status: statusCol === -1 ? "Backlog" : mapStatusString(cells[statusCol]),
24050
+ priority: priorityCol === -1 ? "P2 Medium" : mapPriorityString(cells[priorityCol]),
24051
+ complexity: complexityCol === -1 ? "Small" : mapComplexityField(cells[complexityCol]),
24052
+ sourceId: idCol === -1 ? void 0 : sanitiseText(cells[idCol], 80) || void 0
24053
+ });
24054
+ }
24055
+ return out;
24056
+ };
24057
+ var markdownNormaliser = ({ raw }) => {
24058
+ if (!raw || !raw.trim()) return [];
24059
+ const lines = raw.split(/\r?\n/);
24060
+ const tableHeaderIdx = lines.findIndex(
24061
+ (l, i) => /\|/.test(l) && lines[i + 1] !== void 0 && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && /-/.test(lines[i + 1])
24062
+ );
24063
+ if (tableHeaderIdx !== -1) {
24064
+ const splitRow = (l) => l.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map((c) => c.trim());
24065
+ const header = splitRow(lines[tableHeaderIdx]);
24066
+ const titleCol = findColumn(header, ["title", "name", "task", "summary", "subject"]);
24067
+ if (titleCol === -1) {
24068
+ throw new Error(
24069
+ "Markdown table import: no recognisable title column. Expected a header cell of: title, name, task, summary, subject."
24070
+ );
24071
+ }
24072
+ const notesCol = findColumn(header, ["notes", "description", "details", "body"]);
24073
+ const statusCol = findColumn(header, ["status", "state"]);
24074
+ const priorityCol = findColumn(header, ["priority", "importance"]);
24075
+ const complexityCol = findColumn(header, ["complexity", "estimate", "size", "points", "effort"]);
24076
+ const idCol = findColumn(header, ["id", "key", "identifier", "ref"]);
24077
+ const out2 = [];
24078
+ for (let i = tableHeaderIdx + 2; i < lines.length; i++) {
24079
+ const l = lines[i];
24080
+ if (!/\|/.test(l) || l.trim() === "") break;
24081
+ const cells = splitRow(l);
24082
+ const title = sanitiseText(cells[titleCol], MAX_TITLE_LEN);
24083
+ if (!title) continue;
24084
+ out2.push({
24085
+ title,
24086
+ notes: notesCol === -1 ? "" : sanitiseText(cells[notesCol], MAX_NOTES_LEN, true),
24087
+ status: statusCol === -1 ? "Backlog" : mapStatusString(cells[statusCol]),
24088
+ priority: priorityCol === -1 ? "P2 Medium" : mapPriorityString(cells[priorityCol]),
24089
+ complexity: complexityCol === -1 ? "Small" : mapComplexityField(cells[complexityCol]),
24090
+ sourceId: idCol === -1 ? void 0 : sanitiseText(cells[idCol], 80) || void 0
24091
+ });
24092
+ }
24093
+ return out2;
24094
+ }
24095
+ const out = [];
24096
+ const checklistRe = /^\s*(?:[-*+]|\d+\.)\s+(?:\[( |x|X)\]\s+)?(.*\S)\s*$/;
24097
+ for (const line of lines) {
24098
+ const m = line.match(checklistRe);
24099
+ if (!m) continue;
24100
+ const checked = m[1];
24101
+ const title = sanitiseText(m[2], MAX_TITLE_LEN);
24102
+ if (!title) continue;
24103
+ out.push({
24104
+ title,
24105
+ notes: "",
24106
+ status: checked && checked.toLowerCase() === "x" ? "Done" : "Backlog",
24107
+ priority: "P2 Medium",
24108
+ complexity: "Small"
24109
+ });
24110
+ }
24111
+ return out;
24112
+ };
24113
+ function isRecord(v) {
24114
+ return typeof v === "object" && v !== null;
24115
+ }
24116
+ var linearNormaliser = ({ rows }) => {
24117
+ if (!rows || rows.length === 0) return [];
24118
+ const out = [];
24119
+ for (const row of rows) {
24120
+ if (!isRecord(row)) continue;
24121
+ const issue = row;
24122
+ const title = sanitiseText(issue.title, MAX_TITLE_LEN);
24123
+ if (!title) continue;
24124
+ const stateName = isRecord(issue.state) ? issue.state.name ?? issue.state.type : issue.state;
24125
+ const identifier = sanitiseText(issue.identifier, 80) || sanitiseText(issue.id, 80) || void 0;
24126
+ const url = sanitiseText(issue.url, 300);
24127
+ const description = sanitiseText(issue.description, MAX_NOTES_LEN, true);
24128
+ const notesParts = [];
24129
+ if (description) notesParts.push(description);
24130
+ if (url) notesParts.push(`Reference: ${url}`);
24131
+ out.push({
24132
+ title,
24133
+ notes: sanitiseText(notesParts.join("\n"), MAX_NOTES_LEN, true),
24134
+ status: mapStatusString(stateName),
24135
+ priority: mapPriorityNumber(issue.priority),
24136
+ complexity: issue.estimate == null ? "Small" : estimateToComplexity(issue.estimate),
24137
+ sourceId: identifier
24138
+ });
24139
+ }
24140
+ return out;
24141
+ };
24142
+ function notImplemented(source) {
24143
+ return () => {
24144
+ throw new Error(
24145
+ `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.`
24146
+ );
24147
+ };
24148
+ }
24149
+ var SOURCE_NORMALISERS = {
24150
+ csv: csvNormaliser,
24151
+ markdown: markdownNormaliser,
24152
+ linear: linearNormaliser,
24153
+ // Extension points — build these by replacing notImplemented() with a real normaliser.
24154
+ trello: notImplemented("trello"),
24155
+ todoist: notImplemented("todoist"),
24156
+ notion: notImplemented("notion")
24157
+ };
24158
+ function importMarker(source, sourceId) {
24159
+ return `[papi-import:${source}:${sourceId}]`;
24160
+ }
24161
+ function dedupTitleKey(title) {
24162
+ return title.toLowerCase().replace(/[^a-z0-9\s]/g, "").replace(/\s+/g, " ").trim();
24163
+ }
24164
+ async function importBacklog(adapter2, input) {
24165
+ const normaliser = SOURCE_NORMALISERS[input.source];
24166
+ if (!normaliser) {
24167
+ throw new Error(
24168
+ `Unknown import source "${input.source}". Valid sources: ${Object.keys(SOURCE_NORMALISERS).join(", ")}.`
24169
+ );
24170
+ }
24171
+ const normalised = normaliser({ raw: input.raw, rows: input.rows });
24172
+ const existing = await adapter2.queryBoard({});
24173
+ const existingTitleKeys = /* @__PURE__ */ new Set();
24174
+ const existingMarkers = /* @__PURE__ */ new Set();
24175
+ for (const t of existing) {
24176
+ existingTitleKeys.add(dedupTitleKey(t.title));
24177
+ const notes = t.notes ?? "";
24178
+ const markerMatches = notes.match(/\[papi-import:[^\]]+\]/g);
24179
+ if (markerMatches) for (const mk of markerMatches) existingMarkers.add(mk);
24180
+ }
24181
+ const health = await adapter2.getCycleHealth();
24182
+ warnIfEmpty("getCycleHealth (import)", health);
24183
+ const createdCycle = health.totalCycles;
24184
+ const targetModule = input.module && input.module.trim() || "Core";
24185
+ const createdTasks = [];
24186
+ const skippedTitles = [];
24187
+ const seenThisBatch = /* @__PURE__ */ new Set();
24188
+ for (const task of normalised) {
24189
+ const titleKey = dedupTitleKey(task.title);
24190
+ const marker = task.sourceId ? importMarker(input.source, task.sourceId) : null;
24191
+ const isDup = marker !== null && existingMarkers.has(marker) || existingTitleKeys.has(titleKey) || seenThisBatch.has(marker ?? titleKey);
24192
+ if (isDup) {
24193
+ skippedTitles.push(task.title);
24194
+ continue;
24195
+ }
24196
+ seenThisBatch.add(marker ?? titleKey);
24197
+ if (input.dryRun) {
24198
+ createdTasks.push({ id: "(dry-run)", title: task.title });
24199
+ continue;
24200
+ }
24201
+ const notes = marker ? `${task.notes ? `${task.notes}
24202
+
24203
+ ` : ""}${marker}`.trim() : task.notes;
24204
+ const created = await adapter2.createTask({
24205
+ uuid: randomUUID13(),
24206
+ displayId: "",
24207
+ title: task.title,
24208
+ status: task.status,
24209
+ priority: task.priority,
24210
+ complexity: task.complexity,
24211
+ module: targetModule,
24212
+ epic: "Platform",
24213
+ phase: "Unscoped",
24214
+ owner: "TBD",
24215
+ reviewed: false,
24216
+ createdCycle,
24217
+ notes,
24218
+ taskType: "task",
24219
+ maturity: "raw",
24220
+ source: `import:${input.source}`
24221
+ });
24222
+ createdTasks.push({ id: created.id, title: created.title });
24223
+ existingTitleKeys.add(titleKey);
24224
+ if (marker) existingMarkers.add(marker);
24225
+ }
24226
+ return {
24227
+ source: input.source,
24228
+ normalised: normalised.length,
24229
+ imported: input.dryRun ? 0 : createdTasks.length,
24230
+ skipped: skippedTitles.length,
24231
+ skippedTitles,
24232
+ createdTasks,
24233
+ dryRun: input.dryRun === true
24234
+ };
24235
+ }
24236
+
24237
+ // src/tools/import.ts
24238
+ var ALL_SOURCES = ["csv", "markdown", "linear", "trello", "todoist", "notion"];
24239
+ var backlogImportTool = {
24240
+ name: "backlog_import",
24241
+ annotations: { title: "Import Backlog", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
24242
+ 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.`,
24243
+ inputSchema: {
24244
+ type: "object",
24245
+ properties: {
24246
+ source: {
24247
+ type: "string",
24248
+ enum: ALL_SOURCES,
24249
+ 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)."
24250
+ },
24251
+ raw: {
24252
+ type: "string",
24253
+ 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."
24254
+ },
24255
+ rows: {
24256
+ type: "array",
24257
+ 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 }.",
24258
+ items: { type: "object" }
24259
+ },
24260
+ module: {
24261
+ type: "string",
24262
+ description: 'Module to file imported tasks under (default "Core").'
24263
+ },
24264
+ dry_run: {
24265
+ type: "boolean",
24266
+ description: "When true, normalise and dedup-check without writing anything. Use this to preview what would be imported. Default: false."
24267
+ },
24268
+ project: {
24269
+ type: "string",
24270
+ 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."
24271
+ }
24272
+ },
24273
+ required: ["source"]
24274
+ }
24275
+ };
24276
+ function diagnostic(lastStep, error, hint) {
24277
+ return errorResponse(JSON.stringify({ tool: "backlog_import", lastStep, error, hint }, null, 2));
24278
+ }
24279
+ function formatResult(result, overrideNote) {
24280
+ const lines = [];
24281
+ const verb = result.dryRun ? "Would import" : "Imported";
24282
+ lines.push(
24283
+ `${verb} ${result.dryRun ? result.normalised - result.skipped : result.imported} task(s) from ${result.source}${overrideNote}.`
24284
+ );
24285
+ lines.push(` normalised: ${result.normalised} \xB7 ${result.dryRun ? "would-write" : "written"}: ${result.dryRun ? result.normalised - result.skipped : result.imported} \xB7 skipped (duplicates): ${result.skipped}`);
24286
+ if (result.createdTasks.length > 0 && !result.dryRun) {
24287
+ const preview = result.createdTasks.slice(0, 10).map((t) => ` - ${t.id}: ${t.title}`);
24288
+ lines.push("Created:");
24289
+ lines.push(...preview);
24290
+ if (result.createdTasks.length > 10) lines.push(` \u2026and ${result.createdTasks.length - 10} more`);
24291
+ }
24292
+ if (result.skipped > 0) {
24293
+ const dupPreview = result.skippedTitles.slice(0, 5).map((t) => ` - ${t}`);
24294
+ lines.push(`Skipped ${result.skipped} duplicate(s) (already on the board or repeated in this import):`);
24295
+ lines.push(...dupPreview);
24296
+ if (result.skippedTitles.length > 5) lines.push(` \u2026and ${result.skippedTitles.length - 5} more`);
24297
+ }
24298
+ if (!result.dryRun && result.imported > 0) {
24299
+ lines.push("\nRun `plan` to triage and scope the imported backlog into a cycle.");
24300
+ }
24301
+ return lines.join("\n");
24302
+ }
24303
+ async function handleBacklogImport(adapter2, args) {
24304
+ const source = args.source?.trim();
24305
+ if (!source) {
24306
+ return errorResponse(`source is required \u2014 one of: ${ALL_SOURCES.join(", ")}.`);
24307
+ }
24308
+ if (!ALL_SOURCES.includes(source)) {
24309
+ return errorResponse(`Unknown source "${source}". Valid: ${ALL_SOURCES.join(", ")}.`);
24310
+ }
24311
+ const raw = args.raw;
24312
+ const rawRows = args.rows;
24313
+ const rows = Array.isArray(rawRows) ? rawRows : void 0;
24314
+ const isFlatFile = source === "csv" || source === "markdown";
24315
+ if (isFlatFile && (!raw || !raw.trim())) {
24316
+ return errorResponse(`Source "${source}" needs a \`raw\` string (the exported ${source} text).`);
24317
+ }
24318
+ if (!isFlatFile && IMPLEMENTED_SOURCES.includes(source) && (!rows || rows.length === 0)) {
24319
+ return errorResponse(`Source "${source}" needs a \`rows\` array of issues you read via that tool's MCP.`);
24320
+ }
24321
+ let target = adapter2;
24322
+ let overrideNote = "";
24323
+ try {
24324
+ ({ adapter: target, overrideNote } = await resolvePerCallProjectAdapter(adapter2, args));
24325
+ } catch (err) {
24326
+ if (err instanceof ProjectResolutionError) return errorResponse(err.message);
24327
+ throw err;
24328
+ }
24329
+ try {
24330
+ const result = await importBacklog(target, {
24331
+ source,
24332
+ raw,
24333
+ rows,
24334
+ module: args.module,
24335
+ dryRun: args.dry_run === true
24336
+ });
24337
+ return textResponse(formatResult(result, overrideNote));
24338
+ } catch (err) {
24339
+ const message = err instanceof Error ? err.message : String(err);
24340
+ if (/not implemented yet|no recognisable title column/i.test(message)) {
24341
+ return errorResponse(message);
24342
+ }
24343
+ return diagnostic(
24344
+ "importBacklog",
24345
+ message,
24346
+ "Check the export shape matches the source (csv/markdown need `raw`; linear needs `rows`). Re-run with dry_run:true to preview."
24347
+ );
24348
+ }
24349
+ }
24350
+
23459
24351
  // src/tools/bug.ts
23460
24352
  import os from "os";
23461
24353
  init_git();
23462
24354
 
23463
24355
  // src/services/bug.ts
23464
- import { randomUUID as randomUUID13 } from "crypto";
24356
+ import { randomUUID as randomUUID14 } from "crypto";
23465
24357
  function resolveCurrentPhase2(phases) {
23466
24358
  if (phases.length === 0) return "Unscoped";
23467
24359
  const inProgress = phases.find((p) => p.status === "In Progress");
@@ -23487,7 +24379,7 @@ async function captureBug(adapter2, input) {
23487
24379
  warnIfEmpty("getCycleHealth (bug)", health);
23488
24380
  const phase = input.phase || resolveCurrentPhase2(phases);
23489
24381
  return adapter2.createTask({
23490
- uuid: randomUUID13(),
24382
+ uuid: randomUUID14(),
23491
24383
  displayId: "",
23492
24384
  title: input.text,
23493
24385
  status: "Backlog",
@@ -23741,7 +24633,7 @@ ${lines.join("\n")}`
23741
24633
  init_git();
23742
24634
 
23743
24635
  // src/services/ad-hoc.ts
23744
- import { randomUUID as randomUUID14 } from "crypto";
24636
+ import { randomUUID as randomUUID15 } from "crypto";
23745
24637
  function resolveAdHocCycle(cycle, latest, latestComplete) {
23746
24638
  if (cycle === void 0) return null;
23747
24639
  if (typeof cycle === "number") return cycle;
@@ -23781,7 +24673,7 @@ async function recordAdHoc(adapter2, input) {
23781
24673
  } else {
23782
24674
  const phase = resolveCurrentPhase(phases);
23783
24675
  task = await adapter2.createTask({
23784
- uuid: randomUUID14(),
24676
+ uuid: randomUUID15(),
23785
24677
  displayId: "",
23786
24678
  title: input.title,
23787
24679
  status: landInReview ? "In Review" : "Done",
@@ -23800,7 +24692,7 @@ async function recordAdHoc(adapter2, input) {
23800
24692
  });
23801
24693
  }
23802
24694
  const report = {
23803
- uuid: randomUUID14(),
24695
+ uuid: randomUUID15(),
23804
24696
  createdAt: now.toISOString(),
23805
24697
  taskId: task.id,
23806
24698
  taskName: task.title ?? task.displayId ?? "untitled",
@@ -23954,13 +24846,38 @@ async function handleAdHoc(adapter2, config2, args) {
23954
24846
  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
24847
  if (holdArg) {
23956
24848
  const branch = `feat/${result.task.id}`;
24849
+ let collisionBlock = "";
24850
+ try {
24851
+ const board = await adapter2.queryBoard({ status: ["In Progress"] });
24852
+ 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);
24853
+ const collision = detectWorktreeCollision({
24854
+ taskId: result.task.id,
24855
+ targetBranch: branch,
24856
+ inProgress: otherInProgress,
24857
+ // Held work never runs git server-side — treat as suggest-only.
24858
+ autoWorktree: false
24859
+ });
24860
+ if (collision) {
24861
+ collisionBlock = `
24862
+ > ${collision.warning}
24863
+ > Instead of \`git switch -c\`, isolate into a worktree:
24864
+ > \`\`\`
24865
+ > git worktree add -b ${branch} ${collision.worktreePath}
24866
+ > cd ${collision.worktreePath} && npm run worktree:setup
24867
+ > \`\`\`
24868
+ > ${collision.setupHint}
24869
+
24870
+ `;
24871
+ }
24872
+ } catch {
24873
+ }
23957
24874
  return textResponse(
23958
24875
  `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning}${promoNote} Build report attached.
23959
24876
 
23960
24877
  ## Held for the next cycle \u2014 branch + commit, do NOT merge
23961
24878
  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
24879
 
23963
- 1. Create a branch and commit your code there (never \`main\`):
24880
+ ` + collisionBlock + `1. Create a branch and commit your code there (never \`main\`):
23964
24881
  \`\`\`
23965
24882
  git switch -c ${branch}
23966
24883
  git add -- <your changed files>
@@ -24568,7 +25485,7 @@ import { join as join15 } from "path";
24568
25485
  init_git();
24569
25486
 
24570
25487
  // src/services/review.ts
24571
- import { randomUUID as randomUUID15 } from "crypto";
25488
+ import { randomUUID as randomUUID16 } from "crypto";
24572
25489
  function isValidVerdict(stage, verdict) {
24573
25490
  if (stage === "handoff-review") {
24574
25491
  return verdict === "approve" || verdict === "request-changes" || verdict === "reject";
@@ -24625,7 +25542,7 @@ async function submitReview(adapter2, input) {
24625
25542
  }
24626
25543
  const date = (/* @__PURE__ */ new Date()).toISOString();
24627
25544
  const review = {
24628
- uuid: randomUUID15(),
25545
+ uuid: randomUUID16(),
24629
25546
  taskId: input.taskId,
24630
25547
  stage: input.stage,
24631
25548
  reviewer: input.reviewer,
@@ -24840,6 +25757,7 @@ var reviewSubmitTool = {
24840
25757
  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
25758
  annotations: { title: "Submit Review", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
24842
25759
  inputSchema: {
25760
+ $schema: "https://json-schema.org/draft/2020-12/schema",
24843
25761
  type: "object",
24844
25762
  properties: {
24845
25763
  task_id: {
@@ -24906,7 +25824,21 @@ var reviewSubmitTool = {
24906
25824
  required: ["verdict", "summary", "findings"]
24907
25825
  }
24908
25826
  },
24909
- required: ["task_id", "stage", "verdict", "comments"]
25827
+ required: ["task_id", "stage", "verdict", "comments"],
25828
+ // task-2802: mirror isValidVerdict — the legal verdict set is stage-dependent.
25829
+ // handoff-review takes approve/request-changes/reject; build-acceptance takes
25830
+ // accept/request-changes/reject. The flat enum above lists the union; these
25831
+ // conditionals narrow it per stage so the agent can't pair an impossible verdict.
25832
+ allOf: [
25833
+ {
25834
+ if: { properties: { stage: { const: "handoff-review" } }, required: ["stage"] },
25835
+ then: { properties: { verdict: { enum: ["approve", "request-changes", "reject"] } } }
25836
+ },
25837
+ {
25838
+ if: { properties: { stage: { const: "build-acceptance" } }, required: ["stage"] },
25839
+ then: { properties: { verdict: { enum: ["accept", "request-changes", "reject"] } } }
25840
+ }
25841
+ ]
24910
25842
  }
24911
25843
  };
24912
25844
  function formatReviewList(pendingBuilds) {
@@ -25525,7 +26457,7 @@ async function handleReviewClaim(adapter2, config2, args) {
25525
26457
  }
25526
26458
 
25527
26459
  // src/tools/init.ts
25528
- import { randomUUID as randomUUID16 } from "crypto";
26460
+ import { randomUUID as randomUUID17 } from "crypto";
25529
26461
  import { access as access3, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
25530
26462
  import path5 from "path";
25531
26463
  var initTool = {
@@ -25958,7 +26890,7 @@ ${writeNote}
25958
26890
  );
25959
26891
  }
25960
26892
  if (isDatabaseUser) {
25961
- const projectId = randomUUID16();
26893
+ const projectId = randomUUID17();
25962
26894
  const envVars = {
25963
26895
  PAPI_PROJECT_DIR: projectRoot,
25964
26896
  PAPI_ADAPTER: "pg",
@@ -26333,9 +27265,15 @@ var TASK_REF = /\btask-\d+\b/gi;
26333
27265
  var RECENT_CYCLE_WINDOW = 5;
26334
27266
  var MAX_CANDIDATES = 5;
26335
27267
  async function findUnblockCandidates(adapter2, currentCycle) {
27268
+ try {
27269
+ const blockedProbe = await adapter2.queryBoard({ status: ["Blocked"], compact: true });
27270
+ if (blockedProbe.length === 0) return [];
27271
+ } catch {
27272
+ return [];
27273
+ }
26336
27274
  let allTasks = [];
26337
27275
  try {
26338
- allTasks = await adapter2.queryBoard();
27276
+ allTasks = await adapter2.queryBoard({ compact: true });
26339
27277
  } catch {
26340
27278
  return [];
26341
27279
  }
@@ -26761,6 +27699,7 @@ var orientTool = {
26761
27699
  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
27700
  annotations: { title: "Orient Session", readOnlyHint: true, destructiveHint: false, openWorldHint: false },
26763
27701
  inputSchema: {
27702
+ $schema: "https://json-schema.org/draft/2020-12/schema",
26764
27703
  type: "object",
26765
27704
  properties: {
26766
27705
  environment: {
@@ -26920,7 +27859,13 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
26920
27859
  lines.push(`**Nearing Closure:** ${hierarchy.phasesNearingClosure.join(", ")}`);
26921
27860
  }
26922
27861
  if (hierarchy.stageExitCriteria && hierarchy.stageExitCriteria.length > 0) {
26923
- lines.push(`**Stage Exit Criteria:** ${hierarchy.stageExitCriteria.map((c) => `[ ] ${c}`).join(" | ")}`);
27862
+ const crit = hierarchy.stageExitCriteria;
27863
+ const met = crit.filter((c) => c.met).length;
27864
+ const total = crit.length;
27865
+ lines.push(`**Stage Exit Criteria [${met}/${total} met]:** ${crit.map((c) => `${c.met ? "[x]" : "[ ]"} ${c.text}`).join(" | ")}`);
27866
+ if (met === total) {
27867
+ lines.push(" \u21B3 All exit criteria met \u2014 run `strategy_review` to propose advancing the stage.");
27868
+ }
26924
27869
  }
26925
27870
  lines.push("");
26926
27871
  }
@@ -27125,9 +28070,9 @@ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
27125
28070
  for (const issue of alerts) {
27126
28071
  const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
27127
28072
  lines.push(`- **${issue.severity}**: ${desc}`);
27128
- lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})`);
28073
+ lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})${issue.id ? ` \xB7 id \`${issue.id}\`` : ""}`);
27129
28074
  }
27130
- lines.push("_Escalate: run `idea` with P1 priority to log as a backlog task, or `board_edit` if already handled._");
28075
+ lines.push("_Already fixed? `discovered_issue_resolve <id>` clears it for good (won't reappear). Not yet? `idea` with P1 priority to log it as a backlog task._");
27131
28076
  alertsNote = lines.join("\n");
27132
28077
  }
27133
28078
  if (allLowSev.length > 0) {
@@ -27137,18 +28082,18 @@ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
27137
28082
  for (const issue of unactioned) {
27138
28083
  const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
27139
28084
  lines.push(`- **${issue.severity}**: ${desc}`);
27140
- lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})`);
28085
+ lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})${issue.id ? ` \xB7 id \`${issue.id}\`` : ""}`);
27141
28086
  }
27142
- lines.push("_Run `idea` to log these as backlog tasks, or `board_edit` if already handled._");
28087
+ lines.push("_Already fixed? `discovered_issue_resolve <id>` clears it. Otherwise `idea` to log as a backlog task._");
27143
28088
  unactionedIssuesNote = lines.join("\n");
27144
28089
  }
27145
28090
  return { alertsNote, unactionedIssuesNote };
27146
28091
  }
27147
- async function computeTeamSummary(adapter2) {
28092
+ async function computeTeamSummary(adapter2, contributorsInput) {
27148
28093
  if (typeof adapter2.listContributors !== "function") return void 0;
27149
28094
  let members;
27150
28095
  try {
27151
- members = (await adapter2.listContributors()).length;
28096
+ members = (await (contributorsInput ?? adapter2.listContributors())).length;
27152
28097
  } catch {
27153
28098
  return void 0;
27154
28099
  }
@@ -27165,11 +28110,11 @@ async function computeTeamSummary(adapter2) {
27165
28110
  const reviewQueue = tasks.filter((t) => t.status === "In Review").length;
27166
28111
  return `**Team:** ${members} members \xB7 ${pool} in pool \xB7 ${inFlight} in flight \xB7 ${reviewQueue} in review`;
27167
28112
  }
27168
- async function computeReleaseHistory(adapter2) {
28113
+ async function computeReleaseHistory(adapter2, contributorsInput) {
27169
28114
  if (typeof adapter2.listContributors !== "function") return void 0;
27170
28115
  let contributors;
27171
28116
  try {
27172
- contributors = await adapter2.listContributors();
28117
+ contributors = await (contributorsInput ?? adapter2.listContributors());
27173
28118
  } catch {
27174
28119
  return void 0;
27175
28120
  }
@@ -27500,13 +28445,13 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
27500
28445
  // "writing to the wrong project" for multi-project users on a shared key / stateless
27501
28446
  // HTTP transport where a once-per-session gate can't work.
27502
28447
  tracked("project-banner", async () => {
27503
- if (!adapter2.getProjectInfo) return { banner: "", name: void 0 };
28448
+ if (!adapter2.getProjectInfo) return { banner: "", name: void 0, repoConnected: void 0 };
27504
28449
  const projectInfo = await adapter2.getProjectInfo();
27505
- if (!projectInfo) return { banner: "", name: void 0 };
28450
+ if (!projectInfo) return { banner: "", name: void 0, repoConnected: void 0 };
27506
28451
  const banner = getProjectConnectionBanner(projectInfo.name, projectInfo.slug);
27507
28452
  return { banner: banner ? `
27508
28453
  > ${banner}
27509
- ` : "", name: projectInfo.name };
28454
+ ` : "", name: projectInfo.name, repoConnected: !!projectInfo.repo_url };
27510
28455
  }),
27511
28456
  // Session guidance — proactive nudges (doc_register, context bloat, mode switch)
27512
28457
  tracked("session-guidance", async () => {
@@ -27644,7 +28589,7 @@ ${versionDrift}` : "";
27644
28589
  const patternsNote = patternsOutcome.status === "fulfilled" ? patternsOutcome.value : "";
27645
28590
  const { alertsNote, unactionedIssuesNote } = discoveredIssuesOutcome.status === "fulfilled" ? discoveredIssuesOutcome.value : { alertsNote: "", unactionedIssuesNote: "" };
27646
28591
  const skillProposalsNote = skillScanOutcome.status === "fulfilled" ? skillScanOutcome.value : "";
27647
- const projectBannerResult = projectBannerOutcome.status === "fulfilled" ? projectBannerOutcome.value : { banner: "", name: void 0 };
28592
+ const projectBannerResult = projectBannerOutcome.status === "fulfilled" ? projectBannerOutcome.value : { banner: "", name: void 0, repoConnected: void 0 };
27648
28593
  const projectBannerNote = projectBannerResult.banner;
27649
28594
  const projectName = projectBannerResult.name;
27650
28595
  const sessionGuidanceNote = sessionGuidanceOutcome.status === "fulfilled" ? sessionGuidanceOutcome.value : "";
@@ -27685,16 +28630,27 @@ ${versionDrift}` : "";
27685
28630
  preBuildCheckNote = lines.join("\n");
27686
28631
  }
27687
28632
  }
27688
- tracker.mark("unblock-candidates");
27689
- let unblockNote = "";
27690
- try {
27691
- const candidates = await tracked("unblock-candidates", () => findUnblockCandidates(adapter2, currentCycle))();
27692
- const section = formatUnblockSection(candidates);
27693
- if (section) unblockNote = `
28633
+ tracker.mark("parallel-tail");
28634
+ const sharedContributorsPromise = typeof adapter2.listContributors === "function" ? adapter2.listContributors().catch(() => []) : Promise.resolve([]);
28635
+ const [unblockCandidates, subAgents, teamSummaryLine, releaseHistoryLine, carryForwardRefs] = await Promise.all([
28636
+ tracked("unblock-candidates", () => findUnblockCandidates(adapter2, currentCycle))().catch(() => []),
28637
+ // task-1866: discover project sub-agents for the orient surface (read-only, never throws).
28638
+ listAgents(config2.projectRoot),
28639
+ // task-2071 (MU-3) + task-2072 (MU-5): team summary + release-history — both
28640
+ // multi-member only; solo projects get undefined from both, so orient stays
28641
+ // byte-identical there.
28642
+ tracked("team-summary", () => computeTeamSummary(adapter2, sharedContributorsPromise))().catch(() => void 0),
28643
+ tracked("release-history", () => computeReleaseHistory(adapter2, sharedContributorsPromise))().catch(() => void 0),
28644
+ // task-2751 (C332): resolve every task-NNNN mentioned in the Carry-Forward
28645
+ // prose to its title so orient can name it inline. Built from the board
28646
+ // already in hand — no extra query.
28647
+ resolveCarryForwardRefs(healthResult.carryForward, allTasks, adapter2)
28648
+ ]);
28649
+ const unblockSection = formatUnblockSection(unblockCandidates);
28650
+ const unblockNote = unblockSection ? `
27694
28651
 
27695
- ${section}`;
27696
- } catch {
27697
- }
28652
+ ${unblockSection}` : "";
28653
+ const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
27698
28654
  let deferredGateNote = "";
27699
28655
  if (deepHousekeeping) {
27700
28656
  try {
@@ -27706,16 +28662,22 @@ ${section}`;
27706
28662
  } catch {
27707
28663
  }
27708
28664
  }
28665
+ let onboardingCoachingNote = "";
28666
+ try {
28667
+ const adsForCoaching = await sharedActiveDecisionsPromise;
28668
+ onboardingCoachingNote = formatOnboardingCoachingBlock({
28669
+ surface: "orient",
28670
+ repoConnected: projectBannerResult.repoConnected,
28671
+ hasLocalWorkspace: hasLocalWorkspace(),
28672
+ hasActiveDecisions: adsForCoaching.length > 0,
28673
+ cycleNumber: currentCycle,
28674
+ hasActiveCycle: currentCycle > 0 && !cycleIsComplete
28675
+ });
28676
+ } catch {
28677
+ }
27709
28678
  tracker.mark("format-summary");
27710
- const subAgents = await listAgents(config2.projectRoot);
27711
- const [teamSummaryLine, releaseHistoryLine] = await Promise.all([
27712
- tracked("team-summary", () => computeTeamSummary(adapter2))().catch(() => void 0),
27713
- tracked("release-history", () => computeReleaseHistory(adapter2))().catch(() => void 0)
27714
- ]);
27715
- const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
27716
28679
  const deepHint = deepHousekeeping ? "" : "\n\n*Tip: pass `full: true` for Research Signals + version-drift, or `deep_housekeeping: true` to also check orphaned branches, merged-but-In-Progress tasks, unrecorded commits, unregistered docs, and stale skill forks (implies `full`).*";
27717
- const carryForwardRefs = await resolveCarryForwardRefs(healthResult.carryForward, allTasks, adapter2);
27718
- return textResponse(projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary, clientName, carryForwardRefs) + unblockNote + deferredGateNote + feedbackResolvedNote + alertsNote + ttfvNote + reconciliationNote + mergedInProgressNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection);
28680
+ return textResponse(projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary, clientName, carryForwardRefs) + unblockNote + deferredGateNote + feedbackResolvedNote + alertsNote + ttfvNote + reconciliationNote + mergedInProgressNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + onboardingCoachingNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection);
27719
28681
  } catch (err) {
27720
28682
  const message = err instanceof Error ? err.message : String(err);
27721
28683
  const isKnownFriendly = /^(Orient failed|Project not found|No project|Setup required)/i.test(message);
@@ -27770,7 +28732,7 @@ function enrichClaudeMd(projectRoot, cycleNumber, adapterType, collector, client
27770
28732
  // src/tools/hierarchy.ts
27771
28733
  var hierarchyUpdateTool = {
27772
28734
  name: "hierarchy_update",
27773
- description: "Update the status of a phase, stage, or horizon in the project hierarchy (AD-14). Accepts a level (phase, stage, or horizon), a name or ID, and a new status. For stages, optionally set exit_criteria \u2014 a checklist defining when the stage is considered done. Does not call the Anthropic API.",
28735
+ description: "Create or update a horizon, stage, or phase in the project hierarchy (AD-14). For stages and horizons this UPSERTS: if the named entity exists it is updated, otherwise it is CREATED (pass a label). Phases are update-only (they evolve via plan/strategy). For stages you can set exit_criteria (a checklist), or flip a single criterion with set_criterion_met. NEVER auto-advances \u2014 progression is human-in-loop via strategy_review. Does not call the Anthropic API.",
27774
28736
  annotations: { title: "Update Hierarchy", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
27775
28737
  inputSchema: {
27776
28738
  type: "object",
@@ -27778,129 +28740,218 @@ var hierarchyUpdateTool = {
27778
28740
  level: {
27779
28741
  type: "string",
27780
28742
  enum: ["phase", "stage", "horizon"],
27781
- description: "Which hierarchy level to update."
28743
+ description: "Which hierarchy level to create/update."
27782
28744
  },
27783
28745
  name: {
27784
28746
  type: "string",
27785
- description: "The label or ID of the stage/horizon to update."
28747
+ description: "The label, slug, or ID of the entity. On create, becomes the label if `label` is omitted."
28748
+ },
28749
+ label: {
28750
+ type: "string",
28751
+ description: 'Display label. Required to CREATE a new stage/horizon when `name` does not match an existing one (e.g. "S2: Alpha Cohort").'
28752
+ },
28753
+ slug: {
28754
+ type: "string",
28755
+ description: "Explicit slug for a new entity (stage/horizon). Auto-derived from the label when omitted."
28756
+ },
28757
+ description: {
28758
+ type: "string",
28759
+ description: "Optional longer description (stage/horizon)."
27786
28760
  },
27787
28761
  status: {
27788
28762
  type: "string",
27789
28763
  enum: ["Not Started", "In Progress", "Done", "Deferred"],
27790
- description: "The new status to set."
28764
+ description: 'The status to set. On create, defaults to "Not Started".'
28765
+ },
28766
+ sort_order: {
28767
+ type: "number",
28768
+ description: "Display order for a new entity. Auto-computed (max existing + 10) when omitted."
28769
+ },
28770
+ horizon: {
28771
+ type: "string",
28772
+ description: "Parent horizon (name/slug/id) when CREATING a stage. Defaults to the sole horizon if only one exists."
27791
28773
  },
27792
28774
  exit_criteria: {
27793
28775
  type: "array",
27794
28776
  items: { type: "string" },
27795
- description: 'Checklist defining when this stage is done (stages only). Each item is a completion condition, e.g. "All P0 tasks shipped". Replaces existing criteria.'
28777
+ description: "Checklist defining when a STAGE is done. Each item is a completion condition. REPLACES existing criteria (resets met state \u2014 use set_criterion_met to flip one)."
28778
+ },
28779
+ set_criterion_met: {
28780
+ type: "object",
28781
+ description: "Flip a single stage exit criterion (task-1625). { criterion_id, met, evidence? }.",
28782
+ properties: {
28783
+ criterion_id: { type: "string", description: "The ExitCriterion id to flip." },
28784
+ met: { type: "boolean", description: "true = met, false = unmet." },
28785
+ evidence: { type: "string", description: 'Optional evidence for a met criterion (e.g. "task-1234 shipped").' }
28786
+ },
28787
+ required: ["criterion_id", "met"]
27796
28788
  }
27797
28789
  },
27798
28790
  required: ["level", "name"]
27799
28791
  }
27800
28792
  };
27801
28793
  var VALID_STATUSES3 = /* @__PURE__ */ new Set(["Not Started", "In Progress", "Done", "Deferred"]);
28794
+ function slugify(input) {
28795
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "item";
28796
+ }
28797
+ function nextSortOrder(existing) {
28798
+ if (existing.length === 0) return 10;
28799
+ return Math.max(...existing.map((e) => e.sortOrder)) + 10;
28800
+ }
27802
28801
  async function handleHierarchyUpdate(adapter2, args) {
27803
28802
  const level = args.level;
27804
28803
  const name = args.name;
28804
+ const label = args.label;
28805
+ const slug = args.slug;
28806
+ const description = args.description;
27805
28807
  const status = args.status;
28808
+ const sortOrder = typeof args.sort_order === "number" ? args.sort_order : void 0;
28809
+ const horizonRef = args.horizon;
27806
28810
  const exitCriteria = args.exit_criteria;
28811
+ const setCriterion = args.set_criterion_met;
27807
28812
  if (!level || !name) {
27808
28813
  return errorResponse("Missing required parameters: level, name.");
27809
28814
  }
27810
- if (!status && !exitCriteria) {
27811
- return errorResponse("Nothing to update. Provide at least one of: status, exit_criteria.");
27812
- }
27813
28815
  if (level !== "phase" && level !== "stage" && level !== "horizon") {
27814
28816
  return errorResponse(`Invalid level "${level}". Must be "phase", "stage", or "horizon".`);
27815
28817
  }
27816
28818
  if (status && !VALID_STATUSES3.has(status)) {
27817
28819
  return errorResponse(`Invalid status "${status}". Must be one of: Not Started, In Progress, Done, Deferred.`);
27818
28820
  }
27819
- if (exitCriteria !== void 0 && level !== "stage") {
27820
- return errorResponse("exit_criteria can only be set on stages.");
28821
+ if ((exitCriteria !== void 0 || setCriterion !== void 0) && level !== "stage") {
28822
+ return errorResponse("exit_criteria and set_criterion_met can only be used on stages.");
28823
+ }
28824
+ if (!status && exitCriteria === void 0 && setCriterion === void 0 && !label) {
28825
+ return errorResponse("Nothing to do. Provide at least one of: status, exit_criteria, set_criterion_met, or label (to create).");
27821
28826
  }
27822
28827
  try {
27823
28828
  if (level === "phase") {
27824
- if (!adapter2.readPhases || !adapter2.updatePhaseStatus) {
27825
- return errorResponse("Phase management is not supported by the current adapter.");
27826
- }
27827
- const phases = await adapter2.readPhases();
27828
- const phase = phases.find(
27829
- (p) => p.label.toLowerCase() === name.toLowerCase() || p.id === name || p.slug === name
27830
- );
27831
- if (!phase) {
27832
- const available = phases.map((p) => p.label).join(", ");
27833
- return errorResponse(`Phase "${name}" not found. Available phases: ${available || "none"}`);
27834
- }
27835
- if (!status) {
27836
- return errorResponse("status is required for phase updates.");
27837
- }
27838
- if (phase.status === status) {
27839
- return textResponse(`Phase "${phase.label}" is already "${status}". No change made.`);
27840
- }
27841
- const oldStatus2 = phase.status;
27842
- await adapter2.updatePhaseStatus(phase.id, status);
27843
- return textResponse(`Phase updated: **${phase.label}** ${oldStatus2} \u2192 ${status}`);
28829
+ return await handlePhase(adapter2, name, status);
27844
28830
  }
27845
28831
  if (level === "stage") {
27846
- if (!adapter2.readStages) {
27847
- return errorResponse("Stage management is not supported by the current adapter.");
27848
- }
27849
- const stages = await adapter2.readStages();
27850
- const stage = stages.find(
27851
- (s) => s.label.toLowerCase() === name.toLowerCase() || s.id === name || s.slug === name
27852
- );
27853
- if (!stage) {
27854
- const available = stages.map((s) => s.label).join(", ");
27855
- return errorResponse(`Stage "${name}" not found. Available stages: ${available || "none"}`);
27856
- }
27857
- const resultLines = [];
27858
- if (status) {
27859
- if (!adapter2.updateStageStatus) {
27860
- return errorResponse("Stage status updates are not supported by the current adapter.");
27861
- }
27862
- if (stage.status === status) {
27863
- resultLines.push(`Stage "${stage.label}" is already "${status}".`);
27864
- } else {
27865
- const oldStatus2 = stage.status;
27866
- await adapter2.updateStageStatus(stage.id, status);
27867
- resultLines.push(`Stage updated: **${stage.label}** ${oldStatus2} \u2192 ${status}`);
27868
- }
27869
- }
27870
- if (exitCriteria !== void 0) {
27871
- if (!adapter2.updateStageExitCriteria) {
27872
- return errorResponse("Exit criteria updates are not supported by the current adapter.");
27873
- }
27874
- await adapter2.updateStageExitCriteria(stage.id, exitCriteria);
27875
- resultLines.push(`Exit criteria set (${exitCriteria.length} item${exitCriteria.length !== 1 ? "s" : ""}):`);
27876
- exitCriteria.forEach((c) => resultLines.push(` - ${c}`));
27877
- }
27878
- return textResponse(resultLines.join("\n"));
27879
- }
27880
- if (!adapter2.readHorizons || !adapter2.updateHorizonStatus) {
27881
- return errorResponse("Horizon management is not supported by the current adapter.");
27882
- }
27883
- const horizons = await adapter2.readHorizons();
27884
- const horizon = horizons.find(
27885
- (h) => h.label.toLowerCase() === name.toLowerCase() || h.id === name || h.slug === name
27886
- );
27887
- if (!horizon) {
27888
- const available = horizons.map((h) => h.label).join(", ");
27889
- return errorResponse(`Horizon "${name}" not found. Available horizons: ${available || "none"}`);
27890
- }
27891
- if (!status) {
27892
- return errorResponse("status is required for horizon updates.");
27893
- }
27894
- if (horizon.status === status) {
27895
- return textResponse(`Horizon "${horizon.label}" is already "${status}". No change made.`);
28832
+ return await handleStage(adapter2, { name, label, slug, description, status, sortOrder, horizonRef, exitCriteria, setCriterion });
27896
28833
  }
27897
- const oldStatus = horizon.status;
27898
- await adapter2.updateHorizonStatus(horizon.id, status);
27899
- return textResponse(`Horizon updated: **${horizon.label}** ${oldStatus} \u2192 ${status}`);
28834
+ return await handleHorizon(adapter2, { name, label, slug, description, status, sortOrder });
27900
28835
  } catch (err) {
27901
28836
  return errorResponse(err instanceof Error ? err.message : String(err));
27902
28837
  }
27903
28838
  }
28839
+ async function handlePhase(adapter2, name, status) {
28840
+ if (!adapter2.readPhases || !adapter2.updatePhaseStatus) {
28841
+ return errorResponse("Phase management is not supported by the current adapter.");
28842
+ }
28843
+ if (!status) return errorResponse("status is required for phase updates.");
28844
+ const phases = await adapter2.readPhases();
28845
+ const phase = phases.find((p) => p.label.toLowerCase() === name.toLowerCase() || p.id === name || p.slug === name);
28846
+ if (!phase) {
28847
+ const available = phases.map((p) => p.label).join(", ");
28848
+ return errorResponse(`Phase "${name}" not found. Available phases: ${available || "none"} (phases are update-only \u2014 create them via plan/setup).`);
28849
+ }
28850
+ if (phase.status === status) return textResponse(`Phase "${phase.label}" is already "${status}". No change made.`);
28851
+ const oldStatus = phase.status;
28852
+ await adapter2.updatePhaseStatus(phase.id, status);
28853
+ return textResponse(`Phase updated: **${phase.label}** ${oldStatus} \u2192 ${status}`);
28854
+ }
28855
+ async function handleStage(adapter2, a) {
28856
+ if (!adapter2.readStages) {
28857
+ return errorResponse("Stage management is not supported by the current adapter.");
28858
+ }
28859
+ const stages = await adapter2.readStages();
28860
+ let stage = stages.find(
28861
+ (s) => s.label.toLowerCase() === a.name.toLowerCase() || s.id === a.name || s.slug === a.name
28862
+ );
28863
+ const resultLines = [];
28864
+ let created = false;
28865
+ if (!stage) {
28866
+ if (!adapter2.createHorizon || !adapter2.createStage || !adapter2.readHorizons) {
28867
+ return errorResponse("Stage creation is not supported by the current adapter.");
28868
+ }
28869
+ const createLabel = a.label ?? a.name;
28870
+ const horizons = await adapter2.readHorizons();
28871
+ let parent;
28872
+ if (a.horizonRef) {
28873
+ parent = horizons.find((h) => h.label.toLowerCase() === a.horizonRef.toLowerCase() || h.id === a.horizonRef || h.slug === a.horizonRef);
28874
+ if (!parent) {
28875
+ const available = horizons.map((h) => h.label).join(", ");
28876
+ return errorResponse(`Parent horizon "${a.horizonRef}" not found. Available horizons: ${available || "none"}.`);
28877
+ }
28878
+ } else if (horizons.length === 1) {
28879
+ parent = horizons[0];
28880
+ } else if (horizons.length === 0) {
28881
+ return errorResponse('No horizon exists to attach the stage to. Create a horizon first (level:"horizon", label:"H1: ...").');
28882
+ } else {
28883
+ return errorResponse(`Multiple horizons exist \u2014 pass \`horizon\` to say which one the stage belongs to: ${horizons.map((h) => h.label).join(", ")}.`);
28884
+ }
28885
+ const newId = await adapter2.createStage({
28886
+ slug: a.slug ?? slugify(createLabel),
28887
+ label: createLabel,
28888
+ description: a.description,
28889
+ status: a.status ?? "Not Started",
28890
+ sortOrder: a.sortOrder ?? nextSortOrder(stages.filter((s) => s.horizonId === parent.id)),
28891
+ horizonId: parent.id
28892
+ });
28893
+ const refreshed = await adapter2.readStages();
28894
+ stage = refreshed.find((s) => s.id === newId);
28895
+ if (!stage) return errorResponse("Stage was created but could not be re-read.");
28896
+ created = true;
28897
+ resultLines.push(`Stage created: **${stage.label}** (under ${parent.label}, status ${stage.status})`);
28898
+ }
28899
+ if (a.status && !created) {
28900
+ if (!adapter2.updateStageStatus) return errorResponse("Stage status updates are not supported by the current adapter.");
28901
+ if (stage.status === a.status) {
28902
+ resultLines.push(`Stage "${stage.label}" is already "${a.status}".`);
28903
+ } else {
28904
+ const oldStatus = stage.status;
28905
+ await adapter2.updateStageStatus(stage.id, a.status);
28906
+ resultLines.push(`Stage updated: **${stage.label}** ${oldStatus} \u2192 ${a.status}`);
28907
+ }
28908
+ }
28909
+ if (a.exitCriteria !== void 0) {
28910
+ if (!adapter2.updateStageExitCriteria) return errorResponse("Exit criteria updates are not supported by the current adapter.");
28911
+ await adapter2.updateStageExitCriteria(stage.id, a.exitCriteria);
28912
+ resultLines.push(`Exit criteria set (${a.exitCriteria.length} item${a.exitCriteria.length !== 1 ? "s" : ""}, all unmet):`);
28913
+ a.exitCriteria.forEach((c) => resultLines.push(` - ${c}`));
28914
+ }
28915
+ if (a.setCriterion !== void 0) {
28916
+ if (!adapter2.setCriterionMet) return errorResponse("set_criterion_met is not supported by the current adapter.");
28917
+ await adapter2.setCriterionMet(stage.id, a.setCriterion.criterion_id, a.setCriterion.met, a.setCriterion.evidence ?? null);
28918
+ const after = (await adapter2.readStages()).find((s) => s.id === stage.id);
28919
+ const crit = after?.exitCriteria?.find((c) => c.id === a.setCriterion.criterion_id);
28920
+ if (!crit) {
28921
+ resultLines.push(`\u26A0\uFE0F Criterion id "${a.setCriterion.criterion_id}" not found on this stage \u2014 no change.`);
28922
+ } else {
28923
+ const met = after?.exitCriteria?.filter((c) => c.met).length ?? 0;
28924
+ const total = after?.exitCriteria?.length ?? 0;
28925
+ resultLines.push(`Criterion "${crit.text}" \u2192 ${crit.met ? "met" : "unmet"}. [${met}/${total} criteria met]`);
28926
+ }
28927
+ }
28928
+ return textResponse(resultLines.join("\n"));
28929
+ }
28930
+ async function handleHorizon(adapter2, a) {
28931
+ if (!adapter2.readHorizons) {
28932
+ return errorResponse("Horizon management is not supported by the current adapter.");
28933
+ }
28934
+ const horizons = await adapter2.readHorizons();
28935
+ const horizon = horizons.find((h) => h.label.toLowerCase() === a.name.toLowerCase() || h.id === a.name || h.slug === a.name);
28936
+ if (!horizon) {
28937
+ if (!adapter2.createHorizon) return errorResponse("Horizon creation is not supported by the current adapter.");
28938
+ const createLabel = a.label ?? a.name;
28939
+ const newId = await adapter2.createHorizon({
28940
+ slug: a.slug ?? slugify(createLabel),
28941
+ label: createLabel,
28942
+ description: a.description,
28943
+ status: a.status ?? "Not Started",
28944
+ sortOrder: a.sortOrder ?? nextSortOrder(horizons)
28945
+ });
28946
+ return textResponse(`Horizon created: **${createLabel}** (id ${newId}, status ${a.status ?? "Not Started"})`);
28947
+ }
28948
+ if (!a.status) return errorResponse("status is required to update an existing horizon (or pass a new name to create one).");
28949
+ if (horizon.status === a.status) return textResponse(`Horizon "${horizon.label}" is already "${a.status}". No change made.`);
28950
+ if (!adapter2.updateHorizonStatus) return errorResponse("Horizon status updates are not supported by the current adapter.");
28951
+ const oldStatus = horizon.status;
28952
+ await adapter2.updateHorizonStatus(horizon.id, a.status);
28953
+ return textResponse(`Horizon updated: **${horizon.label}** ${oldStatus} \u2192 ${a.status}`);
28954
+ }
27904
28955
 
27905
28956
  // src/services/zoom-out.ts
27906
28957
  var BUDGET_SOFT = 12e4;
@@ -29906,6 +30957,7 @@ var PAPI_TOOLS = [
29906
30957
  buildExecuteTool,
29907
30958
  buildCancelTool,
29908
30959
  ideaTool,
30960
+ backlogImportTool,
29909
30961
  bugTool,
29910
30962
  bugListTool,
29911
30963
  adHocTool,
@@ -30088,6 +31140,8 @@ function createServer(adapter2, config2) {
30088
31140
  return handleBuildCancel(adapter2, safeArgs);
30089
31141
  case "idea":
30090
31142
  return handleIdea(adapter2, config2, safeArgs);
31143
+ case "backlog_import":
31144
+ return handleBacklogImport(adapter2, safeArgs);
30091
31145
  case "bug":
30092
31146
  return handleBug(adapter2, config2, safeArgs);
30093
31147
  case "bug_list":
@@ -30664,7 +31718,11 @@ async function dispatchRequest(args) {
30664
31718
  const requestConfig = {
30665
31719
  ...baseConfig,
30666
31720
  adapterType: "proxy",
30667
- projectId: effectiveProjectId
31721
+ projectId: effectiveProjectId,
31722
+ // Remote transport: the client cannot read files this server writes, so
31723
+ // prepare must keep returning inline blobs (2905's budget bounds them),
31724
+ // never a server-side file path (task-2906).
31725
+ localFilesystem: false
30668
31726
  };
30669
31727
  const server2 = createServer(adapter2, requestConfig);
30670
31728
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });