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 +11 -1
- 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 +25 -10
- 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 +54 -1
- package/mcp/README.md +48 -36
- package/mcp/server.mjs +263 -72
- package/package.json +1 -1
- package/runner/README.md +101 -25
- 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 +675 -177
- package/runner/split.mjs +1 -1
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.
|
|
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.
|
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
|
*
|
|
@@ -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` —
|
|
338
|
-
// as the file says it.
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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()) {
|
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. */
|
|
@@ -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
|
}
|