cawdev-cli 1.0.11-beta → 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/README.md CHANGED
@@ -10,4 +10,14 @@ https://github.com/4dali/cawdev-cli
10
10
 
11
11
  `jira/import.mjs` brings a Jira Cloud project's issues, epics, sprints and
12
12
  comments into a cawdev project, once and again without duplicates (R337). Run from a checkout; see
13
- "Leaving Jira" in GUIDE.md. Its tests: `node --test "tools/jira/*.test.mjs"`.
13
+ "Leaving Jira" in GUIDE.md. `--team <team id>` (R489) names the team the sprints open in, when the project is
14
+ shared with several writer teams. Its tests: `node --test "tools/jira/*.test.mjs"`.
15
+
16
+ ## The `Sprint:` line
17
+
18
+ A card in a sprint carries one `Sprint:` line in `ROADMAP.md` and `ISSUES.md`
19
+ (`lib/roadmap-format.mjs`): `Sprint: Core team · S7 Notifications` since sprints
20
+ became a team's (R489). Files written before that, `Sprint: S7 Notifications`,
21
+ still parse (with no team). `roadmap/import.mjs` sends the words
22
+ (`Core team · S7`) for the first form and the number for the second, so a file
23
+ naming a sprint the target does not have is refused with the API's own sentence.
@@ -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, { git = runGit, push, label = 'cawdev' } = {}) {
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
+ }
@@ -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
+ }
@@ -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
  *
@@ -332,14 +334,24 @@ export function parseRoadmap(text) {
332
334
  entry.origin = origin[1].trim();
333
335
  continue;
334
336
  }
335
- const sprint = /^Sprint:\s*S(\d+)\s*(.*)$/.exec(line.trim());
337
+ const sprint = /^Sprint:\s*(?:(?!S\d+(?:\s|$))(.+?)\s*·\s*)?S(\d+)\s*(.*)$/.exec(line.trim());
336
338
  if (sprint) {
337
- // R257: `Sprint: S1 Notifications` — the sprint's number and its name
338
- // as the file says it. The same shape the API's `sprint` has, so a
339
- // re-render from either is byte-identical; the importer sends only
340
- // the number, since a sprint's name is the database's to say.
341
- const number = Number(sprint[1]);
342
- entry.sprint = { number, ref: `S${number}`, name: sprint[2].trim() };
339
+ // R257, R489: `Sprint: Core team · S1 Notifications` — a team's
340
+ // sprint, its number and its name as the file says it. A file written
341
+ // before sprints were teams' says `Sprint: S1 Notifications`; that
342
+ // still parses, with no team. The same shape the API's `sprint` has
343
+ // (`label`, `ref`, `number`, `name`), so a re-render from either is
344
+ // byte-identical; the importer sends the team's words and the number,
345
+ // since a sprint's name is the database's to say.
346
+ const number = Number(sprint[2]);
347
+ const team = sprint[1]?.trim() || null;
348
+ entry.sprint = {
349
+ team,
350
+ number,
351
+ ref: `S${number}`,
352
+ label: team ? `${team} · S${number}` : `S${number}`,
353
+ name: sprint[3].trim(),
354
+ };
343
355
  continue;
344
356
  }
345
357
  bodyLines.push(line);
@@ -496,7 +508,7 @@ function originWord(origin) {
496
508
  case 'SONARQUBE':
497
509
  return 'SonarQube';
498
510
  case 'FEEDBACK':
499
- return 'Feedback';
511
+ return origin.label ? `Feedback (from ${origin.label})` : 'Feedback';
500
512
  case 'APPLICATION':
501
513
  return origin.label ? `An application (${origin.label})` : 'An application';
502
514
  case 'SESSION':
@@ -603,7 +615,7 @@ function renderEntry(entry) {
603
615
  }
604
616
  if (entry.tags?.length) {
605
617
  // R315. Sorted, so the export does not churn on the set's own order.
606
- lines.push(`Tags: ${[...entry.tags].sort().join(', ')}`);
618
+ lines.push(`Tags: ${[...entry.tags].sort(byCodeUnit).join(', ')}`);
607
619
  }
608
620
  if (entry.assignee?.username) {
609
621
  // R331. Nothing for a card with nobody, so today's files stay byte-identical.
@@ -619,7 +631,10 @@ function renderEntry(entry) {
619
631
  // API's `sprint` and the parser's are the same shape, which is what makes
620
632
  // render → parse → render a fixed point. State is not written — the file
621
633
  // says which sprint, the database says whether it is still open.
622
- lines.push(`Sprint: ${entry.sprint.ref ?? `S${entry.sprint.number}`} ${entry.sprint.name}`.trimEnd());
634
+ // R489: the label (`Core team · S7`) is the only spelling that means
635
+ // anything outside the team; `ref` alone is the pre-teams file's.
636
+ const label = entry.sprint.label ?? entry.sprint.ref ?? `S${entry.sprint.number}`;
637
+ lines.push(`Sprint: ${label} ${entry.sprint.name ?? ''}`.trimEnd());
623
638
  }
624
639
 
625
640
  if (entry.body?.trim()) {
@@ -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
+ }
@@ -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. */
@@ -75,6 +84,8 @@ export const CAWDEV_READS = new Set([
75
84
  'quality_get',
76
85
  'roadmap_statuses',
77
86
  'roadmap_list',
87
+ // R489. The project's sprints, read from the endpoint a card's picker uses.
88
+ 'sprint_list',
78
89
  'roadmap_get',
79
90
  'issue_list',
80
91
  'changelog_list',
@@ -96,6 +107,26 @@ export const CAWDEV_READS = new Set([
96
107
  */
97
108
  const STAGE_READS = new Set([...CAWDEV_READS].filter((name) => name !== 'report'));
98
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
+
99
130
  /** The `mcp__cawdev__` prefix, once. */
100
131
  const CAWDEV = 'mcp__cawdev__';
101
132
 
@@ -112,6 +143,15 @@ const REPORT = `${CAWDEV}report`;
112
143
  */
113
144
  export const CARD_WRITE = `${CAWDEV}roadmap_create`;
114
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
+
115
155
  /**
116
156
  * Reads that only mean something once the work exists — R302.
117
157
  *
@@ -219,6 +259,14 @@ export function canChangeThings(tool) {
219
259
  if (tool.startsWith(CAWDEV)) {
220
260
  return !STAGE_READS.has(tool.slice(CAWDEV.length));
221
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
+ }
222
270
  // Everybody else's MCP tools, where shape is all there is: one that writes
223
271
  // usually says so in its name. A guess, and acceptable only because the
224
272
  // alternative — treating every third-party MCP tool as a writer — would strip
@@ -300,7 +348,8 @@ export function toolsForStage(stage, profileTools, testMode, options = {}) {
300
348
  const readOnly = (keepsCardWrite) => [
301
349
  ...allowed.filter((tool) => !canChangeThings(tool)
302
350
  || (delegates && DELEGATES.test(tool))
303
- || (keepsCardWrite && tool === CARD_WRITE)),
351
+ || (keepsCardWrite && tool === CARD_WRITE)
352
+ || tool === SUGGEST),
304
353
  ...GIT_READS.filter((tool) => allowed.includes(tool) || allowed.includes('Bash(git *)')),
305
354
  ];
306
355
  switch (stage) {
@@ -410,6 +459,10 @@ export function driftedFrom(stage, toolLine, testMode, readOnlyExperts = [], opt
410
459
  if (stage === 'PLAN' && options.writesItsOwnCard && name === CARD_WRITE) {
411
460
  return null;
412
461
  }
462
+ // R572. Filing a suggestion is the other write a read-only stage was given.
463
+ if (name === SUGGEST) {
464
+ return null;
465
+ }
413
466
  return `cawdev: the ${stage} stage called ${name}, which it should not have been able to. `
414
467
  + notice;
415
468
  }