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.
@@ -33,7 +33,7 @@ const MAX_CONFIG_BYTES = 32 * 1024;
33
33
  * two halves meet in this function and nowhere else.
34
34
  */
35
35
  export function harnessPrompt({
36
- instincts, briefing, plan, lifecycle, repoConfig, experts, skills, cannotDelegate,
36
+ instincts, briefing, plan, lifecycle, repoConfig, experts, skills, mcpServers, cannotDelegate,
37
37
  }) {
38
38
  const parts = [];
39
39
 
@@ -86,6 +86,17 @@ export function harnessPrompt({
86
86
  + 'work its description covers.\n'
87
87
  + lines(maySkills));
88
88
  }
89
+
90
+ // R303. An MCP server the project REQUIRES gets the same heading. Nothing
91
+ // at all for an as-needed one — a server reaches a session through the CLI's
92
+ // own tool listing, and this function said nothing about servers before the
93
+ // entry, so a project that requires none gets byte-for-byte what it got.
94
+ const mustServers = requiredOf(mcpServers);
95
+ if (mustServers.length) {
96
+ reach.push('**MCP servers you MUST use** — this project requires each of these. Call at '
97
+ + 'least one tool under the prefix, before this stage is finished.\n'
98
+ + mustServers.map((each) => `- \`${each.prefix}__…\` — ${each.name}`).join('\n'));
99
+ }
89
100
  if (reach.length) {
90
101
  parts.push(`## What this session may reach\n\n${reach.join('\n\n')}`);
91
102
  }
@@ -0,0 +1,81 @@
1
+ // R294 — which parts of a runner's config a running daemon can take without a
2
+ // restart, and which it cannot.
3
+ //
4
+ // The daemon reads its config ONCE at boot and threads most of it by value —
5
+ // `projects` and their checkouts, `url`, `name`, the token, the MCP servers.
6
+ // Three keys are the exception: they are read fresh off the live `config`
7
+ // object on every use (a spawn, or the next heartbeat), so a SIGHUP that
8
+ // re-reads the file is enough to apply them. That list was spelled in three
9
+ // places — configure.mjs's header, attach.mjs's `reloadDaemon`, runner.mjs's
10
+ // handler — and nothing pinned that the three agreed. It is spelled HERE and
11
+ // nowhere else now, and the one question everything asks of it is
12
+ // `restartNeededFor`: given what the daemon booted with and what the file says
13
+ // now, which keys would only a restart change?
14
+ //
15
+ // Pure, because the daemon answers it and both config doors repeat the answer,
16
+ // and a function all three share is the only way they cannot disagree.
17
+ //
18
+ // Zero dependencies, like everything in tools/.
19
+
20
+ /**
21
+ * The keys runner.mjs's SIGHUP handler applies without a restart.
22
+ *
23
+ * Only settings that are genuinely safe to hot-swap belong here: each one
24
+ * must be read off `config` at the moment it is used, never captured at boot.
25
+ * Adding a key that IS captured would make the daemon claim it applied
26
+ * something it did not, which is worse than saying restart.
27
+ */
28
+ export const HOT_SWAPPABLE = Object.freeze(['agentCommands', 'grantable', 'acceptsRulesFromConsole']);
29
+
30
+ /**
31
+ * The legacy spelling of one key, read as the current one — so a config that
32
+ * says `agentCommand: "claude"` is not reported as differing from one that
33
+ * says `agentCommands: ["claude"]`. runner.mjs's `readConfig` makes the same
34
+ * move; this is it applied to a raw file rather than a booted config.
35
+ */
36
+ export function normaliseConfigFile(file) {
37
+ const { agentCommand, ...rest } = file ?? {};
38
+ if (agentCommand !== undefined && rest.agentCommands === undefined) {
39
+ rest.agentCommands = [agentCommand];
40
+ }
41
+ return rest;
42
+ }
43
+
44
+ /**
45
+ * The keys a restart would change: everything that differs between what the
46
+ * daemon booted with and what the file says now, minus {@link HOT_SWAPPABLE}.
47
+ *
48
+ * Compared against the BOOTED file and not the last one read, so an edit
49
+ * somebody reverts by hand clears the notice by itself. Values are compared
50
+ * with a key-sorted stringify, because `{workspaces: [...], name}` and
51
+ * `{name, workspaces: [...]}` are one config. Names only, never values: the
52
+ * answer is shown in a footer and logged, and the token is one of the keys.
53
+ *
54
+ * @returns {string[]} sorted, empty when nothing needs a restart
55
+ */
56
+ export function restartNeededFor(booted, file) {
57
+ const before = normaliseConfigFile(booted);
58
+ const after = normaliseConfigFile(file);
59
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
60
+ return [...keys]
61
+ .filter((key) => !HOT_SWAPPABLE.includes(key))
62
+ .filter((key) => canonical(before[key]) !== canonical(after[key]))
63
+ .sort();
64
+ }
65
+
66
+ /** Applied, or would be on a SIGHUP: the hot-swappable keys that differ. */
67
+ export function reloadableFor(booted, file) {
68
+ const before = normaliseConfigFile(booted);
69
+ const after = normaliseConfigFile(file);
70
+ return HOT_SWAPPABLE.filter((key) => canonical(before[key]) !== canonical(after[key]));
71
+ }
72
+
73
+ /** JSON with object keys in one order, so two spellings of one value agree. */
74
+ function canonical(value) {
75
+ if (value === undefined) return 'undefined';
76
+ return JSON.stringify(value, (_, inner) => (
77
+ inner && typeof inner === 'object' && !Array.isArray(inner)
78
+ ? Object.fromEntries(Object.keys(inner).sort().map((key) => [key, inner[key]]))
79
+ : inner
80
+ ));
81
+ }
@@ -70,6 +70,9 @@ export const CAWDEV_READS = new Set([
70
70
  'roadmap_where',
71
71
  'code_map',
72
72
  'file_deps',
73
+ // R302. What SonarQube made of the project and of a branch: a read of rows
74
+ // other things wrote, and nothing on its endpoint a token could write.
75
+ 'quality_get',
73
76
  'roadmap_statuses',
74
77
  'roadmap_list',
75
78
  'roadmap_get',
@@ -109,6 +112,30 @@ const REPORT = `${CAWDEV}report`;
109
112
  */
110
113
  export const CARD_WRITE = `${CAWDEV}roadmap_create`;
111
114
 
115
+ /**
116
+ * Reads that only mean something once the work exists — R302.
117
+ *
118
+ * <p>The first list here that narrows by NEED rather than by what a tool can
119
+ * change, and the reason it is a named list rather than a line in each arm: it
120
+ * is read in two places — `toolsForStage` for a walk's PLAN and IMPLEMENT, and
121
+ * `runner.mjs`'s `PROFILE_TOOLS.PLAN` for a plan run spawned as one process —
122
+ * and a name in one place and a literal in the other is how the two drift.
123
+ * Keep it to names a stage before the code exists has no use for: a branch's
124
+ * SonarQube gate is a fact about a tree that PLAN has not written and IMPLEMENT
125
+ * is still writing. The smallest tool list is the rule, and this is the list.
126
+ *
127
+ * <p>Two readers, not three: an Antigravity spawn (`spawnAntigravity`) builds
128
+ * no tool list at all — it cannot narrow, by its own comment — so a PLAN run
129
+ * on that agent holds this read with everything else the server exposes. A
130
+ * pre-existing gap, named here so nobody counts this list as closing it.
131
+ */
132
+ export const AFTER_THE_WORK_EXISTS = [`${CAWDEV}quality_get`];
133
+
134
+ /** The profile's list less what `AFTER_THE_WORK_EXISTS` names — the narrowing above, as a step. */
135
+ export function beforeTheWorkExists(tools) {
136
+ return tools.filter((tool) => !AFTER_THE_WORK_EXISTS.includes(tool));
137
+ }
138
+
112
139
  /** The tool a session delegates with, and what an older CLI called it. */
113
140
  const DELEGATES = /^(Agent|Task)\b/;
114
141
 
@@ -284,7 +311,9 @@ export function toolsForStage(stage, profileTools, testMode, options = {}) {
284
311
  // second place deciding what a profile may do. That subsetting is what
285
312
  // makes the card write safe to allow here — the profile has to have
286
313
  // handed it over first, and only a cardless plan run's does.
287
- return readOnly(writesItsOwnCard);
314
+ // R302. And less the reads that need the work to exist — the narrowing
315
+ // by NEED rather than by capability, and the only one; see the list.
316
+ return beforeTheWorkExists(readOnly(writesItsOwnCard));
288
317
  case 'VERIFY':
289
318
  case 'MEMORY':
290
319
  return readOnly(false);
@@ -299,7 +328,9 @@ export function toolsForStage(stage, profileTools, testMode, options = {}) {
299
328
  }
300
329
  return allowed;
301
330
  case 'IMPLEMENT':
302
- return allowed;
331
+ // R302. The profile's list less the reads that need the work to exist:
332
+ // the branch this stage is writing has no gate until TEST scans it.
333
+ return beforeTheWorkExists(allowed);
303
334
  default:
304
335
  // A stage this version does not know is given the profile's own tools —
305
336
  // the same as no lifecycle at all. Refusing everything would turn a
package/lib/tool-line.mjs CHANGED
@@ -72,21 +72,32 @@ export function describeInput(input) {
72
72
  /**
73
73
  * Which capability a TOOL line named, if it named one — R161.
74
74
  *
75
- * <p>The reader for the two shapes {@link describeCall} writes: `Skill(<key>)`
76
- * and `Agent(<key>)`. The daemon asks it "has this stage used the thing the
77
- * project requires", which is a question about a string, so it belongs beside
78
- * the code that wrote the string and in `lib/` with it — answering it should
79
- * not need a daemon, a platform and a spawned agent.
75
+ * <p>The reader for the three shapes a transcript holds: `Skill(<key>)` and
76
+ * `Agent(<key>)`, which {@link describeCall} writes, and `mcp__<server>__<tool>`,
77
+ * which is the CLI's own name for an MCP server's tool and reaches the
78
+ * transcript through the ordinary `${name} ${input}` path — R303 brought this
79
+ * reader level with `CapabilityUse.java` and `capability-tag.ts`, which both
80
+ * read that shape already. The daemon asks it "has this stage used the thing
81
+ * the project requires", which is a question about a string, so it belongs
82
+ * beside the code that wrote the string and in `lib/` with it — answering it
83
+ * should not need a daemon, a platform and a spawned agent.
80
84
  *
81
85
  * <p>Third reader of the contract this file's header describes. `Task(` is not
82
86
  * matched because `describeCall` normalises it away before anything is written:
83
- * what reaches a transcript is always `Agent(`.
87
+ * what reaches a transcript is always `Agent(`. cawdev's own server is not a
88
+ * capability — every session has it and nobody chose it — so `mcp__cawdev__…`
89
+ * is null here as it is in the platform's reader.
84
90
  *
85
91
  * @returns `{kind, key}`, or null for anything that is not a capability call.
86
92
  */
87
93
  export function capabilityIn(line) {
88
94
  if (typeof line !== 'string') return null;
89
95
  const found = /^(Skill|Agent)\(([^)]+)\)/.exec(line);
90
- if (!found) return null;
91
- return { kind: found[1] === 'Skill' ? 'SKILL' : 'EXPERT', key: found[2] };
96
+ if (found) return { kind: found[1] === 'Skill' ? 'SKILL' : 'EXPERT', key: found[2] };
97
+ const server = /^mcp__([A-Za-z0-9_-]+)__/.exec(line);
98
+ if (server && server[1] !== OUR_SERVER) return { kind: 'MCP_SERVER', key: server[1] };
99
+ return null;
92
100
  }
101
+
102
+ /** `CapabilityUse.OURS`: the run's own MCP server, machinery rather than a capability. */
103
+ const OUR_SERVER = 'cawdev';
package/mcp/README.md CHANGED
@@ -172,6 +172,7 @@ what you are allowed to do.
172
172
  | `changelog_get` | One entry. |
173
173
  | `changelog_add` | Add an entry; no version means `Unreleased`. |
174
174
  | `changelog_update` | Edit one, including moving it to a release when it ships. |
175
+ | `quality_get` | The SonarQube reading — the default branch's gate, a named branch's gate and new problems (inside a run, this run's branch by default), and the open findings with links. Read-only: nothing here refreshes, scans or files (R302). |
175
176
 
176
177
  ### Inside a run
177
178
 
package/mcp/server.mjs CHANGED
@@ -536,6 +536,52 @@ const TOOLS = [
536
536
  },
537
537
  },
538
538
 
539
+ {
540
+ name: 'quality_get',
541
+ description:
542
+ "The SonarQube reading of this project — the default branch's gate and its counts — and, " +
543
+ "if you name a branch, that branch's gate and how many NEW bugs, vulnerabilities, hotspots " +
544
+ "and smells it adds, with the SonarQube link. Inside a run, the branch defaults to this " +
545
+ "run's. A branch nobody has scanned yet says so. A red gate with new vulnerabilities is a " +
546
+ 'finding to report, not a failure to hide. Read-only: nothing here refreshes, scans or files.',
547
+ inputSchema: {
548
+ type: 'object',
549
+ properties: {
550
+ ...PROJECT_ARGUMENT,
551
+ branch: {
552
+ type: 'string',
553
+ description:
554
+ "A branch name. Omit inside a run to read this run's branch; omit outside one for " +
555
+ 'the default branch only.',
556
+ },
557
+ },
558
+ },
559
+ handler: async (config, args) => {
560
+ const slug = await resolveProject(config, args.project);
561
+ // R302. The run's own branch when none was named — the TEST stage that
562
+ // calls this is asking about the branch it just tested, and should not
563
+ // have to know its name. Its own try: a plain `cawd_` token has no run,
564
+ // and `requireRun` refuses it with a sentence about orchestration tools
565
+ // that would be wrong here — outside a run, the default branch alone is
566
+ // the ordinary answer, not a refusal.
567
+ const branch = args.branch
568
+ ?? await requireRun(config)
569
+ .then(({ runId, project }) => api(config, `/api/projects/${project}/runs/${runId}`))
570
+ .then((run) => run?.branch ?? null)
571
+ .catch(() => null);
572
+ const reading = await api(
573
+ config,
574
+ `/api/projects/${slug}/quality/reading${branch ? `?branch=${encodeURIComponent(branch)}` : ''}`,
575
+ );
576
+ // A 204 has no body, and `api` hands that back as null.
577
+ if (!reading) {
578
+ return `${slug} is not on SonarQube — no server, token or project key set on it. `
579
+ + 'Work as you would have anyway.';
580
+ }
581
+ return formatQualityReading(reading, branch);
582
+ },
583
+ },
584
+
539
585
  {
540
586
  name: 'roadmap_comment',
541
587
  description:
@@ -1907,6 +1953,107 @@ function formatFileDeps(map, path) {
1907
1953
  return lines.join('\n');
1908
1954
  }
1909
1955
 
1956
+ /** How many finding lines a reading prints before saying "and N more" — R302. */
1957
+ const FINDING_LINES = 50;
1958
+
1959
+ /**
1960
+ * The reading as a session should read it — R302: one line for the default
1961
+ * branch, one for the branch asked about, then the open findings.
1962
+ *
1963
+ * <p>The branch line comes SECOND and not first, though it is the one the
1964
+ * caller asked about: the default branch's gate is what a person sees on the
1965
+ * project's door, and "your branch is red" means something different under a
1966
+ * green main than under a red one. The findings are the default branch's,
1967
+ * and the last line says so, because a session that read "2 new
1968
+ * vulnerabilities" and then a list of twelve will otherwise look for the two
1969
+ * among the twelve.
1970
+ */
1971
+ function formatQualityReading(reading, branch) {
1972
+ const lines = [];
1973
+ const project = reading.project;
1974
+ if (!project) {
1975
+ lines.push('Default branch: connected to SonarQube, but nothing has read it yet — the timer '
1976
+ + 'has not fired since the connection was set.');
1977
+ } else {
1978
+ // A failed read is the headline, not a clause after the gate: a session
1979
+ // skimming for the verdict stops at the first token, and `gate OK · the
1980
+ // last read failed` reads as OK. The numbers are a dated echo and say so.
1981
+ const parts = project.error
1982
+ ? [`the last read failed: ${project.error}`, `before that: gate ${project.gate ?? 'NONE'}`]
1983
+ : [`gate ${project.gate ?? 'NONE'}`];
1984
+ parts.push(
1985
+ `${count(project.bugs)} bugs`,
1986
+ `${count(project.vulnerabilities)} vulnerabilities`,
1987
+ `${count(project.securityHotspots)} hotspots`,
1988
+ `${count(project.codeSmells)} smells`,
1989
+ `coverage ${percent(project.coverage)}`,
1990
+ );
1991
+ if (project.analysedAt) parts.push(`analysed ${project.analysedAt}`);
1992
+ if (project.dashboardUrl) parts.push(project.dashboardUrl);
1993
+ lines.push(`Default branch: ${parts.join(' · ')}`);
1994
+ }
1995
+
1996
+ const asked = reading.branch;
1997
+ if (asked) {
1998
+ if (!asked.scanned) {
1999
+ lines.push(`Branch ${asked.branch}: no machine has scanned it yet. A coding run's TEST stage `
2000
+ + 'is when a scan happens; until then the default branch above is all there is.');
2001
+ } else if (asked.reading?.error) {
2002
+ lines.push(`Branch ${asked.branch}: the scan could not run — ${asked.reading.error}`);
2003
+ } else {
2004
+ const row = asked.reading;
2005
+ const parts = [
2006
+ `gate ${row.gate ?? 'NONE'}`,
2007
+ `new: ${count(row.newBugs)} bugs`,
2008
+ `${count(row.newVulnerabilities)} vulnerabilities`,
2009
+ `${count(row.newSecurityHotspots)} hotspots`,
2010
+ `${count(row.newCodeSmells)} smells`,
2011
+ `new coverage ${percent(row.newCoverage)}`,
2012
+ ];
2013
+ if (row.analysedAs === 'MAIN') parts.push('analysed as the main branch, not under its own name');
2014
+ if (row.headSha) parts.push(`at ${row.headSha.slice(0, 7)}`);
2015
+ if (row.dashboardUrl) parts.push(row.dashboardUrl);
2016
+ lines.push(`Branch ${asked.branch}: ${parts.join(' · ')}`);
2017
+ if (row.warning) lines.push(` ${row.warning}`);
2018
+ }
2019
+ } else if (branch === null) {
2020
+ lines.push('No branch was read: outside a run, name one to see its gate and new problems.');
2021
+ }
2022
+
2023
+ const findings = reading.findings ?? [];
2024
+ lines.push('');
2025
+ if (!findings.length) {
2026
+ lines.push('Open findings: none.');
2027
+ } else {
2028
+ lines.push(`Open findings (${findings.length}, worst first):`);
2029
+ for (const finding of findings.slice(0, FINDING_LINES)) {
2030
+ const where = finding.path
2031
+ ? ` ${finding.path}${finding.line != null ? `:${finding.line}` : ''}`
2032
+ : '';
2033
+ const filed = finding.became?.ref ? ` (filed as ${finding.became.ref})` : '';
2034
+ lines.push(` ${finding.type} ${finding.severity} ${finding.rule}${where} — ${finding.message}`
2035
+ + ` — ${finding.url}${filed}`);
2036
+ }
2037
+ if (findings.length > FINDING_LINES) {
2038
+ lines.push(` … and ${findings.length - FINDING_LINES} more in SonarQube.`);
2039
+ }
2040
+ }
2041
+ lines.push('');
2042
+ lines.push("These findings are the default branch's; SonarQube's per-branch issue list is not "
2043
+ + "read here, so a branch's new problems are counts and a link.");
2044
+ return lines.join('\n');
2045
+ }
2046
+
2047
+ /** A count as a reading prints it: the number, or a dash for one SonarQube did not report. */
2048
+ function count(value) {
2049
+ return value == null ? '–' : String(value);
2050
+ }
2051
+
2052
+ /** A percentage as a reading prints it: `71.2%`, or a dash for none reported. */
2053
+ function percent(value) {
2054
+ return value == null ? '–' : `${value}%`;
2055
+ }
2056
+
1910
2057
  /**
1911
2058
  * What to call a card — R127. The server says, in `ref`; this is the fallback
1912
2059
  * for anything that predates it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.0.2-beta",
3
+ "version": "1.0.4-beta",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,8 +16,5 @@
16
16
  "engines": {
17
17
  "node": ">=20"
18
18
  },
19
- "license": "UNLICENSED",
20
- "dependencies": {
21
- "cawdev-cli": "^1.0.0-beta"
22
- }
19
+ "license": "UNLICENSED"
23
20
  }
package/runner/README.md CHANGED
@@ -138,15 +138,31 @@ travels beside the secret since this card, and a machine set up before it
138
138
  falls back to a lookup by label the first time this runs, after which it
139
139
  remembers.
140
140
 
141
- Adding a project or a workspace needs a restart to take effect, same as any
142
- other change to `"projects"` — a live daemon says so. Enabling or disabling an
143
- agent and turning `rules` on or off do **not**: `cawdev config` — and the `c`
144
- screen, through the pid the hello names — sends the running daemon a `SIGHUP`,
145
- which re-reads just those three settings from the file and updates what the
146
- hello advertises, so the next `c` shows the new state. That is a unix convention asked of a process this operator already
147
- owns, not a command sent over the control socket in `attach.mjs` — that socket
148
- is deliberately read-only (see its own header), and a config change is not an
149
- exception carved into it.
141
+ **What takes effect when** (R294). Three settings a running daemon takes
142
+ without a restart — `agentCommands`, `grantable`, `acceptsRulesFromConsole`,
143
+ the `HOT_SWAPPABLE` list in `tools/lib/runner-config.mjs`, spelled once —
144
+ because the daemon reads them fresh on every use. Everything else in the file
145
+ (`projects` and their workspaces, `url`, `name`, the token, `browser`, the
146
+ tuning) is read once at boot. Every change made by either door ends the same
147
+ way: the running daemon is sent a `SIGHUP` (through the pid the hello names —
148
+ a unix convention asked of a process this operator already owns, not a command
149
+ over the control socket in `attach.mjs`, which is deliberately read-only), it
150
+ re-reads the file, applies what it can, and **says what it could not**: the
151
+ hello carries `restartNeeded`, the keys the file has moved on from since boot
152
+ that only a restart applies, and `configReadAt`. The door waits for that
153
+ re-read and repeats the daemon's own verdict — "applied, no restart needed",
154
+ or "needs a restart to pick up `projects` — q, then cawdev". A daemon too old
155
+ to answer gets the door's own reading of the same function.
156
+
157
+ While `restartNeeded` is non-empty the attached terminal pins a row in its
158
+ footer — `! config changed: projects — restart to apply (q, then cawdev)` — and
159
+ the `c` screen names what is in the file and not yet served ("serves cawdev ·
160
+ dycrypt after a restart", "cawdev: 1, 2 after a restart"). It is compared
161
+ against the file the daemon **booted** with, not the last one it read, so an
162
+ edit reverted by hand clears it. And it is off the file's own mtime, checked
163
+ on every heartbeat: a change made in an editor with nobody signalling anything
164
+ reaches the footer within thirty seconds. A notice, never a restart — the
165
+ daemon says, a person decides.
150
166
 
151
167
  ### What it shows you
152
168
 
@@ -646,9 +662,93 @@ files at all. R47 removes the need for this by giving each run a workspace.
646
662
  8. **Reports the lifecycle back** and, if the session dies without saying
647
663
  anything, ends the run rather than leaving it `RUNNING` forever.
648
664
 
665
+ **What each stage used, and what the run used** (R76, R305). Every `result`
666
+ event the CLI prints carries the process's cumulative token counts, and the
667
+ daemon files two absolute readings off each: the *stage's* (`POST
668
+ …/stages/{stage}/usage` — the stage's own processes, across its attempts,
669
+ so a stage re-run after a refused gate keeps adding to the first attempt's
670
+ total) and the *run's* (`POST …/usage`). Each stage is its own process
671
+ counting from zero, so the daemon keeps the run's running total itself and
672
+ adds each child's figure to it: **the run's total is the sum of its stages'
673
+ processes**, and a resumed run starts from whatever the claim already
674
+ carried. The platform keeps the larger reading on both, so a lost report is
675
+ healed by the next one. Tokens, never money, and cache reads are not
676
+ counted — `tools/lib/usage.mjs` says why. A daemon older than R305 reports
677
+ the run and never a stage, and every stage after the first on a staged run
678
+ was ignored as a reading that went backwards; restart the daemon to get
679
+ the per-stage figures and the summed total.
680
+
649
681
  Cancelling a run reaches the daemon on its next poll and takes down the child's
650
682
  whole process group — an agent that started a build should not leave it running.
651
683
 
684
+ ### Scanning every branch (R300)
685
+
686
+ A project with a `quality` block has its branch scanned with SonarQube
687
+ **after a coding run's TEST stage settles and before the next stage begins**,
688
+ on the tree the tests just ran against:
689
+
690
+ ```json
691
+ "cawdev": {
692
+ "workspaces": ["/Users/you/code/cawdev"],
693
+ "quality": { "command": "sonar-scanner", "tokenEnv": "SONAR_TOKEN" }
694
+ }
695
+ ```
696
+
697
+ Both keys have those defaults, so `"quality": {}` is enough. `cawdev config
698
+ quality on <slug>` writes the block (and `off` removes it), as does the
699
+ `c` screen inside the attached terminal. The block is read once at boot
700
+ like the rest of `projects`, so **restart the daemon** after adding or
701
+ changing it — the daemon says so itself when it is signalled.
702
+
703
+ **It is the daemon that scans, not the session.** The scanner is a subprocess
704
+ this process starts and nothing the spawned agent can call — a session that
705
+ reported its own gate could lie about it. It runs in the run's checkout with
706
+ the project's key and the branch's name from the claim
707
+ (`-Dsonar.projectKey=`, `-Dsonar.branch.name=`), the repository's own
708
+ `sonar-project.properties` for everything else, and a working directory in the
709
+ daemon's temp dir rather than `.scannerwork/` inside the checkout, which the
710
+ next reset would otherwise clean and the working-copy watcher report. Then it
711
+ waits for the server to finish the analysis, reads the branch's quality gate
712
+ and its new-code measures, and tells the platform:
713
+ `POST /api/runners/{id}/quality/{slug}`, on the machine's `runner:operate`
714
+ token, the same door as the git survey. The run page and the development
715
+ board show the result — *Quality gate: failed · 3 new problems · Open in
716
+ SonarQube* — found by the branch's name.
717
+
718
+ **The token stays on this machine.** `tokenEnv` names the environment variable
719
+ the daemon reads the scanner token from — export it where the daemon runs;
720
+ it is never written to the config file, never sent to the platform, and goes
721
+ to the scanner in its environment, never on its command line. It is **not**
722
+ the token the project's owner entered under the project's Settings →
723
+ SonarQube: that one is the platform's reader credential and never leaves the
724
+ platform. Two credentials, two custodians. The scanner has to read
725
+ `SONAR_TOKEN` from its environment, which `sonar-scanner` does from 4.7 on;
726
+ older scanners want `-Dsonar.login`, which would put the token in `ps`, so
727
+ this daemon does not offer it. The block may also name `url`, for a machine
728
+ that reaches the server by another name than the platform does (a LAN
729
+ hostname), and `timeoutSeconds` (600) bounding the scan and the wait.
730
+
731
+ **Community Edition.** A server that refuses `sonar.branch.name` — every
732
+ Community Edition — is *skipped by default*: the reading carries an error
733
+ saying so, and nothing is analysed. `"whenBranchesRefused": "main"` in the
734
+ block opts into the fallback, which analyses the branch as the project's
735
+ **main** analysis and marks the reading `MAIN` with a warning. Understand what
736
+ that trades: the project's own dashboard, and the platform's project reading
737
+ with it, then describe whichever branch scanned last. Nothing opts in for you.
738
+
739
+ **A failed scan never fails a run.** A machine with no block scans nothing and
740
+ says nothing. A machine with a block and a project with no key logs `not
741
+ scanned` and reports nothing. Everything after that reports *something*: no
742
+ scanner on this machine, no token exported, no server, a server that refused —
743
+ each is an `error` reading with the sentence on it, so the run page says the
744
+ scan could not run rather than showing a branch nobody looked at, and the
745
+ run goes on to its next stage exactly as it would have. Only a staged run
746
+ with a TEST stage is scanned: a lifecycle without one, an unstaged run, a run
747
+ whose gate refused TEST twice or one that reported done inside TEST has no
748
+ settled TEST stage to scan after. The log line is
749
+ `quality: <branch> gate OK · 0 new problems`, or `quality: could not scan
750
+ <branch>: <why>`.
751
+
652
752
  ## What a project's rules can ask of it (R40)
653
753
 
654
754
  A project may decide that a finished run **pushes its branch**, **opens a pull
@@ -658,11 +758,18 @@ with the claim, and the daemon logs them when it takes a run, so you find out
658
758
  what is going to happen before it happens rather than afterwards. `auto_merge`
659
759
  gets a shouted line of its own.
660
760
 
661
- The daemon does not decide any of this. The platform queues `PUSH`, `OPEN_PR`
662
- and `MERGE` on the same `run_action` queue the console's *Commit* button uses,
761
+ The daemon does not decide any of this. The platform queues `PUSH` and
762
+ `OPEN_PR` on the same `run_action` queue the console's *Commit* button uses,
663
763
  and the daemon performs them in `settleActions` — one pass right after the
664
764
  session ends, because the working-copy watcher stopped with the child and the
665
- rules queue their work at exactly that moment.
765
+ rules queue their work at exactly that moment. `auto_merge` is asked for
766
+ later, when the branch reaches Done on the development board (R306): the
767
+ platform queues a `MERGE` **workspace request** attributed to the rule —
768
+ `requestedByRule: "auto_merge"` on the claim — to the machine that wrote the
769
+ branch, and the daemon logs `MERGING <branch> — by auto_merge, nobody is
770
+ reading this diff` when it takes it. It is the same `gh pr merge` the board's
771
+ button runs. (A platform older than R306 still queues a `MERGE` run action,
772
+ and the daemon still performs it.)
666
773
 
667
774
  **cawdev still holds no git-host credential.** This machine does, which is why
668
775
  the work happens here and why R19 and R25's division is unchanged. All that is
@@ -677,6 +784,11 @@ What it actually runs:
677
784
  conflict — the rule wanted one to exist and one does.
678
785
  - `gh pr merge <url> --squash --delete-branch`, and only if a real pull request
679
786
  is found. A `/compare/` URL is not one, and it refuses rather than guessing.
787
+ Before merging it asks `gh pr view --json mergeable,mergeStateStatus` and,
788
+ while the host answers `UNKNOWN` — mergeability is computed lazily, and a
789
+ merge seconds after `gh pr create` used to fail for no reason a person could
790
+ act on — waits up to six times five seconds (R306). Any other answer merges
791
+ at once; the merge is what says whether it could.
680
792
 
681
793
  Every one of them reports a result or a reason, and a machine with no `gh`, no
682
794
  remote or no credentials simply says so. **A rule is a request, not a grant** —