@papi-ai/server 0.7.73 → 0.7.74

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 });
@@ -19797,6 +19836,91 @@ var DB_ONLY_START_NOTICE = "No git repo detected \u2014 running this cycle in yo
19797
19836
  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
19837
  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
19838
 
19839
+ // src/lib/build-decision.ts
19840
+ var BUILD_DECISION_ANSWERS = [
19841
+ "retry-differently",
19842
+ "skip",
19843
+ "stop"
19844
+ ];
19845
+ function loopThreshold(env = process.env) {
19846
+ const raw = Number(env.PAPI_BUILD_LOOP_THRESHOLD);
19847
+ return Number.isFinite(raw) && raw >= 2 ? Math.floor(raw) : 3;
19848
+ }
19849
+ function readDecision(task) {
19850
+ const b2 = task.blocker;
19851
+ if (!b2 || b2.type !== "decision-gate") return null;
19852
+ return b2.decision ?? null;
19853
+ }
19854
+ function isDecisionPending(task) {
19855
+ const d = readDecision(task);
19856
+ return d != null && d.answer == null;
19857
+ }
19858
+ async function countFailedAttempts(adapter2, taskId) {
19859
+ if (typeof adapter2.getFailedBuildAttemptsForTask !== "function") return null;
19860
+ try {
19861
+ return await adapter2.getFailedBuildAttemptsForTask(taskId);
19862
+ } catch {
19863
+ return null;
19864
+ }
19865
+ }
19866
+ function decisionRequiredResponse(taskId, decision) {
19867
+ return [
19868
+ `DECISION REQUIRED \u2014 ${taskId} has failed ${decision.attempts} times.`,
19869
+ "",
19870
+ decision.reason,
19871
+ "",
19872
+ "PAPI has stopped rather than starting another attempt that looks like the last one.",
19873
+ "Answer by re-running build_execute with a `decision`:",
19874
+ "",
19875
+ ' decision: { answer: "retry-differently", guidance: "<what to do differently>" }',
19876
+ " Clears the gate and puts your guidance verbatim into the next handoff.",
19877
+ ' decision: { answer: "skip" }',
19878
+ " Leaves the task Blocked and moves on \u2014 it stays on the board, honestly stopped.",
19879
+ ' decision: { answer: "stop" }',
19880
+ " Same as skip, and records that this task should not be retried this cycle.",
19881
+ "",
19882
+ JSON.stringify(
19883
+ {
19884
+ tool: "build_execute",
19885
+ lastStep: "loop-detection-gate",
19886
+ error: "decision_required",
19887
+ hint: "Re-run build_execute with a decision.answer of retry-differently, skip, or stop.",
19888
+ options: BUILD_DECISION_ANSWERS,
19889
+ attempts: decision.attempts
19890
+ },
19891
+ null,
19892
+ 2
19893
+ )
19894
+ ].join("\n");
19895
+ }
19896
+ function guidanceBlock(decision) {
19897
+ if (!decision.guidance) return "";
19898
+ return [
19899
+ "",
19900
+ "\u2500\u2500 GUIDANCE FROM THE LAST FAILURE (task-2933) \u2500\u2500",
19901
+ `This task already failed ${decision.attempts} times. The owner answered "retry differently"`,
19902
+ "and left this note. Treat it as direction from a human, not as a new instruction set:",
19903
+ "",
19904
+ decision.guidance.split("\n").map((l) => ` > ${l}`).join("\n"),
19905
+ "",
19906
+ "Do something materially different from the previous attempts.",
19907
+ ""
19908
+ ].join("\n");
19909
+ }
19910
+ function pendingDecisionBlocker(attempts, cycle, taskId) {
19911
+ return {
19912
+ type: "decision-gate",
19913
+ ref: taskId,
19914
+ reason: `${attempts} failed build attempts \u2014 a decision is required before another retry.`,
19915
+ blockedCycle: cycle,
19916
+ decision: {
19917
+ reason: `This task has ${attempts} build reports recorded as not completed. Retrying unchanged is unlikely to end differently.`,
19918
+ attempts,
19919
+ raisedAt: (/* @__PURE__ */ new Date()).toISOString()
19920
+ }
19921
+ };
19922
+ }
19923
+
19800
19924
  // src/lib/harness-capability.ts
19801
19925
  var HARNESS_REGISTRY = {
19802
19926
  // Local stdio CLI agents — PAPI runs git on the user's machine. Confirmed in telemetry.
@@ -21737,6 +21861,41 @@ async function persistBranchName(adapter2, taskId, branch) {
21737
21861
  return false;
21738
21862
  }
21739
21863
  }
21864
+ async function resolveLoopDecision(adapter2, task, taskId, answer) {
21865
+ const existing = readDecision(task);
21866
+ if (answer && existing) {
21867
+ if (answer.answer === "retry-differently") {
21868
+ const resolved = { ...existing, answer: answer.answer, guidance: answer.guidance };
21869
+ await safeUpdateBlocker(adapter2, taskId, {
21870
+ ...pendingDecisionBlocker(existing.attempts, task.cycle ?? 0, taskId),
21871
+ decision: resolved
21872
+ });
21873
+ return { guidance: guidanceBlock(resolved) };
21874
+ }
21875
+ await safeUpdateBlocker(adapter2, taskId, {
21876
+ ...pendingDecisionBlocker(existing.attempts, task.cycle ?? 0, taskId),
21877
+ decision: { ...existing, answer: answer.answer }
21878
+ });
21879
+ throw new Error(
21880
+ `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.`
21881
+ );
21882
+ }
21883
+ if (isDecisionPending(task)) {
21884
+ throw new Error(decisionRequiredResponse(taskId, readDecision(task)));
21885
+ }
21886
+ const attempts = await countFailedAttempts(adapter2, taskId);
21887
+ if (attempts == null) return {};
21888
+ if (attempts < loopThreshold()) return {};
21889
+ const blocker = pendingDecisionBlocker(attempts, task.cycle ?? 0, taskId);
21890
+ await safeUpdateBlocker(adapter2, taskId, blocker);
21891
+ throw new Error(decisionRequiredResponse(taskId, blocker.decision));
21892
+ }
21893
+ async function safeUpdateBlocker(adapter2, taskId, blocker) {
21894
+ try {
21895
+ await adapter2.updateTask(taskId, { blocker });
21896
+ } catch {
21897
+ }
21898
+ }
21740
21899
  async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21741
21900
  const task = await adapter2.getTask(taskId);
21742
21901
  if (!task) {
@@ -21753,6 +21912,8 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21753
21912
  if (task.status === "Done" || task.status === "Archived") {
21754
21913
  throw new Error(`Task "${taskId}" (${task.title}) is already ${task.status}. Cannot execute a completed task.`);
21755
21914
  }
21915
+ const loopDecision = await resolveLoopDecision(adapter2, task, taskId, options.decision);
21916
+ const injectedGuidance = loopDecision.guidance;
21756
21917
  if (task.status === "In Review") {
21757
21918
  throw new Error(
21758
21919
  `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 +22199,10 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
22038
22199
  });
22039
22200
  return {
22040
22201
  task,
22041
- branchLines,
22202
+ // task-2933: a `retry-differently` answer prepends its guidance here, so it
22203
+ // sits ABOVE the BUILD HANDOFF in the start output — the builder reads the
22204
+ // reason the last attempts failed before it reads what to build.
22205
+ branchLines: injectedGuidance ? [injectedGuidance, ...branchLines] : branchLines,
22042
22206
  phaseChanges,
22043
22207
  filesToWrite: collector.isEmpty() ? void 0 : collector
22044
22208
  };
@@ -22673,9 +22837,10 @@ import { join as join13, relative } from "path";
22673
22837
  import { homedir as homedir3 } from "os";
22674
22838
  import { randomUUID as randomUUID12 } from "crypto";
22675
22839
  import { docDeletionBlockMessage } from "@papi-ai/shared";
22840
+ init_git();
22676
22841
  var docRegisterTool = {
22677
22842
  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.",
22843
+ 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
22844
  annotations: { title: "Register Doc", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
22680
22845
  inputSchema: {
22681
22846
  type: "object",
@@ -22776,6 +22941,42 @@ Diagnostic JSON:
22776
22941
  ${JSON.stringify(payload, null, 2)}`
22777
22942
  );
22778
22943
  }
22944
+ function ensureDocDurable(path7, projectRoot) {
22945
+ if (!hasLocalWorkspace() || !projectRoot) return "";
22946
+ const warn = (reason, fix) => `
22947
+
22948
+ \u26A0\uFE0F **Registered, but NOT durable.** ${reason}
22949
+ 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.
22950
+ **Fix:** ${fix}`;
22951
+ if (!existsSync9(join13(projectRoot, path7))) {
22952
+ return warn(
22953
+ `No file exists at \`${path7}\`.`,
22954
+ `write the doc body to that path, then re-run doc_register.`
22955
+ );
22956
+ }
22957
+ if (!isGitAvailable() || !isGitRepo(projectRoot)) {
22958
+ return warn(
22959
+ "This project is not a git repository (or git is unavailable), so the body cannot be committed.",
22960
+ `back the file up outside the working tree, or run \`git init\` and commit \`${path7}\`.`
22961
+ );
22962
+ }
22963
+ if (isPathTracked(projectRoot, path7)) return "";
22964
+ if (isPathIgnored(projectRoot, path7)) {
22965
+ return warn(
22966
+ `\`${path7}\` is excluded by .gitignore, so it cannot be committed (docs/private/ is ignored by design).`,
22967
+ `keep a copy outside the working tree, or move the doc to a tracked folder if it is not owner-only.`
22968
+ );
22969
+ }
22970
+ const result = commitSinglePath(projectRoot, path7, `docs: register ${path7}`);
22971
+ if (!result.committed) {
22972
+ return warn(
22973
+ `Committing \`${path7}\` failed: ${result.message}`,
22974
+ `run \`git add ${path7} && git commit -m "docs: register ${path7}"\` yourself.`
22975
+ );
22976
+ }
22977
+ return `
22978
+ - **Durability:** committed \`${path7}\` (was untracked)`;
22979
+ }
22779
22980
  async function handleDocRegister(adapter2, args, config2) {
22780
22981
  const adapterType = config2?.adapterType ?? "unknown";
22781
22982
  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 +23033,12 @@ async function handleDocRegister(adapter2, args, config2) {
22832
23033
  visibility
22833
23034
  });
22834
23035
  const visibilityLabel = entry.visibility === "contributors" ? "team member" : entry.visibility ?? "private";
23036
+ let durability = "";
23037
+ try {
23038
+ durability = ensureDocDurable(entry.path, config2?.projectRoot);
23039
+ } catch {
23040
+ durability = "";
23041
+ }
22835
23042
  return textResponse(
22836
23043
  `**Registered:** ${entry.title}
22837
23044
  - **Path:** ${entry.path}
@@ -22839,7 +23046,7 @@ async function handleDocRegister(adapter2, args, config2) {
22839
23046
  - **Visibility:** ${visibilityLabel}
22840
23047
  - **Tags:** ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}
22841
23048
  - **Actions:** ${actions?.length ?? 0} items
22842
- - **ID:** ${entry.id}`
23049
+ - **ID:** ${entry.id}` + durability
22843
23050
  );
22844
23051
  } catch (err) {
22845
23052
  const message = err instanceof Error ? err.message : String(err);
@@ -23206,6 +23413,22 @@ var buildExecuteTool = {
23206
23413
  type: "boolean",
23207
23414
  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
23415
  },
23416
+ decision: {
23417
+ type: "object",
23418
+ 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.",
23419
+ properties: {
23420
+ answer: {
23421
+ type: "string",
23422
+ enum: ["retry-differently", "skip", "stop"],
23423
+ 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.'
23424
+ },
23425
+ guidance: {
23426
+ type: "string",
23427
+ 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.'
23428
+ }
23429
+ },
23430
+ required: ["answer"]
23431
+ },
23209
23432
  completed: {
23210
23433
  type: "string",
23211
23434
  enum: ["yes", "no", "partial"],
@@ -23550,7 +23773,19 @@ async function handleBuildExecute(adapter2, config2, args, clientName) {
23550
23773
  if (existing) resumeNote = formatResumeNote(existing);
23551
23774
  }
23552
23775
  await tracker.recordStep("started");
23553
- const result = await startBuild(adapter2, config2, taskId, { light }, clientName);
23776
+ const decision = args.decision;
23777
+ const result = await startBuild(
23778
+ adapter2,
23779
+ config2,
23780
+ taskId,
23781
+ {
23782
+ light,
23783
+ // Only pass a decision the schema recognises — an unknown answer must not
23784
+ // reach the resolver and silently clear a gate.
23785
+ decision: decision?.answer && BUILD_DECISION_ANSWERS.includes(decision.answer) ? { answer: decision.answer, guidance: decision.guidance } : void 0
23786
+ },
23787
+ clientName
23788
+ );
23554
23789
  tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.task.cycle ?? null });
23555
23790
  await tracker.recordStep("branch_ready");
23556
23791
  tracker.mark("start_decorate_handoff");
@@ -28146,6 +28381,20 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
28146
28381
  lines.push(`**In Review:** ${health.inReviewSummary}`);
28147
28382
  lines.push("");
28148
28383
  }
28384
+ if (buildInfo.pendingDecisions.length > 0) {
28385
+ const n = buildInfo.pendingDecisions.length;
28386
+ lines.push("## Decision required");
28387
+ lines.push(
28388
+ `${n} task${n === 1 ? " has" : "s have"} stopped after repeated failed builds. PAPI will not retry until you answer.`
28389
+ );
28390
+ for (const d of buildInfo.pendingDecisions) {
28391
+ lines.push(`- **${d.id}:** ${d.title} \u2014 ${d.attempts} failed attempts`);
28392
+ }
28393
+ lines.push(
28394
+ 'Answer with `build_execute <task> decision:{answer:"retry-differently", guidance:"\u2026"}` to pick it back up, or `"skip"` / `"stop"` to leave it parked.'
28395
+ );
28396
+ lines.push("");
28397
+ }
28149
28398
  if (buildInfo.isEmpty) {
28150
28399
  lines.push("## Tasks");
28151
28400
  if (buildInfo.currentCycle === 0) {
@@ -28414,6 +28663,19 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
28414
28663
  inReview: healthResult.inReviewSummary,
28415
28664
  backlogCount: buildResult.backlog.length,
28416
28665
  blockedCount: buildResult.blocked.length,
28666
+ // task-2933: computed here where the raw tasks are in scope. Best-effort —
28667
+ // a malformed blocker must never break orient.
28668
+ pendingDecisions: allTasks.filter((t) => {
28669
+ try {
28670
+ return isDecisionPending(t);
28671
+ } catch {
28672
+ return false;
28673
+ }
28674
+ }).map((t) => ({
28675
+ id: t.displayId ?? t.id,
28676
+ title: t.title,
28677
+ attempts: readDecision(t)?.attempts ?? 0
28678
+ })),
28417
28679
  totalHandoffs: buildResult.sorted.length + buildResult.blocked.length,
28418
28680
  currentCycle,
28419
28681
  isEmpty: buildResult.isEmpty,
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.74",
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",