cawdev-cli 1.1.0 → 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/lib/finish-check.mjs +34 -1
- package/lib/harness-prompt.mjs +6 -0
- package/lib/order.mjs +7 -0
- package/lib/protected.mjs +225 -0
- package/lib/roadmap-format.mjs +4 -2
- package/lib/runner-config.mjs +9 -0
- package/lib/secrets.mjs +2 -2
- package/lib/sonar-analysis.mjs +52 -0
- package/lib/sonarqube-mcp.mjs +50 -0
- package/lib/stage-tools.mjs +52 -1
- package/mcp/README.md +5 -6
- package/mcp/server.mjs +168 -25
- package/package.json +1 -1
- package/runner/README.md +79 -0
- package/runner/attach.mjs +46 -1
- package/runner/cawdev.mjs +20 -2
- package/runner/configure.mjs +62 -0
- package/runner/git-host.mjs +298 -5
- package/runner/pause-note.mjs +104 -0
- package/runner/push-guard.mjs +333 -0
- package/runner/runner.mjs +659 -177
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 {
|
|
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
|
|
|
@@ -407,7 +415,7 @@ async function readConfig() {
|
|
|
407
415
|
* credential does not belong in one.
|
|
408
416
|
*/
|
|
409
417
|
token: process.env.CAWDEV_TOKEN ?? file.token ?? (await loadToken(url)),
|
|
410
|
-
name:
|
|
418
|
+
name: runnerNameOf(file),
|
|
411
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,
|
|
412
420
|
// Which projects this runner serves, and where their working copies are.
|
|
413
421
|
projects: normaliseProjects(file.projects ?? {}),
|
|
@@ -438,6 +446,21 @@ async function readConfig() {
|
|
|
438
446
|
* none. Raw here; `main` probes whether this machine can apply it.
|
|
439
447
|
*/
|
|
440
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
|
+
},
|
|
441
464
|
skillCache: file.skillCache ?? DEFAULTS.skillCache,
|
|
442
465
|
skillPrepareSeconds: file.skillPrepareSeconds ?? DEFAULTS.skillPrepareSeconds,
|
|
443
466
|
idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
|
|
@@ -506,6 +529,10 @@ async function readConfig() {
|
|
|
506
529
|
* "cawdev": { "workspaces": ["/…/ws-1", "/…/ws-2", "/…/ws-3"] }
|
|
507
530
|
* "cawdev": { "workspaces": [...], "quality": { "command": "sonar-scanner", "tokenEnv": "SONAR_TOKEN" } }
|
|
508
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
|
+
*
|
|
509
536
|
* R300: the fourth form adds a `quality` block — see `qualityBlockOf` — and a
|
|
510
537
|
* project with one has its branches scanned after a coding run's TEST stage.
|
|
511
538
|
* Captured here at boot, not hot-swapped: adding or changing the block needs
|
|
@@ -648,6 +675,16 @@ function qualityBlockOf(slug, block) {
|
|
|
648
675
|
* what most skills will want.
|
|
649
676
|
*/
|
|
650
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
|
+
},
|
|
651
688
|
codegraph: {
|
|
652
689
|
/**
|
|
653
690
|
* The npm package is a thin shim: the real artifact is a per-platform
|
|
@@ -745,7 +782,10 @@ async function api(config, path, { method = 'GET', body, token } = {}) {
|
|
|
745
782
|
const text = await response.text();
|
|
746
783
|
const parsed = text ? safeJson(text) : null;
|
|
747
784
|
if (!response.ok) {
|
|
748
|
-
|
|
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;
|
|
749
789
|
}
|
|
750
790
|
return parsed;
|
|
751
791
|
}
|
|
@@ -1198,7 +1238,7 @@ async function prepareSkillIndex(config, skill, local, forProject, cwd, baseComm
|
|
|
1198
1238
|
try {
|
|
1199
1239
|
const built = await runSkillCommand(skill.command, buildArgsFor(skill, local), {
|
|
1200
1240
|
cwd,
|
|
1201
|
-
env:
|
|
1241
|
+
env: agentEnv(config, local.env ?? {}),
|
|
1202
1242
|
timeoutMs: config.skillPrepareSeconds * 1000,
|
|
1203
1243
|
});
|
|
1204
1244
|
|
|
@@ -1410,7 +1450,7 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
|
|
|
1410
1450
|
const attached = [];
|
|
1411
1451
|
const notes = [];
|
|
1412
1452
|
|
|
1413
|
-
for (
|
|
1453
|
+
for (let skill of asked) {
|
|
1414
1454
|
if (!skill?.key || !skill?.command || !Array.isArray(skill.args)) {
|
|
1415
1455
|
notes.push(`A skill arrived that this daemon cannot read (${JSON.stringify(skill)}). `
|
|
1416
1456
|
+ 'It has not been attached.');
|
|
@@ -1425,7 +1465,21 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
|
|
|
1425
1465
|
+ 'should look at the skill row.');
|
|
1426
1466
|
}
|
|
1427
1467
|
|
|
1428
|
-
|
|
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
|
+
}
|
|
1429
1483
|
if (local.indexDir) {
|
|
1430
1484
|
notes.push(await prepareSkillIndex(config, skill, local, run, cwd, baseCommit));
|
|
1431
1485
|
}
|
|
@@ -1857,6 +1911,38 @@ async function prepareMerge(cwd, branch, defaultBranch) {
|
|
|
1857
1911
|
};
|
|
1858
1912
|
}
|
|
1859
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
|
+
|
|
1860
1946
|
/**
|
|
1861
1947
|
* What the session left behind — R155, and it is judged strictly.
|
|
1862
1948
|
*
|
|
@@ -1866,7 +1952,7 @@ async function prepareMerge(cwd, branch, defaultBranch) {
|
|
|
1866
1952
|
* knowable; reporting it as success would land conflict markers, which is the
|
|
1867
1953
|
* one way this feature could do real damage.
|
|
1868
1954
|
*/
|
|
1869
|
-
async function finishMerge(cwd, branch) {
|
|
1955
|
+
async function finishMerge(cwd, branch, guard = null) {
|
|
1870
1956
|
const stillConflicted = await git(cwd, ['diff', '--name-only', '--diff-filter=U'])
|
|
1871
1957
|
.then((said) => said.split('\n').map((line) => line.trim()).filter(Boolean))
|
|
1872
1958
|
.catch(() => []);
|
|
@@ -1887,6 +1973,16 @@ async function finishMerge(cwd, branch) {
|
|
|
1887
1973
|
.catch((failure) => log(` could not commit the resolution: ${failure.message.split('\n')[0]}`));
|
|
1888
1974
|
}
|
|
1889
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
|
+
|
|
1890
1986
|
const pushed = await pushBranch(cwd, branch);
|
|
1891
1987
|
if (!pushed.ok) {
|
|
1892
1988
|
log(` could not push ${branch}: ${pushed.result.split('\n')[0]}`);
|
|
@@ -1912,7 +2008,7 @@ async function finishMerge(cwd, branch) {
|
|
|
1912
2008
|
* arrives second; see `RunService.settleAgentMerge`.
|
|
1913
2009
|
*/
|
|
1914
2010
|
async function finishTheMerge(config, run, cwd, branch, said) {
|
|
1915
|
-
const left = await finishMerge(cwd, branch);
|
|
2011
|
+
const left = await finishMerge(cwd, branch, await guardOf(config, run));
|
|
1916
2012
|
log(left.pushed
|
|
1917
2013
|
? ` ${branch} pushed at ${String(left.mergeCommit ?? '').slice(0, 7)}`
|
|
1918
2014
|
: ` nothing was pushed: the conflict on ${branch} is still there`);
|
|
@@ -1935,7 +2031,9 @@ async function finishTheMerge(config, run, cwd, branch, said) {
|
|
|
1935
2031
|
if (current?.live) {
|
|
1936
2032
|
await finish(config, run, left.pushed ? 'FINISHED' : 'FAILED', left.pushed
|
|
1937
2033
|
? `Merged and pushed ${branch}.`
|
|
1938
|
-
:
|
|
2034
|
+
: left.held
|
|
2035
|
+
? protectedHeld(left.held)
|
|
2036
|
+
: `The conflict on ${branch} was not resolved; nothing was pushed.`);
|
|
1939
2037
|
}
|
|
1940
2038
|
}
|
|
1941
2039
|
|
|
@@ -2704,41 +2802,6 @@ function trimSlash(url) {
|
|
|
2704
2802
|
return url.replace(/\/$/, '');
|
|
2705
2803
|
}
|
|
2706
2804
|
|
|
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
2805
|
/** The gate and the new-code measures, for a BRANCH or MAIN reading — never for PROJECT, which has neither. */
|
|
2743
2806
|
async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report }) {
|
|
2744
2807
|
const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
|
|
@@ -2878,18 +2941,6 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
|
|
|
2878
2941
|
});
|
|
2879
2942
|
}
|
|
2880
2943
|
|
|
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
2944
|
/**
|
|
2894
2945
|
* Reads and reports the code map, unless it would say the same thing again.
|
|
2895
2946
|
*
|
|
@@ -3032,6 +3083,12 @@ async function firstRef(path, candidates) {
|
|
|
3032
3083
|
* turn.
|
|
3033
3084
|
*/
|
|
3034
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) {
|
|
3035
3092
|
const index = run.brief?.index ?? BRIEF.index;
|
|
3036
3093
|
const text = run.brief?.indexText;
|
|
3037
3094
|
|
|
@@ -3098,6 +3155,9 @@ If a decision is genuinely theirs, use the cawdev MCP tool \`ask_user\` and wait
|
|
|
3098
3155
|
|
|
3099
3156
|
${run.branch ? `When what they asked is done, commit it on ${run.branch} — work left
|
|
3100
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.
|
|
3101
3161
|
|
|
3102
3162
|
` : ''}Their instruction:
|
|
3103
3163
|
|
|
@@ -3137,11 +3197,16 @@ task, for context only. \`task_current\` carries the same note.
|
|
|
3137
3197
|
with no right answer, something the entry does not settle — call \`ask_user\`
|
|
3138
3198
|
and wait. Do not guess and carry on. Do not use it to check work you can
|
|
3139
3199
|
check yourself.
|
|
3140
|
-
5.
|
|
3141
|
-
|
|
3142
|
-
|
|
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.
|
|
3143
3204
|
|
|
3144
|
-
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.`;
|
|
3145
3210
|
}
|
|
3146
3211
|
|
|
3147
3212
|
// --- the transcript ----------------------------------------------------------
|
|
@@ -4154,6 +4219,21 @@ async function openPullRequest(cwd, run, title) {
|
|
|
4154
4219
|
}
|
|
4155
4220
|
}
|
|
4156
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
|
+
|
|
4157
4237
|
return openPullRequestFor(cwd, {
|
|
4158
4238
|
declared: run.gitHost ?? null,
|
|
4159
4239
|
branch: run.branch,
|
|
@@ -4246,20 +4326,20 @@ async function openPullRequestFor(cwd, { branch, base, title, body, declared = n
|
|
|
4246
4326
|
}
|
|
4247
4327
|
return {
|
|
4248
4328
|
ok: false,
|
|
4249
|
-
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}.`,
|
|
4250
4330
|
};
|
|
4251
4331
|
}
|
|
4252
4332
|
// The one failure somebody can fix in a minute, said with the fix — and the
|
|
4253
4333
|
// link to open it by hand meanwhile, which `findPullRequest` builds without the CLI.
|
|
4254
|
-
if (opened.code === -1
|
|
4334
|
+
if (opened.code === -1) {
|
|
4255
4335
|
const compare = await findPullRequest(cwd, branch, { declared, base }).catch(() => null);
|
|
4256
4336
|
return {
|
|
4257
4337
|
ok: false,
|
|
4258
|
-
result: `${opened.err}
|
|
4338
|
+
result: `${opened.err} Set it up (${adapter.installHint}), `
|
|
4259
4339
|
+ `then ask again${compare ? ` — or open it yourself: ${compare}` : '.'}`,
|
|
4260
4340
|
};
|
|
4261
4341
|
}
|
|
4262
|
-
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.` };
|
|
4263
4343
|
}
|
|
4264
4344
|
|
|
4265
4345
|
/**
|
|
@@ -4350,7 +4430,7 @@ async function mergePullRequest(cwd, branch, { method = null, declared = null }
|
|
|
4350
4430
|
failure: await classifyMergeFailure(cwd, url, merged, adapter),
|
|
4351
4431
|
// Unchanged: `gh`'s own words still reach the card. The classification is a
|
|
4352
4432
|
// 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.`,
|
|
4433
|
+
result: merged.err || merged.out || `${adapter.bin ?? adapter.name} ${adapter.name === 'gitlab' ? 'mr' : 'pr'} merge failed without saying why.`,
|
|
4354
4434
|
};
|
|
4355
4435
|
}
|
|
4356
4436
|
|
|
@@ -4575,6 +4655,9 @@ async function classifyMergeFailure(cwd, url, merged, adapter) {
|
|
|
4575
4655
|
* produced, and if the platform has more to say it can say it to the next run.
|
|
4576
4656
|
*/
|
|
4577
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}`));
|
|
4578
4661
|
const actions = await api(
|
|
4579
4662
|
config,
|
|
4580
4663
|
`/api/projects/${run.projectSlug}/runs/${run.id}/actions/claim`,
|
|
@@ -4611,62 +4694,96 @@ async function settleActions(config, run, cwd) {
|
|
|
4611
4694
|
/** How often the merge pass runs, in minutes. A merge is a human-timescale event. */
|
|
4612
4695
|
const MERGE_CHECK_MINUTES = 10;
|
|
4613
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
|
+
|
|
4711
|
+
/**
|
|
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
|
+
|
|
4614
4750
|
/**
|
|
4615
|
-
* What
|
|
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.
|
|
4616
4753
|
*
|
|
4617
4754
|
* The pull request URL is preferred over the branch name when we have a real
|
|
4618
|
-
* one, because
|
|
4619
|
-
* deleted — which is precisely when this question gets
|
|
4620
|
-
* 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.
|
|
4621
4758
|
*
|
|
4622
4759
|
* A `/compare/` URL is not a pull request: `findPullRequest` falls back to one
|
|
4623
|
-
* as a link somebody can click, and asking
|
|
4624
|
-
* 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.
|
|
4625
4762
|
*/
|
|
4626
|
-
async function
|
|
4763
|
+
async function askHost(cwd, branch, prUrl, declared = null) {
|
|
4627
4764
|
const target = prUrl && !prUrl.includes('/compare/') ? prUrl : branch;
|
|
4628
4765
|
if (!target) {
|
|
4629
4766
|
return null;
|
|
4630
4767
|
}
|
|
4631
|
-
const
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
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) {
|
|
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) {
|
|
4657
4774
|
return null;
|
|
4658
4775
|
}
|
|
4659
4776
|
return {
|
|
4660
|
-
state,
|
|
4661
|
-
mergeCommit:
|
|
4662
|
-
mergedAt:
|
|
4777
|
+
state: viewed.state,
|
|
4778
|
+
mergeCommit: viewed.mergeCommit ?? null,
|
|
4779
|
+
mergedAt: viewed.mergedAt ?? null,
|
|
4663
4780
|
};
|
|
4664
4781
|
}
|
|
4665
4782
|
|
|
4666
4783
|
/**
|
|
4667
|
-
* What
|
|
4784
|
+
* What the host says about a hand-taken branch's pull request — R346.
|
|
4668
4785
|
*
|
|
4669
|
-
*
|
|
4786
|
+
* Listed by head, not viewed by branch: the latter stops finding the pull
|
|
4670
4787
|
* request once the branch is deleted, and this matches on the pull request's own
|
|
4671
4788
|
* head branch name, which does not change. Fork pull requests are dropped: a
|
|
4672
4789
|
* fork's branch of the same name is not this branch.
|
|
@@ -4675,23 +4792,12 @@ async function askGitHub(cwd, branch, prUrl) {
|
|
|
4675
4792
|
* reused branch name can bring back an older one, which the platform also
|
|
4676
4793
|
* refuses. Otherwise the highest-numbered row speaks.
|
|
4677
4794
|
*/
|
|
4678
|
-
async function
|
|
4795
|
+
async function askHostForBranch(cwd, branch, openedAt, declared = null) {
|
|
4679
4796
|
if (!branch) {
|
|
4680
4797
|
return null;
|
|
4681
4798
|
}
|
|
4682
|
-
const
|
|
4683
|
-
|
|
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;
|
|
4799
|
+
const { adapter } = await hostOf(cwd, declared);
|
|
4800
|
+
const parsed = await adapter.listByHead(cwd, branch);
|
|
4695
4801
|
if (!Array.isArray(parsed)) {
|
|
4696
4802
|
return null;
|
|
4697
4803
|
}
|
|
@@ -4707,14 +4813,13 @@ async function askGitHubForBranch(cwd, branch, openedAt) {
|
|
|
4707
4813
|
if (chosen.state === 'MERGED' && !merged.length) {
|
|
4708
4814
|
return null; // Only a merge from before the item existed: not this item's.
|
|
4709
4815
|
}
|
|
4710
|
-
|
|
4711
|
-
if (!state) {
|
|
4816
|
+
if (!chosen.state) {
|
|
4712
4817
|
return null;
|
|
4713
4818
|
}
|
|
4714
4819
|
return {
|
|
4715
|
-
state,
|
|
4820
|
+
state: chosen.state,
|
|
4716
4821
|
prUrl: chosen.url ?? null,
|
|
4717
|
-
mergeCommit: chosen.mergeCommit
|
|
4822
|
+
mergeCommit: chosen.mergeCommit ?? null,
|
|
4718
4823
|
mergedAt: chosen.mergedAt ?? null,
|
|
4719
4824
|
baseRefName: chosen.baseRefName ?? null,
|
|
4720
4825
|
};
|
|
@@ -4776,7 +4881,7 @@ async function askGit(cwd, head, defaultBranch) {
|
|
|
4776
4881
|
/**
|
|
4777
4882
|
* What became of one branch.
|
|
4778
4883
|
*
|
|
4779
|
-
*
|
|
4884
|
+
* The host first, because it is the only thing that can see a SQUASH merge: a
|
|
4780
4885
|
* squashed branch's commits never appear on the default branch at all, so the
|
|
4781
4886
|
* ancestry test below would confidently and wrongly say "not merged".
|
|
4782
4887
|
*
|
|
@@ -4784,8 +4889,8 @@ async function askGit(cwd, head, defaultBranch) {
|
|
|
4784
4889
|
* that has left the remote. Proving a merge is possible; disproving one is not,
|
|
4785
4890
|
* and UNKNOWN is the honest answer rather than a guess dressed as a reading.
|
|
4786
4891
|
*/
|
|
4787
|
-
async function readMergeState(cwd, { branch, defaultBranch, baseBranch, head, prUrl }) {
|
|
4788
|
-
const viaGh = await
|
|
4892
|
+
async function readMergeState(cwd, { branch, defaultBranch, baseBranch, head, prUrl, gitHost = null }) {
|
|
4893
|
+
const viaGh = await askHost(cwd, branch, prUrl, gitHost).catch(() => null);
|
|
4789
4894
|
if (viaGh) {
|
|
4790
4895
|
return viaGh;
|
|
4791
4896
|
}
|
|
@@ -4901,7 +5006,7 @@ async function checkRunMerges(config, pending, fetched) {
|
|
|
4901
5006
|
|
|
4902
5007
|
/**
|
|
4903
5008
|
* 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
|
|
5009
|
+
* default branch for the git reading to find, so ask the host about the pull
|
|
4905
5010
|
* request. An older platform answers 404 and this returns in silence.
|
|
4906
5011
|
*/
|
|
4907
5012
|
async function checkWorkItemMerges(config, fetched) {
|
|
@@ -4932,7 +5037,7 @@ async function checkWorkItemMerges(config, fetched) {
|
|
|
4932
5037
|
fetched.add(cwd);
|
|
4933
5038
|
await git(cwd, ['fetch', '--prune', 'origin']).catch(() => null);
|
|
4934
5039
|
}
|
|
4935
|
-
const reading = await
|
|
5040
|
+
const reading = await askHostForBranch(cwd, item.branch, item.openedAt, item.gitHost ?? null).catch(() => null);
|
|
4936
5041
|
readings.push({
|
|
4937
5042
|
workItemId: item.workItemId,
|
|
4938
5043
|
state: reading?.state ?? 'UNKNOWN',
|
|
@@ -5216,9 +5321,13 @@ const PROFILE_TOOLS = {
|
|
|
5216
5321
|
// it was asked to judge — and there is still no set_status here, because
|
|
5217
5322
|
// `DONE` is the person's word (R74) and a reviewer that could move the card
|
|
5218
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.
|
|
5219
5327
|
REVIEW: [
|
|
5220
5328
|
...READ_ONLY_CAWDEV,
|
|
5221
5329
|
'mcp__cawdev__roadmap_comment',
|
|
5330
|
+
SUGGEST,
|
|
5222
5331
|
...READ_FILES,
|
|
5223
5332
|
...GIT_READS,
|
|
5224
5333
|
],
|
|
@@ -5267,10 +5376,15 @@ const PROFILE_TOOLS = {
|
|
|
5267
5376
|
// R302. Less the reads that need the work to exist — `quality_get` — the
|
|
5268
5377
|
// same narrowing `toolsForStage` applies to a walk's PLAN stage, by the same
|
|
5269
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.
|
|
5270
5383
|
PLAN: (run) => [
|
|
5271
5384
|
...beforeTheWorkExists(READ_ONLY_CAWDEV),
|
|
5272
5385
|
...READ_FILES,
|
|
5273
5386
|
...GIT_READS,
|
|
5387
|
+
SUGGEST,
|
|
5274
5388
|
...(run?.entryNumber ? [] : [CARD_WRITE]),
|
|
5275
5389
|
],
|
|
5276
5390
|
// R96. Read the whole repository, ask in rounds, write the brief, commit it.
|
|
@@ -5315,11 +5429,11 @@ const PROFILE_TOOLS = {
|
|
|
5315
5429
|
// release may genuinely need to ask. Nothing that creates a card, proposes
|
|
5316
5430
|
// one, or comments.
|
|
5317
5431
|
//
|
|
5318
|
-
// The shell is `git` in full — it commits, tags and pushes — `
|
|
5319
|
-
//
|
|
5320
|
-
//
|
|
5321
|
-
//
|
|
5322
|
-
// 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.
|
|
5323
5437
|
//
|
|
5324
5438
|
// And `Write`/`Edit` on the version files the platform named on the claim,
|
|
5325
5439
|
// and nothing else. `ROADMAP.md`, `ISSUES.md` and `CHANGELOG.md` are
|
|
@@ -5332,8 +5446,6 @@ const PROFILE_TOOLS = {
|
|
|
5332
5446
|
'mcp__cawdev__roadmap_set_status',
|
|
5333
5447
|
...READ_FILES,
|
|
5334
5448
|
'Bash(git *)',
|
|
5335
|
-
'Bash(gh pr create *)',
|
|
5336
|
-
'Bash(gh pr view *)',
|
|
5337
5449
|
'Bash(node *)',
|
|
5338
5450
|
...releaseWrites(run),
|
|
5339
5451
|
],
|
|
@@ -5485,6 +5597,43 @@ function argsForProfile(agentArgs, profile, run, expertAgents = [], skills = [])
|
|
|
5485
5597
|
* re-derived the plan from a précis would be planning again, which is the one
|
|
5486
5598
|
* thing the gate exists to stop it doing after somebody approved the first one.
|
|
5487
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
|
+
|
|
5488
5637
|
/** Longer than ask_user's own ten-minute wait — see the cawdev server of spawnCodex. */
|
|
5489
5638
|
const CAWDEV_TOOL_TIMEOUT_SEC = 660;
|
|
5490
5639
|
|
|
@@ -5524,6 +5673,20 @@ const WAIT_FOR_COMMANDS = 'This stage ends when your turn ends: run every comman
|
|
|
5524
5673
|
+ '`Agent` with `run_in_background: false` and wait for its answer — an agent still '
|
|
5525
5674
|
+ 'working when your turn ends has its work lost, and the stage is run again.';
|
|
5526
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
|
+
|
|
5527
5690
|
/**
|
|
5528
5691
|
* @param hasPlan whether the card has a plan of record — the same test
|
|
5529
5692
|
* harnessPrompt uses to draw "The plan for this card" (`plan?.body`), so the
|
|
@@ -5716,10 +5879,11 @@ this was resolved without opening your transcript.`;
|
|
|
5716
5879
|
cawdev platform.
|
|
5717
5880
|
|
|
5718
5881
|
Your permissions, so you do not spend turns discovering them: \`git\` in full;
|
|
5719
|
-
\`
|
|
5882
|
+
\`node\`; and Write/Edit on exactly these files —
|
|
5720
5883
|
${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
|
|
5722
|
-
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.
|
|
5723
5887
|
|
|
5724
5888
|
If a decision is genuinely the person's, use \`ask_user\` and wait.
|
|
5725
5889
|
|
|
@@ -5989,8 +6153,12 @@ card you created a moment ago. cawdev stores what you report as the card's plan
|
|
|
5989
6153
|
record when this phase ends, and that is the only copy anybody wants: two plans in
|
|
5990
6154
|
two places is how one of them goes stale.
|
|
5991
6155
|
|
|
6156
|
+
${CONSIDER_UNFINISHED}
|
|
6157
|
+
|
|
5992
6158
|
**End with a Done-when list.** ${DONE_WHEN_PLAN}
|
|
5993
6159
|
|
|
6160
|
+
**What is not this card's.** ${SUGGEST_ELSEWHERE}
|
|
6161
|
+
|
|
5994
6162
|
Then \`report\` kind "done" with the plan itself.${asked}`;
|
|
5995
6163
|
}
|
|
5996
6164
|
|
|
@@ -6020,6 +6188,8 @@ can find it.
|
|
|
6020
6188
|
|
|
6021
6189
|
Do not commit, do not push, do not make edits. The branch is not yours to change.
|
|
6022
6190
|
|
|
6191
|
+
**What is not this card's.** A finding about THIS card's work is a comment. ${SUGGEST_ELSEWHERE}
|
|
6192
|
+
|
|
6023
6193
|
Then \`report\` kind "done".`;
|
|
6024
6194
|
}
|
|
6025
6195
|
|
|
@@ -6274,7 +6444,23 @@ const running = new Map();
|
|
|
6274
6444
|
* uncommitted work and all, until somebody discards the run — they are not
|
|
6275
6445
|
* free, whatever `running` says, and must not be reset for the next claim.
|
|
6276
6446
|
*/
|
|
6277
|
-
const told = {
|
|
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
|
+
}
|
|
6278
6464
|
|
|
6279
6465
|
/**
|
|
6280
6466
|
* How this daemon paints its own output — R62.
|
|
@@ -6496,7 +6682,7 @@ async function pickWorkspace(slug, project, offered, held, probed, autoMax = 0,
|
|
|
6496
6682
|
return { workspace: free[0], why: null };
|
|
6497
6683
|
}
|
|
6498
6684
|
|
|
6499
|
-
function noteQueued(run, why) {
|
|
6685
|
+
function noteQueued(run, why, reported = why) {
|
|
6500
6686
|
// Once per REASON, not once per run — R70, now that there are two gates a run
|
|
6501
6687
|
// can be held by. A run refused by the machine's cap and then, when a session
|
|
6502
6688
|
// ends, by the project's checkouts is waiting for a different thing, and
|
|
@@ -6510,7 +6696,8 @@ function noteQueued(run, why) {
|
|
|
6510
6696
|
// knowledge that exists nowhere but here. The platform sees a queued run; only
|
|
6511
6697
|
// this daemon knows it is queued because the checkout is busy rather than
|
|
6512
6698
|
// because nothing has picked it up.
|
|
6513
|
-
|
|
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 });
|
|
6514
6701
|
log(`${why}; leaving "${run.label}" queued`);
|
|
6515
6702
|
}
|
|
6516
6703
|
|
|
@@ -6575,6 +6762,9 @@ function snapshotRuns() {
|
|
|
6575
6762
|
return runs;
|
|
6576
6763
|
}
|
|
6577
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
|
+
|
|
6578
6768
|
async function startRun(config, offered, workspace, source = null) {
|
|
6579
6769
|
const run = offered.run;
|
|
6580
6770
|
const project = config.projects[run.projectSlug];
|
|
@@ -6619,6 +6809,7 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6619
6809
|
// The claim carries everything needed to prepare the working copy, so the
|
|
6620
6810
|
// runner never touches the project API — which is session-only anyway.
|
|
6621
6811
|
defaultBranch = claimed.defaultBranch;
|
|
6812
|
+
run.pushDefaultBranch = claimed.defaultBranch ?? null;
|
|
6622
6813
|
// Whether somebody was shown the uncommitted work and started anyway.
|
|
6623
6814
|
allowDirty = claimed.allowDirty === true;
|
|
6624
6815
|
// Or whether the uncommitted work is THIS BRANCH'S OWN: the platform named
|
|
@@ -6712,6 +6903,8 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6712
6903
|
plan = claimed.plan ?? null;
|
|
6713
6904
|
lifecycle = Array.isArray(claimed.workflow) ? claimed.workflow : [];
|
|
6714
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));
|
|
6715
6908
|
if (instincts.length) {
|
|
6716
6909
|
log(` instincts: ${instincts.map((each) => each.key).join(', ')}`);
|
|
6717
6910
|
}
|
|
@@ -6781,6 +6974,37 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6781
6974
|
briefing = briefing ? `${said}\n\n${briefing}` : said;
|
|
6782
6975
|
baseCommit = await git(resolve(path), ['rev-parse', 'HEAD']).catch(() => baseCommit);
|
|
6783
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
|
+
}
|
|
6784
7008
|
if (run.profile === 'MERGE') {
|
|
6785
7009
|
// R155. The daemon does the merge, BEFORE anything is spawned, and what
|
|
6786
7010
|
// git conflicts on becomes the session's whole write scope. A clean
|
|
@@ -6797,16 +7021,38 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6797
7021
|
}
|
|
6798
7022
|
}
|
|
6799
7023
|
log(` working copy ${cwd ?? path} is on ${branch ?? 'no branch of its own'}`);
|
|
6800
|
-
|
|
6801
|
-
|
|
6802
|
-
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
|
|
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
|
+
}
|
|
6810
7056
|
|
|
6811
7057
|
if (merge?.clean) {
|
|
6812
7058
|
// R155. Nothing conflicted, so there is nothing for a session to decide
|
|
@@ -6847,6 +7093,13 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6847
7093
|
}
|
|
6848
7094
|
}
|
|
6849
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
|
+
|
|
6850
7103
|
/**
|
|
6851
7104
|
* Whether this run is the kind that takes a checkout, before the child exists.
|
|
6852
7105
|
*
|
|
@@ -7287,13 +7540,151 @@ function gateTimeoutSeconds() {
|
|
|
7287
7540
|
* the pipes are the agent's own. `cawdevCommand` is what the next daemon reads
|
|
7288
7541
|
* off the driving note to tell this session's orphan from a reused pid.
|
|
7289
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
|
+
|
|
7290
7615
|
function spawnScoped(config, command, args, options) {
|
|
7291
7616
|
const line = scoped(command, args, config.sessionScope);
|
|
7292
|
-
|
|
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) });
|
|
7293
7620
|
child.cawdevCommand = command;
|
|
7294
7621
|
return child;
|
|
7295
7622
|
}
|
|
7296
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
|
+
|
|
7297
7688
|
async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
7298
7689
|
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
|
|
7299
7690
|
stage = null,
|
|
@@ -7428,7 +7819,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7428
7819
|
// says what to run and the machine says under what conditions, and a
|
|
7429
7820
|
// settings blob that could add environment variables would be a channel
|
|
7430
7821
|
// from a database row into a spawned process.
|
|
7431
|
-
env:
|
|
7822
|
+
env: agentEnv(config, local.env ?? {}),
|
|
7432
7823
|
};
|
|
7433
7824
|
}
|
|
7434
7825
|
|
|
@@ -7452,13 +7843,21 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7452
7843
|
// model becomes a thing people click through. A server this list does not
|
|
7453
7844
|
// name keeps the per-tool suggestion, because nobody declared it as a
|
|
7454
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.
|
|
7455
7850
|
CAWDEV_SKILL_SERVERS: JSON.stringify(
|
|
7456
|
-
attached.
|
|
7851
|
+
attached.filter(({ skill }) => skill.serverName !== 'sonarqube')
|
|
7852
|
+
.map(({ skill }) => skill.toolPrefix ?? `mcp__${skill.serverName}`)),
|
|
7457
7853
|
// R110. The policy, and the checkout it is measured against. The CHECK is
|
|
7458
7854
|
// the server's — it is the process Claude Code asks before a tool call,
|
|
7459
7855
|
// and there is nowhere else the answer can be given in time.
|
|
7460
7856
|
CAWDEV_SHIELD: JSON.stringify(shield ?? {}),
|
|
7461
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 ?? '',
|
|
7462
7861
|
},
|
|
7463
7862
|
};
|
|
7464
7863
|
await writeFile(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2));
|
|
@@ -7641,7 +8040,8 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7641
8040
|
// R303. Only a REQUIRED server changes the prompt, and only where the
|
|
7642
8041
|
// stage can reach it — the same gate `mustUse` applies, so the prompt and
|
|
7643
8042
|
// the nudge never disagree about what a stage owes.
|
|
7644
|
-
mcpServers: attached.map(({ skill }) => ({
|
|
8043
|
+
mcpServers: attached.map(({ skill, local }) => ({
|
|
8044
|
+
when: serversCanBeReached ? local?.when : undefined,
|
|
7645
8045
|
name: skill.name ?? skill.key,
|
|
7646
8046
|
prefix: skill.toolPrefix ?? `mcp__${skill.serverName}`,
|
|
7647
8047
|
required: serversCanBeReached && skill.mode === 'REQUIRED',
|
|
@@ -7695,7 +8095,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7695
8095
|
const agentCmd = config.agentCommands.find((each) => agentOfCommand(each) === 'claude')
|
|
7696
8096
|
?? 'claude';
|
|
7697
8097
|
|
|
7698
|
-
|
|
8098
|
+
let args = [
|
|
7699
8099
|
'--mcp-config',
|
|
7700
8100
|
mcpConfigPath,
|
|
7701
8101
|
// Only when there is something in it. An empty plugin loads nothing and
|
|
@@ -7709,6 +8109,8 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7709
8109
|
skills)]
|
|
7710
8110
|
: argsForProfile(agentArgs, run.profile, run, expertAgents, skills))),
|
|
7711
8111
|
];
|
|
8112
|
+
args = withPushGuard(args);
|
|
8113
|
+
await protectFiles(args, run, shield, { readOnlyStage, mcpDirectory });
|
|
7712
8114
|
log(` spawning: ${agentCmd} ${args.join(' ')} (prompt on stdin)`);
|
|
7713
8115
|
|
|
7714
8116
|
return new Promise((resolvePromise) => {
|
|
@@ -7718,13 +8120,13 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7718
8120
|
// rather than leaving orphaned children behind.
|
|
7719
8121
|
detached: true,
|
|
7720
8122
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
7721
|
-
env: {
|
|
7722
|
-
...process.env,
|
|
8123
|
+
env: agentEnv(config, {
|
|
7723
8124
|
CAWDEV_URL: config.url,
|
|
7724
8125
|
CAWDEV_TOKEN: runToken,
|
|
7725
8126
|
CAWDEV_PROJECT: run.projectSlug,
|
|
7726
8127
|
CAWDEV_GRANTABLE: JSON.stringify(ceiling),
|
|
7727
|
-
|
|
8128
|
+
...pushGuardEnv(run),
|
|
8129
|
+
}),
|
|
7728
8130
|
});
|
|
7729
8131
|
|
|
7730
8132
|
child.cawdevProjectSlug = run.projectSlug;
|
|
@@ -8701,6 +9103,9 @@ async function sayOnTheRun(config, run, text) {
|
|
|
8701
9103
|
async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
8702
9104
|
skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
|
|
8703
9105
|
agyCmd, granted, usage = null) {
|
|
9106
|
+
if (run.protectedLine) {
|
|
9107
|
+
log(' protected paths are enforced at finish only for Antigravity');
|
|
9108
|
+
}
|
|
8704
9109
|
if (!granted.everything) {
|
|
8705
9110
|
// Antigravity needs it even to read: no per-tool allow-list exists on it.
|
|
8706
9111
|
const summary = needsEverything('agy', thisMachine(config), run, { readsWithout: false });
|
|
@@ -9067,6 +9472,9 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
9067
9472
|
};
|
|
9068
9473
|
|
|
9069
9474
|
const mayWrite = writesCodeProfile(run) && !readOnlyStage;
|
|
9475
|
+
if (protectedPolicy(shield).globs.length) {
|
|
9476
|
+
log(' protected paths are enforced at finish only for Codex');
|
|
9477
|
+
}
|
|
9070
9478
|
if (mayWrite && !granted.everything) {
|
|
9071
9479
|
// It must commit and push, which its sandbox refuses, and there is no
|
|
9072
9480
|
// allow-list for its shell — so everything, or nothing. Read-only runs
|
|
@@ -9083,7 +9491,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
9083
9491
|
// The token reaches cawdev's server through `env_vars` — from Codex's own
|
|
9084
9492
|
// environment, never its command line — and is blanked for the shell.
|
|
9085
9493
|
const cawdevEnv = { CAWDEV_URL: config.url, CAWDEV_TOKEN: runToken, CAWDEV_PROJECT: run.projectSlug };
|
|
9086
|
-
const childEnv = { ...
|
|
9494
|
+
const childEnv = agentEnv(config, { ...cawdevEnv, ...pushGuardEnv(run) });
|
|
9087
9495
|
|
|
9088
9496
|
// By name, so a later server replaces an earlier one whole — the ordering
|
|
9089
9497
|
// argument of the Claude Code path: the repository's own first, then R76's
|
|
@@ -9363,6 +9771,7 @@ async function finish(config, run, state, summary, cwd = null) {
|
|
|
9363
9771
|
const settled = await settleBranch(cwd, run.branch, {
|
|
9364
9772
|
push: pushBranch,
|
|
9365
9773
|
label: run.entryRef ?? 'cawdev',
|
|
9774
|
+
protected: await guardOf(config, run),
|
|
9366
9775
|
}).catch((failure) => ({ ok: false, reason: `Checking the branch failed: ${failure.message}` }));
|
|
9367
9776
|
if (!settled.ok) {
|
|
9368
9777
|
log(` not finished: ${settled.reason}`);
|
|
@@ -9377,6 +9786,7 @@ async function finish(config, run, state, summary, cwd = null) {
|
|
|
9377
9786
|
method: 'POST',
|
|
9378
9787
|
body: { state, summary },
|
|
9379
9788
|
}).catch((failure) => log(` could not finish the run: ${failure.message}`));
|
|
9789
|
+
protectedGuards.delete(run.id);
|
|
9380
9790
|
}
|
|
9381
9791
|
|
|
9382
9792
|
// --- cancellation ------------------------------------------------------------
|
|
@@ -9527,10 +9937,12 @@ function capabilities(config) {
|
|
|
9527
9937
|
return JSON.stringify({
|
|
9528
9938
|
projects: Object.keys(config.projects),
|
|
9529
9939
|
// How many checkouts each project has here — R47 — which since R109 is the
|
|
9530
|
-
//
|
|
9531
|
-
//
|
|
9532
|
-
//
|
|
9533
|
-
//
|
|
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.
|
|
9534
9946
|
workspaces: Object.fromEntries(
|
|
9535
9947
|
Object.entries(config.projects).map(([slug, project]) => [slug, project.workspaces.length]),
|
|
9536
9948
|
),
|
|
@@ -9548,6 +9960,12 @@ function capabilities(config) {
|
|
|
9548
9960
|
// offered only to a machine that says this, and a machine that does not
|
|
9549
9961
|
// is told why on the queue rather than handed a base it would ignore.
|
|
9550
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,
|
|
9551
9969
|
// R270. This daemon knows an offer can carry `waitForClean`, and honours it
|
|
9552
9970
|
// in `pickWorkspace` — taking the preferred checkout only when clean, and
|
|
9553
9971
|
// refusing to fall back to a dirty one at all. The same shape as
|
|
@@ -9561,7 +9979,7 @@ function capabilities(config) {
|
|
|
9561
9979
|
// `glab`. An older daemon would fail the unknown kind, so the platform
|
|
9562
9980
|
// hands the request only to a daemon that says so.
|
|
9563
9981
|
createBranch: true,
|
|
9564
|
-
gitHosts: ['github', 'gitlab'],
|
|
9982
|
+
gitHosts: ['github', 'gitlab', 'bitbucket'],
|
|
9565
9983
|
});
|
|
9566
9984
|
}
|
|
9567
9985
|
|
|
@@ -9656,6 +10074,16 @@ async function main() {
|
|
|
9656
10074
|
process.on('SIGHUP', holdHup);
|
|
9657
10075
|
|
|
9658
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`);
|
|
9659
10087
|
// The checkouts made on demand by an earlier run of this daemon, found
|
|
9660
10088
|
// again by their name — nothing about them is in the config.
|
|
9661
10089
|
await syncAutoWorkspaces(config.projects);
|
|
@@ -9777,6 +10205,14 @@ async function main() {
|
|
|
9777
10205
|
// that takes an answer the platform then refuses reads as cawdev being
|
|
9778
10206
|
// broken, which is R58's rule about `a` applied to this one.
|
|
9779
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,
|
|
9780
10216
|
projects: Object.keys(config.projects),
|
|
9781
10217
|
// How many checkouts each has — R62. The per-project half of R47's
|
|
9782
10218
|
// gate, which the bar shows as `cawdev 1/2`. Added rather than
|
|
@@ -9996,12 +10432,34 @@ async function main() {
|
|
|
9996
10432
|
// R73/R80. The heartbeat's answer is what the platform decided about
|
|
9997
10433
|
// this machine, and it changes what the queue loop does next.
|
|
9998
10434
|
if (me && typeof me === 'object') {
|
|
9999
|
-
|
|
10000
|
-
|
|
10001
|
-
|
|
10002
|
-
|
|
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 });
|
|
10003
10461
|
}
|
|
10004
|
-
told.
|
|
10462
|
+
told.maxPlanningRuns = limit;
|
|
10005
10463
|
told.autoResume = !!me.autoResume;
|
|
10006
10464
|
told.heldWorkspaces = Array.isArray(me.heldWorkspaces)
|
|
10007
10465
|
? me.heldWorkspaces.map((each) => each.path).filter(Boolean)
|
|
@@ -10161,8 +10619,11 @@ async function main() {
|
|
|
10161
10619
|
break;
|
|
10162
10620
|
}
|
|
10163
10621
|
|
|
10164
|
-
// ONE gate — R109. There were two; `maxSessions` bounded this
|
|
10165
|
-
// 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.
|
|
10166
10627
|
//
|
|
10167
10628
|
// A project's workspaces bound its CODING runs and nothing else: they are
|
|
10168
10629
|
// checkouts, and only a coding run needs one.
|
|
@@ -10201,7 +10662,7 @@ async function main() {
|
|
|
10201
10662
|
// left to finish; that is the default because killing a machine's work
|
|
10202
10663
|
// to stop it taking more is the surprising reading.
|
|
10203
10664
|
if (told.paused) {
|
|
10204
|
-
noteQueued(offered.run,
|
|
10665
|
+
noteQueued(offered.run, queuedPauseNote(), reportedPauseNote());
|
|
10205
10666
|
skipped += 1;
|
|
10206
10667
|
continue;
|
|
10207
10668
|
}
|
|
@@ -10210,7 +10671,25 @@ async function main() {
|
|
|
10210
10671
|
}
|
|
10211
10672
|
const writes = writesCodeProfile(offered.run);
|
|
10212
10673
|
|
|
10213
|
-
//
|
|
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.)
|
|
10214
10693
|
//
|
|
10215
10694
|
// `maxSessions` bounded agent PROCESSES on this laptop, and R70's own
|
|
10216
10695
|
// note said why: a queue of runs is otherwise a fork bomb with better
|
|
@@ -10286,6 +10765,7 @@ async function main() {
|
|
|
10286
10765
|
taken.set(offered.run.id, {
|
|
10287
10766
|
projectSlug: slug,
|
|
10288
10767
|
writes,
|
|
10768
|
+
planning,
|
|
10289
10769
|
workspace,
|
|
10290
10770
|
label: offered.run.label,
|
|
10291
10771
|
});
|
|
@@ -10305,7 +10785,9 @@ async function main() {
|
|
|
10305
10785
|
// R148. Say it where a person can read it. Fire-and-forget: a daemon
|
|
10306
10786
|
// that died because the platform was slow to take a nicety would be
|
|
10307
10787
|
// worse than one that said nothing.
|
|
10308
|
-
const reasons = [...noted.entries()]
|
|
10788
|
+
const reasons = [...noted.entries()]
|
|
10789
|
+
.filter(([, note]) => note.reported)
|
|
10790
|
+
.map(([runId, note]) => ({ runId, why: note.reported }));
|
|
10309
10791
|
const reasonsBody = JSON.stringify(reasons);
|
|
10310
10792
|
if (reasonsBody !== lastSentReasons) {
|
|
10311
10793
|
lastSentReasons = reasonsBody;
|