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/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({ ask: askServer, reportTask, settled, key, branch, analysedAs, warning, headSha, report });
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
- const row = Array.isArray(stored) ? stored.find((each) => each?.stage === name) : null;
7238
- return { tokensIn: Number(row?.tokensIn) || 0, tokensOut: Number(row?.tokensOut) || 0 };
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 < lifecycle.length; at++) {
7245
- const stage = lifecycle[at];
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, lifecycle, at + 1, why);
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, lifecycle, at + 1, why);
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, lifecycle, at + 1, why);
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, lifecycle, at + 1, why);
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
- const stageExperts = readOnlyStage ? expertAgents.filter(readOnlyExpert) : expertAgents;
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 }), '--allowedTools',
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