@papi-ai/server 0.7.63 → 0.7.65

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.
@@ -1210,12 +1210,12 @@ var init_proxy_adapter = __esm({
1210
1210
  "listContributorReleasePrs",
1211
1211
  "claimReview",
1212
1212
  "getSiblingAds",
1213
- "getSiblingRepoTasks",
1214
- // task-2828 (C339): attributed-intelligence analytics reader — pg-only this cycle.
1215
- // Hosted forwarding needs a SECURITY DEFINER RPC + edge handler (like task-2394 did
1216
- // for getModuleEstimationStats); until then keep it here so hosted degrades to a
1217
- // safe `undefined` rather than forwarding into a 403. Wire under task-2390.
1218
- "getModelOutcomeStats"
1213
+ "getSiblingRepoTasks"
1214
+ // task-2828 (C339): attributed-intelligence analytics reader — pg-only that cycle.
1215
+ // task-2864 (C343): WIRED. getModelOutcomeStats now has an edge case handler (raw
1216
+ // SQL via postgres.js mirroring the pg query + inlined computeModelOutcomes bucketing)
1217
+ // + an ALLOWED_METHODS entry, so it forwards. countPlanRunsForCycle wired alongside it
1218
+ // (task-2860's auto-release guard reader). Both REMOVED from NO_FORWARD.
1219
1219
  // task-2394 (C329) — Batch A wired: findPendingDocActionsForTask,
1220
1220
  // getModuleEstimationStats and getDecisionScorePatterns now have edge case handlers
1221
1221
  // (each backed by a SECURITY DEFINER RPC, migration 20260714140000) plus
@@ -1348,7 +1348,8 @@ var init_proxy_adapter = __esm({
1348
1348
  if (response.status === 401) {
1349
1349
  throw new Error(
1350
1350
  `Auth: Invalid API key \u2014 PAPI_DATA_API_KEY was rejected by the proxy.
1351
- Check PAPI_DATA_API_KEY in your .mcp.json config. You can regenerate it from the PAPI dashboard.
1351
+ This usually means the key was revoked or replaced. Mint a fresh key in the Connect panel on your PAPI dashboard (https://getpapi.ai/hub), then update PAPI_DATA_API_KEY in your .mcp.json.
1352
+ Moving off a local install? Switch to the remote MCP: https://getpapi.ai/docs/install
1352
1353
  (${response.status} on ${method}: ${message})`
1353
1354
  );
1354
1355
  }
@@ -2220,6 +2221,9 @@ var SECTION_HEADERS = [
2220
2221
  "FILES LIKELY TOUCHED",
2221
2222
  "EFFORT"
2222
2223
  ];
2224
+ function normaliseHeaderLine(line) {
2225
+ return line.trim().replace(/^#{1,6}\s*/, "").replace(/^-\s+/, "").replace(/^\*\*(.*?)\*\*$/, "$1").replace(/:\s*$/, "").trim();
2226
+ }
2223
2227
  function splitSections(text) {
2224
2228
  const sections = /* @__PURE__ */ new Map();
2225
2229
  const lines = text.split("\n");
@@ -2232,8 +2236,8 @@ function splitSections(text) {
2232
2236
  }
2233
2237
  };
2234
2238
  for (const line of lines) {
2235
- const trimmed = line.trim();
2236
- const matched = SECTION_HEADERS.find((h) => trimmed === h);
2239
+ const normalised = normaliseHeaderLine(line);
2240
+ const matched = SECTION_HEADERS.find((h) => normalised === h);
2237
2241
  if (matched) {
2238
2242
  flush();
2239
2243
  currentSection = matched;
@@ -2254,7 +2258,10 @@ function parseChecklist(text) {
2254
2258
  return text.split("\n").map((l) => l.replace(/^\s*\[[ x]]\s*/, "").trim()).filter((l) => l.length > 0);
2255
2259
  }
2256
2260
  function parseBuildHandoff(markdown) {
2257
- if (!markdown.includes("BUILD HANDOFF")) return null;
2261
+ if (typeof markdown !== "string" || !markdown.trim()) return null;
2262
+ if (!markdown.includes("BUILD HANDOFF") && splitSections(markdown).size === 0) {
2263
+ return null;
2264
+ }
2258
2265
  const taskIdMatch = markdown.match(/BUILD HANDOFF\s*—\s*(task-\d+)/);
2259
2266
  const taskTitleMatch = markdown.match(/^Task:\s*(.+)$/m);
2260
2267
  const cycleMatch = markdown.match(/^Cycle:\s*(\d+)$/m);
package/dist/index.js CHANGED
@@ -1327,12 +1327,12 @@ var init_proxy_adapter = __esm({
1327
1327
  "listContributorReleasePrs",
1328
1328
  "claimReview",
1329
1329
  "getSiblingAds",
1330
- "getSiblingRepoTasks",
1331
- // task-2828 (C339): attributed-intelligence analytics reader — pg-only this cycle.
1332
- // Hosted forwarding needs a SECURITY DEFINER RPC + edge handler (like task-2394 did
1333
- // for getModuleEstimationStats); until then keep it here so hosted degrades to a
1334
- // safe `undefined` rather than forwarding into a 403. Wire under task-2390.
1335
- "getModelOutcomeStats"
1330
+ "getSiblingRepoTasks"
1331
+ // task-2828 (C339): attributed-intelligence analytics reader — pg-only that cycle.
1332
+ // task-2864 (C343): WIRED. getModelOutcomeStats now has an edge case handler (raw
1333
+ // SQL via postgres.js mirroring the pg query + inlined computeModelOutcomes bucketing)
1334
+ // + an ALLOWED_METHODS entry, so it forwards. countPlanRunsForCycle wired alongside it
1335
+ // (task-2860's auto-release guard reader). Both REMOVED from NO_FORWARD.
1336
1336
  // task-2394 (C329) — Batch A wired: findPendingDocActionsForTask,
1337
1337
  // getModuleEstimationStats and getDecisionScorePatterns now have edge case handlers
1338
1338
  // (each backed by a SECURITY DEFINER RPC, migration 20260714140000) plus
@@ -1465,7 +1465,8 @@ var init_proxy_adapter = __esm({
1465
1465
  if (response.status === 401) {
1466
1466
  throw new Error(
1467
1467
  `Auth: Invalid API key \u2014 PAPI_DATA_API_KEY was rejected by the proxy.
1468
- Check PAPI_DATA_API_KEY in your .mcp.json config. You can regenerate it from the PAPI dashboard.
1468
+ This usually means the key was revoked or replaced. Mint a fresh key in the Connect panel on your PAPI dashboard (https://getpapi.ai/hub), then update PAPI_DATA_API_KEY in your .mcp.json.
1469
+ Moving off a local install? Switch to the remote MCP: https://getpapi.ai/docs/install
1469
1470
  (${response.status} on ${method}: ${message})`
1470
1471
  );
1471
1472
  }
@@ -4333,7 +4334,7 @@ __export(doctor_exports, {
4333
4334
  __testing: () => __testing,
4334
4335
  runDoctor: () => runDoctor
4335
4336
  });
4336
- import { existsSync as existsSync11, readFileSync as readFileSync12 } from "fs";
4337
+ import { existsSync as existsSync11, readFileSync as readFileSync13 } from "fs";
4337
4338
  import { homedir as homedir4 } from "os";
4338
4339
  import { join as join20 } from "path";
4339
4340
  function redact(name, value) {
@@ -4353,7 +4354,7 @@ function findMcpJson() {
4353
4354
  for (const path7 of candidates) {
4354
4355
  if (!existsSync11(path7)) continue;
4355
4356
  try {
4356
- const raw = readFileSync12(path7, "utf-8");
4357
+ const raw = readFileSync13(path7, "utf-8");
4357
4358
  const parsed = JSON.parse(raw);
4358
4359
  const papiEntry = parsed.papi ?? parsed.mcpServers?.papi;
4359
4360
  if (!papiEntry) continue;
@@ -4631,7 +4632,7 @@ __export(reset_exports, {
4631
4632
  removePapiEntry: () => removePapiEntry,
4632
4633
  runReset: () => runReset
4633
4634
  });
4634
- import { existsSync as existsSync12, readFileSync as readFileSync13, writeFileSync as writeFileSync6 } from "fs";
4635
+ import { existsSync as existsSync12, readFileSync as readFileSync14, writeFileSync as writeFileSync6 } from "fs";
4635
4636
  import { homedir as homedir5 } from "os";
4636
4637
  import { join as join21 } from "path";
4637
4638
  import { createInterface } from "readline/promises";
@@ -4641,7 +4642,7 @@ function findResetTarget() {
4641
4642
  let raw;
4642
4643
  let parsed;
4643
4644
  try {
4644
- raw = readFileSync13(path7, "utf-8");
4645
+ raw = readFileSync14(path7, "utf-8");
4645
4646
  parsed = JSON.parse(raw);
4646
4647
  } catch {
4647
4648
  continue;
@@ -4742,7 +4743,7 @@ __export(audit_exports, {
4742
4743
  __testing: () => __testing2,
4743
4744
  runAudit: () => runAudit
4744
4745
  });
4745
- import { existsSync as existsSync13, readFileSync as readFileSync14, readdirSync as readdirSync7 } from "fs";
4746
+ import { existsSync as existsSync13, readFileSync as readFileSync15, readdirSync as readdirSync7 } from "fs";
4746
4747
  import { homedir as homedir6 } from "os";
4747
4748
  import { join as join22 } from "path";
4748
4749
  function safeListDirs(dir) {
@@ -4763,7 +4764,7 @@ function readMcp(projectPath) {
4763
4764
  const path7 = join22(projectPath, ".mcp.json");
4764
4765
  if (!existsSync13(path7)) return { servers: [] };
4765
4766
  try {
4766
- const parsed = JSON.parse(readFileSync14(path7, "utf-8"));
4767
+ const parsed = JSON.parse(readFileSync15(path7, "utf-8"));
4767
4768
  const mcpServers = parsed.mcpServers ?? {};
4768
4769
  const servers = Object.keys(mcpServers);
4769
4770
  if (parsed.papi && !servers.includes("papi")) servers.push("papi");
@@ -4819,7 +4820,7 @@ function readGlobalSkills() {
4819
4820
  function readGlobalMcpServers() {
4820
4821
  if (!existsSync13(GLOBAL_CLAUDE_JSON)) return [];
4821
4822
  try {
4822
- const parsed = JSON.parse(readFileSync14(GLOBAL_CLAUDE_JSON, "utf-8"));
4823
+ const parsed = JSON.parse(readFileSync15(GLOBAL_CLAUDE_JSON, "utf-8"));
4823
4824
  const servers = parsed.mcpServers ?? {};
4824
4825
  return Object.keys(servers).sort((a, b2) => a.localeCompare(b2));
4825
4826
  } catch {
@@ -4985,7 +4986,7 @@ var setup_exports = {};
4985
4986
  __export(setup_exports, {
4986
4987
  runSetup: () => runSetup
4987
4988
  });
4988
- import { existsSync as existsSync14, readFileSync as readFileSync15, writeFileSync as writeFileSync7, chmodSync as chmodSync2, statSync as statSync7 } from "fs";
4989
+ import { existsSync as existsSync14, readFileSync as readFileSync16, writeFileSync as writeFileSync7, chmodSync as chmodSync2, statSync as statSync8 } from "fs";
4989
4990
  import { join as join23 } from "path";
4990
4991
  function baseUrl() {
4991
4992
  const fromEnv = process.env["PAPI_HOST"] ?? process.env["PAPI_BASE_URL"];
@@ -5022,7 +5023,7 @@ function writeMcpJson(opts) {
5022
5023
  let parsed = {};
5023
5024
  if (existsSync14(path7)) {
5024
5025
  try {
5025
- parsed = JSON.parse(readFileSync15(path7, "utf-8"));
5026
+ parsed = JSON.parse(readFileSync16(path7, "utf-8"));
5026
5027
  } catch {
5027
5028
  throw new Error(`.mcp.json at ${path7} is not valid JSON. Fix it or remove it before re-running setup.`);
5028
5029
  }
@@ -5045,7 +5046,7 @@ function writeMcpJson(opts) {
5045
5046
  parsed.mcpServers = mcpServers;
5046
5047
  writeFileSync7(path7, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
5047
5048
  try {
5048
- const mode = statSync7(path7).mode & 511;
5049
+ const mode = statSync8(path7).mode & 511;
5049
5050
  if (mode !== 384) chmodSync2(path7, 384);
5050
5051
  } catch {
5051
5052
  }
@@ -5153,7 +5154,7 @@ var init_setup = __esm({
5153
5154
  });
5154
5155
 
5155
5156
  // src/index.ts
5156
- import { readFileSync as readFileSync16 } from "fs";
5157
+ import { readFileSync as readFileSync17 } from "fs";
5157
5158
  import { dirname as dirname6, join as join24, basename as basename2 } from "path";
5158
5159
  import { fileURLToPath as fileURLToPath4 } from "url";
5159
5160
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -5199,7 +5200,6 @@ function loadConfig() {
5199
5200
  "\nPAPI is running, but no project is configured here.\n" + HELP_FOOTER + "\n"
5200
5201
  );
5201
5202
  }
5202
- const anthropicApiKey = process.env.PAPI_API_KEY ?? "";
5203
5203
  const autoCommit2 = process.env.PAPI_AUTO_COMMIT !== "false";
5204
5204
  const baseBranch = process.env.PAPI_BASE_BRANCH ?? "main";
5205
5205
  const autoPR = process.env.PAPI_AUTO_PR !== "false";
@@ -5273,7 +5273,6 @@ Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env con
5273
5273
  return {
5274
5274
  projectRoot,
5275
5275
  papiDir: path.join(projectRoot, ".papi"),
5276
- anthropicApiKey,
5277
5276
  autoCommit: autoCommit2,
5278
5277
  baseBranch,
5279
5278
  autoPR,
@@ -5564,6 +5563,9 @@ var SECTION_HEADERS = [
5564
5563
  "FILES LIKELY TOUCHED",
5565
5564
  "EFFORT"
5566
5565
  ];
5566
+ function normaliseHeaderLine(line) {
5567
+ return line.trim().replace(/^#{1,6}\s*/, "").replace(/^-\s+/, "").replace(/^\*\*(.*?)\*\*$/, "$1").replace(/:\s*$/, "").trim();
5568
+ }
5567
5569
  function splitSections(text) {
5568
5570
  const sections = /* @__PURE__ */ new Map();
5569
5571
  const lines = text.split("\n");
@@ -5576,8 +5578,8 @@ function splitSections(text) {
5576
5578
  }
5577
5579
  };
5578
5580
  for (const line of lines) {
5579
- const trimmed = line.trim();
5580
- const matched = SECTION_HEADERS.find((h) => trimmed === h);
5581
+ const normalised = normaliseHeaderLine(line);
5582
+ const matched = SECTION_HEADERS.find((h) => normalised === h);
5581
5583
  if (matched) {
5582
5584
  flush();
5583
5585
  currentSection = matched;
@@ -5598,7 +5600,10 @@ function parseChecklist(text) {
5598
5600
  return text.split("\n").map((l) => l.replace(/^\s*\[[ x]]\s*/, "").trim()).filter((l) => l.length > 0);
5599
5601
  }
5600
5602
  function parseBuildHandoff(markdown) {
5601
- if (!markdown.includes("BUILD HANDOFF")) return null;
5603
+ if (typeof markdown !== "string" || !markdown.trim()) return null;
5604
+ if (!markdown.includes("BUILD HANDOFF") && splitSections(markdown).size === 0) {
5605
+ return null;
5606
+ }
5602
5607
  const taskIdMatch = markdown.match(/BUILD HANDOFF\s*—\s*(task-\d+)/);
5603
5608
  const taskTitleMatch = markdown.match(/^Task:\s*(.+)$/m);
5604
5609
  const cycleMatch = markdown.match(/^Cycle:\s*(\d+)$/m);
@@ -5633,6 +5638,28 @@ function parseBuildHandoff(markdown) {
5633
5638
  effort
5634
5639
  };
5635
5640
  }
5641
+ function coerceBuildHandoff(fields, taskId) {
5642
+ const scope = ensureArray(fields.scope);
5643
+ const scopeBoundary = ensureArray(fields.scopeBoundary);
5644
+ if (scope.length === 0 && scopeBoundary.length === 0) return null;
5645
+ const effortRaw = typeof fields.effort === "string" ? fields.effort.trim().toUpperCase() : "";
5646
+ const effort = VALID_EFFORT_SIZES.has(effortRaw) ? effortRaw : "M";
5647
+ const str = (v) => typeof v === "string" ? v.trim() : "";
5648
+ return {
5649
+ uuid: str(fields.uuid) || randomUUID2(),
5650
+ taskId: str(fields.taskId) || taskId || "",
5651
+ taskTitle: str(fields.taskTitle),
5652
+ cycle: typeof fields.cycle === "number" ? fields.cycle : 0,
5653
+ whyNow: str(fields.whyNow),
5654
+ scope,
5655
+ scopeBoundary,
5656
+ acceptanceCriteria: ensureArray(fields.acceptanceCriteria),
5657
+ securityConsiderations: str(fields.securityConsiderations),
5658
+ verificationFiles: ensureArray(fields.verificationFiles),
5659
+ filesLikelyTouched: ensureArray(fields.filesLikelyTouched),
5660
+ effort
5661
+ };
5662
+ }
5636
5663
  function ensureArray(value) {
5637
5664
  if (Array.isArray(value)) return value;
5638
5665
  if (typeof value === "string") {
@@ -8166,7 +8193,7 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
8166
8193
  }
8167
8194
 
8168
8195
  // src/server.ts
8169
- import { readFileSync as readFileSync11 } from "fs";
8196
+ import { readFileSync as readFileSync12 } from "fs";
8170
8197
  import { access as access4, readdir as readdir4, readFile as readFile9 } from "fs/promises";
8171
8198
  import { join as join19, dirname as dirname5 } from "path";
8172
8199
  import { fileURLToPath as fileURLToPath3 } from "url";
@@ -9007,6 +9034,7 @@ Everything in Part 1 (natural language) is **display-only**. Part 2 (structured
9007
9034
  - Updated or created Active Decisions in Part 1? \u2192 Put them in \`activeDecisions\` array (with id and full body including ### heading)
9008
9035
  - Found board corrections (wrong priority, missing fields, stale status) in Part 1? \u2192 Put them in \`boardCorrections\` array
9009
9036
  - Generated BUILD HANDOFFs in Part 1? \u2192 Put them in \`cycleHandoffs\` array
9037
+ - **\`complexity\` uses the LONG forms only** \u2014 "XS", "Small", "Medium", "Large", "XL". Do NOT reuse the handoff EFFORT short-forms (S/M/L) for task complexity.
9010
9038
 
9011
9039
  **Example with populated fields (DO NOT copy literally \u2014 adapt to your actual analysis):**
9012
9040
  \`\`\`json
@@ -9047,13 +9075,20 @@ This is Cycle 0 \u2014 the first planning cycle for a brand-new project.
9047
9075
  - Do NOT assume web-app patterns (routes, pages, components) unless the brief explicitly describes a web application
9048
9076
  - All tasks: status Backlog, priority P1-P2, reviewed true, phase "Phase 1"
9049
9077
 
9078
+ **REASONING LINE (task-2868 \u2014 the first cycle must SHOW PAPI's intelligence, not read as a generic list).** For EVERY task, the \`notes\` field MUST OPEN with a single line in the exact form:
9079
+ \`Why: <one plain-language sentence, \u2264160 chars, on why THIS task earns its place in the first cycle>\`
9080
+ Then a blank line, then any other notes. Rules:
9081
+ - Task 1's \`Why:\` must also justify its POSITION \u2014 say why it is the opening slice (the thinnest thing that puts the core loop in front of the user). This one line is the cycle's sequencing rationale; the dashboard surfaces it above the first task.
9082
+ - Plain language for a semi-technical builder \u2014 no jargon, no raw IDs, not a restatement of the title.
9083
+ - This is a hard requirement: the dashboard's first-cycle experience reads and renders this \`Why:\` line per task.
9084
+
9050
9085
  4. **First Active Decision** \u2014 If the description implies a clear architectural choice, create AD-1 with Confidence: MEDIUM. If no clear choice, skip this.
9051
9086
 
9052
9087
  5. **BUILD HANDOFFs** \u2014 Generate a full BUILD HANDOFF block for EVERY task created in step 3 (all 3-5 tasks). Include each in the \`cycleHandoffs\` array. **tempId join (REQUIRED \u2014 mismatches silently scramble handoffs):** give EVERY \`newTasks\` entry a unique \`tempId\` (\`"new-1"\`, \`"new-2"\`, \u2026), and set each \`cycleHandoffs\` \`taskId\` to the EXACT \`tempId\` of the newTask it belongs to. Do NOT rely on array order, and do NOT reuse a tempId. The builder needs handoffs to run \`build_execute\` \u2014 without them, tasks must be completed via \`ad_hoc\`, which breaks the normal flow.
9053
9088
 
9054
9089
  ### Structured output for Bootstrap:
9055
9090
  In the JSON block, you MUST include:
9056
- - "newTasks": array of task objects with ALL fields: title, status, priority, complexity, module, epic, phase, owner, notes. **This is how tasks get created on the board. If this array is empty, NO tasks will exist.**
9091
+ - "newTasks": array of task objects with ALL fields: title, status, priority, complexity, module, epic, phase, owner, notes. **This is how tasks get created on the board. If this array is empty, NO tasks will exist.** Every \`notes\` value MUST open with the \`Why: <sentence>\` reasoning line described in step 3 (the dashboard's first-cycle experience renders it).
9057
9092
  - "productBrief": the full Product Brief markdown content. **If null, the brief stays as the template.**
9058
9093
  - "activeDecisions": array of {id, body} objects. **If you created AD-1 in Part 1 but this array is empty, the AD will NOT be saved.**
9059
9094
  - "recommendedTaskId": null (the handler will use the first new task)
@@ -9597,10 +9632,17 @@ function coerceToString(value) {
9597
9632
  return JSON.stringify(value, null, 2);
9598
9633
  }
9599
9634
  function coerceStructuredOutput(parsed) {
9600
- const cycleHandoffs = Array.isArray(parsed.cycleHandoffs) ? parsed.cycleHandoffs.map((h) => ({
9601
- taskId: coerceToString(h.taskId),
9602
- buildHandoff: coerceToString(h.buildHandoff)
9603
- })) : [];
9635
+ const cycleHandoffs = Array.isArray(parsed.cycleHandoffs) ? parsed.cycleHandoffs.map((h) => {
9636
+ const { taskId: _t, buildHandoff: _b, ...rest } = h;
9637
+ if (typeof h.buildHandoff === "object" && h.buildHandoff !== null && !Array.isArray(h.buildHandoff)) {
9638
+ Object.assign(rest, h.buildHandoff);
9639
+ }
9640
+ return {
9641
+ taskId: coerceToString(h.taskId),
9642
+ buildHandoff: typeof h.buildHandoff === "string" ? h.buildHandoff : "",
9643
+ ...Object.keys(rest).length > 0 ? { structuredFields: rest } : {}
9644
+ };
9645
+ }) : [];
9604
9646
  const newTasks = Array.isArray(parsed.newTasks) ? parsed.newTasks.map((t) => ({
9605
9647
  // task-2242: stable join key (optional — undefined falls back to index).
9606
9648
  tempId: t.tempId !== void 0 && t.tempId !== null ? coerceToString(t.tempId) : void 0,
@@ -10710,9 +10752,12 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false)
10710
10752
  skipped++;
10711
10753
  continue;
10712
10754
  }
10713
- const parsed = parseBuildHandoff(handoff.buildHandoff);
10755
+ const parsed = parseBuildHandoff(handoff.buildHandoff) ?? (handoff.structuredFields ? coerceBuildHandoff(handoff.structuredFields, handoff.taskId) : null);
10714
10756
  if (!parsed) {
10715
- warnings.push(`Failed to parse handoff for ${handoff.taskId}`);
10757
+ const received = (handoff.buildHandoff ?? "").slice(0, 80);
10758
+ warnings.push(
10759
+ `Failed to parse handoff for ${handoff.taskId}. Received: "${received}${(handoff.buildHandoff ?? "").length > 80 ? "\u2026" : ""}". Send EITHER the BUILD HANDOFF markdown template in \`buildHandoff\` (section headers on their own lines: SCOPE (DO THIS) / SCOPE BOUNDARY (DO NOT DO THIS) / ACCEPTANCE CRITERIA / EFFORT, bullets as "- item") OR structured fields alongside taskId: {scope: string[], scopeBoundary: string[], acceptanceCriteria: string[], effort: "XS|S|M|L|XL"}.`
10760
+ );
10716
10761
  continue;
10717
10762
  }
10718
10763
  const invalidFields = validateHandoffScope(parsed);
@@ -10720,7 +10765,6 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false)
10720
10765
  warnings.push(
10721
10766
  `Rejected handoff for ${handoff.taskId}: missing or empty ${invalidFields.join(", ")}. Handoffs without explicit scope produce ambiguous builds.`
10722
10767
  );
10723
- skipped++;
10724
10768
  continue;
10725
10769
  }
10726
10770
  if (!parsed.createdAt) {
@@ -11195,6 +11239,20 @@ async function resolveOwnerGate(adapter2, config2) {
11195
11239
  }
11196
11240
 
11197
11241
  // src/services/plan.ts
11242
+ var COMPLEXITY_ALIASES = {
11243
+ "XS": "XS",
11244
+ "S": "Small",
11245
+ "SMALL": "Small",
11246
+ "M": "Medium",
11247
+ "MEDIUM": "Medium",
11248
+ "L": "Large",
11249
+ "LARGE": "Large",
11250
+ "XL": "XL"
11251
+ };
11252
+ function normalizeComplexity(value) {
11253
+ const key = (value ?? "").trim().toUpperCase();
11254
+ return COMPLEXITY_ALIASES[key] ?? "Small";
11255
+ }
11198
11256
  var PLAN_BUILD_REPORT_BUDGET = { maxReports: 12, fieldBudget: 280 };
11199
11257
  function leadChainWithRecommended(chain, recommendedTaskId) {
11200
11258
  const rec = recommendedTaskId?.trim();
@@ -12133,10 +12191,13 @@ ${cleanContent}`;
12133
12191
  } catch {
12134
12192
  }
12135
12193
  const handoffs = (data.cycleHandoffs ?? []).map((h) => {
12136
- const parsed = parseBuildHandoff(h.buildHandoff);
12194
+ const parsed = parseBuildHandoff(h.buildHandoff) ?? (h.structuredFields ? coerceBuildHandoff(h.structuredFields, h.taskId) : null);
12137
12195
  if (parsed && !parsed.createdAt) {
12138
12196
  parsed.createdAt = (/* @__PURE__ */ new Date()).toISOString();
12139
12197
  }
12198
+ if (!parsed) {
12199
+ console.error(`[plan] dropping unparseable handoff for ${h.taskId} (not the BUILD HANDOFF template and no structured scope fields)`);
12200
+ }
12140
12201
  return { taskId: h.taskId, handoff: parsed };
12141
12202
  }).filter((h) => h.handoff != null);
12142
12203
  const cycleTaskIds = data.cycleTaskIds?.length ? data.cycleTaskIds : (data.cycleHandoffs ?? []).map((h) => h.taskId);
@@ -12223,7 +12284,7 @@ ${cleanContent}`;
12223
12284
  title: t.title,
12224
12285
  status: t.status || "Backlog",
12225
12286
  priority: t.priority || "P1 High",
12226
- complexity: t.complexity || "Small",
12287
+ complexity: normalizeComplexity(t.complexity),
12227
12288
  module: t.module || "Core",
12228
12289
  epic: t.epic || "Platform",
12229
12290
  phase: t.phase || "Phase 1",
@@ -12371,7 +12432,7 @@ ${cleanContent}`;
12371
12432
  title: task.title,
12372
12433
  status: task.status || "Backlog",
12373
12434
  priority: task.priority || "P1 High",
12374
- complexity: task.complexity || "Small",
12435
+ complexity: normalizeComplexity(task.complexity),
12375
12436
  module: task.module || "Core",
12376
12437
  epic: task.epic || "Platform",
12377
12438
  phase: task.phase || "Phase 1",
@@ -13383,6 +13444,13 @@ async function resolveLlmResponse(inlineResponse, filePath) {
13383
13444
  }
13384
13445
  const resolvedPath = filePath.trim();
13385
13446
  if (!isAbsolute(resolvedPath)) {
13447
+ const looksWindows = /^[A-Za-z]:[\\/]/.test(resolvedPath) || resolvedPath.startsWith("\\\\");
13448
+ if (looksWindows && process.platform !== "win32") {
13449
+ return {
13450
+ ok: false,
13451
+ error: `llm_response_file points at a path on YOUR machine (${resolvedPath}), but this PAPI server runs remotely and cannot read your filesystem. llm_response_file only works with a locally installed (stdio) server. On the hosted connection, pass the content inline via llm_response.`
13452
+ };
13453
+ }
13386
13454
  return {
13387
13455
  ok: false,
13388
13456
  error: `llm_response_file must be an absolute path, got: ${resolvedPath}`
@@ -13629,7 +13697,7 @@ var planTool = {
13629
13697
  },
13630
13698
  llm_response_file: {
13631
13699
  type: "string",
13632
- description: 'Absolute path to a file containing the plan output (mode "apply" only). Use this when the response is too large to pass as a string parameter (some hosts cap inputs around 50KB). The file must be absolute, exist, and be \u2264500KB. Mutually exclusive with llm_response.'
13700
+ description: 'Absolute path to a file containing the plan output (mode "apply" only). LOCAL stdio servers only \u2014 on the hosted connection the server cannot read files on your machine; pass llm_response inline instead. Use this when the response is too large to pass as a string parameter (some hosts cap inputs around 50KB). The file must be absolute, exist, and be \u2264500KB. Mutually exclusive with llm_response.'
13633
13701
  },
13634
13702
  cycle_number: {
13635
13703
  type: "number",
@@ -13704,7 +13772,7 @@ function formatPlanResult(result) {
13704
13772
  return textResponse(
13705
13773
  `${pullLine}**${modeLabel} Mode \u2014 ${cycleLabel}**
13706
13774
 
13707
- \u26A0\uFE0F **Persistence failed:** Structured output could not be parsed. Cycle log, board corrections, handoffs, and Active Decisions were NOT saved. Try running \`plan\` again.`
13775
+ \u26A0\uFE0F **Persistence failed:** Structured output could not be parsed. Cycle log, board corrections, handoffs, and Active Decisions were NOT saved. Your llm_response must contain the literal marker \`<!-- PAPI_STRUCTURED_OUTPUT -->\` followed by the JSON inside a \`\`\`json code fence \u2014 resend with that exact envelope (do not strip the marker or the fence).`
13708
13776
  );
13709
13777
  }
13710
13778
  const lines = [];
@@ -16059,7 +16127,7 @@ var strategyReviewTool = {
16059
16127
  },
16060
16128
  llm_response_file: {
16061
16129
  type: "string",
16062
- description: 'Absolute path to a file containing the review output (mode "apply" only). Use this when the response is too large to pass as a string parameter (some hosts cap inputs around 50KB). The file must be absolute, exist, and be \u2264500KB. Mutually exclusive with llm_response.'
16130
+ description: 'Absolute path to a file containing the review output (mode "apply" only). LOCAL stdio servers only \u2014 on the hosted connection the server cannot read files on your machine; pass llm_response inline instead. Use this when the response is too large to pass as a string parameter (some hosts cap inputs around 50KB). The file must be absolute, exist, and be \u2264500KB. Mutually exclusive with llm_response.'
16063
16131
  },
16064
16132
  cycle_number: {
16065
16133
  type: "number",
@@ -18034,7 +18102,9 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
18034
18102
  }
18035
18103
  if (adapter2.upsertActiveDecision) {
18036
18104
  const title = ad.title || ad.body.split("\n")[0].replace(/^#+\s*/, "").slice(0, 120);
18037
- await adapter2.upsertActiveDecision(ad.id, ad.body, title, ad.confidence || "MEDIUM", 0);
18105
+ const confidenceMatch = ad.body.match(/\[Confidence:\s*(HIGH|MEDIUM|LOW)\]/i);
18106
+ const confidence = confidenceMatch ? confidenceMatch[1].toUpperCase() : ad.confidence || "MEDIUM";
18107
+ await adapter2.upsertActiveDecision(ad.id, ad.body, title, confidence, 0);
18038
18108
  } else {
18039
18109
  await adapter2.updateActiveDecision(ad.id, ad.body, 0);
18040
18110
  }
@@ -18814,10 +18884,14 @@ ${[created, skipped].filter(Boolean).join(", ")}.${idea}`;
18814
18884
  \u26A0\uFE0F **Setup warnings (non-blocking):**
18815
18885
  ${result.warnings.map((w) => `- ${w}`).join("\n")}` : "";
18816
18886
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
18817
- return textResponse(
18818
- `${prefix}Product Brief generated and saved.${briefRegenNote}${adNote}${northStarNote}${taskNote}${constraintsHint}${editorNote}${gitignoreNote}${warningsNote}
18887
+ const isHosted = !!result.filesToWrite && !result.filesToWrite.isEmpty();
18888
+ const filesNote = isHosted ? `
18889
+
18890
+ **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).` : `
18819
18891
 
18820
- **Important:** Setup created/modified files (${harnessFiles}, .claude/settings.json, docs/). Commit these changes before running \`build_execute\` \u2014 it requires a clean working directory.
18892
+ **Important:** Setup created/modified files (${harnessFiles}, .claude/settings.json, docs/). Commit these changes before running \`build_execute\` \u2014 it requires a clean working directory.`;
18893
+ return textResponse(
18894
+ `${prefix}Product Brief generated and saved.${briefRegenNote}${adNote}${northStarNote}${taskNote}${constraintsHint}${editorNote}${gitignoreNote}${warningsNote}${filesNote}
18821
18895
 
18822
18896
  Tip: See \`docs/templates/example-project-brief.md\` for an example of a well-written brief.
18823
18897
 
@@ -23234,7 +23308,7 @@ var bugTool = {
23234
23308
  severity: {
23235
23309
  type: "string",
23236
23310
  enum: ["critical", "major", "minor"],
23237
- description: 'Bug severity (default: "major"). Critical = P1, Major/Minor = P2.'
23311
+ description: 'Bug severity (default: "major"). Critical = P0, Major = P1, Minor = P2.'
23238
23312
  },
23239
23313
  notes: {
23240
23314
  type: "string",
@@ -27135,6 +27209,7 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
27135
27209
  // Skill proposals — one-time codebase scan on first orient per project.
27136
27210
  // PERF: hasToolMilestone (LIMIT 1 EXISTS) replaces readToolMetrics (5000-row pull).
27137
27211
  tracked("skill-scan", async () => {
27212
+ if (!hasLocalWorkspace()) return "";
27138
27213
  const alreadyScanned = adapter2.hasToolMilestone ? await adapter2.hasToolMilestone("milestone:skill_scan_completed") : (await adapter2.readToolMetrics()).some((m) => m.tool === "milestone:skill_scan_completed");
27139
27214
  if (alreadyScanned) return "";
27140
27215
  const proposals = scanForSkillSignals(config2.projectRoot);
@@ -28029,11 +28104,11 @@ var handoffGenerateTool = {
28029
28104
  },
28030
28105
  llm_response: {
28031
28106
  type: "string",
28032
- description: 'Your raw output from executing the handoff prompt (mode "apply" only). Must include both Part 1 (markdown) and Part 2 (structured JSON after <!-- PAPI_STRUCTURED_OUTPUT -->). Mutually exclusive with llm_response_file.'
28107
+ description: 'Your raw output from executing the handoff prompt (mode "apply" only). Must include both Part 1 (markdown) and Part 2 (structured JSON after <!-- PAPI_STRUCTURED_OUTPUT -->, inside a ```json fence). Each cycleHandoffs entry needs EITHER a buildHandoff string in the BUILD HANDOFF markdown template (section headers on their own lines: SCOPE (DO THIS) / SCOPE BOUNDARY (DO NOT DO THIS) / ACCEPTANCE CRITERIA / EFFORT) OR structured fields alongside taskId: {scope: string[], scopeBoundary: string[], acceptanceCriteria: string[], effort: "XS|S|M|L|XL"}. Mutually exclusive with llm_response_file.'
28033
28108
  },
28034
28109
  llm_response_file: {
28035
28110
  type: "string",
28036
- description: 'Absolute path to a file containing the handoff output (mode "apply" only). Use instead of llm_response when the output exceeds tool parameter size limits. Mutually exclusive with llm_response.'
28111
+ description: 'Absolute path to a file containing the handoff output (mode "apply" only). LOCAL stdio servers only \u2014 on the hosted connection the server cannot read files on your machine; pass llm_response inline instead. Use instead of llm_response when the output exceeds tool parameter size limits. Mutually exclusive with llm_response.'
28037
28112
  },
28038
28113
  cycle_number: {
28039
28114
  type: "number",
@@ -28130,10 +28205,12 @@ ${result.userMessage}
28130
28205
  }
28131
28206
  }
28132
28207
 
28208
+ // src/tools/scope-brief.ts
28209
+ import { readFileSync as readFileSync11, statSync as statSync7 } from "fs";
28210
+
28133
28211
  // src/services/scope-brief.ts
28134
28212
  import { writeFileSync as writeFileSync5, mkdirSync as mkdirSync3 } from "fs";
28135
28213
  import { join as join17, dirname as dirname4 } from "path";
28136
- import Anthropic from "@anthropic-ai/sdk";
28137
28214
  var SCOPE_BRIEF_SYSTEM = `You are a technical scoping tool. You receive a brief-class task (too large to build directly) and decompose it into a structured scope document.
28138
28215
 
28139
28216
  A scope document must:
@@ -28164,7 +28241,18 @@ Return the scope document in Markdown. Use this exact structure:
28164
28241
  <Any unknowns that affect scope, or "None">
28165
28242
 
28166
28243
  Return ONLY the markdown document. No preamble, no commentary.`;
28167
- async function runScopeBrief(adapter2, input) {
28244
+ async function buildScopeBriefPrompt(adapter2, taskId) {
28245
+ const tasks = await adapter2.queryBoard({});
28246
+ const task = tasks.find((t) => t.id === taskId || t.displayId === taskId);
28247
+ if (!task) {
28248
+ throw new Error(`Task not found: ${taskId}`);
28249
+ }
28250
+ if (task.scopeClass !== "brief") {
28251
+ throw new Error(`Task ${taskId} is not brief-class (scopeClass=${task.scopeClass ?? "task"}). Only brief-class tasks can be scoped.`);
28252
+ }
28253
+ return { task, systemPrompt: SCOPE_BRIEF_SYSTEM, userContext: buildTaskContext(task) };
28254
+ }
28255
+ async function applyScopeBrief(adapter2, input) {
28168
28256
  const tasks = await adapter2.queryBoard({});
28169
28257
  const task = tasks.find((t) => t.id === input.taskId || t.displayId === input.taskId);
28170
28258
  if (!task) {
@@ -28173,17 +28261,9 @@ async function runScopeBrief(adapter2, input) {
28173
28261
  if (task.scopeClass !== "brief") {
28174
28262
  throw new Error(`Task ${input.taskId} is not brief-class (scopeClass=${task.scopeClass ?? "task"}). Only brief-class tasks can be scoped.`);
28175
28263
  }
28176
- const taskContext = buildTaskContext(task);
28177
- const client = new Anthropic({ apiKey: input.apiKey });
28178
- const response = await client.messages.create({
28179
- model: "claude-sonnet-4-6",
28180
- max_tokens: 2048,
28181
- system: SCOPE_BRIEF_SYSTEM,
28182
- messages: [{ role: "user", content: taskContext }]
28183
- });
28184
- const docContent = response.content[0].type === "text" ? response.content[0].text.trim() : "";
28264
+ const docContent = input.llmMarkdown.trim();
28185
28265
  if (!docContent) {
28186
- throw new Error("LLM returned empty scope document");
28266
+ throw new Error("Scope document is empty \u2014 pass the markdown you produced from the scope prompt as llm_response.");
28187
28267
  }
28188
28268
  const slug = input.taskId.replace(/[^a-z0-9-]/g, "-").toLowerCase();
28189
28269
  const relPath = `docs/scopes/${slug}.md`;
@@ -28268,7 +28348,7 @@ function buildSummary(task, taskCount) {
28268
28348
  // src/tools/scope-brief.ts
28269
28349
  var scopeBriefTool = {
28270
28350
  name: "scope_brief",
28271
- description: "Decompose a brief-class task (Large/XL, too large to build directly) into a structured scope document. Runs an LLM pass to produce sub-tasks, writes docs/scopes/<task-id>.md, registers it in the doc registry, and marks the source task as decomposed. Use before planning a cycle that includes brief-class tasks.",
28351
+ description: 'Decompose a brief-class task (Large/XL, too large to build directly) into a structured scope document. Two phases, like plan/strategy \u2014 PAPI never calls a model itself (AD-58): first call (mode "prepare") returns a scoping prompt for YOU to run in your own AI workspace; then call again (mode "apply") with your markdown output in llm_response to write docs/scopes/<task-id>.md, register it, and mark the source task decomposed. Use before planning a cycle that includes brief-class tasks.',
28272
28352
  annotations: { title: "Scope Brief", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
28273
28353
  inputSchema: {
28274
28354
  type: "object",
@@ -28276,42 +28356,102 @@ var scopeBriefTool = {
28276
28356
  task_id: {
28277
28357
  type: "string",
28278
28358
  description: 'ID of the brief-class task to decompose (e.g. "task-042").'
28359
+ },
28360
+ mode: {
28361
+ type: "string",
28362
+ enum: ["prepare", "apply"],
28363
+ description: '"prepare" (default) returns the scoping prompt for you to run. "apply" persists the scope document you produced.'
28364
+ },
28365
+ llm_response: {
28366
+ type: "string",
28367
+ description: 'Your markdown scope document from running the prepare prompt (mode "apply" only).'
28368
+ },
28369
+ llm_response_file: {
28370
+ type: "string",
28371
+ description: 'Absolute path to a file holding your markdown scope document (mode "apply" only). LOCAL stdio servers only \u2014 the hosted server cannot read your machine. Mutually exclusive with llm_response; must be absolute, exist, and be \u2264500KB.'
28279
28372
  }
28280
28373
  },
28281
28374
  required: ["task_id"]
28282
28375
  }
28283
28376
  };
28377
+ var MAX_RESPONSE_FILE_BYTES = 500 * 1024;
28284
28378
  async function handleScopeBrief(adapter2, config2, args) {
28285
28379
  const taskId = args.task_id;
28286
28380
  if (!taskId) {
28287
28381
  return errorResponse("task_id is required.");
28288
28382
  }
28289
- const apiKey = process.env["ANTHROPIC_API_KEY"];
28290
- if (!apiKey) {
28291
- return errorResponse("ANTHROPIC_API_KEY is not set \u2014 scope_brief requires LLM access.");
28292
- }
28383
+ const mode = args.mode ?? "prepare";
28293
28384
  const health = await adapter2.getCycleHealth().catch(() => ({ totalCycles: 0 }));
28294
- try {
28295
- const result = await runScopeBrief(adapter2, {
28296
- taskId,
28297
- apiKey,
28298
- projectRoot: config2.projectRoot,
28299
- cycleNumber: health.totalCycles,
28300
- adapterType: config2.adapterType
28301
- });
28302
- const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
28303
- return textResponse(
28304
- `**Scope document created:** \`${result.docPath}\`
28385
+ if (mode === "prepare") {
28386
+ try {
28387
+ const { task, systemPrompt, userContext } = await buildScopeBriefPrompt(adapter2, taskId);
28388
+ return textResponse(
28389
+ `**Scope Brief \u2014 prepare** for ${task.id}: ${task.title}
28390
+
28391
+ Run the scoping pass below in YOUR AI workspace (PAPI does not call a model \u2014 AD-58), then call \`scope_brief\` again with \`mode: "apply"\`, the same \`task_id\`, and your markdown document in \`llm_response\`.
28392
+
28393
+ ---
28394
+
28395
+ ## Scoping instructions
28396
+
28397
+ ${systemPrompt}
28398
+
28399
+ ---
28400
+
28401
+ ## Task to decompose
28402
+
28403
+ ${userContext}`
28404
+ );
28405
+ } catch (err) {
28406
+ const message = err instanceof Error ? err.message : String(err);
28407
+ return errorResponse(`scope_brief prepare failed: ${message}`);
28408
+ }
28409
+ }
28410
+ if (mode === "apply") {
28411
+ const llmMarkdown = readLlmResponse(args);
28412
+ if (!llmMarkdown) {
28413
+ return errorResponse('mode "apply" requires your scope document \u2014 pass it in llm_response (or llm_response_file on a local server).');
28414
+ }
28415
+ try {
28416
+ const result = await applyScopeBrief(adapter2, {
28417
+ taskId,
28418
+ llmMarkdown,
28419
+ projectRoot: config2.projectRoot,
28420
+ cycleNumber: health.totalCycles,
28421
+ adapterType: config2.adapterType
28422
+ });
28423
+ const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
28424
+ return textResponse(
28425
+ `**Scope document created:** \`${result.docPath}\`
28305
28426
  - **Sub-tasks defined:** ${result.taskCount}
28306
28427
  - **Doc registry ID:** ${result.docId}
28307
28428
  - **Source task:** ${taskId} marked as decomposed
28308
28429
 
28309
28430
  **Next step:** Review \`${result.docPath}\` and submit each sub-task via \`idea\` with \`Reference: ${result.docPath}\` in notes. The planner will pick them up in the next cycle.` + filesToWriteSection
28310
- );
28311
- } catch (err) {
28312
- const message = err instanceof Error ? err.message : String(err);
28313
- return errorResponse(`scope_brief failed: ${message}`);
28431
+ );
28432
+ } catch (err) {
28433
+ const message = err instanceof Error ? err.message : String(err);
28434
+ return errorResponse(`scope_brief apply failed: ${message}`);
28435
+ }
28314
28436
  }
28437
+ return errorResponse(`Unknown mode "${mode}" \u2014 use "prepare" or "apply".`);
28438
+ }
28439
+ function readLlmResponse(args) {
28440
+ const inline = args.llm_response;
28441
+ if (inline && inline.trim()) return inline;
28442
+ const filePath = args.llm_response_file;
28443
+ if (filePath && filePath.trim()) {
28444
+ try {
28445
+ if (statSync7(filePath).size > MAX_RESPONSE_FILE_BYTES) {
28446
+ throw new Error(`llm_response_file exceeds ${MAX_RESPONSE_FILE_BYTES} bytes`);
28447
+ }
28448
+ const body = readFileSync11(filePath, "utf-8");
28449
+ return body.trim() ? body : null;
28450
+ } catch (err) {
28451
+ throw new Error(`could not read llm_response_file: ${err instanceof Error ? err.message : String(err)}`);
28452
+ }
28453
+ }
28454
+ return null;
28315
28455
  }
28316
28456
 
28317
28457
  // src/tools/ad-view.ts
@@ -29537,7 +29677,7 @@ function createServer(adapter2, config2) {
29537
29677
  const __pkgDir = dirname5(__pkgFilename);
29538
29678
  let serverVersion = "unknown";
29539
29679
  try {
29540
- const pkg = JSON.parse(readFileSync11(join19(__pkgDir, "..", "package.json"), "utf-8"));
29680
+ const pkg = JSON.parse(readFileSync12(join19(__pkgDir, "..", "package.json"), "utf-8"));
29541
29681
  serverVersion = pkg.version ?? "unknown";
29542
29682
  } catch {
29543
29683
  }
@@ -30283,7 +30423,7 @@ async function dispatchRequest(args) {
30283
30423
  var __dirname = dirname6(fileURLToPath4(import.meta.url));
30284
30424
  var pkgVersion = "unknown";
30285
30425
  try {
30286
- const pkg = JSON.parse(readFileSync16(join24(__dirname, "..", "package.json"), "utf-8"));
30426
+ const pkg = JSON.parse(readFileSync17(join24(__dirname, "..", "package.json"), "utf-8"));
30287
30427
  pkgVersion = pkg.version;
30288
30428
  } catch {
30289
30429
  }
package/dist/prompts.js CHANGED
@@ -130,6 +130,7 @@ Everything in Part 1 (natural language) is **display-only**. Part 2 (structured
130
130
  - Updated or created Active Decisions in Part 1? \u2192 Put them in \`activeDecisions\` array (with id and full body including ### heading)
131
131
  - Found board corrections (wrong priority, missing fields, stale status) in Part 1? \u2192 Put them in \`boardCorrections\` array
132
132
  - Generated BUILD HANDOFFs in Part 1? \u2192 Put them in \`cycleHandoffs\` array
133
+ - **\`complexity\` uses the LONG forms only** \u2014 "XS", "Small", "Medium", "Large", "XL". Do NOT reuse the handoff EFFORT short-forms (S/M/L) for task complexity.
133
134
 
134
135
  **Example with populated fields (DO NOT copy literally \u2014 adapt to your actual analysis):**
135
136
  \`\`\`json
@@ -170,13 +171,20 @@ This is Cycle 0 \u2014 the first planning cycle for a brand-new project.
170
171
  - Do NOT assume web-app patterns (routes, pages, components) unless the brief explicitly describes a web application
171
172
  - All tasks: status Backlog, priority P1-P2, reviewed true, phase "Phase 1"
172
173
 
174
+ **REASONING LINE (task-2868 \u2014 the first cycle must SHOW PAPI's intelligence, not read as a generic list).** For EVERY task, the \`notes\` field MUST OPEN with a single line in the exact form:
175
+ \`Why: <one plain-language sentence, \u2264160 chars, on why THIS task earns its place in the first cycle>\`
176
+ Then a blank line, then any other notes. Rules:
177
+ - Task 1's \`Why:\` must also justify its POSITION \u2014 say why it is the opening slice (the thinnest thing that puts the core loop in front of the user). This one line is the cycle's sequencing rationale; the dashboard surfaces it above the first task.
178
+ - Plain language for a semi-technical builder \u2014 no jargon, no raw IDs, not a restatement of the title.
179
+ - This is a hard requirement: the dashboard's first-cycle experience reads and renders this \`Why:\` line per task.
180
+
173
181
  4. **First Active Decision** \u2014 If the description implies a clear architectural choice, create AD-1 with Confidence: MEDIUM. If no clear choice, skip this.
174
182
 
175
183
  5. **BUILD HANDOFFs** \u2014 Generate a full BUILD HANDOFF block for EVERY task created in step 3 (all 3-5 tasks). Include each in the \`cycleHandoffs\` array. **tempId join (REQUIRED \u2014 mismatches silently scramble handoffs):** give EVERY \`newTasks\` entry a unique \`tempId\` (\`"new-1"\`, \`"new-2"\`, \u2026), and set each \`cycleHandoffs\` \`taskId\` to the EXACT \`tempId\` of the newTask it belongs to. Do NOT rely on array order, and do NOT reuse a tempId. The builder needs handoffs to run \`build_execute\` \u2014 without them, tasks must be completed via \`ad_hoc\`, which breaks the normal flow.
176
184
 
177
185
  ### Structured output for Bootstrap:
178
186
  In the JSON block, you MUST include:
179
- - "newTasks": array of task objects with ALL fields: title, status, priority, complexity, module, epic, phase, owner, notes. **This is how tasks get created on the board. If this array is empty, NO tasks will exist.**
187
+ - "newTasks": array of task objects with ALL fields: title, status, priority, complexity, module, epic, phase, owner, notes. **This is how tasks get created on the board. If this array is empty, NO tasks will exist.** Every \`notes\` value MUST open with the \`Why: <sentence>\` reasoning line described in step 3 (the dashboard's first-cycle experience renders it).
180
188
  - "productBrief": the full Product Brief markdown content. **If null, the brief stays as the template.**
181
189
  - "activeDecisions": array of {id, body} objects. **If you created AD-1 in Part 1 but this array is empty, the AD will NOT be saved.**
182
190
  - "recommendedTaskId": null (the handler will use the first new task)
@@ -720,10 +728,17 @@ function coerceToString(value) {
720
728
  return JSON.stringify(value, null, 2);
721
729
  }
722
730
  function coerceStructuredOutput(parsed) {
723
- const cycleHandoffs = Array.isArray(parsed.cycleHandoffs) ? parsed.cycleHandoffs.map((h) => ({
724
- taskId: coerceToString(h.taskId),
725
- buildHandoff: coerceToString(h.buildHandoff)
726
- })) : [];
731
+ const cycleHandoffs = Array.isArray(parsed.cycleHandoffs) ? parsed.cycleHandoffs.map((h) => {
732
+ const { taskId: _t, buildHandoff: _b, ...rest } = h;
733
+ if (typeof h.buildHandoff === "object" && h.buildHandoff !== null && !Array.isArray(h.buildHandoff)) {
734
+ Object.assign(rest, h.buildHandoff);
735
+ }
736
+ return {
737
+ taskId: coerceToString(h.taskId),
738
+ buildHandoff: typeof h.buildHandoff === "string" ? h.buildHandoff : "",
739
+ ...Object.keys(rest).length > 0 ? { structuredFields: rest } : {}
740
+ };
741
+ }) : [];
727
742
  const newTasks = Array.isArray(parsed.newTasks) ? parsed.newTasks.map((t) => ({
728
743
  // task-2242: stable join key (optional — undefined falls back to index).
729
744
  tempId: t.tempId !== void 0 && t.tempId !== null ? coerceToString(t.tempId) : void 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.63",
3
+ "version": "0.7.65",
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",