cawdev-cli 1.0.2-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,11 +33,13 @@ 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';
40
41
  import { cliVersion, isBelow } from '../lib/version.mjs';
42
+ import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
41
43
 
42
44
  // --- configuration -----------------------------------------------------------
43
45
 
@@ -163,6 +165,13 @@ const DEFAULTS = {
163
165
  // would be a question with one sensible answer.
164
166
  'mcp__cawdev__code_map',
165
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',
166
175
  'mcp__cawdev__roadmap_statuses',
167
176
  'mcp__cawdev__roadmap_list',
168
177
  'mcp__cawdev__roadmap_get',
@@ -409,6 +418,14 @@ async function readConfig() {
409
418
  * daemon's cwd is not the terminal's: `cawdev` starts it detached.
410
419
  */
411
420
  configPath: fromFile ? resolve(path) : null,
421
+ /**
422
+ * The file as it was when this daemon booted — R294. What every later
423
+ * reading is compared against, so the daemon can say which of its
424
+ * settings the file has moved on from and only a restart would apply.
425
+ * The RAW file, not `config`: the question is what the operator changed,
426
+ * and defaults filled in here are not something they wrote.
427
+ */
428
+ bootedFile: fromFile ? file : null,
412
429
  };
413
430
 
414
431
  if (!config.token) {
@@ -454,6 +471,12 @@ async function readConfig() {
454
471
  * "dycrypt": "/Users/you/code/dycrypt"
455
472
  * "dycrypt": { "path": "…", "allowedTools": ["Bash(mvn *)", "mcp__roadmap"] }
456
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.
457
480
  *
458
481
  * R47: a bare path means ONE workspace, which is what every config meant
459
482
  * before workspaces existed. Concurrency for a project is
@@ -499,11 +522,51 @@ function normaliseProjects(projects) {
499
522
  // falls through to the machine's answer; `false` here refuses it for one
500
523
  // project on a machine that otherwise allows it.
501
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),
502
528
  };
503
529
  }
504
530
  return normalised;
505
531
  }
506
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
+
507
570
 
508
571
  /**
509
572
  * What THIS daemon knows about a skill, beyond what the platform sent — R76.
@@ -1296,10 +1359,17 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
1296
1359
  notes.push(await prepareSkillIndex(config, skill, local, run, cwd, baseCommit));
1297
1360
  }
1298
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
+ : '';
1299
1369
  notes.push(`${skill.name ?? skill.key} is available to this session as `
1300
1370
  + `${skill.toolPrefix ?? `mcp__${skill.serverName}`}. The first call still asks a person — `
1301
1371
  + `allow ${skill.toolPrefix ?? `mcp__${skill.serverName}`} for the session to cover the `
1302
- + 'rest.');
1372
+ + `rest.${must}`);
1303
1373
  }
1304
1374
  return { attached, notes };
1305
1375
  }
@@ -2230,6 +2300,189 @@ async function surveyGit(config) {
2230
2300
  }
2231
2301
  }
2232
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
+
2233
2486
  /**
2234
2487
  * Reads and reports the code map, unless it would say the same thing again.
2235
2488
  *
@@ -3082,7 +3335,11 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
3082
3335
  if (!branch) {
3083
3336
  result = 'A merge has to name a branch. Nothing was merged.';
3084
3337
  } else {
3085
- 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'}`);
3086
3343
  // R261. A sprint's branch lands with a merge commit; every other branch
3087
3344
  // squashes as it always did. The claim says which.
3088
3345
  ({ ok, result, failure: failureKind } = await mergePullRequest(cwd, branch, {
@@ -3352,8 +3609,10 @@ function announceRules(rules) {
3352
3609
  if (!on.length) return;
3353
3610
  log(` this project's rules: ${on.join(', ')}`);
3354
3611
  if (rules.autoMerge) {
3355
- log(' !! auto_merge is ON: when this run finishes, its branch will be merged '
3356
- + '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');
3357
3616
  }
3358
3617
  }
3359
3618
 
@@ -3505,7 +3764,26 @@ async function openPullRequestFor(cwd, { branch, base, title, body }) {
3505
3764
  * convention wants a merge commit for a release branch too, for the same
3506
3765
  * reason; release branches are not worked through the development board
3507
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.
3508
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
+
3509
3787
  async function mergePullRequest(cwd, branch, { method = null } = {}) {
3510
3788
  const url = await findPullRequest(cwd, branch).catch(() => null);
3511
3789
  if (!url || url.includes('/compare/')) {
@@ -3515,6 +3793,7 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
3515
3793
  result: 'There is no pull request for this branch to merge. Nothing was merged.',
3516
3794
  };
3517
3795
  }
3796
+ await waitOutUnknownMergeability(cwd, url, branch);
3518
3797
  const mergeCommit = method === 'MERGE_COMMIT';
3519
3798
  const merged = await gh(cwd, ['pr', 'merge', url, mergeCommit ? '--merge' : '--squash', '--delete-branch']);
3520
3799
  if (merged.code === 0) {
@@ -3540,6 +3819,33 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
3540
3819
  };
3541
3820
  }
3542
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
+
3543
3849
  /**
3544
3850
  * Whether a tag exists ON THE REMOTE — R156.
3545
3851
  *
@@ -4263,8 +4569,11 @@ const PROFILE_TOOLS = {
4263
4569
  // R198: a plan run on an ISSUE (a confirm) is handed exactly this list. What
4264
4570
  // differs is one paragraph of prompt, not a tool — the "ended without a plan"
4265
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.
4266
4575
  PLAN: (run) => [
4267
- ...READ_ONLY_CAWDEV,
4576
+ ...beforeTheWorkExists(READ_ONLY_CAWDEV),
4268
4577
  ...READ_FILES,
4269
4578
  ...GIT_READS,
4270
4579
  ...(run?.entryNumber ? [] : [CARD_WRITE]),
@@ -4511,7 +4820,10 @@ function stagePrompt(stage, carried) {
4511
4820
  + '`git diff` to find out what the work actually changed. A test you could not run is '
4512
4821
  + 'a line in the testbook, not a failure of this stage.'
4513
4822
  : 'Run what proves the work. Report what passed and what did not, with the output. A '
4514
- + '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.',
4515
4827
  MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
4516
4828
  + 'tried and rejected and why, and anything that turned out to matter and would not be '
4517
4829
  + 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
@@ -4938,7 +5250,9 @@ matters — and stop there. Do not decide the verdict. That is the person's.
4938
5250
  The findings go on the card and the person decides Done or Not done.
4939
5251
 
4940
5252
  **Read the code.** Start with \`code_map\` and \`file_deps\` — cawdev has already
4941
- 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.
4942
5256
 
4943
5257
  **Check against the Build: and Done when:.** Use \`roadmap_get\` to read the
4944
5258
  card's own words, then check the code against them. Point at what is done and
@@ -5034,8 +5348,9 @@ function nudgeText(stage, missing) {
5034
5348
  const names = missing.map((each) => `\`${each}\``).join(', ');
5035
5349
  return `cawdev: this project requires ${names}, and this ${stage.stage} stage has not `
5036
5350
  + 'used it.\n\n'
5037
- + 'Use it now — `Skill` with `skill: "<name>"`, or `Agent` with `subagent_type: "<name>"` '
5038
- + '— 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'
5039
5354
  + 'If it genuinely does not apply to this work, say so in one sentence and stop. '
5040
5355
  + '**You will not be asked again.**';
5041
5356
  }
@@ -5403,6 +5718,14 @@ async function startRun(config, offered, workspace) {
5403
5718
  if (run.baseBranch) {
5404
5719
  log(` cut from ${run.baseBranch} (the sprint's branch)`);
5405
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;
5406
5729
  // R69. Whether this is the same conversation picked back up, and which one.
5407
5730
  // Null on an ordinary claim, which is nearly all of them.
5408
5731
  resume = claimed.resume ?? null;
@@ -5643,9 +5966,9 @@ async function skipRest(config, run, lifecycle, from, why) {
5643
5966
 
5644
5967
  async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace, resume,
5645
5968
  projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield) {
5646
- const spawn = (stage, carried) => spawnAgent(config, run, runToken, cwd, baseCommit, workspace,
5647
- resume, projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
5648
- 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);
5649
5972
 
5650
5973
  // No lifecycle, or a resume: one process, exactly as before.
5651
5974
  //
@@ -5662,8 +5985,34 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
5662
5985
  const carried = {};
5663
5986
  let last = null;
5664
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
+
5665
6008
  for (let at = 0; at < lifecycle.length; at++) {
5666
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);
5667
6016
  // Two attempts at most, and the second only exists because a REFUSED gate
5668
6017
  // should cost one cheap stage rather than a whole run. A third would be a
5669
6018
  // loop nobody asked for.
@@ -5672,7 +6021,19 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
5672
6021
  log(` ${stage.stage}${stage.model ? ` on ${stage.model}` : ''}`
5673
6022
  + `${attempt ? ' (again, after the gate refused it)' : ''}`);
5674
6023
 
5675
- 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
+ }
5676
6037
 
5677
6038
  // A clean exit, OR a turn that ended. The second half is the half that
5678
6039
  // matters now: `endSession` closes a stage's input when its turn is over
@@ -5789,6 +6150,15 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
5789
6150
 
5790
6151
  carried[stage.stage] = last.text;
5791
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
+
5792
6162
  // R108. The MEMORY stage's output IS the briefing, saved by the DAEMON
5793
6163
  // rather than by the session calling a tool. One less thing to trust, and
5794
6164
  // one less tool to hand a stage that should not be writing.
@@ -5899,7 +6269,11 @@ function gateTimeoutSeconds() {
5899
6269
  async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
5900
6270
  projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
5901
6271
  stage = null,
5902
- 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) {
5903
6277
  // R51: what this machine will let a STORED rule cover. The project's rules
5904
6278
  // are filtered through it before they go anywhere near a spawn, so the
5905
6279
  // platform can narrow what runs here and never widen it.
@@ -5946,7 +6320,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
5946
6320
  const agyCmd = config.agentCommands.find((each) => each.endsWith('agy')) ?? 'agy';
5947
6321
  return spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
5948
6322
  skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
5949
- agyCmd, granted);
6323
+ agyCmd, granted, usage);
5950
6324
  }
5951
6325
 
5952
6326
  const ceiling = [
@@ -6064,10 +6438,31 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6064
6438
  // required HERE: requiring what the stage was deliberately not given is how
6065
6439
  // this would come to widen what a run may do, which is the one thing it must
6066
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;
6067
6455
  const mustUse = new Set([
6068
- ...stageExperts.filter((each) => each.mode === 'REQUIRED').map((each) => qualified(each.key)),
6069
- ...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}`),
6070
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);
6071
6466
 
6072
6467
  // R104/R105. The experts and skills the project turned on, as one plugin in
6073
6468
  // the daemon's own directory — see writeRunPlugin for why not the checkout.
@@ -6207,6 +6602,14 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6207
6602
  description: each.description ?? each.name ?? each.key,
6208
6603
  required: each.mode === 'REQUIRED',
6209
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
+ })),
6210
6613
  cannotDelegate,
6211
6614
  // R124. Null on everything but an implementation phase whose card has been
6212
6615
  // planned, which the PLATFORM decides — the runner does not work out which
@@ -6336,6 +6739,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6336
6739
  ?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried) : '')));
6337
6740
 
6338
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;
6339
6745
  // Whether the CLI ever said its turn was over. This, and not the exit code,
6340
6746
  // is what says a STAGE did its work — see the failure branch in
6341
6747
  // walkLifecycle, and endSession above for why the two came apart.
@@ -6444,7 +6850,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6444
6850
  // R161. A required one says so where the person is looking. Only when the
6445
6851
  // stage can actually reach it: `cannot` and this are mutually exclusive
6446
6852
  // by construction, because `mustUse` is built from `stageExperts`.
6447
- const must = mustUse.has(qualified(agent.key))
6853
+ const must = mustUse.has(`EXPERT:${qualified(agent.key)}`)
6448
6854
  ? ' This project REQUIRES it: if this stage finishes without using it, the session '
6449
6855
  + 'will be told.'
6450
6856
  : '';
@@ -6455,7 +6861,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6455
6861
  }
6456
6862
 
6457
6863
  for (const skill of skills) {
6458
- const must = mustUse.has(qualified(skill.key))
6864
+ const must = mustUse.has(`SKILL:${qualified(skill.key)}`)
6459
6865
  ? ' This project REQUIRES it: if this stage finishes without using it, the session '
6460
6866
  + 'will be told.'
6461
6867
  : '';
@@ -6487,8 +6893,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6487
6893
 
6488
6894
  // R76. What this run had already used before this child started. Zero on an
6489
6895
  // ordinary run; on a resume it is what the earlier session spent, which the
6490
- // claim carries on the run itself.
6491
- 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 ?? {
6492
6900
  tokensIn: Number(run.tokensIn) || 0,
6493
6901
  tokensOut: Number(run.tokensOut) || 0,
6494
6902
  };
@@ -6569,7 +6977,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6569
6977
  // lifecycle never reaches here — `endSession` is deliberately not
6570
6978
  // called for one — so REQUIRED is prompt wording and a console row
6571
6979
  // on ASK, ROADMAP and AUDIT, and nothing more.
6572
- const missing = [...mustUse].filter((name) => !used.has(name));
6980
+ const missing = [...mustUse].filter((entry) => !used.has(entry)).map(nameOf);
6573
6981
  // Whether the input stays open for one more turn. NOT an early
6574
6982
  // return out of this handler, which the obvious shape would be:
6575
6983
  // this is the stdout reader's `while`, and returning from it would
@@ -6626,10 +7034,19 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6626
7034
  if (event?.type === 'result') {
6627
7035
  const totals = totalsOf(event);
6628
7036
  if (totals) {
7037
+ childTotals = totals;
6629
7038
  reportUsage(config, run, {
6630
7039
  tokensIn: usageBase.tokensIn + totals.input,
6631
7040
  tokensOut: usageBase.tokensOut + totals.output,
6632
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
+ }
6633
7050
  }
6634
7051
  }
6635
7052
  // R113. Anything at all counts as alive.
@@ -6673,7 +7090,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6673
7090
  // already being walked. See ../lib/tool-line.mjs.
6674
7091
  const reached = capabilityIn(recorded.body);
6675
7092
  if (reached) {
6676
- used.add(reached.key);
7093
+ used.add(`${reached.kind}:${reached.key}`);
6677
7094
  }
6678
7095
  }
6679
7096
  if (stage && recorded.kind === 'TOOL') {
@@ -6806,7 +7223,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
6806
7223
  // R112. A stage hands its result back so the loop can store the plan,
6807
7224
  // gate on it, and carry it into the next stage. A run with no lifecycle
6808
7225
  // resolves with nothing, exactly as it always did.
6809
- resolvePromise(stage ? { code, signal, text: lastText, turnEnded } : undefined);
7226
+ resolvePromise(stage
7227
+ ? { code, signal, text: lastText, turnEnded, usage: childTotals }
7228
+ : undefined);
6810
7229
  });
6811
7230
  });
6812
7231
  }
@@ -7102,7 +7521,7 @@ function inlinedSkillsFor(skills) {
7102
7521
  */
7103
7522
  async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
7104
7523
  skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
7105
- agyCmd, granted) {
7524
+ agyCmd, granted, usage = null) {
7106
7525
  if (!granted.everything) {
7107
7526
  const summary = 'This machine has not allowed everything, and Antigravity has no narrower '
7108
7527
  + 'permission mode — no per-tool allow-list exists on it, and even reading needs it. Turn '
@@ -7222,6 +7641,13 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
7222
7641
 
7223
7642
  return new Promise((resolvePromise) => {
7224
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;
7225
7651
  let err = '';
7226
7652
  let buffer = '';
7227
7653
  const child = spawn(agyCmd, args, {
@@ -7303,10 +7729,23 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
7303
7729
  } else if (event.event === 'result') {
7304
7730
  result = event.result;
7305
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
+ };
7306
7739
  reportUsage(config, run, {
7307
- tokensIn: Number(result.usage.input_tokens) || 0,
7308
- tokensOut: Number(result.usage.output_tokens) || 0,
7740
+ tokensIn: usageBase.tokensIn + childTotals.input,
7741
+ tokensOut: usageBase.tokensOut + childTotals.output,
7309
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
+ }
7310
7749
  }
7311
7750
  }
7312
7751
  }
@@ -7356,7 +7795,9 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
7356
7795
  if (!stage) {
7357
7796
  await settleActions(config, run, cwd);
7358
7797
  }
7359
- resolvePromise(stage ? { code, signal: null, text, turnEnded } : undefined);
7798
+ resolvePromise(stage
7799
+ ? { code, signal: null, text, turnEnded, usage: childTotals }
7800
+ : undefined);
7360
7801
  });
7361
7802
  });
7362
7803
  }
@@ -7390,6 +7831,19 @@ function reportUsage(config, run, totals) {
7390
7831
  }).catch((failure) => log(` could not record what the session used: ${failure.message}`));
7391
7832
  }
7392
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
+
7393
7847
  async function finish(config, run, state, summary) {
7394
7848
  await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
7395
7849
  method: 'POST',
@@ -7608,6 +8062,11 @@ async function main() {
7608
8062
  // The paths still go to the log — the banner says how many, this says
7609
8063
  // which, and "which" is what you need when one of them is wrong.
7610
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
+ }
7611
8070
  }
7612
8071
  await sayWhichServedProjectsTheTokenCannotReach(config);
7613
8072
 
@@ -7659,6 +8118,11 @@ async function main() {
7659
8118
  // inventing a file.
7660
8119
  agents: [...config.agentCommands],
7661
8120
  configPath: config.configPath,
8121
+ // R294: the keys the file has moved on from since boot that only a restart
8122
+ // would apply — nothing, at boot, by definition — and when the file was
8123
+ // last read, so a door that just signalled can wait for a fresh answer.
8124
+ restartNeeded: [],
8125
+ configReadAt: new Date().toISOString(),
7662
8126
  // R81. `cawdev` starts a daemon for you when it finds none, and
7663
8127
  // quitting the UI leaves it running — it is driving sessions. A
7664
8128
  // background process you did not know you started is the cost of that
@@ -7668,45 +8132,88 @@ async function main() {
7668
8132
  };
7669
8133
 
7670
8134
  // R283. `cawdev config agent enable/disable` and `cawdev config rules
7671
- // on/off` write the file and, if a daemon is running on it, send this — NOT
8135
+ // on/off` write the file and, if a daemon is running on it, send SIGHUP — NOT
7672
8136
  // a control-socket command. `control.mjs` is deliberately read-only ("no
7673
8137
  // command can" change anything, so a person acting through it never gets
7674
8138
  // more than their own HTTPS session already grants); SIGHUP is a unix
7675
8139
  // convention for "re-read your config", asked of a process this operator
7676
8140
  // already owns, so it does not open that door. Only the settings that are
7677
- // genuinely safe to hot-swap: `agentCommands`, `grantable` and
7678
- // `acceptsRulesFromConsole` are read fresh from `config` on every use
7679
- // (spawn, or the next heartbeat), never captured once at boot — so mutating
8141
+ // genuinely safe to hot-swap — `HOT_SWAPPABLE`, in lib/runner-config.mjs,
8142
+ // the ONE spelling of that list — are applied: `agentCommands`, `grantable`
8143
+ // and `acceptsRulesFromConsole` are read fresh from `config` on every use
8144
+ // (spawn, or the next heartbeat), never captured once at boot, so mutating
7680
8145
  // them here is enough, with no further plumbing.
7681
- process.on('SIGHUP', async () => {
7682
- const path = configPathFromArgv();
8146
+ //
8147
+ // R294: and the daemon SAYS what it could not apply. Everything else in the
8148
+ // file is read once at boot, and until this a project added to the file was
8149
+ // logged as "nothing changed" — the handler diffed only the three keys it
8150
+ // could swap. `rereadConfig` compares the whole file against the one this
8151
+ // process BOOTED with (not the last one read, so an edit reverted by hand
8152
+ // clears the notice by itself) and publishes the keys only a restart would
8153
+ // apply as `restartNeeded` on the hello, with `configReadAt` so a door that
8154
+ // just signalled can tell a fresh answer from the old one. A notice, never a
8155
+ // restart — R113's rule: the daemon says so, and a person decides. Two
8156
+ // triggers, one function: the signal, and the heartbeat noticing the file's
8157
+ // mtime moved, which is what covers a hand edit with no door at all.
8158
+ let configSeenAt = null;
8159
+ const rereadConfig = async (why) => {
8160
+ const path = config.configPath ?? configPathFromArgv();
7683
8161
  try {
8162
+ // The mtime first, so a re-read on a signal is not followed by a second
8163
+ // one on the next beat for the same write.
8164
+ configSeenAt = (await stat(path).catch(() => null))?.mtimeMs ?? configSeenAt;
7684
8165
  const file = JSON.parse(await readFile(path, 'utf8'));
7685
- const before = {
7686
- acceptsRulesFromConsole: config.acceptsRulesFromConsole,
7687
- grantable: config.grantable,
7688
- agentCommands: config.agentCommands,
8166
+ // What the three keys RESOLVE to, defaults filled in as `readConfig`
8167
+ // fills them, so a key the file omits is not reported as a change.
8168
+ const resolved = {
8169
+ acceptsRulesFromConsole: file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole,
8170
+ grantable: file.grantable ?? DEFAULTS.grantable,
8171
+ agentCommands: file.agentCommands
8172
+ ?? (file.agentCommand ? [file.agentCommand] : null)
8173
+ ?? DEFAULTS.agentCommands,
7689
8174
  };
7690
- config.acceptsRulesFromConsole =
7691
- file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole;
7692
- config.grantable = file.grantable ?? DEFAULTS.grantable;
7693
- config.agentCommands = file.agentCommands
7694
- ?? (file.agentCommand ? [file.agentCommand] : null)
7695
- ?? DEFAULTS.agentCommands;
8175
+ const applied = reloadableFor({
8176
+ agentCommands: config.agentCommands,
8177
+ grantable: config.grantable,
8178
+ acceptsRulesFromConsole: config.acceptsRulesFromConsole,
8179
+ }, resolved);
8180
+ Object.assign(config, resolved);
7696
8181
  // R288: the hello describes the current state, not the booted one — an
7697
8182
  // attach opened after a toggle would otherwise offer to turn on what is
7698
8183
  // already on. The same object `serveControl` sends, mutated in place.
7699
8184
  advertised.acceptsConsoleRules = config.acceptsRulesFromConsole === true;
7700
8185
  advertised.agents = [...config.agentCommands];
7701
- const changed = Object.keys(before)
7702
- .filter((key) => JSON.stringify(before[key]) !== JSON.stringify(config[key]));
7703
- log(changed.length
7704
- ? `re-read ${path} on SIGHUP — changed: ${changed.join(', ')}`
7705
- : `re-read ${path} on SIGHUP — nothing changed`);
8186
+ advertised.restartNeeded = config.bootedFile ? restartNeededFor(config.bootedFile, file) : [];
8187
+ advertised.configReadAt = new Date().toISOString();
8188
+ const said = [
8189
+ applied.length ? `applied: ${applied.join(', ')}` : null,
8190
+ advertised.restartNeeded.length ? `needs restart: ${advertised.restartNeeded.join(', ')}` : null,
8191
+ ].filter(Boolean);
8192
+ log(`re-read ${path} on ${why} — ${said.length ? said.join('; ') : 'nothing changed'}`);
7706
8193
  } catch (failure) {
7707
- log(`SIGHUP re-read of ${path} failed: ${failure.message}`);
8194
+ log(`${why} re-read of ${path} failed: ${failure.message}`);
7708
8195
  }
7709
- });
8196
+ // A terminal already attached got its hello before this; the daemon tells
8197
+ // it rather than waiting for the next attach. An older client ignores an
8198
+ // event type it does not know.
8199
+ control?.publish({ type: 'runner', runner: advertised });
8200
+ };
8201
+ process.on('SIGHUP', () => rereadConfig('SIGHUP'));
8202
+
8203
+ // The heartbeat's half of R294: the file's mtime, read once per beat, and a
8204
+ // re-read when it moved. `stat` and nothing more when it did not, which is
8205
+ // what makes this affordable every thirty seconds. Skipped for a daemon
8206
+ // booted from the environment alone — there is no file to have changed.
8207
+ configSeenAt = config.configPath ? (await stat(config.configPath).catch(() => null))?.mtimeMs ?? null : null;
8208
+ const noticeConfigEdits = async () => {
8209
+ if (!config.configPath) return;
8210
+ const seen = (await stat(config.configPath).catch(() => null))?.mtimeMs ?? null;
8211
+ if (seen === null || seen === configSeenAt) return;
8212
+ // `rereadConfig` records the mtime before it reads: a file caught
8213
+ // mid-write fails to parse, and the write that finishes it moves the
8214
+ // mtime again.
8215
+ await rereadConfig('edit');
8216
+ };
7710
8217
 
7711
8218
  // R52. Nothing here is load-bearing for running an agent, so a daemon that
7712
8219
  // cannot open a socket says so and carries on: trading the ability to run
@@ -7814,7 +8321,7 @@ async function main() {
7814
8321
  : [];
7815
8322
  }
7816
8323
  }).catch((failure) => log(`heartbeat failed: ${failure.message}`));
7817
- await Promise.all([workspacesReported, heartbeatAnswered]);
8324
+ await Promise.all([workspacesReported, heartbeatAnswered, noticeConfigEdits()]);
7818
8325
  };
7819
8326
 
7820
8327
  await beat();