@papi-ai/server 0.7.14 → 0.7.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6733,6 +6733,25 @@ ${newParts.join("\n")}` : newParts.join("\n");
6733
6733
  superseded_by = ${supersededBy ?? null},
6734
6734
  updated_at = now()
6735
6735
  WHERE id = ${id} AND project_id = ${this.projectId}
6736
+ `;
6737
+ }
6738
+ async updateDocAction(docId, actionIndex, update) {
6739
+ const doc = await this.getDoc(docId);
6740
+ if (!doc) throw new Error(`Doc not found: ${docId}`);
6741
+ const actions = [...doc.actions ?? []];
6742
+ if (actionIndex < 0 || actionIndex >= actions.length) {
6743
+ throw new Error(`Action index ${actionIndex} out of range (doc has ${actions.length} action(s))`);
6744
+ }
6745
+ actions[actionIndex] = {
6746
+ ...actions[actionIndex],
6747
+ ...update.status !== void 0 ? { status: update.status } : {},
6748
+ ...update.linkedTaskId !== void 0 ? { linkedTaskId: update.linkedTaskId } : {}
6749
+ };
6750
+ await this.sql`
6751
+ UPDATE doc_registry
6752
+ SET actions = ${this.sql.json(actions)},
6753
+ updated_at = now()
6754
+ WHERE id = ${doc.id} AND project_id = ${this.projectId}
6736
6755
  `;
6737
6756
  }
6738
6757
  async writeDogfoodEntries(entries) {
@@ -7607,7 +7626,7 @@ ${newParts.join("\n")}` : newParts.join("\n");
7607
7626
  const rows = await this.sql`
7608
7627
  SELECT id, slug, label, description, status, sort_order, horizon_id, project_id, exit_criteria, created_at, updated_at
7609
7628
  FROM stages
7610
- WHERE project_id = ${this.projectId} AND status = 'Active'
7629
+ WHERE project_id = ${this.projectId} AND status = 'In Progress'
7611
7630
  ORDER BY sort_order
7612
7631
  LIMIT 1
7613
7632
  `;
@@ -9486,9 +9505,9 @@ var init_git = __esm({
9486
9505
  });
9487
9506
 
9488
9507
  // src/index.ts
9489
- import { readFileSync as readFileSync6 } from "fs";
9508
+ import { readFileSync as readFileSync7 } from "fs";
9490
9509
  import { createServer as createHttpServer } from "http";
9491
- import { dirname as dirname2, join as join13 } from "path";
9510
+ import { dirname as dirname2, join as join14 } from "path";
9492
9511
  import { fileURLToPath as fileURLToPath2 } from "url";
9493
9512
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9494
9513
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -9775,9 +9794,9 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
9775
9794
  }
9776
9795
 
9777
9796
  // src/server.ts
9778
- import { readFileSync as readFileSync5 } from "fs";
9797
+ import { readFileSync as readFileSync6 } from "fs";
9779
9798
  import { access as access4, readdir as readdir2, readFile as readFile5 } from "fs/promises";
9780
- import { join as join12, dirname } from "path";
9799
+ import { join as join13, dirname } from "path";
9781
9800
  import { fileURLToPath } from "url";
9782
9801
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9783
9802
  import {
@@ -10763,6 +10782,7 @@ Standard planning cycle with full board review.
10763
10782
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
10764
10783
  **\u26A0\uFE0F PRIORITY RECALIBRATION \u2014 do NOT rubber-stamp the submitted priority.** The priority set at idea submission reflects the submitter's view at that time, which may be outdated by the time the planner runs. For EVERY unreviewed task, evaluate its priority FROM SCRATCH against: (a) current horizon/stage/phase goals, (b) recent Active Decision changes, (c) recently shipped functionality that makes this task more or less urgent. If your assessed priority differs from the submitted one, set the new priority in \`boardCorrections\` and include the change in a **Priority Recalibration** paragraph in your cycle log (Step 8): list each changed task by ID, old priority \u2192 new priority, and a 1-sentence rationale. This paragraph is how the user sees what the planner recalibrated and why. If no priorities changed during triage, omit the paragraph.
10765
10784
  Also set complexity using the full range \u2014 **XS, Small, Medium, Large, XL** \u2014 based on actual scope, not conservatively. XS = single-line or config change. Small = one file, < 50 lines. Medium = 2-5 files. Large = cross-module, multiple components. XL = architectural, multi-day.
10785
+ **Module classification for cross-cutting tasks:** When a task title contains "audit"/"unfiltered"/"scoping"/"leak" plus a database-entity name (e.g. "audit ... cycle_learnings reads", "unfiltered cycle_tasks queries"), classify the module by the actual code surface that reads/writes the entity \u2014 not by the tool names mentioned in the title. The reasoning surface (e.g. "health" or "strategy_review") is often unrelated to the data-access surface. Resolve this by treating the entity name as the routing signal: tasks touching dashboard read/write paths belong to the Dashboard module even if the title mentions an MCP tool. Misclassification routes the task to the wrong shared cycle branch and surfaces the wrong MODULE INSTRUCTIONS to the builder.
10766
10786
  If a task is clearly obsolete, duplicated, or rejected, set its status to "Cancelled" with a \`closureReason\` explaining why.
10767
10787
  **\u2192 PERSIST:** For each task you set reviewed: true, corrected fields on, or marked "Cancelled", include it in \`boardCorrections\` in Part 2.
10768
10788
 
@@ -11001,6 +11021,7 @@ Standard planning cycle with full board review.
11001
11021
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
11002
11022
  **\u26A0\uFE0F PRIORITY RECALIBRATION \u2014 do NOT rubber-stamp the submitted priority.** The priority set at idea submission reflects the submitter's view at that time, which may be outdated by the time the planner runs. For EVERY unreviewed task, evaluate its priority FROM SCRATCH against: (a) current horizon/stage/phase goals, (b) recent Active Decision changes, (c) recently shipped functionality that makes this task more or less urgent. If your assessed priority differs from the submitted one, set the new priority in \`boardCorrections\` and include the change in a **Priority Recalibration** paragraph in your cycle log (Step 8): list each changed task by ID, old priority \u2192 new priority, and a 1-sentence rationale. This paragraph is how the user sees what the planner recalibrated and why. If no priorities changed during triage, omit the paragraph.
11003
11023
  Also set complexity using the full range \u2014 **XS, Small, Medium, Large, XL** \u2014 based on actual scope, not conservatively. XS = single-line or config change. Small = one file, < 50 lines. Medium = 2-5 files. Large = cross-module, multiple components. XL = architectural, multi-day.
11024
+ **Module classification for cross-cutting tasks:** When a task title contains "audit"/"unfiltered"/"scoping"/"leak" plus a database-entity name (e.g. "audit ... cycle_learnings reads", "unfiltered cycle_tasks queries"), classify the module by the actual code surface that reads/writes the entity \u2014 not by the tool names mentioned in the title. The reasoning surface (e.g. "health" or "strategy_review") is often unrelated to the data-access surface. Resolve this by treating the entity name as the routing signal: tasks touching dashboard read/write paths belong to the Dashboard module even if the title mentions an MCP tool. Misclassification routes the task to the wrong shared cycle branch and surfaces the wrong MODULE INSTRUCTIONS to the builder.
11004
11025
  If a task is clearly obsolete, duplicated, or rejected, set its status to "Cancelled" with a \`closureReason\` explaining why.
11005
11026
  **\u2192 PERSIST:** For each task you set reviewed: true, corrected fields on, or marked "Cancelled", include it in \`boardCorrections\` in Part 2.
11006
11027
 
@@ -11215,6 +11236,16 @@ function buildPlanUserMessage(ctx) {
11215
11236
  if (ctx.registeredDocs) {
11216
11237
  parts.push("### Relevant Research Docs", "", ctx.registeredDocs, "");
11217
11238
  }
11239
+ if (ctx.unactionedDocs) {
11240
+ parts.push(
11241
+ "### Unactioned Research (soft-warn)",
11242
+ "",
11243
+ "These registered docs still have pending actions. Before adding net-new tasks, consider whether one of these unactioned items should be promoted into this cycle via `doc_action_promote`. Surface this in your plan output so the user can decide.",
11244
+ "",
11245
+ ctx.unactionedDocs,
11246
+ ""
11247
+ );
11248
+ }
11218
11249
  if (ctx.carryForwardStaleness) {
11219
11250
  parts.push("### Carry-Forward Staleness", "", ctx.carryForwardStaleness, "");
11220
11251
  }
@@ -11427,6 +11458,13 @@ ${AD_REJECTION_RULES}
11427
11458
 
11428
11459
  **Registered Documents:** If a "### Registered Documents" section is present in context, scan it for: (a) research findings that contradict current ADs or strategy, (b) unactioned research that should influence the next plan. Reference relevant docs by title in your review. If unregistered docs are listed, flag 1-2 that look strategically relevant and suggest registering them.
11429
11460
 
11461
+ **Doc Action Staleness:** If a "### Doc Action Staleness" section is present, treat it as a research-to-action audit. For each entry:
11462
+ - **Stale** (no Done task, >20 cycles old): explicitly call it out in section 2 (Product Gaps) or 3 (Opportunities) \u2014 research that's been sitting unactioned this long is either (a) no longer relevant (recommend marking the doc archived) or (b) silently dropped (recommend promoting via \`doc_action_promote\` into the next plan).
11463
+ - **Completed but not closed** (linked task is Done): instruct in your review that the action should be marked resolved \u2014 it's done work the registry hasn't caught up to.
11464
+ - **Deferred** (<20 cycles, no matching Done task): only mention if it relates to a current strategic theme; otherwise leave it.
11465
+
11466
+ For every stale or unresolved item you call out, propose a concrete next step (promote, archive, or supersede) \u2014 don't just observe.
11467
+
11430
11468
  ## CONDITIONAL SECTIONS (include only when genuinely useful \u2014 most reviews should have 0-2 of these)
11431
11469
 
11432
11470
  6. **Security Posture Review** \u2014 Only if \`[SECURITY]\` tags exist in recent cycle logs.
@@ -12108,6 +12146,7 @@ function applyContextTier(ctx, cycleCount) {
12108
12146
  ctx.reviewPatterns = void 0;
12109
12147
  ctx.horizonContext = void 0;
12110
12148
  ctx.registeredDocs = void 0;
12149
+ ctx.unactionedDocs = void 0;
12111
12150
  ctx.recentReviews = void 0;
12112
12151
  ctx.strategyReviewCadence = void 0;
12113
12152
  }
@@ -12277,6 +12316,32 @@ function formatStrategyRecommendations(recs, currentCycle) {
12277
12316
  }
12278
12317
  return sections.join("\n");
12279
12318
  }
12319
+ function formatUnactionedDocs(docs) {
12320
+ const withPending = docs.map((d) => ({
12321
+ doc: d,
12322
+ pending: (d.actions ?? []).filter((a) => a.status === "pending")
12323
+ })).filter((entry) => entry.pending.length > 0);
12324
+ if (withPending.length === 0) return void 0;
12325
+ const MAX_DOCS = 3;
12326
+ const MAX_ACTIONS_PER_DOC = 2;
12327
+ const top = withPending.slice(0, MAX_DOCS);
12328
+ const lines = [];
12329
+ for (const { doc, pending } of top) {
12330
+ const cycleLabel = `C${doc.cycleUpdated ?? doc.cycleCreated ?? "?"}`;
12331
+ lines.push(`- **${doc.title}** (${doc.path}) [${cycleLabel}, ${pending.length} pending]`);
12332
+ for (const action of pending.slice(0, MAX_ACTIONS_PER_DOC)) {
12333
+ const desc = action.description.length > 100 ? `${action.description.slice(0, 97)}\u2026` : action.description;
12334
+ lines.push(` \u2192 ${desc}`);
12335
+ }
12336
+ if (pending.length > MAX_ACTIONS_PER_DOC) {
12337
+ lines.push(` \u2192 \u2026and ${pending.length - MAX_ACTIONS_PER_DOC} more`);
12338
+ }
12339
+ }
12340
+ if (withPending.length > MAX_DOCS) {
12341
+ lines.push(`_\u2026and ${withPending.length - MAX_DOCS} more doc(s) with pending actions._`);
12342
+ }
12343
+ return lines.join("\n");
12344
+ }
12280
12345
  function formatEstimationCalibration(rows) {
12281
12346
  const total = rows.reduce((sum, r) => sum + r.count, 0);
12282
12347
  const accurate = rows.filter((r) => r.accuracyLabel === "accurate").reduce((sum, r) => sum + r.count, 0);
@@ -12616,11 +12681,13 @@ async function assembleContext(adapter2, mode, _config, filters, focus) {
12616
12681
  }
12617
12682
  }
12618
12683
  let registeredDocsText;
12684
+ let unactionedDocsTextLean;
12619
12685
  if (docsResult.status === "fulfilled" && docsResult.value && docsResult.value.length > 0) {
12620
12686
  const docs = docsResult.value;
12621
12687
  const lines = docs.map((d) => `- **${d.title}** (${d.type}) \u2014 ${d.summary}`);
12622
12688
  registeredDocsText = `${docs.length} active research doc(s):
12623
12689
  ${lines.join("\n")}`;
12690
+ unactionedDocsTextLean = formatUnactionedDocs(docs);
12624
12691
  }
12625
12692
  const boardFlags = detectBoardFlagsFromText(leanSummary.board);
12626
12693
  let carryForwardStalenessLean;
@@ -12672,6 +12739,7 @@ ${lines.join("\n")}`;
12672
12739
  discoveryCanvas: discoveryCanvasText,
12673
12740
  estimationCalibration: estimationCalibrationText,
12674
12741
  registeredDocs: registeredDocsText,
12742
+ unactionedDocs: unactionedDocsTextLean,
12675
12743
  focus,
12676
12744
  boardFlags,
12677
12745
  carryForwardStaleness: carryForwardStalenessLean,
@@ -12749,9 +12817,24 @@ ${lines.join("\n")}`;
12749
12817
  const metricsSnapshots = filteredRaw.length > 0 ? filteredRaw : computeSnapshotsFromBuildReports(allReportsForPatterns);
12750
12818
  const discoveryCanvasTextFull = discoveryCanvasResultFull.status === "fulfilled" ? discoveryCanvasResultFull.value : void 0;
12751
12819
  const taskCommentsTextFull = taskCommentsResultFull.status === "fulfilled" ? taskCommentsResultFull.value : void 0;
12820
+ const strippedTasks = stripTasksForPlan(tasks);
12821
+ const boardFlagsFull = detectBoardFlags(tasks);
12822
+ const horizonCtx = buildHorizonContext(phases, tasks) ?? void 0;
12823
+ const ACTIVE_STATUSES2 = /* @__PURE__ */ new Set(["In Progress", "In Review", "Blocked"]);
12824
+ const p3Excluded = strippedTasks.filter(
12825
+ (t2) => t2.priority === "P3 Low" && !ACTIVE_STATUSES2.has(t2.status)
12826
+ );
12827
+ const plannerTasks = strippedTasks.filter(
12828
+ (t2) => t2.priority !== "P3 Low" || ACTIVE_STATUSES2.has(t2.status)
12829
+ );
12830
+ if (p3Excluded.length > 0) {
12831
+ console.error(`[plan-perf] board tiering: excluded ${p3Excluded.length} P3 Low tasks from planner context`);
12832
+ }
12752
12833
  let registeredDocsTextFull;
12834
+ let unactionedDocsTextFull;
12753
12835
  if (docsResultFull.status === "fulfilled" && docsResultFull.value && docsResultFull.value.length > 0) {
12754
12836
  const allDocs = docsResultFull.value;
12837
+ unactionedDocsTextFull = formatUnactionedDocs(allDocs);
12755
12838
  const taskModules = new Set(plannerTasks.map((t2) => t2.module?.toLowerCase()).filter(Boolean));
12756
12839
  const taskEpics = new Set(plannerTasks.map((t2) => t2.epic?.toLowerCase()).filter(Boolean));
12757
12840
  const HIGH_VALUE_TYPES = /* @__PURE__ */ new Set(["architecture", "guide", "research"]);
@@ -12797,19 +12880,6 @@ ${logLines}`);
12797
12880
  const filtered = reports.filter((r) => (r.cycle ?? 0) >= cutoff);
12798
12881
  return filtered.length > 0 ? filtered : reports.slice(0, 5);
12799
12882
  })() : reports;
12800
- const strippedTasks = stripTasksForPlan(tasks);
12801
- const boardFlagsFull = detectBoardFlags(tasks);
12802
- const horizonCtx = buildHorizonContext(phases, tasks) ?? void 0;
12803
- const ACTIVE_STATUSES2 = /* @__PURE__ */ new Set(["In Progress", "In Review", "Blocked"]);
12804
- const p3Excluded = strippedTasks.filter(
12805
- (t2) => t2.priority === "P3 Low" && !ACTIVE_STATUSES2.has(t2.status)
12806
- );
12807
- const plannerTasks = strippedTasks.filter(
12808
- (t2) => t2.priority !== "P3 Low" || ACTIVE_STATUSES2.has(t2.status)
12809
- );
12810
- if (p3Excluded.length > 0) {
12811
- console.error(`[plan-perf] board tiering: excluded ${p3Excluded.length} P3 Low tasks from planner context`);
12812
- }
12813
12883
  const targetCycle = health.totalCycles + 1;
12814
12884
  const preAssigned = plannerTasks.filter((t2) => t2.cycle === targetCycle);
12815
12885
  const preAssignedText = formatPreAssignedTasks(preAssigned, targetCycle);
@@ -12835,6 +12905,7 @@ ${logLines}`);
12835
12905
  taskComments: taskCommentsTextFull,
12836
12906
  discoveryCanvas: discoveryCanvasTextFull,
12837
12907
  registeredDocs: registeredDocsTextFull,
12908
+ unactionedDocs: unactionedDocsTextFull,
12838
12909
  focus,
12839
12910
  boardFlags: boardFlagsFull,
12840
12911
  carryForwardStaleness: computeCarryForwardStaleness(log),
@@ -13794,17 +13865,19 @@ async function handlePlan(adapter2, config2, args) {
13794
13865
  const expectedCycleNumber = lastPrepareCycleNumber;
13795
13866
  const skipHandoffsCached = lastPrepareSkipHandoffs;
13796
13867
  const skipHandoffs = args.skip_handoffs === true || skipHandoffsCached === true;
13797
- const cycleNumber = !isNaN(rawCycleNumber) ? rawCycleNumber : expectedCycleNumber !== void 0 ? expectedCycleNumber : NaN;
13798
- if (isNaN(cycleNumber)) {
13868
+ const expectedNewCycleNumber = expectedCycleNumber !== void 0 ? expectedCycleNumber + 1 : void 0;
13869
+ const newCycleNumber = !isNaN(rawCycleNumber) ? rawCycleNumber : expectedNewCycleNumber !== void 0 ? expectedNewCycleNumber : NaN;
13870
+ if (isNaN(newCycleNumber)) {
13799
13871
  return errorResponse(
13800
13872
  "cycle_number is required for apply mode. Pass the cycle_number from the prepare phase output."
13801
13873
  );
13802
13874
  }
13803
- if (expectedCycleNumber !== void 0 && cycleNumber !== expectedCycleNumber) {
13875
+ if (expectedNewCycleNumber !== void 0 && newCycleNumber !== expectedNewCycleNumber) {
13804
13876
  return errorResponse(
13805
- `cycle_number mismatch: prepare phase returned cycle ${expectedCycleNumber} but apply received ${cycleNumber}. Pass cycle_number: ${expectedCycleNumber} to match the prepare output.`
13877
+ `cycle_number mismatch: prepare phase is for cycle ${expectedNewCycleNumber} but apply received ${newCycleNumber}. Pass cycle_number: ${expectedNewCycleNumber} to match the prepare output.`
13806
13878
  );
13807
13879
  }
13880
+ const cycleNumber = newCycleNumber - 1;
13808
13881
  lastPrepareContextHashes = void 0;
13809
13882
  lastPrepareUserMessage = void 0;
13810
13883
  lastPrepareContextBytes = void 0;
@@ -13858,7 +13931,7 @@ When done, call \`plan\` again with:
13858
13931
  - \`mode\`: "apply"
13859
13932
  - \`llm_response\`: your complete output (both parts)
13860
13933
  - \`plan_mode\`: "${result.mode}"
13861
- - \`cycle_number\`: ${result.cycleNumber}
13934
+ - \`cycle_number\`: ${result.cycleNumber + 1}
13862
13935
  - \`strategy_review_warning\`: "${result.strategyReviewWarning.replace(/"/g, '\\"')}"
13863
13936
 
13864
13937
  ---
@@ -18703,8 +18776,20 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
18703
18776
  const correctionsCount = args.corrections_count;
18704
18777
  const deadEnds = args.dead_ends;
18705
18778
  const rawBriefImplications = args.brief_implications;
18706
- if (!completed || !effort || !estimatedEffort || !surprises || !discoveredIssues || !architectureNotes) {
18707
- return errorResponse("build_execute with report data requires all fields \u2014 completed, effort, estimated_effort, surprises, discovered_issues, architecture_notes.");
18779
+ const reportFieldChecks = [
18780
+ ["completed", completed],
18781
+ ["effort", effort],
18782
+ ["estimated_effort", estimatedEffort],
18783
+ ["surprises", surprises],
18784
+ ["discovered_issues", discoveredIssues],
18785
+ ["architecture_notes", architectureNotes]
18786
+ ];
18787
+ const missing = reportFieldChecks.filter(([, v]) => !v).map(([k]) => k);
18788
+ if (missing.length > 0) {
18789
+ const sizes = reportFieldChecks.map(([k, v]) => `${k}=${v === void 0 ? "undefined" : v.length}`).join(", ");
18790
+ return errorResponse(
18791
+ `build_execute with report data is missing required field(s): ${missing.join(", ")}. If you sent values for these, the transport may have dropped them \u2014 re-send with shorter content for the affected field(s) or split the report. Field sizes (chars): ${sizes}.`
18792
+ );
18708
18793
  }
18709
18794
  const parsedEffort = parseEffortSize(effort);
18710
18795
  const parsedEstimatedEffort = parseEffortSize(estimatedEffort);
@@ -21516,10 +21601,117 @@ ${lines.join("\n")}`;
21516
21601
  // src/tools/orient.ts
21517
21602
  init_git();
21518
21603
 
21604
+ // src/lib/skill-detection.ts
21605
+ import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync2, statSync as statSync3 } from "fs";
21606
+ import { join as join8 } from "path";
21607
+ function readPackageJson(projectRoot) {
21608
+ const path5 = join8(projectRoot, "package.json");
21609
+ if (!existsSync5(path5)) return null;
21610
+ try {
21611
+ const raw = readFileSync2(path5, "utf-8");
21612
+ return JSON.parse(raw);
21613
+ } catch {
21614
+ return null;
21615
+ }
21616
+ }
21617
+ function allDeps(pkg) {
21618
+ if (!pkg) return {};
21619
+ return { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
21620
+ }
21621
+ function hasDependencyMatching(deps, pattern) {
21622
+ for (const name of Object.keys(deps)) {
21623
+ if (pattern.test(name)) return true;
21624
+ }
21625
+ return false;
21626
+ }
21627
+ function hasGitHubWorkflows(projectRoot) {
21628
+ const dir = join8(projectRoot, ".github", "workflows");
21629
+ if (!existsSync5(dir)) return false;
21630
+ try {
21631
+ const entries = readdirSync4(dir);
21632
+ return entries.some((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
21633
+ } catch {
21634
+ return false;
21635
+ }
21636
+ }
21637
+ function envExampleMentionsStaging(projectRoot) {
21638
+ const path5 = join8(projectRoot, ".env.example");
21639
+ if (!existsSync5(path5)) return false;
21640
+ try {
21641
+ const raw = readFileSync2(path5, "utf-8");
21642
+ return /\b(STAGING_URL|STAGING_API|STAGING_HOST|NEXT_PUBLIC_STAGING)/i.test(raw);
21643
+ } catch {
21644
+ return false;
21645
+ }
21646
+ }
21647
+ function hasVercelConfig(projectRoot) {
21648
+ if (existsSync5(join8(projectRoot, "vercel.json"))) return true;
21649
+ const vercelDir = join8(projectRoot, ".vercel");
21650
+ if (!existsSync5(vercelDir)) return false;
21651
+ try {
21652
+ return statSync3(vercelDir).isDirectory();
21653
+ } catch {
21654
+ return false;
21655
+ }
21656
+ }
21657
+ function scanForSkillSignals(projectRoot) {
21658
+ const proposals = [];
21659
+ const pkg = readPackageJson(projectRoot);
21660
+ const deps = allDeps(pkg);
21661
+ if (hasGitHubWorkflows(projectRoot)) {
21662
+ proposals.push({
21663
+ id: "gh-actions-debug",
21664
+ name: "GitHub Actions debugger",
21665
+ rationale: "Detected .github/workflows/ \u2014 a skill for inspecting CI failures and re-running jobs from Claude Code can shorten the debug loop.",
21666
+ hint: 'Search Claude Code skill registry for "gh-actions" or install via `claude skills add gh-actions-debug`.'
21667
+ });
21668
+ }
21669
+ if (hasDependencyMatching(deps, /^@sentry\//) || hasDependencyMatching(deps, /^@datadog\//)) {
21670
+ proposals.push({
21671
+ id: "error-tracking",
21672
+ name: "Error tracking helper",
21673
+ rationale: "Detected @sentry/* or @datadog/* in package.json \u2014 a skill that fetches recent error events into your context can speed up triage.",
21674
+ hint: 'Search Claude Code skill registry for "sentry" or "datadog".'
21675
+ });
21676
+ }
21677
+ if (hasVercelConfig(projectRoot)) {
21678
+ proposals.push({
21679
+ id: "read-vercel-logs",
21680
+ name: "Vercel deploy logs",
21681
+ rationale: "Detected vercel.json or .vercel/ \u2014 a skill for pulling deploy + runtime logs from Vercel directly into Claude Code helps when builds fail or runtime errors land.",
21682
+ hint: 'Search Claude Code skill registry for "vercel" or install `vercel:logs`.'
21683
+ });
21684
+ }
21685
+ if (envExampleMentionsStaging(projectRoot)) {
21686
+ proposals.push({
21687
+ id: "staging-environment",
21688
+ name: "Staging-environment helper",
21689
+ rationale: "Detected STAGING_* variables in .env.example \u2014 a skill for switching env contexts and seeding staging data can speed up pre-prod testing.",
21690
+ hint: 'Search Claude Code skill registry for "staging" or define your own.'
21691
+ });
21692
+ }
21693
+ return proposals;
21694
+ }
21695
+ function formatSkillProposals(proposals) {
21696
+ if (proposals.length === 0) return "";
21697
+ const lines = ["", "## Skill proposals", "_PAPI scanned your project once and noticed tooling that often pairs with a Claude Code skill. Each proposal is one-shot \u2014 they won't resurface._"];
21698
+ for (const p of proposals) {
21699
+ lines.push("");
21700
+ lines.push(`### ${p.name}`);
21701
+ lines.push(p.rationale);
21702
+ if (p.hint) {
21703
+ lines.push(`_${p.hint}_`);
21704
+ }
21705
+ lines.push("**[Yes]** install it now \xB7 **[Not now]** dismiss for this project");
21706
+ }
21707
+ return "\n" + lines.join("\n");
21708
+ }
21709
+
21519
21710
  // src/tools/doc-registry.ts
21520
- import { readdirSync as readdirSync4, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
21521
- import { join as join8, relative } from "path";
21711
+ import { readdirSync as readdirSync5, existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
21712
+ import { join as join9, relative } from "path";
21522
21713
  import { homedir as homedir2 } from "os";
21714
+ import { randomUUID as randomUUID15 } from "crypto";
21523
21715
  var docRegisterTool = {
21524
21716
  name: "doc_register",
21525
21717
  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.",
@@ -21662,12 +21854,12 @@ ${d.summary}
21662
21854
  ${lines.join("\n---\n\n")}`);
21663
21855
  }
21664
21856
  function scanMdFiles(dir, rootDir) {
21665
- if (!existsSync5(dir)) return [];
21857
+ if (!existsSync6(dir)) return [];
21666
21858
  const files = [];
21667
21859
  try {
21668
- const entries = readdirSync4(dir, { withFileTypes: true });
21860
+ const entries = readdirSync5(dir, { withFileTypes: true });
21669
21861
  for (const entry of entries) {
21670
- const full = join8(dir, entry.name);
21862
+ const full = join9(dir, entry.name);
21671
21863
  if (entry.isDirectory()) {
21672
21864
  files.push(...scanMdFiles(full, rootDir));
21673
21865
  } else if (entry.name.endsWith(".md")) {
@@ -21680,7 +21872,7 @@ function scanMdFiles(dir, rootDir) {
21680
21872
  }
21681
21873
  function extractTitle(filePath) {
21682
21874
  try {
21683
- const content = readFileSync2(filePath, "utf-8").slice(0, 1e3);
21875
+ const content = readFileSync3(filePath, "utf-8").slice(0, 1e3);
21684
21876
  const fmMatch = content.match(/^---[\s\S]*?title:\s*(.+?)$/m);
21685
21877
  if (fmMatch) return fmMatch[1].trim().replace(/^["']|["']$/g, "");
21686
21878
  const headingMatch = content.match(/^#+\s+(.+)$/m);
@@ -21696,17 +21888,17 @@ async function handleDocScan(adapter2, config2, args) {
21696
21888
  const includePlans = args.include_plans ?? false;
21697
21889
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
21698
21890
  const registeredPaths = new Set(registered.map((d) => d.path));
21699
- const docsDir = join8(config2.projectRoot, "docs");
21891
+ const docsDir = join9(config2.projectRoot, "docs");
21700
21892
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
21701
21893
  const unregisteredDocs = docsFiles.filter((f) => !registeredPaths.has(f));
21702
21894
  let unregisteredPlans = [];
21703
21895
  if (includePlans) {
21704
- const plansDir = join8(homedir2(), ".claude", "plans");
21705
- if (existsSync5(plansDir)) {
21896
+ const plansDir = join9(homedir2(), ".claude", "plans");
21897
+ if (existsSync6(plansDir)) {
21706
21898
  const planFiles = scanMdFiles(plansDir, plansDir);
21707
21899
  unregisteredPlans = planFiles.map((f) => `plans/${f}`).filter((f) => !registeredPaths.has(f)).map((f) => ({
21708
21900
  path: f,
21709
- title: extractTitle(join8(plansDir, f.replace("plans/", "")))
21901
+ title: extractTitle(join9(plansDir, f.replace("plans/", "")))
21710
21902
  }));
21711
21903
  }
21712
21904
  }
@@ -21717,7 +21909,7 @@ async function handleDocScan(adapter2, config2, args) {
21717
21909
  if (unregisteredDocs.length > 0) {
21718
21910
  lines.push(`## Unregistered Docs (${unregisteredDocs.length})`);
21719
21911
  for (const f of unregisteredDocs) {
21720
- const title = extractTitle(join8(config2.projectRoot, f));
21912
+ const title = extractTitle(join9(config2.projectRoot, f));
21721
21913
  lines.push(`- \`${f}\`${title ? ` \u2014 ${title}` : ""}`);
21722
21914
  }
21723
21915
  }
@@ -21730,10 +21922,106 @@ async function handleDocScan(adapter2, config2, args) {
21730
21922
  lines.push("", `Use \`doc_register\` to register these files.`);
21731
21923
  return textResponse(lines.join("\n"));
21732
21924
  }
21925
+ var docActionPromoteTool = {
21926
+ name: "doc_action_promote",
21927
+ description: "Promote a single pending action from a registered doc into a Backlog task. The new task gets a `Reference:` line pointing to the source doc, and the doc action is marked resolved with `linkedTaskId` set. Use to close the research-to-action loop \u2014 turn unactioned findings into trackable cycle work. Identify the doc by `doc_path` (preferred) or `doc_id`, and the action by 0-based `action_index` (as listed in `doc_search` output).",
21928
+ annotations: { readOnlyHint: false, destructiveHint: false },
21929
+ inputSchema: {
21930
+ type: "object",
21931
+ properties: {
21932
+ doc_path: { type: "string", description: 'Path of the source doc (e.g. "docs/research/funding-landscape.md"). Either this or doc_id is required.' },
21933
+ doc_id: { type: "string", description: "UUID of the source doc. Either this or doc_path is required." },
21934
+ action_index: { type: "number", description: "0-based index of the action in the doc's actions array." },
21935
+ title: { type: "string", description: "Optional task title override. Defaults to the action description (truncated)." },
21936
+ priority: { type: "string", enum: ["P0 Critical", "P1 High", "P2 Medium", "P3 Low"], description: 'Task priority. Defaults to "P2 Medium".' },
21937
+ complexity: { type: "string", enum: ["XS", "Small", "Medium", "Large", "XL"], description: 'Task complexity. Defaults to "Small".' },
21938
+ module: { type: "string", description: 'Task module. Defaults to "Core".' },
21939
+ epic: { type: "string", description: 'Task epic. Defaults to "Platform".' },
21940
+ notes: { type: "string", description: "Additional notes. A `Reference:` line pointing at the source doc is always prepended." }
21941
+ },
21942
+ required: ["action_index"]
21943
+ }
21944
+ };
21945
+ async function handleDocActionPromote(adapter2, args) {
21946
+ if (!adapter2.searchDocs || !adapter2.getDoc || !adapter2.updateDocAction) {
21947
+ return errorResponse("Doc registry not available \u2014 requires pg adapter.");
21948
+ }
21949
+ const docPath = args.doc_path;
21950
+ const docId = args.doc_id;
21951
+ const actionIndex = args.action_index;
21952
+ if (!docPath && !docId) {
21953
+ return errorResponse("Either doc_path or doc_id is required.");
21954
+ }
21955
+ if (typeof actionIndex !== "number" || actionIndex < 0) {
21956
+ return errorResponse("action_index is required and must be a non-negative number.");
21957
+ }
21958
+ const doc = await adapter2.getDoc(docId ?? docPath);
21959
+ if (!doc) {
21960
+ return errorResponse(`Doc not found: ${docId ?? docPath}`);
21961
+ }
21962
+ const actions = doc.actions ?? [];
21963
+ if (actionIndex >= actions.length) {
21964
+ return errorResponse(`action_index ${actionIndex} out of range \u2014 doc "${doc.title}" has ${actions.length} action(s).`);
21965
+ }
21966
+ const action = actions[actionIndex];
21967
+ if (action.status === "resolved") {
21968
+ return errorResponse(`Action ${actionIndex} on "${doc.title}" is already resolved${action.linkedTaskId ? ` (linked to ${action.linkedTaskId})` : ""}.`);
21969
+ }
21970
+ const VALID_PRIORITIES3 = /* @__PURE__ */ new Set(["P0 Critical", "P1 High", "P2 Medium", "P3 Low"]);
21971
+ const VALID_COMPLEXITIES2 = /* @__PURE__ */ new Set(["XS", "Small", "Medium", "Large", "XL"]);
21972
+ const priority = args.priority && VALID_PRIORITIES3.has(args.priority) ? args.priority : "P2 Medium";
21973
+ const complexity = args.complexity && VALID_COMPLEXITIES2.has(args.complexity) ? args.complexity : "Small";
21974
+ const taskModule = args.module || "Core";
21975
+ const taskEpic = args.epic || "Platform";
21976
+ const titleOverride = args.title?.trim();
21977
+ const fallbackTitle = action.description.length > 100 ? `${action.description.slice(0, 97)}\u2026` : action.description;
21978
+ const taskTitle = titleOverride || fallbackTitle;
21979
+ const referenceLine = `Reference: ${doc.path}`;
21980
+ const userNotes = args.notes?.trim();
21981
+ const notes = userNotes ? `${referenceLine}
21982
+
21983
+ ${userNotes}` : referenceLine;
21984
+ const phases = await adapter2.readPhases();
21985
+ const phase = resolveCurrentPhase(phases);
21986
+ const health = await adapter2.getCycleHealth();
21987
+ const task = await adapter2.createTask({
21988
+ uuid: randomUUID15(),
21989
+ displayId: "",
21990
+ title: taskTitle,
21991
+ status: "Backlog",
21992
+ priority,
21993
+ complexity,
21994
+ module: taskModule,
21995
+ epic: taskEpic,
21996
+ phase,
21997
+ owner: "TBD",
21998
+ reviewed: false,
21999
+ createdCycle: health.totalCycles,
22000
+ notes,
22001
+ taskType: "task",
22002
+ maturity: "raw",
22003
+ docRef: doc.path,
22004
+ source: "llm"
22005
+ });
22006
+ await adapter2.updateDocAction(doc.id, actionIndex, {
22007
+ status: "resolved",
22008
+ linkedTaskId: task.displayId || task.id
22009
+ });
22010
+ const remaining = actions.filter((a, i) => i !== actionIndex && a.status === "pending").length;
22011
+ const remainingNote = remaining > 0 ? `${remaining} pending action(s) still on this doc.` : "All actions on this doc are now resolved.";
22012
+ return textResponse(
22013
+ `**Promoted action \u2192 ${task.displayId || task.id}**
22014
+ - **Source doc:** ${doc.title} (${doc.path})
22015
+ - **Action:** ${action.description}
22016
+ - **Task:** ${taskTitle} [${priority} | ${complexity}]
22017
+ - **Module/Epic:** ${taskModule} / ${taskEpic}
22018
+ - ${remainingNote}`
22019
+ );
22020
+ }
21733
22021
 
21734
22022
  // src/services/session-guidance.ts
21735
- import { existsSync as existsSync6 } from "fs";
21736
- import { join as join9 } from "path";
22023
+ import { existsSync as existsSync7 } from "fs";
22024
+ import { join as join10 } from "path";
21737
22025
  var state = {
21738
22026
  toolCallCount: 0,
21739
22027
  lastOrientAt: null,
@@ -21754,8 +22042,8 @@ async function buildSessionGuidance(adapter2, projectRoot) {
21754
22042
  const signals = [];
21755
22043
  try {
21756
22044
  if (adapter2.searchDocs) {
21757
- const researchDir = join9(projectRoot, "docs", "research");
21758
- if (existsSync6(researchDir)) {
22045
+ const researchDir = join10(projectRoot, "docs", "research");
22046
+ if (existsSync7(researchDir)) {
21759
22047
  const files = scanMdFiles(researchDir, projectRoot);
21760
22048
  if (files.length > 0) {
21761
22049
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
@@ -21792,8 +22080,8 @@ async function buildSessionGuidance(adapter2, projectRoot) {
21792
22080
 
21793
22081
  // src/tools/orient.ts
21794
22082
  import { execFileSync as execFileSync3 } from "child_process";
21795
- import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync7 } from "fs";
21796
- import { join as join10 } from "path";
22083
+ import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync8 } from "fs";
22084
+ import { join as join11 } from "path";
21797
22085
  var GIT_DEPENDENT_ENVS = /* @__PURE__ */ new Set(["cowork", "api"]);
21798
22086
  var VALID_ENVS = /* @__PURE__ */ new Set(["claude-code", "cowork", "api", "unknown"]);
21799
22087
  function normaliseEnvironment(raw) {
@@ -21949,10 +22237,10 @@ async function getHierarchyPosition(adapter2) {
21949
22237
  adapter2.queryBoard()
21950
22238
  ]);
21951
22239
  if (horizons.length === 0) return void 0;
21952
- const isActive = (s) => s.status.toLowerCase() === "active";
21953
- const activeHorizon = horizons.find(isActive) || horizons[0];
22240
+ const isInProgress = (s) => s.status === "In Progress";
22241
+ const activeHorizon = horizons.find(isInProgress) || horizons[0];
21954
22242
  const horizonStages = stages.filter((s) => s.horizonId === activeHorizon.id).sort((a, b2) => (a.sortOrder ?? 0) - (b2.sortOrder ?? 0));
21955
- const activeStage = horizonStages.find(isActive) || horizonStages[0];
22243
+ const activeStage = horizonStages.find(isInProgress) || horizonStages.find((s) => s.status !== "Done") || horizonStages[0];
21956
22244
  if (!activeStage) return void 0;
21957
22245
  const stagePhases = phases.filter((p) => p.stageId === activeStage.id);
21958
22246
  const activePhases = stagePhases.filter((p) => p.status === "In Progress");
@@ -21993,8 +22281,8 @@ function getLatestGitTag(projectRoot) {
21993
22281
  }
21994
22282
  function checkNpmVersionDrift() {
21995
22283
  try {
21996
- const pkgPath = join10(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
21997
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
22284
+ const pkgPath = join11(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
22285
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf-8"));
21998
22286
  const localVersion = pkg.version;
21999
22287
  const packageName = pkg.name;
22000
22288
  const published = execFileSync3("npm", ["view", packageName, "version"], {
@@ -22010,6 +22298,44 @@ function checkNpmVersionDrift() {
22010
22298
  return null;
22011
22299
  }
22012
22300
  }
22301
+ var ALERT_CAP = 10;
22302
+ var UNACTIONED_CAP = 5;
22303
+ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
22304
+ const byRecency = (a, b2) => (b2.createdAt ?? "").localeCompare(a.createdAt ?? "");
22305
+ const unactionedAll = candidateLearnings.filter((l) => !closedTaskIds.has(l.taskId)).map((l) => ({ ...l, severity: l.severity ?? "P3" }));
22306
+ const allAlerts = unactionedAll.filter((l) => l.severity === "P0" || l.severity === "P1").sort(byRecency);
22307
+ const allLowSev = unactionedAll.filter((l) => l.severity === "P2" || l.severity === "P3").sort(byRecency);
22308
+ const totalP2 = allLowSev.filter((l) => l.severity === "P2").length;
22309
+ const totalP3 = allLowSev.filter((l) => l.severity === "P3").length;
22310
+ const alerts = allAlerts.slice(0, ALERT_CAP);
22311
+ const unactioned = allLowSev.slice(0, UNACTIONED_CAP);
22312
+ let alertsNote = "";
22313
+ let unactionedIssuesNote = "";
22314
+ if (allAlerts.length > 0) {
22315
+ const header = allAlerts.length > ALERT_CAP ? `${allAlerts.length} P0/P1 issues discovered during recent builds (showing ${ALERT_CAP} most recent). These are NOT pullable tasks \u2014 they're observations awaiting triage.` : `${allAlerts.length} P0/P1 issue${allAlerts.length !== 1 ? "s" : ""} discovered during recent builds. These are NOT pullable tasks \u2014 they're observations awaiting triage.`;
22316
+ const lines = ["\n\n## \u{1F6A8} Alerts \u2014 Discovered Issues from Recent Builds (not tasks)", header];
22317
+ for (const issue of alerts) {
22318
+ const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
22319
+ lines.push(`- **${issue.severity}**: ${desc}`);
22320
+ lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})`);
22321
+ }
22322
+ lines.push("_Escalate: run `idea` with P1 priority to log as a backlog task, or `board_edit` if already handled._");
22323
+ alertsNote = lines.join("\n");
22324
+ }
22325
+ if (allLowSev.length > 0) {
22326
+ const totalLow = totalP2 + totalP3;
22327
+ const header = totalLow > UNACTIONED_CAP ? `${totalP2} P2 \xB7 ${totalP3} P3 issues discovered during recent builds (showing ${UNACTIONED_CAP} most recent). These are NOT pullable tasks \u2014 they're observations awaiting triage.` : `${totalP2} P2 \xB7 ${totalP3} P3 issues discovered during recent builds. These are NOT pullable tasks \u2014 they're observations awaiting triage.`;
22328
+ const lines = ["\n\n## Discovered Issues from Recent Builds (not tasks)", header];
22329
+ for (const issue of unactioned) {
22330
+ const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
22331
+ lines.push(`- **${issue.severity}**: ${desc}`);
22332
+ lines.push(` \u21B3 discovered during ${issue.taskId} (C${issue.cycleNumber})`);
22333
+ }
22334
+ lines.push("_Run `idea` to log these as backlog tasks, or `board_edit` if already handled._");
22335
+ unactionedIssuesNote = lines.join("\n");
22336
+ }
22337
+ return { alertsNote, unactionedIssuesNote };
22338
+ }
22013
22339
  async function handleOrient(adapter2, config2, args = {}) {
22014
22340
  const environment = normaliseEnvironment(args.environment);
22015
22341
  try {
@@ -22120,7 +22446,7 @@ ${versionDrift}` : "";
22120
22446
  let unregisteredDocsNote = "";
22121
22447
  try {
22122
22448
  if (adapter2.searchDocs) {
22123
- const docsDir = join10(config2.projectRoot, "docs");
22449
+ const docsDir = join11(config2.projectRoot, "docs");
22124
22450
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
22125
22451
  if (docsFiles.length > 0) {
22126
22452
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
@@ -22171,7 +22497,7 @@ ${versionDrift}` : "";
22171
22497
  lines.push(` \u2192 \u2026and ${pendingActions.length - 2} more`);
22172
22498
  }
22173
22499
  }
22174
- lines.push("_Factor into next `strategy_review` or run `doc_search` for details._");
22500
+ lines.push("_Promote an actionable item now via `doc_action_promote`, or factor into next `strategy_review`. Run `doc_search` for full details._");
22175
22501
  researchSignalsNote = lines.join("\n");
22176
22502
  }
22177
22503
  }
@@ -22215,7 +22541,6 @@ ${versionDrift}` : "";
22215
22541
  try {
22216
22542
  const learnings = await adapter2.getCycleLearnings?.({ category: "issue", limit: 30 });
22217
22543
  if (learnings) {
22218
- const byRecency = (a, b2) => (b2.createdAt ?? "").localeCompare(a.createdAt ?? "");
22219
22544
  const candidateLearnings = learnings.filter((l) => !l.actionTaken);
22220
22545
  const referencedTaskIds = Array.from(new Set(candidateLearnings.map((l) => l.taskId).filter(Boolean)));
22221
22546
  let closedTaskIds = /* @__PURE__ */ new Set();
@@ -22228,35 +22553,26 @@ ${versionDrift}` : "";
22228
22553
  } catch {
22229
22554
  }
22230
22555
  }
22231
- const unactionedAll = candidateLearnings.filter((l) => !closedTaskIds.has(l.taskId)).map((l) => ({ ...l, severity: l.severity ?? "P3" }));
22232
- const allAlerts = unactionedAll.filter((l) => l.severity === "P0" || l.severity === "P1").sort(byRecency);
22233
- const allLowSev = unactionedAll.filter((l) => l.severity === "P2" || l.severity === "P3").sort(byRecency);
22234
- const totalP2 = allLowSev.filter((l) => l.severity === "P2").length;
22235
- const totalP3 = allLowSev.filter((l) => l.severity === "P3").length;
22236
- const ALERT_CAP = 10;
22237
- const UNACTIONED_CAP = 5;
22238
- const alerts = allAlerts.slice(0, ALERT_CAP);
22239
- const unactioned = allLowSev.slice(0, UNACTIONED_CAP);
22240
- if (allAlerts.length > 0) {
22241
- const header = allAlerts.length > ALERT_CAP ? `${allAlerts.length} P0/P1 discovered issues awaiting action (showing ${ALERT_CAP} most recent).` : `${allAlerts.length} P0/P1 discovered issue${allAlerts.length !== 1 ? "s" : ""} awaiting action.`;
22242
- const lines = ["\n\n## \u{1F6A8} Alerts", header];
22243
- for (const issue of alerts) {
22244
- const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
22245
- lines.push(`- **${issue.severity}** (C${issue.cycleNumber} / ${issue.taskId}): ${desc}`);
22246
- }
22247
- lines.push("_Escalate: run `idea` with P1 priority, or `board_edit` if already handled._");
22248
- alertsNote = lines.join("\n");
22249
- }
22250
- if (allLowSev.length > 0) {
22251
- const totalLow = totalP2 + totalP3;
22252
- const header = totalLow > UNACTIONED_CAP ? `${totalP2} P2 \xB7 ${totalP3} P3 (showing ${UNACTIONED_CAP} most recent)` : `${totalP2} P2 \xB7 ${totalP3} P3`;
22253
- const lines = ["\n\n## Unactioned Issues", header];
22254
- for (const issue of unactioned) {
22255
- const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
22256
- lines.push(`- **${issue.severity}** (C${issue.cycleNumber} / ${issue.taskId}): ${desc}`);
22257
- }
22258
- lines.push("_Run `idea` to log these as backlog tasks, or `board_edit` if already handled._");
22259
- unactionedIssuesNote = lines.join("\n");
22556
+ const formatted = formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds);
22557
+ alertsNote = formatted.alertsNote;
22558
+ unactionedIssuesNote = formatted.unactionedIssuesNote;
22559
+ }
22560
+ } catch {
22561
+ }
22562
+ let skillProposalsNote = "";
22563
+ try {
22564
+ const metrics = await adapter2.readToolMetrics();
22565
+ const alreadyScanned = metrics.some((m) => m.tool === "milestone:skill_scan_completed");
22566
+ if (!alreadyScanned) {
22567
+ const proposals = scanForSkillSignals(config2.projectRoot);
22568
+ skillProposalsNote = formatSkillProposals(proposals);
22569
+ try {
22570
+ await adapter2.appendToolMetric({
22571
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
22572
+ tool: "milestone:skill_scan_completed",
22573
+ durationMs: 0
22574
+ });
22575
+ } catch {
22260
22576
  }
22261
22577
  }
22262
22578
  } catch {
@@ -22272,16 +22588,16 @@ ${versionDrift}` : "";
22272
22588
  markOrient();
22273
22589
  } catch {
22274
22590
  }
22275
- return textResponse(formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment) + alertsNote + ttfvNote + reconciliationNote + unrecordedNote + unregisteredDocsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + sessionGuidanceNote + versionNote + enrichmentNote);
22591
+ return textResponse(formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment) + alertsNote + ttfvNote + reconciliationNote + unrecordedNote + unregisteredDocsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + versionNote + enrichmentNote);
22276
22592
  } catch (err) {
22277
22593
  const message = err instanceof Error ? err.message : String(err);
22278
22594
  return errorResponse(`Orient failed: ${message}`);
22279
22595
  }
22280
22596
  }
22281
22597
  function enrichClaudeMd(projectRoot, cycleNumber) {
22282
- const claudeMdPath = join10(projectRoot, "CLAUDE.md");
22283
- if (!existsSync7(claudeMdPath)) return "";
22284
- const content = readFileSync3(claudeMdPath, "utf-8");
22598
+ const claudeMdPath = join11(projectRoot, "CLAUDE.md");
22599
+ if (!existsSync8(claudeMdPath)) return "";
22600
+ const content = readFileSync4(claudeMdPath, "utf-8");
22285
22601
  const additions = [];
22286
22602
  if (cycleNumber >= 6 && !content.includes(CLAUDE_MD_ENRICHMENT_SENTINEL_T1)) {
22287
22603
  additions.push(CLAUDE_MD_TIER_1);
@@ -23063,12 +23379,12 @@ ${result.userMessage}
23063
23379
  }
23064
23380
 
23065
23381
  // src/lib/install-id.ts
23066
- import { randomUUID as randomUUID15 } from "crypto";
23067
- import { mkdirSync, readFileSync as readFileSync4, writeFileSync as writeFileSync2, chmodSync } from "fs";
23382
+ import { randomUUID as randomUUID16 } from "crypto";
23383
+ import { mkdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync2, chmodSync } from "fs";
23068
23384
  import { homedir as homedir3 } from "os";
23069
- import { join as join11 } from "path";
23070
- var PAPI_HOME_DIR = join11(homedir3(), ".papi");
23071
- var INSTALL_ID_FILE = join11(PAPI_HOME_DIR, "install-id.json");
23385
+ import { join as join12 } from "path";
23386
+ var PAPI_HOME_DIR = join12(homedir3(), ".papi");
23387
+ var INSTALL_ID_FILE = join12(PAPI_HOME_DIR, "install-id.json");
23072
23388
  var cachedInstallId = null;
23073
23389
  function isValidUuid(s) {
23074
23390
  return typeof s === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
@@ -23076,7 +23392,7 @@ function isValidUuid(s) {
23076
23392
  function getInstallId() {
23077
23393
  if (cachedInstallId) return cachedInstallId;
23078
23394
  try {
23079
- const raw = readFileSync4(INSTALL_ID_FILE, "utf-8");
23395
+ const raw = readFileSync5(INSTALL_ID_FILE, "utf-8");
23080
23396
  const parsed = JSON.parse(raw);
23081
23397
  if (isValidUuid(parsed.install_id)) {
23082
23398
  cachedInstallId = parsed.install_id;
@@ -23086,7 +23402,7 @@ function getInstallId() {
23086
23402
  }
23087
23403
  try {
23088
23404
  mkdirSync(PAPI_HOME_DIR, { recursive: true, mode: 448 });
23089
- const id = randomUUID15();
23405
+ const id = randomUUID16();
23090
23406
  const contents = {
23091
23407
  install_id: id,
23092
23408
  created_at: (/* @__PURE__ */ new Date()).toISOString()
@@ -23206,7 +23522,7 @@ function createServer(adapter2, config2) {
23206
23522
  const __pkgDir = dirname(__pkgFilename);
23207
23523
  let serverVersion = "unknown";
23208
23524
  try {
23209
- const pkg = JSON.parse(readFileSync5(join12(__pkgDir, "..", "package.json"), "utf-8"));
23525
+ const pkg = JSON.parse(readFileSync6(join13(__pkgDir, "..", "package.json"), "utf-8"));
23210
23526
  serverVersion = pkg.version ?? "unknown";
23211
23527
  } catch {
23212
23528
  }
@@ -23221,7 +23537,7 @@ function createServer(adapter2, config2) {
23221
23537
  }
23222
23538
  const __filename = fileURLToPath(import.meta.url);
23223
23539
  const __dirname2 = dirname(__filename);
23224
- const skillsDir = join12(__dirname2, "..", "skills");
23540
+ const skillsDir = join13(__dirname2, "..", "skills");
23225
23541
  function parseSkillFrontmatter(content) {
23226
23542
  const match = content.match(/^---\n([\s\S]*?)\n---/);
23227
23543
  if (!match) return null;
@@ -23239,7 +23555,7 @@ function createServer(adapter2, config2) {
23239
23555
  const mdFiles = files.filter((f) => f.endsWith(".md"));
23240
23556
  const prompts = [];
23241
23557
  for (const file of mdFiles) {
23242
- const content = await readFile5(join12(skillsDir, file), "utf-8");
23558
+ const content = await readFile5(join13(skillsDir, file), "utf-8");
23243
23559
  const meta = parseSkillFrontmatter(content);
23244
23560
  if (meta) {
23245
23561
  prompts.push({ name: meta.name, description: meta.description });
@@ -23255,7 +23571,7 @@ function createServer(adapter2, config2) {
23255
23571
  try {
23256
23572
  const files = await readdir2(skillsDir);
23257
23573
  for (const file of files.filter((f) => f.endsWith(".md"))) {
23258
- const content = await readFile5(join12(skillsDir, file), "utf-8");
23574
+ const content = await readFile5(join13(skillsDir, file), "utf-8");
23259
23575
  const meta = parseSkillFrontmatter(content);
23260
23576
  if (meta?.name === name) {
23261
23577
  const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
@@ -23298,6 +23614,7 @@ function createServer(adapter2, config2) {
23298
23614
  docRegisterTool,
23299
23615
  docSearchTool,
23300
23616
  docScanTool,
23617
+ docActionPromoteTool,
23301
23618
  getSiblingAdsTool,
23302
23619
  handoffGenerateTool
23303
23620
  ]
@@ -23413,6 +23730,9 @@ function createServer(adapter2, config2) {
23413
23730
  case "doc_scan":
23414
23731
  result = await handleDocScan(adapter2, config2, safeArgs);
23415
23732
  break;
23733
+ case "doc_action_promote":
23734
+ result = await handleDocActionPromote(adapter2, safeArgs);
23735
+ break;
23416
23736
  case "get_sibling_ads":
23417
23737
  result = await handleGetSiblingAds(adapter2, safeArgs);
23418
23738
  break;
@@ -23468,7 +23788,7 @@ function createServer(adapter2, config2) {
23468
23788
  var __dirname = dirname2(fileURLToPath2(import.meta.url));
23469
23789
  var pkgVersion = "unknown";
23470
23790
  try {
23471
- const pkg = JSON.parse(readFileSync6(join13(__dirname, "..", "package.json"), "utf-8"));
23791
+ const pkg = JSON.parse(readFileSync7(join14(__dirname, "..", "package.json"), "utf-8"));
23472
23792
  pkgVersion = pkg.version;
23473
23793
  } catch {
23474
23794
  }
package/dist/prompts.js CHANGED
@@ -193,6 +193,7 @@ Standard planning cycle with full board review.
193
193
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
194
194
  **\u26A0\uFE0F PRIORITY RECALIBRATION \u2014 do NOT rubber-stamp the submitted priority.** The priority set at idea submission reflects the submitter's view at that time, which may be outdated by the time the planner runs. For EVERY unreviewed task, evaluate its priority FROM SCRATCH against: (a) current horizon/stage/phase goals, (b) recent Active Decision changes, (c) recently shipped functionality that makes this task more or less urgent. If your assessed priority differs from the submitted one, set the new priority in \`boardCorrections\` and include the change in a **Priority Recalibration** paragraph in your cycle log (Step 8): list each changed task by ID, old priority \u2192 new priority, and a 1-sentence rationale. This paragraph is how the user sees what the planner recalibrated and why. If no priorities changed during triage, omit the paragraph.
195
195
  Also set complexity using the full range \u2014 **XS, Small, Medium, Large, XL** \u2014 based on actual scope, not conservatively. XS = single-line or config change. Small = one file, < 50 lines. Medium = 2-5 files. Large = cross-module, multiple components. XL = architectural, multi-day.
196
+ **Module classification for cross-cutting tasks:** When a task title contains "audit"/"unfiltered"/"scoping"/"leak" plus a database-entity name (e.g. "audit ... cycle_learnings reads", "unfiltered cycle_tasks queries"), classify the module by the actual code surface that reads/writes the entity \u2014 not by the tool names mentioned in the title. The reasoning surface (e.g. "health" or "strategy_review") is often unrelated to the data-access surface. Resolve this by treating the entity name as the routing signal: tasks touching dashboard read/write paths belong to the Dashboard module even if the title mentions an MCP tool. Misclassification routes the task to the wrong shared cycle branch and surfaces the wrong MODULE INSTRUCTIONS to the builder.
196
197
  If a task is clearly obsolete, duplicated, or rejected, set its status to "Cancelled" with a \`closureReason\` explaining why.
197
198
  **\u2192 PERSIST:** For each task you set reviewed: true, corrected fields on, or marked "Cancelled", include it in \`boardCorrections\` in Part 2.
198
199
 
@@ -431,6 +432,7 @@ Standard planning cycle with full board review.
431
432
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
432
433
  **\u26A0\uFE0F PRIORITY RECALIBRATION \u2014 do NOT rubber-stamp the submitted priority.** The priority set at idea submission reflects the submitter's view at that time, which may be outdated by the time the planner runs. For EVERY unreviewed task, evaluate its priority FROM SCRATCH against: (a) current horizon/stage/phase goals, (b) recent Active Decision changes, (c) recently shipped functionality that makes this task more or less urgent. If your assessed priority differs from the submitted one, set the new priority in \`boardCorrections\` and include the change in a **Priority Recalibration** paragraph in your cycle log (Step 8): list each changed task by ID, old priority \u2192 new priority, and a 1-sentence rationale. This paragraph is how the user sees what the planner recalibrated and why. If no priorities changed during triage, omit the paragraph.
433
434
  Also set complexity using the full range \u2014 **XS, Small, Medium, Large, XL** \u2014 based on actual scope, not conservatively. XS = single-line or config change. Small = one file, < 50 lines. Medium = 2-5 files. Large = cross-module, multiple components. XL = architectural, multi-day.
435
+ **Module classification for cross-cutting tasks:** When a task title contains "audit"/"unfiltered"/"scoping"/"leak" plus a database-entity name (e.g. "audit ... cycle_learnings reads", "unfiltered cycle_tasks queries"), classify the module by the actual code surface that reads/writes the entity \u2014 not by the tool names mentioned in the title. The reasoning surface (e.g. "health" or "strategy_review") is often unrelated to the data-access surface. Resolve this by treating the entity name as the routing signal: tasks touching dashboard read/write paths belong to the Dashboard module even if the title mentions an MCP tool. Misclassification routes the task to the wrong shared cycle branch and surfaces the wrong MODULE INSTRUCTIONS to the builder.
434
436
  If a task is clearly obsolete, duplicated, or rejected, set its status to "Cancelled" with a \`closureReason\` explaining why.
435
437
  **\u2192 PERSIST:** For each task you set reviewed: true, corrected fields on, or marked "Cancelled", include it in \`boardCorrections\` in Part 2.
436
438
 
@@ -645,6 +647,16 @@ function buildPlanUserMessage(ctx) {
645
647
  if (ctx.registeredDocs) {
646
648
  parts.push("### Relevant Research Docs", "", ctx.registeredDocs, "");
647
649
  }
650
+ if (ctx.unactionedDocs) {
651
+ parts.push(
652
+ "### Unactioned Research (soft-warn)",
653
+ "",
654
+ "These registered docs still have pending actions. Before adding net-new tasks, consider whether one of these unactioned items should be promoted into this cycle via `doc_action_promote`. Surface this in your plan output so the user can decide.",
655
+ "",
656
+ ctx.unactionedDocs,
657
+ ""
658
+ );
659
+ }
648
660
  if (ctx.carryForwardStaleness) {
649
661
  parts.push("### Carry-Forward Staleness", "", ctx.carryForwardStaleness, "");
650
662
  }
@@ -857,6 +869,13 @@ ${AD_REJECTION_RULES}
857
869
 
858
870
  **Registered Documents:** If a "### Registered Documents" section is present in context, scan it for: (a) research findings that contradict current ADs or strategy, (b) unactioned research that should influence the next plan. Reference relevant docs by title in your review. If unregistered docs are listed, flag 1-2 that look strategically relevant and suggest registering them.
859
871
 
872
+ **Doc Action Staleness:** If a "### Doc Action Staleness" section is present, treat it as a research-to-action audit. For each entry:
873
+ - **Stale** (no Done task, >20 cycles old): explicitly call it out in section 2 (Product Gaps) or 3 (Opportunities) \u2014 research that's been sitting unactioned this long is either (a) no longer relevant (recommend marking the doc archived) or (b) silently dropped (recommend promoting via \`doc_action_promote\` into the next plan).
874
+ - **Completed but not closed** (linked task is Done): instruct in your review that the action should be marked resolved \u2014 it's done work the registry hasn't caught up to.
875
+ - **Deferred** (<20 cycles, no matching Done task): only mention if it relates to a current strategic theme; otherwise leave it.
876
+
877
+ For every stale or unresolved item you call out, propose a concrete next step (promote, archive, or supersede) \u2014 don't just observe.
878
+
860
879
  ## CONDITIONAL SECTIONS (include only when genuinely useful \u2014 most reviews should have 0-2 of these)
861
880
 
862
881
  6. **Security Posture Review** \u2014 Only if \`[SECURITY]\` tags exist in recent cycle logs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.14",
3
+ "version": "0.7.15",
4
4
  "description": "PAPI MCP server — AI-powered sprint planning, build execution, and strategy review for software projects",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",