@papi-ai/server 0.7.80 → 0.7.82

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
@@ -29,6 +29,7 @@ __export(git_exports, {
29
29
  ensureTagAtHead: () => ensureTagAtHead,
30
30
  findTaskCommitsOnBase: () => findTaskCommitsOnBase,
31
31
  getBranchDiff: () => getBranchDiff,
32
+ getCommitFiles: () => getCommitFiles,
32
33
  getCommitsSinceTag: () => getCommitsSinceTag,
33
34
  getCurrentBranch: () => getCurrentBranch,
34
35
  getDocPathsTouchedOnBranch: () => getDocPathsTouchedOnBranch,
@@ -42,11 +43,13 @@ __export(git_exports, {
42
43
  getModifiedFiles: () => getModifiedFiles,
43
44
  getOriginRepoSlug: () => getOriginRepoSlug,
44
45
  getOriginUrl: () => getOriginUrl,
46
+ getPathsDifferingFrom: () => getPathsDifferingFrom,
45
47
  getPullRequestUrl: () => getPullRequestUrl,
46
48
  getRemoteBranchFiles: () => getRemoteBranchFiles,
47
49
  getRootCommitHash: () => getRootCommitHash,
48
50
  getStagedFiles: () => getStagedFiles,
49
51
  getTagTarget: () => getTagTarget,
52
+ getTaskDiff: () => getTaskDiff,
50
53
  getTaskIdsOnBranch: () => getTaskIdsOnBranch,
51
54
  getTrackedModifiedFiles: () => getTrackedModifiedFiles,
52
55
  getUnmergedBranches: () => getUnmergedBranches,
@@ -61,6 +64,7 @@ __export(git_exports, {
61
64
  isGhAvailable: () => isGhAvailable,
62
65
  isGitAvailable: () => isGitAvailable,
63
66
  isGitRepo: () => isGitRepo,
67
+ isPathCommittedAnywhere: () => isPathCommittedAnywhere,
64
68
  isPathIgnored: () => isPathIgnored,
65
69
  isPathTracked: () => isPathTracked,
66
70
  listGroupedCycleBranches: () => listGroupedCycleBranches,
@@ -100,6 +104,22 @@ function isGitRepo(cwd) {
100
104
  return false;
101
105
  }
102
106
  }
107
+ function isPathCommittedAnywhere(cwd, path7) {
108
+ const run = (args) => {
109
+ try {
110
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
111
+ } catch {
112
+ return null;
113
+ }
114
+ };
115
+ const commit = run(["log", "--all", "--format=%H", "-1", "--", path7]);
116
+ if (!commit) return false;
117
+ const committedBlob = run(["rev-parse", `${commit}:${path7}`]);
118
+ if (!committedBlob) return false;
119
+ const onDiskBlob = run(["hash-object", "--", path7]);
120
+ if (!onDiskBlob) return false;
121
+ return committedBlob === onDiskBlob;
122
+ }
103
123
  function isPathTracked(cwd, path7) {
104
124
  try {
105
125
  execFileSync("git", ["ls-files", "--error-unmatch", "--", path7], {
@@ -272,6 +292,50 @@ function getBranchDiff(cwd, base = "origin/main", maxBytes = 2e5) {
272
292
  }
273
293
  return "";
274
294
  }
295
+ function getTaskDiff(cwd, taskId, base = "origin/main", maxBytes = 2e5) {
296
+ const truncate2 = (out) => out.length > maxBytes ? `${out.slice(0, maxBytes)}
297
+
298
+ ... [diff truncated at ${Math.round(maxBytes / 1024)} KB]` : out;
299
+ try {
300
+ const shas = execFileSync(
301
+ "git",
302
+ ["log", "--all", "--format=%H", `--grep=${taskId})`, "--fixed-strings"],
303
+ { cwd, encoding: "utf-8", maxBuffer: 8 * 1024 * 1024 }
304
+ ).split("\n").map((s) => s.trim()).filter(Boolean);
305
+ if (shas.length > 0) {
306
+ const parts = [];
307
+ for (const sha of [...shas].reverse()) {
308
+ try {
309
+ const one = execFileSync("git", ["diff", `${sha}^..${sha}`], {
310
+ cwd,
311
+ encoding: "utf-8",
312
+ maxBuffer: 32 * 1024 * 1024
313
+ });
314
+ if (one) parts.push(one);
315
+ } catch {
316
+ }
317
+ }
318
+ const out = parts.join("\n");
319
+ if (out) {
320
+ const range = shas.length === 1 ? shas[0].slice(0, 8) : `${shas[shas.length - 1].slice(0, 8)}\u2026${shas[0].slice(0, 8)}`;
321
+ return {
322
+ diff: truncate2(out),
323
+ scope: "task-commits",
324
+ detail: `${shas.length} commit(s) for ${taskId} (${range}), each against its own parent`
325
+ };
326
+ }
327
+ }
328
+ } catch {
329
+ }
330
+ const branch = getCurrentBranch(cwd);
331
+ const diff = getBranchDiff(cwd, base, maxBytes);
332
+ if (!diff) return { diff: "", scope: "none", detail: "no diff resolved" };
333
+ return {
334
+ diff,
335
+ scope: "whole-branch",
336
+ detail: `no commit naming ${taskId} was found, so this is the ENTIRE diff of ${branch ?? "the current branch"} vs ${base} \u2014 it may include other tasks' work, and may not include this task's`
337
+ };
338
+ }
275
339
  function getHeadCommitSubject(cwd) {
276
340
  try {
277
341
  const out = execFileSync("git", ["log", "-1", "--format=%s"], {
@@ -319,6 +383,28 @@ function branchExists(cwd, branch) {
319
383
  return false;
320
384
  }
321
385
  }
386
+ function getPathsDifferingFrom(cwd, target) {
387
+ try {
388
+ const out = execFileSync("git", ["diff", "--name-only", "HEAD", target], {
389
+ cwd,
390
+ encoding: "utf-8"
391
+ });
392
+ return out.split("\n").map((l) => l.trim()).filter(Boolean);
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+ function getCommitFiles(cwd, ref = "HEAD") {
398
+ try {
399
+ const out = execFileSync("git", ["show", "--name-only", "--pretty=format:", ref], {
400
+ cwd,
401
+ encoding: "utf-8"
402
+ });
403
+ return out.split("\n").map((l) => l.trim()).filter(Boolean);
404
+ } catch {
405
+ return null;
406
+ }
407
+ }
322
408
  function checkoutBranch(cwd, branch) {
323
409
  try {
324
410
  execFileSync("git", ["checkout", branch], { cwd, encoding: "utf-8" });
@@ -1273,7 +1359,7 @@ function wrapWithForwarding(instance) {
1273
1359
  function createProxyAdapter(config2) {
1274
1360
  return wrapWithForwarding(new ProxyPapiAdapter(config2));
1275
1361
  }
1276
- var JSONB_PASSTHROUGH_KEYS, DISPLAY_ID_METHODS, NO_FORWARD, ProxyPapiAdapter;
1362
+ var JSONB_PASSTHROUGH_KEYS, DISPLAY_ID_METHODS, HOSTED_SERVED, NO_FORWARD, ProxyPapiAdapter;
1277
1363
  var init_proxy_adapter = __esm({
1278
1364
  "src/proxy-adapter.ts"() {
1279
1365
  "use strict";
@@ -1301,6 +1387,156 @@ var init_proxy_adapter = __esm({
1301
1387
  "getRecentReviews",
1302
1388
  "getActiveDecisions"
1303
1389
  ]);
1390
+ HOSTED_SERVED = /* @__PURE__ */ new Set([
1391
+ "actionRecommendation",
1392
+ "addAgendaTopic",
1393
+ "addContributorByEmail",
1394
+ "appendBuildReport",
1395
+ "appendCycleLearnings",
1396
+ "appendCycleMetrics",
1397
+ "appendDecisionEvent",
1398
+ "appendToolMetric",
1399
+ "archiveTasks",
1400
+ "claimTask",
1401
+ "clearPendingReviewResponse",
1402
+ "compressBuildReports",
1403
+ "compressCycleLog",
1404
+ "confirmPendingActiveDecisions",
1405
+ "correctLatestBuildReportEffort",
1406
+ "countDueOwnerActions",
1407
+ "countNudgedOwnerActions",
1408
+ "countOpenOwnerActions",
1409
+ "countOwnerActionsBlockingTasks",
1410
+ "countPlanRunsForCycle",
1411
+ "createConvention",
1412
+ "createCycle",
1413
+ "createHorizon",
1414
+ "createOwnerAction",
1415
+ "createStage",
1416
+ "createTask",
1417
+ "deleteActiveDecision",
1418
+ "deleteConvention",
1419
+ "deleteDoc",
1420
+ "dismissRecommendation",
1421
+ "findPendingDocActionsForTask",
1422
+ "getActiveDecisions",
1423
+ "getActiveStage",
1424
+ "getBuildReportCountForTask",
1425
+ "getBuildReportsSince",
1426
+ "getContextHashes",
1427
+ "getContextUtilisation",
1428
+ "getCostSnapshots",
1429
+ "getCostSummary",
1430
+ "getCurrentNorthStar",
1431
+ "getCycleHealth",
1432
+ "getCycleLearningPatterns",
1433
+ "getCycleLearnings",
1434
+ "getCycleLog",
1435
+ "getCycleLogSince",
1436
+ "getDecisionEvents",
1437
+ "getDecisionEventsSince",
1438
+ "getDecisionScorePatterns",
1439
+ "getDecisionScores",
1440
+ "getDecisionUsage",
1441
+ "getDoc",
1442
+ "getDocBody",
1443
+ "getDocBodyUsage",
1444
+ "getDogfoodLog",
1445
+ "getEstimationCalibration",
1446
+ "getFailedBuildAttemptsForTask",
1447
+ "getHarnessInventory",
1448
+ "getHarnessState",
1449
+ "getLastStrategyReviewCycle",
1450
+ "getLatestDecisionScores",
1451
+ "getModelOutcomeStats",
1452
+ "getModuleEstimationStats",
1453
+ "getNorthStarSetCycle",
1454
+ "getNorthStarStaleness",
1455
+ "getOwnerIdentity",
1456
+ "getPendingAgendaTopics",
1457
+ "getPendingRecommendations",
1458
+ "getPendingReviewResponse",
1459
+ "getPlanContextSummary",
1460
+ "getProjectInfo",
1461
+ "getProjectOwnerUserId",
1462
+ "getRecentBuildReports",
1463
+ "getRecentReviews",
1464
+ "getRecentTaskComments",
1465
+ "getRecommendationEffectiveness",
1466
+ "getStrategyReviews",
1467
+ "getTask",
1468
+ "getTasks",
1469
+ "getToolCallCount",
1470
+ "getUnactionedDogfoodEntries",
1471
+ "getUnnotifiedResolvedFeedback",
1472
+ "hasToolMilestone",
1473
+ "insertPlanRun",
1474
+ "insertToolRun",
1475
+ "linkOwnerActionToTask",
1476
+ "linkPhasesToStage",
1477
+ "listContributors",
1478
+ "listConventions",
1479
+ "listMyBugReports",
1480
+ "listOwnerActionsForBlockerScan",
1481
+ "logEntityReferences",
1482
+ "markAgendaTopicsAddressed",
1483
+ "markCycleLearningResolved",
1484
+ "markFeedbackNotified",
1485
+ "moveTask",
1486
+ "planWriteBack",
1487
+ "projectExists",
1488
+ "queryBoard",
1489
+ "readCycleMetrics",
1490
+ "readCycles",
1491
+ "readCycles",
1492
+ "readDiscoveryCanvas",
1493
+ "readHorizons",
1494
+ "readPhases",
1495
+ "readPlanningLog",
1496
+ "readProductBrief",
1497
+ "readRegistries",
1498
+ "readStages",
1499
+ "readToolMetrics",
1500
+ "recordProgressStep",
1501
+ "recordTransition",
1502
+ "registerDoc",
1503
+ "removeContributorByEmail",
1504
+ "reorderDocs",
1505
+ "replaceHarnessInventory",
1506
+ "resolveLearningsForDoneTasks",
1507
+ "savePendingReviewResponse",
1508
+ "searchDocs",
1509
+ "setCriterionMet",
1510
+ "setCycleHealth",
1511
+ "setHarnessState",
1512
+ "setProjectPapiDir",
1513
+ "storeDocBody",
1514
+ "submitBugReport",
1515
+ "unclaimTask",
1516
+ "updateActiveDecision",
1517
+ "updateCycleLearningActionRef",
1518
+ "updateDiscoveryCanvas",
1519
+ "updateDocAction",
1520
+ "updateDocStatus",
1521
+ "updateDogfoodEntryStatus",
1522
+ "updateHorizonStatus",
1523
+ "updatePhaseStatus",
1524
+ "updateProductBrief",
1525
+ "updateRegistries",
1526
+ "updateStageExitCriteria",
1527
+ "updateStageStatus",
1528
+ "updateTask",
1529
+ "updateTaskStatus",
1530
+ "upsertActiveDecision",
1531
+ "upsertNorthStar",
1532
+ "writeCycleLogEntry",
1533
+ "writeDecisionScore",
1534
+ "writeDogfoodEntries",
1535
+ "writePhases",
1536
+ "writeRecommendation",
1537
+ "writeReview",
1538
+ "writeStrategyReview"
1539
+ ]);
1304
1540
  NO_FORWARD = /* @__PURE__ */ new Set([
1305
1541
  // (1) local-only
1306
1542
  "close",
@@ -1329,7 +1565,7 @@ var init_proxy_adapter = __esm({
1329
1565
  "listContributorReleasePrs",
1330
1566
  "claimReview",
1331
1567
  "getSiblingAds",
1332
- "getSiblingRepoTasks"
1568
+ "getSiblingRepoTasks",
1333
1569
  // task-2828 (C339): attributed-intelligence analytics reader — pg-only that cycle.
1334
1570
  // task-2864 (C343): WIRED. getModelOutcomeStats now has an edge case handler (raw
1335
1571
  // SQL via postgres.js mirroring the pg query + inlined computeModelOutcomes bucketing)
@@ -1353,6 +1589,28 @@ var init_proxy_adapter = __esm({
1353
1589
  // (case handler + ALLOWED_METHODS/WRITE_METHODS entries), so it forwards for
1354
1590
  // hosted callers and persists a project-scoped cycle_progress_steps row. Removed
1355
1591
  // from NO_FORWARD (was the task-2484 pg-only gap) — hosted parity restored.
1592
+ //
1593
+ // (3) pg-only optimisations probed STRUCTURALLY with a local fallback beside them.
1594
+ // These are the dangerous class: the caller asks `typeof adapter.X === 'function'`
1595
+ // to decide whether the fast path exists, and the get-trap answers "yes" for any
1596
+ // name absent from this set. The probe then passes, the call forwards, and the
1597
+ // edge 403s — while a working fallback sits a few lines below, unreachable.
1598
+ // A method belongs here when BOTH are true: it is probed by `typeof` rather than
1599
+ // called unconditionally, and the probe's else-branch is a real fallback.
1600
+ //
1601
+ // task-3290 (C361): allocateActiveDecision + applyActiveDecisionUpdates are pg-only
1602
+ // (atomic id allocation; transactional batch apply). Both are probed in
1603
+ // services/strategy.ts — asDecisionIdAllocator and asDecisionBatchApplier — and both
1604
+ // have read-then-write / sequential fallbacks. Hosted users hit a 403 minting ANY
1605
+ // Active Decision via setup's AD seed or strategy_change until these were listed.
1606
+ "allocateActiveDecision",
1607
+ "applyActiveDecisionUpdates",
1608
+ // task-3290 (C361), same sweep: getLastZoomOutCycle was documented in the parity
1609
+ // ledger as "proxy NO_FORWARD → safe no-op" while NOT being in this set. It is
1610
+ // optional-chain probed in services/health.ts and wrapped in try/catch, so it
1611
+ // degraded rather than crashed — but every hosted `orient` paid a round-trip to
1612
+ // earn a silent 403. Listing it makes the ledger's claim true and skips the trip.
1613
+ "getLastZoomOutCycle"
1356
1614
  ]);
1357
1615
  ProxyPapiAdapter = class _ProxyPapiAdapter {
1358
1616
  endpoint;
@@ -1365,6 +1623,30 @@ var init_proxy_adapter = __esm({
1365
1623
  this.projectId = config2.projectId ?? "";
1366
1624
  this.onAuthRejected = config2.onAuthRejected;
1367
1625
  }
1626
+ /**
1627
+ * Does the hosted path genuinely serve `name`? (task-3022, C362)
1628
+ *
1629
+ * This is the honest answer to the question `typeof adapter.name === 'function'`
1630
+ * USED to ask and could not answer: the get-trap below manufactures a function
1631
+ * for any name absent from NO_FORWARD, so a structural probe reads true for
1632
+ * pg-only methods and the caller then earns a 403 from the edge.
1633
+ *
1634
+ * Answered from NO_FORWARD itself, deliberately — the same set the forwarder
1635
+ * consults — so this can never disagree with what an actual call would do. A
1636
+ * name in NO_FORWARD is either local-only or not yet wired hosted; either way
1637
+ * the caller must take its fallback. The proxy-parity test guarantees every
1638
+ * method is an explicit wrapper, in NO_FORWARD, or in the edge allowlist, so
1639
+ * "not in NO_FORWARD" is a sound proxy for "served hosted".
1640
+ *
1641
+ * A REAL method, not a forwarded one: Reflect.get in the trap returns it before
1642
+ * the forwarding branch is reached, so callers get this implementation rather
1643
+ * than a forwarder that would POST "supportsMethod" to the edge.
1644
+ */
1645
+ supportsMethod(name) {
1646
+ if (NO_FORWARD.has(name)) return false;
1647
+ if (name in _ProxyPapiAdapter.prototype) return true;
1648
+ return HOSTED_SERVED.has(name);
1649
+ }
1368
1650
  /**
1369
1651
  * task-1773: bearer-only auth probe. Hits the USER-scoped `project-list` route
1370
1652
  * (no projectId needed), so it answers exactly one question: does the proxy
@@ -1884,6 +2166,21 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
1884
2166
  updateDogfoodEntryStatus(id, status, linkedTaskId) {
1885
2167
  return this.invoke("updateDogfoodEntryStatus", [id, status, linkedTaskId]);
1886
2168
  }
2169
+ // --- Project conventions (task-3271) ---
2170
+ //
2171
+ // Wired to the edge handler from the start rather than parked in NO_FORWARD.
2172
+ // The hosted remote connector is the only install path an external user has,
2173
+ // so a local-only conventions store would be a feature nobody in the actual
2174
+ // user base can reach.
2175
+ listConventions() {
2176
+ return this.invoke("listConventions");
2177
+ }
2178
+ createConvention(convention) {
2179
+ return this.invoke("createConvention", [convention]);
2180
+ }
2181
+ deleteConvention(id) {
2182
+ return this.invoke("deleteConvention", [id]);
2183
+ }
1887
2184
  // --- Harness inventory (task-1896) ---
1888
2185
  getHarnessInventory() {
1889
2186
  return this.invoke("getHarnessInventory");
@@ -6087,7 +6384,78 @@ function validateFindings(findings) {
6087
6384
  return { ok: violations.length === 0, violations };
6088
6385
  }
6089
6386
  var WAB_WINDOW_DAYS = 7;
6387
+ var WAB_DEFAULT_WEEKS = 8;
6090
6388
  var WAB_WEEK_MS = WAB_WINDOW_DAYS * 24 * 60 * 60 * 1e3;
6389
+ function wabCompletionMs(row) {
6390
+ const ts = row.end_date ?? row.updated_at;
6391
+ if (!ts) return null;
6392
+ const ms = Date.parse(ts);
6393
+ return Number.isNaN(ms) ? null : ms;
6394
+ }
6395
+ function bucketWabSeries(rows, ownerUserId, nowMs, weeks = WAB_DEFAULT_WEEKS) {
6396
+ const buckets = Array.from({ length: weeks }, () => /* @__PURE__ */ new Set());
6397
+ for (const row of rows) {
6398
+ if (!row.user_id) continue;
6399
+ const ms = wabCompletionMs(row);
6400
+ if (ms === null) continue;
6401
+ const idx = Math.floor((nowMs - ms) / WAB_WEEK_MS);
6402
+ if (idx < 0 || idx >= weeks) continue;
6403
+ buckets[idx].add(row.user_id);
6404
+ }
6405
+ const out = [];
6406
+ for (let idx = weeks - 1; idx >= 0; idx--) {
6407
+ const owners = buckets[idx];
6408
+ const external = ownerUserId ? [...owners].filter((id) => id !== ownerUserId).length : owners.size;
6409
+ out.push({
6410
+ weekStart: new Date(nowMs - (idx + 1) * WAB_WEEK_MS).toISOString(),
6411
+ total: owners.size,
6412
+ external
6413
+ });
6414
+ }
6415
+ return out;
6416
+ }
6417
+ var PRODUCT_MARKER = /WHAT SHIPS FOR USERS\b[^\n:]*:/i;
6418
+ var MECHANICS_MARKER = /RELEASE MECHANICS\b[^\n:]*:/i;
6419
+ function splitCarryForward(raw) {
6420
+ const productMatch = PRODUCT_MARKER.exec(raw);
6421
+ const mechanicsMatch = MECHANICS_MARKER.exec(raw);
6422
+ if (!productMatch && !mechanicsMatch) {
6423
+ return { product: raw.trim(), mechanics: "", split: false };
6424
+ }
6425
+ if (!productMatch && mechanicsMatch) {
6426
+ const head = raw.slice(0, mechanicsMatch.index).trim();
6427
+ const tail = raw.slice(mechanicsMatch.index + mechanicsMatch[0].length).trim();
6428
+ return { product: head, mechanics: tail, split: true };
6429
+ }
6430
+ const productStart = productMatch.index + productMatch[0].length;
6431
+ if (!mechanicsMatch) {
6432
+ return { product: raw.slice(productStart).trim(), mechanics: "", split: true };
6433
+ }
6434
+ if (mechanicsMatch.index < productMatch.index) {
6435
+ return {
6436
+ product: raw.slice(productStart).trim(),
6437
+ mechanics: raw.slice(mechanicsMatch.index + mechanicsMatch[0].length, productMatch.index).trim(),
6438
+ split: true
6439
+ };
6440
+ }
6441
+ return {
6442
+ product: raw.slice(productStart, mechanicsMatch.index).trim(),
6443
+ mechanics: raw.slice(mechanicsMatch.index + mechanicsMatch[0].length).trim(),
6444
+ split: true
6445
+ };
6446
+ }
6447
+ function renderCarryForward(raw, decorate = (t) => t) {
6448
+ const { product, mechanics, split } = splitCarryForward(raw);
6449
+ if (!split) return [decorate(product)];
6450
+ const lines = [];
6451
+ if (product) lines.push(decorate(product));
6452
+ if (mechanics) {
6453
+ if (product) lines.push("");
6454
+ lines.push("**Release mechanics** \u2014 needed at release, not now:");
6455
+ lines.push(decorate(mechanics));
6456
+ }
6457
+ return lines;
6458
+ }
6091
6459
  var CHECK_VALUE_MAX = 200;
6092
6460
  function codeSpanSafe(value) {
6093
6461
  const flat = value.replace(/[`\r\n]+/g, " ").replace(/\s+/g, " ").trim();
@@ -7407,6 +7775,24 @@ If a candidate AD body could be invalidated by running a SQL query, refreshing a
7407
7775
  **Negative example (reject):** "External user feedback is now flowing. Stonebridge Systems is actively building." \u2014 this is a fact about the current state of the world. Capture as dogfood/signal observation; do not mint.
7408
7776
 
7409
7777
  This rule applies to: new ADs proposed during planning (Step 9), strategy review AD updates (section 5), and strategy_change AD updates. If you find an existing AD that violates this rule during housekeeping, propose deleting it (action: "delete") with a one-line rationale.`;
7778
+ var AD_ADMISSION_RULES = `**AD Admission Rule \u2014 PROPOSE a decision when the project takes a real stance.**
7779
+
7780
+ The guard above says what to reject. This says what to propose. Rejecting is not the safe default: a stance that never gets minted is a stance the next cycle cannot see, and re-deciding it every session is the exact cost this project is paying you to remove. When a candidate passes all four tests below, propose it \u2014 do not wait for the next strategy review.
7781
+
7782
+ **The four tests. All four must pass.**
7783
+ (a) **Alternatives were real.** Something else could genuinely have been chosen. If there was only ever one way to do it, it is not a decision.
7784
+ (b) **It constrains future work not yet scoped.** It changes what a task nobody has written yet will do. Not "it describes work we did" \u2014 "it binds work we have not planned".
7785
+ (c) **It is arguable today.** A competent person could argue the other side right now, with the evidence currently available. Not "was once debated" \u2014 live.
7786
+ (d) **Reversing it costs more than making it did.** If undoing it is as cheap as doing it, nothing is being constrained.
7787
+
7788
+ **Routing for a near-miss \u2014 a candidate that fails one test still goes somewhere.**
7789
+ - **Fails (c) only** \u2014 real alternatives, binds future work, expensive to reverse, but nobody is arguing the other side any more: it is a **Convention**, not a Decision. A Convention is a settled answer to a recurring question; it does not need adjudicating, it needs to be *known* by whoever builds next. Record it as a project convention so it rides future build handoffs. Do not mint it as an AD, and do not drop it.
7790
+ - **Fails (b)** \u2014 it does not constrain any future work: it is not durable at all, it is just work. Capture it as a task, a build report note, or a doc. Do not mint it.
7791
+ - **Fails (a) or (d)** \u2014 not a stance. Same routing as (b): capture it, do not mint it.
7792
+
7793
+ **Copy variants are NEVER decisions.** A tagline, a headline, a value proposition, a piece of marketing or product wording: swapping one for another constrains no future work, so it fails test (b) outright. Wording changes as often as the market teaches you something, and routing every edit through decision ceremony is what buries the handful of decisions that genuinely constrain the project. Edit the wording where the wording lives. If two variants are being compared against a measurement, that is an experiment, not a decision.
7794
+
7795
+ **A decision decaying into a convention is the healthy path, not a failure.** When a live AD stops being arguable \u2014 the alternatives are no longer on the table and nobody would re-litigate it \u2014 say so during housekeeping and propose retiring it into a convention. An AD registry where most entries are settled is one nobody reads.`;
7410
7796
  var AD_CONFLICT_SURFACING_RULES = `**A contradiction is NOT a veto \u2014 surface it, never silently shelve it.**
7411
7797
 
7412
7798
  Active Decisions are *active*: they can be superseded, modified, or abandoned. You do NOT have authority to kill a piece of work simply because it cuts against one. That is the user's call, and they can only make it if you show it to them.
@@ -7653,7 +8039,7 @@ var PLAN_FRAGMENT_SPIKE = `
7653
8039
  var PLAN_FRAGMENT_DESIGN_BRIEF = `
7654
8040
  **Design brief task detection:** When a task's task type is "design-brief", generate a DESIGN BRIEF handoff. Inside SCOPE (DO THIS), use these type-specific subsections. KEEP the "SCOPE (DO THIS)" header and put these subsections INSIDE it \u2014 the handoff parser only recognises the standard headers (packages/adapter-md build-handoff.ts SECTION_HEADERS) and a handoff with no SCOPE section is rejected outright (task-3050, C357):
7655
8041
  - AUDIENCE: Who this design is for \u2014 persona and context of use (e.g. "non-technical Owner, first dashboard visit")
7656
- - BRAND CONSTRAINTS: Palette, typography, tone \u2014 pull from \`.impeccable.md\` (dev patterns, anti-patterns, component rules) AND \`docs/branding/brand-book.html\` (brand identity, positioning, voice canon) if present. If neither exists, state "No brand doc \u2014 Owner should define constraints before starting."
8042
+ - BRAND CONSTRAINTS: Palette, typography, tone \u2014 pull from \`PRODUCT.md\` (strategic: brand, users, product purpose, design principles) AND \`DESIGN.md\` (visual tokens: palette, typography, elevation, components) if present. If neither exists, state "No brand doc \u2014 Owner should define constraints before starting."
7657
8043
  - DELIVERABLE FORMAT: What the output looks like \u2014 design handoff package / annotated mockup / style spec. Be specific so the person doing the work knows what "done" means.
7658
8044
  - REVIEW POINTS: What the Owner must approve before the design is considered done (e.g. layout, copy, colour, imagery).
7659
8045
  Keep SCOPE BOUNDARY, ACCEPTANCE CRITERIA, SECURITY CONSIDERATIONS, and PRE-BUILD VERIFICATION sections as normal.
@@ -7685,11 +8071,11 @@ var PLAN_FRAGMENT_OPS_BRIEF = `
7685
8071
  var PLAN_FRAGMENT_UI = `
7686
8072
  **UI/visual task detection:** Apply these additions ONLY to tasks whose PRIMARY scope is frontend visual work \u2014 the task's main deliverable must be a UI change, new component, visual design, or page. Do NOT apply to backend tasks, DB migrations, or prompt/config changes that merely mention a dashboard or page in passing. Signal: the task would fail if no .tsx/.css files were changed. If uncertain, skip the UI additions.
7687
8073
  When a task IS a UI task (primary scope is visual/frontend):
7688
- - Add to SCOPE: "Read \`.impeccable.md\` for component patterns, anti-patterns, and dev-loop design rules. Read \`docs/branding/brand-book.html\` for brand identity (positioning, voice, palette as final canon). Use the \`frontend-design\` skill for implementation."
8074
+ - Add to SCOPE: "Read \`PRODUCT.md\` for product purpose, users and design principles, and \`DESIGN.md\` for the visual tokens (palette, typography, elevation, components) \u2014 these are the two files the \`impeccable\` skill reads, and every visual decision must align with them. Run \`impeccable init\` to create them if they do not exist. Use the \`frontend-design\` skill for implementation."
7689
8075
  - For M/L UI tasks, add to SCOPE: "Use the full impeccable workflow: shape (direction approval) \u2192 craft (design+build via \`impeccable craft\` / frontend-design) \u2192 live (in-browser HMR iteration via \`impeccable live\`) \u2192 detect (slop check). The approved direction is the quality bar; expect 2-3 iterations. Playground is for pre-build direction approval of shareable/static artifacts or non-dashboard explorers; Playwright is for post-build verification, not mid-design iteration."
7690
8076
  - Add to ACCEPTANCE CRITERIA: "[ ] Visually verify rendered output in browser \u2014 provide localhost URL or screenshot to user for review." and "[ ] No raw IDs, abbreviations, or jargon visible without human-readable labels or tooltips."
7691
- - If the task involves image selection, add to SCOPE: "Include brand/theme direction constraints for image selection \u2014 pull from \`docs/branding/brand-book.html\` for canonical brand identity."
7692
- The planner's job is scoping, not design direction. Design decisions happen at build time via \`.impeccable.md\` (dev patterns) + \`docs/branding/brand-book.html\` (brand identity) and the frontend-design skill \u2014 don't try to write design specs in the handoff.`;
8077
+ - If the task involves image selection, add to SCOPE: "Include brand/theme direction constraints for image selection \u2014 pull from \`PRODUCT.md\` and \`DESIGN.md\` for canonical brand identity."
8078
+ The planner's job is scoping, not design direction. Design decisions happen at build time via \`PRODUCT.md\` (product purpose, users, design principles) + \`DESIGN.md\` (visual tokens) and the frontend-design skill \u2014 don't try to write design specs in the handoff.`;
7693
8079
  var PLAN_FRAGMENT_PRODUCT_BRIEF = `
7694
8080
  12. **Product Brief** \u2014 Check whether the product brief still reflects reality. Update the brief when ANY of these apply:
7695
8081
  - A new AD was created or an existing AD was superseded that changes product scope, target user, or positioning
@@ -7783,6 +8169,8 @@ ${AD_CONFLICT_SURFACING_RULES}
7783
8169
 
7784
8170
  ${AD_REJECTION_RULES}
7785
8171
 
8172
+ ${AD_ADMISSION_RULES}
8173
+
7786
8174
  **\u2192 PERSIST:** EVERY AD you created, updated, or confirmed with changes MUST appear in \`activeDecisions\` array in Part 2. Include the full replacement body with ### heading.
7787
8175
 
7788
8176
  ### Operational Quality Rules
@@ -8325,6 +8713,8 @@ You MUST cover these 5 sections. Each is mandatory.
8325
8713
 
8326
8714
  ${AD_REJECTION_RULES}
8327
8715
 
8716
+ ${AD_ADMISSION_RULES}
8717
+
8328
8718
  **Registered Documents:** If a "### Registered Documents" section is present in context, scan it for: (a) research findings that contradict current ADs or strategy, (b) unactioned research that should influence the next plan. Reference relevant docs by title in your review. If unregistered docs are listed, flag 1-2 that look strategically relevant and suggest registering them.
8329
8719
 
8330
8720
  **Doc Action Staleness:** If a "### Doc Action Staleness" section is present, treat it as a research-to-action audit. For each entry:
@@ -8673,6 +9063,8 @@ The body field must be the COMPLETE replacement text for the AD block (including
8673
9063
 
8674
9064
  ${AD_REJECTION_RULES}
8675
9065
 
9066
+ ${AD_ADMISSION_RULES}
9067
+
8676
9068
  ## PHASE UPDATES
8677
9069
 
8678
9070
  If the strategic change affects the project's phase structure, include a phaseUpdates array.
@@ -8845,10 +9237,15 @@ function buildPreScanInstruction(opts) {
8845
9237
  const docRouting = opts.hosted ? "paste the relevant content (decision records, roadmap, spec excerpts) directly into `description` \u2014 over a remote connector PAPI cannot read local file paths." : "pass local doc file paths as `sources` (comma-separated), or paste content into `description`.";
8846
9238
  return [
8847
9239
  `**\u{1F50E} Before you generate anything \u2014 scan the project and gather real context.**`,
8848
- `A brief and Active Decisions built from the real code and docs are far sharper than ones guessed from a project name. You (the agent running this) have local access that PAPI does not \u2014 do the gathering, then pass it back.`,
9240
+ // task-3293: state the division of labour ONCE, up front. A user watching this
9241
+ // session sees PAPI say it cannot read their files and then sees their agent
9242
+ // read their files. That reads as a contradiction, or worse, unless it is named
9243
+ // first: PAPI never touches the filesystem, YOU do, and you send back a summary.
9244
+ `**How this works:** PAPI has no access to your machine and never reads your files. You (the agent running this) do the reading locally and send back a short summary. Nothing is uploaded except what you pass in that summary.`,
9245
+ `A brief and Active Decisions built from the real code and docs are far sharper than ones guessed from a project name.`,
8849
9246
  ``,
8850
9247
  `1. **Confirm the environment.** Check that you are running from the root of this project's dev environment \u2014 the folder that holds its source, git history, and docs. If you are not there, or you are connected over a remote connector with no access to the user's files, say so and ask the user where the project lives before continuing.`,
8851
- `2. **Scan the code.** List the top-level directories and files, read the package/build manifest and the README (first ~3000 chars), and collect recent commit subjects. Pass this back as \`codebase_scan\` on the next \`setup\` call.`,
9248
+ `2. **Scan the code.** List the top-level directories and files, read the package/build manifest and the README (first ~3000 chars), and collect recent commit subjects. Pass this back as \`codebase_scan\` on the next \`setup\` call. **Never include secrets** \u2014 skip \`.env\` and any credential, key or certificate file, and do not paste tokens, passwords or connection strings into the summary. PAPI also strips secret-shaped values server-side, but do not rely on that: leave them out.`,
8852
9249
  `3. **Scan the docs.** Look through \`docs/\`, design notes, ADRs/decision records, and any roadmap or planning files \u2014 not just code.`,
8853
9250
  `4. **Check for sibling / separate repos.** Many projects span more than one repo (a separate frontend, backend, infra, or mobile repo, or other packages in a monorepo). Ask the user whether any related repos or directories exist, and scan those too.`,
8854
9251
  `5. **Ask the user to point you at extra context.** Prompt them: "Is there anything else I should read before setting this up \u2014 a PRD or spec, decision records, a roadmap, or links to related repos or docs?" Fold whatever they share into the scan.`,
@@ -9725,6 +10122,13 @@ function formatBlockerWaiting(blocker) {
9725
10122
  }
9726
10123
  }
9727
10124
 
10125
+ // src/lib/adapter-capability.ts
10126
+ function adapterSupports(adapter2, method) {
10127
+ const declared = adapter2.supportsMethod;
10128
+ if (typeof declared === "function") return declared.call(adapter2, method);
10129
+ return typeof adapter2[method] === "function";
10130
+ }
10131
+
9728
10132
  // src/lib/tool-telemetry.ts
9729
10133
  var INSTRUMENTED_TOOLS = /* @__PURE__ */ new Set([
9730
10134
  "plan",
@@ -9747,7 +10151,7 @@ function measureResultBytes(content) {
9747
10151
  }
9748
10152
  function recordToolRun(adapter2, sample) {
9749
10153
  if (sample.toolName !== PLAN_PREPARE_TOOL_NAME && !WRAPPER_INSTRUMENTED_TOOLS.has(sample.toolName)) return;
9750
- if (typeof adapter2.insertToolRun !== "function") return;
10154
+ if (!adapterSupports(adapter2, "insertToolRun")) return;
9751
10155
  try {
9752
10156
  const promise = adapter2.insertToolRun({
9753
10157
  toolName: sample.toolName,
@@ -9821,7 +10225,7 @@ function isProjectOwner(callerUserId, ownerUserId) {
9821
10225
  return caller === owner;
9822
10226
  }
9823
10227
  async function resolveOwnerGate(adapter2, config2) {
9824
- if (typeof adapter2.getOwnerIdentity === "function") {
10228
+ if (adapterSupports(adapter2, "getOwnerIdentity")) {
9825
10229
  try {
9826
10230
  const identity = await adapter2.getOwnerIdentity();
9827
10231
  const callerUserId = identity.callerUserId ?? config2.userId ?? null;
@@ -9843,7 +10247,7 @@ async function resolveOwnerGate(adapter2, config2) {
9843
10247
  };
9844
10248
  }
9845
10249
  }
9846
- if (typeof adapter2.getProjectOwnerUserId === "function") {
10250
+ if (adapterSupports(adapter2, "getProjectOwnerUserId")) {
9847
10251
  let ownerUserId = null;
9848
10252
  let resolutionError;
9849
10253
  try {
@@ -12345,7 +12749,9 @@ async function buildSessionGuidance(callerKey) {
12345
12749
 
12346
12750
  // src/services/onboarding-coaching.ts
12347
12751
  var ONBOARDING_EARLY_CYCLE_MAX = 2;
12752
+ var ONBOARDING_FIRST_CYCLE_MAX = 1;
12348
12753
  var MAX_COACHING_LINES = 4;
12754
+ var COACH_EXPLAIN_VALUE = "First cycle: before you run each step, tell the user in one line what it produces and why it is worth the wait, then run it. Say what it means for their project, not what the tool does.";
12349
12755
  var COACH_CONNECT_REPO = "No repository is linked to this project yet. Link your repo from the dashboard Settings (or capture it during `setup`) so builds, reviews, and releases attach to the right codebase.";
12350
12756
  var COACH_ROOT_DIR = "Before building, confirm this session is running from your project root directory, so commits and builds land against the right files.";
12351
12757
  var COACH_CLICKABLE_TASKS = "Task cards on your dashboard expand on click. Open one to read its full build handoff, comments, and history.";
@@ -12357,10 +12763,14 @@ var ONBOARDING_COACHING_HEADING = "## Getting Started";
12357
12763
  function buildOnboardingCoaching(state) {
12358
12764
  const lines = [];
12359
12765
  const isEarly = state.cycleNumber <= ONBOARDING_EARLY_CYCLE_MAX;
12766
+ const isFirstRun = state.cycleNumber <= ONBOARDING_FIRST_CYCLE_MAX;
12360
12767
  const { surface } = state;
12361
12768
  if (state.repoConnected === false && (surface === "orient" || surface === "plan")) {
12362
12769
  lines.push(COACH_CONNECT_REPO);
12363
12770
  }
12771
+ if (isFirstRun) {
12772
+ lines.push(COACH_EXPLAIN_VALUE);
12773
+ }
12364
12774
  if (state.hasLocalWorkspace && (surface === "setup" || surface === "orient" && isEarly)) {
12365
12775
  lines.push(COACH_ROOT_DIR);
12366
12776
  }
@@ -12500,7 +12910,7 @@ function savePrepareContextFile(projectId, callerKey, content) {
12500
12910
  var planPrepareCache = new PerCallerCache();
12501
12911
  var planTool = {
12502
12912
  name: "plan",
12503
- description: 'Run once per cycle to select tasks and generate BUILD HANDOFFs. Call after setup (first time) or after completing all builds AND running release for the previous cycle. Returns prioritised task recommendations with detailed implementation specs. NEVER call when unbuilt cycle tasks exist \u2014 build and release first. First call returns a planning prompt for you to execute (prepare phase). Then call again with mode "apply" and your output to write results. Use skip_handoffs=true for large backlogs \u2014 handoffs are then generated separately via `handoff_generate`.',
12913
+ description: 'Turn a backlog into one scoped cycle of work, with a written spec for every task in it. plan reads the whole board, the decisions this project has already taken, and how much it actually delivered in recent cycles, then prioritises what to do next and writes a per-task BUILD HANDOFF: scope, what is deliberately out of scope, acceptance criteria, files likely touched, security notes, and the shared branch each task belongs on. It also reports board health, so stale, blocked and drifting work is visible instead of accumulating. This is not the same as planning inside one session: the cycle, the specs and the reasoning are stored, so the next session, the next tool, or the next person picks up where this one stopped. Run once per cycle, after setup the first time, or after completing all builds AND running release for the previous cycle. NEVER call when unbuilt cycle tasks exist: build and release first. First call returns a planning prompt for you to execute (prepare phase). Then call again with mode "apply" and your output to write results. Use skip_handoffs=true for large backlogs, and generate handoffs separately via `handoff_generate`.',
12504
12914
  annotations: { title: "Plan Cycle", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
12505
12915
  inputSchema: {
12506
12916
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -17395,6 +17805,33 @@ async function ensureDesignHookRegistered(projectRoot) {
17395
17805
  var TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
17396
17806
  var CONVENTIONS_SENTINEL = "<!-- PAPI_CONVENTIONS -->";
17397
17807
  var CONVENTIONS_HEADING = "## Code Style Conventions";
17808
+ function describeSeedWriteFailure(err) {
17809
+ const raw = err instanceof Error ? err.message : String(err);
17810
+ const CLASSES = [
17811
+ [
17812
+ /\b(?:401|403)\b|permission|unauthor|forbidden/i,
17813
+ "the storage backend refused the write (permission denied)."
17814
+ ],
17815
+ [
17816
+ /\b(?:5\d\d)\b|internal server error|bad gateway|unavailable/i,
17817
+ "the storage backend returned a server error."
17818
+ ],
17819
+ [
17820
+ /timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|fetch failed|network/i,
17821
+ "PAPI could not reach the storage backend."
17822
+ ],
17823
+ [
17824
+ /duplicate key|unique constraint|23505/i,
17825
+ "a decision with that id already exists."
17826
+ ],
17827
+ [
17828
+ /violates|constraint|invalid input syntax|22P02|23\d{3}/i,
17829
+ "the storage backend rejected the decision record."
17830
+ ]
17831
+ ];
17832
+ const matched = CLASSES.find(([pattern]) => pattern.test(raw));
17833
+ return matched ? `could not be saved \u2014 ${matched[1]}` : "could not be saved \u2014 the write to the storage backend failed.";
17834
+ }
17398
17835
  async function applySetupOutputs(adapter2, config2, input, collector, briefText, adSeedText, conventionsText) {
17399
17836
  const warnings = [];
17400
17837
  await adapter2.updateProductBrief(briefText);
@@ -17434,9 +17871,19 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
17434
17871
  let seededAds = 0;
17435
17872
  let skippedAds = 0;
17436
17873
  if (adSeedText) {
17874
+ let ads;
17875
+ let adSeedFailed = false;
17437
17876
  try {
17438
17877
  const cleaned = adSeedText.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
17439
- const ads = JSON.parse(cleaned);
17878
+ ads = JSON.parse(cleaned);
17879
+ } catch (err) {
17880
+ const msg = err instanceof Error ? err.message : String(err);
17881
+ warnings.push(
17882
+ `AD seeding failed \u2014 active decisions were not created. Check that your ad_seed_response is valid JSON. Error: ${msg}`
17883
+ );
17884
+ adSeedFailed = true;
17885
+ }
17886
+ try {
17440
17887
  if (Array.isArray(ads)) {
17441
17888
  const existingAdIds = adapter2.getActiveDecisions ? new Set(
17442
17889
  (await adapter2.getActiveDecisions({ includeRetired: true }).catch(() => [])).map((a) => a.displayId)
@@ -17460,18 +17907,17 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
17460
17907
  }
17461
17908
  }
17462
17909
  } catch (err) {
17463
- const msg = err instanceof Error ? err.message : String(err);
17464
17910
  warnings.push(
17465
- `AD seeding failed \u2014 active decisions were not created. Check that your ad_seed_response is valid JSON. Error: ${msg}`
17911
+ `Active Decisions: ${describeSeedWriteFailure(err)}` + (seededAds > 0 ? ` ${seededAds} decision(s) were created before this and are saved \u2014 re-running \`setup\` will not duplicate them.` : " No decisions were created.") + " This is a PAPI-side failure, not a problem with your response."
17466
17912
  );
17467
- seededAds = 0;
17913
+ adSeedFailed = true;
17468
17914
  }
17469
17915
  if (skippedAds > 0) {
17470
17916
  warnings.push(
17471
17917
  `Active Decisions: detected ${skippedAds} existing AD(s) and left them untouched${seededAds > 0 ? `; created ${seededAds} new one(s)` : " (none new to create)"}.`
17472
17918
  );
17473
17919
  } else if (seededAds === 0 && adSeedText) {
17474
- if (!warnings.some((w) => w.startsWith("AD seeding failed"))) {
17920
+ if (!adSeedFailed) {
17475
17921
  warnings.push(
17476
17922
  "AD seeding produced 0 active decisions \u2014 the JSON may be valid but empty or missing required `id` and `body` fields."
17477
17923
  );
@@ -17532,6 +17978,61 @@ function isSecretFile(name) {
17532
17978
  const lower = name.toLowerCase();
17533
17979
  return SECRET_PATTERNS.some((p) => lower.includes(p));
17534
17980
  }
17981
+ var SECRET_VALUE_PATTERNS = [
17982
+ // KEY=value / KEY: value where the KEY names a credential. Value must be
17983
+ // non-trivial so `API_KEY=` or `TOKEN=changeme` placeholders in docs are hit too.
17984
+ /\b([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|ACCESS_KEY|API_KEY|APIKEY|AUTH)[A-Z0-9_]*)\s*[:=]\s*\S+/gi,
17985
+ // Provider-issued key formats.
17986
+ /\bsk-[A-Za-z0-9_-]{16,}/g,
17987
+ // OpenAI / Anthropic style
17988
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
17989
+ // GitHub tokens
17990
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
17991
+ /\bAKIA[0-9A-Z]{16}\b/g,
17992
+ // AWS access key id
17993
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
17994
+ // Slack
17995
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
17996
+ // JWT
17997
+ // Credentials embedded in a connection string.
17998
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@]+@/gi,
17999
+ // Whole PEM blocks.
18000
+ /-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*PRIVATE KEY-----/g,
18001
+ /\bBearer\s+[A-Za-z0-9._-]{20,}/g
18002
+ ];
18003
+ var REDACTED = "[redacted by PAPI]";
18004
+ function redactPackageJson(pkg) {
18005
+ if (!pkg) return pkg;
18006
+ const out = { ...pkg };
18007
+ for (const field of ["description", "name"]) {
18008
+ if (typeof out[field] === "string") out[field] = redactSecretValues(out[field]);
18009
+ }
18010
+ return out;
18011
+ }
18012
+ function normaliseClientScan(raw) {
18013
+ return {
18014
+ topLevelDirs: raw.topLevelDirs ?? [],
18015
+ topLevelFiles: (raw.topLevelFiles ?? []).filter((f) => !isSecretFile(f)),
18016
+ packageJson: redactPackageJson(raw.packageJson),
18017
+ readme: redactSecretValues(raw.readme),
18018
+ configFiles: (raw.configFiles ?? []).filter((f) => !isSecretFile(f)),
18019
+ sourceFiles: raw.sourceFiles ?? [],
18020
+ totalFiles: raw.totalFiles ?? 0,
18021
+ sourcePaths: (raw.sourcePaths ?? []).filter((p) => !isSecretFile(p)),
18022
+ commitHistory: raw.commitHistory?.map((c) => redactSecretValues(c))
18023
+ };
18024
+ }
18025
+ function redactSecretValues(text) {
18026
+ if (!text) return text;
18027
+ let out = text;
18028
+ for (const pattern of SECRET_VALUE_PATTERNS) {
18029
+ out = out.replace(pattern, (match) => {
18030
+ const kv = match.match(/^([A-Z0-9_]+)\s*[:=]/i);
18031
+ return kv ? `${kv[1]}=${REDACTED}` : REDACTED;
18032
+ });
18033
+ }
18034
+ return out;
18035
+ }
17535
18036
  async function safeReadFile(filePath, maxBytes = 1e4) {
17536
18037
  if (isSecretFile(basename(filePath))) return null;
17537
18038
  try {
@@ -17721,18 +18222,7 @@ async function prepareSetup(adapter2, config2, input) {
17721
18222
  const hasUserSignalForScan = Boolean(input.description?.trim()) || Boolean(input.targetUsers?.trim());
17722
18223
  const shouldScan = isExistingProject || !hasUserSignalForScan;
17723
18224
  if (input.codebaseScan) {
17724
- const raw = input.codebaseScan;
17725
- const clientScan = {
17726
- topLevelDirs: raw.topLevelDirs ?? [],
17727
- topLevelFiles: raw.topLevelFiles ?? [],
17728
- packageJson: raw.packageJson,
17729
- readme: raw.readme,
17730
- configFiles: raw.configFiles ?? [],
17731
- sourceFiles: raw.sourceFiles ?? [],
17732
- totalFiles: raw.totalFiles ?? 0,
17733
- sourcePaths: raw.sourcePaths ?? [],
17734
- commitHistory: raw.commitHistory
17735
- };
18225
+ const clientScan = normaliseClientScan(input.codebaseScan);
17736
18226
  const hasScanSignal = Boolean(clientScan.readme) || Boolean(clientScan.packageJson) || Boolean(clientScan.commitHistory && clientScan.commitHistory.length > 0) || clientScan.topLevelDirs.length > 0 || clientScan.topLevelFiles.length > 0;
17737
18227
  if (hasScanSignal) {
17738
18228
  codebaseSummary = formatCodebaseSummary(clientScan, sourceContents);
@@ -17747,7 +18237,10 @@ async function prepareSetup(adapter2, config2, input) {
17747
18237
  }
17748
18238
  } else if (shouldScan && !canScanFilesystem && input.existingProject === true) {
17749
18239
  warnings.push(
17750
- "Codebase scan skipped \u2014 PAPI is connected over hosted transport and cannot read your local files. To adopt an existing project, provide `description` and `target_users` directly (the brief generator will use them), or run `setup` from a local stdio install (`npx @papi-ai/server`) where PAPI can scan the tree."
18240
+ // task-3293: this used to end at "cannot read your local files", which read as
18241
+ // a flat contradiction to the very next thing the user saw — their own agent
18242
+ // reading those files. Name the division of labour instead of just the limit.
18243
+ "Codebase scan skipped \u2014 PAPI has no access to your machine and never reads your files directly. Your AI client can: ask it to scan the project and pass the summary back as `codebase_scan` (leaving out `.env` and anything holding keys, tokens or passwords). Or provide `description` and `target_users` directly and the brief generator will use those instead."
17751
18244
  );
17752
18245
  }
17753
18246
  const hasCodebaseSignal = Boolean(codebaseSummary && codebaseSummary.trim().length > 0);
@@ -18077,7 +18570,7 @@ async function ensureMcpJsonGitignored(projectRoot) {
18077
18570
  // src/tools/setup.ts
18078
18571
  var setupTool = {
18079
18572
  name: "setup",
18080
- description: 'Create a new PAPI-tracked project \u2014 generates your Product Brief, Active Decisions, and CLAUDE.md workflow instructions. Run after configuring your MCP credentials (via `init` or manually from getpapi.ai). Only project_name is required \u2014 description and target_users are derived from README, package.json, and commit history when omitted. Set existing_project: true to adopt an existing codebase. ADOPTING AN EXISTING PROJECT OVER A REMOTE/HOSTED CONNECTOR (no local stdio install): PAPI cannot read your filesystem, so YOU (the client) must gather a `codebase_scan` and pass it in \u2014 list top-level dirs/files, the package manifest, the README (first ~3000 chars), and recent commit subjects. Without it, adoption falls back to asking for description/target_users. On a local stdio install PAPI scans the tree itself, so `codebase_scan` is optional there. First call returns prompts (prepare phase), then call again with mode "apply" and your outputs. After setup, run `plan` to start your first cycle.',
18573
+ description: 'Give a project a memory, so every later session starts with its context instead of a blank slate. Setup reads the project, writes a Product Brief that says what it is for and who it serves, records the first Active Decisions and build conventions so settled answers outlive the session that found them, and installs the workflow instructions the coding assistant follows from then on. Everything after this (planning, building, reviewing) reads from what setup writes. It takes a few minutes and runs in two passes: the first returns prompts for you to execute, the second saves the results. Tell the user what each pass is doing while it runs. Run after configuring your MCP credentials (via `init` or manually from getpapi.ai). Only project_name is required \u2014 description and target_users are derived from README, package.json, and commit history when omitted. Set existing_project: true to adopt an existing codebase. ADOPTING AN EXISTING PROJECT OVER A REMOTE/HOSTED CONNECTOR (no local stdio install): PAPI cannot read your filesystem, so YOU (the client) must gather a `codebase_scan` and pass it in \u2014 list top-level dirs/files, the package manifest, the README (first ~3000 chars), and recent commit subjects. Without it, adoption falls back to asking for description/target_users. On a local stdio install PAPI scans the tree itself, so `codebase_scan` is optional there. First call returns prompts (prepare phase), then call again with mode "apply" and your outputs. After setup, run `plan` to start your first cycle.',
18081
18574
  annotations: { title: "Set Up Project", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
18082
18575
  inputSchema: {
18083
18576
  type: "object",
@@ -18261,6 +18754,36 @@ Tip: See \`docs/templates/example-project-brief.md\` for an example of a well-wr
18261
18754
  Next step: run \`plan\` to start your first planning cycle.${filesToWriteSection}`
18262
18755
  );
18263
18756
  }
18757
+ var SETUP_NARRATION_HEADING = "**What happens now, and how long it takes**";
18758
+ function formatSetupNarration(result) {
18759
+ const steps = [];
18760
+ if (result.preScanInstruction || result.newProjectInstruction) {
18761
+ steps.push("Gather the context below, so what follows describes this project rather than a generic one.");
18762
+ }
18763
+ if (result.briefPrompt) {
18764
+ steps.push("Write the Product Brief: what this project is, who it is for, and what it has to get right. Every later plan reads it.");
18765
+ }
18766
+ if (result.adSeedPrompt) {
18767
+ steps.push("Record the first Active Decisions, so the choices already made stop being re-argued in later sessions.");
18768
+ }
18769
+ if (result.conventionsPrompt) {
18770
+ steps.push("Capture the build conventions this project expects, so they reach every future build without being restated.");
18771
+ }
18772
+ if (result.northStarPrompt) {
18773
+ steps.push("Agree a North Star with the user, so progress can be judged against something.");
18774
+ }
18775
+ if (result.initialTasksPrompt) {
18776
+ steps.push("Seed the backlog, so the first planning cycle has real work to choose from.");
18777
+ }
18778
+ steps.push('Call `setup` again with `mode: "apply"` to save all of it.');
18779
+ return [
18780
+ SETUP_NARRATION_HEADING,
18781
+ "",
18782
+ "Expect a few minutes end to end. Nothing is saved until the final step, so do not stop partway.",
18783
+ "Say what you are doing as you go, in one line per step, so the wait is visible to the user:",
18784
+ ...steps.map((s, i) => `${i + 1}. ${s}`)
18785
+ ];
18786
+ }
18264
18787
  async function handleSetup(adapter2, config2, args, clientName) {
18265
18788
  const toolMode = args.mode;
18266
18789
  const REQUIRED_FIELDS = ["project_name"];
@@ -18304,6 +18827,7 @@ PAPI needs the project name. Description and target users are optional \u2014 th
18304
18827
  result.createdProject ? `Project "${result.projectName}" scaffolded \u2014 database tables created.
18305
18828
  ` : ""
18306
18829
  ];
18830
+ sections.push(...formatSetupNarration(result), "");
18307
18831
  if (result.autoDetected) {
18308
18832
  sections.push(
18309
18833
  `**Codebase detected:** Existing codebase found \u2014 running in adoption mode. If this is wrong, re-run setup with \`existing_project: false\`.`,
@@ -18642,7 +19166,7 @@ function isDecisionPending(task) {
18642
19166
  return d != null && d.answer == null;
18643
19167
  }
18644
19168
  async function countFailedAttempts(adapter2, taskId) {
18645
- if (typeof adapter2.getFailedBuildAttemptsForTask !== "function") return null;
19169
+ if (!adapterSupports(adapter2, "getFailedBuildAttemptsForTask")) return null;
18646
19170
  try {
18647
19171
  return await adapter2.getFailedBuildAttemptsForTask(taskId);
18648
19172
  } catch {
@@ -19055,7 +19579,7 @@ To override, pass force=true (emits a telemetry warning).`
19055
19579
  boardHealth: existing?.boardHealth ?? "",
19056
19580
  taskIds: existing?.taskIds ?? []
19057
19581
  };
19058
- if (typeof adapter2.commitRelease === "function") {
19582
+ if (adapterSupports(adapter2, "commitRelease")) {
19059
19583
  try {
19060
19584
  await adapter2.commitRelease({ cycle: completedCycle, snapshot: snapshot ?? null });
19061
19585
  } catch (err) {
@@ -20260,16 +20784,20 @@ function triggerSurfaceHitsOnBranch(projectRoot, baseRef = "origin/main") {
20260
20784
  }
20261
20785
 
20262
20786
  // src/services/build.ts
20263
- function selectAutostashPaths(modified, untracked) {
20787
+ function selectAutostashPaths(modified, untracked, conflictingPaths) {
20264
20788
  const untrackedSet = new Set(untracked);
20265
- const isDocsPath = (p) => p === "docs" || p.startsWith("docs/");
20266
20789
  const isUntrackedDirEntry = (p) => p.endsWith("/") && untracked.some((u) => u.startsWith(p));
20267
20790
  const trackedDirty = modified.filter(
20268
20791
  (p) => !untrackedSet.has(p) && !isUntrackedDirEntry(p)
20269
20792
  );
20270
- const preservedDocs = untracked.filter(isDocsPath);
20271
- const stashableUntracked = untracked.filter((p) => !isDocsPath(p));
20272
- return { toStash: [...trackedDirty, ...stashableUntracked], preservedDocs };
20793
+ if (conflictingPaths == null) {
20794
+ return { toStash: trackedDirty, preservedDocs: untracked };
20795
+ }
20796
+ const conflicting = new Set(conflictingPaths);
20797
+ return {
20798
+ toStash: trackedDirty.filter((p) => conflicting.has(p)),
20799
+ preservedDocs: untracked
20800
+ };
20273
20801
  }
20274
20802
  function resolveRelatedDecisions(provided, buildHandoff) {
20275
20803
  const fromInput = (provided ?? "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -20391,11 +20919,18 @@ function matchedPredictedEntry(changedPath, predicted) {
20391
20919
  function isPathInPredictedScope(changedPath, predicted) {
20392
20920
  return matchedPredictedEntry(changedPath, predicted) !== null;
20393
20921
  }
20922
+ function verifyCommitContents(cwd, intended) {
20923
+ if (intended.length === 0) return [];
20924
+ const actual = getCommitFiles(cwd);
20925
+ if (actual === null) return [];
20926
+ const actualSet = new Set(actual);
20927
+ return intended.filter((p) => !actualSet.has(p));
20928
+ }
20394
20929
  function autoCommit(config2, taskId, taskTitle, predictedFiles) {
20395
20930
  const cwd = config2.projectRoot;
20396
20931
  const message = `feat(${taskId}): ${taskTitle}`;
20397
- if (!isGitAvailable()) return "Auto-commit: skipped (git not found).";
20398
- if (!isGitRepo(cwd)) return "Auto-commit: skipped (not a git repository).";
20932
+ if (!isGitAvailable()) return { line: "Auto-commit: skipped (git not found).", missing: [] };
20933
+ if (!isGitRepo(cwd)) return { line: "Auto-commit: skipped (not a git repository).", missing: [] };
20399
20934
  const safeRun = (fn) => {
20400
20935
  try {
20401
20936
  const r = fn();
@@ -20409,26 +20944,27 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
20409
20944
  `^(feat|fix|chore|refactor|docs|test|style|build|perf|ci)\\(${taskId}\\):`
20410
20945
  );
20411
20946
  if (headSubject && taskCommitRe.test(headSubject)) {
20412
- return `Auto-commit: skipped \u2014 HEAD already has a fresh ${taskId} commit (${headSubject}).`;
20947
+ return { line: `Auto-commit: skipped \u2014 HEAD already has a fresh ${taskId} commit (${headSubject}).`, missing: [] };
20413
20948
  }
20414
20949
  const staged = getStagedFiles(cwd);
20415
20950
  if (staged.length > 0) {
20416
- return safeRun(() => commitStagedOnly(cwd, message)) + ` (selective staging respected: ${staged.length} file(s)).`;
20951
+ const line = safeRun(() => commitStagedOnly(cwd, message)) + ` (selective staging respected: ${staged.length} file(s)).`;
20952
+ return { line, missing: verifyCommitContents(cwd, staged) };
20417
20953
  }
20418
20954
  const checkpoint = readBuildCheckpointIfLocal({ cwd, taskId });
20419
20955
  const headSha = getHeadCommitSha(cwd);
20420
20956
  if (checkpoint?.lastCommitSha && headSha && checkpoint.lastCommitSha !== headSha) {
20421
20957
  const leftover = getModifiedFiles(cwd);
20422
20958
  if (leftover.length === 0) {
20423
- return "Auto-commit: skipped (builder already committed; working tree clean).";
20959
+ return { line: "Auto-commit: skipped (builder already committed; working tree clean).", missing: [] };
20424
20960
  }
20425
20961
  const sample = leftover.slice(0, 10).join(", ");
20426
20962
  const more = leftover.length > 10 ? ` (+${leftover.length - 10} more)` : "";
20427
- return `Auto-commit: skipped \u2014 you already committed during this build, and ${leftover.length} file(s) are still modified. They were NOT committed, because at this point PAPI cannot tell your deliberately-excluded work from a concurrent session's files (task-3054). Left uncommitted: ${sample}${more}. If any belong to ${taskId}, \`git add\` them and re-run build_execute complete.`;
20963
+ return { line: `Auto-commit: skipped \u2014 you already committed during this build, and ${leftover.length} file(s) are still modified. They were NOT committed, because at this point PAPI cannot tell your deliberately-excluded work from a concurrent session's files (task-3054). Left uncommitted: ${sample}${more}. If any belong to ${taskId}, \`git add\` them and re-run build_execute complete.`, missing: [] };
20428
20964
  }
20429
20965
  const modified = getModifiedFiles(cwd);
20430
20966
  if (modified.length === 0) {
20431
- return "Auto-commit: skipped (no working-tree changes).";
20967
+ return { line: "Auto-commit: skipped (no working-tree changes).", missing: [] };
20432
20968
  }
20433
20969
  const commitResult = safeRun(() => stageAllAndCommit(cwd, message));
20434
20970
  if (predictedFiles && predictedFiles.length > 0) {
@@ -20437,13 +20973,22 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
20437
20973
  if (outOfScope.length > 0) {
20438
20974
  const sample = outOfScope.slice(0, 10).join(", ");
20439
20975
  const more = outOfScope.length > 10 ? ` (+${outOfScope.length - 10} more)` : "";
20440
- return `${commitResult} (staged all ${modified.length} changed file(s)). \u2139\uFE0F Scope drift: ${outOfScope.length} committed file(s) were outside the handoff's FILES LIKELY TOUCHED \u2014 handoff under-predicted: ${sample}${more}.`;
20976
+ return {
20977
+ line: `${commitResult} (staged all ${modified.length} changed file(s)). \u2139\uFE0F Scope drift: ${outOfScope.length} committed file(s) were outside the handoff's FILES LIKELY TOUCHED \u2014 handoff under-predicted: ${sample}${more}.`,
20978
+ missing: verifyCommitContents(cwd, modified)
20979
+ };
20441
20980
  }
20442
20981
  const matches = modified.slice(0, 5).map((p) => `${p} \u2190 ${matchedPredictedEntry(p, cleanedPredicted) ?? "?"}`).join(", ");
20443
20982
  const extra = modified.length > 5 ? ` (+${modified.length - 5} more)` : "";
20444
- return `${commitResult} (staged all ${modified.length} changed file(s), each matched to FILES LIKELY TOUCHED: ${matches}${extra}).`;
20983
+ return {
20984
+ line: `${commitResult} (staged all ${modified.length} changed file(s), each matched to FILES LIKELY TOUCHED: ${matches}${extra}).`,
20985
+ missing: verifyCommitContents(cwd, modified)
20986
+ };
20445
20987
  }
20446
- return `${commitResult} (staged all ${modified.length} changed file(s)).`;
20988
+ return {
20989
+ line: `${commitResult} (staged all ${modified.length} changed file(s)).`,
20990
+ missing: verifyCommitContents(cwd, modified)
20991
+ };
20447
20992
  }
20448
20993
  function pushAndCreatePR(config2, taskId, taskTitle, clientName, module, cycleNumber) {
20449
20994
  const lines = [];
@@ -20592,7 +21137,7 @@ async function listBuilds(adapter2, config2) {
20592
21137
  const [{ result: tasks, warnings }, health] = await Promise.all([
20593
21138
  withBaseBranchSync(
20594
21139
  { projectRoot: config2.projectRoot, baseBranch: config2.baseBranch },
20595
- () => adapter2.queryBoard()
21140
+ () => adapter2.queryBoard({ compact: true })
20596
21141
  ),
20597
21142
  adapter2.getCycleHealth().catch(() => null)
20598
21143
  ]);
@@ -20600,14 +21145,16 @@ async function listBuilds(adapter2, config2) {
20600
21145
  if (tasks.length === 0) {
20601
21146
  return { sorted: [], inProgress: [], backlog: [], blocked: [], warnings, isEmpty: true, noHandoffs: false, currentCycle, totalTasks: tasks.length };
20602
21147
  }
20603
- const withHandoff = tasks.filter((t) => {
20604
- if (!t.buildHandoff) return false;
21148
+ const candidates = tasks.filter((t) => {
20605
21149
  if (t.status === "Done" || t.status === "Cancelled" || t.status === "Blocked" || t.status === "Backlog" || t.status === "Deferred" || t.status === "Archived") {
20606
21150
  return false;
20607
21151
  }
20608
21152
  if (currentCycle > 0 && t.cycle !== currentCycle) return false;
20609
21153
  return true;
20610
21154
  });
21155
+ const hydrated = candidates.length > 0 ? await adapter2.getTasks(candidates.map((t) => t.id)) : [];
21156
+ const byId = new Map(hydrated.map((t) => [t.id, t]));
21157
+ const withHandoff = candidates.map((t) => byId.get(t.id)).filter((t) => Boolean(t?.buildHandoff));
20611
21158
  if (withHandoff.length === 0) {
20612
21159
  return { sorted: [], inProgress: [], backlog: [], blocked: [], warnings, isEmpty: false, noHandoffs: true, currentCycle, totalTasks: tasks.length };
20613
21160
  }
@@ -20859,20 +21406,23 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
20859
21406
  );
20860
21407
  }
20861
21408
  if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
21409
+ const stashTarget = branchExists(config2.projectRoot, featureBranch) ? featureBranch : resolveBaseBranch(config2.projectRoot, config2.baseBranch);
21410
+ const conflictingPaths = getPathsDifferingFrom(config2.projectRoot, stashTarget);
20862
21411
  const { toStash, preservedDocs } = selectAutostashPaths(
20863
21412
  getModifiedFiles(config2.projectRoot),
20864
- getUntrackedFiles(config2.projectRoot)
21413
+ getUntrackedFiles(config2.projectRoot),
21414
+ conflictingPaths
20865
21415
  );
20866
- const preservedNote = preservedDocs.length > 0 ? ` ${preservedDocs.length} untracked docs/ file(s) left in place (task-2092 \u2014 registered docs must never be swept).` : "";
21416
+ const preservedNote = preservedDocs.length > 0 ? ` ${preservedDocs.length} untracked file(s) left in place, not stashed (task-3282 \u2014 an untracked file never blocks a branch switch, and a sub-agent's brand-new file is untracked by definition).` : "";
20867
21417
  if (toStash.length === 0) {
20868
21418
  if (preservedDocs.length > 0) {
20869
- branchLines.push(`Auto-stash skipped \u2014 only untracked docs/ files present.${preservedNote}`);
21419
+ branchLines.push(`Auto-stash skipped \u2014 nothing tracked is dirty.${preservedNote}`);
20870
21420
  }
20871
21421
  } else {
20872
21422
  const stashLabel = `papi-autostash/${taskId}-${Math.floor(Date.now() / 1e3)}`;
20873
21423
  try {
20874
21424
  const { execFileSync: execFileSync7 } = await import("child_process");
20875
- execFileSync7("git", ["stash", "push", "-u", "-m", stashLabel, "--", ...toStash], {
21425
+ execFileSync7("git", ["stash", "push", "-m", stashLabel, "--", ...toStash], {
20876
21426
  cwd: config2.projectRoot,
20877
21427
  encoding: "utf-8"
20878
21428
  });
@@ -21102,7 +21652,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
21102
21652
  assertDeployVerification(config2, input, { deployingNow: options.light === true });
21103
21653
  const [healthResult, priorCount] = await Promise.all([
21104
21654
  adapter2.getCycleHealth().catch(() => ({ totalCycles: 0 })),
21105
- typeof adapter2.getBuildReportCountForTask === "function" ? adapter2.getBuildReportCountForTask(taskId).catch(() => 0) : Promise.resolve(0)
21655
+ adapterSupports(adapter2, "getBuildReportCountForTask") ? adapter2.getBuildReportCountForTask(taskId).catch(() => 0) : Promise.resolve(0)
21106
21656
  ]);
21107
21657
  const cycleNumber = healthResult.totalCycles;
21108
21658
  const iterationCount = priorCount + 1;
@@ -21146,7 +21696,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
21146
21696
  console.error(
21147
21697
  `[build] task ${taskId} related_decisions: ${relatedAdIds.length} AD(s) (${relatedAdIds.length === 0 ? "none" : relatedInferred ? "inferred from handoff" : "builder-provided"}).`
21148
21698
  );
21149
- if (report.startedAt && report.completedAt && typeof adapter2.getToolCallCount === "function") {
21699
+ if (report.startedAt && report.completedAt && adapterSupports(adapter2, "getToolCallCount")) {
21150
21700
  try {
21151
21701
  const count = await adapter2.getToolCallCount(report.startedAt, report.completedAt);
21152
21702
  if (count > 0) report.toolCallCount = count;
@@ -21159,7 +21709,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
21159
21709
  const buildReportSummary = `${capitalizeCompleted(input.completed)}. Effort ${input.effort} vs estimated ${input.estimatedEffort}.${iterNote}${surpriseNote}${issueNote}`;
21160
21710
  const statusChange = input.completed === "yes" ? { from: task.status, to: options.light ? "Done" : "In Review" } : void 0;
21161
21711
  let atomicCommitDone = false;
21162
- if (typeof adapter2.commitBuildComplete === "function") {
21712
+ if (adapterSupports(adapter2, "commitBuildComplete")) {
21163
21713
  await adapter2.commitBuildComplete({
21164
21714
  report,
21165
21715
  taskId,
@@ -21172,7 +21722,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
21172
21722
  await adapter2.appendBuildReport(report);
21173
21723
  }
21174
21724
  let reportWriteVerified;
21175
- if (typeof adapter2.getBuildReportCountForTask === "function") {
21725
+ if (adapterSupports(adapter2, "getBuildReportCountForTask")) {
21176
21726
  try {
21177
21727
  const postWriteCount = await adapter2.getBuildReportCountForTask(taskId);
21178
21728
  reportWriteVerified = postWriteCount >= iterationCount;
@@ -21290,7 +21840,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
21290
21840
  if (row.findingKey) insertedByKey.set(row.findingKey, row.inserted);
21291
21841
  }
21292
21842
  const haveInsertSignal = appendedRows.length > 0;
21293
- if (findingRows.length > 0 && typeof adapter2.createTask === "function") {
21843
+ if (findingRows.length > 0 && adapterSupports(adapter2, "createTask")) {
21294
21844
  for (const { finding, learning } of findingRows) {
21295
21845
  if (learning.category !== "issue") continue;
21296
21846
  if (finding.disposition !== "filed") continue;
@@ -21433,7 +21983,20 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
21433
21983
  } else if (dbOnlyMode) {
21434
21984
  commitLine = DB_ONLY_COMPLETE_NOTICE;
21435
21985
  } else {
21436
- commitLine = autoCommit(config2, taskId, task.title, task.buildHandoff?.filesLikelyTouched);
21986
+ const outcome = autoCommit(config2, taskId, task.title, task.buildHandoff?.filesLikelyTouched);
21987
+ commitLine = outcome.line;
21988
+ if (outcome.missing.length > 0) {
21989
+ const sample = outcome.missing.slice(0, 10).join(", ");
21990
+ const more = outcome.missing.length > 10 ? ` (+${outcome.missing.length - 10} more)` : "";
21991
+ if (input.completed === "yes" && !atomicCommitDone) {
21992
+ try {
21993
+ await adapter2.updateTaskStatus(taskId, task.status);
21994
+ } catch {
21995
+ }
21996
+ }
21997
+ commitLine = `\u26A0\uFE0F AUTO-COMMIT VERIFICATION FAILED \u2014 the commit does NOT contain ${outcome.missing.length} file(s) that were staged for it. Missing: ${sample}${more}. This is the task-3029 failure mode: a pre-commit hook reverted modified files while staged adds/renames survived, so the commit is PARTIAL and will not build. ${taskId} was NOT advanced \u2014 it stays ${task.status}. Fix: inspect \`git show --stat HEAD\`, restore the missing changes, \`git add\` them, and re-run build_execute complete.
21998
+ (Original commit note: ${outcome.line})`;
21999
+ }
21437
22000
  }
21438
22001
  if (isGitAvailable() && isGitRepo(config2.projectRoot)) {
21439
22002
  const sha = getHeadCommitSha(config2.projectRoot);
@@ -21603,6 +22166,216 @@ async function cancelBuild(adapter2, taskId, reason, projectRoot) {
21603
22166
  return { task, reason };
21604
22167
  }
21605
22168
 
22169
+ // src/tools/conventions.ts
22170
+ var MAX_CONVENTION_RULE_LENGTH = 2e3;
22171
+ var MAX_CONVENTION_MODULE_LENGTH = 120;
22172
+ var MAX_INJECTED_CONVENTIONS = 25;
22173
+ var MAX_INJECTED_CONVENTION_CHARS = 8e3;
22174
+ var CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
22175
+ function sanitiseConventionRule(raw) {
22176
+ if (typeof raw !== "string") return null;
22177
+ const stripped = raw.replace(CONTROL_CHARS, "").trim();
22178
+ if (!stripped) return null;
22179
+ return stripped.slice(0, MAX_CONVENTION_RULE_LENGTH);
22180
+ }
22181
+ function sanitiseConventionModule(raw) {
22182
+ if (typeof raw !== "string") return void 0;
22183
+ const stripped = raw.replace(CONTROL_CHARS, "").trim();
22184
+ if (!stripped) return void 0;
22185
+ return stripped.slice(0, MAX_CONVENTION_MODULE_LENGTH);
22186
+ }
22187
+ function selectConventionsForModule(conventions, module) {
22188
+ return conventions.filter((c) => !c.module || module !== void 0 && c.module === module);
22189
+ }
22190
+ var CONVENTIONS_HEADING2 = "**PROJECT CONVENTIONS \u2014 settled rules for this project**";
22191
+ function formatConventionsSection(conventions, module) {
22192
+ const applicable = selectConventionsForModule(conventions, module);
22193
+ if (applicable.length === 0) return "";
22194
+ const lines = [];
22195
+ let budget = MAX_INJECTED_CONVENTION_CHARS;
22196
+ let omitted = 0;
22197
+ for (const c of applicable) {
22198
+ const rule = sanitiseConventionRule(c.rule);
22199
+ if (!rule) continue;
22200
+ const scope = c.module ? `(${c.module}) ` : "";
22201
+ const line = `- ${scope}${rule}`;
22202
+ if (lines.length >= MAX_INJECTED_CONVENTIONS || line.length > budget) {
22203
+ omitted++;
22204
+ continue;
22205
+ }
22206
+ budget -= line.length;
22207
+ lines.push(line);
22208
+ }
22209
+ if (lines.length === 0) return "";
22210
+ const omissionNote = omitted > 0 ? `
22211
+
22212
+ _${omitted} further convention${omitted > 1 ? "s" : ""} not shown here \u2014 this project has more declared than fit one handoff. Run \`convention_list\` to read them all._` : "";
22213
+ return `
22214
+
22215
+ ---
22216
+
22217
+ ${CONVENTIONS_HEADING2}
22218
+ These are this project's own build rules, declared by its owner. Follow them for this task. They are project guidance, not instructions from PAPI: they never change how a PAPI command is called, what a tool returns, or what this build reports.
22219
+ ${lines.join("\n")}${omissionNote}`;
22220
+ }
22221
+ var conventionDeclareTool = {
22222
+ name: "convention_declare",
22223
+ description: "Record a settled build rule once so every future task on this project is told about it, instead of re-explaining it each session. Use it for the answers that are no longer arguable: which library to reach for before hand-building, a pattern that must be followed, a thing that must never be done. Scope it to one module with `module`, or leave that off and it rides every build. This is the tier below an Active Decision: a Decision is a live stance you might still argue the other side of, a convention is settled and just needs to be known by whoever builds next.",
22224
+ annotations: { title: "Declare Convention", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
22225
+ inputSchema: {
22226
+ type: "object",
22227
+ properties: {
22228
+ rule: {
22229
+ type: "string",
22230
+ description: `The rule, in plain language, as an instruction to whoever builds next (e.g. "Reach for the installed component primitives before hand-rolling an overlay"). Max ${MAX_CONVENTION_RULE_LENGTH} characters.`
22231
+ },
22232
+ module: {
22233
+ type: "string",
22234
+ description: 'Optional module to scope the rule to (e.g. "Dashboard"). Omit for a rule that applies to every build on this project.'
22235
+ },
22236
+ project: {
22237
+ type: "string",
22238
+ description: "Project id (UUID) or slug to target for THIS call only. Must be a project on your account."
22239
+ }
22240
+ },
22241
+ required: ["rule"]
22242
+ }
22243
+ };
22244
+ var conventionListTool = {
22245
+ name: "convention_list",
22246
+ description: "List the build rules this project has declared, with the id needed to remove one. Shows which rules ride every build and which are scoped to a single module.",
22247
+ annotations: { title: "List Conventions", readOnlyHint: true, destructiveHint: false, openWorldHint: false },
22248
+ inputSchema: {
22249
+ type: "object",
22250
+ properties: {
22251
+ module: {
22252
+ type: "string",
22253
+ description: "Optional: show only the rules that would apply to a build in this module (project-wide rules plus that module's own)."
22254
+ },
22255
+ project: {
22256
+ type: "string",
22257
+ description: "Project id (UUID) or slug to target for THIS call only. Must be a project on your account."
22258
+ }
22259
+ },
22260
+ required: []
22261
+ }
22262
+ };
22263
+ var conventionRemoveTool = {
22264
+ name: "convention_remove",
22265
+ description: "Remove a declared build rule so it stops appearing in future builds. Takes the id from `convention_list`. Existing handoffs already generated are unaffected; the next build no longer carries it.",
22266
+ annotations: { title: "Remove Convention", readOnlyHint: false, destructiveHint: true, openWorldHint: false },
22267
+ inputSchema: {
22268
+ type: "object",
22269
+ properties: {
22270
+ id: {
22271
+ type: "string",
22272
+ description: "The convention id, from `convention_list`."
22273
+ },
22274
+ project: {
22275
+ type: "string",
22276
+ description: "Project id (UUID) or slug to target for THIS call only. Must be a project on your account."
22277
+ }
22278
+ },
22279
+ required: ["id"]
22280
+ }
22281
+ };
22282
+ var UNSUPPORTED = "Conventions are not available on this connection. The store lives in the PAPI database, so a project running on a file-backed adapter has nowhere to record one.";
22283
+ async function handleConventionDeclare(adapter2, config2, args) {
22284
+ const tracker = new ProgressTracker("validate");
22285
+ try {
22286
+ if (!adapter2.createConvention) return errorResponse(UNSUPPORTED);
22287
+ const rule = sanitiseConventionRule(args.rule);
22288
+ if (!rule) {
22289
+ return errorResponse(
22290
+ "A convention needs a rule. Pass `rule` with the instruction you want every future build on this project to follow."
22291
+ );
22292
+ }
22293
+ const wasTruncated = typeof args.rule === "string" && args.rule.trim().length > MAX_CONVENTION_RULE_LENGTH;
22294
+ const moduleScope = sanitiseConventionModule(args.module);
22295
+ tracker.mark("write");
22296
+ const created = await adapter2.createConvention({ rule, ...moduleScope ? { module: moduleScope } : {} });
22297
+ const scopeNote = moduleScope ? `It rides every build in the **${moduleScope}** module.` : "It rides every build on this project.";
22298
+ const truncationNote = wasTruncated ? `
22299
+
22300
+ \u26A0\uFE0F The rule was longer than ${MAX_CONVENTION_RULE_LENGTH} characters and was shortened to fit. Re-declare it shorter if the trimmed version reads wrong.` : "";
22301
+ return textResponse(
22302
+ `Convention recorded. ${scopeNote}
22303
+
22304
+ > ${rule}
22305
+
22306
+ Id: \`${created.id ?? "(unknown)"}\` \u2014 remove it with \`convention_remove\`.${truncationNote}`
22307
+ );
22308
+ } catch (err) {
22309
+ return errorResponse(formatStructuredError({
22310
+ tool: "convention_declare",
22311
+ mode: "declare",
22312
+ adapter: config2.adapterType,
22313
+ lastStep: tracker.lastStep,
22314
+ error: err instanceof Error ? err.message : String(err),
22315
+ hint: defaultHint("convention_declare")
22316
+ }));
22317
+ }
22318
+ }
22319
+ async function handleConventionList(adapter2, config2, args) {
22320
+ const tracker = new ProgressTracker("read");
22321
+ try {
22322
+ if (!adapter2.listConventions) return errorResponse(UNSUPPORTED);
22323
+ const all = await adapter2.listConventions();
22324
+ const moduleFilter = sanitiseConventionModule(args.module);
22325
+ const rows = moduleFilter ? selectConventionsForModule(all, moduleFilter) : all;
22326
+ if (rows.length === 0) {
22327
+ return textResponse(
22328
+ moduleFilter ? `No conventions apply to the **${moduleFilter}** module yet. Declare one with \`convention_declare\` and every future build in it will carry the rule.` : "No conventions declared yet. When you settle a question you do not want to answer again, record it with `convention_declare` and every future build will carry it."
22329
+ );
22330
+ }
22331
+ const lines = rows.map((c) => {
22332
+ const scope = c.module ? `**${c.module}**` : "every build";
22333
+ return `- ${scope} \u2014 ${sanitiseConventionRule(c.rule) ?? c.rule}
22334
+ \`${c.id ?? "(no id)"}\``;
22335
+ });
22336
+ const header = moduleFilter ? `${rows.length} convention${rows.length > 1 ? "s" : ""} apply to a build in **${moduleFilter}** (project-wide rules included):` : `${rows.length} convention${rows.length > 1 ? "s" : ""} declared on this project:`;
22337
+ return textResponse(`${header}
22338
+
22339
+ ${lines.join("\n")}`);
22340
+ } catch (err) {
22341
+ return errorResponse(formatStructuredError({
22342
+ tool: "convention_list",
22343
+ mode: "list",
22344
+ adapter: config2.adapterType,
22345
+ lastStep: tracker.lastStep,
22346
+ error: err instanceof Error ? err.message : String(err),
22347
+ hint: defaultHint("convention_list")
22348
+ }));
22349
+ }
22350
+ }
22351
+ async function handleConventionRemove(adapter2, config2, args) {
22352
+ const tracker = new ProgressTracker("validate");
22353
+ try {
22354
+ if (!adapter2.deleteConvention) return errorResponse(UNSUPPORTED);
22355
+ const id = typeof args.id === "string" ? args.id.trim() : "";
22356
+ if (!id) {
22357
+ return errorResponse("Pass the `id` of the convention to remove. `convention_list` prints the id under each rule.");
22358
+ }
22359
+ tracker.mark("delete");
22360
+ const removed = await adapter2.deleteConvention(id);
22361
+ if (!removed) {
22362
+ return errorResponse(
22363
+ `No convention with id \`${id}\` on this project. Run \`convention_list\` to see what is declared.`
22364
+ );
22365
+ }
22366
+ return textResponse(`Convention removed. Future builds no longer carry it.`);
22367
+ } catch (err) {
22368
+ return errorResponse(formatStructuredError({
22369
+ tool: "convention_remove",
22370
+ mode: "remove",
22371
+ adapter: config2.adapterType,
22372
+ lastStep: tracker.lastStep,
22373
+ error: err instanceof Error ? err.message : String(err),
22374
+ hint: defaultHint("convention_remove")
22375
+ }));
22376
+ }
22377
+ }
22378
+
21606
22379
  // src/tools/module-instructions.ts
21607
22380
  var OWNER_NAME = process.env["PAPI_OWNER"] ?? "cathalos92";
21608
22381
  var MODULE_INSTRUCTIONS = {
@@ -21681,6 +22454,194 @@ function getModuleInstructions(module) {
21681
22454
 
21682
22455
  ${instructions}`;
21683
22456
  }
22457
+ function getBuilderInstructions(module, conventions) {
22458
+ const conventionsSection = formatConventionsSection(conventions, module);
22459
+ const hasModuleScopedOverride = Boolean(
22460
+ module && conventions.some((c) => c.module === module)
22461
+ );
22462
+ const builtIn = hasModuleScopedOverride ? "" : getModuleInstructions(module);
22463
+ return builtIn + conventionsSection;
22464
+ }
22465
+
22466
+ // src/services/build-proposal.ts
22467
+ var MAX_PROPOSAL_TITLE = 200;
22468
+ var MAX_PROPOSAL_BODY = 2e3;
22469
+ function validateProposal(raw) {
22470
+ if (typeof raw !== "object" || raw === null) {
22471
+ return { error: "proposal must be an object." };
22472
+ }
22473
+ const p = raw;
22474
+ if (typeof p.title === "string" && p.title.trim().length > MAX_PROPOSAL_TITLE) {
22475
+ return { error: `proposal.title exceeds ${MAX_PROPOSAL_TITLE} characters.` };
22476
+ }
22477
+ if (typeof p.body === "string" && p.body.trim().length > MAX_PROPOSAL_BODY) {
22478
+ return { error: `proposal.body exceeds ${MAX_PROPOSAL_BODY} characters.` };
22479
+ }
22480
+ const title = sanitiseConventionRule(p.title) ?? "";
22481
+ if (!title) return { error: "proposal.title is required \u2014 one line stating the stance or rule." };
22482
+ const body = sanitiseConventionRule(p.body) ?? "";
22483
+ if (!body) return { error: "proposal.body is required \u2014 what was decided or settled, and why." };
22484
+ const t = p.tests;
22485
+ if (typeof t !== "object" || t === null) {
22486
+ return { error: "proposal.tests is required \u2014 apply the four admission tests and report each as a boolean." };
22487
+ }
22488
+ const tt = t;
22489
+ const keys = [
22490
+ "alternativesWereReal",
22491
+ "constrainsFutureWork",
22492
+ "arguableToday",
22493
+ "reversalCostsMore"
22494
+ ];
22495
+ for (const k of keys) {
22496
+ if (typeof tt[k] !== "boolean") {
22497
+ return { error: `proposal.tests.${k} must be true or false. All four admission tests must be answered explicitly \u2014 an omitted test is not a failed test.` };
22498
+ }
22499
+ }
22500
+ const moduleScope = sanitiseConventionModule(p.module);
22501
+ return {
22502
+ proposal: {
22503
+ title,
22504
+ body,
22505
+ ...moduleScope ? { module: moduleScope } : {},
22506
+ tests: {
22507
+ alternativesWereReal: tt.alternativesWereReal,
22508
+ constrainsFutureWork: tt.constrainsFutureWork,
22509
+ arguableToday: tt.arguableToday,
22510
+ reversalCostsMore: tt.reversalCostsMore
22511
+ }
22512
+ }
22513
+ };
22514
+ }
22515
+ function routeProposal(proposal) {
22516
+ const { alternativesWereReal, constrainsFutureWork, arguableToday, reversalCostsMore } = proposal.tests;
22517
+ if (!constrainsFutureWork) {
22518
+ return {
22519
+ outcome: "rejected",
22520
+ failedTest: "b",
22521
+ reason: "It does not constrain future work, so it is not durable \u2014 it is just work. Capture it as a task, a build report note, or a doc."
22522
+ };
22523
+ }
22524
+ if (!alternativesWereReal) {
22525
+ return {
22526
+ outcome: "rejected",
22527
+ failedTest: "a",
22528
+ reason: "There were no real alternatives, so no stance was taken. If there was only ever one way to do it, it is not a decision. Capture it as a task, a note, or a doc."
22529
+ };
22530
+ }
22531
+ if (!reversalCostsMore) {
22532
+ return {
22533
+ outcome: "rejected",
22534
+ failedTest: "d",
22535
+ reason: "Reversing it is as cheap as doing it, so nothing is being constrained. Capture it as a task, a note, or a doc."
22536
+ };
22537
+ }
22538
+ if (!arguableToday) {
22539
+ return {
22540
+ outcome: "recorded-convention",
22541
+ failedTest: "c",
22542
+ reason: "Real alternatives, binds future work, expensive to reverse, but nobody is arguing the other side any more. That is a Convention, not a Decision \u2014 it does not need adjudicating, it needs to be known by whoever builds next."
22543
+ };
22544
+ }
22545
+ return {
22546
+ outcome: "queued-decision",
22547
+ reason: "All four admission tests pass, so this is a live stance. Queued for your decision \u2014 nothing has been minted."
22548
+ };
22549
+ }
22550
+ var PROPOSED_DECISION_REF = "proposed-decision";
22551
+ async function applyProposal(adapter2, routing, proposal, ctx) {
22552
+ try {
22553
+ if (routing.outcome === "rejected") return {};
22554
+ if (routing.outcome === "recorded-convention") {
22555
+ if (!adapter2.createConvention) {
22556
+ return { error: "This connection has nowhere to record a convention." };
22557
+ }
22558
+ const rule = `${proposal.title}. ${proposal.body}`.slice(0, 2e3);
22559
+ const convention = await adapter2.createConvention({
22560
+ rule,
22561
+ ...proposal.module ? { module: proposal.module } : {}
22562
+ });
22563
+ return { convention };
22564
+ }
22565
+ if (!adapter2.createTask || !adapter2.updateTask) {
22566
+ return { error: "This connection cannot queue a decision for confirmation." };
22567
+ }
22568
+ const provenance = ctx.sourceTaskId ? `Proposed while building ${ctx.sourceTaskId}.` : "Proposed during a build.";
22569
+ const task = await adapter2.createTask({
22570
+ displayId: "",
22571
+ title: `Decision proposed: ${proposal.title}`,
22572
+ status: "Backlog",
22573
+ priority: "P1 High",
22574
+ complexity: "XS",
22575
+ module: proposal.module || "Core",
22576
+ epic: "Platform",
22577
+ phase: "Unscoped",
22578
+ owner: "TBD",
22579
+ reviewed: false,
22580
+ createdCycle: ctx.cycleNumber,
22581
+ taskType: "task",
22582
+ notes: `${provenance} All four admission tests passed, so this is a live stance rather than a settled rule.
22583
+
22584
+ ${proposal.body}
22585
+
22586
+ Accept it by minting the decision with \`strategy_change\`, or dismiss it by cancelling this task. Nothing has been written as an Active Decision.`
22587
+ });
22588
+ await adapter2.updateTask(task.id, {
22589
+ status: "Blocked",
22590
+ blocker: {
22591
+ type: "decision-gate",
22592
+ ref: PROPOSED_DECISION_REF,
22593
+ reason: "A build proposed this decision. Awaiting the owner's call \u2014 it does not auto-clear, because only a person can rule on it.",
22594
+ blockedCycle: ctx.cycleNumber
22595
+ }
22596
+ });
22597
+ return { taskId: task.id };
22598
+ } catch (err) {
22599
+ return { error: err instanceof Error ? err.message : "Unknown error." };
22600
+ }
22601
+ }
22602
+ function formatProposalOutcome(routing, proposal, detail) {
22603
+ if (detail.error) {
22604
+ return `
22605
+
22606
+ ---
22607
+
22608
+ **Proposal not recorded.** ${detail.error}
22609
+
22610
+ The build itself is unaffected \u2014 only the proposal failed.`;
22611
+ }
22612
+ switch (routing.outcome) {
22613
+ case "queued-decision":
22614
+ return `
22615
+
22616
+ ---
22617
+
22618
+ **Decision proposed \u2014 waiting on you.**
22619
+
22620
+ > ${proposal.title}
22621
+
22622
+ ${routing.reason}` + (detail.taskId ? ` It is parked as ${detail.taskId}, blocked on your call, and shows up in \`orient\` as a decision waiting on you. Nothing was written as an Active Decision.` : " Nothing was written as an Active Decision.");
22623
+ case "recorded-convention":
22624
+ return `
22625
+
22626
+ ---
22627
+
22628
+ **Recorded as a convention.**
22629
+
22630
+ > ${proposal.title}
22631
+
22632
+ ${routing.reason}` + (proposal.module ? ` It rides every future build in the **${proposal.module}** module.` : " It rides every future build on this project.") + (detail.convention?.id ? ` Remove it with \`convention_remove\` (id \`${detail.convention.id}\`) if that is not what you wanted.` : "");
22633
+ case "rejected":
22634
+ return `
22635
+
22636
+ ---
22637
+
22638
+ **Proposal not minted, and here is why.**
22639
+
22640
+ > ${proposal.title}
22641
+
22642
+ Failed test (${routing.failedTest}): ${routing.reason}`;
22643
+ }
22644
+ }
21684
22645
 
21685
22646
  // src/tools/doc-registry.ts
21686
22647
  import { readdirSync as readdirSync6, existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync6, mkdirSync as mkdirSync4 } from "fs";
@@ -21700,7 +22661,7 @@ var MAX_DOC_BODY_BYTES = 2 * 1024 * 1024;
21700
22661
  var PAID_TIERS = /* @__PURE__ */ new Set(["pro", "team"]);
21701
22662
  var PRICING_URL = "https://getpapi.ai/pricing";
21702
22663
  async function resolveTier(adapter2) {
21703
- if (typeof adapter2.getMeteredUsage !== "function") return null;
22664
+ if (!adapterSupports(adapter2, "getMeteredUsage")) return null;
21704
22665
  try {
21705
22666
  const usage = await adapter2.getMeteredUsage();
21706
22667
  return usage?.tier ?? null;
@@ -21715,7 +22676,7 @@ async function enforceProjectCap(adapter2, target) {
21715
22676
  if (isSelfHostedDeployment(process.env.PAPI_SELF_HOST)) return null;
21716
22677
  const tier = await resolveTier(adapter2);
21717
22678
  if (tier === null || isPaidTier(tier)) return null;
21718
- if (typeof adapter2.listUserProjects !== "function") return null;
22679
+ if (!adapterSupports(adapter2, "listUserProjects")) return null;
21719
22680
  let projects;
21720
22681
  try {
21721
22682
  projects = await adapter2.listUserProjects();
@@ -21758,7 +22719,7 @@ The registry entry was saved and everything else is unaffected. Split the docume
21758
22719
  const tier = await resolveTier(adapter2);
21759
22720
  if (tier === null) return { storeBody: true };
21760
22721
  const ceiling = DOC_STORAGE_CEILING_BY_TIER[tier] ?? DOC_STORAGE_CEILING_BY_TIER.free;
21761
- if (typeof adapter2.getDocBodyUsage !== "function") return { storeBody: true };
22722
+ if (!adapterSupports(adapter2, "getDocBodyUsage")) return { storeBody: true };
21762
22723
  let usage;
21763
22724
  try {
21764
22725
  usage = await adapter2.getDocBodyUsage();
@@ -21935,6 +22896,7 @@ The registry stores metadata and a summary \u2014 not the body. This doc has one
21935
22896
  );
21936
22897
  }
21937
22898
  if (isPathTracked(projectRoot, path7)) return "";
22899
+ if (isPathCommittedAnywhere(projectRoot, path7)) return "";
21938
22900
  if (isPathIgnored(projectRoot, path7)) {
21939
22901
  return warn(
21940
22902
  `\`${path7}\` is excluded by .gitignore, so it cannot be committed (docs/private/ is ignored by design).`,
@@ -22136,7 +23098,7 @@ function resolveRestoreTarget(projectRoot, docPath) {
22136
23098
  return { abs };
22137
23099
  }
22138
23100
  async function handleDocRead(adapter2, config2, args) {
22139
- if (!adapter2.getDoc || typeof adapter2.getDocBody !== "function") {
23101
+ if (!adapter2.getDoc || !adapterSupports(adapter2, "getDocBody")) {
22140
23102
  return errorResponse(
22141
23103
  "Doc bodies are not available on this adapter \u2014 requires the pg/proxy adapter. Nothing is blocked; the file on disk (if any) is still the copy you have."
22142
23104
  );
@@ -22516,7 +23478,7 @@ var buildDescribeTool = {
22516
23478
  };
22517
23479
  var buildExecuteTool = {
22518
23480
  name: "build_execute",
22519
- description: "Start or complete a build task. Call with just task_id to start (returns BUILD HANDOFF, creates feature branch, marks In Progress). After implementing the task, you MUST call build_execute again with all report fields (completed, effort, estimated_effort, surprises, discovered_issues, architecture_notes) to finish \u2014 do not wait for user confirmation between start and complete. Never call on tasks that are already In Review or Done. Does not call the Anthropic API. Set light=true to skip branch/PR creation (commits to current branch). Set PAPI_LIGHT_MODE=true in env to default all builds to light mode.",
23481
+ description: "Build one task with its spec, its branch and its record kept for you. Starting a build hands back that task's BUILD HANDOFF (scope, what is out of scope, acceptance criteria, the decisions that constrain it, and what recent builds in the same area already learned or ruled out), puts the work on the right branch, and marks the task In Progress so a second session can see it is taken. Completing it records what actually happened: effort against estimate, surprises, dead ends, and bugs found outside the scope. That record is what later cycles are sized and planned from, so nothing has to be remembered. Call with just task_id to start (returns BUILD HANDOFF, creates feature branch, marks In Progress). After implementing the task, you MUST call build_execute again with all report fields (completed, effort, estimated_effort, surprises, discovered_issues, architecture_notes) to finish \u2014 do not wait for user confirmation between start and complete. Never call on tasks that are already In Review or Done. Does not call the Anthropic API. Set light=true to skip branch/PR creation (commits to current branch). Set PAPI_LIGHT_MODE=true in env to default all builds to light mode.",
22520
23482
  annotations: { title: "Run Build", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
22521
23483
  inputSchema: {
22522
23484
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -22555,6 +23517,27 @@ var buildExecuteTool = {
22555
23517
  },
22556
23518
  required: ["answer"]
22557
23519
  },
23520
+ proposal: {
23521
+ type: "object",
23522
+ description: 'task-3273, OPTIONAL: propose a Decision or a Convention you settled while building this. PAPI never mints one for you \u2014 a proposal that passes all four admission tests is QUEUED for the owner behind a decision gate, and nothing is written as an Active Decision. YOU apply the four tests (they are stated in the planning prompts); the server routes on your answers and makes no judgement about the content. Fails "arguable today" only and it is recorded as a Convention instead, riding every future build. Fails "constrains future work" and it is returned to you with the reason, unrecorded.',
23523
+ properties: {
23524
+ title: { type: "string", description: "One line stating the stance or rule." },
23525
+ body: { type: "string", description: "What was decided or settled, and why." },
23526
+ module: { type: "string", description: "Optional module scope. Only used when this lands as a Convention." },
23527
+ tests: {
23528
+ type: "object",
23529
+ description: "The four admission tests, each answered explicitly. An omitted test is NOT a failed test \u2014 the proposal is refused rather than routed.",
23530
+ properties: {
23531
+ alternativesWereReal: { type: "boolean", description: "(a) Something else could genuinely have been chosen." },
23532
+ constrainsFutureWork: { type: "boolean", description: "(b) It changes what a task nobody has written yet will do." },
23533
+ arguableToday: { type: "boolean", description: "(c) A competent person could argue the other side right now." },
23534
+ reversalCostsMore: { type: "boolean", description: "(d) Reversing it costs more than making it did." }
23535
+ },
23536
+ required: ["alternativesWereReal", "constrainsFutureWork", "arguableToday", "reversalCostsMore"]
23537
+ }
23538
+ },
23539
+ required: ["title", "body", "tests"]
23540
+ },
22558
23541
  completed: {
22559
23542
  type: "string",
22560
23543
  enum: ["yes", "no", "partial"],
@@ -23049,7 +24032,12 @@ These approaches were tried in this module and failed. If one looks right, read
23049
24032
  }
23050
24033
  } catch {
23051
24034
  }
23052
- const moduleInstructions = getModuleInstructions(result.task.module);
24035
+ let conventions = [];
24036
+ try {
24037
+ if (adapter2.listConventions) conventions = await adapter2.listConventions();
24038
+ } catch {
24039
+ }
24040
+ const moduleInstructions = getBuilderInstructions(result.task.module, conventions);
23053
24041
  const moduleContext = await getModuleContext(adapter2, result.task);
23054
24042
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
23055
24043
  const modelNote = buildModelRecommendationDirective(
@@ -23269,7 +24257,7 @@ ${checklist}
23269
24257
  });
23270
24258
  }
23271
24259
  let fixedResolvedCount = 0;
23272
- if (fixedIssues && fixedIssues.length > 0 && typeof adapter2.markCycleLearningResolved === "function") {
24260
+ if (fixedIssues && fixedIssues.length > 0 && adapterSupports(adapter2, "markCycleLearningResolved")) {
23273
24261
  const resolvedBy = `build:${result.task.displayId ?? taskId}`;
23274
24262
  for (const learningId of fixedIssues) {
23275
24263
  try {
@@ -23329,7 +24317,27 @@ If any are now fixed, re-run complete with their UUIDs in \`fixed_issues\` \u201
23329
24317
  fixedNote = "\n\n\u2139\uFE0F This build filed discovered issues but passed no `fixed_issues`. When a future build fixes one, pass its UUID in `fixed_issues` so it counts as FIXED (not just auto-cleared) on the hub ledger.";
23330
24318
  }
23331
24319
  }
23332
- return textResponse(formatCompleteResult(result) + fixedNote + docsNote + batchRollupNote);
24320
+ let proposalNote = "";
24321
+ if (args.proposal !== void 0) {
24322
+ const validated = validateProposal(args.proposal);
24323
+ if ("error" in validated) {
24324
+ proposalNote = `
24325
+
24326
+ ---
24327
+
24328
+ **Proposal not recorded.** ${validated.error}
24329
+
24330
+ The build itself is unaffected \u2014 only the proposal failed.`;
24331
+ } else {
24332
+ const routing = routeProposal(validated.proposal);
24333
+ const applied = await applyProposal(adapter2, routing, validated.proposal, {
24334
+ cycleNumber: result.cycleNumber,
24335
+ sourceTaskId: result.task?.id
24336
+ });
24337
+ proposalNote = formatProposalOutcome(routing, validated.proposal, applied);
24338
+ }
24339
+ }
24340
+ return textResponse(formatCompleteResult(result) + fixedNote + docsNote + batchRollupNote + proposalNote);
23333
24341
  } catch (err) {
23334
24342
  const message = err instanceof Error ? err.message : String(err);
23335
24343
  if (isBuildPushError(err)) {
@@ -24519,6 +25527,27 @@ var adHocTool = {
24519
25527
  inputSchema: {
24520
25528
  type: "object",
24521
25529
  properties: {
25530
+ proposal: {
25531
+ type: "object",
25532
+ description: "task-3273, OPTIONAL: propose a Decision or a Convention you settled while doing this work. PAPI never mints one for you \u2014 a proposal passing all four admission tests is QUEUED for the owner behind a decision gate, and nothing is written as an Active Decision. YOU apply the four tests; the server routes on your answers and makes no judgement about the content.",
25533
+ properties: {
25534
+ title: { type: "string", description: "One line stating the stance or rule." },
25535
+ body: { type: "string", description: "What was decided or settled, and why." },
25536
+ module: { type: "string", description: "Optional module scope. Only used when this lands as a Convention." },
25537
+ tests: {
25538
+ type: "object",
25539
+ description: "The four admission tests, each answered explicitly. An omitted test is NOT a failed test.",
25540
+ properties: {
25541
+ alternativesWereReal: { type: "boolean" },
25542
+ constrainsFutureWork: { type: "boolean" },
25543
+ arguableToday: { type: "boolean" },
25544
+ reversalCostsMore: { type: "boolean" }
25545
+ },
25546
+ required: ["alternativesWereReal", "constrainsFutureWork", "arguableToday", "reversalCostsMore"]
25547
+ }
25548
+ },
25549
+ required: ["title", "body", "tests"]
25550
+ },
24522
25551
  title: {
24523
25552
  type: "string",
24524
25553
  description: "What was done \u2014 becomes the task title (required when creating new task, optional when completing existing via task_id)."
@@ -24578,6 +25607,22 @@ var adHocTool = {
24578
25607
  required: []
24579
25608
  }
24580
25609
  };
25610
+ async function recordAdHocProposal(adapter2, args, ctx) {
25611
+ if (args.proposal === void 0) return "";
25612
+ const validated = validateProposal(args.proposal);
25613
+ if ("error" in validated) {
25614
+ return `
25615
+
25616
+ ---
25617
+
25618
+ **Proposal not recorded.** ${validated.error}
25619
+
25620
+ The ad-hoc record itself is unaffected \u2014 only the proposal failed.`;
25621
+ }
25622
+ const routing = routeProposal(validated.proposal);
25623
+ const applied = await applyProposal(adapter2, routing, validated.proposal, ctx);
25624
+ return formatProposalOutcome(routing, validated.proposal, applied);
25625
+ }
24581
25626
  async function handleAdHoc(adapter2, config2, args) {
24582
25627
  const taskId = args.task_id?.trim();
24583
25628
  const title = args.title?.trim();
@@ -24693,12 +25738,12 @@ The task is recorded **In Review** and pinned to **Cycle ${result.task.cycle}**,
24693
25738
  \`\`\`
24694
25739
  2. Leave the branch **unmerged** \u2014 it is picked up by the next cycle's \`release\`.
24695
25740
 
24696
- _To correct: board_edit ${result.task.id} with updated fields._`
25741
+ _To correct: board_edit ${result.task.id} with updated fields._` + await recordAdHocProposal(adapter2, args, { cycleNumber: result.task.cycle ?? 0, sourceTaskId: result.task.id })
24697
25742
  );
24698
25743
  }
24699
25744
  return textResponse(
24700
25745
  `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
24701
- _To correct: board_edit ${result.task.id} with updated fields._`
25746
+ _To correct: board_edit ${result.task.id} with updated fields._` + await recordAdHocProposal(adapter2, args, { cycleNumber: result.task.cycle ?? 0, sourceTaskId: result.task.id })
24702
25747
  );
24703
25748
  }
24704
25749
 
@@ -25421,7 +26466,7 @@ Re-run build_execute complete with a production_verification field, then re-subm
25421
26466
  }
25422
26467
  const newStatus = resolveStatus(input.stage, input.verdict);
25423
26468
  const statusChange = newStatus ? { from: task.status, to: newStatus } : void 0;
25424
- if (typeof adapter2.commitReviewSubmit === "function") {
26469
+ if (adapterSupports(adapter2, "commitReviewSubmit")) {
25425
26470
  await adapter2.commitReviewSubmit({
25426
26471
  review,
25427
26472
  taskId: input.taskId,
@@ -25452,7 +26497,7 @@ Re-run build_execute complete with a production_verification field, then re-subm
25452
26497
  }
25453
26498
  }
25454
26499
  const closedDocActions = [];
25455
- if (newStatus === "Done" && typeof adapter2.findPendingDocActionsForTask === "function" && typeof adapter2.updateDocAction === "function") {
26500
+ if (newStatus === "Done" && adapterSupports(adapter2, "findPendingDocActionsForTask") && adapterSupports(adapter2, "updateDocAction")) {
25456
26501
  try {
25457
26502
  const pending = await adapter2.findPendingDocActionsForTask(input.taskId);
25458
26503
  for (const match of pending) {
@@ -25463,7 +26508,7 @@ Re-run build_execute complete with a production_verification field, then re-subm
25463
26508
  console.error(`[doc-closure] failed to resolve doc action ${match.docId}#${match.actionIndex}: ${err instanceof Error ? err.message : String(err)}`);
25464
26509
  }
25465
26510
  }
25466
- if (closedDocActions.length > 0 && typeof adapter2.appendCycleLearnings === "function") {
26511
+ if (closedDocActions.length > 0 && adapterSupports(adapter2, "appendCycleLearnings")) {
25467
26512
  try {
25468
26513
  await adapter2.appendCycleLearnings([{
25469
26514
  taskId: input.taskId,
@@ -25533,11 +26578,13 @@ async function assembleReviewContext(adapter2, config2, taskId) {
25533
26578
  ${JSON.stringify(task.buildHandoff, null, 2)}` : "### BUILD HANDOFF\n(none recorded)";
25534
26579
  const report = task.buildReport ? `### Build Report
25535
26580
  ${task.buildReport}` : "### Build Report\n(none recorded)";
25536
- const diff = getBranchDiff(config2.projectRoot);
25537
- const diffBlock = diff ? `### Branch diff vs base
26581
+ const { diff, scope, detail } = getTaskDiff(config2.projectRoot, taskId);
26582
+ const diffHeading = scope === "task-commits" ? `### Diff for ${taskId} (${detail})` : `### \u26A0\uFE0F Diff NOT scoped to ${taskId} (${detail})`;
26583
+ const diffBlock = diff ? `${diffHeading}
25538
26584
  \`\`\`diff
25539
26585
  ${diff}
25540
- \`\`\`` : "### Branch diff vs base\n(no diff resolved \u2014 not a git repo, no base ref, or no committed changes)";
26586
+ \`\`\`` : `### Diff for ${taskId}
26587
+ (no diff resolved \u2014 not a git repo, no base ref, or no committed changes)`;
25541
26588
  let projectContext = "";
25542
26589
  const ctxPath = join15(config2.projectRoot, ".agents", "papi-context.md");
25543
26590
  if (existsSync10(ctxPath)) {
@@ -25722,6 +26769,19 @@ function mergeAfterAccept(config2, taskId) {
25722
26769
  };
25723
26770
  }
25724
26771
  const details = [];
26772
+ if (isBranchMergedInto(config2.projectRoot, featureBranch, baseBranch)) {
26773
+ details.push(`Branch '${featureBranch}' is already merged into '${baseBranch}'.`);
26774
+ return { merged: true, skipped: false, message: `Already merged: \`${featureBranch}\` \u2192 \`${baseBranch}\`.`, details };
26775
+ }
26776
+ if (isBranchContentAlreadyInBase(config2.projectRoot, featureBranch, baseBranch)) {
26777
+ details.push(`Branch '${featureBranch}' adds nothing to '${baseBranch}' \u2014 its commits already landed there.`);
26778
+ return {
26779
+ merged: true,
26780
+ skipped: false,
26781
+ message: `Already on \`${baseBranch}\` \u2014 no PR merge needed (these commits were folded into another branch and landed with it).`,
26782
+ details
26783
+ };
26784
+ }
25725
26785
  const papiDir = join15(config2.projectRoot, ".papi");
25726
26786
  if (existsSync10(papiDir)) {
25727
26787
  try {
@@ -26066,7 +27126,7 @@ ${overlap}`;
26066
27126
  \u2705 Verdict recorded. All cycle tasks are Done, but **auto-release is owner-only** \u2014 your identity does not match this project's owner, so no release was cut. Push your branch and open a PR for the owner to run \`release\`.${resolutionNote}`;
26067
27127
  } else if (cycleTasks.length > 0 && cycleTasks.every((t) => t.status === "Done")) {
26068
27128
  let planRunCount = null;
26069
- if (typeof adapter2.countPlanRunsForCycle === "function") {
27129
+ if (adapterSupports(adapter2, "countPlanRunsForCycle")) {
26070
27130
  try {
26071
27131
  planRunCount = await adapter2.countPlanRunsForCycle(result.currentCycle);
26072
27132
  } catch {
@@ -26269,7 +27329,7 @@ async function resolveReviewerIdentity(adapter2, config2) {
26269
27329
  const note = gate.resolutionError ? ` (${gate.resolutionError})` : "";
26270
27330
  return { error: "Reviewing requires a resolvable user identity, but none was found" + note + ". Set PAPI_USER_ID to your account UUID (local) \u2014 hosted sessions derive it from the bearer token." };
26271
27331
  }
26272
- if (!gate.callerIsOwner && typeof adapter2.listContributors === "function") {
27332
+ if (!gate.callerIsOwner && adapterSupports(adapter2, "listContributors")) {
26273
27333
  try {
26274
27334
  const members = await adapter2.listContributors();
26275
27335
  if (!members.some((m) => m.userId === callerUserId)) {
@@ -26284,7 +27344,7 @@ async function resolveReviewerIdentity(adapter2, config2) {
26284
27344
  async function handleReviewClaim(adapter2, config2, args) {
26285
27345
  const taskId = typeof args.task_id === "string" ? args.task_id.trim() : "";
26286
27346
  if (!taskId) return errorResponse('A task_id is required. Example: review_claim task_id="task-2072"');
26287
- if (typeof adapter2.claimReview !== "function") {
27347
+ if (!adapterSupports(adapter2, "claimReview")) {
26288
27348
  return errorResponse("The shared review queue is not available on this adapter.");
26289
27349
  }
26290
27350
  const identity = await resolveReviewerIdentity(adapter2, config2);
@@ -27233,50 +28293,6 @@ function formatUnblockSection(candidates) {
27233
28293
  return lines.join("\n");
27234
28294
  }
27235
28295
 
27236
- // src/lib/carry-forward-shape.ts
27237
- var PRODUCT_MARKER = /WHAT SHIPS FOR USERS\b[^\n:]*:/i;
27238
- var MECHANICS_MARKER = /RELEASE MECHANICS\b[^\n:]*:/i;
27239
- function splitCarryForward(raw) {
27240
- const productMatch = PRODUCT_MARKER.exec(raw);
27241
- const mechanicsMatch = MECHANICS_MARKER.exec(raw);
27242
- if (!productMatch && !mechanicsMatch) {
27243
- return { product: raw.trim(), mechanics: "", split: false };
27244
- }
27245
- if (!productMatch && mechanicsMatch) {
27246
- const head = raw.slice(0, mechanicsMatch.index).trim();
27247
- const tail = raw.slice(mechanicsMatch.index + mechanicsMatch[0].length).trim();
27248
- return { product: head, mechanics: tail, split: true };
27249
- }
27250
- const productStart = productMatch.index + productMatch[0].length;
27251
- if (!mechanicsMatch) {
27252
- return { product: raw.slice(productStart).trim(), mechanics: "", split: true };
27253
- }
27254
- if (mechanicsMatch.index < productMatch.index) {
27255
- return {
27256
- product: raw.slice(productStart).trim(),
27257
- mechanics: raw.slice(mechanicsMatch.index + mechanicsMatch[0].length, productMatch.index).trim(),
27258
- split: true
27259
- };
27260
- }
27261
- return {
27262
- product: raw.slice(productStart, mechanicsMatch.index).trim(),
27263
- mechanics: raw.slice(mechanicsMatch.index + mechanicsMatch[0].length).trim(),
27264
- split: true
27265
- };
27266
- }
27267
- function renderCarryForward(raw, decorate = (t) => t) {
27268
- const { product, mechanics, split } = splitCarryForward(raw);
27269
- if (!split) return [decorate(product)];
27270
- const lines = [];
27271
- if (product) lines.push(decorate(product));
27272
- if (mechanics) {
27273
- if (product) lines.push("");
27274
- lines.push("**Release mechanics** \u2014 needed at release, not now:");
27275
- lines.push(decorate(mechanics));
27276
- }
27277
- return lines;
27278
- }
27279
-
27280
28296
  // src/lib/deferred-gate.ts
27281
28297
  var GATE_PHRASES = [
27282
28298
  "depends on",
@@ -27597,12 +28613,39 @@ var evaluateSelfServeActivation = async (adapter2, projectId) => {
27597
28613
  unevaluatedReason: `mechanically satisfied (${users} non-owner accounts planned in their own project) \u2014 but "ZERO owner intervention" is a human judgement, so this needs an owner tick via set_criterion_met`
27598
28614
  } : { met: false, evidence: `0 non-owner accounts with a planned cycle. Counted ${(/* @__PURE__ */ new Date()).toISOString()}.` };
27599
28615
  };
28616
+ var evaluateWeeklyActiveBuilders = async (adapter2, projectId) => {
28617
+ const sql = sqlOf(adapter2);
28618
+ if (!sql) return { met: null, unevaluatedReason: "no direct SQL access on this adapter (hosted path)" };
28619
+ const owner = await sql`
28620
+ SELECT user_id FROM projects WHERE id = ${projectId}
28621
+ `;
28622
+ const ownerUserId = owner[0]?.user_id ?? null;
28623
+ const rows = await sql`
28624
+ SELECT c.user_id, c.end_date, c.updated_at
28625
+ FROM cycles c
28626
+ JOIN projects p ON p.id = c.project_id
28627
+ JOIN auth.users u ON u.id = p.user_id
28628
+ WHERE c.status = 'complete'
28629
+ AND c.user_id IS NOT NULL
28630
+ AND u.email NOT LIKE ${TEST_ACCOUNT_PATTERN}
28631
+ `;
28632
+ const series = bucketWabSeries(rows, ownerUserId, Date.now(), WAB_DEFAULT_WEEKS);
28633
+ const peak = series.reduce((max, w) => Math.max(max, w.external), 0);
28634
+ let consecutive = 0;
28635
+ let bestRun = 0;
28636
+ for (const week of series) {
28637
+ consecutive = week.external >= 3 ? consecutive + 1 : 0;
28638
+ bestRun = Math.max(bestRun, consecutive);
28639
+ }
28640
+ const counted = `Peak ${peak} external builder(s) in any trailing week; longest run at >=3 is ${bestRun} consecutive week(s) over the last ${WAB_DEFAULT_WEEKS}. Owner and ${TEST_ACCOUNT_PATTERN} excluded. Counted ${(/* @__PURE__ */ new Date()).toISOString()}.`;
28641
+ return bestRun >= 2 ? { met: true, evidence: counted } : { met: false, evidence: counted };
28642
+ };
27600
28643
  var EVALUATORS = {
27601
28644
  "c4a11d42-b222-45af-a470-08970e1bd6a9": evaluateSelfServeActivation,
27602
- "59751bcb-f719-4cf6-a4d4-608770999a22": evaluateUnaidedFullLoop
28645
+ "59751bcb-f719-4cf6-a4d4-608770999a22": evaluateUnaidedFullLoop,
28646
+ "e9d984f4-1e75-4486-8605-5dfd07412915": evaluateWeeklyActiveBuilders
27603
28647
  };
27604
28648
  var UNEVALUATABLE = {
27605
- "e9d984f4-1e75-4486-8605-5dfd07412915": "no evaluator here \u2014 the canonical completion-keyed definition lives in lib/weekly-active-builders.ts (Next app) and must not be re-derived in the server",
27606
28649
  "c596a082-9ad3-4089-b497-46cd189e892b": "threshold is provisional pending an owner ruling \u2014 no settled bar to evaluate against"
27607
28650
  };
27608
28651
  async function evaluateExitCriteria(adapter2, projectId, criteria) {
@@ -27979,13 +29022,43 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
27979
29022
  }
27980
29023
  return lines.join("\n").trimEnd();
27981
29024
  }
29025
+ var NOT_SET_UP_MESSAGE = "Setup required \u2014 this project has no PAPI state yet. That is the normal starting point, not an error. Run `setup` to generate your Product Brief and scaffold the workflow, then `plan` to create your first cycle.";
29026
+ function emptyHealthSummary() {
29027
+ return {
29028
+ cycleNumber: 0,
29029
+ // 'degraded' is exactly what happened: the adapter answered some reads and
29030
+ // not others. 'offline' would overstate it and 'connected' would hide it.
29031
+ connectionStatus: "degraded",
29032
+ reviewWarning: "",
29033
+ zoomOutWarning: "",
29034
+ boardSummary: "",
29035
+ staleTasks: "",
29036
+ inReviewSummary: "",
29037
+ carryForward: "",
29038
+ recommendedMode: "",
29039
+ metricsSection: "",
29040
+ derivedMetricsSection: "",
29041
+ costSection: "",
29042
+ decisionUsageSection: "",
29043
+ decisionLifecycleSection: "",
29044
+ decisionScoresSection: "",
29045
+ contextUtilisationSection: "",
29046
+ northStarSection: "",
29047
+ healthScore: null,
29048
+ healthStatus: null,
29049
+ healthReason: null
29050
+ };
29051
+ }
27982
29052
  async function getHierarchyPosition(adapter2, projectId) {
27983
29053
  try {
27984
29054
  const [horizons, stages, phases, allTasks] = await Promise.all([
27985
29055
  adapter2.readHorizons?.() ?? [],
27986
29056
  adapter2.readStages?.() ?? [],
27987
29057
  adapter2.readPhases(),
27988
- adapter2.queryBoard()
29058
+ // Compact: the only fields read below are `phase` and `status`, for the
29059
+ // per-phase done/total tally. A non-compact pull dragged every handoff and
29060
+ // build report across the wire for two columns.
29061
+ adapter2.queryBoard({ compact: true })
27989
29062
  ]);
27990
29063
  if (horizons.length === 0) return void 0;
27991
29064
  const isInProgress = (s) => s.status === "In Progress";
@@ -28105,7 +29178,7 @@ function formatDiscoveredIssuesBlocks(candidateLearnings, closedTaskIds) {
28105
29178
  return { alertsNote, unactionedIssuesNote };
28106
29179
  }
28107
29180
  async function computeTeamSummary(adapter2, contributorsInput) {
28108
- if (typeof adapter2.listContributors !== "function") return void 0;
29181
+ if (!adapterSupports(adapter2, "listContributors")) return void 0;
28109
29182
  let members;
28110
29183
  try {
28111
29184
  members = (await (contributorsInput ?? adapter2.listContributors())).length;
@@ -28126,7 +29199,7 @@ async function computeTeamSummary(adapter2, contributorsInput) {
28126
29199
  return `**Team:** ${members} members \xB7 ${pool} in pool \xB7 ${inFlight} in flight \xB7 ${reviewQueue} in review`;
28127
29200
  }
28128
29201
  async function computeReleaseHistory(adapter2, contributorsInput) {
28129
- if (typeof adapter2.listContributors !== "function") return void 0;
29202
+ if (!adapterSupports(adapter2, "listContributors")) return void 0;
28130
29203
  let contributors;
28131
29204
  try {
28132
29205
  contributors = await (contributorsInput ?? adapter2.listContributors());
@@ -28166,7 +29239,29 @@ function formatResolvedFeedback(items) {
28166
29239
  lines.push("_Thanks for the signal \u2014 it shaped what shipped._");
28167
29240
  return lines.join("\n");
28168
29241
  }
28169
- async function handleOrient(adapter2, config2, args = {}, clientName) {
29242
+ function withBoardMemo(adapter2) {
29243
+ const inflight = /* @__PURE__ */ new Map();
29244
+ const queryBoard = (options) => {
29245
+ const key = JSON.stringify(options ?? null);
29246
+ const hit = inflight.get(key);
29247
+ if (hit) return hit;
29248
+ const pending = adapter2.queryBoard(options).catch((err) => {
29249
+ inflight.delete(key);
29250
+ throw err;
29251
+ });
29252
+ inflight.set(key, pending);
29253
+ return pending;
29254
+ };
29255
+ return new Proxy(adapter2, {
29256
+ get(target, prop, receiver) {
29257
+ if (prop === "queryBoard") return queryBoard;
29258
+ const value = Reflect.get(target, prop, receiver);
29259
+ return typeof value === "function" ? value.bind(target) : value;
29260
+ }
29261
+ });
29262
+ }
29263
+ async function handleOrient(rawAdapter, config2, args = {}, clientName) {
29264
+ const adapter2 = withBoardMemo(rawAdapter);
28170
29265
  const environment = normaliseEnvironment(args.environment);
28171
29266
  const deepHousekeeping = args.deep_housekeeping === true;
28172
29267
  const fullEnrichment = args.full === true || deepHousekeeping;
@@ -28178,11 +29273,18 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
28178
29273
  } catch {
28179
29274
  }
28180
29275
  tracker.mark("fetch-build-health-hierarchy");
28181
- const [buildResult, healthResult, hierarchy] = await Promise.all([
29276
+ const [buildSettled, healthSettled, hierarchySettled] = await Promise.allSettled([
28182
29277
  tracked("listBuilds", () => listBuilds(adapter2, config2))(),
28183
29278
  tracked("getHealthSummary", () => getHealthSummary(adapter2))(),
28184
29279
  tracked("getHierarchyPosition", () => getHierarchyPosition(adapter2, config2.projectId))()
28185
29280
  ]);
29281
+ if (buildSettled.status === "rejected" && healthSettled.status === "rejected") {
29282
+ throw new Error(NOT_SET_UP_MESSAGE);
29283
+ }
29284
+ if (buildSettled.status === "rejected") throw buildSettled.reason;
29285
+ const buildResult = buildSettled.value;
29286
+ const healthResult = healthSettled.status === "fulfilled" ? healthSettled.value : emptyHealthSummary();
29287
+ const hierarchy = hierarchySettled.status === "fulfilled" ? hierarchySettled.value : void 0;
28186
29288
  const currentCycle = buildResult.currentCycle;
28187
29289
  const cycleIsComplete = healthResult.latestCycleStatus === "complete";
28188
29290
  const allTasks = buildResult.sorted;
@@ -28655,7 +29757,7 @@ ${versionDrift}` : "";
28655
29757
  }
28656
29758
  }
28657
29759
  tracker.mark("parallel-tail");
28658
- const sharedContributorsPromise = typeof adapter2.listContributors === "function" ? adapter2.listContributors().catch(() => []) : Promise.resolve([]);
29760
+ const sharedContributorsPromise = adapterSupports(adapter2, "listContributors") ? adapter2.listContributors().catch(() => []) : Promise.resolve([]);
28659
29761
  const [unblockCandidates, subAgents, teamSummaryLine, releaseHistoryLine, carryForwardRefs] = await Promise.all([
28660
29762
  tracked("unblock-candidates", () => findUnblockCandidates(adapter2, currentCycle))().catch(() => []),
28661
29763
  // task-1866: discover project sub-agents for the orient surface (read-only, never throws).
@@ -30262,7 +31364,7 @@ var contributorListTool = {
30262
31364
  }
30263
31365
  };
30264
31366
  function denyUnlessCohortCapable(adapter2) {
30265
- if (typeof adapter2.listContributors !== "function") {
31367
+ if (!adapterSupports(adapter2, "listContributors")) {
30266
31368
  return "Contributor management is not available on this adapter (requires the pg or proxy adapter \u2014 the local md adapter has no cohort).";
30267
31369
  }
30268
31370
  return null;
@@ -30283,7 +31385,7 @@ async function denyUnlessMember(adapter2, config2) {
30283
31385
  const gate = await resolveOwnerGate(adapter2, config2);
30284
31386
  if (!gate.enforced) return null;
30285
31387
  if (gate.callerIsOwner) return null;
30286
- const callerRole = gate.callerUserId && typeof adapter2.getContributorRole === "function" ? await adapter2.getContributorRole(gate.callerUserId).catch(() => null) : null;
31388
+ const callerRole = gate.callerUserId && adapterSupports(adapter2, "getContributorRole") ? await adapter2.getContributorRole(gate.callerUserId).catch(() => null) : null;
30287
31389
  if (callerRole) return null;
30288
31390
  const note = gate.resolutionError ? ` (Identity resolution failed: ${gate.resolutionError} \u2014 the gate fails closed; retry once connectivity is restored.)` : "";
30289
31391
  return `Listing contributors is restricted to project members. Your identity does not match this project's owner or any contributor.${note}`;
@@ -30384,7 +31486,7 @@ function requireTaskId(args) {
30384
31486
  return id.length > 0 ? id : null;
30385
31487
  }
30386
31488
  async function resolveClaimerIdentity(adapter2, config2) {
30387
- if (typeof adapter2.claimTask !== "function") {
31489
+ if (!adapterSupports(adapter2, "claimTask")) {
30388
31490
  return { error: "Claiming is not available on this adapter." };
30389
31491
  }
30390
31492
  const gate = await resolveOwnerGate(adapter2, config2);
@@ -30395,7 +31497,7 @@ async function resolveClaimerIdentity(adapter2, config2) {
30395
31497
  error: "Claiming requires a resolvable user identity, but none was found" + note + ". Set PAPI_USER_ID to your account UUID (local) \u2014 hosted sessions derive it from your bearer token."
30396
31498
  };
30397
31499
  }
30398
- if (!gate.callerIsOwner && typeof adapter2.listContributors === "function") {
31500
+ if (!gate.callerIsOwner && adapterSupports(adapter2, "listContributors")) {
30399
31501
  try {
30400
31502
  const members = await adapter2.listContributors();
30401
31503
  if (!members.some((m) => m.userId === callerUserId)) {
@@ -30481,7 +31583,7 @@ ${claimedList}`
30481
31583
  async function handleTaskUnclaim(adapter2, config2, args) {
30482
31584
  const taskId = requireTaskId(args);
30483
31585
  if (!taskId) return errorResponse('A task_id is required. Example: task_unclaim task_id="task-2071"');
30484
- if (typeof adapter2.unclaimTask !== "function") {
31586
+ if (!adapterSupports(adapter2, "unclaimTask")) {
30485
31587
  return errorResponse("Unclaiming is not available on this adapter.");
30486
31588
  }
30487
31589
  const identity = await resolveClaimerIdentity(adapter2, config2);
@@ -30532,7 +31634,7 @@ async function handleTaskMove(adapter2, config2, args) {
30532
31634
  if (!taskId) return errorResponse('A task_id is required. Example: task_move task_id="task-42" target_project="other-project" confirm=true');
30533
31635
  const targetProject = requireStr(args.target_project);
30534
31636
  if (!targetProject) return errorResponse("A target_project (slug or UUID) is required \u2014 the project you want to move the task into.");
30535
- if (typeof adapter2.moveTask !== "function") {
31637
+ if (!adapterSupports(adapter2, "moveTask")) {
30536
31638
  return errorResponse("Cross-project move is not available on this adapter.");
30537
31639
  }
30538
31640
  const gate = await resolveOwnerGate(adapter2, config2);
@@ -31087,7 +32189,10 @@ var PAPI_TOOLS = [
31087
32189
  taskClaimTool,
31088
32190
  taskUnclaimTool,
31089
32191
  taskMoveTool,
31090
- inventorySyncTool
32192
+ inventorySyncTool,
32193
+ conventionDeclareTool,
32194
+ conventionListTool,
32195
+ conventionRemoveTool
31091
32196
  ];
31092
32197
  function getToolMetadata() {
31093
32198
  return PAPI_TOOLS.map((t) => ({ name: t.name, description: t.description }));
@@ -31292,6 +32397,12 @@ function createServer(adapter2, config2) {
31292
32397
  return handleTaskMove(adapter2, config2, safeArgs);
31293
32398
  case "task_unclaim":
31294
32399
  return handleTaskUnclaim(adapter2, config2, safeArgs);
32400
+ case "convention_declare":
32401
+ return handleConventionDeclare(adapter2, config2, safeArgs);
32402
+ case "convention_list":
32403
+ return handleConventionList(adapter2, config2, safeArgs);
32404
+ case "convention_remove":
32405
+ return handleConventionRemove(adapter2, config2, safeArgs);
31295
32406
  case "inventory_sync":
31296
32407
  return handleInventorySync(adapter2, config2, safeArgs, getToolMetadata());
31297
32408
  default: