cawdev-cli 1.2.0 → 1.2.2
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 +9 -1
- package/lib/gate-condition.mjs +11 -0
- package/lib/sonar-analysis.mjs +55 -9
- package/lib/stage-tools.mjs +33 -2
- package/mcp/server.mjs +44 -12
- package/package.json +1 -1
- package/runner/README.md +294 -33
- package/runner/attach.mjs +1927 -123
- package/runner/card-import.mjs +115 -0
- package/runner/cawdev.mjs +70 -1
- package/runner/diff-view.mjs +252 -0
- package/runner/git-lock.mjs +73 -0
- package/runner/runner.mjs +508 -39
- package/runner/session-cards.mjs +660 -0
- package/runner/shots.mjs +214 -0
- package/runner/sign-in.mjs +29 -2
- package/runner/split.mjs +307 -74
package/runner/runner.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
15
|
import { createHash } from 'node:crypto';
|
|
16
16
|
import {
|
|
17
|
-
access, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile,
|
|
17
|
+
access, copyFile, lstat, 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';
|
|
@@ -763,7 +763,7 @@ async function sayWhichServedProjectsTheTokenCannotReach(config) {
|
|
|
763
763
|
|
|
764
764
|
// --- talking to cawdev -------------------------------------------------------
|
|
765
765
|
|
|
766
|
-
async function api(config, path, { method = 'GET', body, token } = {}) {
|
|
766
|
+
async function api(config, path, { method = 'GET', body, token, raw } = {}) {
|
|
767
767
|
const response = await fetch(`${config.url}${path}`, {
|
|
768
768
|
method,
|
|
769
769
|
headers: {
|
|
@@ -777,8 +777,11 @@ async function api(config, path, { method = 'GET', body, token } = {}) {
|
|
|
777
777
|
// could raise them for any run it has ever been offered.
|
|
778
778
|
authorization: `Bearer ${token ?? config.token}`,
|
|
779
779
|
...(body ? { 'content-type': 'application/json' } : {}),
|
|
780
|
+
// R604: the raw bytes of a screenshot. The platform never reads this
|
|
781
|
+
// header; it sniffs the bytes.
|
|
782
|
+
...(raw ? { 'content-type': 'application/octet-stream' } : {}),
|
|
780
783
|
},
|
|
781
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
784
|
+
body: raw ?? (body ? JSON.stringify(body) : undefined),
|
|
782
785
|
});
|
|
783
786
|
const text = await response.text();
|
|
784
787
|
const parsed = text ? safeJson(text) : null;
|
|
@@ -1747,11 +1750,22 @@ async function readingCopy(source, defaultBranch) {
|
|
|
1747
1750
|
}
|
|
1748
1751
|
}
|
|
1749
1752
|
|
|
1750
|
-
async function prepareWorkingCopy(path, branch, defaultBranch, allowDirty, baseBranch = null) {
|
|
1753
|
+
async function prepareWorkingCopy(path, branch, defaultBranch, allowDirty, baseBranch = null, { resumingMerge = false } = {}) {
|
|
1751
1754
|
await access(join(path, '.git')).catch(() => {
|
|
1752
1755
|
throw new Error(`${path} is not a git repository.`);
|
|
1753
1756
|
});
|
|
1754
1757
|
|
|
1758
|
+
// R621. A checkout mid-merge was left by something the daemon did not see (a
|
|
1759
|
+
// daemon killed between the merge and the RUNNING post, or a person). Whose
|
|
1760
|
+
// merge it is cannot be known, so it is said and not touched. A MERGE run
|
|
1761
|
+
// carrying on its own branch is the one run that is meant to find it so.
|
|
1762
|
+
if (!resumingMerge && await git(path, ['rev-parse', '-q', '--verify', 'MERGE_HEAD']).then(() => true, () => false)) {
|
|
1763
|
+
throw new Error(
|
|
1764
|
+
`${path} is in the middle of a merge (MERGE_HEAD is set): from a merge run on its branch that did not finish, ` +
|
|
1765
|
+
`or one started by hand. Finish it (resolve and \`git -C ${path} commit\`) or abandon it ` +
|
|
1766
|
+
`(\`git -C ${path} merge --abort\`), then start the run again.`);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1755
1769
|
const dirty = await git(path, ['status', '--porcelain']);
|
|
1756
1770
|
if (dirty && !allowDirty) {
|
|
1757
1771
|
throw new Error(
|
|
@@ -1866,7 +1880,7 @@ async function prepareWorkingCopy(path, branch, defaultBranch, allowDirty, baseB
|
|
|
1866
1880
|
* — an older runner that did not say why — and it is the property that makes
|
|
1867
1881
|
* being wrong there cost a fetch rather than a model.
|
|
1868
1882
|
*/
|
|
1869
|
-
async function prepareMerge(cwd, branch, defaultBranch) {
|
|
1883
|
+
async function prepareMerge(cwd, branch, defaultBranch, from = null) {
|
|
1870
1884
|
const base = defaultBranch || 'main';
|
|
1871
1885
|
await git(cwd, ['fetch', '--prune', 'origin']).catch((failure) => {
|
|
1872
1886
|
log(` fetch skipped: ${failure.message.split('\n')[0]}`);
|
|
@@ -1878,6 +1892,10 @@ async function prepareMerge(cwd, branch, defaultBranch) {
|
|
|
1878
1892
|
const into = await git(cwd, ['rev-parse', '--verify', `origin/${base}`])
|
|
1879
1893
|
.then(() => `origin/${base}`)
|
|
1880
1894
|
.catch(() => base);
|
|
1895
|
+
// R621. Told to the caller BEFORE the merge runs, so a throw below still leaves it known.
|
|
1896
|
+
if (from) {
|
|
1897
|
+
from.into = into;
|
|
1898
|
+
}
|
|
1881
1899
|
|
|
1882
1900
|
let clean = true;
|
|
1883
1901
|
try {
|
|
@@ -1912,6 +1930,55 @@ async function prepareMerge(cwd, branch, defaultBranch) {
|
|
|
1912
1930
|
};
|
|
1913
1931
|
}
|
|
1914
1932
|
|
|
1933
|
+
/**
|
|
1934
|
+
* R621. Puts a checkout back as `prepareMerge` found it, when the run it was
|
|
1935
|
+
* prepared for never started. `from` is `{ before, into, branch }`: HEAD before
|
|
1936
|
+
* `git merge` ran, and the ref merged in (null when not known).
|
|
1937
|
+
*
|
|
1938
|
+
* Never throws. Returns one sentence, or null when there was nothing to undo.
|
|
1939
|
+
* `reset --keep`, never `--hard`: an allowDirty tree's own work must survive,
|
|
1940
|
+
* and git had already refused any dirty file the merge touches.
|
|
1941
|
+
*/
|
|
1942
|
+
async function undoPreparedMerge(cwd, from) {
|
|
1943
|
+
const couldNot = (why) => {
|
|
1944
|
+
const said = `!! could not undo the prepared merge in ${cwd}: ${String(why).split('\n')[0]}`;
|
|
1945
|
+
log(said);
|
|
1946
|
+
return said;
|
|
1947
|
+
};
|
|
1948
|
+
try {
|
|
1949
|
+
const midMerge = await git(cwd, ['rev-parse', '-q', '--verify', 'MERGE_HEAD']).then(() => true, () => false);
|
|
1950
|
+
if (midMerge) {
|
|
1951
|
+
await git(cwd, ['merge', '--abort']);
|
|
1952
|
+
return `the merge prepared in ${cwd} was undone (git merge --abort), because the run did not start`;
|
|
1953
|
+
}
|
|
1954
|
+
const before = from?.before;
|
|
1955
|
+
if (!before) {
|
|
1956
|
+
return null;
|
|
1957
|
+
}
|
|
1958
|
+
const head = await git(cwd, ['rev-parse', 'HEAD']);
|
|
1959
|
+
if (head === before) {
|
|
1960
|
+
return null;
|
|
1961
|
+
}
|
|
1962
|
+
const merged = from.into ? await git(cwd, ['rev-parse', from.into]) : null;
|
|
1963
|
+
// A fast-forward (the branch was strictly behind) moves HEAD onto `into` with no
|
|
1964
|
+
// merge commit; anything else must be a merge commit of `before` and `into`.
|
|
1965
|
+
const fastForward = merged !== null && head === merged
|
|
1966
|
+
&& await git(cwd, ['merge-base', '--is-ancestor', before, head]).then(() => true, () => false);
|
|
1967
|
+
if (!fastForward) {
|
|
1968
|
+
if ((await git(cwd, ['rev-parse', 'HEAD^1'])) !== before) {
|
|
1969
|
+
return couldNot('HEAD is not a merge commit on top of the pre-merge head');
|
|
1970
|
+
}
|
|
1971
|
+
if (merged && (await git(cwd, ['rev-parse', 'HEAD^2'])) !== merged) {
|
|
1972
|
+
return couldNot(`HEAD's second parent is not ${from.into}`);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
await git(cwd, ['reset', '--keep', before]);
|
|
1976
|
+
return `the merge commit prepared on ${from.branch} was undone (reset to ${before.slice(0, 7)}), because the run did not start`;
|
|
1977
|
+
} catch (failure) {
|
|
1978
|
+
return couldNot(failure.message);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1915
1982
|
/**
|
|
1916
1983
|
* Each live run's protected-file guard — R562: `{ globs, mode, allowed, base }`.
|
|
1917
1984
|
*
|
|
@@ -2677,6 +2744,7 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2677
2744
|
const problems = (reading.newBugs ?? 0) + (reading.newVulnerabilities ?? 0)
|
|
2678
2745
|
+ (reading.newSecurityHotspots ?? 0) + (reading.newCodeSmells ?? 0);
|
|
2679
2746
|
log(` quality: ${branch} gate ${reading.gate ?? 'NONE'} · ${problems} new problem${problems === 1 ? '' : 's'}`
|
|
2747
|
+
+ (reading.fixedKeys ? ` · fixed ${reading.fixedKeys.length} · still reported ${reading.stillOpenKeys.length}` : '')
|
|
2680
2748
|
+ (reading.warning ? ` · ${reading.warning}` : ''));
|
|
2681
2749
|
}
|
|
2682
2750
|
};
|
|
@@ -2746,12 +2814,12 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2746
2814
|
return report({ headSha, error: ran.said.split('\n')[0] || 'the scanner failed and said nothing' });
|
|
2747
2815
|
}
|
|
2748
2816
|
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2749
|
-
const
|
|
2750
|
-
const settled = await waitForAnalysis(
|
|
2817
|
+
const serverUrl = (reportTask.serverUrl ?? url).replace(/\/$/, '');
|
|
2818
|
+
const settled = await waitForAnalysis(sonarAsker(serverUrl, token), reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2751
2819
|
return await reportBranchReading({
|
|
2752
|
-
ask:
|
|
2820
|
+
ask: sonarReadAsker(serverUrl, token), reportTask, settled, key, branch, analysedAs: 'MAIN',
|
|
2753
2821
|
warning: "This server does not analyse branches; the branch was analysed as the project's main analysis.",
|
|
2754
|
-
headSha, report,
|
|
2822
|
+
headSha, report, findings: run.findingsToProve,
|
|
2755
2823
|
});
|
|
2756
2824
|
}
|
|
2757
2825
|
|
|
@@ -2793,9 +2861,12 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2793
2861
|
|
|
2794
2862
|
// What the scanner left behind: where the server queued the analysis.
|
|
2795
2863
|
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2796
|
-
const
|
|
2797
|
-
const settled = await waitForAnalysis(
|
|
2798
|
-
await reportBranchReading({
|
|
2864
|
+
const serverUrl = (reportTask.serverUrl ?? url).replace(/\/$/, '');
|
|
2865
|
+
const settled = await waitForAnalysis(sonarAsker(serverUrl, token), reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2866
|
+
await reportBranchReading({
|
|
2867
|
+
ask: sonarReadAsker(serverUrl, token), reportTask, settled, key, branch, analysedAs, warning, headSha, report,
|
|
2868
|
+
findings: run.findingsToProve,
|
|
2869
|
+
});
|
|
2799
2870
|
} catch (failure) {
|
|
2800
2871
|
await report({ headSha, error: failure.message.split('\n')[0] });
|
|
2801
2872
|
} finally {
|
|
@@ -2810,8 +2881,13 @@ function trimSlash(url) {
|
|
|
2810
2881
|
return url.replace(/\/$/, '');
|
|
2811
2882
|
}
|
|
2812
2883
|
|
|
2884
|
+
// Once the analysis has settled, the gate, measures, issue and hotspot pages and finding proofs
|
|
2885
|
+
// together get two minutes: not killed because the wait used up the scan's budget, not unbounded either (R622).
|
|
2886
|
+
const SONAR_READ_BUDGET_MS = 120_000;
|
|
2887
|
+
const sonarReadAsker = (serverUrl, token) => sonarAsker(serverUrl, token, { deadline: Date.now() + SONAR_READ_BUDGET_MS });
|
|
2888
|
+
|
|
2813
2889
|
/** The gate and the new-code measures, for a BRANCH or MAIN reading — never for PROJECT, which has neither. */
|
|
2814
|
-
async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report }) {
|
|
2890
|
+
async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report, findings }) {
|
|
2815
2891
|
const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
|
|
2816
2892
|
const metrics = 'new_bugs,new_vulnerabilities,new_security_hotspots,new_code_smells,new_coverage';
|
|
2817
2893
|
const { component } = await ask(`/api/measures/component?component=${encodeURIComponent(key)}`
|
|
@@ -2838,9 +2914,80 @@ async function reportBranchReading({ ask, reportTask, settled, key, branch, anal
|
|
|
2838
2914
|
newCoverage: measure('new_coverage'),
|
|
2839
2915
|
dashboardUrl: reportTask.dashboardUrl ?? null,
|
|
2840
2916
|
warning,
|
|
2917
|
+
...(await proveFindings({ ask, scanKey: key, branch, analysedAs, findings })),
|
|
2841
2918
|
});
|
|
2842
2919
|
}
|
|
2843
2920
|
|
|
2921
|
+
/**
|
|
2922
|
+
* R318: asks the server whether each finding a fix card was filed from
|
|
2923
|
+
* (`run.findingsToProve`, sent with the claim) is still reported on the tree
|
|
2924
|
+
* just scanned. Returns `{ fixedKeys, stillOpenKeys }`, or `{}` when the claim
|
|
2925
|
+
* named none — then neither field is sent and no request is made, which is
|
|
2926
|
+
* also what a platform older than R318 gets.
|
|
2927
|
+
*
|
|
2928
|
+
* SonarQube gives an issue a NEW key when it copies it onto a branch, so the
|
|
2929
|
+
* finding's own key finds nothing there and a lookup by it would call every
|
|
2930
|
+
* finding fixed. On a BRANCH or PROJECT reading the match is the rule in the
|
|
2931
|
+
* file, narrowed to the line's `hash` when the finding has one (lines move;
|
|
2932
|
+
* the hash of a line's text does not). Without a hash it errs one way only:
|
|
2933
|
+
* another issue of the same rule in that file reads as "still reported". On a
|
|
2934
|
+
* MAIN reading the analysis IS the project's main one, so the key is valid.
|
|
2935
|
+
*
|
|
2936
|
+
* Anything thrown lands in `scanQuality`'s catch and becomes an error
|
|
2937
|
+
* reading, which carries no verdict.
|
|
2938
|
+
*/
|
|
2939
|
+
async function proveFindings({ ask, scanKey, branch, analysedAs, findings }) {
|
|
2940
|
+
if (!Array.isArray(findings) || findings.length === 0) return {};
|
|
2941
|
+
const fixedKeys = [];
|
|
2942
|
+
const stillOpenKeys = [];
|
|
2943
|
+
const enc = encodeURIComponent;
|
|
2944
|
+
const onBranch = analysedAs === 'BRANCH' ? `&branch=${enc(branch)}` : '';
|
|
2945
|
+
for (const finding of findings) {
|
|
2946
|
+
const hotspot = finding.type === 'HOTSPOT';
|
|
2947
|
+
let open;
|
|
2948
|
+
if (analysedAs === 'MAIN') {
|
|
2949
|
+
open = hotspot ? await hotspotStillToReview(ask, finding.key) : await issueStillUnresolved(ask, finding.key);
|
|
2950
|
+
} else if (hotspot) {
|
|
2951
|
+
const found = await pagedSearch(ask,
|
|
2952
|
+
`/api/hotspots/search?projectKey=${enc(scanKey)}${finding.path ? `&files=${enc(finding.path)}` : ''}`
|
|
2953
|
+
+ `&status=TO_REVIEW${onBranch}`, 'hotspots');
|
|
2954
|
+
open = found.some((each) => each.ruleKey === finding.rule);
|
|
2955
|
+
} else {
|
|
2956
|
+
const component = finding.path ? `${scanKey}:${finding.path}` : scanKey;
|
|
2957
|
+
const query = (parameter) => `/api/issues/search?${parameter}=${enc(component)}`
|
|
2958
|
+
+ `&rules=${enc(finding.rule)}&resolved=false${onBranch}`;
|
|
2959
|
+
let found;
|
|
2960
|
+
try {
|
|
2961
|
+
found = await pagedSearch(ask, query('components'), 'issues');
|
|
2962
|
+
} catch (failure) {
|
|
2963
|
+
// Servers before 10.2 know only `componentKeys`.
|
|
2964
|
+
if (!/answered 400/.test(failure.message)) throw failure;
|
|
2965
|
+
found = await pagedSearch(ask, query('componentKeys'), 'issues');
|
|
2966
|
+
}
|
|
2967
|
+
open = finding.lineHash ? found.some((each) => each.hash === finding.lineHash) : found.length > 0;
|
|
2968
|
+
}
|
|
2969
|
+
(open ? stillOpenKeys : fixedKeys).push(finding.key);
|
|
2970
|
+
}
|
|
2971
|
+
return { fixedKeys, stillOpenKeys };
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
/** MAIN: the key is valid. Open while it comes back unresolved; purged (not returned) is fixed. */
|
|
2975
|
+
async function issueStillUnresolved(ask, key) {
|
|
2976
|
+
const body = await ask(`/api/issues/search?issues=${encodeURIComponent(key)}`);
|
|
2977
|
+
return (body.issues ?? []).some((each) => each.key === key && !each.resolution);
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
/** MAIN, hotspot: open while TO_REVIEW; a 404 is a hotspot the server no longer has. */
|
|
2981
|
+
async function hotspotStillToReview(ask, key) {
|
|
2982
|
+
try {
|
|
2983
|
+
const body = await ask(`/api/hotspots/show?hotspot=${encodeURIComponent(key)}`);
|
|
2984
|
+
return body.status === 'TO_REVIEW';
|
|
2985
|
+
} catch (failure) {
|
|
2986
|
+
if (/answered 404/.test(failure.message)) return false;
|
|
2987
|
+
throw failure;
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2844
2991
|
/**
|
|
2845
2992
|
* SonarQube's own pages, `ps=500` at a time, up to the server's `p × ps`
|
|
2846
2993
|
* ceiling worth trying — R313's `LIST_PAGES` argument, in the scanner
|
|
@@ -2911,8 +3058,9 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
|
|
|
2911
3058
|
}
|
|
2912
3059
|
|
|
2913
3060
|
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2914
|
-
const
|
|
2915
|
-
await waitForAnalysis(
|
|
3061
|
+
const serverUrl = (reportTask.serverUrl ?? url).replace(/\/$/, '');
|
|
3062
|
+
await waitForAnalysis(sonarAsker(serverUrl, token), reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
3063
|
+
const ask = sonarReadAsker(serverUrl, token);
|
|
2916
3064
|
|
|
2917
3065
|
const changed = await changedLines(cwd, mergeBase);
|
|
2918
3066
|
const counted = (component, line) => {
|
|
@@ -2942,6 +3090,9 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
|
|
|
2942
3090
|
}
|
|
2943
3091
|
const newSecurityHotspots = hotspots.filter((hotspot) => counted(hotspot.component, hotspot.line)).length;
|
|
2944
3092
|
|
|
3093
|
+
const proven = await proveFindings({
|
|
3094
|
+
ask, scanKey: scratchKey, branch, analysedAs: 'PROJECT', findings: run.findingsToProve });
|
|
3095
|
+
|
|
2945
3096
|
await report({
|
|
2946
3097
|
headSha,
|
|
2947
3098
|
gate: 'NONE',
|
|
@@ -2954,6 +3105,7 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
|
|
|
2954
3105
|
dashboardUrl: reportTask.dashboardUrl ?? null,
|
|
2955
3106
|
warning: 'This server does not analyse branches; the branch was analysed as its own SonarQube project, '
|
|
2956
3107
|
+ 'counting problems on the lines it changed. No gate was computed.',
|
|
3108
|
+
...proven,
|
|
2957
3109
|
});
|
|
2958
3110
|
}
|
|
2959
3111
|
|
|
@@ -5568,12 +5720,18 @@ async function reportUsageLimit(config, run, limit, lastText) {
|
|
|
5568
5720
|
* nothing for the session that was asking. One function for both paths now,
|
|
5569
5721
|
* so they cannot come apart again.
|
|
5570
5722
|
*/
|
|
5571
|
-
function argsBefore(agentArgs, { listOnly = false } = {}) {
|
|
5723
|
+
function argsBefore(agentArgs, { listOnly = false, dropMode = false } = {}) {
|
|
5572
5724
|
const kept = [];
|
|
5573
5725
|
for (let i = 0; i < agentArgs.length; i++) {
|
|
5574
5726
|
if (agentArgs[i] === '--allowedTools') {
|
|
5575
5727
|
break; // variadic: everything after it is a permission
|
|
5576
5728
|
}
|
|
5729
|
+
// R604. SHOW drops the MODE and keeps the prompt tool: nothing is allowed
|
|
5730
|
+
// by a mode, so whatever the list does not name reaches a person.
|
|
5731
|
+
if (dropMode && agentArgs[i] === '--permission-mode') {
|
|
5732
|
+
i += 1;
|
|
5733
|
+
continue;
|
|
5734
|
+
}
|
|
5577
5735
|
if (listOnly && (agentArgs[i] === '--permission-mode'
|
|
5578
5736
|
|| agentArgs[i] === '--permission-prompt-tool')) {
|
|
5579
5737
|
i += 1;
|
|
@@ -5755,12 +5913,33 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5755
5913
|
+ 'to hide. ' + WAIT_FOR_COMMANDS + ' Then commit the branch\'s changes — no stage '
|
|
5756
5914
|
+ 'after this one writes code, and work left uncommitted is work nothing can push. '
|
|
5757
5915
|
+ DONE_WHEN_TICK,
|
|
5916
|
+
// R604. The folder is the daemon's, the captions are the person's reading
|
|
5917
|
+
// guide, and the tree check afterwards is why "change nothing else" is a
|
|
5918
|
+
// fact and not a request.
|
|
5919
|
+
SHOW: 'SHOW THE PERSON what was built, before a pull request is proposed. Write 1 to 8 '
|
|
5920
|
+
+ 'files into `.cawdev/shots/` — a PNG or JPEG of the real screen, taken with the '
|
|
5921
|
+
+ 'repository\'s own Playwright, or with Claude in Chrome when you have it; or, when '
|
|
5922
|
+
+ 'neither can run here, a STATIC, self-contained HTML mock (no scripts, no external '
|
|
5923
|
+
+ 'URLs, inline CSS, images as `data:` URIs). Then write `.cawdev/shots/shots.json` as '
|
|
5924
|
+
+ '`[{"file": "name.png", "caption": "what to look at"}]`, one caption per file. Each '
|
|
5925
|
+
+ 'file must be 2 MB or smaller. CHANGE NOTHING ELSE and do not commit: the stage fails, '
|
|
5926
|
+
+ 'naming the paths, if the tree changes outside that folder (build output that is not '
|
|
5927
|
+
+ 'gitignored counts). Start the app in the background, take the shots, then STOP every '
|
|
5928
|
+
+ 'background command and READ its final output before you end the turn — a command left '
|
|
5929
|
+
+ 'running fails the stage. ' + WAIT_FOR_COMMANDS,
|
|
5758
5930
|
MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
|
|
5759
5931
|
+ 'tried and rejected and why, and anything that turned out to matter and would not be '
|
|
5760
5932
|
+ 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
|
|
5761
5933
|
+ 'briefing and handed to whoever comes next.',
|
|
5762
5934
|
}[stage.stage] ?? `Carry out the ${stage.stage} stage.`;
|
|
5763
5935
|
|
|
5936
|
+
// R604. A refused SHOW sends IMPLEMENT round again; the person's words lead.
|
|
5937
|
+
const rework = stage.rework
|
|
5938
|
+
? `### What the person asked to change after round ${stage.rework.round}\n\n`
|
|
5939
|
+
+ `${stage.rework.comments}\n\nMake these changes on this branch. TEST and SHOW run again `
|
|
5940
|
+
+ 'after you, and the person will look at a new round of screenshots.\n\n'
|
|
5941
|
+
: '';
|
|
5942
|
+
|
|
5764
5943
|
const before = [];
|
|
5765
5944
|
for (const [name, text] of Object.entries(carried ?? {})) {
|
|
5766
5945
|
if (text) {
|
|
@@ -5768,7 +5947,7 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5768
5947
|
}
|
|
5769
5948
|
}
|
|
5770
5949
|
|
|
5771
|
-
return `\n\n---\n\n## This is the ${stage.stage} stage\n\n${asked}`
|
|
5950
|
+
return `\n\n---\n\n## This is the ${stage.stage} stage\n\n${rework}${asked}`
|
|
5772
5951
|
+ (before.length ? `\n\n${before.join('\n\n')}` : '');
|
|
5773
5952
|
}
|
|
5774
5953
|
|
|
@@ -6717,6 +6896,13 @@ function noteQueued(run, why, reported = why) {
|
|
|
6717
6896
|
log(`${why}; leaving "${run.label}" queued`);
|
|
6718
6897
|
}
|
|
6719
6898
|
|
|
6899
|
+
/** The stage a child is and the stages of its walk, by name; none on an unstaged run. */
|
|
6900
|
+
function stamp(child, stage, lifecycle) {
|
|
6901
|
+
child.cawdevStartedAt ??= new Date().toISOString();
|
|
6902
|
+
child.cawdevStage = stage?.stage ?? null;
|
|
6903
|
+
child.cawdevStages = Array.isArray(lifecycle) && stage ? lifecycle.map((each) => each.stage) : null;
|
|
6904
|
+
}
|
|
6905
|
+
|
|
6720
6906
|
/**
|
|
6721
6907
|
* This machine, as somebody attached to it sees it — R52.
|
|
6722
6908
|
*
|
|
@@ -6745,6 +6931,10 @@ function snapshotRuns() {
|
|
|
6745
6931
|
agent: child.cawdevAgent ?? null,
|
|
6746
6932
|
model: child.cawdevModel ?? null,
|
|
6747
6933
|
startedAt: child.cawdevStartedAt ?? null,
|
|
6934
|
+
// The stage this child is and the walk it belongs to — a card's
|
|
6935
|
+
// progress line in advanced mode (session-cards.mjs `stageWalk`).
|
|
6936
|
+
stage: child.cawdevStage ?? null,
|
|
6937
|
+
stages: child.cawdevStages ?? null,
|
|
6748
6938
|
});
|
|
6749
6939
|
}
|
|
6750
6940
|
for (const [id, claim] of taken) {
|
|
@@ -6809,9 +6999,15 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6809
6999
|
let briefing = null;
|
|
6810
7000
|
let plan = null;
|
|
6811
7001
|
let lifecycle = [];
|
|
7002
|
+
// R604. The stages a refused SHOW walks again, and the comments of the latest
|
|
7003
|
+
// refused round when this walk begins after one.
|
|
7004
|
+
let showWalk = { rework: [], comments: null };
|
|
6812
7005
|
let shield = null;
|
|
6813
7006
|
// R155. What `git merge` did before anything was spawned, on a MERGE run.
|
|
6814
7007
|
let merge = null;
|
|
7008
|
+
// R621. What `undoPreparedMerge` needs, and whether RUNNING was accepted.
|
|
7009
|
+
let mergeFrom = null;
|
|
7010
|
+
let started = false;
|
|
6815
7011
|
|
|
6816
7012
|
try {
|
|
6817
7013
|
log(`claiming ${run.projectSlug} ${run.label} on ${run.branch}`);
|
|
@@ -6877,6 +7073,8 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6877
7073
|
// R587. The project's declared version files: the scan reads the first it
|
|
6878
7074
|
// can parse from the workspace and sends it as sonar.projectVersion.
|
|
6879
7075
|
run.versionFiles = Array.isArray(claimed.versionFiles) ? claimed.versionFiles : [];
|
|
7076
|
+
// R318: the findings the card was filed from, for the scan to prove fixed.
|
|
7077
|
+
run.findingsToProve = Array.isArray(claimed.findingsToProve) ? claimed.findingsToProve : [];
|
|
6880
7078
|
// R69. Whether this is the same conversation picked back up, and which one.
|
|
6881
7079
|
// Null on an ordinary claim, which is nearly all of them.
|
|
6882
7080
|
resume = claimed.resume ?? null;
|
|
@@ -6921,6 +7119,10 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6921
7119
|
// one. Read here with everything else the claim carries.
|
|
6922
7120
|
plan = claimed.plan ?? null;
|
|
6923
7121
|
lifecycle = Array.isArray(claimed.workflow) ? claimed.workflow : [];
|
|
7122
|
+
showWalk = {
|
|
7123
|
+
rework: Array.isArray(claimed.rework) ? claimed.rework : [],
|
|
7124
|
+
comments: claimed.showComments ?? null,
|
|
7125
|
+
};
|
|
6924
7126
|
shield = claimed.shield ?? null;
|
|
6925
7127
|
// R562. Every session is told the list up front, whichever profile it is.
|
|
6926
7128
|
run.protectedLine = protectedLine(protectedPolicy(shield));
|
|
@@ -6978,8 +7180,10 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6978
7180
|
if (!allowDirty) {
|
|
6979
7181
|
await resetWorkspace(resolve(path));
|
|
6980
7182
|
}
|
|
7183
|
+
const resumingMerge = run.profile === 'MERGE'
|
|
7184
|
+
&& await git(resolve(path), ['rev-parse', '--abbrev-ref', 'HEAD']).then((now) => now === run.branch, () => false);
|
|
6981
7185
|
const prepared = await prepareWorkingCopy(
|
|
6982
|
-
resolve(path), run.branch, defaultBranch, allowDirty, run.baseBranch);
|
|
7186
|
+
resolve(path), run.branch, defaultBranch, allowDirty, run.baseBranch, { resumingMerge });
|
|
6983
7187
|
branch = prepared.branch;
|
|
6984
7188
|
baseCommit = prepared.base;
|
|
6985
7189
|
if (handoff) {
|
|
@@ -7028,7 +7232,19 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
7028
7232
|
// R155. The daemon does the merge, BEFORE anything is spawned, and what
|
|
7029
7233
|
// git conflicts on becomes the session's whole write scope. A clean
|
|
7030
7234
|
// merge needs no session at all and gets none.
|
|
7031
|
-
|
|
7235
|
+
// R621. Recorded here and not inside `prepareMerge`, so that a throw after
|
|
7236
|
+
// `git merge` ran still has it. A null `before` is kept: there is nothing
|
|
7237
|
+
// safe to reset to, but `git merge --abort` still covers MERGE_HEAD.
|
|
7238
|
+
// Not set at all when the checkout is ALREADY mid-merge (a Carry-on): that
|
|
7239
|
+
// merge is not this attempt's to abort.
|
|
7240
|
+
const alreadyMerging = await git(resolve(path), ['rev-parse', '-q', '--verify', 'MERGE_HEAD'])
|
|
7241
|
+
.then(() => true, () => false);
|
|
7242
|
+
mergeFrom = alreadyMerging ? null : {
|
|
7243
|
+
before: await git(resolve(path), ['rev-parse', 'HEAD']).catch(() => null),
|
|
7244
|
+
into: null,
|
|
7245
|
+
branch,
|
|
7246
|
+
};
|
|
7247
|
+
merge = await prepareMerge(resolve(path), branch, run.baseBranch ?? defaultBranch, mergeFrom ?? undefined);
|
|
7032
7248
|
run.conflictedFiles = merge.conflicted;
|
|
7033
7249
|
log(merge.clean
|
|
7034
7250
|
? ` ${merge.into} merged into ${branch} with no conflict — nothing to resolve`
|
|
@@ -7064,9 +7280,16 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
7064
7280
|
// run's path is worse than one showing nothing.
|
|
7065
7281
|
body: { state: 'RUNNING', workspace: workspace ?? null },
|
|
7066
7282
|
});
|
|
7283
|
+
started = true;
|
|
7067
7284
|
} catch (refused) {
|
|
7068
7285
|
if (PLATFORM_REFUSALS.has(refused.status)) {
|
|
7069
7286
|
log(` the platform did not let this run start (${refused.message}); leaving it as the platform has it`);
|
|
7287
|
+
if (mergeFrom) {
|
|
7288
|
+
const undone = await undoPreparedMerge(resolve(path), mergeFrom);
|
|
7289
|
+
if (undone) {
|
|
7290
|
+
log(` ${undone}`);
|
|
7291
|
+
}
|
|
7292
|
+
}
|
|
7070
7293
|
protectedGuards.delete(run.id);
|
|
7071
7294
|
return;
|
|
7072
7295
|
}
|
|
@@ -7084,7 +7307,7 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
7084
7307
|
}
|
|
7085
7308
|
|
|
7086
7309
|
const said = await walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
7087
|
-
resume, mcpServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield);
|
|
7310
|
+
resume, mcpServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield, showWalk);
|
|
7088
7311
|
|
|
7089
7312
|
if (merge) {
|
|
7090
7313
|
// R155. The session has gone. Whether it resolved anything is a question
|
|
@@ -7097,9 +7320,16 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
7097
7320
|
// Anything that goes wrong before or during the spawn is the run's failure,
|
|
7098
7321
|
// and the reason belongs on the run where someone will see it.
|
|
7099
7322
|
log(` failed: ${failure.message}`);
|
|
7323
|
+
let summary = failure.message;
|
|
7324
|
+
if (mergeFrom && !started) {
|
|
7325
|
+
const undone = await undoPreparedMerge(resolve(path), mergeFrom);
|
|
7326
|
+
if (undone) {
|
|
7327
|
+
summary = `${summary}\n\n${undone}`;
|
|
7328
|
+
}
|
|
7329
|
+
}
|
|
7100
7330
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
|
|
7101
7331
|
method: 'POST',
|
|
7102
|
-
body: { state: 'FAILED', summary
|
|
7332
|
+
body: { state: 'FAILED', summary },
|
|
7103
7333
|
}).catch((second) => log(` could not record the failure: ${second.message}`));
|
|
7104
7334
|
} finally {
|
|
7105
7335
|
reader?.release();
|
|
@@ -7198,7 +7428,8 @@ async function skipRest(config, run, lifecycle, from, why) {
|
|
|
7198
7428
|
}
|
|
7199
7429
|
|
|
7200
7430
|
async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
7201
|
-
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield
|
|
7431
|
+
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
|
|
7432
|
+
show = null) {
|
|
7202
7433
|
const spawn = (stage, carried, usage) => spawnAgent(config, run, runToken, cwd, baseCommit,
|
|
7203
7434
|
workspace, resume, projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle,
|
|
7204
7435
|
shield, stage, carried, usage);
|
|
@@ -7218,6 +7449,22 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7218
7449
|
const carried = {};
|
|
7219
7450
|
let last = null;
|
|
7220
7451
|
|
|
7452
|
+
// R604. A mutable copy: a refused SHOW splices IMPLEMENT, TEST and SHOW back in
|
|
7453
|
+
// after itself. Every skipRest below is passed THIS, not the claim's list.
|
|
7454
|
+
const walk = [...lifecycle];
|
|
7455
|
+
// The comments of a round that was refused before this walk began (the daemon
|
|
7456
|
+
// died, or the run was carried on): the first IMPLEMENT leads with them, and a
|
|
7457
|
+
// walk that starts later is told in `carried`.
|
|
7458
|
+
if (show?.comments) {
|
|
7459
|
+
const first = walk.findIndex((each) => each.stage === 'IMPLEMENT');
|
|
7460
|
+
const rework = { round: show.comments.round, comments: show.comments.comments };
|
|
7461
|
+
if (first === 0) {
|
|
7462
|
+
walk[0] = { ...walk[0], rework };
|
|
7463
|
+
} else {
|
|
7464
|
+
carried[`SHOW (comments on round ${rework.round})`] = rework.comments;
|
|
7465
|
+
}
|
|
7466
|
+
}
|
|
7467
|
+
|
|
7221
7468
|
// R305. What the RUN has been credited with so far. Each stage is its own
|
|
7222
7469
|
// process with its own cumulative counter, so the base the next child adds
|
|
7223
7470
|
// to must move — read once from the claim, it stayed at zero and the platform
|
|
@@ -7233,22 +7480,32 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7233
7480
|
// answers without the fields and the base is zero, which is today's number.
|
|
7234
7481
|
const stored = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/stages`)
|
|
7235
7482
|
.catch(() => []);
|
|
7483
|
+
// R604: a stage walked again after a refused SHOW is not told again about the
|
|
7484
|
+
// failure the person had already moved past.
|
|
7485
|
+
const retold = new Set();
|
|
7486
|
+
const credits = new Map();
|
|
7236
7487
|
const creditedTo = (name) => {
|
|
7237
|
-
|
|
7238
|
-
|
|
7488
|
+
// R604. One per stage NAME for the whole walk: a stage walked again after
|
|
7489
|
+
// a refused SHOW keeps accumulating instead of starting from the stored base.
|
|
7490
|
+
if (!credits.has(name)) {
|
|
7491
|
+
const row = Array.isArray(stored) ? stored.find((each) => each?.stage === name) : null;
|
|
7492
|
+
credits.set(name, { tokensIn: Number(row?.tokensIn) || 0, tokensOut: Number(row?.tokensOut) || 0 });
|
|
7493
|
+
}
|
|
7494
|
+
return credits.get(name);
|
|
7239
7495
|
};
|
|
7240
7496
|
|
|
7241
7497
|
const storedRow = (name) => (Array.isArray(stored)
|
|
7242
7498
|
? stored.find((each) => each?.stage === name) : null);
|
|
7243
7499
|
|
|
7244
|
-
for (let at = 0; at <
|
|
7245
|
-
const stage =
|
|
7500
|
+
walking: for (let at = 0; at < walk.length; at++) {
|
|
7501
|
+
const stage = walk[at];
|
|
7246
7502
|
// Where a carried-on run resumes, and what its DONE stages said, is the
|
|
7247
7503
|
// platform's (R263, `WorkflowService.resumeFrom`): the claim's lifecycle
|
|
7248
7504
|
// starts at the first stage not DONE. What only the daemon knows is WHY
|
|
7249
7505
|
// the stage about to run stopped last time — so that is handed to it.
|
|
7250
7506
|
const before = storedRow(stage.stage);
|
|
7251
|
-
if (before?.state === 'FAILED' && before.outcome) {
|
|
7507
|
+
if (before?.state === 'FAILED' && before.outcome && !retold.has(stage.stage)) {
|
|
7508
|
+
retold.add(stage.stage);
|
|
7252
7509
|
// The attempt that stopped, handed to the one about to run.
|
|
7253
7510
|
carried[`${stage.stage} (the attempt that stopped)`] = `${before.outcome}\n\n`
|
|
7254
7511
|
+ 'This stage is being run again to finish that. Run the commands — and the agents — '
|
|
@@ -7267,6 +7524,8 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7267
7524
|
// loop nobody asked for.
|
|
7268
7525
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
7269
7526
|
await reportStage(config, run, stage.stage, 'begin');
|
|
7527
|
+
// R604. What the tree looks like before SHOW, for the check after it.
|
|
7528
|
+
const treeBefore = stage.stage === 'SHOW' ? await snapshotTree(cwd) : null;
|
|
7270
7529
|
log(` ${stage.stage}${stage.model ? ` on ${stage.model}` : ''}`
|
|
7271
7530
|
+ `${attempt ? ' (again, after the gate refused it)' : ''}`);
|
|
7272
7531
|
|
|
@@ -7331,7 +7590,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7331
7590
|
plan: stage.stage === 'PLAN' ? last?.text ?? null : null,
|
|
7332
7591
|
outcome: `${why} ${last?.text ?? ''}`.trim(),
|
|
7333
7592
|
});
|
|
7334
|
-
await skipRest(config, run,
|
|
7593
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7335
7594
|
return;
|
|
7336
7595
|
}
|
|
7337
7596
|
|
|
@@ -7347,7 +7606,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7347
7606
|
plan: stage.stage === 'PLAN' ? last?.text ?? null : null,
|
|
7348
7607
|
outcome: `${why} ${last?.text ?? ''}`.trim(),
|
|
7349
7608
|
});
|
|
7350
|
-
await skipRest(config, run,
|
|
7609
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7351
7610
|
return;
|
|
7352
7611
|
}
|
|
7353
7612
|
|
|
@@ -7381,6 +7640,84 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7381
7640
|
return;
|
|
7382
7641
|
}
|
|
7383
7642
|
|
|
7643
|
+
// R604. SHOW: judge the tree, upload, raise the gate, and report DONE
|
|
7644
|
+
// only AFTER the person accepts — a daemon that died while the gate was
|
|
7645
|
+
// waiting must resume at SHOW and not skip to MEMORY with nobody having
|
|
7646
|
+
// decided. Never enters the two-attempt refusal path: a refusal re-walks
|
|
7647
|
+
// IMPLEMENT and TEST, with no cap.
|
|
7648
|
+
if (stage.stage === 'SHOW') {
|
|
7649
|
+
const stray = await strayChanges(treeBefore, cwd);
|
|
7650
|
+
if (stray.length) {
|
|
7651
|
+
const why = strayLine(stray);
|
|
7652
|
+
log(` ${why}`);
|
|
7653
|
+
await reportStage(config, run, 'SHOW', 'report', { state: 'FAILED', outcome: why });
|
|
7654
|
+
await finish(config, run, 'FAILED', why);
|
|
7655
|
+
return;
|
|
7656
|
+
}
|
|
7657
|
+
let sealed;
|
|
7658
|
+
try {
|
|
7659
|
+
sealed = await uploadShots(config, run, cwd);
|
|
7660
|
+
} catch (refused) {
|
|
7661
|
+
const why = `The SHOW stage could not hand over its screenshots: ${refused.message}`;
|
|
7662
|
+
log(` ${why}`);
|
|
7663
|
+
await reportStage(config, run, 'SHOW', 'report', { state: 'FAILED', outcome: why });
|
|
7664
|
+
await finish(config, run, 'FAILED', why);
|
|
7665
|
+
return;
|
|
7666
|
+
}
|
|
7667
|
+
const shots = sealed.attachments ?? [];
|
|
7668
|
+
const summary = `${shots.length} screenshot${shots.length === 1 ? '' : 's'} to look at\n\n`
|
|
7669
|
+
+ shots.map((each) => `- **${each.name}** — ${each.caption ?? 'no caption'}`).join('\n');
|
|
7670
|
+
const decision = await awaitGate(config, run, runToken, stage, summary, {
|
|
7671
|
+
toolInput: { stage: 'SHOW', round: sealed.round, attachments: shots.map((each) => each.id) },
|
|
7672
|
+
});
|
|
7673
|
+
if (decision.failed) {
|
|
7674
|
+
// A gate that could not be raised, or whose wait broke, FAILS the stage
|
|
7675
|
+
// rather than reading as a refusal: with no cap, a refusal would loop
|
|
7676
|
+
// IMPLEMENT and TEST for ever on a network blip. /continue re-runs
|
|
7677
|
+
// SHOW, whose new gate supersedes the stale one.
|
|
7678
|
+
const why = `The SHOW gate could not be reached: ${decision.reason}`;
|
|
7679
|
+
await reportStage(config, run, 'SHOW', 'report', { state: 'FAILED', outcome: why });
|
|
7680
|
+
await finish(config, run, 'FAILED', why);
|
|
7681
|
+
return;
|
|
7682
|
+
}
|
|
7683
|
+
if (decision.expired) {
|
|
7684
|
+
const why = `Stopped at the SHOW gate: ${decision.reason}. Nothing was refused — start the `
|
|
7685
|
+
+ 'run again when somebody can look.';
|
|
7686
|
+
await reportStage(config, run, 'SHOW', 'report', { state: 'SKIPPED', outcome: why });
|
|
7687
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7688
|
+
await finish(config, run, 'FINISHED', why, cwd);
|
|
7689
|
+
return;
|
|
7690
|
+
}
|
|
7691
|
+
if (!decision.allowed) {
|
|
7692
|
+
// Comments. The platform has already put IMPLEMENT, TEST and SHOW back
|
|
7693
|
+
// to PENDING; this walks them again, the comments leading IMPLEMENT.
|
|
7694
|
+
log(` the SHOW gate was sent back with comments (round ${sealed.round}); implementing again`);
|
|
7695
|
+
delete carried.IMPLEMENT;
|
|
7696
|
+
delete carried.TEST;
|
|
7697
|
+
delete carried.SHOW;
|
|
7698
|
+
for (const key of Object.keys(carried)) {
|
|
7699
|
+
if (key.startsWith('SHOW (comments on round')) delete carried[key];
|
|
7700
|
+
}
|
|
7701
|
+
const again = (show?.rework ?? []).map((each) => ({
|
|
7702
|
+
...each,
|
|
7703
|
+
...(each.stage === 'IMPLEMENT'
|
|
7704
|
+
? { rework: { round: sealed.round, comments: decision.reason ?? 'No comment was written.' } }
|
|
7705
|
+
: {}),
|
|
7706
|
+
}));
|
|
7707
|
+
if (!again.length) {
|
|
7708
|
+
const why = 'The SHOW gate was sent back with comments, and this platform did not say which '
|
|
7709
|
+
+ 'stages to walk again. Continue the run to start over from IMPLEMENT.';
|
|
7710
|
+
await finish(config, run, 'FAILED', why);
|
|
7711
|
+
return;
|
|
7712
|
+
}
|
|
7713
|
+
walk.splice(at + 1, 0, ...again);
|
|
7714
|
+
continue walking;
|
|
7715
|
+
}
|
|
7716
|
+
await reportStage(config, run, 'SHOW', 'report', { state: 'DONE', outcome: summary });
|
|
7717
|
+
last = { ...last, text: summary };
|
|
7718
|
+
break;
|
|
7719
|
+
}
|
|
7720
|
+
|
|
7384
7721
|
await reportStage(config, run, stage.stage, 'report', {
|
|
7385
7722
|
state: 'DONE',
|
|
7386
7723
|
// The PLAN's text IS the plan — the artefact R109 exists to store, and
|
|
@@ -7402,7 +7739,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7402
7739
|
// second stage on a plan nobody has looked at yet.
|
|
7403
7740
|
const why = `Stopped at the ${stage.stage} gate: ${decision.reason}. Nothing was `
|
|
7404
7741
|
+ 'refused — start the run again when somebody can read it.';
|
|
7405
|
-
await skipRest(config, run,
|
|
7742
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7406
7743
|
await finish(config, run, 'FINISHED', why, cwd);
|
|
7407
7744
|
return;
|
|
7408
7745
|
}
|
|
@@ -7411,7 +7748,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7411
7748
|
// The same trail R122 came from: a run that ends here never reaches the
|
|
7412
7749
|
// stages after it, and stages left PENDING on a run that is over read
|
|
7413
7750
|
// as a lifecycle still going.
|
|
7414
|
-
await skipRest(config, run,
|
|
7751
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7415
7752
|
await finish(config, run, 'FINISHED', why, cwd);
|
|
7416
7753
|
return;
|
|
7417
7754
|
}
|
|
@@ -7427,7 +7764,8 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7427
7764
|
// the tree the tests just ran against, by the daemon and not the session.
|
|
7428
7765
|
// AWAITED: the workspace is released in `perform`'s finally, and a
|
|
7429
7766
|
// scanner still reading files when the next run's reset runs is the
|
|
7430
|
-
// hazard; the block's `timeoutSeconds` bounds the wait
|
|
7767
|
+
// hazard; the block's `timeoutSeconds` bounds the wait and
|
|
7768
|
+
// SONAR_READ_BUDGET_MS the reads after it (R622). It never throws.
|
|
7431
7769
|
if (stage.stage === 'TEST' && run.branch && writesAnythingProfile(run)) {
|
|
7432
7770
|
await scanQuality(config, run, cwd, run.branch);
|
|
7433
7771
|
}
|
|
@@ -7472,6 +7810,115 @@ async function reportStage(config, run, stage, what, body = {}) {
|
|
|
7472
7810
|
.catch((failure) => log(` could not report ${stage}: ${failure.message}`));
|
|
7473
7811
|
}
|
|
7474
7812
|
|
|
7813
|
+
/**
|
|
7814
|
+
* What the tree looked like when SHOW began — R604. Per-path content hashes of
|
|
7815
|
+
* everything that differs from HEAD (tracked) and everything untracked and not
|
|
7816
|
+
* ignored, so `strayChanges` can say WHICH paths a stage touched, including a
|
|
7817
|
+
* file it rewrote that was already dirty.
|
|
7818
|
+
*/
|
|
7819
|
+
async function snapshotTree(cwd) {
|
|
7820
|
+
const head = await git(cwd, ['rev-parse', 'HEAD']).catch(() => '');
|
|
7821
|
+
const hash = async (path) => {
|
|
7822
|
+
try {
|
|
7823
|
+
const info = await stat(join(cwd, path));
|
|
7824
|
+
if (!info.isFile()) return 'not-a-file';
|
|
7825
|
+
if (info.size > 64 * 1024 * 1024) return `big:${info.size}:${info.mtimeMs}`;
|
|
7826
|
+
return createHash('sha256').update(await readFile(join(cwd, path))).digest('hex');
|
|
7827
|
+
} catch {
|
|
7828
|
+
return 'gone';
|
|
7829
|
+
}
|
|
7830
|
+
};
|
|
7831
|
+
// `-z`: git quotes a path with non-ASCII or special characters otherwise, and
|
|
7832
|
+
// a quoted path hashes as "gone" before and after, hiding the change.
|
|
7833
|
+
const lines = async (args) => (await git(cwd, args).catch(() => ''))
|
|
7834
|
+
.split('\0').filter(Boolean);
|
|
7835
|
+
const files = new Map();
|
|
7836
|
+
for (const path of [
|
|
7837
|
+
...await lines(['diff', 'HEAD', '--name-only', '-z']),
|
|
7838
|
+
...await lines(['ls-files', '--others', '--exclude-standard', '-z']),
|
|
7839
|
+
]) {
|
|
7840
|
+
files.set(path, await hash(path));
|
|
7841
|
+
}
|
|
7842
|
+
return { head, files };
|
|
7843
|
+
}
|
|
7844
|
+
|
|
7845
|
+
/**
|
|
7846
|
+
* The paths a SHOW stage changed outside `.cawdev/shots/`, and a commit if it
|
|
7847
|
+
* made one — R604. The tree check is the real guard: SHOW holds a Bash that can
|
|
7848
|
+
* write anywhere, and a narrowed tool list says what it was GIVEN, not what it did.
|
|
7849
|
+
*/
|
|
7850
|
+
async function strayChanges(before, cwd) {
|
|
7851
|
+
const after = await snapshotTree(cwd);
|
|
7852
|
+
const stray = [];
|
|
7853
|
+
if (before.head !== after.head) {
|
|
7854
|
+
stray.push('a commit (HEAD moved)');
|
|
7855
|
+
}
|
|
7856
|
+
for (const path of new Set([...before.files.keys(), ...after.files.keys()])) {
|
|
7857
|
+
if (path.startsWith('.cawdev/shots/')) continue;
|
|
7858
|
+
if (before.files.get(path) !== after.files.get(path)) stray.push(path);
|
|
7859
|
+
}
|
|
7860
|
+
return stray;
|
|
7861
|
+
}
|
|
7862
|
+
|
|
7863
|
+
function strayLine(stray) {
|
|
7864
|
+
const shown = stray.slice(0, 10).map((path) => `\`${path}\``).join(', ');
|
|
7865
|
+
return `The SHOW stage changed the checkout outside \`.cawdev/shots/\`: ${shown}`
|
|
7866
|
+
+ `${stray.length > 10 ? ` and ${stray.length - 10} more` : ''}. SHOW may only write its screenshots; `
|
|
7867
|
+
+ 'build output that is not gitignored counts too (add it to .gitignore). Nothing was shown.';
|
|
7868
|
+
}
|
|
7869
|
+
|
|
7870
|
+
/**
|
|
7871
|
+
* Uploads what SHOW left and seals it as a round — R604. With the MACHINE's
|
|
7872
|
+
* token: the session holds the run token and must not upload what it is judged
|
|
7873
|
+
* by. Throws with the platform's own sentence on any refusal. Deletes the
|
|
7874
|
+
* folder once the round is sealed.
|
|
7875
|
+
*/
|
|
7876
|
+
async function uploadShots(config, run, cwd) {
|
|
7877
|
+
const dir = join(cwd, '.cawdev', 'shots');
|
|
7878
|
+
let manifest;
|
|
7879
|
+
try {
|
|
7880
|
+
manifest = JSON.parse(await readFile(join(dir, 'shots.json'), 'utf8'));
|
|
7881
|
+
} catch {
|
|
7882
|
+
throw new Error('SHOW left no valid `.cawdev/shots/shots.json` — it must be a JSON list of '
|
|
7883
|
+
+ '{"file", "caption"}, one entry per screenshot.');
|
|
7884
|
+
}
|
|
7885
|
+
if (!Array.isArray(manifest) || manifest.length === 0
|
|
7886
|
+
|| manifest.some((each) => typeof each?.file !== 'string' || !each.file)) {
|
|
7887
|
+
throw new Error('`.cawdev/shots/shots.json` must be a non-empty JSON list of '
|
|
7888
|
+
+ '{"file", "caption"}, one entry per screenshot.');
|
|
7889
|
+
}
|
|
7890
|
+
const base = `/api/projects/${run.projectSlug}/runs/${run.id}/attachments`;
|
|
7891
|
+
const files = [];
|
|
7892
|
+
for (const each of manifest) {
|
|
7893
|
+
const name = each.file;
|
|
7894
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
|
|
7895
|
+
throw new Error(`\`${name}\` is not a usable name: letters, digits, \`.\`, \`_\` and \`-\`, `
|
|
7896
|
+
+ 'ending .png, .jpg, .jpeg or .html.');
|
|
7897
|
+
}
|
|
7898
|
+
let info;
|
|
7899
|
+
try {
|
|
7900
|
+
info = await lstat(join(dir, name));
|
|
7901
|
+
} catch {
|
|
7902
|
+
throw new Error(`\`shots.json\` lists \`${name}\` and it is not in \`.cawdev/shots/\`.`);
|
|
7903
|
+
}
|
|
7904
|
+
if (!info.isFile()) {
|
|
7905
|
+
// A symlink would be read as the machine and uploaded: only plain files.
|
|
7906
|
+
throw new Error(`\`${name}\` is not a plain file in \`.cawdev/shots/\`.`);
|
|
7907
|
+
}
|
|
7908
|
+
if (info.size > 2 * 1024 * 1024) {
|
|
7909
|
+
throw new Error(`\`${name}\` is ${(info.size / 1048576).toFixed(1)} MB; a screenshot must be `
|
|
7910
|
+
+ '2 MB or smaller.');
|
|
7911
|
+
}
|
|
7912
|
+
await api(config, `${base}/${encodeURIComponent(name)}`, {
|
|
7913
|
+
method: 'PUT', raw: await readFile(join(dir, name)),
|
|
7914
|
+
});
|
|
7915
|
+
files.push({ name, caption: typeof each.caption === 'string' ? each.caption : null });
|
|
7916
|
+
}
|
|
7917
|
+
const sealed = await api(config, `${base}/rounds`, { method: 'POST', body: { files } });
|
|
7918
|
+
await rm(dir, { recursive: true, force: true });
|
|
7919
|
+
return sealed;
|
|
7920
|
+
}
|
|
7921
|
+
|
|
7475
7922
|
/**
|
|
7476
7923
|
* Waits for a person at a gated stage — R112.
|
|
7477
7924
|
*
|
|
@@ -7488,7 +7935,7 @@ async function reportStage(config, run, stage, what, body = {}) {
|
|
|
7488
7935
|
* this can fail in: a gate that let the run through when the platform was down
|
|
7489
7936
|
* would be a gate that opens under exactly the conditions nobody is watching.
|
|
7490
7937
|
*/
|
|
7491
|
-
async function awaitGate(config, run, runToken, stage, text) {
|
|
7938
|
+
async function awaitGate(config, run, runToken, stage, text, { toolInput } = {}) {
|
|
7492
7939
|
let asked;
|
|
7493
7940
|
try {
|
|
7494
7941
|
asked = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/approvals`, {
|
|
@@ -7498,14 +7945,14 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
7498
7945
|
token: runToken,
|
|
7499
7946
|
body: {
|
|
7500
7947
|
toolName: `stage:${stage.stage}`,
|
|
7501
|
-
toolInput: JSON.stringify({ stage: stage.stage }),
|
|
7948
|
+
toolInput: JSON.stringify(toolInput ?? { stage: stage.stage }),
|
|
7502
7949
|
summary: text?.trim()
|
|
7503
7950
|
|| `The ${stage.stage} stage finished without saying anything.`,
|
|
7504
7951
|
},
|
|
7505
7952
|
});
|
|
7506
7953
|
} catch (failure) {
|
|
7507
7954
|
log(` could not raise the ${stage.stage} gate: ${failure.message}`);
|
|
7508
|
-
return { allowed: false, reason: `the gate could not be raised (${failure.message})` };
|
|
7955
|
+
return { allowed: false, failed: true, reason: `the gate could not be raised (${failure.message})` };
|
|
7509
7956
|
}
|
|
7510
7957
|
|
|
7511
7958
|
log(` waiting at the ${stage.stage} gate`);
|
|
@@ -7538,7 +7985,7 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
7538
7985
|
}
|
|
7539
7986
|
} catch (failure) {
|
|
7540
7987
|
log(` waiting on the ${stage.stage} gate failed: ${failure.message}`);
|
|
7541
|
-
return { allowed: false, reason: `waiting failed (${failure.message})` };
|
|
7988
|
+
return { allowed: false, failed: true, reason: `waiting failed (${failure.message})` };
|
|
7542
7989
|
}
|
|
7543
7990
|
}
|
|
7544
7991
|
return { allowed: false, expired: true, reason: 'nobody answered in time' };
|
|
@@ -7736,7 +8183,22 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7736
8183
|
// is told about all agree. Everything else, and every process with no
|
|
7737
8184
|
// lifecycle, gets them all.
|
|
7738
8185
|
const readOnlyStage = Boolean(stage) && ['PLAN', 'VERIFY', 'MEMORY'].includes(stage.stage);
|
|
7739
|
-
|
|
8186
|
+
// R604. SHOW is not read-only, but it reaches only experts that cannot
|
|
8187
|
+
// change anything: it has nothing to delegate that a Bash it already holds
|
|
8188
|
+
// could not do, and a writer behind it would be a writer past the tree check's
|
|
8189
|
+
// reading of the stage.
|
|
8190
|
+
const showStage = stage?.stage === 'SHOW';
|
|
8191
|
+
const stageExperts = readOnlyStage || showStage ? expertAgents.filter(readOnlyExpert) : expertAgents;
|
|
8192
|
+
|
|
8193
|
+
// R604. The folder SHOW writes into, kept out of git before anything can
|
|
8194
|
+
// `git add -A` it, and made so the stage need not.
|
|
8195
|
+
if (showStage) {
|
|
8196
|
+
await excludeLocally(cwd, '.cawdev/shots');
|
|
8197
|
+
// Emptied first: a SHOW that failed and was continued must not seal the
|
|
8198
|
+
// files its last attempt left behind as this round.
|
|
8199
|
+
await rm(join(cwd, '.cawdev', 'shots'), { recursive: true, force: true });
|
|
8200
|
+
await mkdir(join(cwd, '.cawdev', 'shots'), { recursive: true });
|
|
8201
|
+
}
|
|
7740
8202
|
// R161. From `stageExperts` and NEVER from `expertAgents` — see the note
|
|
7741
8203
|
// above; an expert a read-only stage cannot reach must not be required here.
|
|
7742
8204
|
const cannotDelegate = readOnlyStage && expertAgents.length && !stageExperts.length
|
|
@@ -8088,8 +8550,12 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
8088
8550
|
// has always done for the same profile spawned as one process. Without this
|
|
8089
8551
|
// the list `toolsForStage` narrows was narrowed on paper: the mode wrote
|
|
8090
8552
|
// files the list never named, and the prompt asked a person for the rest.
|
|
8553
|
+
// R604. SHOW: no permission mode (no acceptEdits, never bypass — "allow
|
|
8554
|
+
// everything" does not reach it), the prompt tool kept, and a list narrowed
|
|
8555
|
+
// to the shots folder. The daemon's tree check is the real guard.
|
|
8091
8556
|
const stageArgs = stage
|
|
8092
|
-
? [...argsBefore(agentArgs, { listOnly: !codesFreely || readOnlyStage }),
|
|
8557
|
+
? [...argsBefore(agentArgs, { listOnly: !codesFreely || readOnlyStage, dropMode: showStage }),
|
|
8558
|
+
'--allowedTools',
|
|
8093
8559
|
...toolsForStage(stage.stage,
|
|
8094
8560
|
withSkills(
|
|
8095
8561
|
withDelegation(typeof profileTools === 'function' ? profileTools(run) : profileTools,
|
|
@@ -8102,7 +8568,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
8102
8568
|
// handed is read-only — which `stageExperts` guarantees above.
|
|
8103
8569
|
// R150. And a PLAN stage keeps `roadmap_create` when the run has no
|
|
8104
8570
|
// card to plan, because writing it is the first thing it has to do.
|
|
8105
|
-
{ delegatesReadOnly: readOnlyStage && stageExperts.length > 0,
|
|
8571
|
+
{ delegatesReadOnly: (readOnlyStage || showStage) && stageExperts.length > 0,
|
|
8106
8572
|
writesItsOwnCard: writesItsOwnCard(run) })]
|
|
8107
8573
|
: null;
|
|
8108
8574
|
|
|
@@ -8154,6 +8620,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
8154
8620
|
// `init` (below), which is also the only answer when none was asked.
|
|
8155
8621
|
child.cawdevAgent = 'claude';
|
|
8156
8622
|
child.cawdevModel = model ?? null;
|
|
8623
|
+
stamp(child, stage, lifecycle);
|
|
8157
8624
|
// The checkout this child holds, and what the queue serialises on. Null for
|
|
8158
8625
|
// a run that took none, which is what lets an ASK session run alongside
|
|
8159
8626
|
// three coding ones.
|
|
@@ -9262,6 +9729,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
9262
9729
|
child.cawdevProjectSlug = run.projectSlug;
|
|
9263
9730
|
child.cawdevAgent = 'agy';
|
|
9264
9731
|
child.cawdevModel = run.model ?? null;
|
|
9732
|
+
stamp(child, stage, lifecycle);
|
|
9265
9733
|
// The existing cancellation sweep (`reapCancelled`) reads this map by
|
|
9266
9734
|
// run id and SIGTERMs whatever is in it — true for any child, so a
|
|
9267
9735
|
// cancelled Antigravity run stops exactly the way a cancelled Claude
|
|
@@ -9608,6 +10076,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
9608
10076
|
child.cawdevProjectSlug = run.projectSlug;
|
|
9609
10077
|
child.cawdevAgent = 'codex';
|
|
9610
10078
|
child.cawdevModel = model ?? null;
|
|
10079
|
+
stamp(child, stage, lifecycle);
|
|
9611
10080
|
// `reapCancelled` SIGTERMs whatever is here, whichever CLI it is.
|
|
9612
10081
|
running.set(run.id, child);
|
|
9613
10082
|
|