cawdev-cli 1.1.0 → 1.2.0

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,20 @@ 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';
33
+ import { workspaceVersion } from '../lib/version-files.mjs';
31
34
  import { usageLimitOf } from '../lib/usage-limit.mjs';
32
35
  import { TranscriptBatch } from '../lib/transcript-batch.mjs';
33
36
  import { parseUsage } from '../lib/usage-report.mjs';
@@ -37,24 +40,30 @@ import {
37
40
  import { AI_CONFIG, harnessPrompt, readRepoConfig } from '../lib/harness-prompt.mjs';
38
41
  import { loadToken, storedUrls } from './token-store.mjs';
39
42
  import {
40
- CARD_WRITE, CAWDEV_READS, GIT_READS, beforeTheWorkExists, driftedFrom, readOnlyExpert,
43
+ CARD_WRITE, CAWDEV_READS, GIT_READS, SUGGEST, beforeTheWorkExists, driftedFrom, readOnlyExpert,
41
44
  toolsForStage,
42
45
  } from '../lib/stage-tools.mjs';
43
46
  import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
44
47
  import { findSecret } from '../lib/secrets.mjs';
45
48
  import { BackgroundShells } from '../lib/background-shells.mjs';
46
49
  import { cliVersion, isBelow } from '../lib/version.mjs';
47
- import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
50
+ import { reloadableFor, restartNeededFor, runnerNameOf } from '../lib/runner-config.mjs';
48
51
  import {
49
52
  createAutoWorkspace, discoverAutoWorkspaces, isAutoWorkspace,
50
53
  } from './auto-workspaces.mjs';
51
54
  import { agentOfCommand, needsEverything } from '../lib/agents.mjs';
55
+ import { noteKey, pausedNote, pauseLogLine, reportedPauseNote } from './pause-note.mjs';
52
56
  import { cawdevToolNames, codexArgs, translateCodexEvent } from '../lib/codex.mjs';
53
57
  import {
54
58
  drivingPath, isAlive, killedByRestart, leftBehind, memoryMaxOf, probeScope, readDriving,
55
59
  restartSummary, scoped, snapshotOf, writeDriving,
56
60
  } from './restart.mjs';
57
- import { COMMITS_ITS_WORK, settleBranch } from '../lib/finish-check.mjs';
61
+ import {
62
+ COMMITS_ITS_WORK, heldProtected, protectedHeld, settleBranch,
63
+ } from '../lib/finish-check.mjs';
64
+ import {
65
+ denyRules, notAllowed, protectedBase, protectedChanges, protectedLine, protectedPolicy,
66
+ } from '../lib/protected.mjs';
58
67
 
59
68
  // --- configuration -----------------------------------------------------------
60
69
 
@@ -407,7 +416,7 @@ async function readConfig() {
407
416
  * credential does not belong in one.
408
417
  */
409
418
  token: process.env.CAWDEV_TOKEN ?? file.token ?? (await loadToken(url)),
410
- name: process.env.CAWDEV_RUNNER_NAME ?? file.name ?? DEFAULTS.name,
419
+ name: runnerNameOf(file),
411
420
  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,
412
421
  // Which projects this runner serves, and where their working copies are.
413
422
  projects: normaliseProjects(file.projects ?? {}),
@@ -438,6 +447,21 @@ async function readConfig() {
438
447
  * none. Raw here; `main` probes whether this machine can apply it.
439
448
  */
440
449
  sessionMemoryMax: file.sessionMemoryMax ?? null,
450
+ /**
451
+ * R565. Bitbucket Cloud has no CLI, so this machine calls its API with a
452
+ * token it keeps. `tokenEnv` names the variable the token is read from
453
+ * (never the file: a config sits beside code); `email` is the Atlassian
454
+ * account for an API token, absent for a workspace or repository access
455
+ * token; `apiUrl` is for tests. The variable is removed from every agent's
456
+ * environment — see `agentEnv`.
457
+ *
458
+ * "bitbucket": { "email": "you@example.com", "tokenEnv": "BITBUCKET_TOKEN" }
459
+ */
460
+ bitbucket: {
461
+ email: file.bitbucket?.email ?? null,
462
+ tokenEnv: file.bitbucket?.tokenEnv ?? 'BITBUCKET_TOKEN',
463
+ apiUrl: file.bitbucket?.apiUrl ?? null,
464
+ },
441
465
  skillCache: file.skillCache ?? DEFAULTS.skillCache,
442
466
  skillPrepareSeconds: file.skillPrepareSeconds ?? DEFAULTS.skillPrepareSeconds,
443
467
  idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
@@ -506,6 +530,10 @@ async function readConfig() {
506
530
  * "cawdev": { "workspaces": ["/…/ws-1", "/…/ws-2", "/…/ws-3"] }
507
531
  * "cawdev": { "workspaces": [...], "quality": { "command": "sonar-scanner", "tokenEnv": "SONAR_TOKEN" } }
508
532
  *
533
+ * (R565: a machine with a Bitbucket project also has a top-level
534
+ * `"bitbucket": { "email": "…", "tokenEnv": "BITBUCKET_TOKEN" }` block, beside
535
+ * `projects` and not inside one — see `readConfig`.)
536
+ *
509
537
  * R300: the fourth form adds a `quality` block — see `qualityBlockOf` — and a
510
538
  * project with one has its branches scanned after a coding run's TEST stage.
511
539
  * Captured here at boot, not hot-swapped: adding or changing the block needs
@@ -648,6 +676,16 @@ function qualityBlockOf(slug, block) {
648
676
  * what most skills will want.
649
677
  */
650
678
  const SKILLS_HERE = {
679
+ /**
680
+ * R319. A Java process whose jar, token and server URL are all this
681
+ * machine's: see `sonarqubeLaunch`. `when` is the sentence the prompt gives
682
+ * the session about when to reach for it.
683
+ */
684
+ sonarqube: {
685
+ launch: 'sonarqube',
686
+ when: 'Before drafting a fix for a SonarQube finding, read the rule with `show_rule` '
687
+ + '(and the issue or hotspot it came from) instead of guessing from its one-line message.',
688
+ },
651
689
  codegraph: {
652
690
  /**
653
691
  * The npm package is a thin shim: the real artifact is a per-platform
@@ -745,7 +783,10 @@ async function api(config, path, { method = 'GET', body, token } = {}) {
745
783
  const text = await response.text();
746
784
  const parsed = text ? safeJson(text) : null;
747
785
  if (!response.ok) {
748
- throw new Error(parsed?.message ?? `${method} ${path} -> ${response.status}`);
786
+ const error = new Error(parsed?.message ?? `${method} ${path} -> ${response.status}`);
787
+ // i267: the caller can tell a refusal (4xx) from a platform that is down.
788
+ error.status = response.status;
789
+ throw error;
749
790
  }
750
791
  return parsed;
751
792
  }
@@ -1198,7 +1239,7 @@ async function prepareSkillIndex(config, skill, local, forProject, cwd, baseComm
1198
1239
  try {
1199
1240
  const built = await runSkillCommand(skill.command, buildArgsFor(skill, local), {
1200
1241
  cwd,
1201
- env: { ...process.env, ...(local.env ?? {}) },
1242
+ env: agentEnv(config, local.env ?? {}),
1202
1243
  timeoutMs: config.skillPrepareSeconds * 1000,
1203
1244
  });
1204
1245
 
@@ -1410,7 +1451,7 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
1410
1451
  const attached = [];
1411
1452
  const notes = [];
1412
1453
 
1413
- for (const skill of asked) {
1454
+ for (let skill of asked) {
1414
1455
  if (!skill?.key || !skill?.command || !Array.isArray(skill.args)) {
1415
1456
  notes.push(`A skill arrived that this daemon cannot read (${JSON.stringify(skill)}). `
1416
1457
  + 'It has not been attached.');
@@ -1425,7 +1466,21 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
1425
1466
  + 'should look at the skill row.');
1426
1467
  }
1427
1468
 
1428
- const local = SKILLS_HERE[skill.key] ?? {};
1469
+ let local = SKILLS_HERE[skill.key] ?? {};
1470
+ if (local.launch === 'sonarqube') {
1471
+ const storagePath = skillCacheFor(config, skill, run.projectSlug);
1472
+ await mkdir(storagePath, { recursive: true }).catch(() => { });
1473
+ const launch = sonarqubeLaunch({
1474
+ skill, quality: config.projects?.[run.projectSlug]?.quality, sonarUrl: run.sonarUrl,
1475
+ env: process.env, storagePath,
1476
+ });
1477
+ if (launch.skip) {
1478
+ notes.push(launch.skip);
1479
+ continue;
1480
+ }
1481
+ skill = { ...skill, args: launch.args };
1482
+ local = { ...local, env: launch.env };
1483
+ }
1429
1484
  if (local.indexDir) {
1430
1485
  notes.push(await prepareSkillIndex(config, skill, local, run, cwd, baseCommit));
1431
1486
  }
@@ -1857,6 +1912,38 @@ async function prepareMerge(cwd, branch, defaultBranch) {
1857
1912
  };
1858
1913
  }
1859
1914
 
1915
+ /**
1916
+ * Each live run's protected-file guard — R562: `{ globs, mode, allowed, base }`.
1917
+ *
1918
+ * Fixed once, after the working copy is prepared, and read at every door a run
1919
+ * can finish through. A map rather than one more parameter on a dozen
1920
+ * signatures; entries are dropped when the run's last word is said.
1921
+ */
1922
+ const protectedGuards = new Map();
1923
+
1924
+ /** The guard `settleBranch` and the merge push judge by, or null when nothing is protected. */
1925
+ async function guardOf(config, run) {
1926
+ const guard = protectedGuards.get(run.id);
1927
+ if (!guard?.globs.length || !guard.base) {
1928
+ return null;
1929
+ }
1930
+ // The claim's `allowed` is a snapshot; a person may have allowed a change
1931
+ // since. Read the run's own ALLOWED protected approvals fresh, or an ASK-mode
1932
+ // run that was allowed would still fail here.
1933
+ const allowed = new Set(guard.allowed ?? []);
1934
+ try {
1935
+ const asked = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/approvals`);
1936
+ for (const approval of asked ?? []) {
1937
+ if (approval?.state === 'ALLOWED' && Array.isArray(approval.protectedPaths)) {
1938
+ approval.protectedPaths.forEach((path) => allowed.add(path));
1939
+ }
1940
+ }
1941
+ } catch {
1942
+ // The claim's list stands.
1943
+ }
1944
+ return { ...guard, allowed: [...allowed] };
1945
+ }
1946
+
1860
1947
  /**
1861
1948
  * What the session left behind — R155, and it is judged strictly.
1862
1949
  *
@@ -1866,7 +1953,7 @@ async function prepareMerge(cwd, branch, defaultBranch) {
1866
1953
  * knowable; reporting it as success would land conflict markers, which is the
1867
1954
  * one way this feature could do real damage.
1868
1955
  */
1869
- async function finishMerge(cwd, branch) {
1956
+ async function finishMerge(cwd, branch, guard = null) {
1870
1957
  const stillConflicted = await git(cwd, ['diff', '--name-only', '--diff-filter=U'])
1871
1958
  .then((said) => said.split('\n').map((line) => line.trim()).filter(Boolean))
1872
1959
  .catch(() => []);
@@ -1887,6 +1974,16 @@ async function finishMerge(cwd, branch) {
1887
1974
  .catch((failure) => log(` could not commit the resolution: ${failure.message.split('\n')[0]}`));
1888
1975
  }
1889
1976
 
1977
+ // R562. The resolution is judged against the base the merge started from,
1978
+ // before anything is pushed. Allowances are the branch's, and a merge run is
1979
+ // on the same branch as the coding run that was allowed them.
1980
+ const held = await heldProtected(cwd, guard)
1981
+ .catch((failure) => [`(the branch could not be checked: ${failure.message.split('\n')[0]})`]);
1982
+ if (held.length) {
1983
+ log(` not pushed: ${protectedHeld(held)}`);
1984
+ return { pushed: false, mergeCommit: null, files: [], held };
1985
+ }
1986
+
1890
1987
  const pushed = await pushBranch(cwd, branch);
1891
1988
  if (!pushed.ok) {
1892
1989
  log(` could not push ${branch}: ${pushed.result.split('\n')[0]}`);
@@ -1912,7 +2009,7 @@ async function finishMerge(cwd, branch) {
1912
2009
  * arrives second; see `RunService.settleAgentMerge`.
1913
2010
  */
1914
2011
  async function finishTheMerge(config, run, cwd, branch, said) {
1915
- const left = await finishMerge(cwd, branch);
2012
+ const left = await finishMerge(cwd, branch, await guardOf(config, run));
1916
2013
  log(left.pushed
1917
2014
  ? ` ${branch} pushed at ${String(left.mergeCommit ?? '').slice(0, 7)}`
1918
2015
  : ` nothing was pushed: the conflict on ${branch} is still there`);
@@ -1935,7 +2032,9 @@ async function finishTheMerge(config, run, cwd, branch, said) {
1935
2032
  if (current?.live) {
1936
2033
  await finish(config, run, left.pushed ? 'FINISHED' : 'FAILED', left.pushed
1937
2034
  ? `Merged and pushed ${branch}.`
1938
- : `The conflict on ${branch} was not resolved; nothing was pushed.`);
2035
+ : left.held
2036
+ ? protectedHeld(left.held)
2037
+ : `The conflict on ${branch} was not resolved; nothing was pushed.`);
1939
2038
  }
1940
2039
  }
1941
2040
 
@@ -2606,9 +2705,16 @@ async function scanQuality(config, run, cwd, branch) {
2606
2705
  workDir = await mkdtemp(join(tmpdir(), 'cawdev-sonar-'));
2607
2706
  const deadline = Date.now() + quality.timeoutSeconds * 1000;
2608
2707
  const remaining = () => Math.max(1000, deadline - Date.now());
2708
+ // R587: the version the CODE at this checkout declares. Never a guess and
2709
+ // never `git describe`: a version that moves per commit would move
2710
+ // SonarQube's new-code baseline at every scan.
2711
+ const found = await workspaceVersion(cwd, run.versionFiles);
2712
+ const version = found?.version ?? null;
2713
+ log(versionLogLine(found, run.versionFiles));
2609
2714
  const scannerArgs = (withBranch) => [
2610
2715
  `-Dsonar.host.url=${url}`,
2611
2716
  `-Dsonar.projectKey=${key}`,
2717
+ ...(version ? [`-Dsonar.projectVersion=${version}`] : []),
2612
2718
  ...(withBranch ? [`-Dsonar.branch.name=${branch}`] : []),
2613
2719
  `-Dsonar.scm.revision=${headSha}`,
2614
2720
  `-Dsonar.working.directory=${workDir}`,
@@ -2629,7 +2735,7 @@ async function scanQuality(config, run, cwd, branch) {
2629
2735
  // function's own try/catch, which `return somePromise` would skip —
2630
2736
  // control leaves before the promise settles.
2631
2737
  return await scanAsScratchProject(
2632
- { quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
2738
+ { quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report, version });
2633
2739
  }
2634
2740
  // 'main', and known refused: no reason to run the branch scan first.
2635
2741
  const ran = await runSkillCommand(quality.command, scannerArgs(false), { cwd, env, timeoutMs: remaining() });
@@ -2662,7 +2768,7 @@ async function scanQuality(config, run, cwd, branch) {
2662
2768
  if (/sonar\.branch\.name.*(Developer Edition|above is required)/is.test(ran.said)) {
2663
2769
  if (quality.whenBranchesRefused === 'project') {
2664
2770
  return await scanAsScratchProject(
2665
- { quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
2771
+ { quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report, version });
2666
2772
  }
2667
2773
  if (quality.whenBranchesRefused !== 'main') {
2668
2774
  return report({
@@ -2704,41 +2810,6 @@ function trimSlash(url) {
2704
2810
  return url.replace(/\/$/, '');
2705
2811
  }
2706
2812
 
2707
- /** A GET against the SonarQube server a scan just reported to, authorised with the scanner's own token. */
2708
- function sonarAsker(serverUrl, token) {
2709
- return async (path) => {
2710
- const response = await fetch(`${serverUrl}${path}`, { headers: { authorization: `Bearer ${token}` } });
2711
- if (!response.ok) {
2712
- throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
2713
- }
2714
- return response.json();
2715
- };
2716
- }
2717
-
2718
- /**
2719
- * Waits for a queued analysis to settle — shared by every scan path, BRANCH,
2720
- * MAIN and PROJECT alike. Throws rather than reports: every caller runs
2721
- * inside `scanQuality`'s own try/catch, which turns it into an error reading.
2722
- */
2723
- async function waitForAnalysis(ask, ceTaskId, deadline, timeoutSeconds) {
2724
- let settled = null;
2725
- while (Date.now() < deadline) {
2726
- const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`);
2727
- if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
2728
- settled = current;
2729
- break;
2730
- }
2731
- await new Promise((wake) => setTimeout(wake, 3000));
2732
- }
2733
- if (!settled) {
2734
- throw new Error(`SonarQube had not finished analysing after ${timeoutSeconds}s.`);
2735
- }
2736
- if (settled.status !== 'SUCCESS') {
2737
- throw new Error(settled.errorMessage ?? `SonarQube ${settled.status.toLowerCase()} the analysis.`);
2738
- }
2739
- return settled;
2740
- }
2741
-
2742
2813
  /** The gate and the new-code measures, for a BRANCH or MAIN reading — never for PROJECT, which has neither. */
2743
2814
  async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report }) {
2744
2815
  const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
@@ -2805,7 +2876,14 @@ function shortHash(text) {
2805
2876
  * always `NONE`: a quality gate over a whole scratch project would judge
2806
2877
  * code this branch did not write.
2807
2878
  */
2808
- async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report }) {
2879
+ /** The one line the scan logs about its version: found, declared but empty, or never declared (R587). */
2880
+ function versionLogLine(found, declared) {
2881
+ if (found) return ` quality: version ${found.version} (${found.file})`;
2882
+ if (declared?.length > 0) return ' quality: no version found in the declared version files: scanned without a version';
2883
+ return ' quality: no version file declared: scanned without a version';
2884
+ }
2885
+
2886
+ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report, version = null }) {
2809
2887
  const baseName = run.baseBranch ?? run.defaultBranch ?? 'main';
2810
2888
  const ref = await firstRef(cwd, [`origin/${baseName}`, baseName]);
2811
2889
  const mergeBase = ref ? await git(cwd, ['merge-base', 'HEAD', ref]).catch(() => null) : null;
@@ -2819,6 +2897,7 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
2819
2897
  `-Dsonar.host.url=${url}`,
2820
2898
  `-Dsonar.projectKey=${scratchKey}`,
2821
2899
  `-Dsonar.projectName=${key} · ${branch}`,
2900
+ ...(version ? [`-Dsonar.projectVersion=${version}`] : []),
2822
2901
  `-Dsonar.scm.revision=${headSha}`,
2823
2902
  `-Dsonar.working.directory=${workDir}`,
2824
2903
  ];
@@ -2878,18 +2957,6 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
2878
2957
  });
2879
2958
  }
2880
2959
 
2881
- /** `key=value` lines — the shape of the scanner's `report-task.txt`. */
2882
- function parseProperties(text) {
2883
- const parsed = {};
2884
- for (const line of text.split('\n')) {
2885
- const at = line.indexOf('=');
2886
- if (at > 0) {
2887
- parsed[line.slice(0, at).trim()] = line.slice(at + 1).trim();
2888
- }
2889
- }
2890
- return parsed;
2891
- }
2892
-
2893
2960
  /**
2894
2961
  * Reads and reports the code map, unless it would say the same thing again.
2895
2962
  *
@@ -3032,6 +3099,12 @@ async function firstRef(path, candidates) {
3032
3099
  * turn.
3033
3100
  */
3034
3101
  function briefLine(run) {
3102
+ // R562. Every prompt that names the brief names the protected files first, so
3103
+ // a session checks its plan against the list before it starts.
3104
+ return (run.protectedLine ? `${run.protectedLine}\n\n` : '') + briefIndexLine(run);
3105
+ }
3106
+
3107
+ function briefIndexLine(run) {
3035
3108
  const index = run.brief?.index ?? BRIEF.index;
3036
3109
  const text = run.brief?.indexText;
3037
3110
 
@@ -3098,6 +3171,9 @@ If a decision is genuinely theirs, use the cawdev MCP tool \`ask_user\` and wait
3098
3171
 
3099
3172
  ${run.branch ? `When what they asked is done, commit it on ${run.branch} — work left
3100
3173
  uncommitted is work nothing can push or propose. Never commit to or push main.
3174
+ You may commit on ${run.branch} and push it, and nothing else. Branches, pull
3175
+ requests and merges are opened from cawdev: there is no \`gh\` or \`glab\` in this
3176
+ session, and a push to any other branch is refused.
3101
3177
 
3102
3178
  ` : ''}Their instruction:
3103
3179
 
@@ -3137,11 +3213,16 @@ task, for context only. \`task_current\` carries the same note.
3137
3213
  with no right answer, something the entry does not settle — call \`ask_user\`
3138
3214
  and wait. Do not guess and carry on. Do not use it to check work you can
3139
3215
  check yourself.
3140
- 5. Finish with \`report\` kind "done", naming the branch and, if this repository
3141
- has a remote, the pushed branch or PR. If something stops you that a person
3142
- must resolve, use kind "blocked" instead and say what is in the way.
3216
+ 5. ${SUGGEST_ELSEWHERE}
3217
+ 6. Finish with \`report\` kind "done", naming the branch and, if this repository
3218
+ has a remote, whether the branch is pushed. If something stops you that a
3219
+ person must resolve, use kind "blocked" instead and say what is in the way.
3220
+
3221
+ Commit your work. Never push to main.
3143
3222
 
3144
- Commit your work. Never push to main.`;
3223
+ You may commit on ${run.branch} and push it, and nothing else. Branches, pull
3224
+ requests and merges are opened from cawdev: there is no \`gh\` or \`glab\` in this
3225
+ session, and a push to any other branch is refused.`;
3145
3226
  }
3146
3227
 
3147
3228
  // --- the transcript ----------------------------------------------------------
@@ -4154,6 +4235,21 @@ async function openPullRequest(cwd, run, title) {
4154
4235
  }
4155
4236
  }
4156
4237
 
4238
+ // R565. A release's pull request is cawdev's to open, not the session's. The
4239
+ // host's own default is the base — a release is not stacked on a sprint — and
4240
+ // the body carries the one thing the person merging it must not get wrong.
4241
+ if (run.profile === 'RELEASE') {
4242
+ return openPullRequestFor(cwd, {
4243
+ declared: run.gitHost ?? null,
4244
+ branch: run.branch,
4245
+ base: null,
4246
+ title: run.label || run.branch,
4247
+ body: 'Release opened by cawdev. Merge with a MERGE COMMIT, not a squash: a squash rewrites '
4248
+ + 'the commit and leaves the tag pointing at something that is not on the default branch. '
4249
+ + 'A release branch is not merged from the development board.',
4250
+ });
4251
+ }
4252
+
4157
4253
  return openPullRequestFor(cwd, {
4158
4254
  declared: run.gitHost ?? null,
4159
4255
  branch: run.branch,
@@ -4246,20 +4342,20 @@ async function openPullRequestFor(cwd, { branch, base, title, body, declared = n
4246
4342
  }
4247
4343
  return {
4248
4344
  ok: false,
4249
- result: `${adapter.bin} said it opened ${printed}, but the host does not show a ${adapter.noun} for ${branch}.`,
4345
+ result: `${adapter.bin ?? adapter.name} said it opened ${printed}, but the host does not show a ${adapter.noun} for ${branch}.`,
4250
4346
  };
4251
4347
  }
4252
4348
  // The one failure somebody can fix in a minute, said with the fix — and the
4253
4349
  // link to open it by hand meanwhile, which `findPullRequest` builds without the CLI.
4254
- if (opened.code === -1 && /not installed/.test(opened.err)) {
4350
+ if (opened.code === -1) {
4255
4351
  const compare = await findPullRequest(cwd, branch, { declared, base }).catch(() => null);
4256
4352
  return {
4257
4353
  ok: false,
4258
- result: `${opened.err} Install it and sign in (${adapter.installHint}), `
4354
+ result: `${opened.err} Set it up (${adapter.installHint}), `
4259
4355
  + `then ask again${compare ? ` — or open it yourself: ${compare}` : '.'}`,
4260
4356
  };
4261
4357
  }
4262
- return { ok: false, result: opened.err || opened.out || `${adapter.bin} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} create failed without saying why.` };
4358
+ return { ok: false, result: opened.err || opened.out || `${adapter.bin ?? adapter.name} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} create failed without saying why.` };
4263
4359
  }
4264
4360
 
4265
4361
  /**
@@ -4350,7 +4446,7 @@ async function mergePullRequest(cwd, branch, { method = null, declared = null }
4350
4446
  failure: await classifyMergeFailure(cwd, url, merged, adapter),
4351
4447
  // Unchanged: `gh`'s own words still reach the card. The classification is a
4352
4448
  // word beside them, never a replacement for them.
4353
- result: merged.err || merged.out || `${adapter.bin} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} merge failed without saying why.`,
4449
+ result: merged.err || merged.out || `${adapter.bin ?? adapter.name} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} merge failed without saying why.`,
4354
4450
  };
4355
4451
  }
4356
4452
 
@@ -4575,6 +4671,9 @@ async function classifyMergeFailure(cwd, url, merged, adapter) {
4575
4671
  * produced, and if the platform has more to say it can say it to the next run.
4576
4672
  */
4577
4673
  async function settleActions(config, run, cwd) {
4674
+ // First, so a stray branch is on the run's transcript before cawdev queues
4675
+ // its own pull request on top of it.
4676
+ await auditPushes(config, run, cwd).catch((failure) => log(` could not audit the pushes: ${failure.message}`));
4578
4677
  const actions = await api(
4579
4678
  config,
4580
4679
  `/api/projects/${run.projectSlug}/runs/${run.id}/actions/claim`,
@@ -4611,62 +4710,96 @@ async function settleActions(config, run, cwd) {
4611
4710
  /** How often the merge pass runs, in minutes. A merge is a human-timescale event. */
4612
4711
  const MERGE_CHECK_MINUTES = 10;
4613
4712
 
4713
+ /** `Map<ref, sha>` of the remote's heads and tags, or null when it cannot be read. */
4714
+ async function remoteRefs(cwd) {
4715
+ const listed = await git(cwd, ['ls-remote', '--heads', '--tags', 'origin'], { timeoutMs: 60_000 });
4716
+ const refs = new Map();
4717
+ for (const line of listed.split('\n')) {
4718
+ const [sha, ref] = line.trim().split(/\s+/);
4719
+ // An annotated tag lists twice; the peeled `^{}` line is the commit.
4720
+ if (sha && ref && !ref.endsWith('^{}')) {
4721
+ refs.set(ref, sha);
4722
+ }
4723
+ }
4724
+ return refs;
4725
+ }
4726
+
4614
4727
  /**
4615
- * What `gh` says about the branch's pull request.
4728
+ * R565. After a writing session, say so when it pushed anything but its own
4729
+ * branch — for every agent CLI, because only Claude Code's hook can refuse it
4730
+ * on the way. A new or moved ref is the session's when its commit exists in this
4731
+ * checkout AND is on a local branch: that keeps another person's concurrent
4732
+ * push, whose objects we never fetched, from being blamed on the session.
4733
+ *
4734
+ * Deletions cannot be attributed to the session (a teammate may have merged
4735
+ * and deleted a branch in the meantime), so they are not flagged.
4736
+ */
4737
+ async function auditPushes(config, run, cwd) {
4738
+ const before = run.remoteBefore;
4739
+ if (!before || !run.branch || !cwd) {
4740
+ return;
4741
+ }
4742
+ run.remoteBefore = null; // once: settleActions is reached from several paths
4743
+ const now = await remoteRefs(cwd);
4744
+ for (const [ref, sha] of now) {
4745
+ if (ref === `refs/heads/${run.branch}` || before.get(ref) === sha) {
4746
+ continue;
4747
+ }
4748
+ if (run.profile === 'RELEASE' && ref.startsWith('refs/tags/')) {
4749
+ continue;
4750
+ }
4751
+ const here = await git(cwd, ['cat-file', '-e', `${sha}^{commit}`]).then(() => true, () => false);
4752
+ if (!here) {
4753
+ continue;
4754
+ }
4755
+ const onBranch = (await git(cwd, ['for-each-ref', '--contains', sha, 'refs/heads']).catch(() => '')).trim();
4756
+ if (!onBranch) {
4757
+ continue;
4758
+ }
4759
+ log(` !! this session pushed ${ref}, outside its branch ${run.branch}`);
4760
+ await sayOnTheRun(config, run,
4761
+ `This session pushed \`${ref}\`, which is outside its branch \`${run.branch}\`. `
4762
+ + 'Branches, pull requests and merges are opened from cawdev.');
4763
+ }
4764
+ }
4765
+
4766
+ /**
4767
+ * What the git host says about the branch's pull request — through the host's
4768
+ * own adapter (`gh`, `glab` or Bitbucket's API), R565.
4616
4769
  *
4617
4770
  * The pull request URL is preferred over the branch name when we have a real
4618
- * one, because `gh pr view <branch>` stops finding anything once the branch is
4619
- * deleted — which is precisely when this question gets interesting. A URL keeps
4620
- * answering after the branch it belonged to is gone.
4771
+ * one, because looking a pull request up by branch stops finding anything once
4772
+ * the branch is deleted — which is precisely when this question gets
4773
+ * interesting. A URL keeps answering after the branch it belonged to is gone.
4621
4774
  *
4622
4775
  * A `/compare/` URL is not a pull request: `findPullRequest` falls back to one
4623
- * as a link somebody can click, and asking `gh` about it would be asking about
4624
- * a page that does not exist.
4776
+ * as a link somebody can click, and asking the host about it would be asking
4777
+ * about a page that does not exist.
4625
4778
  */
4626
- async function askGitHub(cwd, branch, prUrl) {
4779
+ async function askHost(cwd, branch, prUrl, declared = null) {
4627
4780
  const target = prUrl && !prUrl.includes('/compare/') ? prUrl : branch;
4628
4781
  if (!target) {
4629
4782
  return null;
4630
4783
  }
4631
- const out = await new Promise((resolvePromise) => {
4632
- const child = spawn(
4633
- 'gh',
4634
- ['pr', 'view', target, '--json', 'state,mergedAt,mergeCommit'],
4635
- { cwd, stdio: ['ignore', 'pipe', 'ignore'] },
4636
- );
4637
- let text = '';
4638
- child.stdout.on('data', (chunk) => (text += chunk));
4639
- child.on('error', () => resolvePromise(null));
4640
- // `close`, not `exit` — see git() above.
4641
- child.on('close', (code) => resolvePromise(code === 0 ? text.trim() : null));
4642
- });
4643
- if (!out) {
4644
- return null;
4645
- }
4646
-
4647
- const parsed = safeJson(out);
4648
- if (!parsed?.state) {
4649
- return null;
4650
- }
4651
- // gh's states are OPEN, CLOSED and MERGED. CLOSED here means closed WITHOUT
4652
- // merging — gh reports a merged PR as MERGED, never as CLOSED — which is the
4653
- // distinction worth keeping: it is a card that needs a person, not a status
4654
- // change.
4655
- const state = { OPEN: 'OPEN', MERGED: 'MERGED', CLOSED: 'CLOSED' }[parsed.state];
4656
- if (!state) {
4784
+ const { adapter } = await hostOf(cwd, declared);
4785
+ const viewed = await adapter.view(cwd, target);
4786
+ // OPEN, CLOSED and MERGED. CLOSED here means closed WITHOUT merging — a merged
4787
+ // pull request is MERGED, never CLOSED — which is the distinction worth
4788
+ // keeping: it is a card that needs a person, not a status change.
4789
+ if (!viewed?.state) {
4657
4790
  return null;
4658
4791
  }
4659
4792
  return {
4660
- state,
4661
- mergeCommit: parsed.mergeCommit?.oid ?? null,
4662
- mergedAt: parsed.mergedAt ?? null,
4793
+ state: viewed.state,
4794
+ mergeCommit: viewed.mergeCommit ?? null,
4795
+ mergedAt: viewed.mergedAt ?? null,
4663
4796
  };
4664
4797
  }
4665
4798
 
4666
4799
  /**
4667
- * What `gh` says about a hand-taken branch's pull request — R346.
4800
+ * What the host says about a hand-taken branch's pull request — R346.
4668
4801
  *
4669
- * `pr list --head`, not `pr view <branch>`: the latter stops finding the pull
4802
+ * Listed by head, not viewed by branch: the latter stops finding the pull
4670
4803
  * request once the branch is deleted, and this matches on the pull request's own
4671
4804
  * head branch name, which does not change. Fork pull requests are dropped: a
4672
4805
  * fork's branch of the same name is not this branch.
@@ -4675,23 +4808,12 @@ async function askGitHub(cwd, branch, prUrl) {
4675
4808
  * reused branch name can bring back an older one, which the platform also
4676
4809
  * refuses. Otherwise the highest-numbered row speaks.
4677
4810
  */
4678
- async function askGitHubForBranch(cwd, branch, openedAt) {
4811
+ async function askHostForBranch(cwd, branch, openedAt, declared = null) {
4679
4812
  if (!branch) {
4680
4813
  return null;
4681
4814
  }
4682
- const out = await new Promise((resolvePromise) => {
4683
- const child = spawn(
4684
- 'gh',
4685
- ['pr', 'list', '--head', branch, '--state', 'all', '--limit', '10', '--json',
4686
- 'number,url,state,mergedAt,mergeCommit,baseRefName,headRefName,isCrossRepository'],
4687
- { cwd, stdio: ['ignore', 'pipe', 'ignore'] },
4688
- );
4689
- let text = '';
4690
- child.stdout.on('data', (chunk) => (text += chunk));
4691
- child.on('error', () => resolvePromise(null));
4692
- child.on('close', (code) => resolvePromise(code === 0 ? text.trim() : null));
4693
- });
4694
- const parsed = out ? safeJson(out) : null;
4815
+ const { adapter } = await hostOf(cwd, declared);
4816
+ const parsed = await adapter.listByHead(cwd, branch);
4695
4817
  if (!Array.isArray(parsed)) {
4696
4818
  return null;
4697
4819
  }
@@ -4707,14 +4829,13 @@ async function askGitHubForBranch(cwd, branch, openedAt) {
4707
4829
  if (chosen.state === 'MERGED' && !merged.length) {
4708
4830
  return null; // Only a merge from before the item existed: not this item's.
4709
4831
  }
4710
- const state = { OPEN: 'OPEN', MERGED: 'MERGED', CLOSED: 'CLOSED' }[chosen.state];
4711
- if (!state) {
4832
+ if (!chosen.state) {
4712
4833
  return null;
4713
4834
  }
4714
4835
  return {
4715
- state,
4836
+ state: chosen.state,
4716
4837
  prUrl: chosen.url ?? null,
4717
- mergeCommit: chosen.mergeCommit?.oid ?? null,
4838
+ mergeCommit: chosen.mergeCommit ?? null,
4718
4839
  mergedAt: chosen.mergedAt ?? null,
4719
4840
  baseRefName: chosen.baseRefName ?? null,
4720
4841
  };
@@ -4776,7 +4897,7 @@ async function askGit(cwd, head, defaultBranch) {
4776
4897
  /**
4777
4898
  * What became of one branch.
4778
4899
  *
4779
- * `gh` first, because it is the only thing that can see a SQUASH merge: a
4900
+ * The host first, because it is the only thing that can see a SQUASH merge: a
4780
4901
  * squashed branch's commits never appear on the default branch at all, so the
4781
4902
  * ancestry test below would confidently and wrongly say "not merged".
4782
4903
  *
@@ -4784,8 +4905,8 @@ async function askGit(cwd, head, defaultBranch) {
4784
4905
  * that has left the remote. Proving a merge is possible; disproving one is not,
4785
4906
  * and UNKNOWN is the honest answer rather than a guess dressed as a reading.
4786
4907
  */
4787
- async function readMergeState(cwd, { branch, defaultBranch, baseBranch, head, prUrl }) {
4788
- const viaGh = await askGitHub(cwd, branch, prUrl).catch(() => null);
4908
+ async function readMergeState(cwd, { branch, defaultBranch, baseBranch, head, prUrl, gitHost = null }) {
4909
+ const viaGh = await askHost(cwd, branch, prUrl, gitHost).catch(() => null);
4789
4910
  if (viaGh) {
4790
4911
  return viaGh;
4791
4912
  }
@@ -4901,7 +5022,7 @@ async function checkRunMerges(config, pending, fetched) {
4901
5022
 
4902
5023
  /**
4903
5024
  * R346: the hand-taken items. A squash or rebase merge leaves no head on the
4904
- * default branch for the git reading to find, so ask `gh` about the pull
5025
+ * default branch for the git reading to find, so ask the host about the pull
4905
5026
  * request. An older platform answers 404 and this returns in silence.
4906
5027
  */
4907
5028
  async function checkWorkItemMerges(config, fetched) {
@@ -4932,7 +5053,7 @@ async function checkWorkItemMerges(config, fetched) {
4932
5053
  fetched.add(cwd);
4933
5054
  await git(cwd, ['fetch', '--prune', 'origin']).catch(() => null);
4934
5055
  }
4935
- const reading = await askGitHubForBranch(cwd, item.branch, item.openedAt).catch(() => null);
5056
+ const reading = await askHostForBranch(cwd, item.branch, item.openedAt, item.gitHost ?? null).catch(() => null);
4936
5057
  readings.push({
4937
5058
  workItemId: item.workItemId,
4938
5059
  state: reading?.state ?? 'UNKNOWN',
@@ -5216,9 +5337,13 @@ const PROFILE_TOOLS = {
5216
5337
  // it was asked to judge — and there is still no set_status here, because
5217
5338
  // `DONE` is the person's word (R74) and a reviewer that could move the card
5218
5339
  // would be giving the verdict it is explicitly told not to give.
5340
+ //
5341
+ // R572. And `backlog_file`: what the reviewer notices that is not this
5342
+ // card's is a suggestion for a person, not a finding on this card.
5219
5343
  REVIEW: [
5220
5344
  ...READ_ONLY_CAWDEV,
5221
5345
  'mcp__cawdev__roadmap_comment',
5346
+ SUGGEST,
5222
5347
  ...READ_FILES,
5223
5348
  ...GIT_READS,
5224
5349
  ],
@@ -5267,10 +5392,15 @@ const PROFILE_TOOLS = {
5267
5392
  // R302. Less the reads that need the work to exist — `quality_get` — the
5268
5393
  // same narrowing `toolsForStage` applies to a walk's PLAN stage, by the same
5269
5394
  // name: a plan run spawned as one process comes through here and not there.
5395
+ //
5396
+ // R572. `backlog_file` too, on every plan run: a planner reads more of the
5397
+ // code than anybody and finds what is not this card's. It files a pending
5398
+ // suggestion and nothing else; see `SUGGEST` in stage-tools.mjs.
5270
5399
  PLAN: (run) => [
5271
5400
  ...beforeTheWorkExists(READ_ONLY_CAWDEV),
5272
5401
  ...READ_FILES,
5273
5402
  ...GIT_READS,
5403
+ SUGGEST,
5274
5404
  ...(run?.entryNumber ? [] : [CARD_WRITE]),
5275
5405
  ],
5276
5406
  // R96. Read the whole repository, ask in rounds, write the brief, commit it.
@@ -5315,11 +5445,11 @@ const PROFILE_TOOLS = {
5315
5445
  // release may genuinely need to ask. Nothing that creates a card, proposes
5316
5446
  // one, or comments.
5317
5447
  //
5318
- // The shell is `git` in full — it commits, tags and pushes — `gh pr create`
5319
- // and `gh pr view`, and `node` for the export scripts. NOT `Bash(gh *)`: it
5320
- // can open the release's pull request and it cannot merge it, and that is a
5321
- // permission rather than a sentence in the prompt. A release is merged by a
5322
- // person, with a merge commit, because a squash orphans the tag.
5448
+ // The shell is `git` in full — it commits, tags and pushes — and `node` for
5449
+ // the export scripts. No `gh` at all (R565): cawdev opens the release's pull
5450
+ // request itself when the session ends, from the branch it pushed, and
5451
+ // `push-guard.mjs` refuses a push to any other branch. A release is merged
5452
+ // by a person, with a merge commit, because a squash orphans the tag.
5323
5453
  //
5324
5454
  // And `Write`/`Edit` on the version files the platform named on the claim,
5325
5455
  // and nothing else. `ROADMAP.md`, `ISSUES.md` and `CHANGELOG.md` are
@@ -5332,8 +5462,6 @@ const PROFILE_TOOLS = {
5332
5462
  'mcp__cawdev__roadmap_set_status',
5333
5463
  ...READ_FILES,
5334
5464
  'Bash(git *)',
5335
- 'Bash(gh pr create *)',
5336
- 'Bash(gh pr view *)',
5337
5465
  'Bash(node *)',
5338
5466
  ...releaseWrites(run),
5339
5467
  ],
@@ -5485,6 +5613,43 @@ function argsForProfile(agentArgs, profile, run, expertAgents = [], skills = [])
5485
5613
  * re-derived the plan from a précis would be planning again, which is the one
5486
5614
  * thing the gate exists to stop it doing after somebody approved the first one.
5487
5615
  */
5616
+ /**
5617
+ * R576. A plan that ignores the other unfinished cards produces two plans that
5618
+ * rewrite the same paragraph. The plan stage holds no writer (R108), so the
5619
+ * session says what it found in labelled sections and cawdev applies them when
5620
+ * the plan is recorded.
5621
+ */
5622
+ const CONSIDER_UNFINISHED = `**Look at the other unfinished cards.** Two plans that change the same files
5623
+ collide when the second is built, and a plan written without looking cannot know.
5624
+ Call \`roadmap_list\` for PLANNED, CONFIRMED and IN_DEVELOPMENT (those have plans)
5625
+ and for CONSIDERING and NEW (those have a brief), then \`roadmap_get\` each card
5626
+ that touches the same area or the same files and read its plan.
5627
+ - A card that has started (IN_DEVELOPMENT, or \`roadmap_get\` shows a branch or runs)
5628
+ and has to land first goes on the \`**Comes after:**\` line. Do not ask about it.
5629
+ - A conflict with a card that has not started (BACKLOG, CONSIDERING, PLANNED, NEW,
5630
+ or CONFIRMED with no branch) is the person's to settle: \`ask_user\`, one question
5631
+ per conflicting card, naming only that card's ref — never this card's ref and never
5632
+ two refs, because cawdev finds the answer again by the one ref in the question.
5633
+ Offer choices such as "this card goes after it", "it goes after this card", "leave
5634
+ both, they do not fight". Then record the conflict under \`**Conflicts decided:**\`,
5635
+ and if this card goes after it, add it to \`**Comes after:**\` too.
5636
+ - If \`ask_user\` hands back a \`question_id\`, call \`await_answer\` once. With
5637
+ still no answer, list the conflict under \`**Conflicts undecided:**\` and carry on —
5638
+ nobody may be watching.
5639
+ Write these before the \`**Done when:**\` list, in this shape:
5640
+
5641
+ **Comes after:** R570, i12
5642
+ **Conflicts decided:**
5643
+ - R571 — both rewrite the claim view's prompt paragraph; decided: R571 goes first.
5644
+ **Conflicts undecided:**
5645
+ - R572 — both add a migration for agent_question; nobody answered.
5646
+
5647
+ \`**Comes after:**\` is one line (\`none\` when there is nothing). Each list item starts
5648
+ with the card's ref and says why the two collide. cawdev applies this when the plan
5649
+ is recorded: it sets this card to start after those cards and writes a comment on
5650
+ each conflicting card saying what was decided and by whom. You cannot do either,
5651
+ so do not try.`;
5652
+
5488
5653
  /** Longer than ask_user's own ten-minute wait — see the cawdev server of spawnCodex. */
5489
5654
  const CAWDEV_TOOL_TIMEOUT_SEC = 660;
5490
5655
 
@@ -5524,6 +5689,20 @@ const WAIT_FOR_COMMANDS = 'This stage ends when your turn ends: run every comman
5524
5689
  + '`Agent` with `run_in_background: false` and wait for its answer — an agent still '
5525
5690
  + 'working when your turn ends has its work lost, and the stage is run again.';
5526
5691
 
5692
+ /**
5693
+ * R572. What a session finds that is not its card's goes to the backlog as it
5694
+ * finds it. R470's run wrote a real follow-up into its final report — "a
5695
+ * separate change, not this card's … a person should decide" — and nobody files
5696
+ * from a report. `backlog_file` stamps the item with this run, so the feedback
5697
+ * page links back to the run and its card without the session writing either.
5698
+ */
5699
+ const SUGGEST_ELSEWHERE = 'Something worth doing that is NOT this card\'s — a bug next door, '
5700
+ + 'a fix outside the scope, a follow-up the work revealed — goes to `backlog_file`, one item '
5701
+ + 'per suggestion, when you find it: say what you saw, where in the code, and what should '
5702
+ + 'happen instead. cawdev links it to this run and this card, and a person accepts or '
5703
+ + 'refuses it. Do not widen this card to do it, and do not leave it only in your report: '
5704
+ + 'a suggestion in a report is one nobody files. Your report may name what you filed.';
5705
+
5527
5706
  /**
5528
5707
  * @param hasPlan whether the card has a plan of record — the same test
5529
5708
  * harnessPrompt uses to draw "The plan for this card" (`plan?.body`), so the
@@ -5716,10 +5895,11 @@ this was resolved without opening your transcript.`;
5716
5895
  cawdev platform.
5717
5896
 
5718
5897
  Your permissions, so you do not spend turns discovering them: \`git\` in full;
5719
- \`gh pr create\` and \`gh pr view\`; \`node\`; and Write/Edit on exactly these files —
5898
+ \`node\`; and Write/Edit on exactly these files —
5720
5899
  ${list} — and nothing else. There is no permission prompt in this session: a
5721
- command outside those is refused and nobody is asked. You cannot merge a pull
5722
- request, run a build, or run tests.
5900
+ command outside those is refused and nobody is asked. You cannot open or merge a
5901
+ pull request, run a build, or run tests — cawdev opens the pull request when you
5902
+ finish, from the branch you pushed.
5723
5903
 
5724
5904
  If a decision is genuinely the person's, use \`ask_user\` and wait.
5725
5905
 
@@ -5989,8 +6169,12 @@ card you created a moment ago. cawdev stores what you report as the card's plan
5989
6169
  record when this phase ends, and that is the only copy anybody wants: two plans in
5990
6170
  two places is how one of them goes stale.
5991
6171
 
6172
+ ${CONSIDER_UNFINISHED}
6173
+
5992
6174
  **End with a Done-when list.** ${DONE_WHEN_PLAN}
5993
6175
 
6176
+ **What is not this card's.** ${SUGGEST_ELSEWHERE}
6177
+
5994
6178
  Then \`report\` kind "done" with the plan itself.${asked}`;
5995
6179
  }
5996
6180
 
@@ -6020,6 +6204,8 @@ can find it.
6020
6204
 
6021
6205
  Do not commit, do not push, do not make edits. The branch is not yours to change.
6022
6206
 
6207
+ **What is not this card's.** A finding about THIS card's work is a comment. ${SUGGEST_ELSEWHERE}
6208
+
6023
6209
  Then \`report\` kind "done".`;
6024
6210
  }
6025
6211
 
@@ -6274,7 +6460,23 @@ const running = new Map();
6274
6460
  * uncommitted work and all, until somebody discards the run — they are not
6275
6461
  * free, whatever `running` says, and must not be reset for the next claim.
6276
6462
  */
6277
- const told = { paused: false, autoResume: false, heldWorkspaces: [] };
6463
+ const told = {
6464
+ paused: false, pausedByUsage: false, limits: [], autoResume: false, heldWorkspaces: [], maxPlanningRuns: null,
6465
+ };
6466
+
6467
+ /**
6468
+ * The queued line for a paused machine — R571. Rebuilt only when the KIND of
6469
+ * pause, the window, its percent or its reset moves (`noteKey`), so the minutes
6470
+ * left in the sentence never make `noteQueued` log once per heartbeat.
6471
+ */
6472
+ let pausedNoteCache = { key: null, text: '' };
6473
+ function queuedPauseNote() {
6474
+ const key = noteKey(told);
6475
+ if (pausedNoteCache.key !== key) {
6476
+ pausedNoteCache = { key, text: pausedNote(told, Date.now()) };
6477
+ }
6478
+ return pausedNoteCache.text;
6479
+ }
6278
6480
 
6279
6481
  /**
6280
6482
  * How this daemon paints its own output — R62.
@@ -6496,7 +6698,7 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0,
6496
6698
  return { workspace: free[0], why: null };
6497
6699
  }
6498
6700
 
6499
- function noteQueued(run, why) {
6701
+ function noteQueued(run, why, reported = why) {
6500
6702
  // Once per REASON, not once per run — R70, now that there are two gates a run
6501
6703
  // can be held by. A run refused by the machine's cap and then, when a session
6502
6704
  // ends, by the project's checkouts is waiting for a different thing, and
@@ -6510,7 +6712,8 @@ function noteQueued(run, why) {
6510
6712
  // knowledge that exists nowhere but here. The platform sees a queued run; only
6511
6713
  // this daemon knows it is queued because the checkout is busy rather than
6512
6714
  // because nothing has picked it up.
6513
- noted.set(run.id, { label: run.label, projectSlug: run.projectSlug, why });
6715
+ // `reported` is what the platform is told; null says nothing (a pause: R571).
6716
+ noted.set(run.id, { label: run.label, projectSlug: run.projectSlug, why, reported });
6514
6717
  log(`${why}; leaving "${run.label}" queued`);
6515
6718
  }
6516
6719
 
@@ -6575,6 +6778,9 @@ function snapshotRuns() {
6575
6778
  return runs;
6576
6779
  }
6577
6780
 
6781
+ /** i267: the statuses that mean "this run is not yours now" on the move to RUNNING. */
6782
+ const PLATFORM_REFUSALS = new Set([400, 404, 409, 410]);
6783
+
6578
6784
  async function startRun(config, offered, workspace, source = null) {
6579
6785
  const run = offered.run;
6580
6786
  const project = config.projects[run.projectSlug];
@@ -6619,6 +6825,7 @@ async function startRun(config, offered, workspace, source = null) {
6619
6825
  // The claim carries everything needed to prepare the working copy, so the
6620
6826
  // runner never touches the project API — which is session-only anyway.
6621
6827
  defaultBranch = claimed.defaultBranch;
6828
+ run.pushDefaultBranch = claimed.defaultBranch ?? null;
6622
6829
  // Whether somebody was shown the uncommitted work and started anyway.
6623
6830
  allowDirty = claimed.allowDirty === true;
6624
6831
  // Or whether the uncommitted work is THIS BRANCH'S OWN: the platform named
@@ -6667,6 +6874,9 @@ async function startRun(config, offered, workspace, source = null) {
6667
6874
  // R316. Whether the project SAYS its server refuses branches — null is
6668
6875
  // "not sure, try the branch scan first", today's behaviour.
6669
6876
  run.sonarBranches = claimed.sonarBranches ?? null;
6877
+ // R587. The project's declared version files: the scan reads the first it
6878
+ // can parse from the workspace and sends it as sonar.projectVersion.
6879
+ run.versionFiles = Array.isArray(claimed.versionFiles) ? claimed.versionFiles : [];
6670
6880
  // R69. Whether this is the same conversation picked back up, and which one.
6671
6881
  // Null on an ordinary claim, which is nearly all of them.
6672
6882
  resume = claimed.resume ?? null;
@@ -6712,6 +6922,8 @@ async function startRun(config, offered, workspace, source = null) {
6712
6922
  plan = claimed.plan ?? null;
6713
6923
  lifecycle = Array.isArray(claimed.workflow) ? claimed.workflow : [];
6714
6924
  shield = claimed.shield ?? null;
6925
+ // R562. Every session is told the list up front, whichever profile it is.
6926
+ run.protectedLine = protectedLine(protectedPolicy(shield));
6715
6927
  if (instincts.length) {
6716
6928
  log(` instincts: ${instincts.map((each) => each.key).join(', ')}`);
6717
6929
  }
@@ -6781,6 +6993,37 @@ async function startRun(config, offered, workspace, source = null) {
6781
6993
  briefing = briefing ? `${said}\n\n${briefing}` : said;
6782
6994
  baseCommit = await git(resolve(path), ['rev-parse', 'HEAD']).catch(() => baseCommit);
6783
6995
  }
6996
+ // R562. The base is fixed ONCE, here, after the working copy is prepared
6997
+ // and never recomputed: a MERGE moves HEAD, and a base recomputed after
6998
+ // it would call main's own commits the agent's. For a MERGE it is the
6999
+ // tip of the branch being merged INTO — what the branch brings with it.
7000
+ const policy = protectedPolicy(shield);
7001
+ if (policy.globs.length) {
7002
+ const baseName = run.baseBranch ?? defaultBranch;
7003
+ const fixed = run.profile === 'MERGE'
7004
+ ? await git(resolve(path), ['rev-parse', `origin/${baseName || 'main'}`])
7005
+ .catch(() => git(resolve(path), ['rev-parse', baseName || 'main']))
7006
+ .catch(() => null)
7007
+ : await protectedBase(resolve(path), baseName, baseCommit);
7008
+ const base = fixed || baseCommit || null;
7009
+ protectedGuards.set(run.id, { ...policy, base });
7010
+ // Checked before the session starts, as the card asks. It does not
7011
+ // refuse to start: the session is the one that can fix it, and the
7012
+ // prompt now says so.
7013
+ if (!base) {
7014
+ log(' !! protected paths: no base to judge this branch against, so a protected '
7015
+ + 'change cannot be caught when the run finishes');
7016
+ }
7017
+ const already = notAllowed(
7018
+ await protectedChanges(resolve(path), base, policy.globs).catch((failure) => {
7019
+ log(` !! protected paths: could not read the branch's changes: ${failure.message}`);
7020
+ return [];
7021
+ }), policy.allowed);
7022
+ if (already.length) {
7023
+ log(` !! this branch already changes protected file(s): ${already.join(', ')}`);
7024
+ }
7025
+ run.protectedLine = protectedLine(policy, already);
7026
+ }
6784
7027
  if (run.profile === 'MERGE') {
6785
7028
  // R155. The daemon does the merge, BEFORE anything is spawned, and what
6786
7029
  // git conflicts on becomes the session's whole write scope. A clean
@@ -6797,16 +7040,38 @@ async function startRun(config, offered, workspace, source = null) {
6797
7040
  }
6798
7041
  }
6799
7042
  log(` working copy ${cwd ?? path} is on ${branch ?? 'no branch of its own'}`);
6800
-
6801
- await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
6802
- method: 'POST',
6803
- // Where it ran, sent with the move to RUNNING because that is the one
6804
- // moment it is known. With three checkouts of one project, a run that
6805
- // does not say which it used makes "what is uncommitted on this run"
6806
- // unanswerable — and a console showing one run's files against another
6807
- // run's path is worse than one showing nothing.
6808
- body: { state: 'RUNNING', workspace: workspace ?? null },
6809
- });
7043
+ // R565. What the remote held before the session, so `auditPushes` can say
7044
+ // what the session added to it. Every agent CLI is audited, not only Claude.
7045
+ if (writesCodeProfile(run) && run.branch) {
7046
+ run.remoteBefore = await remoteRefs(cwd ?? resolve(path)).catch(() => null);
7047
+ }
7048
+
7049
+ // i267. A REFUSED move (400, 404, 409 or 410; PLATFORM_REFUSALS) is the platform saying whose run this is:
7050
+ // it was cancelled while this machine prepared, or put back in the queue.
7051
+ // That is never this run's failure, and the FAILED post below would write
7052
+ // the refusal text over whatever the platform holds as the run's exit.
7053
+ // Nothing is posted; the platform's state stands and, if the run really is
7054
+ // QUEUED, the next poll offers it again and claims it properly. Anything
7055
+ // else (401, 403, 408, 429, a network error, a 5xx) is not the platform
7056
+ // saying whose run this is and still fails the run, as before.
7057
+ try {
7058
+ await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
7059
+ method: 'POST',
7060
+ // Where it ran, sent with the move to RUNNING because that is the one
7061
+ // moment it is known. With three checkouts of one project, a run that
7062
+ // does not say which it used makes "what is uncommitted on this run"
7063
+ // unanswerable — and a console showing one run's files against another
7064
+ // run's path is worse than one showing nothing.
7065
+ body: { state: 'RUNNING', workspace: workspace ?? null },
7066
+ });
7067
+ } catch (refused) {
7068
+ if (PLATFORM_REFUSALS.has(refused.status)) {
7069
+ log(` the platform did not let this run start (${refused.message}); leaving it as the platform has it`);
7070
+ protectedGuards.delete(run.id);
7071
+ return;
7072
+ }
7073
+ throw refused;
7074
+ }
6810
7075
 
6811
7076
  if (merge?.clean) {
6812
7077
  // R155. Nothing conflicted, so there is nothing for a session to decide
@@ -6847,6 +7112,13 @@ async function startRun(config, offered, workspace, source = null) {
6847
7112
  }
6848
7113
  }
6849
7114
 
7115
+ /**
7116
+ * R577: what the planning limit counts — every run that takes no checkout,
7117
+ * except ASK, which never waits. Counted by the run's profile, never its stage:
7118
+ * a CODE run has a PLAN stage and is not one of these.
7119
+ */
7120
+ function countsAsPlanning(run) { return !writesCodeProfile(run) && run.profile !== 'ASK'; }
7121
+
6850
7122
  /**
6851
7123
  * Whether this run is the kind that takes a checkout, before the child exists.
6852
7124
  *
@@ -7287,13 +7559,151 @@ function gateTimeoutSeconds() {
7287
7559
  * the pipes are the agent's own. `cawdevCommand` is what the next daemon reads
7288
7560
  * off the driving note to tell this session's orphan from a reused pid.
7289
7561
  */
7562
+ // --- the agent pushes its own branch and nothing else (R565) ----------------------
7563
+ //
7564
+ // A guard against mistakes, not a security boundary: an agent with a shell and
7565
+ // the operator's credentials can always find a way round it. The wall is branch
7566
+ // protection on the host (tools/runner/README.md says so). What this adds is
7567
+ // that Claude Code is refused the obvious moves — a host CLI, a push to another
7568
+ // branch, a force — with a reason it can read, and that every agent CLI is
7569
+ // AUDITED afterwards (`auditPushes`).
7570
+
7571
+ const PUSH_GUARD = fileURLToPath(new URL('./push-guard.mjs', import.meta.url));
7572
+
7573
+ /** The variables `push-guard.mjs` reads. Empty tags means "this profile pushes no tag". */
7574
+ function pushGuardEnv(run) {
7575
+ return {
7576
+ CAWDEV_PUSH_BRANCH: run.branch ?? '',
7577
+ CAWDEV_DEFAULT_BRANCH: run.pushDefaultBranch ?? '',
7578
+ CAWDEV_PUSH_TAGS: run.profile === 'RELEASE' ? '1' : '',
7579
+ };
7580
+ }
7581
+
7582
+ /**
7583
+ * `--settings` with the PreToolUse hook, and `gh`/`glab` disallowed outright.
7584
+ *
7585
+ * Measured on Claude Code 2.1.309, the way R562's spike is recorded below: a
7586
+ * scratch repository, `--setting-sources ''`, `--permission-mode
7587
+ * bypassPermissions`, haiku asked to run `gh --version`, `git push origin
7588
+ * HEAD:other` and `git push origin <its branch>`. The first two were refused
7589
+ * with the guard's reason and never ran; the third went through. So `--settings`
7590
+ * hooks DO load under `--setting-sources ''`, and the guard holds under bypass.
7591
+ */
7592
+ function pushGuardArgs() {
7593
+ return [
7594
+ '--settings',
7595
+ JSON.stringify({
7596
+ hooks: {
7597
+ PreToolUse: [{
7598
+ matcher: 'Bash',
7599
+ hooks: [{ type: 'command', command: `"${process.execPath}" "${PUSH_GUARD}"` }],
7600
+ }],
7601
+ },
7602
+ }),
7603
+ '--disallowedTools', 'Bash(gh *)', 'Bash(glab *)',
7604
+ ];
7605
+ }
7606
+
7607
+ /**
7608
+ * `args` with the guard placed BEFORE `--allowedTools` (which is variadic and
7609
+ * would swallow it) — when the session is allowed `git` in full. Every Claude
7610
+ * Code spawn goes through this one function, so the coding path, the stage path
7611
+ * and the profile path cannot come apart.
7612
+ */
7613
+ function withPushGuard(args) {
7614
+ const at = args.indexOf('--allowedTools');
7615
+ if (at < 0 || !args.slice(at + 1).includes('Bash(git *)')) {
7616
+ return args;
7617
+ }
7618
+ return [...args.slice(0, at), ...pushGuardArgs(), ...args.slice(at)];
7619
+ }
7620
+
7621
+ /**
7622
+ * The environment an agent (or a skill's command) is spawned with — R565: this
7623
+ * process's own, minus the variable holding the Bitbucket token, plus `extra`.
7624
+ * `GH_TOKEN` and `GITLAB_TOKEN` stay: an operator's `git push` may authenticate
7625
+ * through them. `extra` is applied last but is stripped too, because callers
7626
+ * hand over `{ ...process.env }`.
7627
+ */
7628
+ function agentEnv(config, extra = {}) {
7629
+ const env = { ...process.env, ...extra };
7630
+ delete env[config?.bitbucket?.tokenEnv ?? 'BITBUCKET_TOKEN'];
7631
+ return env;
7632
+ }
7633
+
7290
7634
  function spawnScoped(config, command, args, options) {
7291
7635
  const line = scoped(command, args, config.sessionScope);
7292
- const child = spawn(line.command, line.args, options);
7636
+ // Every agent CLI passes through here, so the Bitbucket token is out of its
7637
+ // environment whatever the caller spread into `options.env`.
7638
+ const child = spawn(line.command, line.args, { ...options, env: agentEnv(config, options.env) });
7293
7639
  child.cawdevCommand = command;
7294
7640
  return child;
7295
7641
  }
7296
7642
 
7643
+ /**
7644
+ * Keeps an Edit away from the project's protected files at the CLI itself —
7645
+ * R562 — by editing the argument list in place, once, for every path to it.
7646
+ *
7647
+ * Measured, not assumed, on Claude Code 2.1.307 (the way i138's spike is
7648
+ * recorded above): a scratch repo, `--setting-sources ''`, a stub MCP
7649
+ * `--permission-prompt-tool`, haiku asked to Write `secret/a.txt`.
7650
+ *
7651
+ * A acceptEdits + --disallowedTools 'Edit(./secret/**)' → refused, file not written
7652
+ * B bypassPermissions + --disallowedTools 'Edit(./secret/**)' → refused, file not written
7653
+ * C acceptEdits + --settings ask 'Edit(./secret/**)' → reached the approve tool
7654
+ * D bypassPermissions + --settings ask 'Edit(./secret/**)' → reached the approve tool
7655
+ * (control: acceptEdits with no rule wrote the file and never asked.)
7656
+ *
7657
+ * Two things the spike found that the plan did not expect. Only `Edit(path)`
7658
+ * rules are matched — `Write(path)` is rejected as "not matched by file
7659
+ * permission checks", Edit covering every file-editing tool — so a single Edit
7660
+ * rule per glob is the whole list. And the ask rule works under
7661
+ * bypassPermissions too, so "allow everything" does not turn ASK into silence.
7662
+ *
7663
+ * `--disallowedTools` is variadic, so it goes in BEFORE `--allowedTools`.
7664
+ * Bash is not covered by any of this (`git checkout main -- pom.xml` changes a
7665
+ * file with no Edit call): the diff gate at every finish door is the real
7666
+ * guard, and this is the early warning.
7667
+ *
7668
+ * - a session with the prompt tool (CODE) in ASK mode: ask rules in a
7669
+ * settings file, so the call reaches `approve` and a person is asked;
7670
+ * - everything else, in either mode: deny rules. These profiles have no
7671
+ * prompt tool, so nobody could be asked, and "cannot" stays "cannot".
7672
+ *
7673
+ * Codex and Antigravity have no flag equivalent; they get the prompt and the
7674
+ * diff gate only.
7675
+ */
7676
+ async function protectFiles(args, run, shield, { readOnlyStage, mcpDirectory }) {
7677
+ const policy = protectedPolicy(shield);
7678
+ if (!policy.globs.length) {
7679
+ return;
7680
+ }
7681
+ const asks = (!run.profile || run.profile === 'CODE') && !readOnlyStage;
7682
+ const rules = denyRules(policy.globs);
7683
+ const at = args.indexOf('--allowedTools');
7684
+ if (asks && policy.mode === 'ASK') {
7685
+ const settingsPath = join(mcpDirectory, 'protected-settings.json');
7686
+ // One --settings only: a second would risk replacing the push guard's hook.
7687
+ // So when the guard's inline settings are there, the ask rules join them in
7688
+ // the file and the flag points at the file.
7689
+ // The guard's own flag, by its content: an operator's `--settings` in the
7690
+ // agent args is theirs and is left as it is.
7691
+ const inline = args.findIndex((arg, i) => arg === '--settings'
7692
+ && String(args[i + 1]).startsWith('{') && String(args[i + 1]).includes(PUSH_GUARD));
7693
+ const guarded = inline !== -1 ? JSON.parse(args[inline + 1]) : {};
7694
+ await writeFile(settingsPath,
7695
+ JSON.stringify({ ...guarded, permissions: { ask: rules } }, null, 2));
7696
+ if (inline !== -1 && guarded.hooks) {
7697
+ args[inline + 1] = settingsPath;
7698
+ } else {
7699
+ args.splice(at === -1 ? args.length : at, 0, '--settings', settingsPath);
7700
+ }
7701
+ } else {
7702
+ args.splice(at === -1 ? args.length : at, 0, '--disallowedTools', ...rules);
7703
+ }
7704
+ log(` protected paths (${policy.mode}): ${policy.globs.join(', ')}`);
7705
+ }
7706
+
7297
7707
  async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
7298
7708
  projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
7299
7709
  stage = null,
@@ -7428,7 +7838,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7428
7838
  // says what to run and the machine says under what conditions, and a
7429
7839
  // settings blob that could add environment variables would be a channel
7430
7840
  // from a database row into a spawned process.
7431
- env: { ...process.env, ...(local.env ?? {}) },
7841
+ env: agentEnv(config, local.env ?? {}),
7432
7842
  };
7433
7843
  }
7434
7844
 
@@ -7452,13 +7862,21 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7452
7862
  // model becomes a thing people click through. A server this list does not
7453
7863
  // name keeps the per-tool suggestion, because nobody declared it as a
7454
7864
  // capability — see `suggestionFor`.
7865
+ //
7866
+ // R319: SonarQube is NOT named — its server holds writers beside its
7867
+ // readers, so a whole-server "allow" would cover them. It keeps the
7868
+ // per-tool suggestion.
7455
7869
  CAWDEV_SKILL_SERVERS: JSON.stringify(
7456
- attached.map(({ skill }) => skill.toolPrefix ?? `mcp__${skill.serverName}`)),
7870
+ attached.filter(({ skill }) => skill.serverName !== 'sonarqube')
7871
+ .map(({ skill }) => skill.toolPrefix ?? `mcp__${skill.serverName}`)),
7457
7872
  // R110. The policy, and the checkout it is measured against. The CHECK is
7458
7873
  // the server's — it is the process Claude Code asks before a tool call,
7459
7874
  // and there is nowhere else the answer can be given in time.
7460
7875
  CAWDEV_SHIELD: JSON.stringify(shield ?? {}),
7461
7876
  CAWDEV_WORKSPACE: cwd,
7877
+ // R562. The commit the branch was cut from, fixed once by the runner, for
7878
+ // `report done`'s diff gate. Empty means nothing to judge against.
7879
+ CAWDEV_PROTECTED_BASE: protectedGuards.get(run.id)?.base ?? '',
7462
7880
  },
7463
7881
  };
7464
7882
  await writeFile(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2));
@@ -7641,7 +8059,8 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7641
8059
  // R303. Only a REQUIRED server changes the prompt, and only where the
7642
8060
  // stage can reach it — the same gate `mustUse` applies, so the prompt and
7643
8061
  // the nudge never disagree about what a stage owes.
7644
- mcpServers: attached.map(({ skill }) => ({
8062
+ mcpServers: attached.map(({ skill, local }) => ({
8063
+ when: serversCanBeReached ? local?.when : undefined,
7645
8064
  name: skill.name ?? skill.key,
7646
8065
  prefix: skill.toolPrefix ?? `mcp__${skill.serverName}`,
7647
8066
  required: serversCanBeReached && skill.mode === 'REQUIRED',
@@ -7695,7 +8114,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7695
8114
  const agentCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'claude')
7696
8115
  ?? 'claude';
7697
8116
 
7698
- const args = [
8117
+ let args = [
7699
8118
  '--mcp-config',
7700
8119
  mcpConfigPath,
7701
8120
  // Only when there is something in it. An empty plugin loads nothing and
@@ -7709,6 +8128,8 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7709
8128
  skills)]
7710
8129
  : argsForProfile(agentArgs, run.profile, run, expertAgents, skills))),
7711
8130
  ];
8131
+ args = withPushGuard(args);
8132
+ await protectFiles(args, run, shield, { readOnlyStage, mcpDirectory });
7712
8133
  log(` spawning: ${agentCmd} ${args.join(' ')} (prompt on stdin)`);
7713
8134
 
7714
8135
  return new Promise((resolvePromise) => {
@@ -7718,13 +8139,13 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
7718
8139
  // rather than leaving orphaned children behind.
7719
8140
  detached: true,
7720
8141
  stdio: ['pipe', 'pipe', 'pipe'],
7721
- env: {
7722
- ...process.env,
8142
+ env: agentEnv(config, {
7723
8143
  CAWDEV_URL: config.url,
7724
8144
  CAWDEV_TOKEN: runToken,
7725
8145
  CAWDEV_PROJECT: run.projectSlug,
7726
8146
  CAWDEV_GRANTABLE: JSON.stringify(ceiling),
7727
- },
8147
+ ...pushGuardEnv(run),
8148
+ }),
7728
8149
  });
7729
8150
 
7730
8151
  child.cawdevProjectSlug = run.projectSlug;
@@ -8701,6 +9122,9 @@ async function sayOnTheRun(config, run, text) {
8701
9122
  async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
8702
9123
  skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
8703
9124
  agyCmd, granted, usage = null) {
9125
+ if (run.protectedLine) {
9126
+ log(' protected paths are enforced at finish only for Antigravity');
9127
+ }
8704
9128
  if (!granted.everything) {
8705
9129
  // Antigravity needs it even to read: no per-tool allow-list exists on it.
8706
9130
  const summary = needsEverything('agy', thisMachine(config), run, { readsWithout: false });
@@ -9067,6 +9491,9 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
9067
9491
  };
9068
9492
 
9069
9493
  const mayWrite = writesCodeProfile(run) && !readOnlyStage;
9494
+ if (protectedPolicy(shield).globs.length) {
9495
+ log(' protected paths are enforced at finish only for Codex');
9496
+ }
9070
9497
  if (mayWrite && !granted.everything) {
9071
9498
  // It must commit and push, which its sandbox refuses, and there is no
9072
9499
  // allow-list for its shell — so everything, or nothing. Read-only runs
@@ -9083,7 +9510,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
9083
9510
  // The token reaches cawdev's server through `env_vars` — from Codex's own
9084
9511
  // environment, never its command line — and is blanked for the shell.
9085
9512
  const cawdevEnv = { CAWDEV_URL: config.url, CAWDEV_TOKEN: runToken, CAWDEV_PROJECT: run.projectSlug };
9086
- const childEnv = { ...process.env, ...cawdevEnv };
9513
+ const childEnv = agentEnv(config, { ...cawdevEnv, ...pushGuardEnv(run) });
9087
9514
 
9088
9515
  // By name, so a later server replaces an earlier one whole — the ordering
9089
9516
  // argument of the Claude Code path: the repository's own first, then R76's
@@ -9363,6 +9790,7 @@ async function finish(config, run, state, summary, cwd = null) {
9363
9790
  const settled = await settleBranch(cwd, run.branch, {
9364
9791
  push: pushBranch,
9365
9792
  label: run.entryRef ?? 'cawdev',
9793
+ protected: await guardOf(config, run),
9366
9794
  }).catch((failure) => ({ ok: false, reason: `Checking the branch failed: ${failure.message}` }));
9367
9795
  if (!settled.ok) {
9368
9796
  log(` not finished: ${settled.reason}`);
@@ -9377,6 +9805,7 @@ async function finish(config, run, state, summary, cwd = null) {
9377
9805
  method: 'POST',
9378
9806
  body: { state, summary },
9379
9807
  }).catch((failure) => log(` could not finish the run: ${failure.message}`));
9808
+ protectedGuards.delete(run.id);
9380
9809
  }
9381
9810
 
9382
9811
  // --- cancellation ------------------------------------------------------------
@@ -9527,10 +9956,12 @@ function capabilities(config) {
9527
9956
  return JSON.stringify({
9528
9957
  projects: Object.keys(config.projects),
9529
9958
  // How many checkouts each project has here — R47 — which since R109 is the
9530
- // ONLY cap this machine reports, and the only one it enforces. The
9531
- // machine-wide `maxSessions` is gone: it bounded processes, a delegated
9532
- // expert costs none, and a gate measuring the wrong thing while looking
9533
- // like it worked is worse than no gate.
9959
+ // only cap this machine reports as a number of its own. The machine-wide
9960
+ // `maxSessions` is gone: it bounded processes, a delegated expert costs
9961
+ // none, and a gate measuring the wrong thing while looking like it worked
9962
+ // is worse than no gate. R577 adds a second, opt-in gate — a limit on
9963
+ // planning runs, set from the console — which is not a config number and
9964
+ // so is announced by `maxPlanningRuns` below instead.
9534
9965
  workspaces: Object.fromEntries(
9535
9966
  Object.entries(config.projects).map(([slug, project]) => [slug, project.workspaces.length]),
9536
9967
  ),
@@ -9548,6 +9979,12 @@ function capabilities(config) {
9548
9979
  // offered only to a machine that says this, and a machine that does not
9549
9980
  // is told why on the queue rather than handed a base it would ignore.
9550
9981
  baseBranches: true,
9982
+ // R577. This daemon enforces the per-machine limit on planning runs that
9983
+ // the platform hands it on the heartbeat. The same shape as
9984
+ // `baseBranches`: an older daemon ignores the number silently, so the
9985
+ // console says so beside the field rather than letting a limit that does
9986
+ // nothing look like one that works.
9987
+ maxPlanningRuns: true,
9551
9988
  // R270. This daemon knows an offer can carry `waitForClean`, and honours it
9552
9989
  // in `pickWorkspace` — taking the preferred checkout only when clean, and
9553
9990
  // refusing to fall back to a dirty one at all. The same shape as
@@ -9561,7 +9998,7 @@ function capabilities(config) {
9561
9998
  // `glab`. An older daemon would fail the unknown kind, so the platform
9562
9999
  // hands the request only to a daemon that says so.
9563
10000
  createBranch: true,
9564
- gitHosts: ['github', 'gitlab'],
10001
+ gitHosts: ['github', 'gitlab', 'bitbucket'],
9565
10002
  });
9566
10003
  }
9567
10004
 
@@ -9656,6 +10093,16 @@ async function main() {
9656
10093
  process.on('SIGHUP', holdHup);
9657
10094
 
9658
10095
  const config = await readConfig();
10096
+ // R565. Bitbucket's token, from the variable the config names. Said once, so
10097
+ // the log shows where it comes from — and whether it is there at all.
10098
+ configureBitbucket({
10099
+ email: config.bitbucket.email,
10100
+ token: process.env[config.bitbucket.tokenEnv],
10101
+ apiUrl: config.bitbucket.apiUrl,
10102
+ });
10103
+ log(process.env[config.bitbucket.tokenEnv]
10104
+ ? `bitbucket: API token from $${config.bitbucket.tokenEnv}${config.bitbucket.email ? ` (as ${config.bitbucket.email})` : ' (as a bearer token)'}`
10105
+ : `bitbucket: no token in $${config.bitbucket.tokenEnv}, so Bitbucket projects cannot be opened or merged from here`);
9659
10106
  // The checkouts made on demand by an earlier run of this daemon, found
9660
10107
  // again by their name — nothing about them is in the config.
9661
10108
  await syncAutoWorkspaces(config.projects);
@@ -9777,6 +10224,14 @@ async function main() {
9777
10224
  // that takes an answer the platform then refuses reads as cawdev being
9778
10225
  // broken, which is R58's rule about `a` applied to this one.
9779
10226
  acceptsConsoleRules: config.acceptsRulesFromConsole === true,
10227
+ // R577: the planning limit the platform last told us, or null. Kept in
10228
+ // step by the heartbeat handler, so a terminal that attaches later reads
10229
+ // what is true now.
10230
+ maxPlanningRuns: told.maxPlanningRuns,
10231
+ // R571: whether the platform says this machine is paused, and by whom. Kept
10232
+ // in step by the heartbeat handler, so `c` offers Pause or Resume truthfully.
10233
+ paused: told.paused,
10234
+ pausedByUsage: told.pausedByUsage,
9780
10235
  projects: Object.keys(config.projects),
9781
10236
  // How many checkouts each has — R62. The per-project half of R47's
9782
10237
  // gate, which the bar shows as `cawdev 1/2`. Added rather than
@@ -9996,12 +10451,34 @@ async function main() {
9996
10451
  // R73/R80. The heartbeat's answer is what the platform decided about
9997
10452
  // this machine, and it changes what the queue loop does next.
9998
10453
  if (me && typeof me === 'object') {
9999
- if (told.paused !== !!me.paused) {
10000
- log(me.paused
10001
- ? 'paused from the console: claiming nothing new, finishing what is running'
10002
- : 'unpaused from the console');
10454
+ const pausedNow = !!me.paused;
10455
+ const byUsageNow = pausedNow && !!me.pausedByUsage;
10456
+ const moved = pauseLogLine(told, me);
10457
+ if (moved) {
10458
+ log(moved);
10459
+ }
10460
+ told.paused = pausedNow;
10461
+ told.pausedByUsage = byUsageNow;
10462
+ told.limits = Array.isArray(me.limits) ? me.limits : [];
10463
+ if (advertised.paused !== pausedNow || advertised.pausedByUsage !== byUsageNow) {
10464
+ advertised.paused = pausedNow;
10465
+ advertised.pausedByUsage = byUsageNow;
10466
+ control?.publish({ type: 'runner', runner: advertised });
10467
+ }
10468
+ // R577. Not a switch but a number: how many planning runs at once. The
10469
+ // claim loop reads it fresh on every pass, so a change lands on the
10470
+ // next heartbeat. Lowering it stops nothing — what is open finishes.
10471
+ const limit = Number.isInteger(me.maxPlanningRuns) && me.maxPlanningRuns > 0
10472
+ ? me.maxPlanningRuns
10473
+ : null;
10474
+ if (told.maxPlanningRuns !== limit) {
10475
+ log(limit
10476
+ ? `planning runs limited to ${limit} at once from the console`
10477
+ : 'no planning limit any more');
10478
+ advertised.maxPlanningRuns = limit;
10479
+ control?.publish({ type: 'runner', runner: advertised });
10003
10480
  }
10004
- told.paused = !!me.paused;
10481
+ told.maxPlanningRuns = limit;
10005
10482
  told.autoResume = !!me.autoResume;
10006
10483
  told.heldWorkspaces = Array.isArray(me.heldWorkspaces)
10007
10484
  ? me.heldWorkspaces.map((each) => each.path).filter(Boolean)
@@ -10161,8 +10638,11 @@ async function main() {
10161
10638
  break;
10162
10639
  }
10163
10640
 
10164
- // ONE gate — R109. There were two; `maxSessions` bounded this machine
10165
- // and is gone, for the reason written where it used to be applied.
10641
+ // ONE gate by default — R109. There were two; `maxSessions` bounded this
10642
+ // machine and is gone, for the reason written where it used to be applied.
10643
+ // R577 adds an opt-in second gate for the runs that take no checkout
10644
+ // (everything but ASK), a number set from the console and read off the
10645
+ // heartbeat; with none set the machine behaves as it did.
10166
10646
  //
10167
10647
  // A project's workspaces bound its CODING runs and nothing else: they are
10168
10648
  // checkouts, and only a coding run needs one.
@@ -10201,7 +10681,7 @@ async function main() {
10201
10681
  // left to finish; that is the default because killing a machine's work
10202
10682
  // to stop it taking more is the surprising reading.
10203
10683
  if (told.paused) {
10204
- noteQueued(offered.run, 'this machine is paused from the console');
10684
+ noteQueued(offered.run, queuedPauseNote(), reportedPauseNote());
10205
10685
  skipped += 1;
10206
10686
  continue;
10207
10687
  }
@@ -10210,7 +10690,25 @@ async function main() {
10210
10690
  }
10211
10691
  const writes = writesCodeProfile(offered.run);
10212
10692
 
10213
- // ONE gate now — R109 removed the machine's.
10693
+ // R577. The second gate, and the only one an operator opts into from the
10694
+ // console: at most this many PLAN, SCOPE, AUDIT or REVIEW runs at once.
10695
+ // A run stays in `taken` from its claim until startRun's finally, so the
10696
+ // count covers all of its stages, the gap before the spawn and the time
10697
+ // it sits waiting on you — which is why the reason says so. Here and not
10698
+ // in the platform's queue: a run bound to no machine is offered to all
10699
+ // of them, and another may take what this one refuses.
10700
+ const planning = countsAsPlanning(offered.run);
10701
+ if (planning && told.maxPlanningRuns) {
10702
+ const open = [...taken.values()].filter((claim) => claim.planning).length;
10703
+ if (open >= told.maxPlanningRuns) {
10704
+ 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`);
10705
+ skipped += 1;
10706
+ continue;
10707
+ }
10708
+ }
10709
+
10710
+ // ONE gate by default — R109 removed the machine's. (R577's planning
10711
+ // limit, above, is off until somebody sets it.)
10214
10712
  //
10215
10713
  // `maxSessions` bounded agent PROCESSES on this laptop, and R70's own
10216
10714
  // note said why: a queue of runs is otherwise a fork bomb with better
@@ -10286,6 +10784,7 @@ async function main() {
10286
10784
  taken.set(offered.run.id, {
10287
10785
  projectSlug: slug,
10288
10786
  writes,
10787
+ planning,
10289
10788
  workspace,
10290
10789
  label: offered.run.label,
10291
10790
  });
@@ -10305,7 +10804,9 @@ async function main() {
10305
10804
  // R148. Say it where a person can read it. Fire-and-forget: a daemon
10306
10805
  // that died because the platform was slow to take a nicety would be
10307
10806
  // worse than one that said nothing.
10308
- const reasons = [...noted.entries()].map(([runId, note]) => ({ runId, why: note.why }));
10807
+ const reasons = [...noted.entries()]
10808
+ .filter(([, note]) => note.reported)
10809
+ .map(([runId, note]) => ({ runId, why: note.reported }));
10309
10810
  const reasonsBody = JSON.stringify(reasons);
10310
10811
  if (reasonsBody !== lastSentReasons) {
10311
10812
  lastSentReasons = reasonsBody;