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.
@@ -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
  }
@@ -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.3-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
@@ -662,9 +662,93 @@ files at all. R47 removes the need for this by giving each run a workspace.
662
662
  8. **Reports the lifecycle back** and, if the session dies without saying
663
663
  anything, ends the run rather than leaving it `RUNNING` forever.
664
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
+
665
681
  Cancelling a run reaches the daemon on its next poll and takes down the child's
666
682
  whole process group — an agent that started a build should not leave it running.
667
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
+
668
752
  ## What a project's rules can ask of it (R40)
669
753
 
670
754
  A project may decide that a finished run **pushes its branch**, **opens a pull
@@ -674,11 +758,18 @@ with the claim, and the daemon logs them when it takes a run, so you find out
674
758
  what is going to happen before it happens rather than afterwards. `auto_merge`
675
759
  gets a shouted line of its own.
676
760
 
677
- The daemon does not decide any of this. The platform queues `PUSH`, `OPEN_PR`
678
- 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,
679
763
  and the daemon performs them in `settleActions` — one pass right after the
680
764
  session ends, because the working-copy watcher stopped with the child and the
681
- 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.)
682
773
 
683
774
  **cawdev still holds no git-host credential.** This machine does, which is why
684
775
  the work happens here and why R19 and R25's division is unchanged. All that is
@@ -693,6 +784,11 @@ What it actually runs:
693
784
  conflict — the rule wanted one to exist and one does.
694
785
  - `gh pr merge <url> --squash --delete-branch`, and only if a real pull request
695
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.
696
792
 
697
793
  Every one of them reports a result or a reason, and a machine with no `gh`, no
698
794
  remote or no credentials simply says so. **A rule is a request, not a grant** —
package/runner/attach.mjs CHANGED
@@ -51,7 +51,7 @@ import { basename, dirname } from 'node:path';
51
51
  import { listSockets, socketPathFor } from './control.mjs';
52
52
  import {
53
53
  RESTART_FROM_ATTACH, addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
54
- setAcceptsRulesFromConsole, setAgentEnabled, slugsOf,
54
+ setAcceptsRulesFromConsole, setAgentEnabled, setQuality, slugsOf,
55
55
  } from './configure.mjs';
56
56
  import {
57
57
  TYPE_A_PATH, absolute, completePath, describe, inspectPath, listDirectories,
@@ -705,6 +705,11 @@ const KNOWN_AGENTS = ['claude', 'agy'];
705
705
  * "enable" would ask the person to remember what the file says. The hint is
706
706
  * where the current value lives; the label is the change.
707
707
  */
708
+ /** A project entry's `quality` block, whatever shape the entry is — R300. */
709
+ function qualityOf(entry) {
710
+ return entry && typeof entry === 'object' && entry.quality ? entry.quality : null;
711
+ }
712
+
708
713
  export function configMenu(file, runner = {}) {
709
714
  const slugs = slugsOf(file);
710
715
  const agents = agentsOf(file);
@@ -737,6 +742,11 @@ export function configMenu(file, runner = {}) {
737
742
  rulesOn
738
743
  ? { id: 'rules', label: 'Stop accepting permission rules from the console', hint: 'on now' }
739
744
  : { id: 'rules', label: 'Accept permission rules from the console', hint: 'off now' },
745
+ // R300: one row per served project, saying whether its branches are
746
+ // scanned here now — the state the row would change, R288's rule.
747
+ ...slugs.map((slug) => (qualityOf(file.projects[slug])
748
+ ? { id: `quality:${slug}`, label: `Stop scanning ${slug} with SonarQube`, hint: `${qualityOf(file.projects[slug]).command ?? 'sonar-scanner'} after TEST now` }
749
+ : { id: `quality:${slug}`, label: `Scan ${slug} with SonarQube after TEST`, hint: 'not scanned here' })),
740
750
  { id: 'show', label: "Show this machine's config", hint: runner.configPath ?? '' },
741
751
  ],
742
752
  });
@@ -2187,6 +2197,13 @@ export class Attached {
2187
2197
  this.runner.agents = agentsOf(next);
2188
2198
  return this.reloadDaemon(file, next);
2189
2199
  }
2200
+ if (chosen.id.startsWith('quality:')) {
2201
+ const slug = chosen.id.slice('quality:'.length);
2202
+ const next = await setQuality({
2203
+ file, configPath, ask, say, ink, slug, enabled: !qualityOf(file.projects?.[slug]),
2204
+ });
2205
+ return this.reloadDaemon(file, next);
2206
+ }
2190
2207
  if (chosen.id === 'rules') {
2191
2208
  const next = await setAcceptsRulesFromConsole({
2192
2209
  file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
package/runner/cawdev.mjs CHANGED
@@ -29,7 +29,7 @@ import { attach, urlFrom, valueOf } from './attach.mjs';
29
29
  import { asker, askOne, mintForThisMachine, setUpThisMachine } from './bootstrap.mjs';
30
30
  import { liveSockets, probeSocket, socketPathFor } from './control.mjs';
31
31
  import {
32
- addProject, addWorkspace, readConfigFile, reloadIfRunning, setAcceptsRulesFromConsole,
32
+ addProject, addWorkspace, readConfigFile, reloadIfRunning, setAcceptsRulesFromConsole, setQuality,
33
33
  setAgentEnabled,
34
34
  } from './configure.mjs';
35
35
  import { restartNeededFor } from '../lib/runner-config.mjs';
@@ -64,6 +64,8 @@ const USAGE = `
64
64
  already serve — R47's concurrency
65
65
  cawdev config agent enable|disable <claude|agy>
66
66
  cawdev config rules on|off accept permission rules from the console
67
+ cawdev config quality on|off <slug> scan that project's branches with
68
+ SonarQube after a run's TEST stage
67
69
  cawdev config show this machine's config, token redacted
68
70
 
69
71
  On a machine with no config, cawdev sets one up: it signs you in through the
@@ -405,7 +407,8 @@ async function runVersion(argv, ink) {
405
407
  /**
406
408
  * `cawdev config` — R283's granular half of the setup walk, on a machine
407
409
  * already configured. A direct subcommand (`add-project`, `add-workspace`,
408
- * `agent enable|disable <command>`, `rules on|off`, `show`) or, with none, a
410
+ * `agent enable|disable <command>`, `rules on|off`, `quality on|off <slug>`,
411
+ * `show`) or, with none, a
409
412
  * menu — the same `Select` widget everything that offers a choice uses.
410
413
  */
411
414
  async function runConfig(args, ink) {
@@ -449,6 +452,8 @@ export async function pickAction(ask, say, pick = askOne) {
449
452
  { id: 'agent-disable', label: 'Disable an agent' },
450
453
  { id: 'rules-on', label: 'Accept permission rules from the console' },
451
454
  { id: 'rules-off', label: 'Stop accepting rules from the console' },
455
+ { id: 'quality-on', label: 'Scan a project\'s branches with SonarQube after TEST' },
456
+ { id: 'quality-off', label: 'Stop scanning a project\'s branches' },
452
457
  { id: 'show', label: 'Show this machine\'s config' },
453
458
  ],
454
459
  }));
@@ -459,6 +464,10 @@ export async function pickAction(ask, say, pick = askOne) {
459
464
  }
460
465
  if (id === 'rules-on') return ['rules', 'on'];
461
466
  if (id === 'rules-off') return ['rules', 'off'];
467
+ if (id === 'quality-on' || id === 'quality-off') {
468
+ const slug = (await ask.line(' Which project (slug)? ')).trim();
469
+ return ['quality', id === 'quality-on' ? 'on' : 'off', slug];
470
+ }
462
471
  return [id];
463
472
  }
464
473
 
@@ -498,6 +507,16 @@ export async function runAction([verb, ...rest], { configPath, file, url, sessio
498
507
  await reload(await setAcceptsRulesFromConsole({ file, configPath, ask, say, ink, enabled: sub === 'on' }));
499
508
  return;
500
509
  }
510
+ if (verb === 'quality') {
511
+ // R300. The block is captured per project at boot, so the daemon's own
512
+ // verdict here is always "restart" — and it is the daemon that says so.
513
+ const [sub, slug] = rest;
514
+ if (!['on', 'off'].includes(sub) || !slug) {
515
+ throw new Error('Name a project: cawdev config quality on|off <slug>');
516
+ }
517
+ await reload(await setQuality({ file, configPath, ask, say, ink, slug, enabled: sub === 'on' }));
518
+ return;
519
+ }
501
520
  throw new Error(`Not a cawdev config command: ${verb}. See cawdev --help.`);
502
521
  }
503
522
 
@@ -361,6 +361,64 @@ export async function setAcceptsRulesFromConsole({
361
361
  return next;
362
362
  }
363
363
 
364
+ /**
365
+ * Turn a project's SonarQube scan on or off — R300.
366
+ *
367
+ * On writes a `quality` block into the project's entry (`{ command, tokenEnv }`,
368
+ * the two things the daemon needs; `runner.mjs`'s `qualityBlockOf` fills in
369
+ * the rest), asking for each with its default offered — Enter keeps it. Off
370
+ * deletes the block and leaves every other key of the entry as it was. A bare
371
+ * path entry is promoted to `{workspaces}` on the way, R47's third form, the
372
+ * way `addWorkspace` promotes one.
373
+ *
374
+ * Nothing here checks the scanner is installed: unlike an agent, the scanner
375
+ * can be missing on a machine that will be told so per run — the daemon
376
+ * reports "not installed" as an error reading, which is the page saying it
377
+ * — and a check here would refuse a config somebody is about to complete.
378
+ * The token is NOT asked for: it is an environment variable this machine
379
+ * exports, never a value in a file people commit.
380
+ */
381
+ export async function setQuality({
382
+ file, configPath, slug, enabled,
383
+ command, tokenEnv,
384
+ ask, say, ink = painter(3),
385
+ write = writeRunnerConfig,
386
+ } = {}) {
387
+ const projects = file.projects ?? {};
388
+ if (!slug || !(slug in projects)) {
389
+ throw new Error(`This machine does not serve ${slug || 'that project'}. `
390
+ + `It serves: ${Object.keys(projects).join(', ') || 'nothing yet'}.`);
391
+ }
392
+ const entry = typeof projects[slug] === 'string'
393
+ ? { workspaces: [projects[slug]] }
394
+ : { ...projects[slug] };
395
+
396
+ let next;
397
+ if (enabled) {
398
+ const chosenCommand = command
399
+ ?? (String(await ask.line(' Scanner command [sonar-scanner]: ')).trim() || 'sonar-scanner');
400
+ const chosenTokenEnv = tokenEnv
401
+ ?? (String(await ask.line(' Environment variable holding the scanner token [SONAR_TOKEN]: ')).trim()
402
+ || 'SONAR_TOKEN');
403
+ entry.quality = { ...(entry.quality ?? {}), command: chosenCommand, tokenEnv: chosenTokenEnv };
404
+ next = { ...file, projects: { ...projects, [slug]: entry } };
405
+ await write(configPath, next);
406
+ say(` ${ink.success('✓')} ${ink.muted(`${slug} scans its branches with`)} ${ink.text(chosenCommand)}`
407
+ + ` ${ink.muted('after TEST, token from')} ${ink.text('$' + chosenTokenEnv)}`);
408
+ say(` ${ink.muted(`Export ${chosenTokenEnv} where the daemon runs. It is never written to this file.`)}`);
409
+ } else {
410
+ if (!entry.quality) {
411
+ say(` ${ink.muted(`${slug} was already not scanned here.`)}`);
412
+ return file;
413
+ }
414
+ delete entry.quality;
415
+ next = { ...file, projects: { ...projects, [slug]: entry } };
416
+ await write(configPath, next);
417
+ say(` ${ink.success('✓')} ${ink.muted(`${slug} is no longer scanned on this machine.`)}`);
418
+ }
419
+ return next;
420
+ }
421
+
364
422
  /**
365
423
  * The hello of a daemon running on this exact config, or null.
366
424
  *