cawdev-cli 1.0.2-beta → 1.0.4-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/harness-prompt.mjs +12 -1
- package/lib/runner-config.mjs +81 -0
- 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 +124 -12
- package/runner/attach.mjs +87 -31
- package/runner/cawdev.mjs +36 -25
- package/runner/configure.mjs +144 -21
- package/runner/runner.mjs +560 -53
- 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
|
}
|
|
@@ -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
|
+
}
|
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
|
@@ -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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
662
|
-
|
|
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** —
|