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 +65 -0
- package/lib/ansi.mjs +2 -0
- package/lib/background-shells.mjs +108 -0
- package/lib/codex.mjs +219 -0
- package/lib/harness-prompt.mjs +14 -6
- package/lib/roadmap-format.mjs +52 -0
- package/lib/runner-config.mjs +3 -2
- package/mcp/server.mjs +53 -4
- package/package.json +1 -1
- package/runner/README.md +389 -11
- package/runner/attach.mjs +1838 -31
- package/runner/auto-workspaces.mjs +106 -0
- package/runner/bootstrap.mjs +37 -5
- package/runner/cawdev.mjs +45 -13
- package/runner/changed-lines.mjs +87 -0
- package/runner/configure.mjs +55 -5
- package/runner/input.mjs +21 -8
- package/runner/prefs.mjs +52 -0
- package/runner/runner.mjs +1256 -147
- package/runner/select.mjs +31 -4
- package/runner/split.mjs +923 -0
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
|
+
}
|
package/lib/harness-prompt.mjs
CHANGED
|
@@ -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(' ')}`);
|
package/lib/roadmap-format.mjs
CHANGED
|
@@ -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
|
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/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.
|