@papi-ai/server 0.7.110 → 0.7.113

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/README.md +3 -0
  2. package/dist/index.js +273 -72
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -50,6 +50,9 @@ database adapter pool, so abandoned client sessions do not accumulate. Set
50
50
  `PAPI_STDIO_IDLE_TIMEOUT_MS=0` to disable this behavior, or provide another
51
51
  non-negative millisecond value in the `.mcp.json` environment block. This
52
52
  setting applies only to stdio mode; hosted HTTP instances are not idle-killed.
53
+ While `plan`, `handoff_generate`, or `strategy_review` is between its prepare
54
+ and apply calls, the server allows an additional bounded grace period for the
55
+ caller to finish the review instead of treating that intentional pause as idle.
53
56
 
54
57
  ## License
55
58
 
package/dist/index.js CHANGED
@@ -7075,7 +7075,7 @@ async function resolveMutationProject(input) {
7075
7075
  if (input.workspacePath) {
7076
7076
  if (!storedPapiDir) {
7077
7077
  throw new ProjectResolutionError(
7078
- `Project "${projectSlug}" has no stored workspace mapping. Refusing to write from "${input.workspacePath}" until the project is explicitly mapped.`
7078
+ `Project "${projectSlug}" has no stored workspace mapping. Refusing to write from "${input.workspacePath}" until the project is explicitly mapped. If your working directory belongs to a different project, run \`project_switch\` or pass \`project=<id>\` on the call.`
7079
7079
  );
7080
7080
  }
7081
7081
  try {
@@ -7087,7 +7087,7 @@ async function resolveMutationProject(input) {
7087
7087
  });
7088
7088
  if (pathResult?.action !== "ok") {
7089
7089
  throw new ProjectResolutionError(
7090
- `Project "${projectSlug}" has not been explicitly verified for this workspace. Refusing to write until its mapping is confirmed.`
7090
+ `Project "${projectSlug}" has not been explicitly verified for this workspace ("${input.workspacePath}"). Refusing to write until its mapping is confirmed. If this session is bound to the wrong project, run \`project_switch\` or pass \`project=<id>\` on the call.`
7091
7091
  );
7092
7092
  }
7093
7093
  } catch (err) {
@@ -7133,6 +7133,10 @@ async function resolveMutationProject(input) {
7133
7133
  function formatMutationScopeReceipt(scope) {
7134
7134
  const target = scope.targetProject ? `
7135
7135
  Target project: ${scope.targetProject.name} (${scope.targetProject.slug}, ${scope.targetProject.id})` : "";
7136
+ const workspaceUnverified = scope.workspacePath.startsWith("unavailable");
7137
+ const noMapping = /mapping=none/.test(scope.resolutionEvidence);
7138
+ const unverifiedNote = workspaceUnverified && noMapping ? `
7139
+ \u26A0 This project was bound from your MCP config with no workspace cross-check \u2014 PAPI cannot confirm it matches your working directory. If a write ever lands on the wrong board: pass \`project=<id>\` per call, run \`project_switch\`, or fix PAPI_PROJECT_ID / the x-papi-project-id header and reconnect.` : "";
7136
7140
  return [
7137
7141
  "---",
7138
7142
  "Project scope receipt",
@@ -7140,7 +7144,8 @@ Target project: ${scope.targetProject.name} (${scope.targetProject.slug}, ${scop
7140
7144
  `Repository evidence: ${scope.repositoryEvidence}`,
7141
7145
  `Workspace: ${scope.workspacePath}`,
7142
7146
  `Resolution: ${scope.resolutionEvidence}`,
7143
- target.trimStart()
7147
+ target.trimStart(),
7148
+ unverifiedNote.trimStart()
7144
7149
  ].filter(Boolean).join("\n");
7145
7150
  }
7146
7151
 
@@ -11241,6 +11246,20 @@ function determineContextTier(cycleCount) {
11241
11246
  if (cycleCount <= 20) return 2;
11242
11247
  return 3;
11243
11248
  }
11249
+ function trimContextForFirstCycle(ctx, cycleCount) {
11250
+ if (cycleCount !== 0 || ctx.mode === "bootstrap") return;
11251
+ ctx.cycleLog = "";
11252
+ ctx.recentBuildReports = "";
11253
+ const ad = ctx.activeDecisions;
11254
+ if (ad && ad.includes("[Confidence: ")) {
11255
+ const blocks = ad.split(/\n(?=### )/);
11256
+ const highOnly = blocks.filter(
11257
+ (b2) => !b2.startsWith("### ") || /\[Confidence: HIGH\]/.test(b2)
11258
+ );
11259
+ const trimmed = highOnly.join("\n").trim();
11260
+ ctx.activeDecisions = trimmed.length > 0 ? trimmed : ad;
11261
+ }
11262
+ }
11244
11263
  function applyContextTier(ctx, cycleCount) {
11245
11264
  const tier = determineContextTier(cycleCount);
11246
11265
  const label = tier === 1 ? "Tier 1 (cycles 1-5)" : tier === 2 ? "Tier 2 (cycles 6-20)" : "Tier 3 (cycles 21+)";
@@ -11994,6 +12013,7 @@ ${lines.join("\n")}`;
11994
12013
  siblingRepoWarning
11995
12014
  };
11996
12015
  const { label: leanTierLabel } = applyContextTier(ctx2, health.totalCycles);
12016
+ trimContextForFirstCycle(ctx2, health.totalCycles);
11997
12017
  ctx2.contextTier = leanTierLabel;
11998
12018
  console.error(`[plan-perf] context tier: ${leanTierLabel} (cycle ${health.totalCycles})`);
11999
12019
  t = startTimer();
@@ -12191,6 +12211,7 @@ ${logLines}`);
12191
12211
  siblingRepoWarning
12192
12212
  };
12193
12213
  const { label: fullTierLabel } = applyContextTier(ctx, health.totalCycles);
12214
+ trimContextForFirstCycle(ctx, health.totalCycles);
12194
12215
  ctx.contextTier = fullTierLabel;
12195
12216
  console.error(`[plan-perf] context tier: ${fullTierLabel} (cycle ${health.totalCycles})`);
12196
12217
  const prevHashes = contextHashesResultFull.status === "fulfilled" ? contextHashesResultFull.value : null;
@@ -12818,15 +12839,20 @@ async function assertSingleActiveCycle(adapter2, opts = {}) {
12818
12839
  for (const c of cycles) {
12819
12840
  if (!newestByNumber.has(c.number)) newestByNumber.set(c.number, c);
12820
12841
  }
12821
- const blocking = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber).filter((c) => opts.userId == null || c.userId === opts.userId);
12822
- if (blocking.length === 0) return [];
12842
+ const blockingAll = [...newestByNumber.values()].filter((c) => c.status === "active" && c.number !== opts.allowNumber).filter((c) => opts.userId == null || c.userId === opts.userId);
12843
+ const phantoms = blockingAll.filter((c) => (c.goals?.length ?? 0) === 0);
12844
+ const blocking = blockingAll.filter((c) => (c.goals?.length ?? 0) > 0);
12845
+ const phantomNotes = phantoms.map(
12846
+ (c) => `Absorbed phantom active cycle ${c.number} (no goals \u2014 incomplete plan apply). Re-planning replaces it.`
12847
+ );
12848
+ if (blocking.length === 0) return phantomNotes;
12823
12849
  if (!opts.autoComplete) {
12824
12850
  const nums = blocking.map((c) => c.number).join(", ");
12825
12851
  throw new Error(
12826
12852
  `Cycle ${nums} is still active for this project \u2014 a second active cycle corrupts orient/build_execute. Run \`release\` to complete it first, or pass \`force: true\` to auto-complete it and continue.`
12827
12853
  );
12828
12854
  }
12829
- const notes = [];
12855
+ const notes = [...phantomNotes];
12830
12856
  for (const stale of blocking) {
12831
12857
  await adapter2.createCycle({
12832
12858
  ...stale,
@@ -12861,6 +12887,18 @@ async function validateAndPrepare(adapter2, force, callerUserId, adapterType) {
12861
12887
  const health = await adapter2.getCycleHealth();
12862
12888
  cycleNumber = health.totalCycles;
12863
12889
  mode = determineMode(health.projectCycleCount ?? health.totalCycles);
12890
+ try {
12891
+ const cyclesForPhantomCheck = await adapter2.readCycles();
12892
+ let callerNewest;
12893
+ for (const c of cyclesForPhantomCheck) {
12894
+ if (callerUserId && c.userId != null && c.userId !== callerUserId) continue;
12895
+ if (!callerNewest || c.number > callerNewest.number) callerNewest = c;
12896
+ }
12897
+ if (callerNewest && callerNewest.status === "active" && (callerNewest.goals?.length ?? 0) === 0 && callerNewest.number === cycleNumber) {
12898
+ cycleNumber = callerNewest.number - 1;
12899
+ }
12900
+ } catch {
12901
+ }
12864
12902
  const blockingCycle = callerUserId ? await resolveCallerLatestCycle(adapter2, callerUserId) : void 0;
12865
12903
  const latestStatus = callerUserId ? blockingCycle?.status : health.latestCycleStatus;
12866
12904
  const blockingNumber = blockingCycle?.number ?? cycleNumber;
@@ -12951,6 +12989,11 @@ function assertApplyPayloadNonEmpty(data, cycleNumber) {
12951
12989
  `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).`
12952
12990
  );
12953
12991
  }
12992
+ if (!data.cycleLogTitle || data.cycleLogTitle.trim().length === 0) {
12993
+ throw new Error(
12994
+ `Plan apply rejected: trimmed/partial apply payload \u2014 nothing persisted. The parsed plan for Cycle ${cycleNumber + 1} has no cycleLogTitle, so the cycle row would be written with empty goals \u2014 the phantom-cycle failure mode that blocks the next plan and renders nowhere on the hub. Re-run the plan and resend the COMPLETE structured output including cycleLogTitle.`
12995
+ );
12996
+ }
12954
12997
  }
12955
12998
  var VALID_PLAN_PRIORITIES = /* @__PURE__ */ new Set(["P0 Critical", "P1 High", "P2 Medium", "P3 Low"]);
12956
12999
  function assertPlanPrioritiesValid(data) {
@@ -13087,7 +13130,7 @@ async function processLlmOutput(adapter2, config2, rawOutput, mode, cycleNumber,
13087
13130
  decisionConflicts
13088
13131
  };
13089
13132
  }
13090
- async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker, density) {
13133
+ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker, density, taskIds) {
13091
13134
  const prepareTimer = startTimer();
13092
13135
  tracker?.mark("validate_and_prepare");
13093
13136
  let t = startTimer();
@@ -13115,10 +13158,43 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
13115
13158
  tracker?.mark("handoffs_only_assemble");
13116
13159
  t = startTimer();
13117
13160
  const targetCycle = cycleNumber + 1;
13118
- const allTasks = await adapter2.queryBoard({ status: ["Backlog", "In Cycle", "Ready", "In Progress"] });
13119
- const preAssigned = allTasks.filter((task) => task.cycle === targetCycle);
13120
- if (preAssigned.length === 0) {
13121
- throw new Error(`No tasks assigned to Cycle ${targetCycle}. Assign tasks first (set cycle = ${targetCycle} via SQL) or run plan without handoffs_only.`);
13161
+ const allTasks = await adapter2.queryBoard({ status: ["Backlog", "In Cycle", "Ready", "In Progress", "In Review", "Blocked"] });
13162
+ let preAssigned;
13163
+ if (taskIds && taskIds.length > 0) {
13164
+ const byDisplayId = new Map(allTasks.map((t2) => [t2.displayId ?? t2.id, t2]));
13165
+ const byId = new Map(allTasks.map((t2) => [t2.id, t2]));
13166
+ const BUILDABLE = /* @__PURE__ */ new Set(["Backlog", "In Cycle", "Ready"]);
13167
+ const resolved = [];
13168
+ const rejected = [];
13169
+ const seen = /* @__PURE__ */ new Set();
13170
+ for (const raw of taskIds) {
13171
+ const task = byDisplayId.get(raw) ?? byId.get(raw);
13172
+ if (!task) {
13173
+ rejected.push(`${raw}: not a task in this project (or already Done/Cancelled/Deferred)`);
13174
+ continue;
13175
+ }
13176
+ if (seen.has(task.id)) continue;
13177
+ seen.add(task.id);
13178
+ if (!BUILDABLE.has(task.status)) {
13179
+ rejected.push(`${raw}: status is "${task.status}" \u2014 only Backlog / In Cycle / Ready tasks can be planned`);
13180
+ continue;
13181
+ }
13182
+ resolved.push(task);
13183
+ }
13184
+ if (rejected.length > 0) {
13185
+ throw new Error(
13186
+ `Cycle ${targetCycle} \u2014 nothing was applied. ${rejected.length} of ${taskIds.length} task id(s) could not be planned:
13187
+ ` + rejected.map((r) => ` - ${r}`).join("\n") + `
13188
+
13189
+ Fix the ids and re-run. Every id must be a currently-buildable task (Backlog / In Cycle / Ready) in this project.`
13190
+ );
13191
+ }
13192
+ preAssigned = resolved;
13193
+ } else {
13194
+ preAssigned = allTasks.filter((task) => task.cycle === targetCycle);
13195
+ if (preAssigned.length === 0) {
13196
+ throw new Error(`No tasks assigned to Cycle ${targetCycle}. Assign tasks first (set cycle = ${targetCycle} via SQL) or run plan without handoffs_only.`);
13197
+ }
13122
13198
  }
13123
13199
  const [decisions, reports, brief] = await Promise.all([
13124
13200
  adapter2.getActiveDecisions(),
@@ -13760,6 +13836,37 @@ function planDelivery(input) {
13760
13836
  }
13761
13837
  }
13762
13838
 
13839
+ // src/lib/harness-capability.ts
13840
+ var HARNESS_REGISTRY = {
13841
+ // Local stdio CLI agents — PAPI runs git on the user's machine. Confirmed in telemetry.
13842
+ "claude-code": { build: true, label: "Claude Code" },
13843
+ "opencode": { build: true, label: "opencode" },
13844
+ "zcode": { build: true, label: "zcode" },
13845
+ "codex": { build: true, label: "Codex" },
13846
+ "cursor": { build: true, label: "Cursor" },
13847
+ // Cloud harnesses with code sandboxes — their agent builds in-sandbox over HTTP+OAuth.
13848
+ "lovable": { build: true, label: "Lovable" },
13849
+ "bolt": { build: true, label: "Bolt" },
13850
+ "replit": { build: true, label: "Replit" },
13851
+ // Chat-only surfaces, no sandbox — planning only.
13852
+ "chatgpt": { build: false, label: "ChatGPT" },
13853
+ "claude.ai": { build: false, label: "Claude.ai" },
13854
+ "claude-desktop": { build: false, label: "Claude Desktop" }
13855
+ };
13856
+ var UNKNOWN_DEFAULT = { build: false, label: "your tool" };
13857
+ function detectHarness(clientName) {
13858
+ const raw = clientName?.trim() || null;
13859
+ if (!raw) {
13860
+ return { ...UNKNOWN_DEFAULT, raw: null, key: null, known: false };
13861
+ }
13862
+ const norm = raw.toLowerCase();
13863
+ const key = HARNESS_REGISTRY[norm] ? norm : Object.keys(HARNESS_REGISTRY).find((k) => norm.includes(k)) ?? null;
13864
+ if (!key) {
13865
+ return { ...UNKNOWN_DEFAULT, label: raw, raw, key: null, known: false };
13866
+ }
13867
+ return { ...HARNESS_REGISTRY[key], raw, key, known: true };
13868
+ }
13869
+
13763
13870
  // src/services/session-guidance.ts
13764
13871
  var DEFAULT_CALLER_KEY = "__default__";
13765
13872
  var sessionStates = /* @__PURE__ */ new Map();
@@ -13836,7 +13943,7 @@ function markOrient(callerKey) {
13836
13943
  }
13837
13944
  function getProjectConnectionBanner(projectName, projectSlug) {
13838
13945
  if (!projectName || !projectSlug) return null;
13839
- return `[Connected: ${projectName} (${projectSlug})] \u2014 confirm this is the project you mean before I write to it. If it's wrong, don't proceed: pass \`project=<id>\` on the call to target a different project, or fix the project id in your MCP config (PAPI_PROJECT_ID for local, x-papi-project-id header for remote) and reconnect.`;
13946
+ return `**\u26A0 Bound to: ${projectName} (${projectSlug})** \u2014 every write on this session lands on THIS project's board. If that is not the project your working directory belongs to, stop: pass \`project=<id>\` on the call to target a different project, run \`project_switch\` to rebind the session, or fix the project id in your MCP config (PAPI_PROJECT_ID for local, x-papi-project-id header for remote) and reconnect.`;
13840
13947
  }
13841
13948
  function detectContextDegradation(now = Date.now(), callerKey) {
13842
13949
  const state = getState(callerKey);
@@ -14019,6 +14126,9 @@ function savePrepareContextFile(projectId, callerKey, content) {
14019
14126
 
14020
14127
  // src/tools/plan.ts
14021
14128
  var planPrepareCache = new PerCallerCache();
14129
+ function hasPendingPlanPrepare() {
14130
+ return planPrepareCache.size() > 0;
14131
+ }
14022
14132
  var planTool = {
14023
14133
  name: "plan",
14024
14134
  description: 'Turn a backlog into one scoped cycle of work, with a written spec for every task in it. plan reads the whole board, the decisions this project has already taken, and how much it actually delivered in recent cycles, then prioritises what to do next and writes a per-task BUILD HANDOFF: scope, what is deliberately out of scope, acceptance criteria, files likely touched, security notes, and the shared branch each task belongs on. It also reports board health, so stale, blocked and drifting work is visible instead of accumulating. This is not the same as planning inside one session: the cycle, the specs and the reasoning are stored, so the next session, the next tool, or the next person picks up where this one stopped. Run once per cycle, after setup the first time, or after completing all builds AND running release for the previous cycle. NEVER call when unbuilt cycle tasks exist: 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, and generate handoffs separately via `handoff_generate`.',
@@ -14081,6 +14191,11 @@ var planTool = {
14081
14191
  type: "boolean",
14082
14192
  description: "Skip backlog analysis and task selection. Only generate BUILD HANDOFFs for tasks already assigned to the target cycle. Requires pre-assigned tasks (set cycle number on tasks first). ~30% of normal plan cost."
14083
14193
  },
14194
+ task_ids: {
14195
+ type: "array",
14196
+ items: { type: "string" },
14197
+ description: 'Plan EXACTLY these backlog tasks into the cycle, with BUILD HANDOFFs, via the same atomic apply as a normal plan. Use when the user says "take these tasks and get them build-ready" or "plan task-12, task-15, task-19" \u2014 do NOT reach for ad_hoc (a receipt) or board_edit (strands them with no handoff). Skips backlog selection: only the named tasks are scoped. Every id must be a real, currently-buildable task (Backlog / In Cycle / Ready) in this project \u2014 unknown or ineligible ids are rejected with a per-id reason and nothing is applied. The apply assigns cycle + handoff + status together, so a failure leaves nothing half-landed.'
14198
+ },
14084
14199
  skip_handoffs: {
14085
14200
  type: "boolean",
14086
14201
  description: "Run full planning (triage, task selection, board management) but skip BUILD HANDOFF generation. Selected tasks are assigned to the cycle without handoffs. Run `handoff_generate` after to create handoffs separately. Reduces planner cognitive load for large backlogs."
@@ -14190,7 +14305,7 @@ function formatPlanResult(result) {
14190
14305
  }
14191
14306
  return response;
14192
14307
  }
14193
- async function handlePlan(adapter2, config2, args) {
14308
+ async function handlePlan(adapter2, config2, args, clientName) {
14194
14309
  const toolMode = args.mode;
14195
14310
  const callerKey = callerKeyFromConfig(config2);
14196
14311
  const filters = {};
@@ -14200,7 +14315,8 @@ async function handlePlan(adapter2, config2, args) {
14200
14315
  if (typeof args.priority === "string") filters.priority = args.priority;
14201
14316
  const focus = typeof args.focus === "string" ? args.focus : void 0;
14202
14317
  const force = args.force === true;
14203
- const handoffsOnly = args.handoffs_only === true;
14318
+ const taskIds = Array.isArray(args.task_ids) ? args.task_ids.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim()) : void 0;
14319
+ const handoffsOnly = args.handoffs_only === true || taskIds !== void 0 && taskIds.length > 0;
14204
14320
  const density = args.density === "light" || args.density === "standard" || args.density === "deep" ? args.density : void 0;
14205
14321
  const tracker = new ProgressTracker(toolMode === "apply" ? "apply_validate" : "prepare_validate").bindStream(adapter2, { stage: "plan" });
14206
14322
  try {
@@ -14282,7 +14398,7 @@ async function handlePlan(adapter2, config2, args) {
14282
14398
  } catch {
14283
14399
  }
14284
14400
  const skipHandoffs = args.skip_handoffs === true;
14285
- const result = await preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker, density);
14401
+ const result = await preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker, density, taskIds);
14286
14402
  const prepareState = {
14287
14403
  contextHashes: result.contextHashes,
14288
14404
  userMessage: result.userMessage,
@@ -14293,7 +14409,15 @@ async function handlePlan(adapter2, config2, args) {
14293
14409
  planPrepareCache.set(callerKey, prepareState);
14294
14410
  savePrepareSpill(adapter2.getProjectId?.(), callerKey, prepareState);
14295
14411
  const explicit = args.dispatch === "inline" ? false : args.dispatch === "subagent" ? true : void 0;
14296
- const dispatch = shouldDispatch(result.contextBytes ?? 0, explicit) ? "subagent" : "inline";
14412
+ let dispatch = shouldDispatch(result.contextBytes ?? 0, explicit) ? "subagent" : "inline";
14413
+ let autoRouteDowngraded = false;
14414
+ if (dispatch === "subagent" && explicit === void 0) {
14415
+ const harness = detectHarness(clientName);
14416
+ if (harness.key !== "claude-code") {
14417
+ dispatch = "inline";
14418
+ autoRouteDowngraded = true;
14419
+ }
14420
+ }
14297
14421
  const modeLabel = result.mode === "bootstrap" ? "Bootstrap" : "Full";
14298
14422
  const header = result.strategyReviewWarning ? `${result.strategyReviewWarning}
14299
14423
  ` : "";
@@ -14329,7 +14453,12 @@ ${result.userMessage}
14329
14453
  const dispatchHeader = result.strategyReviewWarning ? `${result.strategyReviewWarning}
14330
14454
 
14331
14455
  ` : "";
14332
- return { ...textResponse(`${dispatchHeader}${dispatchPrompt}`), _contextBytes: result.contextBytes };
14456
+ const inlineOverride = `
14457
+
14458
+ ---
14459
+
14460
+ > **No sub-agent or \`Task\` primitive in your client?** Re-call \`plan\` with \`dispatch: "inline"\` to get the planning prompt back directly, then run it yourself in a fresh context and pass the output to the apply call. The apply contract is identical either way.`;
14461
+ return { ...textResponse(`${dispatchHeader}${dispatchPrompt}${inlineOverride}`), _contextBytes: result.contextBytes };
14333
14462
  }
14334
14463
  if (contextFilePath) {
14335
14464
  const kb = result.contextBytes !== void 0 ? ` (~${(result.contextBytes / 1024).toFixed(0)} KB)` : "";
@@ -14351,8 +14480,11 @@ The full planning brief \u2014 system prompt + all context${kb} \u2014 has been
14351
14480
  );
14352
14481
  return { ...pathResponse, _contextBytes: result.contextBytes };
14353
14482
  }
14483
+ const downgradeNote = autoRouteDowngraded ? `> This context is large. It is returned inline because your client has no \`Task\` sub-agent primitive. If your client CAN run a sub-agent, re-call \`plan\` with \`dispatch: "subagent"\`.
14484
+
14485
+ ` : "";
14354
14486
  const response = textResponse(
14355
- `${header}## PAPI Cycle Plan \u2014 Prepare Phase (${modeLabel} Mode, Cycle ${result.cycleNumber + 1})
14487
+ `${header}${downgradeNote}## PAPI Cycle Plan \u2014 Prepare Phase (${modeLabel} Mode, Cycle ${result.cycleNumber + 1})
14356
14488
 
14357
14489
  Follow the system prompt and context below to generate a complete cycle plan.
14358
14490
 
@@ -17104,6 +17236,9 @@ function buildStrategyReviewPostDirective(cycleNumber) {
17104
17236
  ].join("\n");
17105
17237
  }
17106
17238
  var reviewPrepareCache = new PerCallerCache();
17239
+ function hasPendingStrategyReview() {
17240
+ return reviewPrepareCache.size() > 0;
17241
+ }
17107
17242
  var strategyReviewTool = {
17108
17243
  name: "strategy_review",
17109
17244
  description: 'Run a Strategy Review \u2014 assesses project direction, velocity, and Active Decisions. Produces recommendations and potential AD updates that feed into the next plan. Offered every 5 cycles; hard-blocked at 7+ overdue cycles. Run it in your current conversation \u2014 only start a fresh one if you are genuinely under context pressure (your host just compacted, you are near the context limit, or the session is heavy with build context), not just because a review is next. First call returns a review prompt for you to execute (prepare phase). Then call again with mode "apply" and your output. Pass `force: true` to run before the cadence gate.',
@@ -17558,6 +17693,7 @@ ${result.userMessage}
17558
17693
  }
17559
17694
 
17560
17695
  // src/lib/task-readiness.ts
17696
+ var ADVISORY_GAP_LABELS = /* @__PURE__ */ new Set(["WHY NOW", "FILES LIKELY TOUCHED"]);
17561
17697
  function hasMeaningfulEntry(value) {
17562
17698
  return Array.isArray(value) && value.some((entry) => {
17563
17699
  if (typeof entry === "string") return entry.trim().length > 0;
@@ -17577,23 +17713,29 @@ function getHandoffGaps(handoff) {
17577
17713
  return gaps;
17578
17714
  }
17579
17715
  function getTaskReadiness(task, currentCycle2) {
17580
- const handoffGaps = getHandoffGaps(task.buildHandoff);
17581
- const handoffComplete = handoffGaps.length === 0;
17716
+ const allGaps = getHandoffGaps(task.buildHandoff);
17717
+ const blockingGaps = allGaps.filter((g) => !ADVISORY_GAP_LABELS.has(g));
17718
+ const advisoryGaps = allGaps.filter((g) => ADVISORY_GAP_LABELS.has(g));
17719
+ const handoffGaps = [...blockingGaps, ...advisoryGaps];
17720
+ const handoffComplete = blockingGaps.length === 0;
17721
+ const advisoryNote = advisoryGaps.length > 0 ? ` A planner should still fill ${advisoryGaps.join(", ")}, but it does not block the build.` : "";
17582
17722
  const cycleMismatch = currentCycle2 != null && currentCycle2 > 0 && task.cycle !== currentCycle2;
17583
17723
  if (task.status === "Backlog") {
17584
17724
  if (!handoffComplete) {
17585
17725
  return {
17586
17726
  label: "Needs Planning",
17587
- reason: `No complete BUILD HANDOFF (${handoffGaps.join(", ")}). Run plan before claiming or building this task.`,
17727
+ reason: `No complete BUILD HANDOFF (${blockingGaps.join(", ")}). Run plan before claiming or building this task.`,
17588
17728
  handoffComplete,
17589
- handoffGaps
17729
+ handoffGaps,
17730
+ advisoryGaps
17590
17731
  };
17591
17732
  }
17592
17733
  return {
17593
17734
  label: "Needs Planning",
17594
17735
  reason: "Backlog work is waiting for a planning pass to assign it to a cycle before it can be built.",
17595
17736
  handoffComplete,
17596
- handoffGaps
17737
+ handoffGaps,
17738
+ advisoryGaps
17597
17739
  };
17598
17740
  }
17599
17741
  if (task.status === "In Cycle" || task.status === "Ready") {
@@ -17602,22 +17744,25 @@ function getTaskReadiness(task, currentCycle2) {
17602
17744
  label: "Needs Planning",
17603
17745
  reason: `Assigned to Cycle ${task.cycle ?? "an earlier cycle"}, not the active Cycle ${currentCycle2}. Run plan before building it.`,
17604
17746
  handoffComplete,
17605
- handoffGaps
17747
+ handoffGaps,
17748
+ advisoryGaps
17606
17749
  };
17607
17750
  }
17608
17751
  if (!handoffComplete) {
17609
17752
  return {
17610
17753
  label: "Needs Planning",
17611
- reason: `Planning has not produced a complete BUILD HANDOFF (${handoffGaps.join(", ")}).`,
17754
+ reason: `Planning has not produced a complete BUILD HANDOFF (${blockingGaps.join(", ")}).`,
17612
17755
  handoffComplete,
17613
- handoffGaps
17756
+ handoffGaps,
17757
+ advisoryGaps
17614
17758
  };
17615
17759
  }
17616
17760
  return {
17617
17761
  label: "Ready to Build",
17618
- reason: "Task is assigned to the active cycle and has a complete BUILD HANDOFF.",
17762
+ reason: `Task is assigned to the active cycle and has a complete BUILD HANDOFF.${advisoryNote}`,
17619
17763
  handoffComplete,
17620
- handoffGaps
17764
+ handoffGaps,
17765
+ advisoryGaps
17621
17766
  };
17622
17767
  }
17623
17768
  const labelByStatus = {
@@ -17633,7 +17778,8 @@ function getTaskReadiness(task, currentCycle2) {
17633
17778
  label,
17634
17779
  reason: label === "Needs Planning" ? "Task is not in a buildable lifecycle state." : `Task status is ${task.status}.`,
17635
17780
  handoffComplete,
17636
- handoffGaps
17781
+ handoffGaps,
17782
+ advisoryGaps
17637
17783
  };
17638
17784
  }
17639
17785
 
@@ -18244,6 +18390,20 @@ async function handleBoardView(adapter2, args) {
18244
18390
  fields,
18245
18391
  membership.isMultiMember ? assigneeColumn(membership.nameOf) : null
18246
18392
  );
18393
+ if (typeof args.cycle === "number") {
18394
+ try {
18395
+ const cycleRows = await adapter2.readCycles();
18396
+ const row = cycleRows.find((c) => c.number === args.cycle);
18397
+ const listed = row?.taskIds?.length ?? 0;
18398
+ const carrying = result.tasks.filter((t) => t.cycle === args.cycle).length;
18399
+ if (row && listed > 0 && listed !== carrying) {
18400
+ output += `
18401
+
18402
+ > \u26A0\uFE0F Cycle ${args.cycle} membership disagrees: the cycle row lists ${listed} task(s) but ${carrying} carry cycle=${args.cycle}. This view shows the latter. Run \`board_reconcile\` to realign them.`;
18403
+ }
18404
+ } catch {
18405
+ }
18406
+ }
18247
18407
  if (!fields) {
18248
18408
  try {
18249
18409
  const comments = await adapter2.getRecentTaskComments?.(30);
@@ -20963,37 +21123,6 @@ function pendingDecisionBlocker(attempts, cycle, taskId) {
20963
21123
  };
20964
21124
  }
20965
21125
 
20966
- // src/lib/harness-capability.ts
20967
- var HARNESS_REGISTRY = {
20968
- // Local stdio CLI agents — PAPI runs git on the user's machine. Confirmed in telemetry.
20969
- "claude-code": { build: true, label: "Claude Code" },
20970
- "opencode": { build: true, label: "opencode" },
20971
- "zcode": { build: true, label: "zcode" },
20972
- "codex": { build: true, label: "Codex" },
20973
- "cursor": { build: true, label: "Cursor" },
20974
- // Cloud harnesses with code sandboxes — their agent builds in-sandbox over HTTP+OAuth.
20975
- "lovable": { build: true, label: "Lovable" },
20976
- "bolt": { build: true, label: "Bolt" },
20977
- "replit": { build: true, label: "Replit" },
20978
- // Chat-only surfaces, no sandbox — planning only.
20979
- "chatgpt": { build: false, label: "ChatGPT" },
20980
- "claude.ai": { build: false, label: "Claude.ai" },
20981
- "claude-desktop": { build: false, label: "Claude Desktop" }
20982
- };
20983
- var UNKNOWN_DEFAULT = { build: false, label: "your tool" };
20984
- function detectHarness(clientName) {
20985
- const raw = clientName?.trim() || null;
20986
- if (!raw) {
20987
- return { ...UNKNOWN_DEFAULT, raw: null, key: null, known: false };
20988
- }
20989
- const norm = raw.toLowerCase();
20990
- const key = HARNESS_REGISTRY[norm] ? norm : Object.keys(HARNESS_REGISTRY).find((k) => norm.includes(k)) ?? null;
20991
- if (!key) {
20992
- return { ...UNKNOWN_DEFAULT, label: raw, raw, key: null, known: false };
20993
- }
20994
- return { ...HARNESS_REGISTRY[key], raw, key, known: true };
20995
- }
20996
-
20997
21126
  // src/lib/harness-build-steps.ts
20998
21127
  init_git();
20999
21128
  function startBuildSteps(branch, base, taskId) {
@@ -25567,6 +25696,9 @@ init_git();
25567
25696
 
25568
25697
  // src/tools/handoff.ts
25569
25698
  var handoffPrepareCache = new PerCallerCache();
25699
+ function hasPendingHandoffGenerate() {
25700
+ return handoffPrepareCache.size() > 0;
25701
+ }
25570
25702
  var handoffGenerateTool = {
25571
25703
  name: "handoff_generate",
25572
25704
  description: "Generate BUILD HANDOFFs for cycle tasks that don't have one yet. Run after `plan` (with skip_handoffs=true) or to regenerate stale handoffs. Uses the prepare/apply pattern \u2014 first call returns a prompt, second call persists results.",
@@ -31771,10 +31903,14 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
31771
31903
  lines.push("Board is empty \u2014 run `plan` to create your next cycle.");
31772
31904
  }
31773
31905
  lines.push("");
31906
+ lines.push(WORK_DUMP_ROUTING_RULE);
31907
+ lines.push("");
31774
31908
  } else if (buildInfo.noHandoffs) {
31775
31909
  lines.push("## Tasks");
31776
31910
  lines.push("No tasks with BUILD HANDOFFs \u2014 run `plan` to generate cycle tasks.");
31777
31911
  lines.push("");
31912
+ lines.push(WORK_DUMP_ROUTING_RULE);
31913
+ lines.push("");
31778
31914
  } else {
31779
31915
  lines.push(`## Tasks with Handoffs`);
31780
31916
  lines.push(`${buildInfo.totalHandoffs} total \u2014 ${buildInfo.inProgress.length} in progress, ${buildInfo.backlogCount} backlog, ${buildInfo.blockedCount} blocked`);
@@ -31805,6 +31941,7 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
31805
31941
  return lines.join("\n").trimEnd();
31806
31942
  }
31807
31943
  var NOT_SET_UP_MESSAGE = "Setup required \u2014 this project has no PAPI state yet. That is the normal starting point, not an error. Run `setup` to generate your Product Brief and scaffold the workflow, then `plan` to create your first cycle.";
31944
+ var WORK_DUMP_ROUTING_RULE = '_If someone hands you a list of work (an audit, a bug list, "build all of these"), do NOT start writing code. File each item with `idea`, then run `plan` to scope a cycle \u2014 that is how work gets a spec, a branch and a review._';
31808
31945
  function emptyHealthSummary() {
31809
31946
  return {
31810
31947
  cycleNumber: 0,
@@ -32160,12 +32297,34 @@ async function handleOrient(rawAdapter, config2, args = {}, clientName, serverVe
32160
32297
  cycleSince: currentCycle2,
32161
32298
  compact: true
32162
32299
  }).catch(() => []) : [];
32163
- const cycleTasksAll = scopeTasksToCaller(cycleTasksAllRaw, callerUserId);
32164
- const cycleCounts = computeCycleTaskCounts(cycleTasksAll, currentCycle2, cycleIsComplete);
32300
+ const cycleCounts = computeCycleTaskCounts(cycleTasksAllRaw, currentCycle2, cycleIsComplete);
32165
32301
  const { inProgress: cycleInProgress, inReview: cycleInReview, inCycle: cycleInCycle, ready: cycleReady, backlog: cycleBacklog, total: cycleTotal, done: cycleDone } = cycleCounts;
32166
32302
  if (!cycleIsComplete && cycleTotal === 0 && cycleDone > 0) {
32167
32303
  buildResult.warnings.unshift(`\u26A0\uFE0F Cycle ${currentCycle2} is complete \u2014 all ${cycleDone} task${cycleDone !== 1 ? "s" : ""} Done. Release has not been run. Run \`release\` now (or \`release\` with \`skipVersion=true\` to close the cycle without a git tag).`);
32168
32304
  }
32305
+ if (!cycleIsComplete && currentCycle2 > 0) {
32306
+ try {
32307
+ const cyclesForPhantom = await adapter2.readCycles();
32308
+ const mine = cyclesForPhantom.filter(
32309
+ (c) => c.number === currentCycle2 && (!callerUserId || c.userId == null || c.userId === callerUserId)
32310
+ );
32311
+ const phantom = mine.find((c) => c.status === "active" && (c.goals?.length ?? 0) === 0);
32312
+ if (phantom) {
32313
+ buildResult.warnings.unshift(
32314
+ `\u26A0\uFE0F Cycle ${currentCycle2} has no goals and looks like a plan that didn't finish writing. Run \`plan\` again \u2014 it will replace this incomplete cycle rather than stacking a new one on top. No data is lost: nothing has been built in it.`
32315
+ );
32316
+ }
32317
+ const cycleRow = mine.find((c) => c.status === "active") ?? mine[0];
32318
+ const taskIdMembership = cycleRow?.taskIds?.length ?? 0;
32319
+ const columnMembership = cycleTasksAllRaw.filter((t) => t.cycle === currentCycle2).length;
32320
+ if (cycleRow && taskIdMembership !== columnMembership && taskIdMembership > 0) {
32321
+ buildResult.warnings.unshift(
32322
+ `\u26A0\uFE0F Cycle ${currentCycle2} membership disagrees: the cycle row lists ${taskIdMembership} task(s) but ${columnMembership} task(s) actually carry cycle=${currentCycle2}. Some cycle tasks may not show on \`board_view cycle=${currentCycle2}\` or the hub. Run \`board_reconcile\` to realign them.`
32323
+ );
32324
+ }
32325
+ } catch {
32326
+ }
32327
+ }
32169
32328
  tracker.mark("parallel-reads");
32170
32329
  const inProgressItems = buildResult.inProgress.map(
32171
32330
  (t) => `- **${t.id}:** ${t.title} (${t.priority} | ${t.complexity})`
@@ -35561,6 +35720,9 @@ var PAPI_TOOLS = [
35561
35720
  function getQualifiedPapiTools() {
35562
35721
  return qualifyTools(PAPI_TOOLS);
35563
35722
  }
35723
+ function hasPendingStdioPrepare() {
35724
+ return hasPendingPlanPrepare() || hasPendingStrategyReview() || hasPendingHandoffGenerate();
35725
+ }
35564
35726
  function getToolMetadata() {
35565
35727
  return PAPI_TOOLS.map((t) => ({ name: t.name, description: t.description }));
35566
35728
  }
@@ -35685,7 +35847,7 @@ function createServer(adapter2, config2, observation) {
35685
35847
  const runHandler = async () => {
35686
35848
  switch (name) {
35687
35849
  case "plan":
35688
- return handlePlan(scopedAdapter, scopedConfig, safeArgs);
35850
+ return handlePlan(scopedAdapter, scopedConfig, safeArgs, server2.getClientVersion()?.name);
35689
35851
  case "strategy_review":
35690
35852
  return handleStrategyReview(scopedAdapter, scopedConfig, safeArgs);
35691
35853
  case "strategy_change":
@@ -36455,11 +36617,36 @@ function startHttpTransport(opts) {
36455
36617
  }
36456
36618
  try {
36457
36619
  parsedBody = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
36458
- observation?.setTool(extractToolName(parsedBody) ?? extractRpcMethod(parsedBody));
36459
36620
  } catch {
36460
36621
  sendError(res, { status: 400, body: { error: "Invalid JSON body" } });
36461
36622
  return;
36462
36623
  }
36624
+ if (Array.isArray(parsedBody)) {
36625
+ logEvent({
36626
+ level: "warn",
36627
+ msg: "jsonrpc_batch_rejected",
36628
+ ip,
36629
+ bearer_prefix: bearerPrefix(bearer),
36630
+ status: 400
36631
+ });
36632
+ if (!res.headersSent) {
36633
+ res.writeHead(400, {
36634
+ "Content-Type": "application/json",
36635
+ "CDN-Cache-Control": "private, no-store",
36636
+ "Vercel-CDN-Cache-Control": "private, no-store"
36637
+ });
36638
+ res.end(JSON.stringify({
36639
+ jsonrpc: "2.0",
36640
+ id: null,
36641
+ error: {
36642
+ code: -32600,
36643
+ message: "JSON-RPC batch requests are not supported. Send each tool call as its own HTTP request."
36644
+ }
36645
+ }));
36646
+ }
36647
+ return;
36648
+ }
36649
+ observation?.setTool(extractToolName(parsedBody) ?? extractRpcMethod(parsedBody));
36463
36650
  }
36464
36651
  logEvent({
36465
36652
  level: "info",
@@ -36710,6 +36897,7 @@ function shouldCheckForUpdate(version, selfHostFlag) {
36710
36897
 
36711
36898
  // src/lib/stdio-idle-timeout.ts
36712
36899
  var DEFAULT_STDIO_IDLE_TIMEOUT_MS = 12e4;
36900
+ var DEFAULT_STDIO_PENDING_GRACE_MS = 15 * 6e4;
36713
36901
  function parseStdioIdleTimeoutMs(raw = process.env["PAPI_STDIO_IDLE_TIMEOUT_MS"]) {
36714
36902
  if (raw === void 0 || raw.trim() === "") return DEFAULT_STDIO_IDLE_TIMEOUT_MS;
36715
36903
  const timeoutMs = Number(raw);
@@ -36720,10 +36908,26 @@ function parseStdioIdleTimeoutMs(raw = process.env["PAPI_STDIO_IDLE_TIMEOUT_MS"]
36720
36908
  }
36721
36909
  return timeoutMs;
36722
36910
  }
36723
- function startStdioIdleTimeout(input, timeoutMs, onTimeout) {
36911
+ function startStdioIdleTimeout(input, timeoutMs, onTimeout, isIdle = () => true) {
36724
36912
  if (timeoutMs === 0) return () => void 0;
36725
36913
  let timer2;
36726
36914
  let stopped = false;
36915
+ let pendingSince;
36916
+ const schedule = () => {
36917
+ timer2 = setTimeout(() => {
36918
+ if (stopped) return;
36919
+ if (!isIdle()) {
36920
+ pendingSince ??= Date.now();
36921
+ if (Date.now() - pendingSince < DEFAULT_STDIO_PENDING_GRACE_MS) {
36922
+ schedule();
36923
+ return;
36924
+ }
36925
+ }
36926
+ stopped = true;
36927
+ input.off("data", refresh);
36928
+ onTimeout();
36929
+ }, timeoutMs);
36930
+ };
36727
36931
  const stop = () => {
36728
36932
  if (stopped) return;
36729
36933
  stopped = true;
@@ -36733,12 +36937,8 @@ function startStdioIdleTimeout(input, timeoutMs, onTimeout) {
36733
36937
  const refresh = () => {
36734
36938
  if (stopped) return;
36735
36939
  if (timer2 !== void 0) clearTimeout(timer2);
36736
- timer2 = setTimeout(() => {
36737
- if (stopped) return;
36738
- stopped = true;
36739
- input.off("data", refresh);
36740
- onTimeout();
36741
- }, timeoutMs);
36940
+ pendingSince = void 0;
36941
+ schedule();
36742
36942
  };
36743
36943
  input.on("data", refresh);
36744
36944
  refresh();
@@ -36968,7 +37168,8 @@ if (isHttpMode && httpPort !== void 0) {
36968
37168
  parseStdioIdleTimeoutMs(),
36969
37169
  () => {
36970
37170
  void gracefulShutdown("idle-timeout");
36971
- }
37171
+ },
37172
+ () => !hasPendingStdioPrepare()
36972
37173
  );
36973
37174
  process.stderr.write(`${formatConnectionBanner({
36974
37175
  serverName: config.serverName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.110",
3
+ "version": "0.7.113",
4
4
  "description": "PAPI MCP server — AI-powered sprint planning, build execution, and strategy review for software projects",
5
5
  "license": "Elastic-2.0",
6
6
  "mcpName": "io.github.getpapi/papi",