cawdev-cli 0.9.0

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/usage.mjs ADDED
@@ -0,0 +1,119 @@
1
+ // What a turn cost, in the unit people actually have.
2
+ //
3
+ // The CLI reports `total_cost_usd`, and it is tempting to show it. **It is a
4
+ // list-price equivalent, not a bill**: the event says so itself with
5
+ // `costBasis: "list"`. Somebody on a Claude subscription pays a flat fee, so
6
+ // "$5.37 so far" tells them a number they will never be charged, and reads as
7
+ // if they have spent it. Tokens are what they actually consumed.
8
+ //
9
+ // Cost belongs in R20, which records it per provider alongside the basis it was
10
+ // quoted on, and leaves it blank when a provider states none. A transcript line
11
+ // is the wrong place to imply an invoice.
12
+
13
+ /** 1234 → "1.2k", 1234567 → "1.2M". Small numbers stay exact. */
14
+ export function formatTokens(count) {
15
+ if (typeof count !== 'number' || !Number.isFinite(count) || count < 0) {
16
+ return '0';
17
+ }
18
+ if (count < 1000) {
19
+ return String(Math.round(count));
20
+ }
21
+ if (count < 1_000_000) {
22
+ const thousands = count / 1000;
23
+ // 12.4k below a hundred, 124k above: a decimal on a big number is noise.
24
+ return `${thousands < 100 ? thousands.toFixed(1) : Math.round(thousands)}k`;
25
+ }
26
+ return `${(count / 1_000_000).toFixed(1)}M`;
27
+ }
28
+
29
+ /** "45s", "13m", "1h 4m" — a turn's length, at the precision that matters. */
30
+ export function formatDuration(milliseconds) {
31
+ if (typeof milliseconds !== 'number' || !Number.isFinite(milliseconds) || milliseconds < 0) {
32
+ return null;
33
+ }
34
+ const seconds = Math.round(milliseconds / 1000);
35
+ if (seconds < 60) {
36
+ return `${seconds}s`;
37
+ }
38
+ const minutes = Math.floor(seconds / 60);
39
+ if (minutes < 60) {
40
+ return `${minutes}m`;
41
+ }
42
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
43
+ }
44
+
45
+ /**
46
+ * The tokens a `result` event reports.
47
+ *
48
+ * `usage` is this turn; `modelUsage` accumulates across the session — which is
49
+ * what the old line's "so far" was reaching for, and worth keeping now that it
50
+ * is expressed in something real.
51
+ *
52
+ * Cache reads are shown because they are usually the largest number by an order
53
+ * of magnitude, and a turn that reads 200k of cache and writes 2k of output
54
+ * looks otherwise inexplicably slow.
55
+ */
56
+ export function describeUsage(event) {
57
+ const usage = event?.usage ?? {};
58
+ const parts = [];
59
+
60
+ const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
61
+ if (input) {
62
+ parts.push(`${formatTokens(input)} in`);
63
+ }
64
+ if (usage.output_tokens) {
65
+ parts.push(`${formatTokens(usage.output_tokens)} out`);
66
+ }
67
+ if (usage.cache_read_input_tokens) {
68
+ parts.push(`${formatTokens(usage.cache_read_input_tokens)} cached`);
69
+ }
70
+
71
+ const session = sessionTotals(event?.modelUsage);
72
+ if (session) {
73
+ parts.push(`session ${formatTokens(session.input)} in, ${formatTokens(session.output)} out`);
74
+ }
75
+ return parts;
76
+ }
77
+
78
+ /**
79
+ * A session's totals so far, or null when the event carries none — R76.
80
+ *
81
+ * The same numbers `describeUsage` puts in a transcript line, exported so they
82
+ * can also be *recorded*. A line is read once by whoever is watching; the point
83
+ * of R76 is a comparison somebody makes later — the same project with a skill
84
+ * on and with it off — and a measurement nobody wrote down cannot be taken
85
+ * afterwards.
86
+ *
87
+ * `modelUsage` accumulates across the turns of ONE session, so this is
88
+ * cumulative for that process and not a delta. What that means for a resumed
89
+ * run is the runner's problem, and `AgentRun.sawUsage` says how it is handled.
90
+ */
91
+ export function totalsOf(event) {
92
+ return sessionTotals(event?.modelUsage);
93
+ }
94
+
95
+ /** Summed across models: a session that switched models still has one total. */
96
+ function sessionTotals(modelUsage) {
97
+ if (!modelUsage || typeof modelUsage !== 'object') {
98
+ return null;
99
+ }
100
+ let input = 0;
101
+ let output = 0;
102
+ for (const model of Object.values(modelUsage)) {
103
+ input += (model?.inputTokens ?? 0) + (model?.cacheCreationInputTokens ?? 0);
104
+ output += model?.outputTokens ?? 0;
105
+ }
106
+ return input || output ? { input, output } : null;
107
+ }
108
+
109
+ /** The whole line: how the turn ended, how long it took, and what it used. */
110
+ export function describeTurn(event) {
111
+ const bits = [`turn ended (${event?.subtype ?? 'done'})`];
112
+
113
+ const duration = formatDuration(event?.duration_ms);
114
+ if (duration) {
115
+ bits.push(`in ${duration}`);
116
+ }
117
+ const usage = describeUsage(event);
118
+ return usage.length ? `${bits.join(' ')} · ${usage.join(', ')}` : bits.join(' ');
119
+ }
package/mcp/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # cawdev MCP server
2
+
3
+ Plain Node, zero dependencies, stdio JSON-RPC — `server.mjs`, plus the shared
4
+ rule matcher in `tools/lib/tool-rules.mjs` that the runner uses too. It gives a coding
5
+ agent a project's roadmap and changelog — the same verbs a person gets in the
6
+ console, because the API was built to mirror these tools one-for-one.
7
+
8
+ ## Setting it up in a repository
9
+
10
+ Mint a token in the cawdev console under **Agent tokens**. Grant it
11
+ `roadmap:write` and `changelog:write` on the project. **You can only grant what
12
+ you hold** — if you are a `READER` there, you get read scopes. A token that
13
+ should only be able to *file feedback* — an application built beside cawdev,
14
+ say — needs just `backlog:write`, which a `READER` may grant and which reaches
15
+ nothing else, not even the roadmap (R199).
16
+
17
+ Then in the repository the agent works in, add `.mcp.json`:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "cawdev": {
23
+ "command": "node",
24
+ "args": ["/path/to/cawdev/tools/mcp/server.mjs"],
25
+ "env": {
26
+ "CAWDEV_URL": "http://localhost:4200",
27
+ "CAWDEV_TOKEN": "cawd_…"
28
+ }
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ Or leave `env` out and put `CAWDEV_URL` / `CAWDEV_TOKEN` / `CAWDEV_PROJECT` in a
35
+ `.env` at the repository root — **gitignore it.** The server searches upward
36
+ from its working directory.
37
+
38
+ **Configuration is read on every call, never cached.** Edit `.env` and the next
39
+ call sees it; no restart. That matters more than it sounds: an agent that has
40
+ been writing to the wrong platform for an hour, because the value changed and
41
+ the process was still holding the old one, is a bad afternoon.
42
+
43
+ ### Which project
44
+
45
+ - A token granted **exactly one** project needs no `project` argument.
46
+ - A multi-project token takes `project` per call, or set `CAWDEV_PROJECT`.
47
+ - Get it wrong and the error lists what the token can actually see.
48
+
49
+ ---
50
+
51
+ ## For the agent reading this
52
+
53
+ **Run `roadmap_where` first** when anything is surprising. It tells you which
54
+ platform you are talking to, which token you are using, *where each value was
55
+ read from*, and who the platform thinks you are. Most confusion is one of those
56
+ four being different from what you assumed.
57
+
58
+ ### How work starts here
59
+
60
+ The sequence is fixed, and the second step is the one people skip:
61
+
62
+ 1. Branch off an up-to-date `main`, named after the entry — `r4-roadmap-entries`.
63
+ 2. **Move the entry to `CODING` naming the branch, before your first commit**
64
+ (`roadmap_set_status`). Not after. The roadmap should be able to answer
65
+ "what is being worked on right now" without asking anyone.
66
+ 3. Build to the entry's "Done when" list.
67
+ 4. Finish with a PR. Never push to `main`. As your session ends the platform
68
+ moves the *branch* on the development board, not the card: to `REVIEW` where
69
+ the project runs `require_review` and somebody is going to read it, and to
70
+ `DONE` where it does not — a finish nobody was asked about (R132), because
71
+ there is no reviewer coming.
72
+ 5. When the PR merges, move the entry to `MERGED` naming it. The branch may then
73
+ be deleted — `CODING` is for work somebody is doing, not work that is done.
74
+
75
+ ### What a status must carry
76
+
77
+ `roadmap_statuses` will tell you, and it is worth reading rather than guessing:
78
+ `CODING` needs a branch, `MERGED` needs the merge — a pull request, a merge
79
+ commit, or a sha — `SHIPPED` needs a version that is a real git tag, and
80
+ `DECLINED` needs a reason. `REVIEW` needs nothing, deliberately: the pull
81
+ request it would otherwise carry belongs to the run, and a project that reviews
82
+ without opening pull requests has none to give it.
83
+
84
+ **Any status may move to any other.** There is no transition diagram — an entry
85
+ really can go from `CONSIDERING` straight to `SHIPPED` if that is what happened.
86
+ The rules are about what a status *carries*, not the path it took.
87
+
88
+ ### The body is what we want; the plan is how; the comments are the argument
89
+
90
+ `roadmap_get` gives you all three, and `task_current` gives you them for the
91
+ entry you are working on.
92
+
93
+ **The plan is the one to read before doing any of the work.** It was written by
94
+ a plan phase and agreed by somebody, and it names the files — R124. If you are
95
+ carrying it out and it turns out to be wrong, say so and stop rather than
96
+ improvising a different change: what was approved was that plan, and a different
97
+ one has not been approved.
98
+
99
+ You cannot write a plan through this server, and you cannot start a plan phase
100
+ either. The first is the platform's — it stores what a plan phase reports when
101
+ the phase ends, so that a session which must not write is not handed a writer to
102
+ do its own bookkeeping with. The second is refused outright: *an agent cannot
103
+ start another agent*, which is `RunAccess.requireCanStart`'s rule and not an
104
+ omission here. **Read the discussion before you propose anything about an
105
+ entry.** It is where an objection was answered and where an obvious-looking
106
+ approach was ruled out with a reason — and re-proposing what was talked out
107
+ three months ago is precisely what it exists to stop.
108
+
109
+ Use `roadmap_comment` for the argument: what you measured, what you tried, why
110
+ you did not take the route somebody would expect. Use `roadmap_update` when the
111
+ discussion reaches a conclusion — the body is where a settled answer goes, so
112
+ that the next reader does not have to reconstruct it from the thread.
113
+
114
+ A comment you write is attributed to your run as well as to the account that
115
+ minted your token, so a person can tell an agent's reading from a colleague's.
116
+
117
+ ### Read what the earlier sessions did before repeating it
118
+
119
+ `task_current` lists **every run this card has already had** — how each ended,
120
+ on what branch, with what model, whether the work was pushed, and the commits it
121
+ made by subject. A card that failed twice on the same branch is telling you
122
+ something its status does not, and the second attempt is the one that most needs
123
+ to know what the first tried.
124
+
125
+ No transcripts come with it, deliberately: the terminal log is on the run's own
126
+ page in the console, and nine of them would fill the context this call exists to
127
+ orient. If you need the detail, the discussion under the entry is where a
128
+ previous session should have written down what it learned — and where yours
129
+ should.
130
+
131
+ ### There is no delete
132
+
133
+ Not in these tools, not in the API. `roadmap_decline` with a reason is the only
134
+ exit an entry has, and the reason is the point: it is what stops the same idea
135
+ being proposed again in six months. Same for changelog entries — correct the
136
+ text, do not remove the record. Same for comments: nothing removes one, and the
137
+ most you can do to your own is correct its wording.
138
+
139
+ ### Ids are permanent, and a card is named by its ref
140
+
141
+ An entry's number never changes and is never reused. Commit messages and code
142
+ comments point at it. Since R221 a roadmap card and an issue each count on
143
+ their own sequence, so `R91` and `i91` are two cards: wherever a tool takes
144
+ `number`, `related`, `after` or `entryNumber`, pass the ref — `"i91"` for an
145
+ issue — and a bare `91` is read as the roadmap card, which is what it always
146
+ was.
147
+
148
+ ### When a call is refused
149
+
150
+ The message is the platform's own and it names the rule you hit — a missing
151
+ scope, a status that needs a branch, a project this token cannot see. Read it
152
+ rather than retrying: the refusal is usually telling you something true about
153
+ what you are allowed to do.
154
+
155
+ ---
156
+
157
+ ## Tools
158
+
159
+ | Tool | What it does |
160
+ |---|---|
161
+ | `roadmap_where` | Which platform, which token, read from where, and who you are. Start here. |
162
+ | `roadmap_statuses` | The eight statuses, what they mean, what each requires. |
163
+ | `roadmap_list` | Entries, optionally by status. `brief` omits bodies — use it to survey. |
164
+ | `roadmap_get` | One entry in full: its body, **the plan agreed for it**, and the discussion on it. |
165
+ | `roadmap_create` | Create an entry; the platform allocates its permanent number. |
166
+ | `roadmap_update` | Title, body, section, related ids, and the cards it starts coding after (`after` — R181). |
167
+ | `roadmap_comment` | Say something *beside* an entry rather than inside it. |
168
+ | `roadmap_set_status` | Move an entry, carrying whatever the status requires. |
169
+ | `roadmap_decline` | Decline with a reason. The only exit. |
170
+ | `backlog_file` | File **feedback**, not a card — something a person should decide about. A WRITER accepts it as a card or an issue, or refuses it (R199). |
171
+ | `changelog_list` | The changelog, grouped by release, newest first. |
172
+ | `changelog_get` | One entry. |
173
+ | `changelog_add` | Add an entry; no version means `Unreleased`. |
174
+ | `changelog_update` | Edit one, including moving it to a release when it ships. |
175
+
176
+ ### Inside a run
177
+
178
+ These only work when the token is a **run token** (`cawdr_`), which the runner
179
+ mints when a run starts and hands to the session it spawns. A plain `cawd_`
180
+ token gets a refusal saying so.
181
+
182
+ | Tool | What it does |
183
+ |---|---|
184
+ | `task_current` | The entry you are working on, its branch, everything already said and asked on this run, **and every earlier run on the same card**. **Call it first**, and again whenever you are unsure where you are. |
185
+ | `report` | `progress` as often as useful; `done` when finished, naming the branch and any PR; `blocked` when a person must resolve something. `done` and `blocked` end the run. |
186
+ | `ask_user` | Ask the person who started the run, and wait. Blocks up to ten minutes, then hands back a `question_id`. |
187
+ | `await_answer` | Resume waiting for a question `ask_user` handed back. |
188
+ | `ask_group` | Ask a **round** — up to twelve questions that belong together, under one title. Blocks until every one of them has been answered, then hands back a `group_id`. |
189
+ | `await_group` | Resume waiting for a round `ask_group` handed back. |
190
+ | `propose_entry` | **Audit and scoping runs only.** Record a finding — or a card cut from a bigger idea (R227) — as a proposal, naming its kind — `issue` with a severity, or `roadmap` without — and, optionally, the `section` it belongs under; a scoping session names one section for every card of the idea. A `roadmap` card **must** say what it starts `after`: `[]` for the first, `"#2"` for the second card proposed in this run (the number the tool answered with — it has to exist already, so file in build order), `"R12"` for a card on the roadmap. A person accepts it as either, or leaves it (R214) — and adds the cards in that order: #2 is refused until #1 is on the roadmap. |
191
+ | `approve` | **Not yours to call.** Claude Code calls it itself, as `--permission-prompt-tool`, when no rule covers a tool call. |
192
+
193
+ **`ask_user` is for decisions that are genuinely theirs** — an architectural
194
+ choice, a trade-off with no right answer, something the entry does not settle.
195
+ Not for checking work you can check yourself. Every question stops the run and
196
+ costs somebody's attention.
197
+
198
+ When `ask_user` returns without an answer, **do not guess and carry on.** You
199
+ asked because the decision was not yours. Call `await_answer`, or `report`
200
+ `blocked` and stop.
201
+
202
+ **`ask_group` is for when you have several questions at once** — R96. One
203
+ question at a time is right for a decision you hit halfway through a piece of
204
+ work; it is wrong when you are trying to understand something, because forty
205
+ separate inbox items arriving over an afternoon is not a conversation, it is an
206
+ interruption repeated forty times. A round arrives as one form under one title,
207
+ the person answers it in one sitting, and you are woken once when all of it is
208
+ answered.
209
+
210
+ Rounds, then, rather than one enormous list: at most twelve, and the next round
211
+ should be shaped by the answers to this one. Give each round an `intro` saying
212
+ what it is about, and offer `options` where there is a small set of plausible
213
+ answers — they can always write their own.
214
+
215
+ After `report done` or `report blocked`, the run is over and your token has
216
+ expired with it. There is nothing further to do.
217
+
218
+ ### When something you need is not allowed (R51)
219
+
220
+ You will not usually notice this happening. When you call a tool no rule covers
221
+ — `mvn`, `npm`, anything outside what the project has granted — Claude Code
222
+ asks `approve` on your behalf, a person is shown the command, and your call
223
+ either goes ahead or comes back refused. From inside the session it looks like a
224
+ tool call that took a while.
225
+
226
+ Two things to do with the answer:
227
+
228
+ - **A refusal carries a reason.** Act on it. "Not from an unattended session" is
229
+ something you can work with — report `blocked` saying what you needed, or find
230
+ an approach that does not need it. Do not retry the same call hoping for a
231
+ different person.
232
+ - **An expiry means nobody was there.** Do not loop. Report `blocked`, say
233
+ exactly what you needed to run and why, and let somebody allow it and start
234
+ you again.
235
+
236
+ ## Checking it works
237
+
238
+ Two smoke tests, both driving the server over real stdio.
239
+
240
+
241
+
242
+ ```sh
243
+ CAWDEV_URL=http://localhost:4200 CAWDEV_TOKEN=cawd_… node tools/mcp/smoke.mjs scratch-project
244
+ ```
245
+
246
+ **Name a scratch project.** The smoke test creates a roadmap entry and declines
247
+ it, and entries cannot be deleted — so it refuses to guess which project you
248
+ meant. It left two declined entries in cawdev's own roadmap before it did.
249
+
250
+ It drives the server the way a client does — spawn, write lines to stdin, read
251
+ lines from stdout — rather than importing its functions, because the parts most
252
+ likely to break are the transport and the framing. CI runs it against the
253
+ compose stack.
254
+
255
+ The orchestration tools, including the case R10 exists for — the agent blocks
256
+ on `ask_user`, something else answers, and the tool call returns the answer:
257
+
258
+ ```sh
259
+ node tools/mcp/orchestration-smoke.mjs scratch-project
260
+ ```
261
+
262
+ It starts a real run (which needs a session, since an agent cannot start another
263
+ agent), drives it through a runner, and exercises the pending path on a
264
+ shortened `CAWDEV_ASK_TIMEOUT_SECONDS`.
265
+
266
+ A single call, by hand:
267
+
268
+ ```sh
269
+ printf '%s\n' \
270
+ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}' \
271
+ '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"roadmap_where","arguments":{}}}' \
272
+ | CAWDEV_TOKEN=cawd_… node tools/mcp/server.mjs
273
+ ```
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+ // R10's "Done when": a scripted JSON-RPC session asks a question, a separate
3
+ // HTTP call answers it, and the tool call returns the answer — plus the
4
+ // ten-minute pending path, on a shortened timeout.
5
+ //
6
+ // node tools/mcp/orchestration-smoke.mjs <project>
7
+ //
8
+ // Needs a session that can start a run (CAWDEV_BASE, CAWDEV_ADMIN_EMAIL,
9
+ // CAWDEV_ADMIN_PASSWORD), because starting a run is a person's act — an agent
10
+ // cannot start another agent, so this test cannot bootstrap itself from a token.
11
+
12
+ import { spawn } from 'node:child_process';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const BASE = process.env.CAWDEV_BASE ?? 'http://localhost:4200';
18
+ const EMAIL = process.env.CAWDEV_ADMIN_EMAIL ?? 'admin@cawdev.local';
19
+ const PASSWORD = process.env.CAWDEV_ADMIN_PASSWORD ?? 'dev-admin-password';
20
+
21
+ const project = process.argv[2];
22
+ if (!project) {
23
+ console.error('Usage: node tools/mcp/orchestration-smoke.mjs <project>');
24
+ console.error('Use a scratch project: this starts a run and creates a roadmap entry.');
25
+ process.exit(2);
26
+ }
27
+
28
+ // --- a session, for the things only a person may do -------------------------
29
+
30
+ let cookie = '';
31
+ let csrf = '';
32
+
33
+ function headers(json = true) {
34
+ return {
35
+ cookie,
36
+ ...(json ? { 'content-type': 'application/json' } : {}),
37
+ ...(csrf ? { 'x-xsrf-token': csrf } : {}),
38
+ };
39
+ }
40
+
41
+ function remember(response) {
42
+ for (const value of response.headers.getSetCookie?.() ?? []) {
43
+ const [pair] = value.split(';');
44
+ const [name, ...rest] = pair.split('=');
45
+ if (name === 'XSRF-TOKEN') csrf = rest.join('=');
46
+ const others = cookie.split('; ').filter((entry) => entry && !entry.startsWith(`${name}=`));
47
+ cookie = [...others, pair].join('; ');
48
+ }
49
+ }
50
+
51
+ async function session(path, options = {}) {
52
+ const response = await fetch(`${BASE}${path}`, { ...options, headers: headers() });
53
+ remember(response);
54
+ const text = await response.text();
55
+ if (!response.ok) {
56
+ throw new Error(`${options.method ?? 'GET'} ${path} -> ${response.status}: ${text}`);
57
+ }
58
+ return text ? JSON.parse(text) : null;
59
+ }
60
+
61
+ await fetch(`${BASE}/api/auth/me`, { headers: headers(false) }).then(remember);
62
+ await session('/api/auth/login', {
63
+ method: 'POST',
64
+ body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
65
+ });
66
+
67
+ // A fresh entry and run. Any earlier live run would block this one.
68
+ for (const run of await session(`/api/projects/${project}/runs`)) {
69
+ if (run.live) {
70
+ await session(`/api/projects/${project}/runs/${run.id}/transition`, {
71
+ method: 'POST',
72
+ body: JSON.stringify({ state: 'CANCELLED', summary: 'Cleared by the orchestration smoke.' }),
73
+ });
74
+ }
75
+ }
76
+
77
+ const entry = await session(`/api/projects/${project}/roadmap`, {
78
+ method: 'POST',
79
+ body: JSON.stringify({
80
+ title: 'orchestration smoke (safe to decline)',
81
+ body: 'Created by tools/mcp/orchestration-smoke.mjs.\n\n**Build:** nothing, really.',
82
+ }),
83
+ });
84
+
85
+ const started = await session(`/api/projects/${project}/runs`, {
86
+ method: 'POST',
87
+ body: JSON.stringify({ entryNumber: entry.number, branch: `r${entry.number}-smoke` }),
88
+ });
89
+ const runId = started.id;
90
+
91
+ // The runner's part: claim it and set it running, so the agent's token is
92
+ // working on a run that is actually going.
93
+ const runnerToken = (
94
+ await session('/api/tokens', {
95
+ method: 'POST',
96
+ body: JSON.stringify({
97
+ label: `orchestration smoke ${Date.now()}`,
98
+ grants: { [project]: ['runner:operate'] },
99
+ }),
100
+ })
101
+ ).secret;
102
+
103
+ async function asRunner(path, body) {
104
+ const response = await fetch(`${BASE}${path}`, {
105
+ method: 'POST',
106
+ headers: {
107
+ authorization: `Bearer ${runnerToken}`,
108
+ ...(body ? { 'content-type': 'application/json' } : {}),
109
+ },
110
+ body: body ? JSON.stringify(body) : undefined,
111
+ });
112
+ if (!response.ok) {
113
+ throw new Error(`${path} -> ${response.status}: ${await response.text()}`);
114
+ }
115
+ return response.json();
116
+ }
117
+
118
+ const runner = await asRunner('/api/runners', { name: `smoke-${Date.now()}` });
119
+ // The claim is where the run token comes from — it goes straight to the runner,
120
+ // never to the person who started the run.
121
+ const claimed = await asRunner(`/api/runners/${runner.id}/claim/${runId}`);
122
+ await asRunner(`/api/projects/${project}/runs/${runId}/transition`, { state: 'RUNNING' });
123
+
124
+ // --- the agent's side, over real stdio ---------------------------------------
125
+
126
+ const server = spawn(process.execPath, [join(here, 'server.mjs')], {
127
+ stdio: ['pipe', 'pipe', 'inherit'],
128
+ env: {
129
+ ...process.env,
130
+ CAWDEV_URL: BASE,
131
+ CAWDEV_TOKEN: claimed.runToken,
132
+ // Short, so the pending path can be exercised without waiting ten minutes.
133
+ CAWDEV_ASK_TIMEOUT_SECONDS: '3',
134
+ },
135
+ });
136
+
137
+ let buffer = '';
138
+ const waiting = new Map();
139
+ server.stdout.on('data', (chunk) => {
140
+ buffer += chunk;
141
+ let newline;
142
+ while ((newline = buffer.indexOf('\n')) !== -1) {
143
+ const line = buffer.slice(0, newline).trim();
144
+ buffer = buffer.slice(newline + 1);
145
+ if (!line) continue;
146
+ const message = JSON.parse(line);
147
+ const resolve = waiting.get(message.id);
148
+ if (resolve) {
149
+ waiting.delete(message.id);
150
+ resolve(message);
151
+ }
152
+ }
153
+ });
154
+
155
+ let nextId = 1;
156
+ function request(method, params) {
157
+ const id = nextId++;
158
+ return new Promise((resolve, reject) => {
159
+ waiting.set(id, resolve);
160
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
161
+ setTimeout(() => reject(new Error(`${method} timed out`)), 60_000).unref();
162
+ });
163
+ }
164
+ const callTool = (name, args = {}) => request('tools/call', { name, arguments: args });
165
+ const textOf = (result) => result?.content?.map((part) => part.text).join('\n') ?? '';
166
+
167
+ const checks = [];
168
+ function check(description, condition, detail = '') {
169
+ checks.push(Boolean(condition));
170
+ console.log(
171
+ `${condition ? 'ok ' : 'FAIL'} ${description}${detail && !condition ? `\n ${detail}` : ''}`,
172
+ );
173
+ }
174
+
175
+ try {
176
+ await request('initialize', { protocolVersion: '2024-11-05', capabilities: {} });
177
+
178
+ const current = await callTool('task_current');
179
+ check(
180
+ 'task_current re-orients: entry, branch and run state',
181
+ !current.result?.isError
182
+ && textOf(current.result).includes(`r${entry.number}-smoke`)
183
+ && textOf(current.result).includes('orchestration smoke'),
184
+ textOf(current.result),
185
+ );
186
+
187
+ const progress = await callTool('report', { kind: 'progress', body: 'Read the entry.' });
188
+ check('report progress keeps the run RUNNING',
189
+ textOf(progress.result).includes('RUNNING'), textOf(progress.result));
190
+
191
+ // The pending path: nobody answers within the shortened timeout.
192
+ const pending = await callTool('ask_user', { question: 'Nobody will answer this one.' });
193
+ check(
194
+ 'ask_user hands back a question_id when nobody answers',
195
+ textOf(pending.result).includes('await_answer')
196
+ && /question_id [0-9a-f-]{36}/.test(textOf(pending.result)),
197
+ textOf(pending.result),
198
+ );
199
+ const pendingId = /question_id ([0-9a-f-]{36})/.exec(textOf(pending.result))?.[1];
200
+
201
+ // R10's headline: the agent asks and blocks; something else answers; the
202
+ // tool call returns the answer.
203
+ const asking = callTool('ask_user', {
204
+ question: 'Postgres or SQLite?',
205
+ options: ['Postgres', 'SQLite'],
206
+ });
207
+
208
+ // Give the ask time to reach the platform, then answer it from outside.
209
+ await new Promise((resolve) => setTimeout(resolve, 800));
210
+ // Three groups since R36, not one list: what is on you, what you passed on,
211
+ // and what somebody wants your opinion about. Nothing has been passed
212
+ // anywhere here, so it is in the first.
213
+ const inbox = await session('/api/inbox');
214
+ const target = inbox.waitingOnYou.find(
215
+ (item) => item.question.question === 'Postgres or SQLite?',
216
+ );
217
+ check('the question reached the inbox with its run context',
218
+ target && target.projectSlug === project, JSON.stringify(inbox));
219
+
220
+ await session(
221
+ `/api/projects/${project}/runs/${runId}/questions/${target.question.id}/answer`,
222
+ { method: 'POST', body: JSON.stringify({ answer: 'Postgres, to match the platform.' }) },
223
+ );
224
+
225
+ const answered = await asking;
226
+ check(
227
+ 'the waiting ask_user call returns the answer',
228
+ !answered.result?.isError
229
+ && textOf(answered.result).includes('Postgres, to match the platform.')
230
+ && textOf(answered.result).includes(EMAIL),
231
+ textOf(answered.result),
232
+ );
233
+
234
+ // And await_answer resumes the one left pending.
235
+ await session(`/api/projects/${project}/runs/${runId}/questions/${pendingId}/answer`, {
236
+ method: 'POST',
237
+ body: JSON.stringify({ answer: 'Answered late.' }),
238
+ });
239
+ const resumed = await callTool('await_answer', { question_id: pendingId });
240
+ check('await_answer picks up the question ask_user handed back',
241
+ textOf(resumed.result).includes('Answered late.'), textOf(resumed.result));
242
+
243
+ const done = await callTool('report', {
244
+ kind: 'done',
245
+ body: `Nothing to build. Branch r${entry.number}-smoke exists only in this test.`,
246
+ });
247
+ check('report done ends the run, and says the token is spent',
248
+ textOf(done.result).includes('FINISHED') && textOf(done.result).includes('expired'),
249
+ textOf(done.result));
250
+
251
+ // The run is over, so its token is too.
252
+ const afterwards = await callTool('task_current');
253
+ check('the run token stops working once the run has ended',
254
+ afterwards.result?.isError, textOf(afterwards.result));
255
+ } finally {
256
+ server.stdin.end();
257
+ // Leave the entry declined rather than lying around as PLANNED — entries
258
+ // cannot be deleted, so the least noise is an honest reason.
259
+ await session(`/api/projects/${project}/roadmap/${entry.number}/decline`, {
260
+ method: 'POST',
261
+ body: JSON.stringify({ reason: 'A smoke-test entry, declined by the test that made it.' }),
262
+ }).catch(() => undefined);
263
+ }
264
+
265
+ const failed = checks.filter((ok) => !ok).length;
266
+ console.log(`\n${checks.length - failed}/${checks.length} checks passed.`);
267
+ process.exit(failed ? 1 : 0);