@papi-ai/server 0.7.45 → 0.7.47

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
@@ -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
  }
@@ -8307,31 +8306,66 @@ function formatBoardForReview(tasks) {
8307
8306
  Notes: ${t.notes}` : ""}`
8308
8307
  ).join("\n\n");
8309
8308
  }
8310
- var EFFORT_MAP = { XS: 1, S: 2, M: 3, L: 5, XL: 8 };
8311
- function computeSnapshotsFromBuildReports(reports) {
8312
- if (reports.length === 0) return [];
8313
- const byCycleMap = /* @__PURE__ */ new Map();
8309
+ function effortWeight(size2) {
8310
+ switch ((size2 || "").toUpperCase()) {
8311
+ case "XS":
8312
+ return 1;
8313
+ case "S":
8314
+ case "SMALL":
8315
+ return 2;
8316
+ case "M":
8317
+ case "MEDIUM":
8318
+ return 3;
8319
+ case "L":
8320
+ case "LARGE":
8321
+ return 5;
8322
+ case "XL":
8323
+ return 8;
8324
+ default:
8325
+ return 3;
8326
+ }
8327
+ }
8328
+ function computeSnapshotsFromBuildReports(reports, tasks) {
8329
+ const reportsByCycle = /* @__PURE__ */ new Map();
8314
8330
  for (const r of reports) {
8315
- const existing = byCycleMap.get(r.cycle) ?? [];
8331
+ const existing = reportsByCycle.get(r.cycle) ?? [];
8316
8332
  existing.push(r);
8317
- byCycleMap.set(r.cycle, existing);
8333
+ reportsByCycle.set(r.cycle, existing);
8334
+ }
8335
+ const tasksByCycle = /* @__PURE__ */ new Map();
8336
+ for (const t of tasks ?? []) {
8337
+ if (t.cycle == null) continue;
8338
+ const existing = tasksByCycle.get(t.cycle) ?? [];
8339
+ existing.push(t);
8340
+ tasksByCycle.set(t.cycle, existing);
8318
8341
  }
8342
+ const cycleNumbers = /* @__PURE__ */ new Set([...reportsByCycle.keys(), ...tasksByCycle.keys()]);
8343
+ if (cycleNumbers.size === 0) return [];
8319
8344
  const snapshots = [];
8320
- for (const [sn, cycleReports] of byCycleMap) {
8321
- const completed = cycleReports.filter((r) => r.completed === "Yes").length;
8322
- const total = cycleReports.length;
8345
+ for (const sn of cycleNumbers) {
8346
+ const cycleReports = reportsByCycle.get(sn) ?? [];
8347
+ const cycleTaskRows = tasksByCycle.get(sn);
8323
8348
  const withEffort = cycleReports.filter((r) => r.estimatedEffort && r.actualEffort);
8324
8349
  const accurate = withEffort.filter((r) => r.estimatedEffort === r.actualEffort).length;
8325
8350
  const matchRate = withEffort.length > 0 ? Math.round(accurate / withEffort.length * 100) : 0;
8326
- let effortPoints = 0;
8327
- for (const r of cycleReports) {
8328
- effortPoints += EFFORT_MAP[r.actualEffort] ?? 3;
8351
+ let completed;
8352
+ let total;
8353
+ let effortPoints;
8354
+ if (cycleTaskRows && cycleTaskRows.length > 0) {
8355
+ const done = cycleTaskRows.filter((t) => t.status === "Done");
8356
+ completed = done.length;
8357
+ total = cycleTaskRows.length;
8358
+ effortPoints = done.reduce((s, t) => s + effortWeight(t.complexity), 0);
8359
+ } else {
8360
+ completed = cycleReports.filter((r) => r.completed === "Yes").length;
8361
+ total = cycleReports.length;
8362
+ effortPoints = cycleReports.reduce((s, r) => s + effortWeight(r.actualEffort), 0);
8329
8363
  }
8330
8364
  snapshots.push({
8331
8365
  cycle: sn,
8332
8366
  date: (/* @__PURE__ */ new Date()).toISOString(),
8333
- accuracy: [{ cycle: sn, reports: total, matchRate, mae: 0, bias: 0 }],
8334
- velocity: [{ cycle: sn, completed, partial: 0, failed: total - completed, effortPoints }]
8367
+ accuracy: [{ cycle: sn, reports: cycleReports.length, matchRate, mae: 0, bias: 0 }],
8368
+ velocity: [{ cycle: sn, completed, partial: 0, failed: Math.max(0, total - completed), effortPoints }]
8335
8369
  });
8336
8370
  }
8337
8371
  snapshots.sort((a, b2) => a.cycle - b2.cycle);
@@ -9273,6 +9307,14 @@ function buildHandoffsOnlyUserMessage(inputs) {
9273
9307
  }
9274
9308
  return parts.join("\n");
9275
9309
  }
9310
+ function extractDependencyChain(displayText) {
9311
+ const match = displayText.match(
9312
+ /^##\s+Dependency Chain[^\n]*\n([\s\S]*?)(?=\n#{2,3}\s|\n-{3,}\s*(?:\n|$)|\n\s*BUILD HANDOFF\b|\n<!--\s*PAPI_STRUCTURED_OUTPUT|$(?![\s\S]))/m
9313
+ );
9314
+ if (!match) return void 0;
9315
+ const body = match[1].trim();
9316
+ return body.length > 0 ? body : void 0;
9317
+ }
9276
9318
  function parseStructuredOutput(raw) {
9277
9319
  const marker = "<!-- PAPI_STRUCTURED_OUTPUT -->";
9278
9320
  const markerIdx = raw.indexOf(marker);
@@ -9288,6 +9330,7 @@ function parseStructuredOutput(raw) {
9288
9330
  try {
9289
9331
  const parsed = JSON.parse(jsonMatch[1].trim());
9290
9332
  const data = coerceStructuredOutput(parsed);
9333
+ data.dependencyChain = extractDependencyChain(displayText);
9291
9334
  return { displayText, data };
9292
9335
  } catch {
9293
9336
  return { displayText, data: null };
@@ -11746,8 +11789,27 @@ ${cleanContent}`;
11746
11789
  contextHashes,
11747
11790
  // task-2071 (MU-3): own the cycle by the planning member (pg defaults to the
11748
11791
  // project owner when unset, so owner-operated plans stay correct).
11749
- ...options.ownerUserId ? { userId: options.ownerUserId } : {}
11792
+ ...options.ownerUserId ? { userId: options.ownerUserId } : {},
11793
+ // task-2483 (C319): persist the plan's Dependency Chain build-order markdown.
11794
+ ...data.dependencyChain ? { dependencyChain: data.dependencyChain } : {}
11750
11795
  };
11796
+ const earlyCreateWarnings = [];
11797
+ try {
11798
+ await adapter2.createCycle({
11799
+ id: `cycle-${newCycleNumber}`,
11800
+ number: newCycleNumber,
11801
+ status: "active",
11802
+ startDate: cycle.startDate,
11803
+ goals: cycle.goals,
11804
+ boardHealth: "",
11805
+ taskIds: [],
11806
+ ...cycle.userId ? { userId: cycle.userId } : {}
11807
+ });
11808
+ } catch (err) {
11809
+ const msg = `early createCycle failed for cycle ${newCycleNumber}: ${err instanceof Error ? err.message : String(err)}`;
11810
+ console.error(`[plan] ${msg}`);
11811
+ earlyCreateWarnings.push(msg);
11812
+ }
11751
11813
  let dedupedNewTasks = data.newTasks ?? [];
11752
11814
  if (dedupedNewTasks.length > 0) {
11753
11815
  const existingTasks = await adapter2.queryBoard({ compact: true });
@@ -11840,7 +11902,7 @@ ${cleanContent}`;
11840
11902
  `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
11903
  );
11842
11904
  }
11843
- const allWarnings = [...result.warnings, ...verifyWarnings];
11905
+ const allWarnings = [...result.warnings, ...verifyWarnings, ...earlyCreateWarnings];
11844
11906
  const handoffCount = data.cycleHandoffs?.length ?? 0;
11845
11907
  const correctionCount = data.boardCorrections?.length ?? 0;
11846
11908
  const newTaskCount = result.newTaskIdMap.size;
@@ -11880,6 +11942,23 @@ ${cleanContent}`;
11880
11942
  });
11881
11943
  const cycleTaskCount = legacyHandoffs.length;
11882
11944
  const cycleEffortPoints = legacyHandoffs.reduce((sum, h) => sum + (effortMap[h.handoff.effort] ?? 3), 0);
11945
+ try {
11946
+ await adapter2.createCycle({
11947
+ id: `cycle-${newCycleNumber}`,
11948
+ number: newCycleNumber,
11949
+ status: "active",
11950
+ startDate: (/* @__PURE__ */ new Date()).toISOString(),
11951
+ goals: data.cycleLogTitle ? [data.cycleLogTitle] : [],
11952
+ boardHealth: "",
11953
+ taskIds: [],
11954
+ contextHashes,
11955
+ ...options.ownerUserId ? { userId: options.ownerUserId } : {}
11956
+ });
11957
+ } catch (err) {
11958
+ const msg = `early createCycle failed for cycle ${newCycleNumber}: ${err instanceof Error ? err.message : String(err)}`;
11959
+ console.error(`[plan] ${msg}`);
11960
+ warnings.push(msg);
11961
+ }
11883
11962
  const cycleLogPromise = adapter2.writeCycleLogEntry({
11884
11963
  uuid: randomUUID8(),
11885
11964
  cycleNumber: newCycleNumber,
@@ -12142,7 +12221,10 @@ ${cleanContent}`;
12142
12221
  taskIds: cycleTaskIds,
12143
12222
  contextHashes,
12144
12223
  // task-2071 (MU-3): own the cycle by the planning member.
12145
- ...options.ownerUserId ? { userId: options.ownerUserId } : {}
12224
+ ...options.ownerUserId ? { userId: options.ownerUserId } : {},
12225
+ // task-2483 (C319): persist the plan's Dependency Chain build-order markdown.
12226
+ // This full write patches the early minimal row created in Phase 1.
12227
+ ...data.dependencyChain ? { dependencyChain: data.dependencyChain } : {}
12146
12228
  };
12147
12229
  await adapter2.createCycle(cycle);
12148
12230
  } catch (err) {
@@ -12468,6 +12550,13 @@ async function preparePlan(adapter2, config2, filters, focus, force, handoffsOnl
12468
12550
  contextHashes
12469
12551
  };
12470
12552
  }
12553
+ var PLAN_STAGE_STEPS = ["health-check", "inbox-triage", "board-integrity", "maturity-gate", "recommendation", "dependency-chain"];
12554
+ async function streamPlanStageSteps(tracker, newCycleNumber) {
12555
+ tracker.setStreamScope({ cycle: newCycleNumber });
12556
+ for (const step of PLAN_STAGE_STEPS) {
12557
+ await tracker.recordStep(step);
12558
+ }
12559
+ }
12471
12560
  async function applyPlan(adapter2, config2, rawLlmOutput, mode, cycleNumber, strategyReviewWarning, contextHashes, planRunMeta, tracker) {
12472
12561
  const applyTimer = startTimer();
12473
12562
  console.error(`[plan-perf] applyPlan: start (llm_response=${rawLlmOutput.length} chars)`);
@@ -12480,6 +12569,7 @@ async function applyPlan(adapter2, config2, rawLlmOutput, mode, cycleNumber, str
12480
12569
  if (config2.adapterType === "pg") {
12481
12570
  tracker?.mark("process_llm_output_pg");
12482
12571
  const result = await processLlmOutput(adapter2, config2, rawLlmOutput, mode, cycleNumber, contextHashes, planRunMeta);
12572
+ if (tracker) await streamPlanStageSteps(tracker, cycleNumber + 1);
12483
12573
  const applyMs2 = applyTimer();
12484
12574
  console.error(`[plan-perf] applyPlan: total=${applyMs2}ms (pg, no git sync)`);
12485
12575
  return { ...result, pullNote: "", strategyReviewWarning };
@@ -12622,12 +12712,58 @@ ${JSON.stringify(payload, null, 2)}`;
12622
12712
  }
12623
12713
  var ProgressTracker = class {
12624
12714
  lastStep;
12715
+ persistAdapter;
12716
+ streamCtx;
12625
12717
  constructor(initial = "start") {
12626
12718
  this.lastStep = initial;
12627
12719
  }
12628
12720
  mark(step) {
12629
12721
  this.lastStep = step;
12630
12722
  }
12723
+ /**
12724
+ * Arm progress-stream persistence for this tool call. Safe to call with any
12725
+ * adapter — persistence stays disarmed unless the adapter exposes BOTH
12726
+ * `getProjectId` and `recordProgressStep` (i.e. the pg/DB-backed path). Returns
12727
+ * `this` for chaining at the tracker construction site.
12728
+ */
12729
+ bindStream(adapter2, ctx) {
12730
+ if (adapter2?.getProjectId && adapter2.recordProgressStep) {
12731
+ this.persistAdapter = adapter2;
12732
+ }
12733
+ this.streamCtx = ctx;
12734
+ return this;
12735
+ }
12736
+ /** Update the bound scope mid-call (e.g. once the cycle number is resolved). */
12737
+ setStreamScope(scope) {
12738
+ if (!this.streamCtx) return;
12739
+ if (scope.cycle !== void 0) this.streamCtx.cycle = scope.cycle;
12740
+ if (scope.taskId !== void 0) this.streamCtx.taskId = scope.taskId;
12741
+ }
12742
+ /**
12743
+ * Record one step of the progress stream. Also advances `lastStep` so the
12744
+ * structured-error diagnostic still points at the most recent named step.
12745
+ * Fire-and-forget and fully guarded — the returned promise always resolves.
12746
+ */
12747
+ recordStep(step, opts = {}) {
12748
+ this.lastStep = step;
12749
+ const adapter2 = this.persistAdapter;
12750
+ const ctx = this.streamCtx;
12751
+ if (!adapter2?.recordProgressStep || !ctx) return Promise.resolve();
12752
+ const row = {
12753
+ stage: opts.stage ?? ctx.stage,
12754
+ step,
12755
+ status: opts.status ?? "complete",
12756
+ cycle: opts.cycle !== void 0 ? opts.cycle : ctx.cycle ?? null,
12757
+ taskId: opts.taskId !== void 0 ? opts.taskId : ctx.taskId ?? null,
12758
+ capabilityKey: opts.capabilityKey ?? null,
12759
+ capabilityEnabled: opts.capabilityEnabled ?? null,
12760
+ metadata: opts.metadata
12761
+ };
12762
+ return adapter2.recordProgressStep(row).catch((err) => {
12763
+ const message = err instanceof Error ? err.message : String(err);
12764
+ console.error(`[progress-stream] failed to persist ${ctx.stage}/${step} (${row.status}): ${message}`);
12765
+ });
12766
+ }
12631
12767
  };
12632
12768
  function defaultHint(tool) {
12633
12769
  const knownTools = /* @__PURE__ */ new Set([
@@ -13088,7 +13224,7 @@ async function handlePlan(adapter2, config2, args) {
13088
13224
  const focus = typeof args.focus === "string" ? args.focus : void 0;
13089
13225
  const force = args.force === true;
13090
13226
  const handoffsOnly = args.handoffs_only === true;
13091
- const tracker = new ProgressTracker(toolMode === "apply" ? "apply_validate" : "prepare_validate");
13227
+ const tracker = new ProgressTracker(toolMode === "apply" ? "apply_validate" : "prepare_validate").bindStream(adapter2, { stage: "plan" });
13092
13228
  try {
13093
13229
  if (toolMode === "apply") {
13094
13230
  const resolved = await resolveLlmResponse(
@@ -18322,6 +18458,86 @@ Examples (use whichever provider you have): ${spec.examples}.
18322
18458
  _Recommendation only \u2014 PAPI never selects or runs a model. Policy: XS/S \u2192 cheap, M/L \u2192 capable, XL \u2192 frontier._`;
18323
18459
  }
18324
18460
 
18461
+ // src/lib/capabilities.ts
18462
+ import {
18463
+ CAPABILITY_KEYS,
18464
+ CAPABILITY_REGISTRY,
18465
+ isCapabilityKey,
18466
+ isCapabilityEnabled
18467
+ } from "@papi-ai/shared";
18468
+
18469
+ // src/lib/directive-builders.ts
18470
+ function buildPrReviewerDirective(caps) {
18471
+ if (!isCapabilityEnabled(caps, "prReviewer")) return null;
18472
+ return `
18473
+
18474
+ **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.`;
18475
+ }
18476
+ function buildChangelogDirective(caps, inner) {
18477
+ if (!isCapabilityEnabled(caps, "changelog")) return null;
18478
+ return inner;
18479
+ }
18480
+ function buildDiscoveredIssuesDirective(caps, issueLines) {
18481
+ if (!isCapabilityEnabled(caps, "discoveredIssues")) return null;
18482
+ if (issueLines.length === 0) return null;
18483
+ return [
18484
+ "",
18485
+ "---",
18486
+ "",
18487
+ `## Discovered Issues (${issueLines.length})`,
18488
+ "",
18489
+ ...issueLines,
18490
+ "",
18491
+ "*These issues were logged during builds \u2014 triage them in the next plan.*"
18492
+ ].join("\n");
18493
+ }
18494
+ function buildModelRecommendationDirective(caps, tierBlock) {
18495
+ if (!isCapabilityEnabled(caps, "modelRecommendation")) return null;
18496
+ return tierBlock;
18497
+ }
18498
+ function buildVerifyHealthCheckDirective(caps) {
18499
+ if (!isCapabilityEnabled(caps, "verifyHealthCheck")) return null;
18500
+ return `
18501
+
18502
+ **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.`;
18503
+ }
18504
+ function buildGestaltPreBuildDirective(caps) {
18505
+ if (!isCapabilityEnabled(caps, "gestaltPreBuild")) return null;
18506
+ return `
18507
+
18508
+ **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.`;
18509
+ }
18510
+ function buildBatchBuildRollupDirective(caps) {
18511
+ if (!isCapabilityEnabled(caps, "batchBuildRollup")) return null;
18512
+ return `
18513
+
18514
+ **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.`;
18515
+ }
18516
+ function buildSecurityScanDirective(caps) {
18517
+ if (!isCapabilityEnabled(caps, "securityScan")) return null;
18518
+ return `
18519
+
18520
+ **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.`;
18521
+ }
18522
+ function buildDeployHookDirective(caps, deployCommand) {
18523
+ if (!isCapabilityEnabled(caps, "deployHook")) return null;
18524
+ const command = deployCommand?.trim() || void 0;
18525
+ if (!command) return null;
18526
+ return `
18527
+
18528
+ ---
18529
+
18530
+ ## Deploy \u2014 run after this release
18531
+
18532
+ The post-release deploy hook is on and a deploy command is configured, so ship the merged release now.
18533
+
18534
+ Run your deploy command yourself:
18535
+ \`\`\`
18536
+ ${command}
18537
+ \`\`\`
18538
+ 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.`;
18539
+ }
18540
+
18325
18541
  // src/services/build.ts
18326
18542
  import { randomUUID as randomUUID11 } from "crypto";
18327
18543
  import { readdirSync as readdirSync5, existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync2, unlinkSync, mkdirSync as mkdirSync2 } from "fs";
@@ -18658,7 +18874,8 @@ To override, pass force=true (emits a telemetry warning).`
18658
18874
  if (adapter2.getBuildReportsSince) {
18659
18875
  try {
18660
18876
  const cycleReports = (await adapter2.getBuildReportsSince(currentCycle)).filter((r) => r.cycle === currentCycle);
18661
- [snapshot] = computeSnapshotsFromBuildReports(cycleReports);
18877
+ const cycleTasks = (await adapter2.queryBoard({ cycleSince: currentCycle, compact: true })).filter((t) => t.cycle === currentCycle);
18878
+ [snapshot] = computeSnapshotsFromBuildReports(cycleReports, cycleTasks);
18662
18879
  } catch (err) {
18663
18880
  console.error(
18664
18881
  `[release] cycle-metrics snapshot compute failed for cycle ${currentCycle} (non-blocking): ${err instanceof Error ? err.message : String(err)}`
@@ -18998,6 +19215,65 @@ async function updatePluginMarketplaces(version) {
18998
19215
  return [cursor, vscode].filter((r) => r !== null);
18999
19216
  }
19000
19217
 
19218
+ // src/lib/release-gate.ts
19219
+ function evaluateReleaseGate(caps, gateCommand, gateResult) {
19220
+ const command = gateCommand?.trim() || void 0;
19221
+ if (!isCapabilityEnabled(caps, "releaseGate")) {
19222
+ return {
19223
+ action: "proceed",
19224
+ stepStatus: "complete",
19225
+ metadata: { skipped: true, reason: "capability_off" },
19226
+ message: ""
19227
+ };
19228
+ }
19229
+ if (!command) {
19230
+ return {
19231
+ action: "proceed",
19232
+ stepStatus: "complete",
19233
+ metadata: { note: "no gate configured" },
19234
+ message: ""
19235
+ };
19236
+ }
19237
+ if (gateResult === "fail") {
19238
+ return {
19239
+ action: "block",
19240
+ stepStatus: "failed",
19241
+ metadata: { command },
19242
+ message: `Release blocked \u2014 quality gate failed.
19243
+
19244
+ The gate command \`${command}\` reported a failure, so PAPI did NOT tag, merge, or close the cycle.
19245
+
19246
+ 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.`
19247
+ };
19248
+ }
19249
+ if (gateResult === "pass") {
19250
+ return {
19251
+ action: "proceed",
19252
+ stepStatus: "complete",
19253
+ metadata: { command },
19254
+ message: ""
19255
+ };
19256
+ }
19257
+ return {
19258
+ action: "directive",
19259
+ stepStatus: "active",
19260
+ metadata: { command },
19261
+ message: `## Quality gate \u2014 run before this release ships
19262
+
19263
+ 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.
19264
+
19265
+ 1. Run the gate command yourself:
19266
+ \`\`\`
19267
+ ${command}
19268
+ \`\`\`
19269
+ 2. Re-call \`release\` with the same \`branch\` and \`version\`, adding:
19270
+ - \`gate_result: "pass"\` if it succeeded \u2014 the release then proceeds normally.
19271
+ - \`gate_result: "fail"\` if it failed \u2014 the release stays blocked so you can fix it first.
19272
+
19273
+ PAPI never runs this command itself (AD-58) \u2014 you run it in your own environment and report the result.`
19274
+ };
19275
+ }
19276
+
19001
19277
  // src/tools/release.ts
19002
19278
  function parseGithubOwner(input) {
19003
19279
  if (!input) return null;
@@ -19078,6 +19354,11 @@ var releaseTool = {
19078
19354
  type: "boolean",
19079
19355
  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
19356
  },
19357
+ gate_result: {
19358
+ type: "string",
19359
+ enum: ["pass", "fail"],
19360
+ 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).'
19361
+ },
19081
19362
  observations: {
19082
19363
  type: "array",
19083
19364
  description: "Optional dogfood observations from this cycle to persist to the DB. Each entry records friction, methodology signals, or commercial insights.",
@@ -19108,6 +19389,7 @@ async function handleRelease(adapter2, config2, args) {
19108
19389
  let version = args.version;
19109
19390
  const force = args.force;
19110
19391
  const skipVersion = args.skipVersion;
19392
+ const gateResult = args.gate_result;
19111
19393
  const rawObservations = args.observations;
19112
19394
  if (!branch || !version) {
19113
19395
  return errorResponse('both branch and version are required. Example: release branch="main" version="v0.1.0-alpha"');
@@ -19121,8 +19403,9 @@ async function handleRelease(adapter2, config2, args) {
19121
19403
  version = `${version}-${suffix}`;
19122
19404
  }
19123
19405
  }
19124
- const tracker = new ProgressTracker("validate-args");
19406
+ const tracker = new ProgressTracker("validate-args").bindStream(adapter2, { stage: "release" });
19125
19407
  try {
19408
+ await tracker.recordStep("release_starting");
19126
19409
  tracker.mark("owner-identity-guard");
19127
19410
  const gate = await resolveOwnerGate(adapter2, config2);
19128
19411
  const productionBaseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
@@ -19249,12 +19532,41 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
19249
19532
  console.error(`[release] gh CLI not available \u2014 ${pendingGrouped.length} shared branch(es) will be squash-merged via git: ${pendingGrouped.join(", ")}`);
19250
19533
  }
19251
19534
  }
19535
+ await tracker.recordStep("readiness_verified");
19536
+ let caps = {};
19537
+ try {
19538
+ const info = adapter2.getProjectInfo ? await adapter2.getProjectInfo() : null;
19539
+ caps = info?.capabilities ?? {};
19540
+ } catch {
19541
+ caps = {};
19542
+ }
19543
+ tracker.mark("quality-gate");
19544
+ const gateDecision = evaluateReleaseGate(caps, config2.gateCommand, gateResult);
19545
+ await tracker.recordStep("quality_gate", {
19546
+ status: gateDecision.stepStatus,
19547
+ capabilityKey: "releaseGate",
19548
+ capabilityEnabled: isCapabilityEnabled(caps, "releaseGate"),
19549
+ metadata: gateDecision.metadata
19550
+ });
19551
+ if (gateDecision.action === "directive") {
19552
+ return textResponse(gateDecision.message);
19553
+ }
19554
+ if (gateDecision.action === "block") {
19555
+ return errorResponse(gateDecision.message);
19556
+ }
19252
19557
  tracker.mark("create-release");
19253
19558
  const result = await createRelease(config2, branch, version, adapter2, void 0, {
19254
19559
  force: force ?? false,
19255
19560
  skipVersion: skipVersion ?? false,
19256
19561
  callerUserId: gate.callerUserId
19257
19562
  });
19563
+ tracker.setStreamScope({ cycle: result.cycleClosed ?? null });
19564
+ await tracker.recordStep("cycle_complete", { metadata: { version: result.version } });
19565
+ for (const m of result.groupedBranchMerges ?? []) {
19566
+ await tracker.recordStep("branch_merged", {
19567
+ metadata: { branch: m.branch, prUrl: m.prUrl ?? null }
19568
+ });
19569
+ }
19258
19570
  const lines = [
19259
19571
  `## Release ${result.version}${skipVersion ? " (skip version)" : ""}`,
19260
19572
  "",
@@ -19283,10 +19595,8 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
19283
19595
  const reports = await adapter2.getBuildReportsSince(closedCycle);
19284
19596
  const EMPTY = /* @__PURE__ */ new Set(["None", "none", "N/A", "", "null"]);
19285
19597
  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
- }
19598
+ const issuesDirective = buildDiscoveredIssuesDirective(caps, issues);
19599
+ if (issuesDirective) lines.push(issuesDirective);
19290
19600
  }
19291
19601
  } catch {
19292
19602
  }
@@ -19306,28 +19616,54 @@ Run \`project_switch <slug>\` to switch the active PAPI project, or verify your
19306
19616
  lines.push("", "\u26A0\uFE0F Dogfood observations could not be saved to DB \u2014 log them manually in DOGFOOD_LOG.md.");
19307
19617
  }
19308
19618
  }
19309
- const cycleUpdateDirective = buildCycleUpdateCurationDirective(result.version, result.cycleClosed ?? 0);
19619
+ const cycleUpdateDirective = buildChangelogDirective(
19620
+ caps,
19621
+ buildCycleUpdateCurationDirective(result.version, result.cycleClosed ?? 0)
19622
+ );
19310
19623
  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 {
19624
+ await tracker.recordStep("changelog", {
19625
+ capabilityKey: "changelog",
19626
+ capabilityEnabled: isCapabilityEnabled(caps, "changelog"),
19627
+ status: cycleUpdateDirective ? "complete" : "active"
19628
+ });
19629
+ const deployDirective = buildDeployHookDirective(caps, config2.deployCommand);
19630
+ if (deployDirective) {
19631
+ lines.push(deployDirective);
19632
+ await tracker.recordStep("deploy_hook", {
19633
+ capabilityKey: "deployHook",
19634
+ capabilityEnabled: isCapabilityEnabled(caps, "deployHook"),
19635
+ status: "complete",
19636
+ metadata: { configured: true }
19637
+ });
19315
19638
  }
19316
- try {
19317
- const registryResults = await updateRegistryListings(result.version);
19318
- for (const r of registryResults) {
19319
- lines.push("", `Registry [${r.registry}]: ${r.message}`);
19639
+ const publishEnabled = isCapabilityEnabled(caps, "publishDirective");
19640
+ if (publishEnabled) {
19641
+ try {
19642
+ const xPosted = await postReleaseToX(result.version, result.cycleClosed ?? 0);
19643
+ if (xPosted) lines.push("", "Posted release announcement to X.");
19644
+ } catch {
19320
19645
  }
19321
- } catch {
19322
19646
  }
19323
- try {
19324
- const marketplaceResults = await updatePluginMarketplaces(result.version);
19325
- for (const r of marketplaceResults) {
19326
- lines.push("", `Marketplace [${r.registry}]: ${r.message}`);
19647
+ if (publishEnabled) {
19648
+ try {
19649
+ const registryResults = await updateRegistryListings(result.version);
19650
+ for (const r of registryResults) {
19651
+ lines.push("", `Registry [${r.registry}]: ${r.message}`);
19652
+ }
19653
+ } catch {
19654
+ }
19655
+ try {
19656
+ const marketplaceResults = await updatePluginMarketplaces(result.version);
19657
+ for (const r of marketplaceResults) {
19658
+ lines.push("", `Marketplace [${r.registry}]: ${r.message}`);
19659
+ }
19660
+ } catch {
19327
19661
  }
19328
- } catch {
19329
19662
  }
19663
+ const verifyDirective = buildVerifyHealthCheckDirective(caps);
19664
+ if (verifyDirective) lines.push(verifyDirective);
19330
19665
  lines.push("", `Next: cycle released! Run \`plan\` to start your next cycle, or \`idea "<what's next>"\` first if your backlog is thin.`);
19666
+ await tracker.recordStep("released", { metadata: { version: result.version } });
19331
19667
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
19332
19668
  return textResponse(lines.join("\n") + filesToWriteSection);
19333
19669
  } catch (err) {
@@ -19775,6 +20111,9 @@ async function describeTask(adapter2, taskId) {
19775
20111
  }
19776
20112
  return { task };
19777
20113
  }
20114
+ function shouldBlockDirtyBranchSwitch(opts) {
20115
+ return opts.trackedDirtyCount > 0 && opts.currentBranch !== opts.baseBranch && opts.currentBranch !== opts.featureBranch;
20116
+ }
19778
20117
  async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
19779
20118
  const task = await adapter2.getTask(taskId);
19780
20119
  if (!task) {
@@ -19896,11 +20235,11 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
19896
20235
  branchLines.push(`Base branch '${config2.baseBranch}' not found \u2014 using '${baseBranch}'.`);
19897
20236
  }
19898
20237
  const trackedDirty = getTrackedModifiedFiles(config2.projectRoot);
19899
- if (trackedDirty.length > 0 && currentBranch !== baseBranch) {
20238
+ if (shouldBlockDirtyBranchSwitch({ trackedDirtyCount: trackedDirty.length, currentBranch, baseBranch, featureBranch })) {
19900
20239
  const shown = trackedDirty.slice(0, 5).join(", ");
19901
20240
  const more = trackedDirty.length > 5 ? ` (+${trackedDirty.length - 5} more)` : "";
19902
20241
  throw new Error(
19903
- `build_execute won't switch branches: the working tree has uncommitted changes on '${currentBranch}' (this task targets '${featureBranch}'). Another session may be working there, and switching would revert those files. Commit or stash them first, or run build_execute from '${baseBranch}'. Dirty: ${shown}${more}.`
20242
+ `build_execute won't switch branches: the working tree has uncommitted changes on '${currentBranch}' (this task targets '${featureBranch}'). Another session may be working there, and switching would revert those files. Commit or stash them first, run build_execute from '${baseBranch}', or check out '${featureBranch}' if this is your own in-progress work. Dirty: ${shown}${more}.`
19904
20243
  );
19905
20244
  }
19906
20245
  if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
@@ -20073,7 +20412,8 @@ function extractDocMeta(absolutePath, relativePath, cycleNumber) {
20073
20412
  }
20074
20413
  return { title, type, cycle, summary, tags: [] };
20075
20414
  }
20076
- function assertDeployVerification(config2, input) {
20415
+ function assertDeployVerification(config2, input, opts = { deployingNow: true }) {
20416
+ if (!opts.deployingNow) return;
20077
20417
  if (config2.adapterType !== "pg" && config2.adapterType !== "proxy") return;
20078
20418
  let triggerHits = [];
20079
20419
  try {
@@ -20082,32 +20422,43 @@ function assertDeployVerification(config2, input) {
20082
20422
  return;
20083
20423
  }
20084
20424
  if (triggerHits.length === 0) return;
20425
+ const mergeDeployHits = triggerHits.filter((p) => !isDeployAtReleaseSurface(p));
20426
+ if (mergeDeployHits.length === 0) return;
20085
20427
  const verification = input.productionVerification;
20086
20428
  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") + `
20429
+ const targetLine = config2.verifyCommand ? `
20090
20430
 
20091
- Run a curl against the live deploy and pass production_verification on build_execute complete:
20431
+ Verify against your configured target (papi.verify): \`${config2.verifyCommand}\`
20432
+ Run it yourself, then pass production_verification on build_execute complete:
20092
20433
  { urls, curl_command, http_status, response_excerpt, verified_at }
20434
+ ` : `
20093
20435
 
20094
- Trigger surface is enforced because changes here have historically shipped to prod undetected (C166, C264-C265, C271).`
20436
+ Run a curl against the live deploy and pass production_verification on build_execute complete:
20437
+ { urls, curl_command, http_status, response_excerpt, verified_at }
20438
+ `;
20439
+ throw new Error(
20440
+ `Deploy-verification required: this branch's diff touches ${mergeDeployHits.length} deploy-on-merge trigger-surface file(s):
20441
+ ` + mergeDeployHits.map((p) => ` - ${p}`).join("\n") + targetLine + `
20442
+ Trigger surface is enforced because changes here have historically shipped to prod undetected (C166, C264-C265, C271). Edge/data-proxy diffs (supabase/functions/**) are verified at release instead.`
20095
20443
  );
20096
20444
  }
20097
20445
  if (verification.http_status < 200 || verification.http_status >= 300) {
20098
20446
  throw new Error(
20099
20447
  `Deploy-verification failed: production_verification reports http_status=${verification.http_status} on a trigger-surface task.
20100
- Trigger files touched: ${triggerHits.join(", ")}
20448
+ Trigger files touched: ${mergeDeployHits.join(", ")}
20101
20449
  Fix the deploy first, then re-run build_execute complete with a 2xx verification.`
20102
20450
  );
20103
20451
  }
20104
20452
  }
20453
+ function isDeployAtReleaseSurface(path7) {
20454
+ return path7.startsWith("supabase/functions/");
20455
+ }
20105
20456
  async function completeBuild(adapter2, config2, taskId, input, options = {}, clientName) {
20106
20457
  const task = await adapter2.getTask(taskId);
20107
20458
  if (!task) {
20108
20459
  throw new Error(`Task "${taskId}" not found on the Cycle Board.`);
20109
20460
  }
20110
- assertDeployVerification(config2, input);
20461
+ assertDeployVerification(config2, input, { deployingNow: options.light === true });
20111
20462
  const [healthResult, priorCount] = await Promise.all([
20112
20463
  adapter2.getCycleHealth().catch(() => ({ totalCycles: 0 })),
20113
20464
  typeof adapter2.getBuildReportCountForTask === "function" ? adapter2.getBuildReportCountForTask(taskId).catch(() => 0) : Promise.resolve(0)
@@ -21304,13 +21655,17 @@ async function handleBuildExecute(adapter2, config2, args, clientName) {
21304
21655
  if (hasReportFields(args)) {
21305
21656
  return handleExecuteComplete(adapter2, config2, taskId, args, light, clientName);
21306
21657
  }
21307
- const tracker = new ProgressTracker("start_build");
21658
+ const tracker = new ProgressTracker("start_build").bindStream(adapter2, { stage: "build", taskId });
21308
21659
  try {
21660
+ await tracker.recordStep("started");
21309
21661
  const result = await startBuild(adapter2, config2, taskId, { light }, clientName);
21662
+ tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.task.cycle ?? null });
21663
+ await tracker.recordStep("branch_ready");
21310
21664
  tracker.mark("start_decorate_handoff");
21311
21665
  const branchInfo = result.branchLines.length > 0 ? result.branchLines.map((l) => `> ${l}`).join("\n") + "\n\n" : "";
21312
21666
  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
21667
  const projectInfo = adapter2.getProjectInfo ? await adapter2.getProjectInfo().catch(() => null) : null;
21668
+ const caps = projectInfo?.capabilities ?? {};
21314
21669
  const projectBanner = projectInfo ? getProjectConnectionBanner(projectInfo.name, projectInfo.slug) : null;
21315
21670
  const projectLine = projectBanner ? `> ${projectBanner}
21316
21671
 
@@ -21358,8 +21713,12 @@ ${entries}`;
21358
21713
  const moduleInstructions = getModuleInstructions(result.task.module);
21359
21714
  const moduleContext = await getModuleContext(adapter2, result.task);
21360
21715
  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);
21716
+ const modelNote = buildModelRecommendationDirective(
21717
+ caps,
21718
+ formatModelRecommendation(result.task.buildHandoff?.effort ?? result.task.complexity)
21719
+ ) ?? "";
21720
+ const gestaltNote = buildGestaltPreBuildDirective(caps) ?? "";
21721
+ return textResponse(header + serializeBuildHandoff(result.task.buildHandoff) + modelNote + gestaltNote + adSection + moduleInstructions + moduleContext + dogfoodSection + verificationNote + chainInstruction + phaseNote + filesToWriteSection);
21363
21722
  } catch (err) {
21364
21723
  if (isNoHandoffError(err)) {
21365
21724
  const lines = [
@@ -21446,7 +21805,7 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
21446
21805
  if (!parsedEstimatedEffort) {
21447
21806
  return errorResponse(`Invalid estimated_effort value "${estimatedEffort}". Must be one of: XS, S, M, L, XL.`);
21448
21807
  }
21449
- const tracker = new ProgressTracker("complete_validate");
21808
+ const tracker = new ProgressTracker("complete_validate").bindStream(adapter2, { stage: "build", taskId });
21450
21809
  try {
21451
21810
  tracker.mark("complete_build");
21452
21811
  const result = await completeBuild(adapter2, config2, taskId, {
@@ -21475,6 +21834,11 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
21475
21834
  preview
21476
21835
  }, { light }, clientName);
21477
21836
  tracker.mark("complete_format");
21837
+ tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.cycleNumber });
21838
+ await tracker.recordStep("report_written");
21839
+ await tracker.recordStep("issues_triaged", {
21840
+ metadata: { autoTriagedCount: result.autoTriagedCount ?? 0 }
21841
+ });
21478
21842
  if (resolvesLearnings && resolvesLearnings.length > 0 && adapter2.updateCycleLearningActionRef) {
21479
21843
  for (const learningId of resolvesLearnings) {
21480
21844
  try {
@@ -21483,8 +21847,35 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
21483
21847
  }
21484
21848
  }
21485
21849
  }
21850
+ await tracker.recordStep("learnings", {
21851
+ metadata: { learningsLinkedCount: result.learningsLinkedCount ?? 0 }
21852
+ });
21853
+ await tracker.recordStep("moving-to-review", {
21854
+ metadata: { status: result.task.status }
21855
+ });
21856
+ if (productionVerification) {
21857
+ await tracker.recordStep("verify", {
21858
+ stage: "release",
21859
+ status: "complete",
21860
+ metadata: {
21861
+ httpStatus: productionVerification.http_status,
21862
+ urlCount: productionVerification.urls.length,
21863
+ targetConfigured: Boolean(config2.verifyCommand)
21864
+ }
21865
+ });
21866
+ }
21486
21867
  const docsNote = await detectUnregisteredDocsNote(adapter2, config2);
21487
- return textResponse(formatCompleteResult(result) + docsNote);
21868
+ let batchRollupNote = "";
21869
+ if (result.cycleProgress && result.cycleProgress.completed >= result.cycleProgress.total) {
21870
+ try {
21871
+ const info = adapter2.getProjectInfo ? await adapter2.getProjectInfo() : null;
21872
+ const caps = info?.capabilities ?? {};
21873
+ batchRollupNote = buildBatchBuildRollupDirective(caps) ?? "";
21874
+ } catch {
21875
+ batchRollupNote = "";
21876
+ }
21877
+ }
21878
+ return textResponse(formatCompleteResult(result) + docsNote + batchRollupNote);
21488
21879
  } catch (err) {
21489
21880
  const message = err instanceof Error ? err.message : String(err);
21490
21881
  if (isBuildPushError(err)) {
@@ -21853,7 +22244,7 @@ function collectDiagnostics(config2) {
21853
22244
  }
21854
22245
  var bugTool = {
21855
22246
  name: "bug",
21856
- description: 'Report a bug OR submit an idea. Routing: a bug about PAPI itself (a PAPI tool/MCP error, the connector, a handoff/cycle problem) auto-submits UPSTREAM to PAPI maintainers with diagnostics \u2014 you do NOT need to set report=true for these. A bug in the user\'s OWN project auto-files as a Backlog task on their board. Override the routing explicitly: report=true forces upstream, report=false forces the user\'s own board. Set `type` ("bug"/"idea") and optional notify-when-fixed / contact-ok consent for upstream submissions. Does not call the Anthropic API.',
22247
+ description: `Report a bug OR submit an idea. Routing: a bug about PAPI itself (a PAPI tool/MCP error, the connector, a handoff/cycle problem) auto-submits UPSTREAM to PAPI maintainers with diagnostics \u2014 you do NOT need to set report=true for these. A bug in the user's OWN project auto-files as a Backlog task on their board. IMPORTANT: report=true is ONLY for a genuine PAPI-product defect \u2014 it is NOT the catch-all for "not about my app". A bug in the user's harness/editor/OS/git/other tooling (e.g. Claude Code, Codex, VS Code, a shell command) is NOT a PAPI bug: file it on the user's own board (report=false / default) or that tool's own tracker, never upstream. Override the routing explicitly: report=true forces upstream (PAPI-product only), report=false forces the user's own board. Set \`type\` ("bug"/"idea") and optional notify-when-fixed / contact-ok consent for upstream submissions. Does not call the Anthropic API.`,
21857
22248
  annotations: { readOnlyHint: false, destructiveHint: false },
21858
22249
  inputSchema: {
21859
22250
  type: "object",
@@ -21885,7 +22276,7 @@ var bugTool = {
21885
22276
  },
21886
22277
  report: {
21887
22278
  type: "boolean",
21888
- description: "Routing override. Leave UNSET to auto-route: PAPI-product bugs go upstream to maintainers, project-domain bugs go to the user's board. Set true to force an upstream diagnostic submission; set false to force a task on the user's own board (use false when an auto-detected PAPI bug is actually about the user's project)."
22279
+ description: "Routing override. Leave UNSET to auto-route: PAPI-product bugs go upstream to maintainers, project-domain bugs go to the user's board. Set true ONLY for a genuine PAPI-product defect (PAPI tool/MCP/connector/handoff) \u2014 a bug in the user's harness/editor/OS/other tooling is NOT a PAPI bug and must go to their own board (report=false or default), not upstream. Set false to force a task on the user's own board (use false when an auto-detected PAPI bug is actually about the user's project, harness, or environment)."
21889
22280
  },
21890
22281
  type: {
21891
22282
  type: "string",
@@ -21902,7 +22293,7 @@ var bugTool = {
21902
22293
  },
21903
22294
  project: {
21904
22295
  type: "string",
21905
- description: "Project id (UUID) or slug to file this bug under, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. Use project_switch to change the session default."
22296
+ description: "BOARD MODE ONLY. Project id (UUID) or slug to file this bug under, overriding the session project for THIS call only. Must be a project on your account \u2014 fails closed otherwise. It CANNOT redirect an upstream (report=true) submission \u2014 those always go to PAPI maintainers. Use project_switch to change the session default."
21906
22297
  }
21907
22298
  },
21908
22299
  required: ["text"]
@@ -22020,6 +22411,12 @@ init_git();
22020
22411
 
22021
22412
  // src/services/ad-hoc.ts
22022
22413
  import { randomUUID as randomUUID14 } from "crypto";
22414
+ function resolveAdHocCycle(cycle, latest, latestComplete) {
22415
+ if (cycle === void 0) return null;
22416
+ if (typeof cycle === "number") return cycle;
22417
+ if (cycle === "current") return latest;
22418
+ return latestComplete ? latest + 1 : latest;
22419
+ }
22023
22420
  async function recordAdHoc(adapter2, input) {
22024
22421
  const [health, phases] = await Promise.all([
22025
22422
  adapter2.getCycleHealth(),
@@ -22027,6 +22424,14 @@ async function recordAdHoc(adapter2, input) {
22027
22424
  ]);
22028
22425
  const cycle = health.totalCycles;
22029
22426
  const now = /* @__PURE__ */ new Date();
22427
+ const held = input.hold === true;
22428
+ const targetCycle = held ? cycle + 1 : resolveAdHocCycle(
22429
+ input.cycle,
22430
+ health.totalCycles,
22431
+ health.latestCycleStatus === "complete"
22432
+ );
22433
+ const promoted = targetCycle !== null;
22434
+ const landInReview = held || promoted && input.stage === "release";
22030
22435
  let task;
22031
22436
  if (input.taskId) {
22032
22437
  const existing = await adapter2.getTask(input.taskId);
@@ -22048,18 +22453,19 @@ async function recordAdHoc(adapter2, input) {
22048
22453
  uuid: randomUUID14(),
22049
22454
  displayId: "",
22050
22455
  title: input.title,
22051
- status: "Done",
22456
+ status: landInReview ? "In Review" : "Done",
22052
22457
  priority: input.priority || "P2 Medium",
22053
22458
  complexity: input.effort === "XS" || input.effort === "S" ? "Small" : "Medium",
22054
22459
  module: input.module || "Core",
22055
22460
  epic: input.epic || "Platform",
22056
22461
  phase,
22057
22462
  owner: input.owner || "Cathal",
22058
- reviewed: true,
22463
+ reviewed: !landInReview,
22059
22464
  createdCycle: cycle,
22465
+ ...targetCycle !== null ? { cycle: targetCycle } : {},
22060
22466
  notes: input.notes ? `[ad-hoc] ${input.notes}` : "[ad-hoc]",
22061
22467
  taskType: input.taskType || "task",
22062
- source: "owner"
22468
+ source: "ad_hoc"
22063
22469
  });
22064
22470
  }
22065
22471
  const report = {
@@ -22134,6 +22540,22 @@ var adHocTool = {
22134
22540
  type: "string",
22135
22541
  enum: ["task", "bug", "research", "discovery", "spike", "idea"],
22136
22542
  description: 'Task type (default: inferred from description \u2014 "fix"/"bug" \u2192 bug, "research"/"investigate" \u2192 research, otherwise task).'
22543
+ },
22544
+ cycle: {
22545
+ 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.',
22546
+ oneOf: [
22547
+ { type: "string", enum: ["current", "next-if-plan-not-run"] },
22548
+ { type: "integer" }
22549
+ ]
22550
+ },
22551
+ stage: {
22552
+ type: "string",
22553
+ enum: ["done", "release"],
22554
+ 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`.'
22555
+ },
22556
+ hold: {
22557
+ type: "boolean",
22558
+ 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
22559
  }
22138
22560
  },
22139
22561
  required: []
@@ -22164,6 +22586,12 @@ async function handleAdHoc(adapter2, config2, args) {
22164
22586
  rawNotes = rawNotes.slice(0, MAX_NOTES_LENGTH);
22165
22587
  notesTruncated = true;
22166
22588
  }
22589
+ const rawCycle = args.cycle;
22590
+ let cycleArg;
22591
+ if (typeof rawCycle === "number") cycleArg = rawCycle;
22592
+ else if (rawCycle === "current" || rawCycle === "next-if-plan-not-run") cycleArg = rawCycle;
22593
+ const stageArg = args.stage === "release" ? "release" : void 0;
22594
+ const holdArg = args.hold === true;
22167
22595
  const result = await recordAdHoc(adapter2, {
22168
22596
  title: title || "",
22169
22597
  taskId,
@@ -22174,9 +22602,12 @@ async function handleAdHoc(adapter2, config2, args) {
22174
22602
  priority: priorityRaw,
22175
22603
  taskType: typeRaw,
22176
22604
  // PROJECT-SPECIFIC: owner resolved from config (PAPI_OWNER env var, default 'Cathal')
22177
- owner: config2.projectOwner
22605
+ owner: config2.projectOwner,
22606
+ cycle: cycleArg,
22607
+ stage: stageArg,
22608
+ hold: holdArg
22178
22609
  });
22179
- if (isGitAvailable() && isGitRepo(config2.projectRoot)) {
22610
+ if (!holdArg && isGitAvailable() && isGitRepo(config2.projectRoot)) {
22180
22611
  try {
22181
22612
  stageDirAndCommit(
22182
22613
  config2.projectRoot,
@@ -22189,8 +22620,29 @@ async function handleAdHoc(adapter2, config2, args) {
22189
22620
  const truncateWarning = notesTruncated ? ` (notes truncated to ${MAX_NOTES_LENGTH} chars)` : "";
22190
22621
  const taskModule = result.task.module || "Core";
22191
22622
  const typeLabel = result.task.taskType || typeRaw;
22623
+ 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)" : ""}.` : "";
22624
+ if (holdArg) {
22625
+ const branch = `feat/${result.task.id}`;
22626
+ return textResponse(
22627
+ `**${result.task.id}:** "${result.task.title}" held for review (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning}${promoNote} Build report attached.
22628
+
22629
+ ## Held for the next cycle \u2014 branch + commit, do NOT merge
22630
+ 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.
22631
+
22632
+ 1. Create a branch and commit your code there (never \`main\`):
22633
+ \`\`\`
22634
+ git switch -c ${branch}
22635
+ git add -- <your changed files>
22636
+ git commit -m "feat(${result.task.id}): ${result.task.title}"
22637
+ git push -u origin ${branch}
22638
+ \`\`\`
22639
+ 2. Leave the branch **unmerged** \u2014 it is picked up by the next cycle's \`release\`.
22640
+
22641
+ _To correct: board_edit ${result.task.id} with updated fields._`
22642
+ );
22643
+ }
22192
22644
  return textResponse(
22193
- `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning} Build report attached.
22645
+ `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule}).${truncateWarning}${promoNote} Build report attached.
22194
22646
  _To correct: board_edit ${result.task.id} with updated fields._`
22195
22647
  );
22196
22648
  }
@@ -23285,13 +23737,22 @@ async function handleReviewSubmit(adapter2, config2, args) {
23285
23737
  if (!stage) {
23286
23738
  return errorResponse('stage is required. Use "handoff-review" or "build-acceptance".');
23287
23739
  }
23740
+ let caps = {};
23741
+ try {
23742
+ const info = adapter2.getProjectInfo ? await adapter2.getProjectInfo() : null;
23743
+ caps = info?.capabilities ?? {};
23744
+ } catch {
23745
+ caps = {};
23746
+ }
23288
23747
  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) {
23748
+ const autoDispatchOptIn = args.dispatch !== "inline" && process.env.PAPI_AUTO_DISPATCH !== "false" && isCapabilityEnabled(caps, "prReviewer");
23749
+ const autoDispatchEligible = !verdict && autoDispatchOptIn;
23750
+ const capabilityAutoReviewEligible = verdict === "accept" && !autoReview && autoDispatchOptIn;
23751
+ if ((explicitDispatch || autoDispatchEligible || capabilityAutoReviewEligible) && stage === "build-acceptance" && taskId) {
23291
23752
  const dispatch = await buildReviewDispatch(adapter2, config2, taskId);
23292
23753
  if (!dispatch.ok) {
23293
23754
  if (explicitDispatch) return errorResponse(dispatch.error);
23294
- } else if (explicitDispatch || dispatch.contextBytes > REVIEW_DISPATCH_THRESHOLD) {
23755
+ } else if (explicitDispatch || capabilityAutoReviewEligible || dispatch.contextBytes > REVIEW_DISPATCH_THRESHOLD) {
23295
23756
  return textResponse(dispatch.prompt);
23296
23757
  }
23297
23758
  }
@@ -23332,7 +23793,8 @@ async function handleReviewSubmit(adapter2, config2, args) {
23332
23793
  }
23333
23794
  }
23334
23795
  }
23335
- const tracker = new ProgressTracker("validate-args");
23796
+ const tracker = new ProgressTracker("validate-args").bindStream(adapter2, { stage: "review", taskId });
23797
+ await tracker.recordStep("reviewer_confirmed");
23336
23798
  const handoffRegenResponse = args.handoff_regen_response;
23337
23799
  if (handoffRegenResponse?.trim()) {
23338
23800
  tracker.mark("apply-handoff-regen");
@@ -23349,6 +23811,14 @@ async function handleReviewSubmit(adapter2, config2, args) {
23349
23811
  adapter2,
23350
23812
  { taskId, stage, verdict, comments, reviewer, autoReview }
23351
23813
  );
23814
+ tracker.setStreamScope({ cycle: result.currentCycle > 0 ? result.currentCycle : null });
23815
+ await tracker.recordStep("verdict_recorded", { metadata: { verdict, stage } });
23816
+ if (result.unblockedTasks.length > 0) {
23817
+ await tracker.recordStep("dependents_unblocked", { metadata: { count: result.unblockedTasks.length } });
23818
+ }
23819
+ if (result.closedDocActions.length > 0) {
23820
+ await tracker.recordStep("docs_closed", { metadata: { count: result.closedDocActions.length } });
23821
+ }
23352
23822
  const statusNote = result.newStatus ? ` Task status updated to **${result.newStatus}**.` : " Task status unchanged.";
23353
23823
  const unblockNote = result.unblockedTasks.length > 0 ? `
23354
23824
 
@@ -23388,6 +23858,7 @@ ${result.handoffRegenPrompt.userMessage}
23388
23858
 
23389
23859
  > ${mergeResult.message}`;
23390
23860
  }
23861
+ await tracker.recordStep("deferred", { metadata: { reason: "merge-skipped" } });
23391
23862
  } else if (!mergeResult.merged) {
23392
23863
  mergeFailed = true;
23393
23864
  let revertNote = "";
@@ -23402,11 +23873,13 @@ ${result.handoffRegenPrompt.userMessage}
23402
23873
  \u26A0\uFE0F **PR merge failed \u2014 task NOT marked Done.**${revertNote}
23403
23874
 
23404
23875
  ${mergeResult.message}`;
23876
+ await tracker.recordStep("deferred", { status: "failed", metadata: { reason: "merge-failed" } });
23405
23877
  } else {
23406
23878
  try {
23407
23879
  await adapter2.updateTask(taskId, { mergedAt: (/* @__PURE__ */ new Date()).toISOString() });
23408
23880
  } catch {
23409
23881
  }
23882
+ await tracker.recordStep("merged");
23410
23883
  const detailLines = mergeResult.details.map((l) => `> ${l}`).join("\n");
23411
23884
  mergeNote = `
23412
23885
 
@@ -23482,6 +23955,7 @@ Merge or squash those PRs first, then run \`release\` manually.`;
23482
23955
  }
23483
23956
  const version = `v0.${result.currentCycle}.0`;
23484
23957
  const releaseResult = await createRelease(config2, baseBranch, version, adapter2, result.currentCycle);
23958
+ await tracker.recordStep("auto_release_triggered", { metadata: { version: releaseResult.version } });
23485
23959
  const pushInfo = releaseResult.pushNotes.join(" ");
23486
23960
  const groupedMergeNote = releaseResult.groupedBranchMerges?.length ? "\n" + releaseResult.groupedBranchMerges.map((r) => `- Merged shared branch \`${r.branch}\` via PR: ${r.prUrl ?? "n/a"}`).join("\n") : "";
23487
23961
  autoReleaseNote = `
@@ -23537,9 +24011,7 @@ ${findingLines}${more}` : "");
23537
24011
  \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
24012
  }
23539
24013
  } 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.`;
24014
+ autoReviewNote = buildPrReviewerDirective(caps) ?? "";
23543
24015
  }
23544
24016
  let nextStepNote = "";
23545
24017
  if (!autoReleaseNote && stage === "build-acceptance") {
@@ -23551,6 +24023,7 @@ ${findingLines}${more}` : "");
23551
24023
  Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
23552
24024
  }
23553
24025
  }
24026
+ const securityNote = stage === "build-acceptance" && verdict === "accept" ? buildSecurityScanDirective(caps) ?? "" : "";
23554
24027
  tracker.mark("format-response");
23555
24028
  return textResponse(
23556
24029
  `**${result.stageLabel}** recorded for ${result.taskId}.
@@ -23558,7 +24031,7 @@ Next: address the feedback, then run \`build_execute ${taskId}\` to resubmit.`;
23558
24031
  - **Verdict:** ${result.verdict}
23559
24032
  - **Comments:** ${result.comments}
23560
24033
 
23561
- ${statusNote}${autoReviewNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
24034
+ ${statusNote}${autoReviewNote}${securityNote}${unblockNote}${docClosureNote}${regenNote}${mergeNote}${overlapNote}${batchSummaryNote}${autoReleaseNote}${nextStepNote}${phaseNote}`
23562
24035
  );
23563
24036
  } catch (err) {
23564
24037
  const message = err instanceof Error ? err.message : String(err);
@@ -24274,7 +24747,12 @@ async function getHealthSummary(adapter2) {
24274
24747
  try {
24275
24748
  try {
24276
24749
  buildReports = await adapter2.getRecentBuildReports(50);
24277
- snapshots = computeSnapshotsFromBuildReports(buildReports);
24750
+ let metricsTasks = [];
24751
+ try {
24752
+ metricsTasks = await adapter2.queryBoard({ cycleSince: Math.max(1, cycleNumber - 6), compact: true });
24753
+ } catch {
24754
+ }
24755
+ snapshots = computeSnapshotsFromBuildReports(buildReports, metricsTasks);
24278
24756
  } catch {
24279
24757
  }
24280
24758
  metricsSection = formatCycleMetrics(snapshots);