cawdev-cli 1.1.0 → 1.1.1
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/finish-check.mjs +34 -1
- package/lib/harness-prompt.mjs +6 -0
- package/lib/order.mjs +7 -0
- package/lib/protected.mjs +225 -0
- package/lib/roadmap-format.mjs +4 -2
- package/lib/runner-config.mjs +9 -0
- package/lib/secrets.mjs +2 -2
- package/lib/sonar-analysis.mjs +52 -0
- package/lib/sonarqube-mcp.mjs +50 -0
- package/lib/stage-tools.mjs +52 -1
- package/mcp/README.md +5 -6
- package/mcp/server.mjs +168 -25
- package/package.json +1 -1
- package/runner/README.md +79 -0
- package/runner/attach.mjs +46 -1
- package/runner/cawdev.mjs +20 -2
- package/runner/configure.mjs +62 -0
- package/runner/git-host.mjs +298 -5
- package/runner/pause-note.mjs +104 -0
- package/runner/push-guard.mjs +333 -0
- package/runner/runner.mjs +659 -177
package/lib/finish-check.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// finish that pushes a key is worse than one that leaves a file behind.
|
|
19
19
|
|
|
20
20
|
import { execFile } from 'node:child_process';
|
|
21
|
+
import { notAllowed, protectedChanges } from './protected.mjs';
|
|
21
22
|
import { findSecret } from './secrets.mjs';
|
|
22
23
|
|
|
23
24
|
/** The profiles whose run is a branch somebody will build on. */
|
|
@@ -109,8 +110,16 @@ export function notDoneYet(left) {
|
|
|
109
110
|
* was), and `{ ok: false, reason }` otherwise, in words for the run's summary.
|
|
110
111
|
* Never forces; a remote that has moved is a reason, not something to win.
|
|
111
112
|
*/
|
|
112
|
-
export async function settleBranch(cwd, branch,
|
|
113
|
+
export async function settleBranch(cwd, branch,
|
|
114
|
+
{ git = runGit, push, label = 'cawdev', protected: policy = null } = {}) {
|
|
113
115
|
const left = await leftOver(cwd, branch, { git });
|
|
116
|
+
// R562. Before the "nothing left" return: a protected change already
|
|
117
|
+
// committed and pushed (by a CLI that has no gate of its own) is exactly what
|
|
118
|
+
// this door exists to catch.
|
|
119
|
+
const held = left ? await heldProtected(cwd, policy) : [];
|
|
120
|
+
if (held.length) {
|
|
121
|
+
return { ok: false, reason: protectedHeld(held) };
|
|
122
|
+
}
|
|
114
123
|
if (!left || !isUnfinished(left)) {
|
|
115
124
|
return { ok: true, note: null };
|
|
116
125
|
}
|
|
@@ -122,6 +131,10 @@ export async function settleBranch(cwd, branch, { git = runGit, push, label = 'c
|
|
|
122
131
|
};
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
// R562. The daemon has no session to ask, so a protected change nobody
|
|
135
|
+
// allowed fails the run (above, before anything is staged), held in the
|
|
136
|
+
// checkout. ASK fails too: Carry on, and the next `report done` asks.
|
|
137
|
+
|
|
125
138
|
let committed = [];
|
|
126
139
|
if (left.dirty.length) {
|
|
127
140
|
await git(cwd, ['add', '-A']);
|
|
@@ -159,6 +172,10 @@ export async function settleBranch(cwd, branch, { git = runGit, push, label = 'c
|
|
|
159
172
|
: null,
|
|
160
173
|
};
|
|
161
174
|
}
|
|
175
|
+
const heldNow = await heldProtected(cwd, policy);
|
|
176
|
+
if (heldNow.length) {
|
|
177
|
+
return { ok: false, reason: protectedHeld(heldNow) };
|
|
178
|
+
}
|
|
162
179
|
const pushed = await push(cwd, branch);
|
|
163
180
|
if (!pushed.ok) {
|
|
164
181
|
return {
|
|
@@ -176,3 +193,19 @@ export async function settleBranch(cwd, branch, { git = runGit, push, label = 'c
|
|
|
176
193
|
: `The daemon pushed ${branch}, which the session had committed and not pushed.`,
|
|
177
194
|
};
|
|
178
195
|
}
|
|
196
|
+
|
|
197
|
+
/** The protected paths this checkout changes that nobody has allowed. */
|
|
198
|
+
export async function heldProtected(cwd, policy) {
|
|
199
|
+
if (!policy?.globs?.length || !policy.base) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
const changed = await protectedChanges(cwd, policy.base, policy.globs);
|
|
203
|
+
return notAllowed(changed, policy.allowed ?? []);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The sentence for a protected change at a door with nobody to ask. */
|
|
207
|
+
export function protectedHeld(paths) {
|
|
208
|
+
return `${named(paths)} ${paths.length === 1 ? 'is' : 'are'} protected in this project and `
|
|
209
|
+
+ "were changed without a person's leave, so nothing was committed or pushed. The work is "
|
|
210
|
+
+ 'held in the checkout.';
|
|
211
|
+
}
|
package/lib/harness-prompt.mjs
CHANGED
|
@@ -105,6 +105,12 @@ export function harnessPrompt({
|
|
|
105
105
|
+ 'least one tool under the prefix, before this stage is finished.\n'
|
|
106
106
|
+ mustServers.map((each) => `- \`${each.prefix}__…\` — ${each.name}`).join('\n'));
|
|
107
107
|
}
|
|
108
|
+
// R319. A server that knows when it is worth reaching for says so, required
|
|
109
|
+
// or not; one that does not still gets nothing.
|
|
110
|
+
const adviceFor = (mcpServers ?? []).filter((each) => each.when);
|
|
111
|
+
if (adviceFor.length) {
|
|
112
|
+
reach.push(adviceFor.map((each) => `- \`${each.prefix}__…\` (${each.name}): ${each.when}`).join('\n'));
|
|
113
|
+
}
|
|
108
114
|
if (reach.length) {
|
|
109
115
|
parts.push(`## What this session may reach\n\n${reach.join('\n\n')}`);
|
|
110
116
|
}
|
package/lib/order.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Code-unit order: what Array#sort does to strings by default and what Java's
|
|
2
|
+
// String.compareTo does, said out loud. Never localeCompare here: ICU weighs
|
|
3
|
+
// `-`, `.` and `_` differently, and an export must not churn (R315) nor a
|
|
4
|
+
// refusal reorder its paths.
|
|
5
|
+
export function byCodeUnit(a, b) {
|
|
6
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
7
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Protected files — R562.
|
|
2
|
+
//
|
|
3
|
+
// A project lists globs an agent may not change. Three layers enforce it and
|
|
4
|
+
// this is the part that does not depend on the CLI: `protectedChanges` compares
|
|
5
|
+
// the branch with the commit it was cut from, so it catches an Edit, a shell
|
|
6
|
+
// `git checkout main -- pom.xml`, a `git mv`, a `sed -i` and a build step alike,
|
|
7
|
+
// on every CLI and on a machine set to allow everything.
|
|
8
|
+
//
|
|
9
|
+
// Zero dependencies, like the rest of tools/.
|
|
10
|
+
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { realpathSync } from 'node:fs';
|
|
13
|
+
import { matches, normalise } from './secrets.mjs';
|
|
14
|
+
import { byCodeUnit } from './order.mjs';
|
|
15
|
+
|
|
16
|
+
/** The modes a project can choose. REFUSE is what an unreadable policy means. */
|
|
17
|
+
export const MODES = ['REFUSE', 'ASK'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The policy out of a shield, whatever shape it arrived in.
|
|
21
|
+
*
|
|
22
|
+
* Unreadable or missing is REFUSE with no globs — never a way of allowing.
|
|
23
|
+
*/
|
|
24
|
+
export function protectedPolicy(shield) {
|
|
25
|
+
const globs = Array.isArray(shield?.protectedPaths)
|
|
26
|
+
? shield.protectedPaths.filter((glob) => typeof glob === 'string' && glob.trim())
|
|
27
|
+
.map((glob) => glob.trim())
|
|
28
|
+
: [];
|
|
29
|
+
const mode = String(shield?.protectedMode ?? '').toUpperCase() === 'ASK' ? 'ASK' : 'REFUSE';
|
|
30
|
+
const allowed = Array.isArray(shield?.protectedAllowed)
|
|
31
|
+
? shield.protectedAllowed.filter((path) => typeof path === 'string')
|
|
32
|
+
: [];
|
|
33
|
+
return { globs, mode, allowed };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a path, absolute or relative to the checkout, is a protected one.
|
|
38
|
+
*
|
|
39
|
+
* A path that leaves the checkout is not protected here — it is the scope
|
|
40
|
+
* rule's business — and a symlink is followed when it exists, so a link into a
|
|
41
|
+
* protected directory is the directory.
|
|
42
|
+
*/
|
|
43
|
+
export function isProtected(path, root, globs) {
|
|
44
|
+
if (typeof path !== 'string' || !path || !Array.isArray(globs) || globs.length === 0) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const base = normalise(root);
|
|
48
|
+
const candidates = [normalise(path.startsWith('/') ? path : `${base}/${path}`)];
|
|
49
|
+
const real = resolveReal(candidates[0]);
|
|
50
|
+
if (real && real !== candidates[0]) {
|
|
51
|
+
candidates.push(real);
|
|
52
|
+
}
|
|
53
|
+
let realBase = base;
|
|
54
|
+
try {
|
|
55
|
+
realBase = realpathSync(base);
|
|
56
|
+
} catch {
|
|
57
|
+
// The checkout is not there; the lexical answer stands.
|
|
58
|
+
}
|
|
59
|
+
return candidates.some((resolved) => {
|
|
60
|
+
for (const root of [base, realBase]) {
|
|
61
|
+
if (resolved === root || resolved.startsWith(`${root}/`)) {
|
|
62
|
+
const relative = resolved === root ? '' : resolved.slice(root.length + 1);
|
|
63
|
+
return globs.some((glob) => matchesHere(relative, glob.replace(/^\.\//, '')));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a relative path is under a glob. On macOS and Windows the default
|
|
72
|
+
* volume is case-insensitive, so `POM.XML` IS `pom.xml` there; comparing
|
|
73
|
+
* without case protects a little more than a case-sensitive volume needs, which
|
|
74
|
+
* is the safe direction to be wrong in.
|
|
75
|
+
*/
|
|
76
|
+
function matchesHere(path, glob) {
|
|
77
|
+
return process.platform === 'darwin' || process.platform === 'win32'
|
|
78
|
+
? matches(path.toLowerCase(), glob.toLowerCase())
|
|
79
|
+
: matches(path, glob);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The real path of the longest part that exists, with the rest appended. */
|
|
83
|
+
function resolveReal(path) {
|
|
84
|
+
const parts = path.split('/');
|
|
85
|
+
for (let keep = parts.length; keep > 1; keep -= 1) {
|
|
86
|
+
const head = parts.slice(0, keep).join('/') || '/';
|
|
87
|
+
try {
|
|
88
|
+
const real = realpathSync(head);
|
|
89
|
+
return normalise([real, ...parts.slice(keep)].join('/'));
|
|
90
|
+
} catch {
|
|
91
|
+
// Not there yet; try the parent.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function runGit(cwd, args) {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
100
|
+
if (error) {
|
|
101
|
+
reject(new Error(stderr?.trim() || error.message));
|
|
102
|
+
} else {
|
|
103
|
+
resolve(stdout);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The protected paths this branch changes, sorted and unique.
|
|
111
|
+
*
|
|
112
|
+
* The committed diff against `base` (a diff, not a log, so a change later
|
|
113
|
+
* reverted does not count; `--no-renames`, so moving a protected file away is
|
|
114
|
+
* caught on its old path) plus the working tree. Empty when there is nothing to
|
|
115
|
+
* judge: no globs, or no base.
|
|
116
|
+
*/
|
|
117
|
+
export async function protectedChanges(cwd, base, globs, { git = runGit } = {}) {
|
|
118
|
+
if (!base || !Array.isArray(globs) || globs.length === 0) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
const touched = new Set();
|
|
122
|
+
// A git failure THROWS. An unreadable base (garbage-collected, a shallow
|
|
123
|
+
// clone) is not a reason to say the branch is clean: the callers turn a throw
|
|
124
|
+
// into a refusal, which is the direction this fails in.
|
|
125
|
+
const diff = await git(cwd, ['diff', '--name-only', '--no-renames', '-z', base, 'HEAD']);
|
|
126
|
+
for (const path of diff.split('\0')) {
|
|
127
|
+
if (path) {
|
|
128
|
+
touched.add(path);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const status = await git(cwd, ['status', '--porcelain', '-z', '--untracked-files=all']);
|
|
132
|
+
const entries = status.split('\0');
|
|
133
|
+
for (let at = 0; at < entries.length; at += 1) {
|
|
134
|
+
const entry = entries[at];
|
|
135
|
+
if (entry.length < 4) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
touched.add(entry.slice(3));
|
|
139
|
+
// A rename or copy carries the old path as the next field.
|
|
140
|
+
if (entry[0] === 'R' || entry[0] === 'C' || entry[1] === 'R' || entry[1] === 'C') {
|
|
141
|
+
at += 1;
|
|
142
|
+
if (entries[at]) {
|
|
143
|
+
touched.add(entries[at]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return [...touched]
|
|
148
|
+
.filter((path) => globs.some((glob) => matchesHere(path, glob.replace(/^\.\//, ''))))
|
|
149
|
+
.sort(byCodeUnit);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The base a branch was cut from: the merge-base with its upstream, else the claim's commit. */
|
|
153
|
+
export async function protectedBase(cwd, baseBranch, fallback, { git = runGit } = {}) {
|
|
154
|
+
if (baseBranch) {
|
|
155
|
+
for (const ref of [`origin/${baseBranch}`, baseBranch]) {
|
|
156
|
+
try {
|
|
157
|
+
const sha = (await git(cwd, ['merge-base', ref, 'HEAD'])).trim();
|
|
158
|
+
if (sha) {
|
|
159
|
+
return sha;
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// Try the next ref.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return fallback || null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** What is changed and not allowed. */
|
|
170
|
+
export function notAllowed(changed, allowed = []) {
|
|
171
|
+
const leave = new Set(allowed);
|
|
172
|
+
return changed.filter((path) => !leave.has(path));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The sentence a refusal gives, shared by every door. */
|
|
176
|
+
export function protectedRefusal(paths, base) {
|
|
177
|
+
const list = paths.join(', ');
|
|
178
|
+
return `Not finished — this branch changes protected file(s) the project never lets an agent `
|
|
179
|
+
+ `change: ${list}. Restore them (git checkout ${base ? base.slice(0, 12) : '<base>'} -- `
|
|
180
|
+
+ `<path>, or delete what you added), commit, push, and report done again.`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The paragraph every prompt carries. Null when nothing is protected. */
|
|
184
|
+
export function protectedLine(policy, already = []) {
|
|
185
|
+
const globs = policy?.globs ?? [];
|
|
186
|
+
if (!globs.length) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const how = policy.mode === 'ASK'
|
|
190
|
+
? 'Changing one stops and asks a person first, and an allowed change is marked on the card in '
|
|
191
|
+
+ 'red; prefer a design that leaves them alone.'
|
|
192
|
+
: 'An agent never changes them — not with an edit, not with the shell, not with git; the run '
|
|
193
|
+
+ 'cannot finish while the branch changes one.';
|
|
194
|
+
const lines = [
|
|
195
|
+
`**Protected files.** This project protects: ${globs.map((glob) => `\`${glob}\``).join(', ')}. `
|
|
196
|
+
+ `${how} Check your plan against this list before you start.`,
|
|
197
|
+
];
|
|
198
|
+
if (already.length) {
|
|
199
|
+
lines.push(`This branch already changes ${already.map((path) => `\`${path}\``).join(', ')}, `
|
|
200
|
+
+ 'which are protected; deal with that first.');
|
|
201
|
+
}
|
|
202
|
+
return lines.join('\n\n');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Rules for the CLI's permission list, one per glob.
|
|
207
|
+
*
|
|
208
|
+
* Only `Edit(path)` is matched — Claude Code rejects `Write(path)` as "not
|
|
209
|
+
* matched by file permission checks" and applies Edit rules to every
|
|
210
|
+
* file-editing tool, NotebookEdit included (measured on 2.1.307; see
|
|
211
|
+
* `protectFiles` in the runner).
|
|
212
|
+
*/
|
|
213
|
+
export function denyRules(globs) {
|
|
214
|
+
return (globs ?? []).map((glob) => `Edit(./${glob.replace(/^\.\//, '')})`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A path relative to the checkout, or null when it is outside it. */
|
|
218
|
+
export function relativeTo(path, root) {
|
|
219
|
+
const base = normalise(root);
|
|
220
|
+
const resolved = normalise(String(path).startsWith('/') ? String(path) : `${base}/${path}`);
|
|
221
|
+
if (resolved === base) {
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
return resolved.startsWith(`${base}/`) ? resolved.slice(base.length + 1) : null;
|
|
225
|
+
}
|
package/lib/roadmap-format.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// is not a fixed point and the generated file churns on every release. Keeping
|
|
5
5
|
// both halves in one file is how they stay agreed.
|
|
6
6
|
|
|
7
|
+
import { byCodeUnit } from './order.mjs';
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* What to call a card — R127 — and, since R221, what identifies one.
|
|
9
11
|
*
|
|
@@ -506,7 +508,7 @@ function originWord(origin) {
|
|
|
506
508
|
case 'SONARQUBE':
|
|
507
509
|
return 'SonarQube';
|
|
508
510
|
case 'FEEDBACK':
|
|
509
|
-
return 'Feedback';
|
|
511
|
+
return origin.label ? `Feedback (from ${origin.label})` : 'Feedback';
|
|
510
512
|
case 'APPLICATION':
|
|
511
513
|
return origin.label ? `An application (${origin.label})` : 'An application';
|
|
512
514
|
case 'SESSION':
|
|
@@ -613,7 +615,7 @@ function renderEntry(entry) {
|
|
|
613
615
|
}
|
|
614
616
|
if (entry.tags?.length) {
|
|
615
617
|
// R315. Sorted, so the export does not churn on the set's own order.
|
|
616
|
-
lines.push(`Tags: ${[...entry.tags].sort().join(', ')}`);
|
|
618
|
+
lines.push(`Tags: ${[...entry.tags].sort(byCodeUnit).join(', ')}`);
|
|
617
619
|
}
|
|
618
620
|
if (entry.assignee?.username) {
|
|
619
621
|
// R331. Nothing for a card with nobody, so today's files stay byte-identical.
|
package/lib/runner-config.mjs
CHANGED
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
* Adding a key that IS captured would make the daemon claim it applied
|
|
26
26
|
* something it did not, which is worse than saying restart.
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* R577: the name this machine registers under — the environment first, then the
|
|
30
|
+
* file, then a default. The daemon and the CLI both ask it, so `cawdev config
|
|
31
|
+
* planning-limit` finds the same machine the daemon registered.
|
|
32
|
+
*/
|
|
33
|
+
export function runnerNameOf(file, env = process.env) {
|
|
34
|
+
return env.CAWDEV_RUNNER_NAME ?? file?.name ?? 'this-machine';
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
export const HOT_SWAPPABLE = Object.freeze(['agentCommands', 'grantable', 'acceptsRulesFromConsole',
|
|
29
38
|
'autoWorkspaces']);
|
|
30
39
|
|
package/lib/secrets.mjs
CHANGED
|
@@ -217,7 +217,7 @@ export function withinScope(path, root, scope = null) {
|
|
|
217
217
|
* daemon's cwd is not the checkout. A silent dependence on where the daemon
|
|
218
218
|
* happens to be standing is exactly the bug this function exists to not have.
|
|
219
219
|
*/
|
|
220
|
-
function normalise(path) {
|
|
220
|
+
export function normalise(path) {
|
|
221
221
|
const absolute = path.startsWith('/');
|
|
222
222
|
const out = [];
|
|
223
223
|
for (const part of path.split('/')) {
|
|
@@ -244,7 +244,7 @@ function normalise(path) {
|
|
|
244
244
|
* zero-dep constraint is what makes `tools/` a directory somebody can read
|
|
245
245
|
* before running it against their repositories.
|
|
246
246
|
*/
|
|
247
|
-
function matches(path, glob) {
|
|
247
|
+
export function matches(path, glob) {
|
|
248
248
|
if (typeof glob !== 'string' || !glob) {
|
|
249
249
|
return false;
|
|
250
250
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The SonarQube server side of a scan, shared by the runner daemon
|
|
2
|
+
// (tools/runner/runner.mjs, scanQuality) and `sonar.sh` (tools/quality/scan.mjs):
|
|
3
|
+
// the scanner's report-task.txt, a GET with the scanner's own token, and the wait
|
|
4
|
+
// for the server's analysis task. One place, so the two cannot drift (R584).
|
|
5
|
+
// Zero dependencies, like all of tools/.
|
|
6
|
+
|
|
7
|
+
/** A GET against the SonarQube server a scan just reported to, authorised with the scanner's own token. */
|
|
8
|
+
export function sonarAsker(serverUrl, token) {
|
|
9
|
+
return async (path) => {
|
|
10
|
+
const response = await fetch(`${serverUrl}${path}`, { headers: { authorization: `Bearer ${token}` } });
|
|
11
|
+
if (!response.ok) {
|
|
12
|
+
throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
|
|
13
|
+
}
|
|
14
|
+
return response.json();
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Waits for a queued analysis to settle — shared by every scan path, BRANCH,
|
|
20
|
+
* MAIN and PROJECT alike. Throws rather than reports: every caller runs
|
|
21
|
+
* inside `scanQuality`'s own try/catch, which turns it into an error reading.
|
|
22
|
+
*/
|
|
23
|
+
export async function waitForAnalysis(ask, ceTaskId, deadline, timeoutSeconds) {
|
|
24
|
+
let settled = null;
|
|
25
|
+
while (Date.now() < deadline) {
|
|
26
|
+
const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`);
|
|
27
|
+
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
|
|
28
|
+
settled = current;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
await new Promise((wake) => setTimeout(wake, 3000));
|
|
32
|
+
}
|
|
33
|
+
if (!settled) {
|
|
34
|
+
throw new Error(`SonarQube had not finished analysing after ${timeoutSeconds}s.`);
|
|
35
|
+
}
|
|
36
|
+
if (settled.status !== 'SUCCESS') {
|
|
37
|
+
throw new Error(settled.errorMessage ?? `SonarQube ${settled.status.toLowerCase()} the analysis.`);
|
|
38
|
+
}
|
|
39
|
+
return settled;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `key=value` lines — the shape of the scanner's `report-task.txt`. */
|
|
43
|
+
export function parseProperties(text) {
|
|
44
|
+
const parsed = {};
|
|
45
|
+
for (const line of text.split('\n')) {
|
|
46
|
+
const at = line.indexOf('=');
|
|
47
|
+
if (at > 0) {
|
|
48
|
+
parsed[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return parsed;
|
|
52
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// R319. What the daemon adds to SonarSource's MCP server before it is spawned.
|
|
2
|
+
//
|
|
3
|
+
// The registry row says WHAT to run (`java -jar <file name>`); this says where
|
|
4
|
+
// the jar is on this machine and under which credentials, because those are
|
|
5
|
+
// facts about the machine and the platform must not hold them. Pure, so the
|
|
6
|
+
// three refusals can be tested without a daemon.
|
|
7
|
+
|
|
8
|
+
import { isAbsolute, join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** The directory, on this machine, that holds the jar the row names. */
|
|
11
|
+
export const JAR_DIR_ENV = 'CAWDEV_SONARQUBE_MCP_DIR';
|
|
12
|
+
|
|
13
|
+
const trimSlash = (text) => String(text).replace(/\/+$/, '');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The arguments and environment to spawn the server with, or the sentence
|
|
17
|
+
* saying why it is not attached. Never throws.
|
|
18
|
+
*
|
|
19
|
+
* - the token is `process.env[quality.tokenEnv]` — the credential R300 already
|
|
20
|
+
* holds — and is put in the SERVER's environment only;
|
|
21
|
+
* - the URL is the machine's override, else the project's connection, so a
|
|
22
|
+
* project on another server gets that server;
|
|
23
|
+
* - a machine with no `quality` block for the project, no token, no URL or no
|
|
24
|
+
* jar directory does not get the server, and is told which.
|
|
25
|
+
*/
|
|
26
|
+
export function sonarqubeLaunch({ skill, quality, sonarUrl, env, storagePath }) {
|
|
27
|
+
const name = skill.name ?? skill.key;
|
|
28
|
+
const dir = env[JAR_DIR_ENV];
|
|
29
|
+
if (!dir || !isAbsolute(dir)) {
|
|
30
|
+
return { skip: `${name} was not attached: set ${JAR_DIR_ENV} to the absolute directory that `
|
|
31
|
+
+ `holds ${skill.args.find((arg) => String(arg).endsWith('.jar')) ?? 'the server jar'}.` };
|
|
32
|
+
}
|
|
33
|
+
if (!quality) {
|
|
34
|
+
return { skip: `${name} was not attached: this machine has no "quality" block for this `
|
|
35
|
+
+ 'project, so it holds no scanner token for the server to use.' };
|
|
36
|
+
}
|
|
37
|
+
const token = env[quality.tokenEnv];
|
|
38
|
+
if (!token) {
|
|
39
|
+
return { skip: `${name} was not attached: $${quality.tokenEnv} is not set on this machine.` };
|
|
40
|
+
}
|
|
41
|
+
const url = quality.url ?? sonarUrl;
|
|
42
|
+
if (!url) {
|
|
43
|
+
return { skip: `${name} was not attached: neither the project's connection nor this machine's `
|
|
44
|
+
+ 'quality block names a SonarQube server.' };
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
args: skill.args.map((arg) => (String(arg).endsWith('.jar') && !isAbsolute(arg) ? join(dir, arg) : arg)),
|
|
48
|
+
env: { SONARQUBE_TOKEN: token, SONARQUBE_URL: trimSlash(url), STORAGE_PATH: storagePath },
|
|
49
|
+
};
|
|
50
|
+
}
|
package/lib/stage-tools.mjs
CHANGED
|
@@ -22,6 +22,15 @@
|
|
|
22
22
|
* named string behind one named option — rather than an entry in `CAWDEV_READS`,
|
|
23
23
|
* which would hand a write to every read-only stage of every profile and call it
|
|
24
24
|
* a read.
|
|
25
|
+
*
|
|
26
|
+
* The second exception — R572 — is {@link SUGGEST}, `backlog_file`, and every
|
|
27
|
+
* read-only stage keeps it. It files FEEDBACK: a pending item a person accepts or
|
|
28
|
+
* refuses. It changes no file, no card, no status and no discussion, so "cannot
|
|
29
|
+
* change anything" survives it in every sense the claim was ever about. It is
|
|
30
|
+
* here because the read-only stages are where a session reads the most code and
|
|
31
|
+
* notices the most that is not this card's, and the only place that left them to
|
|
32
|
+
* say so was a report nobody files from. Named for `CARD_WRITE`'s reason: a
|
|
33
|
+
* writer let through on purpose, not a writer renamed a read.
|
|
25
34
|
*/
|
|
26
35
|
|
|
27
36
|
/** Reading git without being able to change it. */
|
|
@@ -98,6 +107,26 @@ export const CAWDEV_READS = new Set([
|
|
|
98
107
|
*/
|
|
99
108
|
const STAGE_READS = new Set([...CAWDEV_READS].filter((name) => name !== 'report'));
|
|
100
109
|
|
|
110
|
+
/**
|
|
111
|
+
* R319. SonarSource's server, judged by a NAMED list for the reason
|
|
112
|
+
* {@link CAWDEV_READS} is: shape is the worst signal available, and here it is
|
|
113
|
+
* wrong — `change_sonar_issue_status`, `toggle_automatic_analysis` and
|
|
114
|
+
* `create_webhook` are writers the loose end-of-name test below reads as reads.
|
|
115
|
+
* What is not on this list is a writer, including a tool a later version adds.
|
|
116
|
+
* Pinned in stage-tools.test.mjs against the tool list read at 1.29.0.
|
|
117
|
+
*/
|
|
118
|
+
export const SONARQUBE_READS = new Set([
|
|
119
|
+
'show_rule', 'list_languages', 'search_sonar_issues_in_projects',
|
|
120
|
+
'search_security_hotspots', 'show_security_hotspot', 'get_project_quality_gate_status',
|
|
121
|
+
'list_quality_gates', 'get_component_measures', 'search_metrics',
|
|
122
|
+
'search_my_sonarqube_projects', 'list_branches', 'list_pull_requests',
|
|
123
|
+
'get_raw_source', 'search_files_by_coverage', 'get_file_coverage_details',
|
|
124
|
+
'search_duplicated_files', 'get_duplications',
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/** The `mcp__sonarqube__` prefix, once. */
|
|
128
|
+
const SONARQUBE = 'mcp__sonarqube__';
|
|
129
|
+
|
|
101
130
|
/** The `mcp__cawdev__` prefix, once. */
|
|
102
131
|
const CAWDEV = 'mcp__cawdev__';
|
|
103
132
|
|
|
@@ -114,6 +143,15 @@ const REPORT = `${CAWDEV}report`;
|
|
|
114
143
|
*/
|
|
115
144
|
export const CARD_WRITE = `${CAWDEV}roadmap_create`;
|
|
116
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The platform write every read-only stage may hold — R572. See the header.
|
|
148
|
+
*
|
|
149
|
+
* <p>A stage still only holds it when the PROFILE handed it over: this lets it
|
|
150
|
+
* through the read-only filter, it never adds it. `canChangeThings` goes on
|
|
151
|
+
* calling it a writer, which is what keeps it an exception.
|
|
152
|
+
*/
|
|
153
|
+
export const SUGGEST = `${CAWDEV}backlog_file`;
|
|
154
|
+
|
|
117
155
|
/**
|
|
118
156
|
* Reads that only mean something once the work exists — R302.
|
|
119
157
|
*
|
|
@@ -221,6 +259,14 @@ export function canChangeThings(tool) {
|
|
|
221
259
|
if (tool.startsWith(CAWDEV)) {
|
|
222
260
|
return !STAGE_READS.has(tool.slice(CAWDEV.length));
|
|
223
261
|
}
|
|
262
|
+
// The bare server (`mcp__sonarqube`) is Claude Code's whole-server grant and
|
|
263
|
+
// covers the writers too, so it is one.
|
|
264
|
+
if (tool === SONARQUBE.slice(0, -2)) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
if (tool.startsWith(SONARQUBE)) {
|
|
268
|
+
return !SONARQUBE_READS.has(tool.slice(SONARQUBE.length));
|
|
269
|
+
}
|
|
224
270
|
// Everybody else's MCP tools, where shape is all there is: one that writes
|
|
225
271
|
// usually says so in its name. A guess, and acceptable only because the
|
|
226
272
|
// alternative — treating every third-party MCP tool as a writer — would strip
|
|
@@ -302,7 +348,8 @@ export function toolsForStage(stage, profileTools, testMode, options = {}) {
|
|
|
302
348
|
const readOnly = (keepsCardWrite) => [
|
|
303
349
|
...allowed.filter((tool) => !canChangeThings(tool)
|
|
304
350
|
|| (delegates && DELEGATES.test(tool))
|
|
305
|
-
|| (keepsCardWrite && tool === CARD_WRITE)
|
|
351
|
+
|| (keepsCardWrite && tool === CARD_WRITE)
|
|
352
|
+
|| tool === SUGGEST),
|
|
306
353
|
...GIT_READS.filter((tool) => allowed.includes(tool) || allowed.includes('Bash(git *)')),
|
|
307
354
|
];
|
|
308
355
|
switch (stage) {
|
|
@@ -412,6 +459,10 @@ export function driftedFrom(stage, toolLine, testMode, readOnlyExperts = [], opt
|
|
|
412
459
|
if (stage === 'PLAN' && options.writesItsOwnCard && name === CARD_WRITE) {
|
|
413
460
|
return null;
|
|
414
461
|
}
|
|
462
|
+
// R572. Filing a suggestion is the other write a read-only stage was given.
|
|
463
|
+
if (name === SUGGEST) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
415
466
|
return `cawdev: the ${stage} stage called ${name}, which it should not have been able to. `
|
|
416
467
|
+ notice;
|
|
417
468
|
}
|
package/mcp/README.md
CHANGED
|
@@ -67,7 +67,8 @@ The sequence is fixed, and the second step is the one people skip:
|
|
|
67
67
|
"what is being worked on right now" without asking anyone.
|
|
68
68
|
3. Build to the card's "Done when" list.
|
|
69
69
|
4. Finish with a PR. Never push to `main`. As your session ends the platform
|
|
70
|
-
moves the *branch* on the development board, not the card: to `
|
|
70
|
+
moves the *branch* on the development board, not the card: to `TEST` (the board
|
|
71
|
+
column, not the TEST stage of a run) where
|
|
71
72
|
the project runs `require_review` and somebody is going to read it, and to
|
|
72
73
|
`DONE` where it does not — a finish nobody was asked about (R132), because
|
|
73
74
|
there is no reviewer coming.
|
|
@@ -79,9 +80,7 @@ The sequence is fixed, and the second step is the one people skip:
|
|
|
79
80
|
`roadmap_statuses` will tell you, and it is worth reading rather than guessing:
|
|
80
81
|
`IN_DEVELOPMENT` needs a branch, `MERGED` needs the merge — a pull request, a merge
|
|
81
82
|
commit, or a sha — `SHIPPED` needs a version that is a real git tag, and
|
|
82
|
-
`DECLINED` needs a reason.
|
|
83
|
-
request it would otherwise carry belongs to the run, and a project that reviews
|
|
84
|
-
without opening pull requests has none to give it.
|
|
83
|
+
`DECLINED` needs a reason.
|
|
85
84
|
|
|
86
85
|
**A card you file may land in the `BACKLOG`** (R371). Where a new card goes
|
|
87
86
|
when you name no status is the project's rule, per door, and the default for a
|
|
@@ -190,12 +189,12 @@ what you are allowed to do.
|
|
|
190
189
|
| `done_when_tick` | Tick (or untick) one numbered item of the card's Done-when checklist as built, with one line of evidence (R368). `task_current` lists the items. Defaults to the run's own card and refuses another; the reviewer's column is a person's and no token can set it. |
|
|
191
190
|
| `roadmap_set_status` | Move a card, carrying whatever the status requires. |
|
|
192
191
|
| `roadmap_decline` | Decline with a reason. The only exit. |
|
|
193
|
-
| `backlog_file` | File **feedback**, not a card — something a person should decide about. A WRITER accepts it as a card or an issue, or refuses it (R199). Tagged `agent` as a source, plus any `tags` you pass — `customer` when you relay what a user said (R372). |
|
|
192
|
+
| `backlog_file` | File **feedback**, not a card — something a person should decide about. A WRITER accepts it as a card or an issue, or refuses it (R199). Tagged `agent` as a source, plus any `tags` you pass — `customer` when you relay what a user said (R372). Inside a run, what you find that is **not this card's** goes here, one item per suggestion: the platform stamps the item with the run, so the feedback page links the run and its card, and accepting it relates the new card to yours (R572). Every stage holds it, read-only ones included. |
|
|
194
193
|
| `changelog_list` | The changelog, grouped by release, newest first. |
|
|
195
194
|
| `changelog_get` | One entry. |
|
|
196
195
|
| `changelog_add` | Add an entry; no version means `Unreleased`. |
|
|
197
196
|
| `changelog_update` | Edit one, including moving it to a release when it ships. |
|
|
198
|
-
| `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). |
|
|
197
|
+
| `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 (not those a person dismissed, R467). Read-only: nothing here refreshes, scans or files (R302). |
|
|
199
198
|
|
|
200
199
|
### Inside a run
|
|
201
200
|
|