cawdev-cli 1.0.4-beta → 1.0.6-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 ADDED
@@ -0,0 +1,65 @@
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
+ }
28
+
29
+ /**
30
+ * Why an agent that needs the whole machine was refused, and what to do about
31
+ * it — in steps, for THIS machine. Codex and Antigravity have no per-command
32
+ * allow-list, so a session that writes can only run with everything allowed;
33
+ * the refusal used to say "turn on allow everything" and nothing about where,
34
+ * or that the machine must first agree to take rules from the console.
35
+ *
36
+ * Pure, so the wording is read in a test rather than found in a failed run.
37
+ *
38
+ * @param agent `codex` or `agy`
39
+ * @param machine `{ name, acceptsConsoleRules, configPath, consoleUrl }`
40
+ * @param run `{ entryRef, profile }` — what to start again, and how
41
+ * @param readsWithout whether a read-only session of this agent needs nothing
42
+ */
43
+ export function needsEverything(agent, machine = {}, run = {}, { readsWithout = true } = {}) {
44
+ const who = LABELS[agent] ?? agent;
45
+ const short = agent === 'agy' ? 'agy' : agent;
46
+ const again = run.entryRef ? `/new ${run.profile === 'PLAN' ? 'plan' : 'dev'} ${run.entryRef}` : '/new …';
47
+ const lines = [
48
+ `${who} cannot ${readsWithout ? 'write code' : 'run'} on this machine yet: it has no per-command `
49
+ + 'permissions, so a session that commits and pushes must be allowed to run anything — and '
50
+ + 'this machine has not allowed everything.',
51
+ '',
52
+ 'To allow it:',
53
+ machine.acceptsConsoleRules
54
+ ? ' 1. ✓ This machine already accepts permission rules from the console.'
55
+ : ' 1. On this machine: in the cawdev terminal press c → "Accept permission rules from the '
56
+ + `console"${machine.configPath ? ` (or "acceptsRulesFromConsole": true in ${machine.configPath})` : ''}.`,
57
+ ` 2. On cawdev: ${machine.consoleUrl ? `${machine.consoleUrl.replace(/\/+$/, '')}/settings/runners` : 'Settings → Runners'}`
58
+ + ` → ${machine.name ?? 'this machine'} → tick "Allow everything".`,
59
+ ` 3. Start it again: ${again} --${short}`,
60
+ '',
61
+ 'Allowing everything lets every unattended session on this machine run any command in its checkouts.',
62
+ `Or, without it: ${again} --claude${readsWithout && agent === 'codex' && run.entryRef ? ` — or keep ${who} for plans (/new plan ${run.entryRef} --codex)` : ''}.`,
63
+ ];
64
+ return lines.join('\n');
65
+ }
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. */
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The shell commands a Claude Code session started in the BACKGROUND and never
3
+ * saw the end of — read off its stream-json events.
4
+ *
5
+ * A lifecycle stage is one turn (R112): the daemon closes the session the
6
+ * moment the turn ends. A stage that ran `npm test` with `run_in_background`
7
+ * and ended its turn to wait for it loses the run — the result never comes
8
+ * back, nothing is recorded, and the walk marched on to MEMORY and FINISHED
9
+ * with the work unproven and uncommitted (R15, saasng). So the walk asks this,
10
+ * at the stage's end, whether anything it started is unfinished or unread;
11
+ * `walkLifecycle` then fails the stage instead of calling it done, which is
12
+ * what lets `/continue` pick the run back up at that stage.
13
+ *
14
+ * Pure: events in, a list out. Three tool shapes, by name:
15
+ * - `Bash` with `run_in_background: true` starts one; its result names the id
16
+ * ("… running in background with ID: <id>");
17
+ * - `BashOutput` (or `TaskOutput`) reads one by `bash_id`/`shell_id`/`task_id`;
18
+ * the command is over only when a read says a status other than running;
19
+ * - `KillShell`/`KillBash`/`TaskStop` ends one.
20
+ * A command whose id cannot be read off its result stays unfinished: failing
21
+ * loudly and re-running a stage costs a turn, calling unproven work proven
22
+ * costs somebody's afternoon.
23
+ *
24
+ * @author Claude Opus 5.5 (Claude Code)
25
+ * @author Moez Ben MBarka
26
+ */
27
+
28
+ const READS = new Set(['BashOutput', 'TaskOutput']);
29
+ const KILLS = new Set(['KillShell', 'KillBash', 'TaskStop']);
30
+
31
+ /** The text of a tool_result's content, whichever of its two shapes it came in. */
32
+ function textOf(content) {
33
+ if (typeof content === 'string') return content;
34
+ if (Array.isArray(content)) {
35
+ return content.map((part) => (typeof part?.text === 'string' ? part.text : '')).join('\n');
36
+ }
37
+ return '';
38
+ }
39
+
40
+ function shellIdOf(input) {
41
+ const id = input?.bash_id ?? input?.shell_id ?? input?.task_id ?? input?.id;
42
+ return typeof id === 'string' && id ? id : null;
43
+ }
44
+
45
+ export class BackgroundShells {
46
+ constructor() {
47
+ /** tool_use id of each background `Bash` → { command, shellId, done }. */
48
+ this.started = new Map();
49
+ /** tool_use id of each read → the shell id it read. */
50
+ this.reads = new Map();
51
+ }
52
+
53
+ /** One stream-json event. Anything that is not about a shell is ignored. */
54
+ observe(event) {
55
+ const parts = event?.message?.content;
56
+ if (!Array.isArray(parts)) return;
57
+ if (event.type === 'assistant') {
58
+ for (const part of parts) {
59
+ if (part?.type !== 'tool_use') continue;
60
+ if (part.name === 'Bash' && part.input?.run_in_background === true) {
61
+ this.started.set(part.id, {
62
+ command: String(part.input.command ?? '').trim(),
63
+ shellId: null,
64
+ done: false,
65
+ });
66
+ } else if (READS.has(part.name)) {
67
+ const id = shellIdOf(part.input);
68
+ if (id) this.reads.set(part.id, id);
69
+ } else if (KILLS.has(part.name)) {
70
+ this.finish(shellIdOf(part.input));
71
+ }
72
+ }
73
+ return;
74
+ }
75
+ if (event.type === 'user') {
76
+ for (const part of parts) {
77
+ if (part?.type !== 'tool_result') continue;
78
+ const text = textOf(part.content);
79
+ const launched = this.started.get(part.tool_use_id);
80
+ if (launched && !launched.shellId) {
81
+ const id = /\bID:?\s*([A-Za-z0-9_-]+)/.exec(text);
82
+ if (id) launched.shellId = id[1];
83
+ continue;
84
+ }
85
+ const read = this.reads.get(part.tool_use_id);
86
+ if (read) {
87
+ // Over only when the read says so; a read of a command still going
88
+ // is the command still going.
89
+ if (!/<status>\s*running\s*<\/status>|\bstatus:\s*running\b/i.test(text)) {
90
+ this.finish(read);
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ finish(shellId) {
98
+ if (!shellId) return;
99
+ for (const each of this.started.values()) {
100
+ if (each.shellId === shellId) each.done = true;
101
+ }
102
+ }
103
+
104
+ /** The commands started in the background whose end this session never saw. */
105
+ unfinished() {
106
+ return [...this.started.values()].filter((each) => !each.done).map((each) => each.command);
107
+ }
108
+ }
package/lib/codex.mjs ADDED
@@ -0,0 +1,219 @@
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
+ // How long one call may take before Codex gives up on it — 60 s unless
124
+ // set, which is shorter than a person answering a question.
125
+ if (server.toolTimeoutSec) {
126
+ args.push('-c', `${key}.tool_timeout_sec=${tomlValue(server.toolTimeoutSec)}`);
127
+ }
128
+ args.push('-c', `${key}.default_tools_approval_mode="approve"`);
129
+ }
130
+ if (resumeId) {
131
+ args.push('resume', resumeId);
132
+ }
133
+ args.push('-');
134
+ return args;
135
+ }
136
+
137
+ /** A tool result's text, whatever shape the MCP server gave it. */
138
+ function resultText(result) {
139
+ const content = result?.content;
140
+ if (Array.isArray(content)) {
141
+ const text = content.map((each) => each?.text ?? '').filter(Boolean).join('\n');
142
+ if (text) return text;
143
+ }
144
+ return result == null ? '(no output)' : JSON.stringify(result);
145
+ }
146
+
147
+ /**
148
+ * One line of `codex exec --json`, as what the transcript and the run need.
149
+ *
150
+ * <p>Returns `{ sessionId?, lines: [{ kind, body }], usage?, failed?, error?, ended? }`.
151
+ * `ended` is true on `turn.completed` — the turn ending, which is the whole of
152
+ * what a stage is judged by. `failed` is `turn.failed`'s sentence; `error` a
153
+ * top-level error's, which may be passing. An event this version does not know
154
+ * yields nothing.
155
+ */
156
+ export function translateCodexEvent(event) {
157
+ const out = { lines: [] };
158
+ const item = event?.item ?? {};
159
+ switch (event?.type) {
160
+ case 'thread.started':
161
+ out.sessionId = event.thread_id ?? null;
162
+ break;
163
+ case 'item.started':
164
+ if (item.type === 'command_execution') {
165
+ out.lines.push({ kind: 'TOOL', body: `Bash(${item.command ?? ''})` });
166
+ } else if (item.type === 'mcp_tool_call') {
167
+ out.lines.push({
168
+ kind: 'TOOL',
169
+ body: `mcp__${item.server}__${item.tool}(${JSON.stringify(item.arguments ?? {})})`,
170
+ });
171
+ }
172
+ break;
173
+ case 'item.completed':
174
+ if (item.type === 'agent_message' && item.text) {
175
+ out.lines.push({ kind: 'ASSISTANT', body: item.text });
176
+ } else if (item.type === 'command_execution') {
177
+ const output = String(item.aggregated_output ?? '').slice(0, 4000);
178
+ out.lines.push({
179
+ kind: 'TOOL_RESULT',
180
+ body: output || `(exit ${item.exit_code ?? '?'})`,
181
+ });
182
+ } else if (item.type === 'mcp_tool_call') {
183
+ out.lines.push({
184
+ kind: 'TOOL_RESULT',
185
+ body: item.error
186
+ ? `failed: ${item.error.message ?? JSON.stringify(item.error)}`
187
+ : resultText(item.result).slice(0, 4000),
188
+ });
189
+ } else if (item.type === 'file_change') {
190
+ const changes = (item.changes ?? [])
191
+ .map((each) => `${each.kind ?? 'update'} ${each.path ?? ''}`.trim());
192
+ out.lines.push({ kind: 'TOOL', body: `Edit(${changes.join(', ') || 'files'})` });
193
+ } else if (item.type === 'error' && item.message) {
194
+ out.lines.push({ kind: 'SYSTEM', body: item.message });
195
+ }
196
+ break;
197
+ case 'turn.completed':
198
+ out.ended = true;
199
+ out.usage = {
200
+ input: Number(event.usage?.input_tokens) || 0,
201
+ output: Number(event.usage?.output_tokens) || 0,
202
+ };
203
+ break;
204
+ case 'turn.failed':
205
+ out.failed = event.error?.message ?? 'Codex reported the turn failed.';
206
+ out.lines.push({ kind: 'SYSTEM', body: out.failed });
207
+ break;
208
+ case 'error':
209
+ // NOT `failed`: a top-level error is also how a retried stream says
210
+ // "Reconnecting… 2/5". Only `turn.failed` ends a turn badly; this is
211
+ // kept as the last thing it said, for when nothing better arrives.
212
+ out.error = event.message ?? 'Codex reported an error.';
213
+ out.lines.push({ kind: 'SYSTEM', body: out.error });
214
+ break;
215
+ default:
216
+ break;
217
+ }
218
+ return out;
219
+ }
@@ -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. The AS_NEEDED wording
52
- // below is byte-for-byte what it was — when a project requires nothing, and
53
- // that is every project until somebody says otherwise, this function returns
54
- // exactly what it returned before the entry.
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
- + `finished.\n${lines(mustExperts)}`);
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.\n'
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(' ')}`);
@@ -255,6 +255,23 @@ export function parseRoadmap(text) {
255
255
  entry.afterRefs = tokens.map(({ ref }) => ref);
256
256
  continue;
257
257
  }
258
+ const tags = /^Tags:\s*(.+)$/.exec(line.trim());
259
+ if (tags) {
260
+ // R315. Already normalised by the server (lowercase, no `#`) by the
261
+ // time it reaches the file, so a plain split is the whole rule.
262
+ entry.tags = tags[1].split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
263
+ continue;
264
+ }
265
+ // R315. Computed, never stored, and never sent by import.mjs — the same
266
+ // arrangement as `Development:` above. Kept as the exact text so a file
267
+ // parsed and re-rendered without asking the API again is still a fixed
268
+ // point; `renderEntry` recomputes the same text from a live `origin`
269
+ // object when there is one.
270
+ const origin = /^Origin:\s*(.+)$/.exec(line.trim());
271
+ if (origin) {
272
+ entry.origin = origin[1].trim();
273
+ continue;
274
+ }
258
275
  const sprint = /^Sprint:\s*S(\d+)\s*(.*)$/.exec(line.trim());
259
276
  if (sprint) {
260
277
  // R257: `Sprint: S1 Notifications` — the sprint's number and its name
@@ -360,6 +377,33 @@ export function renderRoadmap(entries, { preamble, sectionOrder = [] }) {
360
377
  return parts.join('\n').replace(/\n+$/, '') + '\n';
361
378
  }
362
379
 
380
+ /**
381
+ * The word `Origin:` writes — R315. `entry.origin` is a plain string once it
382
+ * has round-tripped through the file (see the parser above); freshly off the
383
+ * API it is `{kind, label, url}`, and this is the one place that turns the
384
+ * second into the first, so the two paths render identically.
385
+ */
386
+ function originWord(origin) {
387
+ if (typeof origin === 'string') {
388
+ return origin;
389
+ }
390
+ if (!origin || origin.kind === 'NONE') {
391
+ return null;
392
+ }
393
+ switch (origin.kind) {
394
+ case 'SONARQUBE':
395
+ return 'SonarQube';
396
+ case 'FEEDBACK':
397
+ return 'Feedback';
398
+ case 'APPLICATION':
399
+ return origin.label ? `An application (${origin.label})` : 'An application';
400
+ case 'SESSION':
401
+ return origin.label ? `A session (${origin.label})` : 'A session';
402
+ default:
403
+ return null;
404
+ }
405
+ }
406
+
363
407
  function renderEntry(entry) {
364
408
  const lines = [`### ${entryRef(entry)} — ${entry.title}`, ''];
365
409
 
@@ -425,6 +469,14 @@ function renderEntry(entry) {
425
469
  .map(({ ref }) => ref);
426
470
  lines.push(`After: ${written.join(', ')}`);
427
471
  }
472
+ if (entry.tags?.length) {
473
+ // R315. Sorted, so the export does not churn on the set's own order.
474
+ lines.push(`Tags: ${[...entry.tags].sort().join(', ')}`);
475
+ }
476
+ const origin = originWord(entry.origin);
477
+ if (origin) {
478
+ lines.push(`Origin: ${origin}`);
479
+ }
428
480
  if (entry.sprint) {
429
481
  // R257. A line on the card and never a heading: the section is where the
430
482
  // file groups, and a sprint is a set a person opened across sections. The
@@ -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/mcp/server.mjs CHANGED
@@ -665,6 +665,13 @@ const TOOLS = [
665
665
  'Cards this one starts coding after — it waits in the queue until every one of ' +
666
666
  'them is MERGED or SHIPPED. Refs, as `related`. Same project, must exist, never itself.',
667
667
  },
668
+ tags: {
669
+ type: 'array',
670
+ items: { type: 'string' },
671
+ description:
672
+ 'Free words — lowercase, letters/digits/./_/-, up to 32 characters, at least one ' +
673
+ 'letter, at most 12. The leading # is optional and stripped.',
674
+ },
668
675
  },
669
676
  required: ['title'],
670
677
  },
@@ -674,7 +681,7 @@ const TOOLS = [
674
681
  method: 'POST',
675
682
  body: pick(args, [
676
683
  'title', 'body', 'status', 'branch', 'merge', 'version', 'reason', 'section', 'related',
677
- 'after', 'sprint',
684
+ 'after', 'sprint', 'tags',
678
685
  ]),
679
686
  });
680
687
  return `Created ${refOf(entry)} in ${slug}.\n\n${formatEntry(entry, {})}`;
@@ -734,6 +741,13 @@ const TOOLS = [
734
741
  items: { type: ['string', 'integer'] },
735
742
  description: 'Refs — R12, i15; a bare number is a roadmap card (R221).',
736
743
  },
744
+ tags: {
745
+ type: 'array',
746
+ items: { type: 'string' },
747
+ description:
748
+ 'Free words — lowercase, letters/digits/./_/-, up to 32 characters, at least one ' +
749
+ 'letter, at most 12. The leading # is optional and stripped.',
750
+ },
737
751
  },
738
752
  required: ['title', 'severity'],
739
753
  },
@@ -741,7 +755,7 @@ const TOOLS = [
741
755
  const slug = await resolveProject(config, args.project);
742
756
  const issue = await api(config, `/api/projects/${slug}/issues`, {
743
757
  method: 'POST',
744
- body: pick(args, ['title', 'body', 'severity', 'status', 'related']),
758
+ body: pick(args, ['title', 'body', 'severity', 'status', 'related', 'tags']),
745
759
  });
746
760
  return `Filed ${refOf(issue)} in ${slug}.\n\n${formatEntry(issue, {})}`;
747
761
  },
@@ -779,7 +793,7 @@ const TOOLS = [
779
793
  {
780
794
  name: 'roadmap_update',
781
795
  description:
782
- 'Edit an entry\'s title, body, section, sprint, related refs or the cards it starts ' +
796
+ 'Edit an entry\'s title, body, section, sprint, tags, related refs or the cards it starts ' +
783
797
  'after. Use roadmap_set_status to move it.',
784
798
  inputSchema: {
785
799
  type: 'object',
@@ -813,6 +827,12 @@ const TOOLS = [
813
827
  'them is MERGED or SHIPPED. Refs, as `related`. Same project, must exist, never ' +
814
828
  'itself. [] clears it.',
815
829
  },
830
+ tags: {
831
+ type: 'array',
832
+ items: { type: 'string' },
833
+ description:
834
+ 'Free words — same rule as roadmap_create. Leave out to leave them alone; [] clears them.',
835
+ },
816
836
  },
817
837
  required: ['number'],
818
838
  },
@@ -821,7 +841,7 @@ const TOOLS = [
821
841
  const entry = await api(config, `/api/projects/${slug}/roadmap/${args.number}`, {
822
842
  method: 'PATCH',
823
843
  // pick keeps a present 0, which is how a card leaves its sprint.
824
- body: pick(args, ['title', 'body', 'section', 'related', 'after', 'sprint']),
844
+ body: pick(args, ['title', 'body', 'section', 'related', 'after', 'sprint', 'tags']),
825
845
  });
826
846
  return `Updated ${refOf(entry)}.\n\n${formatEntry(entry, {})}`;
827
847
  },
@@ -2011,6 +2031,9 @@ function formatQualityReading(reading, branch) {
2011
2031
  `new coverage ${percent(row.newCoverage)}`,
2012
2032
  ];
2013
2033
  if (row.analysedAs === 'MAIN') parts.push('analysed as the main branch, not under its own name');
2034
+ if (row.analysedAs === 'PROJECT') {
2035
+ parts.push('analysed as its own SonarQube project; new = on lines this branch changed; no gate');
2036
+ }
2014
2037
  if (row.headSha) parts.push(`at ${row.headSha.slice(0, 7)}`);
2015
2038
  if (row.dashboardUrl) parts.push(row.dashboardUrl);
2016
2039
  lines.push(`Branch ${asked.branch}: ${parts.join(' · ')}`);
@@ -2062,6 +2085,23 @@ function refOf(entry) {
2062
2085
  return entry.ref ?? ((entry.kind === 'ISSUE' ? 'i' : 'R') + entry.number);
2063
2086
  }
2064
2087
 
2088
+ /**
2089
+ * The word `formatEntry` prints for a card's origin — R315. Never stored,
2090
+ * always the object shape `{kind, label, url}` off the API — unlike
2091
+ * `roadmap-format.mjs`'s own copy, which also has to read one back out of a
2092
+ * file.
2093
+ */
2094
+ function originWord(origin) {
2095
+ if (!origin || origin.kind === 'NONE') return null;
2096
+ switch (origin.kind) {
2097
+ case 'SONARQUBE': return 'SonarQube';
2098
+ case 'FEEDBACK': return 'Feedback';
2099
+ case 'APPLICATION': return origin.label ? `an application (${origin.label})` : 'an application';
2100
+ case 'SESSION': return origin.label ? `a session (${origin.label})` : 'a session';
2101
+ default: return null;
2102
+ }
2103
+ }
2104
+
2065
2105
  function formatEntry(entry, { brief, comments, plan }) {
2066
2106
  const lines = [`${refOf(entry)} — ${entry.title}`, ` status: ${entry.statusDisplay}`];
2067
2107
  if (entry.branch) lines.push(` branch: ${entry.branch}`);
@@ -2069,6 +2109,9 @@ function formatEntry(entry, { brief, comments, plan }) {
2069
2109
  if (entry.version) lines.push(` version: ${entry.version}`);
2070
2110
  if (entry.declinedReason) lines.push(` declined because: ${entry.declinedReason}`);
2071
2111
  if (entry.section) lines.push(` section: ${entry.section}`);
2112
+ // R315. Sorted for the same reason the export is: a reader's shape must not
2113
+ // depend on the set's own insertion order.
2114
+ if (entry.tags?.length) lines.push(` tags: ${[...entry.tags].sort().map((t) => `#${t}`).join(' ')}`);
2072
2115
  // R257. Always printed when the card has one, so a reader's shape does not
2073
2116
  // depend on which tool printed the card; a closed sprint says so.
2074
2117
  if (entry.sprint) {
@@ -2105,6 +2148,12 @@ function formatEntry(entry, { brief, comments, plan }) {
2105
2148
  lines.push(` written by: a ${entry.createdBy.profile} session, `
2106
2149
  + `under ${entry.createdBy.startedByEmail}`);
2107
2150
  }
2151
+ // R315. Never null on the wire — NONE is a person at the console — so only
2152
+ // ever printed when it says something. Beside `written by` rather than
2153
+ // instead of it: origin says the DOOR, and the session line above already
2154
+ // says who and what kind, when the door was a session.
2155
+ const origin = originWord(entry.origin);
2156
+ if (origin) lines.push(` origin: ${origin}`);
2108
2157
  if (!brief && entry.body) lines.push('', entry.body);
2109
2158
  // R124, between the body and the discussion — the order they are read in:
2110
2159
  // what we want, how we mean to get it, and the argument about both.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.0.4-beta",
3
+ "version": "1.0.6-beta",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {