@papi-ai/server 0.7.69 → 0.7.71

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.
package/dist/index.js CHANGED
@@ -1982,9 +1982,12 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
1982
1982
  return this.invoke("getOwnerIdentity", []);
1983
1983
  }
1984
1984
  // --- Contributor cohort (task-2029, C288) ---
1985
- // Owner-only enforcement is BOTH tool-layer (resolveOwnerGate) and
1986
- // server-side in the edge function (auth-derived caller vs project owner)
1987
- // defence in depth, since this client runs on the user's machine.
1985
+ // Owner-only enforcement is BOTH tool-layer (resolveOwnerGate) and server-side
1986
+ // in the edge function (auth-derived caller vs project owner). task-2442 (C351)
1987
+ // added addContributorByEmail/removeContributorByEmail to the proxy WRITE_METHODS
1988
+ // set too, so a viewer is now rejected at the role-gate wall before the handler —
1989
+ // three layers of defence, since this client runs on the user's machine.
1990
+ // listContributors stays a member-open read (task-2377).
1988
1991
  async listContributors() {
1989
1992
  return this.invoke("listContributors", []);
1990
1993
  }
@@ -10729,6 +10732,14 @@ function validateHandoffScope(handoff) {
10729
10732
  if (!isMeaningful(handoff.scopeBoundary)) invalid.push("scopeBoundary");
10730
10733
  return invalid;
10731
10734
  }
10735
+ function assertHandoffApplyPayloadNonEmpty(data, cycleNumber) {
10736
+ const handoffCount = data.cycleHandoffs?.length ?? 0;
10737
+ if (handoffCount === 0) {
10738
+ throw new Error(
10739
+ `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.`
10740
+ );
10741
+ }
10742
+ }
10732
10743
  async function prepareHandoffs(adapter2, _config, taskIds, force = false) {
10733
10744
  const timer2 = startTimer();
10734
10745
  const cycles = await adapter2.readCycles();
@@ -10785,10 +10796,8 @@ async function applyHandoffs(adapter2, rawLlmOutput, cycleNumber, force = false)
10785
10796
  if (!data) {
10786
10797
  throw new Error("Could not parse structured output. Ensure your output includes <!-- PAPI_STRUCTURED_OUTPUT --> with valid JSON.");
10787
10798
  }
10799
+ assertHandoffApplyPayloadNonEmpty(data, cycleNumber);
10788
10800
  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
10801
  const taskIdsToWrite = handoffs.map((h) => h.taskId);
10793
10802
  const existingHandoffSet = /* @__PURE__ */ new Set();
10794
10803
  try {
@@ -13662,6 +13671,58 @@ async function buildSessionGuidance(callerKey) {
13662
13671
  return signals.slice(0, 3);
13663
13672
  }
13664
13673
 
13674
+ // src/services/onboarding-coaching.ts
13675
+ var ONBOARDING_EARLY_CYCLE_MAX = 2;
13676
+ var MAX_COACHING_LINES = 4;
13677
+ 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.";
13678
+ 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.";
13679
+ var COACH_CLICKABLE_TASKS = "Task cards on your dashboard expand on click. Open one to read its full build handoff, comments, and history.";
13680
+ 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.';
13681
+ 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>"`.';
13682
+ var COACH_NO_DECISIONS = 'No Active Decisions recorded yet. Capture your first with `idea "<a direction or constraint>"` so your project starts steering itself.';
13683
+ 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.";
13684
+ var ONBOARDING_COACHING_HEADING = "## Getting Started";
13685
+ function buildOnboardingCoaching(state) {
13686
+ const lines = [];
13687
+ const isEarly = state.cycleNumber <= ONBOARDING_EARLY_CYCLE_MAX;
13688
+ const { surface } = state;
13689
+ if (state.repoConnected === false && (surface === "orient" || surface === "plan")) {
13690
+ lines.push(COACH_CONNECT_REPO);
13691
+ }
13692
+ if (state.hasLocalWorkspace && (surface === "setup" || surface === "orient" && isEarly)) {
13693
+ lines.push(COACH_ROOT_DIR);
13694
+ }
13695
+ if ((surface === "orient" || surface === "setup") && isEarly) {
13696
+ lines.push(COACH_CLICKABLE_TASKS);
13697
+ }
13698
+ if ((surface === "orient" || surface === "plan") && state.hasActiveCycle && isEarly) {
13699
+ lines.push(COACH_OFF_CYCLE);
13700
+ }
13701
+ if (surface === "orient" && isEarly) {
13702
+ lines.push(state.hasActiveDecisions === false ? COACH_NO_DECISIONS : COACH_SESSION_START);
13703
+ }
13704
+ if (surface === "plan" && state.bootstrapPlan === true) {
13705
+ lines.push(COACH_BACKLOG_IMPORT);
13706
+ }
13707
+ return lines.slice(0, MAX_COACHING_LINES);
13708
+ }
13709
+ function formatOnboardingCoachingBlock(state) {
13710
+ const lines = buildOnboardingCoaching(state);
13711
+ if (lines.length === 0) return "";
13712
+ return `
13713
+
13714
+ ${ONBOARDING_COACHING_HEADING}
13715
+ ${lines.map((l) => `- ${l}`).join("\n")}`;
13716
+ }
13717
+
13718
+ // src/lib/hosted-mode.ts
13719
+ function isHostedTransport() {
13720
+ return Boolean(process.env.PORT || process.env.PAPI_HTTP_PORT);
13721
+ }
13722
+ function hasLocalWorkspace() {
13723
+ return !isHostedTransport();
13724
+ }
13725
+
13665
13726
  // src/lib/per-caller-cache.ts
13666
13727
  var DEFAULT_CALLER_KEY2 = "__default__";
13667
13728
  var MAX_PER_CALLER_ENTRIES = 1e3;
@@ -13905,6 +13966,10 @@ function formatPlanResult(result) {
13905
13966
  } else {
13906
13967
  lines.push("", `Next: run \`build_list\` to see your cycle tasks, then \`build_execute <task_id>\` to start building.`);
13907
13968
  }
13969
+ if (result.onboardingCoaching && result.onboardingCoaching.length > 0) {
13970
+ lines.push("", ONBOARDING_COACHING_HEADING);
13971
+ for (const c of result.onboardingCoaching) lines.push(`- ${c}`);
13972
+ }
13908
13973
  if (result.contextBytes !== void 0) {
13909
13974
  const kb = (result.contextBytes / 1024).toFixed(1);
13910
13975
  lines.push(`---`, `Context: ${kb}KB`);
@@ -13986,7 +14051,15 @@ async function handlePlan(adapter2, config2, args) {
13986
14051
  }, tracker);
13987
14052
  const planProjectInfo = adapter2.getProjectInfo ? await adapter2.getProjectInfo().catch(() => null) : null;
13988
14053
  const projectBanner = planProjectInfo ? getProjectConnectionBanner(planProjectInfo.name, planProjectInfo.slug) ?? void 0 : void 0;
13989
- const response = formatPlanResult({ ...result, contextUtilisation: utilisation, contextBytes, skipHandoffs, projectBanner });
14054
+ const onboardingCoaching = buildOnboardingCoaching({
14055
+ surface: "plan",
14056
+ repoConnected: planProjectInfo ? !!planProjectInfo.repo_url : void 0,
14057
+ hasLocalWorkspace: hasLocalWorkspace(),
14058
+ cycleNumber: result.cycleNumber + 1,
14059
+ hasActiveCycle: true,
14060
+ bootstrapPlan: result.mode === "bootstrap"
14061
+ });
14062
+ const response = formatPlanResult({ ...result, contextUtilisation: utilisation, contextBytes, skipHandoffs, projectBanner, onboardingCoaching });
13990
14063
  return {
13991
14064
  ...response,
13992
14065
  ...contextBytes !== void 0 ? { _contextBytes: contextBytes } : {},
@@ -14138,14 +14211,6 @@ import { existsSync as existsSync2, readdirSync, statSync as statSync2 } from "f
14138
14211
  import { join as join4 } from "path";
14139
14212
  import { homedir as homedir2 } from "os";
14140
14213
 
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
14214
  // src/services/idea.ts
14150
14215
  import { randomUUID as randomUUID9 } from "crypto";
14151
14216
  var OWNER_ACTION_PATTERNS = [
@@ -15602,6 +15667,13 @@ ${cleanContent}`;
15602
15667
  } catch {
15603
15668
  }
15604
15669
  }
15670
+ if (data.hierarchyUpdates && data.hierarchyUpdates.length > 0) {
15671
+ try {
15672
+ await applyHierarchyUpdates(adapter2, data.hierarchyUpdates);
15673
+ } catch (err) {
15674
+ console.error("[strategy] applyHierarchyUpdates failed:", err instanceof Error ? err.message : String(err));
15675
+ }
15676
+ }
15605
15677
  const compressionThreshold = cycleNumber - 5;
15606
15678
  if (compressionThreshold > 0 && data.sessionLogCompressionSummary) {
15607
15679
  await adapter2.compressCycleLog(compressionThreshold, data.sessionLogCompressionSummary);
@@ -15879,6 +15951,79 @@ function buildPhaseLabel(phase) {
15879
15951
  if (!numMatch) return phase.label;
15880
15952
  return `Phase ${numMatch[1]}: ${phase.label}`;
15881
15953
  }
15954
+ function nextHierarchySort(existing) {
15955
+ return existing.length === 0 ? 10 : Math.max(...existing.map((e) => e.sortOrder)) + 10;
15956
+ }
15957
+ async function applyHierarchyUpdates(adapter2, updates) {
15958
+ if (!adapter2.readStages || !adapter2.readHorizons) return;
15959
+ const findHorizon = async (ref) => (await adapter2.readHorizons()).find(
15960
+ (h) => h.slug === ref || h.id === ref || h.label.toLowerCase() === ref.toLowerCase()
15961
+ );
15962
+ const findStage = async (ref) => (await adapter2.readStages()).find(
15963
+ (s) => s.slug === ref || s.id === ref || s.label.toLowerCase() === ref.toLowerCase()
15964
+ );
15965
+ for (const u of updates) {
15966
+ if (u.level === "horizon") {
15967
+ if (u.action === "create") {
15968
+ if (!await findHorizon(u.slug) && adapter2.createHorizon) {
15969
+ const horizons = await adapter2.readHorizons();
15970
+ await adapter2.createHorizon({
15971
+ slug: u.slug,
15972
+ label: u.label ?? u.slug,
15973
+ status: u.status ?? "In Progress",
15974
+ sortOrder: u.sortOrder ?? nextHierarchySort(horizons)
15975
+ });
15976
+ }
15977
+ } else if (u.action === "update_status" && u.status && adapter2.updateHorizonStatus) {
15978
+ const h = await findHorizon(u.slug);
15979
+ if (h) await adapter2.updateHorizonStatus(h.id, u.status);
15980
+ }
15981
+ continue;
15982
+ }
15983
+ if (u.action === "update_criterion" && u.criterionId && typeof u.met === "boolean" && adapter2.setCriterionMet) {
15984
+ const s = await findStage(u.slug);
15985
+ if (s) await adapter2.setCriterionMet(s.id, u.criterionId, u.met, u.evidence ?? null);
15986
+ } else if (u.action === "update_status" && u.status && adapter2.updateStageStatus) {
15987
+ const s = await findStage(u.slug);
15988
+ if (s) await adapter2.updateStageStatus(s.id, u.status);
15989
+ } else if (u.action === "create") {
15990
+ await createStageFromUpdate(adapter2, u, findStage, findHorizon);
15991
+ } else if (u.action === "advance" && adapter2.updateStageStatus) {
15992
+ const current = await findStage(u.slug);
15993
+ if (current) await adapter2.updateStageStatus(current.id, "Done");
15994
+ const newRef = u.newStageSlug ?? u.slug;
15995
+ const existingNext = await findStage(newRef);
15996
+ if (existingNext) {
15997
+ await adapter2.updateStageStatus(existingNext.id, "In Progress");
15998
+ } else if (u.newStageLabel) {
15999
+ await createStageFromUpdate(
16000
+ adapter2,
16001
+ { ...u, slug: newRef, label: u.newStageLabel, status: "In Progress", horizon: u.horizon ?? current?.horizonId },
16002
+ findStage,
16003
+ findHorizon
16004
+ );
16005
+ }
16006
+ }
16007
+ }
16008
+ }
16009
+ async function createStageFromUpdate(adapter2, u, findStage, findHorizon) {
16010
+ if (!adapter2.createStage || !adapter2.readStages || !adapter2.readHorizons) return;
16011
+ if (await findStage(u.slug)) return;
16012
+ const horizons = await adapter2.readHorizons();
16013
+ const parent = u.horizon ? await findHorizon(u.horizon) : horizons.length === 1 ? horizons[0] : horizons.find((h) => h.status === "In Progress");
16014
+ if (!parent) return;
16015
+ const stagesInHorizon = (await adapter2.readStages()).filter((s) => s.horizonId === parent.id);
16016
+ const id = await adapter2.createStage({
16017
+ slug: u.slug,
16018
+ label: u.label ?? u.slug,
16019
+ status: u.status ?? "Not Started",
16020
+ sortOrder: u.sortOrder ?? nextHierarchySort(stagesInHorizon),
16021
+ horizonId: parent.id
16022
+ });
16023
+ if (id && u.exitCriteria?.length && adapter2.updateStageExitCriteria) {
16024
+ await adapter2.updateStageExitCriteria(id, u.exitCriteria);
16025
+ }
16026
+ }
15882
16027
  async function applyPhaseUpdates(adapter2, currentPhases, updates) {
15883
16028
  const phasesById = new Map(currentPhases.map((p) => [p.id, p]));
15884
16029
  const labelMigrations = [];
@@ -19111,8 +19256,14 @@ ${result.warnings.map((w) => `- ${w}`).join("\n")}` : "";
19111
19256
  **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
19257
 
19113
19258
  **Important:** Setup created/modified files (${harnessFiles}, .claude/settings.json, docs/). Commit these changes before running \`build_execute\` \u2014 it requires a clean working directory.`;
19259
+ const coachingNote = formatOnboardingCoachingBlock({
19260
+ surface: "setup",
19261
+ hasLocalWorkspace: hasLocalWorkspace(),
19262
+ cycleNumber: 0,
19263
+ hasActiveCycle: false
19264
+ });
19114
19265
  return textResponse(
19115
- `${prefix}Product Brief generated and saved.${briefRegenNote}${adNote}${northStarNote}${taskNote}${constraintsHint}${editorNote}${gitignoreNote}${warningsNote}${filesNote}
19266
+ `${prefix}Product Brief generated and saved.${briefRegenNote}${adNote}${northStarNote}${taskNote}${constraintsHint}${editorNote}${gitignoreNote}${warningsNote}${filesNote}${coachingNote}
19116
19267
 
19117
19268
  Tip: See \`docs/templates/example-project-brief.md\` for an example of a well-written brief.
19118
19269
 
@@ -19483,8 +19634,8 @@ function buildPapiMetaFramingDirective(caps, inner) {
19483
19634
 
19484
19635
  // src/services/build.ts
19485
19636
  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";
19637
+ import { readdirSync as readdirSync5, existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, mkdirSync as mkdirSync3 } from "fs";
19638
+ import { join as join12 } from "path";
19488
19639
 
19489
19640
  // src/lib/db-only-notices.ts
19490
19641
  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 +20553,38 @@ async function postReleaseToX(version, cycleClosed) {
20402
20553
  return false;
20403
20554
  }
20404
20555
  }
20556
+ function buildHostedReleaseOutput(params) {
20557
+ const { version, branch, cyclePart, warningsBlock, skipVersion } = params;
20558
+ const tagAnnotation = `Release ${version}`;
20559
+ const titleSuffix = skipVersion ? " (skip version)" : "";
20560
+ 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";
20561
+ const commands = skipVersion ? `git checkout ${branch}
20562
+ git pull
20563
+ git push origin ${branch}
20564
+ ` : `git checkout ${branch}
20565
+ git pull
20566
+ git tag -a ${version} -m "${tagAnnotation}"
20567
+ git push origin ${branch}
20568
+ git push origin ${version}
20569
+ `;
20570
+ 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:
20571
+ \`\`\`
20572
+ git -c user.name="Your Name" -c user.email="you@example.com" tag -a ${version} -m "${tagAnnotation}"
20573
+ \`\`\`
20574
+
20575
+ `;
20576
+ return `## Release ${version}${titleSuffix} \u2014 cycle closed in the DB
20577
+
20578
+ ${cyclePart} is now marked **complete** in PAPI's database, so \`orient\` will no longer flag "Release has not been run."
20579
+ ` + warningsBlock + `
20580
+ The hosted remote MCP transport (mcp.getpapi.ai) has no checkout of your project and no git binary, so ${gitHalf}.
20581
+
20582
+ Finish the release locally:
20583
+ \`\`\`
20584
+ ` + commands + `\`\`\`
20585
+
20586
+ ` + identityBlock + `Next: cycle closed! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`;
20587
+ }
20405
20588
  var releaseTool = {
20406
20589
  name: "release",
20407
20590
  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 +20762,12 @@ Next: run \`plan\` to start your next cycle.`
20579
20762
  branchMerges: [],
20580
20763
  changelogEmitted: false
20581
20764
  });
20582
- const tagAnnotation = `Release ${version}`;
20583
20765
  const cyclePart = closed.resolvedCycleNum > 0 ? `Cycle ${closed.resolvedCycleNum}` : "The cycle";
20584
20766
  const warningsBlock = closed.warnings.length > 0 ? `
20585
20767
  \u26A0\uFE0F Warnings: ${closed.warnings.join("; ")}
20586
20768
  ` : "";
20587
20769
  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.`
20770
+ buildHostedReleaseOutput({ version, branch, cyclePart, warningsBlock, skipVersion: skipVersion ?? false })
20609
20771
  );
20610
20772
  }
20611
20773
  tracker.mark("remote-project-guard");
@@ -20862,6 +21024,107 @@ function detectWorktreeCollision(input) {
20862
21024
  // src/services/build.ts
20863
21025
  init_git();
20864
21026
 
21027
+ // src/lib/build-checkpoint.ts
21028
+ import { createHash as createHash4 } from "crypto";
21029
+ import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync6, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
21030
+ import { join as join11 } from "path";
21031
+ var BUILD_CHECKPOINT_VERSION = 1;
21032
+ function cwdHash(cwd) {
21033
+ return createHash4("sha256").update(realpathOrSelf(cwd)).digest("hex").slice(0, 12);
21034
+ }
21035
+ function safeTaskId(taskId) {
21036
+ return taskId.replace(/[^a-zA-Z0-9._-]/g, "_");
21037
+ }
21038
+ function checkpointDir(cwd) {
21039
+ return join11(cwd, ".papi", "state");
21040
+ }
21041
+ function checkpointPath(cwd, taskId) {
21042
+ return join11(checkpointDir(cwd), `build-${safeTaskId(taskId)}.${cwdHash(cwd)}.json`);
21043
+ }
21044
+ function writeBuildCheckpoint(input) {
21045
+ try {
21046
+ const dir = checkpointDir(input.cwd);
21047
+ if (!existsSync7(dir)) {
21048
+ mkdirSync2(dir, { recursive: true });
21049
+ }
21050
+ const checkpoint = {
21051
+ version: BUILD_CHECKPOINT_VERSION,
21052
+ taskId: input.taskId,
21053
+ branch: input.branch,
21054
+ step: input.step,
21055
+ lastCommitSha: input.lastCommitSha,
21056
+ modifiedFiles: input.modifiedFiles,
21057
+ cwd: realpathOrSelf(input.cwd),
21058
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
21059
+ };
21060
+ writeFileSync3(checkpointPath(input.cwd, input.taskId), JSON.stringify(checkpoint, null, 2) + "\n", "utf-8");
21061
+ } catch {
21062
+ }
21063
+ }
21064
+ function readBuildCheckpoint(key) {
21065
+ try {
21066
+ const path7 = checkpointPath(key.cwd, key.taskId);
21067
+ if (!existsSync7(path7)) return null;
21068
+ const parsed = JSON.parse(readFileSync6(path7, "utf-8"));
21069
+ if (!parsed || parsed.version !== BUILD_CHECKPOINT_VERSION || parsed.taskId !== key.taskId) {
21070
+ return null;
21071
+ }
21072
+ return {
21073
+ version: BUILD_CHECKPOINT_VERSION,
21074
+ taskId: parsed.taskId,
21075
+ branch: parsed.branch ?? null,
21076
+ step: "branch_ready",
21077
+ lastCommitSha: parsed.lastCommitSha ?? null,
21078
+ modifiedFiles: Array.isArray(parsed.modifiedFiles) ? parsed.modifiedFiles : [],
21079
+ cwd: typeof parsed.cwd === "string" ? parsed.cwd : realpathOrSelf(key.cwd),
21080
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : ""
21081
+ };
21082
+ } catch {
21083
+ return null;
21084
+ }
21085
+ }
21086
+ function clearBuildCheckpoint(key) {
21087
+ try {
21088
+ const path7 = checkpointPath(key.cwd, key.taskId);
21089
+ if (existsSync7(path7)) {
21090
+ unlinkSync2(path7);
21091
+ }
21092
+ } catch {
21093
+ }
21094
+ }
21095
+ function writeBuildCheckpointIfLocal(input) {
21096
+ if (!hasLocalWorkspace()) return;
21097
+ writeBuildCheckpoint(input);
21098
+ }
21099
+ function readBuildCheckpointIfLocal(key) {
21100
+ if (!hasLocalWorkspace()) return null;
21101
+ return readBuildCheckpoint(key);
21102
+ }
21103
+ function clearBuildCheckpointIfLocal(key) {
21104
+ if (!hasLocalWorkspace()) return;
21105
+ clearBuildCheckpoint(key);
21106
+ }
21107
+ function formatResumeNote(cp) {
21108
+ const files = cp.modifiedFiles.filter((f) => f && f.trim());
21109
+ const fileList = files.length > 0 ? files.slice(0, 12).map((f) => ` - ${f}`).join("\n") + (files.length > 12 ? `
21110
+ - \u2026+${files.length - 12} more` : "") : " _(none recorded)_";
21111
+ const lines = [
21112
+ "> **\u21BB Resuming from checkpoint** \u2014 this task is already In Progress; you are NOT starting clean.",
21113
+ `> - Branch: \`${cp.branch ?? "unknown"}\``,
21114
+ "> - Last step: branch ready",
21115
+ `> - Last commit: ${cp.lastCommitSha ? cp.lastCommitSha.slice(0, 8) : "none"}`,
21116
+ `> - Checkpoint saved: ${cp.updatedAt || "unknown"}`,
21117
+ ">",
21118
+ "> Modified files at last checkpoint:",
21119
+ ...fileList.split("\n").map((l) => `> ${l}`),
21120
+ ">",
21121
+ "> Review the existing branch and changes above before writing new code \u2014 do not re-do work already committed.",
21122
+ "",
21123
+ ""
21124
+ ];
21125
+ return lines.join("\n");
21126
+ }
21127
+
20865
21128
  // src/lib/diff-inspector.ts
20866
21129
  import { execFileSync as execFileSync5 } from "child_process";
20867
21130
  var TRIGGER_SURFACE_GLOBS = [
@@ -21097,51 +21360,22 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
21097
21360
  if (staged.length > 0) {
21098
21361
  return safeRun(() => commitStagedOnly(cwd, message)) + ` (selective staging respected: ${staged.length} file(s)).`;
21099
21362
  }
21363
+ const modified = getModifiedFiles(cwd);
21364
+ if (modified.length === 0) {
21365
+ return "Auto-commit: skipped (no working-tree changes).";
21366
+ }
21367
+ const commitResult = safeRun(() => stageAllAndCommit(cwd, message));
21100
21368
  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
21369
  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.`;
21370
+ const outOfScope = modified.filter((p) => !isPathInPredictedScope(p, cleanedPredicted));
21371
+ if (outOfScope.length > 0) {
21372
+ const sample = outOfScope.slice(0, 10).join(", ");
21373
+ const more = outOfScope.length > 10 ? ` (+${outOfScope.length - 10} more)` : "";
21374
+ 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
21375
  }
21142
- return line;
21376
+ return `${commitResult} (staged all ${modified.length} changed file(s), all within FILES LIKELY TOUCHED).`;
21143
21377
  }
21144
- return safeRun(() => stageAllAndCommit(cwd, message));
21378
+ return `${commitResult} (staged all ${modified.length} changed file(s)).`;
21145
21379
  }
21146
21380
  function pushAndCreatePR(config2, taskId, taskTitle, clientName, module, cycleNumber) {
21147
21381
  const lines = [];
@@ -21618,8 +21852,9 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21618
21852
  await persistBranchName(adapter2, taskId, startedBranch);
21619
21853
  }
21620
21854
  buildStartTimes.set(taskId, (/* @__PURE__ */ new Date()).toISOString());
21855
+ let startSha = null;
21621
21856
  if (isGitAvailable() && isGitRepo(config2.projectRoot)) {
21622
- const startSha = getHeadCommitSha(config2.projectRoot);
21857
+ startSha = getHeadCommitSha(config2.projectRoot);
21623
21858
  if (startSha) taskStartShaMap.set(taskId, startSha);
21624
21859
  }
21625
21860
  let phaseChanges = [];
@@ -21638,6 +21873,14 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
21638
21873
  );
21639
21874
  } catch {
21640
21875
  }
21876
+ writeBuildCheckpointIfLocal({
21877
+ cwd: config2.projectRoot,
21878
+ taskId,
21879
+ branch: getCurrentBranch(config2.projectRoot),
21880
+ step: "branch_ready",
21881
+ lastCommitSha: startSha,
21882
+ modifiedFiles: getModifiedFiles(config2.projectRoot)
21883
+ });
21641
21884
  return {
21642
21885
  task,
21643
21886
  branchLines,
@@ -21652,17 +21895,17 @@ function writeActiveTaskScope(projectRoot, taskId, filesLikelyTouched, adapterTy
21652
21895
  collector.add({ path: ".papi/active-task-scope.txt", content, mode: "overwrite" });
21653
21896
  return;
21654
21897
  }
21655
- const papiDir = join11(projectRoot, ".papi");
21656
- if (!existsSync7(papiDir)) {
21657
- mkdirSync2(papiDir, { recursive: true });
21898
+ const papiDir = join12(projectRoot, ".papi");
21899
+ if (!existsSync8(papiDir)) {
21900
+ mkdirSync3(papiDir, { recursive: true });
21658
21901
  }
21659
- const scopePath = join11(papiDir, "active-task-scope.txt");
21660
- writeFileSync3(scopePath, content, "utf-8");
21902
+ const scopePath = join12(papiDir, "active-task-scope.txt");
21903
+ writeFileSync4(scopePath, content, "utf-8");
21661
21904
  }
21662
21905
  function clearActiveTaskScope(projectRoot) {
21663
- const scopePath = join11(projectRoot, ".papi", "active-task-scope.txt");
21664
- if (existsSync7(scopePath)) {
21665
- unlinkSync2(scopePath);
21906
+ const scopePath = join12(projectRoot, ".papi", "active-task-scope.txt");
21907
+ if (existsSync8(scopePath)) {
21908
+ unlinkSync3(scopePath);
21666
21909
  }
21667
21910
  }
21668
21911
  function sanitiseResponseExcerpt(raw) {
@@ -21681,7 +21924,7 @@ function extractDocMeta(absolutePath, relativePath, cycleNumber) {
21681
21924
  else if (relativePath.startsWith("docs/architecture/")) type = "architecture";
21682
21925
  else if (relativePath.startsWith("docs/audits/")) type = "audit";
21683
21926
  try {
21684
- const content = readFileSync6(absolutePath, "utf-8").slice(0, 2e3);
21927
+ const content = readFileSync7(absolutePath, "utf-8").slice(0, 2e3);
21685
21928
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
21686
21929
  if (fmMatch) {
21687
21930
  const fm = fmMatch[1];
@@ -22078,14 +22321,14 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22078
22321
  let docWarning;
22079
22322
  try {
22080
22323
  if (adapter2.searchDocs && hasLocalWorkspace() && await ownsLocalWorkspace(adapter2, config2.projectRoot)) {
22081
- const docsDir = join11(config2.projectRoot, "docs");
22082
- if (existsSync7(docsDir)) {
22324
+ const docsDir = join12(config2.projectRoot, "docs");
22325
+ if (existsSync8(docsDir)) {
22083
22326
  const scanDir = (dir, depth = 0) => {
22084
22327
  if (depth > 8) return [];
22085
22328
  const entries = readdirSync5(dir, { withFileTypes: true });
22086
22329
  const files = [];
22087
22330
  for (const e of entries) {
22088
- const full = join11(dir, e.name);
22331
+ const full = join12(dir, e.name);
22089
22332
  if (e.isDirectory() && !e.isSymbolicLink()) files.push(...scanDir(full, depth + 1));
22090
22333
  else if (e.name.endsWith(".md")) files.push(full.replace(config2.projectRoot + "/", ""));
22091
22334
  }
@@ -22100,7 +22343,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22100
22343
  const failed = [];
22101
22344
  for (const docPath of unregistered) {
22102
22345
  try {
22103
- const meta = extractDocMeta(join11(config2.projectRoot, docPath), docPath, cycleNumber);
22346
+ const meta = extractDocMeta(join12(config2.projectRoot, docPath), docPath, cycleNumber);
22104
22347
  await adapter2.registerDoc({
22105
22348
  title: meta.title,
22106
22349
  type: meta.type,
@@ -22134,8 +22377,14 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22134
22377
  }
22135
22378
  } catch {
22136
22379
  }
22380
+ if (hasLocalWorkspace()) {
22381
+ try {
22382
+ clearActiveTaskScope(config2.projectRoot);
22383
+ } catch {
22384
+ }
22385
+ }
22137
22386
  try {
22138
- clearActiveTaskScope(config2.projectRoot);
22387
+ clearBuildCheckpointIfLocal({ cwd: config2.projectRoot, taskId });
22139
22388
  } catch {
22140
22389
  }
22141
22390
  return {
@@ -22161,7 +22410,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22161
22410
  localPreview
22162
22411
  };
22163
22412
  }
22164
- async function cancelBuild(adapter2, taskId, reason) {
22413
+ async function cancelBuild(adapter2, taskId, reason, projectRoot) {
22165
22414
  const task = await adapter2.getTask(taskId);
22166
22415
  if (!task) {
22167
22416
  throw new Error(`Task "${taskId}" not found.`);
@@ -22170,6 +22419,18 @@ async function cancelBuild(adapter2, taskId, reason) {
22170
22419
  status: "Cancelled",
22171
22420
  closureReason: reason
22172
22421
  });
22422
+ if (projectRoot) {
22423
+ if (hasLocalWorkspace()) {
22424
+ try {
22425
+ clearActiveTaskScope(projectRoot);
22426
+ } catch {
22427
+ }
22428
+ }
22429
+ try {
22430
+ clearBuildCheckpointIfLocal({ cwd: projectRoot, taskId });
22431
+ } catch {
22432
+ }
22433
+ }
22173
22434
  return { task, reason };
22174
22435
  }
22175
22436
 
@@ -22252,8 +22513,8 @@ ${instructions}`;
22252
22513
  }
22253
22514
 
22254
22515
  // 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";
22516
+ import { readdirSync as readdirSync6, existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
22517
+ import { join as join13, relative } from "path";
22257
22518
  import { homedir as homedir3 } from "os";
22258
22519
  import { randomUUID as randomUUID12 } from "crypto";
22259
22520
  import { docDeletionBlockMessage } from "@papi-ai/shared";
@@ -22451,7 +22712,7 @@ async function handleDocSearch(adapter2, args, config2) {
22451
22712
  const lines = docs.map((d) => {
22452
22713
  const actionCount = d.actions?.filter((a) => a.status === "pending").length ?? 0;
22453
22714
  const actionNote = actionCount > 0 ? ` | ${actionCount} pending action(s)` : "";
22454
- const missingNote = root && d.path && !existsSync8(join12(root, d.path)) ? `
22715
+ const missingNote = root && d.path && !existsSync9(join13(root, d.path)) ? `
22455
22716
  > \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
22717
  return `### ${d.title}
22457
22718
  **Type:** ${d.type} | **Status:** ${d.status} | **Cycle:** ${d.cycleCreated}${d.cycleUpdated ? `\u2192${d.cycleUpdated}` : ""}${actionNote}
@@ -22465,12 +22726,12 @@ ${d.summary}
22465
22726
  ${lines.join("\n---\n\n")}`);
22466
22727
  }
22467
22728
  function scanMdFiles(dir, rootDir) {
22468
- if (!existsSync8(dir)) return [];
22729
+ if (!existsSync9(dir)) return [];
22469
22730
  const files = [];
22470
22731
  try {
22471
22732
  const entries = readdirSync6(dir, { withFileTypes: true });
22472
22733
  for (const entry of entries) {
22473
- const full = join12(dir, entry.name);
22734
+ const full = join13(dir, entry.name);
22474
22735
  if (entry.isDirectory()) {
22475
22736
  files.push(...scanMdFiles(full, rootDir));
22476
22737
  } else if (entry.name.endsWith(".md")) {
@@ -22483,7 +22744,7 @@ function scanMdFiles(dir, rootDir) {
22483
22744
  }
22484
22745
  function extractTitle(filePath) {
22485
22746
  try {
22486
- const content = readFileSync7(filePath, "utf-8").slice(0, 1e3);
22747
+ const content = readFileSync8(filePath, "utf-8").slice(0, 1e3);
22487
22748
  const fmMatch = content.match(/^---[\s\S]*?title:\s*(.+?)$/m);
22488
22749
  if (fmMatch) return fmMatch[1].trim().replace(/^["']|["']$/g, "");
22489
22750
  const headingMatch = content.match(/^#+\s+(.+)$/m);
@@ -22495,7 +22756,7 @@ function extractTitle(filePath) {
22495
22756
  async function detectUnregisteredDocsNote(adapter2, config2) {
22496
22757
  try {
22497
22758
  if (!adapter2.searchDocs || !hasLocalWorkspace()) return "";
22498
- const docsDir = join12(config2.projectRoot, "docs");
22759
+ const docsDir = join13(config2.projectRoot, "docs");
22499
22760
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
22500
22761
  if (docsFiles.length === 0) return "";
22501
22762
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
@@ -22521,17 +22782,17 @@ async function handleDocScan(adapter2, config2, args) {
22521
22782
  const includePlans = args.include_plans ?? false;
22522
22783
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
22523
22784
  const registeredPaths = new Set(registered.map((d) => d.path));
22524
- const docsDir = join12(config2.projectRoot, "docs");
22785
+ const docsDir = join13(config2.projectRoot, "docs");
22525
22786
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
22526
22787
  const unregisteredDocs = docsFiles.filter((f) => !registeredPaths.has(f));
22527
22788
  let unregisteredPlans = [];
22528
22789
  if (includePlans) {
22529
- const plansDir = join12(homedir3(), ".claude", "plans");
22530
- if (existsSync8(plansDir)) {
22790
+ const plansDir = join13(homedir3(), ".claude", "plans");
22791
+ if (existsSync9(plansDir)) {
22531
22792
  const planFiles = scanMdFiles(plansDir, plansDir);
22532
22793
  unregisteredPlans = planFiles.map((f) => `plans/${f}`).filter((f) => !registeredPaths.has(f)).map((f) => ({
22533
22794
  path: f,
22534
- title: extractTitle(join12(plansDir, f.replace("plans/", "")))
22795
+ title: extractTitle(join13(plansDir, f.replace("plans/", "")))
22535
22796
  }));
22536
22797
  }
22537
22798
  }
@@ -22542,7 +22803,7 @@ async function handleDocScan(adapter2, config2, args) {
22542
22803
  if (unregisteredDocs.length > 0) {
22543
22804
  lines.push(`## Unregistered Docs (${unregisteredDocs.length})`);
22544
22805
  for (const f of unregisteredDocs) {
22545
- const title = extractTitle(join12(config2.projectRoot, f));
22806
+ const title = extractTitle(join13(config2.projectRoot, f));
22546
22807
  lines.push(`- \`${f}\`${title ? ` \u2014 ${title}` : ""}`);
22547
22808
  }
22548
22809
  }
@@ -22715,97 +22976,6 @@ async function handleDocReorder(adapter2, args) {
22715
22976
 
22716
22977
  // src/tools/build.ts
22717
22978
  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
22979
  var buildListTool = {
22810
22980
  name: "build_list",
22811
22981
  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.",
@@ -23177,21 +23347,13 @@ async function handleBuildExecute(adapter2, config2, args, clientName) {
23177
23347
  }
23178
23348
  let resumeNote = "";
23179
23349
  if (scopeTask?.status === "In Progress") {
23180
- const existing = readBuildCheckpoint({ cwd: config2.projectRoot, taskId });
23350
+ const existing = readBuildCheckpointIfLocal({ cwd: config2.projectRoot, taskId });
23181
23351
  if (existing) resumeNote = formatResumeNote(existing);
23182
23352
  }
23183
23353
  await tracker.recordStep("started");
23184
23354
  const result = await startBuild(adapter2, config2, taskId, { light }, clientName);
23185
23355
  tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.task.cycle ?? null });
23186
23356
  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
23357
  tracker.mark("start_decorate_handoff");
23196
23358
  const branchInfo = result.branchLines.length > 0 ? result.branchLines.map((l) => `> ${l}`).join("\n") + "\n\n" : "";
23197
23359
  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 +23572,6 @@ Your report was NOT discarded and the task is NOT yet Done \u2014 re-send with \
23410
23572
  preview
23411
23573
  }, { light }, clientName);
23412
23574
  tracker.mark("complete_format");
23413
- clearBuildCheckpoint({ cwd: config2.projectRoot, taskId });
23414
23575
  tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.cycleNumber });
23415
23576
  await tracker.recordStep("report_written");
23416
23577
  if ((result.autoTriagedCount ?? 0) > 0) {
@@ -23607,7 +23768,7 @@ function formatCompleteResult(result) {
23607
23768
  }
23608
23769
  return lines.join("\n");
23609
23770
  }
23610
- async function handleBuildCancel(adapter2, args) {
23771
+ async function handleBuildCancel(adapter2, config2, args) {
23611
23772
  const taskId = args.task_id;
23612
23773
  if (!taskId) {
23613
23774
  return errorResponse("task_id is required.");
@@ -23617,7 +23778,7 @@ async function handleBuildCancel(adapter2, args) {
23617
23778
  return errorResponse("reason is required.");
23618
23779
  }
23619
23780
  try {
23620
- const result = await cancelBuild(adapter2, taskId, reason);
23781
+ const result = await cancelBuild(adapter2, taskId, reason, config2.projectRoot);
23621
23782
  return textResponse(`Cancelled **${result.task.id}** (${result.task.title}).
23622
23783
 
23623
23784
  Reason: ${result.reason}`);
@@ -27133,9 +27294,15 @@ var TASK_REF = /\btask-\d+\b/gi;
27133
27294
  var RECENT_CYCLE_WINDOW = 5;
27134
27295
  var MAX_CANDIDATES = 5;
27135
27296
  async function findUnblockCandidates(adapter2, currentCycle) {
27297
+ try {
27298
+ const blockedProbe = await adapter2.queryBoard({ status: ["Blocked"], compact: true });
27299
+ if (blockedProbe.length === 0) return [];
27300
+ } catch {
27301
+ return [];
27302
+ }
27136
27303
  let allTasks = [];
27137
27304
  try {
27138
- allTasks = await adapter2.queryBoard();
27305
+ allTasks = await adapter2.queryBoard({ compact: true });
27139
27306
  } catch {
27140
27307
  return [];
27141
27308
  }
@@ -27721,7 +27888,13 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
27721
27888
  lines.push(`**Nearing Closure:** ${hierarchy.phasesNearingClosure.join(", ")}`);
27722
27889
  }
27723
27890
  if (hierarchy.stageExitCriteria && hierarchy.stageExitCriteria.length > 0) {
27724
- lines.push(`**Stage Exit Criteria:** ${hierarchy.stageExitCriteria.map((c) => `[ ] ${c}`).join(" | ")}`);
27891
+ const crit = hierarchy.stageExitCriteria;
27892
+ const met = crit.filter((c) => c.met).length;
27893
+ const total = crit.length;
27894
+ lines.push(`**Stage Exit Criteria [${met}/${total} met]:** ${crit.map((c) => `${c.met ? "[x]" : "[ ]"} ${c.text}`).join(" | ")}`);
27895
+ if (met === total) {
27896
+ lines.push(" \u21B3 All exit criteria met \u2014 run `strategy_review` to propose advancing the stage.");
27897
+ }
27725
27898
  }
27726
27899
  lines.push("");
27727
27900
  }
@@ -27926,9 +28099,9 @@ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
27926
28099
  for (const issue of alerts) {
27927
28100
  const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
27928
28101
  lines.push(`- **${issue.severity}**: ${desc}`);
27929
- lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})`);
28102
+ lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})${issue.id ? ` \xB7 id \`${issue.id}\`` : ""}`);
27930
28103
  }
27931
- lines.push("_Escalate: run `idea` with P1 priority to log as a backlog task, or `board_edit` if already handled._");
28104
+ 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
28105
  alertsNote = lines.join("\n");
27933
28106
  }
27934
28107
  if (allLowSev.length > 0) {
@@ -27938,18 +28111,18 @@ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
27938
28111
  for (const issue of unactioned) {
27939
28112
  const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
27940
28113
  lines.push(`- **${issue.severity}**: ${desc}`);
27941
- lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})`);
28114
+ lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})${issue.id ? ` \xB7 id \`${issue.id}\`` : ""}`);
27942
28115
  }
27943
- lines.push("_Run `idea` to log these as backlog tasks, or `board_edit` if already handled._");
28116
+ lines.push("_Already fixed? `discovered_issue_resolve <id>` clears it. Otherwise `idea` to log as a backlog task._");
27944
28117
  unactionedIssuesNote = lines.join("\n");
27945
28118
  }
27946
28119
  return { alertsNote, unactionedIssuesNote };
27947
28120
  }
27948
- async function computeTeamSummary(adapter2) {
28121
+ async function computeTeamSummary(adapter2, contributorsInput) {
27949
28122
  if (typeof adapter2.listContributors !== "function") return void 0;
27950
28123
  let members;
27951
28124
  try {
27952
- members = (await adapter2.listContributors()).length;
28125
+ members = (await (contributorsInput ?? adapter2.listContributors())).length;
27953
28126
  } catch {
27954
28127
  return void 0;
27955
28128
  }
@@ -27966,11 +28139,11 @@ async function computeTeamSummary(adapter2) {
27966
28139
  const reviewQueue = tasks.filter((t) => t.status === "In Review").length;
27967
28140
  return `**Team:** ${members} members \xB7 ${pool} in pool \xB7 ${inFlight} in flight \xB7 ${reviewQueue} in review`;
27968
28141
  }
27969
- async function computeReleaseHistory(adapter2) {
28142
+ async function computeReleaseHistory(adapter2, contributorsInput) {
27970
28143
  if (typeof adapter2.listContributors !== "function") return void 0;
27971
28144
  let contributors;
27972
28145
  try {
27973
- contributors = await adapter2.listContributors();
28146
+ contributors = await (contributorsInput ?? adapter2.listContributors());
27974
28147
  } catch {
27975
28148
  return void 0;
27976
28149
  }
@@ -28301,13 +28474,13 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
28301
28474
  // "writing to the wrong project" for multi-project users on a shared key / stateless
28302
28475
  // HTTP transport where a once-per-session gate can't work.
28303
28476
  tracked("project-banner", async () => {
28304
- if (!adapter2.getProjectInfo) return { banner: "", name: void 0 };
28477
+ if (!adapter2.getProjectInfo) return { banner: "", name: void 0, repoConnected: void 0 };
28305
28478
  const projectInfo = await adapter2.getProjectInfo();
28306
- if (!projectInfo) return { banner: "", name: void 0 };
28479
+ if (!projectInfo) return { banner: "", name: void 0, repoConnected: void 0 };
28307
28480
  const banner = getProjectConnectionBanner(projectInfo.name, projectInfo.slug);
28308
28481
  return { banner: banner ? `
28309
28482
  > ${banner}
28310
- ` : "", name: projectInfo.name };
28483
+ ` : "", name: projectInfo.name, repoConnected: !!projectInfo.repo_url };
28311
28484
  }),
28312
28485
  // Session guidance — proactive nudges (doc_register, context bloat, mode switch)
28313
28486
  tracked("session-guidance", async () => {
@@ -28445,7 +28618,7 @@ ${versionDrift}` : "";
28445
28618
  const patternsNote = patternsOutcome.status === "fulfilled" ? patternsOutcome.value : "";
28446
28619
  const { alertsNote, unactionedIssuesNote } = discoveredIssuesOutcome.status === "fulfilled" ? discoveredIssuesOutcome.value : { alertsNote: "", unactionedIssuesNote: "" };
28447
28620
  const skillProposalsNote = skillScanOutcome.status === "fulfilled" ? skillScanOutcome.value : "";
28448
- const projectBannerResult = projectBannerOutcome.status === "fulfilled" ? projectBannerOutcome.value : { banner: "", name: void 0 };
28621
+ const projectBannerResult = projectBannerOutcome.status === "fulfilled" ? projectBannerOutcome.value : { banner: "", name: void 0, repoConnected: void 0 };
28449
28622
  const projectBannerNote = projectBannerResult.banner;
28450
28623
  const projectName = projectBannerResult.name;
28451
28624
  const sessionGuidanceNote = sessionGuidanceOutcome.status === "fulfilled" ? sessionGuidanceOutcome.value : "";
@@ -28486,16 +28659,27 @@ ${versionDrift}` : "";
28486
28659
  preBuildCheckNote = lines.join("\n");
28487
28660
  }
28488
28661
  }
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 = `
28662
+ tracker.mark("parallel-tail");
28663
+ const sharedContributorsPromise = typeof adapter2.listContributors === "function" ? adapter2.listContributors().catch(() => []) : Promise.resolve([]);
28664
+ const [unblockCandidates, subAgents, teamSummaryLine, releaseHistoryLine, carryForwardRefs] = await Promise.all([
28665
+ tracked("unblock-candidates", () => findUnblockCandidates(adapter2, currentCycle))().catch(() => []),
28666
+ // task-1866: discover project sub-agents for the orient surface (read-only, never throws).
28667
+ listAgents(config2.projectRoot),
28668
+ // task-2071 (MU-3) + task-2072 (MU-5): team summary + release-history — both
28669
+ // multi-member only; solo projects get undefined from both, so orient stays
28670
+ // byte-identical there.
28671
+ tracked("team-summary", () => computeTeamSummary(adapter2, sharedContributorsPromise))().catch(() => void 0),
28672
+ tracked("release-history", () => computeReleaseHistory(adapter2, sharedContributorsPromise))().catch(() => void 0),
28673
+ // task-2751 (C332): resolve every task-NNNN mentioned in the Carry-Forward
28674
+ // prose to its title so orient can name it inline. Built from the board
28675
+ // already in hand — no extra query.
28676
+ resolveCarryForwardRefs(healthResult.carryForward, allTasks, adapter2)
28677
+ ]);
28678
+ const unblockSection = formatUnblockSection(unblockCandidates);
28679
+ const unblockNote = unblockSection ? `
28495
28680
 
28496
- ${section}`;
28497
- } catch {
28498
- }
28681
+ ${unblockSection}` : "";
28682
+ const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
28499
28683
  let deferredGateNote = "";
28500
28684
  if (deepHousekeeping) {
28501
28685
  try {
@@ -28507,16 +28691,22 @@ ${section}`;
28507
28691
  } catch {
28508
28692
  }
28509
28693
  }
28694
+ let onboardingCoachingNote = "";
28695
+ try {
28696
+ const adsForCoaching = await sharedActiveDecisionsPromise;
28697
+ onboardingCoachingNote = formatOnboardingCoachingBlock({
28698
+ surface: "orient",
28699
+ repoConnected: projectBannerResult.repoConnected,
28700
+ hasLocalWorkspace: hasLocalWorkspace(),
28701
+ hasActiveDecisions: adsForCoaching.length > 0,
28702
+ cycleNumber: currentCycle,
28703
+ hasActiveCycle: currentCycle > 0 && !cycleIsComplete
28704
+ });
28705
+ } catch {
28706
+ }
28510
28707
  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
28708
  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);
28709
+ 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
28710
  } catch (err) {
28521
28711
  const message = err instanceof Error ? err.message : String(err);
28522
28712
  const isKnownFriendly = /^(Orient failed|Project not found|No project|Setup required)/i.test(message);
@@ -28571,7 +28761,7 @@ function enrichClaudeMd(projectRoot, cycleNumber, adapterType, collector, client
28571
28761
  // src/tools/hierarchy.ts
28572
28762
  var hierarchyUpdateTool = {
28573
28763
  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.",
28764
+ 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
28765
  annotations: { title: "Update Hierarchy", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
28576
28766
  inputSchema: {
28577
28767
  type: "object",
@@ -28579,129 +28769,218 @@ var hierarchyUpdateTool = {
28579
28769
  level: {
28580
28770
  type: "string",
28581
28771
  enum: ["phase", "stage", "horizon"],
28582
- description: "Which hierarchy level to update."
28772
+ description: "Which hierarchy level to create/update."
28583
28773
  },
28584
28774
  name: {
28585
28775
  type: "string",
28586
- description: "The label or ID of the stage/horizon to update."
28776
+ description: "The label, slug, or ID of the entity. On create, becomes the label if `label` is omitted."
28777
+ },
28778
+ label: {
28779
+ type: "string",
28780
+ description: 'Display label. Required to CREATE a new stage/horizon when `name` does not match an existing one (e.g. "S2: Alpha Cohort").'
28781
+ },
28782
+ slug: {
28783
+ type: "string",
28784
+ description: "Explicit slug for a new entity (stage/horizon). Auto-derived from the label when omitted."
28785
+ },
28786
+ description: {
28787
+ type: "string",
28788
+ description: "Optional longer description (stage/horizon)."
28587
28789
  },
28588
28790
  status: {
28589
28791
  type: "string",
28590
28792
  enum: ["Not Started", "In Progress", "Done", "Deferred"],
28591
- description: "The new status to set."
28793
+ description: 'The status to set. On create, defaults to "Not Started".'
28794
+ },
28795
+ sort_order: {
28796
+ type: "number",
28797
+ description: "Display order for a new entity. Auto-computed (max existing + 10) when omitted."
28798
+ },
28799
+ horizon: {
28800
+ type: "string",
28801
+ description: "Parent horizon (name/slug/id) when CREATING a stage. Defaults to the sole horizon if only one exists."
28592
28802
  },
28593
28803
  exit_criteria: {
28594
28804
  type: "array",
28595
28805
  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.'
28806
+ 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)."
28807
+ },
28808
+ set_criterion_met: {
28809
+ type: "object",
28810
+ description: "Flip a single stage exit criterion (task-1625). { criterion_id, met, evidence? }.",
28811
+ properties: {
28812
+ criterion_id: { type: "string", description: "The ExitCriterion id to flip." },
28813
+ met: { type: "boolean", description: "true = met, false = unmet." },
28814
+ evidence: { type: "string", description: 'Optional evidence for a met criterion (e.g. "task-1234 shipped").' }
28815
+ },
28816
+ required: ["criterion_id", "met"]
28597
28817
  }
28598
28818
  },
28599
28819
  required: ["level", "name"]
28600
28820
  }
28601
28821
  };
28602
28822
  var VALID_STATUSES3 = /* @__PURE__ */ new Set(["Not Started", "In Progress", "Done", "Deferred"]);
28823
+ function slugify(input) {
28824
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "item";
28825
+ }
28826
+ function nextSortOrder(existing) {
28827
+ if (existing.length === 0) return 10;
28828
+ return Math.max(...existing.map((e) => e.sortOrder)) + 10;
28829
+ }
28603
28830
  async function handleHierarchyUpdate(adapter2, args) {
28604
28831
  const level = args.level;
28605
28832
  const name = args.name;
28833
+ const label = args.label;
28834
+ const slug = args.slug;
28835
+ const description = args.description;
28606
28836
  const status = args.status;
28837
+ const sortOrder = typeof args.sort_order === "number" ? args.sort_order : void 0;
28838
+ const horizonRef = args.horizon;
28607
28839
  const exitCriteria = args.exit_criteria;
28840
+ const setCriterion = args.set_criterion_met;
28608
28841
  if (!level || !name) {
28609
28842
  return errorResponse("Missing required parameters: level, name.");
28610
28843
  }
28611
- if (!status && !exitCriteria) {
28612
- return errorResponse("Nothing to update. Provide at least one of: status, exit_criteria.");
28613
- }
28614
28844
  if (level !== "phase" && level !== "stage" && level !== "horizon") {
28615
28845
  return errorResponse(`Invalid level "${level}". Must be "phase", "stage", or "horizon".`);
28616
28846
  }
28617
28847
  if (status && !VALID_STATUSES3.has(status)) {
28618
28848
  return errorResponse(`Invalid status "${status}". Must be one of: Not Started, In Progress, Done, Deferred.`);
28619
28849
  }
28620
- if (exitCriteria !== void 0 && level !== "stage") {
28621
- return errorResponse("exit_criteria can only be set on stages.");
28850
+ if ((exitCriteria !== void 0 || setCriterion !== void 0) && level !== "stage") {
28851
+ return errorResponse("exit_criteria and set_criterion_met can only be used on stages.");
28852
+ }
28853
+ if (!status && exitCriteria === void 0 && setCriterion === void 0 && !label) {
28854
+ return errorResponse("Nothing to do. Provide at least one of: status, exit_criteria, set_criterion_met, or label (to create).");
28622
28855
  }
28623
28856
  try {
28624
28857
  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}`);
28858
+ return await handlePhase(adapter2, name, status);
28645
28859
  }
28646
28860
  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"));
28680
- }
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"}`);
28861
+ return await handleStage(adapter2, { name, label, slug, description, status, sortOrder, horizonRef, exitCriteria, setCriterion });
28691
28862
  }
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}`);
28863
+ return await handleHorizon(adapter2, { name, label, slug, description, status, sortOrder });
28701
28864
  } catch (err) {
28702
28865
  return errorResponse(err instanceof Error ? err.message : String(err));
28703
28866
  }
28704
28867
  }
28868
+ async function handlePhase(adapter2, name, status) {
28869
+ if (!adapter2.readPhases || !adapter2.updatePhaseStatus) {
28870
+ return errorResponse("Phase management is not supported by the current adapter.");
28871
+ }
28872
+ if (!status) return errorResponse("status is required for phase updates.");
28873
+ const phases = await adapter2.readPhases();
28874
+ const phase = phases.find((p) => p.label.toLowerCase() === name.toLowerCase() || p.id === name || p.slug === name);
28875
+ if (!phase) {
28876
+ const available = phases.map((p) => p.label).join(", ");
28877
+ return errorResponse(`Phase "${name}" not found. Available phases: ${available || "none"} (phases are update-only \u2014 create them via plan/setup).`);
28878
+ }
28879
+ if (phase.status === status) return textResponse(`Phase "${phase.label}" is already "${status}". No change made.`);
28880
+ const oldStatus = phase.status;
28881
+ await adapter2.updatePhaseStatus(phase.id, status);
28882
+ return textResponse(`Phase updated: **${phase.label}** ${oldStatus} \u2192 ${status}`);
28883
+ }
28884
+ async function handleStage(adapter2, a) {
28885
+ if (!adapter2.readStages) {
28886
+ return errorResponse("Stage management is not supported by the current adapter.");
28887
+ }
28888
+ const stages = await adapter2.readStages();
28889
+ let stage = stages.find(
28890
+ (s) => s.label.toLowerCase() === a.name.toLowerCase() || s.id === a.name || s.slug === a.name
28891
+ );
28892
+ const resultLines = [];
28893
+ let created = false;
28894
+ if (!stage) {
28895
+ if (!adapter2.createHorizon || !adapter2.createStage || !adapter2.readHorizons) {
28896
+ return errorResponse("Stage creation is not supported by the current adapter.");
28897
+ }
28898
+ const createLabel = a.label ?? a.name;
28899
+ const horizons = await adapter2.readHorizons();
28900
+ let parent;
28901
+ if (a.horizonRef) {
28902
+ parent = horizons.find((h) => h.label.toLowerCase() === a.horizonRef.toLowerCase() || h.id === a.horizonRef || h.slug === a.horizonRef);
28903
+ if (!parent) {
28904
+ const available = horizons.map((h) => h.label).join(", ");
28905
+ return errorResponse(`Parent horizon "${a.horizonRef}" not found. Available horizons: ${available || "none"}.`);
28906
+ }
28907
+ } else if (horizons.length === 1) {
28908
+ parent = horizons[0];
28909
+ } else if (horizons.length === 0) {
28910
+ return errorResponse('No horizon exists to attach the stage to. Create a horizon first (level:"horizon", label:"H1: ...").');
28911
+ } else {
28912
+ return errorResponse(`Multiple horizons exist \u2014 pass \`horizon\` to say which one the stage belongs to: ${horizons.map((h) => h.label).join(", ")}.`);
28913
+ }
28914
+ const newId = await adapter2.createStage({
28915
+ slug: a.slug ?? slugify(createLabel),
28916
+ label: createLabel,
28917
+ description: a.description,
28918
+ status: a.status ?? "Not Started",
28919
+ sortOrder: a.sortOrder ?? nextSortOrder(stages.filter((s) => s.horizonId === parent.id)),
28920
+ horizonId: parent.id
28921
+ });
28922
+ const refreshed = await adapter2.readStages();
28923
+ stage = refreshed.find((s) => s.id === newId);
28924
+ if (!stage) return errorResponse("Stage was created but could not be re-read.");
28925
+ created = true;
28926
+ resultLines.push(`Stage created: **${stage.label}** (under ${parent.label}, status ${stage.status})`);
28927
+ }
28928
+ if (a.status && !created) {
28929
+ if (!adapter2.updateStageStatus) return errorResponse("Stage status updates are not supported by the current adapter.");
28930
+ if (stage.status === a.status) {
28931
+ resultLines.push(`Stage "${stage.label}" is already "${a.status}".`);
28932
+ } else {
28933
+ const oldStatus = stage.status;
28934
+ await adapter2.updateStageStatus(stage.id, a.status);
28935
+ resultLines.push(`Stage updated: **${stage.label}** ${oldStatus} \u2192 ${a.status}`);
28936
+ }
28937
+ }
28938
+ if (a.exitCriteria !== void 0) {
28939
+ if (!adapter2.updateStageExitCriteria) return errorResponse("Exit criteria updates are not supported by the current adapter.");
28940
+ await adapter2.updateStageExitCriteria(stage.id, a.exitCriteria);
28941
+ resultLines.push(`Exit criteria set (${a.exitCriteria.length} item${a.exitCriteria.length !== 1 ? "s" : ""}, all unmet):`);
28942
+ a.exitCriteria.forEach((c) => resultLines.push(` - ${c}`));
28943
+ }
28944
+ if (a.setCriterion !== void 0) {
28945
+ if (!adapter2.setCriterionMet) return errorResponse("set_criterion_met is not supported by the current adapter.");
28946
+ await adapter2.setCriterionMet(stage.id, a.setCriterion.criterion_id, a.setCriterion.met, a.setCriterion.evidence ?? null);
28947
+ const after = (await adapter2.readStages()).find((s) => s.id === stage.id);
28948
+ const crit = after?.exitCriteria?.find((c) => c.id === a.setCriterion.criterion_id);
28949
+ if (!crit) {
28950
+ resultLines.push(`\u26A0\uFE0F Criterion id "${a.setCriterion.criterion_id}" not found on this stage \u2014 no change.`);
28951
+ } else {
28952
+ const met = after?.exitCriteria?.filter((c) => c.met).length ?? 0;
28953
+ const total = after?.exitCriteria?.length ?? 0;
28954
+ resultLines.push(`Criterion "${crit.text}" \u2192 ${crit.met ? "met" : "unmet"}. [${met}/${total} criteria met]`);
28955
+ }
28956
+ }
28957
+ return textResponse(resultLines.join("\n"));
28958
+ }
28959
+ async function handleHorizon(adapter2, a) {
28960
+ if (!adapter2.readHorizons) {
28961
+ return errorResponse("Horizon management is not supported by the current adapter.");
28962
+ }
28963
+ const horizons = await adapter2.readHorizons();
28964
+ const horizon = horizons.find((h) => h.label.toLowerCase() === a.name.toLowerCase() || h.id === a.name || h.slug === a.name);
28965
+ if (!horizon) {
28966
+ if (!adapter2.createHorizon) return errorResponse("Horizon creation is not supported by the current adapter.");
28967
+ const createLabel = a.label ?? a.name;
28968
+ const newId = await adapter2.createHorizon({
28969
+ slug: a.slug ?? slugify(createLabel),
28970
+ label: createLabel,
28971
+ description: a.description,
28972
+ status: a.status ?? "Not Started",
28973
+ sortOrder: a.sortOrder ?? nextSortOrder(horizons)
28974
+ });
28975
+ return textResponse(`Horizon created: **${createLabel}** (id ${newId}, status ${a.status ?? "Not Started"})`);
28976
+ }
28977
+ if (!a.status) return errorResponse("status is required to update an existing horizon (or pass a new name to create one).");
28978
+ if (horizon.status === a.status) return textResponse(`Horizon "${horizon.label}" is already "${a.status}". No change made.`);
28979
+ if (!adapter2.updateHorizonStatus) return errorResponse("Horizon status updates are not supported by the current adapter.");
28980
+ const oldStatus = horizon.status;
28981
+ await adapter2.updateHorizonStatus(horizon.id, a.status);
28982
+ return textResponse(`Horizon updated: **${horizon.label}** ${oldStatus} \u2192 ${a.status}`);
28983
+ }
28705
28984
 
28706
28985
  // src/services/zoom-out.ts
28707
28986
  var BUDGET_SOFT = 12e4;
@@ -30887,7 +31166,7 @@ function createServer(adapter2, config2) {
30887
31166
  case "build_execute":
30888
31167
  return handleBuildExecute(adapter2, config2, safeArgs, server2.getClientVersion()?.name);
30889
31168
  case "build_cancel":
30890
- return handleBuildCancel(adapter2, safeArgs);
31169
+ return handleBuildCancel(adapter2, config2, safeArgs);
30891
31170
  case "idea":
30892
31171
  return handleIdea(adapter2, config2, safeArgs);
30893
31172
  case "backlog_import":