@papi-ai/server 0.7.73 → 0.7.75

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.
@@ -14,6 +14,7 @@ __export(git_exports, {
14
14
  AUTO_WRITTEN_PATHS: () => AUTO_WRITTEN_PATHS,
15
15
  branchExists: () => branchExists,
16
16
  checkoutBranch: () => checkoutBranch,
17
+ commitSinglePath: () => commitSinglePath,
17
18
  commitStagedOnly: () => commitStagedOnly,
18
19
  createAndCheckoutBranch: () => createAndCheckoutBranch,
19
20
  createPullRequest: () => createPullRequest,
@@ -59,6 +60,8 @@ __export(git_exports, {
59
60
  isGhAvailable: () => isGhAvailable,
60
61
  isGitAvailable: () => isGitAvailable,
61
62
  isGitRepo: () => isGitRepo,
63
+ isPathIgnored: () => isPathIgnored,
64
+ isPathTracked: () => isPathTracked,
62
65
  listGroupedCycleBranches: () => listGroupedCycleBranches,
63
66
  listOpenPullRequests: () => listOpenPullRequests,
64
67
  listOrphanFeatBranches: () => listOrphanFeatBranches,
@@ -96,6 +99,42 @@ function isGitRepo(cwd) {
96
99
  return false;
97
100
  }
98
101
  }
102
+ function isPathTracked(cwd, path3) {
103
+ try {
104
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", path3], {
105
+ cwd,
106
+ stdio: "ignore"
107
+ });
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+ function isPathIgnored(cwd, path3) {
114
+ const r = spawnSync("git", ["check-ignore", "-q", "--", path3], { cwd, stdio: "ignore" });
115
+ return r.status === 0;
116
+ }
117
+ function commitSinglePath(cwd, path3, message) {
118
+ const add = spawnSync("git", ["add", "--", path3], { cwd, encoding: "utf-8" });
119
+ if (add.status !== 0) {
120
+ return { committed: false, message: (add.stderr || "git add failed").trim() };
121
+ }
122
+ const pending = spawnSync("git", ["diff", "--cached", "--name-only", "--", path3], {
123
+ cwd,
124
+ encoding: "utf-8"
125
+ });
126
+ if (pending.status !== 0 || !(pending.stdout ?? "").trim()) {
127
+ return { committed: false, message: "No changes to commit." };
128
+ }
129
+ const commit = spawnSync("git", ["commit", "-m", message, "--", path3], {
130
+ cwd,
131
+ encoding: "utf-8"
132
+ });
133
+ if (commit.status !== 0) {
134
+ return { committed: false, message: (commit.stderr || "git commit failed").trim() };
135
+ }
136
+ return { committed: true, message };
137
+ }
99
138
  function stageDirAndCommit(cwd, dir, message) {
100
139
  try {
101
140
  execFileSync("git", ["check-ignore", "-q", dir], { cwd });
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ __export(git_exports, {
15
15
  AUTO_WRITTEN_PATHS: () => AUTO_WRITTEN_PATHS,
16
16
  branchExists: () => branchExists,
17
17
  checkoutBranch: () => checkoutBranch,
18
+ commitSinglePath: () => commitSinglePath,
18
19
  commitStagedOnly: () => commitStagedOnly,
19
20
  createAndCheckoutBranch: () => createAndCheckoutBranch,
20
21
  createPullRequest: () => createPullRequest,
@@ -60,6 +61,8 @@ __export(git_exports, {
60
61
  isGhAvailable: () => isGhAvailable,
61
62
  isGitAvailable: () => isGitAvailable,
62
63
  isGitRepo: () => isGitRepo,
64
+ isPathIgnored: () => isPathIgnored,
65
+ isPathTracked: () => isPathTracked,
63
66
  listGroupedCycleBranches: () => listGroupedCycleBranches,
64
67
  listOpenPullRequests: () => listOpenPullRequests,
65
68
  listOrphanFeatBranches: () => listOrphanFeatBranches,
@@ -97,6 +100,42 @@ function isGitRepo(cwd) {
97
100
  return false;
98
101
  }
99
102
  }
103
+ function isPathTracked(cwd, path7) {
104
+ try {
105
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", path7], {
106
+ cwd,
107
+ stdio: "ignore"
108
+ });
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+ function isPathIgnored(cwd, path7) {
115
+ const r = spawnSync("git", ["check-ignore", "-q", "--", path7], { cwd, stdio: "ignore" });
116
+ return r.status === 0;
117
+ }
118
+ function commitSinglePath(cwd, path7, message) {
119
+ const add = spawnSync("git", ["add", "--", path7], { cwd, encoding: "utf-8" });
120
+ if (add.status !== 0) {
121
+ return { committed: false, message: (add.stderr || "git add failed").trim() };
122
+ }
123
+ const pending = spawnSync("git", ["diff", "--cached", "--name-only", "--", path7], {
124
+ cwd,
125
+ encoding: "utf-8"
126
+ });
127
+ if (pending.status !== 0 || !(pending.stdout ?? "").trim()) {
128
+ return { committed: false, message: "No changes to commit." };
129
+ }
130
+ const commit = spawnSync("git", ["commit", "-m", message, "--", path7], {
131
+ cwd,
132
+ encoding: "utf-8"
133
+ });
134
+ if (commit.status !== 0) {
135
+ return { committed: false, message: (commit.stderr || "git commit failed").trim() };
136
+ }
137
+ return { committed: true, message };
138
+ }
100
139
  function stageDirAndCommit(cwd, dir, message) {
101
140
  try {
102
141
  execFileSync("git", ["check-ignore", "-q", dir], { cwd });
@@ -5002,7 +5041,7 @@ var setup_exports = {};
5002
5041
  __export(setup_exports, {
5003
5042
  runSetup: () => runSetup
5004
5043
  });
5005
- import { existsSync as existsSync15, readFileSync as readFileSync17, writeFileSync as writeFileSync8, chmodSync as chmodSync2, statSync as statSync8 } from "fs";
5044
+ import { existsSync as existsSync15, readFileSync as readFileSync17, writeFileSync as writeFileSync8, chmodSync as chmodSync2, statSync as statSync9 } from "fs";
5006
5045
  import { join as join24 } from "path";
5007
5046
  function baseUrl() {
5008
5047
  const fromEnv = process.env["PAPI_HOST"] ?? process.env["PAPI_BASE_URL"];
@@ -5062,7 +5101,7 @@ function writeMcpJson(opts) {
5062
5101
  parsed.mcpServers = mcpServers;
5063
5102
  writeFileSync8(path7, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
5064
5103
  try {
5065
- const mode = statSync8(path7).mode & 511;
5104
+ const mode = statSync9(path7).mode & 511;
5066
5105
  if (mode !== 384) chmodSync2(path7, 384);
5067
5106
  } catch {
5068
5107
  }
@@ -5172,7 +5211,7 @@ var init_setup = __esm({
5172
5211
  // src/index.ts
5173
5212
  import { readFileSync as readFileSync18 } from "fs";
5174
5213
  import { dirname as dirname6, join as join25, basename as basename2 } from "path";
5175
- import { fileURLToPath as fileURLToPath4 } from "url";
5214
+ import { fileURLToPath as fileURLToPath5 } from "url";
5176
5215
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5177
5216
  import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
5178
5217
  import {
@@ -8218,7 +8257,7 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
8218
8257
  import { readFileSync as readFileSync13 } from "fs";
8219
8258
  import { access as access4, readdir as readdir4, readFile as readFile9 } from "fs/promises";
8220
8259
  import { join as join20, dirname as dirname5 } from "path";
8221
- import { fileURLToPath as fileURLToPath3 } from "url";
8260
+ import { fileURLToPath as fileURLToPath4 } from "url";
8222
8261
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8223
8262
  import {
8224
8263
  CallToolRequestSchema,
@@ -8245,6 +8284,8 @@ var UNIVERSAL_FRAME = `PAPI gives this project a structured plan \u2192 build \u
8245
8284
 
8246
8285
  5. VERIFY BEFORE DONE. Test the change and confirm it works before reporting a task complete. Report failures honestly, with the output.
8247
8286
 
8287
+ 6. NAME THE PROJECT WHEN YOU KNOW IT. If you know which repo this session is working in, pass \`project="<slug>"\` on the call rather than relying on whatever the connection defaults to. An account with more than one project cannot be resolved from a connection with no project bound \u2014 PAPI will stop and ask which one rather than guess, and answering costs a round trip. If PAPI asks, put the list to the user, then re-call with their choice; run \`project_switch\` to make it stick.
8288
+
8248
8289
  PAPI reads and writes all project state through these tools \u2014 they are the source of truth, not local files.`;
8249
8290
 
8250
8291
  // src/lib/response.ts
@@ -8995,7 +9036,7 @@ PRE-BUILD VERIFICATION
8995
9036
  [List 2-5 specific file paths the builder should read BEFORE implementing to check if the functionality already exists. Derive these from FILES LIKELY TOUCHED \u2014 pick the files most likely to already contain the target functionality. ALSO mandate a docs sweep, not just file-existence (task-2161): name any docs the builder should check via doc_search or the docs index \u2014 a design/research/status:final doc or a prior task may already cover the work. If >80% of the scope is already implemented, the builder should report "already built" instead of re-implementing. Include this section for EVERY task \u2014 it prevents wasted build slots on already-shipped code.]
8996
9037
 
8997
9038
  FILES LIKELY TOUCHED
8998
- [files \u2014 real paths only. TEST FILE LOCATIONS (task-2606): PAPI tests are NOT co-located next to source in a src/**/__tests__/ folder. Use the actual layout: server tests \u2192 packages/server/tests/<name>.test.ts; adapter-pg tests \u2192 packages/adapter-pg/src/__tests__/<name>.test.ts; dashboard/root tests \u2192 tests/<area>/<name>.test.ts. Do NOT invent packages/server/src/**/__tests__/ paths \u2014 they do not exist.]
9039
+ [files \u2014 real paths only. TEST FILE LOCATIONS (task-2606): PAPI tests are NOT co-located next to source in a src/**/__tests__/ folder. Use the actual layout: server tests \u2192 packages/server/tests/<name>.test.ts; adapter-pg tests \u2192 packages/adapter-pg/src/__tests__/<name>.test.ts; dashboard/root tests \u2192 tests/<area>/<name>.test.ts. Do NOT invent packages/server/src/**/__tests__/ paths \u2014 they do not exist. MCP RESTART RULE (task-2978): whenever FILES LIKELY TOUCHED includes any \`packages/server/**\` path, append this line verbatim to the handoff \u2014 "MCP RESTART REQUIRED: a mid-session \`npm run mcp-build\` writes a dist the already-booted MCP process never loads, so verifying this change through PAPI's own tools before an explicit MCP reconnect is INVALID. Implement \u2192 mcp-build \u2192 reconnect \u2192 only then verify." This is one line inside this section, not a new section.]
8999
9040
 
9000
9041
  EFFORT
9001
9042
  [XS/S/M/L/XL]
@@ -9009,7 +9050,7 @@ After your natural language output, include this EXACT format on its own line:
9009
9050
  {
9010
9051
  "cycleLogTitle": "string \u2014 short descriptive title WITHOUT 'Cycle N' prefix. Should capture the cycle theme in 3-5 words (e.g. 'MCP Quality + Product Readiness' not 'Cycle 5 \u2014 Board Triage \u2014 Bug Fix'). This is the canonical theme label for the cycle.",
9011
9052
  "cycleLogContent": "string \u2014 5-10 line cycle log body in markdown, NO heading (the ### heading is generated automatically)",
9012
- "cycleLogCarryForward": "string or null \u2014 carry-forward items for next cycle",
9053
+ "cycleLogCarryForward": "string or null \u2014 carry-forward for the next cycle, in TWO NAMED SECTIONS, product signal FIRST. Section 1 begins with the literal label 'WHAT SHIPS FOR USERS:' and names, concretely, what a person using this project can now see or do, and how to check it \u2014 never a deploy step. Section 2 begins with the literal label 'RELEASE MECHANICS:' and carries everything operational: deploy, publish, migrations, gate order, branch counts, spot-checks. The mechanics are load-bearing and must NOT be dropped or shortened \u2014 they go second, not away. orient parses these two labels and renders the product half first, so the labels are a contract, not decoration. If a cycle genuinely ships nothing user-visible, say so in one line under section 1 rather than omitting the label.",
9013
9054
  "cycleLogNotes": "string or null \u2014 1-3 lines of cycle-level observations: estimation accuracy, recurring blockers, velocity trends, dependency signals. Omit if no noteworthy observations.",
9014
9055
  "nextMode": "Full",
9015
9056
  "boardHealth": "string \u2014 e.g. 5 tasks (3 backlog, 2 done)",
@@ -9873,7 +9914,7 @@ After your natural language output, include this EXACT format on its own line:
9873
9914
  \`\`\`json
9874
9915
  {
9875
9916
  "sessionLogTitle": "string \u2014 Strategy Review title WITHOUT 'Cycle N' prefix (e.g. 'Strategy Review' not 'Cycle 5 \u2014 Strategy Review')",
9876
- "sessionLogContent": "string \u2014 5-10 line cycle log body summarizing the review, NO heading (the ### heading is generated automatically)",
9917
+ "sessionLogContent": "string \u2014 5-10 line cycle log body summarizing the review, NO heading (the ### heading is generated automatically). LEAD WITH TANGIBLE PRODUCT INSIGHT: open with what was learned about the product, its users, or the market \u2014 a specific finding a reader can act on. Board hygiene, cadence, cycle counts, branch state and other housekeeping are legitimate content but they TRAIL; they must never be the opening line. If the review's most important finding genuinely is an operational one, say why it matters to the product in the same breath rather than reporting it as admin.",
9877
9918
  "velocityAssessment": "string \u2014 2-3 sentence velocity summary",
9878
9919
  "strategicRecommendations": "string \u2014 key recommendations in markdown",
9879
9920
  "activeDecisionUpdates": [
@@ -10237,7 +10278,7 @@ REFERENCE DOCS
10237
10278
  [Optional \u2014 paths to docs/ files with background context. Omit if not needed.]
10238
10279
 
10239
10280
  FILES LIKELY TOUCHED
10240
- [files \u2014 real paths only. TEST FILE LOCATIONS (task-2606): PAPI tests are NOT co-located next to source in a src/**/__tests__/ folder. Use the actual layout: server tests \u2192 packages/server/tests/<name>.test.ts; adapter-pg tests \u2192 packages/adapter-pg/src/__tests__/<name>.test.ts; dashboard/root tests \u2192 tests/<area>/<name>.test.ts. Do NOT invent packages/server/src/**/__tests__/ paths \u2014 they do not exist.]
10281
+ [files \u2014 real paths only. TEST FILE LOCATIONS (task-2606): PAPI tests are NOT co-located next to source in a src/**/__tests__/ folder. Use the actual layout: server tests \u2192 packages/server/tests/<name>.test.ts; adapter-pg tests \u2192 packages/adapter-pg/src/__tests__/<name>.test.ts; dashboard/root tests \u2192 tests/<area>/<name>.test.ts. Do NOT invent packages/server/src/**/__tests__/ paths \u2014 they do not exist. MCP RESTART RULE (task-2978): whenever FILES LIKELY TOUCHED includes any \`packages/server/**\` path, append this line verbatim to the handoff \u2014 "MCP RESTART REQUIRED: a mid-session \`npm run mcp-build\` writes a dist the already-booted MCP process never loads, so verifying this change through PAPI's own tools before an explicit MCP reconnect is INVALID. Implement \u2192 mcp-build \u2192 reconnect \u2192 only then verify." This is one line inside this section, not a new section.]
10241
10282
 
10242
10283
  EFFORT
10243
10284
  [XS/S/M/L/XL]`;
@@ -10602,9 +10643,9 @@ async function getPrompt(name) {
10602
10643
  if (!endpoint) {
10603
10644
  return LOCAL_PROMPTS[name];
10604
10645
  }
10605
- const cached = cache.get(name);
10606
- if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
10607
- return cached.content;
10646
+ const cached2 = cache.get(name);
10647
+ if (cached2 && Date.now() - cached2.fetchedAt < CACHE_TTL_MS) {
10648
+ return cached2.content;
10608
10649
  }
10609
10650
  try {
10610
10651
  const url = endpoint.endsWith("/") ? `${endpoint}${name}` : `${endpoint}/${name}`;
@@ -10617,19 +10658,19 @@ async function getPrompt(name) {
10617
10658
  }
10618
10659
  const response = await fetch(url, { headers, signal: AbortSignal.timeout(1e4) });
10619
10660
  if (!response.ok) {
10620
- if (cached) return cached.content;
10661
+ if (cached2) return cached2.content;
10621
10662
  return LOCAL_PROMPTS[name];
10622
10663
  }
10623
10664
  const data = await response.json();
10624
10665
  const content = data.content;
10625
10666
  if (typeof content !== "string" || content.length === 0) {
10626
- if (cached) return cached.content;
10667
+ if (cached2) return cached2.content;
10627
10668
  return LOCAL_PROMPTS[name];
10628
10669
  }
10629
10670
  cache.set(name, { content, fetchedAt: Date.now() });
10630
10671
  return content;
10631
10672
  } catch {
10632
- if (cached) return cached.content;
10673
+ if (cached2) return cached2.content;
10633
10674
  return LOCAL_PROMPTS[name];
10634
10675
  }
10635
10676
  }
@@ -19797,6 +19838,91 @@ var DB_ONLY_START_NOTICE = "No git repo detected \u2014 running this cycle in yo
19797
19838
  var DB_ONLY_COMPLETE_NOTICE = "No git repo \u2014 build recorded in your project database only (no commit or PR). Run `git init` (and add a remote) to enable git-backed commits and PR review.";
19798
19839
  var DB_ONLY_RELEASE_NOTICE = "No git repo detected \u2014 this release closed the cycle in your project database only. No tag, branch merge, or CHANGELOG was created. Run `git init` (and add a remote) to enable git-backed releases (tags, merges and changelog).";
19799
19840
 
19841
+ // src/lib/build-decision.ts
19842
+ var BUILD_DECISION_ANSWERS = [
19843
+ "retry-differently",
19844
+ "skip",
19845
+ "stop"
19846
+ ];
19847
+ function loopThreshold(env = process.env) {
19848
+ const raw = Number(env.PAPI_BUILD_LOOP_THRESHOLD);
19849
+ return Number.isFinite(raw) && raw >= 2 ? Math.floor(raw) : 3;
19850
+ }
19851
+ function readDecision(task) {
19852
+ const b2 = task.blocker;
19853
+ if (!b2 || b2.type !== "decision-gate") return null;
19854
+ return b2.decision ?? null;
19855
+ }
19856
+ function isDecisionPending(task) {
19857
+ const d = readDecision(task);
19858
+ return d != null && d.answer == null;
19859
+ }
19860
+ async function countFailedAttempts(adapter2, taskId) {
19861
+ if (typeof adapter2.getFailedBuildAttemptsForTask !== "function") return null;
19862
+ try {
19863
+ return await adapter2.getFailedBuildAttemptsForTask(taskId);
19864
+ } catch {
19865
+ return null;
19866
+ }
19867
+ }
19868
+ function decisionRequiredResponse(taskId, decision) {
19869
+ return [
19870
+ `DECISION REQUIRED \u2014 ${taskId} has failed ${decision.attempts} times.`,
19871
+ "",
19872
+ decision.reason,
19873
+ "",
19874
+ "PAPI has stopped rather than starting another attempt that looks like the last one.",
19875
+ "Answer by re-running build_execute with a `decision`:",
19876
+ "",
19877
+ ' decision: { answer: "retry-differently", guidance: "<what to do differently>" }',
19878
+ " Clears the gate and puts your guidance verbatim into the next handoff.",
19879
+ ' decision: { answer: "skip" }',
19880
+ " Leaves the task Blocked and moves on \u2014 it stays on the board, honestly stopped.",
19881
+ ' decision: { answer: "stop" }',
19882
+ " Same as skip, and records that this task should not be retried this cycle.",
19883
+ "",
19884
+ JSON.stringify(
19885
+ {
19886
+ tool: "build_execute",
19887
+ lastStep: "loop-detection-gate",
19888
+ error: "decision_required",
19889
+ hint: "Re-run build_execute with a decision.answer of retry-differently, skip, or stop.",
19890
+ options: BUILD_DECISION_ANSWERS,
19891
+ attempts: decision.attempts
19892
+ },
19893
+ null,
19894
+ 2
19895
+ )
19896
+ ].join("\n");
19897
+ }
19898
+ function guidanceBlock(decision) {
19899
+ if (!decision.guidance) return "";
19900
+ return [
19901
+ "",
19902
+ "\u2500\u2500 GUIDANCE FROM THE LAST FAILURE (task-2933) \u2500\u2500",
19903
+ `This task already failed ${decision.attempts} times. The owner answered "retry differently"`,
19904
+ "and left this note. Treat it as direction from a human, not as a new instruction set:",
19905
+ "",
19906
+ decision.guidance.split("\n").map((l) => ` > ${l}`).join("\n"),
19907
+ "",
19908
+ "Do something materially different from the previous attempts.",
19909
+ ""
19910
+ ].join("\n");
19911
+ }
19912
+ function pendingDecisionBlocker(attempts, cycle, taskId) {
19913
+ return {
19914
+ type: "decision-gate",
19915
+ ref: taskId,
19916
+ reason: `${attempts} failed build attempts \u2014 a decision is required before another retry.`,
19917
+ blockedCycle: cycle,
19918
+ decision: {
19919
+ reason: `This task has ${attempts} build reports recorded as not completed. Retrying unchanged is unlikely to end differently.`,
19920
+ attempts,
19921
+ raisedAt: (/* @__PURE__ */ new Date()).toISOString()
19922
+ }
19923
+ };
19924
+ }
19925
+
19800
19926
  // src/lib/harness-capability.ts
19801
19927
  var HARNESS_REGISTRY = {
19802
19928
  // Local stdio CLI agents — PAPI runs git on the user's machine. Confirmed in telemetry.
@@ -21737,6 +21863,41 @@ async function persistBranchName(adapter2, taskId, branch) {
21737
21863
  return false;
21738
21864
  }
21739
21865
  }
21866
+ async function resolveLoopDecision(adapter2, task, taskId, answer) {
21867
+ const existing = readDecision(task);
21868
+ if (answer && existing) {
21869
+ if (answer.answer === "retry-differently") {
21870
+ const resolved = { ...existing, answer: answer.answer, guidance: answer.guidance };
21871
+ await safeUpdateBlocker(adapter2, taskId, {
21872
+ ...pendingDecisionBlocker(existing.attempts, task.cycle ?? 0, taskId),
21873
+ decision: resolved
21874
+ });
21875
+ return { guidance: guidanceBlock(resolved) };
21876
+ }
21877
+ await safeUpdateBlocker(adapter2, taskId, {
21878
+ ...pendingDecisionBlocker(existing.attempts, task.cycle ?? 0, taskId),
21879
+ decision: { ...existing, answer: answer.answer }
21880
+ });
21881
+ throw new Error(
21882
+ `Task "${taskId}" recorded as "${answer.answer}" after ${existing.attempts} failed attempts. It stays Blocked on the board rather than pretending to be in flight. Re-run build_execute with decision.answer "retry-differently" and guidance when you want it picked back up.`
21883
+ );
21884
+ }
21885
+ if (isDecisionPending(task)) {
21886
+ throw new Error(decisionRequiredResponse(taskId, readDecision(task)));
21887
+ }
21888
+ const attempts = await countFailedAttempts(adapter2, taskId);
21889
+ if (attempts == null) return {};
21890
+ if (attempts < loopThreshold()) return {};
21891
+ const blocker = pendingDecisionBlocker(attempts, task.cycle ?? 0, taskId);
21892
+ await safeUpdateBlocker(adapter2, taskId, blocker);
21893
+ throw new Error(decisionRequiredResponse(taskId, blocker.decision));
21894
+ }
21895
+ async function safeUpdateBlocker(adapter2, taskId, blocker) {
21896
+ try {
21897
+ await adapter2.updateTask(taskId, { blocker });
21898
+ } catch {
21899
+ }
21900
+ }
21740
21901
  async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21741
21902
  const task = await adapter2.getTask(taskId);
21742
21903
  if (!task) {
@@ -21753,6 +21914,8 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21753
21914
  if (task.status === "Done" || task.status === "Archived") {
21754
21915
  throw new Error(`Task "${taskId}" (${task.title}) is already ${task.status}. Cannot execute a completed task.`);
21755
21916
  }
21917
+ const loopDecision = await resolveLoopDecision(adapter2, task, taskId, options.decision);
21918
+ const injectedGuidance = loopDecision.guidance;
21756
21919
  if (task.status === "In Review") {
21757
21920
  throw new Error(
21758
21921
  `Task "${taskId}" (${task.title}) is already In Review \u2014 it has been built and is awaiting sign-off. Run \`review_submit\` instead of re-building. If the build genuinely needs rework, use \`review_submit\` with verdict \`request-changes\` first.`
@@ -22038,7 +22201,10 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
22038
22201
  });
22039
22202
  return {
22040
22203
  task,
22041
- branchLines,
22204
+ // task-2933: a `retry-differently` answer prepends its guidance here, so it
22205
+ // sits ABOVE the BUILD HANDOFF in the start output — the builder reads the
22206
+ // reason the last attempts failed before it reads what to build.
22207
+ branchLines: injectedGuidance ? [injectedGuidance, ...branchLines] : branchLines,
22042
22208
  phaseChanges,
22043
22209
  filesToWrite: collector.isEmpty() ? void 0 : collector
22044
22210
  };
@@ -22673,9 +22839,10 @@ import { join as join13, relative } from "path";
22673
22839
  import { homedir as homedir3 } from "os";
22674
22840
  import { randomUUID as randomUUID12 } from "crypto";
22675
22841
  import { docDeletionBlockMessage } from "@papi-ai/shared";
22842
+ init_git();
22676
22843
  var docRegisterTool = {
22677
22844
  name: "doc_register",
22678
- description: "Register or update a document in the doc registry. Called after finalising a research/planning doc, or when build_execute detects unregistered docs. Stores metadata and structured summary \u2014 not full content. Re-registering an existing doc updates its summary, tags, actions, type, and status (upsert). Visibility and owner are not changed on re-register.",
22845
+ description: "Register or update a document in the doc registry. Called after finalising a research/planning doc, or when build_execute detects unregistered docs. Stores metadata and structured summary \u2014 not full content; the body stays a file, so an untracked doc is committed at registration to make it durable (or you get a loud warning if it cannot be). Re-registering an existing doc updates its summary, tags, actions, type, and status (upsert). Visibility and owner are not changed on re-register.",
22679
22846
  annotations: { title: "Register Doc", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
22680
22847
  inputSchema: {
22681
22848
  type: "object",
@@ -22776,6 +22943,42 @@ Diagnostic JSON:
22776
22943
  ${JSON.stringify(payload, null, 2)}`
22777
22944
  );
22778
22945
  }
22946
+ function ensureDocDurable(path7, projectRoot) {
22947
+ if (!hasLocalWorkspace() || !projectRoot) return "";
22948
+ const warn = (reason, fix) => `
22949
+
22950
+ \u26A0\uFE0F **Registered, but NOT durable.** ${reason}
22951
+ The registry stores metadata and a summary \u2014 not the body. This doc has one copy, on disk, and a branch switch or stash can take it.
22952
+ **Fix:** ${fix}`;
22953
+ if (!existsSync9(join13(projectRoot, path7))) {
22954
+ return warn(
22955
+ `No file exists at \`${path7}\`.`,
22956
+ `write the doc body to that path, then re-run doc_register.`
22957
+ );
22958
+ }
22959
+ if (!isGitAvailable() || !isGitRepo(projectRoot)) {
22960
+ return warn(
22961
+ "This project is not a git repository (or git is unavailable), so the body cannot be committed.",
22962
+ `back the file up outside the working tree, or run \`git init\` and commit \`${path7}\`.`
22963
+ );
22964
+ }
22965
+ if (isPathTracked(projectRoot, path7)) return "";
22966
+ if (isPathIgnored(projectRoot, path7)) {
22967
+ return warn(
22968
+ `\`${path7}\` is excluded by .gitignore, so it cannot be committed (docs/private/ is ignored by design).`,
22969
+ `keep a copy outside the working tree, or move the doc to a tracked folder if it is not owner-only.`
22970
+ );
22971
+ }
22972
+ const result = commitSinglePath(projectRoot, path7, `docs: register ${path7}`);
22973
+ if (!result.committed) {
22974
+ return warn(
22975
+ `Committing \`${path7}\` failed: ${result.message}`,
22976
+ `run \`git add ${path7} && git commit -m "docs: register ${path7}"\` yourself.`
22977
+ );
22978
+ }
22979
+ return `
22980
+ - **Durability:** committed \`${path7}\` (was untracked)`;
22981
+ }
22779
22982
  async function handleDocRegister(adapter2, args, config2) {
22780
22983
  const adapterType = config2?.adapterType ?? "unknown";
22781
22984
  const continueHint = "doc_register is advisory \u2014 your build/plan/review flow is unaffected. Fix the input (or wait for the registry to recover) and re-run doc_register; or just continue without it.";
@@ -22832,6 +23035,12 @@ async function handleDocRegister(adapter2, args, config2) {
22832
23035
  visibility
22833
23036
  });
22834
23037
  const visibilityLabel = entry.visibility === "contributors" ? "team member" : entry.visibility ?? "private";
23038
+ let durability = "";
23039
+ try {
23040
+ durability = ensureDocDurable(entry.path, config2?.projectRoot);
23041
+ } catch {
23042
+ durability = "";
23043
+ }
22835
23044
  return textResponse(
22836
23045
  `**Registered:** ${entry.title}
22837
23046
  - **Path:** ${entry.path}
@@ -22839,7 +23048,7 @@ async function handleDocRegister(adapter2, args, config2) {
22839
23048
  - **Visibility:** ${visibilityLabel}
22840
23049
  - **Tags:** ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}
22841
23050
  - **Actions:** ${actions?.length ?? 0} items
22842
- - **ID:** ${entry.id}`
23051
+ - **ID:** ${entry.id}` + durability
22843
23052
  );
22844
23053
  } catch (err) {
22845
23054
  const message = err instanceof Error ? err.message : String(err);
@@ -23206,6 +23415,22 @@ var buildExecuteTool = {
23206
23415
  type: "boolean",
23207
23416
  description: "Light-ceremony mode for XS/S tasks. Skips feature branch creation and PR. Work stays on current branch. Build report is still captured. Default false."
23208
23417
  },
23418
+ decision: {
23419
+ type: "object",
23420
+ description: "task-2933: answer a loop-detection gate. After N failed build attempts (default 3, PAPI_BUILD_LOOP_THRESHOLD) build_execute REFUSES to start and asks for a decision. Re-run with this to answer. Only meaningful on a gated task.",
23421
+ properties: {
23422
+ answer: {
23423
+ type: "string",
23424
+ enum: ["retry-differently", "skip", "stop"],
23425
+ description: '"retry-differently" clears the gate and injects `guidance` verbatim into the next handoff. "skip"/"stop" leave the task Blocked on the board rather than pretending it is in flight.'
23426
+ },
23427
+ guidance: {
23428
+ type: "string",
23429
+ description: 'What to do differently. Required in spirit for "retry-differently" \u2014 it is the whole point of the retry. Rendered to the next builder as quoted human direction.'
23430
+ }
23431
+ },
23432
+ required: ["answer"]
23433
+ },
23209
23434
  completed: {
23210
23435
  type: "string",
23211
23436
  enum: ["yes", "no", "partial"],
@@ -23550,7 +23775,19 @@ async function handleBuildExecute(adapter2, config2, args, clientName) {
23550
23775
  if (existing) resumeNote = formatResumeNote(existing);
23551
23776
  }
23552
23777
  await tracker.recordStep("started");
23553
- const result = await startBuild(adapter2, config2, taskId, { light }, clientName);
23778
+ const decision = args.decision;
23779
+ const result = await startBuild(
23780
+ adapter2,
23781
+ config2,
23782
+ taskId,
23783
+ {
23784
+ light,
23785
+ // Only pass a decision the schema recognises — an unknown answer must not
23786
+ // reach the resolver and silently clear a gate.
23787
+ decision: decision?.answer && BUILD_DECISION_ANSWERS.includes(decision.answer) ? { answer: decision.answer, guidance: decision.guidance } : void 0
23788
+ },
23789
+ clientName
23790
+ );
23554
23791
  tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.task.cycle ?? null });
23555
23792
  await tracker.recordStep("branch_ready");
23556
23793
  tracker.mark("start_decorate_handoff");
@@ -24048,7 +24285,7 @@ var ideaTool = {
24048
24285
  },
24049
24286
  project: {
24050
24287
  type: "string",
24051
- description: "Project id (UUID) or slug to write this idea to, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. Use project_switch to change the session default."
24288
+ description: "Project id (UUID) or slug to write this idea to, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. Use project_switch to change the session default. PASS THIS WHENEVER YOU KNOW WHICH REPO THE SESSION IS IN: on a multi-project account a connection with no project bound cannot be resolved, and PAPI will stop and ask rather than guess."
24052
24289
  }
24053
24290
  },
24054
24291
  required: ["text"]
@@ -24495,7 +24732,7 @@ var backlogImportTool = {
24495
24732
  },
24496
24733
  project: {
24497
24734
  type: "string",
24498
- description: "Project id (UUID) or slug to import into, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise."
24735
+ description: "Project id (UUID) or slug to import into, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. PASS THIS WHENEVER YOU KNOW WHICH REPO THE SESSION IS IN: on a multi-project account a connection with no project bound cannot be resolved, and PAPI will stop and ask rather than guess."
24499
24736
  }
24500
24737
  },
24501
24738
  required: ["source"]
@@ -24703,7 +24940,7 @@ var bugTool = {
24703
24940
  },
24704
24941
  project: {
24705
24942
  type: "string",
24706
- description: "BOARD MODE ONLY. Project id (UUID) or slug to file this bug under, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. It CANNOT redirect an upstream (report=true) submission \u2014 those always go to PAPI maintainers. Use project_switch to change the session default."
24943
+ description: "BOARD MODE ONLY. Project id (UUID) or slug to file this bug under, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. It CANNOT redirect an upstream (report=true) submission \u2014 those always go to PAPI maintainers. Use project_switch to change the session default. PASS THIS WHENEVER YOU KNOW WHICH REPO THE SESSION IS IN: on a multi-project account a connection with no project bound cannot be resolved, and PAPI will stop and ask rather than guess."
24707
24944
  }
24708
24945
  },
24709
24946
  required: ["text"]
@@ -27606,6 +27843,50 @@ function formatUnblockSection(candidates) {
27606
27843
  return lines.join("\n");
27607
27844
  }
27608
27845
 
27846
+ // src/lib/carry-forward-shape.ts
27847
+ var PRODUCT_MARKER = /WHAT SHIPS FOR USERS\b[^\n:]*:/i;
27848
+ var MECHANICS_MARKER = /RELEASE MECHANICS\b[^\n:]*:/i;
27849
+ function splitCarryForward(raw) {
27850
+ const productMatch = PRODUCT_MARKER.exec(raw);
27851
+ const mechanicsMatch = MECHANICS_MARKER.exec(raw);
27852
+ if (!productMatch && !mechanicsMatch) {
27853
+ return { product: raw.trim(), mechanics: "", split: false };
27854
+ }
27855
+ if (!productMatch && mechanicsMatch) {
27856
+ const head = raw.slice(0, mechanicsMatch.index).trim();
27857
+ const tail = raw.slice(mechanicsMatch.index + mechanicsMatch[0].length).trim();
27858
+ return { product: head, mechanics: tail, split: true };
27859
+ }
27860
+ const productStart = productMatch.index + productMatch[0].length;
27861
+ if (!mechanicsMatch) {
27862
+ return { product: raw.slice(productStart).trim(), mechanics: "", split: true };
27863
+ }
27864
+ if (mechanicsMatch.index < productMatch.index) {
27865
+ return {
27866
+ product: raw.slice(productStart).trim(),
27867
+ mechanics: raw.slice(mechanicsMatch.index + mechanicsMatch[0].length, productMatch.index).trim(),
27868
+ split: true
27869
+ };
27870
+ }
27871
+ return {
27872
+ product: raw.slice(productStart, mechanicsMatch.index).trim(),
27873
+ mechanics: raw.slice(mechanicsMatch.index + mechanicsMatch[0].length).trim(),
27874
+ split: true
27875
+ };
27876
+ }
27877
+ function renderCarryForward(raw, decorate = (t) => t) {
27878
+ const { product, mechanics, split } = splitCarryForward(raw);
27879
+ if (!split) return [decorate(product)];
27880
+ const lines = [];
27881
+ if (product) lines.push(decorate(product));
27882
+ if (mechanics) {
27883
+ if (product) lines.push("");
27884
+ lines.push("**Release mechanics** \u2014 needed at release, not now:");
27885
+ lines.push(decorate(mechanics));
27886
+ }
27887
+ return lines;
27888
+ }
27889
+
27609
27890
  // src/lib/deferred-gate.ts
27610
27891
  var GATE_PHRASES = [
27611
27892
  "depends on",
@@ -28146,6 +28427,20 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
28146
28427
  lines.push(`**In Review:** ${health.inReviewSummary}`);
28147
28428
  lines.push("");
28148
28429
  }
28430
+ if (buildInfo.pendingDecisions.length > 0) {
28431
+ const n = buildInfo.pendingDecisions.length;
28432
+ lines.push("## Decision required");
28433
+ lines.push(
28434
+ `${n} task${n === 1 ? " has" : "s have"} stopped after repeated failed builds. PAPI will not retry until you answer.`
28435
+ );
28436
+ for (const d of buildInfo.pendingDecisions) {
28437
+ lines.push(`- **${d.id}:** ${d.title} \u2014 ${d.attempts} failed attempts`);
28438
+ }
28439
+ lines.push(
28440
+ 'Answer with `build_execute <task> decision:{answer:"retry-differently", guidance:"\u2026"}` to pick it back up, or `"skip"` / `"stop"` to leave it parked.'
28441
+ );
28442
+ lines.push("");
28443
+ }
28149
28444
  if (buildInfo.isEmpty) {
28150
28445
  lines.push("## Tasks");
28151
28446
  if (buildInfo.currentCycle === 0) {
@@ -28171,7 +28466,7 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
28171
28466
  const hasCarryForward = health.carryForward !== "None found" && !health.carryForward.startsWith("No carry-forward");
28172
28467
  if (hasCarryForward) {
28173
28468
  lines.push("## Carry-Forward");
28174
- lines.push(annotateTaskRefs(health.carryForward, taskRefs));
28469
+ lines.push(...renderCarryForward(health.carryForward, (t) => annotateTaskRefs(t, taskRefs)));
28175
28470
  lines.push("");
28176
28471
  }
28177
28472
  const hasMetrics = health.metricsSection !== "Could not read methodology metrics." && !health.metricsSection.includes("undefined");
@@ -28414,6 +28709,19 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
28414
28709
  inReview: healthResult.inReviewSummary,
28415
28710
  backlogCount: buildResult.backlog.length,
28416
28711
  blockedCount: buildResult.blocked.length,
28712
+ // task-2933: computed here where the raw tasks are in scope. Best-effort —
28713
+ // a malformed blocker must never break orient.
28714
+ pendingDecisions: allTasks.filter((t) => {
28715
+ try {
28716
+ return isDecisionPending(t);
28717
+ } catch {
28718
+ return false;
28719
+ }
28720
+ }).map((t) => ({
28721
+ id: t.displayId ?? t.id,
28722
+ title: t.title,
28723
+ attempts: readDecision(t)?.attempts ?? 0
28724
+ })),
28417
28725
  totalHandoffs: buildResult.sorted.length + buildResult.blocked.length,
28418
28726
  currentCycle,
28419
28727
  isEmpty: buildResult.isEmpty,
@@ -30113,6 +30421,36 @@ ${d.body}`;
30113
30421
  ${formatted}`, meta));
30114
30422
  }
30115
30423
 
30424
+ // src/lib/dist-staleness.ts
30425
+ import { statSync as statSync8 } from "fs";
30426
+ import { fileURLToPath as fileURLToPath3 } from "url";
30427
+ var BOOT_MS = Date.now();
30428
+ var DEFAULT_SKEW_MS = 2e3;
30429
+ var STAT_TTL_MS = 1e4;
30430
+ function isDistStale({ bootMs, distMtimeMs, skewMs = DEFAULT_SKEW_MS }) {
30431
+ if (distMtimeMs === null || !Number.isFinite(distMtimeMs)) return false;
30432
+ return distMtimeMs > bootMs + skewMs;
30433
+ }
30434
+ var cached = null;
30435
+ function checkDistStaleness(now = Date.now()) {
30436
+ if (cached && now - cached.at < STAT_TTL_MS) return cached.stale;
30437
+ let distMtimeMs = null;
30438
+ try {
30439
+ distMtimeMs = statSync8(fileURLToPath3(import.meta.url)).mtimeMs;
30440
+ } catch {
30441
+ distMtimeMs = null;
30442
+ }
30443
+ const stale = isDistStale({ bootMs: BOOT_MS, distMtimeMs });
30444
+ cached = { at: now, stale };
30445
+ return stale;
30446
+ }
30447
+ function stalenessWarning(now = Date.now()) {
30448
+ const age = Math.round((now - BOOT_MS) / 1e3);
30449
+ return `\u26A0\uFE0F This PAPI MCP server process booted ${age}s ago and packages/server has been REBUILT since. It is still running the code it booted with, so anything you verify through these tools right now reflects the OLD bundle. Reconnect / restart the PAPI MCP server before trusting this output.
30450
+
30451
+ `;
30452
+ }
30453
+
30116
30454
  // src/tools/learning-action.ts
30117
30455
  var learningActionTool = {
30118
30456
  name: "learning_action",
@@ -31084,9 +31422,9 @@ async function checkMeter(adapter2, toolName, cacheKey) {
31084
31422
  if (!adapter2.getMeteredUsage) return { blocked: false };
31085
31423
  try {
31086
31424
  let usage;
31087
- const cached = usageCache.get(cacheKey);
31088
- if (cached && Date.now() - cached.at < CACHE_TTL_MS3) {
31089
- usage = cached.usage;
31425
+ const cached2 = usageCache.get(cacheKey);
31426
+ if (cached2 && Date.now() - cached2.at < CACHE_TTL_MS3) {
31427
+ usage = cached2.usage;
31090
31428
  } else {
31091
31429
  usage = await adapter2.getMeteredUsage();
31092
31430
  usageCache.set(cacheKey, { usage, at: Date.now() });
@@ -31275,7 +31613,7 @@ function getToolMetadata() {
31275
31613
  return PAPI_TOOLS.map((t) => ({ name: t.name, description: t.description }));
31276
31614
  }
31277
31615
  function createServer(adapter2, config2) {
31278
- const __pkgFilename = fileURLToPath3(import.meta.url);
31616
+ const __pkgFilename = fileURLToPath4(import.meta.url);
31279
31617
  const __pkgDir = dirname5(__pkgFilename);
31280
31618
  let serverVersion = "unknown";
31281
31619
  try {
@@ -31295,7 +31633,7 @@ function createServer(adapter2, config2) {
31295
31633
  "\n\u26A0 PAPI is running in md mode \u2014 your cycles are not visible on the hosted dashboard.\n Configure DATABASE_URL or sign up at https://getpapi.ai/setup to enable observability.\n\n"
31296
31634
  );
31297
31635
  }
31298
- const __filename = fileURLToPath3(import.meta.url);
31636
+ const __filename = fileURLToPath4(import.meta.url);
31299
31637
  const __dirname2 = dirname5(__filename);
31300
31638
  const skillsDir = join20(__dirname2, "..", "skills");
31301
31639
  function parseSkillFrontmatter(content) {
@@ -31597,6 +31935,15 @@ ${usageLine(decision.usage)}`;
31597
31935
  }
31598
31936
  const footer = formatMetricsFooter(elapsed, usage, contextBytes);
31599
31937
  result.content.push({ type: "text", text: footer });
31938
+ try {
31939
+ if (checkDistStaleness() && result.content.length > 0) {
31940
+ const first = result.content[0];
31941
+ if (first && typeof first.text === "string") {
31942
+ first.text = stalenessWarning() + first.text;
31943
+ }
31944
+ }
31945
+ } catch {
31946
+ }
31600
31947
  return result;
31601
31948
  });
31602
31949
  return server2;
@@ -32042,7 +32389,7 @@ async function dispatchRequest(args) {
32042
32389
  }
32043
32390
 
32044
32391
  // src/index.ts
32045
- var __dirname = dirname6(fileURLToPath4(import.meta.url));
32392
+ var __dirname = dirname6(fileURLToPath5(import.meta.url));
32046
32393
  var pkgVersion = "unknown";
32047
32394
  try {
32048
32395
  const pkg = JSON.parse(readFileSync18(join25(__dirname, "..", "package.json"), "utf-8"));
package/dist/prompts.js CHANGED
@@ -69,7 +69,7 @@ PRE-BUILD VERIFICATION
69
69
  [List 2-5 specific file paths the builder should read BEFORE implementing to check if the functionality already exists. Derive these from FILES LIKELY TOUCHED \u2014 pick the files most likely to already contain the target functionality. ALSO mandate a docs sweep, not just file-existence (task-2161): name any docs the builder should check via doc_search or the docs index \u2014 a design/research/status:final doc or a prior task may already cover the work. If >80% of the scope is already implemented, the builder should report "already built" instead of re-implementing. Include this section for EVERY task \u2014 it prevents wasted build slots on already-shipped code.]
70
70
 
71
71
  FILES LIKELY TOUCHED
72
- [files \u2014 real paths only. TEST FILE LOCATIONS (task-2606): PAPI tests are NOT co-located next to source in a src/**/__tests__/ folder. Use the actual layout: server tests \u2192 packages/server/tests/<name>.test.ts; adapter-pg tests \u2192 packages/adapter-pg/src/__tests__/<name>.test.ts; dashboard/root tests \u2192 tests/<area>/<name>.test.ts. Do NOT invent packages/server/src/**/__tests__/ paths \u2014 they do not exist.]
72
+ [files \u2014 real paths only. TEST FILE LOCATIONS (task-2606): PAPI tests are NOT co-located next to source in a src/**/__tests__/ folder. Use the actual layout: server tests \u2192 packages/server/tests/<name>.test.ts; adapter-pg tests \u2192 packages/adapter-pg/src/__tests__/<name>.test.ts; dashboard/root tests \u2192 tests/<area>/<name>.test.ts. Do NOT invent packages/server/src/**/__tests__/ paths \u2014 they do not exist. MCP RESTART RULE (task-2978): whenever FILES LIKELY TOUCHED includes any \`packages/server/**\` path, append this line verbatim to the handoff \u2014 "MCP RESTART REQUIRED: a mid-session \`npm run mcp-build\` writes a dist the already-booted MCP process never loads, so verifying this change through PAPI's own tools before an explicit MCP reconnect is INVALID. Implement \u2192 mcp-build \u2192 reconnect \u2192 only then verify." This is one line inside this section, not a new section.]
73
73
 
74
74
  EFFORT
75
75
  [XS/S/M/L/XL]
@@ -83,7 +83,7 @@ After your natural language output, include this EXACT format on its own line:
83
83
  {
84
84
  "cycleLogTitle": "string \u2014 short descriptive title WITHOUT 'Cycle N' prefix. Should capture the cycle theme in 3-5 words (e.g. 'MCP Quality + Product Readiness' not 'Cycle 5 \u2014 Board Triage \u2014 Bug Fix'). This is the canonical theme label for the cycle.",
85
85
  "cycleLogContent": "string \u2014 5-10 line cycle log body in markdown, NO heading (the ### heading is generated automatically)",
86
- "cycleLogCarryForward": "string or null \u2014 carry-forward items for next cycle",
86
+ "cycleLogCarryForward": "string or null \u2014 carry-forward for the next cycle, in TWO NAMED SECTIONS, product signal FIRST. Section 1 begins with the literal label 'WHAT SHIPS FOR USERS:' and names, concretely, what a person using this project can now see or do, and how to check it \u2014 never a deploy step. Section 2 begins with the literal label 'RELEASE MECHANICS:' and carries everything operational: deploy, publish, migrations, gate order, branch counts, spot-checks. The mechanics are load-bearing and must NOT be dropped or shortened \u2014 they go second, not away. orient parses these two labels and renders the product half first, so the labels are a contract, not decoration. If a cycle genuinely ships nothing user-visible, say so in one line under section 1 rather than omitting the label.",
87
87
  "cycleLogNotes": "string or null \u2014 1-3 lines of cycle-level observations: estimation accuracy, recurring blockers, velocity trends, dependency signals. Omit if no noteworthy observations.",
88
88
  "nextMode": "Full",
89
89
  "boardHealth": "string \u2014 e.g. 5 tasks (3 backlog, 2 done)",
@@ -947,7 +947,7 @@ After your natural language output, include this EXACT format on its own line:
947
947
  \`\`\`json
948
948
  {
949
949
  "sessionLogTitle": "string \u2014 Strategy Review title WITHOUT 'Cycle N' prefix (e.g. 'Strategy Review' not 'Cycle 5 \u2014 Strategy Review')",
950
- "sessionLogContent": "string \u2014 5-10 line cycle log body summarizing the review, NO heading (the ### heading is generated automatically)",
950
+ "sessionLogContent": "string \u2014 5-10 line cycle log body summarizing the review, NO heading (the ### heading is generated automatically). LEAD WITH TANGIBLE PRODUCT INSIGHT: open with what was learned about the product, its users, or the market \u2014 a specific finding a reader can act on. Board hygiene, cadence, cycle counts, branch state and other housekeeping are legitimate content but they TRAIL; they must never be the opening line. If the review's most important finding genuinely is an operational one, say why it matters to the product in the same breath rather than reporting it as admin.",
951
951
  "velocityAssessment": "string \u2014 2-3 sentence velocity summary",
952
952
  "strategicRecommendations": "string \u2014 key recommendations in markdown",
953
953
  "activeDecisionUpdates": [
@@ -1311,7 +1311,7 @@ REFERENCE DOCS
1311
1311
  [Optional \u2014 paths to docs/ files with background context. Omit if not needed.]
1312
1312
 
1313
1313
  FILES LIKELY TOUCHED
1314
- [files \u2014 real paths only. TEST FILE LOCATIONS (task-2606): PAPI tests are NOT co-located next to source in a src/**/__tests__/ folder. Use the actual layout: server tests \u2192 packages/server/tests/<name>.test.ts; adapter-pg tests \u2192 packages/adapter-pg/src/__tests__/<name>.test.ts; dashboard/root tests \u2192 tests/<area>/<name>.test.ts. Do NOT invent packages/server/src/**/__tests__/ paths \u2014 they do not exist.]
1314
+ [files \u2014 real paths only. TEST FILE LOCATIONS (task-2606): PAPI tests are NOT co-located next to source in a src/**/__tests__/ folder. Use the actual layout: server tests \u2192 packages/server/tests/<name>.test.ts; adapter-pg tests \u2192 packages/adapter-pg/src/__tests__/<name>.test.ts; dashboard/root tests \u2192 tests/<area>/<name>.test.ts. Do NOT invent packages/server/src/**/__tests__/ paths \u2014 they do not exist. MCP RESTART RULE (task-2978): whenever FILES LIKELY TOUCHED includes any \`packages/server/**\` path, append this line verbatim to the handoff \u2014 "MCP RESTART REQUIRED: a mid-session \`npm run mcp-build\` writes a dist the already-booted MCP process never loads, so verifying this change through PAPI's own tools before an explicit MCP reconnect is INVALID. Implement \u2192 mcp-build \u2192 reconnect \u2192 only then verify." This is one line inside this section, not a new section.]
1315
1315
 
1316
1316
  EFFORT
1317
1317
  [XS/S/M/L/XL]`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.73",
3
+ "version": "0.7.75",
4
4
  "description": "PAPI MCP server — AI-powered sprint planning, build execution, and strategy review for software projects",
5
5
  "license": "Elastic-2.0",
6
6
  "mcpName": "io.github.getpapi/papi",