@papi-ai/server 0.7.35 → 0.7.37

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.
@@ -50,6 +50,7 @@ __export(git_exports, {
50
50
  hasRemote: () => hasRemote,
51
51
  hasUncommittedChanges: () => hasUncommittedChanges,
52
52
  hasUnpushedCommits: () => hasUnpushedCommits,
53
+ isBranchContentAlreadyInBase: () => isBranchContentAlreadyInBase,
53
54
  isBranchMergedInto: () => isBranchMergedInto,
54
55
  isGhAvailable: () => isGhAvailable,
55
56
  isGitAvailable: () => isGitAvailable,
@@ -807,6 +808,32 @@ function isBranchMergedInto(cwd, branch, baseBranch) {
807
808
  return false;
808
809
  }
809
810
  }
811
+ function isBranchContentAlreadyInBase(cwd, branch, baseBranch) {
812
+ const resolveCommit = (ref) => {
813
+ try {
814
+ return execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], { cwd, encoding: "utf-8" }).trim();
815
+ } catch {
816
+ return null;
817
+ }
818
+ };
819
+ const branchRef = resolveCommit(branch) ? branch : resolveCommit(`origin/${branch}`) ? `origin/${branch}` : null;
820
+ if (!branchRef) return false;
821
+ try {
822
+ const out = execFileSync("git", ["merge-tree", "--write-tree", baseBranch, branchRef], {
823
+ cwd,
824
+ encoding: "utf-8"
825
+ }).trim();
826
+ const mergedTree = out.split("\n")[0]?.trim();
827
+ if (!mergedTree) return false;
828
+ const baseTree = execFileSync("git", ["rev-parse", `${baseBranch}^{tree}`], {
829
+ cwd,
830
+ encoding: "utf-8"
831
+ }).trim();
832
+ return mergedTree === baseTree;
833
+ } catch {
834
+ return false;
835
+ }
836
+ }
810
837
  function pickModuleCycleBranch(candidates, cycleNumber, module) {
811
838
  if (candidates.length === 0) return void 0;
812
839
  const expected = cycleBranchName(cycleNumber, module);
package/dist/index.js CHANGED
@@ -51,6 +51,7 @@ __export(git_exports, {
51
51
  hasRemote: () => hasRemote,
52
52
  hasUncommittedChanges: () => hasUncommittedChanges,
53
53
  hasUnpushedCommits: () => hasUnpushedCommits,
54
+ isBranchContentAlreadyInBase: () => isBranchContentAlreadyInBase,
54
55
  isBranchMergedInto: () => isBranchMergedInto,
55
56
  isGhAvailable: () => isGhAvailable,
56
57
  isGitAvailable: () => isGitAvailable,
@@ -808,6 +809,32 @@ function isBranchMergedInto(cwd, branch, baseBranch) {
808
809
  return false;
809
810
  }
810
811
  }
812
+ function isBranchContentAlreadyInBase(cwd, branch, baseBranch) {
813
+ const resolveCommit = (ref) => {
814
+ try {
815
+ return execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], { cwd, encoding: "utf-8" }).trim();
816
+ } catch {
817
+ return null;
818
+ }
819
+ };
820
+ const branchRef = resolveCommit(branch) ? branch : resolveCommit(`origin/${branch}`) ? `origin/${branch}` : null;
821
+ if (!branchRef) return false;
822
+ try {
823
+ const out = execFileSync("git", ["merge-tree", "--write-tree", baseBranch, branchRef], {
824
+ cwd,
825
+ encoding: "utf-8"
826
+ }).trim();
827
+ const mergedTree = out.split("\n")[0]?.trim();
828
+ if (!mergedTree) return false;
829
+ const baseTree = execFileSync("git", ["rev-parse", `${baseBranch}^{tree}`], {
830
+ cwd,
831
+ encoding: "utf-8"
832
+ }).trim();
833
+ return mergedTree === baseTree;
834
+ } catch {
835
+ return false;
836
+ }
837
+ }
811
838
  function pickModuleCycleBranch(candidates, cycleNumber, module) {
812
839
  if (candidates.length === 0) return void 0;
813
840
  const expected = cycleBranchName(cycleNumber, module);
@@ -4639,13 +4666,35 @@ import {
4639
4666
 
4640
4667
  // src/config.ts
4641
4668
  import path from "path";
4669
+
4670
+ // src/lib/help-text.ts
4671
+ var PAPI_DISCORD = "https://discord.gg/papi";
4672
+ var PAPI_DOCS = "https://getpapi.ai/docs/install";
4673
+ var HELP_FOOTER = `
4674
+ Need a hand?
4675
+ \u2022 Make sure you're in your project's root folder (where your code lives), then say "run setup".
4676
+ \u2022 Diagnose your setup: npx @papi-ai/server doctor
4677
+ \u2022 Hit a PAPI bug or have an idea? Say "report a bug to PAPI" \u2014 it goes straight to the
4678
+ PAPI team (not your project backlog), with diagnostics attached.
4679
+ \u2022 Get help: ${PAPI_DISCORD} \u2022 Docs: ${PAPI_DOCS}
4680
+ `;
4681
+ var HELP_FOOTER_MD = `
4682
+
4683
+ ## Need a hand?
4684
+ - Make sure you're in your project's **root folder** (where your code lives), then say "run setup".
4685
+ - Diagnose your setup: \`npx @papi-ai/server doctor\`
4686
+ - Hit a PAPI bug or have an idea? Say **"report a bug to PAPI"** \u2014 it goes straight to the PAPI team (not your project backlog), with diagnostics attached.
4687
+ - Get help: [Discord](${PAPI_DISCORD}) \xB7 [Docs](${PAPI_DOCS})
4688
+ `;
4689
+
4690
+ // src/config.ts
4642
4691
  function loadConfig() {
4643
4692
  const projectArgIdx = process.argv.indexOf("--project");
4644
4693
  const configuredRoot = projectArgIdx !== -1 ? process.argv[projectArgIdx + 1] : process.env.PAPI_PROJECT_DIR;
4645
4694
  const projectRoot = configuredRoot ?? process.cwd();
4646
4695
  if (!configuredRoot) {
4647
4696
  process.stderr.write(
4648
- '\nPAPI is running but no project is configured.\nSay "run setup" to get started.\n\n'
4697
+ "\nPAPI is running, but no project is configured here.\n" + HELP_FOOTER + "\n"
4649
4698
  );
4650
4699
  }
4651
4700
  const anthropicApiKey = process.env.PAPI_API_KEY ?? "";
@@ -4693,7 +4742,7 @@ If you intentionally self-host, set PAPI_SELF_HOST=1 in your environment to bypa
4693
4742
  }
4694
4743
  if (databaseUrl && !papiEndpoint && !explicitAdapter && !selfHostOptIn && !dataApiKey) {
4695
4744
  throw new Error(
4696
- 'DATABASE_URL is set but PAPI_ADAPTER is not specified.\n\nIf you want to self-host PAPI against your own database, add to your .mcp.json env:\n "PAPI_ADAPTER": "pg"\n\nIf you are an external user with a PAPI account, remove DATABASE_URL from your config.\nExternal users authenticate via PAPI_DATA_API_KEY \u2014 no DATABASE_URL needed.\n\nInstall guide: https://getpapi.ai/docs/install'
4745
+ 'DATABASE_URL is set but PAPI_ADAPTER is not specified.\n\nIf you want to self-host PAPI against your own database, add to your .mcp.json env:\n "PAPI_ADAPTER": "pg"\n\nIf you are an external user with a PAPI account, remove DATABASE_URL from your config.\nExternal users authenticate via PAPI_DATA_API_KEY \u2014 no DATABASE_URL needed.\n' + HELP_FOOTER
4697
4746
  );
4698
4747
  }
4699
4748
  let adapterType = papiEndpoint ? "pg" : databaseUrl && (explicitAdapter === "pg" || selfHostOptIn) ? "pg" : dataEndpoint ? "proxy" : explicitAdapter ? explicitAdapter : "proxy";
@@ -4712,7 +4761,8 @@ and unlocks dashboard features when you're ready.
4712
4761
  After signing up, add this to your .mcp.json env config:
4713
4762
  "PAPI_USER_ID": "your-email@example.com"
4714
4763
 
4715
- Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env config.`
4764
+ Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env config.
4765
+ ` + HELP_FOOTER
4716
4766
  );
4717
4767
  }
4718
4768
  return {
@@ -7064,6 +7114,9 @@ ${footer}`);
7064
7114
  await this.write("PLANNING_LOG.md", updated);
7065
7115
  }
7066
7116
  };
7117
+ function newTaskJoinKey(task, index) {
7118
+ return task.tempId ?? `new-${index + 1}`;
7119
+ }
7067
7120
  var NONE_PATTERN2 = /^none\b/i;
7068
7121
  function normalizeText2(text) {
7069
7122
  return text.trim().toLowerCase().replace(/[.,;:!]+$/, "").replace(/\s+/g, " ");
@@ -7958,6 +8011,39 @@ function formatDerivedMetrics(snapshots, backlogTasks) {
7958
8011
  }
7959
8012
  return lines.join("\n");
7960
8013
  }
8014
+ function formatLearningPatterns(patterns) {
8015
+ if (!patterns || patterns.length === 0) return void 0;
8016
+ const ranked = [...patterns].sort(
8017
+ (a, b2) => b2.cycles.length - a.cycles.length || b2.frequency - a.frequency
8018
+ );
8019
+ const strong = ranked.filter((p) => p.cycles.length >= 3);
8020
+ const shown = (strong.length > 0 ? strong : ranked).slice(0, 12);
8021
+ if (shown.length === 0) return void 0;
8022
+ const lines = shown.map(
8023
+ (p) => `- \`${p.tag}\` \u2014 recurred in ${p.cycles.length} cycles (${p.frequency}\xD7), most recent C${Math.max(...p.cycles)}`
8024
+ );
8025
+ return lines.join("\n");
8026
+ }
8027
+ function formatDecisionScorePatterns(patterns) {
8028
+ if (!patterns || patterns.length === 0) return void 0;
8029
+ const HIGH_RISK = 15;
8030
+ const moved = (p) => p.previousScore != null && p.previousScore !== p.totalScore;
8031
+ const relevant = patterns.filter((p) => p.totalScore > HIGH_RISK || moved(p));
8032
+ if (relevant.length === 0) return void 0;
8033
+ const ranked = [...relevant].sort(
8034
+ (a, b2) => b2.totalScore - a.totalScore || Math.abs((b2.previousScore ?? b2.totalScore) - b2.totalScore) - Math.abs((a.previousScore ?? a.totalScore) - a.totalScore)
8035
+ );
8036
+ const lines = ranked.slice(0, 10).map((p) => {
8037
+ let line = `- ${p.decisionId}: ${p.title} [${p.confidence}] \u2014 score ${p.totalScore}/25`;
8038
+ if (p.previousScore != null && p.previousScore !== p.totalScore) {
8039
+ const arrow = p.totalScore > p.previousScore ? "\u2191 riskier" : "\u2193 safer";
8040
+ line += ` (${arrow} from ${p.previousScore}/25${p.previousCycle != null ? ` at C${p.previousCycle}` : ""})`;
8041
+ }
8042
+ if (p.totalScore > HIGH_RISK) line += " \u26A0 high-risk";
8043
+ return line;
8044
+ });
8045
+ return lines.join("\n");
8046
+ }
7961
8047
  function formatBuildPatterns(patterns) {
7962
8048
  const sections = [];
7963
8049
  if (patterns.recurringSurprises.length > 0) {
@@ -8230,7 +8316,7 @@ After your natural language output, include this EXACT format on its own line:
8230
8316
  "boardHealth": "string \u2014 e.g. 5 tasks (3 backlog, 2 done)",
8231
8317
  "strategicDirection": "string \u2014 one sentence about current phase/direction",
8232
8318
  "recommendedTaskId": "string or null \u2014 task ID to set In Progress",
8233
- "cycleHandoffs": [{"taskId": "string \u2014 existing task ID or new-N for new tasks", "buildHandoff": "string \u2014 full BUILD HANDOFF text"}],
8319
+ "cycleHandoffs": [{"taskId": "string \u2014 an existing task ID, or the EXACT tempId of a newTasks entry (e.g. "new-1") it provides the handoff for", "buildHandoff": "string \u2014 full BUILD HANDOFF text"}],
8234
8320
  "newTasks": [],
8235
8321
  "boardCorrections": [],
8236
8322
  "productBrief": null,
@@ -8275,7 +8361,7 @@ Everything in Part 1 (natural language) is **display-only**. Part 2 (structured
8275
8361
  **Example with populated fields (DO NOT copy literally \u2014 adapt to your actual analysis):**
8276
8362
  \`\`\`json
8277
8363
  {
8278
- "newTasks": [{"title": "Example task", "status": "Backlog", "priority": "P2 Medium", "complexity": "Small", "module": "Core", "epic": "Platform", "phase": "Phase 1", "owner": "TBD", "notes": "Created during triage"}],
8364
+ "newTasks": [{"tempId": "new-1", "title": "Example task", "status": "Backlog", "priority": "P2 Medium", "complexity": "Small", "module": "Core", "epic": "Platform", "phase": "Phase 1", "owner": "TBD", "notes": "Created during triage"}],
8279
8365
  "activeDecisions": [{"id": "AD-3", "body": "### AD-3: Use REST over GraphQL [Confidence: HIGH]\\n\\n- **Decision:** REST API for v1.\\n- **Evidence:** Simpler for current scope."}],
8280
8366
  "boardCorrections": [{"taskId": "task-005", "updates": {"priority": "P1 High", "reviewed": true}}, {"taskId": "task-009", "updates": {"status": "Cancelled", "closureReason": "Duplicates task-003"}}],
8281
8367
  "cycleHandoffs": [{"taskId": "task-002", "buildHandoff": "BUILD HANDOFF \u2014 task-002\\nTask: ..."}]
@@ -8313,7 +8399,7 @@ This is Cycle 0 \u2014 the first planning cycle for a brand-new project.
8313
8399
 
8314
8400
  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.
8315
8401
 
8316
- 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. Use \`new-N\` IDs to reference them (matching the \`newTasks\` array). The builder needs handoffs to run \`build_execute\` \u2014 without them, tasks must be completed via \`ad_hoc\`, which breaks the normal flow.
8402
+ 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.
8317
8403
 
8318
8404
  ### Structured output for Bootstrap:
8319
8405
  In the JSON block, you MUST include:
@@ -8484,7 +8570,8 @@ Standard planning cycle with full board review.
8484
8570
  - **P1 High** \u2014 Strategically aligned: directly advances the current horizon, phase, or Active Decision goals.
8485
8571
  - **P2 Medium** \u2014 Valuable but not strategically urgent: quality improvements, efficiency, polish, infra.
8486
8572
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
8487
- Within the same priority level, prefer tasks with the highest **impact-to-effort ratio**. Impact is measured by: (a) strategic alignment \u2014 does it advance the current horizon/phase? (b) unlocks other work \u2014 are tasks blocked by this? (c) user-facing \u2014 does it change what users see? (d) compounds over time \u2014 does it make future cycles faster? A high-impact Medium task beats a low-impact Small task at the same priority level. Justify in 2-3 sentences.
8573
+ Within the same priority level, prefer tasks with the highest **impact-to-effort ratio**. Impact is measured by: (a) strategic alignment \u2014 does it advance the current horizon/phase? (b) unlocks other work \u2014 are tasks blocked by this? (c) user-facing \u2014 does it change what users see? (d) compounds over time \u2014 does it make future cycles faster? A high-impact Medium task beats a low-impact Small task at the same priority level. **Capacity-fit (smaller effort) breaks ties between comparable-impact tasks \u2014 it does NOT outrank impact.** Do NOT let a task's smaller size be the deciding factor that repeatedly defers a higher-impact larger task; that is the exact failure mode this step guards against. Justify in 2-3 sentences.
8574
+ **\u26A0\uFE0F Large-task inclusion guarantee (task-2227, structural rule \u2014 not advisory):** Impact weighting alone has historically lost to capacity-fit \u2014 the planner repeatedly filled cycles with XS/S work and deferred high-impact L/XL P1s (9 P1s stalled 6-21 cycles as of C299; C297 named this the "sequencing inversion"). Enforce a HARD rule: **every cycle MUST contain at least one Large or XL strategically-aligned P1 task, UNLESS no unblocked L/XL P1 exists on the board.** Before finalising the selection, scan the candidate pool: if the selected cycle contains zero L/XL tasks AND \u22651 unblocked L/XL P1 exists in the backlog, you MUST force-include the highest-impact such task \u2014 displacing the lowest-impact small task if the budget requires it (respect the single-theme vs mixed-theme budget from the Cycle-sizing rule below rather than always adding on top of an already-full cycle). Record the outcome in a "Large-task guarantee:" line in \`cycleLogNotes\`: name the task the guarantee pulled in, OR "satisfied \u2014 cycle already contains an L/XL task", OR "no unblocked L/XL P1 on the board". Do NOT skip this because the small tasks look individually attractive \u2014 that attractiveness IS the bias.
8488
8575
  **Blocked tasks:** Tasks with status "Blocked" MUST be skipped during task selection \u2014 they are waiting on external dependencies or gates and cannot be built. Do NOT generate BUILD HANDOFFs for blocked tasks. Do NOT recommend blocked tasks. If a blocked task's gate has been resolved (check the notes and recent build reports), emit a \`boardCorrections\` entry to move it back to Backlog. Report blocked task count in the cycle log.
8489
8576
  **Cycle sizing:** Size the cycle based on what the selected tasks actually require \u2014 not a fixed budget. Select the highest-priority unblocked tasks, estimate each one's effort from its scope, and let the total emerge. The historical average effort from Methodology Trends is a reference point for calibration, not a target or floor. A healthy cycle has 6-10 tasks. Cycles with fewer than 5 tasks require explicit justification in the cycle log \u2014 explain why more tasks could not be included. When the backlog has 10+ tasks, the cycle SHOULD have 6+ tasks \u2014 undersized cycles waste planning overhead relative to the available work. If fewer than 5 tasks qualify after filtering (blocked, deferred, raw), check Deferred tasks \u2014 some may be ready to un-defer via a \`boardCorrections\` entry. A 1-task cycle is almost never correct. Prefer grouping tasks by module or similarity \u2014 reduces context switching and enables shared branches during the build phase.
8490
8577
  **Theme-driven sizing:** Single-theme cycles (all tasks in the same module or epic) can absorb 25-30 effort points because builders maintain context across tasks. Mixed-theme cycles should stay at 15-20 effort points to limit context switching. Use the theme to determine the budget, not a fixed number.
@@ -8520,7 +8607,7 @@ ${AD_REJECTION_RULES}
8520
8607
  **Reference docs:** If a task's notes include a \`Reference:\` path (e.g. \`Reference: docs/architecture/papi-brain-v1.md\`), include a REFERENCE DOCS section in the BUILD HANDOFF with those paths. This tells the builder to read the referenced doc for background context before implementing. Do NOT omit or summarise the reference \u2014 pass it through so the builder can access the full document. Only tasks with explicit \`Reference:\` paths in their notes should have this section.
8521
8608
  **Full notes lookup:** Notes in the Board section are truncated to 300 chars for concise task selection. When generating a BUILD HANDOFF for a task, check the "Full Notes for Candidate Tasks" section (if present in context) for that task's complete untruncated notes before writing SCOPE, SCOPE BOUNDARY, and PRE-MORTEM. Submitter context, constraints, and reasoning often live past the 300-char cutoff and must not be dropped from the handoff.
8522
8609
  **Pre-build verification:** EVERY handoff MUST include a PRE-BUILD VERIFICATION section listing 2-5 specific file paths the builder should read before implementing. Derive these from FILES LIKELY TOUCHED \u2014 pick the files most likely to already contain the target functionality. This is the #1 prevention mechanism for wasted build slots (C120, C125, C126 all scheduled already-shipped work). If the builder finds >80% of the scope already implemented, they report "already built" instead of re-implementing.
8523
- **Pre-mortem:** For projects with 10+ cycles, include a PRE-MORTEM section in every BUILD HANDOFF with 1-3 bullet points: (a) most likely technical blocker based on module history, (b) integration risk with adjacent systems, (c) scope creep signal \u2014 what the builder might be tempted to expand beyond scope. Draw from \`dead_ends\` and \`surprises\` in recent build reports for the same module. Omit this section entirely for projects with fewer than 10 cycles.
8610
+ **Pre-mortem:** For projects with 10+ cycles, include a PRE-MORTEM section in every BUILD HANDOFF with 1-3 bullet points: (a) most likely technical blocker based on module history, (b) integration risk with adjacent systems, (c) scope creep signal \u2014 what the builder might be tempted to expand beyond scope. Draw from \`dead_ends\` and \`surprises\` in recent build reports for the same module. **If a "Recurring Learning Patterns (cross-cycle)" section is provided in the context, treat a module or theme that recurs across many cycles as a strong signal \u2014 size such tasks up (lean toward the larger estimate) and name the recurring failure mode explicitly in the pre-mortem.** Omit this section entirely for projects with fewer than 10 cycles.
8524
8611
  **Intra-cycle dependency detection:** After selecting cycle tasks, check every pair for build-order dependencies. Two tasks A and B have an intra-cycle dependency when A must be built before B because B consumes an artifact A creates \u2014 e.g. A adds a new adapter method that B calls, A creates a DB migration B depends on, A introduces a new shared type B imports, A refactors a utility B modifies. Signals: same module + adjacent scope (one is "add X", another is "use X"), or notes explicitly reference the other task. For each dependency detected: (a) populate the DEPENDS ON section in the dependent task's BUILD HANDOFF with the upstream task ID(s); (b) add a \`boardCorrections\` entry for the dependent task with \`updates.dependsOn\` set to the comma-separated upstream IDs \u2014 this persists the dependency so the builder's runtime can reuse the upstream branch; (c) keep SCOPE sections independent but note the ordering in "Why now". Do NOT invent dependencies where tasks merely share a module \u2014 only real build-order coupling counts. Linear chains only \u2014 no multi-level graph resolution. When in doubt, omit.
8525
8612
  **Dependency Chain section (Part 1 markdown):** When intra-cycle dependencies are detected, include a visible **## Dependency Chain** section in Part 1 markdown immediately before the first BUILD HANDOFF block. List each dependency as an arrow chain with a brief reason: \`task-A \u2192 task-B (B calls the adapter method A creates)\`. Then show the full recommended build sequence for all cycle tasks, including standalone tasks: e.g. \`Build order: task-A \u2192 task-B; task-C standalone; task-D standalone\`. Flag circular dependencies with \u26A0\uFE0F and a note. Omit this section entirely when no intra-cycle dependencies exist \u2014 do not include an empty section.
8526
8613
  **Build order in cycle log:** If intra-cycle dependencies were detected, include a "Build order:" line in \`cycleLogNotes\` showing the recommended sequence as arrow chains (e.g. "Build order: task-123 \u2192 task-124; task-130 standalone"). Skip when no dependencies exist.
@@ -8544,7 +8631,7 @@ ${AD_REJECTION_RULES}
8544
8631
  - **Architecture Notes:** If a pattern was established that needs follow-up (e.g. "shared service layer created, MCP migration needed"), propose the follow-up.
8545
8632
  - **Strategy gaps:** If an Active Decision has no board tasks supporting it, propose one.
8546
8633
  - **Dogfood observations:** Unactioned dogfood entries span FOUR categories (friction, methodology, signal, commercial) \u2014 consider ALL of them, not just friction. If an entry (with ID) maps to no existing task, propose one, matching task type to category: friction/signal \u2192 fix or improvement; methodology \u2192 process/tooling change; commercial \u2192 GTM/positioning. **CRITICAL: Include \`dogfood:<uuid>\` in the new task's \`notes\` field** (e.g. \`"notes": "dogfood:abc12345-..."\`). This links the task to the observation so the pipeline can track what was actioned. Without this annotation, the observation stays unactioned forever.
8547
- Create new tasks via the \`newTasks\` array in Part 2. Use \`new-N\` IDs in \`cycleHandoffs\` to reference them. **New-task cap \u2014 scale it to backlog depth, do NOT hard-cap at 3:** when 5+ unblocked backlog candidates already exist, limit new tasks to 3 (there is plenty to select from, so prevent backlog bloat). When the backlog is thin (fewer than 5 unblocked candidates), create as many as needed to bring the cycle into the healthy 5-8 range \u2014 roughly \`6 \u2212 unblocked_candidates\` additional new tasks, derived from the brief, roadmap phases, and recent build reports \u2014 up to an absolute ceiling of **8 new tasks**. This removes the contradiction with the Cycle-sizing rule above (a healthy cycle is 6-10 tasks; <5 needs justification), which the old flat cap of 3 violated whenever the backlog was thin. Never invent filler to hit a number: every new task must trace to a real discovered issue, dogfood entry, roadmap phase, or brief item.
8634
+ Create new tasks via the \`newTasks\` array in Part 2. Give each a unique \`tempId\` (\`"new-1"\`, \`"new-2"\`, \u2026) and set the matching \`cycleHandoffs\` \`taskId\` to that exact tempId (the apply step joins on tempId \u2014 array order is NOT used). **New-task cap \u2014 scale it to backlog depth, do NOT hard-cap at 3:** when 5+ unblocked backlog candidates already exist, limit new tasks to 3 (there is plenty to select from, so prevent backlog bloat). When the backlog is thin (fewer than 5 unblocked candidates), create as many as needed to bring the cycle into the healthy 5-8 range \u2014 roughly \`6 \u2212 unblocked_candidates\` additional new tasks, derived from the brief, roadmap phases, and recent build reports \u2014 up to an absolute ceiling of **8 new tasks**. This removes the contradiction with the Cycle-sizing rule above (a healthy cycle is 6-10 tasks; <5 needs justification), which the old flat cap of 3 violated whenever the backlog was thin. Never invent filler to hit a number: every new task must trace to a real discovered issue, dogfood entry, roadmap phase, or brief item.
8548
8635
  **\u26A0\uFE0F DUPLICATE CHECK:** Before adding a task to \`newTasks\`, scan the Cycle Board above for any existing task with the same or very similar title/scope. If a matching task already exists (even with slightly different wording), do NOT create a duplicate \u2014 reference the existing task ID instead. The board already contains all active tasks; re-creating them wastes IDs and bloats the board.
8549
8636
  **\u26A0\uFE0F ALREADY-BUILT CHECK:** Before creating a task, check the recent build reports and cycle log for evidence that this capability was already shipped. If a recent build report shows this feature was completed (even under a different task name), do NOT create a new task for it. This is especially important for UI features, data models, and integrations that may already exist.`);
8550
8637
  parts.push(PLAN_FRAGMENT_PRODUCT_BRIEF);
@@ -8664,6 +8751,26 @@ function buildPlanUserMessage(ctx) {
8664
8751
  if (ctx.buildPatterns) {
8665
8752
  parts.push("### Build Patterns", "", ctx.buildPatterns, "");
8666
8753
  }
8754
+ if (ctx.learningPatterns) {
8755
+ parts.push(
8756
+ "### Recurring Learning Patterns (cross-cycle)",
8757
+ "",
8758
+ "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.",
8759
+ "",
8760
+ ctx.learningPatterns,
8761
+ ""
8762
+ );
8763
+ }
8764
+ if (ctx.decisionScorePatterns) {
8765
+ parts.push(
8766
+ "### Decision Risk Scores (high-risk + movement)",
8767
+ "",
8768
+ "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.",
8769
+ "",
8770
+ ctx.decisionScorePatterns,
8771
+ ""
8772
+ );
8773
+ }
8667
8774
  if (ctx.reviewPatterns) {
8668
8775
  parts.push("### Review Patterns", "", ctx.reviewPatterns, "");
8669
8776
  }
@@ -8800,6 +8907,8 @@ function coerceStructuredOutput(parsed) {
8800
8907
  buildHandoff: coerceToString(h.buildHandoff)
8801
8908
  })) : [];
8802
8909
  const newTasks = Array.isArray(parsed.newTasks) ? parsed.newTasks.map((t) => ({
8910
+ // task-2242: stable join key (optional — undefined falls back to index).
8911
+ tempId: t.tempId !== void 0 && t.tempId !== null ? coerceToString(t.tempId) : void 0,
8803
8912
  title: coerceToString(t.title),
8804
8913
  status: coerceToString(t.status),
8805
8914
  priority: coerceToString(t.priority),
@@ -9128,6 +9237,16 @@ function buildReviewUserMessage(ctx) {
9128
9237
  if (ctx.decisionUsage) {
9129
9238
  parts.push("### Decision Usage (Reference Frequency)", "", ctx.decisionUsage, "");
9130
9239
  }
9240
+ if (ctx.decisionScoreTrajectory) {
9241
+ parts.push(
9242
+ "### Decision Risk Trajectory (score movement)",
9243
+ "",
9244
+ "Decision risk scores (/25, lower = safer) that are high-risk (>15) or have moved since they were last scored. Call out which decisions got riskier (\u2191) and whether their confidence still holds; consider re-scoring or revisiting a decision whose risk is rising.",
9245
+ "",
9246
+ ctx.decisionScoreTrajectory,
9247
+ ""
9248
+ );
9249
+ }
9131
9250
  if (ctx.recommendationEffectiveness) {
9132
9251
  parts.push("### Recommendation Follow-Through", "", ctx.recommendationEffectiveness, "");
9133
9252
  }
@@ -10266,6 +10385,8 @@ function applyContextTier(ctx, cycleCount) {
10266
10385
  ctx.discoveryCanvas = void 0;
10267
10386
  ctx.estimationCalibration = void 0;
10268
10387
  ctx.buildPatterns = void 0;
10388
+ ctx.learningPatterns = void 0;
10389
+ ctx.decisionScorePatterns = void 0;
10269
10390
  ctx.reviewPatterns = void 0;
10270
10391
  ctx.horizonContext = void 0;
10271
10392
  ctx.registeredDocs = void 0;
@@ -10768,7 +10889,9 @@ async function assembleContext(adapter2, mode, _config, filters, focus) {
10768
10889
  preAssignedResult,
10769
10890
  reportsForCapsResult,
10770
10891
  contextHashesResult,
10771
- doneTasksResult
10892
+ doneTasksResult,
10893
+ learningPatternsResult,
10894
+ decisionScoresResult
10772
10895
  ] = await Promise.allSettled([
10773
10896
  detectReviewPatterns(reviews2, health.totalCycles, 5),
10774
10897
  adapter2.getPendingRecommendations(),
@@ -10783,7 +10906,11 @@ async function assembleContext(adapter2, mode, _config, filters, focus) {
10783
10906
  adapter2.queryBoard({ status: ["Backlog", "In Cycle", "Ready"], compact: true }),
10784
10907
  Promise.resolve(leanBuildReports.slice(0, 10)),
10785
10908
  adapter2.getContextHashes?.(health.totalCycles) ?? Promise.resolve(null),
10786
- adapter2.queryBoard({ status: ["Done", "Cancelled"], compact: true })
10909
+ adapter2.queryBoard({ status: ["Done", "Cancelled"], compact: true }),
10910
+ // task-2241: cross-cycle recurring learning patterns (the compounding-intelligence loop).
10911
+ adapter2.getCycleLearningPatterns?.() ?? Promise.resolve([]),
10912
+ // task-2264: per-decision risk scores + movement (second compounding-intelligence signal).
10913
+ adapter2.getDecisionScorePatterns?.() ?? Promise.resolve([])
10787
10914
  ]);
10788
10915
  timings["parallelReads"] = t();
10789
10916
  let reviewPatternsText2;
@@ -10813,6 +10940,8 @@ async function assembleContext(adapter2, mode, _config, filters, focus) {
10813
10940
  estimationCalibrationText = (estimationCalibrationText ?? "") + moduleLines;
10814
10941
  }
10815
10942
  }
10943
+ const learningPatternsText = learningPatternsResult.status === "fulfilled" ? formatLearningPatterns(learningPatternsResult.value) : void 0;
10944
+ const decisionScorePatternsText = decisionScoresResult.status === "fulfilled" ? formatDecisionScorePatterns(decisionScoresResult.value) : void 0;
10816
10945
  let registeredDocsText;
10817
10946
  let unactionedDocsTextLean;
10818
10947
  if (docsResult.status === "fulfilled" && docsResult.value && docsResult.value.length > 0) {
@@ -10873,6 +11002,8 @@ ${lines.join("\n")}`;
10873
11002
  taskComments: taskCommentsText,
10874
11003
  discoveryCanvas: discoveryCanvasText,
10875
11004
  estimationCalibration: estimationCalibrationText,
11005
+ learningPatterns: learningPatternsText,
11006
+ decisionScorePatterns: decisionScorePatternsText,
10876
11007
  registeredDocs: registeredDocsText,
10877
11008
  unactionedDocs: unactionedDocsTextLean,
10878
11009
  focus,
@@ -10924,7 +11055,9 @@ ${lines.join("\n")}`;
10924
11055
  taskCommentsResultFull,
10925
11056
  docsResultFull,
10926
11057
  contextHashesResultFull,
10927
- doneTasksResultFull
11058
+ doneTasksResultFull,
11059
+ learningPatternsResultFull,
11060
+ decisionScoresResultFull
10928
11061
  ] = await Promise.allSettled([
10929
11062
  Promise.resolve(allBuildReports),
10930
11063
  detectReviewPatterns(reviews, health.totalCycles, 5),
@@ -10933,7 +11066,11 @@ ${lines.join("\n")}`;
10933
11066
  assembleTaskComments(adapter2),
10934
11067
  adapter2.searchDocs?.({ status: "active", limit: 15 }),
10935
11068
  adapter2.getContextHashes?.(health.totalCycles) ?? Promise.resolve(null),
10936
- adapter2.queryBoard({ status: ["Done", "Cancelled"], compact: true })
11069
+ adapter2.queryBoard({ status: ["Done", "Cancelled"], compact: true }),
11070
+ // task-2241: cross-cycle recurring learning patterns (the compounding-intelligence loop).
11071
+ adapter2.getCycleLearningPatterns?.() ?? Promise.resolve([]),
11072
+ // task-2264: per-decision risk scores + movement (second compounding-intelligence signal).
11073
+ adapter2.getDecisionScorePatterns?.() ?? Promise.resolve([])
10937
11074
  ]);
10938
11075
  timings["parallelAdvisory"] = t();
10939
11076
  let buildPatternsText;
@@ -10946,6 +11083,8 @@ ${lines.join("\n")}`;
10946
11083
  } catch {
10947
11084
  }
10948
11085
  }
11086
+ const learningPatternsTextFull = learningPatternsResultFull.status === "fulfilled" ? formatLearningPatterns(learningPatternsResultFull.value) : void 0;
11087
+ const decisionScorePatternsTextFull = decisionScoresResultFull.status === "fulfilled" ? formatDecisionScorePatterns(decisionScoresResultFull.value) : void 0;
10949
11088
  let reviewPatternsText;
10950
11089
  if (reviewPatternsResultFull.status === "fulfilled") {
10951
11090
  reviewPatternsText = hasReviewPatterns(reviewPatternsResultFull.value) ? formatReviewPatterns(reviewPatternsResultFull.value) : void 0;
@@ -11049,6 +11188,8 @@ ${logLines}`);
11049
11188
  methodologyMetrics: formatCycleMetrics(metricsSnapshots),
11050
11189
  recentReviews: formatReviews(reviews),
11051
11190
  buildPatterns: buildPatternsText,
11191
+ learningPatterns: learningPatternsTextFull,
11192
+ decisionScorePatterns: decisionScorePatternsTextFull,
11052
11193
  reviewPatterns: reviewPatternsText,
11053
11194
  horizonContext: horizonCtx,
11054
11195
  strategyRecommendations: strategyRecommendationsText,
@@ -11180,6 +11321,8 @@ ${cleanContent}`;
11180
11321
  strategicDirection: data.strategicDirection
11181
11322
  },
11182
11323
  newTasks: dedupedNewTasks.map((t) => ({
11324
+ tempId: t.tempId,
11325
+ // task-2242: stable handoff join key
11183
11326
  title: t.title,
11184
11327
  status: t.status || "Backlog",
11185
11328
  priority: t.priority || "P1 High",
@@ -11329,7 +11472,7 @@ ${cleanContent}`;
11329
11472
  visibility,
11330
11473
  ownerUserId: inheritedVis.ownerUserId
11331
11474
  });
11332
- newTaskIdMap.set(`new-${i + 1}`, created.id);
11475
+ newTaskIdMap.set(newTaskJoinKey(task, i), created.id);
11333
11476
  if (adapter2.updateCycleLearningActionRef && task.notes) {
11334
11477
  const learningRefs = task.notes.match(/learning:([a-f0-9-]+)/gi);
11335
11478
  if (learningRefs) {
@@ -11488,6 +11631,13 @@ ${cleanContent}`;
11488
11631
  }
11489
11632
  for (const handoff of data.cycleHandoffs) {
11490
11633
  const resolvedId = newTaskIdMap.get(handoff.taskId) ?? handoff.taskId;
11634
+ if (resolvedId === handoff.taskId && /^new-\d+$/i.test(handoff.taskId)) {
11635
+ const titleLine = handoff.buildHandoff.match(/^Task:\s*(.+)/m)?.[1] ?? "(unknown)";
11636
+ console.error(
11637
+ `[plan] UNRESOLVED handoff reference "${handoff.taskId}" (task: ${titleLine.slice(0, 80)}) \u2014 no newTask carries this tempId/index. Handoff NOT written. Ensure each newTasks entry has a tempId matching its cycleHandoffs taskId (task-2242).`
11638
+ );
11639
+ continue;
11640
+ }
11491
11641
  try {
11492
11642
  if (existingHandoffSet.has(resolvedId)) {
11493
11643
  console.error(`[plan] skipping handoff write for ${resolvedId} \u2014 already has one`);
@@ -12016,6 +12166,7 @@ function defaultHint(tool) {
12016
12166
  "plan",
12017
12167
  "strategy_review",
12018
12168
  "orient",
12169
+ "papi",
12019
12170
  "build_execute",
12020
12171
  "review_list",
12021
12172
  "review_submit",
@@ -13444,7 +13595,8 @@ async function assembleContext2(adapter2, cycleNumber, cyclesSinceLastReview, pr
13444
13595
  recData,
13445
13596
  pendingRecs,
13446
13597
  registeredDocs,
13447
- docsWithPendingActions
13598
+ docsWithPendingActions,
13599
+ decisionScores
13448
13600
  ] = await Promise.all([
13449
13601
  adapter2.readProductBrief(),
13450
13602
  // Strategy review needs to see retired ADs to triage/restore them as needed.
@@ -13471,7 +13623,9 @@ async function assembleContext2(adapter2, cycleNumber, cyclesSinceLastReview, pr
13471
13623
  // Doc registry — summaries for strategy review context
13472
13624
  adapter2.searchDocs?.({ status: "active", limit: 10 })?.catch(() => []) ?? Promise.resolve([]),
13473
13625
  // Doc registry — docs with pending actions for staleness audit
13474
- adapter2.searchDocs?.({ hasPendingActions: true, limit: 20 })?.catch(() => []) ?? Promise.resolve([])
13626
+ adapter2.searchDocs?.({ hasPendingActions: true, limit: 20 })?.catch(() => []) ?? Promise.resolve([]),
13627
+ // task-2264: decision risk scores + movement, so the review can show trajectory.
13628
+ adapter2.getDecisionScorePatterns?.()?.catch(() => []) ?? Promise.resolve([])
13475
13629
  ]);
13476
13630
  const pendingAgendaTopics = await (adapter2.getPendingAgendaTopics?.().catch(() => []) ?? Promise.resolve([]));
13477
13631
  const tasks = [...activeTasks, ...recentDoneTasks];
@@ -13752,6 +13906,7 @@ ${lines.join("\n")}`;
13752
13906
  previousReviews: previousReviewsText,
13753
13907
  phases: phasesText,
13754
13908
  decisionUsage: decisionUsageText,
13909
+ decisionScoreTrajectory: formatDecisionScorePatterns(decisionScores),
13755
13910
  northStar: currentNorthStar ?? void 0,
13756
13911
  recommendationEffectiveness: recEffectivenessText,
13757
13912
  adHocCommits: adHocCommitsText,
@@ -17541,12 +17696,60 @@ ${existing}`;
17541
17696
  }
17542
17697
  await writeFile3(changelogPath, body, "utf-8");
17543
17698
  }
17544
- function mergeGroupedCycleBranches(config2, cycleNum, baseBranch) {
17699
+ async function allCancelledBranches(adapter2, cycleNum) {
17700
+ const skip = /* @__PURE__ */ new Set();
17701
+ try {
17702
+ const ALL_STATUSES = [
17703
+ "Backlog",
17704
+ "In Cycle",
17705
+ "Ready",
17706
+ "In Progress",
17707
+ "In Review",
17708
+ "Done",
17709
+ "Blocked",
17710
+ "Cancelled",
17711
+ "Deferred"
17712
+ ];
17713
+ const tasks = await adapter2.queryBoard({ cycleSince: cycleNum, status: ALL_STATUSES, compact: true });
17714
+ const byModule = /* @__PURE__ */ new Map();
17715
+ for (const t of tasks) {
17716
+ if (t.cycle !== cycleNum) continue;
17717
+ const moduleName = (t.module ?? "").trim();
17718
+ if (!moduleName) continue;
17719
+ const entry = byModule.get(moduleName) ?? { total: 0, cancelled: 0 };
17720
+ entry.total++;
17721
+ if (t.status === "Cancelled") entry.cancelled++;
17722
+ byModule.set(moduleName, entry);
17723
+ }
17724
+ for (const [moduleName, entry] of byModule) {
17725
+ if (entry.total > 0 && entry.cancelled === entry.total) {
17726
+ skip.add(cycleBranchName(cycleNum, moduleName));
17727
+ }
17728
+ }
17729
+ } catch {
17730
+ }
17731
+ return skip;
17732
+ }
17733
+ function mergeGroupedCycleBranches(config2, cycleNum, baseBranch, skipBranches = /* @__PURE__ */ new Set()) {
17545
17734
  const branches = listGroupedCycleBranches(config2.projectRoot, cycleNum, baseBranch);
17546
17735
  if (branches.length === 0) return [];
17547
17736
  const ghAvailable = isGhAvailable();
17548
17737
  const results = [];
17549
17738
  for (const branch of branches) {
17739
+ if (skipBranches.has(branch)) {
17740
+ results.push({ branch, prUrl: null, status: "skipped", message: "all cycle tasks Cancelled \u2014 branch not merged (task-2253)" });
17741
+ continue;
17742
+ }
17743
+ if (isBranchContentAlreadyInBase(config2.projectRoot, branch, baseBranch)) {
17744
+ deleteLocalBranch(config2.projectRoot, branch);
17745
+ results.push({
17746
+ branch,
17747
+ prUrl: null,
17748
+ status: "skipped",
17749
+ message: `content already on '${baseBranch}' (tree-equal \u2014 squash-merged); lingering branch cleaned, no re-merge`
17750
+ });
17751
+ continue;
17752
+ }
17550
17753
  if (branchExists(config2.projectRoot, branch) && hasRemote(config2.projectRoot)) {
17551
17754
  const push = gitPush(config2.projectRoot, branch);
17552
17755
  if (!push.success) {
@@ -17769,7 +17972,8 @@ async function createRelease(config2, branch, version, adapter2, cycleNum, optio
17769
17972
  }
17770
17973
  let groupedBranchMerges;
17771
17974
  if (resolvedCycleNum > 0) {
17772
- const mergeResults = mergeGroupedCycleBranches(config2, resolvedCycleNum, branch);
17975
+ const skipBranches = adapter2 ? await allCancelledBranches(adapter2, resolvedCycleNum) : /* @__PURE__ */ new Set();
17976
+ const mergeResults = mergeGroupedCycleBranches(config2, resolvedCycleNum, branch, skipBranches);
17773
17977
  if (mergeResults.length > 0) {
17774
17978
  groupedBranchMerges = mergeResults;
17775
17979
  const failures = mergeResults.filter((r) => r.status === "failed");
@@ -18573,6 +18777,23 @@ If you intend to discard the work, run build_cancel instead.`
18573
18777
  err.gitStderr = scrubbed;
18574
18778
  return err;
18575
18779
  }
18780
+ var UI_FILE_RE = /^(app\/.+\.(tsx|css)|components\/.+\.tsx)$/;
18781
+ var NON_UI_RE = /(^app\/api\/|\.test\.|\.spec\.|\/__tests__\/)/;
18782
+ function inferRouteFromPage(file) {
18783
+ const m = file.match(/^app\/(.*)\/page\.tsx$/);
18784
+ if (!m) return null;
18785
+ const segments = m[1].split("/").filter((s) => s.length > 0 && !(s.startsWith("(") && s.endsWith(")")));
18786
+ return "/" + segments.join("/");
18787
+ }
18788
+ function computeLocalPreview(filesChanged, provided) {
18789
+ const uiFiles = (filesChanged ?? []).filter((f) => UI_FILE_RE.test(f) && !NON_UI_RE.test(f));
18790
+ const hasProvided = !!(provided && (provided.urls?.length || provided.screenshotPath || provided.notes));
18791
+ if (uiFiles.length === 0 && !hasProvided) return void 0;
18792
+ const routes = Array.from(
18793
+ new Set(uiFiles.map(inferRouteFromPage).filter((r) => r !== null))
18794
+ );
18795
+ return { uiFiles, routes, provided: hasProvided ? provided : void 0 };
18796
+ }
18576
18797
  var AMBIENT_DRIFT_PATTERNS = [
18577
18798
  /^\.next\//,
18578
18799
  /^\.next$/,
@@ -19249,6 +19470,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
19249
19470
  const drift = computeScopeDriftSignal(task.buildHandoff?.filesLikelyTouched, driftChanged);
19250
19471
  if (drift) report.scopeDriftSignal = drift;
19251
19472
  }
19473
+ const localPreview = computeLocalPreview(report.filesChanged, input.preview);
19252
19474
  let prLines = [];
19253
19475
  if (options.light) {
19254
19476
  prLines.push("Light mode: skipping push and PR creation.");
@@ -19351,7 +19573,8 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
19351
19573
  autoTriagedCount: autoTriagedCount > 0 ? autoTriagedCount : void 0,
19352
19574
  autoTriagedIds: autoTriagedIds.length > 0 ? autoTriagedIds : void 0,
19353
19575
  autoTriagedDupes: autoTriagedDupes.length > 0 ? autoTriagedDupes : void 0,
19354
- reportWriteVerified
19576
+ reportWriteVerified,
19577
+ localPreview
19355
19578
  };
19356
19579
  }
19357
19580
  async function cancelBuild(adapter2, taskId, reason) {
@@ -19571,6 +19794,15 @@ var buildExecuteTool = {
19571
19794
  verified_at: { type: "string", description: "ISO 8601 timestamp." }
19572
19795
  },
19573
19796
  required: ["urls", "curl_command", "http_status", "response_excerpt", "verified_at"]
19797
+ },
19798
+ preview: {
19799
+ type: "object",
19800
+ description: `Optional. For a build that touched USER-FACING UI: tell the owner how to SEE the result locally so reviewing it doesn't mean reading code or asking "show me". urls = the localhost route(s) to open (e.g. ["http://localhost:3000/hub"]); notes = what to look at; screenshot_path = a path to an image you captured. Surfaced verbatim in the completion output. When the diff touched UI and you omit this, the output nudges you to provide it.`,
19801
+ properties: {
19802
+ urls: { type: "array", items: { type: "string" }, description: "Localhost route(s) to open to view the work." },
19803
+ notes: { type: "string", description: "What the owner should look at / verify." },
19804
+ screenshot_path: { type: "string", description: "Path to a captured screenshot of the rendered result." }
19805
+ }
19574
19806
  }
19575
19807
  },
19576
19808
  required: ["task_id"]
@@ -19848,6 +20080,12 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
19848
20080
  const deadEnds = args.dead_ends;
19849
20081
  const rawBriefImplications = args.brief_implications;
19850
20082
  const resolvesLearnings = Array.isArray(args.resolves_learnings) ? args.resolves_learnings : void 0;
20083
+ const rawPreview = args.preview;
20084
+ const preview = rawPreview ? {
20085
+ urls: Array.isArray(rawPreview.urls) ? rawPreview.urls.filter((u) => typeof u === "string") : void 0,
20086
+ notes: typeof rawPreview.notes === "string" ? rawPreview.notes : void 0,
20087
+ screenshotPath: typeof rawPreview.screenshot_path === "string" ? rawPreview.screenshot_path : void 0
20088
+ } : void 0;
19851
20089
  const rawProductionVerification = args.production_verification;
19852
20090
  const productionVerification = rawProductionVerification ? {
19853
20091
  urls: Array.isArray(rawProductionVerification.urls) ? rawProductionVerification.urls.filter((u) => typeof u === "string") : [],
@@ -19904,7 +20142,8 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
19904
20142
  type: bi.type ?? "new",
19905
20143
  detail: bi.detail ?? ""
19906
20144
  })),
19907
- productionVerification
20145
+ productionVerification,
20146
+ preview
19908
20147
  }, { light });
19909
20148
  tracker.mark("complete_format");
19910
20149
  if (resolvesLearnings && resolvesLearnings.length > 0 && adapter2.updateCycleLearningActionRef) {
@@ -19961,6 +20200,26 @@ function formatCompleteResult(result) {
19961
20200
  if (result.cycleProgress.total > 0) {
19962
20201
  lines.push("", `Cycle progress: ${result.cycleProgress.completed} of ${result.cycleProgress.total} cycle tasks complete.`);
19963
20202
  }
20203
+ if (result.localPreview) {
20204
+ const { routes, provided, uiFiles } = result.localPreview;
20205
+ const previewLines = ["", "### \u{1F441} Preview the work"];
20206
+ if (provided?.urls?.length) {
20207
+ previewLines.push(`Open: ${provided.urls.join(" \xB7 ")}`);
20208
+ } else if (routes.length > 0) {
20209
+ const urls = routes.map((r) => `http://localhost:3000${r}`);
20210
+ previewLines.push(`Run \`npm run dev\` and open: ${urls.join(" \xB7 ")}`);
20211
+ } else {
20212
+ previewLines.push(
20213
+ `This build changed UI (${uiFiles.slice(0, 4).join(", ")}${uiFiles.length > 4 ? `, +${uiFiles.length - 4} more` : ""}). Run \`npm run dev\` and open the page that renders it.`
20214
+ );
20215
+ }
20216
+ if (provided?.screenshotPath) previewLines.push(`Screenshot: ${provided.screenshotPath}`);
20217
+ if (provided?.notes) previewLines.push(`Look at: ${provided.notes}`);
20218
+ if (!provided) {
20219
+ previewLines.push("_Tip: pass `preview` (the route you viewed / a screenshot) on complete so the result travels with the build._");
20220
+ }
20221
+ lines.push(...previewLines);
20222
+ }
19964
20223
  if (result.phaseChanges.length > 0) {
19965
20224
  for (const c of result.phaseChanges) {
19966
20225
  lines.push(`Phase auto-updated: ${c.phaseId} ${c.oldStatus} \u2192 ${c.newStatus}`);
@@ -20239,6 +20498,20 @@ async function captureBug(adapter2, input) {
20239
20498
  }
20240
20499
 
20241
20500
  // src/tools/bug.ts
20501
+ var PAPI_SIGNAL_PATTERNS = [
20502
+ /\bpapi\b/i,
20503
+ /\bmcp\b/i,
20504
+ // Distinctive PAPI MCP tool names (whole-word) — these don't appear in an
20505
+ // everyday project-domain bug report.
20506
+ /\b(orient|build_execute|build_list|build_describe|build_cancel|review_submit|review_list|strategy_review|strategy_change|board_view|board_edit|board_deprioritise|board_reconcile|handoff_generate|doc_register|doc_search|ad_hoc|inventory_sync|scope_brief)\b/i,
20507
+ /\bbuild handoff\b/i,
20508
+ /\bremote connector\b/i
20509
+ ];
20510
+ function looksLikePapiBug(text, notes) {
20511
+ const haystack = `${text}
20512
+ ${notes ?? ""}`;
20513
+ return PAPI_SIGNAL_PATTERNS.some((re) => re.test(haystack));
20514
+ }
20242
20515
  function collectDiagnostics(config2) {
20243
20516
  return {
20244
20517
  nodeVersion: process.version,
@@ -20252,7 +20525,7 @@ function collectDiagnostics(config2) {
20252
20525
  }
20253
20526
  var bugTool = {
20254
20527
  name: "bug",
20255
- description: "Report a bug OR submit an idea to PAPI. Two modes: (1) Default \u2014 creates a Backlog task with severity-based priority for the project board. (2) With report=true \u2014 submits a bug or idea (set `type`) upstream to PAPI maintainers with diagnostics, optionally recording notify-when-fixed and contact-ok consent. Use report=true at a friction moment \u2014 e.g. when a PAPI tool returns a workflow-blocking error. Does not call the Anthropic API.",
20528
+ description: 'Report a bug OR submit an idea. Routing: a bug about PAPI itself (a PAPI tool/MCP error, the connector, a handoff/cycle problem) auto-submits UPSTREAM to PAPI maintainers with diagnostics \u2014 you do NOT need to set report=true for these. A bug in the user\'s OWN project auto-files as a Backlog task on their board. Override the routing explicitly: report=true forces upstream, report=false forces the user\'s own board. Set `type` ("bug"/"idea") and optional notify-when-fixed / contact-ok consent for upstream submissions. Does not call the Anthropic API.',
20256
20529
  annotations: { readOnlyHint: false, destructiveHint: false },
20257
20530
  inputSchema: {
20258
20531
  type: "object",
@@ -20284,7 +20557,7 @@ var bugTool = {
20284
20557
  },
20285
20558
  report: {
20286
20559
  type: "boolean",
20287
- description: "When true, submits a diagnostic bug report with system info instead of creating a board task. Use this to report issues to PAPI maintainers."
20560
+ description: "Routing override. Leave UNSET to auto-route: PAPI-product bugs go upstream to maintainers, project-domain bugs go to the user's board. Set true to force an upstream diagnostic submission; set false to force a task on the user's own board (use false when an auto-detected PAPI bug is actually about the user's project)."
20288
20561
  },
20289
20562
  type: {
20290
20563
  type: "string",
@@ -20312,8 +20585,18 @@ async function handleBug(adapter2, config2, args) {
20312
20585
  if (!text) {
20313
20586
  return errorResponse("text is required \u2014 describe the bug you want to report.");
20314
20587
  }
20315
- if (args.report === true) {
20588
+ const notesArg = args.notes;
20589
+ const explicitReport = typeof args.report === "boolean" ? args.report : void 0;
20590
+ const papiBug = looksLikePapiBug(text, notesArg);
20591
+ const autoRoutedUpstream = explicitReport === void 0 && papiBug;
20592
+ const shouldReportUpstream = explicitReport === true || autoRoutedUpstream;
20593
+ if (shouldReportUpstream) {
20316
20594
  if (!adapter2.submitBugReport) {
20595
+ if (autoRoutedUpstream) {
20596
+ return errorResponse(
20597
+ `This looks like a PAPI bug, which belongs upstream with the PAPI maintainers \u2014 but upstream reporting needs a database adapter (pg or proxy) and this session uses the md adapter, which can't submit it. Two options: (1) switch to the hosted/pg setup to report it upstream, or (2) re-run with report=false to file it on your own project board instead.`
20598
+ );
20599
+ }
20317
20600
  return errorResponse("Bug reports require a database adapter (pg or proxy). The md adapter does not support bug reports.");
20318
20601
  }
20319
20602
  const type = args.type === "idea" ? "idea" : "bug";
@@ -20348,13 +20631,16 @@ async function handleBug(adapter2, config2, args) {
20348
20631
  const followUpLine = followUps.length ? `
20349
20632
 
20350
20633
  Noted: ${followUps.join("; ")}.` : "";
20634
+ const autoRouteLine = autoRoutedUpstream ? `
20635
+
20636
+ _Detected this as a PAPI bug and sent it upstream rather than adding it to your own board. If it's actually about your project, re-run with \`report=false\` to file it on your board instead._` : "";
20351
20637
  return textResponse(
20352
20638
  `**${kindLabel} submitted** \u2014 ID: \`${report.id}\`
20353
20639
 
20354
20640
  ${type === "idea" ? "Idea" : "Description"}: ${text}
20355
20641
  Diagnostics collected: Node ${diagnostics.nodeVersion}, ${diagnostics.platform}/${diagnostics.arch}, adapter=${diagnostics.adapterType}
20356
20642
 
20357
- This ${type} is visible to PAPI maintainers.${followUpLine}`
20643
+ This ${type} is visible to PAPI maintainers.${followUpLine}${autoRouteLine}`
20358
20644
  );
20359
20645
  }
20360
20646
  let target = adapter2;
@@ -22745,7 +23031,7 @@ ${lines.join("\n")}`;
22745
23031
 
22746
23032
  // src/lib/version-handshake.ts
22747
23033
  var MIN_PROXY_VERSION = "1.0.0";
22748
- var SERVER_VERSION = "0.7.35";
23034
+ var SERVER_VERSION = "0.7.36";
22749
23035
  function meetsMinVersion(actual, required) {
22750
23036
  const actualParts = actual.split(".").map((n) => parseInt(n, 10));
22751
23037
  const requiredParts = required.split(".").map((n) => parseInt(n, 10));
@@ -23564,6 +23850,11 @@ var orientTool = {
23564
23850
  required: []
23565
23851
  }
23566
23852
  };
23853
+ var papiTool = {
23854
+ ...orientTool,
23855
+ name: "papi",
23856
+ description: 'Say "papi" to check in with Papi \u2014 an alias for `orient`. Run this FIRST at session start: it returns your cycle number, task counts, in-progress/in-review work, strategy-review cadence, a velocity snapshot, and the recommended next action. Identical to `orient` (same inputs, same output); use whichever name you prefer. Read-only.'
23857
+ };
23567
23858
  function countStalledP1(warnings) {
23568
23859
  const stallLine = warnings.find((w) => w.includes("P1 tasks stalled"));
23569
23860
  if (!stallLine) return 0;
@@ -26093,6 +26384,8 @@ var ABUSE_CEILINGS = {
26093
26384
  var DEFAULT_CEILING = ABUSE_CEILINGS.free;
26094
26385
  var ALWAYS_FREE_TOOLS = /* @__PURE__ */ new Set([
26095
26386
  "orient",
26387
+ "papi",
26388
+ // alias for orient (task-2223) — must share its free treatment
26096
26389
  "board_view",
26097
26390
  "build_list",
26098
26391
  "build_describe",
@@ -26242,6 +26535,7 @@ var TOOLS_REQUIRING_PAPI = /* @__PURE__ */ new Set([
26242
26535
  "review_list",
26243
26536
  "review_submit",
26244
26537
  "orient",
26538
+ "papi",
26245
26539
  "hierarchy_update",
26246
26540
  "zoom_out",
26247
26541
  "handoff_generate",
@@ -26273,6 +26567,7 @@ var PAPI_TOOLS = [
26273
26567
  reviewClaimTool,
26274
26568
  initTool,
26275
26569
  orientTool,
26570
+ papiTool,
26276
26571
  hierarchyUpdateTool,
26277
26572
  zoomOutTool,
26278
26573
  docRegisterTool,
@@ -26447,6 +26742,9 @@ function createServer(adapter2, config2) {
26447
26742
  return handleInit(config2, safeArgs);
26448
26743
  case "orient":
26449
26744
  return handleOrient(adapter2, config2, safeArgs);
26745
+ case "papi":
26746
+ return handleOrient(adapter2, config2, safeArgs);
26747
+ // alias (task-2223)
26450
26748
  case "hierarchy_update":
26451
26749
  return handleHierarchyUpdate(adapter2, safeArgs);
26452
26750
  case "zoom_out":
@@ -26518,7 +26816,7 @@ function createServer(adapter2, config2) {
26518
26816
  }
26519
26817
  throw err;
26520
26818
  }
26521
- if (name === "orient" && adapter2.getMeteredUsage && !result.content.some((c) => c.text.startsWith("Error:"))) {
26819
+ if ((name === "orient" || name === "papi") && adapter2.getMeteredUsage && !result.content.some((c) => c.text.startsWith("Error:"))) {
26522
26820
  try {
26523
26821
  const decision = await checkMeter(adapter2, "plan", config2.projectId ?? "session");
26524
26822
  if (decision.usage) {
@@ -26588,6 +26886,8 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
26588
26886
  var BEARER_PREFIX = "papi_";
26589
26887
  var BEARER_REGEX = /^(papi_|papi_oauth_)[a-f0-9]{64}$/;
26590
26888
  var RESOURCE_METADATA_URL = process.env["PAPI_RESOURCE_METADATA_URL"] ?? "https://getpapi.ai/.well-known/oauth-protected-resource";
26889
+ var DASHBOARD_ORIGIN = process.env["PAPI_DASHBOARD_URL"] ?? "https://getpapi.ai";
26890
+ var MCP_RESOURCE_URL = process.env["NEXT_PUBLIC_MCP_URL"] ?? "https://mcp.getpapi.ai";
26591
26891
  var FRIENDLY_GET_HTML = `<!doctype html>
26592
26892
  <html lang="en"><head><meta charset="utf-8">
26593
26893
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -26715,6 +27015,31 @@ function startHttpTransport(opts) {
26715
27015
  res.end("ok");
26716
27016
  return;
26717
27017
  }
27018
+ if (req.method === "GET" && req.url === "/.well-known/oauth-protected-resource") {
27019
+ res.writeHead(200, {
27020
+ "Content-Type": "application/json",
27021
+ "Cache-Control": "public, max-age=3600",
27022
+ ...cors
27023
+ });
27024
+ res.end(
27025
+ JSON.stringify({
27026
+ resource: MCP_RESOURCE_URL,
27027
+ authorization_servers: [DASHBOARD_ORIGIN],
27028
+ scopes_supported: ["mcp:full"],
27029
+ bearer_methods_supported: ["header"]
27030
+ })
27031
+ );
27032
+ return;
27033
+ }
27034
+ if (req.method === "GET" && req.url === "/.well-known/oauth-authorization-server") {
27035
+ res.writeHead(302, {
27036
+ Location: `${DASHBOARD_ORIGIN}/.well-known/oauth-authorization-server`,
27037
+ "Cache-Control": "public, max-age=3600",
27038
+ ...cors
27039
+ });
27040
+ res.end();
27041
+ return;
27042
+ }
26718
27043
  if (req.url !== "/mcp" && req.url !== "/sse") {
26719
27044
  sendError(res, { status: 404, body: { error: "Not found" } }, cors);
26720
27045
  return;
@@ -27108,7 +27433,7 @@ If you haven't set up PAPI yet:
27108
27433
  2. Complete the onboarding wizard \u2014 it generates your config
27109
27434
  3. Copy the config to your project and restart your AI tool
27110
27435
 
27111
- If you already have an account, check that both **PAPI_PROJECT_ID** and **PAPI_DATA_API_KEY** are set in your .mcp.json env config.`
27436
+ If you already have an account, check that both **PAPI_PROJECT_ID** and **PAPI_DATA_API_KEY** are set in your .mcp.json env config.` + HELP_FOOTER_MD
27112
27437
  }]
27113
27438
  }));
27114
27439
  }
package/dist/prompts.js CHANGED
@@ -89,7 +89,7 @@ After your natural language output, include this EXACT format on its own line:
89
89
  "boardHealth": "string \u2014 e.g. 5 tasks (3 backlog, 2 done)",
90
90
  "strategicDirection": "string \u2014 one sentence about current phase/direction",
91
91
  "recommendedTaskId": "string or null \u2014 task ID to set In Progress",
92
- "cycleHandoffs": [{"taskId": "string \u2014 existing task ID or new-N for new tasks", "buildHandoff": "string \u2014 full BUILD HANDOFF text"}],
92
+ "cycleHandoffs": [{"taskId": "string \u2014 an existing task ID, or the EXACT tempId of a newTasks entry (e.g. "new-1") it provides the handoff for", "buildHandoff": "string \u2014 full BUILD HANDOFF text"}],
93
93
  "newTasks": [],
94
94
  "boardCorrections": [],
95
95
  "productBrief": null,
@@ -134,7 +134,7 @@ Everything in Part 1 (natural language) is **display-only**. Part 2 (structured
134
134
  **Example with populated fields (DO NOT copy literally \u2014 adapt to your actual analysis):**
135
135
  \`\`\`json
136
136
  {
137
- "newTasks": [{"title": "Example task", "status": "Backlog", "priority": "P2 Medium", "complexity": "Small", "module": "Core", "epic": "Platform", "phase": "Phase 1", "owner": "TBD", "notes": "Created during triage"}],
137
+ "newTasks": [{"tempId": "new-1", "title": "Example task", "status": "Backlog", "priority": "P2 Medium", "complexity": "Small", "module": "Core", "epic": "Platform", "phase": "Phase 1", "owner": "TBD", "notes": "Created during triage"}],
138
138
  "activeDecisions": [{"id": "AD-3", "body": "### AD-3: Use REST over GraphQL [Confidence: HIGH]\\n\\n- **Decision:** REST API for v1.\\n- **Evidence:** Simpler for current scope."}],
139
139
  "boardCorrections": [{"taskId": "task-005", "updates": {"priority": "P1 High", "reviewed": true}}, {"taskId": "task-009", "updates": {"status": "Cancelled", "closureReason": "Duplicates task-003"}}],
140
140
  "cycleHandoffs": [{"taskId": "task-002", "buildHandoff": "BUILD HANDOFF \u2014 task-002\\nTask: ..."}]
@@ -172,7 +172,7 @@ This is Cycle 0 \u2014 the first planning cycle for a brand-new project.
172
172
 
173
173
  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
174
 
175
- 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. Use \`new-N\` IDs to reference them (matching the \`newTasks\` array). The builder needs handoffs to run \`build_execute\` \u2014 without them, tasks must be completed via \`ad_hoc\`, which breaks the normal flow.
175
+ 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
176
 
177
177
  ### Structured output for Bootstrap:
178
178
  In the JSON block, you MUST include:
@@ -343,7 +343,8 @@ Standard planning cycle with full board review.
343
343
  - **P1 High** \u2014 Strategically aligned: directly advances the current horizon, phase, or Active Decision goals.
344
344
  - **P2 Medium** \u2014 Valuable but not strategically urgent: quality improvements, efficiency, polish, infra.
345
345
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
346
- Within the same priority level, prefer tasks with the highest **impact-to-effort ratio**. Impact is measured by: (a) strategic alignment \u2014 does it advance the current horizon/phase? (b) unlocks other work \u2014 are tasks blocked by this? (c) user-facing \u2014 does it change what users see? (d) compounds over time \u2014 does it make future cycles faster? A high-impact Medium task beats a low-impact Small task at the same priority level. Justify in 2-3 sentences.
346
+ Within the same priority level, prefer tasks with the highest **impact-to-effort ratio**. Impact is measured by: (a) strategic alignment \u2014 does it advance the current horizon/phase? (b) unlocks other work \u2014 are tasks blocked by this? (c) user-facing \u2014 does it change what users see? (d) compounds over time \u2014 does it make future cycles faster? A high-impact Medium task beats a low-impact Small task at the same priority level. **Capacity-fit (smaller effort) breaks ties between comparable-impact tasks \u2014 it does NOT outrank impact.** Do NOT let a task's smaller size be the deciding factor that repeatedly defers a higher-impact larger task; that is the exact failure mode this step guards against. Justify in 2-3 sentences.
347
+ **\u26A0\uFE0F Large-task inclusion guarantee (task-2227, structural rule \u2014 not advisory):** Impact weighting alone has historically lost to capacity-fit \u2014 the planner repeatedly filled cycles with XS/S work and deferred high-impact L/XL P1s (9 P1s stalled 6-21 cycles as of C299; C297 named this the "sequencing inversion"). Enforce a HARD rule: **every cycle MUST contain at least one Large or XL strategically-aligned P1 task, UNLESS no unblocked L/XL P1 exists on the board.** Before finalising the selection, scan the candidate pool: if the selected cycle contains zero L/XL tasks AND \u22651 unblocked L/XL P1 exists in the backlog, you MUST force-include the highest-impact such task \u2014 displacing the lowest-impact small task if the budget requires it (respect the single-theme vs mixed-theme budget from the Cycle-sizing rule below rather than always adding on top of an already-full cycle). Record the outcome in a "Large-task guarantee:" line in \`cycleLogNotes\`: name the task the guarantee pulled in, OR "satisfied \u2014 cycle already contains an L/XL task", OR "no unblocked L/XL P1 on the board". Do NOT skip this because the small tasks look individually attractive \u2014 that attractiveness IS the bias.
347
348
  **Blocked tasks:** Tasks with status "Blocked" MUST be skipped during task selection \u2014 they are waiting on external dependencies or gates and cannot be built. Do NOT generate BUILD HANDOFFs for blocked tasks. Do NOT recommend blocked tasks. If a blocked task's gate has been resolved (check the notes and recent build reports), emit a \`boardCorrections\` entry to move it back to Backlog. Report blocked task count in the cycle log.
348
349
  **Cycle sizing:** Size the cycle based on what the selected tasks actually require \u2014 not a fixed budget. Select the highest-priority unblocked tasks, estimate each one's effort from its scope, and let the total emerge. The historical average effort from Methodology Trends is a reference point for calibration, not a target or floor. A healthy cycle has 6-10 tasks. Cycles with fewer than 5 tasks require explicit justification in the cycle log \u2014 explain why more tasks could not be included. When the backlog has 10+ tasks, the cycle SHOULD have 6+ tasks \u2014 undersized cycles waste planning overhead relative to the available work. If fewer than 5 tasks qualify after filtering (blocked, deferred, raw), check Deferred tasks \u2014 some may be ready to un-defer via a \`boardCorrections\` entry. A 1-task cycle is almost never correct. Prefer grouping tasks by module or similarity \u2014 reduces context switching and enables shared branches during the build phase.
349
350
  **Theme-driven sizing:** Single-theme cycles (all tasks in the same module or epic) can absorb 25-30 effort points because builders maintain context across tasks. Mixed-theme cycles should stay at 15-20 effort points to limit context switching. Use the theme to determine the budget, not a fixed number.
@@ -379,7 +380,7 @@ ${AD_REJECTION_RULES}
379
380
  **Reference docs:** If a task's notes include a \`Reference:\` path (e.g. \`Reference: docs/architecture/papi-brain-v1.md\`), include a REFERENCE DOCS section in the BUILD HANDOFF with those paths. This tells the builder to read the referenced doc for background context before implementing. Do NOT omit or summarise the reference \u2014 pass it through so the builder can access the full document. Only tasks with explicit \`Reference:\` paths in their notes should have this section.
380
381
  **Full notes lookup:** Notes in the Board section are truncated to 300 chars for concise task selection. When generating a BUILD HANDOFF for a task, check the "Full Notes for Candidate Tasks" section (if present in context) for that task's complete untruncated notes before writing SCOPE, SCOPE BOUNDARY, and PRE-MORTEM. Submitter context, constraints, and reasoning often live past the 300-char cutoff and must not be dropped from the handoff.
381
382
  **Pre-build verification:** EVERY handoff MUST include a PRE-BUILD VERIFICATION section listing 2-5 specific file paths the builder should read before implementing. Derive these from FILES LIKELY TOUCHED \u2014 pick the files most likely to already contain the target functionality. This is the #1 prevention mechanism for wasted build slots (C120, C125, C126 all scheduled already-shipped work). If the builder finds >80% of the scope already implemented, they report "already built" instead of re-implementing.
382
- **Pre-mortem:** For projects with 10+ cycles, include a PRE-MORTEM section in every BUILD HANDOFF with 1-3 bullet points: (a) most likely technical blocker based on module history, (b) integration risk with adjacent systems, (c) scope creep signal \u2014 what the builder might be tempted to expand beyond scope. Draw from \`dead_ends\` and \`surprises\` in recent build reports for the same module. Omit this section entirely for projects with fewer than 10 cycles.
383
+ **Pre-mortem:** For projects with 10+ cycles, include a PRE-MORTEM section in every BUILD HANDOFF with 1-3 bullet points: (a) most likely technical blocker based on module history, (b) integration risk with adjacent systems, (c) scope creep signal \u2014 what the builder might be tempted to expand beyond scope. Draw from \`dead_ends\` and \`surprises\` in recent build reports for the same module. **If a "Recurring Learning Patterns (cross-cycle)" section is provided in the context, treat a module or theme that recurs across many cycles as a strong signal \u2014 size such tasks up (lean toward the larger estimate) and name the recurring failure mode explicitly in the pre-mortem.** Omit this section entirely for projects with fewer than 10 cycles.
383
384
  **Intra-cycle dependency detection:** After selecting cycle tasks, check every pair for build-order dependencies. Two tasks A and B have an intra-cycle dependency when A must be built before B because B consumes an artifact A creates \u2014 e.g. A adds a new adapter method that B calls, A creates a DB migration B depends on, A introduces a new shared type B imports, A refactors a utility B modifies. Signals: same module + adjacent scope (one is "add X", another is "use X"), or notes explicitly reference the other task. For each dependency detected: (a) populate the DEPENDS ON section in the dependent task's BUILD HANDOFF with the upstream task ID(s); (b) add a \`boardCorrections\` entry for the dependent task with \`updates.dependsOn\` set to the comma-separated upstream IDs \u2014 this persists the dependency so the builder's runtime can reuse the upstream branch; (c) keep SCOPE sections independent but note the ordering in "Why now". Do NOT invent dependencies where tasks merely share a module \u2014 only real build-order coupling counts. Linear chains only \u2014 no multi-level graph resolution. When in doubt, omit.
384
385
  **Dependency Chain section (Part 1 markdown):** When intra-cycle dependencies are detected, include a visible **## Dependency Chain** section in Part 1 markdown immediately before the first BUILD HANDOFF block. List each dependency as an arrow chain with a brief reason: \`task-A \u2192 task-B (B calls the adapter method A creates)\`. Then show the full recommended build sequence for all cycle tasks, including standalone tasks: e.g. \`Build order: task-A \u2192 task-B; task-C standalone; task-D standalone\`. Flag circular dependencies with \u26A0\uFE0F and a note. Omit this section entirely when no intra-cycle dependencies exist \u2014 do not include an empty section.
385
386
  **Build order in cycle log:** If intra-cycle dependencies were detected, include a "Build order:" line in \`cycleLogNotes\` showing the recommended sequence as arrow chains (e.g. "Build order: task-123 \u2192 task-124; task-130 standalone"). Skip when no dependencies exist.
@@ -403,7 +404,7 @@ ${AD_REJECTION_RULES}
403
404
  - **Architecture Notes:** If a pattern was established that needs follow-up (e.g. "shared service layer created, MCP migration needed"), propose the follow-up.
404
405
  - **Strategy gaps:** If an Active Decision has no board tasks supporting it, propose one.
405
406
  - **Dogfood observations:** Unactioned dogfood entries span FOUR categories (friction, methodology, signal, commercial) \u2014 consider ALL of them, not just friction. If an entry (with ID) maps to no existing task, propose one, matching task type to category: friction/signal \u2192 fix or improvement; methodology \u2192 process/tooling change; commercial \u2192 GTM/positioning. **CRITICAL: Include \`dogfood:<uuid>\` in the new task's \`notes\` field** (e.g. \`"notes": "dogfood:abc12345-..."\`). This links the task to the observation so the pipeline can track what was actioned. Without this annotation, the observation stays unactioned forever.
406
- Create new tasks via the \`newTasks\` array in Part 2. Use \`new-N\` IDs in \`cycleHandoffs\` to reference them. **New-task cap \u2014 scale it to backlog depth, do NOT hard-cap at 3:** when 5+ unblocked backlog candidates already exist, limit new tasks to 3 (there is plenty to select from, so prevent backlog bloat). When the backlog is thin (fewer than 5 unblocked candidates), create as many as needed to bring the cycle into the healthy 5-8 range \u2014 roughly \`6 \u2212 unblocked_candidates\` additional new tasks, derived from the brief, roadmap phases, and recent build reports \u2014 up to an absolute ceiling of **8 new tasks**. This removes the contradiction with the Cycle-sizing rule above (a healthy cycle is 6-10 tasks; <5 needs justification), which the old flat cap of 3 violated whenever the backlog was thin. Never invent filler to hit a number: every new task must trace to a real discovered issue, dogfood entry, roadmap phase, or brief item.
407
+ Create new tasks via the \`newTasks\` array in Part 2. Give each a unique \`tempId\` (\`"new-1"\`, \`"new-2"\`, \u2026) and set the matching \`cycleHandoffs\` \`taskId\` to that exact tempId (the apply step joins on tempId \u2014 array order is NOT used). **New-task cap \u2014 scale it to backlog depth, do NOT hard-cap at 3:** when 5+ unblocked backlog candidates already exist, limit new tasks to 3 (there is plenty to select from, so prevent backlog bloat). When the backlog is thin (fewer than 5 unblocked candidates), create as many as needed to bring the cycle into the healthy 5-8 range \u2014 roughly \`6 \u2212 unblocked_candidates\` additional new tasks, derived from the brief, roadmap phases, and recent build reports \u2014 up to an absolute ceiling of **8 new tasks**. This removes the contradiction with the Cycle-sizing rule above (a healthy cycle is 6-10 tasks; <5 needs justification), which the old flat cap of 3 violated whenever the backlog was thin. Never invent filler to hit a number: every new task must trace to a real discovered issue, dogfood entry, roadmap phase, or brief item.
407
408
  **\u26A0\uFE0F DUPLICATE CHECK:** Before adding a task to \`newTasks\`, scan the Cycle Board above for any existing task with the same or very similar title/scope. If a matching task already exists (even with slightly different wording), do NOT create a duplicate \u2014 reference the existing task ID instead. The board already contains all active tasks; re-creating them wastes IDs and bloats the board.
408
409
  **\u26A0\uFE0F ALREADY-BUILT CHECK:** Before creating a task, check the recent build reports and cycle log for evidence that this capability was already shipped. If a recent build report shows this feature was completed (even under a different task name), do NOT create a new task for it. This is especially important for UI features, data models, and integrations that may already exist.`);
409
410
  parts.push(PLAN_FRAGMENT_PRODUCT_BRIEF);
@@ -523,6 +524,26 @@ function buildPlanUserMessage(ctx) {
523
524
  if (ctx.buildPatterns) {
524
525
  parts.push("### Build Patterns", "", ctx.buildPatterns, "");
525
526
  }
527
+ if (ctx.learningPatterns) {
528
+ parts.push(
529
+ "### Recurring Learning Patterns (cross-cycle)",
530
+ "",
531
+ "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.",
532
+ "",
533
+ ctx.learningPatterns,
534
+ ""
535
+ );
536
+ }
537
+ if (ctx.decisionScorePatterns) {
538
+ parts.push(
539
+ "### Decision Risk Scores (high-risk + movement)",
540
+ "",
541
+ "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.",
542
+ "",
543
+ ctx.decisionScorePatterns,
544
+ ""
545
+ );
546
+ }
526
547
  if (ctx.reviewPatterns) {
527
548
  parts.push("### Review Patterns", "", ctx.reviewPatterns, "");
528
549
  }
@@ -659,6 +680,8 @@ function coerceStructuredOutput(parsed) {
659
680
  buildHandoff: coerceToString(h.buildHandoff)
660
681
  })) : [];
661
682
  const newTasks = Array.isArray(parsed.newTasks) ? parsed.newTasks.map((t) => ({
683
+ // task-2242: stable join key (optional — undefined falls back to index).
684
+ tempId: t.tempId !== void 0 && t.tempId !== null ? coerceToString(t.tempId) : void 0,
662
685
  title: coerceToString(t.title),
663
686
  status: coerceToString(t.status),
664
687
  priority: coerceToString(t.priority),
@@ -987,6 +1010,16 @@ function buildReviewUserMessage(ctx) {
987
1010
  if (ctx.decisionUsage) {
988
1011
  parts.push("### Decision Usage (Reference Frequency)", "", ctx.decisionUsage, "");
989
1012
  }
1013
+ if (ctx.decisionScoreTrajectory) {
1014
+ parts.push(
1015
+ "### Decision Risk Trajectory (score movement)",
1016
+ "",
1017
+ "Decision risk scores (/25, lower = safer) that are high-risk (>15) or have moved since they were last scored. Call out which decisions got riskier (\u2191) and whether their confidence still holds; consider re-scoring or revisiting a decision whose risk is rising.",
1018
+ "",
1019
+ ctx.decisionScoreTrajectory,
1020
+ ""
1021
+ );
1022
+ }
990
1023
  if (ctx.recommendationEffectiveness) {
991
1024
  parts.push("### Recommendation Follow-Through", "", ctx.recommendationEffectiveness, "");
992
1025
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.35",
3
+ "version": "0.7.37",
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.cathalos92/papi",