@papi-ai/server 0.7.13 → 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
@@ -10,7 +10,12 @@ var __export = (target, all) => {
10
10
  };
11
11
 
12
12
  // ../shared/dist/index.js
13
- var VALID_TRANSITIONS;
13
+ function isLiveDecision(d) {
14
+ if (d.superseded === true) return false;
15
+ if (d.outcome != null && RETIRED_DECISION_OUTCOMES.includes(d.outcome)) return false;
16
+ return true;
17
+ }
18
+ var VALID_TRANSITIONS, RETIRED_DECISION_OUTCOMES;
14
19
  var init_dist = __esm({
15
20
  "../shared/dist/index.js"() {
16
21
  "use strict";
@@ -25,6 +30,7 @@ var init_dist = __esm({
25
30
  "Cancelled": [],
26
31
  "Deferred": ["Backlog", "Cancelled"]
27
32
  };
33
+ RETIRED_DECISION_OUTCOMES = ["resolved", "abandoned", "superseded"];
28
34
  }
29
35
  });
30
36
 
@@ -1441,12 +1447,13 @@ async function detectReviewPatterns(reviews, currentCycle, window = 5, clusterer
1441
1447
  function hasReviewPatterns(patterns) {
1442
1448
  return patterns.recurringFeedback.length > 0 || patterns.requestChangesRate >= 50;
1443
1449
  }
1444
- var VALID_TRANSITIONS2, TASK_TYPE_TIERS, VALID_EFFORT_SIZES, SECTION_HEADERS, YAML_MARKER, YAML_START, YAML_END, VALID_EFFORT_SIZES2, HEADER_SENTINEL, TABLE_HEADER, TABLE_SEPARATOR, PREV_TABLE_HEADER, LEGACY_TABLE_HEADER, SECTION_HEADING, FILE_TEMPLATE, COST_SECTION_HEADING, COST_TABLE_SEPARATOR, FILE_HEADING, ACCURACY_HEADER, ACCURACY_SEPARATOR, VELOCITY_HEADER, VELOCITY_SEPARATOR, EFFORT_SCALE, NONE_PATTERN, HEADER_SENTINEL2, VALID_STAGES, VALID_VERDICTS, STAGE_DISPLAY, VALID_STATUSES, PHASES_START, PHASES_END, YAML_MARKER2, YAML_START2, YAML_END2, VALID_STATUSES2, YAML_MARKER3, YAML_START3, YAML_END3, MdFileAdapter, NONE_PATTERN2;
1450
+ var VALID_TRANSITIONS2, isLiveDecision2, TASK_TYPE_TIERS, VALID_EFFORT_SIZES, SECTION_HEADERS, YAML_MARKER, YAML_START, YAML_END, VALID_EFFORT_SIZES2, HEADER_SENTINEL, TABLE_HEADER, TABLE_SEPARATOR, PREV_TABLE_HEADER, LEGACY_TABLE_HEADER, SECTION_HEADING, FILE_TEMPLATE, COST_SECTION_HEADING, COST_TABLE_SEPARATOR, FILE_HEADING, ACCURACY_HEADER, ACCURACY_SEPARATOR, VELOCITY_HEADER, VELOCITY_SEPARATOR, EFFORT_SCALE, NONE_PATTERN, HEADER_SENTINEL2, VALID_STAGES, VALID_VERDICTS, STAGE_DISPLAY, VALID_STATUSES, PHASES_START, PHASES_END, YAML_MARKER2, YAML_START2, YAML_END2, VALID_STATUSES2, YAML_MARKER3, YAML_START3, YAML_END3, MdFileAdapter, NONE_PATTERN2;
1445
1451
  var init_dist2 = __esm({
1446
1452
  "../adapter-md/dist/index.js"() {
1447
1453
  "use strict";
1448
1454
  init_dist();
1449
1455
  VALID_TRANSITIONS2 = VALID_TRANSITIONS;
1456
+ isLiveDecision2 = isLiveDecision;
1450
1457
  TASK_TYPE_TIERS = {
1451
1458
  bug: 1,
1452
1459
  task: 1,
@@ -1557,11 +1564,18 @@ ${TABLE_SEPARATOR}
1557
1564
  async getCycleHealth() {
1558
1565
  return parseCycleHealth(await this.read("PLANNING_LOG.md"));
1559
1566
  }
1560
- /** Read all Active Decisions from ACTIVE_DECISIONS.md. */
1561
- async getActiveDecisions() {
1567
+ /**
1568
+ * Read Active Decisions from ACTIVE_DECISIONS.md.
1569
+ *
1570
+ * Default filters out retired ADs (outcome ∈ abandoned/superseded/resolved or superseded=true).
1571
+ * Pass { includeRetired: true } for management/triage surfaces. See PapiAdapter docstring.
1572
+ */
1573
+ async getActiveDecisions(options) {
1562
1574
  const content = await this.readOptional("ACTIVE_DECISIONS.md");
1563
1575
  if (!content) return [];
1564
- return parseActiveDecisions(content);
1576
+ const all = parseActiveDecisions(content);
1577
+ if (options?.includeRetired) return all;
1578
+ return all.filter(isLiveDecision2);
1565
1579
  }
1566
1580
  /** Read cycle log entries (newest first), optionally limited to {@link limit} entries. */
1567
1581
  async getCycleLog(limit) {
@@ -6352,11 +6366,31 @@ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
6352
6366
  lastFullMode: 0
6353
6367
  };
6354
6368
  }
6355
- async getActiveDecisions() {
6369
+ /**
6370
+ * Read Active Decisions for this project.
6371
+ *
6372
+ * Default filters out retired ADs (outcome ∈ abandoned/superseded/resolved or superseded=true).
6373
+ * Pass { includeRetired: true } for management/triage surfaces. See PapiAdapter docstring.
6374
+ *
6375
+ * task-1546 (C242 hot-fix): closes the bug where retired ADs leaked into context surfaces.
6376
+ */
6377
+ async getActiveDecisions(options) {
6378
+ if (options?.includeRetired) {
6379
+ const rows2 = await this.sql`
6380
+ SELECT id, display_id, title, confidence, superseded, superseded_by, created_cycle, modified_cycle, body, outcome, revision_count
6381
+ FROM active_decisions
6382
+ WHERE project_id = ${this.projectId}
6383
+ ORDER BY display_id
6384
+ LIMIT 200
6385
+ `;
6386
+ return rows2.map(rowToActiveDecision);
6387
+ }
6356
6388
  const rows = await this.sql`
6357
6389
  SELECT id, display_id, title, confidence, superseded, superseded_by, created_cycle, modified_cycle, body, outcome, revision_count
6358
6390
  FROM active_decisions
6359
6391
  WHERE project_id = ${this.projectId}
6392
+ AND superseded = false
6393
+ AND (outcome IS NULL OR outcome NOT IN ('abandoned', 'superseded', 'resolved'))
6360
6394
  ORDER BY display_id
6361
6395
  LIMIT 200 -- bounded: ADs are bounded by project lifecycle, 200 is a safe ceiling
6362
6396
  `;
@@ -6699,6 +6733,25 @@ ${newParts.join("\n")}` : newParts.join("\n");
6699
6733
  superseded_by = ${supersededBy ?? null},
6700
6734
  updated_at = now()
6701
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}
6702
6755
  `;
6703
6756
  }
6704
6757
  async writeDogfoodEntries(entries) {
@@ -7573,7 +7626,7 @@ ${newParts.join("\n")}` : newParts.join("\n");
7573
7626
  const rows = await this.sql`
7574
7627
  SELECT id, slug, label, description, status, sort_order, horizon_id, project_id, exit_criteria, created_at, updated_at
7575
7628
  FROM stages
7576
- WHERE project_id = ${this.projectId} AND status = 'Active'
7629
+ WHERE project_id = ${this.projectId} AND status = 'In Progress'
7577
7630
  ORDER BY sort_order
7578
7631
  LIMIT 1
7579
7632
  `;
@@ -8575,8 +8628,8 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
8575
8628
  getCycleHealth() {
8576
8629
  return this.invoke("getCycleHealth");
8577
8630
  }
8578
- getActiveDecisions() {
8579
- return this.invoke("getActiveDecisions");
8631
+ getActiveDecisions(options) {
8632
+ return this.invoke("getActiveDecisions", [options ?? {}]);
8580
8633
  }
8581
8634
  getCycleLog(limit) {
8582
8635
  return this.invoke("getCycleLog", [limit]);
@@ -9452,9 +9505,9 @@ var init_git = __esm({
9452
9505
  });
9453
9506
 
9454
9507
  // src/index.ts
9455
- import { readFileSync as readFileSync6 } from "fs";
9508
+ import { readFileSync as readFileSync7 } from "fs";
9456
9509
  import { createServer as createHttpServer } from "http";
9457
- import { dirname as dirname2, join as join13 } from "path";
9510
+ import { dirname as dirname2, join as join14 } from "path";
9458
9511
  import { fileURLToPath as fileURLToPath2 } from "url";
9459
9512
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9460
9513
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -9741,9 +9794,9 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
9741
9794
  }
9742
9795
 
9743
9796
  // src/server.ts
9744
- import { readFileSync as readFileSync5 } from "fs";
9797
+ import { readFileSync as readFileSync6 } from "fs";
9745
9798
  import { access as access4, readdir as readdir2, readFile as readFile5 } from "fs/promises";
9746
- import { join as join12, dirname } from "path";
9799
+ import { join as join13, dirname } from "path";
9747
9800
  import { fileURLToPath } from "url";
9748
9801
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9749
9802
  import {
@@ -10535,6 +10588,20 @@ async function sendSlackWebhook(webhookUrl, summary, header = "PAPI Strategy Rev
10535
10588
  }
10536
10589
 
10537
10590
  // src/prompts.ts
10591
+ var AD_REJECTION_RULES = `**AD Minting Guard \u2014 REJECT observations dressed as decisions.**
10592
+
10593
+ An Active Decision expresses a *stance* the project is taking \u2014 a choice between alternatives that constrains future work. Reject any candidate AD whose body asserts:
10594
+ (a) the existence, identity, or status of a person, user, or external entity (e.g. "User X is building Y", "Customer Z is active");
10595
+ (b) a metric or measurement (e.g. "Signups grew 3x last cycle", "Latency dropped to 200ms");
10596
+ (c) a fact about the current state of the world that could be confirmed or denied by a query rather than challenged by argument (e.g. "External user feedback is now flowing", "The /admin route exists").
10597
+
10598
+ If a candidate AD body could be invalidated by running a SQL query, refreshing a dashboard, or checking a log \u2014 it is an observation, not a decision. Capture it as a build report finding, a dogfood observation, a cycle log note, or a registered doc instead. Do NOT mint it as an AD.
10599
+
10600
+ **Positive example (valid AD):** "Pricing tier strategy: free engine + paid intelligence. Decision: keep cycles free, charge for strategy reviews and analytics. Why: telemetry shows engagement clusters around intelligence surfaces, not engine surfaces." \u2014 this is a stance with alternatives.
10601
+
10602
+ **Negative example (reject):** "External user feedback is now flowing. Stonebridge Systems is actively building." \u2014 this is a fact about the current state of the world. Capture as dogfood/signal observation; do not mint.
10603
+
10604
+ This rule applies to: new ADs proposed during planning (Step 9), strategy review AD updates (section 5), and strategy_change AD updates. If you find an existing AD that violates this rule during housekeeping, propose deleting it (action: "delete") with a one-line rationale.`;
10538
10605
  var PLAN_SYSTEM = `You are the PAPI Cycle Planner \u2014 an autonomous planning engine for software projects.
10539
10606
  You receive project context and produce a planning cycle output with a BUILD HANDOFF.
10540
10607
 
@@ -10715,6 +10782,7 @@ Standard planning cycle with full board review.
10715
10782
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
10716
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.
10717
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.
10718
10786
  If a task is clearly obsolete, duplicated, or rejected, set its status to "Cancelled" with a \`closureReason\` explaining why.
10719
10787
  **\u2192 PERSIST:** For each task you set reviewed: true, corrected fields on, or marked "Cancelled", include it in \`boardCorrections\` in Part 2.
10720
10788
 
@@ -10760,6 +10828,9 @@ Standard planning cycle with full board review.
10760
10828
 
10761
10829
  9. **Active Decisions** \u2014 If any AD needs updating: Type A (confidence change), Type B (modification), or Type C (reversal/supersede).
10762
10830
  **AD Quality Bar:** ADs are for product and architecture choices that constrain future work \u2014 technology selections, data model designs, UX principles, strategic positioning. They are NOT for: process preferences (commit style, PR size), configuration choices (linter rules, tab width), or temporary workarounds. If a decision doesn't affect what gets built or how it's architected, it's not an AD. Apply this bar when proposing new ADs and when triaging existing ones.
10831
+
10832
+ ${AD_REJECTION_RULES}
10833
+
10763
10834
  **\u2192 PERSIST:** EVERY AD you created, updated, or confirmed with changes MUST appear in \`activeDecisions\` array in Part 2. Include the full replacement body with ### heading.
10764
10835
 
10765
10836
  ### Operational Quality Rules
@@ -10950,6 +11021,7 @@ Standard planning cycle with full board review.
10950
11021
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
10951
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.
10952
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.
10953
11025
  If a task is clearly obsolete, duplicated, or rejected, set its status to "Cancelled" with a \`closureReason\` explaining why.
10954
11026
  **\u2192 PERSIST:** For each task you set reviewed: true, corrected fields on, or marked "Cancelled", include it in \`boardCorrections\` in Part 2.
10955
11027
 
@@ -10997,6 +11069,9 @@ Standard planning cycle with full board review.
10997
11069
 
10998
11070
  9. **Active Decisions** \u2014 If any AD needs updating: Type A (confidence change), Type B (modification), or Type C (reversal/supersede).
10999
11071
  **AD Quality Bar:** ADs are for product and architecture choices that constrain future work \u2014 technology selections, data model designs, UX principles, strategic positioning. They are NOT for: process preferences (commit style, PR size), configuration choices (linter rules, tab width), or temporary workarounds. If a decision doesn't affect what gets built or how it's architected, it's not an AD. Apply this bar when proposing new ADs and when triaging existing ones.
11072
+
11073
+ ${AD_REJECTION_RULES}
11074
+
11000
11075
  **\u2192 PERSIST:** EVERY AD you created, updated, or confirmed with changes MUST appear in \`activeDecisions\` array in Part 2. Include the full replacement body with ### heading.
11001
11076
 
11002
11077
  ### Operational Quality Rules
@@ -11161,6 +11236,16 @@ function buildPlanUserMessage(ctx) {
11161
11236
  if (ctx.registeredDocs) {
11162
11237
  parts.push("### Relevant Research Docs", "", ctx.registeredDocs, "");
11163
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
+ }
11164
11249
  if (ctx.carryForwardStaleness) {
11165
11250
  parts.push("### Carry-Forward Staleness", "", ctx.carryForwardStaleness, "");
11166
11251
  }
@@ -11369,8 +11454,17 @@ You MUST cover these 5 sections. Each is mandatory.
11369
11454
  - Note any hierarchy/phase issues worth correcting (1-2 bullets max)
11370
11455
  - Delete ADs that are legacy, process-level, or redundant without discussion
11371
11456
 
11457
+ ${AD_REJECTION_RULES}
11458
+
11372
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.
11373
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
+
11374
11468
  ## CONDITIONAL SECTIONS (include only when genuinely useful \u2014 most reviews should have 0-2 of these)
11375
11469
 
11376
11470
  6. **Security Posture Review** \u2014 Only if \`[SECURITY]\` tags exist in recent cycle logs.
@@ -11678,6 +11772,8 @@ The JSON must be valid. Only include ADs that need changes \u2014 omit unchanged
11678
11772
  For new ADs, use the next available AD number.
11679
11773
  The body field must be the COMPLETE replacement text for the AD block (including the ### heading line).
11680
11774
 
11775
+ ${AD_REJECTION_RULES}
11776
+
11681
11777
  ## PHASE UPDATES
11682
11778
 
11683
11779
  If the strategic change affects the project's phase structure, include a phaseUpdates array.
@@ -12050,6 +12146,7 @@ function applyContextTier(ctx, cycleCount) {
12050
12146
  ctx.reviewPatterns = void 0;
12051
12147
  ctx.horizonContext = void 0;
12052
12148
  ctx.registeredDocs = void 0;
12149
+ ctx.unactionedDocs = void 0;
12053
12150
  ctx.recentReviews = void 0;
12054
12151
  ctx.strategyReviewCadence = void 0;
12055
12152
  }
@@ -12219,6 +12316,32 @@ function formatStrategyRecommendations(recs, currentCycle) {
12219
12316
  }
12220
12317
  return sections.join("\n");
12221
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
+ }
12222
12345
  function formatEstimationCalibration(rows) {
12223
12346
  const total = rows.reduce((sum, r) => sum + r.count, 0);
12224
12347
  const accurate = rows.filter((r) => r.accuracyLabel === "accurate").reduce((sum, r) => sum + r.count, 0);
@@ -12558,11 +12681,13 @@ async function assembleContext(adapter2, mode, _config, filters, focus) {
12558
12681
  }
12559
12682
  }
12560
12683
  let registeredDocsText;
12684
+ let unactionedDocsTextLean;
12561
12685
  if (docsResult.status === "fulfilled" && docsResult.value && docsResult.value.length > 0) {
12562
12686
  const docs = docsResult.value;
12563
12687
  const lines = docs.map((d) => `- **${d.title}** (${d.type}) \u2014 ${d.summary}`);
12564
12688
  registeredDocsText = `${docs.length} active research doc(s):
12565
12689
  ${lines.join("\n")}`;
12690
+ unactionedDocsTextLean = formatUnactionedDocs(docs);
12566
12691
  }
12567
12692
  const boardFlags = detectBoardFlagsFromText(leanSummary.board);
12568
12693
  let carryForwardStalenessLean;
@@ -12614,6 +12739,7 @@ ${lines.join("\n")}`;
12614
12739
  discoveryCanvas: discoveryCanvasText,
12615
12740
  estimationCalibration: estimationCalibrationText,
12616
12741
  registeredDocs: registeredDocsText,
12742
+ unactionedDocs: unactionedDocsTextLean,
12617
12743
  focus,
12618
12744
  boardFlags,
12619
12745
  carryForwardStaleness: carryForwardStalenessLean,
@@ -12691,9 +12817,24 @@ ${lines.join("\n")}`;
12691
12817
  const metricsSnapshots = filteredRaw.length > 0 ? filteredRaw : computeSnapshotsFromBuildReports(allReportsForPatterns);
12692
12818
  const discoveryCanvasTextFull = discoveryCanvasResultFull.status === "fulfilled" ? discoveryCanvasResultFull.value : void 0;
12693
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
+ }
12694
12833
  let registeredDocsTextFull;
12834
+ let unactionedDocsTextFull;
12695
12835
  if (docsResultFull.status === "fulfilled" && docsResultFull.value && docsResultFull.value.length > 0) {
12696
12836
  const allDocs = docsResultFull.value;
12837
+ unactionedDocsTextFull = formatUnactionedDocs(allDocs);
12697
12838
  const taskModules = new Set(plannerTasks.map((t2) => t2.module?.toLowerCase()).filter(Boolean));
12698
12839
  const taskEpics = new Set(plannerTasks.map((t2) => t2.epic?.toLowerCase()).filter(Boolean));
12699
12840
  const HIGH_VALUE_TYPES = /* @__PURE__ */ new Set(["architecture", "guide", "research"]);
@@ -12739,19 +12880,6 @@ ${logLines}`);
12739
12880
  const filtered = reports.filter((r) => (r.cycle ?? 0) >= cutoff);
12740
12881
  return filtered.length > 0 ? filtered : reports.slice(0, 5);
12741
12882
  })() : reports;
12742
- const strippedTasks = stripTasksForPlan(tasks);
12743
- const boardFlagsFull = detectBoardFlags(tasks);
12744
- const horizonCtx = buildHorizonContext(phases, tasks) ?? void 0;
12745
- const ACTIVE_STATUSES2 = /* @__PURE__ */ new Set(["In Progress", "In Review", "Blocked"]);
12746
- const p3Excluded = strippedTasks.filter(
12747
- (t2) => t2.priority === "P3 Low" && !ACTIVE_STATUSES2.has(t2.status)
12748
- );
12749
- const plannerTasks = strippedTasks.filter(
12750
- (t2) => t2.priority !== "P3 Low" || ACTIVE_STATUSES2.has(t2.status)
12751
- );
12752
- if (p3Excluded.length > 0) {
12753
- console.error(`[plan-perf] board tiering: excluded ${p3Excluded.length} P3 Low tasks from planner context`);
12754
- }
12755
12883
  const targetCycle = health.totalCycles + 1;
12756
12884
  const preAssigned = plannerTasks.filter((t2) => t2.cycle === targetCycle);
12757
12885
  const preAssignedText = formatPreAssignedTasks(preAssigned, targetCycle);
@@ -12777,6 +12905,7 @@ ${logLines}`);
12777
12905
  taskComments: taskCommentsTextFull,
12778
12906
  discoveryCanvas: discoveryCanvasTextFull,
12779
12907
  registeredDocs: registeredDocsTextFull,
12908
+ unactionedDocs: unactionedDocsTextFull,
12780
12909
  focus,
12781
12910
  boardFlags: boardFlagsFull,
12782
12911
  carryForwardStaleness: computeCarryForwardStaleness(log),
@@ -13736,17 +13865,19 @@ async function handlePlan(adapter2, config2, args) {
13736
13865
  const expectedCycleNumber = lastPrepareCycleNumber;
13737
13866
  const skipHandoffsCached = lastPrepareSkipHandoffs;
13738
13867
  const skipHandoffs = args.skip_handoffs === true || skipHandoffsCached === true;
13739
- const cycleNumber = !isNaN(rawCycleNumber) ? rawCycleNumber : expectedCycleNumber !== void 0 ? expectedCycleNumber : NaN;
13740
- 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)) {
13741
13871
  return errorResponse(
13742
13872
  "cycle_number is required for apply mode. Pass the cycle_number from the prepare phase output."
13743
13873
  );
13744
13874
  }
13745
- if (expectedCycleNumber !== void 0 && cycleNumber !== expectedCycleNumber) {
13875
+ if (expectedNewCycleNumber !== void 0 && newCycleNumber !== expectedNewCycleNumber) {
13746
13876
  return errorResponse(
13747
- `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.`
13748
13878
  );
13749
13879
  }
13880
+ const cycleNumber = newCycleNumber - 1;
13750
13881
  lastPrepareContextHashes = void 0;
13751
13882
  lastPrepareUserMessage = void 0;
13752
13883
  lastPrepareContextBytes = void 0;
@@ -13800,7 +13931,7 @@ When done, call \`plan\` again with:
13800
13931
  - \`mode\`: "apply"
13801
13932
  - \`llm_response\`: your complete output (both parts)
13802
13933
  - \`plan_mode\`: "${result.mode}"
13803
- - \`cycle_number\`: ${result.cycleNumber}
13934
+ - \`cycle_number\`: ${result.cycleNumber + 1}
13804
13935
  - \`strategy_review_warning\`: "${result.strategyReviewWarning.replace(/"/g, '\\"')}"
13805
13936
 
13806
13937
  ---
@@ -14140,7 +14271,8 @@ async function assembleContext2(adapter2, cycleNumber, cyclesSinceLastReview, pr
14140
14271
  docsWithPendingActions
14141
14272
  ] = await Promise.all([
14142
14273
  adapter2.readProductBrief(),
14143
- adapter2.getActiveDecisions(),
14274
+ // Strategy review needs to see retired ADs to triage/restore them as needed.
14275
+ adapter2.getActiveDecisions({ includeRetired: true }),
14144
14276
  adapter2.getBuildReportsSince(lastReviewCycleNum),
14145
14277
  adapter2.getCycleLogSince(lastReviewCycleNum),
14146
14278
  adapter2.queryBoard({
@@ -14642,7 +14774,7 @@ ${cleanContent}`;
14642
14774
  try {
14643
14775
  const recs = extractRecommendations(data, cycleNumber);
14644
14776
  if (recs.length > 0) {
14645
- const existingAds = await adapter2.getActiveDecisions().catch(() => []);
14777
+ const existingAds = await adapter2.getActiveDecisions({ includeRetired: true }).catch(() => []);
14646
14778
  const existingAdIds = new Set(existingAds.map((ad) => ad.id));
14647
14779
  const filteredRecs = recs.filter((rec) => {
14648
14780
  if (rec.target && /^AD-\d+$/.test(rec.target)) {
@@ -15176,7 +15308,8 @@ async function prepareStrategyChange(adapter2, text) {
15176
15308
  try {
15177
15309
  const [brief, decisions, readPhases, boardTasks, reports, previousReviews] = await Promise.all([
15178
15310
  adapter2.readProductBrief(),
15179
- adapter2.getActiveDecisions(),
15311
+ // Strategy review needs ALL ADs (live + retired) for housekeeping.
15312
+ adapter2.getActiveDecisions({ includeRetired: true }),
15180
15313
  adapter2.readPhases(),
15181
15314
  adapter2.queryBoard().catch(() => []),
15182
15315
  adapter2.getRecentBuildReports(15).catch(() => []),
@@ -15221,7 +15354,7 @@ async function captureDecision(adapter2, input) {
15221
15354
  adId = input.adId;
15222
15355
  adAction = "updated";
15223
15356
  } else {
15224
- const existingAds = await adapter2.getActiveDecisions();
15357
+ const existingAds = await adapter2.getActiveDecisions({ includeRetired: true });
15225
15358
  const maxNum = existingAds.reduce((max, ad) => {
15226
15359
  const match = ad.id.match(/^AD-(\d+)$/);
15227
15360
  return match ? Math.max(max, parseInt(match[1], 10)) : max;
@@ -17638,6 +17771,23 @@ function isNoHandoffError(err) {
17638
17771
  function isBlockedError(err) {
17639
17772
  return err instanceof Error && err.code === "BLOCKED";
17640
17773
  }
17774
+ function computeScopeDriftSignal(predicted, changed) {
17775
+ if (!predicted || predicted.length === 0) return null;
17776
+ if (changed.length === 0) return null;
17777
+ const basename2 = (p) => {
17778
+ const parts = p.split(/[\\/]/);
17779
+ return parts[parts.length - 1] ?? p;
17780
+ };
17781
+ const predictedNames = new Set(predicted.map(basename2).filter(Boolean));
17782
+ const unexpected = changed.filter((c) => !predictedNames.has(basename2(c)));
17783
+ const fraction = unexpected.length / changed.length;
17784
+ const triggered = fraction > 0.5 || unexpected.length > 5;
17785
+ if (!triggered) return null;
17786
+ const sample = unexpected.slice(0, 5).join(", ");
17787
+ const more = unexpected.length > 5 ? ` (+${unexpected.length - 5} more)` : "";
17788
+ const pct = Math.round(fraction * 100);
17789
+ return `${unexpected.length}/${changed.length} changed files (${pct}%) outside FILES LIKELY TOUCHED: ${sample}${more}`;
17790
+ }
17641
17791
  function getUnresolvedDeps(task, allTasks) {
17642
17792
  if (!task.dependsOn) return [];
17643
17793
  const deps = task.dependsOn.split(",").map((d) => d.trim()).filter(Boolean);
@@ -17699,6 +17849,11 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
17699
17849
  if (task.status === "Done" || task.status === "Archived") {
17700
17850
  throw new Error(`Task "${taskId}" (${task.title}) is already ${task.status}. Cannot execute a completed task.`);
17701
17851
  }
17852
+ if (task.status === "In Review") {
17853
+ throw new Error(
17854
+ `Task "${taskId}" (${task.title}) is already In Review \u2014 it has been built and is awaiting sign-off. Run \`review_submit\` instead of re-building. If the build genuinely needs rework, use \`review_submit\` with verdict \`request-changes\` first.`
17855
+ );
17856
+ }
17702
17857
  if (!task.buildHandoff) {
17703
17858
  const err = new Error(`Task "${taskId}" (${task.title}) has no BUILD HANDOFF.`);
17704
17859
  err.code = "NO_HANDOFF";
@@ -18099,6 +18254,8 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
18099
18254
  const baseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
18100
18255
  const changed = getFilesChangedFromBase(config2.projectRoot, baseBranch);
18101
18256
  if (changed.length > 0) report.filesChanged = changed;
18257
+ const drift = computeScopeDriftSignal(task.buildHandoff?.filesLikelyTouched, changed);
18258
+ if (drift) report.scopeDriftSignal = drift;
18102
18259
  }
18103
18260
  let prLines = [];
18104
18261
  if (options.light) {
@@ -18619,8 +18776,20 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
18619
18776
  const correctionsCount = args.corrections_count;
18620
18777
  const deadEnds = args.dead_ends;
18621
18778
  const rawBriefImplications = args.brief_implications;
18622
- if (!completed || !effort || !estimatedEffort || !surprises || !discoveredIssues || !architectureNotes) {
18623
- 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
+ );
18624
18793
  }
18625
18794
  const parsedEffort = parseEffortSize(effort);
18626
18795
  const parsedEstimatedEffort = parseEffortSize(estimatedEffort);
@@ -18669,6 +18838,7 @@ function formatCompleteResult(result) {
18669
18838
  `**Discovered Issues:** ${result.report.discoveredIssues}`,
18670
18839
  `**Architecture Notes:** ${result.report.architectureNotes}`,
18671
18840
  ...result.report.deadEnds ? [`**Dead Ends:** ${result.report.deadEnds}`] : [],
18841
+ ...result.report.scopeDriftSignal ? [`**Scope drift signal:** ${result.report.scopeDriftSignal}`] : [],
18672
18842
  `**Scope Accuracy:** ${result.scopeAccuracy}`,
18673
18843
  "",
18674
18844
  "---",
@@ -21365,7 +21535,7 @@ ${lines.join("\n")}`;
21365
21535
  }
21366
21536
  let decisionLifecycleSection = "";
21367
21537
  try {
21368
- const decisions = await adapter2.getActiveDecisions();
21538
+ const decisions = await adapter2.getActiveDecisions({ includeRetired: true });
21369
21539
  const lifecycleSummary = formatDecisionLifecycleSummary(decisions);
21370
21540
  if (lifecycleSummary) {
21371
21541
  decisionLifecycleSection = `**Lifecycle:** ${lifecycleSummary}`;
@@ -21431,10 +21601,117 @@ ${lines.join("\n")}`;
21431
21601
  // src/tools/orient.ts
21432
21602
  init_git();
21433
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
+
21434
21710
  // src/tools/doc-registry.ts
21435
- import { readdirSync as readdirSync4, existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
21436
- 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";
21437
21713
  import { homedir as homedir2 } from "os";
21714
+ import { randomUUID as randomUUID15 } from "crypto";
21438
21715
  var docRegisterTool = {
21439
21716
  name: "doc_register",
21440
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.",
@@ -21577,12 +21854,12 @@ ${d.summary}
21577
21854
  ${lines.join("\n---\n\n")}`);
21578
21855
  }
21579
21856
  function scanMdFiles(dir, rootDir) {
21580
- if (!existsSync5(dir)) return [];
21857
+ if (!existsSync6(dir)) return [];
21581
21858
  const files = [];
21582
21859
  try {
21583
- const entries = readdirSync4(dir, { withFileTypes: true });
21860
+ const entries = readdirSync5(dir, { withFileTypes: true });
21584
21861
  for (const entry of entries) {
21585
- const full = join8(dir, entry.name);
21862
+ const full = join9(dir, entry.name);
21586
21863
  if (entry.isDirectory()) {
21587
21864
  files.push(...scanMdFiles(full, rootDir));
21588
21865
  } else if (entry.name.endsWith(".md")) {
@@ -21595,7 +21872,7 @@ function scanMdFiles(dir, rootDir) {
21595
21872
  }
21596
21873
  function extractTitle(filePath) {
21597
21874
  try {
21598
- const content = readFileSync2(filePath, "utf-8").slice(0, 1e3);
21875
+ const content = readFileSync3(filePath, "utf-8").slice(0, 1e3);
21599
21876
  const fmMatch = content.match(/^---[\s\S]*?title:\s*(.+?)$/m);
21600
21877
  if (fmMatch) return fmMatch[1].trim().replace(/^["']|["']$/g, "");
21601
21878
  const headingMatch = content.match(/^#+\s+(.+)$/m);
@@ -21611,17 +21888,17 @@ async function handleDocScan(adapter2, config2, args) {
21611
21888
  const includePlans = args.include_plans ?? false;
21612
21889
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
21613
21890
  const registeredPaths = new Set(registered.map((d) => d.path));
21614
- const docsDir = join8(config2.projectRoot, "docs");
21891
+ const docsDir = join9(config2.projectRoot, "docs");
21615
21892
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
21616
21893
  const unregisteredDocs = docsFiles.filter((f) => !registeredPaths.has(f));
21617
21894
  let unregisteredPlans = [];
21618
21895
  if (includePlans) {
21619
- const plansDir = join8(homedir2(), ".claude", "plans");
21620
- if (existsSync5(plansDir)) {
21896
+ const plansDir = join9(homedir2(), ".claude", "plans");
21897
+ if (existsSync6(plansDir)) {
21621
21898
  const planFiles = scanMdFiles(plansDir, plansDir);
21622
21899
  unregisteredPlans = planFiles.map((f) => `plans/${f}`).filter((f) => !registeredPaths.has(f)).map((f) => ({
21623
21900
  path: f,
21624
- title: extractTitle(join8(plansDir, f.replace("plans/", "")))
21901
+ title: extractTitle(join9(plansDir, f.replace("plans/", "")))
21625
21902
  }));
21626
21903
  }
21627
21904
  }
@@ -21632,7 +21909,7 @@ async function handleDocScan(adapter2, config2, args) {
21632
21909
  if (unregisteredDocs.length > 0) {
21633
21910
  lines.push(`## Unregistered Docs (${unregisteredDocs.length})`);
21634
21911
  for (const f of unregisteredDocs) {
21635
- const title = extractTitle(join8(config2.projectRoot, f));
21912
+ const title = extractTitle(join9(config2.projectRoot, f));
21636
21913
  lines.push(`- \`${f}\`${title ? ` \u2014 ${title}` : ""}`);
21637
21914
  }
21638
21915
  }
@@ -21645,10 +21922,106 @@ async function handleDocScan(adapter2, config2, args) {
21645
21922
  lines.push("", `Use \`doc_register\` to register these files.`);
21646
21923
  return textResponse(lines.join("\n"));
21647
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
+ }
21648
22021
 
21649
22022
  // src/services/session-guidance.ts
21650
- import { existsSync as existsSync6 } from "fs";
21651
- import { join as join9 } from "path";
22023
+ import { existsSync as existsSync7 } from "fs";
22024
+ import { join as join10 } from "path";
21652
22025
  var state = {
21653
22026
  toolCallCount: 0,
21654
22027
  lastOrientAt: null,
@@ -21669,8 +22042,8 @@ async function buildSessionGuidance(adapter2, projectRoot) {
21669
22042
  const signals = [];
21670
22043
  try {
21671
22044
  if (adapter2.searchDocs) {
21672
- const researchDir = join9(projectRoot, "docs", "research");
21673
- if (existsSync6(researchDir)) {
22045
+ const researchDir = join10(projectRoot, "docs", "research");
22046
+ if (existsSync7(researchDir)) {
21674
22047
  const files = scanMdFiles(researchDir, projectRoot);
21675
22048
  if (files.length > 0) {
21676
22049
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
@@ -21707,8 +22080,8 @@ async function buildSessionGuidance(adapter2, projectRoot) {
21707
22080
 
21708
22081
  // src/tools/orient.ts
21709
22082
  import { execFileSync as execFileSync3 } from "child_process";
21710
- import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync7 } from "fs";
21711
- import { join as join10 } from "path";
22083
+ import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync8 } from "fs";
22084
+ import { join as join11 } from "path";
21712
22085
  var GIT_DEPENDENT_ENVS = /* @__PURE__ */ new Set(["cowork", "api"]);
21713
22086
  var VALID_ENVS = /* @__PURE__ */ new Set(["claude-code", "cowork", "api", "unknown"]);
21714
22087
  function normaliseEnvironment(raw) {
@@ -21864,10 +22237,10 @@ async function getHierarchyPosition(adapter2) {
21864
22237
  adapter2.queryBoard()
21865
22238
  ]);
21866
22239
  if (horizons.length === 0) return void 0;
21867
- const isActive = (s) => s.status.toLowerCase() === "active";
21868
- const activeHorizon = horizons.find(isActive) || horizons[0];
22240
+ const isInProgress = (s) => s.status === "In Progress";
22241
+ const activeHorizon = horizons.find(isInProgress) || horizons[0];
21869
22242
  const horizonStages = stages.filter((s) => s.horizonId === activeHorizon.id).sort((a, b2) => (a.sortOrder ?? 0) - (b2.sortOrder ?? 0));
21870
- const activeStage = horizonStages.find(isActive) || horizonStages[0];
22243
+ const activeStage = horizonStages.find(isInProgress) || horizonStages.find((s) => s.status !== "Done") || horizonStages[0];
21871
22244
  if (!activeStage) return void 0;
21872
22245
  const stagePhases = phases.filter((p) => p.stageId === activeStage.id);
21873
22246
  const activePhases = stagePhases.filter((p) => p.status === "In Progress");
@@ -21908,8 +22281,8 @@ function getLatestGitTag(projectRoot) {
21908
22281
  }
21909
22282
  function checkNpmVersionDrift() {
21910
22283
  try {
21911
- const pkgPath = join10(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
21912
- 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"));
21913
22286
  const localVersion = pkg.version;
21914
22287
  const packageName = pkg.name;
21915
22288
  const published = execFileSync3("npm", ["view", packageName, "version"], {
@@ -21925,6 +22298,44 @@ function checkNpmVersionDrift() {
21925
22298
  return null;
21926
22299
  }
21927
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
+ }
21928
22339
  async function handleOrient(adapter2, config2, args = {}) {
21929
22340
  const environment = normaliseEnvironment(args.environment);
21930
22341
  try {
@@ -22035,7 +22446,7 @@ ${versionDrift}` : "";
22035
22446
  let unregisteredDocsNote = "";
22036
22447
  try {
22037
22448
  if (adapter2.searchDocs) {
22038
- const docsDir = join10(config2.projectRoot, "docs");
22449
+ const docsDir = join11(config2.projectRoot, "docs");
22039
22450
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
22040
22451
  if (docsFiles.length > 0) {
22041
22452
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
@@ -22086,7 +22497,7 @@ ${versionDrift}` : "";
22086
22497
  lines.push(` \u2192 \u2026and ${pendingActions.length - 2} more`);
22087
22498
  }
22088
22499
  }
22089
- 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._");
22090
22501
  researchSignalsNote = lines.join("\n");
22091
22502
  }
22092
22503
  }
@@ -22130,36 +22541,38 @@ ${versionDrift}` : "";
22130
22541
  try {
22131
22542
  const learnings = await adapter2.getCycleLearnings?.({ category: "issue", limit: 30 });
22132
22543
  if (learnings) {
22133
- const byRecency = (a, b2) => (b2.createdAt ?? "").localeCompare(a.createdAt ?? "");
22134
- const unactionedAll = learnings.filter((l) => !l.actionTaken).map((l) => ({ ...l, severity: l.severity ?? "P3" }));
22135
- const allAlerts = unactionedAll.filter((l) => l.severity === "P0" || l.severity === "P1").sort(byRecency);
22136
- const allLowSev = unactionedAll.filter((l) => l.severity === "P2" || l.severity === "P3").sort(byRecency);
22137
- const totalP2 = allLowSev.filter((l) => l.severity === "P2").length;
22138
- const totalP3 = allLowSev.filter((l) => l.severity === "P3").length;
22139
- const ALERT_CAP = 10;
22140
- const UNACTIONED_CAP = 5;
22141
- const alerts = allAlerts.slice(0, ALERT_CAP);
22142
- const unactioned = allLowSev.slice(0, UNACTIONED_CAP);
22143
- if (allAlerts.length > 0) {
22144
- 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.`;
22145
- const lines = ["\n\n## \u{1F6A8} Alerts", header];
22146
- for (const issue of alerts) {
22147
- const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
22148
- lines.push(`- **${issue.severity}** (C${issue.cycleNumber} / ${issue.taskId}): ${desc}`);
22544
+ const candidateLearnings = learnings.filter((l) => !l.actionTaken);
22545
+ const referencedTaskIds = Array.from(new Set(candidateLearnings.map((l) => l.taskId).filter(Boolean)));
22546
+ let closedTaskIds = /* @__PURE__ */ new Set();
22547
+ if (referencedTaskIds.length > 0) {
22548
+ try {
22549
+ const tasks = await adapter2.getTasks(referencedTaskIds);
22550
+ closedTaskIds = new Set(
22551
+ tasks.filter((t) => t.status === "Done" || t.status === "Cancelled").map((t) => t.id)
22552
+ );
22553
+ } catch {
22149
22554
  }
22150
- lines.push("_Escalate: run `idea` with P1 priority, or `board_edit` if already handled._");
22151
- alertsNote = lines.join("\n");
22152
22555
  }
22153
- if (allLowSev.length > 0) {
22154
- const totalLow = totalP2 + totalP3;
22155
- const header = totalLow > UNACTIONED_CAP ? `${totalP2} P2 \xB7 ${totalP3} P3 (showing ${UNACTIONED_CAP} most recent)` : `${totalP2} P2 \xB7 ${totalP3} P3`;
22156
- const lines = ["\n\n## Unactioned Issues", header];
22157
- for (const issue of unactioned) {
22158
- const desc = issue.summary.length > 100 ? `${issue.summary.slice(0, 97)}\u2026` : issue.summary;
22159
- lines.push(`- **${issue.severity}** (C${issue.cycleNumber} / ${issue.taskId}): ${desc}`);
22160
- }
22161
- lines.push("_Run `idea` to log these as backlog tasks, or `board_edit` if already handled._");
22162
- 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 {
22163
22576
  }
22164
22577
  }
22165
22578
  } catch {
@@ -22175,16 +22588,16 @@ ${versionDrift}` : "";
22175
22588
  markOrient();
22176
22589
  } catch {
22177
22590
  }
22178
- 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);
22179
22592
  } catch (err) {
22180
22593
  const message = err instanceof Error ? err.message : String(err);
22181
22594
  return errorResponse(`Orient failed: ${message}`);
22182
22595
  }
22183
22596
  }
22184
22597
  function enrichClaudeMd(projectRoot, cycleNumber) {
22185
- const claudeMdPath = join10(projectRoot, "CLAUDE.md");
22186
- if (!existsSync7(claudeMdPath)) return "";
22187
- 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");
22188
22601
  const additions = [];
22189
22602
  if (cycleNumber >= 6 && !content.includes(CLAUDE_MD_ENRICHMENT_SENTINEL_T1)) {
22190
22603
  additions.push(CLAUDE_MD_TIER_1);
@@ -22966,12 +23379,12 @@ ${result.userMessage}
22966
23379
  }
22967
23380
 
22968
23381
  // src/lib/install-id.ts
22969
- import { randomUUID as randomUUID15 } from "crypto";
22970
- 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";
22971
23384
  import { homedir as homedir3 } from "os";
22972
- import { join as join11 } from "path";
22973
- var PAPI_HOME_DIR = join11(homedir3(), ".papi");
22974
- 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");
22975
23388
  var cachedInstallId = null;
22976
23389
  function isValidUuid(s) {
22977
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);
@@ -22979,7 +23392,7 @@ function isValidUuid(s) {
22979
23392
  function getInstallId() {
22980
23393
  if (cachedInstallId) return cachedInstallId;
22981
23394
  try {
22982
- const raw = readFileSync4(INSTALL_ID_FILE, "utf-8");
23395
+ const raw = readFileSync5(INSTALL_ID_FILE, "utf-8");
22983
23396
  const parsed = JSON.parse(raw);
22984
23397
  if (isValidUuid(parsed.install_id)) {
22985
23398
  cachedInstallId = parsed.install_id;
@@ -22989,7 +23402,7 @@ function getInstallId() {
22989
23402
  }
22990
23403
  try {
22991
23404
  mkdirSync(PAPI_HOME_DIR, { recursive: true, mode: 448 });
22992
- const id = randomUUID15();
23405
+ const id = randomUUID16();
22993
23406
  const contents = {
22994
23407
  install_id: id,
22995
23408
  created_at: (/* @__PURE__ */ new Date()).toISOString()
@@ -23109,7 +23522,7 @@ function createServer(adapter2, config2) {
23109
23522
  const __pkgDir = dirname(__pkgFilename);
23110
23523
  let serverVersion = "unknown";
23111
23524
  try {
23112
- const pkg = JSON.parse(readFileSync5(join12(__pkgDir, "..", "package.json"), "utf-8"));
23525
+ const pkg = JSON.parse(readFileSync6(join13(__pkgDir, "..", "package.json"), "utf-8"));
23113
23526
  serverVersion = pkg.version ?? "unknown";
23114
23527
  } catch {
23115
23528
  }
@@ -23124,7 +23537,7 @@ function createServer(adapter2, config2) {
23124
23537
  }
23125
23538
  const __filename = fileURLToPath(import.meta.url);
23126
23539
  const __dirname2 = dirname(__filename);
23127
- const skillsDir = join12(__dirname2, "..", "skills");
23540
+ const skillsDir = join13(__dirname2, "..", "skills");
23128
23541
  function parseSkillFrontmatter(content) {
23129
23542
  const match = content.match(/^---\n([\s\S]*?)\n---/);
23130
23543
  if (!match) return null;
@@ -23142,7 +23555,7 @@ function createServer(adapter2, config2) {
23142
23555
  const mdFiles = files.filter((f) => f.endsWith(".md"));
23143
23556
  const prompts = [];
23144
23557
  for (const file of mdFiles) {
23145
- const content = await readFile5(join12(skillsDir, file), "utf-8");
23558
+ const content = await readFile5(join13(skillsDir, file), "utf-8");
23146
23559
  const meta = parseSkillFrontmatter(content);
23147
23560
  if (meta) {
23148
23561
  prompts.push({ name: meta.name, description: meta.description });
@@ -23158,7 +23571,7 @@ function createServer(adapter2, config2) {
23158
23571
  try {
23159
23572
  const files = await readdir2(skillsDir);
23160
23573
  for (const file of files.filter((f) => f.endsWith(".md"))) {
23161
- const content = await readFile5(join12(skillsDir, file), "utf-8");
23574
+ const content = await readFile5(join13(skillsDir, file), "utf-8");
23162
23575
  const meta = parseSkillFrontmatter(content);
23163
23576
  if (meta?.name === name) {
23164
23577
  const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
@@ -23201,6 +23614,7 @@ function createServer(adapter2, config2) {
23201
23614
  docRegisterTool,
23202
23615
  docSearchTool,
23203
23616
  docScanTool,
23617
+ docActionPromoteTool,
23204
23618
  getSiblingAdsTool,
23205
23619
  handoffGenerateTool
23206
23620
  ]
@@ -23316,6 +23730,9 @@ function createServer(adapter2, config2) {
23316
23730
  case "doc_scan":
23317
23731
  result = await handleDocScan(adapter2, config2, safeArgs);
23318
23732
  break;
23733
+ case "doc_action_promote":
23734
+ result = await handleDocActionPromote(adapter2, safeArgs);
23735
+ break;
23319
23736
  case "get_sibling_ads":
23320
23737
  result = await handleGetSiblingAds(adapter2, safeArgs);
23321
23738
  break;
@@ -23371,7 +23788,7 @@ function createServer(adapter2, config2) {
23371
23788
  var __dirname = dirname2(fileURLToPath2(import.meta.url));
23372
23789
  var pkgVersion = "unknown";
23373
23790
  try {
23374
- const pkg = JSON.parse(readFileSync6(join13(__dirname, "..", "package.json"), "utf-8"));
23791
+ const pkg = JSON.parse(readFileSync7(join14(__dirname, "..", "package.json"), "utf-8"));
23375
23792
  pkgVersion = pkg.version;
23376
23793
  } catch {
23377
23794
  }
package/dist/prompts.js CHANGED
@@ -1,4 +1,18 @@
1
1
  // src/prompts.ts
2
+ var AD_REJECTION_RULES = `**AD Minting Guard \u2014 REJECT observations dressed as decisions.**
3
+
4
+ An Active Decision expresses a *stance* the project is taking \u2014 a choice between alternatives that constrains future work. Reject any candidate AD whose body asserts:
5
+ (a) the existence, identity, or status of a person, user, or external entity (e.g. "User X is building Y", "Customer Z is active");
6
+ (b) a metric or measurement (e.g. "Signups grew 3x last cycle", "Latency dropped to 200ms");
7
+ (c) a fact about the current state of the world that could be confirmed or denied by a query rather than challenged by argument (e.g. "External user feedback is now flowing", "The /admin route exists").
8
+
9
+ If a candidate AD body could be invalidated by running a SQL query, refreshing a dashboard, or checking a log \u2014 it is an observation, not a decision. Capture it as a build report finding, a dogfood observation, a cycle log note, or a registered doc instead. Do NOT mint it as an AD.
10
+
11
+ **Positive example (valid AD):** "Pricing tier strategy: free engine + paid intelligence. Decision: keep cycles free, charge for strategy reviews and analytics. Why: telemetry shows engagement clusters around intelligence surfaces, not engine surfaces." \u2014 this is a stance with alternatives.
12
+
13
+ **Negative example (reject):** "External user feedback is now flowing. Stonebridge Systems is actively building." \u2014 this is a fact about the current state of the world. Capture as dogfood/signal observation; do not mint.
14
+
15
+ This rule applies to: new ADs proposed during planning (Step 9), strategy review AD updates (section 5), and strategy_change AD updates. If you find an existing AD that violates this rule during housekeeping, propose deleting it (action: "delete") with a one-line rationale.`;
2
16
  var PLAN_SYSTEM = `You are the PAPI Cycle Planner \u2014 an autonomous planning engine for software projects.
3
17
  You receive project context and produce a planning cycle output with a BUILD HANDOFF.
4
18
 
@@ -179,6 +193,7 @@ Standard planning cycle with full board review.
179
193
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
180
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.
181
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.
182
197
  If a task is clearly obsolete, duplicated, or rejected, set its status to "Cancelled" with a \`closureReason\` explaining why.
183
198
  **\u2192 PERSIST:** For each task you set reviewed: true, corrected fields on, or marked "Cancelled", include it in \`boardCorrections\` in Part 2.
184
199
 
@@ -224,6 +239,9 @@ Standard planning cycle with full board review.
224
239
 
225
240
  9. **Active Decisions** \u2014 If any AD needs updating: Type A (confidence change), Type B (modification), or Type C (reversal/supersede).
226
241
  **AD Quality Bar:** ADs are for product and architecture choices that constrain future work \u2014 technology selections, data model designs, UX principles, strategic positioning. They are NOT for: process preferences (commit style, PR size), configuration choices (linter rules, tab width), or temporary workarounds. If a decision doesn't affect what gets built or how it's architected, it's not an AD. Apply this bar when proposing new ADs and when triaging existing ones.
242
+
243
+ ${AD_REJECTION_RULES}
244
+
227
245
  **\u2192 PERSIST:** EVERY AD you created, updated, or confirmed with changes MUST appear in \`activeDecisions\` array in Part 2. Include the full replacement body with ### heading.
228
246
 
229
247
  ### Operational Quality Rules
@@ -414,6 +432,7 @@ Standard planning cycle with full board review.
414
432
  - **P3 Low** \u2014 Nice-to-have, speculative, or future-horizon work.
415
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.
416
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.
417
436
  If a task is clearly obsolete, duplicated, or rejected, set its status to "Cancelled" with a \`closureReason\` explaining why.
418
437
  **\u2192 PERSIST:** For each task you set reviewed: true, corrected fields on, or marked "Cancelled", include it in \`boardCorrections\` in Part 2.
419
438
 
@@ -461,6 +480,9 @@ Standard planning cycle with full board review.
461
480
 
462
481
  9. **Active Decisions** \u2014 If any AD needs updating: Type A (confidence change), Type B (modification), or Type C (reversal/supersede).
463
482
  **AD Quality Bar:** ADs are for product and architecture choices that constrain future work \u2014 technology selections, data model designs, UX principles, strategic positioning. They are NOT for: process preferences (commit style, PR size), configuration choices (linter rules, tab width), or temporary workarounds. If a decision doesn't affect what gets built or how it's architected, it's not an AD. Apply this bar when proposing new ADs and when triaging existing ones.
483
+
484
+ ${AD_REJECTION_RULES}
485
+
464
486
  **\u2192 PERSIST:** EVERY AD you created, updated, or confirmed with changes MUST appear in \`activeDecisions\` array in Part 2. Include the full replacement body with ### heading.
465
487
 
466
488
  ### Operational Quality Rules
@@ -625,6 +647,16 @@ function buildPlanUserMessage(ctx) {
625
647
  if (ctx.registeredDocs) {
626
648
  parts.push("### Relevant Research Docs", "", ctx.registeredDocs, "");
627
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
+ }
628
660
  if (ctx.carryForwardStaleness) {
629
661
  parts.push("### Carry-Forward Staleness", "", ctx.carryForwardStaleness, "");
630
662
  }
@@ -833,8 +865,17 @@ You MUST cover these 5 sections. Each is mandatory.
833
865
  - Note any hierarchy/phase issues worth correcting (1-2 bullets max)
834
866
  - Delete ADs that are legacy, process-level, or redundant without discussion
835
867
 
868
+ ${AD_REJECTION_RULES}
869
+
836
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.
837
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
+
838
879
  ## CONDITIONAL SECTIONS (include only when genuinely useful \u2014 most reviews should have 0-2 of these)
839
880
 
840
881
  6. **Security Posture Review** \u2014 Only if \`[SECURITY]\` tags exist in recent cycle logs.
@@ -1142,6 +1183,8 @@ The JSON must be valid. Only include ADs that need changes \u2014 omit unchanged
1142
1183
  For new ADs, use the next available AD number.
1143
1184
  The body field must be the COMPLETE replacement text for the AD block (including the ### heading line).
1144
1185
 
1186
+ ${AD_REJECTION_RULES}
1187
+
1145
1188
  ## PHASE UPDATES
1146
1189
 
1147
1190
  If the strategic change affects the project's phase structure, include a phaseUpdates array.
@@ -1438,6 +1481,7 @@ ${inputs.codebaseContext}
1438
1481
  Return a JSON array of 3-10 tasks based on gaps, improvements, and next steps visible from the codebase analysis above.`;
1439
1482
  }
1440
1483
  export {
1484
+ AD_REJECTION_RULES,
1441
1485
  AD_SEED_SYSTEM,
1442
1486
  CONVENTIONS_SYSTEM,
1443
1487
  HANDOFF_REGEN_SYSTEM,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.13",
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",