cawdev-cli 1.2.0 → 1.2.2

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