cawdev-cli 1.0.3-beta → 1.0.4-beta

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
@@ -33,7 +33,8 @@ import {
33
33
  import { AI_CONFIG, harnessPrompt, readRepoConfig } from '../lib/harness-prompt.mjs';
34
34
  import { loadToken, storedUrls } from './token-store.mjs';
35
35
  import {
36
- CARD_WRITE, CAWDEV_READS, GIT_READS, driftedFrom, readOnlyExpert, toolsForStage,
36
+ CARD_WRITE, CAWDEV_READS, GIT_READS, beforeTheWorkExists, driftedFrom, readOnlyExpert,
37
+ toolsForStage,
37
38
  } from '../lib/stage-tools.mjs';
38
39
  import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
39
40
  import { findSecret } from '../lib/secrets.mjs';
@@ -164,6 +165,13 @@ const DEFAULTS = {
164
165
  // would be a question with one sensible answer.
165
166
  'mcp__cawdev__code_map',
166
167
  'mcp__cawdev__file_deps',
168
+ // R302. The SonarQube reading — a read, allowed by default like the map.
169
+ // Through READ_ONLY_CAWDEV this reaches REVIEW and AUDIT as the card
170
+ // asks, and ASK, SCOPE, MERGE, RELEASE and INTERVIEW as well: it is a
171
+ // read and harmless there, and subtracting it from five lists would be
172
+ // five lists to keep in step. PLAN is the one profile that drops it —
173
+ // `beforeTheWorkExists`, below — because the card says so.
174
+ 'mcp__cawdev__quality_get',
167
175
  'mcp__cawdev__roadmap_statuses',
168
176
  'mcp__cawdev__roadmap_list',
169
177
  'mcp__cawdev__roadmap_get',
@@ -463,6 +471,12 @@ async function readConfig() {
463
471
  * "dycrypt": "/Users/you/code/dycrypt"
464
472
  * "dycrypt": { "path": "…", "allowedTools": ["Bash(mvn *)", "mcp__roadmap"] }
465
473
  * "cawdev": { "workspaces": ["/…/ws-1", "/…/ws-2", "/…/ws-3"] }
474
+ * "cawdev": { "workspaces": [...], "quality": { "command": "sonar-scanner", "tokenEnv": "SONAR_TOKEN" } }
475
+ *
476
+ * R300: the fourth form adds a `quality` block — see `qualityBlockOf` — and a
477
+ * project with one has its branches scanned after a coding run's TEST stage.
478
+ * Captured here at boot, not hot-swapped: adding or changing the block needs
479
+ * a restart, and `restartNeededFor` says so.
466
480
  *
467
481
  * R47: a bare path means ONE workspace, which is what every config meant
468
482
  * before workspaces existed. Concurrency for a project is
@@ -508,11 +522,51 @@ function normaliseProjects(projects) {
508
522
  // falls through to the machine's answer; `false` here refuses it for one
509
523
  // project on a machine that otherwise allows it.
510
524
  browser: settings.browser,
525
+ // R300: whether, and how, this project's branches are scanned with
526
+ // SonarQube after TEST. Null is the ordinary case and means never.
527
+ quality: qualityBlockOf(slug, settings.quality),
511
528
  };
512
529
  }
513
530
  return normalised;
514
531
  }
515
532
 
533
+ /**
534
+ * A project's `quality` block, normalised — R300.
535
+ *
536
+ * The scan is opt-in TWICE: here, on the machine, and on the project, which
537
+ * must have a SonarQube key (the claim carries it). A machine with no block
538
+ * never scans and never says so; a machine with one and no scanner on its
539
+ * PATH reports an `error` reading rather than nothing, so the page says the
540
+ * scan could not run instead of showing a branch nobody looked at.
541
+ *
542
+ * `tokenEnv` names the variable this machine holds the scanner token in. The
543
+ * platform never sends one: the project's SonarQube token (R304) is the
544
+ * platform's reader credential and stays there; the scanner's is the
545
+ * operator's and stays here. `url` overrides the platform's `sonarUrl` for
546
+ * a machine that reaches the server by another name (a LAN hostname).
547
+ *
548
+ * `whenBranchesRefused` is what to do on a server that refuses
549
+ * `sonar.branch.name` — Community Edition. The default, `skip`, reports an
550
+ * error saying so; `main` re-runs the scan as the project's MAIN analysis,
551
+ * which overwrites the project's dashboard with this branch's code and so
552
+ * makes the platform's project reading describe whichever branch scanned
553
+ * last. An operator opts into that; nothing opts in for them.
554
+ */
555
+ function qualityBlockOf(slug, block) {
556
+ if (!block) return null;
557
+ const when = block.whenBranchesRefused ?? 'skip';
558
+ if (when !== 'skip' && when !== 'main') {
559
+ throw new Error(`Project "${slug}": quality.whenBranchesRefused must be "skip" or "main", not "${when}".`);
560
+ }
561
+ return {
562
+ command: block.command ?? 'sonar-scanner',
563
+ tokenEnv: block.tokenEnv ?? 'SONAR_TOKEN',
564
+ url: block.url ?? null,
565
+ whenBranchesRefused: when,
566
+ timeoutSeconds: block.timeoutSeconds ?? 600,
567
+ };
568
+ }
569
+
516
570
 
517
571
  /**
518
572
  * What THIS daemon knows about a skill, beyond what the platform sent — R76.
@@ -1305,10 +1359,17 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
1305
1359
  notes.push(await prepareSkillIndex(config, skill, local, run, cwd, baseCommit));
1306
1360
  }
1307
1361
  attached.push({ skill, local });
1362
+ // R303. A required server says so where the person is looking, as the
1363
+ // expert line does. Whether a STAGE is actually asked for it is decided at
1364
+ // spawn — a read-only stage cannot call it and is not nudged for it.
1365
+ const must = skill.mode === 'REQUIRED'
1366
+ ? ' This project REQUIRES it: if a stage finishes without calling one of its tools, the '
1367
+ + 'session will be told.'
1368
+ : '';
1308
1369
  notes.push(`${skill.name ?? skill.key} is available to this session as `
1309
1370
  + `${skill.toolPrefix ?? `mcp__${skill.serverName}`}. The first call still asks a person — `
1310
1371
  + `allow ${skill.toolPrefix ?? `mcp__${skill.serverName}`} for the session to cover the `
1311
- + 'rest.');
1372
+ + `rest.${must}`);
1312
1373
  }
1313
1374
  return { attached, notes };
1314
1375
  }
@@ -2239,6 +2300,189 @@ async function surveyGit(config) {
2239
2300
  }
2240
2301
  }
2241
2302
 
2303
+ /**
2304
+ * Scans the run's branch with SonarQube and tells the platform — R300.
2305
+ *
2306
+ * Runs AFTER the TEST stage settles and BEFORE the next stage begins, on the
2307
+ * workspace the run just wrote, and it is the DAEMON that runs it: a session
2308
+ * that reported its own gate could lie about it (RunnerLimit's argument), so
2309
+ * the scanner is a subprocess this process starts and nothing the spawned
2310
+ * `claude` can call. Only a staged run with a TEST stage gets here — a
2311
+ * lifecycle without one, an unstaged run, a run whose gate refused TEST twice
2312
+ * or one that reported done inside TEST is not scanned, because there was no
2313
+ * settled TEST stage to scan after.
2314
+ *
2315
+ * The shape is `surveyGit`'s: every failure is CAUGHT and reported as an
2316
+ * `error` reading, never thrown to the walk — a failed scan never fails a
2317
+ * run. The two silent exits are the two ways the feature is off: no `quality`
2318
+ * block on this machine (nothing is logged; the operator never asked), and no
2319
+ * key on the project (logged, because the machine did ask and the project is
2320
+ * what is missing). Everything after that reports SOMETHING, so that "could
2321
+ * not scan" and "did not scan" never reach the page as the same answer.
2322
+ *
2323
+ * What is scanned is the WORKING TREE, which after TEST may hold edits the
2324
+ * session had not committed; `headSha` is HEAD, and says where the tree
2325
+ * started rather than that the tree equals it.
2326
+ *
2327
+ * The scanner's working directory is a temp dir OUTSIDE the checkout,
2328
+ * passed as `-Dsonar.working.directory=`: `.scannerwork/` inside it would
2329
+ * show up in the working-copy watcher and in `resetWorkspace`'s `git clean`
2330
+ * list — R76's `.codegraph` lesson, see `excludeLocally`. The token goes in
2331
+ * the child's ENVIRONMENT and never on its argv, which `ps` would show.
2332
+ */
2333
+ async function scanQuality(config, run, cwd, branch) {
2334
+ const slug = run.projectSlug;
2335
+ const quality = config.projects[slug]?.quality;
2336
+ if (!quality) return;
2337
+ if (!run.sonarProjectKey) {
2338
+ log(` quality: ${slug} has no SonarQube key — not scanned`);
2339
+ return;
2340
+ }
2341
+ const key = run.sonarProjectKey;
2342
+ const report = async (reading) => {
2343
+ await api(config, `/api/runners/${config.runnerId}/quality/${slug}`, {
2344
+ method: 'POST',
2345
+ body: { runId: run.id, branch, ...reading },
2346
+ }).catch((failure) => log(` could not report the quality of ${branch}: ${failure.message}`));
2347
+ if (reading.error) {
2348
+ log(` quality: could not scan ${branch}: ${reading.error}`);
2349
+ } else {
2350
+ const problems = (reading.newBugs ?? 0) + (reading.newVulnerabilities ?? 0)
2351
+ + (reading.newSecurityHotspots ?? 0) + (reading.newCodeSmells ?? 0);
2352
+ log(` quality: ${branch} gate ${reading.gate ?? 'NONE'} · ${problems} new problem${problems === 1 ? '' : 's'}`
2353
+ + (reading.warning ? ` · ${reading.warning}` : ''));
2354
+ }
2355
+ };
2356
+
2357
+ const url = quality.url ?? run.sonarUrl;
2358
+ if (!url) {
2359
+ return report({ error: 'No SonarQube server: the platform has none configured and the quality block names no url.' });
2360
+ }
2361
+ const token = process.env[quality.tokenEnv];
2362
+ if (!token) {
2363
+ return report({ error: `The scanner token is not set: export ${quality.tokenEnv} on this machine.` });
2364
+ }
2365
+
2366
+ let headSha = null;
2367
+ let workDir = null;
2368
+ try {
2369
+ headSha = await git(cwd, ['rev-parse', 'HEAD']);
2370
+ workDir = await mkdtemp(join(tmpdir(), 'cawdev-sonar-'));
2371
+ const deadline = Date.now() + quality.timeoutSeconds * 1000;
2372
+ const remaining = () => Math.max(1000, deadline - Date.now());
2373
+ const scannerArgs = (withBranch) => [
2374
+ `-Dsonar.host.url=${url}`,
2375
+ `-Dsonar.projectKey=${key}`,
2376
+ ...(withBranch ? [`-Dsonar.branch.name=${branch}`] : []),
2377
+ `-Dsonar.scm.revision=${headSha}`,
2378
+ `-Dsonar.working.directory=${workDir}`,
2379
+ ];
2380
+ const env = { ...process.env, SONAR_TOKEN: token };
2381
+
2382
+ let analysedAs = 'BRANCH';
2383
+ let warning = null;
2384
+ let ran = await runSkillCommand(quality.command, scannerArgs(true),
2385
+ { cwd, env, timeoutMs: remaining() });
2386
+ if (!ran.ok) {
2387
+ if (/ENOENT/.test(ran.said)) {
2388
+ return report({ headSha, error: `${quality.command} is not installed on this machine (spawn ENOENT).` });
2389
+ }
2390
+ if (/sonar\.branch\.name.*(Developer Edition|above is required)/is.test(ran.said)) {
2391
+ if (quality.whenBranchesRefused !== 'main') {
2392
+ return report({
2393
+ headSha,
2394
+ error: 'This SonarQube server does not analyse branches (Community Edition). '
2395
+ + 'Set "whenBranchesRefused": "main" in the quality block to analyse it as the main analysis instead.',
2396
+ });
2397
+ }
2398
+ // The operator opted in: once more, as the project's main analysis.
2399
+ analysedAs = 'MAIN';
2400
+ warning = "This server does not analyse branches; the branch was analysed as the project's main analysis.";
2401
+ ran = await runSkillCommand(quality.command, scannerArgs(false),
2402
+ { cwd, env, timeoutMs: remaining() });
2403
+ }
2404
+ }
2405
+ if (!ran.ok) {
2406
+ return report({ headSha, error: ran.said.split('\n')[0] || 'the scanner failed and said nothing' });
2407
+ }
2408
+
2409
+ // What the scanner left behind: where the server queued the analysis.
2410
+ const task = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
2411
+ const serverUrl = (task.serverUrl ?? url).replace(/\/$/, '');
2412
+ const headers = { authorization: `Bearer ${token}` };
2413
+ const ask = async (path) => {
2414
+ const response = await fetch(`${serverUrl}${path}`, { headers });
2415
+ if (!response.ok) {
2416
+ throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
2417
+ }
2418
+ return response.json();
2419
+ };
2420
+
2421
+ // The analysis is queued on the server; wait for it to settle.
2422
+ let settled = null;
2423
+ while (Date.now() < deadline) {
2424
+ const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(task.ceTaskId)}`);
2425
+ if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
2426
+ settled = current;
2427
+ break;
2428
+ }
2429
+ await new Promise((wake) => setTimeout(wake, 3000));
2430
+ }
2431
+ if (!settled) {
2432
+ return report({ headSha, error: `SonarQube had not finished analysing after ${quality.timeoutSeconds}s.` });
2433
+ }
2434
+ if (settled.status !== 'SUCCESS') {
2435
+ return report({ headSha, error: settled.errorMessage ?? `SonarQube ${settled.status.toLowerCase()} the analysis.` });
2436
+ }
2437
+
2438
+ const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
2439
+ const metrics = 'new_bugs,new_vulnerabilities,new_security_hotspots,new_code_smells,new_coverage';
2440
+ const { component } = await ask(`/api/measures/component?component=${encodeURIComponent(key)}`
2441
+ + `&metricKeys=${metrics}${analysedAs === 'BRANCH' ? `&branch=${encodeURIComponent(branch)}` : ''}`);
2442
+ const measure = (metric) => {
2443
+ const found = (component?.measures ?? []).find((each) => each.metric === metric);
2444
+ // New-code values sit in `period.value` — `periods[0].value` on older servers.
2445
+ const value = found?.period?.value ?? found?.periods?.[0]?.value ?? found?.value;
2446
+ return value === undefined || value === null || value === '' ? null : Number(value);
2447
+ };
2448
+ const integer = (metric) => {
2449
+ const value = measure(metric);
2450
+ return value === null ? null : Math.round(value);
2451
+ };
2452
+
2453
+ await report({
2454
+ headSha,
2455
+ gate: projectStatus?.status ?? 'NONE',
2456
+ analysedAs,
2457
+ newBugs: integer('new_bugs'),
2458
+ newVulnerabilities: integer('new_vulnerabilities'),
2459
+ newSecurityHotspots: integer('new_security_hotspots'),
2460
+ newCodeSmells: integer('new_code_smells'),
2461
+ newCoverage: measure('new_coverage'),
2462
+ dashboardUrl: task.dashboardUrl ?? null,
2463
+ warning,
2464
+ });
2465
+ } catch (failure) {
2466
+ await report({ headSha, error: failure.message.split('\n')[0] });
2467
+ } finally {
2468
+ if (workDir) {
2469
+ await rm(workDir, { recursive: true, force: true }).catch(() => {});
2470
+ }
2471
+ }
2472
+ }
2473
+
2474
+ /** `key=value` lines — the shape of the scanner's `report-task.txt`. */
2475
+ function parseProperties(text) {
2476
+ const parsed = {};
2477
+ for (const line of text.split('\n')) {
2478
+ const at = line.indexOf('=');
2479
+ if (at > 0) {
2480
+ parsed[line.slice(0, at).trim()] = line.slice(at + 1).trim();
2481
+ }
2482
+ }
2483
+ return parsed;
2484
+ }
2485
+
2242
2486
  /**
2243
2487
  * Reads and reports the code map, unless it would say the same thing again.
2244
2488
  *
@@ -3091,7 +3335,11 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
3091
3335
  if (!branch) {
3092
3336
  result = 'A merge has to name a branch. Nothing was merged.';
3093
3337
  } else {
3094
- log(` MERGING ${branch} — asked for from the console`);
3338
+ // R306: whose idea it was. A rule's merge lands with nobody reading
3339
+ // the diff, and the log is where an operator sees that happen.
3340
+ log(` MERGING ${branch} — ${request.requestedByRule
3341
+ ? `by ${request.requestedByRule}, nobody is reading this diff`
3342
+ : 'asked for from the console'}`);
3095
3343
  // R261. A sprint's branch lands with a merge commit; every other branch
3096
3344
  // squashes as it always did. The claim says which.
3097
3345
  ({ ok, result, failure: failureKind } = await mergePullRequest(cwd, branch, {
@@ -3361,8 +3609,10 @@ function announceRules(rules) {
3361
3609
  if (!on.length) return;
3362
3610
  log(` this project's rules: ${on.join(', ')}`);
3363
3611
  if (rules.autoMerge) {
3364
- log(' !! auto_merge is ON: when this run finishes, its branch will be merged '
3365
- + 'with nobody reading it');
3612
+ // R306: not "when this run finishes" any more — the platform asks for the
3613
+ // merge when the branch reaches Done on the development board.
3614
+ log(' !! auto_merge is ON: its branch will be merged when it reaches Done on the '
3615
+ + 'board, with nobody reading it');
3366
3616
  }
3367
3617
  }
3368
3618
 
@@ -3514,7 +3764,26 @@ async function openPullRequestFor(cwd, { branch, base, title, body }) {
3514
3764
  * convention wants a merge commit for a release branch too, for the same
3515
3765
  * reason; release branches are not worked through the development board
3516
3766
  * today, and if that changes this is the line that has to change with it.)
3767
+ *
3768
+ * **It waits out GitHub's not-yet-computed mergeability — R306.** The host
3769
+ * computes whether a pull request can merge lazily, and `gh pr merge` seconds
3770
+ * after `gh pr create` — which is exactly when a rule's merge arrives on a
3771
+ * project nobody reads first — can fail for no reason a person can act on.
3772
+ * So before merging, `gh pr view --json mergeable` is asked, and while it says
3773
+ * `UNKNOWN` this waits, bounded (`MERGEABILITY_POLLS` × `mergeabilityWaitMs()`),
3774
+ * for the rule, the board's button and a person's press alike. Any other
3775
+ * answer — `MERGEABLE`, `CONFLICTING`, an error, something that is not JSON —
3776
+ * proceeds at once: the merge itself is what says whether it could, and
3777
+ * `classifyMergeFailure` is unchanged.
3517
3778
  */
3779
+ const MERGEABILITY_POLLS = 6;
3780
+
3781
+ /** Between two mergeability questions. Five seconds, unless the environment says otherwise — `gateTimeoutSeconds`'s shape. */
3782
+ function mergeabilityWaitMs() {
3783
+ const configured = Number(process.env.CAWDEV_MERGEABILITY_WAIT_MS);
3784
+ return Number.isFinite(configured) && configured > 0 ? configured : 5000;
3785
+ }
3786
+
3518
3787
  async function mergePullRequest(cwd, branch, { method = null } = {}) {
3519
3788
  const url = await findPullRequest(cwd, branch).catch(() => null);
3520
3789
  if (!url || url.includes('/compare/')) {
@@ -3524,6 +3793,7 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
3524
3793
  result: 'There is no pull request for this branch to merge. Nothing was merged.',
3525
3794
  };
3526
3795
  }
3796
+ await waitOutUnknownMergeability(cwd, url, branch);
3527
3797
  const mergeCommit = method === 'MERGE_COMMIT';
3528
3798
  const merged = await gh(cwd, ['pr', 'merge', url, mergeCommit ? '--merge' : '--squash', '--delete-branch']);
3529
3799
  if (merged.code === 0) {
@@ -3549,6 +3819,33 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
3549
3819
  };
3550
3820
  }
3551
3821
 
3822
+ /**
3823
+ * While the host has not yet said whether the pull request can merge, wait —
3824
+ * bounded. See `mergePullRequest`. One log line if it waited at all, so a
3825
+ * merge that took thirty seconds says why.
3826
+ */
3827
+ async function waitOutUnknownMergeability(cwd, url, branch) {
3828
+ let waited = 0;
3829
+ for (let poll = 0; poll < MERGEABILITY_POLLS; poll++) {
3830
+ const asked = await gh(cwd, ['pr', 'view', url, '--json', 'mergeable,mergeStateStatus']);
3831
+ if (asked.code !== 0) return;
3832
+ let view;
3833
+ try {
3834
+ view = JSON.parse(asked.out || '{}');
3835
+ } catch {
3836
+ // Not JSON — a gh that answered something else. Nothing to wait for.
3837
+ return;
3838
+ }
3839
+ if (view.mergeable !== 'UNKNOWN') {
3840
+ if (waited) log(` ${branch}: mergeability known after ${waited}s`);
3841
+ return;
3842
+ }
3843
+ await sleep(mergeabilityWaitMs());
3844
+ waited += mergeabilityWaitMs() / 1000;
3845
+ }
3846
+ log(` ${branch}: the host still has not computed mergeability after ${waited}s; merging anyway`);
3847
+ }
3848
+
3552
3849
  /**
3553
3850
  * Whether a tag exists ON THE REMOTE — R156.
3554
3851
  *
@@ -4272,8 +4569,11 @@ const PROFILE_TOOLS = {
4272
4569
  // R198: a plan run on an ISSUE (a confirm) is handed exactly this list. What
4273
4570
  // differs is one paragraph of prompt, not a tool — the "ended without a plan"
4274
4571
  // note on the issue is the platform's to write, for the reason above.
4572
+ // R302. Less the reads that need the work to exist — `quality_get` — the
4573
+ // same narrowing `toolsForStage` applies to a walk's PLAN stage, by the same
4574
+ // name: a plan run spawned as one process comes through here and not there.
4275
4575
  PLAN: (run) => [
4276
- ...READ_ONLY_CAWDEV,
4576
+ ...beforeTheWorkExists(READ_ONLY_CAWDEV),
4277
4577
  ...READ_FILES,
4278
4578
  ...GIT_READS,
4279
4579
  ...(run?.entryNumber ? [] : [CARD_WRITE]),
@@ -4520,7 +4820,10 @@ function stagePrompt(stage, carried) {
4520
4820
  + '`git diff` to find out what the work actually changed. A test you could not run is '
4521
4821
  + 'a line in the testbook, not a failure of this stage.'
4522
4822
  : 'Run what proves the work. Report what passed and what did not, with the output. A '
4523
- + 'failing test is a result, not a failure of this stage.',
4823
+ + 'failing test is a result, not a failure of this stage. The branch\'s SonarQube gate '
4824
+ + 'is one call away — `quality_get`, which reads this run\'s branch by default; a red '
4825
+ + 'gate with new vulnerabilities is a finding to report with the output, not a failure '
4826
+ + 'to hide.',
4524
4827
  MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
4525
4828
  + 'tried and rejected and why, and anything that turned out to matter and would not be '
4526
4829
  + 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
@@ -4947,7 +5250,9 @@ matters — and stop there. Do not decide the verdict. That is the person's.
4947
5250
  The findings go on the card and the person decides Done or Not done.
4948
5251
 
4949
5252
  **Read the code.** Start with \`code_map\` and \`file_deps\` — cawdev has already
4950
- mapped this repository. Then read the work with \`git_reads\` tools.
5253
+ mapped this repository. Then read the work with \`git_reads\` tools. \`quality_get\`
5254
+ gives the branch's SonarQube gate and the project's open findings; a new
5255
+ vulnerability belongs in your comment.
4951
5256
 
4952
5257
  **Check against the Build: and Done when:.** Use \`roadmap_get\` to read the
4953
5258
  card's own words, then check the code against them. Point at what is done and
@@ -5043,8 +5348,9 @@ function nudgeText(stage, missing) {
5043
5348
  const names = missing.map((each) => `\`${each}\``).join(', ');
5044
5349
  return `cawdev: this project requires ${names}, and this ${stage.stage} stage has not `
5045
5350
  + 'used it.\n\n'
5046
- + 'Use it now — `Skill` with `skill: "<name>"`, or `Agent` with `subagent_type: "<name>"` '
5047
- + '— and then say what it changed about your answer.\n\n'
5351
+ + 'Use it now — `Skill` with `skill: "<name>"`, `Agent` with `subagent_type: "<name>"`, or '
5352
+ + 'for an MCP server a call to one of its tools (`mcp__<server>__…`) — and then say what it '
5353
+ + 'changed about your answer.\n\n'
5048
5354
  + 'If it genuinely does not apply to this work, say so in one sentence and stop. '
5049
5355
  + '**You will not be asked again.**';
5050
5356
  }
@@ -5412,6 +5718,14 @@ async function startRun(config, offered, workspace) {
5412
5718
  if (run.baseBranch) {
5413
5719
  log(` cut from ${run.baseBranch} (the sprint's branch)`);
5414
5720
  }
5721
+ // R300. Which SonarQube project this is and where — two FACTS the
5722
+ // scanner needs on its command line, learnt with the claim because
5723
+ // reading a project is session-only. No token comes with them; the
5724
+ // scanner runs with this machine's own, from the variable the `quality`
5725
+ // block names. Stored on the run the way `baseBranch` is, so the walk
5726
+ // reads them off `run` after TEST rather than through another parameter.
5727
+ run.sonarProjectKey = claimed.sonarProjectKey ?? null;
5728
+ run.sonarUrl = claimed.sonarUrl ?? null;
5415
5729
  // R69. Whether this is the same conversation picked back up, and which one.
5416
5730
  // Null on an ordinary claim, which is nearly all of them.
5417
5731
  resume = claimed.resume ?? null;
@@ -5652,9 +5966,9 @@ async function skipRest(config, run, lifecycle, from, why) {
5652
5966
 
5653
5967
  async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace, resume,
5654
5968
  projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield) {
5655
- const spawn = (stage, carried) => spawnAgent(config, run, runToken, cwd, baseCommit, workspace,
5656
- resume, projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
5657
- stage, carried);
5969
+ const spawn = (stage, carried, usage) => spawnAgent(config, run, runToken, cwd, baseCommit,
5970
+ workspace, resume, projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle,
5971
+ shield, stage, carried, usage);
5658
5972
 
5659
5973
  // No lifecycle, or a resume: one process, exactly as before.
5660
5974
  //
@@ -5671,8 +5985,34 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
5671
5985
  const carried = {};
5672
5986
  let last = null;
5673
5987
 
5988
+ // R305. What the RUN has been credited with so far. Each stage is its own
5989
+ // process with its own cumulative counter, so the base the next child adds
5990
+ // to must move — read once from the claim, it stayed at zero and the platform
5991
+ // ignored every stage after the first as a reading that went backwards, so a
5992
+ // five-stage run's stored total was its largest stage rather than the sum.
5993
+ const spent = { tokensIn: Number(run.tokensIn) || 0, tokensOut: Number(run.tokensOut) || 0 };
5994
+ // And what each STAGE was already credited with, for the same reason. A run
5995
+ // walked a second time in a fresh session — handed off to another machine,
5996
+ // or one that died before the CLI said its session id — re-walks the same
5997
+ // `run_stage` rows, and the claim's total already holds the first walk; a
5998
+ // stage counted from zero here would be kept as max(walk) by the platform
5999
+ // while the run kept the sum. Read once; an older platform, or a stub,
6000
+ // answers without the fields and the base is zero, which is today's number.
6001
+ const stored = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/stages`)
6002
+ .catch(() => []);
6003
+ const creditedTo = (name) => {
6004
+ const row = Array.isArray(stored) ? stored.find((each) => each?.stage === name) : null;
6005
+ return { tokensIn: Number(row?.tokensIn) || 0, tokensOut: Number(row?.tokensOut) || 0 };
6006
+ };
6007
+
5674
6008
  for (let at = 0; at < lifecycle.length; at++) {
5675
6009
  const stage = lifecycle[at];
6010
+ // R305. What THIS stage's processes have used, across its attempts. Outside
6011
+ // the attempt loop on purpose: `run_stage` is one row per stage and keeps
6012
+ // the larger reading, so a second attempt after a refused gate must report
6013
+ // the first attempt's total plus its own, or the row would show
6014
+ // max(attempt) and the stages would no longer sum to the run.
6015
+ const stageSpent = creditedTo(stage.stage);
5676
6016
  // Two attempts at most, and the second only exists because a REFUSED gate
5677
6017
  // should cost one cheap stage rather than a whole run. A third would be a
5678
6018
  // loop nobody asked for.
@@ -5681,7 +6021,19 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
5681
6021
  log(` ${stage.stage}${stage.model ? ` on ${stage.model}` : ''}`
5682
6022
  + `${attempt ? ' (again, after the gate refused it)' : ''}`);
5683
6023
 
5684
- last = await spawn(stage, carried);
6024
+ // Both handed BY REFERENCE and moved below once the child is over. Safe
6025
+ // only because stages run one at a time: the child reads them at each
6026
+ // `result` event, and nothing else touches them while it lives.
6027
+ last = await spawn(stage, carried, { run: spent, stage: stageSpent });
6028
+
6029
+ // Credited before the exit is judged, so a stage that was killed or
6030
+ // skipped still counts what its `result` events said it used.
6031
+ if (last?.usage) {
6032
+ spent.tokensIn += last.usage.input;
6033
+ spent.tokensOut += last.usage.output;
6034
+ stageSpent.tokensIn += last.usage.input;
6035
+ stageSpent.tokensOut += last.usage.output;
6036
+ }
5685
6037
 
5686
6038
  // A clean exit, OR a turn that ended. The second half is the half that
5687
6039
  // matters now: `endSession` closes a stage's input when its turn is over
@@ -5798,6 +6150,15 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
5798
6150
 
5799
6151
  carried[stage.stage] = last.text;
5800
6152
 
6153
+ // R300. The scanner, after TEST settles and before the next stage — on
6154
+ // the tree the tests just ran against, by the daemon and not the session.
6155
+ // AWAITED: the workspace is released in `perform`'s finally, and a
6156
+ // scanner still reading files when the next run's reset runs is the
6157
+ // hazard; the block's `timeoutSeconds` bounds the wait. It never throws.
6158
+ if (stage.stage === 'TEST' && run.branch && writesAnythingProfile(run)) {
6159
+ await scanQuality(config, run, cwd, run.branch);
6160
+ }
6161
+
5801
6162
  // R108. The MEMORY stage's output IS the briefing, saved by the DAEMON
5802
6163
  // rather than by the session calling a tool. One less thing to trust, and
5803
6164
  // one less tool to hand a stage that should not be writing.
@@ -5908,7 +6269,11 @@ function gateTimeoutSeconds() {
5908
6269
  async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
5909
6270
  projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
5910
6271
  stage = null,
5911
- carried = null) {
6272
+ carried = null,
6273
+ // R305. `{ run, stage }`: what the run and this stage had already used
6274
+ // before this child, as the walk counted them. Null on an unstaged run or
6275
+ // a resume, where the claim's own figure is the base and there is no stage.
6276
+ usage = null) {
5912
6277
  // R51: what this machine will let a STORED rule cover. The project's rules
5913
6278
  // are filtered through it before they go anywhere near a spawn, so the
5914
6279
  // platform can narrow what runs here and never widen it.
@@ -5955,7 +6320,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
5955
6320
  const agyCmd = config.agentCommands.find((each) => each.endsWith('agy')) ?? 'agy';
5956
6321
  return spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
5957
6322
  skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
5958
- agyCmd, granted);
6323
+ agyCmd, granted, usage);
5959
6324
  }
5960
6325
 
5961
6326
  const ceiling = [
@@ -6073,10 +6438,31 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6073
6438
  // required HERE: requiring what the stage was deliberately not given is how
6074
6439
  // this would come to widen what a run may do, which is the one thing it must
6075
6440
  // not do. The mode is read after the narrowing, always.
6441
+ //
6442
+ // R303. An MCP server can be required too, and the entry comes from
6443
+ // `attached` — what this machine actually spawned — and NEVER from the
6444
+ // claim's list, for exactly the reason above: a server the machine refused
6445
+ // cannot be required of a session that has no such tool. And only when the
6446
+ // stage can reach it: a read-only stage is spawned with `listOnly`, which
6447
+ // strips the permission prompt, and `mcp__<server>__*` is never on its list —
6448
+ // so a PLAN stage told it MUST call `mcp__codegraph__…` would be nudged for
6449
+ // something it was deliberately not given. IMPLEMENT and TEST, and a run
6450
+ // with no lifecycle, keep the prompt and can answer the first call.
6451
+ //
6452
+ // Keyed `<kind>:<name>` on both sides — here and where `used` is filled —
6453
+ // so a server named like a skill cannot satisfy the other's requirement.
6454
+ const serversCanBeReached = !readOnlyStage;
6076
6455
  const mustUse = new Set([
6077
- ...stageExperts.filter((each) => each.mode === 'REQUIRED').map((each) => qualified(each.key)),
6078
- ...skills.filter((each) => each.mode === 'REQUIRED').map((each) => qualified(each.key)),
6456
+ ...stageExperts.filter((each) => each.mode === 'REQUIRED')
6457
+ .map((each) => `EXPERT:${qualified(each.key)}`),
6458
+ ...skills.filter((each) => each.mode === 'REQUIRED')
6459
+ .map((each) => `SKILL:${qualified(each.key)}`),
6460
+ ...(serversCanBeReached ? attached : [])
6461
+ .filter(({ skill }) => skill.mode === 'REQUIRED')
6462
+ .map(({ skill }) => `MCP_SERVER:${skill.serverName}`),
6079
6463
  ]);
6464
+ /** The transcript's name for a requirement — what the nudge and the log say. */
6465
+ const nameOf = (entry) => entry.slice(entry.indexOf(':') + 1);
6080
6466
 
6081
6467
  // R104/R105. The experts and skills the project turned on, as one plugin in
6082
6468
  // the daemon's own directory — see writeRunPlugin for why not the checkout.
@@ -6216,6 +6602,14 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6216
6602
  description: each.description ?? each.name ?? each.key,
6217
6603
  required: each.mode === 'REQUIRED',
6218
6604
  })),
6605
+ // R303. Only a REQUIRED server changes the prompt, and only where the
6606
+ // stage can reach it — the same gate `mustUse` applies, so the prompt and
6607
+ // the nudge never disagree about what a stage owes.
6608
+ mcpServers: attached.map(({ skill }) => ({
6609
+ name: skill.name ?? skill.key,
6610
+ prefix: skill.toolPrefix ?? `mcp__${skill.serverName}`,
6611
+ required: serversCanBeReached && skill.mode === 'REQUIRED',
6612
+ })),
6219
6613
  cannotDelegate,
6220
6614
  // R124. Null on everything but an implementation phase whose card has been
6221
6615
  // planned, which the PLATFORM decides — the runner does not work out which
@@ -6345,6 +6739,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6345
6739
  ?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried) : '')));
6346
6740
 
6347
6741
  let lastText = '';
6742
+ // R305. This child's own last reading, handed back to the walk so the next
6743
+ // stage's base can move.
6744
+ let childTotals = null;
6348
6745
  // Whether the CLI ever said its turn was over. This, and not the exit code,
6349
6746
  // is what says a STAGE did its work — see the failure branch in
6350
6747
  // walkLifecycle, and endSession above for why the two came apart.
@@ -6453,7 +6850,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6453
6850
  // R161. A required one says so where the person is looking. Only when the
6454
6851
  // stage can actually reach it: `cannot` and this are mutually exclusive
6455
6852
  // by construction, because `mustUse` is built from `stageExperts`.
6456
- const must = mustUse.has(qualified(agent.key))
6853
+ const must = mustUse.has(`EXPERT:${qualified(agent.key)}`)
6457
6854
  ? ' This project REQUIRES it: if this stage finishes without using it, the session '
6458
6855
  + 'will be told.'
6459
6856
  : '';
@@ -6464,7 +6861,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6464
6861
  }
6465
6862
 
6466
6863
  for (const skill of skills) {
6467
- const must = mustUse.has(qualified(skill.key))
6864
+ const must = mustUse.has(`SKILL:${qualified(skill.key)}`)
6468
6865
  ? ' This project REQUIRES it: if this stage finishes without using it, the session '
6469
6866
  + 'will be told.'
6470
6867
  : '';
@@ -6496,8 +6893,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6496
6893
 
6497
6894
  // R76. What this run had already used before this child started. Zero on an
6498
6895
  // ordinary run; on a resume it is what the earlier session spent, which the
6499
- // claim carries on the run itself.
6500
- const usageBase = {
6896
+ // claim carries on the run itself. R305: on a staged run the walk keeps the
6897
+ // count, because the claim's figure never moves and every stage after the
6898
+ // first would otherwise report from zero again.
6899
+ const usageBase = usage?.run ?? {
6501
6900
  tokensIn: Number(run.tokensIn) || 0,
6502
6901
  tokensOut: Number(run.tokensOut) || 0,
6503
6902
  };
@@ -6578,7 +6977,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6578
6977
  // lifecycle never reaches here — `endSession` is deliberately not
6579
6978
  // called for one — so REQUIRED is prompt wording and a console row
6580
6979
  // on ASK, ROADMAP and AUDIT, and nothing more.
6581
- const missing = [...mustUse].filter((name) => !used.has(name));
6980
+ const missing = [...mustUse].filter((entry) => !used.has(entry)).map(nameOf);
6582
6981
  // Whether the input stays open for one more turn. NOT an early
6583
6982
  // return out of this handler, which the obvious shape would be:
6584
6983
  // this is the stdout reader's `while`, and returning from it would
@@ -6635,10 +7034,19 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6635
7034
  if (event?.type === 'result') {
6636
7035
  const totals = totalsOf(event);
6637
7036
  if (totals) {
7037
+ childTotals = totals;
6638
7038
  reportUsage(config, run, {
6639
7039
  tokensIn: usageBase.tokensIn + totals.input,
6640
7040
  tokensOut: usageBase.tokensOut + totals.output,
6641
7041
  });
7042
+ // R305. The same reading, filed on the stage: its own base plus
7043
+ // this child, absolute for the stage across its attempts.
7044
+ if (stage && usage?.stage) {
7045
+ reportStageUsage(config, run, stage.stage, {
7046
+ tokensIn: usage.stage.tokensIn + totals.input,
7047
+ tokensOut: usage.stage.tokensOut + totals.output,
7048
+ });
7049
+ }
6642
7050
  }
6643
7051
  }
6644
7052
  // R113. Anything at all counts as alive.
@@ -6682,7 +7090,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6682
7090
  // already being walked. See ../lib/tool-line.mjs.
6683
7091
  const reached = capabilityIn(recorded.body);
6684
7092
  if (reached) {
6685
- used.add(reached.key);
7093
+ used.add(`${reached.kind}:${reached.key}`);
6686
7094
  }
6687
7095
  }
6688
7096
  if (stage && recorded.kind === 'TOOL') {
@@ -6815,7 +7223,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6815
7223
  // R112. A stage hands its result back so the loop can store the plan,
6816
7224
  // gate on it, and carry it into the next stage. A run with no lifecycle
6817
7225
  // resolves with nothing, exactly as it always did.
6818
- resolvePromise(stage ? { code, signal, text: lastText, turnEnded } : undefined);
7226
+ resolvePromise(stage
7227
+ ? { code, signal, text: lastText, turnEnded, usage: childTotals }
7228
+ : undefined);
6819
7229
  });
6820
7230
  });
6821
7231
  }
@@ -7111,7 +7521,7 @@ function inlinedSkillsFor(skills) {
7111
7521
  */
7112
7522
  async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
7113
7523
  skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
7114
- agyCmd, granted) {
7524
+ agyCmd, granted, usage = null) {
7115
7525
  if (!granted.everything) {
7116
7526
  const summary = 'This machine has not allowed everything, and Antigravity has no narrower '
7117
7527
  + 'permission mode — no per-tool allow-list exists on it, and even reading needs it. Turn '
@@ -7231,6 +7641,13 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
7231
7641
 
7232
7642
  return new Promise((resolvePromise) => {
7233
7643
  let result = null;
7644
+ // R76 / R305: what the run had already used before this process, and what
7645
+ // this process itself reported — see the same pair on the Claude Code path.
7646
+ const usageBase = usage?.run ?? {
7647
+ tokensIn: Number(run.tokensIn) || 0,
7648
+ tokensOut: Number(run.tokensOut) || 0,
7649
+ };
7650
+ let childTotals = null;
7234
7651
  let err = '';
7235
7652
  let buffer = '';
7236
7653
  const child = spawn(agyCmd, args, {
@@ -7312,10 +7729,23 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
7312
7729
  } else if (event.event === 'result') {
7313
7730
  result = event.result;
7314
7731
  if (result?.usage) {
7732
+ // R305. On the same base as the Claude Code path: what the run had
7733
+ // already used before this process. Reported with no base until
7734
+ // now, so a resume or a second stage here counted from zero.
7735
+ childTotals = {
7736
+ input: Number(result.usage.input_tokens) || 0,
7737
+ output: Number(result.usage.output_tokens) || 0,
7738
+ };
7315
7739
  reportUsage(config, run, {
7316
- tokensIn: Number(result.usage.input_tokens) || 0,
7317
- tokensOut: Number(result.usage.output_tokens) || 0,
7740
+ tokensIn: usageBase.tokensIn + childTotals.input,
7741
+ tokensOut: usageBase.tokensOut + childTotals.output,
7318
7742
  });
7743
+ if (stage && usage?.stage) {
7744
+ reportStageUsage(config, run, stage.stage, {
7745
+ tokensIn: usage.stage.tokensIn + childTotals.input,
7746
+ tokensOut: usage.stage.tokensOut + childTotals.output,
7747
+ });
7748
+ }
7319
7749
  }
7320
7750
  }
7321
7751
  }
@@ -7365,7 +7795,9 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
7365
7795
  if (!stage) {
7366
7796
  await settleActions(config, run, cwd);
7367
7797
  }
7368
- resolvePromise(stage ? { code, signal: null, text, turnEnded } : undefined);
7798
+ resolvePromise(stage
7799
+ ? { code, signal: null, text, turnEnded, usage: childTotals }
7800
+ : undefined);
7369
7801
  });
7370
7802
  });
7371
7803
  }
@@ -7399,6 +7831,19 @@ function reportUsage(config, run, totals) {
7399
7831
  }).catch((failure) => log(` could not record what the session used: ${failure.message}`));
7400
7832
  }
7401
7833
 
7834
+ /**
7835
+ * The same reading, for one STAGE — R305. Absolute for the stage across its
7836
+ * attempts, fire and forget for the reason `reportUsage` is, and the platform
7837
+ * keeps the largest reading here too.
7838
+ */
7839
+ function reportStageUsage(config, run, stageName, totals) {
7840
+ api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/stages/${stageName}/usage`, {
7841
+ method: 'POST',
7842
+ body: totals,
7843
+ }).catch((failure) =>
7844
+ log(` could not record what the ${stageName} stage used: ${failure.message}`));
7845
+ }
7846
+
7402
7847
  async function finish(config, run, state, summary) {
7403
7848
  await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
7404
7849
  method: 'POST',
@@ -7617,6 +8062,11 @@ async function main() {
7617
8062
  // The paths still go to the log — the banner says how many, this says
7618
8063
  // which, and "which" is what you need when one of them is wrong.
7619
8064
  log(`serving ${slug}: ${project.workspaces.join(', ')}`);
8065
+ // R300. Said before the first run, so the log says the feature is on
8066
+ // — and which variable the token is read from — before a scan is missed.
8067
+ if (project.quality) {
8068
+ log(`quality: ${slug} scans with ${project.quality.command} after TEST, token from $${project.quality.tokenEnv}`);
8069
+ }
7620
8070
  }
7621
8071
  await sayWhichServedProjectsTheTokenCannotReach(config);
7622
8072