cawdev-cli 1.0.3-beta → 1.0.4-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/harness-prompt.mjs +12 -1
- package/lib/stage-tools.mjs +33 -2
- package/lib/tool-line.mjs +19 -8
- package/mcp/README.md +1 -0
- package/mcp/server.mjs +147 -0
- package/package.json +2 -5
- package/runner/README.md +99 -3
- package/runner/attach.mjs +18 -1
- package/runner/cawdev.mjs +21 -2
- package/runner/configure.mjs +58 -0
- package/runner/runner.mjs +479 -29
- package/runner/macbook-laptop.json +0 -48
package/lib/harness-prompt.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/stage-tools.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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 (
|
|
91
|
-
|
|
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
|
+
"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
|
|
678
|
-
|
|
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`, `
|
|
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
|
|
package/runner/configure.mjs
CHANGED
|
@@ -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
|
*
|