@papi-ai/server 0.7.36 → 0.7.38

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);
@@ -7087,6 +7114,9 @@ ${footer}`);
7087
7114
  await this.write("PLANNING_LOG.md", updated);
7088
7115
  }
7089
7116
  };
7117
+ function newTaskJoinKey(task, index) {
7118
+ return task.tempId ?? `new-${index + 1}`;
7119
+ }
7090
7120
  var NONE_PATTERN2 = /^none\b/i;
7091
7121
  function normalizeText2(text) {
7092
7122
  return text.trim().toLowerCase().replace(/[.,;:!]+$/, "").replace(/\s+/g, " ");
@@ -7981,6 +8011,39 @@ function formatDerivedMetrics(snapshots, backlogTasks) {
7981
8011
  }
7982
8012
  return lines.join("\n");
7983
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
+ }
7984
8047
  function formatBuildPatterns(patterns) {
7985
8048
  const sections = [];
7986
8049
  if (patterns.recurringSurprises.length > 0) {
@@ -8253,7 +8316,7 @@ After your natural language output, include this EXACT format on its own line:
8253
8316
  "boardHealth": "string \u2014 e.g. 5 tasks (3 backlog, 2 done)",
8254
8317
  "strategicDirection": "string \u2014 one sentence about current phase/direction",
8255
8318
  "recommendedTaskId": "string or null \u2014 task ID to set In Progress",
8256
- "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"}],
8257
8320
  "newTasks": [],
8258
8321
  "boardCorrections": [],
8259
8322
  "productBrief": null,
@@ -8298,7 +8361,7 @@ Everything in Part 1 (natural language) is **display-only**. Part 2 (structured
8298
8361
  **Example with populated fields (DO NOT copy literally \u2014 adapt to your actual analysis):**
8299
8362
  \`\`\`json
8300
8363
  {
8301
- "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"}],
8302
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."}],
8303
8366
  "boardCorrections": [{"taskId": "task-005", "updates": {"priority": "P1 High", "reviewed": true}}, {"taskId": "task-009", "updates": {"status": "Cancelled", "closureReason": "Duplicates task-003"}}],
8304
8367
  "cycleHandoffs": [{"taskId": "task-002", "buildHandoff": "BUILD HANDOFF \u2014 task-002\\nTask: ..."}]
@@ -8336,7 +8399,7 @@ This is Cycle 0 \u2014 the first planning cycle for a brand-new project.
8336
8399
 
8337
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.
8338
8401
 
8339
- 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.
8340
8403
 
8341
8404
  ### Structured output for Bootstrap:
8342
8405
  In the JSON block, you MUST include:
@@ -8544,7 +8607,7 @@ ${AD_REJECTION_RULES}
8544
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.
8545
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.
8546
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.
8547
- **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.
8548
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.
8549
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.
8550
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.
@@ -8568,7 +8631,7 @@ ${AD_REJECTION_RULES}
8568
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.
8569
8632
  - **Strategy gaps:** If an Active Decision has no board tasks supporting it, propose one.
8570
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.
8571
- 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.
8572
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.
8573
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.`);
8574
8637
  parts.push(PLAN_FRAGMENT_PRODUCT_BRIEF);
@@ -8688,6 +8751,26 @@ function buildPlanUserMessage(ctx) {
8688
8751
  if (ctx.buildPatterns) {
8689
8752
  parts.push("### Build Patterns", "", ctx.buildPatterns, "");
8690
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
+ }
8691
8774
  if (ctx.reviewPatterns) {
8692
8775
  parts.push("### Review Patterns", "", ctx.reviewPatterns, "");
8693
8776
  }
@@ -8824,6 +8907,8 @@ function coerceStructuredOutput(parsed) {
8824
8907
  buildHandoff: coerceToString(h.buildHandoff)
8825
8908
  })) : [];
8826
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,
8827
8912
  title: coerceToString(t.title),
8828
8913
  status: coerceToString(t.status),
8829
8914
  priority: coerceToString(t.priority),
@@ -9152,6 +9237,16 @@ function buildReviewUserMessage(ctx) {
9152
9237
  if (ctx.decisionUsage) {
9153
9238
  parts.push("### Decision Usage (Reference Frequency)", "", ctx.decisionUsage, "");
9154
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
+ }
9155
9250
  if (ctx.recommendationEffectiveness) {
9156
9251
  parts.push("### Recommendation Follow-Through", "", ctx.recommendationEffectiveness, "");
9157
9252
  }
@@ -10290,6 +10385,8 @@ function applyContextTier(ctx, cycleCount) {
10290
10385
  ctx.discoveryCanvas = void 0;
10291
10386
  ctx.estimationCalibration = void 0;
10292
10387
  ctx.buildPatterns = void 0;
10388
+ ctx.learningPatterns = void 0;
10389
+ ctx.decisionScorePatterns = void 0;
10293
10390
  ctx.reviewPatterns = void 0;
10294
10391
  ctx.horizonContext = void 0;
10295
10392
  ctx.registeredDocs = void 0;
@@ -10792,7 +10889,9 @@ async function assembleContext(adapter2, mode, _config, filters, focus) {
10792
10889
  preAssignedResult,
10793
10890
  reportsForCapsResult,
10794
10891
  contextHashesResult,
10795
- doneTasksResult
10892
+ doneTasksResult,
10893
+ learningPatternsResult,
10894
+ decisionScoresResult
10796
10895
  ] = await Promise.allSettled([
10797
10896
  detectReviewPatterns(reviews2, health.totalCycles, 5),
10798
10897
  adapter2.getPendingRecommendations(),
@@ -10807,7 +10906,11 @@ async function assembleContext(adapter2, mode, _config, filters, focus) {
10807
10906
  adapter2.queryBoard({ status: ["Backlog", "In Cycle", "Ready"], compact: true }),
10808
10907
  Promise.resolve(leanBuildReports.slice(0, 10)),
10809
10908
  adapter2.getContextHashes?.(health.totalCycles) ?? Promise.resolve(null),
10810
- 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([])
10811
10914
  ]);
10812
10915
  timings["parallelReads"] = t();
10813
10916
  let reviewPatternsText2;
@@ -10837,6 +10940,8 @@ async function assembleContext(adapter2, mode, _config, filters, focus) {
10837
10940
  estimationCalibrationText = (estimationCalibrationText ?? "") + moduleLines;
10838
10941
  }
10839
10942
  }
10943
+ const learningPatternsText = learningPatternsResult.status === "fulfilled" ? formatLearningPatterns(learningPatternsResult.value) : void 0;
10944
+ const decisionScorePatternsText = decisionScoresResult.status === "fulfilled" ? formatDecisionScorePatterns(decisionScoresResult.value) : void 0;
10840
10945
  let registeredDocsText;
10841
10946
  let unactionedDocsTextLean;
10842
10947
  if (docsResult.status === "fulfilled" && docsResult.value && docsResult.value.length > 0) {
@@ -10897,6 +11002,8 @@ ${lines.join("\n")}`;
10897
11002
  taskComments: taskCommentsText,
10898
11003
  discoveryCanvas: discoveryCanvasText,
10899
11004
  estimationCalibration: estimationCalibrationText,
11005
+ learningPatterns: learningPatternsText,
11006
+ decisionScorePatterns: decisionScorePatternsText,
10900
11007
  registeredDocs: registeredDocsText,
10901
11008
  unactionedDocs: unactionedDocsTextLean,
10902
11009
  focus,
@@ -10948,7 +11055,9 @@ ${lines.join("\n")}`;
10948
11055
  taskCommentsResultFull,
10949
11056
  docsResultFull,
10950
11057
  contextHashesResultFull,
10951
- doneTasksResultFull
11058
+ doneTasksResultFull,
11059
+ learningPatternsResultFull,
11060
+ decisionScoresResultFull
10952
11061
  ] = await Promise.allSettled([
10953
11062
  Promise.resolve(allBuildReports),
10954
11063
  detectReviewPatterns(reviews, health.totalCycles, 5),
@@ -10957,7 +11066,11 @@ ${lines.join("\n")}`;
10957
11066
  assembleTaskComments(adapter2),
10958
11067
  adapter2.searchDocs?.({ status: "active", limit: 15 }),
10959
11068
  adapter2.getContextHashes?.(health.totalCycles) ?? Promise.resolve(null),
10960
- 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([])
10961
11074
  ]);
10962
11075
  timings["parallelAdvisory"] = t();
10963
11076
  let buildPatternsText;
@@ -10970,6 +11083,8 @@ ${lines.join("\n")}`;
10970
11083
  } catch {
10971
11084
  }
10972
11085
  }
11086
+ const learningPatternsTextFull = learningPatternsResultFull.status === "fulfilled" ? formatLearningPatterns(learningPatternsResultFull.value) : void 0;
11087
+ const decisionScorePatternsTextFull = decisionScoresResultFull.status === "fulfilled" ? formatDecisionScorePatterns(decisionScoresResultFull.value) : void 0;
10973
11088
  let reviewPatternsText;
10974
11089
  if (reviewPatternsResultFull.status === "fulfilled") {
10975
11090
  reviewPatternsText = hasReviewPatterns(reviewPatternsResultFull.value) ? formatReviewPatterns(reviewPatternsResultFull.value) : void 0;
@@ -11073,6 +11188,8 @@ ${logLines}`);
11073
11188
  methodologyMetrics: formatCycleMetrics(metricsSnapshots),
11074
11189
  recentReviews: formatReviews(reviews),
11075
11190
  buildPatterns: buildPatternsText,
11191
+ learningPatterns: learningPatternsTextFull,
11192
+ decisionScorePatterns: decisionScorePatternsTextFull,
11076
11193
  reviewPatterns: reviewPatternsText,
11077
11194
  horizonContext: horizonCtx,
11078
11195
  strategyRecommendations: strategyRecommendationsText,
@@ -11204,6 +11321,8 @@ ${cleanContent}`;
11204
11321
  strategicDirection: data.strategicDirection
11205
11322
  },
11206
11323
  newTasks: dedupedNewTasks.map((t) => ({
11324
+ tempId: t.tempId,
11325
+ // task-2242: stable handoff join key
11207
11326
  title: t.title,
11208
11327
  status: t.status || "Backlog",
11209
11328
  priority: t.priority || "P1 High",
@@ -11353,7 +11472,7 @@ ${cleanContent}`;
11353
11472
  visibility,
11354
11473
  ownerUserId: inheritedVis.ownerUserId
11355
11474
  });
11356
- newTaskIdMap.set(`new-${i + 1}`, created.id);
11475
+ newTaskIdMap.set(newTaskJoinKey(task, i), created.id);
11357
11476
  if (adapter2.updateCycleLearningActionRef && task.notes) {
11358
11477
  const learningRefs = task.notes.match(/learning:([a-f0-9-]+)/gi);
11359
11478
  if (learningRefs) {
@@ -11512,6 +11631,13 @@ ${cleanContent}`;
11512
11631
  }
11513
11632
  for (const handoff of data.cycleHandoffs) {
11514
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
+ }
11515
11641
  try {
11516
11642
  if (existingHandoffSet.has(resolvedId)) {
11517
11643
  console.error(`[plan] skipping handoff write for ${resolvedId} \u2014 already has one`);
@@ -13469,7 +13595,8 @@ async function assembleContext2(adapter2, cycleNumber, cyclesSinceLastReview, pr
13469
13595
  recData,
13470
13596
  pendingRecs,
13471
13597
  registeredDocs,
13472
- docsWithPendingActions
13598
+ docsWithPendingActions,
13599
+ decisionScores
13473
13600
  ] = await Promise.all([
13474
13601
  adapter2.readProductBrief(),
13475
13602
  // Strategy review needs to see retired ADs to triage/restore them as needed.
@@ -13496,7 +13623,9 @@ async function assembleContext2(adapter2, cycleNumber, cyclesSinceLastReview, pr
13496
13623
  // Doc registry — summaries for strategy review context
13497
13624
  adapter2.searchDocs?.({ status: "active", limit: 10 })?.catch(() => []) ?? Promise.resolve([]),
13498
13625
  // Doc registry — docs with pending actions for staleness audit
13499
- 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([])
13500
13629
  ]);
13501
13630
  const pendingAgendaTopics = await (adapter2.getPendingAgendaTopics?.().catch(() => []) ?? Promise.resolve([]));
13502
13631
  const tasks = [...activeTasks, ...recentDoneTasks];
@@ -13777,6 +13906,7 @@ ${lines.join("\n")}`;
13777
13906
  previousReviews: previousReviewsText,
13778
13907
  phases: phasesText,
13779
13908
  decisionUsage: decisionUsageText,
13909
+ decisionScoreTrajectory: formatDecisionScorePatterns(decisionScores),
13780
13910
  northStar: currentNorthStar ?? void 0,
13781
13911
  recommendationEffectiveness: recEffectivenessText,
13782
13912
  adHocCommits: adHocCommitsText,
@@ -16874,6 +17004,10 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
16874
17004
  priority: task.priority || "P2 Medium",
16875
17005
  complexity: task.complexity || "Small",
16876
17006
  module: task.module || "Core",
17007
+ // epic is NOT NULL in cycle_tasks; default starter tasks to the
17008
+ // 'Platform' epic (matches the idea tool) rather than leaving it
17009
+ // unset (SUP-2026-020 — omitting it tripped a 23502 on every seed).
17010
+ epic: task.epic || "Platform",
16877
17011
  phase: task.phase || "Phase 1",
16878
17012
  owner: "TBD",
16879
17013
  reviewed: false,
@@ -17566,12 +17700,60 @@ ${existing}`;
17566
17700
  }
17567
17701
  await writeFile3(changelogPath, body, "utf-8");
17568
17702
  }
17569
- function mergeGroupedCycleBranches(config2, cycleNum, baseBranch) {
17703
+ async function allCancelledBranches(adapter2, cycleNum) {
17704
+ const skip = /* @__PURE__ */ new Set();
17705
+ try {
17706
+ const ALL_STATUSES = [
17707
+ "Backlog",
17708
+ "In Cycle",
17709
+ "Ready",
17710
+ "In Progress",
17711
+ "In Review",
17712
+ "Done",
17713
+ "Blocked",
17714
+ "Cancelled",
17715
+ "Deferred"
17716
+ ];
17717
+ const tasks = await adapter2.queryBoard({ cycleSince: cycleNum, status: ALL_STATUSES, compact: true });
17718
+ const byModule = /* @__PURE__ */ new Map();
17719
+ for (const t of tasks) {
17720
+ if (t.cycle !== cycleNum) continue;
17721
+ const moduleName = (t.module ?? "").trim();
17722
+ if (!moduleName) continue;
17723
+ const entry = byModule.get(moduleName) ?? { total: 0, cancelled: 0 };
17724
+ entry.total++;
17725
+ if (t.status === "Cancelled") entry.cancelled++;
17726
+ byModule.set(moduleName, entry);
17727
+ }
17728
+ for (const [moduleName, entry] of byModule) {
17729
+ if (entry.total > 0 && entry.cancelled === entry.total) {
17730
+ skip.add(cycleBranchName(cycleNum, moduleName));
17731
+ }
17732
+ }
17733
+ } catch {
17734
+ }
17735
+ return skip;
17736
+ }
17737
+ function mergeGroupedCycleBranches(config2, cycleNum, baseBranch, skipBranches = /* @__PURE__ */ new Set()) {
17570
17738
  const branches = listGroupedCycleBranches(config2.projectRoot, cycleNum, baseBranch);
17571
17739
  if (branches.length === 0) return [];
17572
17740
  const ghAvailable = isGhAvailable();
17573
17741
  const results = [];
17574
17742
  for (const branch of branches) {
17743
+ if (skipBranches.has(branch)) {
17744
+ results.push({ branch, prUrl: null, status: "skipped", message: "all cycle tasks Cancelled \u2014 branch not merged (task-2253)" });
17745
+ continue;
17746
+ }
17747
+ if (isBranchContentAlreadyInBase(config2.projectRoot, branch, baseBranch)) {
17748
+ deleteLocalBranch(config2.projectRoot, branch);
17749
+ results.push({
17750
+ branch,
17751
+ prUrl: null,
17752
+ status: "skipped",
17753
+ message: `content already on '${baseBranch}' (tree-equal \u2014 squash-merged); lingering branch cleaned, no re-merge`
17754
+ });
17755
+ continue;
17756
+ }
17575
17757
  if (branchExists(config2.projectRoot, branch) && hasRemote(config2.projectRoot)) {
17576
17758
  const push = gitPush(config2.projectRoot, branch);
17577
17759
  if (!push.success) {
@@ -17794,7 +17976,8 @@ async function createRelease(config2, branch, version, adapter2, cycleNum, optio
17794
17976
  }
17795
17977
  let groupedBranchMerges;
17796
17978
  if (resolvedCycleNum > 0) {
17797
- const mergeResults = mergeGroupedCycleBranches(config2, resolvedCycleNum, branch);
17979
+ const skipBranches = adapter2 ? await allCancelledBranches(adapter2, resolvedCycleNum) : /* @__PURE__ */ new Set();
17980
+ const mergeResults = mergeGroupedCycleBranches(config2, resolvedCycleNum, branch, skipBranches);
17798
17981
  if (mergeResults.length > 0) {
17799
17982
  groupedBranchMerges = mergeResults;
17800
17983
  const failures = mergeResults.filter((r) => r.status === "failed");
@@ -18598,6 +18781,23 @@ If you intend to discard the work, run build_cancel instead.`
18598
18781
  err.gitStderr = scrubbed;
18599
18782
  return err;
18600
18783
  }
18784
+ var UI_FILE_RE = /^(app\/.+\.(tsx|css)|components\/.+\.tsx)$/;
18785
+ var NON_UI_RE = /(^app\/api\/|\.test\.|\.spec\.|\/__tests__\/)/;
18786
+ function inferRouteFromPage(file) {
18787
+ const m = file.match(/^app\/(.*)\/page\.tsx$/);
18788
+ if (!m) return null;
18789
+ const segments = m[1].split("/").filter((s) => s.length > 0 && !(s.startsWith("(") && s.endsWith(")")));
18790
+ return "/" + segments.join("/");
18791
+ }
18792
+ function computeLocalPreview(filesChanged, provided) {
18793
+ const uiFiles = (filesChanged ?? []).filter((f) => UI_FILE_RE.test(f) && !NON_UI_RE.test(f));
18794
+ const hasProvided = !!(provided && (provided.urls?.length || provided.screenshotPath || provided.notes));
18795
+ if (uiFiles.length === 0 && !hasProvided) return void 0;
18796
+ const routes = Array.from(
18797
+ new Set(uiFiles.map(inferRouteFromPage).filter((r) => r !== null))
18798
+ );
18799
+ return { uiFiles, routes, provided: hasProvided ? provided : void 0 };
18800
+ }
18601
18801
  var AMBIENT_DRIFT_PATTERNS = [
18602
18802
  /^\.next\//,
18603
18803
  /^\.next$/,
@@ -19274,6 +19474,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
19274
19474
  const drift = computeScopeDriftSignal(task.buildHandoff?.filesLikelyTouched, driftChanged);
19275
19475
  if (drift) report.scopeDriftSignal = drift;
19276
19476
  }
19477
+ const localPreview = computeLocalPreview(report.filesChanged, input.preview);
19277
19478
  let prLines = [];
19278
19479
  if (options.light) {
19279
19480
  prLines.push("Light mode: skipping push and PR creation.");
@@ -19376,7 +19577,8 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
19376
19577
  autoTriagedCount: autoTriagedCount > 0 ? autoTriagedCount : void 0,
19377
19578
  autoTriagedIds: autoTriagedIds.length > 0 ? autoTriagedIds : void 0,
19378
19579
  autoTriagedDupes: autoTriagedDupes.length > 0 ? autoTriagedDupes : void 0,
19379
- reportWriteVerified
19580
+ reportWriteVerified,
19581
+ localPreview
19380
19582
  };
19381
19583
  }
19382
19584
  async function cancelBuild(adapter2, taskId, reason) {
@@ -19596,6 +19798,15 @@ var buildExecuteTool = {
19596
19798
  verified_at: { type: "string", description: "ISO 8601 timestamp." }
19597
19799
  },
19598
19800
  required: ["urls", "curl_command", "http_status", "response_excerpt", "verified_at"]
19801
+ },
19802
+ preview: {
19803
+ type: "object",
19804
+ 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.`,
19805
+ properties: {
19806
+ urls: { type: "array", items: { type: "string" }, description: "Localhost route(s) to open to view the work." },
19807
+ notes: { type: "string", description: "What the owner should look at / verify." },
19808
+ screenshot_path: { type: "string", description: "Path to a captured screenshot of the rendered result." }
19809
+ }
19599
19810
  }
19600
19811
  },
19601
19812
  required: ["task_id"]
@@ -19873,6 +20084,12 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
19873
20084
  const deadEnds = args.dead_ends;
19874
20085
  const rawBriefImplications = args.brief_implications;
19875
20086
  const resolvesLearnings = Array.isArray(args.resolves_learnings) ? args.resolves_learnings : void 0;
20087
+ const rawPreview = args.preview;
20088
+ const preview = rawPreview ? {
20089
+ urls: Array.isArray(rawPreview.urls) ? rawPreview.urls.filter((u) => typeof u === "string") : void 0,
20090
+ notes: typeof rawPreview.notes === "string" ? rawPreview.notes : void 0,
20091
+ screenshotPath: typeof rawPreview.screenshot_path === "string" ? rawPreview.screenshot_path : void 0
20092
+ } : void 0;
19876
20093
  const rawProductionVerification = args.production_verification;
19877
20094
  const productionVerification = rawProductionVerification ? {
19878
20095
  urls: Array.isArray(rawProductionVerification.urls) ? rawProductionVerification.urls.filter((u) => typeof u === "string") : [],
@@ -19929,7 +20146,8 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
19929
20146
  type: bi.type ?? "new",
19930
20147
  detail: bi.detail ?? ""
19931
20148
  })),
19932
- productionVerification
20149
+ productionVerification,
20150
+ preview
19933
20151
  }, { light });
19934
20152
  tracker.mark("complete_format");
19935
20153
  if (resolvesLearnings && resolvesLearnings.length > 0 && adapter2.updateCycleLearningActionRef) {
@@ -19986,6 +20204,26 @@ function formatCompleteResult(result) {
19986
20204
  if (result.cycleProgress.total > 0) {
19987
20205
  lines.push("", `Cycle progress: ${result.cycleProgress.completed} of ${result.cycleProgress.total} cycle tasks complete.`);
19988
20206
  }
20207
+ if (result.localPreview) {
20208
+ const { routes, provided, uiFiles } = result.localPreview;
20209
+ const previewLines = ["", "### \u{1F441} Preview the work"];
20210
+ if (provided?.urls?.length) {
20211
+ previewLines.push(`Open: ${provided.urls.join(" \xB7 ")}`);
20212
+ } else if (routes.length > 0) {
20213
+ const urls = routes.map((r) => `http://localhost:3000${r}`);
20214
+ previewLines.push(`Run \`npm run dev\` and open: ${urls.join(" \xB7 ")}`);
20215
+ } else {
20216
+ previewLines.push(
20217
+ `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.`
20218
+ );
20219
+ }
20220
+ if (provided?.screenshotPath) previewLines.push(`Screenshot: ${provided.screenshotPath}`);
20221
+ if (provided?.notes) previewLines.push(`Look at: ${provided.notes}`);
20222
+ if (!provided) {
20223
+ previewLines.push("_Tip: pass `preview` (the route you viewed / a screenshot) on complete so the result travels with the build._");
20224
+ }
20225
+ lines.push(...previewLines);
20226
+ }
19989
20227
  if (result.phaseChanges.length > 0) {
19990
20228
  for (const c of result.phaseChanges) {
19991
20229
  lines.push(`Phase auto-updated: ${c.phaseId} ${c.oldStatus} \u2192 ${c.newStatus}`);
@@ -26652,6 +26890,8 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
26652
26890
  var BEARER_PREFIX = "papi_";
26653
26891
  var BEARER_REGEX = /^(papi_|papi_oauth_)[a-f0-9]{64}$/;
26654
26892
  var RESOURCE_METADATA_URL = process.env["PAPI_RESOURCE_METADATA_URL"] ?? "https://getpapi.ai/.well-known/oauth-protected-resource";
26893
+ var DASHBOARD_ORIGIN = process.env["PAPI_DASHBOARD_URL"] ?? "https://getpapi.ai";
26894
+ var MCP_RESOURCE_URL = process.env["NEXT_PUBLIC_MCP_URL"] ?? "https://mcp.getpapi.ai";
26655
26895
  var FRIENDLY_GET_HTML = `<!doctype html>
26656
26896
  <html lang="en"><head><meta charset="utf-8">
26657
26897
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -26779,6 +27019,31 @@ function startHttpTransport(opts) {
26779
27019
  res.end("ok");
26780
27020
  return;
26781
27021
  }
27022
+ if (req.method === "GET" && req.url === "/.well-known/oauth-protected-resource") {
27023
+ res.writeHead(200, {
27024
+ "Content-Type": "application/json",
27025
+ "Cache-Control": "public, max-age=3600",
27026
+ ...cors
27027
+ });
27028
+ res.end(
27029
+ JSON.stringify({
27030
+ resource: MCP_RESOURCE_URL,
27031
+ authorization_servers: [DASHBOARD_ORIGIN],
27032
+ scopes_supported: ["mcp:full"],
27033
+ bearer_methods_supported: ["header"]
27034
+ })
27035
+ );
27036
+ return;
27037
+ }
27038
+ if (req.method === "GET" && req.url === "/.well-known/oauth-authorization-server") {
27039
+ res.writeHead(302, {
27040
+ Location: `${DASHBOARD_ORIGIN}/.well-known/oauth-authorization-server`,
27041
+ "Cache-Control": "public, max-age=3600",
27042
+ ...cors
27043
+ });
27044
+ res.end();
27045
+ return;
27046
+ }
26782
27047
  if (req.url !== "/mcp" && req.url !== "/sse") {
26783
27048
  sendError(res, { status: 404, body: { error: "Not found" } }, cors);
26784
27049
  return;
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:
@@ -380,7 +380,7 @@ ${AD_REJECTION_RULES}
380
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.
381
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.
382
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.
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. 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.
384
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.
385
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.
386
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.
@@ -404,7 +404,7 @@ ${AD_REJECTION_RULES}
404
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.
405
405
  - **Strategy gaps:** If an Active Decision has no board tasks supporting it, propose one.
406
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.
407
- 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.
408
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.
409
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.`);
410
410
  parts.push(PLAN_FRAGMENT_PRODUCT_BRIEF);
@@ -524,6 +524,26 @@ function buildPlanUserMessage(ctx) {
524
524
  if (ctx.buildPatterns) {
525
525
  parts.push("### Build Patterns", "", ctx.buildPatterns, "");
526
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
+ }
527
547
  if (ctx.reviewPatterns) {
528
548
  parts.push("### Review Patterns", "", ctx.reviewPatterns, "");
529
549
  }
@@ -660,6 +680,8 @@ function coerceStructuredOutput(parsed) {
660
680
  buildHandoff: coerceToString(h.buildHandoff)
661
681
  })) : [];
662
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,
663
685
  title: coerceToString(t.title),
664
686
  status: coerceToString(t.status),
665
687
  priority: coerceToString(t.priority),
@@ -988,6 +1010,16 @@ function buildReviewUserMessage(ctx) {
988
1010
  if (ctx.decisionUsage) {
989
1011
  parts.push("### Decision Usage (Reference Frequency)", "", ctx.decisionUsage, "");
990
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
+ }
991
1023
  if (ctx.recommendationEffectiveness) {
992
1024
  parts.push("### Recommendation Follow-Through", "", ctx.recommendationEffectiveness, "");
993
1025
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.36",
3
+ "version": "0.7.38",
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",