@papi-ai/server 0.7.69 → 0.7.70

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.
Files changed (2) hide show
  1. package/dist/index.js +568 -318
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -10729,6 +10729,14 @@ function validateHandoffScope(handoff) {
10729
10729
  if (!isMeaningful(handoff.scopeBoundary)) invalid.push("scopeBoundary");
10730
10730
  return invalid;
10731
10731
  }
10732
+ function assertHandoffApplyPayloadNonEmpty(data, cycleNumber) {
10733
+ const handoffCount = data.cycleHandoffs?.length ?? 0;
10734
+ if (handoffCount === 0) {
10735
+ throw new Error(
10736
+ `Handoff apply rejected: trimmed/empty apply payload \u2014 nothing persisted. The parsed output for Cycle ${cycleNumber} carries NO handoffs (cycleHandoffs is empty), but handoff prepare only runs when at least one task needs a handoff \u2014 so the handoffs were dropped. The most likely cause is a truncated apply JSON: the output overflowed the client tool-result ceiling (see task-2905/2906). Re-run handoff_generate and resend the COMPLETE structured output \u2014 ensure the JSON after the <!-- PAPI_STRUCTURED_OUTPUT --> marker includes the full cycleHandoffs array.`
10737
+ );
10738
+ }
10739
+ }
10732
10740
  async function prepareHandoffs(adapter2, _config, taskIds, force = false) {
10733
10741
  const timer2 = startTimer();
10734
10742
  const cycles = await adapter2.readCycles();
@@ -10785,10 +10793,8 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false)
10785
10793
  if (!data) {
10786
10794
  throw new Error("Could not parse structured output. Ensure your output includes <!-- PAPI_STRUCTURED_OUTPUT --> with valid JSON.");
10787
10795
  }
10796
+ assertHandoffApplyPayloadNonEmpty(data, cycleNumber);
10788
10797
  const handoffs = data.cycleHandoffs ?? [];
10789
- if (handoffs.length === 0) {
10790
- throw new Error("No cycleHandoffs found in structured output. Ensure your output includes handoffs in the cycleHandoffs array.");
10791
- }
10792
10798
  const taskIdsToWrite = handoffs.map((h) => h.taskId);
10793
10799
  const existingHandoffSet = /* @__PURE__ */ new Set();
10794
10800
  try {
@@ -13662,6 +13668,58 @@ async function buildSessionGuidance(callerKey) {
13662
13668
  return signals.slice(0, 3);
13663
13669
  }
13664
13670
 
13671
+ // src/services/onboarding-coaching.ts
13672
+ var ONBOARDING_EARLY_CYCLE_MAX = 2;
13673
+ var MAX_COACHING_LINES = 4;
13674
+ var COACH_CONNECT_REPO = "No repository is linked to this project yet. Link your repo from the dashboard Settings (or capture it during `setup`) so builds, reviews, and releases attach to the right codebase.";
13675
+ var COACH_ROOT_DIR = "Before building, confirm this session is running from your project root directory, so commits and builds land against the right files.";
13676
+ var COACH_CLICKABLE_TASKS = "Task cards on your dashboard expand on click. Open one to read its full build handoff, comments, and history.";
13677
+ var COACH_OFF_CYCLE = 'When a request falls outside the current cycle, keep it in the loop: `idea "<what you want>"` parks it in the backlog, or promote it into the cycle and generate a build handoff before you build.';
13678
+ var COACH_SESSION_START = 'Start of a session: skim your Active Decisions (the AD summary from `orient`, or `strategy_review`) to reload your project direction, and capture anything new with `idea "<what you want>"`.';
13679
+ var COACH_NO_DECISIONS = 'No Active Decisions recorded yet. Capture your first with `idea "<a direction or constraint>"` so your project starts steering itself.';
13680
+ var COACH_BACKLOG_IMPORT = "Already tracking a backlog elsewhere (Linear, a CSV, a markdown checklist)? Bring it over in one pass with `backlog_import` instead of retyping it.";
13681
+ var ONBOARDING_COACHING_HEADING = "## Getting Started";
13682
+ function buildOnboardingCoaching(state) {
13683
+ const lines = [];
13684
+ const isEarly = state.cycleNumber <= ONBOARDING_EARLY_CYCLE_MAX;
13685
+ const { surface } = state;
13686
+ if (state.repoConnected === false && (surface === "orient" || surface === "plan")) {
13687
+ lines.push(COACH_CONNECT_REPO);
13688
+ }
13689
+ if (state.hasLocalWorkspace && (surface === "setup" || surface === "orient" && isEarly)) {
13690
+ lines.push(COACH_ROOT_DIR);
13691
+ }
13692
+ if ((surface === "orient" || surface === "setup") && isEarly) {
13693
+ lines.push(COACH_CLICKABLE_TASKS);
13694
+ }
13695
+ if ((surface === "orient" || surface === "plan") && state.hasActiveCycle && isEarly) {
13696
+ lines.push(COACH_OFF_CYCLE);
13697
+ }
13698
+ if (surface === "orient" && isEarly) {
13699
+ lines.push(state.hasActiveDecisions === false ? COACH_NO_DECISIONS : COACH_SESSION_START);
13700
+ }
13701
+ if (surface === "plan" && state.bootstrapPlan === true) {
13702
+ lines.push(COACH_BACKLOG_IMPORT);
13703
+ }
13704
+ return lines.slice(0, MAX_COACHING_LINES);
13705
+ }
13706
+ function formatOnboardingCoachingBlock(state) {
13707
+ const lines = buildOnboardingCoaching(state);
13708
+ if (lines.length === 0) return "";
13709
+ return `
13710
+
13711
+ ${ONBOARDING_COACHING_HEADING}
13712
+ ${lines.map((l) => `- ${l}`).join("\n")}`;
13713
+ }
13714
+
13715
+ // src/lib/hosted-mode.ts
13716
+ function isHostedTransport() {
13717
+ return Boolean(process.env.PORT || process.env.PAPI_HTTP_PORT);
13718
+ }
13719
+ function hasLocalWorkspace() {
13720
+ return !isHostedTransport();
13721
+ }
13722
+
13665
13723
  // src/lib/per-caller-cache.ts
13666
13724
  var DEFAULT_CALLER_KEY2 = "__default__";
13667
13725
  var MAX_PER_CALLER_ENTRIES = 1e3;
@@ -13905,6 +13963,10 @@ function formatPlanResult(result) {
13905
13963
  } else {
13906
13964
  lines.push("", `Next: run \`build_list\` to see your cycle tasks, then \`build_execute <task_id>\` to start building.`);
13907
13965
  }
13966
+ if (result.onboardingCoaching && result.onboardingCoaching.length > 0) {
13967
+ lines.push("", ONBOARDING_COACHING_HEADING);
13968
+ for (const c of result.onboardingCoaching) lines.push(`- ${c}`);
13969
+ }
13908
13970
  if (result.contextBytes !== void 0) {
13909
13971
  const kb = (result.contextBytes / 1024).toFixed(1);
13910
13972
  lines.push(`---`, `Context: ${kb}KB`);
@@ -13986,7 +14048,15 @@ async function handlePlan(adapter2, config2, args) {
13986
14048
  }, tracker);
13987
14049
  const planProjectInfo = adapter2.getProjectInfo ? await adapter2.getProjectInfo().catch(() => null) : null;
13988
14050
  const projectBanner = planProjectInfo ? getProjectConnectionBanner(planProjectInfo.name, planProjectInfo.slug) ?? void 0 : void 0;
13989
- const response = formatPlanResult({ ...result, contextUtilisation: utilisation, contextBytes, skipHandoffs, projectBanner });
14051
+ const onboardingCoaching = buildOnboardingCoaching({
14052
+ surface: "plan",
14053
+ repoConnected: planProjectInfo ? !!planProjectInfo.repo_url : void 0,
14054
+ hasLocalWorkspace: hasLocalWorkspace(),
14055
+ cycleNumber: result.cycleNumber + 1,
14056
+ hasActiveCycle: true,
14057
+ bootstrapPlan: result.mode === "bootstrap"
14058
+ });
14059
+ const response = formatPlanResult({ ...result, contextUtilisation: utilisation, contextBytes, skipHandoffs, projectBanner, onboardingCoaching });
13990
14060
  return {
13991
14061
  ...response,
13992
14062
  ...contextBytes !== void 0 ? { _contextBytes: contextBytes } : {},
@@ -14138,14 +14208,6 @@ import { existsSync as existsSync2, readdirSync, statSync as statSync2 } from "f
14138
14208
  import { join as join4 } from "path";
14139
14209
  import { homedir as homedir2 } from "os";
14140
14210
 
14141
- // src/lib/hosted-mode.ts
14142
- function isHostedTransport() {
14143
- return Boolean(process.env.PORT || process.env.PAPI_HTTP_PORT);
14144
- }
14145
- function hasLocalWorkspace() {
14146
- return !isHostedTransport();
14147
- }
14148
-
14149
14211
  // src/services/idea.ts
14150
14212
  import { randomUUID as randomUUID9 } from "crypto";
14151
14213
  var OWNER_ACTION_PATTERNS = [
@@ -15602,6 +15664,13 @@ ${cleanContent}`;
15602
15664
  } catch {
15603
15665
  }
15604
15666
  }
15667
+ if (data.hierarchyUpdates && data.hierarchyUpdates.length > 0) {
15668
+ try {
15669
+ await applyHierarchyUpdates(adapter2, data.hierarchyUpdates);
15670
+ } catch (err) {
15671
+ console.error("[strategy] applyHierarchyUpdates failed:", err instanceof Error ? err.message : String(err));
15672
+ }
15673
+ }
15605
15674
  const compressionThreshold = cycleNumber - 5;
15606
15675
  if (compressionThreshold > 0 && data.sessionLogCompressionSummary) {
15607
15676
  await adapter2.compressCycleLog(compressionThreshold, data.sessionLogCompressionSummary);
@@ -15879,6 +15948,79 @@ function buildPhaseLabel(phase) {
15879
15948
  if (!numMatch) return phase.label;
15880
15949
  return `Phase ${numMatch[1]}: ${phase.label}`;
15881
15950
  }
15951
+ function nextHierarchySort(existing) {
15952
+ return existing.length === 0 ? 10 : Math.max(...existing.map((e) => e.sortOrder)) + 10;
15953
+ }
15954
+ async function applyHierarchyUpdates(adapter2, updates) {
15955
+ if (!adapter2.readStages || !adapter2.readHorizons) return;
15956
+ const findHorizon = async (ref) => (await adapter2.readHorizons()).find(
15957
+ (h) => h.slug === ref || h.id === ref || h.label.toLowerCase() === ref.toLowerCase()
15958
+ );
15959
+ const findStage = async (ref) => (await adapter2.readStages()).find(
15960
+ (s) => s.slug === ref || s.id === ref || s.label.toLowerCase() === ref.toLowerCase()
15961
+ );
15962
+ for (const u of updates) {
15963
+ if (u.level === "horizon") {
15964
+ if (u.action === "create") {
15965
+ if (!await findHorizon(u.slug) && adapter2.createHorizon) {
15966
+ const horizons = await adapter2.readHorizons();
15967
+ await adapter2.createHorizon({
15968
+ slug: u.slug,
15969
+ label: u.label ?? u.slug,
15970
+ status: u.status ?? "In Progress",
15971
+ sortOrder: u.sortOrder ?? nextHierarchySort(horizons)
15972
+ });
15973
+ }
15974
+ } else if (u.action === "update_status" && u.status && adapter2.updateHorizonStatus) {
15975
+ const h = await findHorizon(u.slug);
15976
+ if (h) await adapter2.updateHorizonStatus(h.id, u.status);
15977
+ }
15978
+ continue;
15979
+ }
15980
+ if (u.action === "update_criterion" && u.criterionId && typeof u.met === "boolean" && adapter2.setCriterionMet) {
15981
+ const s = await findStage(u.slug);
15982
+ if (s) await adapter2.setCriterionMet(s.id, u.criterionId, u.met, u.evidence ?? null);
15983
+ } else if (u.action === "update_status" && u.status && adapter2.updateStageStatus) {
15984
+ const s = await findStage(u.slug);
15985
+ if (s) await adapter2.updateStageStatus(s.id, u.status);
15986
+ } else if (u.action === "create") {
15987
+ await createStageFromUpdate(adapter2, u, findStage, findHorizon);
15988
+ } else if (u.action === "advance" && adapter2.updateStageStatus) {
15989
+ const current = await findStage(u.slug);
15990
+ if (current) await adapter2.updateStageStatus(current.id, "Done");
15991
+ const newRef = u.newStageSlug ?? u.slug;
15992
+ const existingNext = await findStage(newRef);
15993
+ if (existingNext) {
15994
+ await adapter2.updateStageStatus(existingNext.id, "In Progress");
15995
+ } else if (u.newStageLabel) {
15996
+ await createStageFromUpdate(
15997
+ adapter2,
15998
+ { ...u, slug: newRef, label: u.newStageLabel, status: "In Progress", horizon: u.horizon ?? current?.horizonId },
15999
+ findStage,
16000
+ findHorizon
16001
+ );
16002
+ }
16003
+ }
16004
+ }
16005
+ }
16006
+ async function createStageFromUpdate(adapter2, u, findStage, findHorizon) {
16007
+ if (!adapter2.createStage || !adapter2.readStages || !adapter2.readHorizons) return;
16008
+ if (await findStage(u.slug)) return;
16009
+ const horizons = await adapter2.readHorizons();
16010
+ const parent = u.horizon ? await findHorizon(u.horizon) : horizons.length === 1 ? horizons[0] : horizons.find((h) => h.status === "In Progress");
16011
+ if (!parent) return;
16012
+ const stagesInHorizon = (await adapter2.readStages()).filter((s) => s.horizonId === parent.id);
16013
+ const id = await adapter2.createStage({
16014
+ slug: u.slug,
16015
+ label: u.label ?? u.slug,
16016
+ status: u.status ?? "Not Started",
16017
+ sortOrder: u.sortOrder ?? nextHierarchySort(stagesInHorizon),
16018
+ horizonId: parent.id
16019
+ });
16020
+ if (id && u.exitCriteria?.length && adapter2.updateStageExitCriteria) {
16021
+ await adapter2.updateStageExitCriteria(id, u.exitCriteria);
16022
+ }
16023
+ }
15882
16024
  async function applyPhaseUpdates(adapter2, currentPhases, updates) {
15883
16025
  const phasesById = new Map(currentPhases.map((p) => [p.id, p]));
15884
16026
  const labelMigrations = [];
@@ -19111,8 +19253,14 @@ ${result.warnings.map((w) => `- ${w}`).join("\n")}` : "";
19111
19253
  **Important:** This is a remote PAPI connection, so the server could not write to your project directory. Setup prepared your files (${harnessFiles}, .claude/settings.json, docs/) and returned them in the scaffolding section below \u2014 **write each one to disk, then commit** before running \`build_execute\` (it requires a clean working directory).` : `
19112
19254
 
19113
19255
  **Important:** Setup created/modified files (${harnessFiles}, .claude/settings.json, docs/). Commit these changes before running \`build_execute\` \u2014 it requires a clean working directory.`;
19256
+ const coachingNote = formatOnboardingCoachingBlock({
19257
+ surface: "setup",
19258
+ hasLocalWorkspace: hasLocalWorkspace(),
19259
+ cycleNumber: 0,
19260
+ hasActiveCycle: false
19261
+ });
19114
19262
  return textResponse(
19115
- `${prefix}Product Brief generated and saved.${briefRegenNote}${adNote}${northStarNote}${taskNote}${constraintsHint}${editorNote}${gitignoreNote}${warningsNote}${filesNote}
19263
+ `${prefix}Product Brief generated and saved.${briefRegenNote}${adNote}${northStarNote}${taskNote}${constraintsHint}${editorNote}${gitignoreNote}${warningsNote}${filesNote}${coachingNote}
19116
19264
 
19117
19265
  Tip: See \`docs/templates/example-project-brief.md\` for an example of a well-written brief.
19118
19266
 
@@ -19483,8 +19631,8 @@ function buildPapiMetaFramingDirective(caps, inner) {
19483
19631
 
19484
19632
  // src/services/build.ts
19485
19633
  import { randomUUID as randomUUID11 } from "crypto";
19486
- import { readdirSync as readdirSync5, existsSync as existsSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2, mkdirSync as mkdirSync2 } from "fs";
19487
- import { join as join11 } from "path";
19634
+ import { readdirSync as readdirSync5, existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, mkdirSync as mkdirSync3 } from "fs";
19635
+ import { join as join12 } from "path";
19488
19636
 
19489
19637
  // src/lib/db-only-notices.ts
19490
19638
  var DB_ONLY_START_NOTICE = "No git repo detected \u2014 running this cycle in your project database only. Your work stays in the working tree; run `git init` (and add a remote) to enable branches, commits and PR review.";
@@ -20402,6 +20550,38 @@ async function postReleaseToX(version, cycleClosed) {
20402
20550
  return false;
20403
20551
  }
20404
20552
  }
20553
+ function buildHostedReleaseOutput(params) {
20554
+ const { version, branch, cyclePart, warningsBlock, skipVersion } = params;
20555
+ const tagAnnotation = `Release ${version}`;
20556
+ const titleSuffix = skipVersion ? " (skip version)" : "";
20557
+ const gitHalf = skipVersion ? "the git half of the release (the CHANGELOG.md commit and the branch push) must run on your own machine" : "the git half of the release (tag, push, CHANGELOG.md) must run on your own machine";
20558
+ const commands = skipVersion ? `git checkout ${branch}
20559
+ git pull
20560
+ git push origin ${branch}
20561
+ ` : `git checkout ${branch}
20562
+ git pull
20563
+ git tag -a ${version} -m "${tagAnnotation}"
20564
+ git push origin ${branch}
20565
+ git push origin ${version}
20566
+ `;
20567
+ const identityBlock = skipVersion ? "" : `If git reports "Author identity unknown" or "Committer identity unknown" (no global git identity configured), pass your identity inline instead of writing global config:
20568
+ \`\`\`
20569
+ git -c user.name="Your Name" -c user.email="you@example.com" tag -a ${version} -m "${tagAnnotation}"
20570
+ \`\`\`
20571
+
20572
+ `;
20573
+ return `## Release ${version}${titleSuffix} \u2014 cycle closed in the DB
20574
+
20575
+ ${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
20576
+ ` + warningsBlock + `
20577
+ The hosted remote MCP transport (mcp.getpapi.ai) has no checkout of your project and no git binary, so ${gitHalf}.
20578
+
20579
+ Finish the release locally:
20580
+ \`\`\`
20581
+ ` + commands + `\`\`\`
20582
+
20583
+ ` + identityBlock + `Next: cycle closed! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`;
20584
+ }
20405
20585
  var releaseTool = {
20406
20586
  name: "release",
20407
20587
  description: "Cut a versioned release \u2014 creates a git tag, generates CHANGELOG.md, and pushes to remote. Pass skipVersion=true to update CHANGELOG and close the cycle without creating a tag or bumping version numbers.",
@@ -20579,33 +20759,12 @@ Next: run \`plan\` to start your next cycle.`
20579
20759
  branchMerges: [],
20580
20760
  changelogEmitted: false
20581
20761
  });
20582
- const tagAnnotation = `Release ${version}`;
20583
20762
  const cyclePart = closed.resolvedCycleNum > 0 ? `Cycle ${closed.resolvedCycleNum}` : "The cycle";
20584
20763
  const warningsBlock = closed.warnings.length > 0 ? `
20585
20764
  \u26A0\uFE0F Warnings: ${closed.warnings.join("; ")}
20586
20765
  ` : "";
20587
20766
  return textResponse(
20588
- `## Release ${version} \u2014 cycle closed in the DB
20589
-
20590
- ${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
20591
- ` + warningsBlock + `
20592
- The hosted remote MCP transport (mcp.getpapi.ai) has no checkout of your project and no git binary, so the git half of the release (tag, push, CHANGELOG.md) must run on your own machine.
20593
-
20594
- Finish the release locally:
20595
- \`\`\`
20596
- git checkout ${branch}
20597
- git pull
20598
- git tag -a ${version} -m "${tagAnnotation}"
20599
- git push origin ${branch}
20600
- git push origin ${version}
20601
- \`\`\`
20602
-
20603
- If git reports "Author identity unknown" or "Committer identity unknown" (no global git identity configured), pass your identity inline instead of writing global config:
20604
- \`\`\`
20605
- git -c user.name="Your Name" -c user.email="you@example.com" tag -a ${version} -m "${tagAnnotation}"
20606
- \`\`\`
20607
-
20608
- Next: cycle closed! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`
20767
+ buildHostedReleaseOutput({ version, branch, cyclePart, warningsBlock, skipVersion: skipVersion ?? false })
20609
20768
  );
20610
20769
  }
20611
20770
  tracker.mark("remote-project-guard");
@@ -20862,6 +21021,95 @@ function detectWorktreeCollision(input) {
20862
21021
  // src/services/build.ts
20863
21022
  init_git();
20864
21023
 
21024
+ // src/lib/build-checkpoint.ts
21025
+ import { createHash as createHash4 } from "crypto";
21026
+ import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync6, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
21027
+ import { join as join11 } from "path";
21028
+ var BUILD_CHECKPOINT_VERSION = 1;
21029
+ function cwdHash(cwd) {
21030
+ return createHash4("sha256").update(realpathOrSelf(cwd)).digest("hex").slice(0, 12);
21031
+ }
21032
+ function safeTaskId(taskId) {
21033
+ return taskId.replace(/[^a-zA-Z0-9._-]/g, "_");
21034
+ }
21035
+ function checkpointDir(cwd) {
21036
+ return join11(cwd, ".papi", "state");
21037
+ }
21038
+ function checkpointPath(cwd, taskId) {
21039
+ return join11(checkpointDir(cwd), `build-${safeTaskId(taskId)}.${cwdHash(cwd)}.json`);
21040
+ }
21041
+ function writeBuildCheckpoint(input) {
21042
+ try {
21043
+ const dir = checkpointDir(input.cwd);
21044
+ if (!existsSync7(dir)) {
21045
+ mkdirSync2(dir, { recursive: true });
21046
+ }
21047
+ const checkpoint = {
21048
+ version: BUILD_CHECKPOINT_VERSION,
21049
+ taskId: input.taskId,
21050
+ branch: input.branch,
21051
+ step: input.step,
21052
+ lastCommitSha: input.lastCommitSha,
21053
+ modifiedFiles: input.modifiedFiles,
21054
+ cwd: realpathOrSelf(input.cwd),
21055
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
21056
+ };
21057
+ writeFileSync3(checkpointPath(input.cwd, input.taskId), JSON.stringify(checkpoint, null, 2) + "\n", "utf-8");
21058
+ } catch {
21059
+ }
21060
+ }
21061
+ function readBuildCheckpoint(key) {
21062
+ try {
21063
+ const path7 = checkpointPath(key.cwd, key.taskId);
21064
+ if (!existsSync7(path7)) return null;
21065
+ const parsed = JSON.parse(readFileSync6(path7, "utf-8"));
21066
+ if (!parsed || parsed.version !== BUILD_CHECKPOINT_VERSION || parsed.taskId !== key.taskId) {
21067
+ return null;
21068
+ }
21069
+ return {
21070
+ version: BUILD_CHECKPOINT_VERSION,
21071
+ taskId: parsed.taskId,
21072
+ branch: parsed.branch ?? null,
21073
+ step: "branch_ready",
21074
+ lastCommitSha: parsed.lastCommitSha ?? null,
21075
+ modifiedFiles: Array.isArray(parsed.modifiedFiles) ? parsed.modifiedFiles : [],
21076
+ cwd: typeof parsed.cwd === "string" ? parsed.cwd : realpathOrSelf(key.cwd),
21077
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : ""
21078
+ };
21079
+ } catch {
21080
+ return null;
21081
+ }
21082
+ }
21083
+ function clearBuildCheckpoint(key) {
21084
+ try {
21085
+ const path7 = checkpointPath(key.cwd, key.taskId);
21086
+ if (existsSync7(path7)) {
21087
+ unlinkSync2(path7);
21088
+ }
21089
+ } catch {
21090
+ }
21091
+ }
21092
+ function formatResumeNote(cp) {
21093
+ const files = cp.modifiedFiles.filter((f) => f && f.trim());
21094
+ const fileList = files.length > 0 ? files.slice(0, 12).map((f) => ` - ${f}`).join("\n") + (files.length > 12 ? `
21095
+ - \u2026+${files.length - 12} more` : "") : " _(none recorded)_";
21096
+ const lines = [
21097
+ "> **\u21BB Resuming from checkpoint** \u2014 this task is already In Progress; you are NOT starting clean.",
21098
+ `> - Branch: \`${cp.branch ?? "unknown"}\``,
21099
+ "> - Last step: branch ready",
21100
+ `> - Last commit: ${cp.lastCommitSha ? cp.lastCommitSha.slice(0, 8) : "none"}`,
21101
+ `> - Checkpoint saved: ${cp.updatedAt || "unknown"}`,
21102
+ ">",
21103
+ "> Modified files at last checkpoint:",
21104
+ ...fileList.split("\n").map((l) => `> ${l}`),
21105
+ ">",
21106
+ "> Review the existing branch and changes above before writing new code \u2014 do not re-do work already committed.",
21107
+ "",
21108
+ ""
21109
+ ];
21110
+ return lines.join("\n");
21111
+ }
21112
+
20865
21113
  // src/lib/diff-inspector.ts
20866
21114
  import { execFileSync as execFileSync5 } from "child_process";
20867
21115
  var TRIGGER_SURFACE_GLOBS = [
@@ -21097,51 +21345,22 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
21097
21345
  if (staged.length > 0) {
21098
21346
  return safeRun(() => commitStagedOnly(cwd, message)) + ` (selective staging respected: ${staged.length} file(s)).`;
21099
21347
  }
21348
+ const modified = getModifiedFiles(cwd);
21349
+ if (modified.length === 0) {
21350
+ return "Auto-commit: skipped (no working-tree changes).";
21351
+ }
21352
+ const commitResult = safeRun(() => stageAllAndCommit(cwd, message));
21100
21353
  if (predictedFiles && predictedFiles.length > 0) {
21101
- const modified = getModifiedFiles(cwd);
21102
- if (modified.length === 0) {
21103
- return "Auto-commit: skipped (no working-tree changes).";
21104
- }
21105
- const dirname7 = (p) => {
21106
- const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
21107
- return idx > 0 ? p.slice(0, idx) : "";
21108
- };
21109
21354
  const cleanedPredicted = sanitisePredictedFiles(predictedFiles);
21110
- const scoped = modified.filter((p) => isPathInPredictedScope(p, cleanedPredicted));
21111
- const untracked = getUntrackedFiles(cwd);
21112
- const scopedSet = new Set(scoped);
21113
- const untrackedInScope = untracked.filter(
21114
- (p) => !scopedSet.has(p) && isPathInPredictedScope(p, cleanedPredicted)
21115
- );
21116
- if (scoped.length === 0 && untrackedInScope.length === 0) {
21117
- const modSample = modified.slice(0, 5).join(", ");
21118
- const predSample = cleanedPredicted.slice(0, 5).join(", ");
21119
- return `Auto-commit: refused \u2014 none of the ${modified.length} modified file(s) intersect FILES LIKELY TOUCHED. Modified: ${modSample}. Expected: ${predSample}. Stage the intended files manually (\`git add <paths>\`) then re-run, or set PAPI_AUTO_COMMIT=false.`;
21120
- }
21121
- const scopedDirs = [...new Set(scoped.map(dirname7).filter((d) => d.length > 0))];
21122
- const isUnderScopedDir = (p) => scopedDirs.some((d) => p === d || p.startsWith(`${d}/`) || p.startsWith(`${d}\\`));
21123
- const inScopeSet = /* @__PURE__ */ new Set([...scoped, ...untrackedInScope]);
21124
- const adjacentUntracked = untracked.filter(
21125
- (p) => !inScopeSet.has(p) && isUnderScopedDir(p)
21126
- );
21127
- const toStage = [...scoped, ...untrackedInScope, ...adjacentUntracked];
21128
- const toStageSet = new Set(toStage);
21129
- const droppedUntracked = untracked.filter((p) => !toStageSet.has(p));
21130
- const droppedModified = modified.filter((p) => !scopedSet.has(p));
21131
- let line = safeRun(() => stagePathsAndCommit(cwd, toStage, message)) + ` (scoped to ${scoped.length}/${modified.length} files via FILES LIKELY TOUCHED` + (untrackedInScope.length > 0 ? ` + ${untrackedInScope.length} new file(s) named in the handoff` : "") + (adjacentUntracked.length > 0 ? ` + ${adjacentUntracked.length} untracked under scoped dir(s)` : "") + `).`;
21132
- if (droppedModified.length > 0) {
21133
- const sample = droppedModified.slice(0, 10).join(", ");
21134
- const more = droppedModified.length > 10 ? ` (+${droppedModified.length - 10} more)` : "";
21135
- line += ` \u26A0\uFE0F ${droppedModified.length} modified file(s) outside FILES LIKELY TOUCHED were NOT staged: ${sample}${more}. If they belong to this task, stage them manually (\`git add <paths>\`) and re-run, or set PAPI_AUTO_COMMIT=false.`;
21136
- }
21137
- if (droppedUntracked.length > 0) {
21138
- const sample = droppedUntracked.slice(0, 10).join(", ");
21139
- const more = droppedUntracked.length > 10 ? ` (+${droppedUntracked.length - 10} more)` : "";
21140
- line += ` \u26A0\uFE0F ${droppedUntracked.length} untracked file(s) were NOT committed: ${sample}${more}. If they belong to this task, run \`git add <paths> && git commit --amend --no-edit\` before pushing \u2014 otherwise the committed tree may not build on checkout/CI.`;
21355
+ const outOfScope = modified.filter((p) => !isPathInPredictedScope(p, cleanedPredicted));
21356
+ if (outOfScope.length > 0) {
21357
+ const sample = outOfScope.slice(0, 10).join(", ");
21358
+ const more = outOfScope.length > 10 ? ` (+${outOfScope.length - 10} more)` : "";
21359
+ return `${commitResult} (staged all ${modified.length} changed file(s)). \u2139\uFE0F Scope drift: ${outOfScope.length} committed file(s) were outside the handoff's FILES LIKELY TOUCHED \u2014 handoff under-predicted: ${sample}${more}.`;
21141
21360
  }
21142
- return line;
21361
+ return `${commitResult} (staged all ${modified.length} changed file(s), all within FILES LIKELY TOUCHED).`;
21143
21362
  }
21144
- return safeRun(() => stageAllAndCommit(cwd, message));
21363
+ return `${commitResult} (staged all ${modified.length} changed file(s)).`;
21145
21364
  }
21146
21365
  function pushAndCreatePR(config2, taskId, taskTitle, clientName, module, cycleNumber) {
21147
21366
  const lines = [];
@@ -21618,8 +21837,9 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21618
21837
  await persistBranchName(adapter2, taskId, startedBranch);
21619
21838
  }
21620
21839
  buildStartTimes.set(taskId, (/* @__PURE__ */ new Date()).toISOString());
21840
+ let startSha = null;
21621
21841
  if (isGitAvailable() && isGitRepo(config2.projectRoot)) {
21622
- const startSha = getHeadCommitSha(config2.projectRoot);
21842
+ startSha = getHeadCommitSha(config2.projectRoot);
21623
21843
  if (startSha) taskStartShaMap.set(taskId, startSha);
21624
21844
  }
21625
21845
  let phaseChanges = [];
@@ -21638,6 +21858,14 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21638
21858
  );
21639
21859
  } catch {
21640
21860
  }
21861
+ writeBuildCheckpoint({
21862
+ cwd: config2.projectRoot,
21863
+ taskId,
21864
+ branch: getCurrentBranch(config2.projectRoot),
21865
+ step: "branch_ready",
21866
+ lastCommitSha: startSha,
21867
+ modifiedFiles: getModifiedFiles(config2.projectRoot)
21868
+ });
21641
21869
  return {
21642
21870
  task,
21643
21871
  branchLines,
@@ -21652,17 +21880,17 @@ function writeActiveTaskScope(projectRoot, taskId, filesLikelyTouched, adapterTy
21652
21880
  collector.add({ path: ".papi/active-task-scope.txt", content, mode: "overwrite" });
21653
21881
  return;
21654
21882
  }
21655
- const papiDir = join11(projectRoot, ".papi");
21656
- if (!existsSync7(papiDir)) {
21657
- mkdirSync2(papiDir, { recursive: true });
21883
+ const papiDir = join12(projectRoot, ".papi");
21884
+ if (!existsSync8(papiDir)) {
21885
+ mkdirSync3(papiDir, { recursive: true });
21658
21886
  }
21659
- const scopePath = join11(papiDir, "active-task-scope.txt");
21660
- writeFileSync3(scopePath, content, "utf-8");
21887
+ const scopePath = join12(papiDir, "active-task-scope.txt");
21888
+ writeFileSync4(scopePath, content, "utf-8");
21661
21889
  }
21662
21890
  function clearActiveTaskScope(projectRoot) {
21663
- const scopePath = join11(projectRoot, ".papi", "active-task-scope.txt");
21664
- if (existsSync7(scopePath)) {
21665
- unlinkSync2(scopePath);
21891
+ const scopePath = join12(projectRoot, ".papi", "active-task-scope.txt");
21892
+ if (existsSync8(scopePath)) {
21893
+ unlinkSync3(scopePath);
21666
21894
  }
21667
21895
  }
21668
21896
  function sanitiseResponseExcerpt(raw) {
@@ -21681,7 +21909,7 @@ function extractDocMeta(absolutePath, relativePath, cycleNumber) {
21681
21909
  else if (relativePath.startsWith("docs/architecture/")) type = "architecture";
21682
21910
  else if (relativePath.startsWith("docs/audits/")) type = "audit";
21683
21911
  try {
21684
- const content = readFileSync6(absolutePath, "utf-8").slice(0, 2e3);
21912
+ const content = readFileSync7(absolutePath, "utf-8").slice(0, 2e3);
21685
21913
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
21686
21914
  if (fmMatch) {
21687
21915
  const fm = fmMatch[1];
@@ -22078,14 +22306,14 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22078
22306
  let docWarning;
22079
22307
  try {
22080
22308
  if (adapter2.searchDocs && hasLocalWorkspace() && await ownsLocalWorkspace(adapter2, config2.projectRoot)) {
22081
- const docsDir = join11(config2.projectRoot, "docs");
22082
- if (existsSync7(docsDir)) {
22309
+ const docsDir = join12(config2.projectRoot, "docs");
22310
+ if (existsSync8(docsDir)) {
22083
22311
  const scanDir = (dir, depth = 0) => {
22084
22312
  if (depth > 8) return [];
22085
22313
  const entries = readdirSync5(dir, { withFileTypes: true });
22086
22314
  const files = [];
22087
22315
  for (const e of entries) {
22088
- const full = join11(dir, e.name);
22316
+ const full = join12(dir, e.name);
22089
22317
  if (e.isDirectory() && !e.isSymbolicLink()) files.push(...scanDir(full, depth + 1));
22090
22318
  else if (e.name.endsWith(".md")) files.push(full.replace(config2.projectRoot + "/", ""));
22091
22319
  }
@@ -22100,7 +22328,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22100
22328
  const failed = [];
22101
22329
  for (const docPath of unregistered) {
22102
22330
  try {
22103
- const meta = extractDocMeta(join11(config2.projectRoot, docPath), docPath, cycleNumber);
22331
+ const meta = extractDocMeta(join12(config2.projectRoot, docPath), docPath, cycleNumber);
22104
22332
  await adapter2.registerDoc({
22105
22333
  title: meta.title,
22106
22334
  type: meta.type,
@@ -22138,6 +22366,10 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22138
22366
  clearActiveTaskScope(config2.projectRoot);
22139
22367
  } catch {
22140
22368
  }
22369
+ try {
22370
+ clearBuildCheckpoint({ cwd: config2.projectRoot, taskId });
22371
+ } catch {
22372
+ }
22141
22373
  return {
22142
22374
  task,
22143
22375
  report,
@@ -22252,8 +22484,8 @@ ${instructions}`;
22252
22484
  }
22253
22485
 
22254
22486
  // src/tools/doc-registry.ts
22255
- import { readdirSync as readdirSync6, existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
22256
- import { join as join12, relative } from "path";
22487
+ import { readdirSync as readdirSync6, existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
22488
+ import { join as join13, relative } from "path";
22257
22489
  import { homedir as homedir3 } from "os";
22258
22490
  import { randomUUID as randomUUID12 } from "crypto";
22259
22491
  import { docDeletionBlockMessage } from "@papi-ai/shared";
@@ -22451,7 +22683,7 @@ async function handleDocSearch(adapter2, args, config2) {
22451
22683
  const lines = docs.map((d) => {
22452
22684
  const actionCount = d.actions?.filter((a) => a.status === "pending").length ?? 0;
22453
22685
  const actionNote = actionCount > 0 ? ` | ${actionCount} pending action(s)` : "";
22454
- const missingNote = root && d.path && !existsSync8(join12(root, d.path)) ? `
22686
+ const missingNote = root && d.path && !existsSync9(join13(root, d.path)) ? `
22455
22687
  > \u26A0\uFE0F **File missing on disk** \u2014 the registry points at \`${d.path}\` but nothing is there. Check \`git stash list\` for a papi-autostash entry, or re-create/deregister the doc.` : "";
22456
22688
  return `### ${d.title}
22457
22689
  **Type:** ${d.type} | **Status:** ${d.status} | **Cycle:** ${d.cycleCreated}${d.cycleUpdated ? `\u2192${d.cycleUpdated}` : ""}${actionNote}
@@ -22465,12 +22697,12 @@ ${d.summary}
22465
22697
  ${lines.join("\n---\n\n")}`);
22466
22698
  }
22467
22699
  function scanMdFiles(dir, rootDir) {
22468
- if (!existsSync8(dir)) return [];
22700
+ if (!existsSync9(dir)) return [];
22469
22701
  const files = [];
22470
22702
  try {
22471
22703
  const entries = readdirSync6(dir, { withFileTypes: true });
22472
22704
  for (const entry of entries) {
22473
- const full = join12(dir, entry.name);
22705
+ const full = join13(dir, entry.name);
22474
22706
  if (entry.isDirectory()) {
22475
22707
  files.push(...scanMdFiles(full, rootDir));
22476
22708
  } else if (entry.name.endsWith(".md")) {
@@ -22483,7 +22715,7 @@ function scanMdFiles(dir, rootDir) {
22483
22715
  }
22484
22716
  function extractTitle(filePath) {
22485
22717
  try {
22486
- const content = readFileSync7(filePath, "utf-8").slice(0, 1e3);
22718
+ const content = readFileSync8(filePath, "utf-8").slice(0, 1e3);
22487
22719
  const fmMatch = content.match(/^---[\s\S]*?title:\s*(.+?)$/m);
22488
22720
  if (fmMatch) return fmMatch[1].trim().replace(/^["']|["']$/g, "");
22489
22721
  const headingMatch = content.match(/^#+\s+(.+)$/m);
@@ -22495,7 +22727,7 @@ function extractTitle(filePath) {
22495
22727
  async function detectUnregisteredDocsNote(adapter2, config2) {
22496
22728
  try {
22497
22729
  if (!adapter2.searchDocs || !hasLocalWorkspace()) return "";
22498
- const docsDir = join12(config2.projectRoot, "docs");
22730
+ const docsDir = join13(config2.projectRoot, "docs");
22499
22731
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
22500
22732
  if (docsFiles.length === 0) return "";
22501
22733
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
@@ -22521,17 +22753,17 @@ async function handleDocScan(adapter2, config2, args) {
22521
22753
  const includePlans = args.include_plans ?? false;
22522
22754
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
22523
22755
  const registeredPaths = new Set(registered.map((d) => d.path));
22524
- const docsDir = join12(config2.projectRoot, "docs");
22756
+ const docsDir = join13(config2.projectRoot, "docs");
22525
22757
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
22526
22758
  const unregisteredDocs = docsFiles.filter((f) => !registeredPaths.has(f));
22527
22759
  let unregisteredPlans = [];
22528
22760
  if (includePlans) {
22529
- const plansDir = join12(homedir3(), ".claude", "plans");
22530
- if (existsSync8(plansDir)) {
22761
+ const plansDir = join13(homedir3(), ".claude", "plans");
22762
+ if (existsSync9(plansDir)) {
22531
22763
  const planFiles = scanMdFiles(plansDir, plansDir);
22532
22764
  unregisteredPlans = planFiles.map((f) => `plans/${f}`).filter((f) => !registeredPaths.has(f)).map((f) => ({
22533
22765
  path: f,
22534
- title: extractTitle(join12(plansDir, f.replace("plans/", "")))
22766
+ title: extractTitle(join13(plansDir, f.replace("plans/", "")))
22535
22767
  }));
22536
22768
  }
22537
22769
  }
@@ -22542,7 +22774,7 @@ async function handleDocScan(adapter2, config2, args) {
22542
22774
  if (unregisteredDocs.length > 0) {
22543
22775
  lines.push(`## Unregistered Docs (${unregisteredDocs.length})`);
22544
22776
  for (const f of unregisteredDocs) {
22545
- const title = extractTitle(join12(config2.projectRoot, f));
22777
+ const title = extractTitle(join13(config2.projectRoot, f));
22546
22778
  lines.push(`- \`${f}\`${title ? ` \u2014 ${title}` : ""}`);
22547
22779
  }
22548
22780
  }
@@ -22715,97 +22947,6 @@ async function handleDocReorder(adapter2, args) {
22715
22947
 
22716
22948
  // src/tools/build.ts
22717
22949
  init_git();
22718
-
22719
- // src/lib/build-checkpoint.ts
22720
- import { createHash as createHash4 } from "crypto";
22721
- import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync8, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4 } from "fs";
22722
- import { join as join13 } from "path";
22723
- var BUILD_CHECKPOINT_VERSION = 1;
22724
- function cwdHash(cwd) {
22725
- return createHash4("sha256").update(realpathOrSelf(cwd)).digest("hex").slice(0, 12);
22726
- }
22727
- function safeTaskId(taskId) {
22728
- return taskId.replace(/[^a-zA-Z0-9._-]/g, "_");
22729
- }
22730
- function checkpointDir(cwd) {
22731
- return join13(cwd, ".papi", "state");
22732
- }
22733
- function checkpointPath(cwd, taskId) {
22734
- return join13(checkpointDir(cwd), `build-${safeTaskId(taskId)}.${cwdHash(cwd)}.json`);
22735
- }
22736
- function writeBuildCheckpoint(input) {
22737
- try {
22738
- const dir = checkpointDir(input.cwd);
22739
- if (!existsSync9(dir)) {
22740
- mkdirSync3(dir, { recursive: true });
22741
- }
22742
- const checkpoint = {
22743
- version: BUILD_CHECKPOINT_VERSION,
22744
- taskId: input.taskId,
22745
- branch: input.branch,
22746
- step: input.step,
22747
- lastCommitSha: input.lastCommitSha,
22748
- modifiedFiles: input.modifiedFiles,
22749
- cwd: realpathOrSelf(input.cwd),
22750
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22751
- };
22752
- writeFileSync4(checkpointPath(input.cwd, input.taskId), JSON.stringify(checkpoint, null, 2) + "\n", "utf-8");
22753
- } catch {
22754
- }
22755
- }
22756
- function readBuildCheckpoint(key) {
22757
- try {
22758
- const path7 = checkpointPath(key.cwd, key.taskId);
22759
- if (!existsSync9(path7)) return null;
22760
- const parsed = JSON.parse(readFileSync8(path7, "utf-8"));
22761
- if (!parsed || parsed.version !== BUILD_CHECKPOINT_VERSION || parsed.taskId !== key.taskId) {
22762
- return null;
22763
- }
22764
- return {
22765
- version: BUILD_CHECKPOINT_VERSION,
22766
- taskId: parsed.taskId,
22767
- branch: parsed.branch ?? null,
22768
- step: "branch_ready",
22769
- lastCommitSha: parsed.lastCommitSha ?? null,
22770
- modifiedFiles: Array.isArray(parsed.modifiedFiles) ? parsed.modifiedFiles : [],
22771
- cwd: typeof parsed.cwd === "string" ? parsed.cwd : realpathOrSelf(key.cwd),
22772
- updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : ""
22773
- };
22774
- } catch {
22775
- return null;
22776
- }
22777
- }
22778
- function clearBuildCheckpoint(key) {
22779
- try {
22780
- const path7 = checkpointPath(key.cwd, key.taskId);
22781
- if (existsSync9(path7)) {
22782
- unlinkSync3(path7);
22783
- }
22784
- } catch {
22785
- }
22786
- }
22787
- function formatResumeNote(cp) {
22788
- const files = cp.modifiedFiles.filter((f) => f && f.trim());
22789
- const fileList = files.length > 0 ? files.slice(0, 12).map((f) => ` - ${f}`).join("\n") + (files.length > 12 ? `
22790
- - \u2026+${files.length - 12} more` : "") : " _(none recorded)_";
22791
- const lines = [
22792
- "> **\u21BB Resuming from checkpoint** \u2014 this task is already In Progress; you are NOT starting clean.",
22793
- `> - Branch: \`${cp.branch ?? "unknown"}\``,
22794
- "> - Last step: branch ready",
22795
- `> - Last commit: ${cp.lastCommitSha ? cp.lastCommitSha.slice(0, 8) : "none"}`,
22796
- `> - Checkpoint saved: ${cp.updatedAt || "unknown"}`,
22797
- ">",
22798
- "> Modified files at last checkpoint:",
22799
- ...fileList.split("\n").map((l) => `> ${l}`),
22800
- ">",
22801
- "> Review the existing branch and changes above before writing new code \u2014 do not re-do work already committed.",
22802
- "",
22803
- ""
22804
- ];
22805
- return lines.join("\n");
22806
- }
22807
-
22808
- // src/tools/build.ts
22809
22950
  var buildListTool = {
22810
22951
  name: "build_list",
22811
22952
  description: "List cycle tasks that have BUILD HANDOFFs ready for execution. Shows task ID, title, status, priority, and complexity. In Progress tasks appear first, then Backlog. Does not call the Anthropic API.",
@@ -23184,14 +23325,6 @@ async function handleBuildExecute(adapter2, config2, args, clientName) {
23184
23325
  const result = await startBuild(adapter2, config2, taskId, { light }, clientName);
23185
23326
  tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.task.cycle ?? null });
23186
23327
  await tracker.recordStep("branch_ready");
23187
- writeBuildCheckpoint({
23188
- cwd: config2.projectRoot,
23189
- taskId,
23190
- branch: getCurrentBranch(config2.projectRoot),
23191
- step: "branch_ready",
23192
- lastCommitSha: getHeadCommitSha(config2.projectRoot),
23193
- modifiedFiles: getModifiedFiles(config2.projectRoot)
23194
- });
23195
23328
  tracker.mark("start_decorate_handoff");
23196
23329
  const branchInfo = result.branchLines.length > 0 ? result.branchLines.map((l) => `> ${l}`).join("\n") + "\n\n" : "";
23197
23330
  const phaseNote = result.phaseChanges.length > 0 ? "\n\n" + result.phaseChanges.map((c) => `Phase auto-updated: ${c.phaseId} ${c.oldStatus} \u2192 ${c.newStatus}`).join("\n") : "";
@@ -23410,7 +23543,6 @@ Your report was NOT discarded and the task is NOT yet Done \u2014 re-send with \
23410
23543
  preview
23411
23544
  }, { light }, clientName);
23412
23545
  tracker.mark("complete_format");
23413
- clearBuildCheckpoint({ cwd: config2.projectRoot, taskId });
23414
23546
  tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.cycleNumber });
23415
23547
  await tracker.recordStep("report_written");
23416
23548
  if ((result.autoTriagedCount ?? 0) > 0) {
@@ -27133,9 +27265,15 @@ var TASK_REF = /\btask-\d+\b/gi;
27133
27265
  var RECENT_CYCLE_WINDOW = 5;
27134
27266
  var MAX_CANDIDATES = 5;
27135
27267
  async function findUnblockCandidates(adapter2, currentCycle) {
27268
+ try {
27269
+ const blockedProbe = await adapter2.queryBoard({ status: ["Blocked"], compact: true });
27270
+ if (blockedProbe.length === 0) return [];
27271
+ } catch {
27272
+ return [];
27273
+ }
27136
27274
  let allTasks = [];
27137
27275
  try {
27138
- allTasks = await adapter2.queryBoard();
27276
+ allTasks = await adapter2.queryBoard({ compact: true });
27139
27277
  } catch {
27140
27278
  return [];
27141
27279
  }
@@ -27721,7 +27859,13 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
27721
27859
  lines.push(`**Nearing Closure:** ${hierarchy.phasesNearingClosure.join(", ")}`);
27722
27860
  }
27723
27861
  if (hierarchy.stageExitCriteria && hierarchy.stageExitCriteria.length > 0) {
27724
- lines.push(`**Stage Exit Criteria:** ${hierarchy.stageExitCriteria.map((c) => `[ ] ${c}`).join(" | ")}`);
27862
+ const crit = hierarchy.stageExitCriteria;
27863
+ const met = crit.filter((c) => c.met).length;
27864
+ const total = crit.length;
27865
+ lines.push(`**Stage Exit Criteria [${met}/${total} met]:** ${crit.map((c) => `${c.met ? "[x]" : "[ ]"} ${c.text}`).join(" | ")}`);
27866
+ if (met === total) {
27867
+ lines.push(" \u21B3 All exit criteria met \u2014 run `strategy_review` to propose advancing the stage.");
27868
+ }
27725
27869
  }
27726
27870
  lines.push("");
27727
27871
  }
@@ -27926,9 +28070,9 @@ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
27926
28070
  for (const issue of alerts) {
27927
28071
  const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
27928
28072
  lines.push(`- **${issue.severity}**: ${desc}`);
27929
- lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})`);
28073
+ lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})${issue.id ? ` \xB7 id \`${issue.id}\`` : ""}`);
27930
28074
  }
27931
- lines.push("_Escalate: run `idea` with P1 priority to log as a backlog task, or `board_edit` if already handled._");
28075
+ lines.push("_Already fixed? `discovered_issue_resolve <id>` clears it for good (won't reappear). Not yet? `idea` with P1 priority to log it as a backlog task._");
27932
28076
  alertsNote = lines.join("\n");
27933
28077
  }
27934
28078
  if (allLowSev.length > 0) {
@@ -27938,18 +28082,18 @@ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
27938
28082
  for (const issue of unactioned) {
27939
28083
  const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
27940
28084
  lines.push(`- **${issue.severity}**: ${desc}`);
27941
- lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})`);
28085
+ lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})${issue.id ? ` \xB7 id \`${issue.id}\`` : ""}`);
27942
28086
  }
27943
- lines.push("_Run `idea` to log these as backlog tasks, or `board_edit` if already handled._");
28087
+ lines.push("_Already fixed? `discovered_issue_resolve <id>` clears it. Otherwise `idea` to log as a backlog task._");
27944
28088
  unactionedIssuesNote = lines.join("\n");
27945
28089
  }
27946
28090
  return { alertsNote, unactionedIssuesNote };
27947
28091
  }
27948
- async function computeTeamSummary(adapter2) {
28092
+ async function computeTeamSummary(adapter2, contributorsInput) {
27949
28093
  if (typeof adapter2.listContributors !== "function") return void 0;
27950
28094
  let members;
27951
28095
  try {
27952
- members = (await adapter2.listContributors()).length;
28096
+ members = (await (contributorsInput ?? adapter2.listContributors())).length;
27953
28097
  } catch {
27954
28098
  return void 0;
27955
28099
  }
@@ -27966,11 +28110,11 @@ async function computeTeamSummary(adapter2) {
27966
28110
  const reviewQueue = tasks.filter((t) => t.status === "In Review").length;
27967
28111
  return `**Team:** ${members} members \xB7 ${pool} in pool \xB7 ${inFlight} in flight \xB7 ${reviewQueue} in review`;
27968
28112
  }
27969
- async function computeReleaseHistory(adapter2) {
28113
+ async function computeReleaseHistory(adapter2, contributorsInput) {
27970
28114
  if (typeof adapter2.listContributors !== "function") return void 0;
27971
28115
  let contributors;
27972
28116
  try {
27973
- contributors = await adapter2.listContributors();
28117
+ contributors = await (contributorsInput ?? adapter2.listContributors());
27974
28118
  } catch {
27975
28119
  return void 0;
27976
28120
  }
@@ -28301,13 +28445,13 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
28301
28445
  // "writing to the wrong project" for multi-project users on a shared key / stateless
28302
28446
  // HTTP transport where a once-per-session gate can't work.
28303
28447
  tracked("project-banner", async () => {
28304
- if (!adapter2.getProjectInfo) return { banner: "", name: void 0 };
28448
+ if (!adapter2.getProjectInfo) return { banner: "", name: void 0, repoConnected: void 0 };
28305
28449
  const projectInfo = await adapter2.getProjectInfo();
28306
- if (!projectInfo) return { banner: "", name: void 0 };
28450
+ if (!projectInfo) return { banner: "", name: void 0, repoConnected: void 0 };
28307
28451
  const banner = getProjectConnectionBanner(projectInfo.name, projectInfo.slug);
28308
28452
  return { banner: banner ? `
28309
28453
  > ${banner}
28310
- ` : "", name: projectInfo.name };
28454
+ ` : "", name: projectInfo.name, repoConnected: !!projectInfo.repo_url };
28311
28455
  }),
28312
28456
  // Session guidance — proactive nudges (doc_register, context bloat, mode switch)
28313
28457
  tracked("session-guidance", async () => {
@@ -28445,7 +28589,7 @@ ${versionDrift}` : "";
28445
28589
  const patternsNote = patternsOutcome.status === "fulfilled" ? patternsOutcome.value : "";
28446
28590
  const { alertsNote, unactionedIssuesNote } = discoveredIssuesOutcome.status === "fulfilled" ? discoveredIssuesOutcome.value : { alertsNote: "", unactionedIssuesNote: "" };
28447
28591
  const skillProposalsNote = skillScanOutcome.status === "fulfilled" ? skillScanOutcome.value : "";
28448
- const projectBannerResult = projectBannerOutcome.status === "fulfilled" ? projectBannerOutcome.value : { banner: "", name: void 0 };
28592
+ const projectBannerResult = projectBannerOutcome.status === "fulfilled" ? projectBannerOutcome.value : { banner: "", name: void 0, repoConnected: void 0 };
28449
28593
  const projectBannerNote = projectBannerResult.banner;
28450
28594
  const projectName = projectBannerResult.name;
28451
28595
  const sessionGuidanceNote = sessionGuidanceOutcome.status === "fulfilled" ? sessionGuidanceOutcome.value : "";
@@ -28486,16 +28630,27 @@ ${versionDrift}` : "";
28486
28630
  preBuildCheckNote = lines.join("\n");
28487
28631
  }
28488
28632
  }
28489
- tracker.mark("unblock-candidates");
28490
- let unblockNote = "";
28491
- try {
28492
- const candidates = await tracked("unblock-candidates", () => findUnblockCandidates(adapter2, currentCycle))();
28493
- const section = formatUnblockSection(candidates);
28494
- if (section) unblockNote = `
28633
+ tracker.mark("parallel-tail");
28634
+ const sharedContributorsPromise = typeof adapter2.listContributors === "function" ? adapter2.listContributors().catch(() => []) : Promise.resolve([]);
28635
+ const [unblockCandidates, subAgents, teamSummaryLine, releaseHistoryLine, carryForwardRefs] = await Promise.all([
28636
+ tracked("unblock-candidates", () => findUnblockCandidates(adapter2, currentCycle))().catch(() => []),
28637
+ // task-1866: discover project sub-agents for the orient surface (read-only, never throws).
28638
+ listAgents(config2.projectRoot),
28639
+ // task-2071 (MU-3) + task-2072 (MU-5): team summary + release-history — both
28640
+ // multi-member only; solo projects get undefined from both, so orient stays
28641
+ // byte-identical there.
28642
+ tracked("team-summary", () => computeTeamSummary(adapter2, sharedContributorsPromise))().catch(() => void 0),
28643
+ tracked("release-history", () => computeReleaseHistory(adapter2, sharedContributorsPromise))().catch(() => void 0),
28644
+ // task-2751 (C332): resolve every task-NNNN mentioned in the Carry-Forward
28645
+ // prose to its title so orient can name it inline. Built from the board
28646
+ // already in hand — no extra query.
28647
+ resolveCarryForwardRefs(healthResult.carryForward, allTasks, adapter2)
28648
+ ]);
28649
+ const unblockSection = formatUnblockSection(unblockCandidates);
28650
+ const unblockNote = unblockSection ? `
28495
28651
 
28496
- ${section}`;
28497
- } catch {
28498
- }
28652
+ ${unblockSection}` : "";
28653
+ const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
28499
28654
  let deferredGateNote = "";
28500
28655
  if (deepHousekeeping) {
28501
28656
  try {
@@ -28507,16 +28662,22 @@ ${section}`;
28507
28662
  } catch {
28508
28663
  }
28509
28664
  }
28665
+ let onboardingCoachingNote = "";
28666
+ try {
28667
+ const adsForCoaching = await sharedActiveDecisionsPromise;
28668
+ onboardingCoachingNote = formatOnboardingCoachingBlock({
28669
+ surface: "orient",
28670
+ repoConnected: projectBannerResult.repoConnected,
28671
+ hasLocalWorkspace: hasLocalWorkspace(),
28672
+ hasActiveDecisions: adsForCoaching.length > 0,
28673
+ cycleNumber: currentCycle,
28674
+ hasActiveCycle: currentCycle > 0 && !cycleIsComplete
28675
+ });
28676
+ } catch {
28677
+ }
28510
28678
  tracker.mark("format-summary");
28511
- const subAgents = await listAgents(config2.projectRoot);
28512
- const [teamSummaryLine, releaseHistoryLine] = await Promise.all([
28513
- tracked("team-summary", () => computeTeamSummary(adapter2))().catch(() => void 0),
28514
- tracked("release-history", () => computeReleaseHistory(adapter2))().catch(() => void 0)
28515
- ]);
28516
- const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
28517
28679
  const deepHint = deepHousekeeping ? "" : "\n\n*Tip: pass `full: true` for Research Signals + version-drift, or `deep_housekeeping: true` to also check orphaned branches, merged-but-In-Progress tasks, unrecorded commits, unregistered docs, and stale skill forks (implies `full`).*";
28518
- const carryForwardRefs = await resolveCarryForwardRefs(healthResult.carryForward, allTasks, adapter2);
28519
- return textResponse(projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary, clientName, carryForwardRefs) + unblockNote + deferredGateNote + feedbackResolvedNote + alertsNote + ttfvNote + reconciliationNote + mergedInProgressNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection);
28680
+ return textResponse(projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary, clientName, carryForwardRefs) + unblockNote + deferredGateNote + feedbackResolvedNote + alertsNote + ttfvNote + reconciliationNote + mergedInProgressNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + onboardingCoachingNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection);
28520
28681
  } catch (err) {
28521
28682
  const message = err instanceof Error ? err.message : String(err);
28522
28683
  const isKnownFriendly = /^(Orient failed|Project not found|No project|Setup required)/i.test(message);
@@ -28571,7 +28732,7 @@ function enrichClaudeMd(projectRoot, cycleNumber, adapterType, collector, client
28571
28732
  // src/tools/hierarchy.ts
28572
28733
  var hierarchyUpdateTool = {
28573
28734
  name: "hierarchy_update",
28574
- description: "Update the status of a phase, stage, or horizon in the project hierarchy (AD-14). Accepts a level (phase, stage, or horizon), a name or ID, and a new status. For stages, optionally set exit_criteria \u2014 a checklist defining when the stage is considered done. Does not call the Anthropic API.",
28735
+ description: "Create or update a horizon, stage, or phase in the project hierarchy (AD-14). For stages and horizons this UPSERTS: if the named entity exists it is updated, otherwise it is CREATED (pass a label). Phases are update-only (they evolve via plan/strategy). For stages you can set exit_criteria (a checklist), or flip a single criterion with set_criterion_met. NEVER auto-advances \u2014 progression is human-in-loop via strategy_review. Does not call the Anthropic API.",
28575
28736
  annotations: { title: "Update Hierarchy", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
28576
28737
  inputSchema: {
28577
28738
  type: "object",
@@ -28579,129 +28740,218 @@ var hierarchyUpdateTool = {
28579
28740
  level: {
28580
28741
  type: "string",
28581
28742
  enum: ["phase", "stage", "horizon"],
28582
- description: "Which hierarchy level to update."
28743
+ description: "Which hierarchy level to create/update."
28583
28744
  },
28584
28745
  name: {
28585
28746
  type: "string",
28586
- description: "The label or ID of the stage/horizon to update."
28747
+ description: "The label, slug, or ID of the entity. On create, becomes the label if `label` is omitted."
28748
+ },
28749
+ label: {
28750
+ type: "string",
28751
+ description: 'Display label. Required to CREATE a new stage/horizon when `name` does not match an existing one (e.g. "S2: Alpha Cohort").'
28752
+ },
28753
+ slug: {
28754
+ type: "string",
28755
+ description: "Explicit slug for a new entity (stage/horizon). Auto-derived from the label when omitted."
28756
+ },
28757
+ description: {
28758
+ type: "string",
28759
+ description: "Optional longer description (stage/horizon)."
28587
28760
  },
28588
28761
  status: {
28589
28762
  type: "string",
28590
28763
  enum: ["Not Started", "In Progress", "Done", "Deferred"],
28591
- description: "The new status to set."
28764
+ description: 'The status to set. On create, defaults to "Not Started".'
28765
+ },
28766
+ sort_order: {
28767
+ type: "number",
28768
+ description: "Display order for a new entity. Auto-computed (max existing + 10) when omitted."
28769
+ },
28770
+ horizon: {
28771
+ type: "string",
28772
+ description: "Parent horizon (name/slug/id) when CREATING a stage. Defaults to the sole horizon if only one exists."
28592
28773
  },
28593
28774
  exit_criteria: {
28594
28775
  type: "array",
28595
28776
  items: { type: "string" },
28596
- description: 'Checklist defining when this stage is done (stages only). Each item is a completion condition, e.g. "All P0 tasks shipped". Replaces existing criteria.'
28777
+ description: "Checklist defining when a STAGE is done. Each item is a completion condition. REPLACES existing criteria (resets met state \u2014 use set_criterion_met to flip one)."
28778
+ },
28779
+ set_criterion_met: {
28780
+ type: "object",
28781
+ description: "Flip a single stage exit criterion (task-1625). { criterion_id, met, evidence? }.",
28782
+ properties: {
28783
+ criterion_id: { type: "string", description: "The ExitCriterion id to flip." },
28784
+ met: { type: "boolean", description: "true = met, false = unmet." },
28785
+ evidence: { type: "string", description: 'Optional evidence for a met criterion (e.g. "task-1234 shipped").' }
28786
+ },
28787
+ required: ["criterion_id", "met"]
28597
28788
  }
28598
28789
  },
28599
28790
  required: ["level", "name"]
28600
28791
  }
28601
28792
  };
28602
28793
  var VALID_STATUSES3 = /* @__PURE__ */ new Set(["Not Started", "In Progress", "Done", "Deferred"]);
28794
+ function slugify(input) {
28795
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "item";
28796
+ }
28797
+ function nextSortOrder(existing) {
28798
+ if (existing.length === 0) return 10;
28799
+ return Math.max(...existing.map((e) => e.sortOrder)) + 10;
28800
+ }
28603
28801
  async function handleHierarchyUpdate(adapter2, args) {
28604
28802
  const level = args.level;
28605
28803
  const name = args.name;
28804
+ const label = args.label;
28805
+ const slug = args.slug;
28806
+ const description = args.description;
28606
28807
  const status = args.status;
28808
+ const sortOrder = typeof args.sort_order === "number" ? args.sort_order : void 0;
28809
+ const horizonRef = args.horizon;
28607
28810
  const exitCriteria = args.exit_criteria;
28811
+ const setCriterion = args.set_criterion_met;
28608
28812
  if (!level || !name) {
28609
28813
  return errorResponse("Missing required parameters: level, name.");
28610
28814
  }
28611
- if (!status && !exitCriteria) {
28612
- return errorResponse("Nothing to update. Provide at least one of: status, exit_criteria.");
28613
- }
28614
28815
  if (level !== "phase" && level !== "stage" && level !== "horizon") {
28615
28816
  return errorResponse(`Invalid level "${level}". Must be "phase", "stage", or "horizon".`);
28616
28817
  }
28617
28818
  if (status && !VALID_STATUSES3.has(status)) {
28618
28819
  return errorResponse(`Invalid status "${status}". Must be one of: Not Started, In Progress, Done, Deferred.`);
28619
28820
  }
28620
- if (exitCriteria !== void 0 && level !== "stage") {
28621
- return errorResponse("exit_criteria can only be set on stages.");
28821
+ if ((exitCriteria !== void 0 || setCriterion !== void 0) && level !== "stage") {
28822
+ return errorResponse("exit_criteria and set_criterion_met can only be used on stages.");
28823
+ }
28824
+ if (!status && exitCriteria === void 0 && setCriterion === void 0 && !label) {
28825
+ return errorResponse("Nothing to do. Provide at least one of: status, exit_criteria, set_criterion_met, or label (to create).");
28622
28826
  }
28623
28827
  try {
28624
28828
  if (level === "phase") {
28625
- if (!adapter2.readPhases || !adapter2.updatePhaseStatus) {
28626
- return errorResponse("Phase management is not supported by the current adapter.");
28627
- }
28628
- const phases = await adapter2.readPhases();
28629
- const phase = phases.find(
28630
- (p) => p.label.toLowerCase() === name.toLowerCase() || p.id === name || p.slug === name
28631
- );
28632
- if (!phase) {
28633
- const available = phases.map((p) => p.label).join(", ");
28634
- return errorResponse(`Phase "${name}" not found. Available phases: ${available || "none"}`);
28635
- }
28636
- if (!status) {
28637
- return errorResponse("status is required for phase updates.");
28638
- }
28639
- if (phase.status === status) {
28640
- return textResponse(`Phase "${phase.label}" is already "${status}". No change made.`);
28641
- }
28642
- const oldStatus2 = phase.status;
28643
- await adapter2.updatePhaseStatus(phase.id, status);
28644
- return textResponse(`Phase updated: **${phase.label}** ${oldStatus2} \u2192 ${status}`);
28829
+ return await handlePhase(adapter2, name, status);
28645
28830
  }
28646
28831
  if (level === "stage") {
28647
- if (!adapter2.readStages) {
28648
- return errorResponse("Stage management is not supported by the current adapter.");
28649
- }
28650
- const stages = await adapter2.readStages();
28651
- const stage = stages.find(
28652
- (s) => s.label.toLowerCase() === name.toLowerCase() || s.id === name || s.slug === name
28653
- );
28654
- if (!stage) {
28655
- const available = stages.map((s) => s.label).join(", ");
28656
- return errorResponse(`Stage "${name}" not found. Available stages: ${available || "none"}`);
28657
- }
28658
- const resultLines = [];
28659
- if (status) {
28660
- if (!adapter2.updateStageStatus) {
28661
- return errorResponse("Stage status updates are not supported by the current adapter.");
28662
- }
28663
- if (stage.status === status) {
28664
- resultLines.push(`Stage "${stage.label}" is already "${status}".`);
28665
- } else {
28666
- const oldStatus2 = stage.status;
28667
- await adapter2.updateStageStatus(stage.id, status);
28668
- resultLines.push(`Stage updated: **${stage.label}** ${oldStatus2} \u2192 ${status}`);
28669
- }
28670
- }
28671
- if (exitCriteria !== void 0) {
28672
- if (!adapter2.updateStageExitCriteria) {
28673
- return errorResponse("Exit criteria updates are not supported by the current adapter.");
28674
- }
28675
- await adapter2.updateStageExitCriteria(stage.id, exitCriteria);
28676
- resultLines.push(`Exit criteria set (${exitCriteria.length} item${exitCriteria.length !== 1 ? "s" : ""}):`);
28677
- exitCriteria.forEach((c) => resultLines.push(` - ${c}`));
28678
- }
28679
- return textResponse(resultLines.join("\n"));
28832
+ return await handleStage(adapter2, { name, label, slug, description, status, sortOrder, horizonRef, exitCriteria, setCriterion });
28680
28833
  }
28681
- if (!adapter2.readHorizons || !adapter2.updateHorizonStatus) {
28682
- return errorResponse("Horizon management is not supported by the current adapter.");
28683
- }
28684
- const horizons = await adapter2.readHorizons();
28685
- const horizon = horizons.find(
28686
- (h) => h.label.toLowerCase() === name.toLowerCase() || h.id === name || h.slug === name
28687
- );
28688
- if (!horizon) {
28689
- const available = horizons.map((h) => h.label).join(", ");
28690
- return errorResponse(`Horizon "${name}" not found. Available horizons: ${available || "none"}`);
28691
- }
28692
- if (!status) {
28693
- return errorResponse("status is required for horizon updates.");
28694
- }
28695
- if (horizon.status === status) {
28696
- return textResponse(`Horizon "${horizon.label}" is already "${status}". No change made.`);
28697
- }
28698
- const oldStatus = horizon.status;
28699
- await adapter2.updateHorizonStatus(horizon.id, status);
28700
- return textResponse(`Horizon updated: **${horizon.label}** ${oldStatus} \u2192 ${status}`);
28834
+ return await handleHorizon(adapter2, { name, label, slug, description, status, sortOrder });
28701
28835
  } catch (err) {
28702
28836
  return errorResponse(err instanceof Error ? err.message : String(err));
28703
28837
  }
28704
28838
  }
28839
+ async function handlePhase(adapter2, name, status) {
28840
+ if (!adapter2.readPhases || !adapter2.updatePhaseStatus) {
28841
+ return errorResponse("Phase management is not supported by the current adapter.");
28842
+ }
28843
+ if (!status) return errorResponse("status is required for phase updates.");
28844
+ const phases = await adapter2.readPhases();
28845
+ const phase = phases.find((p) => p.label.toLowerCase() === name.toLowerCase() || p.id === name || p.slug === name);
28846
+ if (!phase) {
28847
+ const available = phases.map((p) => p.label).join(", ");
28848
+ return errorResponse(`Phase "${name}" not found. Available phases: ${available || "none"} (phases are update-only \u2014 create them via plan/setup).`);
28849
+ }
28850
+ if (phase.status === status) return textResponse(`Phase "${phase.label}" is already "${status}". No change made.`);
28851
+ const oldStatus = phase.status;
28852
+ await adapter2.updatePhaseStatus(phase.id, status);
28853
+ return textResponse(`Phase updated: **${phase.label}** ${oldStatus} \u2192 ${status}`);
28854
+ }
28855
+ async function handleStage(adapter2, a) {
28856
+ if (!adapter2.readStages) {
28857
+ return errorResponse("Stage management is not supported by the current adapter.");
28858
+ }
28859
+ const stages = await adapter2.readStages();
28860
+ let stage = stages.find(
28861
+ (s) => s.label.toLowerCase() === a.name.toLowerCase() || s.id === a.name || s.slug === a.name
28862
+ );
28863
+ const resultLines = [];
28864
+ let created = false;
28865
+ if (!stage) {
28866
+ if (!adapter2.createHorizon || !adapter2.createStage || !adapter2.readHorizons) {
28867
+ return errorResponse("Stage creation is not supported by the current adapter.");
28868
+ }
28869
+ const createLabel = a.label ?? a.name;
28870
+ const horizons = await adapter2.readHorizons();
28871
+ let parent;
28872
+ if (a.horizonRef) {
28873
+ parent = horizons.find((h) => h.label.toLowerCase() === a.horizonRef.toLowerCase() || h.id === a.horizonRef || h.slug === a.horizonRef);
28874
+ if (!parent) {
28875
+ const available = horizons.map((h) => h.label).join(", ");
28876
+ return errorResponse(`Parent horizon "${a.horizonRef}" not found. Available horizons: ${available || "none"}.`);
28877
+ }
28878
+ } else if (horizons.length === 1) {
28879
+ parent = horizons[0];
28880
+ } else if (horizons.length === 0) {
28881
+ return errorResponse('No horizon exists to attach the stage to. Create a horizon first (level:"horizon", label:"H1: ...").');
28882
+ } else {
28883
+ return errorResponse(`Multiple horizons exist \u2014 pass \`horizon\` to say which one the stage belongs to: ${horizons.map((h) => h.label).join(", ")}.`);
28884
+ }
28885
+ const newId = await adapter2.createStage({
28886
+ slug: a.slug ?? slugify(createLabel),
28887
+ label: createLabel,
28888
+ description: a.description,
28889
+ status: a.status ?? "Not Started",
28890
+ sortOrder: a.sortOrder ?? nextSortOrder(stages.filter((s) => s.horizonId === parent.id)),
28891
+ horizonId: parent.id
28892
+ });
28893
+ const refreshed = await adapter2.readStages();
28894
+ stage = refreshed.find((s) => s.id === newId);
28895
+ if (!stage) return errorResponse("Stage was created but could not be re-read.");
28896
+ created = true;
28897
+ resultLines.push(`Stage created: **${stage.label}** (under ${parent.label}, status ${stage.status})`);
28898
+ }
28899
+ if (a.status && !created) {
28900
+ if (!adapter2.updateStageStatus) return errorResponse("Stage status updates are not supported by the current adapter.");
28901
+ if (stage.status === a.status) {
28902
+ resultLines.push(`Stage "${stage.label}" is already "${a.status}".`);
28903
+ } else {
28904
+ const oldStatus = stage.status;
28905
+ await adapter2.updateStageStatus(stage.id, a.status);
28906
+ resultLines.push(`Stage updated: **${stage.label}** ${oldStatus} \u2192 ${a.status}`);
28907
+ }
28908
+ }
28909
+ if (a.exitCriteria !== void 0) {
28910
+ if (!adapter2.updateStageExitCriteria) return errorResponse("Exit criteria updates are not supported by the current adapter.");
28911
+ await adapter2.updateStageExitCriteria(stage.id, a.exitCriteria);
28912
+ resultLines.push(`Exit criteria set (${a.exitCriteria.length} item${a.exitCriteria.length !== 1 ? "s" : ""}, all unmet):`);
28913
+ a.exitCriteria.forEach((c) => resultLines.push(` - ${c}`));
28914
+ }
28915
+ if (a.setCriterion !== void 0) {
28916
+ if (!adapter2.setCriterionMet) return errorResponse("set_criterion_met is not supported by the current adapter.");
28917
+ await adapter2.setCriterionMet(stage.id, a.setCriterion.criterion_id, a.setCriterion.met, a.setCriterion.evidence ?? null);
28918
+ const after = (await adapter2.readStages()).find((s) => s.id === stage.id);
28919
+ const crit = after?.exitCriteria?.find((c) => c.id === a.setCriterion.criterion_id);
28920
+ if (!crit) {
28921
+ resultLines.push(`\u26A0\uFE0F Criterion id "${a.setCriterion.criterion_id}" not found on this stage \u2014 no change.`);
28922
+ } else {
28923
+ const met = after?.exitCriteria?.filter((c) => c.met).length ?? 0;
28924
+ const total = after?.exitCriteria?.length ?? 0;
28925
+ resultLines.push(`Criterion "${crit.text}" \u2192 ${crit.met ? "met" : "unmet"}. [${met}/${total} criteria met]`);
28926
+ }
28927
+ }
28928
+ return textResponse(resultLines.join("\n"));
28929
+ }
28930
+ async function handleHorizon(adapter2, a) {
28931
+ if (!adapter2.readHorizons) {
28932
+ return errorResponse("Horizon management is not supported by the current adapter.");
28933
+ }
28934
+ const horizons = await adapter2.readHorizons();
28935
+ const horizon = horizons.find((h) => h.label.toLowerCase() === a.name.toLowerCase() || h.id === a.name || h.slug === a.name);
28936
+ if (!horizon) {
28937
+ if (!adapter2.createHorizon) return errorResponse("Horizon creation is not supported by the current adapter.");
28938
+ const createLabel = a.label ?? a.name;
28939
+ const newId = await adapter2.createHorizon({
28940
+ slug: a.slug ?? slugify(createLabel),
28941
+ label: createLabel,
28942
+ description: a.description,
28943
+ status: a.status ?? "Not Started",
28944
+ sortOrder: a.sortOrder ?? nextSortOrder(horizons)
28945
+ });
28946
+ return textResponse(`Horizon created: **${createLabel}** (id ${newId}, status ${a.status ?? "Not Started"})`);
28947
+ }
28948
+ if (!a.status) return errorResponse("status is required to update an existing horizon (or pass a new name to create one).");
28949
+ if (horizon.status === a.status) return textResponse(`Horizon "${horizon.label}" is already "${a.status}". No change made.`);
28950
+ if (!adapter2.updateHorizonStatus) return errorResponse("Horizon status updates are not supported by the current adapter.");
28951
+ const oldStatus = horizon.status;
28952
+ await adapter2.updateHorizonStatus(horizon.id, a.status);
28953
+ return textResponse(`Horizon updated: **${horizon.label}** ${oldStatus} \u2192 ${a.status}`);
28954
+ }
28705
28955
 
28706
28956
  // src/services/zoom-out.ts
28707
28957
  var BUDGET_SOFT = 12e4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.69",
3
+ "version": "0.7.70",
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",