cawdev-cli 1.0.3-beta → 1.0.5-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/agents.mjs +27 -0
- package/lib/ansi.mjs +2 -0
- package/lib/codex.mjs +214 -0
- package/lib/harness-prompt.mjs +26 -7
- package/lib/runner-config.mjs +3 -2
- package/lib/stage-tools.mjs +33 -2
- package/lib/tool-line.mjs +19 -8
- package/mcp/README.md +1 -0
- package/mcp/server.mjs +150 -0
- package/package.json +2 -5
- package/runner/README.md +357 -6
- package/runner/attach.mjs +1269 -24
- package/runner/auto-workspaces.mjs +106 -0
- package/runner/bootstrap.mjs +37 -5
- package/runner/cawdev.mjs +49 -13
- package/runner/changed-lines.mjs +87 -0
- package/runner/configure.mjs +77 -3
- package/runner/runner.mjs +1529 -101
- package/runner/select.mjs +31 -4
- package/runner/split.mjs +830 -0
- package/runner/macbook-laptop.json +0 -48
package/lib/agents.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coding agents a machine can spawn, spelled once.
|
|
3
|
+
*
|
|
4
|
+
* <p>Three CLIs, each a command on the PATH: Claude Code, Antigravity and
|
|
5
|
+
* Codex. The setup walk, `cawdev config agent`, the config screen and the
|
|
6
|
+
* daemon all ask "which agent is this command" and "what is it called", and
|
|
7
|
+
* the answer was a `=== 'agy' ? … : 'Claude Code'` in five places — right for
|
|
8
|
+
* two agents and silently wrong for a third.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Every agent, in the order they are offered. */
|
|
12
|
+
export const KNOWN_AGENTS = ['claude', 'agy', 'codex'];
|
|
13
|
+
|
|
14
|
+
const LABELS = { claude: 'Claude Code', agy: 'Antigravity CLI', codex: 'Codex CLI' };
|
|
15
|
+
|
|
16
|
+
/** Which agent a configured command is — by its name, the rule `agentCommands` always used. */
|
|
17
|
+
export function agentOfCommand(command) {
|
|
18
|
+
const name = String(command ?? '').split('/').pop();
|
|
19
|
+
if (name.endsWith('agy')) return 'agy';
|
|
20
|
+
if (name.endsWith('codex')) return 'codex';
|
|
21
|
+
return 'claude';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What a person calls the agent behind a command. */
|
|
25
|
+
export function agentLabel(command) {
|
|
26
|
+
return LABELS[agentOfCommand(command)];
|
|
27
|
+
}
|
package/lib/ansi.mjs
CHANGED
|
@@ -102,6 +102,8 @@ export function painter(depth = colourDepth()) {
|
|
|
102
102
|
text: ink([0xd4, 0xd0, 0xcc], 37),
|
|
103
103
|
|
|
104
104
|
bold: wrap('\x1b[1m'),
|
|
105
|
+
/** Bold AND underlined — a fact the eye should find first (advanced mode's agent · model). */
|
|
106
|
+
strong: wrap('\x1b[1;4m'),
|
|
105
107
|
dim: wrap('\x1b[2m'),
|
|
106
108
|
reverse: wrap('\x1b[7m'),
|
|
107
109
|
/** Plain, for a value that should not compete with the label beside it. */
|
package/lib/codex.mjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI's Codex CLI as a third agent — the arguments a spawn is given and the
|
|
3
|
+
* events it prints, and nothing that touches a process.
|
|
4
|
+
*
|
|
5
|
+
* In lib/ for `code-map.mjs`'s reason: the hard parts are a command line and a
|
|
6
|
+
* stream, both testable without a daemon, a platform or a model. The spawn
|
|
7
|
+
* itself is `spawnCodex` in runner.mjs, beside `spawnAntigravity`.
|
|
8
|
+
*
|
|
9
|
+
* <p>Every flag and field here was checked against the real CLI (0.155) by
|
|
10
|
+
* hand rather than read off documentation:
|
|
11
|
+
*
|
|
12
|
+
* <ul>
|
|
13
|
+
* <li>`codex exec --json` prints JSONL: `thread.started` (carries the id
|
|
14
|
+
* `resume` takes), `item.started`/`item.completed` for `agent_message`,
|
|
15
|
+
* `command_execution`, `mcp_tool_call`, `file_change`…, then
|
|
16
|
+
* `turn.completed` with `usage` or `turn.failed` with `error`.
|
|
17
|
+
* <li>In `exec` the approval policy is NEVER, so an MCP call is refused
|
|
18
|
+
* outright ("requires approval, but approval policy is never") unless its
|
|
19
|
+
* server says `default_tools_approval_mode = "approve"`. With it, cawdev's
|
|
20
|
+
* own server is reached even under `--sandbox read-only`, network included:
|
|
21
|
+
* the sandbox is the SHELL's, not the MCP servers'.
|
|
22
|
+
* <li>`enabled_tools` really narrows a server — a tool left out is not offered
|
|
23
|
+
* at all — and `disabled_tools` takes one away.
|
|
24
|
+
* <li>`env_vars` forwards named variables from Codex's own environment to a
|
|
25
|
+
* server, which keeps the run's token off the command line, where `ps`
|
|
26
|
+
* would show it to anybody on the machine.
|
|
27
|
+
* <li>`shell_environment_policy.set` blanks a variable for every command the
|
|
28
|
+
* model runs — and `exclude` does NOT (tried: `echo $CAWDEV_TOKEN` still
|
|
29
|
+
* printed it). The token reaches cawdev's server through `env_vars`, which
|
|
30
|
+
* reads Codex's own environment and not the shell's, so blanking it for the
|
|
31
|
+
* shell costs the server nothing.
|
|
32
|
+
* <li>`resume` takes the exec-level options BEFORE the subcommand:
|
|
33
|
+
* `codex exec --json -s read-only resume <id> -`; after it, `-s` is refused.
|
|
34
|
+
* <li>`--strict-config` refuses a field it does not know, so a key misspelled
|
|
35
|
+
* here fails the spawn out loud instead of being ignored.
|
|
36
|
+
* </ul>
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** The `mcp__cawdev__` prefix off a Claude Code tool list, keeping cawdev's own names only. */
|
|
40
|
+
export function cawdevToolNames(tools) {
|
|
41
|
+
const prefix = 'mcp__cawdev__';
|
|
42
|
+
return [...new Set((Array.isArray(tools) ? tools : [])
|
|
43
|
+
.filter((tool) => typeof tool === 'string' && tool.startsWith(prefix))
|
|
44
|
+
.map((tool) => tool.slice(prefix.length)))];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A value as TOML, which is what `-c key=value` parses.
|
|
49
|
+
*
|
|
50
|
+
* <p>A JSON string is a TOML basic string for everything JSON escapes; DEL is
|
|
51
|
+
* the one control character JSON leaves alone and TOML refuses.
|
|
52
|
+
*/
|
|
53
|
+
export function tomlValue(value) {
|
|
54
|
+
if (Array.isArray(value)) {
|
|
55
|
+
return `[${value.map(tomlValue).join(', ')}]`;
|
|
56
|
+
}
|
|
57
|
+
if (typeof value === 'boolean' || typeof value === 'number') {
|
|
58
|
+
return String(value);
|
|
59
|
+
}
|
|
60
|
+
if (value && typeof value === 'object') {
|
|
61
|
+
const entries = Object.entries(value).map(([key, each]) => `${tomlKey(key)} = ${tomlValue(each)}`);
|
|
62
|
+
return `{ ${entries.join(', ')} }`;
|
|
63
|
+
}
|
|
64
|
+
return JSON.stringify(String(value ?? '')).replace(/\u007f/g, '\\u007f');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function tomlKey(key) {
|
|
68
|
+
return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlValue(String(key));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The command line for one Codex turn. The prompt goes on stdin (`-`).
|
|
73
|
+
*
|
|
74
|
+
* @param options.sandbox `read-only` — the OS refuses every write the shell
|
|
75
|
+
* attempts — or `bypass`, which is `--dangerously-bypass-approvals-and-sandbox`
|
|
76
|
+
* and is only ever passed where the machine has allowed everything.
|
|
77
|
+
* @param options.servers `[{ name, command, args, env, envVars, enabledTools,
|
|
78
|
+
* disabledTools }]`, each approved, since nobody is there to approve a call.
|
|
79
|
+
* @param options.hideFromShell variables blanked for the model's own commands.
|
|
80
|
+
*/
|
|
81
|
+
export function codexArgs({
|
|
82
|
+
model = null, effort = null, sandbox, resumeId = null, servers = [], hideFromShell = [],
|
|
83
|
+
}) {
|
|
84
|
+
if (sandbox !== 'read-only' && sandbox !== 'bypass') {
|
|
85
|
+
throw new Error(`codexArgs: sandbox must be read-only or bypass, not ${sandbox}`);
|
|
86
|
+
}
|
|
87
|
+
// `--ignore-user-config` is `--setting-sources ''` for this CLI: the
|
|
88
|
+
// operator's own ~/.codex/config.toml can add MCP servers, hooks and an
|
|
89
|
+
// approval reviewer, and none of that may widen what a run was given. The
|
|
90
|
+
// sign-in is untouched — it is read from CODEX_HOME either way.
|
|
91
|
+
const args = ['exec', '--json', '--strict-config', '--ignore-user-config'];
|
|
92
|
+
args.push(...(sandbox === 'bypass'
|
|
93
|
+
? ['--dangerously-bypass-approvals-and-sandbox']
|
|
94
|
+
: ['--sandbox', 'read-only']));
|
|
95
|
+
if (model) {
|
|
96
|
+
args.push('--model', model);
|
|
97
|
+
}
|
|
98
|
+
if (effort) {
|
|
99
|
+
args.push('-c', `model_reasoning_effort=${tomlValue(effort)}`);
|
|
100
|
+
}
|
|
101
|
+
if (hideFromShell.length) {
|
|
102
|
+
args.push('-c', `shell_environment_policy.set=${tomlValue(
|
|
103
|
+
Object.fromEntries(hideFromShell.map((name) => [name, ''])))}`);
|
|
104
|
+
}
|
|
105
|
+
for (const server of servers) {
|
|
106
|
+
const key = `mcp_servers.${tomlKey(server.name)}`;
|
|
107
|
+
args.push('-c', `${key}.command=${tomlValue(server.command)}`);
|
|
108
|
+
if (server.args?.length) {
|
|
109
|
+
args.push('-c', `${key}.args=${tomlValue(server.args)}`);
|
|
110
|
+
}
|
|
111
|
+
if (server.env && Object.keys(server.env).length) {
|
|
112
|
+
args.push('-c', `${key}.env=${tomlValue(server.env)}`);
|
|
113
|
+
}
|
|
114
|
+
if (server.envVars?.length) {
|
|
115
|
+
args.push('-c', `${key}.env_vars=${tomlValue(server.envVars)}`);
|
|
116
|
+
}
|
|
117
|
+
if (server.enabledTools) {
|
|
118
|
+
args.push('-c', `${key}.enabled_tools=${tomlValue(server.enabledTools)}`);
|
|
119
|
+
}
|
|
120
|
+
if (server.disabledTools?.length) {
|
|
121
|
+
args.push('-c', `${key}.disabled_tools=${tomlValue(server.disabledTools)}`);
|
|
122
|
+
}
|
|
123
|
+
args.push('-c', `${key}.default_tools_approval_mode="approve"`);
|
|
124
|
+
}
|
|
125
|
+
if (resumeId) {
|
|
126
|
+
args.push('resume', resumeId);
|
|
127
|
+
}
|
|
128
|
+
args.push('-');
|
|
129
|
+
return args;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A tool result's text, whatever shape the MCP server gave it. */
|
|
133
|
+
function resultText(result) {
|
|
134
|
+
const content = result?.content;
|
|
135
|
+
if (Array.isArray(content)) {
|
|
136
|
+
const text = content.map((each) => each?.text ?? '').filter(Boolean).join('\n');
|
|
137
|
+
if (text) return text;
|
|
138
|
+
}
|
|
139
|
+
return result == null ? '(no output)' : JSON.stringify(result);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One line of `codex exec --json`, as what the transcript and the run need.
|
|
144
|
+
*
|
|
145
|
+
* <p>Returns `{ sessionId?, lines: [{ kind, body }], usage?, failed?, error?, ended? }`.
|
|
146
|
+
* `ended` is true on `turn.completed` — the turn ending, which is the whole of
|
|
147
|
+
* what a stage is judged by. `failed` is `turn.failed`'s sentence; `error` a
|
|
148
|
+
* top-level error's, which may be passing. An event this version does not know
|
|
149
|
+
* yields nothing.
|
|
150
|
+
*/
|
|
151
|
+
export function translateCodexEvent(event) {
|
|
152
|
+
const out = { lines: [] };
|
|
153
|
+
const item = event?.item ?? {};
|
|
154
|
+
switch (event?.type) {
|
|
155
|
+
case 'thread.started':
|
|
156
|
+
out.sessionId = event.thread_id ?? null;
|
|
157
|
+
break;
|
|
158
|
+
case 'item.started':
|
|
159
|
+
if (item.type === 'command_execution') {
|
|
160
|
+
out.lines.push({ kind: 'TOOL', body: `Bash(${item.command ?? ''})` });
|
|
161
|
+
} else if (item.type === 'mcp_tool_call') {
|
|
162
|
+
out.lines.push({
|
|
163
|
+
kind: 'TOOL',
|
|
164
|
+
body: `mcp__${item.server}__${item.tool}(${JSON.stringify(item.arguments ?? {})})`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
case 'item.completed':
|
|
169
|
+
if (item.type === 'agent_message' && item.text) {
|
|
170
|
+
out.lines.push({ kind: 'ASSISTANT', body: item.text });
|
|
171
|
+
} else if (item.type === 'command_execution') {
|
|
172
|
+
const output = String(item.aggregated_output ?? '').slice(0, 4000);
|
|
173
|
+
out.lines.push({
|
|
174
|
+
kind: 'TOOL_RESULT',
|
|
175
|
+
body: output || `(exit ${item.exit_code ?? '?'})`,
|
|
176
|
+
});
|
|
177
|
+
} else if (item.type === 'mcp_tool_call') {
|
|
178
|
+
out.lines.push({
|
|
179
|
+
kind: 'TOOL_RESULT',
|
|
180
|
+
body: item.error
|
|
181
|
+
? `failed: ${item.error.message ?? JSON.stringify(item.error)}`
|
|
182
|
+
: resultText(item.result).slice(0, 4000),
|
|
183
|
+
});
|
|
184
|
+
} else if (item.type === 'file_change') {
|
|
185
|
+
const changes = (item.changes ?? [])
|
|
186
|
+
.map((each) => `${each.kind ?? 'update'} ${each.path ?? ''}`.trim());
|
|
187
|
+
out.lines.push({ kind: 'TOOL', body: `Edit(${changes.join(', ') || 'files'})` });
|
|
188
|
+
} else if (item.type === 'error' && item.message) {
|
|
189
|
+
out.lines.push({ kind: 'SYSTEM', body: item.message });
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
case 'turn.completed':
|
|
193
|
+
out.ended = true;
|
|
194
|
+
out.usage = {
|
|
195
|
+
input: Number(event.usage?.input_tokens) || 0,
|
|
196
|
+
output: Number(event.usage?.output_tokens) || 0,
|
|
197
|
+
};
|
|
198
|
+
break;
|
|
199
|
+
case 'turn.failed':
|
|
200
|
+
out.failed = event.error?.message ?? 'Codex reported the turn failed.';
|
|
201
|
+
out.lines.push({ kind: 'SYSTEM', body: out.failed });
|
|
202
|
+
break;
|
|
203
|
+
case 'error':
|
|
204
|
+
// NOT `failed`: a top-level error is also how a retried stream says
|
|
205
|
+
// "Reconnecting… 2/5". Only `turn.failed` ends a turn badly; this is
|
|
206
|
+
// kept as the last thing it said, for when nothing better arrives.
|
|
207
|
+
out.error = event.message ?? 'Codex reported an error.';
|
|
208
|
+
out.lines.push({ kind: 'SYSTEM', body: out.error });
|
|
209
|
+
break;
|
|
210
|
+
default:
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
package/lib/harness-prompt.mjs
CHANGED
|
@@ -33,7 +33,7 @@ const MAX_CONFIG_BYTES = 32 * 1024;
|
|
|
33
33
|
* two halves meet in this function and nowhere else.
|
|
34
34
|
*/
|
|
35
35
|
export function harnessPrompt({
|
|
36
|
-
instincts, briefing, plan, lifecycle, repoConfig, experts, skills, cannotDelegate,
|
|
36
|
+
instincts, briefing, plan, lifecycle, repoConfig, experts, skills, mcpServers, cannotDelegate,
|
|
37
37
|
}) {
|
|
38
38
|
const parts = [];
|
|
39
39
|
|
|
@@ -48,10 +48,13 @@ export function harnessPrompt({
|
|
|
48
48
|
//
|
|
49
49
|
// R161. A REQUIRED one is named FIRST and in its own block. Two lists rather
|
|
50
50
|
// than one annotated list, because a `(required)` suffix on a line in a list
|
|
51
|
-
// of twelve is a detail; a heading is an instruction.
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
51
|
+
// of twelve is a detail; a heading is an instruction. When a project
|
|
52
|
+
// requires nothing at all — every project until somebody says otherwise —
|
|
53
|
+
// this function still returns exactly what it returned before the entry:
|
|
54
|
+
// the AS_NEEDED wording below has since been extended once, by i257, but a
|
|
55
|
+
// project with no `mustExperts`/`mustSkills`/`mustServers` never reaches
|
|
56
|
+
// either block, so the claim is about that project, not about the wording
|
|
57
|
+
// itself staying fixed forever.
|
|
55
58
|
const reach = [];
|
|
56
59
|
const requiredOf = (list) => (list ?? []).filter((each) => each.required);
|
|
57
60
|
const optionalOf = (list) => (list ?? []).filter((each) => !each.required);
|
|
@@ -63,12 +66,17 @@ export function harnessPrompt({
|
|
|
63
66
|
if (mustExperts.length) {
|
|
64
67
|
reach.push('**Experts you MUST use** — this project requires each of these. Delegate '
|
|
65
68
|
+ 'with the `Agent` tool, `subagent_type` exactly as written, before this stage is '
|
|
66
|
-
+
|
|
69
|
+
+ 'finished. This stage is one process and ends when your turn does: a backgrounded '
|
|
70
|
+
+ '`Agent` call that has not reported back by then is lost, and so is anything gated on '
|
|
71
|
+
+ 'it. Pass `run_in_background: false` and wait for each of these in the same turn — do '
|
|
72
|
+
+ 'not say "I\'ll report back" and stop.\n'
|
|
73
|
+
+ `${lines(mustExperts)}`);
|
|
67
74
|
}
|
|
68
75
|
if (mayExperts.length) {
|
|
69
76
|
reach.push('**Experts** — delegate with the `Agent` tool, `subagent_type` exactly as '
|
|
70
77
|
+ 'written. Use one whenever its description fits the step you are on; they were '
|
|
71
|
-
+ 'chosen for this project
|
|
78
|
+
+ 'chosen for this project. The same rule applies: a call backgrounded past the end of '
|
|
79
|
+
+ 'your turn never comes back.\n'
|
|
72
80
|
+ lines(mayExperts));
|
|
73
81
|
} else if (!mustExperts.length && cannotDelegate?.length) {
|
|
74
82
|
reach.push(`**Experts** — ${cannotDelegate.join(' ')}`);
|
|
@@ -86,6 +94,17 @@ export function harnessPrompt({
|
|
|
86
94
|
+ 'work its description covers.\n'
|
|
87
95
|
+ lines(maySkills));
|
|
88
96
|
}
|
|
97
|
+
|
|
98
|
+
// R303. An MCP server the project REQUIRES gets the same heading. Nothing
|
|
99
|
+
// at all for an as-needed one — a server reaches a session through the CLI's
|
|
100
|
+
// own tool listing, and this function said nothing about servers before the
|
|
101
|
+
// entry, so a project that requires none gets byte-for-byte what it got.
|
|
102
|
+
const mustServers = requiredOf(mcpServers);
|
|
103
|
+
if (mustServers.length) {
|
|
104
|
+
reach.push('**MCP servers you MUST use** — this project requires each of these. Call at '
|
|
105
|
+
+ 'least one tool under the prefix, before this stage is finished.\n'
|
|
106
|
+
+ mustServers.map((each) => `- \`${each.prefix}__…\` — ${each.name}`).join('\n'));
|
|
107
|
+
}
|
|
89
108
|
if (reach.length) {
|
|
90
109
|
parts.push(`## What this session may reach\n\n${reach.join('\n\n')}`);
|
|
91
110
|
}
|
package/lib/runner-config.mjs
CHANGED
|
@@ -25,7 +25,8 @@
|
|
|
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
|
-
export const HOT_SWAPPABLE = Object.freeze(['agentCommands', 'grantable', 'acceptsRulesFromConsole'
|
|
28
|
+
export const HOT_SWAPPABLE = Object.freeze(['agentCommands', 'grantable', 'acceptsRulesFromConsole',
|
|
29
|
+
'autoWorkspaces']);
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* The legacy spelling of one key, read as the current one — so a config that
|
|
@@ -60,7 +61,7 @@ export function restartNeededFor(booted, file) {
|
|
|
60
61
|
return [...keys]
|
|
61
62
|
.filter((key) => !HOT_SWAPPABLE.includes(key))
|
|
62
63
|
.filter((key) => canonical(before[key]) !== canonical(after[key]))
|
|
63
|
-
.sort();
|
|
64
|
+
.sort((a, b) => a.localeCompare(b));
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/** Applied, or would be on a SIGHUP: the hot-swappable keys that differ. */
|
package/lib/stage-tools.mjs
CHANGED
|
@@ -70,6 +70,9 @@ export const CAWDEV_READS = new Set([
|
|
|
70
70
|
'roadmap_where',
|
|
71
71
|
'code_map',
|
|
72
72
|
'file_deps',
|
|
73
|
+
// R302. What SonarQube made of the project and of a branch: a read of rows
|
|
74
|
+
// other things wrote, and nothing on its endpoint a token could write.
|
|
75
|
+
'quality_get',
|
|
73
76
|
'roadmap_statuses',
|
|
74
77
|
'roadmap_list',
|
|
75
78
|
'roadmap_get',
|
|
@@ -109,6 +112,30 @@ const REPORT = `${CAWDEV}report`;
|
|
|
109
112
|
*/
|
|
110
113
|
export const CARD_WRITE = `${CAWDEV}roadmap_create`;
|
|
111
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Reads that only mean something once the work exists — R302.
|
|
117
|
+
*
|
|
118
|
+
* <p>The first list here that narrows by NEED rather than by what a tool can
|
|
119
|
+
* change, and the reason it is a named list rather than a line in each arm: it
|
|
120
|
+
* is read in two places — `toolsForStage` for a walk's PLAN and IMPLEMENT, and
|
|
121
|
+
* `runner.mjs`'s `PROFILE_TOOLS.PLAN` for a plan run spawned as one process —
|
|
122
|
+
* and a name in one place and a literal in the other is how the two drift.
|
|
123
|
+
* Keep it to names a stage before the code exists has no use for: a branch's
|
|
124
|
+
* SonarQube gate is a fact about a tree that PLAN has not written and IMPLEMENT
|
|
125
|
+
* is still writing. The smallest tool list is the rule, and this is the list.
|
|
126
|
+
*
|
|
127
|
+
* <p>Two readers, not three: an Antigravity spawn (`spawnAntigravity`) builds
|
|
128
|
+
* no tool list at all — it cannot narrow, by its own comment — so a PLAN run
|
|
129
|
+
* on that agent holds this read with everything else the server exposes. A
|
|
130
|
+
* pre-existing gap, named here so nobody counts this list as closing it.
|
|
131
|
+
*/
|
|
132
|
+
export const AFTER_THE_WORK_EXISTS = [`${CAWDEV}quality_get`];
|
|
133
|
+
|
|
134
|
+
/** The profile's list less what `AFTER_THE_WORK_EXISTS` names — the narrowing above, as a step. */
|
|
135
|
+
export function beforeTheWorkExists(tools) {
|
|
136
|
+
return tools.filter((tool) => !AFTER_THE_WORK_EXISTS.includes(tool));
|
|
137
|
+
}
|
|
138
|
+
|
|
112
139
|
/** The tool a session delegates with, and what an older CLI called it. */
|
|
113
140
|
const DELEGATES = /^(Agent|Task)\b/;
|
|
114
141
|
|
|
@@ -284,7 +311,9 @@ export function toolsForStage(stage, profileTools, testMode, options = {}) {
|
|
|
284
311
|
// second place deciding what a profile may do. That subsetting is what
|
|
285
312
|
// makes the card write safe to allow here — the profile has to have
|
|
286
313
|
// handed it over first, and only a cardless plan run's does.
|
|
287
|
-
|
|
314
|
+
// R302. And less the reads that need the work to exist — the narrowing
|
|
315
|
+
// by NEED rather than by capability, and the only one; see the list.
|
|
316
|
+
return beforeTheWorkExists(readOnly(writesItsOwnCard));
|
|
288
317
|
case 'VERIFY':
|
|
289
318
|
case 'MEMORY':
|
|
290
319
|
return readOnly(false);
|
|
@@ -299,7 +328,9 @@ export function toolsForStage(stage, profileTools, testMode, options = {}) {
|
|
|
299
328
|
}
|
|
300
329
|
return allowed;
|
|
301
330
|
case 'IMPLEMENT':
|
|
302
|
-
|
|
331
|
+
// R302. The profile's list less the reads that need the work to exist:
|
|
332
|
+
// the branch this stage is writing has no gate until TEST scans it.
|
|
333
|
+
return beforeTheWorkExists(allowed);
|
|
303
334
|
default:
|
|
304
335
|
// A stage this version does not know is given the profile's own tools —
|
|
305
336
|
// the same as no lifecycle at all. Refusing everything would turn a
|
package/lib/tool-line.mjs
CHANGED
|
@@ -72,21 +72,32 @@ export function describeInput(input) {
|
|
|
72
72
|
/**
|
|
73
73
|
* Which capability a TOOL line named, if it named one — R161.
|
|
74
74
|
*
|
|
75
|
-
* <p>The reader for the
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
75
|
+
* <p>The reader for the three shapes a transcript holds: `Skill(<key>)` and
|
|
76
|
+
* `Agent(<key>)`, which {@link describeCall} writes, and `mcp__<server>__<tool>`,
|
|
77
|
+
* which is the CLI's own name for an MCP server's tool and reaches the
|
|
78
|
+
* transcript through the ordinary `${name} ${input}` path — R303 brought this
|
|
79
|
+
* reader level with `CapabilityUse.java` and `capability-tag.ts`, which both
|
|
80
|
+
* read that shape already. The daemon asks it "has this stage used the thing
|
|
81
|
+
* the project requires", which is a question about a string, so it belongs
|
|
82
|
+
* beside the code that wrote the string and in `lib/` with it — answering it
|
|
83
|
+
* should not need a daemon, a platform and a spawned agent.
|
|
80
84
|
*
|
|
81
85
|
* <p>Third reader of the contract this file's header describes. `Task(` is not
|
|
82
86
|
* matched because `describeCall` normalises it away before anything is written:
|
|
83
|
-
* what reaches a transcript is always `Agent(`.
|
|
87
|
+
* what reaches a transcript is always `Agent(`. cawdev's own server is not a
|
|
88
|
+
* capability — every session has it and nobody chose it — so `mcp__cawdev__…`
|
|
89
|
+
* is null here as it is in the platform's reader.
|
|
84
90
|
*
|
|
85
91
|
* @returns `{kind, key}`, or null for anything that is not a capability call.
|
|
86
92
|
*/
|
|
87
93
|
export function capabilityIn(line) {
|
|
88
94
|
if (typeof line !== 'string') return null;
|
|
89
95
|
const found = /^(Skill|Agent)\(([^)]+)\)/.exec(line);
|
|
90
|
-
if (
|
|
91
|
-
|
|
96
|
+
if (found) return { kind: found[1] === 'Skill' ? 'SKILL' : 'EXPERT', key: found[2] };
|
|
97
|
+
const server = /^mcp__([A-Za-z0-9_-]+)__/.exec(line);
|
|
98
|
+
if (server && server[1] !== OUR_SERVER) return { kind: 'MCP_SERVER', key: server[1] };
|
|
99
|
+
return null;
|
|
92
100
|
}
|
|
101
|
+
|
|
102
|
+
/** `CapabilityUse.OURS`: the run's own MCP server, machinery rather than a capability. */
|
|
103
|
+
const OUR_SERVER = 'cawdev';
|
package/mcp/README.md
CHANGED
|
@@ -172,6 +172,7 @@ what you are allowed to do.
|
|
|
172
172
|
| `changelog_get` | One entry. |
|
|
173
173
|
| `changelog_add` | Add an entry; no version means `Unreleased`. |
|
|
174
174
|
| `changelog_update` | Edit one, including moving it to a release when it ships. |
|
|
175
|
+
| `quality_get` | The SonarQube reading — the default branch's gate, a named branch's gate and new problems (inside a run, this run's branch by default), and the open findings with links. Read-only: nothing here refreshes, scans or files (R302). |
|
|
175
176
|
|
|
176
177
|
### Inside a run
|
|
177
178
|
|
package/mcp/server.mjs
CHANGED
|
@@ -536,6 +536,52 @@ const TOOLS = [
|
|
|
536
536
|
},
|
|
537
537
|
},
|
|
538
538
|
|
|
539
|
+
{
|
|
540
|
+
name: 'quality_get',
|
|
541
|
+
description:
|
|
542
|
+
"The SonarQube reading of this project — the default branch's gate and its counts — and, " +
|
|
543
|
+
"if you name a branch, that branch's gate and how many NEW bugs, vulnerabilities, hotspots " +
|
|
544
|
+
"and smells it adds, with the SonarQube link. Inside a run, the branch defaults to this " +
|
|
545
|
+
"run's. A branch nobody has scanned yet says so. A red gate with new vulnerabilities is a " +
|
|
546
|
+
'finding to report, not a failure to hide. Read-only: nothing here refreshes, scans or files.',
|
|
547
|
+
inputSchema: {
|
|
548
|
+
type: 'object',
|
|
549
|
+
properties: {
|
|
550
|
+
...PROJECT_ARGUMENT,
|
|
551
|
+
branch: {
|
|
552
|
+
type: 'string',
|
|
553
|
+
description:
|
|
554
|
+
"A branch name. Omit inside a run to read this run's branch; omit outside one for " +
|
|
555
|
+
'the default branch only.',
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
handler: async (config, args) => {
|
|
560
|
+
const slug = await resolveProject(config, args.project);
|
|
561
|
+
// R302. The run's own branch when none was named — the TEST stage that
|
|
562
|
+
// calls this is asking about the branch it just tested, and should not
|
|
563
|
+
// have to know its name. Its own try: a plain `cawd_` token has no run,
|
|
564
|
+
// and `requireRun` refuses it with a sentence about orchestration tools
|
|
565
|
+
// that would be wrong here — outside a run, the default branch alone is
|
|
566
|
+
// the ordinary answer, not a refusal.
|
|
567
|
+
const branch = args.branch
|
|
568
|
+
?? await requireRun(config)
|
|
569
|
+
.then(({ runId, project }) => api(config, `/api/projects/${project}/runs/${runId}`))
|
|
570
|
+
.then((run) => run?.branch ?? null)
|
|
571
|
+
.catch(() => null);
|
|
572
|
+
const reading = await api(
|
|
573
|
+
config,
|
|
574
|
+
`/api/projects/${slug}/quality/reading${branch ? `?branch=${encodeURIComponent(branch)}` : ''}`,
|
|
575
|
+
);
|
|
576
|
+
// A 204 has no body, and `api` hands that back as null.
|
|
577
|
+
if (!reading) {
|
|
578
|
+
return `${slug} is not on SonarQube — no server, token or project key set on it. `
|
|
579
|
+
+ 'Work as you would have anyway.';
|
|
580
|
+
}
|
|
581
|
+
return formatQualityReading(reading, branch);
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
|
|
539
585
|
{
|
|
540
586
|
name: 'roadmap_comment',
|
|
541
587
|
description:
|
|
@@ -1907,6 +1953,110 @@ function formatFileDeps(map, path) {
|
|
|
1907
1953
|
return lines.join('\n');
|
|
1908
1954
|
}
|
|
1909
1955
|
|
|
1956
|
+
/** How many finding lines a reading prints before saying "and N more" — R302. */
|
|
1957
|
+
const FINDING_LINES = 50;
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* The reading as a session should read it — R302: one line for the default
|
|
1961
|
+
* branch, one for the branch asked about, then the open findings.
|
|
1962
|
+
*
|
|
1963
|
+
* <p>The branch line comes SECOND and not first, though it is the one the
|
|
1964
|
+
* caller asked about: the default branch's gate is what a person sees on the
|
|
1965
|
+
* project's door, and "your branch is red" means something different under a
|
|
1966
|
+
* green main than under a red one. The findings are the default branch's,
|
|
1967
|
+
* and the last line says so, because a session that read "2 new
|
|
1968
|
+
* vulnerabilities" and then a list of twelve will otherwise look for the two
|
|
1969
|
+
* among the twelve.
|
|
1970
|
+
*/
|
|
1971
|
+
function formatQualityReading(reading, branch) {
|
|
1972
|
+
const lines = [];
|
|
1973
|
+
const project = reading.project;
|
|
1974
|
+
if (!project) {
|
|
1975
|
+
lines.push('Default branch: connected to SonarQube, but nothing has read it yet — the timer '
|
|
1976
|
+
+ 'has not fired since the connection was set.');
|
|
1977
|
+
} else {
|
|
1978
|
+
// A failed read is the headline, not a clause after the gate: a session
|
|
1979
|
+
// skimming for the verdict stops at the first token, and `gate OK · the
|
|
1980
|
+
// last read failed` reads as OK. The numbers are a dated echo and say so.
|
|
1981
|
+
const parts = project.error
|
|
1982
|
+
? [`the last read failed: ${project.error}`, `before that: gate ${project.gate ?? 'NONE'}`]
|
|
1983
|
+
: [`gate ${project.gate ?? 'NONE'}`];
|
|
1984
|
+
parts.push(
|
|
1985
|
+
`${count(project.bugs)} bugs`,
|
|
1986
|
+
`${count(project.vulnerabilities)} vulnerabilities`,
|
|
1987
|
+
`${count(project.securityHotspots)} hotspots`,
|
|
1988
|
+
`${count(project.codeSmells)} smells`,
|
|
1989
|
+
`coverage ${percent(project.coverage)}`,
|
|
1990
|
+
);
|
|
1991
|
+
if (project.analysedAt) parts.push(`analysed ${project.analysedAt}`);
|
|
1992
|
+
if (project.dashboardUrl) parts.push(project.dashboardUrl);
|
|
1993
|
+
lines.push(`Default branch: ${parts.join(' · ')}`);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
const asked = reading.branch;
|
|
1997
|
+
if (asked) {
|
|
1998
|
+
if (!asked.scanned) {
|
|
1999
|
+
lines.push(`Branch ${asked.branch}: no machine has scanned it yet. A coding run's TEST stage `
|
|
2000
|
+
+ 'is when a scan happens; until then the default branch above is all there is.');
|
|
2001
|
+
} else if (asked.reading?.error) {
|
|
2002
|
+
lines.push(`Branch ${asked.branch}: the scan could not run — ${asked.reading.error}`);
|
|
2003
|
+
} else {
|
|
2004
|
+
const row = asked.reading;
|
|
2005
|
+
const parts = [
|
|
2006
|
+
`gate ${row.gate ?? 'NONE'}`,
|
|
2007
|
+
`new: ${count(row.newBugs)} bugs`,
|
|
2008
|
+
`${count(row.newVulnerabilities)} vulnerabilities`,
|
|
2009
|
+
`${count(row.newSecurityHotspots)} hotspots`,
|
|
2010
|
+
`${count(row.newCodeSmells)} smells`,
|
|
2011
|
+
`new coverage ${percent(row.newCoverage)}`,
|
|
2012
|
+
];
|
|
2013
|
+
if (row.analysedAs === 'MAIN') parts.push('analysed as the main branch, not under its own name');
|
|
2014
|
+
if (row.analysedAs === 'PROJECT') {
|
|
2015
|
+
parts.push('analysed as its own SonarQube project; new = on lines this branch changed; no gate');
|
|
2016
|
+
}
|
|
2017
|
+
if (row.headSha) parts.push(`at ${row.headSha.slice(0, 7)}`);
|
|
2018
|
+
if (row.dashboardUrl) parts.push(row.dashboardUrl);
|
|
2019
|
+
lines.push(`Branch ${asked.branch}: ${parts.join(' · ')}`);
|
|
2020
|
+
if (row.warning) lines.push(` ${row.warning}`);
|
|
2021
|
+
}
|
|
2022
|
+
} else if (branch === null) {
|
|
2023
|
+
lines.push('No branch was read: outside a run, name one to see its gate and new problems.');
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
const findings = reading.findings ?? [];
|
|
2027
|
+
lines.push('');
|
|
2028
|
+
if (!findings.length) {
|
|
2029
|
+
lines.push('Open findings: none.');
|
|
2030
|
+
} else {
|
|
2031
|
+
lines.push(`Open findings (${findings.length}, worst first):`);
|
|
2032
|
+
for (const finding of findings.slice(0, FINDING_LINES)) {
|
|
2033
|
+
const where = finding.path
|
|
2034
|
+
? ` ${finding.path}${finding.line != null ? `:${finding.line}` : ''}`
|
|
2035
|
+
: '';
|
|
2036
|
+
const filed = finding.became?.ref ? ` (filed as ${finding.became.ref})` : '';
|
|
2037
|
+
lines.push(` ${finding.type} ${finding.severity} ${finding.rule}${where} — ${finding.message}`
|
|
2038
|
+
+ ` — ${finding.url}${filed}`);
|
|
2039
|
+
}
|
|
2040
|
+
if (findings.length > FINDING_LINES) {
|
|
2041
|
+
lines.push(` … and ${findings.length - FINDING_LINES} more in SonarQube.`);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
lines.push('');
|
|
2045
|
+
lines.push("These findings are the default branch's; SonarQube's per-branch issue list is not "
|
|
2046
|
+
+ "read here, so a branch's new problems are counts and a link.");
|
|
2047
|
+
return lines.join('\n');
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/** A count as a reading prints it: the number, or a dash for one SonarQube did not report. */
|
|
2051
|
+
function count(value) {
|
|
2052
|
+
return value == null ? '–' : String(value);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/** A percentage as a reading prints it: `71.2%`, or a dash for none reported. */
|
|
2056
|
+
function percent(value) {
|
|
2057
|
+
return value == null ? '–' : `${value}%`;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1910
2060
|
/**
|
|
1911
2061
|
* What to call a card — R127. The server says, in `ref`; this is the fallback
|
|
1912
2062
|
* for anything that predates it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cawdev-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5-beta",
|
|
4
4
|
"description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,8 +16,5 @@
|
|
|
16
16
|
"engines": {
|
|
17
17
|
"node": ">=20"
|
|
18
18
|
},
|
|
19
|
-
"license": "UNLICENSED"
|
|
20
|
-
"dependencies": {
|
|
21
|
-
"cawdev-cli": "^1.0.0-beta"
|
|
22
|
-
}
|
|
19
|
+
"license": "UNLICENSED"
|
|
23
20
|
}
|