@papi-ai/server 0.7.45 → 0.7.46

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.
@@ -1163,6 +1163,10 @@ var init_proxy_adapter = __esm({
1163
1163
  "claimReview",
1164
1164
  "getSiblingAds",
1165
1165
  "getSiblingRepoTasks"
1166
+ // task-2489 (C320): recordProgressStep is now wired to the edge data-proxy
1167
+ // (case handler + ALLOWED_METHODS/WRITE_METHODS entries), so it forwards for
1168
+ // hosted callers and persists a project-scoped cycle_progress_steps row. Removed
1169
+ // from NO_FORWARD (was the task-2484 pg-only gap) — hosted parity restored.
1166
1170
  ]);
1167
1171
  ProxyPapiAdapter = class _ProxyPapiAdapter {
1168
1172
  endpoint;
@@ -1883,27 +1887,14 @@ import { execSync } from "child_process";
1883
1887
  import { readFile, writeFile, access } from "fs/promises";
1884
1888
  import { randomUUID as randomUUID6 } from "crypto";
1885
1889
  import { join } from "path";
1886
-
1887
- // ../shared/dist/index.js
1888
- var VALID_TRANSITIONS = {
1889
- "Backlog": ["In Cycle", "Ready", "In Progress", "Blocked", "Cancelled", "Deferred", "Done"],
1890
- "In Cycle": ["In Progress", "Backlog", "Blocked", "Cancelled"],
1891
- "Ready": ["In Progress", "Backlog", "Blocked", "Cancelled"],
1892
- "In Progress": ["In Review", "Backlog", "Blocked", "Cancelled"],
1893
- "In Review": ["Done", "In Progress", "Blocked", "Cancelled"],
1894
- "Done": [],
1895
- "Blocked": ["Backlog", "Ready", "In Cycle", "In Progress", "Cancelled"],
1896
- "Cancelled": [],
1897
- "Deferred": ["Backlog", "Cancelled"]
1898
- };
1899
- var RETIRED_DECISION_OUTCOMES = ["resolved", "abandoned", "superseded"];
1900
- function isLiveDecision(d) {
1901
- if (d.superseded === true) return false;
1902
- if (d.outcome != null && RETIRED_DECISION_OUTCOMES.includes(d.outcome)) return false;
1903
- return true;
1904
- }
1905
-
1906
- // ../adapter-md/dist/index.js
1890
+ import {
1891
+ VALID_TRANSITIONS as _VALID_TRANSITIONS,
1892
+ isValidTransition as _isValidTransition,
1893
+ validateTransition as _validateTransition,
1894
+ isValidStatus as _isValidStatus,
1895
+ isLiveDecision as _isLiveDecision,
1896
+ RETIRED_DECISION_OUTCOMES as _RETIRED_DECISION_OUTCOMES
1897
+ } from "@papi-ai/shared";
1907
1898
  import { randomUUID } from "crypto";
1908
1899
  import { randomUUID as randomUUID3 } from "crypto";
1909
1900
  import yaml from "js-yaml";
@@ -1912,8 +1903,8 @@ import { randomUUID as randomUUID4 } from "crypto";
1912
1903
  import { randomUUID as randomUUID5 } from "crypto";
1913
1904
  import yaml2 from "js-yaml";
1914
1905
  import yaml3 from "js-yaml";
1915
- var VALID_TRANSITIONS2 = VALID_TRANSITIONS;
1916
- var isLiveDecision2 = isLiveDecision;
1906
+ var VALID_TRANSITIONS = _VALID_TRANSITIONS;
1907
+ var isLiveDecision = _isLiveDecision;
1917
1908
  function extractSection(content, heading) {
1918
1909
  const headingPattern = new RegExp(`^## ${heading}\\s*$`, "m");
1919
1910
  const start = content.search(headingPattern);
@@ -3106,6 +3097,7 @@ function toCycle(raw) {
3106
3097
  };
3107
3098
  if (raw.end_date) cycle.endDate = raw.end_date;
3108
3099
  if (raw.user_id) cycle.userId = raw.user_id;
3100
+ if (raw.dependency_chain) cycle.dependencyChain = raw.dependency_chain;
3109
3101
  return cycle;
3110
3102
  }
3111
3103
  function fromCycle(cycle) {
@@ -3120,6 +3112,7 @@ function fromCycle(cycle) {
3120
3112
  };
3121
3113
  if (cycle.endDate) raw.end_date = cycle.endDate;
3122
3114
  if (cycle.userId) raw.user_id = cycle.userId;
3115
+ if (cycle.dependencyChain) raw.dependency_chain = cycle.dependencyChain;
3123
3116
  return raw;
3124
3117
  }
3125
3118
  function extractYamlBlock2(content) {
@@ -3164,7 +3157,7 @@ function prependCycle(cycle, content) {
3164
3157
  const yamlStr = yaml2.dump({ cycles: [raw] }, { lineWidth: 120, quotingType: '"' });
3165
3158
  return header + YAML_START2 + "\n" + yamlStr + YAML_END2 + "\n";
3166
3159
  }
3167
- const existing = parseCycles(content);
3160
+ const existing = parseCycles(content).filter((c) => c.number !== cycle.number);
3168
3161
  const merged = [cycle, ...existing];
3169
3162
  return serializeCycles(merged, content);
3170
3163
  }
@@ -3261,7 +3254,7 @@ var MdFileAdapter = class {
3261
3254
  if (!content) return [];
3262
3255
  const all = parseActiveDecisions(content);
3263
3256
  if (options?.includeRetired) return all;
3264
- return all.filter(isLiveDecision2);
3257
+ return all.filter(isLiveDecision);
3265
3258
  }
3266
3259
  /** Read cycle log entries (newest first), optionally limited to {@link limit} entries. */
3267
3260
  async getCycleLog(limit) {
@@ -3429,7 +3422,7 @@ var MdFileAdapter = class {
3429
3422
  }
3430
3423
  if (updates.status && updates.status !== tasks[idx].status && !options?.force) {
3431
3424
  const from = tasks[idx].status;
3432
- const allowed = VALID_TRANSITIONS2[from];
3425
+ const allowed = VALID_TRANSITIONS[from];
3433
3426
  if (!allowed.includes(updates.status)) {
3434
3427
  console.warn(`[papi] Warning: invalid status transition "${from}" \u2192 "${updates.status}" for task ${id}. Allowed from "${from}": ${allowed.length > 0 ? allowed.join(", ") : "none"}`);
3435
3428
  }
package/dist/index.js CHANGED
@@ -1280,6 +1280,10 @@ var init_proxy_adapter = __esm({
1280
1280
  "claimReview",
1281
1281
  "getSiblingAds",
1282
1282
  "getSiblingRepoTasks"
1283
+ // task-2489 (C320): recordProgressStep is now wired to the edge data-proxy
1284
+ // (case handler + ALLOWED_METHODS/WRITE_METHODS entries), so it forwards for
1285
+ // hosted callers and persists a project-scoped cycle_progress_steps row. Removed
1286
+ // from NO_FORWARD (was the task-2484 pg-only gap) — hosted parity restored.
1283
1287
  ]);
1284
1288
  ProxyPapiAdapter = class _ProxyPapiAdapter {
1285
1289
  endpoint;
@@ -5061,6 +5065,9 @@ function loadConfig() {
5061
5065
  const projectOwner = process.env.PAPI_OWNER ?? "Cathal";
5062
5066
  const skipProjectSpecificRules = process.env.PAPI_SKIP_PROJECT_RULES === "true";
5063
5067
  const userId = process.env.PAPI_USER_ID || void 0;
5068
+ const gateCommand = process.env.PAPI_GATE?.trim() || void 0;
5069
+ const deployCommand = process.env.PAPI_DEPLOY?.trim() || void 0;
5070
+ const verifyCommand = process.env.PAPI_VERIFY?.trim() || void 0;
5064
5071
  const telemetryEnabled = process.env.PAPI_TELEMETRY !== "off" && process.env.PAPI_TELEMETRY !== "false";
5065
5072
  const papiEndpoint = process.env.PAPI_ENDPOINT;
5066
5073
  const dataEndpoint = process.env.PAPI_DATA_ENDPOINT;
@@ -5135,7 +5142,10 @@ Already have an account? Make sure PAPI_USER_ID is set in your .mcp.json env con
5135
5142
  skipProjectSpecificRules,
5136
5143
  userId,
5137
5144
  telemetryEnabled,
5138
- resolutionMethod
5145
+ resolutionMethod,
5146
+ gateCommand,
5147
+ deployCommand,
5148
+ verifyCommand
5139
5149
  };
5140
5150
  }
5141
5151
 
@@ -5147,27 +5157,14 @@ import { execSync } from "child_process";
5147
5157
  import { readFile, writeFile, access } from "fs/promises";
5148
5158
  import { randomUUID as randomUUID6 } from "crypto";
5149
5159
  import { join } from "path";
5150
-
5151
- // ../shared/dist/index.js
5152
- var VALID_TRANSITIONS = {
5153
- "Backlog": ["In Cycle", "Ready", "In Progress", "Blocked", "Cancelled", "Deferred", "Done"],
5154
- "In Cycle": ["In Progress", "Backlog", "Blocked", "Cancelled"],
5155
- "Ready": ["In Progress", "Backlog", "Blocked", "Cancelled"],
5156
- "In Progress": ["In Review", "Backlog", "Blocked", "Cancelled"],
5157
- "In Review": ["Done", "In Progress", "Blocked", "Cancelled"],
5158
- "Done": [],
5159
- "Blocked": ["Backlog", "Ready", "In Cycle", "In Progress", "Cancelled"],
5160
- "Cancelled": [],
5161
- "Deferred": ["Backlog", "Cancelled"]
5162
- };
5163
- var RETIRED_DECISION_OUTCOMES = ["resolved", "abandoned", "superseded"];
5164
- function isLiveDecision(d) {
5165
- if (d.superseded === true) return false;
5166
- if (d.outcome != null && RETIRED_DECISION_OUTCOMES.includes(d.outcome)) return false;
5167
- return true;
5168
- }
5169
-
5170
- // ../adapter-md/dist/index.js
5160
+ import {
5161
+ VALID_TRANSITIONS as _VALID_TRANSITIONS,
5162
+ isValidTransition as _isValidTransition,
5163
+ validateTransition as _validateTransition,
5164
+ isValidStatus as _isValidStatus,
5165
+ isLiveDecision as _isLiveDecision,
5166
+ RETIRED_DECISION_OUTCOMES as _RETIRED_DECISION_OUTCOMES
5167
+ } from "@papi-ai/shared";
5171
5168
  import { randomUUID } from "crypto";
5172
5169
  import { randomUUID as randomUUID3 } from "crypto";
5173
5170
  import yaml from "js-yaml";
@@ -5176,8 +5173,8 @@ import { randomUUID as randomUUID4 } from "crypto";
5176
5173
  import { randomUUID as randomUUID5 } from "crypto";
5177
5174
  import yaml2 from "js-yaml";
5178
5175
  import yaml3 from "js-yaml";
5179
- var VALID_TRANSITIONS2 = VALID_TRANSITIONS;
5180
- var isLiveDecision2 = isLiveDecision;
5176
+ var VALID_TRANSITIONS = _VALID_TRANSITIONS;
5177
+ var isLiveDecision = _isLiveDecision;
5181
5178
  function extractSection(content, heading) {
5182
5179
  const headingPattern = new RegExp(`^## ${heading}\\s*$`, "m");
5183
5180
  const start = content.search(headingPattern);
@@ -6478,6 +6475,7 @@ function toCycle(raw) {
6478
6475
  };
6479
6476
  if (raw.end_date) cycle.endDate = raw.end_date;
6480
6477
  if (raw.user_id) cycle.userId = raw.user_id;
6478
+ if (raw.dependency_chain) cycle.dependencyChain = raw.dependency_chain;
6481
6479
  return cycle;
6482
6480
  }
6483
6481
  function fromCycle(cycle) {
@@ -6492,6 +6490,7 @@ function fromCycle(cycle) {
6492
6490
  };
6493
6491
  if (cycle.endDate) raw.end_date = cycle.endDate;
6494
6492
  if (cycle.userId) raw.user_id = cycle.userId;
6493
+ if (cycle.dependencyChain) raw.dependency_chain = cycle.dependencyChain;
6495
6494
  return raw;
6496
6495
  }
6497
6496
  function extractYamlBlock2(content) {
@@ -6536,7 +6535,7 @@ function prependCycle(cycle, content) {
6536
6535
  const yamlStr = yaml2.dump({ cycles: [raw] }, { lineWidth: 120, quotingType: '"' });
6537
6536
  return header + YAML_START2 + "\n" + yamlStr + YAML_END2 + "\n";
6538
6537
  }
6539
- const existing = parseCycles(content);
6538
+ const existing = parseCycles(content).filter((c) => c.number !== cycle.number);
6540
6539
  const merged = [cycle, ...existing];
6541
6540
  return serializeCycles(merged, content);
6542
6541
  }
@@ -6633,7 +6632,7 @@ var MdFileAdapter = class {
6633
6632
  if (!content) return [];
6634
6633
  const all = parseActiveDecisions(content);
6635
6634
  if (options?.includeRetired) return all;
6636
- return all.filter(isLiveDecision2);
6635
+ return all.filter(isLiveDecision);
6637
6636
  }
6638
6637
  /** Read cycle log entries (newest first), optionally limited to {@link limit} entries. */
6639
6638
  async getCycleLog(limit) {
@@ -6801,7 +6800,7 @@ var MdFileAdapter = class {
6801
6800
  }
6802
6801
  if (updates.status && updates.status !== tasks[idx].status && !options?.force) {
6803
6802
  const from = tasks[idx].status;
6804
- const allowed = VALID_TRANSITIONS2[from];
6803
+ const allowed = VALID_TRANSITIONS[from];
6805
6804
  if (!allowed.includes(updates.status)) {
6806
6805
  console.warn(`[papi] Warning: invalid status transition "${from}" \u2192 "${updates.status}" for task ${id}. Allowed from "${from}": ${allowed.length > 0 ? allowed.join(", ") : "none"}`);
6807
6806
  }
@@ -9273,6 +9272,14 @@ function buildHandoffsOnlyUserMessage(inputs) {
9273
9272
  }
9274
9273
  return parts.join("\n");
9275
9274
  }
9275
+ function extractDependencyChain(displayText) {
9276
+ const match = displayText.match(
9277
+ /^##\s+Dependency Chain[^\n]*\n([\s\S]*?)(?=\n#{2,3}\s|$(?![\s\S]))/m
9278
+ );
9279
+ if (!match) return void 0;
9280
+ const body = match[1].trim();
9281
+ return body.length > 0 ? body : void 0;
9282
+ }
9276
9283
  function parseStructuredOutput(raw) {
9277
9284
  const marker = "<!-- PAPI_STRUCTURED_OUTPUT -->";
9278
9285
  const markerIdx = raw.indexOf(marker);
@@ -9288,6 +9295,7 @@ function parseStructuredOutput(raw) {
9288
9295
  try {
9289
9296
  const parsed = JSON.parse(jsonMatch[1].trim());
9290
9297
  const data = coerceStructuredOutput(parsed);
9298
+ data.dependencyChain = extractDependencyChain(displayText);
9291
9299
  return { displayText, data };
9292
9300
  } catch {
9293
9301
  return { displayText, data: null };
@@ -11746,8 +11754,27 @@ ${cleanContent}`;
11746
11754
  contextHashes,
11747
11755
  // task-2071 (MU-3): own the cycle by the planning member (pg defaults to the
11748
11756
  // project owner when unset, so owner-operated plans stay correct).
11749
- ...options.ownerUserId ? { userId: options.ownerUserId } : {}
11757
+ ...options.ownerUserId ? { userId: options.ownerUserId } : {},
11758
+ // task-2483 (C319): persist the plan's Dependency Chain build-order markdown.
11759
+ ...data.dependencyChain ? { dependencyChain: data.dependencyChain } : {}
11750
11760
  };
11761
+ const earlyCreateWarnings = [];
11762
+ try {
11763
+ await adapter2.createCycle({
11764
+ id: `cycle-${newCycleNumber}`,
11765
+ number: newCycleNumber,
11766
+ status: "active",
11767
+ startDate: cycle.startDate,
11768
+ goals: cycle.goals,
11769
+ boardHealth: "",
11770
+ taskIds: [],
11771
+ ...cycle.userId ? { userId: cycle.userId } : {}
11772
+ });
11773
+ } catch (err) {
11774
+ const msg = `early createCycle failed for cycle ${newCycleNumber}: ${err instanceof Error ? err.message : String(err)}`;
11775
+ console.error(`[plan] ${msg}`);
11776
+ earlyCreateWarnings.push(msg);
11777
+ }
11751
11778
  let dedupedNewTasks = data.newTasks ?? [];
11752
11779
  if (dedupedNewTasks.length > 0) {
11753
11780
  const existingTasks = await adapter2.queryBoard({ compact: true });
@@ -11840,7 +11867,7 @@ ${cleanContent}`;
11840
11867
  `Plan write-back created ${actualNewTasks} of ${expectedNewTasks} new tasks the transaction was asked to create \u2014 investigate the planWriteBack handler. (This count is returned by the committed transaction, not a board re-read, so it is not a hosted read-lag artifact.)`
11841
11868
  );
11842
11869
  }
11843
- const allWarnings = [...result.warnings, ...verifyWarnings];
11870
+ const allWarnings = [...result.warnings, ...verifyWarnings, ...earlyCreateWarnings];
11844
11871
  const handoffCount = data.cycleHandoffs?.length ?? 0;
11845
11872
  const correctionCount = data.boardCorrections?.length ?? 0;
11846
11873
  const newTaskCount = result.newTaskIdMap.size;
@@ -11880,6 +11907,23 @@ ${cleanContent}`;
11880
11907
  });
11881
11908
  const cycleTaskCount = legacyHandoffs.length;
11882
11909
  const cycleEffortPoints = legacyHandoffs.reduce((sum, h) => sum + (effortMap[h.handoff.effort] ?? 3), 0);
11910
+ try {
11911
+ await adapter2.createCycle({
11912
+ id: `cycle-${newCycleNumber}`,
11913
+ number: newCycleNumber,
11914
+ status: "active",
11915
+ startDate: (/* @__PURE__ */ new Date()).toISOString(),
11916
+ goals: data.cycleLogTitle ? [data.cycleLogTitle] : [],
11917
+ boardHealth: "",
11918
+ taskIds: [],
11919
+ contextHashes,
11920
+ ...options.ownerUserId ? { userId: options.ownerUserId } : {}
11921
+ });
11922
+ } catch (err) {
11923
+ const msg = `early createCycle failed for cycle ${newCycleNumber}: ${err instanceof Error ? err.message : String(err)}`;
11924
+ console.error(`[plan] ${msg}`);
11925
+ warnings.push(msg);
11926
+ }
11883
11927
  const cycleLogPromise = adapter2.writeCycleLogEntry({
11884
11928
  uuid: randomUUID8(),
11885
11929
  cycleNumber: newCycleNumber,
@@ -12142,7 +12186,10 @@ ${cleanContent}`;
12142
12186
  taskIds: cycleTaskIds,
12143
12187
  contextHashes,
12144
12188
  // task-2071 (MU-3): own the cycle by the planning member.
12145
- ...options.ownerUserId ? { userId: options.ownerUserId } : {}
12189
+ ...options.ownerUserId ? { userId: options.ownerUserId } : {},
12190
+ // task-2483 (C319): persist the plan's Dependency Chain build-order markdown.
12191
+ // This full write patches the early minimal row created in Phase 1.
12192
+ ...data.dependencyChain ? { dependencyChain: data.dependencyChain } : {}
12146
12193
  };
12147
12194
  await adapter2.createCycle(cycle);
12148
12195
  } catch (err) {
@@ -12622,12 +12669,58 @@ ${JSON.stringify(payload, null, 2)}`;
12622
12669
  }
12623
12670
  var ProgressTracker = class {
12624
12671
  lastStep;
12672
+ persistAdapter;
12673
+ streamCtx;
12625
12674
  constructor(initial = "start") {
12626
12675
  this.lastStep = initial;
12627
12676
  }
12628
12677
  mark(step) {
12629
12678
  this.lastStep = step;
12630
12679
  }
12680
+ /**
12681
+ * Arm progress-stream persistence for this tool call. Safe to call with any
12682
+ * adapter — persistence stays disarmed unless the adapter exposes BOTH
12683
+ * `getProjectId` and `recordProgressStep` (i.e. the pg/DB-backed path). Returns
12684
+ * `this` for chaining at the tracker construction site.
12685
+ */
12686
+ bindStream(adapter2, ctx) {
12687
+ if (adapter2?.getProjectId && adapter2.recordProgressStep) {
12688
+ this.persistAdapter = adapter2;
12689
+ }
12690
+ this.streamCtx = ctx;
12691
+ return this;
12692
+ }
12693
+ /** Update the bound scope mid-call (e.g. once the cycle number is resolved). */
12694
+ setStreamScope(scope) {
12695
+ if (!this.streamCtx) return;
12696
+ if (scope.cycle !== void 0) this.streamCtx.cycle = scope.cycle;
12697
+ if (scope.taskId !== void 0) this.streamCtx.taskId = scope.taskId;
12698
+ }
12699
+ /**
12700
+ * Record one step of the progress stream. Also advances `lastStep` so the
12701
+ * structured-error diagnostic still points at the most recent named step.
12702
+ * Fire-and-forget and fully guarded — the returned promise always resolves.
12703
+ */
12704
+ recordStep(step, opts = {}) {
12705
+ this.lastStep = step;
12706
+ const adapter2 = this.persistAdapter;
12707
+ const ctx = this.streamCtx;
12708
+ if (!adapter2?.recordProgressStep || !ctx) return Promise.resolve();
12709
+ const row = {
12710
+ stage: opts.stage ?? ctx.stage,
12711
+ step,
12712
+ status: opts.status ?? "complete",
12713
+ cycle: opts.cycle !== void 0 ? opts.cycle : ctx.cycle ?? null,
12714
+ taskId: opts.taskId !== void 0 ? opts.taskId : ctx.taskId ?? null,
12715
+ capabilityKey: opts.capabilityKey ?? null,
12716
+ capabilityEnabled: opts.capabilityEnabled ?? null,
12717
+ metadata: opts.metadata
12718
+ };
12719
+ return adapter2.recordProgressStep(row).catch((err) => {
12720
+ const message = err instanceof Error ? err.message : String(err);
12721
+ console.error(`[progress-stream] failed to persist ${ctx.stage}/${step} (${row.status}): ${message}`);
12722
+ });
12723
+ }
12631
12724
  };
12632
12725
  function defaultHint(tool) {
12633
12726
  const knownTools = /* @__PURE__ */ new Set([
@@ -13088,7 +13181,7 @@ async function handlePlan(adapter2, config2, args) {
13088
13181
  const focus = typeof args.focus === "string" ? args.focus : void 0;
13089
13182
  const force = args.force === true;
13090
13183
  const handoffsOnly = args.handoffs_only === true;
13091
- const tracker = new ProgressTracker(toolMode === "apply" ? "apply_validate" : "prepare_validate");
13184
+ const tracker = new ProgressTracker(toolMode === "apply" ? "apply_validate" : "prepare_validate").bindStream(adapter2, { stage: "plan" });
13092
13185
  try {
13093
13186
  if (toolMode === "apply") {
13094
13187
  const resolved = await resolveLlmResponse(
@@ -13160,6 +13253,10 @@ async function handlePlan(adapter2, config2, args) {
13160
13253
  }
13161
13254
  const skipHandoffs = args.skip_handoffs === true;
13162
13255
  const result = await preparePlan(adapter2, config2, filters, focus, force, handoffsOnly, skipHandoffs, tracker);
13256
+ tracker.setStreamScope({ cycle: result.cycleNumber + 1 });
13257
+ for (const step of ["health-check", "inbox-triage", "board-integrity", "maturity-gate", "recommendation", "dependency-chain"]) {
13258
+ await tracker.recordStep(step);
13259
+ }
13163
13260
  planPrepareCache.set(callerKey, {
13164
13261
  contextHashes: result.contextHashes,
13165
13262
  userMessage: result.userMessage,
@@ -18322,6 +18419,86 @@ Examples (use whichever provider you have): ${spec.examples}.
18322
18419
  _Recommendation only \u2014 PAPI never selects or runs a model. Policy: XS/S \u2192 cheap, M/L \u2192 capable, XL \u2192 frontier._`;
18323
18420
  }
18324
18421
 
18422
+ // src/lib/capabilities.ts
18423
+ import {
18424
+ CAPABILITY_KEYS,
18425
+ CAPABILITY_REGISTRY,
18426
+ isCapabilityKey,
18427
+ isCapabilityEnabled
18428
+ } from "@papi-ai/shared";
18429
+
18430
+ // src/lib/directive-builders.ts
18431
+ function buildPrReviewerDirective(caps) {
18432
+ if (!isCapabilityEnabled(caps, "prReviewer")) return null;
18433
+ return `
18434
+
18435
+ **Quality Gate:** no auto-review attached. PAPI's standard pre-accept step is a code review of the branch diff \u2014 run \`review_submit\` with \`dispatch:"subagent"\` to auto-review, or attach \`auto_review\` findings. Risk-tier work (auth, data, migrations, CI) should always carry one.`;
18436
+ }
18437
+ function buildChangelogDirective(caps, inner) {
18438
+ if (!isCapabilityEnabled(caps, "changelog")) return null;
18439
+ return inner;
18440
+ }
18441
+ function buildDiscoveredIssuesDirective(caps, issueLines) {
18442
+ if (!isCapabilityEnabled(caps, "discoveredIssues")) return null;
18443
+ if (issueLines.length === 0) return null;
18444
+ return [
18445
+ "",
18446
+ "---",
18447
+ "",
18448
+ `## Discovered Issues (${issueLines.length})`,
18449
+ "",
18450
+ ...issueLines,
18451
+ "",
18452
+ "*These issues were logged during builds \u2014 triage them in the next plan.*"
18453
+ ].join("\n");
18454
+ }
18455
+ function buildModelRecommendationDirective(caps, tierBlock) {
18456
+ if (!isCapabilityEnabled(caps, "modelRecommendation")) return null;
18457
+ return tierBlock;
18458
+ }
18459
+ function buildVerifyHealthCheckDirective(caps) {
18460
+ if (!isCapabilityEnabled(caps, "verifyHealthCheck")) return null;
18461
+ return `
18462
+
18463
+ **Health check:** before you consider the cycle shipped, verify cycle state (plan validity, review coverage, branch hygiene) \u2014 run the \`papi-verify\` skill or a quick \`board_view\` pass.`;
18464
+ }
18465
+ function buildGestaltPreBuildDirective(caps) {
18466
+ if (!isCapabilityEnabled(caps, "gestaltPreBuild")) return null;
18467
+ return `
18468
+
18469
+ **Gestalt pre-build:** on the first task of a multi-task cycle, read every task's BUILD HANDOFF together (shared files, sequencing, module split) before building \u2014 one-time check at cycle start.`;
18470
+ }
18471
+ function buildBatchBuildRollupDirective(caps) {
18472
+ if (!isCapabilityEnabled(caps, "batchBuildRollup")) return null;
18473
+ return `
18474
+
18475
+ **Batch rollup:** after the final task of a batch, emit a cycle-level rollup \u2014 build summary table + discovered issues grouped by severity + next action.`;
18476
+ }
18477
+ function buildSecurityScanDirective(caps) {
18478
+ if (!isCapabilityEnabled(caps, "securityScan")) return null;
18479
+ return `
18480
+
18481
+ **Security scan:** risk-tier changes (auth, data, migrations, secrets, endpoints) should carry a security pass \u2014 OWASP top-10 + secret-exposure check on the changed surface before merge.`;
18482
+ }
18483
+ function buildDeployHookDirective(caps, deployCommand) {
18484
+ if (!isCapabilityEnabled(caps, "deployHook")) return null;
18485
+ const command = deployCommand?.trim() || void 0;
18486
+ if (!command) return null;
18487
+ return `
18488
+
18489
+ ---
18490
+
18491
+ ## Deploy \u2014 run after this release
18492
+
18493
+ The post-release deploy hook is on and a deploy command is configured, so ship the merged release now.
18494
+
18495
+ Run your deploy command yourself:
18496
+ \`\`\`
18497
+ ${command}
18498
+ \`\`\`
18499
+ PAPI never runs this command itself (AD-58) \u2014 you run it in your own environment. Turn the "Post-release deploy" capability off in the dashboard, or unset PAPI_DEPLOY, to stop this reminder.`;
18500
+ }
18501
+
18325
18502
  // src/services/build.ts
18326
18503
  import { randomUUID as randomUUID11 } from "crypto";
18327
18504
  import { readdirSync as readdirSync5, existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync2, unlinkSync, mkdirSync as mkdirSync2 } from "fs";
@@ -18998,6 +19175,65 @@ async function updatePluginMarketplaces(version) {
18998
19175
  return [cursor, vscode].filter((r) => r !== null);
18999
19176
  }
19000
19177
 
19178
+ // src/lib/release-gate.ts
19179
+ function evaluateReleaseGate(caps, gateCommand, gateResult) {
19180
+ const command = gateCommand?.trim() || void 0;
19181
+ if (!isCapabilityEnabled(caps, "releaseGate")) {
19182
+ return {
19183
+ action: "proceed",
19184
+ stepStatus: "complete",
19185
+ metadata: { skipped: true, reason: "capability_off" },
19186
+ message: ""
19187
+ };
19188
+ }
19189
+ if (!command) {
19190
+ return {
19191
+ action: "proceed",
19192
+ stepStatus: "complete",
19193
+ metadata: { note: "no gate configured" },
19194
+ message: ""
19195
+ };
19196
+ }
19197
+ if (gateResult === "fail") {
19198
+ return {
19199
+ action: "block",
19200
+ stepStatus: "failed",
19201
+ metadata: { command },
19202
+ message: `Release blocked \u2014 quality gate failed.
19203
+
19204
+ The gate command \`${command}\` reported a failure, so PAPI did NOT tag, merge, or close the cycle.
19205
+
19206
+ Fix the failing tests/build, re-run \`${command}\`, and call \`release\` again with \`gate_result: "pass"\` once it is green. To ship past a failing gate anyway, turn the "Release quality gate" capability off in the dashboard, or unset PAPI_GATE.`
19207
+ };
19208
+ }
19209
+ if (gateResult === "pass") {
19210
+ return {
19211
+ action: "proceed",
19212
+ stepStatus: "complete",
19213
+ metadata: { command },
19214
+ message: ""
19215
+ };
19216
+ }
19217
+ return {
19218
+ action: "directive",
19219
+ stepStatus: "active",
19220
+ metadata: { command },
19221
+ message: `## Quality gate \u2014 run before this release ships
19222
+
19223
+ PAPI's release quality gate is on and a gate command is configured, so the release is HELD until the gate passes. Nothing has been tagged, merged, or closed yet.
19224
+
19225
+ 1. Run the gate command yourself:
19226
+ \`\`\`
19227
+ ${command}
19228
+ \`\`\`
19229
+ 2. Re-call \`release\` with the same \`branch\` and \`version\`, adding:
19230
+ - \`gate_result: "pass"\` if it succeeded \u2014 the release then proceeds normally.
19231
+ - \`gate_result: "fail"\` if it failed \u2014 the release stays blocked so you can fix it first.
19232
+
19233
+ PAPI never runs this command itself (AD-58) \u2014 you run it in your own environment and report the result.`
19234
+ };
19235
+ }
19236
+
19001
19237
  // src/tools/release.ts
19002
19238
  function parseGithubOwner(input) {
19003
19239
  if (!input) return null;
@@ -19078,6 +19314,11 @@ var releaseTool = {
19078
19314
  type: "boolean",
19079
19315
  description: "Update CHANGELOG.md and mark the cycle complete, but skip creating a git tag and pushing it. Use when you want cycle closure and changelog tracking without burning a version number. The version parameter is still used for the CHANGELOG entry heading."
19080
19316
  },
19317
+ gate_result: {
19318
+ type: "string",
19319
+ enum: ["pass", "fail"],
19320
+ description: 'task-2482 (release quality gate): the result of running the configured gate command (papi.gate / PAPI_GATE). Set this on the SECOND release call AFTER you have run the gate command yourself \u2014 "pass" lets the release proceed, "fail" keeps it blocked. Leave it UNSET on the first call: if the release-quality-gate capability is on and a gate command is configured, release returns a directive telling you to run the command, then re-call with the result. PAPI never runs the command itself (AD-58).'
19321
+ },
19081
19322
  observations: {
19082
19323
  type: "array",
19083
19324
  description: "Optional dogfood observations from this cycle to persist to the DB. Each entry records friction, methodology signals, or commercial insights.",
@@ -19108,6 +19349,7 @@ async function handleRelease(adapter2, config2, args) {
19108
19349
  let version = args.version;
19109
19350
  const force = args.force;
19110
19351
  const skipVersion = args.skipVersion;
19352
+ const gateResult = args.gate_result;
19111
19353
  const rawObservations = args.observations;
19112
19354
  if (!branch || !version) {
19113
19355
  return errorResponse('both branch and version are required. Example: release branch="main" version="v0.1.0-alpha"');
@@ -19121,8 +19363,9 @@ async function handleRelease(adapter2, config2, args) {
19121
19363
  version = `${version}-${suffix}`;
19122
19364
  }
19123
19365
  }
19124
- const tracker = new ProgressTracker("validate-args");
19366
+ const tracker = new ProgressTracker("validate-args").bindStream(adapter2, { stage: "release" });
19125
19367
  try {
19368
+ await tracker.recordStep("release_starting");
19126
19369
  tracker.mark("owner-identity-guard");
19127
19370
  const gate = await resolveOwnerGate(adapter2, config2);
19128
19371
  const productionBaseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
@@ -19249,12 +19492,41 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
19249
19492
  console.error(`[release] gh CLI not available \u2014 ${pendingGrouped.length} shared branch(es) will be squash-merged via git: ${pendingGrouped.join(", ")}`);
19250
19493
  }
19251
19494
  }
19495
+ await tracker.recordStep("readiness_verified");
19496
+ let caps = {};
19497
+ try {
19498
+ const info = adapter2.getProjectInfo ? await adapter2.getProjectInfo() : null;
19499
+ caps = info?.capabilities ?? {};
19500
+ } catch {
19501
+ caps = {};
19502
+ }
19503
+ tracker.mark("quality-gate");
19504
+ const gateDecision = evaluateReleaseGate(caps, config2.gateCommand, gateResult);
19505
+ await tracker.recordStep("quality_gate", {
19506
+ status: gateDecision.stepStatus,
19507
+ capabilityKey: "releaseGate",
19508
+ capabilityEnabled: isCapabilityEnabled(caps, "releaseGate"),
19509
+ metadata: gateDecision.metadata
19510
+ });
19511
+ if (gateDecision.action === "directive") {
19512
+ return textResponse(gateDecision.message);
19513
+ }
19514
+ if (gateDecision.action === "block") {
19515
+ return errorResponse(gateDecision.message);
19516
+ }
19252
19517
  tracker.mark("create-release");
19253
19518
  const result = await createRelease(config2, branch, version, adapter2, void 0, {
19254
19519
  force: force ?? false,
19255
19520
  skipVersion: skipVersion ?? false,
19256
19521
  callerUserId: gate.callerUserId
19257
19522
  });
19523
+ tracker.setStreamScope({ cycle: result.cycleClosed ?? null });
19524
+ await tracker.recordStep("cycle_complete", { metadata: { version: result.version } });
19525
+ for (const m of result.groupedBranchMerges ?? []) {
19526
+ await tracker.recordStep("branch_merged", {
19527
+ metadata: { branch: m.branch, prUrl: m.prUrl ?? null }
19528
+ });
19529
+ }
19258
19530
  const lines = [
19259
19531
  `## Release ${result.version}${skipVersion ? " (skip version)" : ""}`,
19260
19532
  "",
@@ -19283,10 +19555,8 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
19283
19555
  const reports = await adapter2.getBuildReportsSince(closedCycle);
19284
19556
  const EMPTY = /* @__PURE__ */ new Set(["None", "none", "N/A", "", "null"]);
19285
19557
  const issues = reports.filter((r) => r.discoveredIssues && !EMPTY.has(r.discoveredIssues.trim())).map((r) => `- **${r.taskId}** (${r.taskName}): ${r.discoveredIssues}`);
19286
- if (issues.length > 0) {
19287
- lines.push("", "---", "", `## Discovered Issues (${issues.length})`, "", ...issues);
19288
- lines.push("", "*These issues were logged during builds \u2014 triage them in the next plan.*");
19289
- }
19558
+ const issuesDirective = buildDiscoveredIssuesDirective(caps, issues);
19559
+ if (issuesDirective) lines.push(issuesDirective);
19290
19560
  }
19291
19561
  } catch {
19292
19562
  }
@@ -19306,28 +19576,54 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
19306
19576
  lines.push("", "\u26A0\uFE0F Dogfood observations could not be saved to DB \u2014 log them manually in DOGFOOD_LOG.md.");
19307
19577
  }
19308
19578
  }
19309
- const cycleUpdateDirective = buildCycleUpdateCurationDirective(result.version, result.cycleClosed ?? 0);
19579
+ const cycleUpdateDirective = buildChangelogDirective(
19580
+ caps,
19581
+ buildCycleUpdateCurationDirective(result.version, result.cycleClosed ?? 0)
19582
+ );
19310
19583
  if (cycleUpdateDirective) lines.push(cycleUpdateDirective);
19311
- try {
19312
- const xPosted = await postReleaseToX(result.version, result.cycleClosed ?? 0);
19313
- if (xPosted) lines.push("", "Posted release announcement to X.");
19314
- } catch {
19584
+ await tracker.recordStep("changelog", {
19585
+ capabilityKey: "changelog",
19586
+ capabilityEnabled: isCapabilityEnabled(caps, "changelog"),
19587
+ status: cycleUpdateDirective ? "complete" : "active"
19588
+ });
19589
+ const deployDirective = buildDeployHookDirective(caps, config2.deployCommand);
19590
+ if (deployDirective) {
19591
+ lines.push(deployDirective);
19592
+ await tracker.recordStep("deploy_hook", {
19593
+ capabilityKey: "deployHook",
19594
+ capabilityEnabled: isCapabilityEnabled(caps, "deployHook"),
19595
+ status: "complete",
19596
+ metadata: { configured: true }
19597
+ });
19315
19598
  }
19316
- try {
19317
- const registryResults = await updateRegistryListings(result.version);
19318
- for (const r of registryResults) {
19319
- lines.push("", `Registry [${r.registry}]: ${r.message}`);
19599
+ const publishEnabled = isCapabilityEnabled(caps, "publishDirective");
19600
+ if (publishEnabled) {
19601
+ try {
19602
+ const xPosted = await postReleaseToX(result.version, result.cycleClosed ?? 0);
19603
+ if (xPosted) lines.push("", "Posted release announcement to X.");
19604
+ } catch {
19320
19605
  }
19321
- } catch {
19322
19606
  }
19323
- try {
19324
- const marketplaceResults = await updatePluginMarketplaces(result.version);
19325
- for (const r of marketplaceResults) {
19326
- lines.push("", `Marketplace [${r.registry}]: ${r.message}`);
19607
+ if (publishEnabled) {
19608
+ try {
19609
+ const registryResults = await updateRegistryListings(result.version);
19610
+ for (const r of registryResults) {
19611
+ lines.push("", `Registry [${r.registry}]: ${r.message}`);
19612
+ }
19613
+ } catch {
19614
+ }
19615
+ try {
19616
+ const marketplaceResults = await updatePluginMarketplaces(result.version);
19617
+ for (const r of marketplaceResults) {
19618
+ lines.push("", `Marketplace [${r.registry}]: ${r.message}`);
19619
+ }
19620
+ } catch {
19327
19621
  }
19328
- } catch {
19329
19622
  }
19623
+ const verifyDirective = buildVerifyHealthCheckDirective(caps);
19624
+ if (verifyDirective) lines.push(verifyDirective);
19330
19625
  lines.push("", `Next: cycle released! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`);
19626
+ await tracker.recordStep("released", { metadata: { version: result.version } });
19331
19627
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
19332
19628
  return textResponse(lines.join("\n") + filesToWriteSection);
19333
19629
  } catch (err) {
@@ -20084,13 +20380,19 @@ function assertDeployVerification(config2, input) {
20084
20380
  if (triggerHits.length === 0) return;
20085
20381
  const verification = input.productionVerification;
20086
20382
  if (!verification) {
20087
- throw new Error(
20088
- `Deploy-verification required: this branch's diff touches ${triggerHits.length} trigger-surface file(s):
20089
- ` + triggerHits.map((p) => ` - ${p}`).join("\n") + `
20383
+ const targetLine = config2.verifyCommand ? `
20090
20384
 
20091
- Run a curl against the live deploy and pass production_verification on build_execute complete:
20385
+ Verify against your configured target (papi.verify): \`${config2.verifyCommand}\`
20386
+ Run it yourself, then pass production_verification on build_execute complete:
20092
20387
  { urls, curl_command, http_status, response_excerpt, verified_at }
20388
+ ` : `
20093
20389
 
20390
+ Run a curl against the live deploy and pass production_verification on build_execute complete:
20391
+ { urls, curl_command, http_status, response_excerpt, verified_at }
20392
+ `;
20393
+ throw new Error(
20394
+ `Deploy-verification required: this branch's diff touches ${triggerHits.length} trigger-surface file(s):
20395
+ ` + triggerHits.map((p) => ` - ${p}`).join("\n") + targetLine + `
20094
20396
  Trigger surface is enforced because changes here have historically shipped to prod undetected (C166, C264-C265, C271).`
20095
20397
  );
20096
20398
  }
@@ -21304,13 +21606,17 @@ async function handleBuildExecute(adapter2, config2, args, clientName) {
21304
21606
  if (hasReportFields(args)) {
21305
21607
  return handleExecuteComplete(adapter2, config2, taskId, args, light, clientName);
21306
21608
  }
21307
- const tracker = new ProgressTracker("start_build");
21609
+ const tracker = new ProgressTracker("start_build").bindStream(adapter2, { stage: "build", taskId });
21308
21610
  try {
21611
+ await tracker.recordStep("started");
21309
21612
  const result = await startBuild(adapter2, config2, taskId, { light }, clientName);
21613
+ tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.task.cycle ?? null });
21614
+ await tracker.recordStep("branch_ready");
21310
21615
  tracker.mark("start_decorate_handoff");
21311
21616
  const branchInfo = result.branchLines.length > 0 ? result.branchLines.map((l) => `> ${l}`).join("\n") + "\n\n" : "";
21312
21617
  const phaseNote = result.phaseChanges.length > 0 ? "\n\n" + result.phaseChanges.map((c) => `Phase auto-updated: ${c.phaseId} ${c.oldStatus} \u2192 ${c.newStatus}`).join("\n") : "";
21313
21618
  const projectInfo = adapter2.getProjectInfo ? await adapter2.getProjectInfo().catch(() => null) : null;
21619
+ const caps = projectInfo?.capabilities ?? {};
21314
21620
  const projectBanner = projectInfo ? getProjectConnectionBanner(projectInfo.name, projectInfo.slug) : null;
21315
21621
  const projectLine = projectBanner ? `> ${projectBanner}
21316
21622
 
@@ -21358,8 +21664,12 @@ ${entries}`;
21358
21664
  const moduleInstructions = getModuleInstructions(result.task.module);
21359
21665
  const moduleContext = await getModuleContext(adapter2, result.task);
21360
21666
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
21361
- const modelNote = formatModelRecommendation(result.task.buildHandoff?.effort ?? result.task.complexity);
21362
- return textResponse(header + serializeBuildHandoff(result.task.buildHandoff) + modelNote + adSection + moduleInstructions + moduleContext + dogfoodSection + verificationNote + chainInstruction + phaseNote + filesToWriteSection);
21667
+ const modelNote = buildModelRecommendationDirective(
21668
+ caps,
21669
+ formatModelRecommendation(result.task.buildHandoff?.effort ?? result.task.complexity)
21670
+ ) ?? "";
21671
+ const gestaltNote = buildGestaltPreBuildDirective(caps) ?? "";
21672
+ return textResponse(header + serializeBuildHandoff(result.task.buildHandoff) + modelNote + gestaltNote + adSection + moduleInstructions + moduleContext + dogfoodSection + verificationNote + chainInstruction + phaseNote + filesToWriteSection);
21363
21673
  } catch (err) {
21364
21674
  if (isNoHandoffError(err)) {
21365
21675
  const lines = [
@@ -21446,7 +21756,7 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
21446
21756
  if (!parsedEstimatedEffort) {
21447
21757
  return errorResponse(`Invalid estimated_effort value "${estimatedEffort}". Must be one of: XS, S, M, L, XL.`);
21448
21758
  }
21449
- const tracker = new ProgressTracker("complete_validate");
21759
+ const tracker = new ProgressTracker("complete_validate").bindStream(adapter2, { stage: "build", taskId });
21450
21760
  try {
21451
21761
  tracker.mark("complete_build");
21452
21762
  const result = await completeBuild(adapter2, config2, taskId, {
@@ -21475,6 +21785,11 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
21475
21785
  preview
21476
21786
  }, { light }, clientName);
21477
21787
  tracker.mark("complete_format");
21788
+ tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.cycleNumber });
21789
+ await tracker.recordStep("report_written");
21790
+ await tracker.recordStep("issues_triaged", {
21791
+ metadata: { autoTriagedCount: result.autoTriagedCount ?? 0 }
21792
+ });
21478
21793
  if (resolvesLearnings && resolvesLearnings.length > 0 && adapter2.updateCycleLearningActionRef) {
21479
21794
  for (const learningId of resolvesLearnings) {
21480
21795
  try {
@@ -21483,8 +21798,35 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
21483
21798
  }
21484
21799
  }
21485
21800
  }
21801
+ await tracker.recordStep("learnings", {
21802
+ metadata: { learningsLinkedCount: result.learningsLinkedCount ?? 0 }
21803
+ });
21804
+ await tracker.recordStep("moving-to-review", {
21805
+ metadata: { status: result.task.status }
21806
+ });
21807
+ if (productionVerification) {
21808
+ await tracker.recordStep("verify", {
21809
+ stage: "release",
21810
+ status: "complete",
21811
+ metadata: {
21812
+ httpStatus: productionVerification.http_status,
21813
+ urlCount: productionVerification.urls.length,
21814
+ targetConfigured: Boolean(config2.verifyCommand)
21815
+ }
21816
+ });
21817
+ }
21486
21818
  const docsNote = await detectUnregisteredDocsNote(adapter2, config2);
21487
- return textResponse(formatCompleteResult(result) + docsNote);
21819
+ let batchRollupNote = "";
21820
+ if (result.cycleProgress && result.cycleProgress.completed >= result.cycleProgress.total) {
21821
+ try {
21822
+ const info = adapter2.getProjectInfo ? await adapter2.getProjectInfo() : null;
21823
+ const caps = info?.capabilities ?? {};
21824
+ batchRollupNote = buildBatchBuildRollupDirective(caps) ?? "";
21825
+ } catch {
21826
+ batchRollupNote = "";
21827
+ }
21828
+ }
21829
+ return textResponse(formatCompleteResult(result) + docsNote + batchRollupNote);
21488
21830
  } catch (err) {
21489
21831
  const message = err instanceof Error ? err.message : String(err);
21490
21832
  if (isBuildPushError(err)) {
@@ -22020,6 +22362,12 @@ init_git();
22020
22362
 
22021
22363
  // src/services/ad-hoc.ts
22022
22364
  import { randomUUID as randomUUID14 } from "crypto";
22365
+ function resolveAdHocCycle(cycle, latest, latestComplete) {
22366
+ if (cycle === void 0) return null;
22367
+ if (typeof cycle === "number") return cycle;
22368
+ if (cycle === "current") return latest;
22369
+ return latestComplete ? latest + 1 : latest;
22370
+ }
22023
22371
  async function recordAdHoc(adapter2, input) {
22024
22372
  const [health, phases] = await Promise.all([
22025
22373
  adapter2.getCycleHealth(),
@@ -22027,6 +22375,14 @@ async function recordAdHoc(adapter2, input) {
22027
22375
  ]);
22028
22376
  const cycle = health.totalCycles;
22029
22377
  const now = /* @__PURE__ */ new Date();
22378
+ const held = input.hold === true;
22379
+ const targetCycle = held ? cycle + 1 : resolveAdHocCycle(
22380
+ input.cycle,
22381
+ health.totalCycles,
22382
+ health.latestCycleStatus === "complete"
22383
+ );
22384
+ const promoted = targetCycle !== null;
22385
+ const landInReview = held || promoted && input.stage === "release";
22030
22386
  let task;
22031
22387
  if (input.taskId) {
22032
22388
  const existing = await adapter2.getTask(input.taskId);
@@ -22048,18 +22404,19 @@ async function recordAdHoc(adapter2, input) {
22048
22404
  uuid: randomUUID14(),
22049
22405
  displayId: "",
22050
22406
  title: input.title,
22051
- status: "Done",
22407
+ status: landInReview ? "In Review" : "Done",
22052
22408
  priority: input.priority || "P2 Medium",
22053
22409
  complexity: input.effort === "XS" || input.effort === "S" ? "Small" : "Medium",
22054
22410
  module: input.module || "Core",
22055
22411
  epic: input.epic || "Platform",
22056
22412
  phase,
22057
22413
  owner: input.owner || "Cathal",
22058
- reviewed: true,
22414
+ reviewed: !landInReview,
22059
22415
  createdCycle: cycle,
22416
+ ...targetCycle !== null ? { cycle: targetCycle } : {},
22060
22417
  notes: input.notes ? `[ad-hoc] ${input.notes}` : "[ad-hoc]",
22061
22418
  taskType: input.taskType || "task",
22062
- source: "owner"
22419
+ source: "ad_hoc"
22063
22420
  });
22064
22421
  }
22065
22422
  const report = {
@@ -22134,6 +22491,22 @@ var adHocTool = {
22134
22491
  type: "string",
22135
22492
  enum: ["task", "bug", "research", "discovery", "spike", "idea"],
22136
22493
  description: 'Task type (default: inferred from description \u2014 "fix"/"bug" \u2192 bug, "research"/"investigate" \u2192 research, otherwise task).'
22494
+ },
22495
+ cycle: {
22496
+ description: 'task-2352: promote this ad-hoc work into a cycle so it shows on the hub and is counted as INJECTED work (distinct from planned). Omit for the default behaviour (an immediate Done task with no cycle). "current" = the active cycle; "next-if-plan-not-run" = the next cycle if the latest already shipped (so between-cycle work is not lost), else current; or an explicit cycle number.',
22497
+ oneOf: [
22498
+ { type: "string", enum: ["current", "next-if-plan-not-run"] },
22499
+ { type: "integer" }
22500
+ ]
22501
+ },
22502
+ stage: {
22503
+ type: "string",
22504
+ enum: ["done", "release"],
22505
+ description: 'task-2352: where promoted work lands. "done" (default) records it as Done + reviewed. "release" lands it In Review so it is reviewed and released WITH the cycle (fold-in) instead of being force-completed. Only meaningful with `cycle`.'
22506
+ },
22507
+ hold: {
22508
+ type: "boolean",
22509
+ description: "task-2477: held-adhoc. When true, do NOT force-complete or commit to main \u2014 record the task In Review pinned to the NEXT cycle (current + 1) so the planner won't re-plan it, and return a branch/PR directive (commit on feat/<task-id>, never main, leave unmerged) so it rides the next cycle's review \u2192 release bundled with planned work. One-call replacement for the two-call ad_hoc + board_edit stopgap. Takes precedence over `cycle`/`stage`."
22137
22510
  }
22138
22511
  },
22139
22512
  required: []
@@ -22164,6 +22537,12 @@ async function handleAdHoc(adapter2, config2, args) {
22164
22537
  rawNotes = rawNotes.slice(0, MAX_NOTES_LENGTH);
22165
22538
  notesTruncated = true;
22166
22539
  }
22540
+ const rawCycle = args.cycle;
22541
+ let cycleArg;
22542
+ if (typeof rawCycle === "number") cycleArg = rawCycle;
22543
+ else if (rawCycle === "current" || rawCycle === "next-if-plan-not-run") cycleArg = rawCycle;
22544
+ const stageArg = args.stage === "release" ? "release" : void 0;
22545
+ const holdArg = args.hold === true;
22167
22546
  const result = await recordAdHoc(adapter2, {
22168
22547
  title: title || "",
22169
22548
  taskId,
@@ -22174,9 +22553,12 @@ async function handleAdHoc(adapter2, config2, args) {
22174
22553
  priority: priorityRaw,
22175
22554
  taskType: typeRaw,
22176
22555
  // PROJECT-SPECIFIC: owner resolved from config (PAPI_OWNER env var, default 'Cathal')
22177
- owner: config2.projectOwner
22556
+ owner: config2.projectOwner,
22557
+ cycle: cycleArg,
22558
+ stage: stageArg,
22559
+ hold: holdArg
22178
22560
  });
22179
- if (isGitAvailable() && isGitRepo(config2.projectRoot)) {
22561
+ if (!holdArg && isGitAvailable() && isGitRepo(config2.projectRoot)) {
22180
22562
  try {
22181
22563
  stageDirAndCommit(
22182
22564
  config2.projectRoot,
@@ -22189,8 +22571,29 @@ async function handleAdHoc(adapter2, config2, args) {
22189
22571
  const truncateWarning = notesTruncated ? ` (notes truncated to ${MAX_NOTES_LENGTH} chars)` : "";
22190
22572
  const taskModule = result.task.module || "Core";
22191
22573
  const typeLabel = result.task.taskType || typeRaw;
22574
+ const promoNote = result.task.cycle != null ? ` Promoted into Cycle ${result.task.cycle} as injected work${result.task.status === "In Review" ? " (In Review \u2014 will release with the cycle)" : ""}.` : "";
22575
+ if (holdArg) {
22576
+ const branch = `feat/${result.task.id}`;
22577
+ return textResponse(
22578
+ `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning}${promoNote} Build report attached.
22579
+
22580
+ ## Held for the next cycle \u2014 branch + commit, do NOT merge
22581
+ The task is recorded **In Review** and pinned to **Cycle ${result.task.cycle}**, so the planner won't re-plan it and it rides that cycle's review \u2192 release bundled with planned work.
22582
+
22583
+ 1. Create a branch and commit your code there (never \`main\`):
22584
+ \`\`\`
22585
+ git switch -c ${branch}
22586
+ git add -- <your changed files>
22587
+ git commit -m "feat(${result.task.id}): ${result.task.title}"
22588
+ git push -u origin ${branch}
22589
+ \`\`\`
22590
+ 2. Leave the branch **unmerged** \u2014 it is picked up by the next cycle's \`release\`.
22591
+
22592
+ _To correct: board_edit ${result.task.id} with updated fields._`
22593
+ );
22594
+ }
22192
22595
  return textResponse(
22193
- `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning} Build report attached.
22596
+ `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning}${promoNote} Build report attached.
22194
22597
  _To correct: board_edit ${result.task.id} with updated fields._`
22195
22598
  );
22196
22599
  }
@@ -23285,13 +23688,22 @@ async function handleReviewSubmit(adapter2, config2, args) {
23285
23688
  if (!stage) {
23286
23689
  return errorResponse('stage is required. Use "handoff-review" or "build-acceptance".');
23287
23690
  }
23691
+ let caps = {};
23692
+ try {
23693
+ const info = adapter2.getProjectInfo ? await adapter2.getProjectInfo() : null;
23694
+ caps = info?.capabilities ?? {};
23695
+ } catch {
23696
+ caps = {};
23697
+ }
23288
23698
  const explicitDispatch = args.dispatch === "subagent";
23289
- const autoDispatchEligible = !verdict && args.dispatch !== "inline" && process.env.PAPI_AUTO_DISPATCH !== "false";
23290
- if ((explicitDispatch || autoDispatchEligible) && stage === "build-acceptance" && taskId) {
23699
+ const autoDispatchOptIn = args.dispatch !== "inline" && process.env.PAPI_AUTO_DISPATCH !== "false" && isCapabilityEnabled(caps, "prReviewer");
23700
+ const autoDispatchEligible = !verdict && autoDispatchOptIn;
23701
+ const capabilityAutoReviewEligible = verdict === "accept" && !autoReview && autoDispatchOptIn;
23702
+ if ((explicitDispatch || autoDispatchEligible || capabilityAutoReviewEligible) && stage === "build-acceptance" && taskId) {
23291
23703
  const dispatch = await buildReviewDispatch(adapter2, config2, taskId);
23292
23704
  if (!dispatch.ok) {
23293
23705
  if (explicitDispatch) return errorResponse(dispatch.error);
23294
- } else if (explicitDispatch || dispatch.contextBytes > REVIEW_DISPATCH_THRESHOLD) {
23706
+ } else if (explicitDispatch || capabilityAutoReviewEligible || dispatch.contextBytes > REVIEW_DISPATCH_THRESHOLD) {
23295
23707
  return textResponse(dispatch.prompt);
23296
23708
  }
23297
23709
  }
@@ -23332,7 +23744,8 @@ async function handleReviewSubmit(adapter2, config2, args) {
23332
23744
  }
23333
23745
  }
23334
23746
  }
23335
- const tracker = new ProgressTracker("validate-args");
23747
+ const tracker = new ProgressTracker("validate-args").bindStream(adapter2, { stage: "review", taskId });
23748
+ await tracker.recordStep("reviewer_confirmed");
23336
23749
  const handoffRegenResponse = args.handoff_regen_response;
23337
23750
  if (handoffRegenResponse?.trim()) {
23338
23751
  tracker.mark("apply-handoff-regen");
@@ -23349,6 +23762,14 @@ async function handleReviewSubmit(adapter2, config2, args) {
23349
23762
  adapter2,
23350
23763
  { taskId, stage, verdict, comments, reviewer, autoReview }
23351
23764
  );
23765
+ tracker.setStreamScope({ cycle: result.currentCycle > 0 ? result.currentCycle : null });
23766
+ await tracker.recordStep("verdict_recorded", { metadata: { verdict, stage } });
23767
+ if (result.unblockedTasks.length > 0) {
23768
+ await tracker.recordStep("dependents_unblocked", { metadata: { count: result.unblockedTasks.length } });
23769
+ }
23770
+ if (result.closedDocActions.length > 0) {
23771
+ await tracker.recordStep("docs_closed", { metadata: { count: result.closedDocActions.length } });
23772
+ }
23352
23773
  const statusNote = result.newStatus ? ` Task status updated to **${result.newStatus}**.` : " Task status unchanged.";
23353
23774
  const unblockNote = result.unblockedTasks.length > 0 ? `
23354
23775
 
@@ -23388,6 +23809,7 @@ ${result.handoffRegenPrompt.userMessage}
23388
23809
 
23389
23810
  > ${mergeResult.message}`;
23390
23811
  }
23812
+ await tracker.recordStep("deferred", { metadata: { reason: "merge-skipped" } });
23391
23813
  } else if (!mergeResult.merged) {
23392
23814
  mergeFailed = true;
23393
23815
  let revertNote = "";
@@ -23402,11 +23824,13 @@ ${result.handoffRegenPrompt.userMessage}
23402
23824
  \u26A0\uFE0F **PR merge failed \u2014 task NOT marked Done.**${revertNote}
23403
23825
 
23404
23826
  ${mergeResult.message}`;
23827
+ await tracker.recordStep("deferred", { status: "failed", metadata: { reason: "merge-failed" } });
23405
23828
  } else {
23406
23829
  try {
23407
23830
  await adapter2.updateTask(taskId, { mergedAt: (/* @__PURE__ */ new Date()).toISOString() });
23408
23831
  } catch {
23409
23832
  }
23833
+ await tracker.recordStep("merged");
23410
23834
  const detailLines = mergeResult.details.map((l) => `> ${l}`).join("\n");
23411
23835
  mergeNote = `
23412
23836
 
@@ -23482,6 +23906,7 @@ Merge or squash those PRs first, then run \`release\` manually.`;
23482
23906
  }
23483
23907
  const version = `v0.${result.currentCycle}.0`;
23484
23908
  const releaseResult = await createRelease(config2, baseBranch, version, adapter2, result.currentCycle);
23909
+ await tracker.recordStep("auto_release_triggered", { metadata: { version: releaseResult.version } });
23485
23910
  const pushInfo = releaseResult.pushNotes.join(" ");
23486
23911
  const groupedMergeNote = releaseResult.groupedBranchMerges?.length ? "\n" + releaseResult.groupedBranchMerges.map((r) => `- Merged shared branch \`${r.branch}\` via PR: ${r.prUrl ?? "n/a"}`).join("\n") : "";
23487
23912
  autoReleaseNote = `
@@ -23537,9 +23962,7 @@ ${findingLines}${more}` : "");
23537
23962
  \u26A0\uFE0F **Override recorded:** ${reviewer} accepted past a ${autoReview.verdict} quality gate (${autoReview.findings.length} finding${autoReview.findings.length === 1 ? "" : "s"}). The verdict + findings are stored on this review for the audit trail.`;
23538
23963
  }
23539
23964
  } else if (stage === "build-acceptance" && verdict === "accept") {
23540
- autoReviewNote = `
23541
-
23542
- **Quality Gate:** no auto-review attached. PAPI's standard pre-accept step is a code review of the branch diff \u2014 run \`review_submit\` with \`dispatch:"subagent"\` to auto-review, or attach \`auto_review\` findings. Risk-tier work (auth, data, migrations, CI) should always carry one.`;
23965
+ autoReviewNote = buildPrReviewerDirective(caps) ?? "";
23543
23966
  }
23544
23967
  let nextStepNote = "";
23545
23968
  if (!autoReleaseNote && stage === "build-acceptance") {
@@ -23551,6 +23974,7 @@ ${findingLines}${more}` : "");
23551
23974
  Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
23552
23975
  }
23553
23976
  }
23977
+ const securityNote = stage === "build-acceptance" && verdict === "accept" ? buildSecurityScanDirective(caps) ?? "" : "";
23554
23978
  tracker.mark("format-response");
23555
23979
  return textResponse(
23556
23980
  `**${result.stageLabel}** recorded for ${result.taskId}.
@@ -23558,7 +23982,7 @@ Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
23558
23982
  - **Verdict:** ${result.verdict}
23559
23983
  - **Comments:** ${result.comments}
23560
23984
 
23561
- ${statusNote}${autoReviewNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
23985
+ ${statusNote}${autoReviewNote}${securityNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
23562
23986
  );
23563
23987
  } catch (err) {
23564
23988
  const message = err instanceof Error ? err.message : String(err);
package/dist/prompts.js CHANGED
@@ -659,6 +659,14 @@ function buildHandoffsOnlyUserMessage(inputs) {
659
659
  }
660
660
  return parts.join("\n");
661
661
  }
662
+ function extractDependencyChain(displayText) {
663
+ const match = displayText.match(
664
+ /^##\s+Dependency Chain[^\n]*\n([\s\S]*?)(?=\n#{2,3}\s|$(?![\s\S]))/m
665
+ );
666
+ if (!match) return void 0;
667
+ const body = match[1].trim();
668
+ return body.length > 0 ? body : void 0;
669
+ }
662
670
  function parseStructuredOutput(raw) {
663
671
  const marker = "<!-- PAPI_STRUCTURED_OUTPUT -->";
664
672
  const markerIdx = raw.indexOf(marker);
@@ -674,6 +682,7 @@ function parseStructuredOutput(raw) {
674
682
  try {
675
683
  const parsed = JSON.parse(jsonMatch[1].trim());
676
684
  const data = coerceStructuredOutput(parsed);
685
+ data.dependencyChain = extractDependencyChain(displayText);
677
686
  return { displayText, data };
678
687
  } catch {
679
688
  return { displayText, data: null };
@@ -1502,6 +1511,7 @@ export {
1502
1511
  buildReviewSystemPrompt,
1503
1512
  buildReviewUserMessage,
1504
1513
  buildVisionTasksPrompt,
1514
+ extractDependencyChain,
1505
1515
  parseReviewStructuredOutput,
1506
1516
  parseStrategyChangeOutput,
1507
1517
  parseStructuredOutput
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@papi-ai/server",
3
- "version": "0.7.45",
4
- "description": "PAPI MCP server AI-powered sprint planning, build execution, and strategy review for software projects",
3
+ "version": "0.7.46",
4
+ "description": "PAPI MCP server \u2014 AI-powered sprint planning, build execution, and strategy review for software projects",
5
5
  "license": "Elastic-2.0",
6
6
  "mcpName": "io.github.getpapi/papi",
7
7
  "type": "module",
@@ -53,6 +53,7 @@
53
53
  "@modelcontextprotocol/sdk": "^1.27.1",
54
54
  "@papi-ai/adapter-md": "^0.2.0",
55
55
  "@papi-ai/adapter-pg": "^0.2.0",
56
+ "@papi-ai/shared": "^0.1.0",
56
57
  "@papi-ai/skills": "^0.1.0",
57
58
  "js-yaml": "^4.1.0"
58
59
  },