cawdev-cli 1.2.0 → 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/mcp/server.mjs +33 -8
- 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 +393 -26
- 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';
|
|
@@ -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;
|
|
@@ -2677,6 +2680,7 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2677
2680
|
const problems = (reading.newBugs ?? 0) + (reading.newVulnerabilities ?? 0)
|
|
2678
2681
|
+ (reading.newSecurityHotspots ?? 0) + (reading.newCodeSmells ?? 0);
|
|
2679
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}` : '')
|
|
2680
2684
|
+ (reading.warning ? ` · ${reading.warning}` : ''));
|
|
2681
2685
|
}
|
|
2682
2686
|
};
|
|
@@ -2751,7 +2755,7 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2751
2755
|
return await reportBranchReading({
|
|
2752
2756
|
ask: askServer, reportTask, settled, key, branch, analysedAs: 'MAIN',
|
|
2753
2757
|
warning: "This server does not analyse branches; the branch was analysed as the project's main analysis.",
|
|
2754
|
-
headSha, report,
|
|
2758
|
+
headSha, report, findings: run.findingsToProve,
|
|
2755
2759
|
});
|
|
2756
2760
|
}
|
|
2757
2761
|
|
|
@@ -2795,7 +2799,10 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2795
2799
|
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2796
2800
|
const askServer = sonarAsker((reportTask.serverUrl ?? url).replace(/\/$/, ''), token);
|
|
2797
2801
|
const settled = await waitForAnalysis(askServer, reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2798
|
-
await reportBranchReading({
|
|
2802
|
+
await reportBranchReading({
|
|
2803
|
+
ask: askServer, reportTask, settled, key, branch, analysedAs, warning, headSha, report,
|
|
2804
|
+
findings: run.findingsToProve,
|
|
2805
|
+
});
|
|
2799
2806
|
} catch (failure) {
|
|
2800
2807
|
await report({ headSha, error: failure.message.split('\n')[0] });
|
|
2801
2808
|
} finally {
|
|
@@ -2811,7 +2818,7 @@ function trimSlash(url) {
|
|
|
2811
2818
|
}
|
|
2812
2819
|
|
|
2813
2820
|
/** 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 }) {
|
|
2821
|
+
async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report, findings }) {
|
|
2815
2822
|
const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
|
|
2816
2823
|
const metrics = 'new_bugs,new_vulnerabilities,new_security_hotspots,new_code_smells,new_coverage';
|
|
2817
2824
|
const { component } = await ask(`/api/measures/component?component=${encodeURIComponent(key)}`
|
|
@@ -2838,9 +2845,80 @@ async function reportBranchReading({ ask, reportTask, settled, key, branch, anal
|
|
|
2838
2845
|
newCoverage: measure('new_coverage'),
|
|
2839
2846
|
dashboardUrl: reportTask.dashboardUrl ?? null,
|
|
2840
2847
|
warning,
|
|
2848
|
+
...(await proveFindings({ ask, scanKey: key, branch, analysedAs, findings })),
|
|
2841
2849
|
});
|
|
2842
2850
|
}
|
|
2843
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
|
+
|
|
2844
2922
|
/**
|
|
2845
2923
|
* SonarQube's own pages, `ps=500` at a time, up to the server's `p × ps`
|
|
2846
2924
|
* ceiling worth trying — R313's `LIST_PAGES` argument, in the scanner
|
|
@@ -2942,6 +3020,9 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
|
|
|
2942
3020
|
}
|
|
2943
3021
|
const newSecurityHotspots = hotspots.filter((hotspot) => counted(hotspot.component, hotspot.line)).length;
|
|
2944
3022
|
|
|
3023
|
+
const proven = await proveFindings({
|
|
3024
|
+
ask, scanKey: scratchKey, branch, analysedAs: 'PROJECT', findings: run.findingsToProve });
|
|
3025
|
+
|
|
2945
3026
|
await report({
|
|
2946
3027
|
headSha,
|
|
2947
3028
|
gate: 'NONE',
|
|
@@ -2954,6 +3035,7 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
|
|
|
2954
3035
|
dashboardUrl: reportTask.dashboardUrl ?? null,
|
|
2955
3036
|
warning: 'This server does not analyse branches; the branch was analysed as its own SonarQube project, '
|
|
2956
3037
|
+ 'counting problems on the lines it changed. No gate was computed.',
|
|
3038
|
+
...proven,
|
|
2957
3039
|
});
|
|
2958
3040
|
}
|
|
2959
3041
|
|
|
@@ -5568,12 +5650,18 @@ async function reportUsageLimit(config, run, limit, lastText) {
|
|
|
5568
5650
|
* nothing for the session that was asking. One function for both paths now,
|
|
5569
5651
|
* so they cannot come apart again.
|
|
5570
5652
|
*/
|
|
5571
|
-
function argsBefore(agentArgs, { listOnly = false } = {}) {
|
|
5653
|
+
function argsBefore(agentArgs, { listOnly = false, dropMode = false } = {}) {
|
|
5572
5654
|
const kept = [];
|
|
5573
5655
|
for (let i = 0; i < agentArgs.length; i++) {
|
|
5574
5656
|
if (agentArgs[i] === '--allowedTools') {
|
|
5575
5657
|
break; // variadic: everything after it is a permission
|
|
5576
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
|
+
}
|
|
5577
5665
|
if (listOnly && (agentArgs[i] === '--permission-mode'
|
|
5578
5666
|
|| agentArgs[i] === '--permission-prompt-tool')) {
|
|
5579
5667
|
i += 1;
|
|
@@ -5755,12 +5843,33 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5755
5843
|
+ 'to hide. ' + WAIT_FOR_COMMANDS + ' Then commit the branch\'s changes — no stage '
|
|
5756
5844
|
+ 'after this one writes code, and work left uncommitted is work nothing can push. '
|
|
5757
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,
|
|
5758
5860
|
MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
|
|
5759
5861
|
+ 'tried and rejected and why, and anything that turned out to matter and would not be '
|
|
5760
5862
|
+ 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
|
|
5761
5863
|
+ 'briefing and handed to whoever comes next.',
|
|
5762
5864
|
}[stage.stage] ?? `Carry out the ${stage.stage} stage.`;
|
|
5763
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
|
+
|
|
5764
5873
|
const before = [];
|
|
5765
5874
|
for (const [name, text] of Object.entries(carried ?? {})) {
|
|
5766
5875
|
if (text) {
|
|
@@ -5768,7 +5877,7 @@ function stagePrompt(stage, carried, hasPlan = true) {
|
|
|
5768
5877
|
}
|
|
5769
5878
|
}
|
|
5770
5879
|
|
|
5771
|
-
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}`
|
|
5772
5881
|
+ (before.length ? `\n\n${before.join('\n\n')}` : '');
|
|
5773
5882
|
}
|
|
5774
5883
|
|
|
@@ -6717,6 +6826,13 @@ function noteQueued(run, why, reported = why) {
|
|
|
6717
6826
|
log(`${why}; leaving "${run.label}" queued`);
|
|
6718
6827
|
}
|
|
6719
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
|
+
|
|
6720
6836
|
/**
|
|
6721
6837
|
* This machine, as somebody attached to it sees it — R52.
|
|
6722
6838
|
*
|
|
@@ -6745,6 +6861,10 @@ function snapshotRuns() {
|
|
|
6745
6861
|
agent: child.cawdevAgent ?? null,
|
|
6746
6862
|
model: child.cawdevModel ?? null,
|
|
6747
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,
|
|
6748
6868
|
});
|
|
6749
6869
|
}
|
|
6750
6870
|
for (const [id, claim] of taken) {
|
|
@@ -6809,6 +6929,9 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6809
6929
|
let briefing = null;
|
|
6810
6930
|
let plan = null;
|
|
6811
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 };
|
|
6812
6935
|
let shield = null;
|
|
6813
6936
|
// R155. What `git merge` did before anything was spawned, on a MERGE run.
|
|
6814
6937
|
let merge = null;
|
|
@@ -6877,6 +7000,8 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6877
7000
|
// R587. The project's declared version files: the scan reads the first it
|
|
6878
7001
|
// can parse from the workspace and sends it as sonar.projectVersion.
|
|
6879
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 : [];
|
|
6880
7005
|
// R69. Whether this is the same conversation picked back up, and which one.
|
|
6881
7006
|
// Null on an ordinary claim, which is nearly all of them.
|
|
6882
7007
|
resume = claimed.resume ?? null;
|
|
@@ -6921,6 +7046,10 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6921
7046
|
// one. Read here with everything else the claim carries.
|
|
6922
7047
|
plan = claimed.plan ?? null;
|
|
6923
7048
|
lifecycle = Array.isArray(claimed.workflow) ? claimed.workflow : [];
|
|
7049
|
+
showWalk = {
|
|
7050
|
+
rework: Array.isArray(claimed.rework) ? claimed.rework : [],
|
|
7051
|
+
comments: claimed.showComments ?? null,
|
|
7052
|
+
};
|
|
6924
7053
|
shield = claimed.shield ?? null;
|
|
6925
7054
|
// R562. Every session is told the list up front, whichever profile it is.
|
|
6926
7055
|
run.protectedLine = protectedLine(protectedPolicy(shield));
|
|
@@ -7084,7 +7213,7 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
7084
7213
|
}
|
|
7085
7214
|
|
|
7086
7215
|
const said = await walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
7087
|
-
resume, mcpServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield);
|
|
7216
|
+
resume, mcpServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield, showWalk);
|
|
7088
7217
|
|
|
7089
7218
|
if (merge) {
|
|
7090
7219
|
// R155. The session has gone. Whether it resolved anything is a question
|
|
@@ -7198,7 +7327,8 @@ async function skipRest(config, run, lifecycle, from, why) {
|
|
|
7198
7327
|
}
|
|
7199
7328
|
|
|
7200
7329
|
async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
7201
|
-
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield
|
|
7330
|
+
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
|
|
7331
|
+
show = null) {
|
|
7202
7332
|
const spawn = (stage, carried, usage) => spawnAgent(config, run, runToken, cwd, baseCommit,
|
|
7203
7333
|
workspace, resume, projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle,
|
|
7204
7334
|
shield, stage, carried, usage);
|
|
@@ -7218,6 +7348,22 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7218
7348
|
const carried = {};
|
|
7219
7349
|
let last = null;
|
|
7220
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
|
+
|
|
7221
7367
|
// R305. What the RUN has been credited with so far. Each stage is its own
|
|
7222
7368
|
// process with its own cumulative counter, so the base the next child adds
|
|
7223
7369
|
// to must move — read once from the claim, it stayed at zero and the platform
|
|
@@ -7233,22 +7379,32 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7233
7379
|
// answers without the fields and the base is zero, which is today's number.
|
|
7234
7380
|
const stored = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/stages`)
|
|
7235
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();
|
|
7236
7386
|
const creditedTo = (name) => {
|
|
7237
|
-
|
|
7238
|
-
|
|
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);
|
|
7239
7394
|
};
|
|
7240
7395
|
|
|
7241
7396
|
const storedRow = (name) => (Array.isArray(stored)
|
|
7242
7397
|
? stored.find((each) => each?.stage === name) : null);
|
|
7243
7398
|
|
|
7244
|
-
for (let at = 0; at <
|
|
7245
|
-
const stage =
|
|
7399
|
+
walking: for (let at = 0; at < walk.length; at++) {
|
|
7400
|
+
const stage = walk[at];
|
|
7246
7401
|
// Where a carried-on run resumes, and what its DONE stages said, is the
|
|
7247
7402
|
// platform's (R263, `WorkflowService.resumeFrom`): the claim's lifecycle
|
|
7248
7403
|
// starts at the first stage not DONE. What only the daemon knows is WHY
|
|
7249
7404
|
// the stage about to run stopped last time — so that is handed to it.
|
|
7250
7405
|
const before = storedRow(stage.stage);
|
|
7251
|
-
if (before?.state === 'FAILED' && before.outcome) {
|
|
7406
|
+
if (before?.state === 'FAILED' && before.outcome && !retold.has(stage.stage)) {
|
|
7407
|
+
retold.add(stage.stage);
|
|
7252
7408
|
// The attempt that stopped, handed to the one about to run.
|
|
7253
7409
|
carried[`${stage.stage} (the attempt that stopped)`] = `${before.outcome}\n\n`
|
|
7254
7410
|
+ 'This stage is being run again to finish that. Run the commands — and the agents — '
|
|
@@ -7267,6 +7423,8 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7267
7423
|
// loop nobody asked for.
|
|
7268
7424
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
7269
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;
|
|
7270
7428
|
log(` ${stage.stage}${stage.model ? ` on ${stage.model}` : ''}`
|
|
7271
7429
|
+ `${attempt ? ' (again, after the gate refused it)' : ''}`);
|
|
7272
7430
|
|
|
@@ -7331,7 +7489,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7331
7489
|
plan: stage.stage === 'PLAN' ? last?.text ?? null : null,
|
|
7332
7490
|
outcome: `${why} ${last?.text ?? ''}`.trim(),
|
|
7333
7491
|
});
|
|
7334
|
-
await skipRest(config, run,
|
|
7492
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7335
7493
|
return;
|
|
7336
7494
|
}
|
|
7337
7495
|
|
|
@@ -7347,7 +7505,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7347
7505
|
plan: stage.stage === 'PLAN' ? last?.text ?? null : null,
|
|
7348
7506
|
outcome: `${why} ${last?.text ?? ''}`.trim(),
|
|
7349
7507
|
});
|
|
7350
|
-
await skipRest(config, run,
|
|
7508
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7351
7509
|
return;
|
|
7352
7510
|
}
|
|
7353
7511
|
|
|
@@ -7381,6 +7539,84 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7381
7539
|
return;
|
|
7382
7540
|
}
|
|
7383
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
|
+
|
|
7384
7620
|
await reportStage(config, run, stage.stage, 'report', {
|
|
7385
7621
|
state: 'DONE',
|
|
7386
7622
|
// The PLAN's text IS the plan — the artefact R109 exists to store, and
|
|
@@ -7402,7 +7638,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7402
7638
|
// second stage on a plan nobody has looked at yet.
|
|
7403
7639
|
const why = `Stopped at the ${stage.stage} gate: ${decision.reason}. Nothing was `
|
|
7404
7640
|
+ 'refused — start the run again when somebody can read it.';
|
|
7405
|
-
await skipRest(config, run,
|
|
7641
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7406
7642
|
await finish(config, run, 'FINISHED', why, cwd);
|
|
7407
7643
|
return;
|
|
7408
7644
|
}
|
|
@@ -7411,7 +7647,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7411
7647
|
// The same trail R122 came from: a run that ends here never reaches the
|
|
7412
7648
|
// stages after it, and stages left PENDING on a run that is over read
|
|
7413
7649
|
// as a lifecycle still going.
|
|
7414
|
-
await skipRest(config, run,
|
|
7650
|
+
await skipRest(config, run, walk, at + 1, why);
|
|
7415
7651
|
await finish(config, run, 'FINISHED', why, cwd);
|
|
7416
7652
|
return;
|
|
7417
7653
|
}
|
|
@@ -7472,6 +7708,115 @@ async function reportStage(config, run, stage, what, body = {}) {
|
|
|
7472
7708
|
.catch((failure) => log(` could not report ${stage}: ${failure.message}`));
|
|
7473
7709
|
}
|
|
7474
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
|
+
|
|
7475
7820
|
/**
|
|
7476
7821
|
* Waits for a person at a gated stage — R112.
|
|
7477
7822
|
*
|
|
@@ -7488,7 +7833,7 @@ async function reportStage(config, run, stage, what, body = {}) {
|
|
|
7488
7833
|
* this can fail in: a gate that let the run through when the platform was down
|
|
7489
7834
|
* would be a gate that opens under exactly the conditions nobody is watching.
|
|
7490
7835
|
*/
|
|
7491
|
-
async function awaitGate(config, run, runToken, stage, text) {
|
|
7836
|
+
async function awaitGate(config, run, runToken, stage, text, { toolInput } = {}) {
|
|
7492
7837
|
let asked;
|
|
7493
7838
|
try {
|
|
7494
7839
|
asked = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/approvals`, {
|
|
@@ -7498,14 +7843,14 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
7498
7843
|
token: runToken,
|
|
7499
7844
|
body: {
|
|
7500
7845
|
toolName: `stage:${stage.stage}`,
|
|
7501
|
-
toolInput: JSON.stringify({ stage: stage.stage }),
|
|
7846
|
+
toolInput: JSON.stringify(toolInput ?? { stage: stage.stage }),
|
|
7502
7847
|
summary: text?.trim()
|
|
7503
7848
|
|| `The ${stage.stage} stage finished without saying anything.`,
|
|
7504
7849
|
},
|
|
7505
7850
|
});
|
|
7506
7851
|
} catch (failure) {
|
|
7507
7852
|
log(` could not raise the ${stage.stage} gate: ${failure.message}`);
|
|
7508
|
-
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})` };
|
|
7509
7854
|
}
|
|
7510
7855
|
|
|
7511
7856
|
log(` waiting at the ${stage.stage} gate`);
|
|
@@ -7538,7 +7883,7 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
7538
7883
|
}
|
|
7539
7884
|
} catch (failure) {
|
|
7540
7885
|
log(` waiting on the ${stage.stage} gate failed: ${failure.message}`);
|
|
7541
|
-
return { allowed: false, reason: `waiting failed (${failure.message})` };
|
|
7886
|
+
return { allowed: false, failed: true, reason: `waiting failed (${failure.message})` };
|
|
7542
7887
|
}
|
|
7543
7888
|
}
|
|
7544
7889
|
return { allowed: false, expired: true, reason: 'nobody answered in time' };
|
|
@@ -7736,7 +8081,22 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7736
8081
|
// is told about all agree. Everything else, and every process with no
|
|
7737
8082
|
// lifecycle, gets them all.
|
|
7738
8083
|
const readOnlyStage = Boolean(stage) && ['PLAN', 'VERIFY', 'MEMORY'].includes(stage.stage);
|
|
7739
|
-
|
|
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
|
+
}
|
|
7740
8100
|
// R161. From `stageExperts` and NEVER from `expertAgents` — see the note
|
|
7741
8101
|
// above; an expert a read-only stage cannot reach must not be required here.
|
|
7742
8102
|
const cannotDelegate = readOnlyStage && expertAgents.length && !stageExperts.length
|
|
@@ -8088,8 +8448,12 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
8088
8448
|
// has always done for the same profile spawned as one process. Without this
|
|
8089
8449
|
// the list `toolsForStage` narrows was narrowed on paper: the mode wrote
|
|
8090
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.
|
|
8091
8454
|
const stageArgs = stage
|
|
8092
|
-
? [...argsBefore(agentArgs, { listOnly: !codesFreely || readOnlyStage }),
|
|
8455
|
+
? [...argsBefore(agentArgs, { listOnly: !codesFreely || readOnlyStage, dropMode: showStage }),
|
|
8456
|
+
'--allowedTools',
|
|
8093
8457
|
...toolsForStage(stage.stage,
|
|
8094
8458
|
withSkills(
|
|
8095
8459
|
withDelegation(typeof profileTools === 'function' ? profileTools(run) : profileTools,
|
|
@@ -8102,7 +8466,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
8102
8466
|
// handed is read-only — which `stageExperts` guarantees above.
|
|
8103
8467
|
// R150. And a PLAN stage keeps `roadmap_create` when the run has no
|
|
8104
8468
|
// card to plan, because writing it is the first thing it has to do.
|
|
8105
|
-
{ delegatesReadOnly: readOnlyStage && stageExperts.length > 0,
|
|
8469
|
+
{ delegatesReadOnly: (readOnlyStage || showStage) && stageExperts.length > 0,
|
|
8106
8470
|
writesItsOwnCard: writesItsOwnCard(run) })]
|
|
8107
8471
|
: null;
|
|
8108
8472
|
|
|
@@ -8154,6 +8518,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
8154
8518
|
// `init` (below), which is also the only answer when none was asked.
|
|
8155
8519
|
child.cawdevAgent = 'claude';
|
|
8156
8520
|
child.cawdevModel = model ?? null;
|
|
8521
|
+
stamp(child, stage, lifecycle);
|
|
8157
8522
|
// The checkout this child holds, and what the queue serialises on. Null for
|
|
8158
8523
|
// a run that took none, which is what lets an ASK session run alongside
|
|
8159
8524
|
// three coding ones.
|
|
@@ -9262,6 +9627,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
9262
9627
|
child.cawdevProjectSlug = run.projectSlug;
|
|
9263
9628
|
child.cawdevAgent = 'agy';
|
|
9264
9629
|
child.cawdevModel = run.model ?? null;
|
|
9630
|
+
stamp(child, stage, lifecycle);
|
|
9265
9631
|
// The existing cancellation sweep (`reapCancelled`) reads this map by
|
|
9266
9632
|
// run id and SIGTERMs whatever is in it — true for any child, so a
|
|
9267
9633
|
// cancelled Antigravity run stops exactly the way a cancelled Claude
|
|
@@ -9608,6 +9974,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
9608
9974
|
child.cawdevProjectSlug = run.projectSlug;
|
|
9609
9975
|
child.cawdevAgent = 'codex';
|
|
9610
9976
|
child.cawdevModel = model ?? null;
|
|
9977
|
+
stamp(child, stage, lifecycle);
|
|
9611
9978
|
// `reapCancelled` SIGTERMs whatever is here, whichever CLI it is.
|
|
9612
9979
|
running.set(run.id, child);
|
|
9613
9980
|
|