cawdev-cli 1.1.1 → 1.2.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 +9 -1
- package/lib/gate-condition.mjs +11 -0
- package/lib/stage-tools.mjs +33 -2
- package/lib/version-files.mjs +99 -0
- package/mcp/server.mjs +40 -0
- package/package.json +1 -1
- package/runner/README.md +260 -32
- package/runner/attach.mjs +1692 -110
- 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 +415 -29
- package/runner/session-cards.mjs +448 -0
- package/runner/shots.mjs +214 -0
- package/runner/sign-in.mjs +29 -2
- package/runner/split.mjs +272 -64
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';
|
|
@@ -30,6 +30,7 @@ import { bannerLines, tintLog } from './banner.mjs';
|
|
|
30
30
|
import { codeMapOf } from '../lib/code-map.mjs';
|
|
31
31
|
import { sonarqubeLaunch } from '../lib/sonarqube-mcp.mjs';
|
|
32
32
|
import { parseProperties, sonarAsker, waitForAnalysis } from '../lib/sonar-analysis.mjs';
|
|
33
|
+
import { workspaceVersion } from '../lib/version-files.mjs';
|
|
33
34
|
import { usageLimitOf } from '../lib/usage-limit.mjs';
|
|
34
35
|
import { TranscriptBatch } from '../lib/transcript-batch.mjs';
|
|
35
36
|
import { parseUsage } from '../lib/usage-report.mjs';
|
|
@@ -762,7 +763,7 @@ async function sayWhichServedProjectsTheTokenCannotReach(config) {
|
|
|
762
763
|
|
|
763
764
|
// --- talking to cawdev -------------------------------------------------------
|
|
764
765
|
|
|
765
|
-
async function api(config, path, { method = 'GET', body, token } = {}) {
|
|
766
|
+
async function api(config, path, { method = 'GET', body, token, raw } = {}) {
|
|
766
767
|
const response = await fetch(`${config.url}${path}`, {
|
|
767
768
|
method,
|
|
768
769
|
headers: {
|
|
@@ -776,8 +777,11 @@ async function api(config, path, { method = 'GET', body, token } = {}) {
|
|
|
776
777
|
// could raise them for any run it has ever been offered.
|
|
777
778
|
authorization: `Bearer ${token ?? config.token}`,
|
|
778
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' } : {}),
|
|
779
783
|
},
|
|
780
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
784
|
+
body: raw ?? (body ? JSON.stringify(body) : undefined),
|
|
781
785
|
});
|
|
782
786
|
const text = await response.text();
|
|
783
787
|
const parsed = text ? safeJson(text) : null;
|
|
@@ -2676,6 +2680,7 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2676
2680
|
const problems = (reading.newBugs ?? 0) + (reading.newVulnerabilities ?? 0)
|
|
2677
2681
|
+ (reading.newSecurityHotspots ?? 0) + (reading.newCodeSmells ?? 0);
|
|
2678
2682
|
log(` quality: ${branch} gate ${reading.gate ?? 'NONE'} · ${problems} new problem${problems === 1 ? '' : 's'}`
|
|
2683
|
+
+ (reading.fixedKeys ? ` · fixed ${reading.fixedKeys.length} · still reported ${reading.stillOpenKeys.length}` : '')
|
|
2679
2684
|
+ (reading.warning ? ` · ${reading.warning}` : ''));
|
|
2680
2685
|
}
|
|
2681
2686
|
};
|
|
@@ -2704,9 +2709,16 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2704
2709
|
workDir = await mkdtemp(join(tmpdir(), 'cawdev-sonar-'));
|
|
2705
2710
|
const deadline = Date.now() + quality.timeoutSeconds * 1000;
|
|
2706
2711
|
const remaining = () => Math.max(1000, deadline - Date.now());
|
|
2712
|
+
// R587: the version the CODE at this checkout declares. Never a guess and
|
|
2713
|
+
// never `git describe`: a version that moves per commit would move
|
|
2714
|
+
// SonarQube's new-code baseline at every scan.
|
|
2715
|
+
const found = await workspaceVersion(cwd, run.versionFiles);
|
|
2716
|
+
const version = found?.version ?? null;
|
|
2717
|
+
log(versionLogLine(found, run.versionFiles));
|
|
2707
2718
|
const scannerArgs = (withBranch) => [
|
|
2708
2719
|
`-Dsonar.host.url=${url}`,
|
|
2709
2720
|
`-Dsonar.projectKey=${key}`,
|
|
2721
|
+
...(version ? [`-Dsonar.projectVersion=${version}`] : []),
|
|
2710
2722
|
...(withBranch ? [`-Dsonar.branch.name=${branch}`] : []),
|
|
2711
2723
|
`-Dsonar.scm.revision=${headSha}`,
|
|
2712
2724
|
`-Dsonar.working.directory=${workDir}`,
|
|
@@ -2727,7 +2739,7 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2727
2739
|
// function's own try/catch, which `return somePromise` would skip —
|
|
2728
2740
|
// control leaves before the promise settles.
|
|
2729
2741
|
return await scanAsScratchProject(
|
|
2730
|
-
{ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
|
|
2742
|
+
{ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report, version });
|
|
2731
2743
|
}
|
|
2732
2744
|
// 'main', and known refused: no reason to run the branch scan first.
|
|
2733
2745
|
const ran = await runSkillCommand(quality.command, scannerArgs(false), { cwd, env, timeoutMs: remaining() });
|
|
@@ -2743,7 +2755,7 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2743
2755
|
return await reportBranchReading({
|
|
2744
2756
|
ask: askServer, reportTask, settled, key, branch, analysedAs: 'MAIN',
|
|
2745
2757
|
warning: "This server does not analyse branches; the branch was analysed as the project's main analysis.",
|
|
2746
|
-
headSha, report,
|
|
2758
|
+
headSha, report, findings: run.findingsToProve,
|
|
2747
2759
|
});
|
|
2748
2760
|
}
|
|
2749
2761
|
|
|
@@ -2760,7 +2772,7 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2760
2772
|
if (/sonar\.branch\.name.*(Developer Edition|above is required)/is.test(ran.said)) {
|
|
2761
2773
|
if (quality.whenBranchesRefused === 'project') {
|
|
2762
2774
|
return await scanAsScratchProject(
|
|
2763
|
-
{ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report });
|
|
2775
|
+
{ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report, version });
|
|
2764
2776
|
}
|
|
2765
2777
|
if (quality.whenBranchesRefused !== 'main') {
|
|
2766
2778
|
return report({
|
|
@@ -2787,7 +2799,10 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2787
2799
|
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2788
2800
|
const askServer = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
|
|
2789
2801
|
const settled = await waitForAnalysis(askServer, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2790
|
-
await reportBranchReading({
|
|
2802
|
+
await reportBranchReading({
|
|
2803
|
+
ask: askServer, reportTask, settled, key, branch, analysedAs, warning, headSha, report,
|
|
2804
|
+
findings: run.findingsToProve,
|
|
2805
|
+
});
|
|
2791
2806
|
} catch (failure) {
|
|
2792
2807
|
await report({ headSha, error: failure.message.split('\n')[0] });
|
|
2793
2808
|
} finally {
|
|
@@ -2803,7 +2818,7 @@ function trimSlash(url) {
|
|
|
2803
2818
|
}
|
|
2804
2819
|
|
|
2805
2820
|
/** The gate and the new-code measures, for a BRANCH or MAIN reading — never for PROJECT, which has neither. */
|
|
2806
|
-
async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report }) {
|
|
2821
|
+
async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report, findings }) {
|
|
2807
2822
|
const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
|
|
2808
2823
|
const metrics = 'new_bugs,new_vulnerabilities,new_security_hotspots,new_code_smells,new_coverage';
|
|
2809
2824
|
const { component } = await ask(`/api/measures/component?component=${encodeURIComponent(key)}`
|
|
@@ -2830,9 +2845,80 @@ async function reportBranchReading({ ask, reportTask, settled, key, branch, anal
|
|
|
2830
2845
|
newCoverage: measure('new_coverage'),
|
|
2831
2846
|
dashboardUrl: reportTask.dashboardUrl ?? null,
|
|
2832
2847
|
warning,
|
|
2848
|
+
...(await proveFindings({ ask, scanKey: key, branch, analysedAs, findings })),
|
|
2833
2849
|
});
|
|
2834
2850
|
}
|
|
2835
2851
|
|
|
2852
|
+
/**
|
|
2853
|
+
* R318: asks the server whether each finding a fix card was filed from
|
|
2854
|
+
* (`run.findingsToProve`, sent with the claim) is still reported on the tree
|
|
2855
|
+
* just scanned. Returns `{ fixedKeys, stillOpenKeys }`, or `{}` when the claim
|
|
2856
|
+
* named none — then neither field is sent and no request is made, which is
|
|
2857
|
+
* also what a platform older than R318 gets.
|
|
2858
|
+
*
|
|
2859
|
+
* SonarQube gives an issue a NEW key when it copies it onto a branch, so the
|
|
2860
|
+
* finding's own key finds nothing there and a lookup by it would call every
|
|
2861
|
+
* finding fixed. On a BRANCH or PROJECT reading the match is the rule in the
|
|
2862
|
+
* file, narrowed to the line's `hash` when the finding has one (lines move;
|
|
2863
|
+
* the hash of a line's text does not). Without a hash it errs one way only:
|
|
2864
|
+
* another issue of the same rule in that file reads as "still reported". On a
|
|
2865
|
+
* MAIN reading the analysis IS the project's main one, so the key is valid.
|
|
2866
|
+
*
|
|
2867
|
+
* Anything thrown lands in `scanQuality`'s catch and becomes an error
|
|
2868
|
+
* reading, which carries no verdict.
|
|
2869
|
+
*/
|
|
2870
|
+
async function proveFindings({ ask, scanKey, branch, analysedAs, findings }) {
|
|
2871
|
+
if (!Array.isArray(findings) || findings.length === 0) return {};
|
|
2872
|
+
const fixedKeys = [];
|
|
2873
|
+
const stillOpenKeys = [];
|
|
2874
|
+
const enc = encodeURIComponent;
|
|
2875
|
+
const onBranch = analysedAs === 'BRANCH' ? `&branch=${enc(branch)}` : '';
|
|
2876
|
+
for (const finding of findings) {
|
|
2877
|
+
const hotspot = finding.type === 'HOTSPOT';
|
|
2878
|
+
let open;
|
|
2879
|
+
if (analysedAs === 'MAIN') {
|
|
2880
|
+
open = hotspot ? await hotspotStillToReview(ask, finding.key) : await issueStillUnresolved(ask, finding.key);
|
|
2881
|
+
} else if (hotspot) {
|
|
2882
|
+
const found = await pagedSearch(ask,
|
|
2883
|
+
`/api/hotspots/search?projectKey=${enc(scanKey)}${finding.path ? `&files=${enc(finding.path)}` : ''}`
|
|
2884
|
+
+ `&status=TO_REVIEW${onBranch}`, 'hotspots');
|
|
2885
|
+
open = found.some((each) => each.ruleKey === finding.rule);
|
|
2886
|
+
} else {
|
|
2887
|
+
const component = finding.path ? `${scanKey}:${finding.path}` : scanKey;
|
|
2888
|
+
const query = (parameter) => `/api/issues/search?${parameter}=${enc(component)}`
|
|
2889
|
+
+ `&rules=${enc(finding.rule)}&resolved=false${onBranch}`;
|
|
2890
|
+
let found;
|
|
2891
|
+
try {
|
|
2892
|
+
found = await pagedSearch(ask, query('components'), 'issues');
|
|
2893
|
+
} catch (failure) {
|
|
2894
|
+
// Servers before 10.2 know only `componentKeys`.
|
|
2895
|
+
if (!/answered 400/.test(failure.message)) throw failure;
|
|
2896
|
+
found = await pagedSearch(ask, query('componentKeys'), 'issues');
|
|
2897
|
+
}
|
|
2898
|
+
open = finding.lineHash ? found.some((each) => each.hash === finding.lineHash) : found.length > 0;
|
|
2899
|
+
}
|
|
2900
|
+
(open ? stillOpenKeys : fixedKeys).push(finding.key);
|
|
2901
|
+
}
|
|
2902
|
+
return { fixedKeys, stillOpenKeys };
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
/** MAIN: the key is valid. Open while it comes back unresolved; purged (not returned) is fixed. */
|
|
2906
|
+
async function issueStillUnresolved(ask, key) {
|
|
2907
|
+
const body = await ask(`/api/issues/search?issues=${encodeURIComponent(key)}`);
|
|
2908
|
+
return (body.issues ?? []).some((each) => each.key === key && !each.resolution);
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
/** MAIN, hotspot: open while TO_REVIEW; a 404 is a hotspot the server no longer has. */
|
|
2912
|
+
async function hotspotStillToReview(ask, key) {
|
|
2913
|
+
try {
|
|
2914
|
+
const body = await ask(`/api/hotspots/show?hotspot=${encodeURIComponent(key)}`);
|
|
2915
|
+
return body.status === 'TO_REVIEW';
|
|
2916
|
+
} catch (failure) {
|
|
2917
|
+
if (/answered 404/.test(failure.message)) return false;
|
|
2918
|
+
throw failure;
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2836
2922
|
/**
|
|
2837
2923
|
* SonarQube's own pages, `ps=500` at a time, up to the server's `p × ps`
|
|
2838
2924
|
* ceiling worth trying — R313's `LIST_PAGES` argument, in the scanner
|
|
@@ -2868,7 +2954,14 @@ function shortHash(text) {
|
|
|
2868
2954
|
* always `NONE`: a quality gate over a whole scratch project would judge
|
|
2869
2955
|
* code this branch did not write.
|
|
2870
2956
|
*/
|
|
2871
|
-
|
|
2957
|
+
/** The one line the scan logs about its version: found, declared but empty, or never declared (R587). */
|
|
2958
|
+
function versionLogLine(found, declared) {
|
|
2959
|
+
if (found) return ` quality: version ${found.version} (${found.file})`;
|
|
2960
|
+
if (declared?.length > 0) return ' quality: no version found in the declared version files: scanned without a version';
|
|
2961
|
+
return ' quality: no version file declared: scanned without a version';
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token, headSha, workDir, deadline, report, version = null }) {
|
|
2872
2965
|
const baseName = run.baseBranch ?? run.defaultBranch ?? 'main';
|
|
2873
2966
|
const ref = await firstRef(cwd, [`origin/${baseName}`, baseName]);
|
|
2874
2967
|
const mergeBase = ref ? await git(cwd, ['merge-base', 'HEAD', ref]).catch(() => null) : null;
|
|
@@ -2882,6 +2975,7 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
|
|
|
2882
2975
|
`-Dsonar.host.url=${url}`,
|
|
2883
2976
|
`-Dsonar.projectKey=${scratchKey}`,
|
|
2884
2977
|
`-Dsonar.projectName=${key} · ${branch}`,
|
|
2978
|
+
...(version ? [`-Dsonar.projectVersion=${version}`] : []),
|
|
2885
2979
|
`-Dsonar.scm.revision=${headSha}`,
|
|
2886
2980
|
`-Dsonar.working.directory=${workDir}`,
|
|
2887
2981
|
];
|
|
@@ -2926,6 +3020,9 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
|
|
|
2926
3020
|
}
|
|
2927
3021
|
const newSecurityHotspots = hotspots.filter((hotspot) => counted(hotspot.component, hotspot.line)).length;
|
|
2928
3022
|
|
|
3023
|
+
const proven = await proveFindings({
|
|
3024
|
+
ask, scanKey: scratchKey, branch, analysedAs: 'PROJECT', findings: run.findingsToProve });
|
|
3025
|
+
|
|
2929
3026
|
await report({
|
|
2930
3027
|
headSha,
|
|
2931
3028
|
gate: 'NONE',
|
|
@@ -2938,6 +3035,7 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
|
|
|
2938
3035
|
dashboardUrl: reportTask.dashboardUrl ?? null,
|
|
2939
3036
|
warning: 'This server does not analyse branches; the branch was analysed as its own SonarQube project, '
|
|
2940
3037
|
+ 'counting problems on the lines it changed. No gate was computed.',
|
|
3038
|
+
...proven,
|
|
2941
3039
|
});
|
|
2942
3040
|
}
|
|
2943
3041
|
|
|
@@ -5552,12 +5650,18 @@ async function reportUsageLimit(config, run, limit, lastText) {
|
|
|
5552
5650
|
* nothing for the session that was asking. One function for both paths now,
|
|
5553
5651
|
* so they cannot come apart again.
|
|
5554
5652
|
*/
|
|
5555
|
-
function argsBefore(agentArgs, { listOnly = false } = {}) {
|
|
5653
|
+
function argsBefore(agentArgs, { listOnly = false, dropMode = false } = {}) {
|
|
5556
5654
|
const kept = [];
|
|
5557
5655
|
for (let i = 0; i < agentArgs.length; i++) {
|
|
5558
5656
|
if (agentArgs[i] === '--allowedTools') {
|
|
5559
5657
|
break; // variadic: everything after it is a permission
|
|
5560
5658
|
}
|
|
5659
|
+
// R604. SHOW drops the MODE and keeps the prompt tool: nothing is allowed
|
|
5660
|
+
// by a mode, so whatever the list does not name reaches a person.
|
|
5661
|
+
if (dropMode && agentArgs[i] === '--permission-mode') {
|
|
5662
|
+
i += 1;
|
|
5663
|
+
continue;
|
|
5664
|
+
}
|
|
5561
5665
|
if (listOnly && (agentArgs[i] === '--permission-mode'
|
|
5562
5666
|
|| agentArgs[i] === '--permission-prompt-tool')) {
|
|
5563
5667
|
i += 1;
|
|
@@ -5739,12 +5843,33 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5739
5843
|
+ 'to hide. ' + WAIT_FOR_COMMANDS + ' Then commit the branch\'s changes — no stage '
|
|
5740
5844
|
+ 'after this one writes code, and work left uncommitted is work nothing can push. '
|
|
5741
5845
|
+ DONE_WHEN_TICK,
|
|
5846
|
+
// R604. The folder is the daemon's, the captions are the person's reading
|
|
5847
|
+
// guide, and the tree check afterwards is why "change nothing else" is a
|
|
5848
|
+
// fact and not a request.
|
|
5849
|
+
SHOW: 'SHOW THE PERSON what was built, before a pull request is proposed. Write 1 to 8 '
|
|
5850
|
+
+ 'files into `.cawdev/shots/` — a PNG or JPEG of the real screen, taken with the '
|
|
5851
|
+
+ 'repository\'s own Playwright, or with Claude in Chrome when you have it; or, when '
|
|
5852
|
+
+ 'neither can run here, a STATIC, self-contained HTML mock (no scripts, no external '
|
|
5853
|
+
+ 'URLs, inline CSS, images as `data:` URIs). Then write `.cawdev/shots/shots.json` as '
|
|
5854
|
+
+ '`[{"file": "name.png", "caption": "what to look at"}]`, one caption per file. Each '
|
|
5855
|
+
+ 'file must be 2 MB or smaller. CHANGE NOTHING ELSE and do not commit: the stage fails, '
|
|
5856
|
+
+ 'naming the paths, if the tree changes outside that folder (build output that is not '
|
|
5857
|
+
+ 'gitignored counts). Start the app in the background, take the shots, then STOP every '
|
|
5858
|
+
+ 'background command and READ its final output before you end the turn — a command left '
|
|
5859
|
+
+ 'running fails the stage. ' + WAIT_FOR_COMMANDS,
|
|
5742
5860
|
MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
|
|
5743
5861
|
+ 'tried and rejected and why, and anything that turned out to matter and would not be '
|
|
5744
5862
|
+ 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
|
|
5745
5863
|
+ 'briefing and handed to whoever comes next.',
|
|
5746
5864
|
}[stage.stage] ?? `Carry out the ${stage.stage} stage.`;
|
|
5747
5865
|
|
|
5866
|
+
// R604. A refused SHOW sends IMPLEMENT round again; the person's words lead.
|
|
5867
|
+
const rework = stage.rework
|
|
5868
|
+
? `### What the person asked to change after round ${stage.rework.round}\n\n`
|
|
5869
|
+
+ `${stage.rework.comments}\n\nMake these changes on this branch. TEST and SHOW run again `
|
|
5870
|
+
+ 'after you, and the person will look at a new round of screenshots.\n\n'
|
|
5871
|
+
: '';
|
|
5872
|
+
|
|
5748
5873
|
const before = [];
|
|
5749
5874
|
for (const [name, text] of Object.entries(carried ?? {})) {
|
|
5750
5875
|
if (text) {
|
|
@@ -5752,7 +5877,7 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5752
5877
|
}
|
|
5753
5878
|
}
|
|
5754
5879
|
|
|
5755
|
-
return `\n\n---\n\n## This is the ${stage.stage} stage\n\n${asked}`
|
|
5880
|
+
return `\n\n---\n\n## This is the ${stage.stage} stage\n\n${rework}${asked}`
|
|
5756
5881
|
+ (before.length ? `\n\n${before.join('\n\n')}` : '');
|
|
5757
5882
|
}
|
|
5758
5883
|
|
|
@@ -6701,6 +6826,13 @@ function noteQueued(run, why, reported = why) {
|
|
|
6701
6826
|
log(`${why}; leaving "${run.label}" queued`);
|
|
6702
6827
|
}
|
|
6703
6828
|
|
|
6829
|
+
/** The stage a child is and the stages of its walk, by name; none on an unstaged run. */
|
|
6830
|
+
function stamp(child, stage, lifecycle) {
|
|
6831
|
+
child.cawdevStartedAt ??= new Date().toISOString();
|
|
6832
|
+
child.cawdevStage = stage?.stage ?? null;
|
|
6833
|
+
child.cawdevStages = Array.isArray(lifecycle) && stage ? lifecycle.map((each) => each.stage) : null;
|
|
6834
|
+
}
|
|
6835
|
+
|
|
6704
6836
|
/**
|
|
6705
6837
|
* This machine, as somebody attached to it sees it — R52.
|
|
6706
6838
|
*
|
|
@@ -6729,6 +6861,10 @@ function snapshotRuns() {
|
|
|
6729
6861
|
agent: child.cawdevAgent ?? null,
|
|
6730
6862
|
model: child.cawdevModel ?? null,
|
|
6731
6863
|
startedAt: child.cawdevStartedAt ?? null,
|
|
6864
|
+
// The stage this child is and the walk it belongs to — a card's
|
|
6865
|
+
// progress line in advanced mode (session-cards.mjs `stageWalk`).
|
|
6866
|
+
stage: child.cawdevStage ?? null,
|
|
6867
|
+
stages: child.cawdevStages ?? null,
|
|
6732
6868
|
});
|
|
6733
6869
|
}
|
|
6734
6870
|
for (const [id, claim] of taken) {
|
|
@@ -6793,6 +6929,9 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6793
6929
|
let briefing = null;
|
|
6794
6930
|
let plan = null;
|
|
6795
6931
|
let lifecycle = [];
|
|
6932
|
+
// R604. The stages a refused SHOW walks again, and the comments of the latest
|
|
6933
|
+
// refused round when this walk begins after one.
|
|
6934
|
+
let showWalk = { rework: [], comments: null };
|
|
6796
6935
|
let shield = null;
|
|
6797
6936
|
// R155. What `git merge` did before anything was spawned, on a MERGE run.
|
|
6798
6937
|
let merge = null;
|
|
@@ -6858,6 +6997,11 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6858
6997
|
// R316. Whether the project SAYS its server refuses branches — null is
|
|
6859
6998
|
// "not sure, try the branch scan first", today's behaviour.
|
|
6860
6999
|
run.sonarBranches = claimed.sonarBranches ?? null;
|
|
7000
|
+
// R587. The project's declared version files: the scan reads the first it
|
|
7001
|
+
// can parse from the workspace and sends it as sonar.projectVersion.
|
|
7002
|
+
run.versionFiles = Array.isArray(claimed.versionFiles) ? claimed.versionFiles : [];
|
|
7003
|
+
// R318: the findings the card was filed from, for the scan to prove fixed.
|
|
7004
|
+
run.findingsToProve = Array.isArray(claimed.findingsToProve) ? claimed.findingsToProve : [];
|
|
6861
7005
|
// R69. Whether this is the same conversation picked back up, and which one.
|
|
6862
7006
|
// Null on an ordinary claim, which is nearly all of them.
|
|
6863
7007
|
resume = claimed.resume ?? null;
|
|
@@ -6902,6 +7046,10 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6902
7046
|
// one. Read here with everything else the claim carries.
|
|
6903
7047
|
plan = claimed.plan ?? null;
|
|
6904
7048
|
lifecycle = Array.isArray(claimed.workflow) ? claimed.workflow : [];
|
|
7049
|
+
showWalk = {
|
|
7050
|
+
rework: Array.isArray(claimed.rework) ? claimed.rework : [],
|
|
7051
|
+
comments: claimed.showComments ?? null,
|
|
7052
|
+
};
|
|
6905
7053
|
shield = claimed.shield ?? null;
|
|
6906
7054
|
// R562. Every session is told the list up front, whichever profile it is.
|
|
6907
7055
|
run.protectedLine = protectedLine(protectedPolicy(shield));
|
|
@@ -7065,7 +7213,7 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
7065
7213
|
}
|
|
7066
7214
|
|
|
7067
7215
|
const said = await walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
7068
|
-
resume, mcpServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield);
|
|
7216
|
+
resume, mcpServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield, showWalk);
|
|
7069
7217
|
|
|
7070
7218
|
if (merge) {
|
|
7071
7219
|
// R155. The session has gone. Whether it resolved anything is a question
|
|
@@ -7179,7 +7327,8 @@ async function skipRest(config, run, lifecycle, from, why) {
|
|
|
7179
7327
|
}
|
|
7180
7328
|
|
|
7181
7329
|
async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
7182
|
-
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield
|
|
7330
|
+
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
|
|
7331
|
+
show = null) {
|
|
7183
7332
|
const spawn = (stage, carried, usage) => spawnAgent(config, run, runToken, cwd, baseCommit,
|
|
7184
7333
|
workspace, resume, projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle,
|
|
7185
7334
|
shield, stage, carried, usage);
|
|
@@ -7199,6 +7348,22 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7199
7348
|
const carried = {};
|
|
7200
7349
|
let last = null;
|
|
7201
7350
|
|
|
7351
|
+
// R604. A mutable copy: a refused SHOW splices IMPLEMENT, TEST and SHOW back in
|
|
7352
|
+
// after itself. Every skipRest below is passed THIS, not the claim's list.
|
|
7353
|
+
const walk = [...lifecycle];
|
|
7354
|
+
// The comments of a round that was refused before this walk began (the daemon
|
|
7355
|
+
// died, or the run was carried on): the first IMPLEMENT leads with them, and a
|
|
7356
|
+
// walk that starts later is told in `carried`.
|
|
7357
|
+
if (show?.comments) {
|
|
7358
|
+
const first = walk.findIndex((each) => each.stage === 'IMPLEMENT');
|
|
7359
|
+
const rework = { round: show.comments.round, comments: show.comments.comments };
|
|
7360
|
+
if (first === 0) {
|
|
7361
|
+
walk[0] = { ...walk[0], rework };
|
|
7362
|
+
} else {
|
|
7363
|
+
carried[`SHOW (comments on round ${rework.round})`] = rework.comments;
|
|
7364
|
+
}
|
|
7365
|
+
}
|
|
7366
|
+
|
|
7202
7367
|
// R305. What the RUN has been credited with so far. Each stage is its own
|
|
7203
7368
|
// process with its own cumulative counter, so the base the next child adds
|
|
7204
7369
|
// to must move — read once from the claim, it stayed at zero and the platform
|
|
@@ -7214,22 +7379,32 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7214
7379
|
// answers without the fields and the base is zero, which is today's number.
|
|
7215
7380
|
const stored = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/stages`)
|
|
7216
7381
|
.catch(() => []);
|
|
7382
|
+
// R604: a stage walked again after a refused SHOW is not told again about the
|
|
7383
|
+
// failure the person had already moved past.
|
|
7384
|
+
const retold = new Set();
|
|
7385
|
+
const credits = new Map();
|
|
7217
7386
|
const creditedTo = (name) => {
|
|
7218
|
-
|
|
7219
|
-
|
|
7387
|
+
// R604. One per stage NAME for the whole walk: a stage walked again after
|
|
7388
|
+
// a refused SHOW keeps accumulating instead of starting from the stored base.
|
|
7389
|
+
if (!credits.has(name)) {
|
|
7390
|
+
const row = Array.isArray(stored) ? stored.find((each) => each?.stage === name) : null;
|
|
7391
|
+
credits.set(name, { tokensIn: Number(row?.tokensIn) || 0, tokensOut: Number(row?.tokensOut) || 0 });
|
|
7392
|
+
}
|
|
7393
|
+
return credits.get(name);
|
|
7220
7394
|
};
|
|
7221
7395
|
|
|
7222
7396
|
const storedRow = (name) => (Array.isArray(stored)
|
|
7223
7397
|
? stored.find((each) => each?.stage === name) : null);
|
|
7224
7398
|
|
|
7225
|
-
for (let at = 0; at <
|
|
7226
|
-
const stage =
|
|
7399
|
+
walking: for (let at = 0; at < walk.length; at++) {
|
|
7400
|
+
const stage = walk[at];
|
|
7227
7401
|
// Where a carried-on run resumes, and what its DONE stages said, is the
|
|
7228
7402
|
// platform's (R263, `WorkflowService.resumeFrom`): the claim's lifecycle
|
|
7229
7403
|
// starts at the first stage not DONE. What only the daemon knows is WHY
|
|
7230
7404
|
// the stage about to run stopped last time — so that is handed to it.
|
|
7231
7405
|
const before = storedRow(stage.stage);
|
|
7232
|
-
if (before?.state === 'FAILED' && before.outcome) {
|
|
7406
|
+
if (before?.state === 'FAILED' && before.outcome && !retold.has(stage.stage)) {
|
|
7407
|
+
retold.add(stage.stage);
|
|
7233
7408
|
// The attempt that stopped, handed to the one about to run.
|
|
7234
7409
|
carried[`${stage.stage} (the attempt that stopped)`] = `${before.outcome}\n\n`
|
|
7235
7410
|
+ 'This stage is being run again to finish that. Run the commands — and the agents — '
|
|
@@ -7248,6 +7423,8 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7248
7423
|
// loop nobody asked for.
|
|
7249
7424
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
7250
7425
|
await reportStage(config, run, stage.stage, 'begin');
|
|
7426
|
+
// R604. What the tree looks like before SHOW, for the check after it.
|
|
7427
|
+
const treeBefore = stage.stage === 'SHOW' ? await snapshotTree(cwd) : null;
|
|
7251
7428
|
log(` ${stage.stage}${stage.model ? ` on ${stage.model}` : ''}`
|
|
7252
7429
|
+ `${attempt ? ' (again, after the gate refused it)' : ''}`);
|
|
7253
7430
|
|
|
@@ -7312,7 +7489,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7312
7489
|
plan: stage.stage === 'PLAN' ? last?.text ?? null : null,
|
|
7313
7490
|
outcome: `${why} ${last?.text ?? ''}`.trim(),
|
|
7314
7491
|
});
|
|
7315
|
-
await skipRest(config, run,
|
|
7492
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7316
7493
|
return;
|
|
7317
7494
|
}
|
|
7318
7495
|
|
|
@@ -7328,7 +7505,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7328
7505
|
plan: stage.stage === 'PLAN' ? last?.text ?? null : null,
|
|
7329
7506
|
outcome: `${why} ${last?.text ?? ''}`.trim(),
|
|
7330
7507
|
});
|
|
7331
|
-
await skipRest(config, run,
|
|
7508
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7332
7509
|
return;
|
|
7333
7510
|
}
|
|
7334
7511
|
|
|
@@ -7362,6 +7539,84 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7362
7539
|
return;
|
|
7363
7540
|
}
|
|
7364
7541
|
|
|
7542
|
+
// R604. SHOW: judge the tree, upload, raise the gate, and report DONE
|
|
7543
|
+
// only AFTER the person accepts — a daemon that died while the gate was
|
|
7544
|
+
// waiting must resume at SHOW and not skip to MEMORY with nobody having
|
|
7545
|
+
// decided. Never enters the two-attempt refusal path: a refusal re-walks
|
|
7546
|
+
// IMPLEMENT and TEST, with no cap.
|
|
7547
|
+
if (stage.stage === 'SHOW') {
|
|
7548
|
+
const stray = await strayChanges(treeBefore, cwd);
|
|
7549
|
+
if (stray.length) {
|
|
7550
|
+
const why = strayLine(stray);
|
|
7551
|
+
log(` ${why}`);
|
|
7552
|
+
await reportStage(config, run, 'SHOW', 'report', { state: 'FAILED', outcome: why });
|
|
7553
|
+
await finish(config, run, 'FAILED', why);
|
|
7554
|
+
return;
|
|
7555
|
+
}
|
|
7556
|
+
let sealed;
|
|
7557
|
+
try {
|
|
7558
|
+
sealed = await uploadShots(config, run, cwd);
|
|
7559
|
+
} catch (refused) {
|
|
7560
|
+
const why = `The SHOW stage could not hand over its screenshots: ${refused.message}`;
|
|
7561
|
+
log(` ${why}`);
|
|
7562
|
+
await reportStage(config, run, 'SHOW', 'report', { state: 'FAILED', outcome: why });
|
|
7563
|
+
await finish(config, run, 'FAILED', why);
|
|
7564
|
+
return;
|
|
7565
|
+
}
|
|
7566
|
+
const shots = sealed.attachments ?? [];
|
|
7567
|
+
const summary = `${shots.length} screenshot${shots.length === 1 ? '' : 's'} to look at\n\n`
|
|
7568
|
+
+ shots.map((each) => `- **${each.name}** — ${each.caption ?? 'no caption'}`).join('\n');
|
|
7569
|
+
const decision = await awaitGate(config, run, runToken, stage, summary, {
|
|
7570
|
+
toolInput: { stage: 'SHOW', round: sealed.round, attachments: shots.map((each) => each.id) },
|
|
7571
|
+
});
|
|
7572
|
+
if (decision.failed) {
|
|
7573
|
+
// A gate that could not be raised, or whose wait broke, FAILS the stage
|
|
7574
|
+
// rather than reading as a refusal: with no cap, a refusal would loop
|
|
7575
|
+
// IMPLEMENT and TEST for ever on a network blip. /continue re-runs
|
|
7576
|
+
// SHOW, whose new gate supersedes the stale one.
|
|
7577
|
+
const why = `The SHOW gate could not be reached: ${decision.reason}`;
|
|
7578
|
+
await reportStage(config, run, 'SHOW', 'report', { state: 'FAILED', outcome: why });
|
|
7579
|
+
await finish(config, run, 'FAILED', why);
|
|
7580
|
+
return;
|
|
7581
|
+
}
|
|
7582
|
+
if (decision.expired) {
|
|
7583
|
+
const why = `Stopped at the SHOW gate: ${decision.reason}. Nothing was refused — start the `
|
|
7584
|
+
+ 'run again when somebody can look.';
|
|
7585
|
+
await reportStage(config, run, 'SHOW', 'report', { state: 'SKIPPED', outcome: why });
|
|
7586
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7587
|
+
await finish(config, run, 'FINISHED', why, cwd);
|
|
7588
|
+
return;
|
|
7589
|
+
}
|
|
7590
|
+
if (!decision.allowed) {
|
|
7591
|
+
// Comments. The platform has already put IMPLEMENT, TEST and SHOW back
|
|
7592
|
+
// to PENDING; this walks them again, the comments leading IMPLEMENT.
|
|
7593
|
+
log(` the SHOW gate was sent back with comments (round ${sealed.round}); implementing again`);
|
|
7594
|
+
delete carried.IMPLEMENT;
|
|
7595
|
+
delete carried.TEST;
|
|
7596
|
+
delete carried.SHOW;
|
|
7597
|
+
for (const key of Object.keys(carried)) {
|
|
7598
|
+
if (key.startsWith('SHOW (comments on round')) delete carried[key];
|
|
7599
|
+
}
|
|
7600
|
+
const again = (show?.rework ?? []).map((each) => ({
|
|
7601
|
+
...each,
|
|
7602
|
+
...(each.stage === 'IMPLEMENT'
|
|
7603
|
+
? { rework: { round: sealed.round, comments: decision.reason ?? 'No comment was written.' } }
|
|
7604
|
+
: {}),
|
|
7605
|
+
}));
|
|
7606
|
+
if (!again.length) {
|
|
7607
|
+
const why = 'The SHOW gate was sent back with comments, and this platform did not say which '
|
|
7608
|
+
+ 'stages to walk again. Continue the run to start over from IMPLEMENT.';
|
|
7609
|
+
await finish(config, run, 'FAILED', why);
|
|
7610
|
+
return;
|
|
7611
|
+
}
|
|
7612
|
+
walk.splice(at + 1, 0, ...again);
|
|
7613
|
+
continue walking;
|
|
7614
|
+
}
|
|
7615
|
+
await reportStage(config, run, 'SHOW', 'report', { state: 'DONE', outcome: summary });
|
|
7616
|
+
last = { ...last, text: summary };
|
|
7617
|
+
break;
|
|
7618
|
+
}
|
|
7619
|
+
|
|
7365
7620
|
await reportStage(config, run, stage.stage, 'report', {
|
|
7366
7621
|
state: 'DONE',
|
|
7367
7622
|
// The PLAN's text IS the plan — the artefact R109 exists to store, and
|
|
@@ -7383,7 +7638,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7383
7638
|
// second stage on a plan nobody has looked at yet.
|
|
7384
7639
|
const why = `Stopped at the ${stage.stage} gate: ${decision.reason}. Nothing was `
|
|
7385
7640
|
+ 'refused — start the run again when somebody can read it.';
|
|
7386
|
-
await skipRest(config, run,
|
|
7641
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7387
7642
|
await finish(config, run, 'FINISHED', why, cwd);
|
|
7388
7643
|
return;
|
|
7389
7644
|
}
|
|
@@ -7392,7 +7647,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7392
7647
|
// The same trail R122 came from: a run that ends here never reaches the
|
|
7393
7648
|
// stages after it, and stages left PENDING on a run that is over read
|
|
7394
7649
|
// as a lifecycle still going.
|
|
7395
|
-
await skipRest(config, run,
|
|
7650
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7396
7651
|
await finish(config, run, 'FINISHED', why, cwd);
|
|
7397
7652
|
return;
|
|
7398
7653
|
}
|
|
@@ -7453,6 +7708,115 @@ async function reportStage(config, run, stage, what, body = {}) {
|
|
|
7453
7708
|
.catch((failure) => log(` could not report ${stage}: ${failure.message}`));
|
|
7454
7709
|
}
|
|
7455
7710
|
|
|
7711
|
+
/**
|
|
7712
|
+
* What the tree looked like when SHOW began — R604. Per-path content hashes of
|
|
7713
|
+
* everything that differs from HEAD (tracked) and everything untracked and not
|
|
7714
|
+
* ignored, so `strayChanges` can say WHICH paths a stage touched, including a
|
|
7715
|
+
* file it rewrote that was already dirty.
|
|
7716
|
+
*/
|
|
7717
|
+
async function snapshotTree(cwd) {
|
|
7718
|
+
const head = await git(cwd, ['rev-parse', 'HEAD']).catch(() => '');
|
|
7719
|
+
const hash = async (path) => {
|
|
7720
|
+
try {
|
|
7721
|
+
const info = await stat(join(cwd, path));
|
|
7722
|
+
if (!info.isFile()) return 'not-a-file';
|
|
7723
|
+
if (info.size > 64 * 1024 * 1024) return `big:${info.size}:${info.mtimeMs}`;
|
|
7724
|
+
return createHash('sha256').update(await readFile(join(cwd, path))).digest('hex');
|
|
7725
|
+
} catch {
|
|
7726
|
+
return 'gone';
|
|
7727
|
+
}
|
|
7728
|
+
};
|
|
7729
|
+
// `-z`: git quotes a path with non-ASCII or special characters otherwise, and
|
|
7730
|
+
// a quoted path hashes as "gone" before and after, hiding the change.
|
|
7731
|
+
const lines = async (args) => (await git(cwd, args).catch(() => ''))
|
|
7732
|
+
.split('\0').filter(Boolean);
|
|
7733
|
+
const files = new Map();
|
|
7734
|
+
for (const path of [
|
|
7735
|
+
...await lines(['diff', 'HEAD', '--name-only', '-z']),
|
|
7736
|
+
...await lines(['ls-files', '--others', '--exclude-standard', '-z']),
|
|
7737
|
+
]) {
|
|
7738
|
+
files.set(path, await hash(path));
|
|
7739
|
+
}
|
|
7740
|
+
return { head, files };
|
|
7741
|
+
}
|
|
7742
|
+
|
|
7743
|
+
/**
|
|
7744
|
+
* The paths a SHOW stage changed outside `.cawdev/shots/`, and a commit if it
|
|
7745
|
+
* made one — R604. The tree check is the real guard: SHOW holds a Bash that can
|
|
7746
|
+
* write anywhere, and a narrowed tool list says what it was GIVEN, not what it did.
|
|
7747
|
+
*/
|
|
7748
|
+
async function strayChanges(before, cwd) {
|
|
7749
|
+
const after = await snapshotTree(cwd);
|
|
7750
|
+
const stray = [];
|
|
7751
|
+
if (before.head !== after.head) {
|
|
7752
|
+
stray.push('a commit (HEAD moved)');
|
|
7753
|
+
}
|
|
7754
|
+
for (const path of new Set([...before.files.keys(), ...after.files.keys()])) {
|
|
7755
|
+
if (path.startsWith('.cawdev/shots/')) continue;
|
|
7756
|
+
if (before.files.get(path) !== after.files.get(path)) stray.push(path);
|
|
7757
|
+
}
|
|
7758
|
+
return stray;
|
|
7759
|
+
}
|
|
7760
|
+
|
|
7761
|
+
function strayLine(stray) {
|
|
7762
|
+
const shown = stray.slice(0, 10).map((path) => `\`${path}\``).join(', ');
|
|
7763
|
+
return `The SHOW stage changed the checkout outside \`.cawdev/shots/\`: ${shown}`
|
|
7764
|
+
+ `${stray.length > 10 ? ` and ${stray.length - 10} more` : ''}. SHOW may only write its screenshots; `
|
|
7765
|
+
+ 'build output that is not gitignored counts too (add it to .gitignore). Nothing was shown.';
|
|
7766
|
+
}
|
|
7767
|
+
|
|
7768
|
+
/**
|
|
7769
|
+
* Uploads what SHOW left and seals it as a round — R604. With the MACHINE's
|
|
7770
|
+
* token: the session holds the run token and must not upload what it is judged
|
|
7771
|
+
* by. Throws with the platform's own sentence on any refusal. Deletes the
|
|
7772
|
+
* folder once the round is sealed.
|
|
7773
|
+
*/
|
|
7774
|
+
async function uploadShots(config, run, cwd) {
|
|
7775
|
+
const dir = join(cwd, '.cawdev', 'shots');
|
|
7776
|
+
let manifest;
|
|
7777
|
+
try {
|
|
7778
|
+
manifest = JSON.parse(await readFile(join(dir, 'shots.json'), 'utf8'));
|
|
7779
|
+
} catch {
|
|
7780
|
+
throw new Error('SHOW left no valid `.cawdev/shots/shots.json` — it must be a JSON list of '
|
|
7781
|
+
+ '{"file", "caption"}, one entry per screenshot.');
|
|
7782
|
+
}
|
|
7783
|
+
if (!Array.isArray(manifest) || manifest.length === 0
|
|
7784
|
+
|| manifest.some((each) => typeof each?.file !== 'string' || !each.file)) {
|
|
7785
|
+
throw new Error('`.cawdev/shots/shots.json` must be a non-empty JSON list of '
|
|
7786
|
+
+ '{"file", "caption"}, one entry per screenshot.');
|
|
7787
|
+
}
|
|
7788
|
+
const base = `/api/projects/${run.projectSlug}/runs/${run.id}/attachments`;
|
|
7789
|
+
const files = [];
|
|
7790
|
+
for (const each of manifest) {
|
|
7791
|
+
const name = each.file;
|
|
7792
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
|
|
7793
|
+
throw new Error(`\`${name}\` is not a usable name: letters, digits, \`.\`, \`_\` and \`-\`, `
|
|
7794
|
+
+ 'ending .png, .jpg, .jpeg or .html.');
|
|
7795
|
+
}
|
|
7796
|
+
let info;
|
|
7797
|
+
try {
|
|
7798
|
+
info = await lstat(join(dir, name));
|
|
7799
|
+
} catch {
|
|
7800
|
+
throw new Error(`\`shots.json\` lists \`${name}\` and it is not in \`.cawdev/shots/\`.`);
|
|
7801
|
+
}
|
|
7802
|
+
if (!info.isFile()) {
|
|
7803
|
+
// A symlink would be read as the machine and uploaded: only plain files.
|
|
7804
|
+
throw new Error(`\`${name}\` is not a plain file in \`.cawdev/shots/\`.`);
|
|
7805
|
+
}
|
|
7806
|
+
if (info.size > 2 * 1024 * 1024) {
|
|
7807
|
+
throw new Error(`\`${name}\` is ${(info.size / 1048576).toFixed(1)} MB; a screenshot must be `
|
|
7808
|
+
+ '2 MB or smaller.');
|
|
7809
|
+
}
|
|
7810
|
+
await api(config, `${base}/${encodeURIComponent(name)}`, {
|
|
7811
|
+
method: 'PUT', raw: await readFile(join(dir, name)),
|
|
7812
|
+
});
|
|
7813
|
+
files.push({ name, caption: typeof each.caption === 'string' ? each.caption : null });
|
|
7814
|
+
}
|
|
7815
|
+
const sealed = await api(config, `${base}/rounds`, { method: 'POST', body: { files } });
|
|
7816
|
+
await rm(dir, { recursive: true, force: true });
|
|
7817
|
+
return sealed;
|
|
7818
|
+
}
|
|
7819
|
+
|
|
7456
7820
|
/**
|
|
7457
7821
|
* Waits for a person at a gated stage — R112.
|
|
7458
7822
|
*
|
|
@@ -7469,7 +7833,7 @@ async function reportStage(config, run, stage, what, body = {}) {
|
|
|
7469
7833
|
* this can fail in: a gate that let the run through when the platform was down
|
|
7470
7834
|
* would be a gate that opens under exactly the conditions nobody is watching.
|
|
7471
7835
|
*/
|
|
7472
|
-
async function awaitGate(config, run, runToken, stage, text) {
|
|
7836
|
+
async function awaitGate(config, run, runToken, stage, text, { toolInput } = {}) {
|
|
7473
7837
|
let asked;
|
|
7474
7838
|
try {
|
|
7475
7839
|
asked = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/approvals`, {
|
|
@@ -7479,14 +7843,14 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
7479
7843
|
token: runToken,
|
|
7480
7844
|
body: {
|
|
7481
7845
|
toolName: `stage:${stage.stage}`,
|
|
7482
|
-
toolInput: JSON.stringify({ stage: stage.stage }),
|
|
7846
|
+
toolInput: JSON.stringify(toolInput ?? { stage: stage.stage }),
|
|
7483
7847
|
summary: text?.trim()
|
|
7484
7848
|
|| `The ${stage.stage} stage finished without saying anything.`,
|
|
7485
7849
|
},
|
|
7486
7850
|
});
|
|
7487
7851
|
} catch (failure) {
|
|
7488
7852
|
log(` could not raise the ${stage.stage} gate: ${failure.message}`);
|
|
7489
|
-
return { allowed: false, reason: `the gate could not be raised (${failure.message})` };
|
|
7853
|
+
return { allowed: false, failed: true, reason: `the gate could not be raised (${failure.message})` };
|
|
7490
7854
|
}
|
|
7491
7855
|
|
|
7492
7856
|
log(` waiting at the ${stage.stage} gate`);
|
|
@@ -7519,7 +7883,7 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
7519
7883
|
}
|
|
7520
7884
|
} catch (failure) {
|
|
7521
7885
|
log(` waiting on the ${stage.stage} gate failed: ${failure.message}`);
|
|
7522
|
-
return { allowed: false, reason: `waiting failed (${failure.message})` };
|
|
7886
|
+
return { allowed: false, failed: true, reason: `waiting failed (${failure.message})` };
|
|
7523
7887
|
}
|
|
7524
7888
|
}
|
|
7525
7889
|
return { allowed: false, expired: true, reason: 'nobody answered in time' };
|
|
@@ -7717,7 +8081,22 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7717
8081
|
// is told about all agree. Everything else, and every process with no
|
|
7718
8082
|
// lifecycle, gets them all.
|
|
7719
8083
|
const readOnlyStage = Boolean(stage) && ['PLAN', 'VERIFY', 'MEMORY'].includes(stage.stage);
|
|
7720
|
-
|
|
8084
|
+
// R604. SHOW is not read-only, but it reaches only experts that cannot
|
|
8085
|
+
// change anything: it has nothing to delegate that a Bash it already holds
|
|
8086
|
+
// could not do, and a writer behind it would be a writer past the tree check's
|
|
8087
|
+
// reading of the stage.
|
|
8088
|
+
const showStage = stage?.stage === 'SHOW';
|
|
8089
|
+
const stageExperts = readOnlyStage || showStage ? expertAgents.filter(readOnlyExpert) : expertAgents;
|
|
8090
|
+
|
|
8091
|
+
// R604. The folder SHOW writes into, kept out of git before anything can
|
|
8092
|
+
// `git add -A` it, and made so the stage need not.
|
|
8093
|
+
if (showStage) {
|
|
8094
|
+
await excludeLocally(cwd, '.cawdev/shots');
|
|
8095
|
+
// Emptied first: a SHOW that failed and was continued must not seal the
|
|
8096
|
+
// files its last attempt left behind as this round.
|
|
8097
|
+
await rm(join(cwd, '.cawdev', 'shots'), { recursive: true, force: true });
|
|
8098
|
+
await mkdir(join(cwd, '.cawdev', 'shots'), { recursive: true });
|
|
8099
|
+
}
|
|
7721
8100
|
// R161. From `stageExperts` and NEVER from `expertAgents` — see the note
|
|
7722
8101
|
// above; an expert a read-only stage cannot reach must not be required here.
|
|
7723
8102
|
const cannotDelegate = readOnlyStage && expertAgents.length && !stageExperts.length
|
|
@@ -8069,8 +8448,12 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
8069
8448
|
// has always done for the same profile spawned as one process. Without this
|
|
8070
8449
|
// the list `toolsForStage` narrows was narrowed on paper: the mode wrote
|
|
8071
8450
|
// files the list never named, and the prompt asked a person for the rest.
|
|
8451
|
+
// R604. SHOW: no permission mode (no acceptEdits, never bypass — "allow
|
|
8452
|
+
// everything" does not reach it), the prompt tool kept, and a list narrowed
|
|
8453
|
+
// to the shots folder. The daemon's tree check is the real guard.
|
|
8072
8454
|
const stageArgs = stage
|
|
8073
|
-
? [...argsBefore(agentArgs, { listOnly: !codesFreely || readOnlyStage }),
|
|
8455
|
+
? [...argsBefore(agentArgs, { listOnly: !codesFreely || readOnlyStage, dropMode: showStage }),
|
|
8456
|
+
'--allowedTools',
|
|
8074
8457
|
...toolsForStage(stage.stage,
|
|
8075
8458
|
withSkills(
|
|
8076
8459
|
withDelegation(typeof profileTools === 'function' ? profileTools(run) : profileTools,
|
|
@@ -8083,7 +8466,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
8083
8466
|
// handed is read-only — which `stageExperts` guarantees above.
|
|
8084
8467
|
// R150. And a PLAN stage keeps `roadmap_create` when the run has no
|
|
8085
8468
|
// card to plan, because writing it is the first thing it has to do.
|
|
8086
|
-
{ delegatesReadOnly: readOnlyStage && stageExperts.length > 0,
|
|
8469
|
+
{ delegatesReadOnly: (readOnlyStage || showStage) && stageExperts.length > 0,
|
|
8087
8470
|
writesItsOwnCard: writesItsOwnCard(run) })]
|
|
8088
8471
|
: null;
|
|
8089
8472
|
|
|
@@ -8135,6 +8518,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
8135
8518
|
// `init` (below), which is also the only answer when none was asked.
|
|
8136
8519
|
child.cawdevAgent = 'claude';
|
|
8137
8520
|
child.cawdevModel = model ?? null;
|
|
8521
|
+
stamp(child, stage, lifecycle);
|
|
8138
8522
|
// The checkout this child holds, and what the queue serialises on. Null for
|
|
8139
8523
|
// a run that took none, which is what lets an ASK session run alongside
|
|
8140
8524
|
// three coding ones.
|
|
@@ -9243,6 +9627,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
9243
9627
|
child.cawdevProjectSlug = run.projectSlug;
|
|
9244
9628
|
child.cawdevAgent = 'agy';
|
|
9245
9629
|
child.cawdevModel = run.model ?? null;
|
|
9630
|
+
stamp(child, stage, lifecycle);
|
|
9246
9631
|
// The existing cancellation sweep (`reapCancelled`) reads this map by
|
|
9247
9632
|
// run id and SIGTERMs whatever is in it — true for any child, so a
|
|
9248
9633
|
// cancelled Antigravity run stops exactly the way a cancelled Claude
|
|
@@ -9589,6 +9974,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
9589
9974
|
child.cawdevProjectSlug = run.projectSlug;
|
|
9590
9975
|
child.cawdevAgent = 'codex';
|
|
9591
9976
|
child.cawdevModel = model ?? null;
|
|
9977
|
+
stamp(child, stage, lifecycle);
|
|
9592
9978
|
// `reapCancelled` SIGTERMs whatever is here, whichever CLI it is.
|
|
9593
9979
|
running.set(run.id, child);
|
|
9594
9980
|
|