@papi-ai/server 0.7.75 → 0.7.76

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
@@ -1357,6 +1357,9 @@ var init_proxy_adapter = __esm({
1357
1357
  // getToolCallCount + updatePhaseStatus are ABSENT: they now have edge handlers and
1358
1358
  // are served through the forwarder (getToolCallCount = SUP-2026-026 handler #1).
1359
1359
  "getContributorRole",
1360
+ // task-3018 (C356): storeDocBody + getDocBodyUsage are now WIRED — edge case
1361
+ // handlers + ALLOWED_METHODS/WRITE_METHODS entries exist, so they forward and
1362
+ // hosted callers get body storage. Removed from this list, as task-3017 required.
1360
1363
  // task-2728 (C331): createOwnerAction REMOVED from NO_FORWARD — the PRODUCER half
1361
1364
  // of the owner-action queue. Six readers were wired C329 (task-2412) but the
1362
1365
  // producer stayed here, so the hosted Owner Action Queue was structurally empty
@@ -1384,7 +1387,7 @@ var init_proxy_adapter = __esm({
1384
1387
  // now works for hosted users without exposing one member's owner actions to another.
1385
1388
  // task-2393 (C329) — Batch B wired: markCycleLearningResolved (the P1 — hosted
1386
1389
  // discovered_issue_resolve hard-errored), correctLatestBuildReportEffort,
1387
- // updateStageExitCriteria, updateDocAction, resolveLearningsForDoneTasks all have
1390
+ // updateStageExitCriteria, updateDocAction all have
1388
1391
  // edge case handlers + ALLOWED_METHODS/WRITE_METHODS entries now, so they forward.
1389
1392
  // task-2489 (C320): recordProgressStep is now wired to the edge data-proxy
1390
1393
  // (case handler + ALLOWED_METHODS/WRITE_METHODS entries), so it forwards for
@@ -1968,9 +1971,19 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
1968
1971
  return this.invoke("updateDocStatus", [id, status, supersededBy]);
1969
1972
  }
1970
1973
  // --- Cycle Learnings ---
1971
- appendCycleLearnings(learnings) {
1972
- return this.invoke("appendCycleLearnings", [learnings]);
1974
+ /**
1975
+ * task-2999 / task-2998 (C356): the edge handler now calls append_cycle_learnings
1976
+ * and returns { id, findingKey, inserted } per row, so hosted callers get the same
1977
+ * per-row signal the pg path does. The [] normalisation stays as a floor for an
1978
+ * edge deployed BEFORE that change — callers must read [] as "no signal
1979
+ * available", never as "nothing was new".
1980
+ */
1981
+ async appendCycleLearnings(learnings) {
1982
+ const result = await this.invoke("appendCycleLearnings", [learnings]);
1983
+ return Array.isArray(result) ? result : [];
1973
1984
  }
1985
+ // task-2998: includeResolved was missing here, so the flag could not even be SENT
1986
+ // to the edge — the hosted caught->fixed ledger had no way to ask for history.
1974
1987
  getCycleLearnings(opts) {
1975
1988
  return this.invoke("getCycleLearnings", [opts]);
1976
1989
  }
@@ -2207,9 +2220,9 @@ var init_query = __esm({
2207
2220
  CLOSE = {};
2208
2221
  Query = class extends Promise {
2209
2222
  constructor(strings, args, handler, canceller, options = {}) {
2210
- let resolve3, reject;
2223
+ let resolve4, reject;
2211
2224
  super((a, b2) => {
2212
- resolve3 = a;
2225
+ resolve4 = a;
2213
2226
  reject = b2;
2214
2227
  });
2215
2228
  this.tagged = Array.isArray(strings.raw);
@@ -2220,7 +2233,7 @@ var init_query = __esm({
2220
2233
  this.options = options;
2221
2234
  this.state = null;
2222
2235
  this.statement = null;
2223
- this.resolve = (x) => (this.active = false, resolve3(x));
2236
+ this.resolve = (x) => (this.active = false, resolve4(x));
2224
2237
  this.reject = (x) => (this.active = false, reject(x));
2225
2238
  this.active = false;
2226
2239
  this.cancelled = null;
@@ -2268,12 +2281,12 @@ var init_query = __esm({
2268
2281
  if (this.executed && !this.active)
2269
2282
  return { done: true };
2270
2283
  prev && prev();
2271
- const promise = new Promise((resolve3, reject) => {
2284
+ const promise = new Promise((resolve4, reject) => {
2272
2285
  this.cursorFn = (value) => {
2273
- resolve3({ value, done: false });
2286
+ resolve4({ value, done: false });
2274
2287
  return new Promise((r) => prev = r);
2275
2288
  };
2276
- this.resolve = () => (this.active = false, resolve3({ done: true }));
2289
+ this.resolve = () => (this.active = false, resolve4({ done: true }));
2277
2290
  this.reject = (x) => (this.active = false, reject(x));
2278
2291
  });
2279
2292
  this.execute();
@@ -2871,12 +2884,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
2871
2884
  x.on("drain", drain);
2872
2885
  return x;
2873
2886
  }
2874
- async function cancel({ pid, secret }, resolve3, reject) {
2887
+ async function cancel({ pid, secret }, resolve4, reject) {
2875
2888
  try {
2876
2889
  cancelMessage = bytes_default().i32(16).i32(80877102).i32(pid).i32(secret).end(16);
2877
2890
  await connect();
2878
2891
  socket.once("error", reject);
2879
- socket.once("close", resolve3);
2892
+ socket.once("close", resolve4);
2880
2893
  } catch (error2) {
2881
2894
  reject(error2);
2882
2895
  }
@@ -3893,7 +3906,7 @@ var init_subscribe = __esm({
3893
3906
  // ../../node_modules/postgres/src/large.js
3894
3907
  import Stream2 from "stream";
3895
3908
  function largeObject(sql, oid, mode = 131072 | 262144) {
3896
- return new Promise(async (resolve3, reject) => {
3909
+ return new Promise(async (resolve4, reject) => {
3897
3910
  await sql.begin(async (sql2) => {
3898
3911
  let finish;
3899
3912
  !oid && ([{ oid }] = await sql2`select lo_creat(-1) as oid`);
@@ -3919,7 +3932,7 @@ function largeObject(sql, oid, mode = 131072 | 262144) {
3919
3932
  ) seek
3920
3933
  `
3921
3934
  };
3922
- resolve3(lo);
3935
+ resolve4(lo);
3923
3936
  return new Promise(async (r) => finish = r);
3924
3937
  async function readable({
3925
3938
  highWaterMark = 2048 * 8,
@@ -4084,8 +4097,8 @@ function Postgres(a, b2) {
4084
4097
  }
4085
4098
  async function reserve() {
4086
4099
  const queue = queue_default();
4087
- const c = open.length ? open.shift() : await new Promise((resolve3, reject) => {
4088
- const query = { reserve: resolve3, reject };
4100
+ const c = open.length ? open.shift() : await new Promise((resolve4, reject) => {
4101
+ const query = { reserve: resolve4, reject };
4089
4102
  queries.push(query);
4090
4103
  closed.length && connect(closed.shift(), query);
4091
4104
  });
@@ -4122,9 +4135,9 @@ function Postgres(a, b2) {
4122
4135
  let uncaughtError, result;
4123
4136
  name && await sql2`savepoint ${sql2(name)}`;
4124
4137
  try {
4125
- result = await new Promise((resolve3, reject) => {
4138
+ result = await new Promise((resolve4, reject) => {
4126
4139
  const x = fn2(sql2);
4127
- Promise.resolve(Array.isArray(x) ? Promise.all(x) : x).then(resolve3, reject);
4140
+ Promise.resolve(Array.isArray(x) ? Promise.all(x) : x).then(resolve4, reject);
4128
4141
  });
4129
4142
  if (uncaughtError)
4130
4143
  throw uncaughtError;
@@ -4181,8 +4194,8 @@ function Postgres(a, b2) {
4181
4194
  return c.execute(query) ? move(c, busy) : move(c, full);
4182
4195
  }
4183
4196
  function cancel(query) {
4184
- return new Promise((resolve3, reject) => {
4185
- query.state ? query.active ? connection_default(options).cancel(query.state, resolve3, reject) : query.cancelled = { resolve: resolve3, reject } : (queries.remove(query), query.cancelled = true, query.reject(Errors.generic("57014", "canceling statement due to user request")), resolve3());
4197
+ return new Promise((resolve4, reject) => {
4198
+ query.state ? query.active ? connection_default(options).cancel(query.state, resolve4, reject) : query.cancelled = { resolve: resolve4, reject } : (queries.remove(query), query.cancelled = true, query.reject(Errors.generic("57014", "canceling statement due to user request")), resolve4());
4186
4199
  });
4187
4200
  }
4188
4201
  async function end({ timeout = null } = {}) {
@@ -4201,11 +4214,11 @@ function Postgres(a, b2) {
4201
4214
  async function close() {
4202
4215
  await Promise.all(connections.map((c) => c.end()));
4203
4216
  }
4204
- async function destroy(resolve3) {
4217
+ async function destroy(resolve4) {
4205
4218
  await Promise.all(connections.map((c) => c.terminate()));
4206
4219
  while (queries.length)
4207
4220
  queries.shift().reject(Errors.connection("CONNECTION_DESTROYED", options));
4208
- resolve3();
4221
+ resolve4();
4209
4222
  }
4210
4223
  function connect(c, query) {
4211
4224
  move(c, connecting);
@@ -4389,7 +4402,7 @@ __export(doctor_exports, {
4389
4402
  __testing: () => __testing,
4390
4403
  runDoctor: () => runDoctor
4391
4404
  });
4392
- import { existsSync as existsSync12, readFileSync as readFileSync14 } from "fs";
4405
+ import { existsSync as existsSync12, readFileSync as readFileSync15 } from "fs";
4393
4406
  import { homedir as homedir4 } from "os";
4394
4407
  import { join as join21 } from "path";
4395
4408
  function redact(name, value) {
@@ -4409,7 +4422,7 @@ function findMcpJson() {
4409
4422
  for (const path7 of candidates) {
4410
4423
  if (!existsSync12(path7)) continue;
4411
4424
  try {
4412
- const raw = readFileSync14(path7, "utf-8");
4425
+ const raw = readFileSync15(path7, "utf-8");
4413
4426
  const parsed = JSON.parse(raw);
4414
4427
  const papiEntry = parsed.papi ?? parsed.mcpServers?.papi;
4415
4428
  if (!papiEntry) continue;
@@ -4687,7 +4700,7 @@ __export(reset_exports, {
4687
4700
  removePapiEntry: () => removePapiEntry,
4688
4701
  runReset: () => runReset
4689
4702
  });
4690
- import { existsSync as existsSync13, readFileSync as readFileSync15, writeFileSync as writeFileSync7 } from "fs";
4703
+ import { existsSync as existsSync13, readFileSync as readFileSync16, writeFileSync as writeFileSync8 } from "fs";
4691
4704
  import { homedir as homedir5 } from "os";
4692
4705
  import { join as join22 } from "path";
4693
4706
  import { createInterface } from "readline/promises";
@@ -4697,7 +4710,7 @@ function findResetTarget() {
4697
4710
  let raw;
4698
4711
  let parsed;
4699
4712
  try {
4700
- raw = readFileSync15(path7, "utf-8");
4713
+ raw = readFileSync16(path7, "utf-8");
4701
4714
  parsed = JSON.parse(raw);
4702
4715
  } catch {
4703
4716
  continue;
@@ -4768,7 +4781,7 @@ async function runReset(args = []) {
4768
4781
  }
4769
4782
  }
4770
4783
  try {
4771
- writeFileSync7(target.path, removePapiEntry(target), "utf-8");
4784
+ writeFileSync8(target.path, removePapiEntry(target), "utf-8");
4772
4785
  process.stdout.write(`
4773
4786
  \u2713 Removed papi entry from ${target.path}
4774
4787
  `);
@@ -4798,7 +4811,7 @@ __export(audit_exports, {
4798
4811
  __testing: () => __testing2,
4799
4812
  runAudit: () => runAudit
4800
4813
  });
4801
- import { existsSync as existsSync14, readFileSync as readFileSync16, readdirSync as readdirSync7 } from "fs";
4814
+ import { existsSync as existsSync14, readFileSync as readFileSync17, readdirSync as readdirSync7 } from "fs";
4802
4815
  import { homedir as homedir6 } from "os";
4803
4816
  import { join as join23 } from "path";
4804
4817
  function safeListDirs(dir) {
@@ -4819,7 +4832,7 @@ function readMcp(projectPath) {
4819
4832
  const path7 = join23(projectPath, ".mcp.json");
4820
4833
  if (!existsSync14(path7)) return { servers: [] };
4821
4834
  try {
4822
- const parsed = JSON.parse(readFileSync16(path7, "utf-8"));
4835
+ const parsed = JSON.parse(readFileSync17(path7, "utf-8"));
4823
4836
  const mcpServers = parsed.mcpServers ?? {};
4824
4837
  const servers = Object.keys(mcpServers);
4825
4838
  if (parsed.papi && !servers.includes("papi")) servers.push("papi");
@@ -4875,7 +4888,7 @@ function readGlobalSkills() {
4875
4888
  function readGlobalMcpServers() {
4876
4889
  if (!existsSync14(GLOBAL_CLAUDE_JSON)) return [];
4877
4890
  try {
4878
- const parsed = JSON.parse(readFileSync16(GLOBAL_CLAUDE_JSON, "utf-8"));
4891
+ const parsed = JSON.parse(readFileSync17(GLOBAL_CLAUDE_JSON, "utf-8"));
4879
4892
  const servers = parsed.mcpServers ?? {};
4880
4893
  return Object.keys(servers).sort((a, b2) => a.localeCompare(b2));
4881
4894
  } catch {
@@ -5041,7 +5054,7 @@ var setup_exports = {};
5041
5054
  __export(setup_exports, {
5042
5055
  runSetup: () => runSetup
5043
5056
  });
5044
- import { existsSync as existsSync15, readFileSync as readFileSync17, writeFileSync as writeFileSync8, chmodSync as chmodSync2, statSync as statSync9 } from "fs";
5057
+ import { existsSync as existsSync15, readFileSync as readFileSync18, writeFileSync as writeFileSync9, chmodSync as chmodSync2, statSync as statSync9 } from "fs";
5045
5058
  import { join as join24 } from "path";
5046
5059
  function baseUrl() {
5047
5060
  const fromEnv = process.env["PAPI_HOST"] ?? process.env["PAPI_BASE_URL"];
@@ -5071,14 +5084,14 @@ async function postJson(url, body) {
5071
5084
  return { status: res.status, data };
5072
5085
  }
5073
5086
  function sleep(ms) {
5074
- return new Promise((resolve3) => setTimeout(resolve3, ms));
5087
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
5075
5088
  }
5076
5089
  function writeMcpJson(opts) {
5077
5090
  const path7 = join24(process.cwd(), ".mcp.json");
5078
5091
  let parsed = {};
5079
5092
  if (existsSync15(path7)) {
5080
5093
  try {
5081
- parsed = JSON.parse(readFileSync17(path7, "utf-8"));
5094
+ parsed = JSON.parse(readFileSync18(path7, "utf-8"));
5082
5095
  } catch {
5083
5096
  throw new Error(`.mcp.json at ${path7} is not valid JSON. Fix it or remove it before re-running setup.`);
5084
5097
  }
@@ -5099,7 +5112,7 @@ function writeMcpJson(opts) {
5099
5112
  }
5100
5113
  mcpServers.papi = papiEntry;
5101
5114
  parsed.mcpServers = mcpServers;
5102
- writeFileSync8(path7, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
5115
+ writeFileSync9(path7, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
5103
5116
  try {
5104
5117
  const mode = statSync9(path7).mode & 511;
5105
5118
  if (mode !== 384) chmodSync2(path7, 384);
@@ -5209,8 +5222,8 @@ var init_setup = __esm({
5209
5222
  });
5210
5223
 
5211
5224
  // src/index.ts
5212
- import { readFileSync as readFileSync18 } from "fs";
5213
- import { dirname as dirname6, join as join25, basename as basename2 } from "path";
5225
+ import { readFileSync as readFileSync19 } from "fs";
5226
+ import { dirname as dirname7, join as join25, basename as basename2 } from "path";
5214
5227
  import { fileURLToPath as fileURLToPath5 } from "url";
5215
5228
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5216
5229
  import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
@@ -8254,9 +8267,9 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
8254
8267
  }
8255
8268
 
8256
8269
  // src/server.ts
8257
- import { readFileSync as readFileSync13 } from "fs";
8270
+ import { readFileSync as readFileSync14 } from "fs";
8258
8271
  import { access as access4, readdir as readdir4, readFile as readFile9 } from "fs/promises";
8259
- import { join as join20, dirname as dirname5 } from "path";
8272
+ import { join as join20, dirname as dirname6 } from "path";
8260
8273
  import { fileURLToPath as fileURLToPath4 } from "url";
8261
8274
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8262
8275
  import {
@@ -15281,6 +15294,23 @@ async function assembleContext2(adapter2, cycleNumber, cyclesSinceLastReview, pr
15281
15294
  const cappedBrief = capProductBrief2(productBrief);
15282
15295
  const smartBoard = formatBoardForReviewSmart(tasks, lastReviewCycleNum);
15283
15296
  const buildReportsText = formatRecentReportsSummary(reports, 10);
15297
+ let surpriseDigestText = "";
15298
+ try {
15299
+ if (adapter2.getCycleLearnings) {
15300
+ const surpriseRows = await adapter2.getCycleLearnings({ category: "surprise", limit: 40 });
15301
+ surpriseDigestText = formatSurpriseDigest(surpriseRows.map((s) => ({
15302
+ summary: s.summary,
15303
+ module: s.module,
15304
+ occurrences: s.occurrences,
15305
+ lastSeenCycle: s.lastSeenCycle,
15306
+ cycleNumber: s.cycleNumber
15307
+ })));
15308
+ }
15309
+ } catch {
15310
+ }
15311
+ const buildReportsWithDigest = surpriseDigestText ? `${buildReportsText}
15312
+
15313
+ ${surpriseDigestText}` : buildReportsText;
15284
15314
  logDataSourceSummary("strategy_review", [
15285
15315
  { label: "productBrief", hasData: warnIfEmpty("readProductBrief", productBrief) },
15286
15316
  { label: "activeDecisions", hasData: warnIfEmpty("getActiveDecisions", decisions) },
@@ -15512,7 +15542,7 @@ ${lines.join("\n")}`;
15512
15542
  lastReviewCycle: lastReviewCycleNum,
15513
15543
  productBrief: cappedBrief,
15514
15544
  activeDecisions: formatActiveDecisionsForReview(decisions),
15515
- allBuildReports: buildReportsText,
15545
+ allBuildReports: buildReportsWithDigest,
15516
15546
  sessionLog: formatCycleLog(recentLog),
15517
15547
  board: smartBoard,
15518
15548
  earnedPushback,
@@ -16200,9 +16230,31 @@ function formatRecentReportsSummary(reports, count) {
16200
16230
  if (issues) lines.push(` _Issues:_ ${issues}`);
16201
16231
  const arch = trunc(r.architectureNotes, 200);
16202
16232
  if (arch) lines.push(` _Architecture:_ ${arch}`);
16233
+ const dead = trunc(r.deadEnds, 200);
16234
+ if (dead) lines.push(` _Dead ends:_ ${dead}`);
16203
16235
  return lines.join("\n");
16204
16236
  }).join("\n");
16205
16237
  }
16238
+ function formatSurpriseDigest(surprises) {
16239
+ if (surprises.length === 0) return "";
16240
+ const top = [...surprises].sort((a, b2) => (b2.occurrences ?? 1) - (a.occurrences ?? 1) || (b2.lastSeenCycle ?? b2.cycleNumber) - (a.lastSeenCycle ?? a.cycleNumber)).slice(0, 8);
16241
+ const byModule = /* @__PURE__ */ new Map();
16242
+ for (const s of top) {
16243
+ const key = s.module?.trim() || "Unattributed";
16244
+ if (!byModule.has(key)) byModule.set(key, []);
16245
+ byModule.get(key).push(s);
16246
+ }
16247
+ const lines = ["**Assumption failures across cycles (surprises):**"];
16248
+ for (const [module, items] of byModule) {
16249
+ lines.push(`- **${module}**`);
16250
+ for (const s of items) {
16251
+ const summary = s.summary.length > 140 ? `${s.summary.slice(0, 140)}...` : s.summary;
16252
+ const seen = (s.occurrences ?? 1) > 1 ? ` (seen ${s.occurrences}\xD7)` : "";
16253
+ lines.push(` - ${summary} \u2014 C${s.lastSeenCycle ?? s.cycleNumber}${seen}`);
16254
+ }
16255
+ }
16256
+ return lines.join("\n");
16257
+ }
16206
16258
  function formatPhasesForReview(phases, currentCycle) {
16207
16259
  if (phases.length === 0) return void 0;
16208
16260
  const lines = [];
@@ -19752,6 +19804,9 @@ import {
19752
19804
  isCapabilityEnabled
19753
19805
  } from "@papi-ai/shared";
19754
19806
 
19807
+ // src/tools/build.ts
19808
+ import { validateFindings, MIN_REASON_LENGTH } from "@papi-ai/shared";
19809
+
19755
19810
  // src/lib/directive-builders.ts
19756
19811
  function buildPrReviewerDirective(caps) {
19757
19812
  if (!isCapabilityEnabled(caps, "prReviewer")) return null;
@@ -19832,6 +19887,7 @@ function buildPapiMetaFramingDirective(caps, inner) {
19832
19887
  import { randomUUID as randomUUID11 } from "crypto";
19833
19888
  import { readdirSync as readdirSync5, existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, mkdirSync as mkdirSync3 } from "fs";
19834
19889
  import { join as join12 } from "path";
19890
+ import { splitFindings, findingKey } from "@papi-ai/shared";
19835
19891
 
19836
19892
  // src/lib/db-only-notices.ts
19837
19893
  var DB_ONLY_START_NOTICE = "No git repo detected \u2014 running this cycle in your project database only. Your work stays in the working tree; run `git init` (and add a remote) to enable branches, commits and PR review.";
@@ -22390,6 +22446,8 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22390
22446
  } catch {
22391
22447
  }
22392
22448
  }
22449
+ let appendedRows = [];
22450
+ const findingRows = [];
22393
22451
  if (adapter2.appendCycleLearnings) {
22394
22452
  const learnings = [];
22395
22453
  const taskModule = task.module ?? "";
@@ -22417,10 +22475,52 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22417
22475
  relatedDecision: adIds[0]
22418
22476
  });
22419
22477
  };
22420
- extractLearning(input.surprises, "surprise");
22421
- extractLearning(input.discoveredIssues, "issue");
22478
+ const structured = input.findings ?? [];
22479
+ const pushFinding = (f) => {
22480
+ const kind = f.kind ?? "issue";
22481
+ const summary = (f.summary ?? "").trim();
22482
+ if (!summary) return;
22483
+ const severity = ["P0", "P1", "P2", "P3"].includes(String(f.severity)) ? f.severity : void 0;
22484
+ const learning = {
22485
+ taskId: task.id,
22486
+ cycleNumber,
22487
+ category: kind,
22488
+ severity,
22489
+ summary,
22490
+ detail: f.detail,
22491
+ tags: taskModule ? [taskModule.toLowerCase()] : [],
22492
+ relatedDecision: adIds[0],
22493
+ module: f.module ?? taskModule ?? void 0,
22494
+ disposition: f.disposition,
22495
+ dispositionReason: f.reason
22496
+ };
22497
+ findingRows.push({ finding: f, learning });
22498
+ learnings.push(learning);
22499
+ };
22500
+ if (structured.length > 0) {
22501
+ for (const f of structured) pushFinding(f);
22502
+ } else {
22503
+ const blobs = [
22504
+ [input.discoveredIssues, "issue"],
22505
+ [input.surprises, "surprise"],
22506
+ [input.deadEnds, "dead_end"]
22507
+ ];
22508
+ for (const [text, kind] of blobs) {
22509
+ for (const parsed of splitFindings(text, kind)) {
22510
+ pushFinding({
22511
+ kind,
22512
+ // splitFindings yields 'note' for unprefixed content; that is not a
22513
+ // severity the column accepts, so it becomes undefined rather than
22514
+ // being coerced into a P-level it never claimed.
22515
+ severity: parsed.severity === "note" ? void 0 : parsed.severity,
22516
+ summary: parsed.summary,
22517
+ detail: parsed.summary,
22518
+ module: taskModule || void 0
22519
+ });
22520
+ }
22521
+ }
22522
+ }
22422
22523
  extractLearning(input.architectureNotes, "architecture");
22423
- extractLearning(input.deadEnds, "dead_end");
22424
22524
  if (input.scopeAccuracy && input.scopeAccuracy !== "accurate") {
22425
22525
  learnings.push({
22426
22526
  taskId: task.id,
@@ -22432,47 +22532,48 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22432
22532
  }
22433
22533
  if (learnings.length > 0) {
22434
22534
  try {
22435
- await adapter2.appendCycleLearnings(learnings);
22436
- } catch {
22535
+ appendedRows = await adapter2.appendCycleLearnings(learnings) ?? [];
22536
+ } catch (err) {
22537
+ console.error(
22538
+ `[papi] appendCycleLearnings failed for ${task.displayId} (${learnings.length} finding(s) NOT recorded): ${err instanceof Error ? err.message : String(err)}`
22539
+ );
22437
22540
  }
22438
22541
  }
22439
22542
  }
22440
- const AUTO_TRIAGE_MIN_SEVERITY = /* @__PURE__ */ new Set(["P0", "P1", "P2"]);
22543
+ const AUTO_TRIAGE_MIN_SEVERITY = /* @__PURE__ */ new Set(["P0", "P1"]);
22441
22544
  let autoTriagedCount = 0;
22442
22545
  let autoTriagedSoftSkipped = 0;
22443
22546
  const autoTriagedIds = [];
22444
22547
  const autoTriagedDupes = [];
22445
- if (input.discoveredIssues && input.discoveredIssues !== "None" && typeof adapter2.createTask === "function") {
22446
- const issueLines = input.discoveredIssues.split(/\n|;/).map((s) => s.trim()).filter((s) => s.length > 0);
22447
- const backlogTitleMap = /* @__PURE__ */ new Map();
22448
- try {
22449
- const backlog = await adapter2.queryBoard({ status: ["Backlog"] });
22450
- for (const t of backlog) {
22451
- const normalized = t.title.replace(/^\[Auto-triaged\]\s*/i, "").trim().toLowerCase();
22452
- if (normalized && !backlogTitleMap.has(normalized)) {
22453
- backlogTitleMap.set(normalized, t.displayId);
22454
- }
22455
- }
22456
- } catch {
22457
- }
22458
- for (const line of issueLines) {
22459
- const sevMatch = line.match(/^(P[0-3])[\s:]+/i);
22460
- if (!sevMatch) continue;
22461
- const severityLabel = sevMatch[1].toUpperCase();
22462
- if (!AUTO_TRIAGE_MIN_SEVERITY.has(severityLabel)) {
22548
+ const insertedByKey = /* @__PURE__ */ new Map();
22549
+ for (const row of appendedRows) {
22550
+ if (row.findingKey) insertedByKey.set(row.findingKey, row.inserted);
22551
+ }
22552
+ const haveInsertSignal = appendedRows.length > 0;
22553
+ if (findingRows.length > 0 && typeof adapter2.createTask === "function") {
22554
+ for (const { finding, learning } of findingRows) {
22555
+ if (learning.category !== "issue") continue;
22556
+ if (finding.disposition !== "filed") continue;
22557
+ const severityLabel = learning.severity;
22558
+ if (!severityLabel || !AUTO_TRIAGE_MIN_SEVERITY.has(severityLabel)) {
22463
22559
  autoTriagedSoftSkipped++;
22464
22560
  continue;
22465
22561
  }
22466
- const priority = severityLabel === "P0" ? "P0 Critical" : severityLabel === "P1" ? "P1 High" : severityLabel === "P2" ? "P2 Medium" : "P3 Low";
22467
- const titleRaw = line.replace(/^P[0-3][\s:]+/i, "").trim();
22468
- const title = titleRaw.length > 120 ? titleRaw.slice(0, 120) : titleRaw;
22469
- if (!title) continue;
22470
- const normalized = title.toLowerCase();
22471
- const dupId = backlogTitleMap.get(normalized);
22472
- if (dupId) {
22473
- autoTriagedDupes.push(dupId);
22562
+ const key = findingKey(learning.summary);
22563
+ if (haveInsertSignal && key && insertedByKey.get(key) === false) {
22564
+ autoTriagedDupes.push(learning.summary.slice(0, 60));
22474
22565
  continue;
22475
22566
  }
22567
+ if (!haveInsertSignal) {
22568
+ autoTriagedSoftSkipped++;
22569
+ console.error(
22570
+ `[papi] auto-triage skipped for "${learning.summary.slice(0, 60)}" \u2014 the adapter returned no per-row inserted signal, so recurrence cannot be distinguished from a new finding.`
22571
+ );
22572
+ continue;
22573
+ }
22574
+ const priority = severityLabel === "P0" ? "P0 Critical" : "P1 High";
22575
+ const title = learning.summary.length > 120 ? learning.summary.slice(0, 120) : learning.summary;
22576
+ if (!title) continue;
22476
22577
  try {
22477
22578
  const created = await adapter2.createTask({
22478
22579
  uuid: "",
@@ -22481,20 +22582,17 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
22481
22582
  status: "Backlog",
22482
22583
  priority,
22483
22584
  complexity: "Small",
22484
- module: task.module ?? "",
22585
+ module: finding.module ?? task.module ?? "",
22485
22586
  phase: task.phase ?? "",
22486
22587
  owner: "papi",
22487
22588
  reviewed: false,
22488
22589
  taskType: "discovery",
22489
22590
  source: "build_complete",
22490
- notes: `Origin: ${task.displayId} (${task.title}), cycle ${cycleNumber}. Original issue: ${line}`,
22591
+ notes: `Origin: ${task.displayId} (${task.title}), cycle ${cycleNumber}. Filed rather than fixed${finding.reason ? `: ${finding.reason}` : ""}. Original issue: ${learning.detail ?? learning.summary}`,
22491
22592
  createdCycle: cycleNumber
22492
22593
  });
22493
22594
  autoTriagedCount++;
22494
- if (created?.displayId) {
22495
- autoTriagedIds.push(created.displayId);
22496
- backlogTitleMap.set(normalized, created.displayId);
22497
- }
22595
+ if (created?.displayId) autoTriagedIds.push(created.displayId);
22498
22596
  } catch {
22499
22597
  }
22500
22598
  }
@@ -22834,15 +22932,118 @@ ${instructions}`;
22834
22932
  }
22835
22933
 
22836
22934
  // src/tools/doc-registry.ts
22837
- import { readdirSync as readdirSync6, existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
22838
- import { join as join13, relative } from "path";
22935
+ import { readdirSync as readdirSync6, existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync5, mkdirSync as mkdirSync4 } from "fs";
22936
+ import { join as join13, relative, isAbsolute as isAbsolute2, dirname as dirname4, resolve as resolve3, sep } from "path";
22839
22937
  import { homedir as homedir3 } from "os";
22840
22938
  import { randomUUID as randomUUID12 } from "crypto";
22841
22939
  import { docDeletionBlockMessage } from "@papi-ai/shared";
22842
22940
  init_git();
22941
+
22942
+ // src/services/entitlements.ts
22943
+ import { evaluateContributorGate } from "@papi-ai/shared";
22944
+ var FREE_PROJECT_CAP = 3;
22945
+ var DOC_STORAGE_CEILING_BY_TIER = {
22946
+ free: { bytes: 25 * 1024 * 1024, docs: 200 },
22947
+ pro: { bytes: 250 * 1024 * 1024, docs: 2e3 },
22948
+ team: { bytes: 1024 * 1024 * 1024, docs: 1e4 }
22949
+ };
22950
+ var MAX_DOC_BODY_BYTES = 2 * 1024 * 1024;
22951
+ var PAID_TIERS = /* @__PURE__ */ new Set(["pro", "team"]);
22952
+ var PRICING_URL = "https://getpapi.ai/pricing";
22953
+ async function resolveTier(adapter2) {
22954
+ if (typeof adapter2.getMeteredUsage !== "function") return null;
22955
+ try {
22956
+ const usage = await adapter2.getMeteredUsage();
22957
+ return usage?.tier ?? null;
22958
+ } catch {
22959
+ return null;
22960
+ }
22961
+ }
22962
+ function isPaidTier(tier) {
22963
+ return tier !== null && PAID_TIERS.has(tier);
22964
+ }
22965
+ async function enforceProjectCap(adapter2, target) {
22966
+ const tier = await resolveTier(adapter2);
22967
+ if (tier === null || isPaidTier(tier)) return null;
22968
+ if (typeof adapter2.listUserProjects !== "function") return null;
22969
+ let projects;
22970
+ try {
22971
+ projects = await adapter2.listUserProjects();
22972
+ } catch {
22973
+ return null;
22974
+ }
22975
+ const matchesExisting = projects.some(
22976
+ (p) => target.papiDir && p.papi_dir && p.papi_dir === target.papiDir || target.name && p.name && p.name.trim().toLowerCase() === target.name.trim().toLowerCase()
22977
+ );
22978
+ if (matchesExisting) return null;
22979
+ if (projects.length >= FREE_PROJECT_CAP) return projectCapMessage(projects.length);
22980
+ return null;
22981
+ }
22982
+ async function resolveContributorUpsell(adapter2) {
22983
+ const tier = await resolveTier(adapter2);
22984
+ return evaluateContributorGate(tier).upsell ?? null;
22985
+ }
22986
+ function projectCapMessage(currentCount) {
22987
+ return `**You're on the Free plan (${currentCount} of ${FREE_PROJECT_CAP} projects).**
22988
+
22989
+ Free covers up to ${FREE_PROJECT_CAP} projects. To run more, upgrade to Pro for unlimited projects at a flat monthly price (no usage bills): ${PRICING_URL}
22990
+
22991
+ Your existing projects are untouched, and you can keep working in any of them.`;
22992
+ }
22993
+ function humanBytes(n) {
22994
+ if (n >= 1024 * 1024 * 1024) return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`;
22995
+ if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
22996
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
22997
+ return `${n} bytes`;
22998
+ }
22999
+ async function checkDocStorageCap(adapter2, incomingBytes) {
23000
+ if (incomingBytes > MAX_DOC_BODY_BYTES) {
23001
+ return {
23002
+ storeBody: false,
23003
+ message: `**Body not stored \u2014 this document is ${humanBytes(incomingBytes)}, above the ${humanBytes(MAX_DOC_BODY_BYTES)} per-document limit.**
23004
+
23005
+ The registry entry was saved and everything else is unaffected. Split the document, or keep it as a file in your repo.`
23006
+ };
23007
+ }
23008
+ const tier = await resolveTier(adapter2);
23009
+ if (tier === null) return { storeBody: true };
23010
+ const ceiling = DOC_STORAGE_CEILING_BY_TIER[tier] ?? DOC_STORAGE_CEILING_BY_TIER.free;
23011
+ if (typeof adapter2.getDocBodyUsage !== "function") return { storeBody: true };
23012
+ let usage;
23013
+ try {
23014
+ usage = await adapter2.getDocBodyUsage();
23015
+ } catch {
23016
+ return { storeBody: true };
23017
+ }
23018
+ if (!usage) return { storeBody: true };
23019
+ const upgrade = tier === "free" ? `Upgrade to Pro for ${humanBytes(DOC_STORAGE_CEILING_BY_TIER.pro.bytes)} at a flat monthly price: ${PRICING_URL}` : `See plan limits: ${PRICING_URL}`;
23020
+ if (usage.totalBytes + incomingBytes > ceiling.bytes) {
23021
+ return {
23022
+ storeBody: false,
23023
+ message: `**Body not stored \u2014 you are at your ${tier} plan's document storage ceiling.**
23024
+
23025
+ Using ${humanBytes(usage.totalBytes)} of ${humanBytes(ceiling.bytes)} across ${usage.docCount} stored documents. The registry entry WAS saved, so the doc is still tracked, searchable and linkable \u2014 only the stored copy of its body was skipped.
23026
+
23027
+ ${upgrade}`
23028
+ };
23029
+ }
23030
+ if (usage.docCount >= ceiling.docs) {
23031
+ return {
23032
+ storeBody: false,
23033
+ message: `**Body not stored \u2014 you are at your ${tier} plan's stored-document limit.**
23034
+
23035
+ ${usage.docCount} of ${ceiling.docs} documents stored (${humanBytes(usage.totalBytes)}). The registry entry WAS saved, so the doc is still tracked, searchable and linkable \u2014 only the stored copy of its body was skipped.
23036
+
23037
+ ${upgrade}`
23038
+ };
23039
+ }
23040
+ return { storeBody: true };
23041
+ }
23042
+
23043
+ // src/tools/doc-registry.ts
22843
23044
  var docRegisterTool = {
22844
23045
  name: "doc_register",
22845
- description: "Register or update a document in the doc registry. Called after finalising a research/planning doc, or when build_execute detects unregistered docs. Stores metadata and structured summary \u2014 not full content; the body stays a file, so an untracked doc is committed at registration to make it durable (or you get a loud warning if it cannot be). Re-registering an existing doc updates its summary, tags, actions, type, and status (upsert). Visibility and owner are not changed on re-register.",
23046
+ description: "Register or update a document in the doc registry. Called after finalising a research/planning doc, or when build_execute detects unregistered docs. Stores metadata, a structured summary, AND the document body (task-3017) so a doc survives a branch switch, an autostash, or a gitignored docs/private/ folder \u2014 pass `body`, or omit it and the file is read from disk when a local workspace is available. Bodies are stored per-user against a plan ceiling; at the ceiling the registration still succeeds and only the body is skipped. Only .md paths are accepted. An untracked doc is still committed at registration to make it durable (or you get a loud warning if it cannot be). Re-registering an existing doc updates its summary, tags, actions, type, and status (upsert). Visibility and owner are not changed on re-register.",
22846
23047
  annotations: { title: "Register Doc", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
22847
23048
  inputSchema: {
22848
23049
  type: "object",
@@ -22854,6 +23055,10 @@ var docRegisterTool = {
22854
23055
  summary: { type: "string", description: 'Structured 2-4 sentence summary. Format: "Conclusions: ... Open questions: ... Unactioned: ..."' },
22855
23056
  tags: { type: "array", items: { type: "string" }, description: "Tags from project vocabulary." },
22856
23057
  cycle: { type: "number", description: "Current cycle number." },
23058
+ body: {
23059
+ type: "string",
23060
+ description: "OPTIONAL full markdown body. Omit it and the file is read from disk when a local workspace is available; on a hosted-only session with no body supplied, metadata is registered and the tool says the body was not stored. Stored so the doc survives a branch switch, an autostash, or living in a gitignored folder."
23061
+ },
22857
23062
  visibility: { type: "string", enum: ["public", "contributors", "private"], description: 'Visibility tier. Defaults to "private" (owner-only). "public" = shipped with PAPI (anyone can read); "contributors" = team-member tier (shared with the project cohort). Choose private unless you have explicit intent to share. FOLDER CONVENTION: place the doc body to match \u2014 docs/private/ (owner-only, gitignored), docs/contributors/ (team), docs/public/ (everyone). If you omit this param, the tier is inferred from the folder; anything else defaults to private.' },
22858
23063
  actions: {
22859
23064
  type: "array",
@@ -22891,6 +23096,19 @@ var docSearchTool = {
22891
23096
  required: []
22892
23097
  }
22893
23098
  };
23099
+ var docReadTool = {
23100
+ name: "doc_read",
23101
+ description: "Read a registered document's stored BODY back out of the database, and optionally restore it to disk. This is the recovery path: when a doc is gone from the working tree \u2014 a branch switch, a stash, a gitignored docs/private/ folder that never made it into git \u2014 doc_read is how the content comes back. Identify the doc by path or UUID via `id_or_path`. Pass `write_to_disk: true` to write the body back to its registered path; an existing file with DIFFERENT content is never overwritten (the conflict is reported and nothing is written). Bodies are stored by doc_register from C356 onward, so a doc registered before that, or one whose body was skipped at a storage ceiling, has metadata but no body \u2014 doc_read says so plainly rather than returning an empty document.",
23102
+ annotations: { title: "Read Doc", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
23103
+ inputSchema: {
23104
+ type: "object",
23105
+ properties: {
23106
+ id_or_path: { type: "string", description: 'Registered path (e.g. "docs/private/notes.md") or the doc UUID.' },
23107
+ write_to_disk: { type: "boolean", description: "Write the body back to its registered path (default false \u2014 read only). Refuses to overwrite a file whose content differs." }
23108
+ },
23109
+ required: ["id_or_path"]
23110
+ }
23111
+ };
22894
23112
  var docScanTool = {
22895
23113
  name: "doc_scan",
22896
23114
  description: "Scan docs/ and plans directories for unregistered .md files. Returns a list of files not yet in the doc registry. Use this to find docs that need registration.",
@@ -22922,8 +23140,8 @@ function normalizeDocPath(rawPath, projectRoot) {
22922
23140
  const isWindowsDrive = /^[A-Za-z]:\//.test(fwd);
22923
23141
  const isUnc = /^\/\//.test(fwd);
22924
23142
  const isPosixAbs = fwd.startsWith("/");
22925
- const isAbsolute2 = isWindowsDrive || isUnc || isPosixAbs;
22926
- if (!isAbsolute2) return { path: fwd };
23143
+ const isAbsolute3 = isWindowsDrive || isUnc || isPosixAbs;
23144
+ if (!isAbsolute3) return { path: fwd };
22927
23145
  if (projectRoot) {
22928
23146
  const root = projectRoot.replace(/\\/g, "/").replace(/\/+$/, "");
22929
23147
  if (fwd.toLowerCase().startsWith(`${root.toLowerCase()}/`)) {
@@ -23012,6 +23230,14 @@ async function handleDocRegister(adapter2, args, config2) {
23012
23230
  continueHint
23013
23231
  );
23014
23232
  }
23233
+ if (!path7.toLowerCase().endsWith(".md")) {
23234
+ return docRegisterSoftFail(
23235
+ adapterType,
23236
+ "path-validation",
23237
+ `Only .md documents can be registered \u2014 "${path7}" is not markdown.`,
23238
+ "The doc registry indexes markdown notes and research docs. Nothing is blocked: continue your flow, and register the .md version if there is one."
23239
+ );
23240
+ }
23015
23241
  try {
23016
23242
  let supersededBy;
23017
23243
  if (supersededByPath) {
@@ -23034,6 +23260,44 @@ async function handleDocRegister(adapter2, args, config2) {
23034
23260
  actions,
23035
23261
  visibility
23036
23262
  });
23263
+ let bodyNote = "";
23264
+ try {
23265
+ const supplied = typeof args.body === "string" ? args.body : void 0;
23266
+ let body = supplied;
23267
+ if (body === void 0 && hasLocalWorkspace()) {
23268
+ try {
23269
+ const abs = isAbsolute2(entry.path) ? entry.path : join13(config2?.projectRoot ?? process.cwd(), entry.path);
23270
+ if (existsSync9(abs)) body = readFileSync8(abs, "utf8");
23271
+ } catch {
23272
+ }
23273
+ }
23274
+ if (body === void 0) {
23275
+ bodyNote = hasLocalWorkspace() ? "\n\n_Body not stored \u2014 the file could not be read from disk. Pass `body` to store it._" : "\n\n_Body not stored \u2014 no local workspace on this session. Pass `body` to store it._";
23276
+ } else if (typeof adapter2.storeDocBody !== "function") {
23277
+ bodyNote = "\n\n_Body not stored \u2014 this adapter does not support body storage._";
23278
+ } else {
23279
+ const decision = await checkDocStorageCap(adapter2, Buffer.byteLength(body, "utf8"));
23280
+ if (!decision.storeBody) {
23281
+ bodyNote = `
23282
+
23283
+ ${decision.message}`;
23284
+ } else {
23285
+ const result = await adapter2.storeDocBody({
23286
+ docId: entry.id,
23287
+ body,
23288
+ // Resolved by resolveDocVisibility above — the SAME resolution the
23289
+ // registry row used, so doc_bodies' denormalised copy cannot disagree
23290
+ // with doc_registry on the very first write.
23291
+ visibility: entry.visibility ?? visibility,
23292
+ ownerUserId: entry.ownerUserId
23293
+ });
23294
+ bodyNote = result.stored ? `
23295
+ - **Body stored:** ${result.byteSize.toLocaleString()} bytes` : "\n- **Body:** unchanged since last registration";
23296
+ }
23297
+ }
23298
+ } catch {
23299
+ bodyNote = "\n\n_Body not stored \u2014 storage was unavailable. The registry entry was saved._";
23300
+ }
23037
23301
  const visibilityLabel = entry.visibility === "contributors" ? "team member" : entry.visibility ?? "private";
23038
23302
  let durability = "";
23039
23303
  try {
@@ -23048,7 +23312,7 @@ async function handleDocRegister(adapter2, args, config2) {
23048
23312
  - **Visibility:** ${visibilityLabel}
23049
23313
  - **Tags:** ${entry.tags.length > 0 ? entry.tags.join(", ") : "none"}
23050
23314
  - **Actions:** ${actions?.length ?? 0} items
23051
- - **ID:** ${entry.id}` + durability
23315
+ - **ID:** ${entry.id}` + bodyNote + durability
23052
23316
  );
23053
23317
  } catch (err) {
23054
23318
  const message = err instanceof Error ? err.message : String(err);
@@ -23077,7 +23341,7 @@ async function handleDocSearch(adapter2, args, config2) {
23077
23341
  const actionCount = d.actions?.filter((a) => a.status === "pending").length ?? 0;
23078
23342
  const actionNote = actionCount > 0 ? ` | ${actionCount} pending action(s)` : "";
23079
23343
  const missingNote = root && d.path && !existsSync9(join13(root, d.path)) ? `
23080
- > \u26A0\uFE0F **File missing on disk** \u2014 the registry points at \`${d.path}\` but nothing is there. Check \`git stash list\` for a papi-autostash entry, or re-create/deregister the doc.` : "";
23344
+ > \u26A0\uFE0F **File missing on disk** \u2014 the registry points at \`${d.path}\` but nothing is there. Recover it with \`doc_read\` (\`id_or_path: "${d.path}", write_to_disk: true\`) if its body was stored, or check \`git stash list\` for a papi-autostash entry.` : "";
23081
23345
  return `### ${d.title}
23082
23346
  **Type:** ${d.type} | **Status:** ${d.status} | **Cycle:** ${d.cycleCreated}${d.cycleUpdated ? `\u2192${d.cycleUpdated}` : ""}${actionNote}
23083
23347
  **Path:** ${d.path}${missingNote}
@@ -23089,6 +23353,96 @@ ${d.summary}
23089
23353
 
23090
23354
  ${lines.join("\n---\n\n")}`);
23091
23355
  }
23356
+ function resolveRestoreTarget(projectRoot, docPath) {
23357
+ const normalized = normalizeDocPath(docPath, projectRoot);
23358
+ if ("error" in normalized) return { error: normalized.error };
23359
+ const root = resolve3(projectRoot);
23360
+ const abs = resolve3(root, normalized.path);
23361
+ if (abs !== root && !abs.startsWith(root + sep)) {
23362
+ return {
23363
+ error: `Refusing to write outside the project root. The registered path \`${docPath}\` resolves to \`${abs}\`, which is not under \`${root}\`.`
23364
+ };
23365
+ }
23366
+ return { abs };
23367
+ }
23368
+ async function handleDocRead(adapter2, config2, args) {
23369
+ if (!adapter2.getDoc || typeof adapter2.getDocBody !== "function") {
23370
+ return errorResponse(
23371
+ "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."
23372
+ );
23373
+ }
23374
+ const idOrPath = args.id_or_path?.trim();
23375
+ if (!idOrPath) {
23376
+ return errorResponse("id_or_path is required \u2014 pass the doc's registered path or its UUID.");
23377
+ }
23378
+ const writeToDisk = args.write_to_disk === true;
23379
+ const doc = await adapter2.getDoc(idOrPath);
23380
+ if (!doc) {
23381
+ return errorResponse(
23382
+ `No registered doc matches \`${idOrPath}\` in this project. Check \`doc_search\` for the exact path, or \`doc_scan\` for unregistered files.`
23383
+ );
23384
+ }
23385
+ const gate = await resolveOwnerGate(adapter2, config2);
23386
+ const requesterUserId = gate.enforced ? gate.callerUserId : gate.ownerUserId ?? gate.callerUserId;
23387
+ const stored = await adapter2.getDocBody(doc.id, requesterUserId);
23388
+ const header = `**${doc.title}**
23389
+ - **Path:** ${doc.path}
23390
+ - **Type:** ${doc.type} | **Status:** ${doc.status} | **Visibility:** ${doc.visibility ?? "private"}
23391
+ `;
23392
+ if (!stored) {
23393
+ return textResponse(
23394
+ header + `- **Body:** not stored
23395
+
23396
+ No body is stored for this doc. That happens for three reasons: it was registered before body storage landed (C356); the body was skipped because the account was at its plan storage ceiling; or it was registered from a hosted session with no \`body\` argument and no local file to read.
23397
+
23398
+ **Fix:** re-run \`doc_register\` for \`${doc.path}\` with the file present, or pass \`body\` explicitly.`
23399
+ );
23400
+ }
23401
+ if (!stored.permitted) {
23402
+ return errorResponse(
23403
+ `\`${doc.path}\` has a stored body, but its **${doc.visibility ?? "private"}** tier does not let you read it. ` + (doc.visibility === "private" ? "Private bodies are readable by the doc owner alone \u2014 project membership is not enough, by design (task-3016)." : "Contributor-tier bodies are readable by project members only.")
23404
+ );
23405
+ }
23406
+ const body = stored.body ?? "";
23407
+ let restoreNote = "";
23408
+ if (writeToDisk) {
23409
+ restoreNote = restoreBodyToDisk(config2.projectRoot, doc.path, body);
23410
+ }
23411
+ return textResponse(
23412
+ header + `- **Stored:** ${stored.byteSize.toLocaleString()} bytes, updated ${stored.updatedAt}
23413
+ ` + restoreNote + `
23414
+ ---
23415
+
23416
+ ${body}`
23417
+ );
23418
+ }
23419
+ function restoreBodyToDisk(projectRoot, docPath, body) {
23420
+ if (!hasLocalWorkspace()) {
23421
+ return "- **Not restored:** this session has no local workspace (hosted transport). The body is above \u2014 write it yourself.\n";
23422
+ }
23423
+ const target = resolveRestoreTarget(projectRoot, docPath);
23424
+ if ("error" in target) return `- **Not restored:** ${target.error}
23425
+ `;
23426
+ try {
23427
+ if (existsSync9(target.abs)) {
23428
+ const onDisk = readFileSync8(target.abs, "utf8");
23429
+ if (onDisk === body) {
23430
+ return `- **Already on disk:** \`${docPath}\` is byte-identical to the stored body. Nothing written.
23431
+ `;
23432
+ }
23433
+ return `- \u26A0\uFE0F **Conflict \u2014 nothing written.** A DIFFERENT file already exists at \`${docPath}\` (${Buffer.byteLength(onDisk, "utf8").toLocaleString()} bytes on disk vs ${Buffer.byteLength(body, "utf8").toLocaleString()} stored). Move or delete it first, then re-run with \`write_to_disk: true\`. The stored body is printed below either way.
23434
+ `;
23435
+ }
23436
+ mkdirSync4(dirname4(target.abs), { recursive: true });
23437
+ writeFileSync5(target.abs, body, "utf8");
23438
+ return `- **Restored:** wrote ${Buffer.byteLength(body, "utf8").toLocaleString()} bytes to \`${docPath}\`
23439
+ `;
23440
+ } catch (err) {
23441
+ const message = err instanceof Error ? err.message : String(err);
23442
+ return `- **Not restored:** writing \`${docPath}\` failed \u2014 ${message}. The body is printed below.
23443
+ `;
23444
+ }
23445
+ }
23092
23446
  function scanMdFiles(dir, rootDir) {
23093
23447
  if (!existsSync9(dir)) return [];
23094
23448
  const files = [];
@@ -23531,6 +23885,24 @@ var buildExecuteTool = {
23531
23885
  },
23532
23886
  required: ["urls", "curl_command", "http_status", "response_excerpt", "verified_at"]
23533
23887
  },
23888
+ findings: {
23889
+ type: "array",
23890
+ maxItems: 12,
23891
+ description: 'OPTIONAL, and the highest-leverage field here. One entry per DISCRETE thing you found, instead of (or alongside) the prose blobs. The point is `disposition`: PAPI has never had a verb for "found it, fixed it, moving on", so the only way to record a finding was to file it \u2014 which costs more than fixing it and grows the board faster than the fixes. Use fixed_now when you already fixed it (the row is recorded as caught AND fixed, and is never auto-triaged), filed when it genuinely needs its own task, wont_fix when it is not worth fixing. A P2/P3 you are filing rather than fixing, and any wont_fix, needs a `reason` of at least 12 characters. Omit this array entirely and nothing changes \u2014 the prose blobs are still parsed.',
23892
+ items: {
23893
+ type: "object",
23894
+ properties: {
23895
+ kind: { type: "string", enum: ["issue", "dead_end", "surprise"], description: "What kind of finding. Only `issue` carries a disposition." },
23896
+ severity: { type: "string", enum: ["P0", "P1", "P2", "P3"], description: "Severity, for issues." },
23897
+ summary: { type: "string", maxLength: 200, description: 'One line. No "Pn:" prefix \u2014 severity has its own field.' },
23898
+ detail: { type: "string", maxLength: 600, description: "The verbatim context: what breaks, where, and how it reproduces." },
23899
+ disposition: { type: "string", enum: ["fixed_now", "filed", "wont_fix"], description: "Required for kind=issue. fixed_now = already fixed in this build. filed = needs its own task. wont_fix = not worth fixing." },
23900
+ reason: { type: "string", maxLength: 200, description: "Why you filed rather than fixed, or why it will not be fixed. Required (>= 12 chars) for a filed P2/P3 and for any wont_fix." },
23901
+ module: { type: "string", description: "Module the finding belongs to. Defaults to the task's module." }
23902
+ },
23903
+ required: ["kind", "summary"]
23904
+ }
23905
+ },
23534
23906
  preview: {
23535
23907
  type: "object",
23536
23908
  description: `Optional. For a build that touched USER-FACING UI: tell the owner how to SEE the result locally so reviewing it doesn't mean reading code or asking "show me". urls = the localhost route(s) to open (e.g. ["http://localhost:3000/hub"]); notes = what to look at; screenshot_path = a path to an image you captured. Surfaced verbatim in the completion output. When the diff touched UI and you omit this, the output nudges you to provide it.`,
@@ -23861,6 +24233,24 @@ If this build fixes any of these, pass their UUIDs in \`fixed_issues\` on comple
23861
24233
  }
23862
24234
  } catch {
23863
24235
  }
24236
+ let ruledOutSection = "";
24237
+ try {
24238
+ const moduleTag = result.task.module?.trim();
24239
+ if (moduleTag && adapter2.getCycleLearnings) {
24240
+ const deadEnds = (await adapter2.getCycleLearnings({ category: "dead_end", limit: 25 })).filter((l) => !l.resolvedAt).filter((l) => (l.module ?? "").toLowerCase() === moduleTag.toLowerCase() || l.tags.some((t) => t.toLowerCase() === moduleTag.toLowerCase())).slice(0, 4);
24241
+ if (deadEnds.length > 0) {
24242
+ const rows = deadEnds.map((l) => `- ${l.summary.length > 180 ? `${l.summary.slice(0, 180)}...` : l.summary}`).join("\n");
24243
+ ruledOutSection = `
24244
+
24245
+ ---
24246
+
24247
+ **RULED OUT IN ${moduleTag} \u2014 do not re-walk these:**
24248
+ ${rows}
24249
+ These approaches were tried in this module and failed. If one looks right, read why it was ruled out before spending time on it.`;
24250
+ }
24251
+ }
24252
+ } catch {
24253
+ }
23864
24254
  const moduleInstructions = getModuleInstructions(result.task.module);
23865
24255
  const moduleContext = await getModuleContext(adapter2, result.task);
23866
24256
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
@@ -23870,7 +24260,7 @@ If this build fixes any of these, pass their UUIDs in \`fixed_issues\` on comple
23870
24260
  ) ?? "";
23871
24261
  const gestaltNote = buildGestaltPreBuildDirective(caps) ?? "";
23872
24262
  const buildDisciplineSection = buildPapiMetaFramingDirective(caps, buildDisciplineNote) ?? "";
23873
- return textResponse(resumeNote + header + serializeBuildHandoff(result.task.buildHandoff) + modelNote + gestaltNote + adSection + moduleInstructions + moduleContext + dogfoodSection + openIssuesSection + verificationNote + buildDisciplineSection + chainInstruction + phaseNote + filesToWriteSection);
24263
+ return textResponse(resumeNote + header + serializeBuildHandoff(result.task.buildHandoff) + modelNote + gestaltNote + adSection + moduleInstructions + moduleContext + dogfoodSection + openIssuesSection + ruledOutSection + verificationNote + buildDisciplineSection + chainInstruction + phaseNote + filesToWriteSection);
23874
24264
  } catch (err) {
23875
24265
  if (isNoHandoffError(err)) {
23876
24266
  const lines = [
@@ -23959,6 +24349,20 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
23959
24349
  if (!parsedEstimatedEffort) {
23960
24350
  return errorResponse(`Invalid estimated_effort value "${estimatedEffort}". Must be one of: XS, S, M, L, XL.`);
23961
24351
  }
24352
+ const rawFindings = Array.isArray(args.findings) ? args.findings : void 0;
24353
+ const findingsCheck = validateFindings(rawFindings);
24354
+ if (!findingsCheck.ok) {
24355
+ const offenders = findingsCheck.violations.map((v, i) => `${i + 1}. ${v.message}`).join("\n\n");
24356
+ return textResponse(
24357
+ `**${findingsCheck.violations.length} finding${findingsCheck.violations.length === 1 ? "" : "s"} need a disposition or a reason before ${taskId} can complete.**
24358
+
24359
+ You already have the file open and the context loaded \u2014 fixing costs less than filing, triaging, planning and re-contexting it three cycles from now. A P2 or P3 you are filing rather than fixing needs a reason of at least ${MIN_REASON_LENGTH} characters; so does anything marked wont_fix. P0 and P1 file freely.
24360
+
24361
+ ${offenders}
24362
+
24363
+ Your report was NOT discarded and the task is NOT yet Done \u2014 fix what you can, set a disposition on the rest, and re-send \`build_execute\` complete with the SAME report fields.`
24364
+ );
24365
+ }
23962
24366
  const acceptanceConfirmed = args.acceptance_confirmed === true;
23963
24367
  if (completed === "yes" && !acceptanceConfirmed) {
23964
24368
  const gateInfo = adapter2.getProjectInfo ? await adapter2.getProjectInfo().catch(() => null) : null;
@@ -24005,7 +24409,9 @@ Your report was NOT discarded and the task is NOT yet Done \u2014 re-send with \
24005
24409
  detail: bi.detail ?? ""
24006
24410
  })),
24007
24411
  productionVerification,
24008
- preview
24412
+ preview,
24413
+ // task-3001: already gate-validated above, so the service can trust it.
24414
+ findings: rawFindings
24009
24415
  }, { light }, clientName);
24010
24416
  tracker.mark("complete_format");
24011
24417
  tracker.setStreamScope({ taskId: result.task.displayId ?? result.task.id, cycle: result.cycleNumber });
@@ -24030,9 +24436,10 @@ Your report was NOT discarded and the task is NOT yet Done \u2014 re-send with \
24030
24436
  }
24031
24437
  let fixedResolvedCount = 0;
24032
24438
  if (fixedIssues && fixedIssues.length > 0 && typeof adapter2.markCycleLearningResolved === "function") {
24439
+ const resolvedBy = `build:${result.task.displayId ?? taskId}`;
24033
24440
  for (const learningId of fixedIssues) {
24034
24441
  try {
24035
- await adapter2.markCycleLearningResolved(learningId, clientName);
24442
+ await adapter2.markCycleLearningResolved(learningId, resolvedBy);
24036
24443
  fixedResolvedCount++;
24037
24444
  } catch {
24038
24445
  }
@@ -27258,8 +27665,8 @@ Path: ${mcpJsonPath}`
27258
27665
  const fileBody = target.render(envVars);
27259
27666
  const wroteToShared = !target.isProjectScoped && !!existingConfig;
27260
27667
  if (wroteToShared) {
27261
- const sep = existingConfig.endsWith("\n") ? "\n" : "\n\n";
27262
- await writeMcpConfig(mcpJsonPath, target.configPath, existingConfig + sep + fileBody, target.isProjectScoped, config2.adapterType, collector);
27668
+ const sep2 = existingConfig.endsWith("\n") ? "\n" : "\n\n";
27669
+ await writeMcpConfig(mcpJsonPath, target.configPath, existingConfig + sep2 + fileBody, target.isProjectScoped, config2.adapterType, collector);
27263
27670
  } else {
27264
27671
  await writeMcpConfig(mcpJsonPath, target.configPath, fileBody, target.isProjectScoped, config2.adapterType, collector);
27265
27672
  }
@@ -27330,8 +27737,8 @@ ${writeNote}
27330
27737
  const fileBody = target.render(envVars);
27331
27738
  const wroteToShared = !target.isProjectScoped && !!existingConfig;
27332
27739
  if (wroteToShared) {
27333
- const sep = existingConfig.endsWith("\n") ? "\n" : "\n\n";
27334
- await writeMcpConfig(mcpJsonPath, target.configPath, existingConfig + sep + fileBody, target.isProjectScoped, config2.adapterType, collector);
27740
+ const sep2 = existingConfig.endsWith("\n") ? "\n" : "\n\n";
27741
+ await writeMcpConfig(mcpJsonPath, target.configPath, existingConfig + sep2 + fileBody, target.isProjectScoped, config2.adapterType, collector);
27335
27742
  } else {
27336
27743
  await writeMcpConfig(mcpJsonPath, target.configPath, fileBody, target.isProjectScoped, config2.adapterType, collector);
27337
27744
  }
@@ -27374,8 +27781,8 @@ ${writeNote}
27374
27781
  const fileBody = target.render(envVars);
27375
27782
  const wroteToShared = !target.isProjectScoped && !!existingConfig;
27376
27783
  if (wroteToShared) {
27377
- const sep = existingConfig.endsWith("\n") ? "\n" : "\n\n";
27378
- await writeMcpConfig(mcpJsonPath, target.configPath, existingConfig + sep + fileBody, target.isProjectScoped, config2.adapterType, collector);
27784
+ const sep2 = existingConfig.endsWith("\n") ? "\n" : "\n\n";
27785
+ await writeMcpConfig(mcpJsonPath, target.configPath, existingConfig + sep2 + fileBody, target.isProjectScoped, config2.adapterType, collector);
27379
27786
  } else {
27380
27787
  await writeMcpConfig(mcpJsonPath, target.configPath, fileBody, target.isProjectScoped, config2.adapterType, collector);
27381
27788
  }
@@ -28159,8 +28566,94 @@ async function verifyProject(adapter2) {
28159
28566
  // src/tools/orient.ts
28160
28567
  import { execFile as execFile2 } from "child_process";
28161
28568
  import { promisify as promisify2 } from "util";
28162
- import { readFileSync as readFileSync11, writeFileSync as writeFileSync5, existsSync as existsSync11 } from "fs";
28569
+ import { readFileSync as readFileSync11, writeFileSync as writeFileSync6, existsSync as existsSync11 } from "fs";
28163
28570
  import { join as join17 } from "path";
28571
+
28572
+ // src/lib/exit-criteria-evaluators.ts
28573
+ var TEST_ACCOUNT_PATTERN = "ftue-%@test.papi.dev";
28574
+ function sqlOf(adapter2) {
28575
+ const candidate = adapter2.sql;
28576
+ return typeof candidate === "function" ? candidate : void 0;
28577
+ }
28578
+ var evaluateUnaidedFullLoop = async (adapter2, projectId) => {
28579
+ const sql = sqlOf(adapter2);
28580
+ if (!sql) return { met: null, unevaluatedReason: "no direct SQL access on this adapter (hosted path)" };
28581
+ const rows = await sql`
28582
+ SELECT count(DISTINCT p.user_id)::text AS builders, count(*)::text AS cycles
28583
+ FROM cycles c
28584
+ JOIN projects p ON p.id = c.project_id
28585
+ JOIN auth.users u ON u.id = p.user_id
28586
+ WHERE c.status = 'complete'
28587
+ AND p.user_id IS DISTINCT FROM (SELECT user_id FROM projects WHERE id = ${projectId})
28588
+ AND u.email NOT LIKE ${TEST_ACCOUNT_PATTERN}
28589
+ `;
28590
+ const builders2 = Number(rows[0]?.builders ?? 0);
28591
+ const cycles = Number(rows[0]?.cycles ?? 0);
28592
+ return builders2 >= 1 ? {
28593
+ met: null,
28594
+ evidence: `${builders2} non-owner account(s) completed ${cycles} cycle(s) in their own projects; owner and ${TEST_ACCOUNT_PATTERN} excluded. Counted ${(/* @__PURE__ */ new Date()).toISOString()}.`,
28595
+ unevaluatedReason: `mechanically satisfied (${builders2} non-owner builders, ${cycles} completed cycles) \u2014 but "without owner assistance" is a human judgement, so this needs an owner tick via set_criterion_met`
28596
+ } : { met: false, evidence: `0 completed cycles on non-owner projects. Counted ${(/* @__PURE__ */ new Date()).toISOString()}.` };
28597
+ };
28598
+ var evaluateSelfServeActivation = async (adapter2, projectId) => {
28599
+ const sql = sqlOf(adapter2);
28600
+ if (!sql) return { met: null, unevaluatedReason: "no direct SQL access on this adapter (hosted path)" };
28601
+ const rows = await sql`
28602
+ SELECT count(DISTINCT p.user_id)::text AS users
28603
+ FROM cycles c
28604
+ JOIN projects p ON p.id = c.project_id
28605
+ JOIN auth.users u ON u.id = p.user_id
28606
+ WHERE p.user_id IS DISTINCT FROM (SELECT user_id FROM projects WHERE id = ${projectId})
28607
+ AND u.email NOT LIKE ${TEST_ACCOUNT_PATTERN}
28608
+ AND EXISTS (SELECT 1 FROM cycle_tasks t WHERE t.cycle = c.number AND t.project_id = c.project_id)
28609
+ `;
28610
+ const users = Number(rows[0]?.users ?? 0);
28611
+ return users >= 1 ? {
28612
+ met: null,
28613
+ evidence: `${users} non-owner account(s) produced a planned cycle with tasks in their own project; owner and ${TEST_ACCOUNT_PATTERN} excluded. Counted ${(/* @__PURE__ */ new Date()).toISOString()}.`,
28614
+ 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`
28615
+ } : { met: false, evidence: `0 non-owner accounts with a planned cycle. Counted ${(/* @__PURE__ */ new Date()).toISOString()}.` };
28616
+ };
28617
+ var EVALUATORS = {
28618
+ "c4a11d42-b222-45af-a470-08970e1bd6a9": evaluateSelfServeActivation,
28619
+ "59751bcb-f719-4cf6-a4d4-608770999a22": evaluateUnaidedFullLoop
28620
+ };
28621
+ var UNEVALUATABLE = {
28622
+ "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",
28623
+ "c596a082-9ad3-4089-b497-46cd189e892b": "threshold is provisional pending an owner ruling \u2014 no settled bar to evaluate against"
28624
+ };
28625
+ async function evaluateExitCriteria(adapter2, projectId, criteria) {
28626
+ return Promise.all(criteria.map(async (c) => {
28627
+ const evaluator = EVALUATORS[c.id];
28628
+ if (!evaluator) {
28629
+ const reason = UNEVALUATABLE[c.id];
28630
+ if (!reason) return { ...c, met: c.met, autoEvaluated: false };
28631
+ return c.met ? { ...c, met: true, autoEvaluated: false } : { ...c, met: null, unevaluatedReason: reason, autoEvaluated: false };
28632
+ }
28633
+ try {
28634
+ const result = await evaluator(adapter2, projectId);
28635
+ return {
28636
+ ...c,
28637
+ met: result.met,
28638
+ evidence: result.evidence ?? c.evidence,
28639
+ unevaluatedReason: result.unevaluatedReason,
28640
+ autoEvaluated: result.met !== null
28641
+ };
28642
+ } catch (err) {
28643
+ return {
28644
+ ...c,
28645
+ met: c.met ? true : null,
28646
+ unevaluatedReason: `evaluator failed: ${err instanceof Error ? err.message : String(err)}`,
28647
+ autoEvaluated: false
28648
+ };
28649
+ }
28650
+ }));
28651
+ }
28652
+ function criterionMarker(met) {
28653
+ return met === true ? "[x]" : met === false ? "[ ]" : "[?]";
28654
+ }
28655
+
28656
+ // src/tools/orient.ts
28164
28657
  var execFileAsync2 = promisify2(execFile2);
28165
28658
  var GIT_DEPENDENT_ENVS = /* @__PURE__ */ new Set(["hosted", "api"]);
28166
28659
  var VALID_ENVS = /* @__PURE__ */ new Set(["local-cli", "hosted", "api", "unknown"]);
@@ -28369,9 +28862,15 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
28369
28862
  }
28370
28863
  if (hierarchy.stageExitCriteria && hierarchy.stageExitCriteria.length > 0) {
28371
28864
  const crit = hierarchy.stageExitCriteria;
28372
- const met = crit.filter((c) => c.met).length;
28373
28865
  const total = crit.length;
28374
- lines.push(`**Stage Exit Criteria [${met}/${total} met]:** ${crit.map((c) => `${c.met ? "[x]" : "[ ]"} ${c.text}`).join(" | ")}`);
28866
+ const met = crit.filter((c) => c.met === true).length;
28867
+ const unevaluated = crit.filter((c) => c.met === null || c.met === void 0).length;
28868
+ const countLabel = unevaluated > 0 ? `${met}/${total} met, ${unevaluated} unevaluated` : `${met}/${total} met`;
28869
+ lines.push(`**Stage Exit Criteria [${countLabel}]:** ${crit.map((c) => `${criterionMarker(c.met ?? null)} ${c.text}`).join(" | ")}`);
28870
+ for (const c of crit) {
28871
+ const reason = c.unevaluatedReason;
28872
+ if (reason) lines.push(` \u21B3 UNEVALUATED \u2014 ${reason}`);
28873
+ }
28375
28874
  if (met === total) {
28376
28875
  lines.push(" \u21B3 All exit criteria met \u2014 run `strategy_review` to propose advancing the stage.");
28377
28876
  }
@@ -28487,7 +28986,7 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
28487
28986
  }
28488
28987
  return lines.join("\n").trimEnd();
28489
28988
  }
28490
- async function getHierarchyPosition(adapter2) {
28989
+ async function getHierarchyPosition(adapter2, projectId) {
28491
28990
  try {
28492
28991
  const [horizons, stages, phases, allTasks] = await Promise.all([
28493
28992
  adapter2.readHorizons?.() ?? [],
@@ -28520,7 +29019,14 @@ async function getHierarchyPosition(adapter2) {
28520
29019
  stage: activeStage.label,
28521
29020
  activePhases: activePhases.map((p) => p.label),
28522
29021
  phasesNearingClosure: nearingClosure.map((p) => p.label),
28523
- stageExitCriteria: activeStage.exitCriteria
29022
+ // task-3008: EVALUATE rather than read the hand-ticked boolean. Best-effort —
29023
+ // a failing evaluator degrades that criterion to unevaluated and leaves the
29024
+ // rest intact, because orient is the first call of every session.
29025
+ stageExitCriteria: await evaluateExitCriteria(
29026
+ adapter2,
29027
+ projectId ?? "",
29028
+ activeStage.exitCriteria ?? []
29029
+ ).catch(() => (activeStage.exitCriteria ?? []).map((c) => ({ ...c, autoEvaluated: false })))
28524
29030
  };
28525
29031
  } catch {
28526
29032
  return void 0;
@@ -28689,7 +29195,7 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
28689
29195
  const [buildResult, healthResult, hierarchy] = await Promise.all([
28690
29196
  tracked("listBuilds", () => listBuilds(adapter2, config2))(),
28691
29197
  tracked("getHealthSummary", () => getHealthSummary(adapter2))(),
28692
- tracked("getHierarchyPosition", () => getHierarchyPosition(adapter2))()
29198
+ tracked("getHierarchyPosition", () => getHierarchyPosition(adapter2, config2.projectId))()
28693
29199
  ]);
28694
29200
  const currentCycle = buildResult.currentCycle;
28695
29201
  const cycleIsComplete = healthResult.latestCycleStatus === "complete";
@@ -28944,10 +29450,6 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
28944
29450
  // Discovered issues — split into Alerts (P0/P1) and Unactioned (P2/P3).
28945
29451
  // Older records without a severity field are treated as P3.
28946
29452
  tracked("discovered-issues", async () => {
28947
- try {
28948
- await adapter2.resolveLearningsForDoneTasks?.();
28949
- } catch {
28950
- }
28951
29453
  const learnings = await adapter2.getCycleLearnings?.({ category: "issue", limit: 30 });
28952
29454
  if (!learnings) return { alertsNote: "", unactionedIssuesNote: "" };
28953
29455
  const candidateLearnings = learnings.filter((l) => !l.actionTaken);
@@ -29256,7 +29758,7 @@ function enrichClaudeMd(projectRoot, cycleNumber, adapterType, collector, client
29256
29758
  additions.push(dedupeEnrichmentBlob(content, CLAUDE_MD_TIER_2));
29257
29759
  }
29258
29760
  if (additions.length === 0) return "";
29259
- writeFileSync5(claudeMdPath, content + additions.join(""), "utf-8");
29761
+ writeFileSync6(claudeMdPath, content + additions.join(""), "utf-8");
29260
29762
  const tierNames = [];
29261
29763
  if (additions.some((a) => a.includes(CLAUDE_MD_ENRICHMENT_SENTINEL_T1))) tierNames.push("Established (batch building, strategy reviews, AD lifecycle)");
29262
29764
  if (additions.some((a) => a.includes(CLAUDE_MD_ENRICHMENT_SENTINEL_T2))) tierNames.push("Mature (idea pipeline, doc registry, advanced patterns)");
@@ -30071,8 +30573,8 @@ ${result.userMessage}
30071
30573
  import { readFileSync as readFileSync12, statSync as statSync7 } from "fs";
30072
30574
 
30073
30575
  // src/services/scope-brief.ts
30074
- import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync4 } from "fs";
30075
- import { join as join18, dirname as dirname4 } from "path";
30576
+ import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync5 } from "fs";
30577
+ import { join as join18, dirname as dirname5 } from "path";
30076
30578
  var SCOPE_BRIEF_SYSTEM = `You are a technical scoping tool. You receive a brief-class task (too large to build directly) and decompose it into a structured scope document.
30077
30579
 
30078
30580
  A scope document must:
@@ -30135,8 +30637,8 @@ async function applyScopeBrief(adapter2, input) {
30135
30637
  if (input.adapterType === "proxy") {
30136
30638
  collector.add({ path: relPath, content: docBody, mode: "overwrite" });
30137
30639
  } else {
30138
- mkdirSync4(dirname4(absPath), { recursive: true });
30139
- writeFileSync6(absPath, docBody, "utf-8");
30640
+ mkdirSync5(dirname5(absPath), { recursive: true });
30641
+ writeFileSync7(absPath, docBody, "utf-8");
30140
30642
  }
30141
30643
  const taskCount = countSubTasks(docContent);
30142
30644
  const summary = buildSummary(task, taskCount);
@@ -30422,34 +30924,75 @@ ${formatted}`, meta));
30422
30924
  }
30423
30925
 
30424
30926
  // src/lib/dist-staleness.ts
30425
- import { statSync as statSync8 } from "fs";
30927
+ import { readFileSync as readFileSync13, statSync as statSync8 } from "fs";
30928
+ import { createHash as createHash5 } from "crypto";
30426
30929
  import { fileURLToPath as fileURLToPath3 } from "url";
30427
30930
  var BOOT_MS = Date.now();
30428
30931
  var DEFAULT_SKEW_MS = 2e3;
30429
30932
  var STAT_TTL_MS = 1e4;
30430
- function isDistStale({ bootMs, distMtimeMs, skewMs = DEFAULT_SKEW_MS }) {
30933
+ var SELF_PATH = (() => {
30934
+ try {
30935
+ return fileURLToPath3(import.meta.url);
30936
+ } catch {
30937
+ return null;
30938
+ }
30939
+ })();
30940
+ function hashFile(path7) {
30941
+ try {
30942
+ return createHash5("sha256").update(readFileSync13(path7)).digest("hex");
30943
+ } catch {
30944
+ return null;
30945
+ }
30946
+ }
30947
+ var BOOT_HASH = SELF_PATH ? hashFile(SELF_PATH) : null;
30948
+ function bundleRewritten({ bootMs, distMtimeMs, skewMs = DEFAULT_SKEW_MS }) {
30431
30949
  if (distMtimeMs === null || !Number.isFinite(distMtimeMs)) return false;
30432
30950
  return distMtimeMs > bootMs + skewMs;
30433
30951
  }
30952
+ function isDistStale({ bootHash, distHash }) {
30953
+ if (!bootHash || !distHash) return false;
30954
+ return bootHash !== distHash;
30955
+ }
30956
+ function shouldAnnounce(currentHash2, alreadyAnnouncedHash) {
30957
+ if (!currentHash2) return false;
30958
+ return currentHash2 !== alreadyAnnouncedHash;
30959
+ }
30434
30960
  var cached = null;
30435
- function checkDistStaleness(now = Date.now()) {
30436
- if (cached && now - cached.at < STAT_TTL_MS) return cached.stale;
30961
+ var currentHash = BOOT_HASH;
30962
+ var announcedHash = null;
30963
+ function computeStale() {
30964
+ if (!SELF_PATH || !BOOT_HASH) return false;
30437
30965
  let distMtimeMs = null;
30438
30966
  try {
30439
- distMtimeMs = statSync8(fileURLToPath3(import.meta.url)).mtimeMs;
30967
+ distMtimeMs = statSync8(SELF_PATH).mtimeMs;
30440
30968
  } catch {
30441
- distMtimeMs = null;
30969
+ return false;
30442
30970
  }
30443
- const stale = isDistStale({ bootMs: BOOT_MS, distMtimeMs });
30971
+ if (!bundleRewritten({ bootMs: BOOT_MS, distMtimeMs })) {
30972
+ currentHash = BOOT_HASH;
30973
+ return false;
30974
+ }
30975
+ currentHash = hashFile(SELF_PATH);
30976
+ return isDistStale({ bootHash: BOOT_HASH, distHash: currentHash });
30977
+ }
30978
+ function checkDistStaleness(now = Date.now()) {
30979
+ if (cached && now - cached.at < STAT_TTL_MS) return cached.stale;
30980
+ const stale = computeStale();
30444
30981
  cached = { at: now, stale };
30445
30982
  return stale;
30446
30983
  }
30447
30984
  function stalenessWarning(now = Date.now()) {
30448
30985
  const age = Math.round((now - BOOT_MS) / 1e3);
30449
- return `\u26A0\uFE0F This PAPI MCP server process booted ${age}s ago and packages/server has been REBUILT since. It is still running the code it booted with, so anything you verify through these tools right now reflects the OLD bundle. Reconnect / restart the PAPI MCP server before trusting this output.
30986
+ return `\u26A0\uFE0F This PAPI MCP server process booted ${age}s ago and packages/server has been REBUILT with DIFFERENT code since. It is still running the code it booted with, so anything you verify through these tools right now reflects the OLD bundle. Reconnect / restart the PAPI MCP server before trusting this output. (Shown once per rebuild \u2014 the condition persists until you restart.)
30450
30987
 
30451
30988
  `;
30452
30989
  }
30990
+ function consumeStalenessWarning(now = Date.now()) {
30991
+ if (!checkDistStaleness(now)) return null;
30992
+ if (!shouldAnnounce(currentHash, announcedHash)) return null;
30993
+ announcedHash = currentHash;
30994
+ return stalenessWarning(now);
30995
+ }
30453
30996
 
30454
30997
  // src/tools/learning-action.ts
30455
30998
  var learningActionTool = {
@@ -30578,54 +31121,6 @@ async function handleDiscoveredIssueResolve(adapter2, args) {
30578
31121
 
30579
31122
  // src/tools/project.ts
30580
31123
  import path6 from "path";
30581
-
30582
- // src/services/entitlements.ts
30583
- import { evaluateContributorGate } from "@papi-ai/shared";
30584
- var FREE_PROJECT_CAP = 3;
30585
- var PAID_TIERS = /* @__PURE__ */ new Set(["pro", "team"]);
30586
- var PRICING_URL = "https://getpapi.ai/pricing";
30587
- async function resolveTier(adapter2) {
30588
- if (typeof adapter2.getMeteredUsage !== "function") return null;
30589
- try {
30590
- const usage = await adapter2.getMeteredUsage();
30591
- return usage?.tier ?? null;
30592
- } catch {
30593
- return null;
30594
- }
30595
- }
30596
- function isPaidTier(tier) {
30597
- return tier !== null && PAID_TIERS.has(tier);
30598
- }
30599
- async function enforceProjectCap(adapter2, target) {
30600
- const tier = await resolveTier(adapter2);
30601
- if (tier === null || isPaidTier(tier)) return null;
30602
- if (typeof adapter2.listUserProjects !== "function") return null;
30603
- let projects;
30604
- try {
30605
- projects = await adapter2.listUserProjects();
30606
- } catch {
30607
- return null;
30608
- }
30609
- const matchesExisting = projects.some(
30610
- (p) => target.papiDir && p.papi_dir && p.papi_dir === target.papiDir || target.name && p.name && p.name.trim().toLowerCase() === target.name.trim().toLowerCase()
30611
- );
30612
- if (matchesExisting) return null;
30613
- if (projects.length >= FREE_PROJECT_CAP) return projectCapMessage(projects.length);
30614
- return null;
30615
- }
30616
- async function resolveContributorUpsell(adapter2) {
30617
- const tier = await resolveTier(adapter2);
30618
- return evaluateContributorGate(tier).upsell ?? null;
30619
- }
30620
- function projectCapMessage(currentCount) {
30621
- return `**You're on the Free plan (${currentCount} of ${FREE_PROJECT_CAP} projects).**
30622
-
30623
- Free covers up to ${FREE_PROJECT_CAP} projects. To run more, upgrade to Pro for unlimited projects at a flat monthly price (no usage bills): ${PRICING_URL}
30624
-
30625
- Your existing projects are untouched, and you can keep working in any of them.`;
30626
- }
30627
-
30628
- // src/tools/project.ts
30629
31124
  function workspacePapiDir(config2) {
30630
31125
  if (!hasLocalWorkspace()) return void 0;
30631
31126
  return config2.papiDir;
@@ -31095,7 +31590,7 @@ Its build reports, comments, and history moved with it; the cycle assignment was
31095
31590
  // src/services/harness-inventory.ts
31096
31591
  import { readdir as readdir3, readFile as readFile8, stat as stat3 } from "fs/promises";
31097
31592
  import { join as join19 } from "path";
31098
- import { createHash as createHash5 } from "crypto";
31593
+ import { createHash as createHash6 } from "crypto";
31099
31594
  var RECOMMENDED_HOOKS = ["stop-release-check.sh", "claude-md-size-guard.sh"];
31100
31595
  async function computeFingerprint(root) {
31101
31596
  const parts = [];
@@ -31121,7 +31616,7 @@ async function computeFingerprint(root) {
31121
31616
  } catch {
31122
31617
  parts.push("manifest:none");
31123
31618
  }
31124
- return createHash5("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
31619
+ return createHash6("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
31125
31620
  }
31126
31621
  async function readSkillDescription(skillDir) {
31127
31622
  try {
@@ -31591,6 +32086,7 @@ var PAPI_TOOLS = [
31591
32086
  docActionPromoteTool,
31592
32087
  docDeleteTool,
31593
32088
  docReorderTool,
32089
+ docReadTool,
31594
32090
  getSiblingAdsTool,
31595
32091
  handoffGenerateTool,
31596
32092
  scopeBriefTool,
@@ -31614,10 +32110,10 @@ function getToolMetadata() {
31614
32110
  }
31615
32111
  function createServer(adapter2, config2) {
31616
32112
  const __pkgFilename = fileURLToPath4(import.meta.url);
31617
- const __pkgDir = dirname5(__pkgFilename);
32113
+ const __pkgDir = dirname6(__pkgFilename);
31618
32114
  let serverVersion = "unknown";
31619
32115
  try {
31620
- const pkg = JSON.parse(readFileSync13(join20(__pkgDir, "..", "package.json"), "utf-8"));
32116
+ const pkg = JSON.parse(readFileSync14(join20(__pkgDir, "..", "package.json"), "utf-8"));
31621
32117
  serverVersion = pkg.version ?? "unknown";
31622
32118
  } catch {
31623
32119
  }
@@ -31634,7 +32130,7 @@ function createServer(adapter2, config2) {
31634
32130
  );
31635
32131
  }
31636
32132
  const __filename = fileURLToPath4(import.meta.url);
31637
- const __dirname2 = dirname5(__filename);
32133
+ const __dirname2 = dirname6(__filename);
31638
32134
  const skillsDir = join20(__dirname2, "..", "skills");
31639
32135
  function parseSkillFrontmatter(content) {
31640
32136
  const match = content.match(/^---\n([\s\S]*?)\n---/);
@@ -31795,6 +32291,8 @@ function createServer(adapter2, config2) {
31795
32291
  return handleDocDelete(adapter2, config2, safeArgs);
31796
32292
  case "doc_reorder":
31797
32293
  return handleDocReorder(adapter2, safeArgs);
32294
+ case "doc_read":
32295
+ return handleDocRead(adapter2, config2, safeArgs);
31798
32296
  case "get_sibling_ads":
31799
32297
  return handleGetSiblingAds(adapter2, safeArgs);
31800
32298
  case "handoff_generate":
@@ -31936,10 +32434,11 @@ ${usageLine(decision.usage)}`;
31936
32434
  const footer = formatMetricsFooter(elapsed, usage, contextBytes);
31937
32435
  result.content.push({ type: "text", text: footer });
31938
32436
  try {
31939
- if (checkDistStaleness() && result.content.length > 0) {
32437
+ const warning = consumeStalenessWarning();
32438
+ if (warning && result.content.length > 0) {
31940
32439
  const first = result.content[0];
31941
32440
  if (first && typeof first.text === "string") {
31942
- first.text = stalenessWarning() + first.text;
32441
+ first.text = warning + first.text;
31943
32442
  }
31944
32443
  }
31945
32444
  } catch {
@@ -32389,10 +32888,10 @@ async function dispatchRequest(args) {
32389
32888
  }
32390
32889
 
32391
32890
  // src/index.ts
32392
- var __dirname = dirname6(fileURLToPath5(import.meta.url));
32891
+ var __dirname = dirname7(fileURLToPath5(import.meta.url));
32393
32892
  var pkgVersion = "unknown";
32394
32893
  try {
32395
- const pkg = JSON.parse(readFileSync18(join25(__dirname, "..", "package.json"), "utf-8"));
32894
+ const pkg = JSON.parse(readFileSync19(join25(__dirname, "..", "package.json"), "utf-8"));
32396
32895
  pkgVersion = pkg.version;
32397
32896
  } catch {
32398
32897
  }