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/README.md +175 -0
- package/lib/ansi.mjs +224 -0
- package/lib/cawdev.mjs +104 -0
- package/lib/code-map.mjs +164 -0
- package/lib/harness-prompt.mjs +197 -0
- package/lib/roadmap-format.mjs +453 -0
- package/lib/run-plugin.mjs +119 -0
- package/lib/secrets.mjs +290 -0
- package/lib/stage-tools.mjs +384 -0
- package/lib/tool-line.mjs +92 -0
- package/lib/tool-rules.mjs +282 -0
- package/lib/transcript-batch.mjs +88 -0
- package/lib/usage-limit.mjs +80 -0
- package/lib/usage-report.mjs +142 -0
- package/lib/usage.mjs +119 -0
- package/mcp/README.md +273 -0
- package/mcp/orchestration-smoke.mjs +267 -0
- package/mcp/server.mjs +2163 -0
- package/mcp/smoke.mjs +220 -0
- package/package.json +20 -0
- package/runner/README.md +930 -0
- package/runner/attach.mjs +2397 -0
- package/runner/banner.mjs +106 -0
- package/runner/bootstrap.mjs +501 -0
- package/runner/brand.mjs +57 -0
- package/runner/cawdev.mjs +414 -0
- package/runner/control.mjs +225 -0
- package/runner/history.mjs +91 -0
- package/runner/input.mjs +355 -0
- package/runner/macbook-laptop.json +48 -0
- package/runner/runner.mjs +7445 -0
- package/runner/scrollback.mjs +165 -0
- package/runner/select.mjs +316 -0
- package/runner/session-store.mjs +78 -0
- package/runner/sign-in.mjs +210 -0
- package/runner/stub-agent.mjs +212 -0
- package/runner/token-store.mjs +107 -0
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);
|