@papi-ai/server 0.7.38 → 0.7.41

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
@@ -22,6 +22,7 @@ __export(git_exports, {
22
22
  cycleBranchName: () => cycleBranchName,
23
23
  deleteLocalBranch: () => deleteLocalBranch,
24
24
  detectBoardMismatches: () => detectBoardMismatches,
25
+ detectMergedInProgress: () => detectMergedInProgress,
25
26
  detectUnrecordedCommits: () => detectUnrecordedCommits,
26
27
  ensureLatestDevelop: () => ensureLatestDevelop,
27
28
  ensureTagAtHead: () => ensureTagAtHead,
@@ -44,6 +45,7 @@ __export(git_exports, {
44
45
  getStagedFiles: () => getStagedFiles,
45
46
  getTagTarget: () => getTagTarget,
46
47
  getTaskIdsOnBranch: () => getTaskIdsOnBranch,
48
+ getTrackedModifiedFiles: () => getTrackedModifiedFiles,
47
49
  getUnmergedBranches: () => getUnmergedBranches,
48
50
  getUntrackedFiles: () => getUntrackedFiles,
49
51
  gitPull: () => gitPull,
@@ -62,6 +64,7 @@ __export(git_exports, {
62
64
  normalizeGitUrl: () => normalizeGitUrl,
63
65
  pickModuleCycleBranch: () => pickModuleCycleBranch,
64
66
  resolveBaseBranch: () => resolveBaseBranch,
67
+ resolveDefaultBranch: () => resolveDefaultBranch,
65
68
  runAutoCommit: () => runAutoCommit,
66
69
  squashMergePullRequest: () => squashMergePullRequest,
67
70
  stageAllAndCommit: () => stageAllAndCommit,
@@ -176,6 +179,25 @@ function getModifiedFiles(cwd) {
176
179
  return [];
177
180
  }
178
181
  }
182
+ function getTrackedModifiedFiles(cwd) {
183
+ try {
184
+ const out = execFileSync("git", ["status", "--porcelain"], {
185
+ cwd,
186
+ encoding: "utf-8"
187
+ }).replace(/\n+$/, "");
188
+ if (!out) return [];
189
+ const paths = /* @__PURE__ */ new Set();
190
+ for (const line of out.split("\n")) {
191
+ if (line.length < 4) continue;
192
+ if (line.startsWith("??")) continue;
193
+ const path7 = line.slice(3).trim();
194
+ if (path7) paths.add(path7);
195
+ }
196
+ return Array.from(paths);
197
+ } catch {
198
+ return [];
199
+ }
200
+ }
179
201
  function getUntrackedFiles(cwd) {
180
202
  try {
181
203
  const out = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
@@ -640,6 +662,22 @@ function resolveBaseBranch(cwd, preferred) {
640
662
  if (preferred !== "master" && branchExists(cwd, "master")) return "master";
641
663
  return preferred;
642
664
  }
665
+ function resolveDefaultBranch(cwd, preferred) {
666
+ try {
667
+ const ref = execFileSync(
668
+ "git",
669
+ ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
670
+ { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
671
+ ).trim();
672
+ const name = ref.replace(/^origin\//, "");
673
+ if (name && branchExists(cwd, name)) return name;
674
+ } catch {
675
+ }
676
+ for (const candidate of [preferred, "main", "master", "trunk"]) {
677
+ if (candidate && branchExists(cwd, candidate)) return candidate;
678
+ }
679
+ return preferred;
680
+ }
643
681
  function detectBoardMismatches(cwd, tasks) {
644
682
  const empty = { codeAhead: [], staleInProgress: [] };
645
683
  if (!isGitAvailable() || !isGitRepo(cwd)) return empty;
@@ -673,6 +711,46 @@ function detectBoardMismatches(cwd, tasks) {
673
711
  return empty;
674
712
  }
675
713
  }
714
+ function escapeRegexLiteral(s) {
715
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
716
+ }
717
+ function detectMergedInProgress(cwd, preferredBase, tasks) {
718
+ if (!isGitAvailable() || !isGitRepo(cwd)) return [];
719
+ const inProgress = tasks.filter((t) => t.status === "In Progress");
720
+ if (inProgress.length === 0) return [];
721
+ const base = resolveDefaultBranch(cwd, preferredBase);
722
+ if (!branchExists(cwd, base)) return [];
723
+ let raw;
724
+ try {
725
+ raw = execFileSync(
726
+ "git",
727
+ ["log", base, "--format=%h%x01%s", "-n", "1000"],
728
+ { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
729
+ );
730
+ } catch {
731
+ return [];
732
+ }
733
+ const commits = raw.split("\n").map((line) => {
734
+ const idx = line.indexOf("");
735
+ if (idx === -1) return null;
736
+ return { hash: line.slice(0, idx).trim(), subject: line.slice(idx + 1).trim() };
737
+ }).filter((c) => c !== null && c.hash !== "");
738
+ const hits = [];
739
+ for (const task of inProgress) {
740
+ const re = new RegExp(`(^|[^\\w-])${escapeRegexLiteral(task.displayId)}([^\\w-]|$)`);
741
+ const hit = commits.find((c) => re.test(c.subject));
742
+ if (!hit) continue;
743
+ const prMatch = hit.subject.match(/#(\d+)/);
744
+ hits.push({
745
+ displayId: task.displayId,
746
+ title: task.title,
747
+ commit: hit.hash,
748
+ subject: hit.subject,
749
+ pr: prMatch ? `#${prMatch[1]}` : null
750
+ });
751
+ }
752
+ return hits;
753
+ }
676
754
  function detectUnrecordedCommits(cwd, baseBranch) {
677
755
  if (!isGitAvailable() || !isGitRepo(cwd)) return [];
678
756
  const latestTag = getLatestTag(cwd);
@@ -1236,6 +1314,13 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
1236
1314
  updateTaskStatus(id, status) {
1237
1315
  return this.invoke("updateTaskStatus", [id, status]);
1238
1316
  }
1317
+ // task-2292: cross-project move. The bound projectId (set on this adapter) is
1318
+ // the SOURCE; the target is passed in args. Ownership of BOTH projects is
1319
+ // verified server-side in the edge function from the bearer — callerUserId is
1320
+ // ignored here (cannot be spoofed). The edge resolves a target slug → UUID.
1321
+ moveTask(taskId, targetProject, _callerUserId) {
1322
+ return this.invoke("moveTask", [taskId, targetProject]);
1323
+ }
1239
1324
  // task-2155 (MU-3 task B): hosted/proxy claim write-path. The data-proxy gates
1240
1325
  // these via WRITE_METHODS (active editor may write, viewer cannot) and binds the
1241
1326
  // assignee to the bearer-derived caller server-side — the assigneeId passed here
@@ -1325,6 +1410,33 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
1325
1410
  appendToolMetric(metric) {
1326
1411
  return this.invoke("appendToolMetric", [metric]);
1327
1412
  }
1413
+ /**
1414
+ * task-2288 (C308): emit a telemetry event via the proxy's /telemetry endpoint
1415
+ * using THIS adapter's per-request bearer (this.apiKey) — the same credential
1416
+ * invoke() uses. This is the fix for the hosted-transport blackout: on the
1417
+ * multi-tenant Railway pod there is no PAPI_DATA_API_KEY env var, so the
1418
+ * env-var emitTelemetryEvent path (lib/telemetry.ts) dropped every event since
1419
+ * 2026-06-16. Fire-and-forget — never throws, never blocks the tool response.
1420
+ * Not routed through invoke() because /telemetry is a distinct endpoint and a
1421
+ * telemetry failure must never surface as a tool error.
1422
+ */
1423
+ emitTelemetry(event) {
1424
+ fetch(`${this.endpoint}/telemetry`, {
1425
+ method: "POST",
1426
+ headers: {
1427
+ "Content-Type": "application/json",
1428
+ "Authorization": `Bearer ${this.apiKey}`
1429
+ },
1430
+ body: JSON.stringify({
1431
+ projectId: event.projectId,
1432
+ toolName: event.toolName,
1433
+ eventType: event.eventType,
1434
+ metadata: event.metadata ?? {}
1435
+ }),
1436
+ signal: AbortSignal.timeout(5e3)
1437
+ }).catch(() => {
1438
+ });
1439
+ }
1328
1440
  readToolMetrics() {
1329
1441
  return this.invoke("readToolMetrics");
1330
1442
  }
@@ -1476,6 +1588,15 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
1476
1588
  submitBugReport(report) {
1477
1589
  return this.invoke("submitBugReport", [report]);
1478
1590
  }
1591
+ // task-2270: notify-back. The data-proxy ignores the client-supplied userId
1592
+ // and scopes to the bearer-validated caller (same pattern as the owner-action
1593
+ // counts), so a user can only ever read/mark their OWN resolved submissions.
1594
+ getUnnotifiedResolvedFeedback(userId) {
1595
+ return this.invoke("getUnnotifiedResolvedFeedback", [userId]);
1596
+ }
1597
+ markFeedbackNotified(ids, userId) {
1598
+ return this.invoke("markFeedbackNotified", [ids, userId]);
1599
+ }
1479
1600
  // --- Doc Registry ---
1480
1601
  registerDoc(entry) {
1481
1602
  return this.invoke("registerDoc", [entry]);
@@ -1656,9 +1777,9 @@ var init_query = __esm({
1656
1777
  CLOSE = {};
1657
1778
  Query = class extends Promise {
1658
1779
  constructor(strings, args, handler, canceller, options = {}) {
1659
- let resolve2, reject;
1780
+ let resolve3, reject;
1660
1781
  super((a, b2) => {
1661
- resolve2 = a;
1782
+ resolve3 = a;
1662
1783
  reject = b2;
1663
1784
  });
1664
1785
  this.tagged = Array.isArray(strings.raw);
@@ -1669,7 +1790,7 @@ var init_query = __esm({
1669
1790
  this.options = options;
1670
1791
  this.state = null;
1671
1792
  this.statement = null;
1672
- this.resolve = (x) => (this.active = false, resolve2(x));
1793
+ this.resolve = (x) => (this.active = false, resolve3(x));
1673
1794
  this.reject = (x) => (this.active = false, reject(x));
1674
1795
  this.active = false;
1675
1796
  this.cancelled = null;
@@ -1717,12 +1838,12 @@ var init_query = __esm({
1717
1838
  if (this.executed && !this.active)
1718
1839
  return { done: true };
1719
1840
  prev && prev();
1720
- const promise = new Promise((resolve2, reject) => {
1841
+ const promise = new Promise((resolve3, reject) => {
1721
1842
  this.cursorFn = (value) => {
1722
- resolve2({ value, done: false });
1843
+ resolve3({ value, done: false });
1723
1844
  return new Promise((r) => prev = r);
1724
1845
  };
1725
- this.resolve = () => (this.active = false, resolve2({ done: true }));
1846
+ this.resolve = () => (this.active = false, resolve3({ done: true }));
1726
1847
  this.reject = (x) => (this.active = false, reject(x));
1727
1848
  });
1728
1849
  this.execute();
@@ -2320,12 +2441,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
2320
2441
  x.on("drain", drain);
2321
2442
  return x;
2322
2443
  }
2323
- async function cancel({ pid, secret }, resolve2, reject) {
2444
+ async function cancel({ pid, secret }, resolve3, reject) {
2324
2445
  try {
2325
2446
  cancelMessage = bytes_default().i32(16).i32(80877102).i32(pid).i32(secret).end(16);
2326
2447
  await connect();
2327
2448
  socket.once("error", reject);
2328
- socket.once("close", resolve2);
2449
+ socket.once("close", resolve3);
2329
2450
  } catch (error2) {
2330
2451
  reject(error2);
2331
2452
  }
@@ -3342,7 +3463,7 @@ var init_subscribe = __esm({
3342
3463
  // ../../node_modules/postgres/src/large.js
3343
3464
  import Stream2 from "stream";
3344
3465
  function largeObject(sql, oid, mode = 131072 | 262144) {
3345
- return new Promise(async (resolve2, reject) => {
3466
+ return new Promise(async (resolve3, reject) => {
3346
3467
  await sql.begin(async (sql2) => {
3347
3468
  let finish;
3348
3469
  !oid && ([{ oid }] = await sql2`select lo_creat(-1) as oid`);
@@ -3368,7 +3489,7 @@ function largeObject(sql, oid, mode = 131072 | 262144) {
3368
3489
  ) seek
3369
3490
  `
3370
3491
  };
3371
- resolve2(lo);
3492
+ resolve3(lo);
3372
3493
  return new Promise(async (r) => finish = r);
3373
3494
  async function readable({
3374
3495
  highWaterMark = 2048 * 8,
@@ -3533,8 +3654,8 @@ function Postgres(a, b2) {
3533
3654
  }
3534
3655
  async function reserve() {
3535
3656
  const queue = queue_default();
3536
- const c = open.length ? open.shift() : await new Promise((resolve2, reject) => {
3537
- const query = { reserve: resolve2, reject };
3657
+ const c = open.length ? open.shift() : await new Promise((resolve3, reject) => {
3658
+ const query = { reserve: resolve3, reject };
3538
3659
  queries.push(query);
3539
3660
  closed.length && connect(closed.shift(), query);
3540
3661
  });
@@ -3571,9 +3692,9 @@ function Postgres(a, b2) {
3571
3692
  let uncaughtError, result;
3572
3693
  name && await sql2`savepoint ${sql2(name)}`;
3573
3694
  try {
3574
- result = await new Promise((resolve2, reject) => {
3695
+ result = await new Promise((resolve3, reject) => {
3575
3696
  const x = fn2(sql2);
3576
- Promise.resolve(Array.isArray(x) ? Promise.all(x) : x).then(resolve2, reject);
3697
+ Promise.resolve(Array.isArray(x) ? Promise.all(x) : x).then(resolve3, reject);
3577
3698
  });
3578
3699
  if (uncaughtError)
3579
3700
  throw uncaughtError;
@@ -3630,8 +3751,8 @@ function Postgres(a, b2) {
3630
3751
  return c.execute(query) ? move(c, busy) : move(c, full);
3631
3752
  }
3632
3753
  function cancel(query) {
3633
- return new Promise((resolve2, reject) => {
3634
- query.state ? query.active ? connection_default(options).cancel(query.state, resolve2, reject) : query.cancelled = { resolve: resolve2, reject } : (queries.remove(query), query.cancelled = true, query.reject(Errors.generic("57014", "canceling statement due to user request")), resolve2());
3754
+ return new Promise((resolve3, reject) => {
3755
+ 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());
3635
3756
  });
3636
3757
  }
3637
3758
  async function end({ timeout = null } = {}) {
@@ -3650,11 +3771,11 @@ function Postgres(a, b2) {
3650
3771
  async function close() {
3651
3772
  await Promise.all(connections.map((c) => c.end()));
3652
3773
  }
3653
- async function destroy(resolve2) {
3774
+ async function destroy(resolve3) {
3654
3775
  await Promise.all(connections.map((c) => c.terminate()));
3655
3776
  while (queries.length)
3656
3777
  queries.shift().reject(Errors.connection("CONNECTION_DESTROYED", options));
3657
- resolve2();
3778
+ resolve3();
3658
3779
  }
3659
3780
  function connect(c, query) {
3660
3781
  move(c, connecting);
@@ -3838,9 +3959,9 @@ __export(doctor_exports, {
3838
3959
  __testing: () => __testing,
3839
3960
  runDoctor: () => runDoctor
3840
3961
  });
3841
- import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
3962
+ import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
3842
3963
  import { homedir as homedir4 } from "os";
3843
- import { join as join18 } from "path";
3964
+ import { join as join19 } from "path";
3844
3965
  function redact(name, value) {
3845
3966
  if (!value) return "(empty)";
3846
3967
  if (SECRET_VARS.has(name)) {
@@ -3851,14 +3972,14 @@ function redact(name, value) {
3851
3972
  }
3852
3973
  function findMcpJson() {
3853
3974
  const candidates = [
3854
- join18(process.cwd(), ".mcp.json"),
3855
- join18(homedir4(), ".claude", ".mcp.json"),
3856
- join18(homedir4(), ".mcp.json")
3975
+ join19(process.cwd(), ".mcp.json"),
3976
+ join19(homedir4(), ".claude", ".mcp.json"),
3977
+ join19(homedir4(), ".mcp.json")
3857
3978
  ];
3858
3979
  for (const path7 of candidates) {
3859
- if (!existsSync9(path7)) continue;
3980
+ if (!existsSync10(path7)) continue;
3860
3981
  try {
3861
- const raw = readFileSync10(path7, "utf-8");
3982
+ const raw = readFileSync11(path7, "utf-8");
3862
3983
  const parsed = JSON.parse(raw);
3863
3984
  const papiEntry = parsed.papi ?? parsed.mcpServers?.papi;
3864
3985
  if (!papiEntry) continue;
@@ -4132,17 +4253,17 @@ __export(reset_exports, {
4132
4253
  removePapiEntry: () => removePapiEntry,
4133
4254
  runReset: () => runReset
4134
4255
  });
4135
- import { existsSync as existsSync10, readFileSync as readFileSync11, writeFileSync as writeFileSync5 } from "fs";
4256
+ import { existsSync as existsSync11, readFileSync as readFileSync12, writeFileSync as writeFileSync5 } from "fs";
4136
4257
  import { homedir as homedir5 } from "os";
4137
- import { join as join19 } from "path";
4258
+ import { join as join20 } from "path";
4138
4259
  import { createInterface } from "readline/promises";
4139
4260
  function findResetTarget() {
4140
4261
  for (const path7 of CANDIDATE_PATHS()) {
4141
- if (!existsSync10(path7)) continue;
4262
+ if (!existsSync11(path7)) continue;
4142
4263
  let raw;
4143
4264
  let parsed;
4144
4265
  try {
4145
- raw = readFileSync11(path7, "utf-8");
4266
+ raw = readFileSync12(path7, "utf-8");
4146
4267
  parsed = JSON.parse(raw);
4147
4268
  } catch {
4148
4269
  continue;
@@ -4230,9 +4351,9 @@ var init_reset = __esm({
4230
4351
  "src/cli/reset.ts"() {
4231
4352
  "use strict";
4232
4353
  CANDIDATE_PATHS = () => [
4233
- join19(process.cwd(), ".mcp.json"),
4234
- join19(homedir5(), ".claude", ".mcp.json"),
4235
- join19(homedir5(), ".mcp.json")
4354
+ join20(process.cwd(), ".mcp.json"),
4355
+ join20(homedir5(), ".claude", ".mcp.json"),
4356
+ join20(homedir5(), ".mcp.json")
4236
4357
  ];
4237
4358
  }
4238
4359
  });
@@ -4243,9 +4364,9 @@ __export(audit_exports, {
4243
4364
  __testing: () => __testing2,
4244
4365
  runAudit: () => runAudit
4245
4366
  });
4246
- import { existsSync as existsSync11, readFileSync as readFileSync12, readdirSync as readdirSync7 } from "fs";
4367
+ import { existsSync as existsSync12, readFileSync as readFileSync13, readdirSync as readdirSync7 } from "fs";
4247
4368
  import { homedir as homedir6 } from "os";
4248
- import { join as join20 } from "path";
4369
+ import { join as join21 } from "path";
4249
4370
  function safeListDirs(dir) {
4250
4371
  try {
4251
4372
  return readdirSync7(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort((a, b2) => a.localeCompare(b2));
@@ -4261,10 +4382,10 @@ function safeListFiles(dir, ext) {
4261
4382
  }
4262
4383
  }
4263
4384
  function readMcp(projectPath) {
4264
- const path7 = join20(projectPath, ".mcp.json");
4265
- if (!existsSync11(path7)) return { servers: [] };
4385
+ const path7 = join21(projectPath, ".mcp.json");
4386
+ if (!existsSync12(path7)) return { servers: [] };
4266
4387
  try {
4267
- const parsed = JSON.parse(readFileSync12(path7, "utf-8"));
4388
+ const parsed = JSON.parse(readFileSync13(path7, "utf-8"));
4268
4389
  const mcpServers = parsed.mcpServers ?? {};
4269
4390
  const servers = Object.keys(mcpServers);
4270
4391
  if (parsed.papi && !servers.includes("papi")) servers.push("papi");
@@ -4296,18 +4417,18 @@ function auditProjectSync(projectPath, name) {
4296
4417
  path: projectPath,
4297
4418
  papiProjectId,
4298
4419
  mcpServers: servers,
4299
- skills: safeListDirs(join20(projectPath, ".claude", "skills")),
4300
- agentSkills: safeListDirs(join20(projectPath, ".agents", "skills")),
4301
- agents: safeListFiles(join20(projectPath, ".claude", "agents"), ".md"),
4302
- hooks: safeListFiles(join20(projectPath, ".claude", "hooks"), ".sh")
4420
+ skills: safeListDirs(join21(projectPath, ".claude", "skills")),
4421
+ agentSkills: safeListDirs(join21(projectPath, ".agents", "skills")),
4422
+ agents: safeListFiles(join21(projectPath, ".claude", "agents"), ".md"),
4423
+ hooks: safeListFiles(join21(projectPath, ".claude", "hooks"), ".sh")
4303
4424
  };
4304
4425
  }
4305
4426
  function discoverProjects() {
4306
4427
  const out = [];
4307
4428
  for (const root of PROJECT_ROOTS) {
4308
4429
  for (const name of safeListDirs(root)) {
4309
- const path7 = join20(root, name);
4310
- if (existsSync11(join20(path7, ".mcp.json")) || existsSync11(join20(path7, ".claude"))) {
4430
+ const path7 = join21(root, name);
4431
+ if (existsSync12(join21(path7, ".mcp.json")) || existsSync12(join21(path7, ".claude"))) {
4311
4432
  out.push({ name, path: path7 });
4312
4433
  }
4313
4434
  }
@@ -4318,9 +4439,9 @@ function readGlobalSkills() {
4318
4439
  return safeListDirs(GLOBAL_SKILLS_DIR);
4319
4440
  }
4320
4441
  function readGlobalMcpServers() {
4321
- if (!existsSync11(GLOBAL_CLAUDE_JSON)) return [];
4442
+ if (!existsSync12(GLOBAL_CLAUDE_JSON)) return [];
4322
4443
  try {
4323
- const parsed = JSON.parse(readFileSync12(GLOBAL_CLAUDE_JSON, "utf-8"));
4444
+ const parsed = JSON.parse(readFileSync13(GLOBAL_CLAUDE_JSON, "utf-8"));
4324
4445
  const servers = parsed.mcpServers ?? {};
4325
4446
  return Object.keys(servers).sort((a, b2) => a.localeCompare(b2));
4326
4447
  } catch {
@@ -4472,9 +4593,9 @@ var PROJECT_ROOTS, GLOBAL_SKILLS_DIR, GLOBAL_CLAUDE_JSON, IDLE_WINDOW_DAYS, GLOB
4472
4593
  var init_audit = __esm({
4473
4594
  "src/cli/audit.ts"() {
4474
4595
  "use strict";
4475
- PROJECT_ROOTS = [join20(homedir6(), "Ai-App-Projects"), join20(homedir6(), "android-projects")];
4476
- GLOBAL_SKILLS_DIR = join20(homedir6(), ".claude", "skills");
4477
- GLOBAL_CLAUDE_JSON = join20(homedir6(), ".claude.json");
4596
+ PROJECT_ROOTS = [join21(homedir6(), "Ai-App-Projects"), join21(homedir6(), "android-projects")];
4597
+ GLOBAL_SKILLS_DIR = join21(homedir6(), ".claude", "skills");
4598
+ GLOBAL_CLAUDE_JSON = join21(homedir6(), ".claude.json");
4478
4599
  IDLE_WINDOW_DAYS = 30;
4479
4600
  GLOBALIZE_THRESHOLD = 3;
4480
4601
  __testing2 = { readMcp, computeFlags, formatReport: formatReport2, discoverProjects, auditProjectSync };
@@ -4486,8 +4607,8 @@ var setup_exports = {};
4486
4607
  __export(setup_exports, {
4487
4608
  runSetup: () => runSetup
4488
4609
  });
4489
- import { existsSync as existsSync12, readFileSync as readFileSync13, writeFileSync as writeFileSync6, chmodSync as chmodSync2, statSync as statSync5 } from "fs";
4490
- import { join as join21 } from "path";
4610
+ import { existsSync as existsSync13, readFileSync as readFileSync14, writeFileSync as writeFileSync6, chmodSync as chmodSync2, statSync as statSync6 } from "fs";
4611
+ import { join as join22 } from "path";
4491
4612
  function baseUrl() {
4492
4613
  const fromEnv = process.env["PAPI_HOST"] ?? process.env["PAPI_BASE_URL"];
4493
4614
  if (fromEnv) return fromEnv.replace(/\/$/, "");
@@ -4516,14 +4637,14 @@ async function postJson(url, body) {
4516
4637
  return { status: res.status, data };
4517
4638
  }
4518
4639
  function sleep(ms) {
4519
- return new Promise((resolve2) => setTimeout(resolve2, ms));
4640
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
4520
4641
  }
4521
4642
  function writeMcpJson(opts) {
4522
- const path7 = join21(process.cwd(), ".mcp.json");
4643
+ const path7 = join22(process.cwd(), ".mcp.json");
4523
4644
  let parsed = {};
4524
- if (existsSync12(path7)) {
4645
+ if (existsSync13(path7)) {
4525
4646
  try {
4526
- parsed = JSON.parse(readFileSync13(path7, "utf-8"));
4647
+ parsed = JSON.parse(readFileSync14(path7, "utf-8"));
4527
4648
  } catch {
4528
4649
  throw new Error(`.mcp.json at ${path7} is not valid JSON. Fix it or remove it before re-running setup.`);
4529
4650
  }
@@ -4546,7 +4667,7 @@ function writeMcpJson(opts) {
4546
4667
  parsed.mcpServers = mcpServers;
4547
4668
  writeFileSync6(path7, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
4548
4669
  try {
4549
- const mode = statSync5(path7).mode & 511;
4670
+ const mode = statSync6(path7).mode & 511;
4550
4671
  if (mode !== 384) chmodSync2(path7, 384);
4551
4672
  } catch {
4552
4673
  }
@@ -4654,9 +4775,9 @@ var init_setup = __esm({
4654
4775
  });
4655
4776
 
4656
4777
  // src/index.ts
4657
- import { readFileSync as readFileSync14 } from "fs";
4658
- import { dirname as dirname5, join as join22 } from "path";
4659
- import { fileURLToPath as fileURLToPath3 } from "url";
4778
+ import { readFileSync as readFileSync15 } from "fs";
4779
+ import { dirname as dirname6, join as join23 } from "path";
4780
+ import { fileURLToPath as fileURLToPath4 } from "url";
4660
4781
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4661
4782
  import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
4662
4783
  import {
@@ -4688,6 +4809,8 @@ var HELP_FOOTER_MD = `
4688
4809
  `;
4689
4810
 
4690
4811
  // src/config.ts
4812
+ var STRATEGY_REVIEW_OFFER_GAP = 5;
4813
+ var STRATEGY_REVIEW_BLOCK_GAP = 7;
4691
4814
  function loadConfig() {
4692
4815
  const projectArgIdx = process.argv.indexOf("--project");
4693
4816
  const configuredRoot = projectArgIdx !== -1 ? process.argv[projectArgIdx + 1] : process.env.PAPI_PROJECT_DIR;
@@ -7653,18 +7776,36 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
7653
7776
  }
7654
7777
 
7655
7778
  // src/server.ts
7656
- import { readFileSync as readFileSync9 } from "fs";
7779
+ import { readFileSync as readFileSync10 } from "fs";
7657
7780
  import { access as access4, readdir as readdir4, readFile as readFile9 } from "fs/promises";
7658
- import { join as join17, dirname as dirname4 } from "path";
7659
- import { fileURLToPath as fileURLToPath2 } from "url";
7781
+ import { join as join18, dirname as dirname5 } from "path";
7782
+ import { fileURLToPath as fileURLToPath3 } from "url";
7660
7783
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7661
7784
  import {
7662
7785
  CallToolRequestSchema,
7663
7786
  ListToolsRequestSchema,
7664
7787
  ListPromptsRequestSchema,
7665
- GetPromptRequestSchema
7788
+ GetPromptRequestSchema,
7789
+ ListResourcesRequestSchema,
7790
+ ListResourceTemplatesRequestSchema,
7791
+ ReadResourceRequestSchema
7666
7792
  } from "@modelcontextprotocol/sdk/types.js";
7667
7793
 
7794
+ // src/universal-frame.ts
7795
+ var UNIVERSAL_FRAME = `PAPI gives this project a structured plan \u2192 build \u2192 review cycle, persisted across sessions. Follow it:
7796
+
7797
+ 1. ORIENT FIRST. At the start of every session, call \`orient\` (or \`papi\`) before anything else \u2014 it returns the current cycle, what's in flight, and the recommended next action. Re-run it after any context compression.
7798
+
7799
+ 2. THE CYCLE, IN ORDER: \`plan\` (once per cycle) \u2192 \`build_list\` (pick a task) \u2192 \`build_execute <task>\` to start \u2192 implement the task from its BUILD HANDOFF \u2192 \`build_execute\` again to complete with a build report \u2192 \`review_submit\` \u2192 \`release\` when every cycle task is done.
7800
+
7801
+ 3. STATUS DISCIPLINE. Check a task's status before acting. "In Review" = already built \u2014 never re-build it; submit a review instead. "In Progress" = a build started \u2014 check the existing branch before writing new code. "Backlog" = not started, but check for an existing feature branch first.
7802
+
7803
+ 4. STAY IN SCOPE. Build exactly what the BUILD HANDOFF specifies. Mechanical steps (branch creation, commits, status updates) are automatic \u2014 only "what to build" needs the user's confirmation.
7804
+
7805
+ 5. VERIFY BEFORE DONE. Test the change and confirm it works before reporting a task complete. Report failures honestly, with the output.
7806
+
7807
+ PAPI reads and writes all project state through these tools \u2014 they are the source of truth, not local files.`;
7808
+
7668
7809
  // src/lib/response.ts
7669
7810
  function textResponse(text, usage) {
7670
7811
  const result = {
@@ -10985,7 +11126,7 @@ ${lines.join("\n")}`;
10985
11126
  console.error(`[plan-perf] assembleContext (lean): ${JSON.stringify(timings)}ms`);
10986
11127
  const gap = health.cyclesSinceLastStrategyReview;
10987
11128
  const lastReviewCycle = health.totalCycles - gap;
10988
- const strategyReviewCadence = gap <= 0 ? `\u2713 Strategy review completed this cycle (C${health.totalCycles}). No carry-forward needed.` : gap < 5 ? `\u2713 Strategy review on track \u2014 last review was C${lastReviewCycle} (${gap} cycle(s) ago). Next due: C${lastReviewCycle + 5}.` : `\u26A0\uFE0F Strategy review overdue \u2014 last review was C${lastReviewCycle} (${gap} cycles ago). Due now.`;
11129
+ const strategyReviewCadence = gap <= 0 ? `\u2713 Strategy review completed this cycle (C${health.totalCycles}). No carry-forward needed.` : gap < STRATEGY_REVIEW_OFFER_GAP ? `\u2713 Strategy review on track \u2014 last review was C${lastReviewCycle} (${gap} cycle(s) ago). Next due: C${lastReviewCycle + STRATEGY_REVIEW_OFFER_GAP}.` : gap < STRATEGY_REVIEW_BLOCK_GAP ? `\u25CB Strategy review available \u2014 last review was C${lastReviewCycle} (${gap} cycles ago). Offered now (optional); planning hard-blocks at ${STRATEGY_REVIEW_BLOCK_GAP} cycles.` : `\u26A0\uFE0F Strategy review overdue \u2014 last review was C${lastReviewCycle} (${gap} cycles ago). Planning is blocked until it runs (or \`force: true\`).`;
10989
11130
  let ctx2 = {
10990
11131
  mode,
10991
11132
  cycleNumber: health.totalCycles,
@@ -11175,7 +11316,7 @@ ${logLines}`);
11175
11316
  const preAssignedText = formatPreAssignedTasks(preAssigned, targetCycle);
11176
11317
  const gapFull = health.cyclesSinceLastStrategyReview;
11177
11318
  const lastReviewCycleFull = health.totalCycles - gapFull;
11178
- const strategyReviewCadenceFull = gapFull <= 0 ? `\u2713 Strategy review completed this cycle (C${health.totalCycles}). No carry-forward needed.` : gapFull < 5 ? `\u2713 Strategy review on track \u2014 last review was C${lastReviewCycleFull} (${gapFull} cycle(s) ago). Next due: C${lastReviewCycleFull + 5}.` : `\u26A0\uFE0F Strategy review overdue \u2014 last review was C${lastReviewCycleFull} (${gapFull} cycles ago). Due now.`;
11319
+ const strategyReviewCadenceFull = gapFull <= 0 ? `\u2713 Strategy review completed this cycle (C${health.totalCycles}). No carry-forward needed.` : gapFull < STRATEGY_REVIEW_OFFER_GAP ? `\u2713 Strategy review on track \u2014 last review was C${lastReviewCycleFull} (${gapFull} cycle(s) ago). Next due: C${lastReviewCycleFull + STRATEGY_REVIEW_OFFER_GAP}.` : gapFull < STRATEGY_REVIEW_BLOCK_GAP ? `\u25CB Strategy review available \u2014 last review was C${lastReviewCycleFull} (${gapFull} cycles ago). Offered now (optional); planning hard-blocks at ${STRATEGY_REVIEW_BLOCK_GAP} cycles.` : `\u26A0\uFE0F Strategy review overdue \u2014 last review was C${lastReviewCycleFull} (${gapFull} cycles ago). Planning is blocked until it runs (or \`force: true\`).`;
11179
11320
  let ctx = {
11180
11321
  mode,
11181
11322
  cycleNumber: health.totalCycles,
@@ -11779,15 +11920,15 @@ Run \`review_submit\` to clear them, or pass \`force: true\` to bypass this bloc
11779
11920
  console.error(`[plan] ${note}`);
11780
11921
  }
11781
11922
  const gap = health.cyclesSinceLastStrategyReview;
11782
- if (gap >= 5 && !force) {
11923
+ if (gap >= STRATEGY_REVIEW_BLOCK_GAP && !force) {
11783
11924
  const lastReviewCycle = cycleNumber - gap;
11784
11925
  throw new Error(
11785
- `Strategy Review gate \u2014 last review was Cycle ${lastReviewCycle}, current is Cycle ${cycleNumber} (${gap} cycles ago). A strategy review is required every 5 cycles before planning can continue.
11926
+ `Strategy Review gate \u2014 last review was Cycle ${lastReviewCycle}, current is Cycle ${cycleNumber} (${gap} cycles ago). A strategy review is required at least every ${STRATEGY_REVIEW_BLOCK_GAP} cycles before planning can continue.
11786
11927
 
11787
11928
  Run \`strategy_review\` first, or pass \`force: true\` to bypass this gate.`
11788
11929
  );
11789
11930
  }
11790
- if (gap >= 5 && force) {
11931
+ if (gap >= STRATEGY_REVIEW_BLOCK_GAP && force) {
11791
11932
  const lastReviewCycle = cycleNumber - gap;
11792
11933
  strategyReviewWarning = `> \u26A0\uFE0F **Strategy Review gate bypassed** (force: true) \u2014 last review was Cycle ${lastReviewCycle}, current is Cycle ${cycleNumber} (${gap} cycles ago). Run \`strategy_review\` after this cycle.
11793
11934
 
@@ -12360,8 +12501,6 @@ function getState(callerKey) {
12360
12501
  function callerKeyFromConfig(config2) {
12361
12502
  return config2.projectId ?? config2.userId ?? void 0;
12362
12503
  }
12363
- var CONTEXT_BLOAT_CALL_THRESHOLD = 40;
12364
- var ORIENT_GAP_MS = 3 * 60 * 60 * 1e3;
12365
12504
  var REVIEW_LIST_GUARD_WINDOW_MS = 15 * 60 * 1e3;
12366
12505
  var FAILURE_WINDOW_MS = 30 * 60 * 1e3;
12367
12506
  var FAILURE_RATE_THRESHOLD = 8;
@@ -12401,37 +12540,20 @@ function getProjectConnectionBanner(projectName, projectSlug) {
12401
12540
  function detectContextDegradation(now = Date.now(), callerKey) {
12402
12541
  const state = getState(callerKey);
12403
12542
  if (state.consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD) {
12404
- return `Context degradation (clash): ${state.consecutiveFailures} tool calls failed in a row \u2014 the session may be stuck on a contradiction. Consider a fresh window after this task.`;
12543
+ return `${state.consecutiveFailures} tool calls failed in a row \u2014 the session may be stuck on a contradiction. Re-read the last error and re-check your assumptions before continuing.`;
12405
12544
  }
12406
12545
  const cutoff = now - FAILURE_WINDOW_MS;
12407
12546
  const recentFailures = state.failureTimestamps.filter((t) => t >= cutoff).length;
12408
12547
  if (recentFailures >= FAILURE_RATE_THRESHOLD) {
12409
12548
  const mins = Math.round(FAILURE_WINDOW_MS / 6e4);
12410
- return `Context degradation (distraction/confusion): ${recentFailures} tool failures in the last ${mins}min. A fresh window often clears this faster than pushing on.`;
12549
+ return `${recentFailures} tool failures in the last ${mins}min \u2014 something's off. Re-read the last error and re-check your assumptions before continuing.`;
12411
12550
  }
12412
12551
  return null;
12413
12552
  }
12414
12553
  async function buildSessionGuidance(callerKey) {
12415
- const state = getState(callerKey);
12416
12554
  const signals = [];
12417
12555
  const degradation = detectContextDegradation(Date.now(), callerKey);
12418
12556
  if (degradation) signals.push(degradation);
12419
- if (state.toolCallCount > CONTEXT_BLOAT_CALL_THRESHOLD) {
12420
- signals.push(
12421
- `${state.toolCallCount} tool calls this session \u2014 context may be bloated. Consider starting a fresh window.`
12422
- );
12423
- }
12424
- if (state.lastOrientAt && Date.now() - state.lastOrientAt > ORIENT_GAP_MS) {
12425
- const hours = Math.round((Date.now() - state.lastOrientAt) / (60 * 60 * 1e3));
12426
- signals.push(
12427
- `${hours}h since last orient \u2014 session may be stale. Consider a fresh window for best results.`
12428
- );
12429
- }
12430
- if (state.releaseSinceLastOrient) {
12431
- signals.push(
12432
- "Release just ran \u2014 start a fresh session before the next `plan` to keep planning context clean."
12433
- );
12434
- }
12435
12557
  return signals.slice(0, 3);
12436
12558
  }
12437
12559
 
@@ -12583,6 +12705,7 @@ function formatPlanResult(result) {
12583
12705
  );
12584
12706
  }
12585
12707
  const lines = [];
12708
+ if (result.projectBanner) lines.push(`> ${result.projectBanner}`, "");
12586
12709
  lines.push(`${result.strategyReviewWarning}${pullLine}**${modeLabel} Mode \u2014 ${cycleLabel}**`);
12587
12710
  if (result.writeSummary) {
12588
12711
  const ws = result.writeSummary;
@@ -12692,7 +12815,9 @@ async function handlePlan(adapter2, config2, args) {
12692
12815
  source: "mcp-server",
12693
12816
  confirmCancellations: args.confirm_cancellations === true
12694
12817
  }, tracker);
12695
- const response = formatPlanResult({ ...result, contextUtilisation: utilisation, contextBytes, skipHandoffs });
12818
+ const planProjectInfo = adapter2.getProjectInfo ? await adapter2.getProjectInfo().catch(() => null) : null;
12819
+ const projectBanner = planProjectInfo ? getProjectConnectionBanner(planProjectInfo.name, planProjectInfo.slug) ?? void 0 : void 0;
12820
+ const response = formatPlanResult({ ...result, contextUtilisation: utilisation, contextBytes, skipHandoffs, projectBanner });
12696
12821
  return {
12697
12822
  ...response,
12698
12823
  ...contextBytes !== void 0 ? { _contextBytes: contextBytes } : {},
@@ -15847,8 +15972,8 @@ ${existing}` : entry;
15847
15972
  }
15848
15973
 
15849
15974
  // src/services/setup.ts
15850
- import { mkdir, writeFile as writeFile2, readFile as readFile4, readdir, access as access2, stat as stat2 } from "fs/promises";
15851
- import { join as join5, basename, extname, dirname as dirname2 } from "path";
15975
+ import { mkdir, writeFile as writeFile2, readFile as readFile4, readdir, access as access2, stat as stat2, chmod } from "fs/promises";
15976
+ import { join as join7, basename, extname, dirname as dirname3 } from "path";
15852
15977
  import { execFileSync as execFileSync3 } from "child_process";
15853
15978
 
15854
15979
  // src/lib/detect-codebase.ts
@@ -15933,6 +16058,159 @@ function planBundleInstall(projectRoot, projectName, opts = {}) {
15933
16058
  return out;
15934
16059
  }
15935
16060
 
16061
+ // src/lib/design-bundle.ts
16062
+ import { readFileSync as readFileSync2, existsSync as existsSync4, statSync as statSync4 } from "fs";
16063
+ import { dirname as dirname2, join as join5, resolve as resolve2 } from "path";
16064
+ import { fileURLToPath as fileURLToPath2 } from "url";
16065
+ var DESIGN_ASSETS = [
16066
+ { srcRel: join5("agents", "frontend-design-engineer.md"), destRel: join5(".claude", "agents", "frontend-design-engineer.md"), executable: false },
16067
+ { srcRel: join5("skills", "design-critique", "SKILL.md"), destRel: join5(".claude", "skills", "design-critique", "SKILL.md"), executable: false },
16068
+ { srcRel: join5("hooks", "frontend-design-guard.sh"), destRel: join5(".claude", "hooks", "frontend-design-guard.sh"), executable: true }
16069
+ ];
16070
+ var DESIGN_HOOK_COMMAND = ".claude/hooks/frontend-design-guard.sh";
16071
+ function resolveDesignAssetsDir() {
16072
+ let dir = dirname2(fileURLToPath2(import.meta.url));
16073
+ for (let i = 0; i < 5; i++) {
16074
+ const candidate = join5(dir, "design-assets");
16075
+ if (existsSync4(join5(candidate, "agents", "frontend-design-engineer.md"))) return candidate;
16076
+ const parent = resolve2(dir, "..");
16077
+ if (parent === dir) break;
16078
+ dir = parent;
16079
+ }
16080
+ return void 0;
16081
+ }
16082
+ function planDesignInstall(projectRoot, opts = {}) {
16083
+ const assetsDir = resolveDesignAssetsDir();
16084
+ if (!assetsDir) return [];
16085
+ const out = [];
16086
+ for (const asset of DESIGN_ASSETS) {
16087
+ const srcAbs = join5(assetsDir, asset.srcRel);
16088
+ if (!existsSync4(srcAbs)) continue;
16089
+ const dest = projectRoot ? join5(projectRoot, asset.destRel) : asset.destRel;
16090
+ if (opts.skipExisting && projectRoot && existsSync4(dest) && statSync4(dest).isFile()) continue;
16091
+ out.push({ dest, content: readFileSync2(srcAbs, "utf8"), executable: asset.executable });
16092
+ }
16093
+ return out;
16094
+ }
16095
+
16096
+ // src/lib/skill-detection.ts
16097
+ import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync3, statSync as statSync5 } from "fs";
16098
+ import { join as join6 } from "path";
16099
+ function readPackageJson(projectRoot) {
16100
+ const path7 = join6(projectRoot, "package.json");
16101
+ if (!existsSync5(path7)) return null;
16102
+ try {
16103
+ const raw = readFileSync3(path7, "utf-8");
16104
+ return JSON.parse(raw);
16105
+ } catch {
16106
+ return null;
16107
+ }
16108
+ }
16109
+ function allDeps(pkg) {
16110
+ if (!pkg) return {};
16111
+ return { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
16112
+ }
16113
+ function hasDependencyMatching(deps, pattern) {
16114
+ for (const name of Object.keys(deps)) {
16115
+ if (pattern.test(name)) return true;
16116
+ }
16117
+ return false;
16118
+ }
16119
+ var FRONTEND_DEP_PATTERN = /^(react|react-dom|next|vue|svelte|preact|solid-js|astro|nuxt|tailwindcss)$|^@(sveltejs|angular|remix-run)\//;
16120
+ function detectsFrontendStack(projectRoot) {
16121
+ return hasDependencyMatching(allDeps(readPackageJson(projectRoot)), FRONTEND_DEP_PATTERN);
16122
+ }
16123
+ function hasGitHubWorkflows(projectRoot) {
16124
+ const dir = join6(projectRoot, ".github", "workflows");
16125
+ if (!existsSync5(dir)) return false;
16126
+ try {
16127
+ const entries = readdirSync4(dir);
16128
+ return entries.some((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
16129
+ } catch {
16130
+ return false;
16131
+ }
16132
+ }
16133
+ function envExampleMentionsStaging(projectRoot) {
16134
+ const path7 = join6(projectRoot, ".env.example");
16135
+ if (!existsSync5(path7)) return false;
16136
+ try {
16137
+ const raw = readFileSync3(path7, "utf-8");
16138
+ return /\b(STAGING_URL|STAGING_API|STAGING_HOST|NEXT_PUBLIC_STAGING)/i.test(raw);
16139
+ } catch {
16140
+ return false;
16141
+ }
16142
+ }
16143
+ function hasVercelConfig(projectRoot) {
16144
+ if (existsSync5(join6(projectRoot, "vercel.json"))) return true;
16145
+ const vercelDir = join6(projectRoot, ".vercel");
16146
+ if (!existsSync5(vercelDir)) return false;
16147
+ try {
16148
+ return statSync5(vercelDir).isDirectory();
16149
+ } catch {
16150
+ return false;
16151
+ }
16152
+ }
16153
+ function scanForSkillSignals(projectRoot) {
16154
+ const proposals = [];
16155
+ const pkg = readPackageJson(projectRoot);
16156
+ const deps = allDeps(pkg);
16157
+ if (hasGitHubWorkflows(projectRoot)) {
16158
+ proposals.push({
16159
+ id: "gh-actions-debug",
16160
+ name: "GitHub Actions debugger",
16161
+ rationale: "Detected .github/workflows/ \u2014 a skill for inspecting CI failures and re-running jobs from Claude Code can shorten the debug loop.",
16162
+ hint: 'Search Claude Code skill registry for "gh-actions" or install via `claude skills add gh-actions-debug`.'
16163
+ });
16164
+ }
16165
+ if (hasDependencyMatching(deps, /^@sentry\//) || hasDependencyMatching(deps, /^@datadog\//)) {
16166
+ proposals.push({
16167
+ id: "error-tracking",
16168
+ name: "Error tracking helper",
16169
+ rationale: "Detected @sentry/* or @datadog/* in package.json \u2014 a skill that fetches recent error events into your context can speed up triage.",
16170
+ hint: 'Search Claude Code skill registry for "sentry" or "datadog".'
16171
+ });
16172
+ }
16173
+ if (hasVercelConfig(projectRoot)) {
16174
+ proposals.push({
16175
+ id: "read-vercel-logs",
16176
+ name: "Vercel deploy logs",
16177
+ rationale: "Detected vercel.json or .vercel/ \u2014 a skill for pulling deploy + runtime logs from Vercel directly into Claude Code helps when builds fail or runtime errors land.",
16178
+ hint: 'Search Claude Code skill registry for "vercel" or install `vercel:logs`.'
16179
+ });
16180
+ }
16181
+ if (envExampleMentionsStaging(projectRoot)) {
16182
+ proposals.push({
16183
+ id: "staging-environment",
16184
+ name: "Staging-environment helper",
16185
+ rationale: "Detected STAGING_* variables in .env.example \u2014 a skill for switching env contexts and seeding staging data can speed up pre-prod testing.",
16186
+ hint: 'Search Claude Code skill registry for "staging" or define your own.'
16187
+ });
16188
+ }
16189
+ if (detectsFrontendStack(projectRoot)) {
16190
+ proposals.push({
16191
+ id: "frontend-design-engineer",
16192
+ name: "Frontend design engineer (agent + critique)",
16193
+ rationale: "Detected a frontend stack (React/Next/Vue/Svelte/Tailwind) \u2014 PAPI ships a frontend-design-engineer sub-agent + a design-critique skill that isolate the visual layer, run a falsifiable anti-slop critique before and after building, and verify in-browser. They read your own DESIGN.md/PRODUCT.md for brand, so the output is yours, not generic.",
16194
+ hint: "Re-run `setup` to install them into .claude/, or copy from the PAPI server package under design-assets/. A build-time guard hook is included (opt-in)."
16195
+ });
16196
+ }
16197
+ return proposals;
16198
+ }
16199
+ function formatSkillProposals(proposals) {
16200
+ if (proposals.length === 0) return "";
16201
+ const lines = ["", "## Skill proposals", "_PAPI scanned your project once and noticed tooling that often pairs with a Claude Code skill. Each proposal is one-shot \u2014 they won't resurface._"];
16202
+ for (const p of proposals) {
16203
+ lines.push("");
16204
+ lines.push(`### ${p.name}`);
16205
+ lines.push(p.rationale);
16206
+ if (p.hint) {
16207
+ lines.push(`_${p.hint}_`);
16208
+ }
16209
+ lines.push("**[Yes]** install it now \xB7 **[Not now]** dismiss for this project");
16210
+ }
16211
+ return "\n" + lines.join("\n");
16212
+ }
16213
+
15936
16214
  // src/templates.ts
15937
16215
  var PLANNING_LOG_TEMPLATE = `# PAPI Planning Log
15938
16216
 
@@ -16335,6 +16613,9 @@ function substitute(template, vars) {
16335
16613
  }
16336
16614
  return result;
16337
16615
  }
16616
+ function shouldWriteClaudeMd(clientName) {
16617
+ return !clientName || /claude/i.test(clientName);
16618
+ }
16338
16619
  async function scaffoldPapiDir(adapter2, config2, input, collector) {
16339
16620
  const isPg = config2.adapterType === "pg" || config2.adapterType === "proxy";
16340
16621
  const vars = {
@@ -16350,7 +16631,7 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
16350
16631
  await mkdir(config2.papiDir, { recursive: true });
16351
16632
  for (const [filename, template] of Object.entries(FILE_TEMPLATES)) {
16352
16633
  const content = substitute(template, vars);
16353
- await writeFile2(join5(config2.papiDir, filename), content, "utf-8");
16634
+ await writeFile2(join7(config2.papiDir, filename), content, "utf-8");
16354
16635
  }
16355
16636
  }
16356
16637
  } else {
@@ -16368,13 +16649,13 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
16368
16649
  const useCollector = config2.adapterType === "proxy";
16369
16650
  const docsRel = "docs";
16370
16651
  const commandsRel = ".claude/commands";
16371
- const commandsDir = useCollector ? commandsRel : join5(config2.projectRoot, ".claude", "commands");
16372
- const docsDir = useCollector ? docsRel : join5(config2.projectRoot, "docs");
16652
+ const commandsDir = useCollector ? commandsRel : join7(config2.projectRoot, ".claude", "commands");
16653
+ const docsDir = useCollector ? docsRel : join7(config2.projectRoot, "docs");
16373
16654
  if (!useCollector) {
16374
16655
  await mkdir(commandsDir, { recursive: true });
16375
16656
  await mkdir(docsDir, { recursive: true });
16376
16657
  }
16377
- const claudeMdPath = useCollector ? "CLAUDE.md" : join5(config2.projectRoot, "CLAUDE.md");
16658
+ const claudeMdPath = useCollector ? "CLAUDE.md" : join7(config2.projectRoot, "CLAUDE.md");
16378
16659
  let claudeMdExists = false;
16379
16660
  if (!useCollector) {
16380
16661
  try {
@@ -16383,7 +16664,7 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
16383
16664
  } catch {
16384
16665
  }
16385
16666
  }
16386
- const docsIndexPath = useCollector ? `${docsRel}/INDEX.md` : join5(docsDir, "INDEX.md");
16667
+ const docsIndexPath = useCollector ? `${docsRel}/INDEX.md` : join7(docsDir, "INDEX.md");
16387
16668
  let docsIndexExists = false;
16388
16669
  if (!useCollector) {
16389
16670
  try {
@@ -16393,16 +16674,16 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
16393
16674
  }
16394
16675
  }
16395
16676
  const scaffoldFiles = {
16396
- [useCollector ? `${commandsRel}/papi-audit.md` : join5(commandsDir, "papi-audit.md")]: PAPI_AUDIT_COMMAND_TEMPLATE,
16397
- [useCollector ? `${commandsRel}/test.md` : join5(commandsDir, "test.md")]: TEST_COMMAND_TEMPLATE,
16398
- [useCollector ? `${docsRel}/README.md` : join5(docsDir, "README.md")]: substitute(DOCS_README_TEMPLATE, vars)
16677
+ [useCollector ? `${commandsRel}/papi-audit.md` : join7(commandsDir, "papi-audit.md")]: PAPI_AUDIT_COMMAND_TEMPLATE,
16678
+ [useCollector ? `${commandsRel}/test.md` : join7(commandsDir, "test.md")]: TEST_COMMAND_TEMPLATE,
16679
+ [useCollector ? `${docsRel}/README.md` : join7(docsDir, "README.md")]: substitute(DOCS_README_TEMPLATE, vars)
16399
16680
  };
16400
16681
  if (!docsIndexExists) {
16401
16682
  scaffoldFiles[docsIndexPath] = substitute(DOCS_INDEX_TEMPLATE, vars);
16402
16683
  }
16403
- if (!claudeMdExists) {
16684
+ if (!claudeMdExists && shouldWriteClaudeMd(input.clientName)) {
16404
16685
  scaffoldFiles[claudeMdPath] = substitute(CLAUDE_MD_STUB, vars);
16405
- } else {
16686
+ } else if (claudeMdExists) {
16406
16687
  try {
16407
16688
  const existing = await readFile4(claudeMdPath, "utf-8");
16408
16689
  if (!existing.includes("AGENTS.md")) {
@@ -16415,14 +16696,14 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
16415
16696
  const bundleRoot = useCollector ? "" : config2.projectRoot;
16416
16697
  for (const [dest, content] of Object.entries(planBundleInstall(bundleRoot, input.projectName, { skipExisting: true }))) {
16417
16698
  if (!useCollector) {
16418
- await mkdir(dirname2(dest), { recursive: true });
16699
+ await mkdir(dirname3(dest), { recursive: true });
16419
16700
  }
16420
16701
  scaffoldFiles[dest] = content;
16421
16702
  }
16422
16703
  if (useCollector) {
16423
16704
  scaffoldFiles[".cursor/rules/papi.mdc"] = substitute(CURSOR_RULES_TEMPLATE, vars);
16424
16705
  } else {
16425
- const cursorDir = join5(config2.projectRoot, ".cursor");
16706
+ const cursorDir = join7(config2.projectRoot, ".cursor");
16426
16707
  let cursorDetected = false;
16427
16708
  try {
16428
16709
  await access2(cursorDir);
@@ -16430,8 +16711,8 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
16430
16711
  } catch {
16431
16712
  }
16432
16713
  if (cursorDetected) {
16433
- const cursorRulesDir = join5(cursorDir, "rules");
16434
- const cursorRulesPath = join5(cursorRulesDir, "papi.mdc");
16714
+ const cursorRulesDir = join7(cursorDir, "rules");
16715
+ const cursorRulesPath = join7(cursorRulesDir, "papi.mdc");
16435
16716
  await mkdir(cursorRulesDir, { recursive: true });
16436
16717
  try {
16437
16718
  await access2(cursorRulesPath);
@@ -16449,6 +16730,19 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
16449
16730
  await writeFile2(filepath, content, "utf-8");
16450
16731
  }
16451
16732
  }
16733
+ if (!useCollector && detectsFrontendStack(config2.projectRoot)) {
16734
+ for (const entry of planDesignInstall(config2.projectRoot, { skipExisting: true })) {
16735
+ await mkdir(dirname3(entry.dest), { recursive: true });
16736
+ await writeFile2(entry.dest, entry.content, "utf-8");
16737
+ if (entry.executable) {
16738
+ try {
16739
+ await chmod(entry.dest, 493);
16740
+ } catch {
16741
+ }
16742
+ }
16743
+ }
16744
+ await ensureDesignHookRegistered(config2.projectRoot);
16745
+ }
16452
16746
  if (!isPg) {
16453
16747
  await adapter2.writePhases([{
16454
16748
  id: "phase-0",
@@ -16473,7 +16767,7 @@ async function scaffoldPapiDir(adapter2, config2, input, collector) {
16473
16767
  }
16474
16768
  var PAPI_PERMISSION = "mcp__papi__*";
16475
16769
  async function ensurePapiPermission(projectRoot) {
16476
- const settingsPath = join5(projectRoot, ".claude", "settings.json");
16770
+ const settingsPath = join7(projectRoot, ".claude", "settings.json");
16477
16771
  try {
16478
16772
  let settings = {};
16479
16773
  try {
@@ -16492,7 +16786,43 @@ async function ensurePapiPermission(projectRoot) {
16492
16786
  if (!allow.includes(PAPI_PERMISSION)) {
16493
16787
  allow.push(PAPI_PERMISSION);
16494
16788
  }
16495
- await mkdir(join5(projectRoot, ".claude"), { recursive: true });
16789
+ await mkdir(join7(projectRoot, ".claude"), { recursive: true });
16790
+ await writeFile2(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
16791
+ } catch {
16792
+ }
16793
+ }
16794
+ async function ensureDesignHookRegistered(projectRoot) {
16795
+ const settingsPath = join7(projectRoot, ".claude", "settings.json");
16796
+ try {
16797
+ let settings = {};
16798
+ try {
16799
+ settings = JSON.parse(await readFile4(settingsPath, "utf-8"));
16800
+ } catch {
16801
+ }
16802
+ if (!settings.hooks || typeof settings.hooks !== "object") {
16803
+ settings.hooks = {};
16804
+ }
16805
+ const hooks = settings.hooks;
16806
+ if (!Array.isArray(hooks.PreToolUse)) {
16807
+ hooks.PreToolUse = [];
16808
+ }
16809
+ const pre = hooks.PreToolUse;
16810
+ for (const matcher of ["Edit", "Write"]) {
16811
+ let entry = pre.find((e) => e && e["matcher"] === matcher);
16812
+ if (!entry) {
16813
+ entry = { matcher, hooks: [] };
16814
+ pre.push(entry);
16815
+ }
16816
+ if (!Array.isArray(entry["hooks"])) {
16817
+ entry["hooks"] = [];
16818
+ }
16819
+ const chain = entry["hooks"];
16820
+ const already = chain.some((h) => h && h["command"] === DESIGN_HOOK_COMMAND);
16821
+ if (!already) {
16822
+ chain.push({ type: "command", command: DESIGN_HOOK_COMMAND });
16823
+ }
16824
+ }
16825
+ await mkdir(join7(projectRoot, ".claude"), { recursive: true });
16496
16826
  await writeFile2(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
16497
16827
  } catch {
16498
16828
  }
@@ -16581,7 +16911,7 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
16581
16911
  }
16582
16912
  }
16583
16913
  }
16584
- if (conventionsText?.trim()) {
16914
+ if (conventionsText?.trim() && shouldWriteClaudeMd(input.clientName)) {
16585
16915
  const conventionsBlock = `${CONVENTIONS_SENTINEL}
16586
16916
  ${conventionsText.trim()}
16587
16917
  `;
@@ -16596,7 +16926,7 @@ ${conventionsText.trim()}
16596
16926
  );
16597
16927
  } else {
16598
16928
  try {
16599
- const claudeMdPath = join5(config2.projectRoot, "CLAUDE.md");
16929
+ const claudeMdPath = join7(config2.projectRoot, "CLAUDE.md");
16600
16930
  const existing = await readFile4(claudeMdPath, "utf-8");
16601
16931
  if (existing.includes(CONVENTIONS_SENTINEL) || existing.includes(CONVENTIONS_HEADING)) {
16602
16932
  warnings.push(
@@ -16683,13 +17013,13 @@ async function scanCodebase(projectRoot) {
16683
17013
  }
16684
17014
  let packageJson;
16685
17015
  try {
16686
- const content = await readFile4(join5(projectRoot, "package.json"), "utf-8");
17016
+ const content = await readFile4(join7(projectRoot, "package.json"), "utf-8");
16687
17017
  packageJson = JSON.parse(content);
16688
17018
  } catch {
16689
17019
  }
16690
17020
  let readme;
16691
17021
  for (const name of ["README.md", "readme.md", "README.txt", "README"]) {
16692
- const content = await safeReadFile(join5(projectRoot, name), 5e3);
17022
+ const content = await safeReadFile(join7(projectRoot, name), 5e3);
16693
17023
  if (content) {
16694
17024
  readme = content;
16695
17025
  break;
@@ -16699,7 +17029,7 @@ async function scanCodebase(projectRoot) {
16699
17029
  let totalFiles = topLevelFiles.length;
16700
17030
  for (const dir of topLevelDirs) {
16701
17031
  try {
16702
- const entries = await readdir(join5(projectRoot, dir), { withFileTypes: true });
17032
+ const entries = await readdir(join7(projectRoot, dir), { withFileTypes: true });
16703
17033
  const files = entries.filter((e) => e.isFile());
16704
17034
  const extensions = [...new Set(files.map((f) => extname(f.name).toLowerCase()).filter(Boolean))];
16705
17035
  totalFiles += files.length;
@@ -17026,7 +17356,7 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
17026
17356
  }
17027
17357
  }
17028
17358
  }
17029
- {
17359
+ if (shouldWriteClaudeMd(input.clientName)) {
17030
17360
  const dogfoodSection = [
17031
17361
  "",
17032
17362
  "## Dogfood Logging",
@@ -17046,7 +17376,7 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
17046
17376
  collector.add({ path: "CLAUDE.md", content: dogfoodSection, mode: "append" });
17047
17377
  } else {
17048
17378
  try {
17049
- const claudeMdPath = join5(config2.projectRoot, "CLAUDE.md");
17379
+ const claudeMdPath = join7(config2.projectRoot, "CLAUDE.md");
17050
17380
  const existing = await readFile4(claudeMdPath, "utf-8");
17051
17381
  if (!existing.includes("Dogfood Logging")) {
17052
17382
  await writeFile2(claudeMdPath, existing + dogfoodSection, "utf-8");
@@ -17091,7 +17421,7 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
17091
17421
  cursorScaffolded = true;
17092
17422
  } else {
17093
17423
  try {
17094
- await access2(join5(config2.projectRoot, ".cursor", "rules", "papi.mdc"));
17424
+ await access2(join7(config2.projectRoot, ".cursor", "rules", "papi.mdc"));
17095
17425
  cursorScaffolded = true;
17096
17426
  } catch {
17097
17427
  }
@@ -17111,11 +17441,11 @@ async function applySetup(adapter2, config2, input, briefText, adSeedText, conve
17111
17441
  }
17112
17442
  async function ensureMcpJsonGitignored(projectRoot) {
17113
17443
  try {
17114
- await access2(join5(projectRoot, ".git"));
17444
+ await access2(join7(projectRoot, ".git"));
17115
17445
  } catch {
17116
17446
  return void 0;
17117
17447
  }
17118
- const gitignorePath = join5(projectRoot, ".gitignore");
17448
+ const gitignorePath = join7(projectRoot, ".gitignore");
17119
17449
  let existing = "";
17120
17450
  try {
17121
17451
  existing = await readFile4(gitignorePath, "utf-8");
@@ -17260,7 +17590,8 @@ function extractInput(args) {
17260
17590
  codebaseScan: args.codebase_scan && typeof args.codebase_scan === "object" ? args.codebase_scan : void 0
17261
17591
  };
17262
17592
  }
17263
- function formatSuccessResponse(result, constraints) {
17593
+ function formatSuccessResponse(result, constraints, writesClaudeMd = true) {
17594
+ const harnessFiles = writesClaudeMd ? "AGENTS.md, CLAUDE.md" : "AGENTS.md";
17264
17595
  const prefix = result.createdProject ? `PAPI project "${result.projectName}" initialised and ` : "";
17265
17596
  const briefRegenNote = result.briefRegenerated ? `
17266
17597
 
@@ -17277,7 +17608,7 @@ ${result.seededAds} Active Decision${result.seededAds > 1 ? "s" : ""} seeded bas
17277
17608
  ${[created, skipped].filter(Boolean).join(", ")}.${idea}`;
17278
17609
  })() : '\n\nNo starter tasks yet. Describe what you want to build, or seed the board with `idea "<what you want to build>"`, so your first `plan` has something to work from.';
17279
17610
  const constraintsHint = !constraints ? '\n\nTip: consider adding `constraints` (e.g. "must use PostgreSQL", "HIPAA compliant", "offline-first") to improve Active Decision seeding.' : "";
17280
- const editorNote = result.cursorScaffolded ? "\n\nCursor detected \u2014 `.cursor/rules/papi.mdc` scaffolded alongside CLAUDE.md." : "";
17611
+ const editorNote = result.cursorScaffolded ? "\n\nCursor detected \u2014 `.cursor/rules/papi.mdc` scaffolded alongside your `AGENTS.md` harness." : "";
17281
17612
  const gitignoreNote = result.gitignoreNote ? `
17282
17613
 
17283
17614
  \u{1F512} ${result.gitignoreNote}` : "";
@@ -17289,7 +17620,7 @@ ${result.warnings.map((w) => `- ${w}`).join("\n")}` : "";
17289
17620
  return textResponse(
17290
17621
  `${prefix}Product Brief generated and saved.${briefRegenNote}${adNote}${taskNote}${constraintsHint}${editorNote}${gitignoreNote}${warningsNote}
17291
17622
 
17292
- **Important:** Setup created/modified files (CLAUDE.md, .claude/settings.json, docs/). Commit these changes before running \`build_execute\` \u2014 it requires a clean working directory.
17623
+ **Important:** Setup created/modified files (${harnessFiles}, .claude/settings.json, docs/). Commit these changes before running \`build_execute\` \u2014 it requires a clean working directory.
17293
17624
 
17294
17625
  Tip: See \`docs/templates/example-project-brief.md\` for an example of a well-written brief.
17295
17626
 
@@ -17298,7 +17629,7 @@ Tip: See \`docs/templates/example-project-brief.md\` for an example of a well-wr
17298
17629
  Next step: run \`plan\` to start your first planning cycle.${filesToWriteSection}`
17299
17630
  );
17300
17631
  }
17301
- async function handleSetup(adapter2, config2, args) {
17632
+ async function handleSetup(adapter2, config2, args, clientName) {
17302
17633
  const toolMode = args.mode;
17303
17634
  const REQUIRED_FIELDS = ["project_name"];
17304
17635
  const missing = REQUIRED_FIELDS.filter((f) => !args[f] || typeof args[f] === "string" && !args[f].trim());
@@ -17310,6 +17641,8 @@ PAPI needs the project name. Description and target users are optional \u2014 th
17310
17641
  );
17311
17642
  }
17312
17643
  const input = extractInput(args);
17644
+ input.clientName = clientName;
17645
+ const writesClaudeMd = !clientName || /claude/i.test(clientName);
17313
17646
  const tracker = new ProgressTracker(toolMode === "apply" ? "apply_validate" : "prepare_validate");
17314
17647
  try {
17315
17648
  if (toolMode === "apply") {
@@ -17321,7 +17654,7 @@ PAPI needs the project name. Description and target users are optional \u2014 th
17321
17654
  tracker.mark("apply_setup_writeback");
17322
17655
  const result = await applySetup(adapter2, config2, input, briefResponse, adSeedResponse, conventionsResponse, initialTasksResponse);
17323
17656
  tracker.mark("apply_format_response");
17324
- return formatSuccessResponse(result, args.constraints);
17657
+ return formatSuccessResponse(result, args.constraints, writesClaudeMd);
17325
17658
  }
17326
17659
  {
17327
17660
  tracker.mark("prepare_setup");
@@ -17495,10 +17828,127 @@ ${result.initialTasksPrompt.user}
17495
17828
  }
17496
17829
  }
17497
17830
 
17831
+ // src/lib/model-routing.ts
17832
+ var TIER_SPECS = {
17833
+ cheap: {
17834
+ label: "Cheap / fast",
17835
+ rationale: "XS\u2013S work \u2014 single-file fixes, UI polish, mechanical edits. A small model is faster and cheaper with no quality loss here.",
17836
+ examples: "Anthropic Claude Haiku \xB7 OpenAI GPT-5 mini \xB7 Google Gemini Flash \xB7 Mistral/Groq small"
17837
+ },
17838
+ capable: {
17839
+ label: "Capable",
17840
+ rationale: "M\u2013L work \u2014 multi-file features, data/plumbing, ambiguous handoffs. Needs a strong general-purpose model.",
17841
+ examples: "Anthropic Claude Sonnet \xB7 OpenAI GPT-5 \xB7 Google Gemini Pro \xB7 Mistral Large"
17842
+ },
17843
+ frontier: {
17844
+ label: "Frontier",
17845
+ rationale: "XL work \u2014 architecture, cross-cutting refactors, the hardest reasoning. Reach for your most capable model.",
17846
+ examples: "Anthropic Claude Opus \xB7 OpenAI GPT-5 Pro / o-series \xB7 Google Gemini Ultra"
17847
+ }
17848
+ };
17849
+ function tierForComplexity(complexity) {
17850
+ switch ((complexity ?? "").trim()) {
17851
+ case "XS":
17852
+ case "S":
17853
+ case "Small":
17854
+ return "cheap";
17855
+ case "XL":
17856
+ return "frontier";
17857
+ case "M":
17858
+ case "Medium":
17859
+ case "L":
17860
+ case "Large":
17861
+ return "capable";
17862
+ default:
17863
+ return "capable";
17864
+ }
17865
+ }
17866
+ function modelTierTag(complexity) {
17867
+ return `\u{1F916} ${TIER_SPECS[tierForComplexity(complexity)].label}`;
17868
+ }
17869
+ function formatModelRecommendation(complexity) {
17870
+ const spec = TIER_SPECS[tierForComplexity(complexity)];
17871
+ return `
17872
+
17873
+ ---
17874
+
17875
+ **\u{1F916} Suggested model tier: ${spec.label}**
17876
+ ${spec.rationale}
17877
+ Examples (use whichever provider you have): ${spec.examples}.
17878
+ _Recommendation only \u2014 PAPI never selects or runs a model. Policy: XS/S \u2192 cheap, M/L \u2192 capable, XL \u2192 frontier._`;
17879
+ }
17880
+
17498
17881
  // src/services/build.ts
17499
17882
  import { randomUUID as randomUUID11 } from "crypto";
17500
- import { readdirSync as readdirSync4, existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, unlinkSync, mkdirSync as mkdirSync2 } from "fs";
17501
- import { join as join8 } from "path";
17883
+ import { readdirSync as readdirSync5, existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync2, unlinkSync, mkdirSync as mkdirSync2 } from "fs";
17884
+ import { join as join10 } from "path";
17885
+
17886
+ // src/lib/harness-capability.ts
17887
+ var HARNESS_REGISTRY = {
17888
+ // Local stdio CLI agents — PAPI runs git on the user's machine. Confirmed in telemetry.
17889
+ "claude-code": { build: true, label: "Claude Code" },
17890
+ "opencode": { build: true, label: "opencode" },
17891
+ "zcode": { build: true, label: "zcode" },
17892
+ "codex": { build: true, label: "Codex" },
17893
+ "cursor": { build: true, label: "Cursor" },
17894
+ // Cloud harnesses with code sandboxes — their agent builds in-sandbox over HTTP+OAuth.
17895
+ "lovable": { build: true, label: "Lovable" },
17896
+ "bolt": { build: true, label: "Bolt" },
17897
+ "replit": { build: true, label: "Replit" },
17898
+ // Chat-only surfaces, no sandbox — planning only.
17899
+ "chatgpt": { build: false, label: "ChatGPT" },
17900
+ "claude.ai": { build: false, label: "Claude.ai" },
17901
+ "claude-desktop": { build: false, label: "Claude Desktop" }
17902
+ };
17903
+ var UNKNOWN_DEFAULT = { build: false, label: "your tool" };
17904
+ function detectHarness(clientName) {
17905
+ const raw = clientName?.trim() || null;
17906
+ if (!raw) {
17907
+ return { ...UNKNOWN_DEFAULT, raw: null, key: null, known: false };
17908
+ }
17909
+ const norm = raw.toLowerCase();
17910
+ const key = HARNESS_REGISTRY[norm] ? norm : Object.keys(HARNESS_REGISTRY).find((k) => norm.includes(k)) ?? null;
17911
+ if (!key) {
17912
+ return { ...UNKNOWN_DEFAULT, label: raw, raw, key: null, known: false };
17913
+ }
17914
+ return { ...HARNESS_REGISTRY[key], raw, key, known: true };
17915
+ }
17916
+
17917
+ // src/lib/harness-build-steps.ts
17918
+ init_git();
17919
+ function startBuildSteps(branch, base, taskId) {
17920
+ return [
17921
+ `PAPI is connected over HTTP and can't reach your files \u2014 your harness runs git in its own sandbox. To start ${taskId}:`,
17922
+ ` git fetch origin`,
17923
+ ` git checkout ${branch} 2>/dev/null || git checkout -b ${branch} origin/${base} 2>/dev/null || git checkout -b ${branch}`,
17924
+ `Then implement the task on branch \`${branch}\`.`
17925
+ ];
17926
+ }
17927
+ function completeBuildSteps(branch, base, taskId, title) {
17928
+ const message = `${taskId}: ${title}`.replace(/["`\\]/g, "'");
17929
+ return [
17930
+ `To ship ${taskId} from your sandbox:`,
17931
+ ` git add -A`,
17932
+ ` git commit -m "${message}"`,
17933
+ ` git push -u origin ${branch}`,
17934
+ `Then open a PR from \`${branch}\` into \`${base}\`.`
17935
+ ];
17936
+ }
17937
+ function hostedBranchName(taskId, module, cycleNumber) {
17938
+ return module && cycleNumber > 0 ? cycleBranchName(cycleNumber, module) : taskBranchName(taskId);
17939
+ }
17940
+ function hostedStartSteps(clientName, taskId, module, cycleNumber, base) {
17941
+ if (!detectHarness(clientName).build) return null;
17942
+ const branch = hostedBranchName(taskId, module, cycleNumber);
17943
+ return { branch, steps: startBuildSteps(branch, base, taskId) };
17944
+ }
17945
+ function hostedCompleteSteps(clientName, taskId, module, cycleNumber, base, title) {
17946
+ if (!detectHarness(clientName).build) return null;
17947
+ const branch = hostedBranchName(taskId, module, cycleNumber);
17948
+ return completeBuildSteps(branch, base, taskId, title);
17949
+ }
17950
+
17951
+ // src/services/build.ts
17502
17952
  init_git();
17503
17953
 
17504
17954
  // src/lib/owns-local-workspace.ts
@@ -17506,16 +17956,16 @@ init_git();
17506
17956
 
17507
17957
  // src/services/release.ts
17508
17958
  import { writeFile as writeFile3, readFile as readFile5 } from "fs/promises";
17509
- import { join as join7 } from "path";
17959
+ import { join as join9 } from "path";
17510
17960
  import { execFileSync as execFileSync4 } from "child_process";
17511
17961
 
17512
17962
  // src/lib/install-id.ts
17513
17963
  import { randomUUID as randomUUID10 } from "crypto";
17514
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync, chmodSync } from "fs";
17964
+ import { mkdirSync, readFileSync as readFileSync4, writeFileSync, chmodSync } from "fs";
17515
17965
  import { homedir as homedir2 } from "os";
17516
- import { join as join6 } from "path";
17517
- var PAPI_HOME_DIR = join6(homedir2(), ".papi");
17518
- var INSTALL_ID_FILE = join6(PAPI_HOME_DIR, "install-id.json");
17966
+ import { join as join8 } from "path";
17967
+ var PAPI_HOME_DIR = join8(homedir2(), ".papi");
17968
+ var INSTALL_ID_FILE = join8(PAPI_HOME_DIR, "install-id.json");
17519
17969
  var cachedInstallId = null;
17520
17970
  function isValidUuid(s) {
17521
17971
  return typeof s === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
@@ -17523,7 +17973,7 @@ function isValidUuid(s) {
17523
17973
  function getInstallId() {
17524
17974
  if (cachedInstallId) return cachedInstallId;
17525
17975
  try {
17526
- const raw = readFileSync2(INSTALL_ID_FILE, "utf-8");
17976
+ const raw = readFileSync4(INSTALL_ID_FILE, "utf-8");
17527
17977
  const parsed = JSON.parse(raw);
17528
17978
  if (isValidUuid(parsed.install_id)) {
17529
17979
  cachedInstallId = parsed.install_id;
@@ -17922,6 +18372,64 @@ To override, pass force=true (emits a telemetry warning).`
17922
18372
  }
17923
18373
  return { resolvedCycleNum, warnings, force, skipVersion };
17924
18374
  }
18375
+ async function contributorAutoPrRelease(config2, adapter2, version, productionBaseBranch, options) {
18376
+ if (!isGhAvailable()) {
18377
+ throw new Error(
18378
+ `Release blocked \u2014 opening a release PR needs the GitHub CLI (\`gh\`) authenticated locally. Install and \`gh auth login\`, then re-run \`release\`. (As a contributor you cannot release to ${productionBaseBranch} directly; the owner merges your PR.)`
18379
+ );
18380
+ }
18381
+ const cycleNum = await resolveCycleToClose(adapter2, version, options?.callerUserId);
18382
+ const branches = listGroupedCycleBranches(config2.projectRoot, cycleNum, productionBaseBranch);
18383
+ if (branches.length === 0) {
18384
+ throw new Error(
18385
+ `Release blocked \u2014 no cycle branch found to open a PR from (cycle ${cycleNum || "?"}). Make sure your work is committed on a \`feat/cycle-\u2026\` branch and pushed, then re-run \`release\`.`
18386
+ );
18387
+ }
18388
+ const prs = [];
18389
+ for (const branch of branches) {
18390
+ const push = gitPush(config2.projectRoot, branch);
18391
+ if (!push.success) {
18392
+ prs.push({ branch, url: null, error: `push failed: ${push.message}` });
18393
+ continue;
18394
+ }
18395
+ const existing = getPullRequestUrl(config2.projectRoot, branch);
18396
+ if (existing) {
18397
+ prs.push({ branch, url: existing });
18398
+ continue;
18399
+ }
18400
+ const title = `Release ${version}: ${branch}`;
18401
+ const body = `Contributor release PR for cycle ${cycleNum || "?"} (\`${branch}\`).
18402
+
18403
+ Opened by a non-owner editor via \`release\` (task-2244). The project owner reviews and merges this into \`${productionBaseBranch}\`; the contributor's own cycle is already marked complete in PAPI.`;
18404
+ const created = createPullRequest(config2.projectRoot, branch, productionBaseBranch, title, body);
18405
+ prs.push(
18406
+ created.success ? { branch, url: created.message.trim() || getPullRequestUrl(config2.projectRoot, branch) } : { branch, url: null, error: created.message }
18407
+ );
18408
+ }
18409
+ if (!prs.some((p) => p.url)) {
18410
+ const detail = prs.map((p) => `${p.branch}: ${p.error ?? "no PR url returned"}`).join("; ");
18411
+ throw new Error(`Release blocked \u2014 could not open any release PR. ${detail}`);
18412
+ }
18413
+ if (adapter2?.recordContributorReleasePr) {
18414
+ for (const p of prs) {
18415
+ if (!p.url) continue;
18416
+ try {
18417
+ await adapter2.recordContributorReleasePr({
18418
+ prUrl: p.url,
18419
+ branch: p.branch,
18420
+ cycle: cycleNum > 0 ? cycleNum : null,
18421
+ contributorUserId: options?.callerUserId ?? null
18422
+ });
18423
+ } catch (err) {
18424
+ console.error(
18425
+ `[release] recordContributorReleasePr failed for ${p.url} (non-blocking): ${err instanceof Error ? err.message : String(err)}`
18426
+ );
18427
+ }
18428
+ }
18429
+ }
18430
+ const closed = await closeCycleState(config2, adapter2, version, void 0, options);
18431
+ return { prs, cycleClosed: closed.resolvedCycleNum, warnings: closed.warnings };
18432
+ }
17925
18433
  async function createRelease(config2, branch, version, adapter2, cycleNum, options) {
17926
18434
  const collector = new FileWriteCollector();
17927
18435
  if (!isGitAvailable()) {
@@ -18009,7 +18517,7 @@ async function createRelease(config2, branch, version, adapter2, cycleNum, optio
18009
18517
  }
18010
18518
  }
18011
18519
  const latestTag = getLatestTag(config2.projectRoot);
18012
- const changelogPath = join7(config2.projectRoot, "CHANGELOG.md");
18520
+ const changelogPath = join9(config2.projectRoot, "CHANGELOG.md");
18013
18521
  if (!latestTag) {
18014
18522
  const initialContent = INITIAL_RELEASE_NOTES.replace("v0.1.0-alpha", version);
18015
18523
  if (config2.adapterType === "proxy") {
@@ -18296,11 +18804,50 @@ async function handleRelease(adapter2, config2, args) {
18296
18804
  const resolutionNote = gate.resolutionError ? `
18297
18805
 
18298
18806
  Identity resolution failed (${gate.resolutionError}) \u2014 the gate fails closed. Retry once connectivity is restored.` : "";
18299
- return errorResponse(
18300
- `Release to ${productionBaseBranch} is restricted to the project owner.
18807
+ tracker.mark("contributor-role-gate");
18808
+ const callerRole = adapter2.getContributorRole && gate.callerUserId ? await adapter2.getContributorRole(gate.callerUserId).catch(() => null) : null;
18809
+ if (callerRole !== "editor") {
18810
+ const roleNote = callerRole === "viewer" ? `Your role on this project is "viewer", which cannot release. Ask the owner (or an editor) to release, or ask for editor access.` : `Your identity does not match this project's owner, and you do not have an editor role to open a release PR. If you are a contributor, ask the owner for editor access (or push your branch and open a PR manually). If you ARE the owner on a local (pg) setup, set PAPI_USER_ID to your account UUID in .mcp.json; on the hosted/proxy setup your identity comes from your API key \u2014 check you are using YOUR key for YOUR project.`;
18811
+ return errorResponse(
18812
+ `Release to ${productionBaseBranch} is restricted to the project owner.
18301
18813
 
18302
- Your identity does not match this project's owner. If you are a contributor, push your branch and open a PR for the owner to release. If you ARE the owner on a local (pg) setup, set PAPI_USER_ID to your account UUID in .mcp.json; on the hosted/proxy setup your identity comes from your API key \u2014 check you are using YOUR key for YOUR project. Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
18303
- );
18814
+ ` + roleNote + `
18815
+
18816
+ Releasing to a non-base branch (e.g. dev) is unaffected.` + resolutionNote
18817
+ );
18818
+ }
18819
+ tracker.mark("contributor-auto-pr");
18820
+ try {
18821
+ const cr = await contributorAutoPrRelease(config2, adapter2, version, productionBaseBranch, {
18822
+ force: force ?? false,
18823
+ callerUserId: gate.callerUserId
18824
+ });
18825
+ const opened = cr.prs.filter((p) => p.url);
18826
+ const failed = cr.prs.filter((p) => !p.url);
18827
+ const cyclePart = cr.cycleClosed > 0 ? `Cycle ${cr.cycleClosed}` : "Your cycle";
18828
+ const prLines = opened.map((p) => `- \`${p.branch}\` \u2192 ${p.url}`);
18829
+ const failLines = failed.map((p) => `- \`${p.branch}\` \u2014 \u26A0\uFE0F ${p.error ?? "no PR opened"}`);
18830
+ const warnBlock = cr.warnings.length > 0 ? `
18831
+ \u26A0\uFE0F Warnings: ${cr.warnings.join("; ")}
18832
+ ` : "";
18833
+ return textResponse(
18834
+ `## Release ${version} \u2014 PR opened for owner review
18835
+
18836
+ You released as an **editor**, so PAPI opened a pull request to \`${productionBaseBranch}\` instead of merging directly. The owner reviews and merges it.
18837
+
18838
+ **Pull request(s):**
18839
+ ${prLines.join("\n")}
18840
+ ` + (failLines.length > 0 ? `
18841
+ **Could not open:**
18842
+ ${failLines.join("\n")}
18843
+ ` : "") + warnBlock + `
18844
+ ${cyclePart} is now marked **complete** in PAPI \u2014 your work is shipped from your side. Any changes the owner requests come forward as a fast-follow in your next cycle; your closed cycle is not rewound.
18845
+
18846
+ Next: run \`plan\` to start your next cycle.`
18847
+ );
18848
+ } catch (err) {
18849
+ return errorResponse(err instanceof Error ? err.message : String(err));
18850
+ }
18304
18851
  }
18305
18852
  }
18306
18853
  if (isHostedTransport()) {
@@ -18665,7 +19212,7 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
18665
19212
  if (modified.length === 0) {
18666
19213
  return "Auto-commit: skipped (no working-tree changes).";
18667
19214
  }
18668
- const dirname6 = (p) => {
19215
+ const dirname7 = (p) => {
18669
19216
  const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
18670
19217
  return idx > 0 ? p.slice(0, idx) : "";
18671
19218
  };
@@ -18677,7 +19224,7 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
18677
19224
  return `Auto-commit: refused \u2014 none of the ${modified.length} modified file(s) intersect FILES LIKELY TOUCHED. Modified: ${modSample}. Expected: ${predSample}. Stage the intended files manually (\`git add <paths>\`) then re-run, or set PAPI_AUTO_COMMIT=false.`;
18678
19225
  }
18679
19226
  const untracked = getUntrackedFiles(cwd);
18680
- const scopedDirs = [...new Set(scoped.map(dirname6).filter((d) => d.length > 0))];
19227
+ const scopedDirs = [...new Set(scoped.map(dirname7).filter((d) => d.length > 0))];
18681
19228
  const isUnderScopedDir = (p) => scopedDirs.some((d) => p === d || p.startsWith(`${d}/`) || p.startsWith(`${d}\\`));
18682
19229
  const scopedSet = new Set(scoped);
18683
19230
  const adjacentUntracked = untracked.filter(
@@ -18702,8 +19249,13 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
18702
19249
  }
18703
19250
  return safeRun(() => stageAllAndCommit(cwd, message));
18704
19251
  }
18705
- function pushAndCreatePR(config2, taskId, taskTitle) {
19252
+ function pushAndCreatePR(config2, taskId, taskTitle, clientName, module, cycleNumber) {
18706
19253
  const lines = [];
19254
+ if (!hasLocalWorkspace()) {
19255
+ const steps = hostedCompleteSteps(clientName, taskId, module, cycleNumber ?? 0, config2.baseBranch, taskTitle);
19256
+ if (steps) lines.push(...steps);
19257
+ return lines;
19258
+ }
18707
19259
  if (!isGitAvailable() || !isGitRepo(config2.projectRoot)) {
18708
19260
  return lines;
18709
19261
  }
@@ -18888,7 +19440,7 @@ async function describeTask(adapter2, taskId) {
18888
19440
  }
18889
19441
  return { task };
18890
19442
  }
18891
- async function startBuild(adapter2, config2, taskId, options = {}) {
19443
+ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
18892
19444
  const task = await adapter2.getTask(taskId);
18893
19445
  if (!task) {
18894
19446
  throw new Error(`Task "${taskId}" not found on the Cycle Board.`);
@@ -18930,6 +19482,12 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
18930
19482
  const branchLines = [];
18931
19483
  if (options.light) {
18932
19484
  branchLines.push("Light mode: skipping branch creation \u2014 working on current branch.");
19485
+ } else if (!hasLocalWorkspace()) {
19486
+ const cycleHealth = await adapter2.getCycleHealth().catch(() => null);
19487
+ const hosted = hostedStartSteps(clientName, taskId, task.module, cycleHealth?.totalCycles ?? 0, config2.baseBranch);
19488
+ if (hosted) {
19489
+ branchLines.push(...hosted.steps);
19490
+ }
18933
19491
  } else if (config2.autoCommit && isGitAvailable() && isGitRepo(config2.projectRoot)) {
18934
19492
  const cycleHealth = await adapter2.getCycleHealth().catch(() => null);
18935
19493
  const cycleNumber = cycleHealth?.totalCycles ?? 0;
@@ -18998,6 +19556,18 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
18998
19556
  branchLines.push(`Reusing shared cycle branch for ${task.complexity} ${task.module} task.`);
18999
19557
  }
19000
19558
  } else {
19559
+ const baseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
19560
+ if (baseBranch !== config2.baseBranch) {
19561
+ branchLines.push(`Base branch '${config2.baseBranch}' not found \u2014 using '${baseBranch}'.`);
19562
+ }
19563
+ const trackedDirty = getTrackedModifiedFiles(config2.projectRoot);
19564
+ if (trackedDirty.length > 0 && currentBranch !== baseBranch) {
19565
+ const shown = trackedDirty.slice(0, 5).join(", ");
19566
+ const more = trackedDirty.length > 5 ? ` (+${trackedDirty.length - 5} more)` : "";
19567
+ throw new Error(
19568
+ `build_execute won't switch branches: the working tree has uncommitted changes on '${currentBranch}' (this task targets '${featureBranch}'). Another session may be working there, and switching would revert those files. Commit or stash them first, or run build_execute from '${baseBranch}'. Dirty: ${shown}${more}.`
19569
+ );
19570
+ }
19001
19571
  if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
19002
19572
  const { toStash, preservedDocs } = selectAutostashPaths(
19003
19573
  getModifiedFiles(config2.projectRoot),
@@ -19026,10 +19596,6 @@ async function startBuild(adapter2, config2, taskId, options = {}) {
19026
19596
  }
19027
19597
  }
19028
19598
  }
19029
- const baseBranch = resolveBaseBranch(config2.projectRoot, config2.baseBranch);
19030
- if (baseBranch !== config2.baseBranch) {
19031
- branchLines.push(`Base branch '${config2.baseBranch}' not found \u2014 using '${baseBranch}'.`);
19032
- }
19033
19599
  const featureBranchExistsLocally = branchExists(config2.projectRoot, featureBranch);
19034
19600
  const featureBranchOnOrigin = !!originCycleBranch && originCycleBranch === featureBranch;
19035
19601
  const featureBranchExists = featureBranchExistsLocally || featureBranchOnOrigin;
@@ -19125,16 +19691,16 @@ function writeActiveTaskScope(projectRoot, taskId, filesLikelyTouched, adapterTy
19125
19691
  collector.add({ path: ".papi/active-task-scope.txt", content, mode: "overwrite" });
19126
19692
  return;
19127
19693
  }
19128
- const papiDir = join8(projectRoot, ".papi");
19129
- if (!existsSync4(papiDir)) {
19694
+ const papiDir = join10(projectRoot, ".papi");
19695
+ if (!existsSync6(papiDir)) {
19130
19696
  mkdirSync2(papiDir, { recursive: true });
19131
19697
  }
19132
- const scopePath = join8(papiDir, "active-task-scope.txt");
19698
+ const scopePath = join10(papiDir, "active-task-scope.txt");
19133
19699
  writeFileSync2(scopePath, content, "utf-8");
19134
19700
  }
19135
19701
  function clearActiveTaskScope(projectRoot) {
19136
- const scopePath = join8(projectRoot, ".papi", "active-task-scope.txt");
19137
- if (existsSync4(scopePath)) {
19702
+ const scopePath = join10(projectRoot, ".papi", "active-task-scope.txt");
19703
+ if (existsSync6(scopePath)) {
19138
19704
  unlinkSync(scopePath);
19139
19705
  }
19140
19706
  }
@@ -19154,7 +19720,7 @@ function extractDocMeta(absolutePath, relativePath, cycleNumber) {
19154
19720
  else if (relativePath.startsWith("docs/architecture/")) type = "architecture";
19155
19721
  else if (relativePath.startsWith("docs/audits/")) type = "audit";
19156
19722
  try {
19157
- const content = readFileSync3(absolutePath, "utf-8").slice(0, 2e3);
19723
+ const content = readFileSync5(absolutePath, "utf-8").slice(0, 2e3);
19158
19724
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
19159
19725
  if (fmMatch) {
19160
19726
  const fm = fmMatch[1];
@@ -19201,7 +19767,7 @@ Fix the deploy first, then re-run build_execute complete with a 2xx verification
19201
19767
  );
19202
19768
  }
19203
19769
  }
19204
- async function completeBuild(adapter2, config2, taskId, input, options = {}) {
19770
+ async function completeBuild(adapter2, config2, taskId, input, options = {}, clientName) {
19205
19771
  const task = await adapter2.getTask(taskId);
19206
19772
  if (!task) {
19207
19773
  throw new Error(`Task "${taskId}" not found on the Cycle Board.`);
@@ -19479,7 +20045,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
19479
20045
  if (options.light) {
19480
20046
  prLines.push("Light mode: skipping push and PR creation.");
19481
20047
  } else if (config2.autoCommit && input.completed === "yes") {
19482
- prLines = pushAndCreatePR(config2, taskId, task.title);
20048
+ prLines = pushAndCreatePR(config2, taskId, task.title, clientName, task.module, cycleNumber);
19483
20049
  }
19484
20050
  const allTasks = await adapter2.queryBoard();
19485
20051
  warnIfEmpty("queryBoard (cycle progress)", allTasks);
@@ -19499,14 +20065,14 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
19499
20065
  let docWarning;
19500
20066
  try {
19501
20067
  if (adapter2.searchDocs && hasLocalWorkspace() && await ownsLocalWorkspace(adapter2, config2.projectRoot)) {
19502
- const docsDir = join8(config2.projectRoot, "docs");
19503
- if (existsSync4(docsDir)) {
20068
+ const docsDir = join10(config2.projectRoot, "docs");
20069
+ if (existsSync6(docsDir)) {
19504
20070
  const scanDir = (dir, depth = 0) => {
19505
20071
  if (depth > 8) return [];
19506
- const entries = readdirSync4(dir, { withFileTypes: true });
20072
+ const entries = readdirSync5(dir, { withFileTypes: true });
19507
20073
  const files = [];
19508
20074
  for (const e of entries) {
19509
- const full = join8(dir, e.name);
20075
+ const full = join10(dir, e.name);
19510
20076
  if (e.isDirectory() && !e.isSymbolicLink()) files.push(...scanDir(full, depth + 1));
19511
20077
  else if (e.name.endsWith(".md")) files.push(full.replace(config2.projectRoot + "/", ""));
19512
20078
  }
@@ -19521,7 +20087,7 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}) {
19521
20087
  const failed = [];
19522
20088
  for (const docPath of unregistered) {
19523
20089
  try {
19524
- const meta = extractDocMeta(join8(config2.projectRoot, docPath), docPath, cycleNumber);
20090
+ const meta = extractDocMeta(join10(config2.projectRoot, docPath), docPath, cycleNumber);
19525
20091
  await adapter2.registerDoc({
19526
20092
  title: meta.title,
19527
20093
  type: meta.type,
@@ -19598,6 +20164,9 @@ var OWNER_NAME = process.env["PAPI_OWNER"] ?? "cathalos92";
19598
20164
  var MODULE_INSTRUCTIONS = {
19599
20165
  Dashboard: `**\u26A0\uFE0F MANDATORY \u2014 Dashboard Module Rules (skip = rework)**
19600
20166
 
20167
+ **STEP 0 \u2014 Dispatch the design agent for the visual layer (do NOT hand-write UI in the main build context).**
20168
+ Any \`.tsx\` that renders visible UI must be built by the \`frontend-design-engineer\` subagent (dispatch via the Task tool), not inline here. It works in an isolated window loaded only with brand canon (DESIGN.md / PRODUCT.md / brand-book), runs design-critique before and after, builds via the \`frontend-design\` / \`impeccable\` skills, self-checks the falsifiable anti-slop blocklist, and verifies in-browser at populated / empty / mobile states. You remain responsible for data, routes, types, and tests \u2014 hand the visual layer to the agent and integrate the report it returns. Components hand-written in this context produce generic output that fails review. (If \`.claude/agents/frontend-design-engineer.md\` is absent \u2014 e.g. a fresh external install \u2014 run STEPs 1-4 below yourself.)
20169
+
19601
20170
  **STEP 1 \u2014 BEFORE writing any code:**
19602
20171
  If you use the \`impeccable\` skill (recommended for dashboard work), it reads two root files for design context: **PRODUCT.md** (strategic \u2014 brand, users, product purpose, design principles) and **DESIGN.md** (visual tokens \u2014 palette, typography, elevation, components). Run \`impeccable init\` to create them if they don't exist yet. Every visual decision must align with these files; if your output contradicts them, it is wrong. (The legacy \`.impeccable.md\` is no longer read by the skill.)
19603
20172
 
@@ -19837,7 +20406,7 @@ function isResearchOrSpike(task) {
19837
20406
  }
19838
20407
  function formatListItem(task) {
19839
20408
  const base = `- **${task.id}:** ${task.title}
19840
- Status: ${task.status} | Priority: ${task.priority} | Complexity: ${task.complexity}`;
20409
+ Status: ${task.status} | Priority: ${task.priority} | Complexity: ${task.complexity} | ${modelTierTag(task.complexity)}`;
19841
20410
  if (isResearchOrSpike(task)) {
19842
20411
  return base + "\n _Research/spike \u2014 still needed?_ **(a)** build_execute | **(b)** fast-close (answered) | **(c)** deprioritise | **(d)** discuss";
19843
20412
  }
@@ -19959,7 +20528,7 @@ async function handleBuildDescribe(adapter2, args) {
19959
20528
  return errorResponse(err instanceof Error ? err.message : String(err));
19960
20529
  }
19961
20530
  }
19962
- async function handleBuildExecute(adapter2, config2, args) {
20531
+ async function handleBuildExecute(adapter2, config2, args, clientName) {
19963
20532
  const taskId = args.task_id;
19964
20533
  if (!taskId) {
19965
20534
  return errorResponse("task_id is required.");
@@ -19983,15 +20552,20 @@ async function handleBuildExecute(adapter2, config2, args) {
19983
20552
  }
19984
20553
  }
19985
20554
  if (hasReportFields(args)) {
19986
- return handleExecuteComplete(adapter2, config2, taskId, args, light);
20555
+ return handleExecuteComplete(adapter2, config2, taskId, args, light, clientName);
19987
20556
  }
19988
20557
  const tracker = new ProgressTracker("start_build");
19989
20558
  try {
19990
- const result = await startBuild(adapter2, config2, taskId, { light });
20559
+ const result = await startBuild(adapter2, config2, taskId, { light }, clientName);
19991
20560
  tracker.mark("start_decorate_handoff");
19992
20561
  const branchInfo = result.branchLines.length > 0 ? result.branchLines.map((l) => `> ${l}`).join("\n") + "\n\n" : "";
19993
20562
  const phaseNote = result.phaseChanges.length > 0 ? "\n\n" + result.phaseChanges.map((c) => `Phase auto-updated: ${c.phaseId} ${c.oldStatus} \u2192 ${c.newStatus}`).join("\n") : "";
19994
- const header = `${branchInfo}**Executing ${result.task.id}: ${result.task.title}** \u2014 marked In Progress.
20563
+ const projectInfo = adapter2.getProjectInfo ? await adapter2.getProjectInfo().catch(() => null) : null;
20564
+ const projectBanner = projectInfo ? getProjectConnectionBanner(projectInfo.name, projectInfo.slug) : null;
20565
+ const projectLine = projectBanner ? `> ${projectBanner}
20566
+
20567
+ ` : "";
20568
+ const header = `${projectLine}${branchInfo}**Executing ${result.task.id}: ${result.task.title}** \u2014 marked In Progress.
19995
20569
 
19996
20570
  ---
19997
20571
 
@@ -20034,7 +20608,8 @@ ${entries}`;
20034
20608
  const moduleInstructions = getModuleInstructions(result.task.module);
20035
20609
  const moduleContext = await getModuleContext(adapter2, result.task);
20036
20610
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
20037
- return textResponse(header + serializeBuildHandoff(result.task.buildHandoff) + adSection + moduleInstructions + moduleContext + dogfoodSection + verificationNote + chainInstruction + phaseNote + filesToWriteSection);
20611
+ const modelNote = formatModelRecommendation(result.task.buildHandoff?.effort ?? result.task.complexity);
20612
+ return textResponse(header + serializeBuildHandoff(result.task.buildHandoff) + modelNote + adSection + moduleInstructions + moduleContext + dogfoodSection + verificationNote + chainInstruction + phaseNote + filesToWriteSection);
20038
20613
  } catch (err) {
20039
20614
  if (isNoHandoffError(err)) {
20040
20615
  const lines = [
@@ -20070,7 +20645,7 @@ ${entries}`;
20070
20645
  }));
20071
20646
  }
20072
20647
  }
20073
- async function handleExecuteComplete(adapter2, config2, taskId, args, light = false) {
20648
+ async function handleExecuteComplete(adapter2, config2, taskId, args, light = false, clientName) {
20074
20649
  const completed = args.completed;
20075
20650
  const effort = args.effort;
20076
20651
  const estimatedEffort = args.estimated_effort;
@@ -20148,7 +20723,7 @@ async function handleExecuteComplete(adapter2, config2, taskId, args, light = fa
20148
20723
  })),
20149
20724
  productionVerification,
20150
20725
  preview
20151
- }, { light });
20726
+ }, { light }, clientName);
20152
20727
  tracker.mark("complete_format");
20153
20728
  if (resolvesLearnings && resolvesLearnings.length > 0 && adapter2.updateCycleLearningActionRef) {
20154
20729
  for (const learningId of resolvesLearnings) {
@@ -20279,14 +20854,12 @@ function formatCompleteResult(result) {
20279
20854
  } else {
20280
20855
  lines.push("", `Next: run \`build_list\` to see ${remaining} remaining cycle task${remaining === 1 ? "" : "s"}, then \`build_execute <task_id>\` to start the next one.`);
20281
20856
  }
20282
- lines.push("", "*Tip: Start a new chat for your next build to keep context fresh and avoid hitting context window limits.*");
20283
20857
  } else {
20284
20858
  if (hasDiscoveredIssues) {
20285
20859
  lines.push("", "All cycle tasks complete. Discovered issues logged \u2014 run `review_list` then `review_submit` to review builds, then `release` to close the cycle.");
20286
20860
  } else {
20287
20861
  lines.push("", "All cycle tasks complete. Run `review_list` then `review_submit` to review builds, then `release` to close the cycle.");
20288
20862
  }
20289
- lines.push("", "*Tip: Start a new chat for your next session to keep context fresh.*");
20290
20863
  }
20291
20864
  return lines.join("\n");
20292
20865
  }
@@ -20875,13 +21448,13 @@ _To correct: board_edit ${result.task.id} with updated fields._`
20875
21448
  init_git();
20876
21449
 
20877
21450
  // src/services/reconcile.ts
20878
- import { readFileSync as readFileSync4 } from "fs";
20879
- import { join as join9 } from "path";
21451
+ import { readFileSync as readFileSync6 } from "fs";
21452
+ import { join as join11 } from "path";
20880
21453
  function loadDocsIndex(projectRoot) {
20881
21454
  if (!hasLocalWorkspace()) return "";
20882
21455
  try {
20883
- const indexPath = join9(projectRoot, "docs", "INDEX.md");
20884
- const raw = readFileSync4(indexPath, "utf8");
21456
+ const indexPath = join11(projectRoot, "docs", "INDEX.md");
21457
+ const raw = readFileSync6(indexPath, "utf8");
20885
21458
  const rows = raw.split("\n").filter((l) => l.startsWith("| ["));
20886
21459
  if (rows.length === 0) return "";
20887
21460
  const entries = rows.map((row) => {
@@ -21456,8 +22029,8 @@ Produce your analysis and structured output above. Present Part 1 to the user an
21456
22029
  }
21457
22030
 
21458
22031
  // src/tools/review.ts
21459
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
21460
- import { join as join10 } from "path";
22032
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
22033
+ import { join as join12 } from "path";
21461
22034
  init_git();
21462
22035
 
21463
22036
  // src/services/review.ts
@@ -21669,11 +22242,11 @@ ${task.buildReport}` : "### Build Report\n(none recorded)";
21669
22242
  ${diff}
21670
22243
  \`\`\`` : "### Branch diff vs base\n(no diff resolved \u2014 not a git repo, no base ref, or no committed changes)";
21671
22244
  let projectContext = "";
21672
- const ctxPath = join10(config2.projectRoot, ".agents", "papi-context.md");
21673
- if (existsSync5(ctxPath)) {
22245
+ const ctxPath = join12(config2.projectRoot, ".agents", "papi-context.md");
22246
+ if (existsSync7(ctxPath)) {
21674
22247
  try {
21675
22248
  projectContext = `### Project context (.agents/papi-context.md)
21676
- ${readFileSync5(ctxPath, "utf-8")}
22249
+ ${readFileSync7(ctxPath, "utf-8")}
21677
22250
 
21678
22251
  `;
21679
22252
  } catch {
@@ -21828,8 +22401,8 @@ function mergeAfterAccept(config2, taskId) {
21828
22401
  };
21829
22402
  }
21830
22403
  const details = [];
21831
- const papiDir = join10(config2.projectRoot, ".papi");
21832
- if (existsSync5(papiDir)) {
22404
+ const papiDir = join12(config2.projectRoot, ".papi");
22405
+ if (existsSync7(papiDir)) {
21833
22406
  try {
21834
22407
  const commitResult = stageDirAndCommit(
21835
22408
  config2.projectRoot,
@@ -22859,8 +23432,9 @@ async function getHealthSummary(adapter2) {
22859
23432
  const cycleNumber = health.totalCycles;
22860
23433
  const cyclesSinceReview = health.cyclesSinceLastStrategyReview;
22861
23434
  const reviewDue = health.strategyReviewDue;
22862
- const reviewGateBlocking = cyclesSinceReview >= 5;
22863
- const reviewWarning = reviewGateBlocking ? `\u26A0\uFE0F GATE \u2014 ${cyclesSinceReview} cycles since last Strategy Review. \`plan\` is blocked until \`strategy_review\` runs (or \`force: true\`).` : `\u2713 On track \u2014 ${cyclesSinceReview} cycle(s) since last review. Next due: ${reviewDue}`;
23435
+ const reviewGateBlocking = cyclesSinceReview >= STRATEGY_REVIEW_BLOCK_GAP;
23436
+ const reviewAvailable = cyclesSinceReview >= STRATEGY_REVIEW_OFFER_GAP && !reviewGateBlocking;
23437
+ const reviewWarning = reviewGateBlocking ? `\u26A0\uFE0F GATE \u2014 ${cyclesSinceReview} cycles since last Strategy Review. \`plan\` is blocked until \`strategy_review\` runs (or \`force: true\`).` : reviewAvailable ? `\u25CB Strategy review available \u2014 ${cyclesSinceReview} cycles since last review. Offered now (optional); \`plan\` hard-blocks at ${STRATEGY_REVIEW_BLOCK_GAP} cycles.` : `\u2713 On track \u2014 ${cyclesSinceReview} cycle(s) since last review. Next due: ${reviewDue}`;
22864
23438
  const deferredCount = activeTasks.filter((t) => t.status === "Deferred").length;
22865
23439
  const nonDeferredTasks = activeTasks.filter((t) => t.status !== "Deferred");
22866
23440
  const statusCounts = countByStatus(nonDeferredTasks);
@@ -23139,115 +23713,9 @@ function formatUnblockSection(candidates) {
23139
23713
  return lines.join("\n");
23140
23714
  }
23141
23715
 
23142
- // src/lib/skill-detection.ts
23143
- import { existsSync as existsSync6, readdirSync as readdirSync5, readFileSync as readFileSync6, statSync as statSync4 } from "fs";
23144
- import { join as join11 } from "path";
23145
- function readPackageJson(projectRoot) {
23146
- const path7 = join11(projectRoot, "package.json");
23147
- if (!existsSync6(path7)) return null;
23148
- try {
23149
- const raw = readFileSync6(path7, "utf-8");
23150
- return JSON.parse(raw);
23151
- } catch {
23152
- return null;
23153
- }
23154
- }
23155
- function allDeps(pkg) {
23156
- if (!pkg) return {};
23157
- return { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
23158
- }
23159
- function hasDependencyMatching(deps, pattern) {
23160
- for (const name of Object.keys(deps)) {
23161
- if (pattern.test(name)) return true;
23162
- }
23163
- return false;
23164
- }
23165
- function hasGitHubWorkflows(projectRoot) {
23166
- const dir = join11(projectRoot, ".github", "workflows");
23167
- if (!existsSync6(dir)) return false;
23168
- try {
23169
- const entries = readdirSync5(dir);
23170
- return entries.some((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
23171
- } catch {
23172
- return false;
23173
- }
23174
- }
23175
- function envExampleMentionsStaging(projectRoot) {
23176
- const path7 = join11(projectRoot, ".env.example");
23177
- if (!existsSync6(path7)) return false;
23178
- try {
23179
- const raw = readFileSync6(path7, "utf-8");
23180
- return /\b(STAGING_URL|STAGING_API|STAGING_HOST|NEXT_PUBLIC_STAGING)/i.test(raw);
23181
- } catch {
23182
- return false;
23183
- }
23184
- }
23185
- function hasVercelConfig(projectRoot) {
23186
- if (existsSync6(join11(projectRoot, "vercel.json"))) return true;
23187
- const vercelDir = join11(projectRoot, ".vercel");
23188
- if (!existsSync6(vercelDir)) return false;
23189
- try {
23190
- return statSync4(vercelDir).isDirectory();
23191
- } catch {
23192
- return false;
23193
- }
23194
- }
23195
- function scanForSkillSignals(projectRoot) {
23196
- const proposals = [];
23197
- const pkg = readPackageJson(projectRoot);
23198
- const deps = allDeps(pkg);
23199
- if (hasGitHubWorkflows(projectRoot)) {
23200
- proposals.push({
23201
- id: "gh-actions-debug",
23202
- name: "GitHub Actions debugger",
23203
- rationale: "Detected .github/workflows/ \u2014 a skill for inspecting CI failures and re-running jobs from Claude Code can shorten the debug loop.",
23204
- hint: 'Search Claude Code skill registry for "gh-actions" or install via `claude skills add gh-actions-debug`.'
23205
- });
23206
- }
23207
- if (hasDependencyMatching(deps, /^@sentry\//) || hasDependencyMatching(deps, /^@datadog\//)) {
23208
- proposals.push({
23209
- id: "error-tracking",
23210
- name: "Error tracking helper",
23211
- rationale: "Detected @sentry/* or @datadog/* in package.json \u2014 a skill that fetches recent error events into your context can speed up triage.",
23212
- hint: 'Search Claude Code skill registry for "sentry" or "datadog".'
23213
- });
23214
- }
23215
- if (hasVercelConfig(projectRoot)) {
23216
- proposals.push({
23217
- id: "read-vercel-logs",
23218
- name: "Vercel deploy logs",
23219
- rationale: "Detected vercel.json or .vercel/ \u2014 a skill for pulling deploy + runtime logs from Vercel directly into Claude Code helps when builds fail or runtime errors land.",
23220
- hint: 'Search Claude Code skill registry for "vercel" or install `vercel:logs`.'
23221
- });
23222
- }
23223
- if (envExampleMentionsStaging(projectRoot)) {
23224
- proposals.push({
23225
- id: "staging-environment",
23226
- name: "Staging-environment helper",
23227
- rationale: "Detected STAGING_* variables in .env.example \u2014 a skill for switching env contexts and seeding staging data can speed up pre-prod testing.",
23228
- hint: 'Search Claude Code skill registry for "staging" or define your own.'
23229
- });
23230
- }
23231
- return proposals;
23232
- }
23233
- function formatSkillProposals(proposals) {
23234
- if (proposals.length === 0) return "";
23235
- const lines = ["", "## Skill proposals", "_PAPI scanned your project once and noticed tooling that often pairs with a Claude Code skill. Each proposal is one-shot \u2014 they won't resurface._"];
23236
- for (const p of proposals) {
23237
- lines.push("");
23238
- lines.push(`### ${p.name}`);
23239
- lines.push(p.rationale);
23240
- if (p.hint) {
23241
- lines.push(`_${p.hint}_`);
23242
- }
23243
- lines.push("**[Yes]** install it now \xB7 **[Not now]** dismiss for this project");
23244
- }
23245
- return "\n" + lines.join("\n");
23246
- }
23247
-
23248
23716
  // src/tools/agent-list.ts
23249
23717
  import { readdir as readdir2, readFile as readFile7 } from "fs/promises";
23250
- import { join as join12 } from "path";
23718
+ import { join as join13 } from "path";
23251
23719
  var NO_AGENTS_HINT = "No project sub-agents found in `.claude/agents/`. Add a `*.md` file with `name` + `description` frontmatter \u2014 see the 1926-Census marketing sub-agent for a reference implementation.";
23252
23720
  function parseAgentFrontmatter(content) {
23253
23721
  const match = content.match(/^---\n([\s\S]*?)\n---/);
@@ -23259,7 +23727,7 @@ function parseAgentFrontmatter(content) {
23259
23727
  return { name: nameMatch?.[1].trim(), description };
23260
23728
  }
23261
23729
  async function listAgents(projectRoot) {
23262
- const agentsDir = join12(projectRoot, ".claude", "agents");
23730
+ const agentsDir = join13(projectRoot, ".claude", "agents");
23263
23731
  let files;
23264
23732
  try {
23265
23733
  files = await readdir2(agentsDir);
@@ -23270,7 +23738,7 @@ async function listAgents(projectRoot) {
23270
23738
  for (const file of files.filter((f) => f.endsWith(".md"))) {
23271
23739
  let content;
23272
23740
  try {
23273
- content = await readFile7(join12(agentsDir, file), "utf-8");
23741
+ content = await readFile7(join13(agentsDir, file), "utf-8");
23274
23742
  } catch {
23275
23743
  continue;
23276
23744
  }
@@ -23278,7 +23746,7 @@ async function listAgents(projectRoot) {
23278
23746
  agents.push({
23279
23747
  name: meta?.name ?? file.replace(/\.md$/, ""),
23280
23748
  description: meta?.description ?? "",
23281
- path: join12(".claude", "agents", file)
23749
+ path: join13(".claude", "agents", file)
23282
23750
  });
23283
23751
  }
23284
23752
  agents.sort((a, b2) => a.name.localeCompare(b2.name));
@@ -23319,8 +23787,8 @@ ${formatted}`);
23319
23787
  }
23320
23788
 
23321
23789
  // src/tools/doc-registry.ts
23322
- import { readdirSync as readdirSync6, existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
23323
- import { join as join13, relative } from "path";
23790
+ import { readdirSync as readdirSync6, existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
23791
+ import { join as join14, relative } from "path";
23324
23792
  import { homedir as homedir3 } from "os";
23325
23793
  import { randomUUID as randomUUID16 } from "crypto";
23326
23794
  var docRegisterTool = {
@@ -23517,7 +23985,7 @@ async function handleDocSearch(adapter2, args, config2) {
23517
23985
  const lines = docs.map((d) => {
23518
23986
  const actionCount = d.actions?.filter((a) => a.status === "pending").length ?? 0;
23519
23987
  const actionNote = actionCount > 0 ? ` | ${actionCount} pending action(s)` : "";
23520
- const missingNote = root && d.path && !existsSync7(join13(root, d.path)) ? `
23988
+ const missingNote = root && d.path && !existsSync8(join14(root, d.path)) ? `
23521
23989
  > \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.` : "";
23522
23990
  return `### ${d.title}
23523
23991
  **Type:** ${d.type} | **Status:** ${d.status} | **Cycle:** ${d.cycleCreated}${d.cycleUpdated ? `\u2192${d.cycleUpdated}` : ""}${actionNote}
@@ -23531,12 +23999,12 @@ ${d.summary}
23531
23999
  ${lines.join("\n---\n\n")}`);
23532
24000
  }
23533
24001
  function scanMdFiles(dir, rootDir) {
23534
- if (!existsSync7(dir)) return [];
24002
+ if (!existsSync8(dir)) return [];
23535
24003
  const files = [];
23536
24004
  try {
23537
24005
  const entries = readdirSync6(dir, { withFileTypes: true });
23538
24006
  for (const entry of entries) {
23539
- const full = join13(dir, entry.name);
24007
+ const full = join14(dir, entry.name);
23540
24008
  if (entry.isDirectory()) {
23541
24009
  files.push(...scanMdFiles(full, rootDir));
23542
24010
  } else if (entry.name.endsWith(".md")) {
@@ -23549,7 +24017,7 @@ function scanMdFiles(dir, rootDir) {
23549
24017
  }
23550
24018
  function extractTitle(filePath) {
23551
24019
  try {
23552
- const content = readFileSync7(filePath, "utf-8").slice(0, 1e3);
24020
+ const content = readFileSync8(filePath, "utf-8").slice(0, 1e3);
23553
24021
  const fmMatch = content.match(/^---[\s\S]*?title:\s*(.+?)$/m);
23554
24022
  if (fmMatch) return fmMatch[1].trim().replace(/^["']|["']$/g, "");
23555
24023
  const headingMatch = content.match(/^#+\s+(.+)$/m);
@@ -23570,17 +24038,17 @@ async function handleDocScan(adapter2, config2, args) {
23570
24038
  const includePlans = args.include_plans ?? false;
23571
24039
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
23572
24040
  const registeredPaths = new Set(registered.map((d) => d.path));
23573
- const docsDir = join13(config2.projectRoot, "docs");
24041
+ const docsDir = join14(config2.projectRoot, "docs");
23574
24042
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
23575
24043
  const unregisteredDocs = docsFiles.filter((f) => !registeredPaths.has(f));
23576
24044
  let unregisteredPlans = [];
23577
24045
  if (includePlans) {
23578
- const plansDir = join13(homedir3(), ".claude", "plans");
23579
- if (existsSync7(plansDir)) {
24046
+ const plansDir = join14(homedir3(), ".claude", "plans");
24047
+ if (existsSync8(plansDir)) {
23580
24048
  const planFiles = scanMdFiles(plansDir, plansDir);
23581
24049
  unregisteredPlans = planFiles.map((f) => `plans/${f}`).filter((f) => !registeredPaths.has(f)).map((f) => ({
23582
24050
  path: f,
23583
- title: extractTitle(join13(plansDir, f.replace("plans/", "")))
24051
+ title: extractTitle(join14(plansDir, f.replace("plans/", "")))
23584
24052
  }));
23585
24053
  }
23586
24054
  }
@@ -23591,7 +24059,7 @@ async function handleDocScan(adapter2, config2, args) {
23591
24059
  if (unregisteredDocs.length > 0) {
23592
24060
  lines.push(`## Unregistered Docs (${unregisteredDocs.length})`);
23593
24061
  for (const f of unregisteredDocs) {
23594
- const title = extractTitle(join13(config2.projectRoot, f));
24062
+ const title = extractTitle(join14(config2.projectRoot, f));
23595
24063
  lines.push(`- \`${f}\`${title ? ` \u2014 ${title}` : ""}`);
23596
24064
  }
23597
24065
  }
@@ -23701,6 +24169,22 @@ ${userNotes}` : referenceLine;
23701
24169
  );
23702
24170
  }
23703
24171
 
24172
+ // src/lib/harness-guidance.ts
24173
+ function buildLoopRunsHere(i) {
24174
+ if (i.forceLocal) return true;
24175
+ if (i.harness.known) return i.harness.build;
24176
+ if (i.envHintsNoGit) return false;
24177
+ return i.localWorkspace;
24178
+ }
24179
+ function nextActionQualifier(harness) {
24180
+ const where = harness.known ? harness.label : "your tool";
24181
+ return `_Note: \`build_execute\`, \`review_submit\`, and \`release\` run where your project files live. From ${where} you can plan, log ideas, and steer the board \u2014 the build / review / release loop runs in the environment that holds your code._`;
24182
+ }
24183
+ function connectOnlyRoleNote(harness) {
24184
+ if (harness.build || !harness.known) return null;
24185
+ return `**You're connected as a planning surface.** ${harness.label} is a first-class place to plan, capture decisions, and steer the board. The code gets built wherever your build harness lives (a CLI like Codex, or a sandbox like Lovable or Bolt) and your plan + decisions travel with it. This is a full half of the loop, not a limited mode.`;
24186
+ }
24187
+
23704
24188
  // src/lib/enrichment.ts
23705
24189
  function headingSlug(line) {
23706
24190
  const match = line.match(/^##\s+(.+?)(?:\s*\([^)]*\))?\s*$/);
@@ -23786,8 +24270,8 @@ async function verifyProject(adapter2) {
23786
24270
  // src/tools/orient.ts
23787
24271
  import { execFile as execFile2 } from "child_process";
23788
24272
  import { promisify as promisify2 } from "util";
23789
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync3, existsSync as existsSync8 } from "fs";
23790
- import { join as join14 } from "path";
24273
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync3, existsSync as existsSync9 } from "fs";
24274
+ import { join as join15 } from "path";
23791
24275
  var execFileAsync2 = promisify2(execFile2);
23792
24276
  var GIT_DEPENDENT_ENVS = /* @__PURE__ */ new Set(["hosted", "api"]);
23793
24277
  var VALID_ENVS = /* @__PURE__ */ new Set(["local-cli", "hosted", "api", "unknown"]);
@@ -23832,7 +24316,7 @@ async function runWithLimit(concurrency, tasks) {
23832
24316
  }
23833
24317
  var orientTool = {
23834
24318
  name: "orient",
23835
- description: "Session orientation \u2014 run this FIRST at session start before any other tool. Single call that replaces build_list + health. Returns: cycle number, task counts by status, in-progress/in-review tasks, strategy review cadence, velocity snapshot, recommended next action, and a release reminder when all cycle tasks are Done but release has not run. Read-only, does not modify any files. Pass `environment` to qualify git-dependent recommendations (build_execute, release, review_submit) for non-Claude-Code callers.",
24319
+ description: "Session orientation \u2014 run this FIRST at session start before any other tool. Single call that replaces build_list + health. Returns: cycle number, task counts by status, in-progress/in-review tasks, strategy review cadence, velocity snapshot, recommended next action, and a release reminder when all cycle tasks are Done but release has not run. Read-only, does not modify any files. PAPI detects build capability from the connecting harness (clientInfo); pass `environment` only to override that detection for git-dependent recommendations (build_execute, release, review_submit).",
23836
24320
  annotations: { readOnlyHint: true, destructiveHint: false },
23837
24321
  inputSchema: {
23838
24322
  type: "object",
@@ -23840,7 +24324,7 @@ var orientTool = {
23840
24324
  environment: {
23841
24325
  type: "string",
23842
24326
  enum: ["local-cli", "hosted", "api", "unknown"],
23843
- description: 'Caller environment. "local-cli" (local CLI with git \u2014 Claude Code, Codex, or any local MCP client) sees all recommendations unchanged. "hosted" or "api" (no local git) suppresses/qualifies build_execute, release, and review_submit recommendations because those require a local session with git access. Default "unknown" = show everything (safe default). Legacy values "claude-code" and "cowork" are accepted as aliases (deprecated).'
24327
+ description: `Caller environment OVERRIDE. By default PAPI branches git-dependent recommendations on the connecting harness's detected capability \u2014 a build-capable harness (local CLI like Codex/opencode, or a sandboxed cloud harness like Lovable/Bolt/Replit) sees the full loop; a connect-only chat surface sees planning-only framing. Pass this only to override: "local-cli" forces the full loop, "hosted"/"api" hints no local git for an UNRECOGNISED harness. Default "unknown" = trust detection. Legacy "claude-code"/"cowork" accepted as aliases (deprecated).`
23844
24328
  },
23845
24329
  deep_housekeeping: {
23846
24330
  type: "boolean",
@@ -23884,7 +24368,7 @@ function formatSynthesisParagraph(opts) {
23884
24368
  parts.push(`Next: ${cleanMode}`);
23885
24369
  return parts.join(" ");
23886
24370
  }
23887
- function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoot, environment = "unknown", subAgents = [], projectName, teamSummary) {
24371
+ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoot, environment = "unknown", subAgents = [], projectName, teamSummary, clientName) {
23888
24372
  const lines = [];
23889
24373
  const cycleIsComplete = health.latestCycleStatus === "complete";
23890
24374
  const tagSuffix = latestTag ? ` \u2014 ${latestTag}` : "";
@@ -23906,6 +24390,12 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
23906
24390
  lines.push(`**Connection:** ${statusIcon} ${statusLabel}`);
23907
24391
  lines.push("");
23908
24392
  }
24393
+ const harness = detectHarness(clientName);
24394
+ const roleNote = connectOnlyRoleNote(harness);
24395
+ if (roleNote) {
24396
+ lines.push(`> ${roleNote}`);
24397
+ lines.push("");
24398
+ }
23909
24399
  if (buildInfo.warnings.length > 0) {
23910
24400
  for (const w of buildInfo.warnings) {
23911
24401
  lines.push(`> ${w}`);
@@ -23917,11 +24407,15 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
23917
24407
  lines.push("");
23918
24408
  }
23919
24409
  const isGitDependentRec = /\*\*(Build|Review)\*\*/.test(health.recommendedMode);
23920
- if (GIT_DEPENDENT_ENVS.has(environment) && isGitDependentRec) {
23921
- lines.push(`> **Next action:** ${health.recommendedMode}`);
23922
- lines.push(`> _Note: \`build_execute\`, \`review_submit\`, and \`release\` require a local Claude Code session with git access. From \`${environment}\` you can view state, log ideas, and edit the board \u2014 but the build/review/release loop must run through Claude Code._`);
23923
- } else {
23924
- lines.push(`> **Next action:** ${health.recommendedMode}`);
24410
+ const loopRunsHere = buildLoopRunsHere({
24411
+ harness,
24412
+ localWorkspace: hasLocalWorkspace(),
24413
+ forceLocal: environment === "local-cli",
24414
+ envHintsNoGit: GIT_DEPENDENT_ENVS.has(environment)
24415
+ });
24416
+ lines.push(`> **Next action:** ${health.recommendedMode}`);
24417
+ if (isGitDependentRec && !loopRunsHere) {
24418
+ lines.push(`> ${nextActionQualifier(harness)}`);
23925
24419
  }
23926
24420
  lines.push("");
23927
24421
  lines.push(`**Strategy Review:** ${health.reviewWarning}`);
@@ -24087,8 +24581,8 @@ async function getLatestGitTag(projectRoot) {
24087
24581
  }
24088
24582
  async function checkNpmVersionDrift() {
24089
24583
  try {
24090
- const pkgPath = join14(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
24091
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf-8"));
24584
+ const pkgPath = join15(new URL(".", import.meta.url).pathname, "..", "..", "package.json");
24585
+ const pkg = JSON.parse(readFileSync9(pkgPath, "utf-8"));
24092
24586
  const localVersion = pkg.version;
24093
24587
  const packageName = pkg.name;
24094
24588
  const { stdout } = await execFileAsync2("npm", ["view", packageName, "version"], {
@@ -24205,7 +24699,23 @@ async function computeReleaseHistory(adapter2) {
24205
24699
  if (released.length === 0) return void 0;
24206
24700
  return `**Recent releases:** ${released.join(" \xB7 ")}`;
24207
24701
  }
24208
- async function handleOrient(adapter2, config2, args = {}) {
24702
+ function formatResolvedFeedback(items) {
24703
+ if (items.length === 0) return "";
24704
+ const MAX = 5;
24705
+ const shown = items.slice(0, MAX);
24706
+ const lines = ["\n\n## \u2705 Your feedback shipped"];
24707
+ for (const it of shown) {
24708
+ const kind = it.type === "idea" ? "Idea" : "Bug";
24709
+ const verb = it.status === "wont_fix" ? "closed" : "resolved";
24710
+ const desc = it.description.length > 90 ? `${it.description.slice(0, 87)}\u2026` : it.description;
24711
+ const note = it.resolutionNote?.trim() ? ` \u2014 ${it.resolutionNote.trim()}` : "";
24712
+ lines.push(`- **${kind}** "${desc}" was ${verb}${note}`);
24713
+ }
24714
+ if (items.length > MAX) lines.push(`- \u2026and ${items.length - MAX} more`);
24715
+ lines.push("_Thanks for the signal \u2014 it shaped what shipped._");
24716
+ return lines.join("\n");
24717
+ }
24718
+ async function handleOrient(adapter2, config2, args = {}, clientName) {
24209
24719
  const environment = normaliseEnvironment(args.environment);
24210
24720
  const deepHousekeeping = args.deep_housekeeping === true;
24211
24721
  const fullEnrichment = args.full === true || deepHousekeeping;
@@ -24249,6 +24759,7 @@ async function handleOrient(adapter2, config2, args = {}) {
24249
24759
  backlogHandoffCount: allTasks.filter((t) => t.cycle !== currentCycle).length
24250
24760
  };
24251
24761
  const sharedActiveDecisionsPromise = adapter2.getActiveDecisions();
24762
+ const resolvedFeedbackPromise = adapter2.getUnnotifiedResolvedFeedback ? adapter2.getUnnotifiedResolvedFeedback(config2.userId).catch(() => []) : Promise.resolve([]);
24252
24763
  const [
24253
24764
  proxyVersionOutcome,
24254
24765
  p1BacklogOutcome,
@@ -24528,7 +25039,7 @@ async function handleOrient(adapter2, config2, args = {}) {
24528
25039
  // task-1652: deep housekeeping — opt-in sweep (board, unrecorded commits, unregistered docs)
24529
25040
  // task-1865: also detects stale skill forks vs the @papi-ai/skills registry.
24530
25041
  tracked("deep-housekeeping", async () => {
24531
- if (!deepHousekeeping) return { reconciliationNote: "", unrecordedNote: "", unregisteredDocsNote: "", staleSkillsNote: "" };
25042
+ if (!deepHousekeeping) return { reconciliationNote: "", mergedInProgressNote: "", unrecordedNote: "", unregisteredDocsNote: "", staleSkillsNote: "" };
24532
25043
  let reconciliationNote2 = "";
24533
25044
  try {
24534
25045
  const mismatches = detectBoardMismatches(config2.projectRoot, allTasks);
@@ -24540,6 +25051,22 @@ async function handleOrient(adapter2, config2, args = {}) {
24540
25051
  }
24541
25052
  } catch {
24542
25053
  }
25054
+ let mergedInProgressNote2 = "";
25055
+ try {
25056
+ if (hasLocalWorkspace()) {
25057
+ const merged = detectMergedInProgress(config2.projectRoot, config2.baseBranch, allTasks);
25058
+ if (merged.length > 0) {
25059
+ const lines = ["\n\n## Merged but In Progress"];
25060
+ lines.push(`${merged.length} In-Progress task(s) already merged to the default branch \u2014 close them out with \`build_execute\` complete (or \`board_edit\` to mark Done):`);
25061
+ for (const m of merged) {
25062
+ const ref = m.pr ? `${m.pr} (\`${m.commit}\`)` : `\`${m.commit}\``;
25063
+ lines.push(`- \u26A0\uFE0F **${m.displayId}** \u2014 merged via ${ref}: ${m.subject}`);
25064
+ }
25065
+ mergedInProgressNote2 = lines.join("\n");
25066
+ }
25067
+ }
25068
+ } catch {
25069
+ }
24543
25070
  let unrecordedNote2 = "";
24544
25071
  try {
24545
25072
  const unrecorded = detectUnrecordedCommits(config2.projectRoot, config2.baseBranch);
@@ -24564,7 +25091,7 @@ async function handleOrient(adapter2, config2, args = {}) {
24564
25091
  let unregisteredDocsNote2 = "";
24565
25092
  try {
24566
25093
  if (adapter2.searchDocs && hasLocalWorkspace()) {
24567
- const docsDir = join14(config2.projectRoot, "docs");
25094
+ const docsDir = join15(config2.projectRoot, "docs");
24568
25095
  const docsFiles = scanMdFiles(docsDir, config2.projectRoot);
24569
25096
  if (docsFiles.length > 0) {
24570
25097
  const registered = await adapter2.searchDocs({ limit: 500, status: "all" });
@@ -24591,7 +25118,7 @@ async function handleOrient(adapter2, config2, args = {}) {
24591
25118
  }
24592
25119
  } catch {
24593
25120
  }
24594
- return { reconciliationNote: reconciliationNote2, unrecordedNote: unrecordedNote2, unregisteredDocsNote: unregisteredDocsNote2, staleSkillsNote: staleSkillsNote2 };
25121
+ return { reconciliationNote: reconciliationNote2, mergedInProgressNote: mergedInProgressNote2, unrecordedNote: unrecordedNote2, unregisteredDocsNote: unregisteredDocsNote2, staleSkillsNote: staleSkillsNote2 };
24595
25122
  })
24596
25123
  ]);
24597
25124
  const proxyWarning = proxyVersionOutcome.status === "fulfilled" ? proxyVersionOutcome.value : void 0;
@@ -24627,7 +25154,13 @@ ${versionDrift}` : "";
24627
25154
  const projectName = projectBannerResult.name;
24628
25155
  const sessionGuidanceNote = sessionGuidanceOutcome.status === "fulfilled" ? sessionGuidanceOutcome.value : "";
24629
25156
  const deliveryShapeNote = deliveryShapeOutcome.status === "fulfilled" ? deliveryShapeOutcome.value : "";
24630
- const { reconciliationNote, unrecordedNote, unregisteredDocsNote, staleSkillsNote } = deepHousekeepingOutcome.status === "fulfilled" ? deepHousekeepingOutcome.value : { reconciliationNote: "", unrecordedNote: "", unregisteredDocsNote: "", staleSkillsNote: "" };
25157
+ const resolvedFeedback = await resolvedFeedbackPromise;
25158
+ const feedbackResolvedNote = formatResolvedFeedback(resolvedFeedback);
25159
+ if (resolvedFeedback.length > 0 && adapter2.markFeedbackNotified) {
25160
+ void adapter2.markFeedbackNotified(resolvedFeedback.map((f) => f.id), config2.userId).catch(() => {
25161
+ });
25162
+ }
25163
+ const { reconciliationNote, mergedInProgressNote, unrecordedNote, unregisteredDocsNote, staleSkillsNote } = deepHousekeepingOutcome.status === "fulfilled" ? deepHousekeepingOutcome.value : { reconciliationNote: "", mergedInProgressNote: "", unrecordedNote: "", unregisteredDocsNote: "", staleSkillsNote: "" };
24631
25164
  let enrichmentNote = "";
24632
25165
  const enrichmentCollector = new FileWriteCollector();
24633
25166
  try {
@@ -24674,8 +25207,8 @@ ${section}`;
24674
25207
  tracked("release-history", () => computeReleaseHistory(adapter2))().catch(() => void 0)
24675
25208
  ]);
24676
25209
  const teamSummary = [teamSummaryLine, releaseHistoryLine].filter(Boolean).join("\n") || void 0;
24677
- const deepHint = deepHousekeeping ? "" : "\n\n*Tip: pass `full: true` for Research Signals + version-drift, or `deep_housekeeping: true` to also check orphaned branches, unrecorded commits, unregistered docs, and stale skill forks (implies `full`).*";
24678
- return textResponse(projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary) + unblockNote + alertsNote + ttfvNote + reconciliationNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection);
25210
+ const deepHint = deepHousekeeping ? "" : "\n\n*Tip: pass `full: true` for Research Signals + version-drift, or `deep_housekeeping: true` to also check orphaned branches, merged-but-In-Progress tasks, unrecorded commits, unregistered docs, and stale skill forks (implies `full`).*";
25211
+ return textResponse(projectBannerNote + formatOrientSummary(healthResult, buildInfo, hierarchy, latestTag, config2.projectRoot, environment, subAgents, projectName, teamSummary, clientName) + unblockNote + feedbackResolvedNote + alertsNote + ttfvNote + reconciliationNote + mergedInProgressNote + unrecordedNote + unregisteredDocsNote + staleSkillsNote + researchSignalsNote + recsNote + pendingReviewNote + patternsNote + unactionedIssuesNote + skillProposalsNote + sessionGuidanceNote + versionNote + enrichmentNote + deliveryShapeNote + preBuildCheckNote + deepHint + enrichmentFilesSection);
24679
25212
  } catch (err) {
24680
25213
  const message = err instanceof Error ? err.message : String(err);
24681
25214
  const isKnownFriendly = /^(Orient failed|Project not found|No project|Setup required)/i.test(message);
@@ -24706,9 +25239,9 @@ function enrichClaudeMd(projectRoot, cycleNumber, adapterType, collector) {
24706
25239
 
24707
25240
  \u{1F4DD} **CLAUDE.md enriched** \u2014 added ${tierNames2.join(" + ")} guidance for cycle ${cycleNumber}+ projects.`;
24708
25241
  }
24709
- const claudeMdPath = join14(projectRoot, "CLAUDE.md");
24710
- if (!existsSync8(claudeMdPath)) return "";
24711
- const content = readFileSync8(claudeMdPath, "utf-8");
25242
+ const claudeMdPath = join15(projectRoot, "CLAUDE.md");
25243
+ if (!existsSync9(claudeMdPath)) return "";
25244
+ const content = readFileSync9(claudeMdPath, "utf-8");
24712
25245
  const additions = [];
24713
25246
  if (cycleNumber >= 6 && !content.includes(CLAUDE_MD_ENRICHMENT_SENTINEL_T1)) {
24714
25247
  additions.push(dedupeEnrichmentBlob(content, CLAUDE_MD_TIER_1));
@@ -25414,7 +25947,7 @@ ${result.userMessage}
25414
25947
 
25415
25948
  // src/services/scope-brief.ts
25416
25949
  import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync3 } from "fs";
25417
- import { join as join15, dirname as dirname3 } from "path";
25950
+ import { join as join16, dirname as dirname4 } from "path";
25418
25951
  import Anthropic from "@anthropic-ai/sdk";
25419
25952
  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.
25420
25953
 
@@ -25469,13 +26002,13 @@ async function runScopeBrief(adapter2, input) {
25469
26002
  }
25470
26003
  const slug = input.taskId.replace(/[^a-z0-9-]/g, "-").toLowerCase();
25471
26004
  const relPath = `docs/scopes/${slug}.md`;
25472
- const absPath = join15(input.projectRoot, relPath);
26005
+ const absPath = join16(input.projectRoot, relPath);
25473
26006
  const docBody = addFrontmatter(docContent, task, input.cycleNumber);
25474
26007
  const collector = new FileWriteCollector();
25475
26008
  if (input.adapterType === "proxy") {
25476
26009
  collector.add({ path: relPath, content: docBody, mode: "overwrite" });
25477
26010
  } else {
25478
- mkdirSync3(dirname3(absPath), { recursive: true });
26011
+ mkdirSync3(dirname4(absPath), { recursive: true });
25479
26012
  writeFileSync4(absPath, docBody, "utf-8");
25480
26013
  }
25481
26014
  const taskCount = countSubTasks(docContent);
@@ -26213,21 +26746,90 @@ async function handleTaskUnclaim(adapter2, config2, args) {
26213
26746
  return textResponse(`\u2705 Released **${result.id}** (${result.title}) back to the shared Pool.`);
26214
26747
  }
26215
26748
 
26749
+ // src/tools/task-move.ts
26750
+ var taskMoveTool = {
26751
+ name: "task_move",
26752
+ description: "Move a task from the current project to another project you own. Reassigns the task a fresh id in the target (collision-free) and carries its build reports, comments, and history with it; the cycle assignment is cleared so it lands in the target project's backlog. You must own (or have write access to) BOTH projects. Destructive-ish and cross-project, so it requires confirm=true \u2014 without it you get a preview only. Does not call the Anthropic API.",
26753
+ annotations: { readOnlyHint: false, destructiveHint: true },
26754
+ inputSchema: {
26755
+ type: "object",
26756
+ properties: {
26757
+ task_id: { type: "string", description: 'The task to move, e.g. "task-2292".' },
26758
+ target_project: {
26759
+ type: "string",
26760
+ description: 'The destination project \u2014 its slug (e.g. "papi-ui") or UUID. Must be a project you own.'
26761
+ },
26762
+ confirm: {
26763
+ type: "boolean",
26764
+ description: "Set true to perform the move. Omit (or false) to get a preview of what would happen."
26765
+ }
26766
+ },
26767
+ required: ["task_id", "target_project"]
26768
+ }
26769
+ };
26770
+ function requireStr(value) {
26771
+ const s = typeof value === "string" ? value.trim() : "";
26772
+ return s.length > 0 ? s : null;
26773
+ }
26774
+ async function handleTaskMove(adapter2, config2, args) {
26775
+ const taskId = requireStr(args.task_id);
26776
+ if (!taskId) return errorResponse('A task_id is required. Example: task_move task_id="task-42" target_project="other-project" confirm=true');
26777
+ const targetProject = requireStr(args.target_project);
26778
+ if (!targetProject) return errorResponse("A target_project (slug or UUID) is required \u2014 the project you want to move the task into.");
26779
+ if (typeof adapter2.moveTask !== "function") {
26780
+ return errorResponse("Cross-project move is not available on this adapter.");
26781
+ }
26782
+ const gate = await resolveOwnerGate(adapter2, config2);
26783
+ const callerUserId = gate.callerUserId;
26784
+ if (!callerUserId) {
26785
+ const note = gate.resolutionError ? ` (${gate.resolutionError})` : "";
26786
+ return errorResponse(
26787
+ "Moving a task 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."
26788
+ );
26789
+ }
26790
+ const task = await adapter2.getTask(taskId);
26791
+ if (!task) return errorResponse(`Task "${taskId}" was not found on this project's board. task_move moves a task FROM the project you're connected to \u2014 switch to the source project first if needed.`);
26792
+ if (args.confirm !== true) {
26793
+ return textResponse(
26794
+ `\u26A0\uFE0F **Preview \u2014 no changes made.**
26795
+
26796
+ This will move **${taskId}** (${task.title}) into project \`${targetProject}\`:
26797
+ - it gets a NEW task id in the target (the current "${taskId}" is freed in this project)
26798
+ - its build reports, comments, and history move with it
26799
+ - its cycle assignment is cleared (it lands in the target's backlog)
26800
+ - you must own both this project and \`${targetProject}\`
26801
+
26802
+ This is a cross-project write and cannot be auto-undone. To proceed:
26803
+ \`task_move task_id="${taskId}" target_project="${targetProject}" confirm=true\``
26804
+ );
26805
+ }
26806
+ try {
26807
+ const res = await adapter2.moveTask(taskId, targetProject, callerUserId);
26808
+ return textResponse(
26809
+ `\u2705 Moved **${taskId}** from ${res.sourceName} to ${res.targetName} as **${res.newDisplayId}**.
26810
+
26811
+ Its build reports, comments, and history moved with it; the cycle assignment was cleared, so it sits in ${res.targetName}'s backlog. Switch to ${res.targetName} to plan or build it.`
26812
+ );
26813
+ } catch (err) {
26814
+ return errorResponse(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
26815
+ }
26816
+ }
26817
+
26216
26818
  // src/services/harness-inventory.ts
26217
26819
  import { readdir as readdir3, readFile as readFile8, stat as stat3 } from "fs/promises";
26218
- import { join as join16 } from "path";
26820
+ import { join as join17 } from "path";
26219
26821
  import { createHash as createHash3 } from "crypto";
26220
26822
  var RECOMMENDED_HOOKS = ["stop-release-check.sh", "claude-md-size-guard.sh"];
26221
26823
  async function computeFingerprint(root) {
26222
26824
  const parts = [];
26223
26825
  for (const sub of [".claude/skills", ".claude/agents", ".claude/hooks"]) {
26224
- const dir = join16(root, sub);
26826
+ const dir = join17(root, sub);
26225
26827
  try {
26226
26828
  const names = (await readdir3(dir)).sort((a, b2) => a.localeCompare(b2));
26227
26829
  for (const name of names) {
26228
26830
  let mtime = "";
26229
26831
  try {
26230
- mtime = String(Math.floor((await stat3(join16(dir, name))).mtimeMs));
26832
+ mtime = String(Math.floor((await stat3(join17(dir, name))).mtimeMs));
26231
26833
  } catch {
26232
26834
  }
26233
26835
  parts.push(`${sub}/${name}:${mtime}`);
@@ -26246,7 +26848,7 @@ async function computeFingerprint(root) {
26246
26848
  }
26247
26849
  async function readSkillDescription(skillDir) {
26248
26850
  try {
26249
- const content = await readFile8(join16(skillDir, "SKILL.md"), "utf-8");
26851
+ const content = await readFile8(join17(skillDir, "SKILL.md"), "utf-8");
26250
26852
  const fm = content.match(/^---\n([\s\S]*?)\n---/);
26251
26853
  if (!fm) return void 0;
26252
26854
  const desc = fm[1].match(/^description:\s*[>|]?\s*\n?([\s\S]*?)(?=\n\w+:|\n---|$)/m);
@@ -26266,17 +26868,17 @@ async function scanInventory(root, toolDefs) {
26266
26868
  version = loadManifest().packageVersion;
26267
26869
  } catch {
26268
26870
  }
26269
- const skillsDir = join16(root, ".claude", "skills");
26871
+ const skillsDir = join17(root, ".claude", "skills");
26270
26872
  try {
26271
26873
  const dirents = await readdir3(skillsDir, { withFileTypes: true });
26272
26874
  for (const d of dirents.filter((e) => e.isDirectory())) {
26273
26875
  entries.push({
26274
26876
  kind: "skill",
26275
26877
  name: d.name,
26276
- description: await readSkillDescription(join16(skillsDir, d.name)),
26878
+ description: await readSkillDescription(join17(skillsDir, d.name)),
26277
26879
  version,
26278
26880
  status: stale.has(d.name) ? "stale_fork" : "ok",
26279
- path: join16(".claude", "skills", d.name)
26881
+ path: join17(".claude", "skills", d.name)
26280
26882
  });
26281
26883
  }
26282
26884
  } catch {
@@ -26292,9 +26894,9 @@ async function scanInventory(root, toolDefs) {
26292
26894
  }
26293
26895
  const present = /* @__PURE__ */ new Set();
26294
26896
  try {
26295
- for (const f of (await readdir3(join16(root, ".claude", "hooks"))).filter((n) => n.endsWith(".sh"))) {
26897
+ for (const f of (await readdir3(join17(root, ".claude", "hooks"))).filter((n) => n.endsWith(".sh"))) {
26296
26898
  present.add(f);
26297
- entries.push({ kind: "hook", name: f, status: "ok", path: join16(".claude", "hooks", f) });
26899
+ entries.push({ kind: "hook", name: f, status: "ok", path: join17(".claude", "hooks", f) });
26298
26900
  }
26299
26901
  } catch {
26300
26902
  }
@@ -26373,6 +26975,130 @@ async function handleInventorySync(adapter2, config2, args, toolDefs) {
26373
26975
  return textResponse(lines.join("\n"));
26374
26976
  }
26375
26977
 
26978
+ // src/resources.ts
26979
+ var SCHEME = "mcp://papi";
26980
+ var RESOURCE_TEMPLATES = [
26981
+ {
26982
+ uriTemplate: `${SCHEME}/decisions/{adId}`,
26983
+ name: "Active Decision",
26984
+ description: "A single Active Decision (e.g. AD-12) \u2014 title, confidence, body, and supersession status.",
26985
+ mimeType: "text/markdown"
26986
+ },
26987
+ {
26988
+ uriTemplate: `${SCHEME}/build-reports/cycle-{cycle}`,
26989
+ name: "Cycle build reports",
26990
+ description: "Every build report recorded for a given cycle number (e.g. cycle-310).",
26991
+ mimeType: "text/markdown"
26992
+ },
26993
+ {
26994
+ uriTemplate: `${SCHEME}/cycles/{cycle}`,
26995
+ name: "Cycle summary",
26996
+ description: "Summary, task count, effort, and carry-forward for a given cycle number.",
26997
+ mimeType: "text/markdown"
26998
+ },
26999
+ {
27000
+ uriTemplate: `${SCHEME}/handoffs/{taskId}`,
27001
+ name: "Build handoff",
27002
+ description: "The read-only BUILD HANDOFF for a task (e.g. task-1801).",
27003
+ mimeType: "text/markdown"
27004
+ }
27005
+ ];
27006
+ async function listPapiResources(adapter2) {
27007
+ const out = [];
27008
+ try {
27009
+ const ads = await adapter2.getActiveDecisions();
27010
+ for (const d of ads.filter((d2) => !d2.superseded).slice(0, 50)) {
27011
+ out.push({
27012
+ uri: `${SCHEME}/decisions/${d.id}`,
27013
+ name: `${d.id}: ${d.title}`,
27014
+ description: `Active Decision [${d.confidence}]`,
27015
+ mimeType: "text/markdown"
27016
+ });
27017
+ }
27018
+ } catch {
27019
+ }
27020
+ try {
27021
+ const log2 = await adapter2.getCycleLog(10);
27022
+ for (const e of log2) {
27023
+ out.push({
27024
+ uri: `${SCHEME}/cycles/${e.cycleNumber}`,
27025
+ name: `Cycle ${e.cycleNumber} summary`,
27026
+ description: e.title,
27027
+ mimeType: "text/markdown"
27028
+ });
27029
+ out.push({
27030
+ uri: `${SCHEME}/build-reports/cycle-${e.cycleNumber}`,
27031
+ name: `Cycle ${e.cycleNumber} build reports`,
27032
+ mimeType: "text/markdown"
27033
+ });
27034
+ }
27035
+ } catch {
27036
+ }
27037
+ return out;
27038
+ }
27039
+ function parseCycleNumber(raw) {
27040
+ const m = raw.match(/(\d+)\s*$/);
27041
+ return m ? parseInt(m[1], 10) : NaN;
27042
+ }
27043
+ async function readPapiResource(adapter2, uri) {
27044
+ if (!uri.startsWith(`${SCHEME}/`)) {
27045
+ throw new Error(`Unknown resource URI: ${uri}. Expected ${SCHEME}/...`);
27046
+ }
27047
+ const path7 = uri.slice(`${SCHEME}/`.length);
27048
+ const slash = path7.indexOf("/");
27049
+ const kind = slash === -1 ? path7 : path7.slice(0, slash);
27050
+ const rest = slash === -1 ? "" : path7.slice(slash + 1);
27051
+ const md = (text) => ({ uri, mimeType: "text/markdown", text });
27052
+ switch (kind) {
27053
+ case "decisions": {
27054
+ const adId = rest.trim();
27055
+ if (!adId) throw new Error(`Resource URI missing an AD id: ${uri}`);
27056
+ const ads = await adapter2.getActiveDecisions({ includeRetired: true });
27057
+ const target = ads.find((d) => d.id === adId);
27058
+ if (!target) throw new Error(`Active Decision not found in this project: ${adId}`);
27059
+ const supersededNote = target.superseded ? ` [SUPERSEDED by ${target.supersededBy ?? "unknown"}]` : "";
27060
+ const supersedes = ads.filter((d) => d.supersededBy === target.id).map((d) => d.id);
27061
+ const chain = supersedes.length > 0 ? `
27062
+
27063
+ _Supersedes: ${supersedes.join(", ")}_` : "";
27064
+ return md(`## ${target.id}: ${target.title} [${target.confidence}]${supersededNote}
27065
+
27066
+ ${target.body}${chain}`);
27067
+ }
27068
+ case "build-reports": {
27069
+ const cycle = parseCycleNumber(rest);
27070
+ if (Number.isNaN(cycle)) throw new Error(`Resource URI missing a cycle number: ${uri}`);
27071
+ const reports = (await adapter2.getBuildReportsSince(cycle)).filter((r) => r.cycle === cycle);
27072
+ return md(`# Build Reports \u2014 Cycle ${cycle} (${reports.length})
27073
+
27074
+ ${formatBuildReports(reports)}`);
27075
+ }
27076
+ case "cycles": {
27077
+ const cycle = parseCycleNumber(rest);
27078
+ if (Number.isNaN(cycle)) throw new Error(`Resource URI missing a cycle number: ${uri}`);
27079
+ const entry = (await adapter2.getCycleLogSince(cycle)).find((e) => e.cycleNumber === cycle);
27080
+ if (!entry) throw new Error(`No cycle log entry found in this project for cycle ${cycle}`);
27081
+ return md(formatCycleLog([entry]));
27082
+ }
27083
+ case "handoffs": {
27084
+ const taskId = rest.trim();
27085
+ if (!taskId) throw new Error(`Resource URI missing a task id: ${uri}`);
27086
+ const task = await adapter2.getTask(taskId);
27087
+ if (!task) throw new Error(`Task not found in this project: ${taskId}`);
27088
+ if (!task.buildHandoff) {
27089
+ return md(`# ${taskId}: ${task.title}
27090
+
27091
+ _No BUILD HANDOFF recorded for this task._`);
27092
+ }
27093
+ return md(`# BUILD HANDOFF \u2014 ${taskId}: ${task.title}
27094
+
27095
+ ${serializeBuildHandoff(task.buildHandoff)}`);
27096
+ }
27097
+ default:
27098
+ throw new Error(`Unknown resource family "${kind}" in ${uri}. Known: decisions, build-reports, cycles, handoffs.`);
27099
+ }
27100
+ }
27101
+
26376
27102
  // src/services/metering.ts
26377
27103
  var TIER_ALLOWANCES = {
26378
27104
  free: 1e3,
@@ -26593,32 +27319,36 @@ var PAPI_TOOLS = [
26593
27319
  contributorListTool,
26594
27320
  taskClaimTool,
26595
27321
  taskUnclaimTool,
27322
+ taskMoveTool,
26596
27323
  inventorySyncTool
26597
27324
  ];
26598
27325
  function getToolMetadata() {
26599
27326
  return PAPI_TOOLS.map((t) => ({ name: t.name, description: t.description }));
26600
27327
  }
26601
27328
  function createServer(adapter2, config2) {
26602
- const __pkgFilename = fileURLToPath2(import.meta.url);
26603
- const __pkgDir = dirname4(__pkgFilename);
27329
+ const __pkgFilename = fileURLToPath3(import.meta.url);
27330
+ const __pkgDir = dirname5(__pkgFilename);
26604
27331
  let serverVersion = "unknown";
26605
27332
  try {
26606
- const pkg = JSON.parse(readFileSync9(join17(__pkgDir, "..", "package.json"), "utf-8"));
27333
+ const pkg = JSON.parse(readFileSync10(join18(__pkgDir, "..", "package.json"), "utf-8"));
26607
27334
  serverVersion = pkg.version ?? "unknown";
26608
27335
  } catch {
26609
27336
  }
26610
27337
  const server2 = new Server(
26611
27338
  { name: "papi", version: serverVersion },
26612
- { capabilities: { tools: {}, prompts: {} } }
27339
+ // task-2305: `instructions` is surfaced in the MCP initialize response, so every
27340
+ // connecting host LLM gets PAPI's cycle frame on connect — zero config, any harness.
27341
+ // task-1801: `resources` capability for the PAPI read surface exposed as MCP resources.
27342
+ { capabilities: { tools: {}, prompts: {}, resources: {} }, instructions: UNIVERSAL_FRAME }
26613
27343
  );
26614
27344
  if (config2.adapterType === "md") {
26615
27345
  process.stderr.write(
26616
27346
  "\n\u26A0 PAPI is running in md mode \u2014 your cycles are not visible on the hosted dashboard.\n Configure DATABASE_URL or sign up at https://getpapi.ai/setup to enable observability.\n\n"
26617
27347
  );
26618
27348
  }
26619
- const __filename = fileURLToPath2(import.meta.url);
26620
- const __dirname2 = dirname4(__filename);
26621
- const skillsDir = join17(__dirname2, "..", "skills");
27349
+ const __filename = fileURLToPath3(import.meta.url);
27350
+ const __dirname2 = dirname5(__filename);
27351
+ const skillsDir = join18(__dirname2, "..", "skills");
26622
27352
  function parseSkillFrontmatter(content) {
26623
27353
  const match = content.match(/^---\n([\s\S]*?)\n---/);
26624
27354
  if (!match) return null;
@@ -26636,7 +27366,7 @@ function createServer(adapter2, config2) {
26636
27366
  const mdFiles = files.filter((f) => f.endsWith(".md"));
26637
27367
  const prompts = [];
26638
27368
  for (const file of mdFiles) {
26639
- const content = await readFile9(join17(skillsDir, file), "utf-8");
27369
+ const content = await readFile9(join18(skillsDir, file), "utf-8");
26640
27370
  const meta = parseSkillFrontmatter(content);
26641
27371
  if (meta) {
26642
27372
  prompts.push({ name: meta.name, description: meta.description });
@@ -26652,7 +27382,7 @@ function createServer(adapter2, config2) {
26652
27382
  try {
26653
27383
  const files = await readdir4(skillsDir);
26654
27384
  for (const file of files.filter((f) => f.endsWith(".md"))) {
26655
- const content = await readFile9(join17(skillsDir, file), "utf-8");
27385
+ const content = await readFile9(join18(skillsDir, file), "utf-8");
26656
27386
  const meta = parseSkillFrontmatter(content);
26657
27387
  if (meta?.name === name) {
26658
27388
  const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
@@ -26666,6 +27396,15 @@ function createServer(adapter2, config2) {
26666
27396
  }
26667
27397
  throw new Error(`Prompt not found: ${name}`);
26668
27398
  });
27399
+ server2.setRequestHandler(ListResourcesRequestSchema, async () => ({
27400
+ resources: await listPapiResources(adapter2)
27401
+ }));
27402
+ server2.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
27403
+ resourceTemplates: RESOURCE_TEMPLATES
27404
+ }));
27405
+ server2.setRequestHandler(ReadResourceRequestSchema, async (request) => ({
27406
+ contents: [await readPapiResource(adapter2, request.params.uri)]
27407
+ }));
26669
27408
  server2.setRequestHandler(ListToolsRequestSchema, async () => ({
26670
27409
  tools: [...PAPI_TOOLS]
26671
27410
  }));
@@ -26717,13 +27456,13 @@ function createServer(adapter2, config2) {
26717
27456
  case "board_edit":
26718
27457
  return handleBoardEdit(adapter2, safeArgs);
26719
27458
  case "setup":
26720
- return handleSetup(adapter2, config2, safeArgs);
27459
+ return handleSetup(adapter2, config2, safeArgs, server2.getClientVersion()?.name);
26721
27460
  case "build_list":
26722
27461
  return handleBuildList(adapter2, config2);
26723
27462
  case "build_describe":
26724
27463
  return handleBuildDescribe(adapter2, safeArgs);
26725
27464
  case "build_execute":
26726
- return handleBuildExecute(adapter2, config2, safeArgs);
27465
+ return handleBuildExecute(adapter2, config2, safeArgs, server2.getClientVersion()?.name);
26727
27466
  case "build_cancel":
26728
27467
  return handleBuildCancel(adapter2, safeArgs);
26729
27468
  case "idea":
@@ -26745,9 +27484,9 @@ function createServer(adapter2, config2) {
26745
27484
  case "init":
26746
27485
  return handleInit(config2, safeArgs);
26747
27486
  case "orient":
26748
- return handleOrient(adapter2, config2, safeArgs);
27487
+ return handleOrient(adapter2, config2, safeArgs, server2.getClientVersion()?.name);
26749
27488
  case "papi":
26750
- return handleOrient(adapter2, config2, safeArgs);
27489
+ return handleOrient(adapter2, config2, safeArgs, server2.getClientVersion()?.name);
26751
27490
  // alias (task-2223)
26752
27491
  case "hierarchy_update":
26753
27492
  return handleHierarchyUpdate(adapter2, safeArgs);
@@ -26789,6 +27528,8 @@ function createServer(adapter2, config2) {
26789
27528
  return handleContributorList(adapter2, config2, safeArgs);
26790
27529
  case "task_claim":
26791
27530
  return handleTaskClaim(adapter2, config2, safeArgs);
27531
+ case "task_move":
27532
+ return handleTaskMove(adapter2, config2, safeArgs);
26792
27533
  case "task_unclaim":
26793
27534
  return handleTaskUnclaim(adapter2, config2, safeArgs);
26794
27535
  case "inventory_sync":
@@ -26857,17 +27598,28 @@ ${usageLine(decision.usage)}`;
26857
27598
  }
26858
27599
  const telemetryProjectId = resolveTelemetryProjectId(config2);
26859
27600
  if (telemetryProjectId) {
26860
- emitToolCall(telemetryProjectId, name, elapsed, {
26861
- adapter_type: config2.adapterType
26862
- });
27601
+ const adapterEmit = adapter2.emitTelemetry?.bind(adapter2) ?? null;
27602
+ if (adapterEmit) {
27603
+ adapterEmit({
27604
+ projectId: telemetryProjectId,
27605
+ toolName: name,
27606
+ eventType: "tool_call",
27607
+ metadata: { duration_ms: elapsed, adapter_type: config2.adapterType }
27608
+ });
27609
+ } else {
27610
+ emitToolCall(telemetryProjectId, name, elapsed, {
27611
+ adapter_type: config2.adapterType
27612
+ });
27613
+ }
26863
27614
  const isApplyMode = safeArgs.mode === "apply";
26864
27615
  if (!isError) {
26865
- if (name === "setup" && isApplyMode) {
26866
- emitMilestone(telemetryProjectId, "setup_completed");
26867
- } else if (name === "plan" && isApplyMode) {
26868
- emitMilestone(telemetryProjectId, "plan_completed");
26869
- } else if (name === "release") {
26870
- emitMilestone(telemetryProjectId, "release_completed");
27616
+ const milestone = name === "setup" && isApplyMode ? "setup_completed" : name === "plan" && isApplyMode ? "plan_completed" : name === "release" ? "release_completed" : null;
27617
+ if (milestone) {
27618
+ if (adapterEmit) {
27619
+ adapterEmit({ projectId: telemetryProjectId, toolName: milestone, eventType: "milestone" });
27620
+ } else {
27621
+ emitMilestone(telemetryProjectId, milestone);
27622
+ }
26871
27623
  }
26872
27624
  }
26873
27625
  }
@@ -27297,10 +28049,10 @@ async function dispatchRequest(args) {
27297
28049
  }
27298
28050
 
27299
28051
  // src/index.ts
27300
- var __dirname = dirname5(fileURLToPath3(import.meta.url));
28052
+ var __dirname = dirname6(fileURLToPath4(import.meta.url));
27301
28053
  var pkgVersion = "unknown";
27302
28054
  try {
27303
- const pkg = JSON.parse(readFileSync14(join22(__dirname, "..", "package.json"), "utf-8"));
28055
+ const pkg = JSON.parse(readFileSync15(join23(__dirname, "..", "package.json"), "utf-8"));
27304
28056
  pkgVersion = pkg.version;
27305
28057
  } catch {
27306
28058
  }