cawdev-cli 1.0.6-beta → 1.0.8-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/finish-check.mjs +178 -0
- package/lib/roadmap-format.mjs +34 -7
- package/mcp/README.md +7 -2
- package/mcp/server.mjs +88 -11
- package/package.json +1 -1
- package/runner/README.md +83 -4
- package/runner/attach.mjs +83 -23
- package/runner/cawdev.mjs +2 -2
- package/runner/configure.mjs +96 -18
- package/runner/input.mjs +5 -1
- package/runner/restart.mjs +191 -0
- package/runner/runner.mjs +203 -12
- package/runner/sign-in.mjs +11 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// A finished run is committed and pushed — R345.
|
|
2
|
+
//
|
|
3
|
+
// R325's run ended FINISHED with a file modified and its branch never pushed,
|
|
4
|
+
// and the next run in that checkout refused to start on top of it. Nothing
|
|
5
|
+
// between a coding session's last turn and FINISHED looked at the checkout:
|
|
6
|
+
// the merge run did (`finishMerge`), an ordinary CODE run did not, on either
|
|
7
|
+
// door — the daemon's `finish` or the session's own `report done`.
|
|
8
|
+
//
|
|
9
|
+
// Two answers, one per door, both reading `leftOver`:
|
|
10
|
+
// - `report done` refuses while anything is left and says what, so the
|
|
11
|
+
// session — which knows what the files are — commits them in its own turn;
|
|
12
|
+
// - the daemon, when it is the one ending the run, `settleBranch`es: commits
|
|
13
|
+
// what is left and pushes, or reports why it could not, which the caller
|
|
14
|
+
// turns into FAILED with the checkout held (R80) instead of a FINISHED
|
|
15
|
+
// that strands the next run.
|
|
16
|
+
//
|
|
17
|
+
// No `--force`, ever, and nothing committed that looks like a credential: a
|
|
18
|
+
// finish that pushes a key is worse than one that leaves a file behind.
|
|
19
|
+
|
|
20
|
+
import { execFile } from 'node:child_process';
|
|
21
|
+
import { findSecret } from './secrets.mjs';
|
|
22
|
+
|
|
23
|
+
/** The profiles whose run is a branch somebody will build on. */
|
|
24
|
+
export const COMMITS_ITS_WORK = new Set(['CODE']);
|
|
25
|
+
|
|
26
|
+
/** How many paths a refusal or a summary names; the count still tells the truth. */
|
|
27
|
+
const NAMED = 10;
|
|
28
|
+
|
|
29
|
+
function runGit(cwd, args) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (failure, stdout, stderr) => {
|
|
32
|
+
if (failure) {
|
|
33
|
+
failure.message = (stderr || failure.message).trim();
|
|
34
|
+
reject(failure);
|
|
35
|
+
} else {
|
|
36
|
+
resolve(stdout);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What a run left in its checkout that is not on the remote: uncommitted
|
|
44
|
+
* paths, and how many commits on `branch` no remote ref contains — the work a
|
|
45
|
+
* push would carry. Measured against EVERY remote ref, not `origin/<branch>`,
|
|
46
|
+
* so a branch the remote has never seen but whose commits are all on `main`
|
|
47
|
+
* (a run stopped at its plan gate) is not "unpushed work", and a checkout
|
|
48
|
+
* with no remote at all has nothing to push. `null` when the directory is not
|
|
49
|
+
* a git checkout we can read: nothing to judge is not a failure to judge.
|
|
50
|
+
*/
|
|
51
|
+
export async function leftOver(cwd, branch, { git = runGit } = {}) {
|
|
52
|
+
if (!cwd || !branch) return null;
|
|
53
|
+
let status;
|
|
54
|
+
try {
|
|
55
|
+
status = await git(cwd, ['status', '--porcelain', '--untracked-files=all']);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const dirty = status.split('\n').map((line) => line.slice(3).trim()).filter(Boolean);
|
|
60
|
+
const current = (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '')).trim();
|
|
61
|
+
const remote = (await git(cwd, ['remote']).catch(() => '')).split('\n')[0]?.trim() || null;
|
|
62
|
+
const exists = await git(cwd, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`])
|
|
63
|
+
.then(() => true, () => false);
|
|
64
|
+
let ahead = 0;
|
|
65
|
+
if (remote && exists) {
|
|
66
|
+
const counted = await git(cwd, ['rev-list', '--count', branch, '--not', '--remotes'])
|
|
67
|
+
.catch(() => '0');
|
|
68
|
+
ahead = Number(counted.trim()) || 0;
|
|
69
|
+
}
|
|
70
|
+
return { dirty, ahead, remote, onBranch: current === branch, branch };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether anything is left — what both doors ask first. */
|
|
74
|
+
export function isUnfinished(left) {
|
|
75
|
+
return Boolean(left) && (left.dirty.length > 0 || left.ahead > 0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function named(paths) {
|
|
79
|
+
const shown = paths.slice(0, NAMED).map((path) => `\`${path}\``).join(', ');
|
|
80
|
+
return paths.length > NAMED ? `${shown} and ${paths.length - NAMED} more` : shown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What `report done` says instead of finishing — to the session, which can fix
|
|
85
|
+
* it. Names the files and the commits, and says what "done" means here.
|
|
86
|
+
*/
|
|
87
|
+
export function notDoneYet(left) {
|
|
88
|
+
const what = [];
|
|
89
|
+
if (!left.onBranch && left.dirty.length) {
|
|
90
|
+
what.push(`the checkout is not on \`${left.branch}\``);
|
|
91
|
+
}
|
|
92
|
+
if (left.dirty.length) {
|
|
93
|
+
what.push(`${left.dirty.length} uncommitted file(s): ${named(left.dirty)}`);
|
|
94
|
+
}
|
|
95
|
+
if (left.ahead > 0) {
|
|
96
|
+
what.push(`${left.ahead} commit(s) on \`${left.branch}\` that ${left.remote} has not seen`);
|
|
97
|
+
}
|
|
98
|
+
return `Not finished yet — the run is still open. A finished run is committed and pushed, `
|
|
99
|
+
+ `and this one has ${what.join('; ')}. Commit what belongs to this work on `
|
|
100
|
+
+ `\`${left.branch}\` (discard what does not), \`git push\`, then report done again. If `
|
|
101
|
+
+ 'something stops you, report blocked and say what.';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Commits what the session left and pushes the branch — the daemon's door.
|
|
106
|
+
*
|
|
107
|
+
* Returns `{ ok: true, note }` when the branch is clean and on the remote (the
|
|
108
|
+
* note says what was done on the session's behalf, or is null when nothing
|
|
109
|
+
* was), and `{ ok: false, reason }` otherwise, in words for the run's summary.
|
|
110
|
+
* Never forces; a remote that has moved is a reason, not something to win.
|
|
111
|
+
*/
|
|
112
|
+
export async function settleBranch(cwd, branch, { git = runGit, push, label = 'cawdev' } = {}) {
|
|
113
|
+
const left = await leftOver(cwd, branch, { git });
|
|
114
|
+
if (!left || !isUnfinished(left)) {
|
|
115
|
+
return { ok: true, note: null };
|
|
116
|
+
}
|
|
117
|
+
if (!left.onBranch && left.dirty.length) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
reason: `The checkout is not on ${branch}, so the daemon did not commit or push anything `
|
|
121
|
+
+ 'for it. The work is held in the checkout.',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let committed = [];
|
|
126
|
+
if (left.dirty.length) {
|
|
127
|
+
await git(cwd, ['add', '-A']);
|
|
128
|
+
const staged = await git(cwd, ['diff', '--cached']).catch(() => '');
|
|
129
|
+
const secret = findSecret(staged);
|
|
130
|
+
if (secret) {
|
|
131
|
+
await git(cwd, ['reset', '-q']).catch(() => null);
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
reason: `The session left ${left.dirty.length} uncommitted file(s) (${named(left.dirty)}), `
|
|
135
|
+
+ `and the change contains what looks like ${secret.name}, so the daemon did not `
|
|
136
|
+
+ 'commit it. The work is held in the checkout: commit what belongs, or discard it.',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
await git(cwd, ['commit', '-q', '-m', `${label}: what the session left uncommitted`,
|
|
141
|
+
'-m', `Committed by the cawdev daemon as the run finished (R345): ${left.dirty.join(', ')}`]);
|
|
142
|
+
committed = left.dirty;
|
|
143
|
+
} catch (failure) {
|
|
144
|
+
await git(cwd, ['reset', '-q']).catch(() => null);
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
reason: `The session left ${left.dirty.length} uncommitted file(s) (${named(left.dirty)}) and `
|
|
148
|
+
+ `committing them failed: ${failure.message.split('\n')[0]}. The work is held in the checkout.`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!left.remote) {
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
note: committed.length
|
|
157
|
+
? `The session left ${committed.length} file(s) uncommitted (${named(committed)}); the `
|
|
158
|
+
+ 'daemon committed them. This checkout has no remote, so nothing was pushed.'
|
|
159
|
+
: null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const pushed = await push(cwd, branch);
|
|
163
|
+
if (!pushed.ok) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: `${branch} could not be pushed: ${String(pushed.result).split('\n')[0]}`
|
|
167
|
+
+ (committed.length ? ` (the daemon had committed ${named(committed)} first)` : '')
|
|
168
|
+
+ '. The work is held in the checkout.',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
note: committed.length
|
|
174
|
+
? `The session left ${committed.length} file(s) uncommitted (${named(committed)}); the `
|
|
175
|
+
+ `daemon committed them and pushed ${branch}.`
|
|
176
|
+
: `The daemon pushed ${branch}, which the session had committed and not pushed.`,
|
|
177
|
+
};
|
|
178
|
+
}
|
package/lib/roadmap-format.mjs
CHANGED
|
@@ -95,9 +95,9 @@ export function issueSectionOf(entry) {
|
|
|
95
95
|
* Issues, rendered as their own file.
|
|
96
96
|
*
|
|
97
97
|
* Sorted by severity inside each section, critical first, because that IS the
|
|
98
|
-
* question the file is read to answer.
|
|
99
|
-
*
|
|
100
|
-
*
|
|
98
|
+
* question the file is read to answer. Within a severity the order is the one
|
|
99
|
+
* given — R335: the server's, which is the board's, a person's by dragging.
|
|
100
|
+
* `Array.prototype.sort` is stable, so a tie keeps it.
|
|
101
101
|
*/
|
|
102
102
|
export function renderIssues(entries, { preamble }) {
|
|
103
103
|
const rank = { CRITICAL: 0, MEDIUM: 1, MINOR: 2 };
|
|
@@ -108,8 +108,7 @@ export function renderIssues(entries, { preamble }) {
|
|
|
108
108
|
bySection.get(section).push(entry);
|
|
109
109
|
}
|
|
110
110
|
for (const list of bySection.values()) {
|
|
111
|
-
list.sort((a, b) =>
|
|
112
|
-
(rank[a.severity] ?? 3) - (rank[b.severity] ?? 3) || a.number - b.number);
|
|
111
|
+
list.sort((a, b) => (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3));
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
const ordered = [
|
|
@@ -225,6 +224,14 @@ export function parseRoadmap(text) {
|
|
|
225
224
|
entry.severity = severity[1].trim();
|
|
226
225
|
continue;
|
|
227
226
|
}
|
|
227
|
+
const priority = /^Priority:\s*(.+)$/.exec(line.trim());
|
|
228
|
+
if (priority) {
|
|
229
|
+
entry.priority = priority[1].trim().toUpperCase();
|
|
230
|
+
if (!['HIGHEST', 'HIGH', 'MEDIUM', 'LOW', 'LOWEST'].includes(entry.priority)) {
|
|
231
|
+
problems.push(`${block.heading}: Priority "${priority[1].trim()}" is not one of highest, high, medium, low, lowest.`);
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
228
235
|
const development = /^Development:\s*(.+)$/.exec(line.trim());
|
|
229
236
|
if (development) {
|
|
230
237
|
entry.development = statusFromDisplay(development[1].trim());
|
|
@@ -262,6 +269,13 @@ export function parseRoadmap(text) {
|
|
|
262
269
|
entry.tags = tags[1].split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
|
|
263
270
|
continue;
|
|
264
271
|
}
|
|
272
|
+
const assignee = /^Assignee:\s*(\S+)$/.exec(line.trim());
|
|
273
|
+
if (assignee) {
|
|
274
|
+
// R331. A username only: the email is for a tooltip and never leaves
|
|
275
|
+
// the platform in a file.
|
|
276
|
+
entry.assignee = { username: assignee[1] };
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
265
279
|
// R315. Computed, never stored, and never sent by import.mjs — the same
|
|
266
280
|
// arrangement as `Development:` above. Kept as the exact text so a file
|
|
267
281
|
// parsed and re-rendered without asking the API again is still a fixed
|
|
@@ -304,7 +318,8 @@ export function parseRoadmap(text) {
|
|
|
304
318
|
entries.push(entry);
|
|
305
319
|
}
|
|
306
320
|
|
|
307
|
-
|
|
321
|
+
// File order, not number order — R335: a section's order is a person's, and
|
|
322
|
+
// the importer's third pass sends it back to the server as written.
|
|
308
323
|
return { entries, problems };
|
|
309
324
|
}
|
|
310
325
|
|
|
@@ -350,7 +365,9 @@ function* splitBlocks(text) {
|
|
|
350
365
|
*/
|
|
351
366
|
export function renderRoadmap(entries, { preamble, sectionOrder = [] }) {
|
|
352
367
|
const bySection = new Map();
|
|
353
|
-
|
|
368
|
+
// In the order given, not sorted — R335: the server's order is the board's
|
|
369
|
+
// and the file's order is what an import rebuilds ranks from.
|
|
370
|
+
for (const entry of entries) {
|
|
354
371
|
const section = sectionOf(entry);
|
|
355
372
|
if (!bySection.has(section)) bySection.set(section, []);
|
|
356
373
|
bySection.get(section).push(entry);
|
|
@@ -420,6 +437,12 @@ function renderEntry(entry) {
|
|
|
420
437
|
lines.push(`Severity: ${entry.severity}`);
|
|
421
438
|
}
|
|
422
439
|
|
|
440
|
+
// R335. MEDIUM is the default and is not written, so a card nobody has
|
|
441
|
+
// prioritised reads as it did before the field existed.
|
|
442
|
+
if (entry.priority && entry.priority !== 'MEDIUM') {
|
|
443
|
+
lines.push(`Priority: ${entry.priority}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
423
446
|
// Each status writes only what it currently claims. A MERGED entry keeps its
|
|
424
447
|
// branch in the database as history, but the file must not print it: the
|
|
425
448
|
// branch is expected to be deleted, and `--live` would then be reading a claim
|
|
@@ -473,6 +496,10 @@ function renderEntry(entry) {
|
|
|
473
496
|
// R315. Sorted, so the export does not churn on the set's own order.
|
|
474
497
|
lines.push(`Tags: ${[...entry.tags].sort().join(', ')}`);
|
|
475
498
|
}
|
|
499
|
+
if (entry.assignee?.username) {
|
|
500
|
+
// R331. Nothing for a card with nobody, so today's files stay byte-identical.
|
|
501
|
+
lines.push(`Assignee: ${entry.assignee.username}`);
|
|
502
|
+
}
|
|
476
503
|
const origin = originWord(entry.origin);
|
|
477
504
|
if (origin) {
|
|
478
505
|
lines.push(`Origin: ${origin}`);
|
package/mcp/README.md
CHANGED
|
@@ -163,7 +163,7 @@ what you are allowed to do.
|
|
|
163
163
|
| `roadmap_list` | Entries, optionally by status. `brief` omits bodies — use it to survey. |
|
|
164
164
|
| `roadmap_get` | One entry in full: its body, **the plan agreed for it**, and the discussion on it. |
|
|
165
165
|
| `roadmap_create` | Create an entry; the platform allocates its permanent number. |
|
|
166
|
-
| `roadmap_update` | Title, body, section, related ids,
|
|
166
|
+
| `roadmap_update` | Title, body, section, related ids, the cards it starts coding after (`after` — R181), and who it is assigned to (`assignee` — R331: a username, `""` clears; `roadmap_list`/`issue_list` take `assignee` too, and `me` means the token's owner). |
|
|
167
167
|
| `roadmap_comment` | Say something *beside* an entry rather than inside it. |
|
|
168
168
|
| `roadmap_set_status` | Move an entry, carrying whatever the status requires. |
|
|
169
169
|
| `roadmap_decline` | Decline with a reason. The only exit. |
|
|
@@ -183,7 +183,7 @@ token gets a refusal saying so.
|
|
|
183
183
|
| Tool | What it does |
|
|
184
184
|
|---|---|
|
|
185
185
|
| `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. |
|
|
186
|
-
| `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
|
+
| `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; on a coding run `done` is refused until everything is committed and pushed (R345). |
|
|
187
187
|
| `ask_user` | Ask the person who started the run, and wait. Blocks up to ten minutes, then hands back a `question_id`. |
|
|
188
188
|
| `await_answer` | Resume waiting for a question `ask_user` handed back. |
|
|
189
189
|
| `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`. |
|
|
@@ -216,6 +216,11 @@ answers — they can always write their own.
|
|
|
216
216
|
After `report done` or `report blocked`, the run is over and your token has
|
|
217
217
|
expired with it. There is nothing further to do.
|
|
218
218
|
|
|
219
|
+
On a coding run, `report done` is **refused** while the checkout has
|
|
220
|
+
uncommitted files or commits the remote has not seen (R345) — the answer names
|
|
221
|
+
them. Commit what belongs to the work (discard what does not), `git push`, and
|
|
222
|
+
report done again; `report blocked` if something stops you.
|
|
223
|
+
|
|
219
224
|
### When something you need is not allowed (R51)
|
|
220
225
|
|
|
221
226
|
You will not usually notice this happening. When you call a tool no rule covers
|
package/mcp/server.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
withinCeiling,
|
|
23
23
|
} from '../lib/tool-rules.mjs';
|
|
24
24
|
import { findDestructive, withinScope } from '../lib/secrets.mjs';
|
|
25
|
+
import { COMMITS_ITS_WORK, isUnfinished, leftOver, notDoneYet } from '../lib/finish-check.mjs';
|
|
25
26
|
|
|
26
27
|
const NAME = 'cawdev';
|
|
27
28
|
const VERSION = '0.1.0';
|
|
@@ -421,6 +422,12 @@ const TOOLS = [
|
|
|
421
422
|
'DECLINED',
|
|
422
423
|
],
|
|
423
424
|
},
|
|
425
|
+
assignee: {
|
|
426
|
+
type: 'string',
|
|
427
|
+
description:
|
|
428
|
+
'Only cards assigned to this person — a username, or "me" for the owner of this ' +
|
|
429
|
+
'token. An unknown username is refused.',
|
|
430
|
+
},
|
|
424
431
|
brief: { type: 'boolean', description: 'Omit bodies. Default true.' },
|
|
425
432
|
},
|
|
426
433
|
},
|
|
@@ -432,6 +439,7 @@ const TOOLS = [
|
|
|
432
439
|
// R257. The API refuses a number with no sprint behind it, naming the
|
|
433
440
|
// sprints there are; that sentence is passed through as the tool error.
|
|
434
441
|
if (args.sprint) query.set('sprint', String(args.sprint));
|
|
442
|
+
if (args.assignee) query.set('assignee', args.assignee);
|
|
435
443
|
query.set('brief', String(brief));
|
|
436
444
|
|
|
437
445
|
const entries = await api(config, `/api/projects/${slug}/roadmap?${query}`);
|
|
@@ -672,6 +680,18 @@ const TOOLS = [
|
|
|
672
680
|
'Free words — lowercase, letters/digits/./_/-, up to 32 characters, at least one ' +
|
|
673
681
|
'letter, at most 12. The leading # is optional and stripped.',
|
|
674
682
|
},
|
|
683
|
+
assignee: {
|
|
684
|
+
type: 'string',
|
|
685
|
+
description:
|
|
686
|
+
'A username — the person responsible for the card. Leave out for nobody. Refused, naming the ' +
|
|
687
|
+
'person, unless they hold WRITER or more on the project and are not disabled.',
|
|
688
|
+
},
|
|
689
|
+
priority: {
|
|
690
|
+
type: 'string',
|
|
691
|
+
enum: ['HIGHEST', 'HIGH', 'MEDIUM', 'LOW', 'LOWEST'],
|
|
692
|
+
description:
|
|
693
|
+
'How much it matters; it does not reorder the board. Leave out for MEDIUM.',
|
|
694
|
+
},
|
|
675
695
|
},
|
|
676
696
|
required: ['title'],
|
|
677
697
|
},
|
|
@@ -681,7 +701,7 @@ const TOOLS = [
|
|
|
681
701
|
method: 'POST',
|
|
682
702
|
body: pick(args, [
|
|
683
703
|
'title', 'body', 'status', 'branch', 'merge', 'version', 'reason', 'section', 'related',
|
|
684
|
-
'after', 'sprint', 'tags',
|
|
704
|
+
'after', 'sprint', 'tags', 'assignee', 'priority',
|
|
685
705
|
]),
|
|
686
706
|
});
|
|
687
707
|
return `Created ${refOf(entry)} in ${slug}.\n\n${formatEntry(entry, {})}`;
|
|
@@ -703,6 +723,10 @@ const TOOLS = [
|
|
|
703
723
|
type: 'string',
|
|
704
724
|
enum: ['NEW', 'CONFIRMED', 'IN_DEVELOPMENT', 'MERGED', 'SHIPPED', 'DECLINED'],
|
|
705
725
|
},
|
|
726
|
+
assignee: {
|
|
727
|
+
type: 'string',
|
|
728
|
+
description: 'Only issues assigned to this person — a username, or "me".',
|
|
729
|
+
},
|
|
706
730
|
brief: { type: 'boolean', description: 'Drop the bodies.' },
|
|
707
731
|
},
|
|
708
732
|
},
|
|
@@ -711,6 +735,7 @@ const TOOLS = [
|
|
|
711
735
|
const query = new URLSearchParams();
|
|
712
736
|
if (args.status) query.set('status', args.status);
|
|
713
737
|
if (args.brief) query.set('brief', 'true');
|
|
738
|
+
if (args.assignee) query.set('assignee', args.assignee);
|
|
714
739
|
const suffix = query.toString() ? `?${query}` : '';
|
|
715
740
|
const issues = await api(config, `/api/projects/${slug}/issues${suffix}`);
|
|
716
741
|
if (!issues.length) {
|
|
@@ -748,6 +773,18 @@ const TOOLS = [
|
|
|
748
773
|
'Free words — lowercase, letters/digits/./_/-, up to 32 characters, at least one ' +
|
|
749
774
|
'letter, at most 12. The leading # is optional and stripped.',
|
|
750
775
|
},
|
|
776
|
+
assignee: {
|
|
777
|
+
type: 'string',
|
|
778
|
+
description:
|
|
779
|
+
'A username — the person responsible for the card. Leave out for nobody. Refused, naming the ' +
|
|
780
|
+
'person, unless they hold WRITER or more on the project and are not disabled.',
|
|
781
|
+
},
|
|
782
|
+
priority: {
|
|
783
|
+
type: 'string',
|
|
784
|
+
enum: ['HIGHEST', 'HIGH', 'MEDIUM', 'LOW', 'LOWEST'],
|
|
785
|
+
description:
|
|
786
|
+
'How much it matters; it does not reorder the board. Leave out for MEDIUM.',
|
|
787
|
+
},
|
|
751
788
|
},
|
|
752
789
|
required: ['title', 'severity'],
|
|
753
790
|
},
|
|
@@ -755,7 +792,7 @@ const TOOLS = [
|
|
|
755
792
|
const slug = await resolveProject(config, args.project);
|
|
756
793
|
const issue = await api(config, `/api/projects/${slug}/issues`, {
|
|
757
794
|
method: 'POST',
|
|
758
|
-
body: pick(args, ['title', 'body', 'severity', 'status', 'related', 'tags']),
|
|
795
|
+
body: pick(args, ['title', 'body', 'severity', 'status', 'related', 'tags', 'assignee', 'priority']),
|
|
759
796
|
});
|
|
760
797
|
return `Filed ${refOf(issue)} in ${slug}.\n\n${formatEntry(issue, {})}`;
|
|
761
798
|
},
|
|
@@ -833,6 +870,18 @@ const TOOLS = [
|
|
|
833
870
|
description:
|
|
834
871
|
'Free words — same rule as roadmap_create. Leave out to leave them alone; [] clears them.',
|
|
835
872
|
},
|
|
873
|
+
assignee: {
|
|
874
|
+
type: 'string',
|
|
875
|
+
description:
|
|
876
|
+
'A username — the person responsible for the card. Leave out to leave it alone; "" takes the card off them. Refused, naming the ' +
|
|
877
|
+
'person, unless they hold WRITER or more on the project and are not disabled.',
|
|
878
|
+
},
|
|
879
|
+
priority: {
|
|
880
|
+
type: 'string',
|
|
881
|
+
enum: ['HIGHEST', 'HIGH', 'MEDIUM', 'LOW', 'LOWEST'],
|
|
882
|
+
description:
|
|
883
|
+
'How much it matters; it does not reorder the board. Leave out for MEDIUM (leave out to leave it alone).',
|
|
884
|
+
},
|
|
836
885
|
},
|
|
837
886
|
required: ['number'],
|
|
838
887
|
},
|
|
@@ -841,7 +890,7 @@ const TOOLS = [
|
|
|
841
890
|
const entry = await api(config, `/api/projects/${slug}/roadmap/${args.number}`, {
|
|
842
891
|
method: 'PATCH',
|
|
843
892
|
// pick keeps a present 0, which is how a card leaves its sprint.
|
|
844
|
-
body: pick(args, ['title', 'body', 'section', 'related', 'after', 'sprint', 'tags']),
|
|
893
|
+
body: pick(args, ['title', 'body', 'section', 'related', 'after', 'sprint', 'tags', 'assignee', 'priority']),
|
|
845
894
|
});
|
|
846
895
|
return `Updated ${refOf(entry)}.\n\n${formatEntry(entry, {})}`;
|
|
847
896
|
},
|
|
@@ -1181,7 +1230,9 @@ const TOOLS = [
|
|
|
1181
1230
|
description:
|
|
1182
1231
|
'Say what is happening. `progress` as often as useful; `done` when the work is finished, ' +
|
|
1183
1232
|
'naming the branch and any PR; `blocked` when something stops you that a person must ' +
|
|
1184
|
-
'resolve. done and blocked also end the run — you do not need a separate step.'
|
|
1233
|
+
'resolve. done and blocked also end the run — you do not need a separate step. On a ' +
|
|
1234
|
+
'coding run, done is refused while the checkout has uncommitted files or unpushed ' +
|
|
1235
|
+
'commits: commit and push first.',
|
|
1185
1236
|
inputSchema: {
|
|
1186
1237
|
type: 'object',
|
|
1187
1238
|
properties: {
|
|
@@ -1192,6 +1243,21 @@ const TOOLS = [
|
|
|
1192
1243
|
},
|
|
1193
1244
|
handler: async (config, args) => {
|
|
1194
1245
|
const { runId, project } = await requireRun(config);
|
|
1246
|
+
|
|
1247
|
+
// R345: a finished run is committed and pushed. Asked HERE, before the
|
|
1248
|
+
// message is posted, because this is the one moment the session that
|
|
1249
|
+
// made the files is still there to say what they are — the daemon's own
|
|
1250
|
+
// check at the end can only commit them blind or hold them.
|
|
1251
|
+
if (args.kind === 'done') {
|
|
1252
|
+
const run = await api(config, `/api/projects/${project}/runs/${runId}`).catch(() => null);
|
|
1253
|
+
if (run && run.branch && COMMITS_ITS_WORK.has(run.profile)) {
|
|
1254
|
+
const left = await leftOver(process.env.CAWDEV_WORKSPACE ?? process.cwd(), run.branch);
|
|
1255
|
+
if (isUnfinished(left)) {
|
|
1256
|
+
return notDoneYet(left);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1195
1261
|
const message = await api(config, `/api/projects/${project}/runs/${runId}/messages`, {
|
|
1196
1262
|
method: 'POST',
|
|
1197
1263
|
body: { kind: args.kind.toUpperCase(), body: args.body },
|
|
@@ -1651,9 +1717,13 @@ async function decide(config, args) {
|
|
|
1651
1717
|
}
|
|
1652
1718
|
// EXPIRED, or the poll gave up before the platform expired it. Same answer
|
|
1653
1719
|
// either way, and the difference is on the run for whoever reads it later.
|
|
1720
|
+
// R339: a person may still allow it late, and if they do the session is
|
|
1721
|
+
// sent a message saying so — so this says to carry on, not to stop.
|
|
1654
1722
|
return deny(
|
|
1655
|
-
`Nobody answered this permission request in time. Do not retry it in a loop
|
|
1656
|
-
`
|
|
1723
|
+
`Nobody answered this permission request in time. Do not retry it in a loop. Carry on ` +
|
|
1724
|
+
`with whatever does not need it; if a person allows it later you will be sent a ` +
|
|
1725
|
+
`message saying so, and may run it then. If nothing else can be done without it, ` +
|
|
1726
|
+
`report blocked and say exactly what you needed to run and why.`,
|
|
1657
1727
|
);
|
|
1658
1728
|
} catch (failure) {
|
|
1659
1729
|
return deny(
|
|
@@ -1806,9 +1876,10 @@ function skillServers() {
|
|
|
1806
1876
|
/**
|
|
1807
1877
|
* Waits for somebody to decide, for as long as the platform will hold it open.
|
|
1808
1878
|
*
|
|
1809
|
-
*
|
|
1810
|
-
*
|
|
1811
|
-
*
|
|
1879
|
+
* R339: the platform's wait is the project's setting now, and may be never,
|
|
1880
|
+
* so this has no deadline of its own: it returns when the request stops being
|
|
1881
|
+
* PENDING — allowed, refused, or expired by the platform's sweep. A test may
|
|
1882
|
+
* still bound it.
|
|
1812
1883
|
*/
|
|
1813
1884
|
async function pollForDecision(config, project, runId, approvalId) {
|
|
1814
1885
|
const deadline = Date.now() + approvalTimeoutSeconds() * 1000;
|
|
@@ -1831,10 +1902,10 @@ async function pollForDecision(config, project, runId, approvalId) {
|
|
|
1831
1902
|
return null;
|
|
1832
1903
|
}
|
|
1833
1904
|
|
|
1834
|
-
/**
|
|
1905
|
+
/** For ever: the platform decides when nobody is coming (R339). Overridable for tests. */
|
|
1835
1906
|
function approvalTimeoutSeconds() {
|
|
1836
1907
|
const configured = Number(process.env.CAWDEV_APPROVAL_TIMEOUT_SECONDS);
|
|
1837
|
-
return Number.isFinite(configured) && configured > 0 ? configured :
|
|
1908
|
+
return Number.isFinite(configured) && configured > 0 ? configured : Infinity;
|
|
1838
1909
|
}
|
|
1839
1910
|
|
|
1840
1911
|
// There is no roadmap_delete or changelog_delete, and there will not be. The
|
|
@@ -2112,6 +2183,12 @@ function formatEntry(entry, { brief, comments, plan }) {
|
|
|
2112
2183
|
// R315. Sorted for the same reason the export is: a reader's shape must not
|
|
2113
2184
|
// depend on the set's own insertion order.
|
|
2114
2185
|
if (entry.tags?.length) lines.push(` tags: ${[...entry.tags].sort().map((t) => `#${t}`).join(' ')}`);
|
|
2186
|
+
// R331. A username, never the email.
|
|
2187
|
+
if (entry.assignee) lines.push(` assignee: ${entry.assignee.username}`);
|
|
2188
|
+
// R335. Not printed at MEDIUM, as the export does not write it.
|
|
2189
|
+
if (entry.priority && entry.priority !== 'MEDIUM') {
|
|
2190
|
+
lines.push(` priority: ${entry.priority}`);
|
|
2191
|
+
}
|
|
2115
2192
|
// R257. Always printed when the card has one, so a reader's shape does not
|
|
2116
2193
|
// depend on which tool printed the card; a closed sprint says so.
|
|
2117
2194
|
if (entry.sprint) {
|
package/package.json
CHANGED
package/runner/README.md
CHANGED
|
@@ -68,6 +68,13 @@ page and waits for you to approve a code; after that it is remembered, and
|
|
|
68
68
|
`/login` does it again on demand. No password is ever typed into the terminal —
|
|
69
69
|
that belongs on a page your browser has told you the origin of.
|
|
70
70
|
|
|
71
|
+
When the platform stops honouring that session — it expired, you signed out in
|
|
72
|
+
the console, or your account went — the first request answered 401 says so
|
|
73
|
+
once, the footer pins `session expired — /login to sign in again`, and the
|
|
74
|
+
terminal drops to watching only until `/login` succeeds. The polls pick up again
|
|
75
|
+
on their own after it. In advanced mode `/login` stays in advanced mode: the URL
|
|
76
|
+
and the code are printed in the prompt window.
|
|
77
|
+
|
|
71
78
|
```
|
|
72
79
|
cawdev the runner here, starting one if there is none
|
|
73
80
|
cawdev --runner <name> when this machine runs more than one
|
|
@@ -124,7 +131,7 @@ is for scripts and for a machine with no daemon to attach to:
|
|
|
124
131
|
|
|
125
132
|
```
|
|
126
133
|
cawdev config a menu, the same Select widget as everywhere else
|
|
127
|
-
cawdev config add-project serve one more project you can write to
|
|
134
|
+
cawdev config add-project serve one more project you can write to, or create one
|
|
128
135
|
cawdev config add-workspace another checkout for a project already served
|
|
129
136
|
cawdev config remove-project <slug> stop serving a project; its checkouts stay on disk
|
|
130
137
|
cawdev config agent enable|disable <claude|agy|codex>
|
|
@@ -139,6 +146,18 @@ travels beside the secret since this card, and a machine set up before it
|
|
|
139
146
|
falls back to a lookup by label the first time this runs, after which it
|
|
140
147
|
remembers.
|
|
141
148
|
|
|
149
|
+
**Add a project lists what you can serve, and ends with "Create a new project on
|
|
150
|
+
cawdev".** The list is shown even when one project is left — the screen names
|
|
151
|
+
what it is about to clone. Creating asks for a name (the slug is cawdev's,
|
|
152
|
+
`<username>-<name>`, and is shown before anything is made), the git URL — blank
|
|
153
|
+
when the checkout is already on this machine, whose `origin` is then the
|
|
154
|
+
repository — and where the checkout goes. The clone happens first and the
|
|
155
|
+
project is created on cawdev (`POST /api/projects`) only after one last
|
|
156
|
+
confirmation, so a clone that fails leaves nothing on the platform. Then it is
|
|
157
|
+
served like any added project, and the rest of the console's setup walk —
|
|
158
|
+
rules, members, the CTO Interview — is linked at
|
|
159
|
+
`<url>/projects/<slug>/setup`.
|
|
160
|
+
|
|
142
161
|
**What takes effect when** (R294). Three settings a running daemon takes
|
|
143
162
|
without a restart — `agentCommands`, `grantable`, `acceptsRulesFromConsole`,
|
|
144
163
|
the `HOT_SWAPPABLE` list in `tools/lib/runner-config.mjs`, spelled once —
|
|
@@ -222,7 +241,10 @@ move through the command list while one is open.
|
|
|
222
241
|
| `/quit` | leave |
|
|
223
242
|
|
|
224
243
|
**Typing `/` filters that list as you go**, each row with its description; arrows
|
|
225
|
-
and `enter` pick one and `tab` completes as far as the matches agree
|
|
244
|
+
and `enter` pick one and `tab` completes as far as the matches agree — then
|
|
245
|
+
moves down the list, so `/lo` `tab` `tab` reaches `/login` past `/log`. A name
|
|
246
|
+
typed in full is the first row, so `/log` `enter` runs `/log`. Advanced mode
|
|
247
|
+
draws the same list above its prompt, six rows at a time. Guessing a
|
|
226
248
|
command name and being told `no such command` is a step, and it is the step this
|
|
227
249
|
removes.
|
|
228
250
|
|
|
@@ -1088,6 +1110,27 @@ settled TEST stage to scan after. The log line is
|
|
|
1088
1110
|
`quality: <branch> gate OK · 0 new problems`, or `quality: could not scan
|
|
1089
1111
|
<branch>: <why>`.
|
|
1090
1112
|
|
|
1113
|
+
## A finished run is committed and pushed (R345)
|
|
1114
|
+
|
|
1115
|
+
Whatever the project's rules say, a coding run (`CODE`) reaches `FINISHED`
|
|
1116
|
+
only with a clean checkout and its branch's commits on the remote. Two doors,
|
|
1117
|
+
one check (`tools/lib/finish-check.mjs`):
|
|
1118
|
+
|
|
1119
|
+
- **`report done` refuses** while anything is left and tells the session what —
|
|
1120
|
+
the uncommitted files by name, the commits the remote has not seen — so the
|
|
1121
|
+
session that made them commits what belongs and pushes, in its own turn.
|
|
1122
|
+
- **When the daemon ends the run** (the lifecycle's last stage, or a session
|
|
1123
|
+
that exited without reporting) it settles the branch itself: commits what is
|
|
1124
|
+
left as `<card>: what the session left uncommitted`, pushes without force,
|
|
1125
|
+
and says so in the summary. It does **not** commit anything that looks like a
|
|
1126
|
+
credential, and it does not win a push the remote refuses — either is a
|
|
1127
|
+
`FAILED` run with the reason in its summary and the checkout held, so
|
|
1128
|
+
*Carry on*, *Commit* or *Discard* decide, not the next run.
|
|
1129
|
+
|
|
1130
|
+
Before this, R325's run finished with a file modified and nothing pushed, and
|
|
1131
|
+
the next run in that checkout refused to start on top of it. A checkout with no
|
|
1132
|
+
remote commits what is left and asks nothing of a push.
|
|
1133
|
+
|
|
1091
1134
|
## What a project's rules can ask of it (R40)
|
|
1092
1135
|
|
|
1093
1136
|
A project may decide that a finished run **pushes its branch**, **opens a pull
|
|
@@ -1200,6 +1243,39 @@ A run **`WAITING_ON_USER` is never swept.** That is the one state where nothing
|
|
|
1200
1243
|
happening is correct: it is stalled on a person, who may reasonably take a day.
|
|
1201
1244
|
Nor is a `PAUSED` or `USAGE_LIMITED` one (below) — there is no process to lose.
|
|
1202
1245
|
|
|
1246
|
+
### When the daemon itself was killed (R339)
|
|
1247
|
+
|
|
1248
|
+
A daemon that dies and is restarted — by systemd, by `cawdev`, by hand — comes
|
|
1249
|
+
back with nothing in its hands, and the platform could only infer that five
|
|
1250
|
+
minutes later, as "its runner is alive but stopped driving it". So the daemon
|
|
1251
|
+
keeps a note of the runs it is driving (`~/.cawdev/driving/<runner id>.json`,
|
|
1252
|
+
rewritten when the set changes), and on boot the next one reads it: every run
|
|
1253
|
+
the dead daemon was driving that the platform still believes in is failed **at
|
|
1254
|
+
once** as `DAEMON_RESTARTED`, with the old and new pids and where to look for the
|
|
1255
|
+
cause. A session the old daemon left running is stopped first — only when its
|
|
1256
|
+
pid still runs the command it was spawned with. The workspace is held exactly as
|
|
1257
|
+
for a vanished runner, and a machine with **auto-resume** on carries such a run
|
|
1258
|
+
on by itself **once**; a second restart on the same run waits for a person,
|
|
1259
|
+
because a test suite that killed the daemon twice will kill it a third time.
|
|
1260
|
+
|
|
1261
|
+
The usual cause on a small Linux box is memory: the session runs a test suite,
|
|
1262
|
+
the kernel's OOM killer picks the biggest process, and that is sometimes the
|
|
1263
|
+
daemon. `journalctl -k | grep -i "killed process"` says so. Two fixes, both on
|
|
1264
|
+
the machine:
|
|
1265
|
+
|
|
1266
|
+
- **`"sessionMemoryMax": "6G"`** in `runner.config.json` spawns each agent
|
|
1267
|
+
session under `systemd-run --scope -p MemoryMax=…` (a size systemd reads —
|
|
1268
|
+
`6G`, `512M`, `75%`). A session that blows through it is killed on its own,
|
|
1269
|
+
the ordinary "agent was terminated" failure, and the daemon stays up. Linux
|
|
1270
|
+
only, and it needs a systemd manager for the user (`loginctl enable-linger
|
|
1271
|
+
<user>` on a box with nobody logged in) or a daemon running as root; the
|
|
1272
|
+
daemon probes once at boot and says in its log whether it applied. Needs a
|
|
1273
|
+
restart to change.
|
|
1274
|
+
- **Protect the daemon and give the box room**: in the daemon's systemd unit,
|
|
1275
|
+
`OOMScoreAdjust=-800` makes the kernel pick a test worker over the daemon;
|
|
1276
|
+
and add swap (`fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap
|
|
1277
|
+
/swapfile && swapon /swapfile`, plus its `/etc/fstab` line).
|
|
1278
|
+
|
|
1203
1279
|
## When the usage limit hits (R73)
|
|
1204
1280
|
|
|
1205
1281
|
Claude Code stops with a message naming the window — the five-hour one or the
|
|
@@ -1309,8 +1385,11 @@ your inbox and on the run, and waits for an answer. Allow it once, or allow it
|
|
|
1309
1385
|
always here — which writes a rule on the project that every runner serving it
|
|
1310
1386
|
inherits.
|
|
1311
1387
|
|
|
1312
|
-
Nobody answering is a decision too: after
|
|
1313
|
-
|
|
1388
|
+
Nobody answering is a decision too: after the project's wait — fifteen minutes
|
|
1389
|
+
unless its Rules page says otherwise, and possibly never (R339) — the request
|
|
1390
|
+
expires and the session is told to carry on without it. Allowing it later still
|
|
1391
|
+
works: the grant lasts the rest of the run and the session is sent a message to
|
|
1392
|
+
try again. A stage gate never expires.
|
|
1314
1393
|
|
|
1315
1394
|
You can still say in advance what a project may do, and for anything a session
|
|
1316
1395
|
needs on every run that is the better answer — with `allowedTools`, which
|