cawdev-cli 1.0.11-beta → 1.1.1

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/runner/runner.mjs CHANGED
@@ -17,17 +17,19 @@ import {
17
17
  access, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile,
18
18
  } from 'node:fs/promises';
19
19
  import { homedir, tmpdir } from 'node:os';
20
-
20
+ import { fileURLToPath } from 'node:url';
21
21
  import { join, resolve } from 'node:path';
22
22
  import { spawnFailureSaid, workspaceProblem, workspaceProblemSaid } from './workspace-check.mjs';
23
23
  import { changedLines } from './changed-lines.mjs';
24
- import { adapterFor, hostFor, repoOf } from './git-host.mjs';
24
+ import { adapterFor, configureBitbucket, hostFor, repoOf } from './git-host.mjs';
25
25
  import { describeTurn, totalsOf } from '../lib/usage.mjs';
26
26
  import { withinCeiling } from '../lib/tool-rules.mjs';
27
27
  import { serveControl } from './control.mjs';
28
28
  import { painter } from '../lib/ansi.mjs';
29
29
  import { bannerLines, tintLog } from './banner.mjs';
30
30
  import { codeMapOf } from '../lib/code-map.mjs';
31
+ import { sonarqubeLaunch } from '../lib/sonarqube-mcp.mjs';
32
+ import { parseProperties, sonarAsker, waitForAnalysis } from '../lib/sonar-analysis.mjs';
31
33
  import { usageLimitOf } from '../lib/usage-limit.mjs';
32
34
  import { TranscriptBatch } from '../lib/transcript-batch.mjs';
33
35
  import { parseUsage } from '../lib/usage-report.mjs';
@@ -37,24 +39,30 @@ import {
37
39
  import { AI_CONFIG, harnessPrompt, readRepoConfig } from '../lib/harness-prompt.mjs';
38
40
  import { loadToken, storedUrls } from './token-store.mjs';
39
41
  import {
40
- CARD_WRITE, CAWDEV_READS, GIT_READS, beforeTheWorkExists, driftedFrom, readOnlyExpert,
42
+ CARD_WRITE, CAWDEV_READS, GIT_READS, SUGGEST, beforeTheWorkExists, driftedFrom, readOnlyExpert,
41
43
  toolsForStage,
42
44
  } from '../lib/stage-tools.mjs';
43
45
  import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
44
46
  import { findSecret } from '../lib/secrets.mjs';
45
47
  import { BackgroundShells } from '../lib/background-shells.mjs';
46
48
  import { cliVersion, isBelow } from '../lib/version.mjs';
47
- import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
49
+ import { reloadableFor, restartNeededFor, runnerNameOf } from '../lib/runner-config.mjs';
48
50
  import {
49
51
  createAutoWorkspace, discoverAutoWorkspaces, isAutoWorkspace,
50
52
  } from './auto-workspaces.mjs';
51
53
  import { agentOfCommand, needsEverything } from '../lib/agents.mjs';
54
+ import { noteKey, pausedNote, pauseLogLine, reportedPauseNote } from './pause-note.mjs';
52
55
  import { cawdevToolNames, codexArgs, translateCodexEvent } from '../lib/codex.mjs';
53
56
  import {
54
57
  drivingPath, isAlive, killedByRestart, leftBehind, memoryMaxOf, probeScope, readDriving,
55
58
  restartSummary, scoped, snapshotOf, writeDriving,
56
59
  } from './restart.mjs';
57
- import { COMMITS_ITS_WORK, settleBranch } from '../lib/finish-check.mjs';
60
+ import {
61
+ COMMITS_ITS_WORK, heldProtected, protectedHeld, settleBranch,
62
+ } from '../lib/finish-check.mjs';
63
+ import {
64
+ denyRules, notAllowed, protectedBase, protectedChanges, protectedLine, protectedPolicy,
65
+ } from '../lib/protected.mjs';
58
66
 
59
67
  // --- configuration -----------------------------------------------------------
60
68
 
@@ -189,6 +197,7 @@ const DEFAULTS = {
189
197
  'mcp__cawdev__quality_get',
190
198
  'mcp__cawdev__roadmap_statuses',
191
199
  'mcp__cawdev__roadmap_list',
200
+ 'mcp__cawdev__sprint_list',
192
201
  'mcp__cawdev__roadmap_get',
193
202
  // The MCP server has served this since R37, its README documents it, and
194
203
  // the REVIEW prompt below TELLS a session to file its findings with it —
@@ -406,7 +415,7 @@ async function readConfig() {
406
415
  * credential does not belong in one.
407
416
  */
408
417
  token: process.env.CAWDEV_TOKEN ?? file.token ?? (await loadToken(url)),
409
- name: process.env.CAWDEV_RUNNER_NAME ?? file.name ?? DEFAULTS.name,
418
+ name: runnerNameOf(file),
410
419
  agentCommands: file.agentCommands ?? (file.agentCommand ? [file.agentCommand] : null) ?? (process.env.CAWDEV_AGENT_COMMAND ? process.env.CAWDEV_AGENT_COMMAND.split(',').map(s => s.trim()) : null) ?? DEFAULTS.agentCommands,
411
420
  // Which projects this runner serves, and where their working copies are.
412
421
  projects: normaliseProjects(file.projects ?? {}),
@@ -437,6 +446,21 @@ async function readConfig() {
437
446
  * none. Raw here; `main` probes whether this machine can apply it.
438
447
  */
439
448
  sessionMemoryMax: file.sessionMemoryMax ?? null,
449
+ /**
450
+ * R565. Bitbucket Cloud has no CLI, so this machine calls its API with a
451
+ * token it keeps. `tokenEnv` names the variable the token is read from
452
+ * (never the file: a config sits beside code); `email` is the Atlassian
453
+ * account for an API token, absent for a workspace or repository access
454
+ * token; `apiUrl` is for tests. The variable is removed from every agent's
455
+ * environment — see `agentEnv`.
456
+ *
457
+ * "bitbucket": { "email": "you@example.com", "tokenEnv": "BITBUCKET_TOKEN" }
458
+ */
459
+ bitbucket: {
460
+ email: file.bitbucket?.email ?? null,
461
+ tokenEnv: file.bitbucket?.tokenEnv ?? 'BITBUCKET_TOKEN',
462
+ apiUrl: file.bitbucket?.apiUrl ?? null,
463
+ },
440
464
  skillCache: file.skillCache ?? DEFAULTS.skillCache,
441
465
  skillPrepareSeconds: file.skillPrepareSeconds ?? DEFAULTS.skillPrepareSeconds,
442
466
  idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
@@ -505,6 +529,10 @@ async function readConfig() {
505
529
  * "cawdev": { "workspaces": ["/…/ws-1", "/…/ws-2", "/…/ws-3"] }
506
530
  * "cawdev": { "workspaces": [...], "quality": { "command": "sonar-scanner", "tokenEnv": "SONAR_TOKEN" } }
507
531
  *
532
+ * (R565: a machine with a Bitbucket project also has a top-level
533
+ * `"bitbucket": { "email": "…", "tokenEnv": "BITBUCKET_TOKEN" }` block, beside
534
+ * `projects` and not inside one — see `readConfig`.)
535
+ *
508
536
  * R300: the fourth form adds a `quality` block — see `qualityBlockOf` — and a
509
537
  * project with one has its branches scanned after a coding run's TEST stage.
510
538
  * Captured here at boot, not hot-swapped: adding or changing the block needs
@@ -647,6 +675,16 @@ function qualityBlockOf(slug, block) {
647
675
  * what most skills will want.
648
676
  */
649
677
  const SKILLS_HERE = {
678
+ /**
679
+ * R319. A Java process whose jar, token and server URL are all this
680
+ * machine's: see `sonarqubeLaunch`. `when` is the sentence the prompt gives
681
+ * the session about when to reach for it.
682
+ */
683
+ sonarqube: {
684
+ launch: 'sonarqube',
685
+ when: 'Before drafting a fix for a SonarQube finding, read the rule with `show_rule` '
686
+ + '(and the issue or hotspot it came from) instead of guessing from its one-line message.',
687
+ },
650
688
  codegraph: {
651
689
  /**
652
690
  * The npm package is a thin shim: the real artifact is a per-platform
@@ -744,7 +782,10 @@ async function api(config, path, { method = 'GET', body, token } = {}) {
744
782
  const text = await response.text();
745
783
  const parsed = text ? safeJson(text) : null;
746
784
  if (!response.ok) {
747
- throw new Error(parsed?.message ?? `${method} ${path} -> ${response.status}`);
785
+ const error = new Error(parsed?.message ?? `${method} ${path} -> ${response.status}`);
786
+ // i267: the caller can tell a refusal (4xx) from a platform that is down.
787
+ error.status = response.status;
788
+ throw error;
748
789
  }
749
790
  return parsed;
750
791
  }
@@ -1197,7 +1238,7 @@ async function prepareSkillIndex(config, skill, local, forProject, cwd, baseComm
1197
1238
  try {
1198
1239
  const built = await runSkillCommand(skill.command, buildArgsFor(skill, local), {
1199
1240
  cwd,
1200
- env: { ...process.env, ...(local.env ?? {}) },
1241
+ env: agentEnv(config, local.env ?? {}),
1201
1242
  timeoutMs: config.skillPrepareSeconds * 1000,
1202
1243
  });
1203
1244
 
@@ -1409,7 +1450,7 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
1409
1450
  const attached = [];
1410
1451
  const notes = [];
1411
1452
 
1412
- for (const skill of asked) {
1453
+ for (let skill of asked) {
1413
1454
  if (!skill?.key || !skill?.command || !Array.isArray(skill.args)) {
1414
1455
  notes.push(`A skill arrived that this daemon cannot read (${JSON.stringify(skill)}). `
1415
1456
  + 'It has not been attached.');
@@ -1424,7 +1465,21 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
1424
1465
  + 'should look at the skill row.');
1425
1466
  }
1426
1467
 
1427
- const local = SKILLS_HERE[skill.key] ?? {};
1468
+ let local = SKILLS_HERE[skill.key] ?? {};
1469
+ if (local.launch === 'sonarqube') {
1470
+ const storagePath = skillCacheFor(config, skill, run.projectSlug);
1471
+ await mkdir(storagePath, { recursive: true }).catch(() => { });
1472
+ const launch = sonarqubeLaunch({
1473
+ skill, quality: config.projects?.[run.projectSlug]?.quality, sonarUrl: run.sonarUrl,
1474
+ env: process.env, storagePath,
1475
+ });
1476
+ if (launch.skip) {
1477
+ notes.push(launch.skip);
1478
+ continue;
1479
+ }
1480
+ skill = { ...skill, args: launch.args };
1481
+ local = { ...local, env: launch.env };
1482
+ }
1428
1483
  if (local.indexDir) {
1429
1484
  notes.push(await prepareSkillIndex(config, skill, local, run, cwd, baseCommit));
1430
1485
  }
@@ -1856,6 +1911,38 @@ async function prepareMerge(cwd, branch, defaultBranch) {
1856
1911
  };
1857
1912
  }
1858
1913
 
1914
+ /**
1915
+ * Each live run's protected-file guard — R562: `{ globs, mode, allowed, base }`.
1916
+ *
1917
+ * Fixed once, after the working copy is prepared, and read at every door a run
1918
+ * can finish through. A map rather than one more parameter on a dozen
1919
+ * signatures; entries are dropped when the run's last word is said.
1920
+ */
1921
+ const protectedGuards = new Map();
1922
+
1923
+ /** The guard `settleBranch` and the merge push judge by, or null when nothing is protected. */
1924
+ async function guardOf(config, run) {
1925
+ const guard = protectedGuards.get(run.id);
1926
+ if (!guard?.globs.length || !guard.base) {
1927
+ return null;
1928
+ }
1929
+ // The claim's `allowed` is a snapshot; a person may have allowed a change
1930
+ // since. Read the run's own ALLOWED protected approvals fresh, or an ASK-mode
1931
+ // run that was allowed would still fail here.
1932
+ const allowed = new Set(guard.allowed ?? []);
1933
+ try {
1934
+ const asked = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/approvals`);
1935
+ for (const approval of asked ?? []) {
1936
+ if (approval?.state === 'ALLOWED' && Array.isArray(approval.protectedPaths)) {
1937
+ approval.protectedPaths.forEach((path) => allowed.add(path));
1938
+ }
1939
+ }
1940
+ } catch {
1941
+ // The claim's list stands.
1942
+ }
1943
+ return { ...guard, allowed: [...allowed] };
1944
+ }
1945
+
1859
1946
  /**
1860
1947
  * What the session left behind — R155, and it is judged strictly.
1861
1948
  *
@@ -1865,7 +1952,7 @@ async function prepareMerge(cwd, branch, defaultBranch) {
1865
1952
  * knowable; reporting it as success would land conflict markers, which is the
1866
1953
  * one way this feature could do real damage.
1867
1954
  */
1868
- async function finishMerge(cwd, branch) {
1955
+ async function finishMerge(cwd, branch, guard = null) {
1869
1956
  const stillConflicted = await git(cwd, ['diff', '--name-only', '--diff-filter=U'])
1870
1957
  .then((said) => said.split('\n').map((line) => line.trim()).filter(Boolean))
1871
1958
  .catch(() => []);
@@ -1886,6 +1973,16 @@ async function finishMerge(cwd, branch) {
1886
1973
  .catch((failure) => log(` could not commit the resolution: ${failure.message.split('\n')[0]}`));
1887
1974
  }
1888
1975
 
1976
+ // R562. The resolution is judged against the base the merge started from,
1977
+ // before anything is pushed. Allowances are the branch's, and a merge run is
1978
+ // on the same branch as the coding run that was allowed them.
1979
+ const held = await heldProtected(cwd, guard)
1980
+ .catch((failure) => [`(the branch could not be checked: ${failure.message.split('\n')[0]})`]);
1981
+ if (held.length) {
1982
+ log(` not pushed: ${protectedHeld(held)}`);
1983
+ return { pushed: false, mergeCommit: null, files: [], held };
1984
+ }
1985
+
1889
1986
  const pushed = await pushBranch(cwd, branch);
1890
1987
  if (!pushed.ok) {
1891
1988
  log(` could not push ${branch}: ${pushed.result.split('\n')[0]}`);
@@ -1911,7 +2008,7 @@ async function finishMerge(cwd, branch) {
1911
2008
  * arrives second; see `RunService.settleAgentMerge`.
1912
2009
  */
1913
2010
  async function finishTheMerge(config, run, cwd, branch, said) {
1914
- const left = await finishMerge(cwd, branch);
2011
+ const left = await finishMerge(cwd, branch, await guardOf(config, run));
1915
2012
  log(left.pushed
1916
2013
  ? ` ${branch} pushed at ${String(left.mergeCommit ?? '').slice(0, 7)}`
1917
2014
  : ` nothing was pushed: the conflict on ${branch} is still there`);
@@ -1934,7 +2031,9 @@ async function finishTheMerge(config, run, cwd, branch, said) {
1934
2031
  if (current?.live) {
1935
2032
  await finish(config, run, left.pushed ? 'FINISHED' : 'FAILED', left.pushed
1936
2033
  ? `Merged and pushed ${branch}.`
1937
- : `The conflict on ${branch} was not resolved; nothing was pushed.`);
2034
+ : left.held
2035
+ ? protectedHeld(left.held)
2036
+ : `The conflict on ${branch} was not resolved; nothing was pushed.`);
1938
2037
  }
1939
2038
  }
1940
2039
 
@@ -2703,41 +2802,6 @@ function trimSlash(url) {
2703
2802
  return url.replace(/\/$/, '');
2704
2803
  }
2705
2804
 
2706
- /** A GET against the SonarQube server a scan just reported to, authorised with the scanner's own token. */
2707
- function sonarAsker(serverUrl, token) {
2708
- return async (path) => {
2709
- const response = await fetch(`${serverUrl}${path}`, { headers: { authorization: `Bearer ${token}` } });
2710
- if (!response.ok) {
2711
- throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
2712
- }
2713
- return response.json();
2714
- };
2715
- }
2716
-
2717
- /**
2718
- * Waits for a queued analysis to settle — shared by every scan path, BRANCH,
2719
- * MAIN and PROJECT alike. Throws rather than reports: every caller runs
2720
- * inside `scanQuality`'s own try/catch, which turns it into an error reading.
2721
- */
2722
- async function waitForAnalysis(ask, ceTaskId, deadline, timeoutSeconds) {
2723
- let settled = null;
2724
- while (Date.now() < deadline) {
2725
- const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`);
2726
- if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
2727
- settled = current;
2728
- break;
2729
- }
2730
- await new Promise((wake) => setTimeout(wake, 3000));
2731
- }
2732
- if (!settled) {
2733
- throw new Error(`SonarQube had not finished analysing after ${timeoutSeconds}s.`);
2734
- }
2735
- if (settled.status !== 'SUCCESS') {
2736
- throw new Error(settled.errorMessage ?? `SonarQube ${settled.status.toLowerCase()} the analysis.`);
2737
- }
2738
- return settled;
2739
- }
2740
-
2741
2805
  /** The gate and the new-code measures, for a BRANCH or MAIN reading — never for PROJECT, which has neither. */
2742
2806
  async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report }) {
2743
2807
  const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
@@ -2877,18 +2941,6 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
2877
2941
  });
2878
2942
  }
2879
2943
 
2880
- /** `key=value` lines — the shape of the scanner's `report-task.txt`. */
2881
- function parseProperties(text) {
2882
- const parsed = {};
2883
- for (const line of text.split('\n')) {
2884
- const at = line.indexOf('=');
2885
- if (at > 0) {
2886
- parsed[line.slice(0, at).trim()] = line.slice(at + 1).trim();
2887
- }
2888
- }
2889
- return parsed;
2890
- }
2891
-
2892
2944
  /**
2893
2945
  * Reads and reports the code map, unless it would say the same thing again.
2894
2946
  *
@@ -3031,6 +3083,12 @@ async function firstRef(path, candidates) {
3031
3083
  * turn.
3032
3084
  */
3033
3085
  function briefLine(run) {
3086
+ // R562. Every prompt that names the brief names the protected files first, so
3087
+ // a session checks its plan against the list before it starts.
3088
+ return (run.protectedLine ? `${run.protectedLine}\n\n` : '') + briefIndexLine(run);
3089
+ }
3090
+
3091
+ function briefIndexLine(run) {
3034
3092
  const index = run.brief?.index ?? BRIEF.index;
3035
3093
  const text = run.brief?.indexText;
3036
3094
 
@@ -3097,6 +3155,9 @@ If a decision is genuinely theirs, use the cawdev MCP tool \`ask_user\` and wait
3097
3155
 
3098
3156
  ${run.branch ? `When what they asked is done, commit it on ${run.branch} — work left
3099
3157
  uncommitted is work nothing can push or propose. Never commit to or push main.
3158
+ You may commit on ${run.branch} and push it, and nothing else. Branches, pull
3159
+ requests and merges are opened from cawdev: there is no \`gh\` or \`glab\` in this
3160
+ session, and a push to any other branch is refused.
3100
3161
 
3101
3162
  ` : ''}Their instruction:
3102
3163
 
@@ -3136,11 +3197,16 @@ task, for context only. \`task_current\` carries the same note.
3136
3197
  with no right answer, something the entry does not settle — call \`ask_user\`
3137
3198
  and wait. Do not guess and carry on. Do not use it to check work you can
3138
3199
  check yourself.
3139
- 5. Finish with \`report\` kind "done", naming the branch and, if this repository
3140
- has a remote, the pushed branch or PR. If something stops you that a person
3141
- must resolve, use kind "blocked" instead and say what is in the way.
3200
+ 5. ${SUGGEST_ELSEWHERE}
3201
+ 6. Finish with \`report\` kind "done", naming the branch and, if this repository
3202
+ has a remote, whether the branch is pushed. If something stops you that a
3203
+ person must resolve, use kind "blocked" instead and say what is in the way.
3142
3204
 
3143
- Commit your work. Never push to main.`;
3205
+ Commit your work. Never push to main.
3206
+
3207
+ You may commit on ${run.branch} and push it, and nothing else. Branches, pull
3208
+ requests and merges are opened from cawdev: there is no \`gh\` or \`glab\` in this
3209
+ session, and a push to any other branch is refused.`;
3144
3210
  }
3145
3211
 
3146
3212
  // --- the transcript ----------------------------------------------------------
@@ -4153,6 +4219,21 @@ async function openPullRequest(cwd, run, title) {
4153
4219
  }
4154
4220
  }
4155
4221
 
4222
+ // R565. A release's pull request is cawdev's to open, not the session's. The
4223
+ // host's own default is the base — a release is not stacked on a sprint — and
4224
+ // the body carries the one thing the person merging it must not get wrong.
4225
+ if (run.profile === 'RELEASE') {
4226
+ return openPullRequestFor(cwd, {
4227
+ declared: run.gitHost ?? null,
4228
+ branch: run.branch,
4229
+ base: null,
4230
+ title: run.label || run.branch,
4231
+ body: 'Release opened by cawdev. Merge with a MERGE COMMIT, not a squash: a squash rewrites '
4232
+ + 'the commit and leaves the tag pointing at something that is not on the default branch. '
4233
+ + 'A release branch is not merged from the development board.',
4234
+ });
4235
+ }
4236
+
4156
4237
  return openPullRequestFor(cwd, {
4157
4238
  declared: run.gitHost ?? null,
4158
4239
  branch: run.branch,
@@ -4245,20 +4326,20 @@ async function openPullRequestFor(cwd, { branch, base, title, body, declared = n
4245
4326
  }
4246
4327
  return {
4247
4328
  ok: false,
4248
- result: `${adapter.bin} said it opened ${printed}, but the host does not show a ${adapter.noun} for ${branch}.`,
4329
+ result: `${adapter.bin ?? adapter.name} said it opened ${printed}, but the host does not show a ${adapter.noun} for ${branch}.`,
4249
4330
  };
4250
4331
  }
4251
4332
  // The one failure somebody can fix in a minute, said with the fix — and the
4252
4333
  // link to open it by hand meanwhile, which `findPullRequest` builds without the CLI.
4253
- if (opened.code === -1 && /not installed/.test(opened.err)) {
4334
+ if (opened.code === -1) {
4254
4335
  const compare = await findPullRequest(cwd, branch, { declared, base }).catch(() => null);
4255
4336
  return {
4256
4337
  ok: false,
4257
- result: `${opened.err} Install it and sign in (${adapter.installHint}), `
4338
+ result: `${opened.err} Set it up (${adapter.installHint}), `
4258
4339
  + `then ask again${compare ? ` — or open it yourself: ${compare}` : '.'}`,
4259
4340
  };
4260
4341
  }
4261
- return { ok: false, result: opened.err || opened.out || `${adapter.bin} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} create failed without saying why.` };
4342
+ return { ok: false, result: opened.err || opened.out || `${adapter.bin ?? adapter.name} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} create failed without saying why.` };
4262
4343
  }
4263
4344
 
4264
4345
  /**
@@ -4349,7 +4430,7 @@ async function mergePullRequest(cwd, branch, { method = null, declared = null }
4349
4430
  failure: await classifyMergeFailure(cwd, url, merged, adapter),
4350
4431
  // Unchanged: `gh`'s own words still reach the card. The classification is a
4351
4432
  // word beside them, never a replacement for them.
4352
- result: merged.err || merged.out || `${adapter.bin} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} merge failed without saying why.`,
4433
+ result: merged.err || merged.out || `${adapter.bin ?? adapter.name} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} merge failed without saying why.`,
4353
4434
  };
4354
4435
  }
4355
4436
 
@@ -4574,6 +4655,9 @@ async function classifyMergeFailure(cwd, url, merged, adapter) {
4574
4655
  * produced, and if the platform has more to say it can say it to the next run.
4575
4656
  */
4576
4657
  async function settleActions(config, run, cwd) {
4658
+ // First, so a stray branch is on the run's transcript before cawdev queues
4659
+ // its own pull request on top of it.
4660
+ await auditPushes(config, run, cwd).catch((failure) => log(` could not audit the pushes: ${failure.message}`));
4577
4661
  const actions = await api(
4578
4662
  config,
4579
4663
  `/api/projects/${run.projectSlug}/runs/${run.id}/actions/claim`,
@@ -4610,62 +4694,96 @@ async function settleActions(config, run, cwd) {
4610
4694
  /** How often the merge pass runs, in minutes. A merge is a human-timescale event. */
4611
4695
  const MERGE_CHECK_MINUTES = 10;
4612
4696
 
4697
+ /** `Map<ref, sha>` of the remote's heads and tags, or null when it cannot be read. */
4698
+ async function remoteRefs(cwd) {
4699
+ const listed = await git(cwd, ['ls-remote', '--heads', '--tags', 'origin'], { timeoutMs: 60_000 });
4700
+ const refs = new Map();
4701
+ for (const line of listed.split('\n')) {
4702
+ const [sha, ref] = line.trim().split(/\s+/);
4703
+ // An annotated tag lists twice; the peeled `^{}` line is the commit.
4704
+ if (sha && ref && !ref.endsWith('^{}')) {
4705
+ refs.set(ref, sha);
4706
+ }
4707
+ }
4708
+ return refs;
4709
+ }
4710
+
4613
4711
  /**
4614
- * What `gh` says about the branch's pull request.
4712
+ * R565. After a writing session, say so when it pushed anything but its own
4713
+ * branch — for every agent CLI, because only Claude Code's hook can refuse it
4714
+ * on the way. A new or moved ref is the session's when its commit exists in this
4715
+ * checkout AND is on a local branch: that keeps another person's concurrent
4716
+ * push, whose objects we never fetched, from being blamed on the session.
4717
+ *
4718
+ * Deletions cannot be attributed to the session (a teammate may have merged
4719
+ * and deleted a branch in the meantime), so they are not flagged.
4720
+ */
4721
+ async function auditPushes(config, run, cwd) {
4722
+ const before = run.remoteBefore;
4723
+ if (!before || !run.branch || !cwd) {
4724
+ return;
4725
+ }
4726
+ run.remoteBefore = null; // once: settleActions is reached from several paths
4727
+ const now = await remoteRefs(cwd);
4728
+ for (const [ref, sha] of now) {
4729
+ if (ref === `refs/heads/${run.branch}` || before.get(ref) === sha) {
4730
+ continue;
4731
+ }
4732
+ if (run.profile === 'RELEASE' && ref.startsWith('refs/tags/')) {
4733
+ continue;
4734
+ }
4735
+ const here = await git(cwd, ['cat-file', '-e', `${sha}^{commit}`]).then(() => true, () => false);
4736
+ if (!here) {
4737
+ continue;
4738
+ }
4739
+ const onBranch = (await git(cwd, ['for-each-ref', '--contains', sha, 'refs/heads']).catch(() => '')).trim();
4740
+ if (!onBranch) {
4741
+ continue;
4742
+ }
4743
+ log(` !! this session pushed ${ref}, outside its branch ${run.branch}`);
4744
+ await sayOnTheRun(config, run,
4745
+ `This session pushed \`${ref}\`, which is outside its branch \`${run.branch}\`. `
4746
+ + 'Branches, pull requests and merges are opened from cawdev.');
4747
+ }
4748
+ }
4749
+
4750
+ /**
4751
+ * What the git host says about the branch's pull request — through the host's
4752
+ * own adapter (`gh`, `glab` or Bitbucket's API), R565.
4615
4753
  *
4616
4754
  * The pull request URL is preferred over the branch name when we have a real
4617
- * one, because `gh pr view <branch>` stops finding anything once the branch is
4618
- * deleted — which is precisely when this question gets interesting. A URL keeps
4619
- * answering after the branch it belonged to is gone.
4755
+ * one, because looking a pull request up by branch stops finding anything once
4756
+ * the branch is deleted — which is precisely when this question gets
4757
+ * interesting. A URL keeps answering after the branch it belonged to is gone.
4620
4758
  *
4621
4759
  * A `/compare/` URL is not a pull request: `findPullRequest` falls back to one
4622
- * as a link somebody can click, and asking `gh` about it would be asking about
4623
- * a page that does not exist.
4760
+ * as a link somebody can click, and asking the host about it would be asking
4761
+ * about a page that does not exist.
4624
4762
  */
4625
- async function askGitHub(cwd, branch, prUrl) {
4763
+ async function askHost(cwd, branch, prUrl, declared = null) {
4626
4764
  const target = prUrl && !prUrl.includes('/compare/') ? prUrl : branch;
4627
4765
  if (!target) {
4628
4766
  return null;
4629
4767
  }
4630
- const out = await new Promise((resolvePromise) => {
4631
- const child = spawn(
4632
- 'gh',
4633
- ['pr', 'view', target, '--json', 'state,mergedAt,mergeCommit'],
4634
- { cwd, stdio: ['ignore', 'pipe', 'ignore'] },
4635
- );
4636
- let text = '';
4637
- child.stdout.on('data', (chunk) => (text += chunk));
4638
- child.on('error', () => resolvePromise(null));
4639
- // `close`, not `exit` — see git() above.
4640
- child.on('close', (code) => resolvePromise(code === 0 ? text.trim() : null));
4641
- });
4642
- if (!out) {
4643
- return null;
4644
- }
4645
-
4646
- const parsed = safeJson(out);
4647
- if (!parsed?.state) {
4648
- return null;
4649
- }
4650
- // gh's states are OPEN, CLOSED and MERGED. CLOSED here means closed WITHOUT
4651
- // merging — gh reports a merged PR as MERGED, never as CLOSED — which is the
4652
- // distinction worth keeping: it is a card that needs a person, not a status
4653
- // change.
4654
- const state = { OPEN: 'OPEN', MERGED: 'MERGED', CLOSED: 'CLOSED' }[parsed.state];
4655
- if (!state) {
4768
+ const { adapter } = await hostOf(cwd, declared);
4769
+ const viewed = await adapter.view(cwd, target);
4770
+ // OPEN, CLOSED and MERGED. CLOSED here means closed WITHOUT merging — a merged
4771
+ // pull request is MERGED, never CLOSED — which is the distinction worth
4772
+ // keeping: it is a card that needs a person, not a status change.
4773
+ if (!viewed?.state) {
4656
4774
  return null;
4657
4775
  }
4658
4776
  return {
4659
- state,
4660
- mergeCommit: parsed.mergeCommit?.oid ?? null,
4661
- mergedAt: parsed.mergedAt ?? null,
4777
+ state: viewed.state,
4778
+ mergeCommit: viewed.mergeCommit ?? null,
4779
+ mergedAt: viewed.mergedAt ?? null,
4662
4780
  };
4663
4781
  }
4664
4782
 
4665
4783
  /**
4666
- * What `gh` says about a hand-taken branch's pull request — R346.
4784
+ * What the host says about a hand-taken branch's pull request — R346.
4667
4785
  *
4668
- * `pr list --head`, not `pr view <branch>`: the latter stops finding the pull
4786
+ * Listed by head, not viewed by branch: the latter stops finding the pull
4669
4787
  * request once the branch is deleted, and this matches on the pull request's own
4670
4788
  * head branch name, which does not change. Fork pull requests are dropped: a
4671
4789
  * fork's branch of the same name is not this branch.
@@ -4674,23 +4792,12 @@ async function askGitHub(cwd, branch, prUrl) {
4674
4792
  * reused branch name can bring back an older one, which the platform also
4675
4793
  * refuses. Otherwise the highest-numbered row speaks.
4676
4794
  */
4677
- async function askGitHubForBranch(cwd, branch, openedAt) {
4795
+ async function askHostForBranch(cwd, branch, openedAt, declared = null) {
4678
4796
  if (!branch) {
4679
4797
  return null;
4680
4798
  }
4681
- const out = await new Promise((resolvePromise) => {
4682
- const child = spawn(
4683
- 'gh',
4684
- ['pr', 'list', '--head', branch, '--state', 'all', '--limit', '10', '--json',
4685
- 'number,url,state,mergedAt,mergeCommit,baseRefName,headRefName,isCrossRepository'],
4686
- { cwd, stdio: ['ignore', 'pipe', 'ignore'] },
4687
- );
4688
- let text = '';
4689
- child.stdout.on('data', (chunk) => (text += chunk));
4690
- child.on('error', () => resolvePromise(null));
4691
- child.on('close', (code) => resolvePromise(code === 0 ? text.trim() : null));
4692
- });
4693
- const parsed = out ? safeJson(out) : null;
4799
+ const { adapter } = await hostOf(cwd, declared);
4800
+ const parsed = await adapter.listByHead(cwd, branch);
4694
4801
  if (!Array.isArray(parsed)) {
4695
4802
  return null;
4696
4803
  }
@@ -4706,14 +4813,13 @@ async function askGitHubForBranch(cwd, branch, openedAt) {
4706
4813
  if (chosen.state === 'MERGED' && !merged.length) {
4707
4814
  return null; // Only a merge from before the item existed: not this item's.
4708
4815
  }
4709
- const state = { OPEN: 'OPEN', MERGED: 'MERGED', CLOSED: 'CLOSED' }[chosen.state];
4710
- if (!state) {
4816
+ if (!chosen.state) {
4711
4817
  return null;
4712
4818
  }
4713
4819
  return {
4714
- state,
4820
+ state: chosen.state,
4715
4821
  prUrl: chosen.url ?? null,
4716
- mergeCommit: chosen.mergeCommit?.oid ?? null,
4822
+ mergeCommit: chosen.mergeCommit ?? null,
4717
4823
  mergedAt: chosen.mergedAt ?? null,
4718
4824
  baseRefName: chosen.baseRefName ?? null,
4719
4825
  };
@@ -4775,7 +4881,7 @@ async function askGit(cwd, head, defaultBranch) {
4775
4881
  /**
4776
4882
  * What became of one branch.
4777
4883
  *
4778
- * `gh` first, because it is the only thing that can see a SQUASH merge: a
4884
+ * The host first, because it is the only thing that can see a SQUASH merge: a
4779
4885
  * squashed branch's commits never appear on the default branch at all, so the
4780
4886
  * ancestry test below would confidently and wrongly say "not merged".
4781
4887
  *
@@ -4783,8 +4889,8 @@ async function askGit(cwd, head, defaultBranch) {
4783
4889
  * that has left the remote. Proving a merge is possible; disproving one is not,
4784
4890
  * and UNKNOWN is the honest answer rather than a guess dressed as a reading.
4785
4891
  */
4786
- async function readMergeState(cwd, { branch, defaultBranch, baseBranch, head, prUrl }) {
4787
- const viaGh = await askGitHub(cwd, branch, prUrl).catch(() => null);
4892
+ async function readMergeState(cwd, { branch, defaultBranch, baseBranch, head, prUrl, gitHost = null }) {
4893
+ const viaGh = await askHost(cwd, branch, prUrl, gitHost).catch(() => null);
4788
4894
  if (viaGh) {
4789
4895
  return viaGh;
4790
4896
  }
@@ -4900,7 +5006,7 @@ async function checkRunMerges(config, pending, fetched) {
4900
5006
 
4901
5007
  /**
4902
5008
  * R346: the hand-taken items. A squash or rebase merge leaves no head on the
4903
- * default branch for the git reading to find, so ask `gh` about the pull
5009
+ * default branch for the git reading to find, so ask the host about the pull
4904
5010
  * request. An older platform answers 404 and this returns in silence.
4905
5011
  */
4906
5012
  async function checkWorkItemMerges(config, fetched) {
@@ -4931,7 +5037,7 @@ async function checkWorkItemMerges(config, fetched) {
4931
5037
  fetched.add(cwd);
4932
5038
  await git(cwd, ['fetch', '--prune', 'origin']).catch(() => null);
4933
5039
  }
4934
- const reading = await askGitHubForBranch(cwd, item.branch, item.openedAt).catch(() => null);
5040
+ const reading = await askHostForBranch(cwd, item.branch, item.openedAt, item.gitHost ?? null).catch(() => null);
4935
5041
  readings.push({
4936
5042
  workItemId: item.workItemId,
4937
5043
  state: reading?.state ?? 'UNKNOWN',
@@ -5215,9 +5321,13 @@ const PROFILE_TOOLS = {
5215
5321
  // it was asked to judge — and there is still no set_status here, because
5216
5322
  // `DONE` is the person's word (R74) and a reviewer that could move the card
5217
5323
  // would be giving the verdict it is explicitly told not to give.
5324
+ //
5325
+ // R572. And `backlog_file`: what the reviewer notices that is not this
5326
+ // card's is a suggestion for a person, not a finding on this card.
5218
5327
  REVIEW: [
5219
5328
  ...READ_ONLY_CAWDEV,
5220
5329
  'mcp__cawdev__roadmap_comment',
5330
+ SUGGEST,
5221
5331
  ...READ_FILES,
5222
5332
  ...GIT_READS,
5223
5333
  ],
@@ -5266,10 +5376,15 @@ const PROFILE_TOOLS = {
5266
5376
  // R302. Less the reads that need the work to exist — `quality_get` — the
5267
5377
  // same narrowing `toolsForStage` applies to a walk's PLAN stage, by the same
5268
5378
  // name: a plan run spawned as one process comes through here and not there.
5379
+ //
5380
+ // R572. `backlog_file` too, on every plan run: a planner reads more of the
5381
+ // code than anybody and finds what is not this card's. It files a pending
5382
+ // suggestion and nothing else; see `SUGGEST` in stage-tools.mjs.
5269
5383
  PLAN: (run) => [
5270
5384
  ...beforeTheWorkExists(READ_ONLY_CAWDEV),
5271
5385
  ...READ_FILES,
5272
5386
  ...GIT_READS,
5387
+ SUGGEST,
5273
5388
  ...(run?.entryNumber ? [] : [CARD_WRITE]),
5274
5389
  ],
5275
5390
  // R96. Read the whole repository, ask in rounds, write the brief, commit it.
@@ -5314,11 +5429,11 @@ const PROFILE_TOOLS = {
5314
5429
  // release may genuinely need to ask. Nothing that creates a card, proposes
5315
5430
  // one, or comments.
5316
5431
  //
5317
- // The shell is `git` in full — it commits, tags and pushes — `gh pr create`
5318
- // and `gh pr view`, and `node` for the export scripts. NOT `Bash(gh *)`: it
5319
- // can open the release's pull request and it cannot merge it, and that is a
5320
- // permission rather than a sentence in the prompt. A release is merged by a
5321
- // person, with a merge commit, because a squash orphans the tag.
5432
+ // The shell is `git` in full — it commits, tags and pushes — and `node` for
5433
+ // the export scripts. No `gh` at all (R565): cawdev opens the release's pull
5434
+ // request itself when the session ends, from the branch it pushed, and
5435
+ // `push-guard.mjs` refuses a push to any other branch. A release is merged
5436
+ // by a person, with a merge commit, because a squash orphans the tag.
5322
5437
  //
5323
5438
  // And `Write`/`Edit` on the version files the platform named on the claim,
5324
5439
  // and nothing else. `ROADMAP.md`, `ISSUES.md` and `CHANGELOG.md` are
@@ -5331,8 +5446,6 @@ const PROFILE_TOOLS = {
5331
5446
  'mcp__cawdev__roadmap_set_status',
5332
5447
  ...READ_FILES,
5333
5448
  'Bash(git *)',
5334
- 'Bash(gh pr create *)',
5335
- 'Bash(gh pr view *)',
5336
5449
  'Bash(node *)',
5337
5450
  ...releaseWrites(run),
5338
5451
  ],
@@ -5484,6 +5597,43 @@ function argsForProfile(agentArgs, profile, run, expertAgents = [], skills = [])
5484
5597
  * re-derived the plan from a précis would be planning again, which is the one
5485
5598
  * thing the gate exists to stop it doing after somebody approved the first one.
5486
5599
  */
5600
+ /**
5601
+ * R576. A plan that ignores the other unfinished cards produces two plans that
5602
+ * rewrite the same paragraph. The plan stage holds no writer (R108), so the
5603
+ * session says what it found in labelled sections and cawdev applies them when
5604
+ * the plan is recorded.
5605
+ */
5606
+ const CONSIDER_UNFINISHED = `**Look at the other unfinished cards.** Two plans that change the same files
5607
+ collide when the second is built, and a plan written without looking cannot know.
5608
+ Call \`roadmap_list\` for PLANNED, CONFIRMED and IN_DEVELOPMENT (those have plans)
5609
+ and for CONSIDERING and NEW (those have a brief), then \`roadmap_get\` each card
5610
+ that touches the same area or the same files and read its plan.
5611
+ - A card that has started (IN_DEVELOPMENT, or \`roadmap_get\` shows a branch or runs)
5612
+ and has to land first goes on the \`**Comes after:**\` line. Do not ask about it.
5613
+ - A conflict with a card that has not started (BACKLOG, CONSIDERING, PLANNED, NEW,
5614
+ or CONFIRMED with no branch) is the person's to settle: \`ask_user\`, one question
5615
+ per conflicting card, naming only that card's ref — never this card's ref and never
5616
+ two refs, because cawdev finds the answer again by the one ref in the question.
5617
+ Offer choices such as "this card goes after it", "it goes after this card", "leave
5618
+ both, they do not fight". Then record the conflict under \`**Conflicts decided:**\`,
5619
+ and if this card goes after it, add it to \`**Comes after:**\` too.
5620
+ - If \`ask_user\` hands back a \`question_id\`, call \`await_answer\` once. With
5621
+ still no answer, list the conflict under \`**Conflicts undecided:**\` and carry on —
5622
+ nobody may be watching.
5623
+ Write these before the \`**Done when:**\` list, in this shape:
5624
+
5625
+ **Comes after:** R570, i12
5626
+ **Conflicts decided:**
5627
+ - R571 — both rewrite the claim view's prompt paragraph; decided: R571 goes first.
5628
+ **Conflicts undecided:**
5629
+ - R572 — both add a migration for agent_question; nobody answered.
5630
+
5631
+ \`**Comes after:**\` is one line (\`none\` when there is nothing). Each list item starts
5632
+ with the card's ref and says why the two collide. cawdev applies this when the plan
5633
+ is recorded: it sets this card to start after those cards and writes a comment on
5634
+ each conflicting card saying what was decided and by whom. You cannot do either,
5635
+ so do not try.`;
5636
+
5487
5637
  /** Longer than ask_user's own ten-minute wait — see the cawdev server of spawnCodex. */
5488
5638
  const CAWDEV_TOOL_TIMEOUT_SEC = 660;
5489
5639
 
@@ -5523,6 +5673,20 @@ const WAIT_FOR_COMMANDS = 'This stage ends when your turn ends: run every comman
5523
5673
  + '`Agent` with `run_in_background: false` and wait for its answer — an agent still '
5524
5674
  + 'working when your turn ends has its work lost, and the stage is run again.';
5525
5675
 
5676
+ /**
5677
+ * R572. What a session finds that is not its card's goes to the backlog as it
5678
+ * finds it. R470's run wrote a real follow-up into its final report — "a
5679
+ * separate change, not this card's … a person should decide" — and nobody files
5680
+ * from a report. `backlog_file` stamps the item with this run, so the feedback
5681
+ * page links back to the run and its card without the session writing either.
5682
+ */
5683
+ const SUGGEST_ELSEWHERE = 'Something worth doing that is NOT this card\'s — a bug next door, '
5684
+ + 'a fix outside the scope, a follow-up the work revealed — goes to `backlog_file`, one item '
5685
+ + 'per suggestion, when you find it: say what you saw, where in the code, and what should '
5686
+ + 'happen instead. cawdev links it to this run and this card, and a person accepts or '
5687
+ + 'refuses it. Do not widen this card to do it, and do not leave it only in your report: '
5688
+ + 'a suggestion in a report is one nobody files. Your report may name what you filed.';
5689
+
5526
5690
  /**
5527
5691
  * @param hasPlan whether the card has a plan of record — the same test
5528
5692
  * harnessPrompt uses to draw "The plan for this card" (`plan?.body`), so the
@@ -5715,10 +5879,11 @@ this was resolved without opening your transcript.`;
5715
5879
  cawdev platform.
5716
5880
 
5717
5881
  Your permissions, so you do not spend turns discovering them: \`git\` in full;
5718
- \`gh pr create\` and \`gh pr view\`; \`node\`; and Write/Edit on exactly these files —
5882
+ \`node\`; and Write/Edit on exactly these files —
5719
5883
  ${list} — and nothing else. There is no permission prompt in this session: a
5720
- command outside those is refused and nobody is asked. You cannot merge a pull
5721
- request, run a build, or run tests.
5884
+ command outside those is refused and nobody is asked. You cannot open or merge a
5885
+ pull request, run a build, or run tests — cawdev opens the pull request when you
5886
+ finish, from the branch you pushed.
5722
5887
 
5723
5888
  If a decision is genuinely the person's, use \`ask_user\` and wait.
5724
5889
 
@@ -5988,8 +6153,12 @@ card you created a moment ago. cawdev stores what you report as the card's plan
5988
6153
  record when this phase ends, and that is the only copy anybody wants: two plans in
5989
6154
  two places is how one of them goes stale.
5990
6155
 
6156
+ ${CONSIDER_UNFINISHED}
6157
+
5991
6158
  **End with a Done-when list.** ${DONE_WHEN_PLAN}
5992
6159
 
6160
+ **What is not this card's.** ${SUGGEST_ELSEWHERE}
6161
+
5993
6162
  Then \`report\` kind "done" with the plan itself.${asked}`;
5994
6163
  }
5995
6164
 
@@ -6019,6 +6188,8 @@ can find it.
6019
6188
 
6020
6189
  Do not commit, do not push, do not make edits. The branch is not yours to change.
6021
6190
 
6191
+ **What is not this card's.** A finding about THIS card's work is a comment. ${SUGGEST_ELSEWHERE}
6192
+
6022
6193
  Then \`report\` kind "done".`;
6023
6194
  }
6024
6195
 
@@ -6273,7 +6444,23 @@ const running = new Map();
6273
6444
  * uncommitted work and all, until somebody discards the run — they are not
6274
6445
  * free, whatever `running` says, and must not be reset for the next claim.
6275
6446
  */
6276
- const told = { paused: false, autoResume: false, heldWorkspaces: [] };
6447
+ const told = {
6448
+ paused: false, pausedByUsage: false, limits: [], autoResume: false, heldWorkspaces: [], maxPlanningRuns: null,
6449
+ };
6450
+
6451
+ /**
6452
+ * The queued line for a paused machine — R571. Rebuilt only when the KIND of
6453
+ * pause, the window, its percent or its reset moves (`noteKey`), so the minutes
6454
+ * left in the sentence never make `noteQueued` log once per heartbeat.
6455
+ */
6456
+ let pausedNoteCache = { key: null, text: '' };
6457
+ function queuedPauseNote() {
6458
+ const key = noteKey(told);
6459
+ if (pausedNoteCache.key !== key) {
6460
+ pausedNoteCache = { key, text: pausedNote(told, Date.now()) };
6461
+ }
6462
+ return pausedNoteCache.text;
6463
+ }
6277
6464
 
6278
6465
  /**
6279
6466
  * How this daemon paints its own output — R62.
@@ -6495,7 +6682,7 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0,
6495
6682
  return { workspace: free[0], why: null };
6496
6683
  }
6497
6684
 
6498
- function noteQueued(run, why) {
6685
+ function noteQueued(run, why, reported = why) {
6499
6686
  // Once per REASON, not once per run — R70, now that there are two gates a run
6500
6687
  // can be held by. A run refused by the machine's cap and then, when a session
6501
6688
  // ends, by the project's checkouts is waiting for a different thing, and
@@ -6509,7 +6696,8 @@ function noteQueued(run, why) {
6509
6696
  // knowledge that exists nowhere but here. The platform sees a queued run; only
6510
6697
  // this daemon knows it is queued because the checkout is busy rather than
6511
6698
  // because nothing has picked it up.
6512
- noted.set(run.id, { label: run.label, projectSlug: run.projectSlug, why });
6699
+ // `reported` is what the platform is told; null says nothing (a pause: R571).
6700
+ noted.set(run.id, { label: run.label, projectSlug: run.projectSlug, why, reported });
6513
6701
  log(`${why}; leaving "${run.label}" queued`);
6514
6702
  }
6515
6703
 
@@ -6574,6 +6762,9 @@ function snapshotRuns() {
6574
6762
  return runs;
6575
6763
  }
6576
6764
 
6765
+ /** i267: the statuses that mean "this run is not yours now" on the move to RUNNING. */
6766
+ const PLATFORM_REFUSALS = new Set([400, 404, 409, 410]);
6767
+
6577
6768
  async function startRun(config, offered, workspace, source = null) {
6578
6769
  const run = offered.run;
6579
6770
  const project = config.projects[run.projectSlug];
@@ -6618,6 +6809,7 @@ async function startRun(config, offered, workspace, source = null) {
6618
6809
  // The claim carries everything needed to prepare the working copy, so the
6619
6810
  // runner never touches the project API — which is session-only anyway.
6620
6811
  defaultBranch = claimed.defaultBranch;
6812
+ run.pushDefaultBranch = claimed.defaultBranch ?? null;
6621
6813
  // Whether somebody was shown the uncommitted work and started anyway.
6622
6814
  allowDirty = claimed.allowDirty === true;
6623
6815
  // Or whether the uncommitted work is THIS BRANCH'S OWN: the platform named
@@ -6711,6 +6903,8 @@ async function startRun(config, offered, workspace, source = null) {
6711
6903
  plan = claimed.plan ?? null;
6712
6904
  lifecycle = Array.isArray(claimed.workflow) ? claimed.workflow : [];
6713
6905
  shield = claimed.shield ?? null;
6906
+ // R562. Every session is told the list up front, whichever profile it is.
6907
+ run.protectedLine = protectedLine(protectedPolicy(shield));
6714
6908
  if (instincts.length) {
6715
6909
  log(` instincts: ${instincts.map((each) => each.key).join(', ')}`);
6716
6910
  }
@@ -6780,6 +6974,37 @@ async function startRun(config, offered, workspace, source = null) {
6780
6974
  briefing = briefing ? `${said}\n\n${briefing}` : said;
6781
6975
  baseCommit = await git(resolve(path), ['rev-parse', 'HEAD']).catch(() => baseCommit);
6782
6976
  }
6977
+ // R562. The base is fixed ONCE, here, after the working copy is prepared
6978
+ // and never recomputed: a MERGE moves HEAD, and a base recomputed after
6979
+ // it would call main's own commits the agent's. For a MERGE it is the
6980
+ // tip of the branch being merged INTO — what the branch brings with it.
6981
+ const policy = protectedPolicy(shield);
6982
+ if (policy.globs.length) {
6983
+ const baseName = run.baseBranch ?? defaultBranch;
6984
+ const fixed = run.profile === 'MERGE'
6985
+ ? await git(resolve(path), ['rev-parse', `origin/${baseName || 'main'}`])
6986
+ .catch(() => git(resolve(path), ['rev-parse', baseName || 'main']))
6987
+ .catch(() => null)
6988
+ : await protectedBase(resolve(path), baseName, baseCommit);
6989
+ const base = fixed || baseCommit || null;
6990
+ protectedGuards.set(run.id, { ...policy, base });
6991
+ // Checked before the session starts, as the card asks. It does not
6992
+ // refuse to start: the session is the one that can fix it, and the
6993
+ // prompt now says so.
6994
+ if (!base) {
6995
+ log(' !! protected paths: no base to judge this branch against, so a protected '
6996
+ + 'change cannot be caught when the run finishes');
6997
+ }
6998
+ const already = notAllowed(
6999
+ await protectedChanges(resolve(path), base, policy.globs).catch((failure) => {
7000
+ log(` !! protected paths: could not read the branch's changes: ${failure.message}`);
7001
+ return [];
7002
+ }), policy.allowed);
7003
+ if (already.length) {
7004
+ log(` !! this branch already changes protected file(s): ${already.join(', ')}`);
7005
+ }
7006
+ run.protectedLine = protectedLine(policy, already);
7007
+ }
6783
7008
  if (run.profile === 'MERGE') {
6784
7009
  // R155. The daemon does the merge, BEFORE anything is spawned, and what
6785
7010
  // git conflicts on becomes the session's whole write scope. A clean
@@ -6796,16 +7021,38 @@ async function startRun(config, offered, workspace, source = null) {
6796
7021
  }
6797
7022
  }
6798
7023
  log(` working copy ${cwd ?? path} is on ${branch ?? 'no branch of its own'}`);
6799
-
6800
- await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
6801
- method: 'POST',
6802
- // Where it ran, sent with the move to RUNNING because that is the one
6803
- // moment it is known. With three checkouts of one project, a run that
6804
- // does not say which it used makes "what is uncommitted on this run"
6805
- // unanswerable — and a console showing one run's files against another
6806
- // run's path is worse than one showing nothing.
6807
- body: { state: 'RUNNING', workspace: workspace ?? null },
6808
- });
7024
+ // R565. What the remote held before the session, so `auditPushes` can say
7025
+ // what the session added to it. Every agent CLI is audited, not only Claude.
7026
+ if (writesCodeProfile(run) && run.branch) {
7027
+ run.remoteBefore = await remoteRefs(cwd ?? resolve(path)).catch(() => null);
7028
+ }
7029
+
7030
+ // i267. A REFUSED move (400, 404, 409 or 410; PLATFORM_REFUSALS) is the platform saying whose run this is:
7031
+ // it was cancelled while this machine prepared, or put back in the queue.
7032
+ // That is never this run's failure, and the FAILED post below would write
7033
+ // the refusal text over whatever the platform holds as the run's exit.
7034
+ // Nothing is posted; the platform's state stands and, if the run really is
7035
+ // QUEUED, the next poll offers it again and claims it properly. Anything
7036
+ // else (401, 403, 408, 429, a network error, a 5xx) is not the platform
7037
+ // saying whose run this is and still fails the run, as before.
7038
+ try {
7039
+ await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
7040
+ method: 'POST',
7041
+ // Where it ran, sent with the move to RUNNING because that is the one
7042
+ // moment it is known. With three checkouts of one project, a run that
7043
+ // does not say which it used makes "what is uncommitted on this run"
7044
+ // unanswerable — and a console showing one run's files against another
7045
+ // run's path is worse than one showing nothing.
7046
+ body: { state: 'RUNNING', workspace: workspace ?? null },
7047
+ });
7048
+ } catch (refused) {
7049
+ if (PLATFORM_REFUSALS.has(refused.status)) {
7050
+ log(` the platform did not let this run start (${refused.message}); leaving it as the platform has it`);
7051
+ protectedGuards.delete(run.id);
7052
+ return;
7053
+ }
7054
+ throw refused;
7055
+ }
6809
7056
 
6810
7057
  if (merge?.clean) {
6811
7058
  // R155. Nothing conflicted, so there is nothing for a session to decide
@@ -6846,6 +7093,13 @@ async function startRun(config, offered, workspace, source = null) {
6846
7093
  }
6847
7094
  }
6848
7095
 
7096
+ /**
7097
+ * R577: what the planning limit counts — every run that takes no checkout,
7098
+ * except ASK, which never waits. Counted by the run's profile, never its stage:
7099
+ * a CODE run has a PLAN stage and is not one of these.
7100
+ */
7101
+ function countsAsPlanning(run) { return !writesCodeProfile(run) && run.profile !== 'ASK'; }
7102
+
6849
7103
  /**
6850
7104
  * Whether this run is the kind that takes a checkout, before the child exists.
6851
7105
  *
@@ -7286,13 +7540,151 @@ function gateTimeoutSeconds() {
7286
7540
  * the pipes are the agent's own. `cawdevCommand` is what the next daemon reads
7287
7541
  * off the driving note to tell this session's orphan from a reused pid.
7288
7542
  */
7543
+ // --- the agent pushes its own branch and nothing else (R565) ----------------------
7544
+ //
7545
+ // A guard against mistakes, not a security boundary: an agent with a shell and
7546
+ // the operator's credentials can always find a way round it. The wall is branch
7547
+ // protection on the host (tools/runner/README.md says so). What this adds is
7548
+ // that Claude Code is refused the obvious moves — a host CLI, a push to another
7549
+ // branch, a force — with a reason it can read, and that every agent CLI is
7550
+ // AUDITED afterwards (`auditPushes`).
7551
+
7552
+ const PUSH_GUARD = fileURLToPath(new URL('./push-guard.mjs', import.meta.url));
7553
+
7554
+ /** The variables `push-guard.mjs` reads. Empty tags means "this profile pushes no tag". */
7555
+ function pushGuardEnv(run) {
7556
+ return {
7557
+ CAWDEV_PUSH_BRANCH: run.branch ?? '',
7558
+ CAWDEV_DEFAULT_BRANCH: run.pushDefaultBranch ?? '',
7559
+ CAWDEV_PUSH_TAGS: run.profile === 'RELEASE' ? '1' : '',
7560
+ };
7561
+ }
7562
+
7563
+ /**
7564
+ * `--settings` with the PreToolUse hook, and `gh`/`glab` disallowed outright.
7565
+ *
7566
+ * Measured on Claude Code 2.1.309, the way R562's spike is recorded below: a
7567
+ * scratch repository, `--setting-sources ''`, `--permission-mode
7568
+ * bypassPermissions`, haiku asked to run `gh --version`, `git push origin
7569
+ * HEAD:other` and `git push origin <its branch>`. The first two were refused
7570
+ * with the guard's reason and never ran; the third went through. So `--settings`
7571
+ * hooks DO load under `--setting-sources ''`, and the guard holds under bypass.
7572
+ */
7573
+ function pushGuardArgs() {
7574
+ return [
7575
+ '--settings',
7576
+ JSON.stringify({
7577
+ hooks: {
7578
+ PreToolUse: [{
7579
+ matcher: 'Bash',
7580
+ hooks: [{ type: 'command', command: `"${process.execPath}" "${PUSH_GUARD}"` }],
7581
+ }],
7582
+ },
7583
+ }),
7584
+ '--disallowedTools', 'Bash(gh *)', 'Bash(glab *)',
7585
+ ];
7586
+ }
7587
+
7588
+ /**
7589
+ * `args` with the guard placed BEFORE `--allowedTools` (which is variadic and
7590
+ * would swallow it) — when the session is allowed `git` in full. Every Claude
7591
+ * Code spawn goes through this one function, so the coding path, the stage path
7592
+ * and the profile path cannot come apart.
7593
+ */
7594
+ function withPushGuard(args) {
7595
+ const at = args.indexOf('--allowedTools');
7596
+ if (at < 0 || !args.slice(at + 1).includes('Bash(git *)')) {
7597
+ return args;
7598
+ }
7599
+ return [...args.slice(0, at), ...pushGuardArgs(), ...args.slice(at)];
7600
+ }
7601
+
7602
+ /**
7603
+ * The environment an agent (or a skill's command) is spawned with — R565: this
7604
+ * process's own, minus the variable holding the Bitbucket token, plus `extra`.
7605
+ * `GH_TOKEN` and `GITLAB_TOKEN` stay: an operator's `git push` may authenticate
7606
+ * through them. `extra` is applied last but is stripped too, because callers
7607
+ * hand over `{ ...process.env }`.
7608
+ */
7609
+ function agentEnv(config, extra = {}) {
7610
+ const env = { ...process.env, ...extra };
7611
+ delete env[config?.bitbucket?.tokenEnv ?? 'BITBUCKET_TOKEN'];
7612
+ return env;
7613
+ }
7614
+
7289
7615
  function spawnScoped(config, command, args, options) {
7290
7616
  const line = scoped(command, args, config.sessionScope);
7291
- const child = spawn(line.command, line.args, options);
7617
+ // Every agent CLI passes through here, so the Bitbucket token is out of its
7618
+ // environment whatever the caller spread into `options.env`.
7619
+ const child = spawn(line.command, line.args, { ...options, env: agentEnv(config, options.env) });
7292
7620
  child.cawdevCommand = command;
7293
7621
  return child;
7294
7622
  }
7295
7623
 
7624
+ /**
7625
+ * Keeps an Edit away from the project's protected files at the CLI itself —
7626
+ * R562 — by editing the argument list in place, once, for every path to it.
7627
+ *
7628
+ * Measured, not assumed, on Claude Code 2.1.307 (the way i138's spike is
7629
+ * recorded above): a scratch repo, `--setting-sources ''`, a stub MCP
7630
+ * `--permission-prompt-tool`, haiku asked to Write `secret/a.txt`.
7631
+ *
7632
+ * A acceptEdits + --disallowedTools 'Edit(./secret/**)' → refused, file not written
7633
+ * B bypassPermissions + --disallowedTools 'Edit(./secret/**)' → refused, file not written
7634
+ * C acceptEdits + --settings ask 'Edit(./secret/**)' → reached the approve tool
7635
+ * D bypassPermissions + --settings ask 'Edit(./secret/**)' → reached the approve tool
7636
+ * (control: acceptEdits with no rule wrote the file and never asked.)
7637
+ *
7638
+ * Two things the spike found that the plan did not expect. Only `Edit(path)`
7639
+ * rules are matched — `Write(path)` is rejected as "not matched by file
7640
+ * permission checks", Edit covering every file-editing tool — so a single Edit
7641
+ * rule per glob is the whole list. And the ask rule works under
7642
+ * bypassPermissions too, so "allow everything" does not turn ASK into silence.
7643
+ *
7644
+ * `--disallowedTools` is variadic, so it goes in BEFORE `--allowedTools`.
7645
+ * Bash is not covered by any of this (`git checkout main -- pom.xml` changes a
7646
+ * file with no Edit call): the diff gate at every finish door is the real
7647
+ * guard, and this is the early warning.
7648
+ *
7649
+ * - a session with the prompt tool (CODE) in ASK mode: ask rules in a
7650
+ * settings file, so the call reaches `approve` and a person is asked;
7651
+ * - everything else, in either mode: deny rules. These profiles have no
7652
+ * prompt tool, so nobody could be asked, and "cannot" stays "cannot".
7653
+ *
7654
+ * Codex and Antigravity have no flag equivalent; they get the prompt and the
7655
+ * diff gate only.
7656
+ */
7657
+ async function protectFiles(args, run, shield, { readOnlyStage, mcpDirectory }) {
7658
+ const policy = protectedPolicy(shield);
7659
+ if (!policy.globs.length) {
7660
+ return;
7661
+ }
7662
+ const asks = (!run.profile || run.profile === 'CODE') && !readOnlyStage;
7663
+ const rules = denyRules(policy.globs);
7664
+ const at = args.indexOf('--allowedTools');
7665
+ if (asks && policy.mode === 'ASK') {
7666
+ const settingsPath = join(mcpDirectory, 'protected-settings.json');
7667
+ // One --settings only: a second would risk replacing the push guard's hook.
7668
+ // So when the guard's inline settings are there, the ask rules join them in
7669
+ // the file and the flag points at the file.
7670
+ // The guard's own flag, by its content: an operator's `--settings` in the
7671
+ // agent args is theirs and is left as it is.
7672
+ const inline = args.findIndex((arg, i) => arg === '--settings'
7673
+ && String(args[i + 1]).startsWith('{') && String(args[i + 1]).includes(PUSH_GUARD));
7674
+ const guarded = inline !== -1 ? JSON.parse(args[inline + 1]) : {};
7675
+ await writeFile(settingsPath,
7676
+ JSON.stringify({ ...guarded, permissions: { ask: rules } }, null, 2));
7677
+ if (inline !== -1 && guarded.hooks) {
7678
+ args[inline + 1] = settingsPath;
7679
+ } else {
7680
+ args.splice(at === -1 ? args.length : at, 0, '--settings', settingsPath);
7681
+ }
7682
+ } else {
7683
+ args.splice(at === -1 ? args.length : at, 0, '--disallowedTools', ...rules);
7684
+ }
7685
+ log(` protected paths (${policy.mode}): ${policy.globs.join(', ')}`);
7686
+ }
7687
+
7296
7688
  async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
7297
7689
  projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
7298
7690
  stage = null,
@@ -7427,7 +7819,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7427
7819
  // says what to run and the machine says under what conditions, and a
7428
7820
  // settings blob that could add environment variables would be a channel
7429
7821
  // from a database row into a spawned process.
7430
- env: { ...process.env, ...(local.env ?? {}) },
7822
+ env: agentEnv(config, local.env ?? {}),
7431
7823
  };
7432
7824
  }
7433
7825
 
@@ -7451,13 +7843,21 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7451
7843
  // model becomes a thing people click through. A server this list does not
7452
7844
  // name keeps the per-tool suggestion, because nobody declared it as a
7453
7845
  // capability — see `suggestionFor`.
7846
+ //
7847
+ // R319: SonarQube is NOT named — its server holds writers beside its
7848
+ // readers, so a whole-server "allow" would cover them. It keeps the
7849
+ // per-tool suggestion.
7454
7850
  CAWDEV_SKILL_SERVERS: JSON.stringify(
7455
- attached.map(({ skill }) => skill.toolPrefix ?? `mcp__${skill.serverName}`)),
7851
+ attached.filter(({ skill }) => skill.serverName !== 'sonarqube')
7852
+ .map(({ skill }) => skill.toolPrefix ?? `mcp__${skill.serverName}`)),
7456
7853
  // R110. The policy, and the checkout it is measured against. The CHECK is
7457
7854
  // the server's — it is the process Claude Code asks before a tool call,
7458
7855
  // and there is nowhere else the answer can be given in time.
7459
7856
  CAWDEV_SHIELD: JSON.stringify(shield ?? {}),
7460
7857
  CAWDEV_WORKSPACE: cwd,
7858
+ // R562. The commit the branch was cut from, fixed once by the runner, for
7859
+ // `report done`'s diff gate. Empty means nothing to judge against.
7860
+ CAWDEV_PROTECTED_BASE: protectedGuards.get(run.id)?.base ?? '',
7461
7861
  },
7462
7862
  };
7463
7863
  await writeFile(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2));
@@ -7640,7 +8040,8 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7640
8040
  // R303. Only a REQUIRED server changes the prompt, and only where the
7641
8041
  // stage can reach it — the same gate `mustUse` applies, so the prompt and
7642
8042
  // the nudge never disagree about what a stage owes.
7643
- mcpServers: attached.map(({ skill }) => ({
8043
+ mcpServers: attached.map(({ skill, local }) => ({
8044
+ when: serversCanBeReached ? local?.when : undefined,
7644
8045
  name: skill.name ?? skill.key,
7645
8046
  prefix: skill.toolPrefix ?? `mcp__${skill.serverName}`,
7646
8047
  required: serversCanBeReached && skill.mode === 'REQUIRED',
@@ -7694,7 +8095,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7694
8095
  const agentCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'claude')
7695
8096
  ?? 'claude';
7696
8097
 
7697
- const args = [
8098
+ let args = [
7698
8099
  '--mcp-config',
7699
8100
  mcpConfigPath,
7700
8101
  // Only when there is something in it. An empty plugin loads nothing and
@@ -7708,6 +8109,8 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7708
8109
  skills)]
7709
8110
  : argsForProfile(agentArgs, run.profile, run, expertAgents, skills))),
7710
8111
  ];
8112
+ args = withPushGuard(args);
8113
+ await protectFiles(args, run, shield, { readOnlyStage, mcpDirectory });
7711
8114
  log(` spawning: ${agentCmd} ${args.join(' ')} (prompt on stdin)`);
7712
8115
 
7713
8116
  return new Promise((resolvePromise) => {
@@ -7717,13 +8120,13 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7717
8120
  // rather than leaving orphaned children behind.
7718
8121
  detached: true,
7719
8122
  stdio: ['pipe', 'pipe', 'pipe'],
7720
- env: {
7721
- ...process.env,
8123
+ env: agentEnv(config, {
7722
8124
  CAWDEV_URL: config.url,
7723
8125
  CAWDEV_TOKEN: runToken,
7724
8126
  CAWDEV_PROJECT: run.projectSlug,
7725
8127
  CAWDEV_GRANTABLE: JSON.stringify(ceiling),
7726
- },
8128
+ ...pushGuardEnv(run),
8129
+ }),
7727
8130
  });
7728
8131
 
7729
8132
  child.cawdevProjectSlug = run.projectSlug;
@@ -8700,6 +9103,9 @@ async function sayOnTheRun(config, run, text) {
8700
9103
  async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
8701
9104
  skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
8702
9105
  agyCmd, granted, usage = null) {
9106
+ if (run.protectedLine) {
9107
+ log(' protected paths are enforced at finish only for Antigravity');
9108
+ }
8703
9109
  if (!granted.everything) {
8704
9110
  // Antigravity needs it even to read: no per-tool allow-list exists on it.
8705
9111
  const summary = needsEverything('agy', thisMachine(config), run, { readsWithout: false });
@@ -9066,6 +9472,9 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
9066
9472
  };
9067
9473
 
9068
9474
  const mayWrite = writesCodeProfile(run) && !readOnlyStage;
9475
+ if (protectedPolicy(shield).globs.length) {
9476
+ log(' protected paths are enforced at finish only for Codex');
9477
+ }
9069
9478
  if (mayWrite && !granted.everything) {
9070
9479
  // It must commit and push, which its sandbox refuses, and there is no
9071
9480
  // allow-list for its shell — so everything, or nothing. Read-only runs
@@ -9082,7 +9491,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
9082
9491
  // The token reaches cawdev's server through `env_vars` — from Codex's own
9083
9492
  // environment, never its command line — and is blanked for the shell.
9084
9493
  const cawdevEnv = { CAWDEV_URL: config.url, CAWDEV_TOKEN: runToken, CAWDEV_PROJECT: run.projectSlug };
9085
- const childEnv = { ...process.env, ...cawdevEnv };
9494
+ const childEnv = agentEnv(config, { ...cawdevEnv, ...pushGuardEnv(run) });
9086
9495
 
9087
9496
  // By name, so a later server replaces an earlier one whole — the ordering
9088
9497
  // argument of the Claude Code path: the repository's own first, then R76's
@@ -9362,6 +9771,7 @@ async function finish(config, run, state, summary, cwd = null) {
9362
9771
  const settled = await settleBranch(cwd, run.branch, {
9363
9772
  push: pushBranch,
9364
9773
  label: run.entryRef ?? 'cawdev',
9774
+ protected: await guardOf(config, run),
9365
9775
  }).catch((failure) => ({ ok: false, reason: `Checking the branch failed: ${failure.message}` }));
9366
9776
  if (!settled.ok) {
9367
9777
  log(` not finished: ${settled.reason}`);
@@ -9376,6 +9786,7 @@ async function finish(config, run, state, summary, cwd = null) {
9376
9786
  method: 'POST',
9377
9787
  body: { state, summary },
9378
9788
  }).catch((failure) => log(` could not finish the run: ${failure.message}`));
9789
+ protectedGuards.delete(run.id);
9379
9790
  }
9380
9791
 
9381
9792
  // --- cancellation ------------------------------------------------------------
@@ -9526,10 +9937,12 @@ function capabilities(config) {
9526
9937
  return JSON.stringify({
9527
9938
  projects: Object.keys(config.projects),
9528
9939
  // How many checkouts each project has here — R47 — which since R109 is the
9529
- // ONLY cap this machine reports, and the only one it enforces. The
9530
- // machine-wide `maxSessions` is gone: it bounded processes, a delegated
9531
- // expert costs none, and a gate measuring the wrong thing while looking
9532
- // like it worked is worse than no gate.
9940
+ // only cap this machine reports as a number of its own. The machine-wide
9941
+ // `maxSessions` is gone: it bounded processes, a delegated expert costs
9942
+ // none, and a gate measuring the wrong thing while looking like it worked
9943
+ // is worse than no gate. R577 adds a second, opt-in gate — a limit on
9944
+ // planning runs, set from the console — which is not a config number and
9945
+ // so is announced by `maxPlanningRuns` below instead.
9533
9946
  workspaces: Object.fromEntries(
9534
9947
  Object.entries(config.projects).map(([slug, project]) => [slug, project.workspaces.length]),
9535
9948
  ),
@@ -9547,6 +9960,12 @@ function capabilities(config) {
9547
9960
  // offered only to a machine that says this, and a machine that does not
9548
9961
  // is told why on the queue rather than handed a base it would ignore.
9549
9962
  baseBranches: true,
9963
+ // R577. This daemon enforces the per-machine limit on planning runs that
9964
+ // the platform hands it on the heartbeat. The same shape as
9965
+ // `baseBranches`: an older daemon ignores the number silently, so the
9966
+ // console says so beside the field rather than letting a limit that does
9967
+ // nothing look like one that works.
9968
+ maxPlanningRuns: true,
9550
9969
  // R270. This daemon knows an offer can carry `waitForClean`, and honours it
9551
9970
  // in `pickWorkspace` — taking the preferred checkout only when clean, and
9552
9971
  // refusing to fall back to a dirty one at all. The same shape as
@@ -9560,7 +9979,7 @@ function capabilities(config) {
9560
9979
  // `glab`. An older daemon would fail the unknown kind, so the platform
9561
9980
  // hands the request only to a daemon that says so.
9562
9981
  createBranch: true,
9563
- gitHosts: ['github', 'gitlab'],
9982
+ gitHosts: ['github', 'gitlab', 'bitbucket'],
9564
9983
  });
9565
9984
  }
9566
9985
 
@@ -9643,7 +10062,28 @@ async function main() {
9643
10062
  // right way.
9644
10063
  quiet = attaching;
9645
10064
 
10065
+ // A SIGHUP that arrives while booting — `cawdev config` run a moment after
10066
+ // the daemon started — is held, not fatal: Node's default for an unhandled
10067
+ // SIGHUP is to exit, and the real handler is only installed once
10068
+ // `rereadConfig` exists, well after the first lines are logged. It is
10069
+ // applied as soon as that handler is in place.
10070
+ let hupWhileBooting = false;
10071
+ const holdHup = () => {
10072
+ hupWhileBooting = true;
10073
+ };
10074
+ process.on('SIGHUP', holdHup);
10075
+
9646
10076
  const config = await readConfig();
10077
+ // R565. Bitbucket's token, from the variable the config names. Said once, so
10078
+ // the log shows where it comes from — and whether it is there at all.
10079
+ configureBitbucket({
10080
+ email: config.bitbucket.email,
10081
+ token: process.env[config.bitbucket.tokenEnv],
10082
+ apiUrl: config.bitbucket.apiUrl,
10083
+ });
10084
+ log(process.env[config.bitbucket.tokenEnv]
10085
+ ? `bitbucket: API token from $${config.bitbucket.tokenEnv}${config.bitbucket.email ? ` (as ${config.bitbucket.email})` : ' (as a bearer token)'}`
10086
+ : `bitbucket: no token in $${config.bitbucket.tokenEnv}, so Bitbucket projects cannot be opened or merged from here`);
9647
10087
  // The checkouts made on demand by an earlier run of this daemon, found
9648
10088
  // again by their name — nothing about them is in the config.
9649
10089
  await syncAutoWorkspaces(config.projects);
@@ -9765,6 +10205,14 @@ async function main() {
9765
10205
  // that takes an answer the platform then refuses reads as cawdev being
9766
10206
  // broken, which is R58's rule about `a` applied to this one.
9767
10207
  acceptsConsoleRules: config.acceptsRulesFromConsole === true,
10208
+ // R577: the planning limit the platform last told us, or null. Kept in
10209
+ // step by the heartbeat handler, so a terminal that attaches later reads
10210
+ // what is true now.
10211
+ maxPlanningRuns: told.maxPlanningRuns,
10212
+ // R571: whether the platform says this machine is paused, and by whom. Kept
10213
+ // in step by the heartbeat handler, so `c` offers Pause or Resume truthfully.
10214
+ paused: told.paused,
10215
+ pausedByUsage: told.pausedByUsage,
9768
10216
  projects: Object.keys(config.projects),
9769
10217
  // How many checkouts each has — R62. The per-project half of R47's
9770
10218
  // gate, which the bar shows as `cawdev 1/2`. Added rather than
@@ -9869,7 +10317,11 @@ async function main() {
9869
10317
  // event type it does not know.
9870
10318
  control?.publish({ type: 'runner', runner: advertised });
9871
10319
  };
10320
+ process.off('SIGHUP', holdHup);
9872
10321
  process.on('SIGHUP', () => rereadConfig('SIGHUP'));
10322
+ if (hupWhileBooting) {
10323
+ void rereadConfig('SIGHUP (sent while booting)');
10324
+ }
9873
10325
 
9874
10326
  // The heartbeat's half of R294: the file's mtime, read once per beat, and a
9875
10327
  // re-read when it moved. `stat` and nothing more when it did not, which is
@@ -9980,12 +10432,34 @@ async function main() {
9980
10432
  // R73/R80. The heartbeat's answer is what the platform decided about
9981
10433
  // this machine, and it changes what the queue loop does next.
9982
10434
  if (me && typeof me === 'object') {
9983
- if (told.paused !== !!me.paused) {
9984
- log(me.paused
9985
- ? 'paused from the console: claiming nothing new, finishing what is running'
9986
- : 'unpaused from the console');
10435
+ const pausedNow = !!me.paused;
10436
+ const byUsageNow = pausedNow && !!me.pausedByUsage;
10437
+ const moved = pauseLogLine(told, me);
10438
+ if (moved) {
10439
+ log(moved);
10440
+ }
10441
+ told.paused = pausedNow;
10442
+ told.pausedByUsage = byUsageNow;
10443
+ told.limits = Array.isArray(me.limits) ? me.limits : [];
10444
+ if (advertised.paused !== pausedNow || advertised.pausedByUsage !== byUsageNow) {
10445
+ advertised.paused = pausedNow;
10446
+ advertised.pausedByUsage = byUsageNow;
10447
+ control?.publish({ type: 'runner', runner: advertised });
10448
+ }
10449
+ // R577. Not a switch but a number: how many planning runs at once. The
10450
+ // claim loop reads it fresh on every pass, so a change lands on the
10451
+ // next heartbeat. Lowering it stops nothing — what is open finishes.
10452
+ const limit = Number.isInteger(me.maxPlanningRuns) && me.maxPlanningRuns > 0
10453
+ ? me.maxPlanningRuns
10454
+ : null;
10455
+ if (told.maxPlanningRuns !== limit) {
10456
+ log(limit
10457
+ ? `planning runs limited to ${limit} at once from the console`
10458
+ : 'no planning limit any more');
10459
+ advertised.maxPlanningRuns = limit;
10460
+ control?.publish({ type: 'runner', runner: advertised });
9987
10461
  }
9988
- told.paused = !!me.paused;
10462
+ told.maxPlanningRuns = limit;
9989
10463
  told.autoResume = !!me.autoResume;
9990
10464
  told.heldWorkspaces = Array.isArray(me.heldWorkspaces)
9991
10465
  ? me.heldWorkspaces.map((each) => each.path).filter(Boolean)
@@ -10145,8 +10619,11 @@ async function main() {
10145
10619
  break;
10146
10620
  }
10147
10621
 
10148
- // ONE gate — R109. There were two; `maxSessions` bounded this machine
10149
- // and is gone, for the reason written where it used to be applied.
10622
+ // ONE gate by default — R109. There were two; `maxSessions` bounded this
10623
+ // machine and is gone, for the reason written where it used to be applied.
10624
+ // R577 adds an opt-in second gate for the runs that take no checkout
10625
+ // (everything but ASK), a number set from the console and read off the
10626
+ // heartbeat; with none set the machine behaves as it did.
10150
10627
  //
10151
10628
  // A project's workspaces bound its CODING runs and nothing else: they are
10152
10629
  // checkouts, and only a coding run needs one.
@@ -10185,7 +10662,7 @@ async function main() {
10185
10662
  // left to finish; that is the default because killing a machine's work
10186
10663
  // to stop it taking more is the surprising reading.
10187
10664
  if (told.paused) {
10188
- noteQueued(offered.run, 'this machine is paused from the console');
10665
+ noteQueued(offered.run, queuedPauseNote(), reportedPauseNote());
10189
10666
  skipped += 1;
10190
10667
  continue;
10191
10668
  }
@@ -10194,7 +10671,25 @@ async function main() {
10194
10671
  }
10195
10672
  const writes = writesCodeProfile(offered.run);
10196
10673
 
10197
- // ONE gate now — R109 removed the machine's.
10674
+ // R577. The second gate, and the only one an operator opts into from the
10675
+ // console: at most this many PLAN, SCOPE, AUDIT or REVIEW runs at once.
10676
+ // A run stays in `taken` from its claim until startRun's finally, so the
10677
+ // count covers all of its stages, the gap before the spawn and the time
10678
+ // it sits waiting on you — which is why the reason says so. Here and not
10679
+ // in the platform's queue: a run bound to no machine is offered to all
10680
+ // of them, and another may take what this one refuses.
10681
+ const planning = countsAsPlanning(offered.run);
10682
+ if (planning && told.maxPlanningRuns) {
10683
+ const open = [...taken.values()].filter((claim) => claim.planning).length;
10684
+ if (open >= told.maxPlanningRuns) {
10685
+ noteQueued(offered.run, `this machine runs at most ${told.maxPlanningRuns} planning run${told.maxPlanningRuns === 1 ? '' : 's'} at once and ${open} ${open === 1 ? 'is' : 'are'} open — one waiting on you still counts`);
10686
+ skipped += 1;
10687
+ continue;
10688
+ }
10689
+ }
10690
+
10691
+ // ONE gate by default — R109 removed the machine's. (R577's planning
10692
+ // limit, above, is off until somebody sets it.)
10198
10693
  //
10199
10694
  // `maxSessions` bounded agent PROCESSES on this laptop, and R70's own
10200
10695
  // note said why: a queue of runs is otherwise a fork bomb with better
@@ -10270,6 +10765,7 @@ async function main() {
10270
10765
  taken.set(offered.run.id, {
10271
10766
  projectSlug: slug,
10272
10767
  writes,
10768
+ planning,
10273
10769
  workspace,
10274
10770
  label: offered.run.label,
10275
10771
  });
@@ -10289,7 +10785,9 @@ async function main() {
10289
10785
  // R148. Say it where a person can read it. Fire-and-forget: a daemon
10290
10786
  // that died because the platform was slow to take a nicety would be
10291
10787
  // worse than one that said nothing.
10292
- const reasons = [...noted.entries()].map(([runId, note]) => ({ runId, why: note.why }));
10788
+ const reasons = [...noted.entries()]
10789
+ .filter(([, note]) => note.reported)
10790
+ .map(([runId, note]) => ({ runId, why: note.reported }));
10293
10791
  const reasonsBody = JSON.stringify(reasons);
10294
10792
  if (reasonsBody !== lastSentReasons) {
10295
10793
  lastSentReasons = reasonsBody;