@papi-ai/server 0.7.80 → 0.7.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ __export(git_exports, {
29
29
  ensureTagAtHead: () => ensureTagAtHead,
30
30
  findTaskCommitsOnBase: () => findTaskCommitsOnBase,
31
31
  getBranchDiff: () => getBranchDiff,
32
+ getCommitFiles: () => getCommitFiles,
32
33
  getCommitsSinceTag: () => getCommitsSinceTag,
33
34
  getCurrentBranch: () => getCurrentBranch,
34
35
  getDocPathsTouchedOnBranch: () => getDocPathsTouchedOnBranch,
@@ -42,11 +43,13 @@ __export(git_exports, {
42
43
  getModifiedFiles: () => getModifiedFiles,
43
44
  getOriginRepoSlug: () => getOriginRepoSlug,
44
45
  getOriginUrl: () => getOriginUrl,
46
+ getPathsDifferingFrom: () => getPathsDifferingFrom,
45
47
  getPullRequestUrl: () => getPullRequestUrl,
46
48
  getRemoteBranchFiles: () => getRemoteBranchFiles,
47
49
  getRootCommitHash: () => getRootCommitHash,
48
50
  getStagedFiles: () => getStagedFiles,
49
51
  getTagTarget: () => getTagTarget,
52
+ getTaskDiff: () => getTaskDiff,
50
53
  getTaskIdsOnBranch: () => getTaskIdsOnBranch,
51
54
  getTrackedModifiedFiles: () => getTrackedModifiedFiles,
52
55
  getUnmergedBranches: () => getUnmergedBranches,
@@ -272,6 +275,50 @@ function getBranchDiff(cwd, base = "origin/main", maxBytes = 2e5) {
272
275
  }
273
276
  return "";
274
277
  }
278
+ function getTaskDiff(cwd, taskId, base = "origin/main", maxBytes = 2e5) {
279
+ const truncate2 = (out) => out.length > maxBytes ? `${out.slice(0, maxBytes)}
280
+
281
+ ... [diff truncated at ${Math.round(maxBytes / 1024)} KB]` : out;
282
+ try {
283
+ const shas = execFileSync(
284
+ "git",
285
+ ["log", "--all", "--format=%H", `--grep=${taskId})`, "--fixed-strings"],
286
+ { cwd, encoding: "utf-8", maxBuffer: 8 * 1024 * 1024 }
287
+ ).split("\n").map((s) => s.trim()).filter(Boolean);
288
+ if (shas.length > 0) {
289
+ const parts = [];
290
+ for (const sha of [...shas].reverse()) {
291
+ try {
292
+ const one = execFileSync("git", ["diff", `${sha}^..${sha}`], {
293
+ cwd,
294
+ encoding: "utf-8",
295
+ maxBuffer: 32 * 1024 * 1024
296
+ });
297
+ if (one) parts.push(one);
298
+ } catch {
299
+ }
300
+ }
301
+ const out = parts.join("\n");
302
+ if (out) {
303
+ const range = shas.length === 1 ? shas[0].slice(0, 8) : `${shas[shas.length - 1].slice(0, 8)}\u2026${shas[0].slice(0, 8)}`;
304
+ return {
305
+ diff: truncate2(out),
306
+ scope: "task-commits",
307
+ detail: `${shas.length} commit(s) for ${taskId} (${range}), each against its own parent`
308
+ };
309
+ }
310
+ }
311
+ } catch {
312
+ }
313
+ const branch = getCurrentBranch(cwd);
314
+ const diff = getBranchDiff(cwd, base, maxBytes);
315
+ if (!diff) return { diff: "", scope: "none", detail: "no diff resolved" };
316
+ return {
317
+ diff,
318
+ scope: "whole-branch",
319
+ detail: `no commit naming ${taskId} was found, so this is the ENTIRE diff of ${branch ?? "the current branch"} vs ${base} \u2014 it may include other tasks' work, and may not include this task's`
320
+ };
321
+ }
275
322
  function getHeadCommitSubject(cwd) {
276
323
  try {
277
324
  const out = execFileSync("git", ["log", "-1", "--format=%s"], {
@@ -319,6 +366,28 @@ function branchExists(cwd, branch) {
319
366
  return false;
320
367
  }
321
368
  }
369
+ function getPathsDifferingFrom(cwd, target) {
370
+ try {
371
+ const out = execFileSync("git", ["diff", "--name-only", "HEAD", target], {
372
+ cwd,
373
+ encoding: "utf-8"
374
+ });
375
+ return out.split("\n").map((l) => l.trim()).filter(Boolean);
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+ function getCommitFiles(cwd, ref = "HEAD") {
381
+ try {
382
+ const out = execFileSync("git", ["show", "--name-only", "--pretty=format:", ref], {
383
+ cwd,
384
+ encoding: "utf-8"
385
+ });
386
+ return out.split("\n").map((l) => l.trim()).filter(Boolean);
387
+ } catch {
388
+ return null;
389
+ }
390
+ }
322
391
  function checkoutBranch(cwd, branch) {
323
392
  try {
324
393
  execFileSync("git", ["checkout", branch], { cwd, encoding: "utf-8" });
@@ -1329,7 +1398,7 @@ var init_proxy_adapter = __esm({
1329
1398
  "listContributorReleasePrs",
1330
1399
  "claimReview",
1331
1400
  "getSiblingAds",
1332
- "getSiblingRepoTasks"
1401
+ "getSiblingRepoTasks",
1333
1402
  // task-2828 (C339): attributed-intelligence analytics reader — pg-only that cycle.
1334
1403
  // task-2864 (C343): WIRED. getModelOutcomeStats now has an edge case handler (raw
1335
1404
  // SQL via postgres.js mirroring the pg query + inlined computeModelOutcomes bucketing)
@@ -1353,6 +1422,28 @@ var init_proxy_adapter = __esm({
1353
1422
  // (case handler + ALLOWED_METHODS/WRITE_METHODS entries), so it forwards for
1354
1423
  // hosted callers and persists a project-scoped cycle_progress_steps row. Removed
1355
1424
  // from NO_FORWARD (was the task-2484 pg-only gap) — hosted parity restored.
1425
+ //
1426
+ // (3) pg-only optimisations probed STRUCTURALLY with a local fallback beside them.
1427
+ // These are the dangerous class: the caller asks `typeof adapter.X === 'function'`
1428
+ // to decide whether the fast path exists, and the get-trap answers "yes" for any
1429
+ // name absent from this set. The probe then passes, the call forwards, and the
1430
+ // edge 403s — while a working fallback sits a few lines below, unreachable.
1431
+ // A method belongs here when BOTH are true: it is probed by `typeof` rather than
1432
+ // called unconditionally, and the probe's else-branch is a real fallback.
1433
+ //
1434
+ // task-3290 (C361): allocateActiveDecision + applyActiveDecisionUpdates are pg-only
1435
+ // (atomic id allocation; transactional batch apply). Both are probed in
1436
+ // services/strategy.ts — asDecisionIdAllocator and asDecisionBatchApplier — and both
1437
+ // have read-then-write / sequential fallbacks. Hosted users hit a 403 minting ANY
1438
+ // Active Decision via setup's AD seed or strategy_change until these were listed.
1439
+ "allocateActiveDecision",
1440
+ "applyActiveDecisionUpdates",
1441
+ // task-3290 (C361), same sweep: getLastZoomOutCycle was documented in the parity
1442
+ // ledger as "proxy NO_FORWARD → safe no-op" while NOT being in this set. It is
1443
+ // optional-chain probed in services/health.ts and wrapped in try/catch, so it
1444
+ // degraded rather than crashed — but every hosted `orient` paid a round-trip to
1445
+ // earn a silent 403. Listing it makes the ledger's claim true and skips the trip.
1446
+ "getLastZoomOutCycle"
1356
1447
  ]);
1357
1448
  ProxyPapiAdapter = class _ProxyPapiAdapter {
1358
1449
  endpoint;
@@ -1884,6 +1975,21 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
1884
1975
  updateDogfoodEntryStatus(id, status, linkedTaskId) {
1885
1976
  return this.invoke("updateDogfoodEntryStatus", [id, status, linkedTaskId]);
1886
1977
  }
1978
+ // --- Project conventions (task-3271) ---
1979
+ //
1980
+ // Wired to the edge handler from the start rather than parked in NO_FORWARD.
1981
+ // The hosted remote connector is the only install path an external user has,
1982
+ // so a local-only conventions store would be a feature nobody in the actual
1983
+ // user base can reach.
1984
+ listConventions() {
1985
+ return this.invoke("listConventions");
1986
+ }
1987
+ createConvention(convention) {
1988
+ return this.invoke("createConvention", [convention]);
1989
+ }
1990
+ deleteConvention(id) {
1991
+ return this.invoke("deleteConvention", [id]);
1992
+ }
1887
1993
  // --- Harness inventory (task-1896) ---
1888
1994
  getHarnessInventory() {
1889
1995
  return this.invoke("getHarnessInventory");
@@ -7407,6 +7513,24 @@ If a candidate AD body could be invalidated by running a SQL query, refreshing a
7407
7513
  **Negative example (reject):** "External user feedback is now flowing. Stonebridge Systems is actively building." \u2014 this is a fact about the current state of the world. Capture as dogfood/signal observation; do not mint.
7408
7514
 
7409
7515
  This rule applies to: new ADs proposed during planning (Step 9), strategy review AD updates (section 5), and strategy_change AD updates. If you find an existing AD that violates this rule during housekeeping, propose deleting it (action: "delete") with a one-line rationale.`;
7516
+ var AD_ADMISSION_RULES = `**AD Admission Rule \u2014 PROPOSE a decision when the project takes a real stance.**
7517
+
7518
+ The guard above says what to reject. This says what to propose. Rejecting is not the safe default: a stance that never gets minted is a stance the next cycle cannot see, and re-deciding it every session is the exact cost this project is paying you to remove. When a candidate passes all four tests below, propose it \u2014 do not wait for the next strategy review.
7519
+
7520
+ **The four tests. All four must pass.**
7521
+ (a) **Alternatives were real.** Something else could genuinely have been chosen. If there was only ever one way to do it, it is not a decision.
7522
+ (b) **It constrains future work not yet scoped.** It changes what a task nobody has written yet will do. Not "it describes work we did" \u2014 "it binds work we have not planned".
7523
+ (c) **It is arguable today.** A competent person could argue the other side right now, with the evidence currently available. Not "was once debated" \u2014 live.
7524
+ (d) **Reversing it costs more than making it did.** If undoing it is as cheap as doing it, nothing is being constrained.
7525
+
7526
+ **Routing for a near-miss \u2014 a candidate that fails one test still goes somewhere.**
7527
+ - **Fails (c) only** \u2014 real alternatives, binds future work, expensive to reverse, but nobody is arguing the other side any more: it is a **Convention**, not a Decision. A Convention is a settled answer to a recurring question; it does not need adjudicating, it needs to be *known* by whoever builds next. Record it as a project convention so it rides future build handoffs. Do not mint it as an AD, and do not drop it.
7528
+ - **Fails (b)** \u2014 it does not constrain any future work: it is not durable at all, it is just work. Capture it as a task, a build report note, or a doc. Do not mint it.
7529
+ - **Fails (a) or (d)** \u2014 not a stance. Same routing as (b): capture it, do not mint it.
7530
+
7531
+ **Copy variants are NEVER decisions.** A tagline, a headline, a value proposition, a piece of marketing or product wording: swapping one for another constrains no future work, so it fails test (b) outright. Wording changes as often as the market teaches you something, and routing every edit through decision ceremony is what buries the handful of decisions that genuinely constrain the project. Edit the wording where the wording lives. If two variants are being compared against a measurement, that is an experiment, not a decision.
7532
+
7533
+ **A decision decaying into a convention is the healthy path, not a failure.** When a live AD stops being arguable \u2014 the alternatives are no longer on the table and nobody would re-litigate it \u2014 say so during housekeeping and propose retiring it into a convention. An AD registry where most entries are settled is one nobody reads.`;
7410
7534
  var AD_CONFLICT_SURFACING_RULES = `**A contradiction is NOT a veto \u2014 surface it, never silently shelve it.**
7411
7535
 
7412
7536
  Active Decisions are *active*: they can be superseded, modified, or abandoned. You do NOT have authority to kill a piece of work simply because it cuts against one. That is the user's call, and they can only make it if you show it to them.
@@ -7653,7 +7777,7 @@ var PLAN_FRAGMENT_SPIKE = `
7653
7777
  var PLAN_FRAGMENT_DESIGN_BRIEF = `
7654
7778
  **Design brief task detection:** When a task's task type is "design-brief", generate a DESIGN BRIEF handoff. Inside SCOPE (DO THIS), use these type-specific subsections. KEEP the "SCOPE (DO THIS)" header and put these subsections INSIDE it \u2014 the handoff parser only recognises the standard headers (packages/adapter-md build-handoff.ts SECTION_HEADERS) and a handoff with no SCOPE section is rejected outright (task-3050, C357):
7655
7779
  - AUDIENCE: Who this design is for \u2014 persona and context of use (e.g. "non-technical Owner, first dashboard visit")
7656
- - BRAND CONSTRAINTS: Palette, typography, tone \u2014 pull from \`.impeccable.md\` (dev patterns, anti-patterns, component rules) AND \`docs/branding/brand-book.html\` (brand identity, positioning, voice canon) if present. If neither exists, state "No brand doc \u2014 Owner should define constraints before starting."
7780
+ - BRAND CONSTRAINTS: Palette, typography, tone \u2014 pull from \`PRODUCT.md\` (strategic: brand, users, product purpose, design principles) AND \`DESIGN.md\` (visual tokens: palette, typography, elevation, components) if present. If neither exists, state "No brand doc \u2014 Owner should define constraints before starting."
7657
7781
  - DELIVERABLE FORMAT: What the output looks like \u2014 design handoff package / annotated mockup / style spec. Be specific so the person doing the work knows what "done" means.
7658
7782
  - REVIEW POINTS: What the Owner must approve before the design is considered done (e.g. layout, copy, colour, imagery).
7659
7783
  Keep SCOPE BOUNDARY, ACCEPTANCE CRITERIA, SECURITY CONSIDERATIONS, and PRE-BUILD VERIFICATION sections as normal.
@@ -7685,11 +7809,11 @@ var PLAN_FRAGMENT_OPS_BRIEF = `
7685
7809
  var PLAN_FRAGMENT_UI = `
7686
7810
  **UI/visual task detection:** Apply these additions ONLY to tasks whose PRIMARY scope is frontend visual work \u2014 the task's main deliverable must be a UI change, new component, visual design, or page. Do NOT apply to backend tasks, DB migrations, or prompt/config changes that merely mention a dashboard or page in passing. Signal: the task would fail if no .tsx/.css files were changed. If uncertain, skip the UI additions.
7687
7811
  When a task IS a UI task (primary scope is visual/frontend):
7688
- - Add to SCOPE: "Read \`.impeccable.md\` for component patterns, anti-patterns, and dev-loop design rules. Read \`docs/branding/brand-book.html\` for brand identity (positioning, voice, palette as final canon). Use the \`frontend-design\` skill for implementation."
7812
+ - Add to SCOPE: "Read \`PRODUCT.md\` for product purpose, users and design principles, and \`DESIGN.md\` for the visual tokens (palette, typography, elevation, components) \u2014 these are the two files the \`impeccable\` skill reads, and every visual decision must align with them. Run \`impeccable init\` to create them if they do not exist. Use the \`frontend-design\` skill for implementation."
7689
7813
  - For M/L UI tasks, add to SCOPE: "Use the full impeccable workflow: shape (direction approval) \u2192 craft (design+build via \`impeccable craft\` / frontend-design) \u2192 live (in-browser HMR iteration via \`impeccable live\`) \u2192 detect (slop check). The approved direction is the quality bar; expect 2-3 iterations. Playground is for pre-build direction approval of shareable/static artifacts or non-dashboard explorers; Playwright is for post-build verification, not mid-design iteration."
7690
7814
  - Add to ACCEPTANCE CRITERIA: "[ ] Visually verify rendered output in browser \u2014 provide localhost URL or screenshot to user for review." and "[ ] No raw IDs, abbreviations, or jargon visible without human-readable labels or tooltips."
7691
- - If the task involves image selection, add to SCOPE: "Include brand/theme direction constraints for image selection \u2014 pull from \`docs/branding/brand-book.html\` for canonical brand identity."
7692
- The planner's job is scoping, not design direction. Design decisions happen at build time via \`.impeccable.md\` (dev patterns) + \`docs/branding/brand-book.html\` (brand identity) and the frontend-design skill \u2014 don't try to write design specs in the handoff.`;
7815
+ - If the task involves image selection, add to SCOPE: "Include brand/theme direction constraints for image selection \u2014 pull from \`PRODUCT.md\` and \`DESIGN.md\` for canonical brand identity."
7816
+ The planner's job is scoping, not design direction. Design decisions happen at build time via \`PRODUCT.md\` (product purpose, users, design principles) + \`DESIGN.md\` (visual tokens) and the frontend-design skill \u2014 don't try to write design specs in the handoff.`;
7693
7817
  var PLAN_FRAGMENT_PRODUCT_BRIEF = `
7694
7818
  12. **Product Brief** \u2014 Check whether the product brief still reflects reality. Update the brief when ANY of these apply:
7695
7819
  - A new AD was created or an existing AD was superseded that changes product scope, target user, or positioning
@@ -7783,6 +7907,8 @@ ${AD_CONFLICT_SURFACING_RULES}
7783
7907
 
7784
7908
  ${AD_REJECTION_RULES}
7785
7909
 
7910
+ ${AD_ADMISSION_RULES}
7911
+
7786
7912
  **\u2192 PERSIST:** EVERY AD you created, updated, or confirmed with changes MUST appear in \`activeDecisions\` array in Part 2. Include the full replacement body with ### heading.
7787
7913
 
7788
7914
  ### Operational Quality Rules
@@ -8325,6 +8451,8 @@ You MUST cover these 5 sections. Each is mandatory.
8325
8451
 
8326
8452
  ${AD_REJECTION_RULES}
8327
8453
 
8454
+ ${AD_ADMISSION_RULES}
8455
+
8328
8456
  **Registered Documents:** If a "### Registered Documents" section is present in context, scan it for: (a) research findings that contradict current ADs or strategy, (b) unactioned research that should influence the next plan. Reference relevant docs by title in your review. If unregistered docs are listed, flag 1-2 that look strategically relevant and suggest registering them.
8329
8457
 
8330
8458
  **Doc Action Staleness:** If a "### Doc Action Staleness" section is present, treat it as a research-to-action audit. For each entry:
@@ -8673,6 +8801,8 @@ The body field must be the COMPLETE replacement text for the AD block (including
8673
8801
 
8674
8802
  ${AD_REJECTION_RULES}
8675
8803
 
8804
+ ${AD_ADMISSION_RULES}
8805
+
8676
8806
  ## PHASE UPDATES
8677
8807
 
8678
8808
  If the strategic change affects the project's phase structure, include a phaseUpdates array.
@@ -8845,10 +8975,15 @@ function buildPreScanInstruction(opts) {
8845
8975
  const docRouting = opts.hosted ? "paste the relevant content (decision records, roadmap, spec excerpts) directly into `description` \u2014 over a remote connector PAPI cannot read local file paths." : "pass local doc file paths as `sources` (comma-separated), or paste content into `description`.";
8846
8976
  return [
8847
8977
  `**\u{1F50E} Before you generate anything \u2014 scan the project and gather real context.**`,
8848
- `A brief and Active Decisions built from the real code and docs are far sharper than ones guessed from a project name. You (the agent running this) have local access that PAPI does not \u2014 do the gathering, then pass it back.`,
8978
+ // task-3293: state the division of labour ONCE, up front. A user watching this
8979
+ // session sees PAPI say it cannot read their files and then sees their agent
8980
+ // read their files. That reads as a contradiction, or worse, unless it is named
8981
+ // first: PAPI never touches the filesystem, YOU do, and you send back a summary.
8982
+ `**How this works:** PAPI has no access to your machine and never reads your files. You (the agent running this) do the reading locally and send back a short summary. Nothing is uploaded except what you pass in that summary.`,
8983
+ `A brief and Active Decisions built from the real code and docs are far sharper than ones guessed from a project name.`,
8849
8984
  ``,
8850
8985
  `1. **Confirm the environment.** Check that you are running from the root of this project's dev environment \u2014 the folder that holds its source, git history, and docs. If you are not there, or you are connected over a remote connector with no access to the user's files, say so and ask the user where the project lives before continuing.`,
8851
- `2. **Scan the code.** List the top-level directories and files, read the package/build manifest and the README (first ~3000 chars), and collect recent commit subjects. Pass this back as \`codebase_scan\` on the next \`setup\` call.`,
8986
+ `2. **Scan the code.** List the top-level directories and files, read the package/build manifest and the README (first ~3000 chars), and collect recent commit subjects. Pass this back as \`codebase_scan\` on the next \`setup\` call. **Never include secrets** \u2014 skip \`.env\` and any credential, key or certificate file, and do not paste tokens, passwords or connection strings into the summary. PAPI also strips secret-shaped values server-side, but do not rely on that: leave them out.`,
8852
8987
  `3. **Scan the docs.** Look through \`docs/\`, design notes, ADRs/decision records, and any roadmap or planning files \u2014 not just code.`,
8853
8988
  `4. **Check for sibling / separate repos.** Many projects span more than one repo (a separate frontend, backend, infra, or mobile repo, or other packages in a monorepo). Ask the user whether any related repos or directories exist, and scan those too.`,
8854
8989
  `5. **Ask the user to point you at extra context.** Prompt them: "Is there anything else I should read before setting this up \u2014 a PRD or spec, decision records, a roadmap, or links to related repos or docs?" Fold whatever they share into the scan.`,
@@ -12345,7 +12480,9 @@ async function buildSessionGuidance(callerKey) {
12345
12480
 
12346
12481
  // src/services/onboarding-coaching.ts
12347
12482
  var ONBOARDING_EARLY_CYCLE_MAX = 2;
12483
+ var ONBOARDING_FIRST_CYCLE_MAX = 1;
12348
12484
  var MAX_COACHING_LINES = 4;
12485
+ var COACH_EXPLAIN_VALUE = "First cycle: before you run each step, tell the user in one line what it produces and why it is worth the wait, then run it. Say what it means for their project, not what the tool does.";
12349
12486
  var COACH_CONNECT_REPO = "No repository is linked to this project yet. Link your repo from the dashboard Settings (or capture it during `setup`) so builds, reviews, and releases attach to the right codebase.";
12350
12487
  var COACH_ROOT_DIR = "Before building, confirm this session is running from your project root directory, so commits and builds land against the right files.";
12351
12488
  var COACH_CLICKABLE_TASKS = "Task cards on your dashboard expand on click. Open one to read its full build handoff, comments, and history.";
@@ -12357,10 +12494,14 @@ var ONBOARDING_COACHING_HEADING = "## Getting Started";
12357
12494
  function buildOnboardingCoaching(state) {
12358
12495
  const lines = [];
12359
12496
  const isEarly = state.cycleNumber <= ONBOARDING_EARLY_CYCLE_MAX;
12497
+ const isFirstRun = state.cycleNumber <= ONBOARDING_FIRST_CYCLE_MAX;
12360
12498
  const { surface } = state;
12361
12499
  if (state.repoConnected === false && (surface === "orient" || surface === "plan")) {
12362
12500
  lines.push(COACH_CONNECT_REPO);
12363
12501
  }
12502
+ if (isFirstRun) {
12503
+ lines.push(COACH_EXPLAIN_VALUE);
12504
+ }
12364
12505
  if (state.hasLocalWorkspace && (surface === "setup" || surface === "orient" && isEarly)) {
12365
12506
  lines.push(COACH_ROOT_DIR);
12366
12507
  }
@@ -12500,7 +12641,7 @@ function savePrepareContextFile(projectId, callerKey, content) {
12500
12641
  var planPrepareCache = new PerCallerCache();
12501
12642
  var planTool = {
12502
12643
  name: "plan",
12503
- description: 'Run once per cycle to select tasks and generate BUILD HANDOFFs. Call after setup (first time) or after completing all builds AND running release for the previous cycle. Returns prioritised task recommendations with detailed implementation specs. NEVER call when unbuilt cycle tasks exist \u2014 build and release first. First call returns a planning prompt for you to execute (prepare phase). Then call again with mode "apply" and your output to write results. Use skip_handoffs=true for large backlogs \u2014 handoffs are then generated separately via `handoff_generate`.',
12644
+ description: 'Turn a backlog into one scoped cycle of work, with a written spec for every task in it. plan reads the whole board, the decisions this project has already taken, and how much it actually delivered in recent cycles, then prioritises what to do next and writes a per-task BUILD HANDOFF: scope, what is deliberately out of scope, acceptance criteria, files likely touched, security notes, and the shared branch each task belongs on. It also reports board health, so stale, blocked and drifting work is visible instead of accumulating. This is not the same as planning inside one session: the cycle, the specs and the reasoning are stored, so the next session, the next tool, or the next person picks up where this one stopped. Run once per cycle, after setup the first time, or after completing all builds AND running release for the previous cycle. NEVER call when unbuilt cycle tasks exist: build and release first. First call returns a planning prompt for you to execute (prepare phase). Then call again with mode "apply" and your output to write results. Use skip_handoffs=true for large backlogs, and generate handoffs separately via `handoff_generate`.',
12504
12645
  annotations: { title: "Plan Cycle", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
12505
12646
  inputSchema: {
12506
12647
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -17395,6 +17536,33 @@ async function ensureDesignHookRegistered(projectRoot) {
17395
17536
  var TEMPLATE_MARKER = "*Describe your project's core value proposition here.*";
17396
17537
  var CONVENTIONS_SENTINEL = "<!-- PAPI_CONVENTIONS -->";
17397
17538
  var CONVENTIONS_HEADING = "## Code Style Conventions";
17539
+ function describeSeedWriteFailure(err) {
17540
+ const raw = err instanceof Error ? err.message : String(err);
17541
+ const CLASSES = [
17542
+ [
17543
+ /\b(?:401|403)\b|permission|unauthor|forbidden/i,
17544
+ "the storage backend refused the write (permission denied)."
17545
+ ],
17546
+ [
17547
+ /\b(?:5\d\d)\b|internal server error|bad gateway|unavailable/i,
17548
+ "the storage backend returned a server error."
17549
+ ],
17550
+ [
17551
+ /timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|fetch failed|network/i,
17552
+ "PAPI could not reach the storage backend."
17553
+ ],
17554
+ [
17555
+ /duplicate key|unique constraint|23505/i,
17556
+ "a decision with that id already exists."
17557
+ ],
17558
+ [
17559
+ /violates|constraint|invalid input syntax|22P02|23\d{3}/i,
17560
+ "the storage backend rejected the decision record."
17561
+ ]
17562
+ ];
17563
+ const matched = CLASSES.find(([pattern]) => pattern.test(raw));
17564
+ return matched ? `could not be saved \u2014 ${matched[1]}` : "could not be saved \u2014 the write to the storage backend failed.";
17565
+ }
17398
17566
  async function applySetupOutputs(adapter2, config2, input, collector, briefText, adSeedText, conventionsText) {
17399
17567
  const warnings = [];
17400
17568
  await adapter2.updateProductBrief(briefText);
@@ -17434,9 +17602,19 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
17434
17602
  let seededAds = 0;
17435
17603
  let skippedAds = 0;
17436
17604
  if (adSeedText) {
17605
+ let ads;
17606
+ let adSeedFailed = false;
17437
17607
  try {
17438
17608
  const cleaned = adSeedText.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
17439
- const ads = JSON.parse(cleaned);
17609
+ ads = JSON.parse(cleaned);
17610
+ } catch (err) {
17611
+ const msg = err instanceof Error ? err.message : String(err);
17612
+ warnings.push(
17613
+ `AD seeding failed \u2014 active decisions were not created. Check that your ad_seed_response is valid JSON. Error: ${msg}`
17614
+ );
17615
+ adSeedFailed = true;
17616
+ }
17617
+ try {
17440
17618
  if (Array.isArray(ads)) {
17441
17619
  const existingAdIds = adapter2.getActiveDecisions ? new Set(
17442
17620
  (await adapter2.getActiveDecisions({ includeRetired: true }).catch(() => [])).map((a) => a.displayId)
@@ -17460,18 +17638,17 @@ async function applySetupOutputs(adapter2, config2, input, collector, briefText,
17460
17638
  }
17461
17639
  }
17462
17640
  } catch (err) {
17463
- const msg = err instanceof Error ? err.message : String(err);
17464
17641
  warnings.push(
17465
- `AD seeding failed \u2014 active decisions were not created. Check that your ad_seed_response is valid JSON. Error: ${msg}`
17642
+ `Active Decisions: ${describeSeedWriteFailure(err)}` + (seededAds > 0 ? ` ${seededAds} decision(s) were created before this and are saved \u2014 re-running \`setup\` will not duplicate them.` : " No decisions were created.") + " This is a PAPI-side failure, not a problem with your response."
17466
17643
  );
17467
- seededAds = 0;
17644
+ adSeedFailed = true;
17468
17645
  }
17469
17646
  if (skippedAds > 0) {
17470
17647
  warnings.push(
17471
17648
  `Active Decisions: detected ${skippedAds} existing AD(s) and left them untouched${seededAds > 0 ? `; created ${seededAds} new one(s)` : " (none new to create)"}.`
17472
17649
  );
17473
17650
  } else if (seededAds === 0 && adSeedText) {
17474
- if (!warnings.some((w) => w.startsWith("AD seeding failed"))) {
17651
+ if (!adSeedFailed) {
17475
17652
  warnings.push(
17476
17653
  "AD seeding produced 0 active decisions \u2014 the JSON may be valid but empty or missing required `id` and `body` fields."
17477
17654
  );
@@ -17532,6 +17709,61 @@ function isSecretFile(name) {
17532
17709
  const lower = name.toLowerCase();
17533
17710
  return SECRET_PATTERNS.some((p) => lower.includes(p));
17534
17711
  }
17712
+ var SECRET_VALUE_PATTERNS = [
17713
+ // KEY=value / KEY: value where the KEY names a credential. Value must be
17714
+ // non-trivial so `API_KEY=` or `TOKEN=changeme` placeholders in docs are hit too.
17715
+ /\b([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|ACCESS_KEY|API_KEY|APIKEY|AUTH)[A-Z0-9_]*)\s*[:=]\s*\S+/gi,
17716
+ // Provider-issued key formats.
17717
+ /\bsk-[A-Za-z0-9_-]{16,}/g,
17718
+ // OpenAI / Anthropic style
17719
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
17720
+ // GitHub tokens
17721
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
17722
+ /\bAKIA[0-9A-Z]{16}\b/g,
17723
+ // AWS access key id
17724
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
17725
+ // Slack
17726
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
17727
+ // JWT
17728
+ // Credentials embedded in a connection string.
17729
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@]+@/gi,
17730
+ // Whole PEM blocks.
17731
+ /-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*PRIVATE KEY-----/g,
17732
+ /\bBearer\s+[A-Za-z0-9._-]{20,}/g
17733
+ ];
17734
+ var REDACTED = "[redacted by PAPI]";
17735
+ function redactPackageJson(pkg) {
17736
+ if (!pkg) return pkg;
17737
+ const out = { ...pkg };
17738
+ for (const field of ["description", "name"]) {
17739
+ if (typeof out[field] === "string") out[field] = redactSecretValues(out[field]);
17740
+ }
17741
+ return out;
17742
+ }
17743
+ function normaliseClientScan(raw) {
17744
+ return {
17745
+ topLevelDirs: raw.topLevelDirs ?? [],
17746
+ topLevelFiles: (raw.topLevelFiles ?? []).filter((f) => !isSecretFile(f)),
17747
+ packageJson: redactPackageJson(raw.packageJson),
17748
+ readme: redactSecretValues(raw.readme),
17749
+ configFiles: (raw.configFiles ?? []).filter((f) => !isSecretFile(f)),
17750
+ sourceFiles: raw.sourceFiles ?? [],
17751
+ totalFiles: raw.totalFiles ?? 0,
17752
+ sourcePaths: (raw.sourcePaths ?? []).filter((p) => !isSecretFile(p)),
17753
+ commitHistory: raw.commitHistory?.map((c) => redactSecretValues(c))
17754
+ };
17755
+ }
17756
+ function redactSecretValues(text) {
17757
+ if (!text) return text;
17758
+ let out = text;
17759
+ for (const pattern of SECRET_VALUE_PATTERNS) {
17760
+ out = out.replace(pattern, (match) => {
17761
+ const kv = match.match(/^([A-Z0-9_]+)\s*[:=]/i);
17762
+ return kv ? `${kv[1]}=${REDACTED}` : REDACTED;
17763
+ });
17764
+ }
17765
+ return out;
17766
+ }
17535
17767
  async function safeReadFile(filePath, maxBytes = 1e4) {
17536
17768
  if (isSecretFile(basename(filePath))) return null;
17537
17769
  try {
@@ -17721,18 +17953,7 @@ async function prepareSetup(adapter2, config2, input) {
17721
17953
  const hasUserSignalForScan = Boolean(input.description?.trim()) || Boolean(input.targetUsers?.trim());
17722
17954
  const shouldScan = isExistingProject || !hasUserSignalForScan;
17723
17955
  if (input.codebaseScan) {
17724
- const raw = input.codebaseScan;
17725
- const clientScan = {
17726
- topLevelDirs: raw.topLevelDirs ?? [],
17727
- topLevelFiles: raw.topLevelFiles ?? [],
17728
- packageJson: raw.packageJson,
17729
- readme: raw.readme,
17730
- configFiles: raw.configFiles ?? [],
17731
- sourceFiles: raw.sourceFiles ?? [],
17732
- totalFiles: raw.totalFiles ?? 0,
17733
- sourcePaths: raw.sourcePaths ?? [],
17734
- commitHistory: raw.commitHistory
17735
- };
17956
+ const clientScan = normaliseClientScan(input.codebaseScan);
17736
17957
  const hasScanSignal = Boolean(clientScan.readme) || Boolean(clientScan.packageJson) || Boolean(clientScan.commitHistory && clientScan.commitHistory.length > 0) || clientScan.topLevelDirs.length > 0 || clientScan.topLevelFiles.length > 0;
17737
17958
  if (hasScanSignal) {
17738
17959
  codebaseSummary = formatCodebaseSummary(clientScan, sourceContents);
@@ -17747,7 +17968,10 @@ async function prepareSetup(adapter2, config2, input) {
17747
17968
  }
17748
17969
  } else if (shouldScan && !canScanFilesystem && input.existingProject === true) {
17749
17970
  warnings.push(
17750
- "Codebase scan skipped \u2014 PAPI is connected over hosted transport and cannot read your local files. To adopt an existing project, provide `description` and `target_users` directly (the brief generator will use them), or run `setup` from a local stdio install (`npx @papi-ai/server`) where PAPI can scan the tree."
17971
+ // task-3293: this used to end at "cannot read your local files", which read as
17972
+ // a flat contradiction to the very next thing the user saw — their own agent
17973
+ // reading those files. Name the division of labour instead of just the limit.
17974
+ "Codebase scan skipped \u2014 PAPI has no access to your machine and never reads your files directly. Your AI client can: ask it to scan the project and pass the summary back as `codebase_scan` (leaving out `.env` and anything holding keys, tokens or passwords). Or provide `description` and `target_users` directly and the brief generator will use those instead."
17751
17975
  );
17752
17976
  }
17753
17977
  const hasCodebaseSignal = Boolean(codebaseSummary && codebaseSummary.trim().length > 0);
@@ -18077,7 +18301,7 @@ async function ensureMcpJsonGitignored(projectRoot) {
18077
18301
  // src/tools/setup.ts
18078
18302
  var setupTool = {
18079
18303
  name: "setup",
18080
- description: 'Create a new PAPI-tracked project \u2014 generates your Product Brief, Active Decisions, and CLAUDE.md workflow instructions. Run after configuring your MCP credentials (via `init` or manually from getpapi.ai). Only project_name is required \u2014 description and target_users are derived from README, package.json, and commit history when omitted. Set existing_project: true to adopt an existing codebase. ADOPTING AN EXISTING PROJECT OVER A REMOTE/HOSTED CONNECTOR (no local stdio install): PAPI cannot read your filesystem, so YOU (the client) must gather a `codebase_scan` and pass it in \u2014 list top-level dirs/files, the package manifest, the README (first ~3000 chars), and recent commit subjects. Without it, adoption falls back to asking for description/target_users. On a local stdio install PAPI scans the tree itself, so `codebase_scan` is optional there. First call returns prompts (prepare phase), then call again with mode "apply" and your outputs. After setup, run `plan` to start your first cycle.',
18304
+ description: 'Give a project a memory, so every later session starts with its context instead of a blank slate. Setup reads the project, writes a Product Brief that says what it is for and who it serves, records the first Active Decisions and build conventions so settled answers outlive the session that found them, and installs the workflow instructions the coding assistant follows from then on. Everything after this (planning, building, reviewing) reads from what setup writes. It takes a few minutes and runs in two passes: the first returns prompts for you to execute, the second saves the results. Tell the user what each pass is doing while it runs. Run after configuring your MCP credentials (via `init` or manually from getpapi.ai). Only project_name is required \u2014 description and target_users are derived from README, package.json, and commit history when omitted. Set existing_project: true to adopt an existing codebase. ADOPTING AN EXISTING PROJECT OVER A REMOTE/HOSTED CONNECTOR (no local stdio install): PAPI cannot read your filesystem, so YOU (the client) must gather a `codebase_scan` and pass it in \u2014 list top-level dirs/files, the package manifest, the README (first ~3000 chars), and recent commit subjects. Without it, adoption falls back to asking for description/target_users. On a local stdio install PAPI scans the tree itself, so `codebase_scan` is optional there. First call returns prompts (prepare phase), then call again with mode "apply" and your outputs. After setup, run `plan` to start your first cycle.',
18081
18305
  annotations: { title: "Set Up Project", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
18082
18306
  inputSchema: {
18083
18307
  type: "object",
@@ -18261,6 +18485,36 @@ Tip: See \`docs/templates/example-project-brief.md\` for an example of a well-wr
18261
18485
  Next step: run \`plan\` to start your first planning cycle.${filesToWriteSection}`
18262
18486
  );
18263
18487
  }
18488
+ var SETUP_NARRATION_HEADING = "**What happens now, and how long it takes**";
18489
+ function formatSetupNarration(result) {
18490
+ const steps = [];
18491
+ if (result.preScanInstruction || result.newProjectInstruction) {
18492
+ steps.push("Gather the context below, so what follows describes this project rather than a generic one.");
18493
+ }
18494
+ if (result.briefPrompt) {
18495
+ steps.push("Write the Product Brief: what this project is, who it is for, and what it has to get right. Every later plan reads it.");
18496
+ }
18497
+ if (result.adSeedPrompt) {
18498
+ steps.push("Record the first Active Decisions, so the choices already made stop being re-argued in later sessions.");
18499
+ }
18500
+ if (result.conventionsPrompt) {
18501
+ steps.push("Capture the build conventions this project expects, so they reach every future build without being restated.");
18502
+ }
18503
+ if (result.northStarPrompt) {
18504
+ steps.push("Agree a North Star with the user, so progress can be judged against something.");
18505
+ }
18506
+ if (result.initialTasksPrompt) {
18507
+ steps.push("Seed the backlog, so the first planning cycle has real work to choose from.");
18508
+ }
18509
+ steps.push('Call `setup` again with `mode: "apply"` to save all of it.');
18510
+ return [
18511
+ SETUP_NARRATION_HEADING,
18512
+ "",
18513
+ "Expect a few minutes end to end. Nothing is saved until the final step, so do not stop partway.",
18514
+ "Say what you are doing as you go, in one line per step, so the wait is visible to the user:",
18515
+ ...steps.map((s, i) => `${i + 1}. ${s}`)
18516
+ ];
18517
+ }
18264
18518
  async function handleSetup(adapter2, config2, args, clientName) {
18265
18519
  const toolMode = args.mode;
18266
18520
  const REQUIRED_FIELDS = ["project_name"];
@@ -18304,6 +18558,7 @@ PAPI needs the project name. Description and target users are optional \u2014 th
18304
18558
  result.createdProject ? `Project "${result.projectName}" scaffolded \u2014 database tables created.
18305
18559
  ` : ""
18306
18560
  ];
18561
+ sections.push(...formatSetupNarration(result), "");
18307
18562
  if (result.autoDetected) {
18308
18563
  sections.push(
18309
18564
  `**Codebase detected:** Existing codebase found \u2014 running in adoption mode. If this is wrong, re-run setup with \`existing_project: false\`.`,
@@ -20260,16 +20515,20 @@ function triggerSurfaceHitsOnBranch(projectRoot, baseRef = "origin/main") {
20260
20515
  }
20261
20516
 
20262
20517
  // src/services/build.ts
20263
- function selectAutostashPaths(modified, untracked) {
20518
+ function selectAutostashPaths(modified, untracked, conflictingPaths) {
20264
20519
  const untrackedSet = new Set(untracked);
20265
- const isDocsPath = (p) => p === "docs" || p.startsWith("docs/");
20266
20520
  const isUntrackedDirEntry = (p) => p.endsWith("/") && untracked.some((u) => u.startsWith(p));
20267
20521
  const trackedDirty = modified.filter(
20268
20522
  (p) => !untrackedSet.has(p) && !isUntrackedDirEntry(p)
20269
20523
  );
20270
- const preservedDocs = untracked.filter(isDocsPath);
20271
- const stashableUntracked = untracked.filter((p) => !isDocsPath(p));
20272
- return { toStash: [...trackedDirty, ...stashableUntracked], preservedDocs };
20524
+ if (conflictingPaths == null) {
20525
+ return { toStash: trackedDirty, preservedDocs: untracked };
20526
+ }
20527
+ const conflicting = new Set(conflictingPaths);
20528
+ return {
20529
+ toStash: trackedDirty.filter((p) => conflicting.has(p)),
20530
+ preservedDocs: untracked
20531
+ };
20273
20532
  }
20274
20533
  function resolveRelatedDecisions(provided, buildHandoff) {
20275
20534
  const fromInput = (provided ?? "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -20391,11 +20650,18 @@ function matchedPredictedEntry(changedPath, predicted) {
20391
20650
  function isPathInPredictedScope(changedPath, predicted) {
20392
20651
  return matchedPredictedEntry(changedPath, predicted) !== null;
20393
20652
  }
20653
+ function verifyCommitContents(cwd, intended) {
20654
+ if (intended.length === 0) return [];
20655
+ const actual = getCommitFiles(cwd);
20656
+ if (actual === null) return [];
20657
+ const actualSet = new Set(actual);
20658
+ return intended.filter((p) => !actualSet.has(p));
20659
+ }
20394
20660
  function autoCommit(config2, taskId, taskTitle, predictedFiles) {
20395
20661
  const cwd = config2.projectRoot;
20396
20662
  const message = `feat(${taskId}): ${taskTitle}`;
20397
- if (!isGitAvailable()) return "Auto-commit: skipped (git not found).";
20398
- if (!isGitRepo(cwd)) return "Auto-commit: skipped (not a git repository).";
20663
+ if (!isGitAvailable()) return { line: "Auto-commit: skipped (git not found).", missing: [] };
20664
+ if (!isGitRepo(cwd)) return { line: "Auto-commit: skipped (not a git repository).", missing: [] };
20399
20665
  const safeRun = (fn) => {
20400
20666
  try {
20401
20667
  const r = fn();
@@ -20409,26 +20675,27 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
20409
20675
  `^(feat|fix|chore|refactor|docs|test|style|build|perf|ci)\\(${taskId}\\):`
20410
20676
  );
20411
20677
  if (headSubject && taskCommitRe.test(headSubject)) {
20412
- return `Auto-commit: skipped \u2014 HEAD already has a fresh ${taskId} commit (${headSubject}).`;
20678
+ return { line: `Auto-commit: skipped \u2014 HEAD already has a fresh ${taskId} commit (${headSubject}).`, missing: [] };
20413
20679
  }
20414
20680
  const staged = getStagedFiles(cwd);
20415
20681
  if (staged.length > 0) {
20416
- return safeRun(() => commitStagedOnly(cwd, message)) + ` (selective staging respected: ${staged.length} file(s)).`;
20682
+ const line = safeRun(() => commitStagedOnly(cwd, message)) + ` (selective staging respected: ${staged.length} file(s)).`;
20683
+ return { line, missing: verifyCommitContents(cwd, staged) };
20417
20684
  }
20418
20685
  const checkpoint = readBuildCheckpointIfLocal({ cwd, taskId });
20419
20686
  const headSha = getHeadCommitSha(cwd);
20420
20687
  if (checkpoint?.lastCommitSha && headSha && checkpoint.lastCommitSha !== headSha) {
20421
20688
  const leftover = getModifiedFiles(cwd);
20422
20689
  if (leftover.length === 0) {
20423
- return "Auto-commit: skipped (builder already committed; working tree clean).";
20690
+ return { line: "Auto-commit: skipped (builder already committed; working tree clean).", missing: [] };
20424
20691
  }
20425
20692
  const sample = leftover.slice(0, 10).join(", ");
20426
20693
  const more = leftover.length > 10 ? ` (+${leftover.length - 10} more)` : "";
20427
- return `Auto-commit: skipped \u2014 you already committed during this build, and ${leftover.length} file(s) are still modified. They were NOT committed, because at this point PAPI cannot tell your deliberately-excluded work from a concurrent session's files (task-3054). Left uncommitted: ${sample}${more}. If any belong to ${taskId}, \`git add\` them and re-run build_execute complete.`;
20694
+ return { line: `Auto-commit: skipped \u2014 you already committed during this build, and ${leftover.length} file(s) are still modified. They were NOT committed, because at this point PAPI cannot tell your deliberately-excluded work from a concurrent session's files (task-3054). Left uncommitted: ${sample}${more}. If any belong to ${taskId}, \`git add\` them and re-run build_execute complete.`, missing: [] };
20428
20695
  }
20429
20696
  const modified = getModifiedFiles(cwd);
20430
20697
  if (modified.length === 0) {
20431
- return "Auto-commit: skipped (no working-tree changes).";
20698
+ return { line: "Auto-commit: skipped (no working-tree changes).", missing: [] };
20432
20699
  }
20433
20700
  const commitResult = safeRun(() => stageAllAndCommit(cwd, message));
20434
20701
  if (predictedFiles && predictedFiles.length > 0) {
@@ -20437,13 +20704,22 @@ function autoCommit(config2, taskId, taskTitle, predictedFiles) {
20437
20704
  if (outOfScope.length > 0) {
20438
20705
  const sample = outOfScope.slice(0, 10).join(", ");
20439
20706
  const more = outOfScope.length > 10 ? ` (+${outOfScope.length - 10} more)` : "";
20440
- return `${commitResult} (staged all ${modified.length} changed file(s)). \u2139\uFE0F Scope drift: ${outOfScope.length} committed file(s) were outside the handoff's FILES LIKELY TOUCHED \u2014 handoff under-predicted: ${sample}${more}.`;
20707
+ return {
20708
+ line: `${commitResult} (staged all ${modified.length} changed file(s)). \u2139\uFE0F Scope drift: ${outOfScope.length} committed file(s) were outside the handoff's FILES LIKELY TOUCHED \u2014 handoff under-predicted: ${sample}${more}.`,
20709
+ missing: verifyCommitContents(cwd, modified)
20710
+ };
20441
20711
  }
20442
20712
  const matches = modified.slice(0, 5).map((p) => `${p} \u2190 ${matchedPredictedEntry(p, cleanedPredicted) ?? "?"}`).join(", ");
20443
20713
  const extra = modified.length > 5 ? ` (+${modified.length - 5} more)` : "";
20444
- return `${commitResult} (staged all ${modified.length} changed file(s), each matched to FILES LIKELY TOUCHED: ${matches}${extra}).`;
20714
+ return {
20715
+ line: `${commitResult} (staged all ${modified.length} changed file(s), each matched to FILES LIKELY TOUCHED: ${matches}${extra}).`,
20716
+ missing: verifyCommitContents(cwd, modified)
20717
+ };
20445
20718
  }
20446
- return `${commitResult} (staged all ${modified.length} changed file(s)).`;
20719
+ return {
20720
+ line: `${commitResult} (staged all ${modified.length} changed file(s)).`,
20721
+ missing: verifyCommitContents(cwd, modified)
20722
+ };
20447
20723
  }
20448
20724
  function pushAndCreatePR(config2, taskId, taskTitle, clientName, module, cycleNumber) {
20449
20725
  const lines = [];
@@ -20859,20 +21135,23 @@ async function startBuild(adapter2, config2, taskId, options = {}, clientName) {
20859
21135
  );
20860
21136
  }
20861
21137
  if (hasUncommittedChanges(config2.projectRoot, AUTO_WRITTEN_PATHS)) {
21138
+ const stashTarget = branchExists(config2.projectRoot, featureBranch) ? featureBranch : resolveBaseBranch(config2.projectRoot, config2.baseBranch);
21139
+ const conflictingPaths = getPathsDifferingFrom(config2.projectRoot, stashTarget);
20862
21140
  const { toStash, preservedDocs } = selectAutostashPaths(
20863
21141
  getModifiedFiles(config2.projectRoot),
20864
- getUntrackedFiles(config2.projectRoot)
21142
+ getUntrackedFiles(config2.projectRoot),
21143
+ conflictingPaths
20865
21144
  );
20866
- const preservedNote = preservedDocs.length > 0 ? ` ${preservedDocs.length} untracked docs/ file(s) left in place (task-2092 \u2014 registered docs must never be swept).` : "";
21145
+ const preservedNote = preservedDocs.length > 0 ? ` ${preservedDocs.length} untracked file(s) left in place, not stashed (task-3282 \u2014 an untracked file never blocks a branch switch, and a sub-agent's brand-new file is untracked by definition).` : "";
20867
21146
  if (toStash.length === 0) {
20868
21147
  if (preservedDocs.length > 0) {
20869
- branchLines.push(`Auto-stash skipped \u2014 only untracked docs/ files present.${preservedNote}`);
21148
+ branchLines.push(`Auto-stash skipped \u2014 nothing tracked is dirty.${preservedNote}`);
20870
21149
  }
20871
21150
  } else {
20872
21151
  const stashLabel = `papi-autostash/${taskId}-${Math.floor(Date.now() / 1e3)}`;
20873
21152
  try {
20874
21153
  const { execFileSync: execFileSync7 } = await import("child_process");
20875
- execFileSync7("git", ["stash", "push", "-u", "-m", stashLabel, "--", ...toStash], {
21154
+ execFileSync7("git", ["stash", "push", "-m", stashLabel, "--", ...toStash], {
20876
21155
  cwd: config2.projectRoot,
20877
21156
  encoding: "utf-8"
20878
21157
  });
@@ -21433,7 +21712,20 @@ async function completeBuild(adapter2, config2, taskId, input, options = {}, cli
21433
21712
  } else if (dbOnlyMode) {
21434
21713
  commitLine = DB_ONLY_COMPLETE_NOTICE;
21435
21714
  } else {
21436
- commitLine = autoCommit(config2, taskId, task.title, task.buildHandoff?.filesLikelyTouched);
21715
+ const outcome = autoCommit(config2, taskId, task.title, task.buildHandoff?.filesLikelyTouched);
21716
+ commitLine = outcome.line;
21717
+ if (outcome.missing.length > 0) {
21718
+ const sample = outcome.missing.slice(0, 10).join(", ");
21719
+ const more = outcome.missing.length > 10 ? ` (+${outcome.missing.length - 10} more)` : "";
21720
+ if (input.completed === "yes" && !atomicCommitDone) {
21721
+ try {
21722
+ await adapter2.updateTaskStatus(taskId, task.status);
21723
+ } catch {
21724
+ }
21725
+ }
21726
+ commitLine = `\u26A0\uFE0F AUTO-COMMIT VERIFICATION FAILED \u2014 the commit does NOT contain ${outcome.missing.length} file(s) that were staged for it. Missing: ${sample}${more}. This is the task-3029 failure mode: a pre-commit hook reverted modified files while staged adds/renames survived, so the commit is PARTIAL and will not build. ${taskId} was NOT advanced \u2014 it stays ${task.status}. Fix: inspect \`git show --stat HEAD\`, restore the missing changes, \`git add\` them, and re-run build_execute complete.
21727
+ (Original commit note: ${outcome.line})`;
21728
+ }
21437
21729
  }
21438
21730
  if (isGitAvailable() && isGitRepo(config2.projectRoot)) {
21439
21731
  const sha = getHeadCommitSha(config2.projectRoot);
@@ -21603,6 +21895,216 @@ async function cancelBuild(adapter2, taskId, reason, projectRoot) {
21603
21895
  return { task, reason };
21604
21896
  }
21605
21897
 
21898
+ // src/tools/conventions.ts
21899
+ var MAX_CONVENTION_RULE_LENGTH = 2e3;
21900
+ var MAX_CONVENTION_MODULE_LENGTH = 120;
21901
+ var MAX_INJECTED_CONVENTIONS = 25;
21902
+ var MAX_INJECTED_CONVENTION_CHARS = 8e3;
21903
+ var CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
21904
+ function sanitiseConventionRule(raw) {
21905
+ if (typeof raw !== "string") return null;
21906
+ const stripped = raw.replace(CONTROL_CHARS, "").trim();
21907
+ if (!stripped) return null;
21908
+ return stripped.slice(0, MAX_CONVENTION_RULE_LENGTH);
21909
+ }
21910
+ function sanitiseConventionModule(raw) {
21911
+ if (typeof raw !== "string") return void 0;
21912
+ const stripped = raw.replace(CONTROL_CHARS, "").trim();
21913
+ if (!stripped) return void 0;
21914
+ return stripped.slice(0, MAX_CONVENTION_MODULE_LENGTH);
21915
+ }
21916
+ function selectConventionsForModule(conventions, module) {
21917
+ return conventions.filter((c) => !c.module || module !== void 0 && c.module === module);
21918
+ }
21919
+ var CONVENTIONS_HEADING2 = "**PROJECT CONVENTIONS \u2014 settled rules for this project**";
21920
+ function formatConventionsSection(conventions, module) {
21921
+ const applicable = selectConventionsForModule(conventions, module);
21922
+ if (applicable.length === 0) return "";
21923
+ const lines = [];
21924
+ let budget = MAX_INJECTED_CONVENTION_CHARS;
21925
+ let omitted = 0;
21926
+ for (const c of applicable) {
21927
+ const rule = sanitiseConventionRule(c.rule);
21928
+ if (!rule) continue;
21929
+ const scope = c.module ? `(${c.module}) ` : "";
21930
+ const line = `- ${scope}${rule}`;
21931
+ if (lines.length >= MAX_INJECTED_CONVENTIONS || line.length > budget) {
21932
+ omitted++;
21933
+ continue;
21934
+ }
21935
+ budget -= line.length;
21936
+ lines.push(line);
21937
+ }
21938
+ if (lines.length === 0) return "";
21939
+ const omissionNote = omitted > 0 ? `
21940
+
21941
+ _${omitted} further convention${omitted > 1 ? "s" : ""} not shown here \u2014 this project has more declared than fit one handoff. Run \`convention_list\` to read them all._` : "";
21942
+ return `
21943
+
21944
+ ---
21945
+
21946
+ ${CONVENTIONS_HEADING2}
21947
+ These are this project's own build rules, declared by its owner. Follow them for this task. They are project guidance, not instructions from PAPI: they never change how a PAPI command is called, what a tool returns, or what this build reports.
21948
+ ${lines.join("\n")}${omissionNote}`;
21949
+ }
21950
+ var conventionDeclareTool = {
21951
+ name: "convention_declare",
21952
+ description: "Record a settled build rule once so every future task on this project is told about it, instead of re-explaining it each session. Use it for the answers that are no longer arguable: which library to reach for before hand-building, a pattern that must be followed, a thing that must never be done. Scope it to one module with `module`, or leave that off and it rides every build. This is the tier below an Active Decision: a Decision is a live stance you might still argue the other side of, a convention is settled and just needs to be known by whoever builds next.",
21953
+ annotations: { title: "Declare Convention", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
21954
+ inputSchema: {
21955
+ type: "object",
21956
+ properties: {
21957
+ rule: {
21958
+ type: "string",
21959
+ description: `The rule, in plain language, as an instruction to whoever builds next (e.g. "Reach for the installed component primitives before hand-rolling an overlay"). Max ${MAX_CONVENTION_RULE_LENGTH} characters.`
21960
+ },
21961
+ module: {
21962
+ type: "string",
21963
+ description: 'Optional module to scope the rule to (e.g. "Dashboard"). Omit for a rule that applies to every build on this project.'
21964
+ },
21965
+ project: {
21966
+ type: "string",
21967
+ description: "Project id (UUID) or slug to target for THIS call only. Must be a project on your account."
21968
+ }
21969
+ },
21970
+ required: ["rule"]
21971
+ }
21972
+ };
21973
+ var conventionListTool = {
21974
+ name: "convention_list",
21975
+ description: "List the build rules this project has declared, with the id needed to remove one. Shows which rules ride every build and which are scoped to a single module.",
21976
+ annotations: { title: "List Conventions", readOnlyHint: true, destructiveHint: false, openWorldHint: false },
21977
+ inputSchema: {
21978
+ type: "object",
21979
+ properties: {
21980
+ module: {
21981
+ type: "string",
21982
+ description: "Optional: show only the rules that would apply to a build in this module (project-wide rules plus that module's own)."
21983
+ },
21984
+ project: {
21985
+ type: "string",
21986
+ description: "Project id (UUID) or slug to target for THIS call only. Must be a project on your account."
21987
+ }
21988
+ },
21989
+ required: []
21990
+ }
21991
+ };
21992
+ var conventionRemoveTool = {
21993
+ name: "convention_remove",
21994
+ description: "Remove a declared build rule so it stops appearing in future builds. Takes the id from `convention_list`. Existing handoffs already generated are unaffected; the next build no longer carries it.",
21995
+ annotations: { title: "Remove Convention", readOnlyHint: false, destructiveHint: true, openWorldHint: false },
21996
+ inputSchema: {
21997
+ type: "object",
21998
+ properties: {
21999
+ id: {
22000
+ type: "string",
22001
+ description: "The convention id, from `convention_list`."
22002
+ },
22003
+ project: {
22004
+ type: "string",
22005
+ description: "Project id (UUID) or slug to target for THIS call only. Must be a project on your account."
22006
+ }
22007
+ },
22008
+ required: ["id"]
22009
+ }
22010
+ };
22011
+ var UNSUPPORTED = "Conventions are not available on this connection. The store lives in the PAPI database, so a project running on a file-backed adapter has nowhere to record one.";
22012
+ async function handleConventionDeclare(adapter2, config2, args) {
22013
+ const tracker = new ProgressTracker("validate");
22014
+ try {
22015
+ if (!adapter2.createConvention) return errorResponse(UNSUPPORTED);
22016
+ const rule = sanitiseConventionRule(args.rule);
22017
+ if (!rule) {
22018
+ return errorResponse(
22019
+ "A convention needs a rule. Pass `rule` with the instruction you want every future build on this project to follow."
22020
+ );
22021
+ }
22022
+ const wasTruncated = typeof args.rule === "string" && args.rule.trim().length > MAX_CONVENTION_RULE_LENGTH;
22023
+ const moduleScope = sanitiseConventionModule(args.module);
22024
+ tracker.mark("write");
22025
+ const created = await adapter2.createConvention({ rule, ...moduleScope ? { module: moduleScope } : {} });
22026
+ const scopeNote = moduleScope ? `It rides every build in the **${moduleScope}** module.` : "It rides every build on this project.";
22027
+ const truncationNote = wasTruncated ? `
22028
+
22029
+ \u26A0\uFE0F The rule was longer than ${MAX_CONVENTION_RULE_LENGTH} characters and was shortened to fit. Re-declare it shorter if the trimmed version reads wrong.` : "";
22030
+ return textResponse(
22031
+ `Convention recorded. ${scopeNote}
22032
+
22033
+ > ${rule}
22034
+
22035
+ Id: \`${created.id ?? "(unknown)"}\` \u2014 remove it with \`convention_remove\`.${truncationNote}`
22036
+ );
22037
+ } catch (err) {
22038
+ return errorResponse(formatStructuredError({
22039
+ tool: "convention_declare",
22040
+ mode: "declare",
22041
+ adapter: config2.adapterType,
22042
+ lastStep: tracker.lastStep,
22043
+ error: err instanceof Error ? err.message : String(err),
22044
+ hint: defaultHint("convention_declare")
22045
+ }));
22046
+ }
22047
+ }
22048
+ async function handleConventionList(adapter2, config2, args) {
22049
+ const tracker = new ProgressTracker("read");
22050
+ try {
22051
+ if (!adapter2.listConventions) return errorResponse(UNSUPPORTED);
22052
+ const all = await adapter2.listConventions();
22053
+ const moduleFilter = sanitiseConventionModule(args.module);
22054
+ const rows = moduleFilter ? selectConventionsForModule(all, moduleFilter) : all;
22055
+ if (rows.length === 0) {
22056
+ return textResponse(
22057
+ moduleFilter ? `No conventions apply to the **${moduleFilter}** module yet. Declare one with \`convention_declare\` and every future build in it will carry the rule.` : "No conventions declared yet. When you settle a question you do not want to answer again, record it with `convention_declare` and every future build will carry it."
22058
+ );
22059
+ }
22060
+ const lines = rows.map((c) => {
22061
+ const scope = c.module ? `**${c.module}**` : "every build";
22062
+ return `- ${scope} \u2014 ${sanitiseConventionRule(c.rule) ?? c.rule}
22063
+ \`${c.id ?? "(no id)"}\``;
22064
+ });
22065
+ const header = moduleFilter ? `${rows.length} convention${rows.length > 1 ? "s" : ""} apply to a build in **${moduleFilter}** (project-wide rules included):` : `${rows.length} convention${rows.length > 1 ? "s" : ""} declared on this project:`;
22066
+ return textResponse(`${header}
22067
+
22068
+ ${lines.join("\n")}`);
22069
+ } catch (err) {
22070
+ return errorResponse(formatStructuredError({
22071
+ tool: "convention_list",
22072
+ mode: "list",
22073
+ adapter: config2.adapterType,
22074
+ lastStep: tracker.lastStep,
22075
+ error: err instanceof Error ? err.message : String(err),
22076
+ hint: defaultHint("convention_list")
22077
+ }));
22078
+ }
22079
+ }
22080
+ async function handleConventionRemove(adapter2, config2, args) {
22081
+ const tracker = new ProgressTracker("validate");
22082
+ try {
22083
+ if (!adapter2.deleteConvention) return errorResponse(UNSUPPORTED);
22084
+ const id = typeof args.id === "string" ? args.id.trim() : "";
22085
+ if (!id) {
22086
+ return errorResponse("Pass the `id` of the convention to remove. `convention_list` prints the id under each rule.");
22087
+ }
22088
+ tracker.mark("delete");
22089
+ const removed = await adapter2.deleteConvention(id);
22090
+ if (!removed) {
22091
+ return errorResponse(
22092
+ `No convention with id \`${id}\` on this project. Run \`convention_list\` to see what is declared.`
22093
+ );
22094
+ }
22095
+ return textResponse(`Convention removed. Future builds no longer carry it.`);
22096
+ } catch (err) {
22097
+ return errorResponse(formatStructuredError({
22098
+ tool: "convention_remove",
22099
+ mode: "remove",
22100
+ adapter: config2.adapterType,
22101
+ lastStep: tracker.lastStep,
22102
+ error: err instanceof Error ? err.message : String(err),
22103
+ hint: defaultHint("convention_remove")
22104
+ }));
22105
+ }
22106
+ }
22107
+
21606
22108
  // src/tools/module-instructions.ts
21607
22109
  var OWNER_NAME = process.env["PAPI_OWNER"] ?? "cathalos92";
21608
22110
  var MODULE_INSTRUCTIONS = {
@@ -21681,6 +22183,194 @@ function getModuleInstructions(module) {
21681
22183
 
21682
22184
  ${instructions}`;
21683
22185
  }
22186
+ function getBuilderInstructions(module, conventions) {
22187
+ const conventionsSection = formatConventionsSection(conventions, module);
22188
+ const hasModuleScopedOverride = Boolean(
22189
+ module && conventions.some((c) => c.module === module)
22190
+ );
22191
+ const builtIn = hasModuleScopedOverride ? "" : getModuleInstructions(module);
22192
+ return builtIn + conventionsSection;
22193
+ }
22194
+
22195
+ // src/services/build-proposal.ts
22196
+ var MAX_PROPOSAL_TITLE = 200;
22197
+ var MAX_PROPOSAL_BODY = 2e3;
22198
+ function validateProposal(raw) {
22199
+ if (typeof raw !== "object" || raw === null) {
22200
+ return { error: "proposal must be an object." };
22201
+ }
22202
+ const p = raw;
22203
+ if (typeof p.title === "string" && p.title.trim().length > MAX_PROPOSAL_TITLE) {
22204
+ return { error: `proposal.title exceeds ${MAX_PROPOSAL_TITLE} characters.` };
22205
+ }
22206
+ if (typeof p.body === "string" && p.body.trim().length > MAX_PROPOSAL_BODY) {
22207
+ return { error: `proposal.body exceeds ${MAX_PROPOSAL_BODY} characters.` };
22208
+ }
22209
+ const title = sanitiseConventionRule(p.title) ?? "";
22210
+ if (!title) return { error: "proposal.title is required \u2014 one line stating the stance or rule." };
22211
+ const body = sanitiseConventionRule(p.body) ?? "";
22212
+ if (!body) return { error: "proposal.body is required \u2014 what was decided or settled, and why." };
22213
+ const t = p.tests;
22214
+ if (typeof t !== "object" || t === null) {
22215
+ return { error: "proposal.tests is required \u2014 apply the four admission tests and report each as a boolean." };
22216
+ }
22217
+ const tt = t;
22218
+ const keys = [
22219
+ "alternativesWereReal",
22220
+ "constrainsFutureWork",
22221
+ "arguableToday",
22222
+ "reversalCostsMore"
22223
+ ];
22224
+ for (const k of keys) {
22225
+ if (typeof tt[k] !== "boolean") {
22226
+ return { error: `proposal.tests.${k} must be true or false. All four admission tests must be answered explicitly \u2014 an omitted test is not a failed test.` };
22227
+ }
22228
+ }
22229
+ const moduleScope = sanitiseConventionModule(p.module);
22230
+ return {
22231
+ proposal: {
22232
+ title,
22233
+ body,
22234
+ ...moduleScope ? { module: moduleScope } : {},
22235
+ tests: {
22236
+ alternativesWereReal: tt.alternativesWereReal,
22237
+ constrainsFutureWork: tt.constrainsFutureWork,
22238
+ arguableToday: tt.arguableToday,
22239
+ reversalCostsMore: tt.reversalCostsMore
22240
+ }
22241
+ }
22242
+ };
22243
+ }
22244
+ function routeProposal(proposal) {
22245
+ const { alternativesWereReal, constrainsFutureWork, arguableToday, reversalCostsMore } = proposal.tests;
22246
+ if (!constrainsFutureWork) {
22247
+ return {
22248
+ outcome: "rejected",
22249
+ failedTest: "b",
22250
+ reason: "It does not constrain future work, so it is not durable \u2014 it is just work. Capture it as a task, a build report note, or a doc."
22251
+ };
22252
+ }
22253
+ if (!alternativesWereReal) {
22254
+ return {
22255
+ outcome: "rejected",
22256
+ failedTest: "a",
22257
+ reason: "There were no real alternatives, so no stance was taken. If there was only ever one way to do it, it is not a decision. Capture it as a task, a note, or a doc."
22258
+ };
22259
+ }
22260
+ if (!reversalCostsMore) {
22261
+ return {
22262
+ outcome: "rejected",
22263
+ failedTest: "d",
22264
+ reason: "Reversing it is as cheap as doing it, so nothing is being constrained. Capture it as a task, a note, or a doc."
22265
+ };
22266
+ }
22267
+ if (!arguableToday) {
22268
+ return {
22269
+ outcome: "recorded-convention",
22270
+ failedTest: "c",
22271
+ reason: "Real alternatives, binds future work, expensive to reverse, but nobody is arguing the other side any more. That is a Convention, not a Decision \u2014 it does not need adjudicating, it needs to be known by whoever builds next."
22272
+ };
22273
+ }
22274
+ return {
22275
+ outcome: "queued-decision",
22276
+ reason: "All four admission tests pass, so this is a live stance. Queued for your decision \u2014 nothing has been minted."
22277
+ };
22278
+ }
22279
+ var PROPOSED_DECISION_REF = "proposed-decision";
22280
+ async function applyProposal(adapter2, routing, proposal, ctx) {
22281
+ try {
22282
+ if (routing.outcome === "rejected") return {};
22283
+ if (routing.outcome === "recorded-convention") {
22284
+ if (!adapter2.createConvention) {
22285
+ return { error: "This connection has nowhere to record a convention." };
22286
+ }
22287
+ const rule = `${proposal.title}. ${proposal.body}`.slice(0, 2e3);
22288
+ const convention = await adapter2.createConvention({
22289
+ rule,
22290
+ ...proposal.module ? { module: proposal.module } : {}
22291
+ });
22292
+ return { convention };
22293
+ }
22294
+ if (!adapter2.createTask || !adapter2.updateTask) {
22295
+ return { error: "This connection cannot queue a decision for confirmation." };
22296
+ }
22297
+ const provenance = ctx.sourceTaskId ? `Proposed while building ${ctx.sourceTaskId}.` : "Proposed during a build.";
22298
+ const task = await adapter2.createTask({
22299
+ displayId: "",
22300
+ title: `Decision proposed: ${proposal.title}`,
22301
+ status: "Backlog",
22302
+ priority: "P1 High",
22303
+ complexity: "XS",
22304
+ module: proposal.module || "Core",
22305
+ epic: "Platform",
22306
+ phase: "Unscoped",
22307
+ owner: "TBD",
22308
+ reviewed: false,
22309
+ createdCycle: ctx.cycleNumber,
22310
+ taskType: "task",
22311
+ notes: `${provenance} All four admission tests passed, so this is a live stance rather than a settled rule.
22312
+
22313
+ ${proposal.body}
22314
+
22315
+ Accept it by minting the decision with \`strategy_change\`, or dismiss it by cancelling this task. Nothing has been written as an Active Decision.`
22316
+ });
22317
+ await adapter2.updateTask(task.id, {
22318
+ status: "Blocked",
22319
+ blocker: {
22320
+ type: "decision-gate",
22321
+ ref: PROPOSED_DECISION_REF,
22322
+ reason: "A build proposed this decision. Awaiting the owner's call \u2014 it does not auto-clear, because only a person can rule on it.",
22323
+ blockedCycle: ctx.cycleNumber
22324
+ }
22325
+ });
22326
+ return { taskId: task.id };
22327
+ } catch (err) {
22328
+ return { error: err instanceof Error ? err.message : "Unknown error." };
22329
+ }
22330
+ }
22331
+ function formatProposalOutcome(routing, proposal, detail) {
22332
+ if (detail.error) {
22333
+ return `
22334
+
22335
+ ---
22336
+
22337
+ **Proposal not recorded.** ${detail.error}
22338
+
22339
+ The build itself is unaffected \u2014 only the proposal failed.`;
22340
+ }
22341
+ switch (routing.outcome) {
22342
+ case "queued-decision":
22343
+ return `
22344
+
22345
+ ---
22346
+
22347
+ **Decision proposed \u2014 waiting on you.**
22348
+
22349
+ > ${proposal.title}
22350
+
22351
+ ${routing.reason}` + (detail.taskId ? ` It is parked as ${detail.taskId}, blocked on your call, and shows up in \`orient\` as a decision waiting on you. Nothing was written as an Active Decision.` : " Nothing was written as an Active Decision.");
22352
+ case "recorded-convention":
22353
+ return `
22354
+
22355
+ ---
22356
+
22357
+ **Recorded as a convention.**
22358
+
22359
+ > ${proposal.title}
22360
+
22361
+ ${routing.reason}` + (proposal.module ? ` It rides every future build in the **${proposal.module}** module.` : " It rides every future build on this project.") + (detail.convention?.id ? ` Remove it with \`convention_remove\` (id \`${detail.convention.id}\`) if that is not what you wanted.` : "");
22362
+ case "rejected":
22363
+ return `
22364
+
22365
+ ---
22366
+
22367
+ **Proposal not minted, and here is why.**
22368
+
22369
+ > ${proposal.title}
22370
+
22371
+ Failed test (${routing.failedTest}): ${routing.reason}`;
22372
+ }
22373
+ }
21684
22374
 
21685
22375
  // src/tools/doc-registry.ts
21686
22376
  import { readdirSync as readdirSync6, existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync6, mkdirSync as mkdirSync4 } from "fs";
@@ -22516,7 +23206,7 @@ var buildDescribeTool = {
22516
23206
  };
22517
23207
  var buildExecuteTool = {
22518
23208
  name: "build_execute",
22519
- description: "Start or complete a build task. Call with just task_id to start (returns BUILD HANDOFF, creates feature branch, marks In Progress). After implementing the task, you MUST call build_execute again with all report fields (completed, effort, estimated_effort, surprises, discovered_issues, architecture_notes) to finish \u2014 do not wait for user confirmation between start and complete. Never call on tasks that are already In Review or Done. Does not call the Anthropic API. Set light=true to skip branch/PR creation (commits to current branch). Set PAPI_LIGHT_MODE=true in env to default all builds to light mode.",
23209
+ description: "Build one task with its spec, its branch and its record kept for you. Starting a build hands back that task's BUILD HANDOFF (scope, what is out of scope, acceptance criteria, the decisions that constrain it, and what recent builds in the same area already learned or ruled out), puts the work on the right branch, and marks the task In Progress so a second session can see it is taken. Completing it records what actually happened: effort against estimate, surprises, dead ends, and bugs found outside the scope. That record is what later cycles are sized and planned from, so nothing has to be remembered. Call with just task_id to start (returns BUILD HANDOFF, creates feature branch, marks In Progress). After implementing the task, you MUST call build_execute again with all report fields (completed, effort, estimated_effort, surprises, discovered_issues, architecture_notes) to finish \u2014 do not wait for user confirmation between start and complete. Never call on tasks that are already In Review or Done. Does not call the Anthropic API. Set light=true to skip branch/PR creation (commits to current branch). Set PAPI_LIGHT_MODE=true in env to default all builds to light mode.",
22520
23210
  annotations: { title: "Run Build", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
22521
23211
  inputSchema: {
22522
23212
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -22555,6 +23245,27 @@ var buildExecuteTool = {
22555
23245
  },
22556
23246
  required: ["answer"]
22557
23247
  },
23248
+ proposal: {
23249
+ type: "object",
23250
+ description: 'task-3273, OPTIONAL: propose a Decision or a Convention you settled while building this. PAPI never mints one for you \u2014 a proposal that passes all four admission tests is QUEUED for the owner behind a decision gate, and nothing is written as an Active Decision. YOU apply the four tests (they are stated in the planning prompts); the server routes on your answers and makes no judgement about the content. Fails "arguable today" only and it is recorded as a Convention instead, riding every future build. Fails "constrains future work" and it is returned to you with the reason, unrecorded.',
23251
+ properties: {
23252
+ title: { type: "string", description: "One line stating the stance or rule." },
23253
+ body: { type: "string", description: "What was decided or settled, and why." },
23254
+ module: { type: "string", description: "Optional module scope. Only used when this lands as a Convention." },
23255
+ tests: {
23256
+ type: "object",
23257
+ description: "The four admission tests, each answered explicitly. An omitted test is NOT a failed test \u2014 the proposal is refused rather than routed.",
23258
+ properties: {
23259
+ alternativesWereReal: { type: "boolean", description: "(a) Something else could genuinely have been chosen." },
23260
+ constrainsFutureWork: { type: "boolean", description: "(b) It changes what a task nobody has written yet will do." },
23261
+ arguableToday: { type: "boolean", description: "(c) A competent person could argue the other side right now." },
23262
+ reversalCostsMore: { type: "boolean", description: "(d) Reversing it costs more than making it did." }
23263
+ },
23264
+ required: ["alternativesWereReal", "constrainsFutureWork", "arguableToday", "reversalCostsMore"]
23265
+ }
23266
+ },
23267
+ required: ["title", "body", "tests"]
23268
+ },
22558
23269
  completed: {
22559
23270
  type: "string",
22560
23271
  enum: ["yes", "no", "partial"],
@@ -23049,7 +23760,12 @@ These approaches were tried in this module and failed. If one looks right, read
23049
23760
  }
23050
23761
  } catch {
23051
23762
  }
23052
- const moduleInstructions = getModuleInstructions(result.task.module);
23763
+ let conventions = [];
23764
+ try {
23765
+ if (adapter2.listConventions) conventions = await adapter2.listConventions();
23766
+ } catch {
23767
+ }
23768
+ const moduleInstructions = getBuilderInstructions(result.task.module, conventions);
23053
23769
  const moduleContext = await getModuleContext(adapter2, result.task);
23054
23770
  const filesToWriteSection = result.filesToWrite ? formatFilesToWriteSection(result.filesToWrite) : "";
23055
23771
  const modelNote = buildModelRecommendationDirective(
@@ -23329,7 +24045,27 @@ If any are now fixed, re-run complete with their UUIDs in \`fixed_issues\` \u201
23329
24045
  fixedNote = "\n\n\u2139\uFE0F This build filed discovered issues but passed no `fixed_issues`. When a future build fixes one, pass its UUID in `fixed_issues` so it counts as FIXED (not just auto-cleared) on the hub ledger.";
23330
24046
  }
23331
24047
  }
23332
- return textResponse(formatCompleteResult(result) + fixedNote + docsNote + batchRollupNote);
24048
+ let proposalNote = "";
24049
+ if (args.proposal !== void 0) {
24050
+ const validated = validateProposal(args.proposal);
24051
+ if ("error" in validated) {
24052
+ proposalNote = `
24053
+
24054
+ ---
24055
+
24056
+ **Proposal not recorded.** ${validated.error}
24057
+
24058
+ The build itself is unaffected \u2014 only the proposal failed.`;
24059
+ } else {
24060
+ const routing = routeProposal(validated.proposal);
24061
+ const applied = await applyProposal(adapter2, routing, validated.proposal, {
24062
+ cycleNumber: result.cycleNumber,
24063
+ sourceTaskId: result.task?.id
24064
+ });
24065
+ proposalNote = formatProposalOutcome(routing, validated.proposal, applied);
24066
+ }
24067
+ }
24068
+ return textResponse(formatCompleteResult(result) + fixedNote + docsNote + batchRollupNote + proposalNote);
23333
24069
  } catch (err) {
23334
24070
  const message = err instanceof Error ? err.message : String(err);
23335
24071
  if (isBuildPushError(err)) {
@@ -24519,6 +25255,27 @@ var adHocTool = {
24519
25255
  inputSchema: {
24520
25256
  type: "object",
24521
25257
  properties: {
25258
+ proposal: {
25259
+ type: "object",
25260
+ description: "task-3273, OPTIONAL: propose a Decision or a Convention you settled while doing this work. PAPI never mints one for you \u2014 a proposal passing all four admission tests is QUEUED for the owner behind a decision gate, and nothing is written as an Active Decision. YOU apply the four tests; the server routes on your answers and makes no judgement about the content.",
25261
+ properties: {
25262
+ title: { type: "string", description: "One line stating the stance or rule." },
25263
+ body: { type: "string", description: "What was decided or settled, and why." },
25264
+ module: { type: "string", description: "Optional module scope. Only used when this lands as a Convention." },
25265
+ tests: {
25266
+ type: "object",
25267
+ description: "The four admission tests, each answered explicitly. An omitted test is NOT a failed test.",
25268
+ properties: {
25269
+ alternativesWereReal: { type: "boolean" },
25270
+ constrainsFutureWork: { type: "boolean" },
25271
+ arguableToday: { type: "boolean" },
25272
+ reversalCostsMore: { type: "boolean" }
25273
+ },
25274
+ required: ["alternativesWereReal", "constrainsFutureWork", "arguableToday", "reversalCostsMore"]
25275
+ }
25276
+ },
25277
+ required: ["title", "body", "tests"]
25278
+ },
24522
25279
  title: {
24523
25280
  type: "string",
24524
25281
  description: "What was done \u2014 becomes the task title (required when creating new task, optional when completing existing via task_id)."
@@ -24578,6 +25335,22 @@ var adHocTool = {
24578
25335
  required: []
24579
25336
  }
24580
25337
  };
25338
+ async function recordAdHocProposal(adapter2, args, ctx) {
25339
+ if (args.proposal === void 0) return "";
25340
+ const validated = validateProposal(args.proposal);
25341
+ if ("error" in validated) {
25342
+ return `
25343
+
25344
+ ---
25345
+
25346
+ **Proposal not recorded.** ${validated.error}
25347
+
25348
+ The ad-hoc record itself is unaffected \u2014 only the proposal failed.`;
25349
+ }
25350
+ const routing = routeProposal(validated.proposal);
25351
+ const applied = await applyProposal(adapter2, routing, validated.proposal, ctx);
25352
+ return formatProposalOutcome(routing, validated.proposal, applied);
25353
+ }
24581
25354
  async function handleAdHoc(adapter2, config2, args) {
24582
25355
  const taskId = args.task_id?.trim();
24583
25356
  const title = args.title?.trim();
@@ -24693,12 +25466,12 @@ The task is recorded **In Review** and pinned to **Cycle ${result.task.cycle}**,
24693
25466
  \`\`\`
24694
25467
  2. Leave the branch **unmerged** \u2014 it is picked up by the next cycle's \`release\`.
24695
25468
 
24696
- _To correct: board_edit ${result.task.id} with updated fields._`
25469
+ _To correct: board_edit ${result.task.id} with updated fields._` + await recordAdHocProposal(adapter2, args, { cycleNumber: result.task.cycle ?? 0, sourceTaskId: result.task.id })
24697
25470
  );
24698
25471
  }
24699
25472
  return textResponse(
24700
25473
  `**${result.task.id}:** "${result.task.title}" recorded (${effortRaw}, ${priorityRaw}, ${typeLabel}, ${taskModule})${overrideNote}.${truncateWarning}${promoNote} Build report attached.
24701
- _To correct: board_edit ${result.task.id} with updated fields._`
25474
+ _To correct: board_edit ${result.task.id} with updated fields._` + await recordAdHocProposal(adapter2, args, { cycleNumber: result.task.cycle ?? 0, sourceTaskId: result.task.id })
24702
25475
  );
24703
25476
  }
24704
25477
 
@@ -25533,11 +26306,13 @@ async function assembleReviewContext(adapter2, config2, taskId) {
25533
26306
  ${JSON.stringify(task.buildHandoff, null, 2)}` : "### BUILD HANDOFF\n(none recorded)";
25534
26307
  const report = task.buildReport ? `### Build Report
25535
26308
  ${task.buildReport}` : "### Build Report\n(none recorded)";
25536
- const diff = getBranchDiff(config2.projectRoot);
25537
- const diffBlock = diff ? `### Branch diff vs base
26309
+ const { diff, scope, detail } = getTaskDiff(config2.projectRoot, taskId);
26310
+ const diffHeading = scope === "task-commits" ? `### Diff for ${taskId} (${detail})` : `### \u26A0\uFE0F Diff NOT scoped to ${taskId} (${detail})`;
26311
+ const diffBlock = diff ? `${diffHeading}
25538
26312
  \`\`\`diff
25539
26313
  ${diff}
25540
- \`\`\`` : "### Branch diff vs base\n(no diff resolved \u2014 not a git repo, no base ref, or no committed changes)";
26314
+ \`\`\`` : `### Diff for ${taskId}
26315
+ (no diff resolved \u2014 not a git repo, no base ref, or no committed changes)`;
25541
26316
  let projectContext = "";
25542
26317
  const ctxPath = join15(config2.projectRoot, ".agents", "papi-context.md");
25543
26318
  if (existsSync10(ctxPath)) {
@@ -27979,6 +28754,33 @@ function formatOrientSummary(health, buildInfo, hierarchy, latestTag, projectRoo
27979
28754
  }
27980
28755
  return lines.join("\n").trimEnd();
27981
28756
  }
28757
+ var NOT_SET_UP_MESSAGE = "Setup required \u2014 this project has no PAPI state yet. That is the normal starting point, not an error. Run `setup` to generate your Product Brief and scaffold the workflow, then `plan` to create your first cycle.";
28758
+ function emptyHealthSummary() {
28759
+ return {
28760
+ cycleNumber: 0,
28761
+ // 'degraded' is exactly what happened: the adapter answered some reads and
28762
+ // not others. 'offline' would overstate it and 'connected' would hide it.
28763
+ connectionStatus: "degraded",
28764
+ reviewWarning: "",
28765
+ zoomOutWarning: "",
28766
+ boardSummary: "",
28767
+ staleTasks: "",
28768
+ inReviewSummary: "",
28769
+ carryForward: "",
28770
+ recommendedMode: "",
28771
+ metricsSection: "",
28772
+ derivedMetricsSection: "",
28773
+ costSection: "",
28774
+ decisionUsageSection: "",
28775
+ decisionLifecycleSection: "",
28776
+ decisionScoresSection: "",
28777
+ contextUtilisationSection: "",
28778
+ northStarSection: "",
28779
+ healthScore: null,
28780
+ healthStatus: null,
28781
+ healthReason: null
28782
+ };
28783
+ }
27982
28784
  async function getHierarchyPosition(adapter2, projectId) {
27983
28785
  try {
27984
28786
  const [horizons, stages, phases, allTasks] = await Promise.all([
@@ -28178,11 +28980,18 @@ async function handleOrient(adapter2, config2, args = {}, clientName) {
28178
28980
  } catch {
28179
28981
  }
28180
28982
  tracker.mark("fetch-build-health-hierarchy");
28181
- const [buildResult, healthResult, hierarchy] = await Promise.all([
28983
+ const [buildSettled, healthSettled, hierarchySettled] = await Promise.allSettled([
28182
28984
  tracked("listBuilds", () => listBuilds(adapter2, config2))(),
28183
28985
  tracked("getHealthSummary", () => getHealthSummary(adapter2))(),
28184
28986
  tracked("getHierarchyPosition", () => getHierarchyPosition(adapter2, config2.projectId))()
28185
28987
  ]);
28988
+ if (buildSettled.status === "rejected" && healthSettled.status === "rejected") {
28989
+ throw new Error(NOT_SET_UP_MESSAGE);
28990
+ }
28991
+ if (buildSettled.status === "rejected") throw buildSettled.reason;
28992
+ const buildResult = buildSettled.value;
28993
+ const healthResult = healthSettled.status === "fulfilled" ? healthSettled.value : emptyHealthSummary();
28994
+ const hierarchy = hierarchySettled.status === "fulfilled" ? hierarchySettled.value : void 0;
28186
28995
  const currentCycle = buildResult.currentCycle;
28187
28996
  const cycleIsComplete = healthResult.latestCycleStatus === "complete";
28188
28997
  const allTasks = buildResult.sorted;
@@ -31087,7 +31896,10 @@ var PAPI_TOOLS = [
31087
31896
  taskClaimTool,
31088
31897
  taskUnclaimTool,
31089
31898
  taskMoveTool,
31090
- inventorySyncTool
31899
+ inventorySyncTool,
31900
+ conventionDeclareTool,
31901
+ conventionListTool,
31902
+ conventionRemoveTool
31091
31903
  ];
31092
31904
  function getToolMetadata() {
31093
31905
  return PAPI_TOOLS.map((t) => ({ name: t.name, description: t.description }));
@@ -31292,6 +32104,12 @@ function createServer(adapter2, config2) {
31292
32104
  return handleTaskMove(adapter2, config2, safeArgs);
31293
32105
  case "task_unclaim":
31294
32106
  return handleTaskUnclaim(adapter2, config2, safeArgs);
32107
+ case "convention_declare":
32108
+ return handleConventionDeclare(adapter2, config2, safeArgs);
32109
+ case "convention_list":
32110
+ return handleConventionList(adapter2, config2, safeArgs);
32111
+ case "convention_remove":
32112
+ return handleConventionRemove(adapter2, config2, safeArgs);
31295
32113
  case "inventory_sync":
31296
32114
  return handleInventorySync(adapter2, config2, safeArgs, getToolMetadata());
31297
32115
  default: