@papi-ai/server 0.7.6 → 0.7.8

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 (3) hide show
  1. package/dist/index.js +1667 -867
  2. package/dist/prompts.js +46 -7
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -215,6 +215,30 @@ ${after}`;
215
215
  function parseNorthStar(content) {
216
216
  return extractSection(content, "North Star").replace(/^## North Star\s*/m, "").trim();
217
217
  }
218
+ function upsertNorthStarInContent(content, statement) {
219
+ const headingPattern = /^## North Star\s*$/m;
220
+ const start = content.search(headingPattern);
221
+ if (start === -1) {
222
+ const cycleLogIdx = content.search(/^## (?:Cycle Log|Sprint Log)/m);
223
+ const newSection = `## North Star
224
+
225
+ ${statement}
226
+
227
+ `;
228
+ if (cycleLogIdx === -1) {
229
+ return content.trimEnd() + "\n\n" + newSection;
230
+ }
231
+ return content.slice(0, cycleLogIdx) + newSection + content.slice(cycleLogIdx);
232
+ }
233
+ const afterHeading = content.slice(start);
234
+ const nextSection = afterHeading.slice(1).search(/^## /m);
235
+ const sectionEnd = nextSection === -1 ? content.length : start + nextSection + 1;
236
+ return content.slice(0, start) + `## North Star
237
+
238
+ ${statement}
239
+
240
+ ` + content.slice(sectionEnd);
241
+ }
218
242
  function parseDeferred(content) {
219
243
  const section = extractSection(content, "Deferred / Parking Lot");
220
244
  return section.split("\n").filter((line) => line.match(/^-\s+/)).map((line) => line.replace(/^-\s+/, "").trim());
@@ -1428,7 +1452,8 @@ var init_dist2 = __esm({
1428
1452
  task: 1,
1429
1453
  research: 2,
1430
1454
  spike: 2,
1431
- idea: 3
1455
+ idea: 3,
1456
+ discovery: 1
1432
1457
  };
1433
1458
  VALID_EFFORT_SIZES = /* @__PURE__ */ new Set(["XS", "S", "M", "L", "XL"]);
1434
1459
  SECTION_HEADERS = [
@@ -2174,6 +2199,23 @@ ${footer}`);
2174
2199
  async getDecisionUsage(_currentCycle) {
2175
2200
  return [];
2176
2201
  }
2202
+ // --- North Star ---
2203
+ async getCurrentNorthStar() {
2204
+ const content = await this.read("PLANNING_LOG.md");
2205
+ const ns = parseNorthStar(content);
2206
+ return ns || null;
2207
+ }
2208
+ async getNorthStarSetCycle() {
2209
+ return null;
2210
+ }
2211
+ async getNorthStarStaleness() {
2212
+ return null;
2213
+ }
2214
+ async upsertNorthStar(statement, _cycleNumber) {
2215
+ const content = await this.read("PLANNING_LOG.md");
2216
+ const updated = upsertNorthStarInContent(content, statement);
2217
+ await this.write("PLANNING_LOG.md", updated);
2218
+ }
2177
2219
  };
2178
2220
  NONE_PATTERN2 = /^none\b/i;
2179
2221
  }
@@ -4468,6 +4510,7 @@ function rowToTask(row) {
4468
4510
  if (row.stage_id != null) task.stageId = row.stage_id;
4469
4511
  if (row.doc_ref != null) task.docRef = row.doc_ref;
4470
4512
  if (row.source != null) task.source = row.source;
4513
+ if (row.opportunity != null) task.opportunity = row.opportunity;
4471
4514
  return task;
4472
4515
  }
4473
4516
  function rowToBuildReport(row) {
@@ -4494,6 +4537,7 @@ function rowToBuildReport(row) {
4494
4537
  if (row.handoff_accuracy != null) report.handoffAccuracy = row.handoff_accuracy;
4495
4538
  if (row.brief_implications != null) report.briefImplications = row.brief_implications;
4496
4539
  if (row.dead_ends != null) report.deadEnds = row.dead_ends;
4540
+ if (row.tool_call_count != null) report.toolCallCount = row.tool_call_count;
4497
4541
  return report;
4498
4542
  }
4499
4543
  function rowToReview(row) {
@@ -6094,6 +6138,22 @@ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
6094
6138
  `;
6095
6139
  return rows.length > 0 ? { setCycle: rows[0].set_cycle, setAt: rows[0].set_at } : null;
6096
6140
  }
6141
+ async upsertNorthStar(statement, _cycleNumber) {
6142
+ const [newRow] = await this.sql`
6143
+ INSERT INTO north_stars (project_id, statement, set_at)
6144
+ VALUES (${this.projectId}, ${statement}, now())
6145
+ RETURNING id
6146
+ `;
6147
+ if (newRow) {
6148
+ await this.sql`
6149
+ UPDATE north_stars
6150
+ SET superseded_by_id = ${newRow.id}, superseded_at = now()
6151
+ WHERE project_id = ${this.projectId}
6152
+ AND superseded_by_id IS NULL
6153
+ AND id != ${newRow.id}
6154
+ `;
6155
+ }
6156
+ }
6097
6157
  async getEstimationCalibration() {
6098
6158
  const rows = await this.sql`
6099
6159
  SELECT estimated_effort, actual_effort, accuracy_label, COUNT(*)::text AS count
@@ -6666,7 +6726,7 @@ ${newParts.join("\n")}` : newParts.join("\n");
6666
6726
  async queryBoard(options) {
6667
6727
  if (!options) {
6668
6728
  const rows2 = await this.sql`
6669
- SELECT id, project_id, display_id, title, status, priority, complexity, module, epic, phase, owner, reviewed, cycle, created_cycle, created_at, why, depends_on, notes, closure_reason, state_history, build_handoff, build_report, task_type, maturity, stage_id, doc_ref, source, updated_at
6729
+ SELECT id, project_id, display_id, title, status, priority, complexity, module, epic, phase, owner, reviewed, cycle, created_cycle, created_at, why, depends_on, notes, closure_reason, state_history, build_handoff, build_report, task_type, maturity, stage_id, doc_ref, source, opportunity, updated_at
6670
6730
  FROM cycle_tasks
6671
6731
  WHERE project_id = ${this.projectId}
6672
6732
  ORDER BY display_id
@@ -6707,7 +6767,7 @@ ${newParts.join("\n")}` : newParts.join("\n");
6707
6767
  where = this.sql`${where} AND ${conditions[i]}`;
6708
6768
  }
6709
6769
  const rows = await this.sql`
6710
- SELECT id, project_id, display_id, title, status, priority, complexity, module, epic, phase, owner, reviewed, cycle, created_cycle, created_at, why, depends_on, notes, closure_reason, state_history, build_handoff, build_report, task_type, maturity, stage_id, doc_ref, source, updated_at
6770
+ SELECT id, project_id, display_id, title, status, priority, complexity, module, epic, phase, owner, reviewed, cycle, created_cycle, created_at, why, depends_on, notes, closure_reason, state_history, build_handoff, build_report, task_type, maturity, stage_id, doc_ref, source, opportunity, updated_at
6711
6771
  FROM cycle_tasks WHERE ${where} ORDER BY display_id
6712
6772
  LIMIT 2000 -- matches no-options path ceiling
6713
6773
  `;
@@ -6715,7 +6775,7 @@ ${newParts.join("\n")}` : newParts.join("\n");
6715
6775
  }
6716
6776
  async getTask(id) {
6717
6777
  const [row] = await this.sql`
6718
- SELECT id, project_id, display_id, title, status, priority, complexity, module, epic, phase, owner, reviewed, cycle, created_cycle, created_at, why, depends_on, notes, closure_reason, state_history, build_handoff, build_report, task_type, maturity, stage_id, doc_ref, source, updated_at
6778
+ SELECT id, project_id, display_id, title, status, priority, complexity, module, epic, phase, owner, reviewed, cycle, created_cycle, created_at, why, depends_on, notes, closure_reason, state_history, build_handoff, build_report, task_type, maturity, stage_id, doc_ref, source, opportunity, updated_at
6719
6779
  FROM cycle_tasks
6720
6780
  WHERE project_id = ${this.projectId} AND display_id = ${id}
6721
6781
  LIMIT 1
@@ -6725,7 +6785,7 @@ ${newParts.join("\n")}` : newParts.join("\n");
6725
6785
  async getTasks(ids) {
6726
6786
  if (ids.length === 0) return [];
6727
6787
  const rows = await this.sql`
6728
- SELECT id, project_id, display_id, title, status, priority, complexity, module, epic, phase, owner, reviewed, cycle, created_cycle, created_at, why, depends_on, notes, closure_reason, state_history, build_handoff, build_report, task_type, maturity, stage_id, doc_ref, source, updated_at
6788
+ SELECT id, project_id, display_id, title, status, priority, complexity, module, epic, phase, owner, reviewed, cycle, created_cycle, created_at, why, depends_on, notes, closure_reason, state_history, build_handoff, build_report, task_type, maturity, stage_id, doc_ref, source, opportunity, updated_at
6729
6789
  FROM cycle_tasks
6730
6790
  WHERE project_id = ${this.projectId} AND display_id = ANY(${ids})
6731
6791
  LIMIT 2000 -- matches board ceiling; ids[] won't exceed this in practice
@@ -6745,7 +6805,7 @@ ${newParts.join("\n")}` : newParts.join("\n");
6745
6805
  project_id, display_id, title, status, priority, complexity,
6746
6806
  module, epic, phase, owner, reviewed, cycle, created_cycle,
6747
6807
  why, depends_on, notes, closure_reason, state_history,
6748
- build_handoff, build_report, task_type, maturity, stage_id, doc_ref, source
6808
+ build_handoff, build_report, task_type, maturity, stage_id, doc_ref, source, opportunity
6749
6809
  ) VALUES (
6750
6810
  ${this.projectId}, ${displayId}, ${task.title}, ${task.status}, ${task.priority},
6751
6811
  ${normaliseComplexity(task.complexity)}, ${task.module}, ${task.epic ?? null}, ${task.phase}, ${task.owner},
@@ -6759,7 +6819,8 @@ ${newParts.join("\n")}` : newParts.join("\n");
6759
6819
  ${task.maturity ?? null},
6760
6820
  ${task.stageId ?? null},
6761
6821
  ${task.docRef ?? null},
6762
- ${task.source ?? null}
6822
+ ${task.source ?? null},
6823
+ ${task.opportunity ?? null}
6763
6824
  )
6764
6825
  RETURNING *
6765
6826
  `;
@@ -6791,6 +6852,7 @@ ${newParts.join("\n")}` : newParts.join("\n");
6791
6852
  if (updates.stageId !== void 0) columnMap["stage_id"] = updates.stageId;
6792
6853
  if (updates.docRef !== void 0) columnMap["doc_ref"] = updates.docRef;
6793
6854
  if (updates.source !== void 0) columnMap["source"] = updates.source;
6855
+ if (updates.opportunity !== void 0) columnMap["opportunity"] = updates.opportunity;
6794
6856
  const keys = Object.keys(columnMap);
6795
6857
  if (keys.length === 0) return;
6796
6858
  await this.sql`
@@ -7190,6 +7252,16 @@ ${newParts.join("\n")}` : newParts.join("\n");
7190
7252
  `;
7191
7253
  return rows.map(rowToToolCallMetric);
7192
7254
  }
7255
+ async getToolCallCount(startedAt, completedAt) {
7256
+ const rows = await this.sql`
7257
+ SELECT COUNT(*)::text AS count
7258
+ FROM tool_call_metrics
7259
+ WHERE project_id = ${this.projectId}
7260
+ AND timestamp >= ${startedAt}
7261
+ AND timestamp <= ${completedAt}
7262
+ `;
7263
+ return parseInt(rows[0]?.count ?? "0", 10);
7264
+ }
7193
7265
  // -------------------------------------------------------------------------
7194
7266
  // Cost Summary
7195
7267
  // -------------------------------------------------------------------------
@@ -7631,7 +7703,7 @@ ${r.content}` + (r.carry_forward ? `
7631
7703
  FROM cycle_tasks
7632
7704
  WHERE project_id = ${this.projectId}
7633
7705
  AND status NOT IN ('Done', 'Cancelled', 'Archived', 'Deferred')
7634
- AND (task_type IN ('task', 'bug', 'research') OR task_type IS NULL)
7706
+ AND (task_type IN ('task', 'bug', 'research', 'discovery', 'spike') OR task_type IS NULL)
7635
7707
  ORDER BY
7636
7708
  CASE priority
7637
7709
  WHEN 'P0 Critical' THEN 0
@@ -7929,7 +8001,8 @@ ${newParts.join("\n")}` : newParts.join("\n");
7929
8001
  build_report: task.buildReport ?? null,
7930
8002
  task_type: task.taskType ?? null,
7931
8003
  maturity: task.maturity ?? null,
7932
- stage_id: task.stageId ?? null
8004
+ stage_id: task.stageId ?? null,
8005
+ opportunity: task.opportunity ?? null
7933
8006
  };
7934
8007
  });
7935
8008
  const taskCols = [
@@ -7955,7 +8028,8 @@ ${newParts.join("\n")}` : newParts.join("\n");
7955
8028
  "build_report",
7956
8029
  "task_type",
7957
8030
  "maturity",
7958
- "stage_id"
8031
+ "stage_id",
8032
+ "opportunity"
7959
8033
  ];
7960
8034
  const insertedRows = await tx`
7961
8035
  INSERT INTO cycle_tasks ${tx(taskRows, ...taskCols)}
@@ -8567,6 +8641,9 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
8567
8641
  getNorthStarStaleness() {
8568
8642
  return this.invoke("getNorthStarStaleness");
8569
8643
  }
8644
+ upsertNorthStar(statement, cycleNumber) {
8645
+ return this.invoke("upsertNorthStar", [statement, cycleNumber]);
8646
+ }
8570
8647
  // --- Optional pg-only methods ---
8571
8648
  getEstimationCalibration() {
8572
8649
  return this.invoke("getEstimationCalibration");
@@ -8649,6 +8726,7 @@ __export(git_exports, {
8649
8726
  createAndCheckoutBranch: () => createAndCheckoutBranch,
8650
8727
  createPullRequest: () => createPullRequest,
8651
8728
  createTag: () => createTag,
8729
+ cycleBranchName: () => cycleBranchName,
8652
8730
  deleteLocalBranch: () => deleteLocalBranch,
8653
8731
  detectBoardMismatches: () => detectBoardMismatches,
8654
8732
  detectUnrecordedCommits: () => detectUnrecordedCommits,
@@ -9104,6 +9182,9 @@ function detectUnrecordedCommits(cwd, baseBranch) {
9104
9182
  function taskBranchName(taskId) {
9105
9183
  return `feat/${taskId}`;
9106
9184
  }
9185
+ function cycleBranchName(cycleNumber, module) {
9186
+ return `feat/cycle-${cycleNumber}-${module.toLowerCase().replace(/\s+/g, "-")}`;
9187
+ }
9107
9188
  function getHeadCommitSha(cwd) {
9108
9189
  try {
9109
9190
  return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf-8" }).trim() || null;
@@ -9143,9 +9224,11 @@ var init_git = __esm({
9143
9224
 
9144
9225
  // src/index.ts
9145
9226
  import { readFileSync as readFileSync4 } from "fs";
9227
+ import { createServer as createHttpServer } from "http";
9146
9228
  import { dirname as dirname2, join as join11 } from "path";
9147
9229
  import { fileURLToPath as fileURLToPath2 } from "url";
9148
9230
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9231
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9149
9232
  import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
9150
9233
  import {
9151
9234
  CallToolRequestSchema as CallToolRequestSchema2,
@@ -9168,6 +9251,8 @@ function loadConfig() {
9168
9251
  const baseBranch = process.env.PAPI_BASE_BRANCH ?? "main";
9169
9252
  const autoPR = process.env.PAPI_AUTO_PR !== "false";
9170
9253
  const lightMode = process.env.PAPI_LIGHT_MODE === "true";
9254
+ const projectOwner = process.env.PAPI_OWNER ?? "Cathal";
9255
+ const skipProjectSpecificRules = process.env.PAPI_SKIP_PROJECT_RULES === "true";
9171
9256
  const papiEndpoint = process.env.PAPI_ENDPOINT;
9172
9257
  const dataEndpoint = process.env.PAPI_DATA_ENDPOINT;
9173
9258
  const databaseUrl = process.env.DATABASE_URL;
@@ -9182,7 +9267,9 @@ function loadConfig() {
9182
9267
  autoPR,
9183
9268
  adapterType,
9184
9269
  papiEndpoint,
9185
- lightMode
9270
+ lightMode,
9271
+ projectOwner,
9272
+ skipProjectSpecificRules
9186
9273
  };
9187
9274
  }
9188
9275
 
@@ -9538,7 +9625,7 @@ function formatDetailedTask(t) {
9538
9625
  return `- **${t.id}:** ${t.title}
9539
9626
  Status: ${t.status} | Priority: ${t.priority} | Complexity: ${t.complexity}${typeTag}
9540
9627
  Module: ${t.module} | Epic: ${t.epic} | Phase: ${t.phase} | Owner: ${t.owner}
9541
- Reviewed: ${t.reviewed}${t.dependsOn ? ` | Depends on: ${t.dependsOn}` : ""}${hasHandoff ? " | Has BUILD HANDOFF: yes" : ""}${t.docRef ? ` | Doc ref: ${t.docRef}` : ""}${notes ? `
9628
+ Reviewed: ${t.reviewed}${t.dependsOn ? ` | Depends on: ${t.dependsOn}` : ""}${hasHandoff ? " | Has BUILD HANDOFF: yes" : ""}${t.docRef ? ` | Doc ref: ${t.docRef}` : ""}${t.opportunity ? ` | Opportunity: ${t.opportunity}` : ""}${notes ? `
9542
9629
  Notes: ${notes}` : ""}`;
9543
9630
  }
9544
9631
  function formatBoardForPlan(tasks, filters, currentCycle) {
@@ -9945,6 +10032,191 @@ function logDataSourceSummary(service, sources) {
9945
10032
  console.error(`[data-sources] ${service}: ${populated.length}/${sources.length} sources have data \u2014 empty: ${emptyLabels}`);
9946
10033
  }
9947
10034
 
10035
+ // src/lib/codebase-scan.ts
10036
+ import { execSync as execSync2 } from "child_process";
10037
+ var STOP_WORDS = /* @__PURE__ */ new Set([
10038
+ "a",
10039
+ "an",
10040
+ "the",
10041
+ "and",
10042
+ "or",
10043
+ "but",
10044
+ "in",
10045
+ "on",
10046
+ "at",
10047
+ "to",
10048
+ "for",
10049
+ "of",
10050
+ "with",
10051
+ "by",
10052
+ "from",
10053
+ "is",
10054
+ "are",
10055
+ "was",
10056
+ "were",
10057
+ "be",
10058
+ "been",
10059
+ "has",
10060
+ "have",
10061
+ "had",
10062
+ "do",
10063
+ "does",
10064
+ "did",
10065
+ "will",
10066
+ "would",
10067
+ "could",
10068
+ "should",
10069
+ "may",
10070
+ "might",
10071
+ "can",
10072
+ "not",
10073
+ "no",
10074
+ "if",
10075
+ "then",
10076
+ "than",
10077
+ "that",
10078
+ "this",
10079
+ "it",
10080
+ "its",
10081
+ "all",
10082
+ "each",
10083
+ "every",
10084
+ "both",
10085
+ "as",
10086
+ "so",
10087
+ "up",
10088
+ "out",
10089
+ "about",
10090
+ "into",
10091
+ "over",
10092
+ "after",
10093
+ "before",
10094
+ "between",
10095
+ "under",
10096
+ "above",
10097
+ "such",
10098
+ "only",
10099
+ "also",
10100
+ "just",
10101
+ "more",
10102
+ "most",
10103
+ "other",
10104
+ "some",
10105
+ "any",
10106
+ "new",
10107
+ "when",
10108
+ "how",
10109
+ "what",
10110
+ "which",
10111
+ "who",
10112
+ "add",
10113
+ "create",
10114
+ "build",
10115
+ "implement",
10116
+ "make",
10117
+ "update",
10118
+ "fix",
10119
+ "use",
10120
+ "via",
10121
+ "show",
10122
+ "display",
10123
+ "view",
10124
+ "page",
10125
+ "data",
10126
+ "based",
10127
+ "using",
10128
+ "task",
10129
+ "feature",
10130
+ "system",
10131
+ "tool",
10132
+ "mode",
10133
+ "field",
10134
+ "type",
10135
+ "status",
10136
+ "current",
10137
+ "default",
10138
+ "existing",
10139
+ "need",
10140
+ "instead",
10141
+ "allow",
10142
+ "change"
10143
+ ]);
10144
+ function extractSearchTerms(title, notes) {
10145
+ const combined = `${title} ${notes ?? ""}`;
10146
+ const camelCase = combined.match(/[a-z][a-zA-Z]{5,}/g) ?? [];
10147
+ const snakeCase = combined.match(/[a-z]+_[a-z_]+/g) ?? [];
10148
+ const hyphenated = combined.match(/[a-z]+-[a-z]+-?[a-z]*/g) ?? [];
10149
+ const filePaths = combined.match(/[\w/.-]+\.(ts|tsx|js|jsx|sql|md)/g) ?? [];
10150
+ const words = combined.toLowerCase().replace(/[^a-z0-9\s_-]/g, " ").split(/\s+/).filter((w) => w.length >= 4 && !STOP_WORDS.has(w));
10151
+ const seen = /* @__PURE__ */ new Set();
10152
+ const terms = [];
10153
+ for (const group of [filePaths, camelCase, snakeCase, hyphenated, words]) {
10154
+ for (const term of group) {
10155
+ const normalized = term.toLowerCase();
10156
+ if (!seen.has(normalized) && normalized.length >= 4) {
10157
+ seen.add(normalized);
10158
+ terms.push(term);
10159
+ }
10160
+ }
10161
+ }
10162
+ return terms.slice(0, 8);
10163
+ }
10164
+ function grepForTerm(projectRoot, term) {
10165
+ try {
10166
+ const result = execSync2(
10167
+ `grep -rl --include='*.ts' --include='*.tsx' --include='*.js' --include='*.sql' --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=.git --exclude-dir=.next ${JSON.stringify(term)} ${JSON.stringify(projectRoot)} 2>/dev/null | head -5`,
10168
+ { encoding: "utf-8", timeout: 3e3 }
10169
+ );
10170
+ return result.trim().split("\n").filter(Boolean).map(
10171
+ (p) => p.replace(projectRoot + "/", "")
10172
+ );
10173
+ } catch {
10174
+ return [];
10175
+ }
10176
+ }
10177
+ function scanCodebaseForTasks(projectRoot, tasks) {
10178
+ if (tasks.length === 0) return "";
10179
+ const startTime = Date.now();
10180
+ const results = [];
10181
+ for (const task of tasks) {
10182
+ const terms = extractSearchTerms(task.title, task.notes);
10183
+ if (terms.length === 0) continue;
10184
+ const matches = [];
10185
+ for (const term of terms) {
10186
+ if (term.length < 4) continue;
10187
+ const files = grepForTerm(projectRoot, term);
10188
+ if (files.length > 0) {
10189
+ matches.push({ term, files });
10190
+ }
10191
+ if (Date.now() - startTime > 5e3) {
10192
+ console.error(`[codebase-scan] timeout after ${Date.now() - startTime}ms \u2014 partial results returned`);
10193
+ break;
10194
+ }
10195
+ }
10196
+ if (matches.length > 0) {
10197
+ results.push({ taskId: task.id, terms, matches });
10198
+ }
10199
+ if (Date.now() - startTime > 5e3) break;
10200
+ }
10201
+ if (results.length === 0) return "";
10202
+ const elapsed = Date.now() - startTime;
10203
+ console.error(`[codebase-scan] scanned ${tasks.length} tasks in ${elapsed}ms \u2014 ${results.length} with matches`);
10204
+ const lines = [
10205
+ `Codebase scan found existing implementations for ${results.length}/${tasks.length} candidate tasks (${elapsed}ms):`,
10206
+ ""
10207
+ ];
10208
+ for (const result of results) {
10209
+ lines.push(`**${result.taskId}:**`);
10210
+ for (const match of result.matches.slice(0, 3)) {
10211
+ const fileList = match.files.slice(0, 3).join(", ");
10212
+ const moreCount = match.files.length > 3 ? ` (+${match.files.length - 3} more)` : "";
10213
+ lines.push(` - "${match.term}" \u2192 ${fileList}${moreCount}`);
10214
+ }
10215
+ lines.push("");
10216
+ }
10217
+ return lines.join("\n").trim();
10218
+ }
10219
+
9948
10220
  // src/lib/slack.ts
9949
10221
  async function sendSlackWebhook(webhookUrl, summary, header = "PAPI Strategy Review") {
9950
10222
  if (!webhookUrl) return void 0;
@@ -10004,6 +10276,9 @@ Task: [title]
10004
10276
  Cycle: [N]
10005
10277
  Why now: [justification]
10006
10278
 
10279
+ DEPENDS ON
10280
+ [Optional \u2014 comma-separated task IDs this task depends on (e.g. "task-123, task-124"). Include only when another task in this same cycle must be built first because this task consumes artifacts it creates (e.g. new adapter method, new type, new migration). The builder will reuse the upstream task's branch so dependent commits stack on the same branch for a single PR. Omit this section entirely if there are no intra-cycle dependencies.]
10281
+
10007
10282
  SCOPE (DO THIS)
10008
10283
  [specific deliverables \u2014 write for the simplest viable path first]
10009
10284
 
@@ -10192,8 +10467,11 @@ Standard planning cycle with full board review.
10192
10467
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
10193
10468
  Within the same priority level, prefer tasks with the highest **impact-to-effort ratio**. Impact is measured by: (a) strategic alignment \u2014 does it advance the current horizon/phase? (b) unlocks other work \u2014 are tasks blocked by this? (c) user-facing \u2014 does it change what users see? (d) compounds over time \u2014 does it make future cycles faster? A high-impact Medium task beats a low-impact Small task at the same priority level. Justify in 2-3 sentences.
10194
10469
  **Blocked tasks:** Tasks with status "Blocked" MUST be skipped during task selection \u2014 they are waiting on external dependencies or gates and cannot be built. Do NOT generate BUILD HANDOFFs for blocked tasks. Do NOT recommend blocked tasks. If a blocked task's gate has been resolved (check the notes and recent build reports), emit a \`boardCorrections\` entry to move it back to Backlog. Report blocked task count in the cycle log.
10195
- **Cycle sizing:** Size the cycle based on what the selected tasks actually require \u2014 not a fixed budget. Select the highest-priority unblocked tasks, estimate each one's effort from its scope, and let the total emerge from the tasks themselves. The historical average effort from Methodology Trends is a reference point for calibration, not a target or floor. A healthy cycle has 4-6 tasks. Cycles with fewer than 4 tasks require explicit justification in the cycle log \u2014 explain why more tasks could not be included. When the backlog has 10+ tasks, the cycle SHOULD have 5+ tasks \u2014 undersized cycles waste planning overhead relative to the available work. If fewer than 4 tasks qualify after filtering (blocked, deferred, raw), check Deferred tasks \u2014 some may be ready to un-defer via a \`boardCorrections\` entry. A 1-task cycle is almost never correct.
10470
+ **Cycle sizing:** Size the cycle based on what the selected tasks actually require \u2014 not a fixed budget. Select the highest-priority unblocked tasks, estimate each one's effort from its scope, and let the total emerge. The historical average effort from Methodology Trends is a reference point for calibration, not a target or floor. A healthy cycle has 6-10 tasks. Cycles with fewer than 5 tasks require explicit justification in the cycle log \u2014 explain why more tasks could not be included. When the backlog has 10+ tasks, the cycle SHOULD have 6+ tasks \u2014 undersized cycles waste planning overhead relative to the available work. If fewer than 5 tasks qualify after filtering (blocked, deferred, raw), check Deferred tasks \u2014 some may be ready to un-defer via a \`boardCorrections\` entry. A 1-task cycle is almost never correct. Prefer grouping tasks by module or similarity \u2014 reduces context switching and enables shared branches during the build phase.
10471
+ **Theme-driven sizing:** Single-theme cycles (all tasks in the same module or epic) can absorb 25-30 effort points because builders maintain context across tasks. Mixed-theme cycles should stay at 15-20 effort points to limit context switching. Use the theme to determine the budget, not a fixed number.
10196
10472
  **Theme coherence:** After selecting candidate tasks, check whether they form a coherent theme \u2014 all serving one goal, phase, or module. Single-theme cycles produce better build quality and less context switching. If the top candidates touch 3+ unrelated modules or epics, prefer regrouping around the highest-priority theme and deferring the outliers. Mixed-theme cycles are acceptable when justified (e.g. a P0 fix alongside P1 feature work), but the justification must appear in the cycle log. Name the theme in 3-5 words \u2014 it becomes the \`cycleLogTitle\`.
10473
+ **Epic-aware batching:** Epic is the primary grouping signal for theme coherence. When multiple candidate tasks share the same epic (e.g. "Onboarding Redesign", "Dashboard Polish"), prefer co-scheduling them \u2014 they solve connected problems and benefit from shared context during the build. Steps: (1) After filtering by priority, group eligible tasks by epic. (2) If an epic has 3+ eligible tasks, prefer scheduling 2-4 of them together over cherry-picking across epics. (3) Report the epic distribution in the cycle log (e.g. "4 tasks from Onboarding epic, 1 from Platform"). Priority still overrides: a P0 fix from a different epic always takes precedence.
10474
+ **Opportunity clustering:** If backlog tasks have an \`opportunity\` field populated, group them by opportunity before selecting. Tasks sharing the same opportunity solve the same user problem \u2014 co-scheduling them produces more coherent cycles. Report opportunity clusters in the cycle log when present (e.g. "3 tasks clustered under 'planner accuracy' opportunity").
10197
10475
 
10198
10476
  8. **Cycle Log** \u2014 Write 5-10 line entry: what was triaged, what was recommended and why, observations, AD updates. Include a **Priority Recalibration** paragraph if any unreviewed task priorities were changed during triage (Step 2) \u2014 list each by ID with old \u2192 new priority and rationale. Include a **Priority Drift Suggestions** paragraph if reviewed task drift was detected (Step 3).
10199
10477
  **Cycle Notes** \u2014 Optionally include 1-3 lines of cycle-level observations in \`cycleLogNotes\`: estimation accuracy patterns, recurring blockers, velocity trends, or dependency signals. These notes persist across cycles so future planning runs can learn from them. Use null if there are no noteworthy observations this cycle.
@@ -10212,11 +10490,17 @@ Standard planning cycle with full board review.
10212
10490
  **Scope pre-check:** Before writing the SCOPE section of each handoff, cross-reference the task against the "Recently Shipped Capabilities" section in the context below (if present). For each candidate task: (1) check if the task's title or scope overlaps with any recently shipped task, (2) check if the FILES LIKELY TOUCHED overlap with files already modified in recent builds, (3) check the architecture notes from recent builds for patterns that already cover this task's scope. If >80% of a task's scope appears in recently shipped capabilities, recommend cancellation via \`boardCorrections\` or reduce the handoff scope to only the missing pieces \u2014 explicitly note what already exists. C126 task-728 was over-scoped because the planner assumed Blocked status needed creating from scratch \u2014 it already existed in types, DB, orient, and build_list. Over-scoped handoffs waste builder time on verification and cause estimation mismatches.
10213
10491
  **Simplest Viable Path rule:** Before writing each BUILD HANDOFF, identify the simplest approach that satisfies the task's goal \u2014 the minimum change, fewest new abstractions, and smallest blast radius. Write the SCOPE (DO THIS) section for that simplest path FIRST. If you believe a more complex approach is warranted (new abstractions, multi-file refactors, framework changes), you MUST include a "WHY NOT SIMPLER" line in the handoff explaining why the simple path is insufficient. If you cannot articulate a concrete reason, use the simpler path. Pay special attention to tasks involving auth, data access, multi-user features, and infrastructure \u2014 these are the most common over-engineering targets.
10214
10492
  **Maturity gate applies here:** Do NOT generate BUILD HANDOFFs for tasks that failed the maturity gate in step 6 (phase prerequisites not met, dependency chain incomplete). Raw tasks that the planner has scoped and upgraded to "investigated" in step 6 ARE eligible for handoffs.
10493
+ **Intra-cycle dependency detection:** After selecting cycle tasks, check every pair for build-order dependencies. Two tasks A and B have an intra-cycle dependency when A must be built before B because B consumes an artifact A creates \u2014 e.g. A adds a new adapter method that B calls, A creates a DB migration B depends on, A introduces a new shared type B imports, A refactors a utility B modifies. Signals: same module + adjacent scope (one is "add X", another is "use X"), or notes explicitly reference the other task. For each dependency detected:
10494
+ - Populate the DEPENDS ON section in the dependent task's BUILD HANDOFF with the upstream task ID(s).
10495
+ - Add a \`boardCorrections\` entry for the dependent task with \`updates.dependsOn\` set to the comma-separated upstream IDs \u2014 this persists the dependency so the builder's runtime can reuse the upstream branch.
10496
+ - Keep the SCOPE sections independent (each task still has its own deliverable) but note the ordering in "Why now" \u2014 e.g. "depends on task-123 completing the adapter method".
10497
+ Do NOT invent dependencies where tasks merely share a module \u2014 only real build-order coupling counts. Linear chains only \u2014 do not attempt to resolve multi-level graphs. When in doubt, omit the dependency and let the builder discover it.
10215
10498
  **Security section guidance:** Each handoff includes a SECURITY CONSIDERATIONS section. Populate it when the task involves: data exposure risks (PII, secrets in logs/storage), secrets or credentials handling (API keys, tokens, env vars), auth/access control changes, or dependency security risks (new packages, version changes). For pure refactoring, documentation, prompt-text, or UI-only tasks, write "None \u2014 no security-relevant changes".
10216
10499
  **Estimation calibration:** Estimate **XS** for: copy/text-only changes, single string replacements, config tweaks, and any task where the scope is "change words in an existing file" with no logic changes. Estimate **S** for: wiring existing adapter methods, adding API routes following established patterns, modifying prompts, or documentation-only changes. Default to S for pattern-following work. Only use M when genuine new architecture, new DB tables, or multi-file architectural changes are needed. Historical data shows systematic over-estimation (198 over vs 8 under out of 528 tasks) \u2014 when in doubt, estimate smaller. If an "Estimation Calibration (Historical)" section is provided in the context below, use its data to adjust your estimates \u2014 it shows how often each estimated size matched the actual effort. Pay special attention to systematic over/under-estimation patterns (e.g. if M\u2192S happens frequently, estimate S instead of M for similar work).
10217
10500
  **Reference docs:** If a task's notes include a \`Reference:\` path (e.g. \`Reference: docs/architecture/papi-brain-v1.md\`), include a REFERENCE DOCS section in the BUILD HANDOFF with those paths. This tells the builder to read the referenced doc for background context before implementing. Do NOT omit or summarise the reference \u2014 pass it through so the builder can access the full document. Only tasks with explicit \`Reference:\` paths in their notes should have this section.
10218
10501
  **Pre-build verification:** EVERY handoff MUST include a PRE-BUILD VERIFICATION section listing 2-5 specific file paths the builder should read before implementing. Derive these from FILES LIKELY TOUCHED \u2014 pick the files most likely to already contain the target functionality. This is the #1 prevention mechanism for wasted build slots (C120, C125, C126 all scheduled already-shipped work). If the builder finds >80% of the scope already implemented, they report "already built" instead of re-implementing.
10219
10502
  **Pre-mortem:** For projects with 10+ cycles, include a PRE-MORTEM section in every BUILD HANDOFF with 1-3 bullet points: (a) most likely technical blocker based on module history, (b) integration risk with adjacent systems, (c) scope creep signal \u2014 what the builder might be tempted to expand beyond scope. Draw from \`dead_ends\` and \`surprises\` in recent build reports for the same module. Omit this section entirely for projects with fewer than 10 cycles.
10503
+ **Build order in cycle log:** If any intra-cycle dependencies were detected in this cycle, include a "Build Order" paragraph in \`cycleLogNotes\` showing the recommended build sequence as arrow chains (e.g. "Build order: task-123 \u2192 task-124; task-130 standalone"). Skip this paragraph when no dependencies exist.
10220
10504
  **Research task detection:** When a task's title starts with "Research:" or the task type is "research", add a RESEARCH OUTPUT section to the BUILD HANDOFF after ACCEPTANCE CRITERIA:
10221
10505
 
10222
10506
  RESEARCH OUTPUT
@@ -10234,7 +10518,8 @@ Standard planning cycle with full board review.
10234
10518
  **Idea task detection:** When a task's task type is "idea", add a scope clarification note to the BUILD HANDOFF:
10235
10519
  - Add to SCOPE (DO THIS): "This task originated as an idea. Confirm the exact deliverable before implementing \u2014 check task notes and any referenced docs for intent. If scope is unclear, flag it in the build report surprises."
10236
10520
 
10237
- **UI/visual task detection:** When a task's title or notes contain keywords suggesting frontend visual work (e.g. "visual", "design", "UI", "styling", "refresh", "frontend", "landing page", "hero", "carousel", "theme", "layout", "cockpit", "dashboard", "page"), apply these handoff additions:
10521
+ **UI/visual task detection:** Apply these additions ONLY to tasks whose PRIMARY scope is frontend visual work \u2014 the task's main deliverable must be a UI change, new component, visual design, or page. Do NOT apply to backend tasks, DB migrations, or prompt/config changes that merely mention a dashboard or page in passing. Signal: the task would fail if no .tsx/.css files were changed. If uncertain, skip the UI additions.
10522
+ When a task IS a UI task (primary scope is visual/frontend):
10238
10523
  - Add to SCOPE: "Read \`.impeccable.md\` for brand palette, design principles, and audience context before writing any code. Use the \`frontend-design\` skill for implementation."
10239
10524
  - For M/L UI tasks, add to SCOPE: "Use the full UI toolchain: Playground (design preview) \u2192 Frontend-design (build) \u2192 Playwright (verify). The playground is the quality bar. Expect 2-3 iterations."
10240
10525
  - Add to ACCEPTANCE CRITERIA: "[ ] Visually verify rendered output in browser \u2014 provide localhost URL or screenshot to user for review." and "[ ] No raw IDs, abbreviations, or jargon visible without human-readable labels or tooltips."
@@ -10289,6 +10574,11 @@ var PLAN_FRAGMENT_BUG = `
10289
10574
  var PLAN_FRAGMENT_IDEA = `
10290
10575
  **Idea task detection:** When a task's task type is "idea", add a scope clarification note to the BUILD HANDOFF:
10291
10576
  - Add to SCOPE (DO THIS): "This task originated as an idea. Confirm the exact deliverable before implementing \u2014 check task notes and any referenced docs for intent. If scope is unclear, flag it in the build report surprises."`;
10577
+ var PLAN_FRAGMENT_TASK = `
10578
+ **Task type detection:** When a task's task type is "task" (generic implementation task), add these handoff sections:
10579
+ - SCOPE (DO THIS) must include: a clear deliverable statement and what "done" looks like (e.g. "User can X", "Function returns Y", "Page renders Z").
10580
+ - Add to ACCEPTANCE CRITERIA: "[ ] Scope matches handoff \u2014 no unrelated code changed" and "[ ] Out-of-scope items documented if discovered during implementation."
10581
+ - Add a SCOPE BOUNDARY (DO NOT DO THIS) section with at least one explicit exclusion \u2014 state what this task is NOT responsible for.`;
10292
10582
  var PLAN_FRAGMENT_SPIKE = `
10293
10583
  **Spike task detection:** When a task's task type is "spike" or the title starts with "Spike:", apply these rules:
10294
10584
  - Spikes are time-boxed investigations, not implementation tasks. The deliverable is a FINDING, not code.
@@ -10300,7 +10590,8 @@ var PLAN_FRAGMENT_SPIKE = `
10300
10590
  - Keep SCOPE BOUNDARY, SECURITY CONSIDERATIONS, and PRE-BUILD VERIFICATION as normal.
10301
10591
  - Spikes should be estimated conservatively: XS or S. If a spike needs M+ effort, it's not a spike \u2014 reclassify as a research task.`;
10302
10592
  var PLAN_FRAGMENT_UI = `
10303
- **UI/visual task detection:** When a task's title or notes contain keywords suggesting frontend visual work (e.g. "visual", "design", "UI", "styling", "refresh", "frontend", "landing page", "hero", "carousel", "theme", "layout", "cockpit", "dashboard", "page"), apply these handoff additions:
10593
+ **UI/visual task detection:** Apply these additions ONLY to tasks whose PRIMARY scope is frontend visual work \u2014 the task's main deliverable must be a UI change, new component, visual design, or page. Do NOT apply to backend tasks, DB migrations, or prompt/config changes that merely mention a dashboard or page in passing. Signal: the task would fail if no .tsx/.css files were changed. If uncertain, skip the UI additions.
10594
+ When a task IS a UI task (primary scope is visual/frontend):
10304
10595
  - Add to SCOPE: "Read \`.impeccable.md\` for brand palette, design principles, and audience context before writing any code. Use the \`frontend-design\` skill for implementation."
10305
10596
  - For M/L UI tasks, add to SCOPE: "Use the full UI toolchain: Playground (design preview) \u2192 Frontend-design (build) \u2192 Playwright (verify). The playground is the quality bar. Expect 2-3 iterations."
10306
10597
  - Add to ACCEPTANCE CRITERIA: "[ ] Visually verify rendered output in browser \u2014 provide localhost URL or screenshot to user for review." and "[ ] No raw IDs, abbreviations, or jargon visible without human-readable labels or tooltips."
@@ -10379,8 +10670,11 @@ Standard planning cycle with full board review.
10379
10670
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
10380
10671
  Within the same priority level, prefer tasks with the highest **impact-to-effort ratio**. Impact is measured by: (a) strategic alignment \u2014 does it advance the current horizon/phase? (b) unlocks other work \u2014 are tasks blocked by this? (c) user-facing \u2014 does it change what users see? (d) compounds over time \u2014 does it make future cycles faster? A high-impact Medium task beats a low-impact Small task at the same priority level. Justify in 2-3 sentences.
10381
10672
  **Blocked tasks:** Tasks with status "Blocked" MUST be skipped during task selection \u2014 they are waiting on external dependencies or gates and cannot be built. Do NOT generate BUILD HANDOFFs for blocked tasks. Do NOT recommend blocked tasks. If a blocked task's gate has been resolved (check the notes and recent build reports), emit a \`boardCorrections\` entry to move it back to Backlog. Report blocked task count in the cycle log.
10382
- **Cycle sizing:** Size the cycle based on what the selected tasks actually require \u2014 not a fixed budget. Select the highest-priority unblocked tasks, estimate each one's effort from its scope, and let the total emerge from the tasks themselves. The historical average effort from Methodology Trends is a reference point for calibration, not a target or floor. A healthy cycle has 4-6 tasks. Cycles with fewer than 4 tasks require explicit justification in the cycle log \u2014 explain why more tasks could not be included. When the backlog has 10+ tasks, the cycle SHOULD have 5+ tasks \u2014 undersized cycles waste planning overhead relative to the available work. If fewer than 4 tasks qualify after filtering (blocked, deferred, raw), check Deferred tasks \u2014 some may be ready to un-defer via a \`boardCorrections\` entry. A 1-task cycle is almost never correct.
10673
+ **Cycle sizing:** Size the cycle based on what the selected tasks actually require \u2014 not a fixed budget. Select the highest-priority unblocked tasks, estimate each one's effort from its scope, and let the total emerge. The historical average effort from Methodology Trends is a reference point for calibration, not a target or floor. A healthy cycle has 6-10 tasks. Cycles with fewer than 5 tasks require explicit justification in the cycle log \u2014 explain why more tasks could not be included. When the backlog has 10+ tasks, the cycle SHOULD have 6+ tasks \u2014 undersized cycles waste planning overhead relative to the available work. If fewer than 5 tasks qualify after filtering (blocked, deferred, raw), check Deferred tasks \u2014 some may be ready to un-defer via a \`boardCorrections\` entry. A 1-task cycle is almost never correct. Prefer grouping tasks by module or similarity \u2014 reduces context switching and enables shared branches during the build phase.
10674
+ **Theme-driven sizing:** Single-theme cycles (all tasks in the same module or epic) can absorb 25-30 effort points because builders maintain context across tasks. Mixed-theme cycles should stay at 15-20 effort points to limit context switching. Use the theme to determine the budget, not a fixed number.
10383
10675
  **Theme coherence:** After selecting candidate tasks, check whether they form a coherent theme \u2014 all serving one goal, phase, or module. Single-theme cycles produce better build quality and less context switching. If the top candidates touch 3+ unrelated modules or epics, prefer regrouping around the highest-priority theme and deferring the outliers. Mixed-theme cycles are acceptable when justified (e.g. a P0 fix alongside P1 feature work), but the justification must appear in the cycle log. Name the theme in 3-5 words \u2014 it becomes the \`cycleLogTitle\`.
10676
+ **Epic-aware batching:** Epic is the primary grouping signal for theme coherence. When multiple candidate tasks share the same epic (e.g. "Onboarding Redesign", "Dashboard Polish"), prefer co-scheduling them \u2014 they solve connected problems and benefit from shared context during the build. Steps: (1) After filtering by priority, group eligible tasks by epic. (2) If an epic has 3+ eligible tasks, prefer scheduling 2-4 of them together over cherry-picking across epics. (3) Report the epic distribution in the cycle log (e.g. "4 tasks from Onboarding epic, 1 from Platform"). Priority still overrides: a P0 fix from a different epic always takes precedence.
10677
+ **Opportunity clustering:** If backlog tasks have an \`opportunity\` field populated, group them by opportunity before selecting. Tasks sharing the same opportunity solve the same user problem \u2014 co-scheduling them produces more coherent cycles. Report opportunity clusters in the cycle log when present (e.g. "3 tasks clustered under 'planner accuracy' opportunity").
10384
10678
 
10385
10679
  8. **Cycle Log** \u2014 Write 5-10 line entry: what was triaged, what was recommended and why, observations, AD updates. Include a **Priority Recalibration** paragraph if any unreviewed task priorities were changed during triage (Step 2) \u2014 list each by ID with old \u2192 new priority and rationale. Include a **Priority Drift Suggestions** paragraph if reviewed task drift was detected (Step 3).
10386
10680
  **Cycle Notes** \u2014 Optionally include 1-3 lines of cycle-level observations in \`cycleLogNotes\`: estimation accuracy patterns, recurring blockers, velocity trends, or dependency signals. These notes persist across cycles so future planning runs can learn from them. Use null if there are no noteworthy observations this cycle.
@@ -10403,11 +10697,14 @@ Standard planning cycle with full board review.
10403
10697
  **Estimation calibration:** Estimate **XS** for: copy/text-only changes, single string replacements, config tweaks, and any task where the scope is "change words in an existing file" with no logic changes. Estimate **S** for: wiring existing adapter methods, adding API routes following established patterns, modifying prompts, or documentation-only changes. Default to S for pattern-following work. Only use M when genuine new architecture, new DB tables, or multi-file architectural changes are needed. Historical data shows systematic over-estimation (198 over vs 8 under out of 528 tasks) \u2014 when in doubt, estimate smaller. If an "Estimation Calibration (Historical)" section is provided in the context below, use its data to adjust your estimates \u2014 it shows how often each estimated size matched the actual effort. Pay special attention to systematic over/under-estimation patterns (e.g. if M\u2192S happens frequently, estimate S instead of M for similar work).
10404
10698
  **Reference docs:** If a task's notes include a \`Reference:\` path (e.g. \`Reference: docs/architecture/papi-brain-v1.md\`), include a REFERENCE DOCS section in the BUILD HANDOFF with those paths. This tells the builder to read the referenced doc for background context before implementing. Do NOT omit or summarise the reference \u2014 pass it through so the builder can access the full document. Only tasks with explicit \`Reference:\` paths in their notes should have this section.
10405
10699
  **Pre-build verification:** EVERY handoff MUST include a PRE-BUILD VERIFICATION section listing 2-5 specific file paths the builder should read before implementing. Derive these from FILES LIKELY TOUCHED \u2014 pick the files most likely to already contain the target functionality. This is the #1 prevention mechanism for wasted build slots (C120, C125, C126 all scheduled already-shipped work). If the builder finds >80% of the scope already implemented, they report "already built" instead of re-implementing.
10406
- **Pre-mortem:** For projects with 10+ cycles, include a PRE-MORTEM section in every BUILD HANDOFF with 1-3 bullet points: (a) most likely technical blocker based on module history, (b) integration risk with adjacent systems, (c) scope creep signal \u2014 what the builder might be tempted to expand beyond scope. Draw from \`dead_ends\` and \`surprises\` in recent build reports for the same module. Omit this section entirely for projects with fewer than 10 cycles.`);
10700
+ **Pre-mortem:** For projects with 10+ cycles, include a PRE-MORTEM section in every BUILD HANDOFF with 1-3 bullet points: (a) most likely technical blocker based on module history, (b) integration risk with adjacent systems, (c) scope creep signal \u2014 what the builder might be tempted to expand beyond scope. Draw from \`dead_ends\` and \`surprises\` in recent build reports for the same module. Omit this section entirely for projects with fewer than 10 cycles.
10701
+ **Intra-cycle dependency detection:** After selecting cycle tasks, check every pair for build-order dependencies. Two tasks A and B have an intra-cycle dependency when A must be built before B because B consumes an artifact A creates \u2014 e.g. A adds a new adapter method that B calls, A creates a DB migration B depends on, A introduces a new shared type B imports, A refactors a utility B modifies. Signals: same module + adjacent scope (one is "add X", another is "use X"), or notes explicitly reference the other task. For each dependency detected: (a) populate the DEPENDS ON section in the dependent task's BUILD HANDOFF with the upstream task ID(s); (b) add a \`boardCorrections\` entry for the dependent task with \`updates.dependsOn\` set to the comma-separated upstream IDs \u2014 this persists the dependency so the builder's runtime can reuse the upstream branch; (c) keep SCOPE sections independent but note the ordering in "Why now". Do NOT invent dependencies where tasks merely share a module \u2014 only real build-order coupling counts. Linear chains only \u2014 no multi-level graph resolution. When in doubt, omit.
10702
+ **Build order in cycle log:** If intra-cycle dependencies were detected, include a "Build order:" line in \`cycleLogNotes\` showing the recommended sequence as arrow chains (e.g. "Build order: task-123 \u2192 task-124; task-130 standalone"). Skip when no dependencies exist.`);
10407
10703
  if (flags.hasResearchTasks) parts.push(PLAN_FRAGMENT_RESEARCH);
10408
10704
  if (flags.hasBugTasks) parts.push(PLAN_FRAGMENT_BUG);
10409
10705
  if (flags.hasIdeaTasks) parts.push(PLAN_FRAGMENT_IDEA);
10410
10706
  if (flags.hasSpikeTasks) parts.push(PLAN_FRAGMENT_SPIKE);
10707
+ if (flags.hasTaskTasks) parts.push(PLAN_FRAGMENT_TASK);
10411
10708
  if (flags.hasUITasks) parts.push(PLAN_FRAGMENT_UI);
10412
10709
  parts.push(`
10413
10710
  11. **New Tasks (max 3 per cycle)** \u2014 Actively mine the Recent Build Reports for task candidates. For each report, check:
@@ -10472,6 +10769,9 @@ function buildPlanUserMessage(ctx) {
10472
10769
  );
10473
10770
  }
10474
10771
  parts.push("", "---", "", "## PROJECT CONTEXT", "");
10772
+ if (ctx.contextTier) {
10773
+ parts.push(`**Context tier:** ${ctx.contextTier}`, "");
10774
+ }
10475
10775
  parts.push("### Product Brief", "", ctx.productBrief, "");
10476
10776
  if (ctx.northStar) {
10477
10777
  parts.push("### North Star (current)", "", ctx.northStar, "");
@@ -10489,12 +10789,18 @@ function buildPlanUserMessage(ctx) {
10489
10789
  if (ctx.cycleLog) {
10490
10790
  parts.push("### Cycle Log", "", ctx.cycleLog, "");
10491
10791
  }
10792
+ if (ctx.strategyReviewCadence) {
10793
+ parts.push("### Strategy Review Cadence (computed from DB)", "", ctx.strategyReviewCadence, "");
10794
+ }
10492
10795
  if (ctx.board) {
10493
10796
  parts.push("### Board", "", ctx.board, "");
10494
10797
  }
10495
10798
  if (ctx.preAssignedTasks) {
10496
10799
  parts.push("### Pre-Assigned Tasks", "", ctx.preAssignedTasks, "");
10497
10800
  }
10801
+ if (ctx.codebaseScan) {
10802
+ parts.push("### Codebase Scan (existing implementations)", "", ctx.codebaseScan, "");
10803
+ }
10498
10804
  if (ctx.buildPatterns) {
10499
10805
  parts.push("### Build Patterns", "", ctx.buildPatterns, "");
10500
10806
  }
@@ -10845,7 +11151,8 @@ After your natural language output, include this EXACT format on its own line:
10845
11151
  "category": "friction | methodology | signal | commercial",
10846
11152
  "content": "string \u2014 specific observation from using PAPI on this project (e.g. 'deprioritise clears handoffs unnecessarily, wasting planner tokens')"
10847
11153
  }
10848
- ]
11154
+ ],
11155
+ "northStar": "string or null \u2014 the current North Star statement. Include if you assessed it in section 4 and it is still accurate (copy the current statement verbatim). Include the updated version if you revised it. Use null ONLY if no North Star has ever been set for this project."
10849
11156
  }
10850
11157
  \`\`\`
10851
11158
 
@@ -11032,7 +11339,8 @@ After your natural language output, include this EXACT format on its own line:
11032
11339
  },
11033
11340
  "oldLabel": "string \u2014 only for modify/remove: the previous phase label so tasks can be migrated"
11034
11341
  }
11035
- ]
11342
+ ],
11343
+ "northStar": "string or null \u2014 include the North Star statement if this strategic change defines or revises the project North Star. Use null if the change does not affect the North Star."
11036
11344
  }
11037
11345
  \`\`\`
11038
11346
 
@@ -11088,6 +11396,9 @@ Task: [title]
11088
11396
  Cycle: [N]
11089
11397
  Why now: [justification]
11090
11398
 
11399
+ DEPENDS ON
11400
+ [Optional \u2014 comma-separated task IDs this task depends on (e.g. "task-123, task-124"). Include only when another task in this same cycle must be built first because this task consumes artifacts it creates (e.g. new adapter method, new type, new migration). The builder will reuse the upstream task's branch so dependent commits stack on the same branch for a single PR. Omit this section entirely if there are no intra-cycle dependencies.]
11401
+
11091
11402
  SCOPE (DO THIS)
11092
11403
  [specific deliverables \u2014 write for the simplest viable path first]
11093
11404
 
@@ -11388,6 +11699,32 @@ async function getPrompt(name) {
11388
11699
  }
11389
11700
 
11390
11701
  // src/services/plan.ts
11702
+ function determineContextTier(cycleCount) {
11703
+ if (cycleCount <= 5) return 1;
11704
+ if (cycleCount <= 20) return 2;
11705
+ return 3;
11706
+ }
11707
+ function applyContextTier(ctx, cycleCount) {
11708
+ const tier = determineContextTier(cycleCount);
11709
+ const label = tier === 1 ? "Tier 1 (cycles 1-5)" : tier === 2 ? "Tier 2 (cycles 6-20)" : "Tier 3 (cycles 21+)";
11710
+ if (tier <= 2) {
11711
+ ctx.strategyRecommendations = void 0;
11712
+ ctx.dogfoodEntries = void 0;
11713
+ }
11714
+ if (tier === 1) {
11715
+ ctx.methodologyMetrics = void 0;
11716
+ ctx.carryForwardStaleness = void 0;
11717
+ ctx.discoveryCanvas = void 0;
11718
+ ctx.estimationCalibration = void 0;
11719
+ ctx.buildPatterns = void 0;
11720
+ ctx.reviewPatterns = void 0;
11721
+ ctx.horizonContext = void 0;
11722
+ ctx.registeredDocs = void 0;
11723
+ ctx.recentReviews = void 0;
11724
+ ctx.strategyReviewCadence = void 0;
11725
+ }
11726
+ return { tier, label };
11727
+ }
11391
11728
  function determineMode(totalCycles) {
11392
11729
  if (totalCycles === 0) return "bootstrap";
11393
11730
  return "full";
@@ -11622,6 +11959,7 @@ function detectBoardFlags(tasks) {
11622
11959
  let hasResearchTasks = false;
11623
11960
  let hasIdeaTasks = false;
11624
11961
  let hasSpikeTasks = false;
11962
+ let hasTaskTasks = false;
11625
11963
  let hasUITasks = false;
11626
11964
  const uiKeywords = /\b(visual|design|UI|styling|refresh|frontend|landing page|hero|carousel|theme|layout|cockpit|dashboard|page)\b/i;
11627
11965
  for (const t of tasks) {
@@ -11629,9 +11967,10 @@ function detectBoardFlags(tasks) {
11629
11967
  if (t.taskType === "research" || /^Research:/i.test(t.title)) hasResearchTasks = true;
11630
11968
  if (t.taskType === "idea") hasIdeaTasks = true;
11631
11969
  if (t.taskType === "spike" || /^Spike:/i.test(t.title)) hasSpikeTasks = true;
11970
+ if (t.taskType === "task") hasTaskTasks = true;
11632
11971
  if (uiKeywords.test(t.title) || uiKeywords.test(t.notes ?? "")) hasUITasks = true;
11633
11972
  }
11634
- return { hasBugTasks, hasResearchTasks, hasIdeaTasks, hasSpikeTasks, hasUITasks };
11973
+ return { hasBugTasks, hasResearchTasks, hasIdeaTasks, hasSpikeTasks, hasTaskTasks, hasUITasks };
11635
11974
  }
11636
11975
  function detectBoardFlagsFromText(boardText) {
11637
11976
  return {
@@ -11639,6 +11978,7 @@ function detectBoardFlagsFromText(boardText) {
11639
11978
  hasResearchTasks: /\b(research|Research:)\b/i.test(boardText),
11640
11979
  hasIdeaTasks: /\bidea\b/i.test(boardText),
11641
11980
  hasSpikeTasks: /\b(spike|Spike:)\b/i.test(boardText),
11981
+ hasTaskTasks: /\btask\b/i.test(boardText),
11642
11982
  hasUITasks: /\b(visual|design|UI|styling|refresh|frontend|landing page|hero|carousel|theme|layout|cockpit|dashboard|page)\b/i.test(boardText)
11643
11983
  };
11644
11984
  }
@@ -11749,10 +12089,11 @@ async function assembleContext(adapter2, mode, _config, filters, focus) {
11749
12089
  timings["getPlanContextSummary"] = t();
11750
12090
  if (leanSummary) {
11751
12091
  t = startTimer();
11752
- let [metricsSnapshots2, reviews2] = await Promise.all([
12092
+ const [metricsSnapshotsRaw, reviews2] = await Promise.all([
11753
12093
  adapter2.readCycleMetrics(),
11754
12094
  adapter2.getRecentReviews(5)
11755
12095
  ]);
12096
+ let metricsSnapshots2 = metricsSnapshotsRaw;
11756
12097
  let leanBuildReports = [];
11757
12098
  try {
11758
12099
  leanBuildReports = await adapter2.getRecentBuildReports(50);
@@ -11849,6 +12190,9 @@ ${lines.join("\n")}`;
11849
12190
  ]);
11850
12191
  timings["total"] = totalTimer();
11851
12192
  console.error(`[plan-perf] assembleContext (lean): ${JSON.stringify(timings)}ms`);
12193
+ const gap = health.cyclesSinceLastStrategyReview;
12194
+ const lastReviewCycle = health.totalCycles - gap;
12195
+ const strategyReviewCadence = gap <= 0 ? `\u2713 Strategy review completed this cycle (C${health.totalCycles}). No carry-forward needed.` : gap < 5 ? `\u2713 Strategy review on track \u2014 last review was C${lastReviewCycle} (${gap} cycle(s) ago). Next due: C${lastReviewCycle + 5}.` : `\u26A0\uFE0F Strategy review overdue \u2014 last review was C${lastReviewCycle} (${gap} cycles ago). Due now.`;
11852
12196
  let ctx2 = {
11853
12197
  mode,
11854
12198
  cycleNumber: health.totalCycles,
@@ -11870,8 +12214,12 @@ ${lines.join("\n")}`;
11870
12214
  boardFlags,
11871
12215
  carryForwardStaleness: carryForwardStalenessLean,
11872
12216
  preAssignedTasks: preAssignedTextLean,
11873
- recentlyShippedCapabilities: recentlyShippedLean
12217
+ recentlyShippedCapabilities: recentlyShippedLean,
12218
+ strategyReviewCadence
11874
12219
  };
12220
+ const { label: leanTierLabel } = applyContextTier(ctx2, health.totalCycles);
12221
+ ctx2.contextTier = leanTierLabel;
12222
+ console.error(`[plan-perf] context tier: ${leanTierLabel} (cycle ${health.totalCycles})`);
11875
12223
  t = startTimer();
11876
12224
  const prevHashes2 = contextHashesResult.status === "fulfilled" ? contextHashesResult.value : null;
11877
12225
  const { ctx: diffedCtx2, newHashes: newHashes2, savedBytes: savedBytes2 } = applyContextDiff(ctx2, prevHashes2);
@@ -11933,7 +12281,8 @@ ${lines.join("\n")}`;
11933
12281
  if (pendingRecsResultFull.status === "fulfilled" && pendingRecsResultFull.value.length > 0) {
11934
12282
  strategyRecommendationsText = formatStrategyRecommendations(pendingRecsResultFull.value);
11935
12283
  }
11936
- const metricsSnapshots = allReportsForPatterns.length > 0 ? computeSnapshotsFromBuildReports(allReportsForPatterns) : rawMetricsSnapshots.filter((s) => s.accuracy.length > 0 || s.velocity.length > 0);
12284
+ const filteredRaw = rawMetricsSnapshots.filter((s) => s.accuracy.length > 0 || s.velocity.length > 0);
12285
+ const metricsSnapshots = filteredRaw.length > 0 ? filteredRaw : computeSnapshotsFromBuildReports(allReportsForPatterns);
11937
12286
  const discoveryCanvasTextFull = discoveryCanvasResultFull.status === "fulfilled" ? discoveryCanvasResultFull.value : void 0;
11938
12287
  const taskCommentsTextFull = taskCommentsResultFull.status === "fulfilled" ? taskCommentsResultFull.value : void 0;
11939
12288
  let registeredDocsTextFull;
@@ -11969,6 +12318,9 @@ ${lines.join("\n")}`;
11969
12318
  const targetCycle = health.totalCycles + 1;
11970
12319
  const preAssigned = strippedTasks.filter((t2) => t2.cycle === targetCycle);
11971
12320
  const preAssignedText = formatPreAssignedTasks(preAssigned, targetCycle);
12321
+ const gapFull = health.cyclesSinceLastStrategyReview;
12322
+ const lastReviewCycleFull = health.totalCycles - gapFull;
12323
+ const strategyReviewCadenceFull = gapFull <= 0 ? `\u2713 Strategy review completed this cycle (C${health.totalCycles}). No carry-forward needed.` : gapFull < 5 ? `\u2713 Strategy review on track \u2014 last review was C${lastReviewCycleFull} (${gapFull} cycle(s) ago). Next due: C${lastReviewCycleFull + 5}.` : `\u26A0\uFE0F Strategy review overdue \u2014 last review was C${lastReviewCycleFull} (${gapFull} cycles ago). Due now.`;
11972
12324
  let ctx = {
11973
12325
  mode,
11974
12326
  cycleNumber: health.totalCycles,
@@ -11992,8 +12344,12 @@ ${lines.join("\n")}`;
11992
12344
  boardFlags: boardFlagsFull,
11993
12345
  carryForwardStaleness: computeCarryForwardStaleness(log),
11994
12346
  preAssignedTasks: preAssignedText,
11995
- recentlyShippedCapabilities: formatRecentlyShippedCapabilities(allReportsForPatterns)
12347
+ recentlyShippedCapabilities: formatRecentlyShippedCapabilities(reports),
12348
+ strategyReviewCadence: strategyReviewCadenceFull
11996
12349
  };
12350
+ const { label: fullTierLabel } = applyContextTier(ctx, health.totalCycles);
12351
+ ctx.contextTier = fullTierLabel;
12352
+ console.error(`[plan-perf] context tier: ${fullTierLabel} (cycle ${health.totalCycles})`);
11997
12353
  const prevHashes = contextHashesResultFull.status === "fulfilled" ? contextHashesResultFull.value : null;
11998
12354
  const { ctx: diffedCtx, newHashes, savedBytes } = applyContextDiff(ctx, prevHashes);
11999
12355
  ctx = diffedCtx;
@@ -12183,7 +12539,15 @@ ${cleanContent}`;
12183
12539
  taskCount: cycleTaskCount > 0 ? cycleTaskCount : void 0,
12184
12540
  effortPoints: cycleEffortPoints > 0 ? cycleEffortPoints : void 0
12185
12541
  });
12186
- const healthPromise = Promise.resolve();
12542
+ const healthUpdates = {
12543
+ totalCycles: newCycleNumber,
12544
+ boardHealth: data.boardHealth,
12545
+ strategicDirection: data.strategicDirection
12546
+ };
12547
+ if (data.nextMode === "Full") {
12548
+ healthUpdates.lastFullMode = newCycleNumber;
12549
+ }
12550
+ const healthPromise = adapter2.setCycleHealth(healthUpdates);
12187
12551
  const newTaskIdMap = /* @__PURE__ */ new Map();
12188
12552
  const createTasksPromise = (async () => {
12189
12553
  if (!data.newTasks || data.newTasks.length === 0) return;
@@ -12603,6 +12967,17 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
12603
12967
  }
12604
12968
  if (skipHandoffs) context.skipHandoffs = true;
12605
12969
  t = startTimer();
12970
+ try {
12971
+ const scanTasks = await adapter2.queryBoard({ status: ["Backlog", "In Cycle", "Ready"] });
12972
+ const candidates = scanTasks.filter((task) => task.priority !== "P3 Low").slice(0, 15).map((task) => ({ id: task.id, title: task.title, notes: task.notes }));
12973
+ const scanResult = scanCodebaseForTasks(config2.projectRoot, candidates);
12974
+ if (scanResult) context.codebaseScan = scanResult;
12975
+ } catch (err) {
12976
+ console.error(`[plan] codebase scan failed (non-critical): ${err instanceof Error ? err.message : err}`);
12977
+ }
12978
+ const scanMs = t();
12979
+ console.error(`[plan-perf] codebaseScan: ${scanMs}ms`);
12980
+ t = startTimer();
12606
12981
  const userMessage = buildPlanUserMessage(context);
12607
12982
  const buildMessageMs = t();
12608
12983
  const totalMs = prepareTimer();
@@ -12779,6 +13154,7 @@ var lastPrepareSkipHandoffs;
12779
13154
  var planTool = {
12780
13155
  name: "plan",
12781
13156
  description: 'Run once per cycle to select tasks and generate BUILD HANDOFFs. Call after setup (first time) or after completing all builds AND running release for the previous cycle. Returns prioritised task recommendations with detailed implementation specs. NEVER call when unbuilt cycle tasks exist \u2014 build and release first. First call returns a planning prompt for you to execute (prepare phase). Then call again with mode "apply" and your output to write results. Use skip_handoffs=true for large backlogs \u2014 handoffs are then generated separately via `handoff_generate`.',
13157
+ annotations: { readOnlyHint: false, destructiveHint: false },
12782
13158
  inputSchema: {
12783
13159
  type: "object",
12784
13160
  properties: {
@@ -12906,24 +13282,30 @@ async function handlePlan(adapter2, config2, args) {
12906
13282
  return errorResponse('llm_response is required for mode "apply". Pass your complete plan output including the <!-- PAPI_STRUCTURED_OUTPUT --> block.');
12907
13283
  }
12908
13284
  const planMode = args.plan_mode || "full";
12909
- const cycleNumber = typeof args.cycle_number === "number" ? args.cycle_number : 0;
13285
+ const rawCycleNumber = args.cycle_number != null ? Number(args.cycle_number) : NaN;
12910
13286
  const strategyReviewWarning = args.strategy_review_warning || "";
12911
13287
  const contextHashes = lastPrepareContextHashes;
12912
13288
  const inputContext = lastPrepareUserMessage;
12913
13289
  const contextBytes = lastPrepareContextBytes;
12914
13290
  const expectedCycleNumber = lastPrepareCycleNumber;
12915
13291
  const skipHandoffsCached = lastPrepareSkipHandoffs;
12916
- lastPrepareContextHashes = void 0;
12917
- lastPrepareUserMessage = void 0;
12918
- lastPrepareContextBytes = void 0;
12919
- lastPrepareCycleNumber = void 0;
12920
- lastPrepareSkipHandoffs = void 0;
12921
13292
  const skipHandoffs = args.skip_handoffs === true || skipHandoffsCached === true;
13293
+ const cycleNumber = !isNaN(rawCycleNumber) ? rawCycleNumber : expectedCycleNumber !== void 0 ? expectedCycleNumber : NaN;
13294
+ if (isNaN(cycleNumber)) {
13295
+ return errorResponse(
13296
+ "cycle_number is required for apply mode. Pass the cycle_number from the prepare phase output."
13297
+ );
13298
+ }
12922
13299
  if (expectedCycleNumber !== void 0 && cycleNumber !== expectedCycleNumber) {
12923
13300
  return errorResponse(
12924
13301
  `cycle_number mismatch: prepare phase returned cycle ${expectedCycleNumber} but apply received ${cycleNumber}. Pass cycle_number: ${expectedCycleNumber} to match the prepare output.`
12925
13302
  );
12926
13303
  }
13304
+ lastPrepareContextHashes = void 0;
13305
+ lastPrepareUserMessage = void 0;
13306
+ lastPrepareContextBytes = void 0;
13307
+ lastPrepareCycleNumber = void 0;
13308
+ lastPrepareSkipHandoffs = void 0;
12927
13309
  const result = await applyPlan(adapter2, config2, llmResponse, planMode, cycleNumber, strategyReviewWarning, contextHashes, { contextBytes: contextBytes ?? void 0, skipHandoffs: skipHandoffs || void 0 });
12928
13310
  let utilisation;
12929
13311
  if (inputContext) {
@@ -13122,7 +13504,7 @@ function classifyRecommendation(text) {
13122
13504
  if (lower.includes("new task") || lower.includes("create task") || lower.includes("add task") || lower.includes("spike")) {
13123
13505
  return "new_task";
13124
13506
  }
13125
- if (lower.includes("process") || lower.includes("workflow") || lower.includes("methodology") || lower.includes("retrospective") || lower.includes("dogfood")) {
13507
+ if (lower.includes("process") || lower.includes("workflow") || lower.includes("methodology") || lower.includes("retrospective") || lower.includes("dogfood") || lower.includes("refine")) {
13126
13508
  return "process_improvement";
13127
13509
  }
13128
13510
  if (lower.includes("infrastructure") || lower.includes("deploy") || lower.includes("ci/cd") || lower.includes("pipeline") || lower.includes("hosting") || lower.includes("database") || lower.includes("migration")) {
@@ -13782,6 +14164,12 @@ ${cleanContent}`;
13782
14164
  } catch {
13783
14165
  }
13784
14166
  }
14167
+ if (data.northStar && adapter2.upsertNorthStar) {
14168
+ try {
14169
+ await adapter2.upsertNorthStar(data.northStar, cycleNumber);
14170
+ } catch {
14171
+ }
14172
+ }
13785
14173
  const compressionThreshold = cycleNumber - 5;
13786
14174
  if (compressionThreshold > 0 && data.sessionLogCompressionSummary) {
13787
14175
  await adapter2.compressCycleLog(compressionThreshold, data.sessionLogCompressionSummary);
@@ -13841,7 +14229,7 @@ ${cleanContent}`;
13841
14229
  try {
13842
14230
  const canvas = await adapter2.readDiscoveryCanvas();
13843
14231
  const updates = {};
13844
- let populatedSections = [];
14232
+ const populatedSections = [];
13845
14233
  if (!canvas.landscapeReferences || canvas.landscapeReferences.length === 0) {
13846
14234
  if (data.activeDecisionUpdates?.length) {
13847
14235
  const entries = data.activeDecisionUpdates.filter((ad) => ad.body && ad.action !== "delete").slice(0, 3).map((ad) => ({ name: ad.id, notes: ad.body.slice(0, 200) }));
@@ -14104,10 +14492,17 @@ function formatVelocitySummary(reports, cycleCount) {
14104
14492
  function formatRecentReportsSummary(reports, count) {
14105
14493
  const recent = reports.sort((a, b2) => b2.cycle - a.cycle || b2.date.localeCompare(a.date)).slice(0, count);
14106
14494
  if (recent.length === 0) return "No recent build reports.";
14495
+ const trunc = (s, max) => s && s !== "None" ? s.length > max ? s.slice(0, max) + "..." : s : null;
14107
14496
  return recent.map((r) => {
14108
14497
  const effort = `${r.actualEffort} vs ${r.estimatedEffort}`;
14109
- const surprises = r.surprises && r.surprises !== "None" ? ` \u2014 ${r.surprises.slice(0, 80)}${r.surprises.length > 80 ? "..." : ""}` : "";
14110
- return `- S${r.cycle} ${r.taskName}: ${effort}${surprises}`;
14498
+ const lines = [`- C${r.cycle} ${r.taskName}: ${effort}`];
14499
+ const surprises = trunc(r.surprises, 200);
14500
+ if (surprises) lines.push(` _Surprises:_ ${surprises}`);
14501
+ const issues = trunc(r.discoveredIssues, 200);
14502
+ if (issues) lines.push(` _Issues:_ ${issues}`);
14503
+ const arch = trunc(r.architectureNotes, 200);
14504
+ if (arch) lines.push(` _Architecture:_ ${arch}`);
14505
+ return lines.join("\n");
14111
14506
  }).join("\n");
14112
14507
  }
14113
14508
  function formatPhasesForReview(phases, currentCycle) {
@@ -14133,7 +14528,7 @@ async function formatHierarchyForReview(adapter2, currentCycle, prefetchedTasks)
14133
14528
  } catch {
14134
14529
  }
14135
14530
  if (horizons.length === 0 && phases.length === 0) return void 0;
14136
- let tasksByPhase = /* @__PURE__ */ new Map();
14531
+ const tasksByPhase = /* @__PURE__ */ new Map();
14137
14532
  try {
14138
14533
  const allTasks = prefetchedTasks ?? await adapter2.queryBoard();
14139
14534
  for (const t of allTasks) {
@@ -14278,6 +14673,12 @@ ${cleanContent}`;
14278
14673
  const currentPhases = await adapter2.readPhases();
14279
14674
  await applyPhaseUpdates(adapter2, currentPhases, data.phaseUpdates);
14280
14675
  }
14676
+ if (data.northStar && adapter2.upsertNorthStar) {
14677
+ try {
14678
+ await adapter2.upsertNorthStar(data.northStar, cycleNumber);
14679
+ } catch {
14680
+ }
14681
+ }
14281
14682
  } catch (err) {
14282
14683
  writeBackFailed = err instanceof Error ? err.message : String(err);
14283
14684
  }
@@ -14400,6 +14801,7 @@ var lastReviewContextBytes;
14400
14801
  var strategyReviewTool = {
14401
14802
  name: "strategy_review",
14402
14803
  description: 'Run a Strategy Review \u2014 assesses project direction, velocity, and Active Decisions. Produces recommendations and potential AD updates that feed into the next plan. Offered every 5 cycles; hard-blocked at 7+ overdue cycles. Run in its own dedicated session \u2014 do not mix with building. First call returns a review prompt for you to execute (prepare phase). Then call again with mode "apply" and your output. Pass `force: true` to run before the cadence gate.',
14804
+ annotations: { readOnlyHint: false, destructiveHint: false },
14403
14805
  inputSchema: {
14404
14806
  type: "object",
14405
14807
  properties: {
@@ -14427,6 +14829,7 @@ var strategyReviewTool = {
14427
14829
  var strategyChangeTool = {
14428
14830
  name: "strategy_change",
14429
14831
  description: 'Apply a strategic shift to the project. Three modes: "capture" for lightweight mid-conversation decision capture (no LLM round-trip), "prepare" to get a change prompt for full analysis, "apply" to persist analysis output. Use "capture" when you detect a strategic decision in conversation and want to persist it quickly without disrupting the build flow.',
14832
+ annotations: { readOnlyHint: false, destructiveHint: false },
14430
14833
  inputSchema: {
14431
14834
  type: "object",
14432
14835
  properties: {
@@ -14764,6 +15167,7 @@ async function archiveTasks(adapter2, phases, statuses) {
14764
15167
  var boardViewTool = {
14765
15168
  name: "board_view",
14766
15169
  description: 'View the Board. By default shows active tasks only (excludes Done/Cancelled), sorted by priority, limited to 50. Use status="all" to see everything. Use mode="summary" for counts only (no task details). Does not call the Anthropic API.',
15170
+ annotations: { readOnlyHint: true, destructiveHint: false },
14767
15171
  inputSchema: {
14768
15172
  type: "object",
14769
15173
  properties: {
@@ -14795,6 +15199,7 @@ var boardViewTool = {
14795
15199
  var boardDeprioritiseTool = {
14796
15200
  name: "board_deprioritise",
14797
15201
  description: `Remove a task from the current cycle. Four actions: "backlog" (not now, maybe later \u2014 preserves handoff), "defer" (valid but premature \u2014 hidden from planner), "block" (waiting on external dependency \u2014 visible on board but skipped by planner), "cancel" (don't want this \u2014 permanently closed with reason). When a user rejects a task, ALWAYS ask which action they want. Does not call the Anthropic API.`,
15202
+ annotations: { readOnlyHint: false, destructiveHint: true },
14798
15203
  inputSchema: {
14799
15204
  type: "object",
14800
15205
  properties: {
@@ -14830,6 +15235,7 @@ var boardDeprioritiseTool = {
14830
15235
  var boardArchiveTool = {
14831
15236
  name: "board_archive",
14832
15237
  description: "Archive tasks from the Board to the archive file. When both phase and status are provided, only tasks matching BOTH are archived (AND logic). When only one is provided, all matching tasks are archived. Does not call the Anthropic API.",
15238
+ annotations: { readOnlyHint: false, destructiveHint: true },
14833
15239
  inputSchema: {
14834
15240
  type: "object",
14835
15241
  properties: {
@@ -14848,6 +15254,7 @@ var boardArchiveTool = {
14848
15254
  var boardEditTool = {
14849
15255
  name: "board_edit",
14850
15256
  description: "Edit fields on an existing task. Supports title, priority, complexity, module, epic, phase, notes, status, and maturity. Pass task_id plus any fields to update. Does not call the Anthropic API.",
15257
+ annotations: { readOnlyHint: false, destructiveHint: false },
14851
15258
  inputSchema: {
14852
15259
  type: "object",
14853
15260
  properties: {
@@ -15114,7 +15521,7 @@ async function handleBoardEdit(adapter2, args) {
15114
15521
  try {
15115
15522
  const dogfoodLog = await adapter2.getDogfoodLog?.(50) ?? [];
15116
15523
  const linked = dogfoodLog.filter((e) => e.linkedTaskId === taskId || e.linkedTaskId === task.id);
15117
- const newStatus = updates.status === "Done" ? "actioned" : "dismissed";
15524
+ const newStatus = "resolved";
15118
15525
  await Promise.all(linked.map((e) => adapter2.updateDogfoodEntryStatus(e.id, newStatus)));
15119
15526
  } catch {
15120
15527
  }
@@ -15486,6 +15893,7 @@ When the system compresses prior messages, immediately:
15486
15893
 
15487
15894
  - **XS/S tasks in the same cycle and module:** Group on shared branch. One PR, one merge.
15488
15895
  - **M/L tasks or different modules:** Own branch per task. Isolated PRs.
15896
+ - **Dependent tasks (any size):** When a task's BUILD HANDOFF lists a \`DEPENDS ON\` task from the same cycle, \`build_execute\` automatically reuses the upstream task's branch so commits stack for a single PR. Do not create a separate branch manually.
15489
15897
  - **Commit per task within grouped branches** \u2014 traceable git history.
15490
15898
  - **Never use \`build_execute\` with \`light=true\` on shared branches.** Light mode commits directly to the current branch without creating a PR. When a shared branch is squash-merged, those commits are collapsed \u2014 any CLAUDE.md or documentation changes are stripped. Use light mode only on isolated single-task branches where no squash-merge will occur.
15491
15899
 
@@ -16285,6 +16693,7 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
16285
16693
  var setupTool = {
16286
16694
  name: "setup",
16287
16695
  description: `Initialise a new PAPI project or adopt an existing codebase. Only 3 inputs needed: project name, what it does, and who it's for. Set existing_project: true to adopt an existing codebase \u2014 PAPI scans the project structure and generates a context-aware brief, hierarchy, and initial backlog tasks. First call returns prompts (prepare phase), then call again with mode "apply" and your outputs. After setup, run plan to start your first cycle.`,
16696
+ annotations: { readOnlyHint: false, destructiveHint: false },
16288
16697
  inputSchema: {
16289
16698
  type: "object",
16290
16699
  properties: {
@@ -16554,6 +16963,8 @@ import { randomUUID as randomUUID9 } from "crypto";
16554
16963
  import { readdirSync as readdirSync3, existsSync as existsSync3, readFileSync } from "fs";
16555
16964
  import { join as join5 } from "path";
16556
16965
  var buildStartTimes = /* @__PURE__ */ new Map();
16966
+ var taskBranchMap = /* @__PURE__ */ new Map();
16967
+ var SHARED_BRANCH_COMPLEXITIES = /* @__PURE__ */ new Set(["XS", "Small"]);
16557
16968
  function capitalizeCompleted(value) {
16558
16969
  const map = {
16559
16970
  yes: "Yes",
@@ -16573,7 +16984,7 @@ function pushAndCreatePR(config2, taskId, taskTitle) {
16573
16984
  if (!isGitAvailable() || !isGitRepo(config2.projectRoot)) {
16574
16985
  return lines;
16575
16986
  }
16576
- const featureBranch = taskBranchName(taskId);
16987
+ const featureBranch = taskBranchMap.get(taskId) ?? taskBranchName(taskId);
16577
16988
  const currentBranch = getCurrentBranch(config2.projectRoot);
16578
16989
  if (currentBranch !== featureBranch) {
16579
16990
  return lines;
@@ -16705,10 +17116,38 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
16705
17116
  if (options.light) {
16706
17117
  branchLines.push("Light mode: skipping branch creation \u2014 working on current branch.");
16707
17118
  } else if (config2.autoCommit && isGitAvailable() && isGitRepo(config2.projectRoot)) {
16708
- const featureBranch = taskBranchName(taskId);
17119
+ const cycleHealth = await adapter2.getCycleHealth().catch(() => null);
17120
+ const cycleNumber = cycleHealth?.totalCycles ?? 0;
17121
+ let depBranchReuse = null;
17122
+ if (task.dependsOn) {
17123
+ const depIds = task.dependsOn.split(",").map((d) => d.trim()).filter(Boolean);
17124
+ for (const depId of depIds) {
17125
+ const mappedBranch = taskBranchMap.get(depId);
17126
+ if (mappedBranch && branchExists(config2.projectRoot, mappedBranch)) {
17127
+ depBranchReuse = { branch: mappedBranch, upstreamId: depId };
17128
+ break;
17129
+ }
17130
+ const fallbackBranch = taskBranchName(depId);
17131
+ if (branchExists(config2.projectRoot, fallbackBranch)) {
17132
+ depBranchReuse = { branch: fallbackBranch, upstreamId: depId };
17133
+ break;
17134
+ }
17135
+ }
17136
+ }
17137
+ const useSharedBranch = !depBranchReuse && SHARED_BRANCH_COMPLEXITIES.has(task.complexity) && !!task.module && cycleNumber > 0;
17138
+ const featureBranch = depBranchReuse ? depBranchReuse.branch : useSharedBranch ? cycleBranchName(cycleNumber, task.module) : taskBranchName(taskId);
17139
+ if (depBranchReuse) {
17140
+ branchLines.push(
17141
+ `Reusing branch '${depBranchReuse.branch}' from dependency ${depBranchReuse.upstreamId} \u2014 commits will stack for a single PR.`
17142
+ );
17143
+ }
17144
+ taskBranchMap.set(taskId, featureBranch);
16709
17145
  const currentBranch = getCurrentBranch(config2.projectRoot);
16710
17146
  if (currentBranch === featureBranch) {
16711
17147
  branchLines.push(`Already on branch '${featureBranch}'.`);
17148
+ if (useSharedBranch) {
17149
+ branchLines.push(`Reusing shared cycle branch for ${task.complexity} ${task.module} task.`);
17150
+ }
16712
17151
  } else {
16713
17152
  if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
16714
17153
  throw new Error("Working directory has uncommitted changes. Please commit or stash them before running `build_execute`.");
@@ -16717,13 +17156,14 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
16717
17156
  if (baseBranch !== config2.baseBranch) {
16718
17157
  branchLines.push(`Base branch '${config2.baseBranch}' not found \u2014 using '${baseBranch}'.`);
16719
17158
  }
16720
- if (currentBranch !== baseBranch) {
17159
+ const featureBranchExists = branchExists(config2.projectRoot, featureBranch);
17160
+ if (currentBranch !== baseBranch && !featureBranchExists) {
16721
17161
  const checkout = checkoutBranch(config2.projectRoot, baseBranch);
16722
17162
  if (!checkout.success) {
16723
17163
  branchLines.push(`Warning: ${checkout.message} Proceeding on current branch '${currentBranch}'.`);
16724
17164
  }
16725
17165
  }
16726
- if (hasRemote(config2.projectRoot)) {
17166
+ if (hasRemote(config2.projectRoot) && !featureBranchExists) {
16727
17167
  const pull = gitPull(config2.projectRoot);
16728
17168
  branchLines.push(pull.success ? pull.message : `Warning: ${pull.message}`);
16729
17169
  }
@@ -16733,10 +17173,10 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
16733
17173
  `Warning: ${unmerged.length} unmerged feature branch${unmerged.length === 1 ? "" : "es"}: ${unmerged.join(", ")}. New branch may diverge if these contain changes needed here.`
16734
17174
  );
16735
17175
  }
16736
- if (branchExists(config2.projectRoot, featureBranch)) {
17176
+ if (featureBranchExists) {
16737
17177
  const checkout = checkoutBranch(config2.projectRoot, featureBranch);
16738
17178
  branchLines.push(
16739
- checkout.success ? `Checked out existing branch '${featureBranch}'.` : `Warning: ${checkout.message}`
17179
+ checkout.success ? useSharedBranch ? `Checked out shared cycle branch '${featureBranch}' \u2014 reusing for ${task.complexity} ${task.module} task.` : `Checked out existing branch '${featureBranch}'.` : `Warning: ${checkout.message}`
16740
17180
  );
16741
17181
  if (checkout.success) {
16742
17182
  branchLines.push(
@@ -16746,7 +17186,7 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
16746
17186
  } else {
16747
17187
  const create = createAndCheckoutBranch(config2.projectRoot, featureBranch);
16748
17188
  branchLines.push(
16749
- create.success ? `Created branch '${featureBranch}'.` : `Warning: ${create.message}`
17189
+ create.success ? useSharedBranch ? `Created shared cycle branch '${featureBranch}' for ${task.module} XS/S tasks.` : `Created branch '${featureBranch}'.` : `Warning: ${create.message}`
16750
17190
  );
16751
17191
  if (create.success) {
16752
17192
  branchLines.push(
@@ -16772,7 +17212,7 @@ function extractDocMeta(absolutePath, relativePath, cycleNumber) {
16772
17212
  let title = relativePath.split("/").pop()?.replace(".md", "") ?? relativePath;
16773
17213
  let type = "reference";
16774
17214
  let cycle = cycleNumber;
16775
- let summary = "Auto-registered \u2014 no summary available. Update via doc_register.";
17215
+ const summary = "Auto-registered \u2014 no summary available. Update via doc_register.";
16776
17216
  if (relativePath.startsWith("docs/research/")) type = "research";
16777
17217
  else if (relativePath.startsWith("docs/architecture/")) type = "architecture";
16778
17218
  else if (relativePath.startsWith("docs/audits/")) type = "audit";
@@ -16830,11 +17270,27 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
16830
17270
  completedAt: now.toISOString()
16831
17271
  };
16832
17272
  buildStartTimes.delete(taskId);
17273
+ taskBranchMap.delete(taskId);
16833
17274
  if (input.relatedDecisions) {
16834
17275
  const adIds = input.relatedDecisions.split(",").map((s) => s.trim()).filter(Boolean);
16835
17276
  if (adIds.length > 0) report.relatedDecisions = adIds;
16836
17277
  }
17278
+ if (report.startedAt && report.completedAt && typeof adapter2.getToolCallCount === "function") {
17279
+ try {
17280
+ const count = await adapter2.getToolCallCount(report.startedAt, report.completedAt);
17281
+ if (count > 0) report.toolCallCount = count;
17282
+ } catch {
17283
+ }
17284
+ }
16837
17285
  await adapter2.appendBuildReport(report);
17286
+ let reportWriteVerified;
17287
+ if (typeof adapter2.getBuildReportCountForTask === "function") {
17288
+ try {
17289
+ const postWriteCount = await adapter2.getBuildReportCountForTask(taskId);
17290
+ reportWriteVerified = postWriteCount >= iterationCount;
17291
+ } catch {
17292
+ }
17293
+ }
16838
17294
  if (adapter2.appendCycleLearnings) {
16839
17295
  const learnings = [];
16840
17296
  const taskModule = task.module ?? "";
@@ -16881,6 +17337,39 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
16881
17337
  }
16882
17338
  }
16883
17339
  }
17340
+ let autoTriagedCount = 0;
17341
+ if (input.discoveredIssues && input.discoveredIssues !== "None" && typeof adapter2.createTask === "function") {
17342
+ const issueLines = input.discoveredIssues.split(/\n|;/).map((s) => s.trim()).filter((s) => s.length > 0);
17343
+ for (const line of issueLines) {
17344
+ const sevMatch = line.match(/^(P[0-3])[\s:]+/i);
17345
+ if (!sevMatch) continue;
17346
+ const severityLabel = sevMatch[1].toUpperCase();
17347
+ const priority = severityLabel === "P0" || severityLabel === "P1" ? "P1 High" : severityLabel === "P2" ? "P2 Medium" : "P3 Low";
17348
+ const titleRaw = line.replace(/^P[0-3][\s:]+/i, "").trim();
17349
+ const title = titleRaw.length > 120 ? titleRaw.slice(0, 120) : titleRaw;
17350
+ if (!title) continue;
17351
+ try {
17352
+ await adapter2.createTask({
17353
+ uuid: "",
17354
+ displayId: "",
17355
+ title: `[Auto-triaged] ${title}`,
17356
+ status: "Backlog",
17357
+ priority,
17358
+ complexity: "Small",
17359
+ module: task.module ?? "",
17360
+ phase: task.phase ?? "",
17361
+ owner: "papi",
17362
+ reviewed: false,
17363
+ taskType: "discovery",
17364
+ source: "build_complete",
17365
+ notes: `Origin: ${task.displayId} (${task.title}), cycle ${cycleNumber}. Original issue: ${line}`,
17366
+ createdCycle: cycleNumber
17367
+ });
17368
+ autoTriagedCount++;
17369
+ } catch {
17370
+ }
17371
+ }
17372
+ }
16884
17373
  if (adapter2.updateCycleLearningActionRef && task.notes) {
16885
17374
  const learningRefs = task.notes.match(/learning:([a-f0-9-]+)/gi);
16886
17375
  if (learningRefs) {
@@ -16923,6 +17412,52 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
16923
17412
  await adapter2.updateTaskStatus(taskId, "In Review");
16924
17413
  }
16925
17414
  }
17415
+ let dogfoodResolvedCount = 0;
17416
+ if (input.completed === "yes" && adapter2.getDogfoodLog && adapter2.updateDogfoodEntryStatus) {
17417
+ try {
17418
+ const dogfoodLog = await adapter2.getDogfoodLog(50);
17419
+ const linked = dogfoodLog.filter(
17420
+ (e) => (e.linkedTaskId === taskId || e.linkedTaskId === task.id) && e.status !== "resolved"
17421
+ );
17422
+ if (linked.length > 0) {
17423
+ await Promise.all(linked.map((e) => adapter2.updateDogfoodEntryStatus(e.id, "resolved")));
17424
+ dogfoodResolvedCount = linked.length;
17425
+ }
17426
+ } catch {
17427
+ }
17428
+ }
17429
+ let learningsLinkedCount = 0;
17430
+ if (input.completed === "yes" && adapter2.getCycleLearnings && adapter2.updateCycleLearningActionRef) {
17431
+ try {
17432
+ const recentLearnings = await adapter2.getCycleLearnings({ limit: 30 });
17433
+ const unactioned = recentLearnings.filter(
17434
+ (l) => !l.actionRef && l.cycleNumber >= cycleNumber - 5
17435
+ );
17436
+ if (unactioned.length > 0) {
17437
+ const taskText = `${task.title} ${task.notes ?? ""}`.toLowerCase();
17438
+ const taskWords = new Set(
17439
+ taskText.match(/\b[a-z]{4,}\b/g) ?? []
17440
+ );
17441
+ const taskModule = (task.module ?? "").toLowerCase();
17442
+ for (const learning of unactioned) {
17443
+ const learningModule = (learning.tags[0] ?? "").toLowerCase();
17444
+ if (!taskModule || !learningModule || taskModule !== learningModule) continue;
17445
+ const learningText = `${learning.summary} ${learning.detail ?? ""}`.toLowerCase();
17446
+ const learningWords = learningText.match(/\b[a-z]{4,}\b/g) ?? [];
17447
+ const hasKeywordOverlap = learningWords.some((w) => taskWords.has(w));
17448
+ if (!hasKeywordOverlap) continue;
17449
+ if (learning.id) {
17450
+ try {
17451
+ await adapter2.updateCycleLearningActionRef(learning.id, task.id);
17452
+ learningsLinkedCount++;
17453
+ } catch {
17454
+ }
17455
+ }
17456
+ }
17457
+ }
17458
+ } catch {
17459
+ }
17460
+ }
16926
17461
  const statusNote = input.completed === "yes" ? options.light ? `Task "${task.title}" (${taskId}) marked Done (light mode \u2014 no review needed).` : `Task "${task.title}" (${taskId}) marked In Review \u2014 ready for your sign-off via \`review_submit\`.` : `Task "${task.title}" (${taskId}) status unchanged (completed: ${input.completed}).`;
16927
17462
  let commitLine;
16928
17463
  if (config2.autoCommit) {
@@ -17023,7 +17558,11 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
17023
17558
  completed: input.completed,
17024
17559
  scopeAccuracy: input.scopeAccuracy,
17025
17560
  phaseChanges,
17026
- docWarning
17561
+ docWarning,
17562
+ dogfoodResolvedCount: dogfoodResolvedCount > 0 ? dogfoodResolvedCount : void 0,
17563
+ learningsLinkedCount: learningsLinkedCount > 0 ? learningsLinkedCount : void 0,
17564
+ autoTriagedCount: autoTriagedCount > 0 ? autoTriagedCount : void 0,
17565
+ reportWriteVerified
17027
17566
  };
17028
17567
  }
17029
17568
  async function cancelBuild(adapter2, taskId, reason) {
@@ -17080,8 +17619,6 @@ For M/L tasks: use the full toolchain \u2014 Playground (design preview) \u2192
17080
17619
  - Check adapter-pg implementation, not adapter-md. adapter-md is legacy.
17081
17620
  - Verify the full write\u2192DB\u2192read\u2192consumer path for any data changes.
17082
17621
  - Run migrations on dev before prod. Test with \`execute_sql\` via Supabase MCP.
17083
- - When adding adapter interface methods, implement in BOTH adapter-md and adapter-pg.
17084
- - Build order matters: adapter-md \u2192 adapter-pg \u2192 server.
17085
17622
  - .papi/ files may be stale \u2014 DB via MCP tools is the source of truth.`,
17086
17623
  Auth: `**MODULE INSTRUCTIONS \u2014 Auth**
17087
17624
  - NEVER expose the Supabase service role key in client-side code or API responses.
@@ -17116,6 +17653,7 @@ ${instructions}`;
17116
17653
  var buildListTool = {
17117
17654
  name: "build_list",
17118
17655
  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.",
17656
+ annotations: { readOnlyHint: true, destructiveHint: false },
17119
17657
  inputSchema: {
17120
17658
  type: "object",
17121
17659
  properties: {},
@@ -17125,6 +17663,7 @@ var buildListTool = {
17125
17663
  var buildDescribeTool = {
17126
17664
  name: "build_describe",
17127
17665
  description: "Show the full BUILD HANDOFF for a specific task, including scope, acceptance criteria, and implementation guidance. Does not call the Anthropic API.",
17666
+ annotations: { readOnlyHint: true, destructiveHint: false },
17128
17667
  inputSchema: {
17129
17668
  type: "object",
17130
17669
  properties: {
@@ -17139,6 +17678,7 @@ var buildDescribeTool = {
17139
17678
  var buildExecuteTool = {
17140
17679
  name: "build_execute",
17141
17680
  description: "Start or complete a build task. Call with just task_id to start (returns BUILD HANDOFF, creates feature branch, marks In Progress). After implementing the task, you MUST call build_execute again with all report fields (completed, effort, estimated_effort, surprises, discovered_issues, architecture_notes) to finish \u2014 do not wait for user confirmation between start and complete. Never call on tasks that are already In Review or Done. Does not call the Anthropic API. Set light=true to skip branch/PR creation (commits to current branch). Set PAPI_LIGHT_MODE=true in env to default all builds to light mode.",
17681
+ annotations: { readOnlyHint: false, destructiveHint: false },
17142
17682
  inputSchema: {
17143
17683
  type: "object",
17144
17684
  properties: {
@@ -17171,7 +17711,7 @@ var buildExecuteTool = {
17171
17711
  },
17172
17712
  discovered_issues: {
17173
17713
  type: "string",
17174
- description: `Problems found DURING this build that are OUTSIDE this task's scope. Include severity (P0-P3). Good: "P2: Auth middleware doesn't validate token expiry \u2014 affects all protected routes." Bad: "Had to install a dependency." Only real bugs or gaps that need their own task. Use "None" if none. Required for complete.`
17714
+ description: `Problems found DURING this build that are OUTSIDE this task's scope. Include severity (P0-P3). Good: "P2: Auth middleware doesn't validate token expiry \u2014 affects all protected routes." Bad: "Had to install a dependency." Only real bugs or gaps that need their own task. Use "None" if none. Required for complete. TIP: When submitting a follow-up idea for a discovered issue, include "learning:<uuid>" in the idea notes to link it to this cycle learning entry \u2014 use the UUID returned in the build completion output.`
17175
17715
  },
17176
17716
  architecture_notes: {
17177
17717
  type: "string",
@@ -17223,6 +17763,7 @@ var buildExecuteTool = {
17223
17763
  var buildCancelTool = {
17224
17764
  name: "build_cancel",
17225
17765
  description: "Cancel a build task with a reason. Sets the task status to Cancelled and records the closure reason. Does not call the Anthropic API.",
17766
+ annotations: { readOnlyHint: false, destructiveHint: true },
17226
17767
  inputSchema: {
17227
17768
  type: "object",
17228
17769
  properties: {
@@ -17390,9 +17931,28 @@ If >80% of the scope is already implemented, call \`build_execute\` with complet
17390
17931
  adSection = formatRelevantADs(relevant);
17391
17932
  } catch {
17392
17933
  }
17934
+ let dogfoodSection = "";
17935
+ try {
17936
+ if (adapter2.getDogfoodLog) {
17937
+ const dogfoodLog = await adapter2.getDogfoodLog(50);
17938
+ const linked = dogfoodLog.filter(
17939
+ (e) => e.linkedTaskId === result.task.id || e.linkedTaskId === result.task.displayId
17940
+ );
17941
+ if (linked.length > 0) {
17942
+ const entries = linked.map((e) => `- [${e.category}] ${e.content}`).join("\n");
17943
+ dogfoodSection = `
17944
+
17945
+ ---
17946
+
17947
+ **DOGFOOD CONTEXT** \u2014 This task was linked to ${linked.length} observation(s):
17948
+ ${entries}`;
17949
+ }
17950
+ }
17951
+ } catch {
17952
+ }
17393
17953
  const moduleInstructions = getModuleInstructions(result.task.module);
17394
17954
  const moduleContext = await getModuleContext(adapter2, result.task);
17395
- return textResponse(header + serializeBuildHandoff(result.task.buildHandoff) + adSection + moduleInstructions + moduleContext + verificationNote + chainInstruction + phaseNote);
17955
+ return textResponse(header + serializeBuildHandoff(result.task.buildHandoff) + adSection + moduleInstructions + moduleContext + dogfoodSection + verificationNote + chainInstruction + phaseNote);
17396
17956
  } catch (err) {
17397
17957
  if (isNoHandoffError(err)) {
17398
17958
  const lines = [
@@ -17499,6 +18059,18 @@ function formatCompleteResult(result) {
17499
18059
  lines.push(`Phase auto-updated: ${c.phaseId} ${c.oldStatus} \u2192 ${c.newStatus}`);
17500
18060
  }
17501
18061
  }
18062
+ if (result.dogfoodResolvedCount) {
18063
+ lines.push("", `Resolved ${result.dogfoodResolvedCount} dogfood observation(s) linked to this task.`);
18064
+ }
18065
+ if (result.learningsLinkedCount) {
18066
+ lines.push("", `Linked ${result.learningsLinkedCount} unactioned learning(s) to this task.`);
18067
+ }
18068
+ if (result.autoTriagedCount) {
18069
+ lines.push("", `\u{1F516} Auto-triaged ${result.autoTriagedCount} discovered issue(s) to Backlog.`);
18070
+ }
18071
+ if (result.reportWriteVerified === false) {
18072
+ lines.push("", "\u26A0\uFE0F Build report write could not be verified \u2014 the report may not have been persisted. Run `build_list` to check, and resubmit if missing.");
18073
+ }
17502
18074
  if (result.docWarning) {
17503
18075
  lines.push("", `\u{1F4C4} ${result.docWarning}`);
17504
18076
  }
@@ -17638,7 +18210,7 @@ function resolveCurrentPhase(phases) {
17638
18210
  const sorted = [...phases].sort((a, b2) => a.order - b2.order);
17639
18211
  return sorted[0].label;
17640
18212
  }
17641
- var STOP_WORDS = /* @__PURE__ */ new Set([
18213
+ var STOP_WORDS2 = /* @__PURE__ */ new Set([
17642
18214
  "a",
17643
18215
  "an",
17644
18216
  "the",
@@ -17732,7 +18304,7 @@ var STOP_WORDS = /* @__PURE__ */ new Set([
17732
18304
  ]);
17733
18305
  function extractKeywords(text) {
17734
18306
  return new Set(
17735
- text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w))
18307
+ text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !STOP_WORDS2.has(w))
17736
18308
  );
17737
18309
  }
17738
18310
  async function findSimilarTasks(adapter2, ideaTitle) {
@@ -17799,14 +18371,15 @@ ${lines.join("\n")}
17799
18371
  ]);
17800
18372
  warnIfEmpty("getCycleHealth (idea)", health);
17801
18373
  const phase = input.phase || resolveCurrentPhase(phases);
17802
- const VALID_PRIORITIES2 = /* @__PURE__ */ new Set(["P0 Critical", "P1 High", "P2 Medium", "P3 Low"]);
18374
+ const VALID_PRIORITIES3 = /* @__PURE__ */ new Set(["P0 Critical", "P1 High", "P2 Medium", "P3 Low"]);
17803
18375
  const VALID_COMPLEXITIES2 = /* @__PURE__ */ new Set(["XS", "Small", "Medium", "Large", "XL"]);
17804
- const priority = input.priority && VALID_PRIORITIES2.has(input.priority) ? input.priority : "P2 Medium";
18376
+ const priority = input.priority && VALID_PRIORITIES3.has(input.priority) ? input.priority : "P2 Medium";
17805
18377
  const complexity = input.complexity && VALID_COMPLEXITIES2.has(input.complexity) ? input.complexity : "Small";
17806
- const VALID_TYPES = /* @__PURE__ */ new Set(["task", "bug", "research", "idea", "spike"]);
18378
+ const VALID_TYPES2 = /* @__PURE__ */ new Set(["task", "bug", "research", "idea", "spike", "discovery"]);
17807
18379
  let taskTitle = input.text;
17808
18380
  let taskType = "idea";
17809
- if (input.type && VALID_TYPES.has(input.type)) {
18381
+ let typeInferred = false;
18382
+ if (input.type && VALID_TYPES2.has(input.type)) {
17810
18383
  taskType = input.type;
17811
18384
  } else {
17812
18385
  const PREFIX_MAP = {
@@ -17820,6 +18393,20 @@ ${lines.join("\n")}
17820
18393
  taskType = PREFIX_MAP[key];
17821
18394
  taskTitle = input.text.slice(prefixMatch[0].length);
17822
18395
  }
18396
+ } else {
18397
+ const searchText = `${input.text} ${input.notes ?? ""}`.toLowerCase();
18398
+ if (/\b(bug|fix|broken|crash|error)\b/.test(searchText)) {
18399
+ taskType = "bug";
18400
+ } else if (/\b(research|investigate|explore|spike)\b/.test(searchText)) {
18401
+ taskType = "research";
18402
+ } else if (/\b(performance|optimize|speed|latency)\b/.test(searchText)) {
18403
+ taskType = "task";
18404
+ } else if (/\b(verify|confirm)\b/.test(searchText)) {
18405
+ taskType = "spike";
18406
+ } else {
18407
+ taskType = "task";
18408
+ }
18409
+ typeInferred = true;
17823
18410
  }
17824
18411
  }
17825
18412
  const task = await adapter2.createTask({
@@ -17839,7 +18426,8 @@ ${lines.join("\n")}
17839
18426
  taskType,
17840
18427
  maturity: "raw",
17841
18428
  docRef: input.docRef,
17842
- source: "llm"
18429
+ source: "llm",
18430
+ opportunity: input.opportunity
17843
18431
  });
17844
18432
  if (input.notes && adapter2.updateCycleLearningActionRef) {
17845
18433
  const learningRefs = input.notes.match(/learning:([a-f0-9-]+)/gi);
@@ -17865,7 +18453,27 @@ ${lines.join("\n")}
17865
18453
  }
17866
18454
  }
17867
18455
  }
17868
- return { routing: "task", task, message: `${task.id}: "${task.title}" \u2014 added to backlog` };
18456
+ if (adapter2.getDogfoodLog && adapter2.updateDogfoodEntryStatus) {
18457
+ try {
18458
+ const dogfoodLog = await adapter2.getDogfoodLog(50);
18459
+ const unlinked = dogfoodLog.filter((e) => e.status === "observed" && !e.linkedTaskId);
18460
+ if (unlinked.length > 0) {
18461
+ const taskText = `${task.title} ${input.notes ?? ""}`.toLowerCase();
18462
+ const taskKeywords = taskText.match(/\b[a-z]{4,}\b/g) ?? [];
18463
+ const taskKeywordSet = new Set(taskKeywords);
18464
+ for (const entry of unlinked) {
18465
+ const entryKeywords = entry.content.toLowerCase().match(/\b[a-z]{4,}\b/g) ?? [];
18466
+ const overlap = entryKeywords.filter((w) => taskKeywordSet.has(w));
18467
+ if (overlap.length >= 2) {
18468
+ await adapter2.updateDogfoodEntryStatus(entry.id, "backlog-created", task.id);
18469
+ }
18470
+ }
18471
+ }
18472
+ } catch {
18473
+ }
18474
+ }
18475
+ const typeNote = typeInferred ? ` [type: ${taskType} \u2014 inferred from text]` : "";
18476
+ return { routing: "task", task, message: `${task.id}: "${task.title}" \u2014 added to backlog${typeNote}` };
17869
18477
  }
17870
18478
  var CANVAS_SECTION_LABELS = {
17871
18479
  landscape: "Landscape References",
@@ -17905,6 +18513,7 @@ async function routeToDiscovery(adapter2, section, input) {
17905
18513
  // src/tools/idea.ts
17906
18514
  var ideaTool = {
17907
18515
  name: "idea",
18516
+ annotations: { readOnlyHint: false, destructiveHint: false },
17908
18517
  description: "Capture an idea as a Backlog task. The next plan run will triage and scope it. Use anytime to log bugs, feature requests, or improvements without interrupting the current cycle. IMPORTANT: If this idea originates from a research or planning session, you MUST include a Reference: line in notes pointing to the source doc. Without it, the planner has no context and will misinterpret the intent. Does not call the Anthropic API.",
17909
18518
  inputSchema: {
17910
18519
  type: "object",
@@ -17915,7 +18524,7 @@ var ideaTool = {
17915
18524
  },
17916
18525
  notes: {
17917
18526
  type: "string",
17918
- description: 'Additional context, constraints, or reasoning. MANDATORY: If this idea comes from a research or planning session, include a "Reference: <path>" line pointing to the source doc. Tasks submitted without references get misinterpreted by the planner \u2014 this is the #1 cause of wasted build slots (C146: task-807 was scoped as landing page copy when it was actually a dashboard UX task, because the source research doc was missing). Use doc_search to find relevant docs before submitting.'
18527
+ description: 'Additional context, constraints, or reasoning. MANDATORY: If this idea comes from a research or planning session, include a "Reference: <path>" line pointing to the source doc. Tasks submitted without references get misinterpreted by the planner \u2014 this is the #1 cause of wasted build slots (C146: task-807 was scoped as landing page copy when it was actually a dashboard UX task, because the source research doc was missing). Use doc_search to find relevant docs before submitting. TIP: If this idea addresses a known cycle learning, include "learning:<uuid>" in the notes (e.g. "learning:abc12345-..."). This links the idea to the learning entry and marks it actioned in the pipeline. Example: "Addresses recurring friction. learning:3f9a1c2e-..."'
17919
18528
  },
17920
18529
  module: {
17921
18530
  type: "string",
@@ -17949,12 +18558,16 @@ var ideaTool = {
17949
18558
  },
17950
18559
  type: {
17951
18560
  type: "string",
17952
- enum: ["task", "bug", "research", "spike"],
17953
- description: 'Task type. Defaults to "task". Use "bug" for defects, "research" for investigation tasks, "spike" for time-boxed experiments. The planner uses this to generate type-specific BUILD HANDOFFs.'
18561
+ enum: ["task", "bug", "research", "spike", "discovery"],
18562
+ description: 'Task type. Defaults to "task". Use "bug" for defects, "research" for investigation tasks, "spike" for time-boxed experiments, "discovery" for issues found during a build that need their own task. The planner uses this to generate type-specific BUILD HANDOFFs.'
17954
18563
  },
17955
18564
  doc_ref: {
17956
18565
  type: "string",
17957
18566
  description: 'Path to a reference document (e.g. "docs/research/foo.md"). Stored as a structured field \u2014 replaces the fragile "Reference:" line in notes.'
18567
+ },
18568
+ opportunity: {
18569
+ type: "string",
18570
+ description: "What user problem does this solve? Auto-fill from problem context in notes when submitting ideas that describe a user pain point. The planner uses this to cluster backlog tasks by opportunity."
17958
18571
  }
17959
18572
  },
17960
18573
  required: ["text"]
@@ -17983,7 +18596,8 @@ async function handleIdea(adapter2, config2, args) {
17983
18596
  discovery: args.discovery === true,
17984
18597
  force: args.force === true,
17985
18598
  docRef: args.doc_ref?.trim(),
17986
- type: args.type
18599
+ type: args.type,
18600
+ opportunity: args.opportunity?.trim()
17987
18601
  };
17988
18602
  const useGit = isGitAvailable() && isGitRepo(config2.projectRoot);
17989
18603
  const currentBranch = useGit ? getCurrentBranch(config2.projectRoot) : null;
@@ -18090,6 +18704,7 @@ function collectDiagnostics(config2) {
18090
18704
  var bugTool = {
18091
18705
  name: "bug",
18092
18706
  description: "Report a bug. Two modes: (1) Default \u2014 creates a Backlog task with severity-based priority for the project board. (2) With report=true \u2014 submits a diagnostic bug report with system info for cross-project visibility (external user issue reporting). Does not call the Anthropic API.",
18707
+ annotations: { readOnlyHint: false, destructiveHint: false },
18093
18708
  inputSchema: {
18094
18709
  type: "object",
18095
18710
  properties: {
@@ -18233,16 +18848,16 @@ async function recordAdHoc(adapter2, input) {
18233
18848
  displayId: "",
18234
18849
  title: input.title,
18235
18850
  status: "Done",
18236
- priority: "P3 Low",
18851
+ priority: input.priority || "P2 Medium",
18237
18852
  complexity: input.effort === "XS" || input.effort === "S" ? "Small" : "Medium",
18238
18853
  module: input.module || "Core",
18239
18854
  epic: input.epic || "Platform",
18240
18855
  phase,
18241
- owner: "Cathal",
18856
+ owner: input.owner || "Cathal",
18242
18857
  reviewed: true,
18243
18858
  createdCycle: cycle,
18244
18859
  notes: input.notes ? `[ad-hoc] ${input.notes}` : "[ad-hoc]",
18245
- taskType: "task",
18860
+ taskType: input.taskType || "task",
18246
18861
  source: "owner"
18247
18862
  });
18248
18863
  }
@@ -18267,9 +18882,20 @@ async function recordAdHoc(adapter2, input) {
18267
18882
 
18268
18883
  // src/tools/ad-hoc.ts
18269
18884
  var VALID_EFFORTS = ["XS", "S", "M", "L", "XL"];
18885
+ var VALID_PRIORITIES = ["P0 Critical", "P1 High", "P2 Medium", "P3 Low"];
18886
+ var VALID_TYPES = ["task", "bug", "research", "discovery", "spike", "idea"];
18887
+ function inferTaskType(description) {
18888
+ const lower = description.toLowerCase();
18889
+ if (/\bfix\b|bug|error|crash|broken|regression|defect/.test(lower)) return "bug";
18890
+ if (/research|investigate|explore|analysis|audit|review|assess/.test(lower)) return "research";
18891
+ if (/discover|discovery|found|uncovered/.test(lower)) return "discovery";
18892
+ if (/spike|poc|proof.of.concept|prototype|experiment/.test(lower)) return "spike";
18893
+ return "task";
18894
+ }
18270
18895
  var adHocTool = {
18271
18896
  name: "ad_hoc",
18272
18897
  description: "Record work done outside the normal cycle. Creates a Done task with a lightweight build report, or associates work with an existing task if task_id is provided (without changing task status \u2014 use build_execute for status transitions). Use for quick fixes, bug patches, or ad-hoc changes. Does not call the Anthropic API.",
18898
+ annotations: { readOnlyHint: false, destructiveHint: false },
18273
18899
  inputSchema: {
18274
18900
  type: "object",
18275
18901
  properties: {
@@ -18297,6 +18923,16 @@ var adHocTool = {
18297
18923
  epic: {
18298
18924
  type: "string",
18299
18925
  description: 'Epic this relates to (default: "Platform").'
18926
+ },
18927
+ priority: {
18928
+ type: "string",
18929
+ enum: ["P0 Critical", "P1 High", "P2 Medium", "P3 Low"],
18930
+ description: "Task priority (default: P2 Medium). Use P1 for important fixes, P0 for critical incidents."
18931
+ },
18932
+ type: {
18933
+ type: "string",
18934
+ enum: ["task", "bug", "research", "discovery", "spike", "idea"],
18935
+ description: 'Task type (default: inferred from description \u2014 "fix"/"bug" \u2192 bug, "research"/"investigate" \u2192 research, otherwise task).'
18300
18936
  }
18301
18937
  },
18302
18938
  required: []
@@ -18312,6 +18948,14 @@ async function handleAdHoc(adapter2, config2, args) {
18312
18948
  if (!VALID_EFFORTS.includes(effortRaw)) {
18313
18949
  return errorResponse(`effort must be one of: ${VALID_EFFORTS.join(", ")}`);
18314
18950
  }
18951
+ const priorityRaw = args.priority || "P2 Medium";
18952
+ if (!VALID_PRIORITIES.includes(priorityRaw)) {
18953
+ return errorResponse(`priority must be one of: ${VALID_PRIORITIES.join(", ")}`);
18954
+ }
18955
+ const typeRaw = args.type || inferTaskType(title || (args.notes ?? ""));
18956
+ if (!VALID_TYPES.includes(typeRaw)) {
18957
+ return errorResponse(`type must be one of: ${VALID_TYPES.join(", ")}`);
18958
+ }
18315
18959
  const MAX_NOTES_LENGTH = 2e3;
18316
18960
  let rawNotes = args.notes?.trim();
18317
18961
  let notesTruncated = false;
@@ -18325,7 +18969,11 @@ async function handleAdHoc(adapter2, config2, args) {
18325
18969
  notes: rawNotes,
18326
18970
  effort: effortRaw,
18327
18971
  module: args.module,
18328
- epic: args.epic
18972
+ epic: args.epic,
18973
+ priority: priorityRaw,
18974
+ taskType: typeRaw,
18975
+ // PROJECT-SPECIFIC: owner resolved from config (PAPI_OWNER env var, default 'Cathal')
18976
+ owner: config2.projectOwner
18329
18977
  });
18330
18978
  if (isGitAvailable() && isGitRepo(config2.projectRoot)) {
18331
18979
  try {
@@ -18338,8 +18986,11 @@ async function handleAdHoc(adapter2, config2, args) {
18338
18986
  }
18339
18987
  }
18340
18988
  const truncateWarning = notesTruncated ? ` (notes truncated to ${MAX_NOTES_LENGTH} chars)` : "";
18989
+ const taskModule = result.task.module || "Core";
18990
+ const typeLabel = result.task.taskType || typeRaw;
18341
18991
  return textResponse(
18342
- `**${result.task.id}:** "${result.task.title}" \u2014 recorded as ad-hoc (${effortRaw}). Build report attached.${truncateWarning}`
18992
+ `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning} Build report attached.
18993
+ _To correct: board_edit ${result.task.id} with updated fields._`
18343
18994
  );
18344
18995
  }
18345
18996
 
@@ -18480,9 +19131,9 @@ async function prepareReconcile(adapter2) {
18480
19131
  }
18481
19132
  return lines.join("\n");
18482
19133
  }
18483
- var STOP_WORDS2 = /* @__PURE__ */ new Set(["the", "a", "an", "and", "or", "for", "in", "on", "to", "of", "is", "with", "from", "by", "vs", "not", "no", "do"]);
19134
+ var STOP_WORDS3 = /* @__PURE__ */ new Set(["the", "a", "an", "and", "or", "for", "in", "on", "to", "of", "is", "with", "from", "by", "vs", "not", "no", "do"]);
18484
19135
  function tokenize(s) {
18485
- return s.toLowerCase().replace(/[^a-z0-9\s-]/g, "").split(/\s+/).filter((w) => w.length > 2 && !STOP_WORDS2.has(w));
19136
+ return s.toLowerCase().replace(/[^a-z0-9\s-]/g, "").split(/\s+/).filter((w) => w.length > 2 && !STOP_WORDS3.has(w));
18486
19137
  }
18487
19138
  function titleKeywords(title) {
18488
19139
  return new Set(tokenize(title));
@@ -18562,7 +19213,7 @@ async function applyReconcile(adapter2, corrections) {
18562
19213
  }
18563
19214
  return { applied, skipped, details, phaseChanges };
18564
19215
  }
18565
- var VALID_PRIORITIES = /* @__PURE__ */ new Set(["P0 Critical", "P1 High", "P2 Medium", "P3 Low"]);
19216
+ var VALID_PRIORITIES2 = /* @__PURE__ */ new Set(["P0 Critical", "P1 High", "P2 Medium", "P3 Low"]);
18566
19217
  var VALID_COMPLEXITIES = /* @__PURE__ */ new Set(["XS", "Small", "Medium", "Large", "XL"]);
18567
19218
  async function prepareRetriage(adapter2) {
18568
19219
  const health = await adapter2.getCycleHealth();
@@ -18623,7 +19274,7 @@ async function applyRetriage(adapter2, retriages) {
18623
19274
  skipped++;
18624
19275
  continue;
18625
19276
  }
18626
- if (!VALID_PRIORITIES.has(r.priority)) {
19277
+ if (!VALID_PRIORITIES2.has(r.priority)) {
18627
19278
  details.push(`${r.taskId}: skipped \u2014 invalid priority "${r.priority}"`);
18628
19279
  skipped++;
18629
19280
  continue;
@@ -18659,6 +19310,7 @@ async function applyRetriage(adapter2, retriages) {
18659
19310
  var boardReconcileTool = {
18660
19311
  name: "board_reconcile",
18661
19312
  description: 'Holistic backlog review to group, merge, cancel, defer, or retriage tasks. "prepare"/"apply" for cleanup. "retriage-prepare"/"retriage-apply" to reassess priority and complexity on existing backlog tasks. Does not call the Anthropic API.',
19313
+ annotations: { readOnlyHint: false, destructiveHint: false },
18662
19314
  inputSchema: {
18663
19315
  type: "object",
18664
19316
  properties: {
@@ -18989,451 +19641,118 @@ Assess each task above and produce your retriage output. Then call \`board_recon
18989
19641
  return errorResponse(`Unknown mode: ${mode}. Use "prepare", "apply", "retriage-prepare", or "retriage-apply".`);
18990
19642
  }
18991
19643
 
18992
- // src/services/health.ts
18993
- function computeHealthScore(cycleNumber, snapshots, activeTasks, decisionUsage) {
18994
- if (cycleNumber < 3) return null;
18995
- const scores = [];
18996
- const recentSnaps = snapshots.slice(-3);
18997
- const baselineSnaps = snapshots.slice(-10);
18998
- if (recentSnaps.length > 0 && baselineSnaps.length > 0) {
18999
- const avg = (snaps) => snaps.reduce((s, sn) => s + (sn.velocity[0]?.effortPoints ?? 0), 0) / snaps.length;
19000
- const recentAvg = avg(recentSnaps);
19001
- const baselineAvg = avg(baselineSnaps);
19002
- const velocityScore = baselineAvg > 0 ? Math.min(100, Math.round(recentAvg / baselineAvg * 100)) : 50;
19003
- scores.push({ name: "Velocity", score: velocityScore, weight: 0.25 });
19004
- } else {
19005
- scores.push({ name: "Velocity", score: 50, weight: 0.25 });
19644
+ // src/services/release.ts
19645
+ init_git();
19646
+ import { writeFile as writeFile3 } from "fs/promises";
19647
+ import { join as join6 } from "path";
19648
+ var INITIAL_RELEASE_NOTES = `# Changelog
19649
+
19650
+ ## v0.1.0-alpha \u2014 Initial Release
19651
+
19652
+ PAPI MCP Server \u2014 the AI-powered project planning framework.
19653
+
19654
+ ### Commands
19655
+ - **setup** \u2014 Initialise a new PAPI project with Product Brief generation
19656
+ - **plan** \u2014 Run cycle planning with embedded BUILD HANDOFFs (Bootstrap + Full modes)
19657
+ - **build_list / build_describe / build_execute / build_cancel** \u2014 Manage build tasks
19658
+ - **board_view / board_deprioritise / board_archive** \u2014 View and manage the Board
19659
+ - **strategy_review / strategy_change** \u2014 Run Strategy Reviews and apply strategic changes
19660
+ - **review_list / review_submit** \u2014 Human review loop for handoffs and builds
19661
+ - **idea** \u2014 Capture ideas as backlog tasks for future triage
19662
+ - **health** \u2014 Cycle Health Summary dashboard
19663
+ - **release** \u2014 Cut versioned releases with git tags and changelogs
19664
+
19665
+ ### Features
19666
+ - .md file persistence in .papi/ directory
19667
+ - Bootstrap + Full planning modes with Anthropic API integration
19668
+ - Embedded BUILD HANDOFFs with dual write-back build reports
19669
+ - Auto-commit and auto-PR after builds
19670
+ - Board corrections and Active Decision persistence
19671
+ - Single-purpose MCP tools for optimal LLM tool selection
19672
+ - Consistent error handling across all tools
19673
+ `;
19674
+ function generateChangelog(version, commits) {
19675
+ const date = (/* @__PURE__ */ new Date()).toISOString();
19676
+ const commitList = commits.map((c) => `- ${c}`).join("\n");
19677
+ return `# Changelog
19678
+
19679
+ ## ${version} \u2014 ${date}
19680
+
19681
+ ${commitList}
19682
+ `;
19683
+ }
19684
+ async function createRelease(config2, branch, version, adapter2) {
19685
+ if (!isGitAvailable()) {
19686
+ throw new Error("git is not available.");
19006
19687
  }
19007
- if (recentSnaps.length > 0) {
19008
- const avgMatchRate = recentSnaps.reduce((s, sn) => s + (sn.accuracy[0]?.matchRate ?? 0), 0) / recentSnaps.length;
19009
- scores.push({ name: "Estimation accuracy", score: Math.round(avgMatchRate), weight: 0.25 });
19010
- } else {
19011
- scores.push({ name: "Estimation accuracy", score: 50, weight: 0.25 });
19688
+ if (!isGitRepo(config2.projectRoot)) {
19689
+ throw new Error("not a git repository.");
19012
19690
  }
19013
- const inReviewCount = activeTasks.filter((t) => t.status === "In Review").length;
19014
- const reviewScore = inReviewCount === 0 ? 100 : inReviewCount <= 2 ? 60 : 20;
19015
- scores.push({ name: "Review throughput", score: reviewScore, weight: 0.2 });
19016
- const backlogTasks = activeTasks.filter((t) => t.status === "Backlog");
19017
- if (backlogTasks.length > 0) {
19018
- const criticalCount = backlogTasks.filter(
19019
- (t) => t.priority === "P0 Critical" || t.priority === "P1 High"
19020
- ).length;
19021
- const criticalRatio = criticalCount / backlogTasks.length;
19022
- const backlogScore = criticalRatio > 0.5 ? 40 : criticalRatio > 0.3 ? 70 : 90;
19023
- scores.push({ name: "Backlog health", score: backlogScore, weight: 0.15 });
19024
- } else {
19025
- scores.push({ name: "Backlog health", score: 80, weight: 0.15 });
19691
+ if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
19692
+ throw new Error("working directory has uncommitted changes. Commit or stash them before releasing.");
19026
19693
  }
19027
- if (decisionUsage.length > 0) {
19028
- const staleCount = decisionUsage.filter((u) => u.cyclesSinceLastReference >= 10).length;
19029
- const freshRatio = (decisionUsage.length - staleCount) / decisionUsage.length;
19030
- scores.push({ name: "AD freshness", score: Math.round(freshRatio * 100), weight: 0.15 });
19031
- } else {
19032
- scores.push({ name: "AD freshness", score: 70, weight: 0.15 });
19694
+ const warnings = [];
19695
+ if (adapter2) {
19696
+ try {
19697
+ const versionMatch = version.match(/^v0\.(\d+)\./);
19698
+ const currentCycle = versionMatch ? parseInt(versionMatch[1], 10) : 0;
19699
+ if (currentCycle > 0) {
19700
+ await adapter2.createCycle({
19701
+ id: `cycle-${currentCycle}`,
19702
+ number: currentCycle,
19703
+ status: "complete",
19704
+ startDate: (/* @__PURE__ */ new Date()).toISOString(),
19705
+ endDate: (/* @__PURE__ */ new Date()).toISOString(),
19706
+ goals: [],
19707
+ boardHealth: "",
19708
+ taskIds: []
19709
+ });
19710
+ }
19711
+ } catch (err) {
19712
+ const msg = `createCycle (mark complete) failed: ${err instanceof Error ? err.message : String(err)}`;
19713
+ console.error(`[release] ${msg}`);
19714
+ warnings.push(msg);
19715
+ }
19033
19716
  }
19034
- const totalScore = Math.round(scores.reduce((sum, s) => sum + s.score * s.weight, 0));
19035
- const status = totalScore >= 70 ? "GREEN" : totalScore >= 50 ? "AMBER" : "RED";
19036
- const worst = scores.reduce((min, s) => s.score < min.score ? s : min, scores[0]);
19037
- const reason = status === "GREEN" ? "All components healthy" : `${worst.name} below target (${worst.score}/100)`;
19038
- return { score: totalScore, status, reason };
19039
- }
19040
- function countByStatus(tasks) {
19041
- const counts = /* @__PURE__ */ new Map();
19042
- for (const task of tasks) {
19043
- counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
19717
+ const checkout = checkoutBranch(config2.projectRoot, branch);
19718
+ if (!checkout.success) {
19719
+ throw new Error(checkout.message);
19044
19720
  }
19045
- return counts;
19046
- }
19047
- async function getHealthSummary(adapter2) {
19048
- const health = await adapter2.getCycleHealth();
19049
- const activeTasks = await adapter2.queryBoard({
19050
- status: ["Backlog", "In Cycle", "Ready", "In Progress", "In Review", "Blocked"]
19051
- });
19052
- const logEntries = await adapter2.getCycleLog(3);
19053
- const cycleNumber = health.totalCycles;
19054
- const cyclesSinceReview = health.cyclesSinceLastStrategyReview;
19055
- const reviewDue = health.strategyReviewDue;
19056
- const reviewGateBlocking = cyclesSinceReview >= 5;
19057
- const reviewWarning = reviewGateBlocking ? `\u26A0\uFE0F GATE \u2014 ${cyclesSinceReview} cycles since last Strategy Review. \`plan\` is blocked until \`strategy_review\` runs (or \`force: true\`).` : `\u2713 On track \u2014 ${cyclesSinceReview} cycle(s) since last review. Next due: ${reviewDue}`;
19058
- const deferredCount = activeTasks.filter((t) => t.status === "Deferred").length;
19059
- const nonDeferredTasks = activeTasks.filter((t) => t.status !== "Deferred");
19060
- const statusCounts = countByStatus(nonDeferredTasks);
19061
- let boardSummary;
19062
- if (nonDeferredTasks.length === 0 && deferredCount === 0) {
19063
- boardSummary = "0 tasks \u2014 board may need reloading";
19064
- } else {
19065
- const parts = [];
19066
- for (const [status, count] of statusCounts) {
19067
- parts.push(`${count} ${status}`);
19068
- }
19069
- boardSummary = `${nonDeferredTasks.length} active tasks \u2014 ${parts.join(", ")}`;
19070
- if (deferredCount > 0) {
19071
- boardSummary += ` + ${deferredCount} deferred`;
19721
+ if (hasRemote(config2.projectRoot)) {
19722
+ const pull = gitPull(config2.projectRoot);
19723
+ if (!pull.success) {
19724
+ warnings.push(`git pull failed: ${pull.message}. Run manually.`);
19072
19725
  }
19073
19726
  }
19074
- const inProgressTasks = activeTasks.filter((t) => t.status === "In Progress");
19075
- const staleTasks = inProgressTasks.length > 0 ? `${inProgressTasks.length} task(s) In Progress: ${inProgressTasks.map((t) => t.id).join(", ")}` : "No tasks currently In Progress";
19076
- const inReviewTasks = activeTasks.filter((t) => t.status === "In Review");
19077
- const inReviewSummary = inReviewTasks.length > 0 ? `${inReviewTasks.length} task(s) ready for sign-off: ${inReviewTasks.map((t) => t.id).join(", ")}` : "No tasks waiting for sign-off";
19078
- let carryForward = "None found";
19079
- if (logEntries.length > 0) {
19080
- const latest = logEntries[0];
19081
- if (latest.carryForward) {
19082
- carryForward = latest.carryForward;
19083
- } else {
19084
- carryForward = `No carry-forward in Cycle ${latest.cycleNumber}`;
19085
- }
19727
+ if (tagExists(config2.projectRoot, version)) {
19728
+ throw new Error(`tag "${version}" already exists. Use a different version.`);
19086
19729
  }
19087
- let recommendedMode;
19088
- const reasons = [];
19089
- if (reviewGateBlocking) {
19090
- reasons.push(`Strategy Review overdue (${cyclesSinceReview} cycles)`);
19730
+ const latestTag = getLatestTag(config2.projectRoot);
19731
+ let changelogContent;
19732
+ if (!latestTag) {
19733
+ changelogContent = INITIAL_RELEASE_NOTES.replace("v0.1.0-alpha", version);
19734
+ } else {
19735
+ const commits = getCommitsSinceTag(config2.projectRoot, latestTag);
19736
+ changelogContent = generateChangelog(version, commits);
19091
19737
  }
19092
- if (activeTasks.length === 0) {
19093
- reasons.push("Board is empty \u2014 needs task reload/triage");
19738
+ const changelogPath = join6(config2.projectRoot, "CHANGELOG.md");
19739
+ await writeFile3(changelogPath, changelogContent, "utf-8");
19740
+ const commitResult = stageAllAndCommit(config2.projectRoot, `release: ${version}`);
19741
+ const commitNote = commitResult.committed ? `Committed CHANGELOG.md.` : `CHANGELOG.md: ${commitResult.message}`;
19742
+ const tagResult = createTag(config2.projectRoot, version, `Release ${version}`);
19743
+ if (!tagResult.success) {
19744
+ throw new Error(tagResult.message);
19094
19745
  }
19095
- const unbuiltCycleTasks = activeTasks.filter(
19096
- (t) => t.cycle === cycleNumber && (t.status === "In Cycle" || t.status === "Ready")
19097
- );
19098
- const inProgressCycleTasks = activeTasks.filter(
19099
- (t) => t.cycle === cycleNumber && t.status === "In Progress"
19100
- );
19101
- const inReviewCycleTasks = activeTasks.filter(
19102
- (t) => t.cycle === cycleNumber && t.status === "In Review"
19103
- );
19104
- if (reasons.length > 0) {
19105
- recommendedMode = `**Full** \u2014 ${reasons.join("; ")}`;
19106
- } else if (unbuiltCycleTasks.length > 0) {
19107
- recommendedMode = `**Build** \u2014 ${unbuiltCycleTasks.length} cycle task(s) not yet started`;
19108
- } else if (inProgressCycleTasks.length > 0) {
19109
- recommendedMode = `**Build** \u2014 ${inProgressCycleTasks.length} task(s) in progress`;
19110
- } else if (inReviewCycleTasks.length > 0) {
19111
- recommendedMode = `**Review** \u2014 ${inReviewCycleTasks.length} task(s) awaiting review`;
19746
+ const pushNotes = [];
19747
+ if (hasRemote(config2.projectRoot)) {
19748
+ const branchPush = gitPush(config2.projectRoot, branch);
19749
+ pushNotes.push(branchPush.success ? `Pushed '${branch}' to origin.` : `Push branch failed: ${branchPush.message}`);
19750
+ if (!branchPush.success) warnings.push(branchPush.message);
19751
+ const tagPush = gitPush(config2.projectRoot, version);
19752
+ pushNotes.push(tagPush.success ? `Pushed tag '${version}' to origin.` : `Push tag failed: ${tagPush.message}`);
19753
+ if (!tagPush.success) warnings.push(tagPush.message);
19112
19754
  } else {
19113
- recommendedMode = `**Full** \u2014 ready for next cycle`;
19114
- }
19115
- let metricsSection;
19116
- let derivedMetricsSection = "";
19117
- let snapshots = [];
19118
- try {
19119
- try {
19120
- const reports = await adapter2.getRecentBuildReports(50);
19121
- snapshots = computeSnapshotsFromBuildReports(reports);
19122
- } catch {
19123
- }
19124
- metricsSection = formatCycleMetrics(snapshots);
19125
- derivedMetricsSection = formatDerivedMetrics(snapshots, activeTasks);
19126
- } catch (_err) {
19127
- metricsSection = "Could not read methodology metrics.";
19128
- }
19129
- try {
19130
- const recentReports = await adapter2.getRecentBuildReports(50);
19131
- if (recentReports.length > 0) {
19132
- const taskCounts = /* @__PURE__ */ new Map();
19133
- for (const r of recentReports) {
19134
- taskCounts.set(r.taskId, (taskCounts.get(r.taskId) ?? 0) + 1);
19135
- }
19136
- const iterCounts = [...taskCounts.values()];
19137
- const avgIter = iterCounts.reduce((s, c) => s + c, 0) / iterCounts.length;
19138
- const multiIterTasks = iterCounts.filter((c) => c > 1).length;
19139
- if (avgIter > 1 || multiIterTasks > 0) {
19140
- derivedMetricsSection += `
19141
-
19142
- **Rework**
19143
- - Average iterations: ${avgIter.toFixed(1)} (${multiIterTasks} task${multiIterTasks !== 1 ? "s" : ""} with pushbacks)`;
19144
- }
19145
- }
19146
- } catch {
19147
- }
19148
- const costSection = "Disabled \u2014 local MCP, no API costs.";
19149
- let decisionUsageSection = "";
19150
- let decisionUsageEntries = [];
19151
- try {
19152
- const usage = await adapter2.getDecisionUsage(cycleNumber);
19153
- decisionUsageEntries = usage;
19154
- if (usage.length > 0) {
19155
- const stale = usage.filter((u) => u.cyclesSinceLastReference >= 5);
19156
- if (stale.length > 0) {
19157
- const lines = stale.map(
19158
- (u) => `- ${u.decisionId}: last referenced Cycle ${u.lastReferencedCycle} (${u.cyclesSinceLastReference} cycles ago)`
19159
- );
19160
- decisionUsageSection = `**Stale ADs (5+ cycles unreferenced):**
19161
- ${lines.join("\n")}`;
19162
- } else {
19163
- decisionUsageSection = `All ${usage.length} tracked ADs referenced within last 5 cycles.`;
19164
- }
19165
- }
19166
- } catch {
19167
- }
19168
- let decisionLifecycleSection = "";
19169
- try {
19170
- const decisions = await adapter2.getActiveDecisions();
19171
- const lifecycleSummary = formatDecisionLifecycleSummary(decisions);
19172
- if (lifecycleSummary) {
19173
- decisionLifecycleSection = `**Lifecycle:** ${lifecycleSummary}`;
19174
- }
19175
- } catch {
19176
- }
19177
- const decisionScoresSection = "";
19178
- let contextUtilisationSection = "";
19179
- try {
19180
- const utilData = await adapter2.getContextUtilisation?.();
19181
- if (utilData && utilData.length > 0) {
19182
- const lines = utilData.filter((u) => u.cycleNumber === cycleNumber).map((u) => `- ${u.tool}: ${(u.avgUtilisation * 100).toFixed(0)}% utilisation (${(u.avgContextBytes / 1024).toFixed(1)}KB avg context)`);
19183
- if (lines.length > 0) {
19184
- contextUtilisationSection = `**Current cycle:**
19185
- ${lines.join("\n")}`;
19186
- }
19187
- }
19188
- } catch {
19189
- }
19190
- let northStarSection = "";
19191
- try {
19192
- const staleness = await adapter2.getNorthStarStaleness?.();
19193
- if (staleness) {
19194
- const cycleGap = cycleNumber - staleness.setCycle;
19195
- const daysSinceSet = Math.floor((Date.now() - new Date(staleness.setAt).getTime()) / (1e3 * 60 * 60 * 24));
19196
- northStarSection = `\u2713 North Star set Cycle ${staleness.setCycle} (${cycleGap} cycles, ${daysSinceSet} days ago)`;
19197
- } else {
19198
- const setAtCycle = await adapter2.getNorthStarSetCycle?.();
19199
- if (setAtCycle != null) {
19200
- northStarSection = `\u2713 North Star set Cycle ${setAtCycle}`;
19201
- } else if (adapter2.getCurrentNorthStar) {
19202
- const ns = await adapter2.getCurrentNorthStar();
19203
- northStarSection = ns ? "" : "\u26A0\uFE0F No North Star set \u2014 consider defining one";
19204
- }
19205
- }
19206
- } catch {
19207
- }
19208
- const healthResult = computeHealthScore(cycleNumber, snapshots, activeTasks, decisionUsageEntries);
19209
- return {
19210
- cycleNumber,
19211
- latestCycleStatus: health.latestCycleStatus,
19212
- connectionStatus: getConnectionStatus(),
19213
- reviewWarning,
19214
- boardSummary,
19215
- staleTasks,
19216
- inReviewSummary,
19217
- carryForward,
19218
- recommendedMode,
19219
- metricsSection,
19220
- derivedMetricsSection,
19221
- costSection,
19222
- decisionUsageSection,
19223
- decisionLifecycleSection,
19224
- decisionScoresSection,
19225
- contextUtilisationSection,
19226
- northStarSection,
19227
- healthScore: healthResult?.score ?? null,
19228
- healthStatus: healthResult?.status ?? null,
19229
- healthReason: healthResult?.reason ?? null
19230
- };
19231
- }
19232
-
19233
- // src/tools/health.ts
19234
- var healthTool = {
19235
- name: "health",
19236
- description: "Cycle Health Summary \u2014 shows current cycle number, Strategy Review cadence status (AD-5), board health (task counts by status, stale tasks), last carry-forward items, and recommended next mode. Read-only, does not modify any files.",
19237
- inputSchema: {
19238
- type: "object",
19239
- properties: {},
19240
- required: []
19241
- }
19242
- };
19243
- function formatHealthSummary(summary) {
19244
- const lines = [];
19245
- lines.push(`# Cycle ${summary.cycleNumber} \u2014 Health`);
19246
- lines.push("");
19247
- if (summary.connectionStatus !== "offline") {
19248
- const statusIcon = summary.connectionStatus === "connected" ? "\u2713" : "\u26A0\uFE0F";
19249
- const statusLabel = summary.connectionStatus === "connected" ? "Supabase connected" : "Supabase degraded \u2014 data may be stale. Check DATABASE_URL in .mcp.json";
19250
- lines.push(`**Connection:** ${statusIcon} ${statusLabel}`);
19251
- lines.push("");
19252
- }
19253
- lines.push(`> **Next action:** ${summary.recommendedMode}`);
19254
- lines.push("");
19255
- lines.push(`**Strategy Review:** ${summary.reviewWarning}`);
19256
- lines.push("");
19257
- lines.push(`## Board`);
19258
- lines.push(summary.boardSummary);
19259
- const hasInProgress = !summary.staleTasks.startsWith("No tasks");
19260
- const hasInReview = !summary.inReviewSummary.startsWith("No tasks");
19261
- if (hasInProgress || hasInReview) {
19262
- lines.push("");
19263
- if (hasInProgress) lines.push(`- **In Progress:** ${summary.staleTasks}`);
19264
- if (hasInReview) lines.push(`- **In Review:** ${summary.inReviewSummary}`);
19265
- }
19266
- lines.push("");
19267
- const hasCarryForward = summary.carryForward !== "None found" && !summary.carryForward.startsWith("No carry-forward");
19268
- if (hasCarryForward) {
19269
- lines.push(`## Carry-Forward`);
19270
- lines.push(summary.carryForward);
19271
- lines.push("");
19272
- }
19273
- const hasMetrics = summary.metricsSection !== "Could not read methodology metrics." && !summary.metricsSection.includes("undefined");
19274
- if (hasMetrics) {
19275
- lines.push(`## Trends`);
19276
- lines.push(summary.metricsSection);
19277
- lines.push("");
19278
- }
19279
- if (summary.derivedMetricsSection) {
19280
- lines.push(`## Insights`);
19281
- lines.push(summary.derivedMetricsSection);
19282
- lines.push("");
19283
- }
19284
- const hasCost = summary.costSection !== "No metrics data yet." && summary.costSection !== "Could not read metrics data.";
19285
- if (hasCost) {
19286
- lines.push(`## Cost`);
19287
- lines.push(summary.costSection);
19288
- lines.push("");
19289
- }
19290
- if (summary.northStarSection) {
19291
- lines.push(`## North Star`);
19292
- lines.push(summary.northStarSection);
19293
- lines.push("");
19294
- }
19295
- if (summary.decisionUsageSection) {
19296
- lines.push(`## Decision Usage`);
19297
- lines.push(summary.decisionUsageSection);
19298
- if (summary.decisionLifecycleSection) {
19299
- lines.push(summary.decisionLifecycleSection);
19300
- }
19301
- lines.push("");
19302
- }
19303
- if (summary.contextUtilisationSection) {
19304
- lines.push(`## Context Utilisation`);
19305
- lines.push(summary.contextUtilisationSection);
19306
- lines.push("");
19307
- }
19308
- if (summary.decisionScoresSection) {
19309
- lines.push(`## Decision Scores`);
19310
- lines.push(summary.decisionScoresSection);
19311
- lines.push("");
19312
- }
19313
- return lines.join("\n").trimEnd();
19314
- }
19315
- async function handleHealth(adapter2) {
19316
- try {
19317
- const summary = await getHealthSummary(adapter2);
19318
- return textResponse(formatHealthSummary(summary));
19319
- } catch (err) {
19320
- const message = err instanceof Error ? err.message : String(err);
19321
- return errorResponse(`Could not read cycle health. Run \`setup\` first to initialise your project. (${message})`);
19322
- }
19323
- }
19324
-
19325
- // src/services/release.ts
19326
- init_git();
19327
- import { writeFile as writeFile3 } from "fs/promises";
19328
- import { join as join6 } from "path";
19329
- var INITIAL_RELEASE_NOTES = `# Changelog
19330
-
19331
- ## v0.1.0-alpha \u2014 Initial Release
19332
-
19333
- PAPI MCP Server \u2014 the AI-powered project planning framework.
19334
-
19335
- ### Commands
19336
- - **setup** \u2014 Initialise a new PAPI project with Product Brief generation
19337
- - **plan** \u2014 Run cycle planning with embedded BUILD HANDOFFs (Bootstrap + Full modes)
19338
- - **build_list / build_describe / build_execute / build_cancel** \u2014 Manage build tasks
19339
- - **board_view / board_deprioritise / board_archive** \u2014 View and manage the Board
19340
- - **strategy_review / strategy_change** \u2014 Run Strategy Reviews and apply strategic changes
19341
- - **review_list / review_submit** \u2014 Human review loop for handoffs and builds
19342
- - **idea** \u2014 Capture ideas as backlog tasks for future triage
19343
- - **health** \u2014 Cycle Health Summary dashboard
19344
- - **release** \u2014 Cut versioned releases with git tags and changelogs
19345
-
19346
- ### Features
19347
- - .md file persistence in .papi/ directory
19348
- - Bootstrap + Full planning modes with Anthropic API integration
19349
- - Embedded BUILD HANDOFFs with dual write-back build reports
19350
- - Auto-commit and auto-PR after builds
19351
- - Board corrections and Active Decision persistence
19352
- - Single-purpose MCP tools for optimal LLM tool selection
19353
- - Consistent error handling across all tools
19354
- `;
19355
- function generateChangelog(version, commits) {
19356
- const date = (/* @__PURE__ */ new Date()).toISOString();
19357
- const commitList = commits.map((c) => `- ${c}`).join("\n");
19358
- return `# Changelog
19359
-
19360
- ## ${version} \u2014 ${date}
19361
-
19362
- ${commitList}
19363
- `;
19364
- }
19365
- async function createRelease(config2, branch, version, adapter2) {
19366
- if (!isGitAvailable()) {
19367
- throw new Error("git is not available.");
19368
- }
19369
- if (!isGitRepo(config2.projectRoot)) {
19370
- throw new Error("not a git repository.");
19371
- }
19372
- if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
19373
- throw new Error("working directory has uncommitted changes. Commit or stash them before releasing.");
19374
- }
19375
- const warnings = [];
19376
- if (adapter2) {
19377
- try {
19378
- const versionMatch = version.match(/^v0\.(\d+)\./);
19379
- const currentCycle = versionMatch ? parseInt(versionMatch[1], 10) : 0;
19380
- if (currentCycle > 0) {
19381
- await adapter2.createCycle({
19382
- id: `cycle-${currentCycle}`,
19383
- number: currentCycle,
19384
- status: "complete",
19385
- startDate: (/* @__PURE__ */ new Date()).toISOString(),
19386
- endDate: (/* @__PURE__ */ new Date()).toISOString(),
19387
- goals: [],
19388
- boardHealth: "",
19389
- taskIds: []
19390
- });
19391
- }
19392
- } catch (err) {
19393
- const msg = `createCycle (mark complete) failed: ${err instanceof Error ? err.message : String(err)}`;
19394
- console.error(`[release] ${msg}`);
19395
- warnings.push(msg);
19396
- }
19397
- }
19398
- const checkout = checkoutBranch(config2.projectRoot, branch);
19399
- if (!checkout.success) {
19400
- throw new Error(checkout.message);
19401
- }
19402
- if (hasRemote(config2.projectRoot)) {
19403
- const pull = gitPull(config2.projectRoot);
19404
- if (!pull.success) {
19405
- warnings.push(`git pull failed: ${pull.message}. Run manually.`);
19406
- }
19407
- }
19408
- if (tagExists(config2.projectRoot, version)) {
19409
- throw new Error(`tag "${version}" already exists. Use a different version.`);
19410
- }
19411
- const latestTag = getLatestTag(config2.projectRoot);
19412
- let changelogContent;
19413
- if (!latestTag) {
19414
- changelogContent = INITIAL_RELEASE_NOTES.replace("v0.1.0-alpha", version);
19415
- } else {
19416
- const commits = getCommitsSinceTag(config2.projectRoot, latestTag);
19417
- changelogContent = generateChangelog(version, commits);
19418
- }
19419
- const changelogPath = join6(config2.projectRoot, "CHANGELOG.md");
19420
- await writeFile3(changelogPath, changelogContent, "utf-8");
19421
- const commitResult = stageAllAndCommit(config2.projectRoot, `release: ${version}`);
19422
- const commitNote = commitResult.committed ? `Committed CHANGELOG.md.` : `CHANGELOG.md: ${commitResult.message}`;
19423
- const tagResult = createTag(config2.projectRoot, version, `Release ${version}`);
19424
- if (!tagResult.success) {
19425
- throw new Error(tagResult.message);
19426
- }
19427
- const pushNotes = [];
19428
- if (hasRemote(config2.projectRoot)) {
19429
- const branchPush = gitPush(config2.projectRoot, branch);
19430
- pushNotes.push(branchPush.success ? `Pushed '${branch}' to origin.` : `Push branch failed: ${branchPush.message}`);
19431
- if (!branchPush.success) warnings.push(branchPush.message);
19432
- const tagPush = gitPush(config2.projectRoot, version);
19433
- pushNotes.push(tagPush.success ? `Pushed tag '${version}' to origin.` : `Push tag failed: ${tagPush.message}`);
19434
- if (!tagPush.success) warnings.push(tagPush.message);
19435
- } else {
19436
- pushNotes.push("Push: skipped (no remote).");
19755
+ pushNotes.push("Push: skipped (no remote).");
19437
19756
  }
19438
19757
  return {
19439
19758
  version,
@@ -19449,6 +19768,7 @@ async function createRelease(config2, branch, version, adapter2) {
19449
19768
  var releaseTool = {
19450
19769
  name: "release",
19451
19770
  description: "Cut a versioned release \u2014 creates a git tag, generates CHANGELOG.md, and pushes to remote.",
19771
+ annotations: { readOnlyHint: false, destructiveHint: true },
19452
19772
  inputSchema: {
19453
19773
  type: "object",
19454
19774
  properties: {
@@ -19459,14 +19779,36 @@ var releaseTool = {
19459
19779
  version: {
19460
19780
  type: "string",
19461
19781
  description: 'The version tag to create (e.g. "v0.1.0-alpha"). Must start with "v".'
19462
- }
19463
- },
19464
- required: ["branch", "version"]
19465
- }
19782
+ },
19783
+ observations: {
19784
+ type: "array",
19785
+ description: "Optional dogfood observations from this cycle to persist to the DB. Each entry records friction, methodology signals, or commercial insights.",
19786
+ items: {
19787
+ type: "object",
19788
+ properties: {
19789
+ content: { type: "string", description: "The observation text." },
19790
+ category: {
19791
+ type: "string",
19792
+ enum: ["friction", "methodology", "signal", "commercial"],
19793
+ description: "Observation category."
19794
+ },
19795
+ severity: {
19796
+ type: "string",
19797
+ enum: ["P0", "P1", "P2", "P3"],
19798
+ description: "Optional severity for friction/signal observations."
19799
+ }
19800
+ },
19801
+ required: ["content", "category"]
19802
+ }
19803
+ }
19804
+ },
19805
+ required: ["branch", "version"]
19806
+ }
19466
19807
  };
19467
19808
  async function handleRelease(adapter2, config2, args) {
19468
19809
  const branch = args.branch;
19469
19810
  const version = args.version;
19811
+ const rawObservations = args.observations;
19470
19812
  if (!branch || !version) {
19471
19813
  return errorResponse('both branch and version are required. Example: release branch="main" version="v0.1.0-alpha"');
19472
19814
  }
@@ -19504,6 +19846,23 @@ async function handleRelease(adapter2, config2, args) {
19504
19846
  }
19505
19847
  } catch {
19506
19848
  }
19849
+ if (rawObservations && rawObservations.length > 0 && adapter2.writeDogfoodEntries) {
19850
+ try {
19851
+ const cycleMatch = version.match(/^v0\.(\d+)\./);
19852
+ const cycleNum = cycleMatch ? parseInt(cycleMatch[1], 10) : 0;
19853
+ const entries = rawObservations.map((obs) => ({
19854
+ cycleNumber: cycleNum,
19855
+ category: obs.category,
19856
+ content: obs.content,
19857
+ sourceTool: "release",
19858
+ status: "observed"
19859
+ }));
19860
+ await adapter2.writeDogfoodEntries(entries);
19861
+ lines.push("", `Dogfood: ${entries.length} observation(s) saved to DB.`);
19862
+ } catch {
19863
+ lines.push("", "\u26A0\uFE0F Dogfood observations could not be saved to DB \u2014 log them manually in DOGFOOD_LOG.md.");
19864
+ }
19865
+ }
19507
19866
  lines.push("", `Next: cycle released! Run \`plan\` to start your next planning cycle.`);
19508
19867
  return textResponse(lines.join("\n"));
19509
19868
  } catch (err) {
@@ -19629,7 +19988,6 @@ async function submitReview(adapter2, input) {
19629
19988
  handoffRegenPrompt = await prepareHandoffRegen(task, input.comments);
19630
19989
  }
19631
19990
  const stageLabel = input.stage === "handoff-review" ? "Handoff Review" : "Build Acceptance";
19632
- const slackWarning = void 0;
19633
19991
  let phaseChanges = [];
19634
19992
  if (newStatus) {
19635
19993
  try {
@@ -19645,7 +20003,6 @@ async function submitReview(adapter2, input) {
19645
20003
  newStatus,
19646
20004
  unblockedTasks,
19647
20005
  handoffRegenerated,
19648
- slackWarning,
19649
20006
  handoffRegenPrompt,
19650
20007
  currentCycle: cycle,
19651
20008
  phaseChanges
@@ -19656,6 +20013,7 @@ async function submitReview(adapter2, input) {
19656
20013
  var reviewListTool = {
19657
20014
  name: "review_list",
19658
20015
  description: "List tasks ready for your sign-off \u2014 shows completed builds waiting for approval or feedback. Does not call the Anthropic API.",
20016
+ annotations: { readOnlyHint: true, destructiveHint: false },
19659
20017
  inputSchema: {
19660
20018
  type: "object",
19661
20019
  properties: {},
@@ -19665,6 +20023,7 @@ var reviewListTool = {
19665
20023
  var reviewSubmitTool = {
19666
20024
  name: "review_submit",
19667
20025
  description: "Record a review verdict on a completed build (build-acceptance) or task plan (handoff-review). ALWAYS ask the human for their verdict before calling \u2014 never auto-submit without human input. Accept moves the task to Done, request-changes sends it back for rework, reject discards the build. Updates task status based on the verdict. On handoff-review with suggested changes, returns a prompt to revise the BUILD HANDOFF.",
20026
+ annotations: { readOnlyHint: false, destructiveHint: false },
19668
20027
  inputSchema: {
19669
20028
  type: "object",
19670
20029
  properties: {
@@ -19694,10 +20053,6 @@ var reviewSubmitTool = {
19694
20053
  type: "string",
19695
20054
  description: "Your locally-generated BUILD HANDOFF regen output. Pass this to save a handoff that was regenerated in local mode (no API key)."
19696
20055
  },
19697
- notify: {
19698
- type: "boolean",
19699
- description: "Send Slack notification. Default true. Set false for batch middle reviews to avoid spam."
19700
- },
19701
20056
  auto_review: {
19702
20057
  type: "object",
19703
20058
  description: "Optional automated code review results to attach to this review. Run PR analysis first, then pass findings here.",
@@ -19814,7 +20169,6 @@ async function handleReviewSubmit(adapter2, config2, args) {
19814
20169
  const verdict = args.verdict;
19815
20170
  const comments = args.comments;
19816
20171
  const reviewer = args.reviewer ?? "human";
19817
- const notify = args.notify !== false;
19818
20172
  const rawAutoReview = args.auto_review;
19819
20173
  let autoReview;
19820
20174
  if (rawAutoReview?.verdict && rawAutoReview?.summary && Array.isArray(rawAutoReview?.findings)) {
@@ -19857,7 +20211,7 @@ async function handleReviewSubmit(adapter2, config2, args) {
19857
20211
  try {
19858
20212
  const result = await submitReview(
19859
20213
  adapter2,
19860
- { taskId, stage, verdict, comments, reviewer, notify, autoReview }
20214
+ { taskId, stage, verdict, comments, reviewer, autoReview }
19861
20215
  );
19862
20216
  const statusNote = result.newStatus ? ` Task status updated to **${result.newStatus}**.` : " Task status unchanged.";
19863
20217
  const unblockNote = result.unblockedTasks.length > 0 ? `
@@ -19890,15 +20244,42 @@ ${result.handoffRegenPrompt.userMessage}
19890
20244
  mergeNote = "\n\n" + mergeLines.map((l) => `> ${l}`).join("\n");
19891
20245
  }
19892
20246
  }
19893
- const slackNote = result.slackWarning ? `
19894
-
19895
- ${result.slackWarning}` : "";
19896
20247
  let autoReleaseNote = "";
20248
+ let batchSummaryNote = "";
19897
20249
  if (stage === "build-acceptance" && verdict === "accept" && result.newStatus === "Done" && result.currentCycle > 0) {
19898
20250
  try {
19899
20251
  const allTasks = await adapter2.queryBoard();
19900
20252
  const cycleTasks = allTasks.filter((t) => t.cycle === result.currentCycle);
19901
20253
  if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done")) {
20254
+ try {
20255
+ const allReviews = await adapter2.getRecentReviews(200);
20256
+ const cycleReviews = allReviews.filter(
20257
+ (r) => r.cycle === result.currentCycle && r.stage === "build-acceptance"
20258
+ );
20259
+ const reviewsWithAutoReview = cycleReviews.filter((r) => r.autoReview);
20260
+ if (reviewsWithAutoReview.length > 0) {
20261
+ const verdictCounts = { pass: 0, warn: 0, fail: 0 };
20262
+ const findingsBySeverity = { error: 0, warning: 0, info: 0 };
20263
+ for (const r of reviewsWithAutoReview) {
20264
+ if (r.autoReview) {
20265
+ verdictCounts[r.autoReview.verdict] = (verdictCounts[r.autoReview.verdict] ?? 0) + 1;
20266
+ for (const f of r.autoReview.findings) {
20267
+ findingsBySeverity[f.severity] = (findingsBySeverity[f.severity] ?? 0) + 1;
20268
+ }
20269
+ }
20270
+ }
20271
+ const totalFindings = findingsBySeverity.error + findingsBySeverity.warning + findingsBySeverity.info;
20272
+ batchSummaryNote = `
20273
+
20274
+ ---
20275
+
20276
+ **Cycle ${result.currentCycle} Auto-Review Summary** (${reviewsWithAutoReview.length}/${cycleReviews.length} reviews had auto-review)
20277
+
20278
+ - Verdicts: ${verdictCounts.pass} pass, ${verdictCounts.warn} warn, ${verdictCounts.fail} fail
20279
+ ` + (totalFindings > 0 ? `- Findings: ${findingsBySeverity.error} error${findingsBySeverity.error !== 1 ? "s" : ""}, ${findingsBySeverity.warning} warning${findingsBySeverity.warning !== 1 ? "s" : ""}, ${findingsBySeverity.info} info` : "- No findings logged");
20280
+ }
20281
+ } catch {
20282
+ }
19902
20283
  const version = `v0.${result.currentCycle}.0`;
19903
20284
  const baseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
19904
20285
  const releaseResult = await createRelease(config2, baseBranch, version, adapter2);
@@ -19943,7 +20324,7 @@ Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
19943
20324
  - **Verdict:** ${result.verdict}
19944
20325
  - **Comments:** ${result.comments}
19945
20326
 
19946
- ${statusNote}${autoReviewNote}${unblockNote}${regenNote}${mergeNote}${slackNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
20327
+ ${statusNote}${autoReviewNote}${unblockNote}${regenNote}${mergeNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
19947
20328
  );
19948
20329
  } catch (err) {
19949
20330
  return errorResponse(err instanceof Error ? err.message : String(err));
@@ -19957,6 +20338,7 @@ import path4 from "path";
19957
20338
  var initTool = {
19958
20339
  name: "init",
19959
20340
  description: "Initialise PAPI in the current project. Generates a .mcp.json config file with pg adapter settings pointed at the hosted Supabase instance. Run once per project to get started.",
20341
+ annotations: { readOnlyHint: false, destructiveHint: false },
19960
20342
  inputSchema: {
19961
20343
  type: "object",
19962
20344
  properties: {
@@ -19966,148 +20348,607 @@ var initTool = {
19966
20348
  },
19967
20349
  force: {
19968
20350
  type: "boolean",
19969
- description: "Overwrite existing .mcp.json if it already exists. Default: false."
20351
+ description: "Overwrite existing .mcp.json if it already exists. Default: false."
20352
+ }
20353
+ },
20354
+ required: []
20355
+ }
20356
+ };
20357
+ async function ensureGitignoreEntry(projectRoot, entry) {
20358
+ const gitignorePath = path4.join(projectRoot, ".gitignore");
20359
+ let content = "";
20360
+ try {
20361
+ content = await readFile4(gitignorePath, "utf-8");
20362
+ } catch {
20363
+ }
20364
+ const lines = content.split("\n");
20365
+ if (lines.some((line) => line.trim() === entry)) {
20366
+ return;
20367
+ }
20368
+ const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
20369
+ await writeFile4(gitignorePath, content + separator + entry + "\n", "utf-8");
20370
+ }
20371
+ async function handleInit(config2, args) {
20372
+ const projectRoot = config2.projectRoot;
20373
+ const mcpJsonPath = path4.join(projectRoot, ".mcp.json");
20374
+ const force = args.force === true;
20375
+ const projectName = args.project_name?.trim() || path4.basename(projectRoot);
20376
+ const usingCwdDefault = !process.env.PAPI_PROJECT_DIR && process.argv.indexOf("--project") === -1;
20377
+ let existingConfig = null;
20378
+ try {
20379
+ await access3(mcpJsonPath);
20380
+ existingConfig = await readFile4(mcpJsonPath, "utf-8");
20381
+ } catch {
20382
+ }
20383
+ if (existingConfig && !force) {
20384
+ try {
20385
+ const parsed = JSON.parse(existingConfig);
20386
+ if (parsed.mcpServers?.papi) {
20387
+ return errorResponse(
20388
+ `.mcp.json already exists with a PAPI server configured.
20389
+ Use \`init\` with \`force: true\` to overwrite, or edit the file manually.
20390
+ Path: ${mcpJsonPath}`
20391
+ );
20392
+ }
20393
+ } catch {
20394
+ if (!force) {
20395
+ return errorResponse(
20396
+ `.mcp.json exists but contains invalid JSON.
20397
+ Use \`init\` with \`force: true\` to overwrite.
20398
+ Path: ${mcpJsonPath}`
20399
+ );
20400
+ }
20401
+ }
20402
+ }
20403
+ const existingApiKey = process.env.PAPI_DATA_API_KEY;
20404
+ const existingProjectId = process.env.PAPI_PROJECT_ID;
20405
+ const isProxyUser = Boolean(existingApiKey) || config2.adapterType === "proxy";
20406
+ const isDatabaseUser = Boolean(process.env.DATABASE_URL) || config2.adapterType === "pg";
20407
+ if (isProxyUser && existingApiKey && existingProjectId) {
20408
+ const mcpConfig = {
20409
+ mcpServers: {
20410
+ papi: {
20411
+ command: "npx",
20412
+ args: ["-y", "@papi-ai/server"],
20413
+ env: {
20414
+ PAPI_PROJECT_ID: existingProjectId,
20415
+ PAPI_DATA_API_KEY: existingApiKey
20416
+ }
20417
+ }
20418
+ }
20419
+ };
20420
+ await writeFile4(mcpJsonPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
20421
+ await ensureGitignoreEntry(projectRoot, ".mcp.json");
20422
+ return textResponse(
20423
+ `# PAPI Initialised \u2014 ${projectName}
20424
+
20425
+ **Config:** \`${mcpJsonPath}\`
20426
+
20427
+ Your existing API key and project ID have been saved to .mcp.json.
20428
+
20429
+ ## Next Steps
20430
+
20431
+ 1. **Restart your MCP client** to pick up the new config.
20432
+ 2. **Run \`setup\`** \u2014 this scaffolds your project with a Product Brief and CLAUDE.md.
20433
+ `
20434
+ );
20435
+ }
20436
+ if (isDatabaseUser) {
20437
+ const projectId = randomUUID14();
20438
+ const mcpConfig = {
20439
+ mcpServers: {
20440
+ papi: {
20441
+ command: "npx",
20442
+ args: ["-y", "@papi-ai/server"],
20443
+ env: {
20444
+ PAPI_PROJECT_DIR: projectRoot,
20445
+ PAPI_ADAPTER: "pg",
20446
+ DATABASE_URL: process.env.DATABASE_URL || "<YOUR_DATABASE_URL>",
20447
+ PAPI_PROJECT_ID: projectId
20448
+ }
20449
+ }
20450
+ }
20451
+ };
20452
+ await writeFile4(mcpJsonPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
20453
+ await ensureGitignoreEntry(projectRoot, ".mcp.json");
20454
+ const output2 = [
20455
+ `# PAPI Initialised \u2014 ${projectName}`,
20456
+ "",
20457
+ `**Project ID:** \`${projectId}\``,
20458
+ `**Config:** \`${mcpJsonPath}\``,
20459
+ "",
20460
+ "## Next Steps",
20461
+ "",
20462
+ ...process.env.DATABASE_URL ? ["1. **Restart your MCP client** to pick up the new config."] : ["1. **Set your DATABASE_URL** \u2014 replace `<YOUR_DATABASE_URL>` in `.mcp.json` with your Supabase connection string."],
20463
+ "2. **Run `setup`** \u2014 this scaffolds your project with a Product Brief, Active Decisions, and CLAUDE.md."
20464
+ ].join("\n");
20465
+ return textResponse(output2);
20466
+ }
20467
+ const output = [
20468
+ `# PAPI \u2014 Account Required`,
20469
+ "",
20470
+ `PAPI needs an account to store your project data.`,
20471
+ "",
20472
+ "## Get Started in 3 Steps",
20473
+ "",
20474
+ "1. **Sign up** at https://getpapi.ai/login",
20475
+ "2. **Complete the onboarding wizard** \u2014 it generates your `.mcp.json` config with your API key and project ID",
20476
+ "3. **Download the config**, place it in your project root, and restart your MCP client",
20477
+ "",
20478
+ "The onboarding wizard generates everything you need \u2014 no manual configuration required.",
20479
+ "",
20480
+ `> Already have an account? Make sure both \`PAPI_PROJECT_ID\` and \`PAPI_DATA_API_KEY\` are set in your .mcp.json.`
20481
+ ].join("\n");
20482
+ return textResponse(output);
20483
+ }
20484
+
20485
+ // src/services/health.ts
20486
+ function computeHealthScore(cycleNumber, snapshots, activeTasks, decisionUsage) {
20487
+ if (cycleNumber < 3) return null;
20488
+ const scores = [];
20489
+ const recentSnaps = snapshots.slice(-3);
20490
+ const baselineSnaps = snapshots.slice(-10);
20491
+ if (recentSnaps.length > 0 && baselineSnaps.length > 0) {
20492
+ const avg = (snaps) => snaps.reduce((s, sn) => s + (sn.velocity[0]?.effortPoints ?? 0), 0) / snaps.length;
20493
+ const recentAvg = avg(recentSnaps);
20494
+ const baselineAvg = avg(baselineSnaps);
20495
+ const velocityScore = baselineAvg > 0 ? Math.min(100, Math.round(recentAvg / baselineAvg * 100)) : 50;
20496
+ scores.push({ name: "Velocity", score: velocityScore, weight: 0.25 });
20497
+ } else {
20498
+ scores.push({ name: "Velocity", score: 50, weight: 0.25 });
20499
+ }
20500
+ if (recentSnaps.length > 0) {
20501
+ const avgMatchRate = recentSnaps.reduce((s, sn) => s + (sn.accuracy[0]?.matchRate ?? 0), 0) / recentSnaps.length;
20502
+ scores.push({ name: "Estimation accuracy", score: Math.round(avgMatchRate), weight: 0.25 });
20503
+ } else {
20504
+ scores.push({ name: "Estimation accuracy", score: 50, weight: 0.25 });
20505
+ }
20506
+ const inReviewCount = activeTasks.filter((t) => t.status === "In Review").length;
20507
+ const reviewScore = inReviewCount === 0 ? 100 : inReviewCount <= 2 ? 60 : 20;
20508
+ scores.push({ name: "Review throughput", score: reviewScore, weight: 0.2 });
20509
+ const backlogTasks = activeTasks.filter((t) => t.status === "Backlog");
20510
+ if (backlogTasks.length > 0) {
20511
+ const criticalCount = backlogTasks.filter(
20512
+ (t) => t.priority === "P0 Critical" || t.priority === "P1 High"
20513
+ ).length;
20514
+ const criticalRatio = criticalCount / backlogTasks.length;
20515
+ const backlogScore = criticalRatio > 0.5 ? 40 : criticalRatio > 0.3 ? 70 : 90;
20516
+ scores.push({ name: "Backlog health", score: backlogScore, weight: 0.15 });
20517
+ } else {
20518
+ scores.push({ name: "Backlog health", score: 80, weight: 0.15 });
20519
+ }
20520
+ if (decisionUsage.length > 0) {
20521
+ const staleCount = decisionUsage.filter((u) => u.cyclesSinceLastReference >= 10).length;
20522
+ const freshRatio = (decisionUsage.length - staleCount) / decisionUsage.length;
20523
+ scores.push({ name: "AD freshness", score: Math.round(freshRatio * 100), weight: 0.15 });
20524
+ } else {
20525
+ scores.push({ name: "AD freshness", score: 70, weight: 0.15 });
20526
+ }
20527
+ const totalScore = Math.round(scores.reduce((sum, s) => sum + s.score * s.weight, 0));
20528
+ const status = totalScore >= 70 ? "GREEN" : totalScore >= 50 ? "AMBER" : "RED";
20529
+ const worst = scores.reduce((min, s) => s.score < min.score ? s : min, scores[0]);
20530
+ const reason = status === "GREEN" ? "All components healthy" : `${worst.name} below target (${worst.score}/100)`;
20531
+ return { score: totalScore, status, reason };
20532
+ }
20533
+ function countByStatus(tasks) {
20534
+ const counts = /* @__PURE__ */ new Map();
20535
+ for (const task of tasks) {
20536
+ counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
20537
+ }
20538
+ return counts;
20539
+ }
20540
+ async function getHealthSummary(adapter2) {
20541
+ const health = await adapter2.getCycleHealth();
20542
+ const activeTasks = await adapter2.queryBoard({
20543
+ status: ["Backlog", "In Cycle", "Ready", "In Progress", "In Review", "Blocked"]
20544
+ });
20545
+ const logEntries = await adapter2.getCycleLog(3);
20546
+ const cycleNumber = health.totalCycles;
20547
+ const cyclesSinceReview = health.cyclesSinceLastStrategyReview;
20548
+ const reviewDue = health.strategyReviewDue;
20549
+ const reviewGateBlocking = cyclesSinceReview >= 5;
20550
+ const reviewWarning = reviewGateBlocking ? `\u26A0\uFE0F GATE \u2014 ${cyclesSinceReview} cycles since last Strategy Review. \`plan\` is blocked until \`strategy_review\` runs (or \`force: true\`).` : `\u2713 On track \u2014 ${cyclesSinceReview} cycle(s) since last review. Next due: ${reviewDue}`;
20551
+ const deferredCount = activeTasks.filter((t) => t.status === "Deferred").length;
20552
+ const nonDeferredTasks = activeTasks.filter((t) => t.status !== "Deferred");
20553
+ const statusCounts = countByStatus(nonDeferredTasks);
20554
+ let boardSummary;
20555
+ if (nonDeferredTasks.length === 0 && deferredCount === 0) {
20556
+ boardSummary = "0 tasks \u2014 board may need reloading";
20557
+ } else {
20558
+ const parts = [];
20559
+ for (const [status, count] of statusCounts) {
20560
+ parts.push(`${count} ${status}`);
20561
+ }
20562
+ boardSummary = `${nonDeferredTasks.length} active tasks \u2014 ${parts.join(", ")}`;
20563
+ if (deferredCount > 0) {
20564
+ boardSummary += ` + ${deferredCount} deferred`;
20565
+ }
20566
+ }
20567
+ const inProgressTasks = activeTasks.filter((t) => t.status === "In Progress");
20568
+ const staleTasks = inProgressTasks.length > 0 ? `${inProgressTasks.length} task(s) In Progress: ${inProgressTasks.map((t) => t.id).join(", ")}` : "No tasks currently In Progress";
20569
+ const inReviewTasks = activeTasks.filter((t) => t.status === "In Review");
20570
+ const inReviewSummary = inReviewTasks.length > 0 ? `${inReviewTasks.length} task(s) ready for sign-off: ${inReviewTasks.map((t) => t.id).join(", ")}` : "No tasks waiting for sign-off";
20571
+ let carryForward = "None found";
20572
+ if (logEntries.length > 0) {
20573
+ const latest = logEntries[0];
20574
+ if (latest.carryForward) {
20575
+ carryForward = latest.carryForward;
20576
+ } else {
20577
+ carryForward = `No carry-forward in Cycle ${latest.cycleNumber}`;
20578
+ }
20579
+ }
20580
+ let recommendedMode;
20581
+ const reasons = [];
20582
+ if (reviewGateBlocking) {
20583
+ reasons.push(`Strategy Review overdue (${cyclesSinceReview} cycles)`);
20584
+ }
20585
+ if (activeTasks.length === 0) {
20586
+ reasons.push("Board is empty \u2014 needs task reload/triage");
20587
+ }
20588
+ const unbuiltCycleTasks = activeTasks.filter(
20589
+ (t) => t.cycle === cycleNumber && (t.status === "In Cycle" || t.status === "Ready")
20590
+ );
20591
+ const inProgressCycleTasks = activeTasks.filter(
20592
+ (t) => t.cycle === cycleNumber && t.status === "In Progress"
20593
+ );
20594
+ const inReviewCycleTasks = activeTasks.filter(
20595
+ (t) => t.cycle === cycleNumber && t.status === "In Review"
20596
+ );
20597
+ if (reasons.length > 0) {
20598
+ recommendedMode = `**Full** \u2014 ${reasons.join("; ")}`;
20599
+ } else if (unbuiltCycleTasks.length > 0) {
20600
+ recommendedMode = `**Build** \u2014 ${unbuiltCycleTasks.length} cycle task(s) not yet started`;
20601
+ } else if (inProgressCycleTasks.length > 0) {
20602
+ recommendedMode = `**Build** \u2014 ${inProgressCycleTasks.length} task(s) in progress`;
20603
+ } else if (inReviewCycleTasks.length > 0) {
20604
+ recommendedMode = `**Review** \u2014 ${inReviewCycleTasks.length} task(s) awaiting review`;
20605
+ } else {
20606
+ recommendedMode = `**Full** \u2014 ready for next cycle`;
20607
+ }
20608
+ let metricsSection;
20609
+ let derivedMetricsSection = "";
20610
+ let snapshots = [];
20611
+ try {
20612
+ try {
20613
+ const reports = await adapter2.getRecentBuildReports(50);
20614
+ snapshots = computeSnapshotsFromBuildReports(reports);
20615
+ } catch {
20616
+ }
20617
+ metricsSection = formatCycleMetrics(snapshots);
20618
+ derivedMetricsSection = formatDerivedMetrics(snapshots, activeTasks);
20619
+ } catch (_err) {
20620
+ metricsSection = "Could not read methodology metrics.";
20621
+ }
20622
+ try {
20623
+ const recentReports = await adapter2.getRecentBuildReports(50);
20624
+ if (recentReports.length > 0) {
20625
+ const taskCounts = /* @__PURE__ */ new Map();
20626
+ for (const r of recentReports) {
20627
+ taskCounts.set(r.taskId, (taskCounts.get(r.taskId) ?? 0) + 1);
20628
+ }
20629
+ const iterCounts = [...taskCounts.values()];
20630
+ const avgIter = iterCounts.reduce((s, c) => s + c, 0) / iterCounts.length;
20631
+ const multiIterTasks = iterCounts.filter((c) => c > 1).length;
20632
+ if (avgIter > 1 || multiIterTasks > 0) {
20633
+ derivedMetricsSection += `
20634
+
20635
+ **Rework**
20636
+ - Average iterations: ${avgIter.toFixed(1)} (${multiIterTasks} task${multiIterTasks !== 1 ? "s" : ""} with pushbacks)`;
20637
+ }
20638
+ }
20639
+ } catch {
20640
+ }
20641
+ const costSection = "Disabled \u2014 local MCP, no API costs.";
20642
+ let decisionUsageSection = "";
20643
+ let decisionUsageEntries = [];
20644
+ try {
20645
+ const usage = await adapter2.getDecisionUsage(cycleNumber);
20646
+ decisionUsageEntries = usage;
20647
+ if (usage.length > 0) {
20648
+ const stale = usage.filter((u) => u.cyclesSinceLastReference >= 5);
20649
+ if (stale.length > 0) {
20650
+ const lines = stale.map(
20651
+ (u) => `- ${u.decisionId}: last referenced Cycle ${u.lastReferencedCycle} (${u.cyclesSinceLastReference} cycles ago)`
20652
+ );
20653
+ decisionUsageSection = `**Stale ADs (5+ cycles unreferenced):**
20654
+ ${lines.join("\n")}`;
20655
+ } else {
20656
+ decisionUsageSection = `All ${usage.length} tracked ADs referenced within last 5 cycles.`;
20657
+ }
20658
+ }
20659
+ } catch {
20660
+ }
20661
+ let decisionLifecycleSection = "";
20662
+ try {
20663
+ const decisions = await adapter2.getActiveDecisions();
20664
+ const lifecycleSummary = formatDecisionLifecycleSummary(decisions);
20665
+ if (lifecycleSummary) {
20666
+ decisionLifecycleSection = `**Lifecycle:** ${lifecycleSummary}`;
20667
+ }
20668
+ } catch {
20669
+ }
20670
+ const decisionScoresSection = "";
20671
+ let contextUtilisationSection = "";
20672
+ try {
20673
+ const utilData = await adapter2.getContextUtilisation?.();
20674
+ if (utilData && utilData.length > 0) {
20675
+ const lines = utilData.filter((u) => u.cycleNumber === cycleNumber).map((u) => `- ${u.tool}: ${(u.avgUtilisation * 100).toFixed(0)}% utilisation (${(u.avgContextBytes / 1024).toFixed(1)}KB avg context)`);
20676
+ if (lines.length > 0) {
20677
+ contextUtilisationSection = `**Current cycle:**
20678
+ ${lines.join("\n")}`;
20679
+ }
20680
+ }
20681
+ } catch {
20682
+ }
20683
+ let northStarSection = "";
20684
+ try {
20685
+ const staleness = await adapter2.getNorthStarStaleness?.();
20686
+ if (staleness) {
20687
+ const cycleGap = cycleNumber - staleness.setCycle;
20688
+ const daysSinceSet = Math.floor((Date.now() - new Date(staleness.setAt).getTime()) / (1e3 * 60 * 60 * 24));
20689
+ northStarSection = `\u2713 North Star set Cycle ${staleness.setCycle} (${cycleGap} cycles, ${daysSinceSet} days ago)`;
20690
+ } else {
20691
+ const setAtCycle = await adapter2.getNorthStarSetCycle?.();
20692
+ if (setAtCycle != null) {
20693
+ northStarSection = `\u2713 North Star set Cycle ${setAtCycle}`;
20694
+ } else if (adapter2.getCurrentNorthStar) {
20695
+ const ns = await adapter2.getCurrentNorthStar();
20696
+ northStarSection = ns ? "" : "\u26A0\uFE0F No North Star set \u2014 consider defining one";
20697
+ }
20698
+ }
20699
+ } catch {
20700
+ }
20701
+ const healthResult = computeHealthScore(cycleNumber, snapshots, activeTasks, decisionUsageEntries);
20702
+ return {
20703
+ cycleNumber,
20704
+ latestCycleStatus: health.latestCycleStatus,
20705
+ connectionStatus: getConnectionStatus(),
20706
+ reviewWarning,
20707
+ boardSummary,
20708
+ staleTasks,
20709
+ inReviewSummary,
20710
+ carryForward,
20711
+ recommendedMode,
20712
+ metricsSection,
20713
+ derivedMetricsSection,
20714
+ costSection,
20715
+ decisionUsageSection,
20716
+ decisionLifecycleSection,
20717
+ decisionScoresSection,
20718
+ contextUtilisationSection,
20719
+ northStarSection,
20720
+ healthScore: healthResult?.score ?? null,
20721
+ healthStatus: healthResult?.status ?? null,
20722
+ healthReason: healthResult?.reason ?? null
20723
+ };
20724
+ }
20725
+
20726
+ // src/tools/orient.ts
20727
+ init_git();
20728
+
20729
+ // src/tools/doc-registry.ts
20730
+ import { readdirSync as readdirSync4, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
20731
+ import { join as join8, relative } from "path";
20732
+ import { homedir as homedir2 } from "os";
20733
+ var docRegisterTool = {
20734
+ name: "doc_register",
20735
+ description: "Register a document in the doc registry. Called after finalising a research/planning doc, or when build_execute detects unregistered docs. Stores metadata and structured summary \u2014 not full content.",
20736
+ annotations: { readOnlyHint: false, destructiveHint: false },
20737
+ inputSchema: {
20738
+ type: "object",
20739
+ properties: {
20740
+ path: { type: "string", description: 'Relative path from project root (e.g. "docs/research/funding-landscape.md").' },
20741
+ title: { type: "string", description: "Document title." },
20742
+ type: { type: "string", enum: ["research", "audit", "spec", "guide", "architecture", "positioning", "framework", "reference"], description: "Document type." },
20743
+ status: { type: "string", enum: ["active", "draft", "superseded", "actioned", "legacy", "archived"], description: 'Document status. Defaults to "active".' },
20744
+ summary: { type: "string", description: 'Structured 2-4 sentence summary. Format: "Conclusions: ... Open questions: ... Unactioned: ..."' },
20745
+ tags: { type: "array", items: { type: "string" }, description: "Tags from project vocabulary." },
20746
+ cycle: { type: "number", description: "Current cycle number." },
20747
+ actions: {
20748
+ type: "array",
20749
+ items: {
20750
+ type: "object",
20751
+ properties: {
20752
+ description: { type: "string" },
20753
+ status: { type: "string", enum: ["pending", "resolved"] },
20754
+ linkedTaskId: { type: "string" }
20755
+ },
20756
+ required: ["description", "status"]
20757
+ },
20758
+ description: "Actionable findings from the document."
20759
+ },
20760
+ superseded_by_path: { type: "string", description: "Path of the doc that supersedes this one (sets status to superseded)." }
20761
+ },
20762
+ required: ["path", "title", "type", "summary", "cycle"]
20763
+ }
20764
+ };
20765
+ var docSearchTool = {
20766
+ name: "doc_search",
20767
+ description: "Search the doc registry for documents by type, tags, keyword, or pending actions. Returns summaries, not full content. Use for context gathering in plan, strategy review, and idea dedup.",
20768
+ annotations: { readOnlyHint: true, destructiveHint: false },
20769
+ inputSchema: {
20770
+ type: "object",
20771
+ properties: {
20772
+ type: { type: "string", description: 'Filter by doc type (e.g. "research", "architecture").' },
20773
+ status: { type: "string", description: 'Filter by status. Defaults to "active".' },
20774
+ tags: { type: "array", items: { type: "string" }, description: "Filter by tags (OR match)." },
20775
+ keyword: { type: "string", description: "Search title and summary text." },
20776
+ has_pending_actions: { type: "boolean", description: "Only docs with unresolved action items." },
20777
+ since_cycle: { type: "number", description: "Docs updated since this cycle." },
20778
+ limit: { type: "number", description: "Max results (default: 10)." }
20779
+ },
20780
+ required: []
20781
+ }
20782
+ };
20783
+ var docScanTool = {
20784
+ name: "doc_scan",
20785
+ description: "Scan docs/ and plans directories for unregistered .md files. Returns a list of files not yet in the doc registry. Use this to find docs that need registration.",
20786
+ annotations: { readOnlyHint: true, destructiveHint: false },
20787
+ inputSchema: {
20788
+ type: "object",
20789
+ properties: {
20790
+ include_plans: {
20791
+ type: "boolean",
20792
+ description: "Also scan ~/.claude/plans/ for plan files (default: false)."
19970
20793
  }
19971
20794
  },
19972
20795
  required: []
19973
20796
  }
19974
20797
  };
19975
- async function ensureGitignoreEntry(projectRoot, entry) {
19976
- const gitignorePath = path4.join(projectRoot, ".gitignore");
19977
- let content = "";
20798
+ async function handleDocRegister(adapter2, args) {
20799
+ if (!adapter2.registerDoc) {
20800
+ return errorResponse("Doc registry not available \u2014 requires pg adapter.");
20801
+ }
20802
+ const path5 = args.path;
20803
+ const title = args.title;
20804
+ const type = args.type;
20805
+ const status = args.status ?? "active";
20806
+ const summary = args.summary;
20807
+ const tags = args.tags ?? [];
20808
+ const cycle = args.cycle;
20809
+ const actions = args.actions;
20810
+ const supersededByPath = args.superseded_by_path;
20811
+ if (!path5 || !title || !type || !summary || !cycle) {
20812
+ return errorResponse("Required fields: path, title, type, summary, cycle.");
20813
+ }
20814
+ let supersededBy;
20815
+ if (supersededByPath) {
20816
+ const existing = await adapter2.getDoc?.(supersededByPath);
20817
+ if (existing) {
20818
+ supersededBy = existing.id;
20819
+ await adapter2.updateDocStatus?.(existing.id, "superseded", void 0);
20820
+ }
20821
+ }
20822
+ const entry = await adapter2.registerDoc({
20823
+ title,
20824
+ type,
20825
+ path: path5,
20826
+ status: supersededByPath ? "superseded" : status,
20827
+ summary,
20828
+ tags,
20829
+ cycleCreated: cycle,
20830
+ cycleUpdated: cycle,
20831
+ supersededBy,
20832
+ actions
20833
+ });
20834
+ return textResponse(
20835
+ `**Registered:** ${entry.title}
20836
+ - **Path:** ${entry.path}
20837
+ - **Type:** ${entry.type} | **Status:** ${entry.status}
20838
+ - **Tags:** ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}
20839
+ - **Actions:** ${actions?.length ?? 0} items
20840
+ - **ID:** ${entry.id}`
20841
+ );
20842
+ }
20843
+ async function handleDocSearch(adapter2, args) {
20844
+ if (!adapter2.searchDocs) {
20845
+ return errorResponse("Doc registry not available \u2014 requires pg adapter.");
20846
+ }
20847
+ const input = {
20848
+ type: args.type,
20849
+ status: args.status,
20850
+ tags: args.tags,
20851
+ keyword: args.keyword,
20852
+ hasPendingActions: args.has_pending_actions,
20853
+ sinceCycle: args.since_cycle,
20854
+ limit: args.limit
20855
+ };
20856
+ const docs = await adapter2.searchDocs(input);
20857
+ if (docs.length === 0) {
20858
+ return textResponse("No documents found matching the search criteria.");
20859
+ }
20860
+ const lines = docs.map((d) => {
20861
+ const actionCount = d.actions?.filter((a) => a.status === "pending").length ?? 0;
20862
+ const actionNote = actionCount > 0 ? ` | ${actionCount} pending action(s)` : "";
20863
+ return `### ${d.title}
20864
+ **Type:** ${d.type} | **Status:** ${d.status} | **Cycle:** ${d.cycleCreated}${d.cycleUpdated ? `\u2192${d.cycleUpdated}` : ""}${actionNote}
20865
+ **Path:** ${d.path}
20866
+ **Tags:** ${d.tags.length > 0 ? d.tags.join(", ") : "none"}
20867
+ ${d.summary}
20868
+ `;
20869
+ });
20870
+ return textResponse(`**${docs.length} document(s) found:**
20871
+
20872
+ ${lines.join("\n---\n\n")}`);
20873
+ }
20874
+ function scanMdFiles(dir, rootDir) {
20875
+ if (!existsSync5(dir)) return [];
20876
+ const files = [];
19978
20877
  try {
19979
- content = await readFile4(gitignorePath, "utf-8");
20878
+ const entries = readdirSync4(dir, { withFileTypes: true });
20879
+ for (const entry of entries) {
20880
+ const full = join8(dir, entry.name);
20881
+ if (entry.isDirectory()) {
20882
+ files.push(...scanMdFiles(full, rootDir));
20883
+ } else if (entry.name.endsWith(".md")) {
20884
+ files.push(relative(rootDir, full));
20885
+ }
20886
+ }
19980
20887
  } catch {
19981
20888
  }
19982
- const lines = content.split("\n");
19983
- if (lines.some((line) => line.trim() === entry)) {
19984
- return;
19985
- }
19986
- const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
19987
- await writeFile4(gitignorePath, content + separator + entry + "\n", "utf-8");
20889
+ return files;
19988
20890
  }
19989
- async function handleInit(config2, args) {
19990
- const projectRoot = config2.projectRoot;
19991
- const mcpJsonPath = path4.join(projectRoot, ".mcp.json");
19992
- const force = args.force === true;
19993
- const projectName = args.project_name?.trim() || path4.basename(projectRoot);
19994
- const usingCwdDefault = !process.env.PAPI_PROJECT_DIR && process.argv.indexOf("--project") === -1;
19995
- let existingConfig = null;
20891
+ function extractTitle(filePath) {
19996
20892
  try {
19997
- await access3(mcpJsonPath);
19998
- existingConfig = await readFile4(mcpJsonPath, "utf-8");
20893
+ const content = readFileSync2(filePath, "utf-8").slice(0, 1e3);
20894
+ const fmMatch = content.match(/^---[\s\S]*?title:\s*(.+?)$/m);
20895
+ if (fmMatch) return fmMatch[1].trim().replace(/^["']|["']$/g, "");
20896
+ const headingMatch = content.match(/^#+\s+(.+)$/m);
20897
+ if (headingMatch) return headingMatch[1].trim();
19999
20898
  } catch {
20000
20899
  }
20001
- if (existingConfig && !force) {
20002
- try {
20003
- const parsed = JSON.parse(existingConfig);
20004
- if (parsed.mcpServers?.papi) {
20005
- return errorResponse(
20006
- `.mcp.json already exists with a PAPI server configured.
20007
- Use \`init\` with \`force: true\` to overwrite, or edit the file manually.
20008
- Path: ${mcpJsonPath}`
20009
- );
20010
- }
20011
- } catch {
20012
- if (!force) {
20013
- return errorResponse(
20014
- `.mcp.json exists but contains invalid JSON.
20015
- Use \`init\` with \`force: true\` to overwrite.
20016
- Path: ${mcpJsonPath}`
20017
- );
20018
- }
20900
+ return void 0;
20901
+ }
20902
+ async function handleDocScan(adapter2, config2, args) {
20903
+ if (!adapter2.searchDocs) {
20904
+ return errorResponse("Doc registry not available on this adapter.");
20905
+ }
20906
+ const includePlans = args.include_plans ?? false;
20907
+ const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
20908
+ const registeredPaths = new Set(registered.map((d) => d.path));
20909
+ const docsDir = join8(config2.projectRoot, "docs");
20910
+ const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
20911
+ const unregisteredDocs = docsFiles.filter((f) => !registeredPaths.has(f));
20912
+ let unregisteredPlans = [];
20913
+ if (includePlans) {
20914
+ const plansDir = join8(homedir2(), ".claude", "plans");
20915
+ if (existsSync5(plansDir)) {
20916
+ const planFiles = scanMdFiles(plansDir, plansDir);
20917
+ unregisteredPlans = planFiles.map((f) => `plans/${f}`).filter((f) => !registeredPaths.has(f)).map((f) => ({
20918
+ path: f,
20919
+ title: extractTitle(join8(plansDir, f.replace("plans/", "")))
20920
+ }));
20019
20921
  }
20020
20922
  }
20021
- const existingApiKey = process.env.PAPI_DATA_API_KEY;
20022
- const existingProjectId = process.env.PAPI_PROJECT_ID;
20023
- const isProxyUser = Boolean(existingApiKey) || config2.adapterType === "proxy";
20024
- const isDatabaseUser = Boolean(process.env.DATABASE_URL) || config2.adapterType === "pg";
20025
- if (isProxyUser && existingApiKey && existingProjectId) {
20026
- const mcpConfig = {
20027
- mcpServers: {
20028
- papi: {
20029
- command: "npx",
20030
- args: ["-y", "@papi-ai/server"],
20031
- env: {
20032
- PAPI_PROJECT_ID: existingProjectId,
20033
- PAPI_DATA_API_KEY: existingApiKey
20034
- }
20035
- }
20036
- }
20037
- };
20038
- await writeFile4(mcpJsonPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
20039
- await ensureGitignoreEntry(projectRoot, ".mcp.json");
20040
- return textResponse(
20041
- `# PAPI Initialised \u2014 ${projectName}
20042
-
20043
- **Config:** \`${mcpJsonPath}\`
20044
-
20045
- Your existing API key and project ID have been saved to .mcp.json.
20046
-
20047
- ## Next Steps
20048
-
20049
- 1. **Restart your MCP client** to pick up the new config.
20050
- 2. **Run \`setup\`** \u2014 this scaffolds your project with a Product Brief and CLAUDE.md.
20051
- `
20052
- );
20923
+ const lines = [];
20924
+ if (unregisteredDocs.length === 0 && unregisteredPlans.length === 0) {
20925
+ return textResponse("All docs are registered. No unregistered files found.");
20053
20926
  }
20054
- if (isDatabaseUser) {
20055
- const projectId = randomUUID14();
20056
- const mcpConfig = {
20057
- mcpServers: {
20058
- papi: {
20059
- command: "npx",
20060
- args: ["-y", "@papi-ai/server"],
20061
- env: {
20062
- PAPI_PROJECT_DIR: projectRoot,
20063
- PAPI_ADAPTER: "pg",
20064
- DATABASE_URL: process.env.DATABASE_URL || "<YOUR_DATABASE_URL>",
20065
- PAPI_PROJECT_ID: projectId
20066
- }
20067
- }
20068
- }
20069
- };
20070
- await writeFile4(mcpJsonPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
20071
- await ensureGitignoreEntry(projectRoot, ".mcp.json");
20072
- const output2 = [
20073
- `# PAPI Initialised \u2014 ${projectName}`,
20074
- "",
20075
- `**Project ID:** \`${projectId}\``,
20076
- `**Config:** \`${mcpJsonPath}\``,
20077
- "",
20078
- "## Next Steps",
20079
- "",
20080
- ...process.env.DATABASE_URL ? ["1. **Restart your MCP client** to pick up the new config."] : ["1. **Set your DATABASE_URL** \u2014 replace `<YOUR_DATABASE_URL>` in `.mcp.json` with your Supabase connection string."],
20081
- "2. **Run `setup`** \u2014 this scaffolds your project with a Product Brief, Active Decisions, and CLAUDE.md."
20082
- ].join("\n");
20083
- return textResponse(output2);
20927
+ if (unregisteredDocs.length > 0) {
20928
+ lines.push(`## Unregistered Docs (${unregisteredDocs.length})`);
20929
+ for (const f of unregisteredDocs) {
20930
+ const title = extractTitle(join8(config2.projectRoot, f));
20931
+ lines.push(`- \`${f}\`${title ? ` \u2014 ${title}` : ""}`);
20932
+ }
20084
20933
  }
20085
- const output = [
20086
- `# PAPI \u2014 Account Required`,
20087
- "",
20088
- `PAPI needs an account to store your project data.`,
20089
- "",
20090
- "## Get Started in 3 Steps",
20091
- "",
20092
- "1. **Sign up** at https://getpapi.ai/login",
20093
- "2. **Complete the onboarding wizard** \u2014 it generates your `.mcp.json` config with your API key and project ID",
20094
- "3. **Download the config**, place it in your project root, and restart your MCP client",
20095
- "",
20096
- "The onboarding wizard generates everything you need \u2014 no manual configuration required.",
20097
- "",
20098
- `> Already have an account? Make sure both \`PAPI_PROJECT_ID\` and \`PAPI_DATA_API_KEY\` are set in your .mcp.json.`
20099
- ].join("\n");
20100
- return textResponse(output);
20934
+ if (unregisteredPlans.length > 0) {
20935
+ lines.push("", `## Unregistered Plans (${unregisteredPlans.length})`);
20936
+ for (const p of unregisteredPlans) {
20937
+ lines.push(`- \`${p.path}\`${p.title ? ` \u2014 ${p.title}` : ""}`);
20938
+ }
20939
+ }
20940
+ lines.push("", `Use \`doc_register\` to register these files.`);
20941
+ return textResponse(lines.join("\n"));
20101
20942
  }
20102
20943
 
20103
20944
  // src/tools/orient.ts
20104
- init_git();
20105
20945
  import { execFileSync as execFileSync3 } from "child_process";
20106
- import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync5 } from "fs";
20107
- import { join as join8 } from "path";
20946
+ import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync6 } from "fs";
20947
+ import { join as join9 } from "path";
20108
20948
  var orientTool = {
20109
20949
  name: "orient",
20110
20950
  description: "Session orientation \u2014 run this FIRST at session start before any other tool. Single call that replaces build_list + health. Returns: cycle number, task counts by status, in-progress/in-review tasks, strategy review cadence, velocity snapshot, recommended next action, and a release reminder when all cycle tasks are Done but release has not run. Read-only, does not modify any files.",
20951
+ annotations: { readOnlyHint: true, destructiveHint: false },
20111
20952
  inputSchema: {
20112
20953
  type: "object",
20113
20954
  properties: {},
@@ -20283,8 +21124,8 @@ function getLatestGitTag(projectRoot) {
20283
21124
  }
20284
21125
  function checkNpmVersionDrift() {
20285
21126
  try {
20286
- const pkgPath = join8(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
20287
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
21127
+ const pkgPath = join9(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
21128
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
20288
21129
  const localVersion = pkg.version;
20289
21130
  const packageName = pkg.name;
20290
21131
  const published = execFileSync3("npm", ["view", packageName, "version"], {
@@ -20324,6 +21165,17 @@ async function handleOrient(adapter2, config2) {
20324
21165
  if (!cycleIsComplete && cycleTotal === 0 && cycleDone > 0) {
20325
21166
  buildResult.warnings.unshift(`\u26A0\uFE0F Cycle ${currentCycle} is complete \u2014 all ${cycleDone} task${cycleDone !== 1 ? "s" : ""} Done. Release has not been run. Run \`release\` now.`);
20326
21167
  }
21168
+ try {
21169
+ const p1BacklogTasks = await adapter2.queryBoard({ status: ["Backlog"], priority: ["P1 High"] });
21170
+ const stalledP1 = p1BacklogTasks.filter(
21171
+ (t) => t.createdCycle != null && currentCycle - t.createdCycle >= 3
21172
+ );
21173
+ if (stalledP1.length > 0) {
21174
+ const ids = stalledP1.map((t) => `${t.displayId} (${currentCycle - (t.createdCycle ?? currentCycle)}+ cycles)`).join(", ");
21175
+ buildResult.warnings.push(`\u26A0\uFE0F P1 tasks stalled 3+ cycles: ${ids}`);
21176
+ }
21177
+ } catch {
21178
+ }
20327
21179
  const inProgressItems = buildResult.inProgress.map(
20328
21180
  (t) => `- **${t.id}:** ${t.title} (${t.priority} | ${t.complexity})`
20329
21181
  );
@@ -20379,12 +21231,36 @@ ${versionDrift}` : "";
20379
21231
  try {
20380
21232
  const unrecorded = detectUnrecordedCommits(config2.projectRoot, config2.baseBranch);
20381
21233
  if (unrecorded.length > 0) {
21234
+ const doneTasks = await adapter2.queryBoard({ status: ["Done"] });
21235
+ const adHocDoneTasks = doneTasks.filter((t) => t.cycle == null);
21236
+ const alreadyRecorded = adHocDoneTasks.length >= unrecorded.length;
20382
21237
  const lines = ["\n\n## Unrecorded Work"];
20383
- lines.push(`${unrecorded.length} commit(s) on ${config2.baseBranch} since last release not captured by \`build_execute\`. Run \`ad_hoc\` to record them.`);
21238
+ if (alreadyRecorded) {
21239
+ lines.push(`${unrecorded.length} commit(s) on ${config2.baseBranch} since last release not matched to \`build_execute\` commits. ${adHocDoneTasks.length} ad_hoc task(s) already recorded \u2014 this work may already be captured. Verify before running \`ad_hoc\` again.`);
21240
+ } else {
21241
+ lines.push(`${unrecorded.length} commit(s) on ${config2.baseBranch} since last release not captured by \`build_execute\`. Run \`ad_hoc\` to record them.`);
21242
+ }
20384
21243
  for (const c of unrecorded) {
20385
21244
  lines.push(`- \`${c.hash}\` ${c.message}`);
20386
21245
  }
20387
- unrecordedNote = lines.join("\n");
21246
+ unrecordedNote = lines.join("\n");
21247
+ }
21248
+ } catch {
21249
+ }
21250
+ let unregisteredDocsNote = "";
21251
+ try {
21252
+ if (adapter2.searchDocs) {
21253
+ const docsDir = join9(config2.projectRoot, "docs");
21254
+ const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
21255
+ if (docsFiles.length > 0) {
21256
+ const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
21257
+ const registeredPaths = new Set(registered.map((d) => d.path));
21258
+ const unregisteredCount = docsFiles.filter((f) => !registeredPaths.has(f)).length;
21259
+ if (unregisteredCount > 0) {
21260
+ unregisteredDocsNote = `
21261
+ \u26A0\uFE0F **${unregisteredCount} unregistered doc(s) in docs/** \u2014 run \`doc_scan\` to review, then \`doc_register\` to index them.`;
21262
+ }
21263
+ }
20388
21264
  }
20389
21265
  } catch {
20390
21266
  }
@@ -20421,16 +21297,33 @@ ${versionDrift}` : "";
20421
21297
  }
20422
21298
  } catch {
20423
21299
  }
20424
- return textResponse(formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot) + ttfvNote + reconciliationNote + unrecordedNote + recsNote + pendingReviewNote + patternsNote + versionNote + enrichmentNote);
21300
+ let unactionedIssuesNote = "";
21301
+ try {
21302
+ const learnings = await adapter2.getCycleLearnings?.({ category: "issue", limit: 20 });
21303
+ if (learnings) {
21304
+ const unactioned = learnings.filter((l) => !l.actionTaken && l.severity && ["P0", "P1", "P2"].includes(l.severity)).slice(0, 5);
21305
+ if (unactioned.length > 0) {
21306
+ const lines = ["\n\n## Unactioned Issues"];
21307
+ for (const issue of unactioned) {
21308
+ const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
21309
+ lines.push(`- **${issue.severity}** (C${issue.cycleNumber} / ${issue.taskId}): ${desc}`);
21310
+ }
21311
+ lines.push("_Run `idea` to log these as backlog tasks, or `board_edit` if already handled._");
21312
+ unactionedIssuesNote = lines.join("\n");
21313
+ }
21314
+ }
21315
+ } catch {
21316
+ }
21317
+ return textResponse(formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot) + ttfvNote + reconciliationNote + unrecordedNote + unregisteredDocsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + versionNote + enrichmentNote);
20425
21318
  } catch (err) {
20426
21319
  const message = err instanceof Error ? err.message : String(err);
20427
21320
  return errorResponse(`Orient failed: ${message}`);
20428
21321
  }
20429
21322
  }
20430
21323
  function enrichClaudeMd(projectRoot, cycleNumber) {
20431
- const claudeMdPath = join8(projectRoot, "CLAUDE.md");
20432
- if (!existsSync5(claudeMdPath)) return "";
20433
- const content = readFileSync2(claudeMdPath, "utf-8");
21324
+ const claudeMdPath = join9(projectRoot, "CLAUDE.md");
21325
+ if (!existsSync6(claudeMdPath)) return "";
21326
+ const content = readFileSync3(claudeMdPath, "utf-8");
20434
21327
  const additions = [];
20435
21328
  if (cycleNumber >= 6 && !content.includes(CLAUDE_MD_ENRICHMENT_SENTINEL_T1)) {
20436
21329
  additions.push(CLAUDE_MD_TIER_1);
@@ -20452,6 +21345,7 @@ function enrichClaudeMd(projectRoot, cycleNumber) {
20452
21345
  var hierarchyUpdateTool = {
20453
21346
  name: "hierarchy_update",
20454
21347
  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.",
21348
+ annotations: { readOnlyHint: false, destructiveHint: false },
20455
21349
  inputSchema: {
20456
21350
  type: "object",
20457
21351
  properties: {
@@ -20847,6 +21741,7 @@ async function applyZoomOut(adapter2, llmResponse, cycleNumber) {
20847
21741
  var zoomOutTool = {
20848
21742
  name: "zoom_out",
20849
21743
  description: 'Run a Zoom-Out Retrospective \u2014 a higher-level meta-retrospective that sits above strategy reviews. Analyses the full project arc: every cycle, decision, and pivot. Use when you want to step back and see the big picture after many cycles. First call returns a prompt (prepare phase). Then call again with mode "apply" and your output.',
21744
+ annotations: { readOnlyHint: false, destructiveHint: false },
20850
21745
  inputSchema: {
20851
21746
  type: "object",
20852
21747
  properties: {
@@ -20921,222 +21816,11 @@ ${result.userMessage}
20921
21816
  }
20922
21817
  }
20923
21818
 
20924
- // src/tools/doc-registry.ts
20925
- import { readdirSync as readdirSync4, existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
20926
- import { join as join9, relative } from "path";
20927
- import { homedir as homedir2 } from "os";
20928
- var docRegisterTool = {
20929
- name: "doc_register",
20930
- description: "Register a document in the doc registry. Called after finalising a research/planning doc, or when build_execute detects unregistered docs. Stores metadata and structured summary \u2014 not full content.",
20931
- inputSchema: {
20932
- type: "object",
20933
- properties: {
20934
- path: { type: "string", description: 'Relative path from project root (e.g. "docs/research/funding-landscape.md").' },
20935
- title: { type: "string", description: "Document title." },
20936
- type: { type: "string", enum: ["research", "audit", "spec", "guide", "architecture", "positioning", "framework", "reference"], description: "Document type." },
20937
- status: { type: "string", enum: ["active", "draft", "superseded", "actioned", "legacy", "archived"], description: 'Document status. Defaults to "active".' },
20938
- summary: { type: "string", description: 'Structured 2-4 sentence summary. Format: "Conclusions: ... Open questions: ... Unactioned: ..."' },
20939
- tags: { type: "array", items: { type: "string" }, description: "Tags from project vocabulary." },
20940
- cycle: { type: "number", description: "Current cycle number." },
20941
- actions: {
20942
- type: "array",
20943
- items: {
20944
- type: "object",
20945
- properties: {
20946
- description: { type: "string" },
20947
- status: { type: "string", enum: ["pending", "resolved"] },
20948
- linkedTaskId: { type: "string" }
20949
- },
20950
- required: ["description", "status"]
20951
- },
20952
- description: "Actionable findings from the document."
20953
- },
20954
- superseded_by_path: { type: "string", description: "Path of the doc that supersedes this one (sets status to superseded)." }
20955
- },
20956
- required: ["path", "title", "type", "summary", "cycle"]
20957
- }
20958
- };
20959
- var docSearchTool = {
20960
- name: "doc_search",
20961
- description: "Search the doc registry for documents by type, tags, keyword, or pending actions. Returns summaries, not full content. Use for context gathering in plan, strategy review, and idea dedup.",
20962
- inputSchema: {
20963
- type: "object",
20964
- properties: {
20965
- type: { type: "string", description: 'Filter by doc type (e.g. "research", "architecture").' },
20966
- status: { type: "string", description: 'Filter by status. Defaults to "active".' },
20967
- tags: { type: "array", items: { type: "string" }, description: "Filter by tags (OR match)." },
20968
- keyword: { type: "string", description: "Search title and summary text." },
20969
- has_pending_actions: { type: "boolean", description: "Only docs with unresolved action items." },
20970
- since_cycle: { type: "number", description: "Docs updated since this cycle." },
20971
- limit: { type: "number", description: "Max results (default: 10)." }
20972
- },
20973
- required: []
20974
- }
20975
- };
20976
- var docScanTool = {
20977
- name: "doc_scan",
20978
- description: "Scan docs/ and plans directories for unregistered .md files. Returns a list of files not yet in the doc registry. Use this to find docs that need registration.",
20979
- inputSchema: {
20980
- type: "object",
20981
- properties: {
20982
- include_plans: {
20983
- type: "boolean",
20984
- description: "Also scan ~/.claude/plans/ for plan files (default: false)."
20985
- }
20986
- },
20987
- required: []
20988
- }
20989
- };
20990
- async function handleDocRegister(adapter2, args) {
20991
- if (!adapter2.registerDoc) {
20992
- return errorResponse("Doc registry not available \u2014 requires pg adapter.");
20993
- }
20994
- const path5 = args.path;
20995
- const title = args.title;
20996
- const type = args.type;
20997
- const status = args.status ?? "active";
20998
- const summary = args.summary;
20999
- const tags = args.tags ?? [];
21000
- const cycle = args.cycle;
21001
- const actions = args.actions;
21002
- const supersededByPath = args.superseded_by_path;
21003
- if (!path5 || !title || !type || !summary || !cycle) {
21004
- return errorResponse("Required fields: path, title, type, summary, cycle.");
21005
- }
21006
- let supersededBy;
21007
- if (supersededByPath) {
21008
- const existing = await adapter2.getDoc?.(supersededByPath);
21009
- if (existing) {
21010
- supersededBy = existing.id;
21011
- await adapter2.updateDocStatus?.(existing.id, "superseded", void 0);
21012
- }
21013
- }
21014
- const entry = await adapter2.registerDoc({
21015
- title,
21016
- type,
21017
- path: path5,
21018
- status: supersededByPath ? "superseded" : status,
21019
- summary,
21020
- tags,
21021
- cycleCreated: cycle,
21022
- cycleUpdated: cycle,
21023
- supersededBy,
21024
- actions
21025
- });
21026
- return textResponse(
21027
- `**Registered:** ${entry.title}
21028
- - **Path:** ${entry.path}
21029
- - **Type:** ${entry.type} | **Status:** ${entry.status}
21030
- - **Tags:** ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}
21031
- - **Actions:** ${actions?.length ?? 0} items
21032
- - **ID:** ${entry.id}`
21033
- );
21034
- }
21035
- async function handleDocSearch(adapter2, args) {
21036
- if (!adapter2.searchDocs) {
21037
- return errorResponse("Doc registry not available \u2014 requires pg adapter.");
21038
- }
21039
- const input = {
21040
- type: args.type,
21041
- status: args.status,
21042
- tags: args.tags,
21043
- keyword: args.keyword,
21044
- hasPendingActions: args.has_pending_actions,
21045
- sinceCycle: args.since_cycle,
21046
- limit: args.limit
21047
- };
21048
- const docs = await adapter2.searchDocs(input);
21049
- if (docs.length === 0) {
21050
- return textResponse("No documents found matching the search criteria.");
21051
- }
21052
- const lines = docs.map((d) => {
21053
- const actionCount = d.actions?.filter((a) => a.status === "pending").length ?? 0;
21054
- const actionNote = actionCount > 0 ? ` | ${actionCount} pending action(s)` : "";
21055
- return `### ${d.title}
21056
- **Type:** ${d.type} | **Status:** ${d.status} | **Cycle:** ${d.cycleCreated}${d.cycleUpdated ? `\u2192${d.cycleUpdated}` : ""}${actionNote}
21057
- **Path:** ${d.path}
21058
- **Tags:** ${d.tags.length > 0 ? d.tags.join(", ") : "none"}
21059
- ${d.summary}
21060
- `;
21061
- });
21062
- return textResponse(`**${docs.length} document(s) found:**
21063
-
21064
- ${lines.join("\n---\n\n")}`);
21065
- }
21066
- function scanMdFiles(dir, rootDir) {
21067
- if (!existsSync6(dir)) return [];
21068
- const files = [];
21069
- try {
21070
- const entries = readdirSync4(dir, { withFileTypes: true });
21071
- for (const entry of entries) {
21072
- const full = join9(dir, entry.name);
21073
- if (entry.isDirectory()) {
21074
- files.push(...scanMdFiles(full, rootDir));
21075
- } else if (entry.name.endsWith(".md")) {
21076
- files.push(relative(rootDir, full));
21077
- }
21078
- }
21079
- } catch {
21080
- }
21081
- return files;
21082
- }
21083
- function extractTitle(filePath) {
21084
- try {
21085
- const content = readFileSync3(filePath, "utf-8").slice(0, 1e3);
21086
- const fmMatch = content.match(/^---[\s\S]*?title:\s*(.+?)$/m);
21087
- if (fmMatch) return fmMatch[1].trim().replace(/^["']|["']$/g, "");
21088
- const headingMatch = content.match(/^#+\s+(.+)$/m);
21089
- if (headingMatch) return headingMatch[1].trim();
21090
- } catch {
21091
- }
21092
- return void 0;
21093
- }
21094
- async function handleDocScan(adapter2, config2, args) {
21095
- if (!adapter2.searchDocs) {
21096
- return errorResponse("Doc registry not available on this adapter.");
21097
- }
21098
- const includePlans = args.include_plans ?? false;
21099
- const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
21100
- const registeredPaths = new Set(registered.map((d) => d.path));
21101
- const docsDir = join9(config2.projectRoot, "docs");
21102
- const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
21103
- const unregisteredDocs = docsFiles.filter((f) => !registeredPaths.has(f));
21104
- let unregisteredPlans = [];
21105
- if (includePlans) {
21106
- const plansDir = join9(homedir2(), ".claude", "plans");
21107
- if (existsSync6(plansDir)) {
21108
- const planFiles = scanMdFiles(plansDir, plansDir);
21109
- unregisteredPlans = planFiles.map((f) => `plans/${f}`).filter((f) => !registeredPaths.has(f)).map((f) => ({
21110
- path: f,
21111
- title: extractTitle(join9(plansDir, f.replace("plans/", "")))
21112
- }));
21113
- }
21114
- }
21115
- const lines = [];
21116
- if (unregisteredDocs.length === 0 && unregisteredPlans.length === 0) {
21117
- return textResponse("All docs are registered. No unregistered files found.");
21118
- }
21119
- if (unregisteredDocs.length > 0) {
21120
- lines.push(`## Unregistered Docs (${unregisteredDocs.length})`);
21121
- for (const f of unregisteredDocs) {
21122
- const title = extractTitle(join9(config2.projectRoot, f));
21123
- lines.push(`- \`${f}\`${title ? ` \u2014 ${title}` : ""}`);
21124
- }
21125
- }
21126
- if (unregisteredPlans.length > 0) {
21127
- lines.push("", `## Unregistered Plans (${unregisteredPlans.length})`);
21128
- for (const p of unregisteredPlans) {
21129
- lines.push(`- \`${p.path}\`${p.title ? ` \u2014 ${p.title}` : ""}`);
21130
- }
21131
- }
21132
- lines.push("", `Use \`doc_register\` to register these files.`);
21133
- return textResponse(lines.join("\n"));
21134
- }
21135
-
21136
21819
  // src/tools/sibling-ads.ts
21137
21820
  var getSiblingAdsTool = {
21138
21821
  name: "get_sibling_ads",
21139
21822
  description: "Read Active Decisions from sibling PAPI projects that share the same Supabase instance. Requires PAPI_SIBLING_PROJECT_IDS env var (comma-separated project UUIDs). Returns ADs labelled by source project \u2014 useful for cross-project architectural alignment. pg adapter only \u2014 returns an error if using md or proxy adapter.",
21823
+ annotations: { readOnlyHint: true, destructiveHint: false },
21140
21824
  inputSchema: {
21141
21825
  type: "object",
21142
21826
  properties: {
@@ -21318,6 +22002,7 @@ var lastPrepareContextBytes2;
21318
22002
  var handoffGenerateTool = {
21319
22003
  name: "handoff_generate",
21320
22004
  description: "Generate BUILD HANDOFFs for cycle tasks that don't have one yet. Run after `plan` (with skip_handoffs=true) or to regenerate stale handoffs. Uses the prepare/apply pattern \u2014 first call returns a prompt, second call persists results.",
22005
+ annotations: { readOnlyHint: false, destructiveHint: false },
21321
22006
  inputSchema: {
21322
22007
  type: "object",
21323
22008
  properties: {
@@ -21428,12 +22113,14 @@ function isEnabled() {
21428
22113
  }
21429
22114
  function emitTelemetryEvent(event) {
21430
22115
  if (!isEnabled()) return;
22116
+ const userId = event.user_id ?? process.env["PAPI_USER_ID"] ?? void 0;
21431
22117
  const body = {
21432
22118
  project_id: event.project_id,
21433
22119
  tool_name: event.tool_name,
21434
22120
  event_type: event.event_type,
21435
22121
  metadata: event.metadata ?? {}
21436
22122
  };
22123
+ if (userId) body["user_id"] = userId;
21437
22124
  fetch(`${TELEMETRY_SUPABASE_URL}/rest/v1/telemetry_events`, {
21438
22125
  method: "POST",
21439
22126
  headers: {
@@ -21480,7 +22167,6 @@ var TOOLS_REQUIRING_PAPI = /* @__PURE__ */ new Set([
21480
22167
  "idea",
21481
22168
  "bug",
21482
22169
  "ad_hoc",
21483
- "health",
21484
22170
  "board_reconcile",
21485
22171
  "review_list",
21486
22172
  "review_submit",
@@ -21562,7 +22248,6 @@ function createServer(adapter2, config2) {
21562
22248
  bugTool,
21563
22249
  adHocTool,
21564
22250
  boardReconcileTool,
21565
- healthTool,
21566
22251
  releaseTool,
21567
22252
  reviewListTool,
21568
22253
  reviewSubmitTool,
@@ -21654,9 +22339,6 @@ function createServer(adapter2, config2) {
21654
22339
  case "board_reconcile":
21655
22340
  result = await handleBoardReconcile(adapter2, config2, safeArgs);
21656
22341
  break;
21657
- case "health":
21658
- result = await handleHealth(adapter2);
21659
- break;
21660
22342
  case "release":
21661
22343
  result = await handleRelease(adapter2, config2, safeArgs);
21662
22344
  break;
@@ -21841,5 +22523,123 @@ if (pkgVersion !== "unknown") {
21841
22523
  }
21842
22524
  })();
21843
22525
  }
21844
- var transport = new StdioServerTransport();
21845
- await server.connect(transport);
22526
+ var httpPortRaw = process.env["PAPI_HTTP_PORT"] ?? process.env["PORT"];
22527
+ var httpPort = httpPortRaw ? parseInt(httpPortRaw, 10) : void 0;
22528
+ var httpHost = process.env["PORT"] ? "0.0.0.0" : "127.0.0.1";
22529
+ if (httpPort) {
22530
+ if (isNaN(httpPort) || httpPort < 1 || httpPort > 65535) {
22531
+ process.stderr.write(`[papi] Invalid PAPI_HTTP_PORT: "${process.env.PAPI_HTTP_PORT}". Must be a number between 1 and 65535.
22532
+ `);
22533
+ process.exit(1);
22534
+ }
22535
+ const httpToken = process.env.PAPI_HTTP_TOKEN;
22536
+ if (!httpToken) {
22537
+ process.stderr.write("[papi] WARNING: PAPI_HTTP_TOKEN is not set. HTTP transport is unauthenticated \u2014 anyone with the URL can call your PAPI tools. Set PAPI_HTTP_TOKEN to a secret string.\n");
22538
+ }
22539
+ const createServerForRequest = () => {
22540
+ if (adapter && !setupError) {
22541
+ return createServer(adapter, config);
22542
+ }
22543
+ const errorServer = new Server2(
22544
+ { name: "papi", version: pkgVersion },
22545
+ { capabilities: { tools: {} } }
22546
+ );
22547
+ const errorMessage = setupError || "Unknown startup error";
22548
+ errorServer.setRequestHandler(ListToolsRequestSchema2, async () => ({
22549
+ tools: [{
22550
+ name: "setup",
22551
+ description: "PAPI is not connected \u2014 run this tool for setup instructions.",
22552
+ inputSchema: { type: "object", properties: {}, required: [] }
22553
+ }]
22554
+ }));
22555
+ errorServer.setRequestHandler(CallToolRequestSchema2, async () => ({
22556
+ content: [{
22557
+ type: "text",
22558
+ text: `# PAPI Connection Error
22559
+
22560
+ ${errorMessage}
22561
+
22562
+ ## Quick Fix
22563
+
22564
+ If you haven't set up PAPI yet:
22565
+ 1. Go to https://getpapi.ai/login and sign up
22566
+ 2. Complete the onboarding wizard \u2014 it generates your config
22567
+ 3. Copy the config to your project and restart your AI tool
22568
+
22569
+ If you already have an account, check that both **PAPI_PROJECT_ID** and **PAPI_DATA_API_KEY** are set in your .mcp.json env config.`
22570
+ }]
22571
+ }));
22572
+ return errorServer;
22573
+ };
22574
+ const httpServer = createHttpServer((req, res) => {
22575
+ if (req.method === "GET" && req.url === "/healthz") {
22576
+ res.writeHead(200, { "Content-Type": "text/plain" });
22577
+ res.end("ok");
22578
+ return;
22579
+ }
22580
+ if (httpToken) {
22581
+ const authHeader = req.headers["authorization"] ?? "";
22582
+ const provided = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
22583
+ if (provided !== httpToken) {
22584
+ res.writeHead(401, { "Content-Type": "application/json" });
22585
+ res.end(JSON.stringify({ error: "Unauthorized" }));
22586
+ return;
22587
+ }
22588
+ }
22589
+ if (req.url === "/mcp" || req.url === "/sse") {
22590
+ if (req.method === "POST") {
22591
+ const chunks = [];
22592
+ req.on("data", (chunk) => chunks.push(chunk));
22593
+ req.on("end", () => {
22594
+ let parsedBody;
22595
+ try {
22596
+ parsedBody = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
22597
+ } catch {
22598
+ res.writeHead(400, { "Content-Type": "application/json" });
22599
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
22600
+ return;
22601
+ }
22602
+ (async () => {
22603
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
22604
+ const reqServer = createServerForRequest();
22605
+ await reqServer.connect(transport);
22606
+ await transport.handleRequest(req, res, parsedBody);
22607
+ await reqServer.close();
22608
+ })().catch((err) => {
22609
+ process.stderr.write(`[papi] HTTP transport error: ${err instanceof Error ? err.message : String(err)}
22610
+ `);
22611
+ if (!res.headersSent) {
22612
+ res.writeHead(500, { "Content-Type": "application/json" });
22613
+ res.end(JSON.stringify({ error: "Internal server error" }));
22614
+ }
22615
+ });
22616
+ });
22617
+ return;
22618
+ }
22619
+ (async () => {
22620
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
22621
+ const reqServer = createServerForRequest();
22622
+ await reqServer.connect(transport);
22623
+ await transport.handleRequest(req, res);
22624
+ await reqServer.close();
22625
+ })().catch((err) => {
22626
+ process.stderr.write(`[papi] HTTP transport error: ${err instanceof Error ? err.message : String(err)}
22627
+ `);
22628
+ if (!res.headersSent) {
22629
+ res.writeHead(500, { "Content-Type": "application/json" });
22630
+ res.end(JSON.stringify({ error: "Internal server error" }));
22631
+ }
22632
+ });
22633
+ return;
22634
+ }
22635
+ res.writeHead(404, { "Content-Type": "text/plain" });
22636
+ res.end("Not found");
22637
+ });
22638
+ httpServer.listen(httpPort, httpHost, () => {
22639
+ process.stderr.write(`[papi] HTTP transport listening on http://${httpHost}:${httpPort}/mcp
22640
+ `);
22641
+ });
22642
+ } else {
22643
+ const transport = new StdioServerTransport();
22644
+ await server.connect(transport);
22645
+ }