cawdev-cli 1.0.7-beta → 1.0.9-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/README.md +6 -1
- package/lib/finish-check.mjs +178 -0
- package/lib/roadmap-format.mjs +130 -8
- package/mcp/README.md +7 -2
- package/mcp/server.mjs +157 -7
- package/package.json +1 -1
- package/runner/README.md +34 -1
- package/runner/attach.mjs +2 -2
- package/runner/cawdev.mjs +2 -2
- package/runner/configure.mjs +96 -18
- package/runner/runner.mjs +33 -7
package/README.md
CHANGED
|
@@ -5,4 +5,9 @@ and spawning processes are Node's home turf, and a file with no dependencies is
|
|
|
5
5
|
one you can read before running it against your repositories.
|
|
6
6
|
visit https://cawdev.com to learn more
|
|
7
7
|
|
|
8
|
-
https://github.com/4dali/cawdev-cli
|
|
8
|
+
https://github.com/4dali/cawdev-cli
|
|
9
|
+
## jira/
|
|
10
|
+
|
|
11
|
+
`jira/import.mjs` brings a Jira Cloud project's issues, epics, sprints and
|
|
12
|
+
comments into a cawdev project, once and again without duplicates (R337). Run from a checkout; see
|
|
13
|
+
"Leaving Jira" in GUIDE.md. Its tests: `node --test "tools/jira/*.test.mjs"`.
|
|
@@ -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,40 @@ 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
|
+
}
|
|
235
|
+
// R351. The date shape is checked here, where the line is read: a
|
|
236
|
+
// `Due:` that is not a date is a hand edit, and --live would otherwise
|
|
237
|
+
// report it as drift from a database that never held it.
|
|
238
|
+
const due = /^Due:\s*(.+)$/.exec(line.trim());
|
|
239
|
+
if (due) {
|
|
240
|
+
entry.due = due[1].trim();
|
|
241
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.due) || Number.isNaN(Date.parse(entry.due))) {
|
|
242
|
+
problems.push(`${block.heading}: Due "${due[1].trim()}" is not a date written 2026-11-01.`);
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
// R358. The version the card means to ship in; a free string, as `Version:` is.
|
|
247
|
+
const target = /^Target:\s*(.+)$/.exec(line.trim());
|
|
248
|
+
if (target) {
|
|
249
|
+
entry.targetVersion = target[1].trim();
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const estimate = /^Estimate:\s*(.+)$/.exec(line.trim());
|
|
253
|
+
if (estimate) {
|
|
254
|
+
const n = Number(estimate[1].trim());
|
|
255
|
+
entry.estimate = n;
|
|
256
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
257
|
+
problems.push(`${block.heading}: Estimate "${estimate[1].trim()}" is not a positive whole number.`);
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
228
261
|
const development = /^Development:\s*(.+)$/.exec(line.trim());
|
|
229
262
|
if (development) {
|
|
230
263
|
entry.development = statusFromDisplay(development[1].trim());
|
|
@@ -255,6 +288,16 @@ export function parseRoadmap(text) {
|
|
|
255
288
|
entry.afterRefs = tokens.map(({ ref }) => ref);
|
|
256
289
|
continue;
|
|
257
290
|
}
|
|
291
|
+
const parent = /^Parent:\s*(\S+)\s*$/.exec(line.trim());
|
|
292
|
+
if (parent) {
|
|
293
|
+
// R336. One ref, always written in full (R300, i12). A token that is
|
|
294
|
+
// not a card is left as ordinary body text rather than eaten.
|
|
295
|
+
const token = parseRefToken(parent[1]);
|
|
296
|
+
if (token) {
|
|
297
|
+
entry.parentRef = token.ref;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
258
301
|
const tags = /^Tags:\s*(.+)$/.exec(line.trim());
|
|
259
302
|
if (tags) {
|
|
260
303
|
// R315. Already normalised by the server (lowercase, no `#`) by the
|
|
@@ -262,6 +305,13 @@ export function parseRoadmap(text) {
|
|
|
262
305
|
entry.tags = tags[1].split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
|
|
263
306
|
continue;
|
|
264
307
|
}
|
|
308
|
+
const assignee = /^Assignee:\s*(\S+)$/.exec(line.trim());
|
|
309
|
+
if (assignee) {
|
|
310
|
+
// R331. A username only: the email is for a tooltip and never leaves
|
|
311
|
+
// the platform in a file.
|
|
312
|
+
entry.assignee = { username: assignee[1] };
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
265
315
|
// R315. Computed, never stored, and never sent by import.mjs — the same
|
|
266
316
|
// arrangement as `Development:` above. Kept as the exact text so a file
|
|
267
317
|
// parsed and re-rendered without asking the API again is still a fixed
|
|
@@ -304,7 +354,8 @@ export function parseRoadmap(text) {
|
|
|
304
354
|
entries.push(entry);
|
|
305
355
|
}
|
|
306
356
|
|
|
307
|
-
|
|
357
|
+
// File order, not number order — R335: a section's order is a person's, and
|
|
358
|
+
// the importer's third pass sends it back to the server as written.
|
|
308
359
|
return { entries, problems };
|
|
309
360
|
}
|
|
310
361
|
|
|
@@ -341,6 +392,45 @@ function* splitBlocks(text) {
|
|
|
341
392
|
if (entry) yield entry;
|
|
342
393
|
}
|
|
343
394
|
|
|
395
|
+
/** The ref of the card this one sits under, from either shape — R336. */
|
|
396
|
+
function parentRefOf(entry) {
|
|
397
|
+
const ref = entry.parent?.ref ?? entry.parentRef;
|
|
398
|
+
return ref ? (parseRefToken(ref)?.ref ?? null) : null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Files each child directly after its parent, within one section — R336.
|
|
403
|
+
*
|
|
404
|
+
* Pure. A child keeps its place among its siblings (they stay in the order
|
|
405
|
+
* given), and a child whose parent is in some other section stays exactly
|
|
406
|
+
* where it was: the file groups by section first, and an epic spread over
|
|
407
|
+
* phases is still one epic by its `Parent:` lines.
|
|
408
|
+
*/
|
|
409
|
+
export function nestUnderParents(list) {
|
|
410
|
+
const refs = new Map(list.map((entry) => [entryRef(entry), entry]));
|
|
411
|
+
const nestedUnder = (entry) => {
|
|
412
|
+
const parent = parentRefOf(entry);
|
|
413
|
+
const card = parent && refs.get(parent);
|
|
414
|
+
// A parent that is itself a child is bad data; leave the child where it is
|
|
415
|
+
// rather than lose it behind a card that is not a root.
|
|
416
|
+
return card && !parentRefOf(card) ? parent : null;
|
|
417
|
+
};
|
|
418
|
+
const children = new Map();
|
|
419
|
+
for (const entry of list) {
|
|
420
|
+
const parent = nestedUnder(entry);
|
|
421
|
+
if (parent) {
|
|
422
|
+
if (!children.has(parent)) children.set(parent, []);
|
|
423
|
+
children.get(parent).push(entry);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const out = [];
|
|
427
|
+
for (const entry of list) {
|
|
428
|
+
if (nestedUnder(entry)) continue;
|
|
429
|
+
out.push(entry, ...(children.get(entryRef(entry)) ?? []));
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
|
|
344
434
|
/**
|
|
345
435
|
* Writes entries as ROADMAP.md's entry sections.
|
|
346
436
|
*
|
|
@@ -350,7 +440,9 @@ function* splitBlocks(text) {
|
|
|
350
440
|
*/
|
|
351
441
|
export function renderRoadmap(entries, { preamble, sectionOrder = [] }) {
|
|
352
442
|
const bySection = new Map();
|
|
353
|
-
|
|
443
|
+
// In the order given, not sorted — R335: the server's order is the board's
|
|
444
|
+
// and the file's order is what an import rebuilds ranks from.
|
|
445
|
+
for (const entry of entries) {
|
|
354
446
|
const section = sectionOf(entry);
|
|
355
447
|
if (!bySection.has(section)) bySection.set(section, []);
|
|
356
448
|
bySection.get(section).push(entry);
|
|
@@ -368,7 +460,7 @@ export function renderRoadmap(entries, { preamble, sectionOrder = [] }) {
|
|
|
368
460
|
const parts = [preamble.trimEnd(), ''];
|
|
369
461
|
for (const section of ordered) {
|
|
370
462
|
parts.push('---', '', `## ${section}`, '');
|
|
371
|
-
for (const entry of bySection.get(section)) {
|
|
463
|
+
for (const entry of nestUnderParents(bySection.get(section))) {
|
|
372
464
|
parts.push(renderEntry(entry), '');
|
|
373
465
|
}
|
|
374
466
|
}
|
|
@@ -420,6 +512,25 @@ function renderEntry(entry) {
|
|
|
420
512
|
lines.push(`Severity: ${entry.severity}`);
|
|
421
513
|
}
|
|
422
514
|
|
|
515
|
+
// R335. MEDIUM is the default and is not written, so a card nobody has
|
|
516
|
+
// prioritised reads as it did before the field existed.
|
|
517
|
+
if (entry.priority && entry.priority !== 'MEDIUM') {
|
|
518
|
+
lines.push(`Priority: ${entry.priority}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// R351. Two facts, written only when the card carries them, after the
|
|
522
|
+
// priority: when, then how big.
|
|
523
|
+
if (entry.due) {
|
|
524
|
+
lines.push(`Due: ${entry.due}`);
|
|
525
|
+
}
|
|
526
|
+
if (entry.estimate != null && entry.estimate > 0) {
|
|
527
|
+
lines.push(`Estimate: ${entry.estimate}`);
|
|
528
|
+
}
|
|
529
|
+
// R358. Where it means to ship; dropped once it has, because `Version:` then says.
|
|
530
|
+
if (entry.targetVersion && !entry.version) {
|
|
531
|
+
lines.push(`Target: ${entry.targetVersion}`);
|
|
532
|
+
}
|
|
533
|
+
|
|
423
534
|
// Each status writes only what it currently claims. A MERGED entry keeps its
|
|
424
535
|
// branch in the database as history, but the file must not print it: the
|
|
425
536
|
// branch is expected to be deleted, and `--live` would then be reading a claim
|
|
@@ -469,10 +580,21 @@ function renderEntry(entry) {
|
|
|
469
580
|
.map(({ ref }) => ref);
|
|
470
581
|
lines.push(`After: ${written.join(', ')}`);
|
|
471
582
|
}
|
|
583
|
+
{
|
|
584
|
+
// R336. The API gives `parent` as {number, ref, title}; the parser gives
|
|
585
|
+
// `parentRef`. Always the full ref, and no line at all without a parent, so
|
|
586
|
+
// no existing card's text changes.
|
|
587
|
+
const parent = parentRefOf(entry);
|
|
588
|
+
if (parent) lines.push(`Parent: ${parent}`);
|
|
589
|
+
}
|
|
472
590
|
if (entry.tags?.length) {
|
|
473
591
|
// R315. Sorted, so the export does not churn on the set's own order.
|
|
474
592
|
lines.push(`Tags: ${[...entry.tags].sort().join(', ')}`);
|
|
475
593
|
}
|
|
594
|
+
if (entry.assignee?.username) {
|
|
595
|
+
// R331. Nothing for a card with nobody, so today's files stay byte-identical.
|
|
596
|
+
lines.push(`Assignee: ${entry.assignee.username}`);
|
|
597
|
+
}
|
|
476
598
|
const origin = originWord(entry.origin);
|
|
477
599
|
if (origin) {
|
|
478
600
|
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), when it is due and how big it is (`due` — R351: an ISO date, `""` clears; `estimate`: a positive number, `0` clears), the version it is meant for (`targetVersion` — R358, `""` clears; since R362 it must be one the project defined under Project settings → Versions, and any other name is refused), 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,38 @@ 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
|
+
},
|
|
695
|
+
due: {
|
|
696
|
+
type: 'string',
|
|
697
|
+
description: 'When it is due, written 2026-11-01. A fact on the card; it moves nothing. Leave out for none.',
|
|
698
|
+
},
|
|
699
|
+
estimate: {
|
|
700
|
+
type: 'integer',
|
|
701
|
+
minimum: 1,
|
|
702
|
+
description: "How big it is, in the team's own unit (points, days — the platform does not say). Leave out for none.",
|
|
703
|
+
},
|
|
704
|
+
targetVersion: {
|
|
705
|
+
type: 'string',
|
|
706
|
+
description: 'The version it is meant to ship in — one the project DEFINED under Project settings → Versions (R362); any other name is refused. The release corrects it if it lands elsewhere. Leave out for none.',
|
|
707
|
+
},
|
|
708
|
+
parent: {
|
|
709
|
+
type: 'string',
|
|
710
|
+
description:
|
|
711
|
+
'The card this one sits under (an epic), e.g. "R300". One level only: the parent ' +
|
|
712
|
+
'must be the same kind and have no parent itself, and this card must have no ' +
|
|
713
|
+
'children. An open card cannot be filed under a SHIPPED or DECLINED one.',
|
|
714
|
+
},
|
|
675
715
|
},
|
|
676
716
|
required: ['title'],
|
|
677
717
|
},
|
|
@@ -681,7 +721,7 @@ const TOOLS = [
|
|
|
681
721
|
method: 'POST',
|
|
682
722
|
body: pick(args, [
|
|
683
723
|
'title', 'body', 'status', 'branch', 'merge', 'version', 'reason', 'section', 'related',
|
|
684
|
-
'after', 'sprint', 'tags',
|
|
724
|
+
'after', 'sprint', 'tags', 'assignee', 'priority', 'due', 'estimate', 'targetVersion', 'parent',
|
|
685
725
|
]),
|
|
686
726
|
});
|
|
687
727
|
return `Created ${refOf(entry)} in ${slug}.\n\n${formatEntry(entry, {})}`;
|
|
@@ -703,6 +743,10 @@ const TOOLS = [
|
|
|
703
743
|
type: 'string',
|
|
704
744
|
enum: ['NEW', 'CONFIRMED', 'IN_DEVELOPMENT', 'MERGED', 'SHIPPED', 'DECLINED'],
|
|
705
745
|
},
|
|
746
|
+
assignee: {
|
|
747
|
+
type: 'string',
|
|
748
|
+
description: 'Only issues assigned to this person — a username, or "me".',
|
|
749
|
+
},
|
|
706
750
|
brief: { type: 'boolean', description: 'Drop the bodies.' },
|
|
707
751
|
},
|
|
708
752
|
},
|
|
@@ -711,6 +755,7 @@ const TOOLS = [
|
|
|
711
755
|
const query = new URLSearchParams();
|
|
712
756
|
if (args.status) query.set('status', args.status);
|
|
713
757
|
if (args.brief) query.set('brief', 'true');
|
|
758
|
+
if (args.assignee) query.set('assignee', args.assignee);
|
|
714
759
|
const suffix = query.toString() ? `?${query}` : '';
|
|
715
760
|
const issues = await api(config, `/api/projects/${slug}/issues${suffix}`);
|
|
716
761
|
if (!issues.length) {
|
|
@@ -748,6 +793,18 @@ const TOOLS = [
|
|
|
748
793
|
'Free words — lowercase, letters/digits/./_/-, up to 32 characters, at least one ' +
|
|
749
794
|
'letter, at most 12. The leading # is optional and stripped.',
|
|
750
795
|
},
|
|
796
|
+
assignee: {
|
|
797
|
+
type: 'string',
|
|
798
|
+
description:
|
|
799
|
+
'A username — the person responsible for the card. Leave out for nobody. Refused, naming the ' +
|
|
800
|
+
'person, unless they hold WRITER or more on the project and are not disabled.',
|
|
801
|
+
},
|
|
802
|
+
priority: {
|
|
803
|
+
type: 'string',
|
|
804
|
+
enum: ['HIGHEST', 'HIGH', 'MEDIUM', 'LOW', 'LOWEST'],
|
|
805
|
+
description:
|
|
806
|
+
'How much it matters; it does not reorder the board. Leave out for MEDIUM.',
|
|
807
|
+
},
|
|
751
808
|
},
|
|
752
809
|
required: ['title', 'severity'],
|
|
753
810
|
},
|
|
@@ -755,7 +812,7 @@ const TOOLS = [
|
|
|
755
812
|
const slug = await resolveProject(config, args.project);
|
|
756
813
|
const issue = await api(config, `/api/projects/${slug}/issues`, {
|
|
757
814
|
method: 'POST',
|
|
758
|
-
body: pick(args, ['title', 'body', 'severity', 'status', 'related', 'tags']),
|
|
815
|
+
body: pick(args, ['title', 'body', 'severity', 'status', 'related', 'tags', 'assignee', 'priority']),
|
|
759
816
|
});
|
|
760
817
|
return `Filed ${refOf(issue)} in ${slug}.\n\n${formatEntry(issue, {})}`;
|
|
761
818
|
},
|
|
@@ -793,8 +850,8 @@ const TOOLS = [
|
|
|
793
850
|
{
|
|
794
851
|
name: 'roadmap_update',
|
|
795
852
|
description:
|
|
796
|
-
'Edit an entry\'s title, body, section, sprint, tags, related refs or the cards it
|
|
797
|
-
'after. Use roadmap_set_status to move it.',
|
|
853
|
+
'Edit an entry\'s title, body, section, sprint, tags, parent, related refs or the cards it ' +
|
|
854
|
+
'starts after. Use roadmap_set_status to move it.',
|
|
798
855
|
inputSchema: {
|
|
799
856
|
type: 'object',
|
|
800
857
|
properties: {
|
|
@@ -833,6 +890,37 @@ const TOOLS = [
|
|
|
833
890
|
description:
|
|
834
891
|
'Free words — same rule as roadmap_create. Leave out to leave them alone; [] clears them.',
|
|
835
892
|
},
|
|
893
|
+
assignee: {
|
|
894
|
+
type: 'string',
|
|
895
|
+
description:
|
|
896
|
+
'A username — the person responsible for the card. Leave out to leave it alone; "" takes the card off them. Refused, naming the ' +
|
|
897
|
+
'person, unless they hold WRITER or more on the project and are not disabled.',
|
|
898
|
+
},
|
|
899
|
+
priority: {
|
|
900
|
+
type: 'string',
|
|
901
|
+
enum: ['HIGHEST', 'HIGH', 'MEDIUM', 'LOW', 'LOWEST'],
|
|
902
|
+
description:
|
|
903
|
+
'How much it matters; it does not reorder the board. Leave out for MEDIUM (leave out to leave it alone).',
|
|
904
|
+
},
|
|
905
|
+
due: {
|
|
906
|
+
type: 'string',
|
|
907
|
+
description: 'When it is due, written 2026-11-01. Leave out to leave it alone; "" clears it.',
|
|
908
|
+
},
|
|
909
|
+
estimate: {
|
|
910
|
+
type: 'integer',
|
|
911
|
+
minimum: 0,
|
|
912
|
+
description: "How big it is, in the team's own unit. Leave out to leave it alone; 0 clears it.",
|
|
913
|
+
},
|
|
914
|
+
targetVersion: {
|
|
915
|
+
type: 'string',
|
|
916
|
+
description: 'The version it is meant to ship in — one the project defined (R362); any other name is refused. Leave out to leave it alone; "" clears it.',
|
|
917
|
+
},
|
|
918
|
+
parent: {
|
|
919
|
+
type: 'string',
|
|
920
|
+
description:
|
|
921
|
+
'The card this one sits under (an epic), e.g. "R300". "" takes the card out of its ' +
|
|
922
|
+
'parent; leave out to leave it alone. Same rules as roadmap_create.',
|
|
923
|
+
},
|
|
836
924
|
},
|
|
837
925
|
required: ['number'],
|
|
838
926
|
},
|
|
@@ -841,7 +929,10 @@ const TOOLS = [
|
|
|
841
929
|
const entry = await api(config, `/api/projects/${slug}/roadmap/${args.number}`, {
|
|
842
930
|
method: 'PATCH',
|
|
843
931
|
// pick keeps a present 0, which is how a card leaves its sprint.
|
|
844
|
-
body: pick(args, [
|
|
932
|
+
body: pick(args, [
|
|
933
|
+
'title', 'body', 'section', 'related', 'after', 'sprint', 'tags', 'assignee', 'priority',
|
|
934
|
+
'due', 'estimate', 'targetVersion', 'parent',
|
|
935
|
+
]),
|
|
845
936
|
});
|
|
846
937
|
return `Updated ${refOf(entry)}.\n\n${formatEntry(entry, {})}`;
|
|
847
938
|
},
|
|
@@ -1105,8 +1196,22 @@ const TOOLS = [
|
|
|
1105
1196
|
`run: ${run.state}${run.runnerName ? ` on ${run.runnerName}` : ''}`,
|
|
1106
1197
|
`started by ${run.startedByEmail}`,
|
|
1107
1198
|
'',
|
|
1108
|
-
formatEntry(entry, { comments, plan: plans?.[0] ?? null }),
|
|
1109
1199
|
);
|
|
1200
|
+
// R336. The epic this card was cut from, so a session sees the context.
|
|
1201
|
+
// Its own try, like the history above: a parent that cannot be read (a
|
|
1202
|
+
// 404, no grant) costs the paragraph and never the call.
|
|
1203
|
+
if (entry.parent) {
|
|
1204
|
+
const epic = await api(config, `/api/projects/${project}/roadmap/${entry.parent.ref}`)
|
|
1205
|
+
.catch(() => null);
|
|
1206
|
+
if (epic) {
|
|
1207
|
+
lines.push(
|
|
1208
|
+
`This card is part of ${entry.parent.ref} — ${epic.title}:`,
|
|
1209
|
+
epicExcerpt(epic.body),
|
|
1210
|
+
'',
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
lines.push(formatEntry(entry, { comments, plan: plans?.[0] ?? null }));
|
|
1110
1215
|
|
|
1111
1216
|
// What happened the other times. A session is told the entry and the
|
|
1112
1217
|
// branch but not that two previous runs on this card failed, which is
|
|
@@ -1181,7 +1286,9 @@ const TOOLS = [
|
|
|
1181
1286
|
description:
|
|
1182
1287
|
'Say what is happening. `progress` as often as useful; `done` when the work is finished, ' +
|
|
1183
1288
|
'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.'
|
|
1289
|
+
'resolve. done and blocked also end the run — you do not need a separate step. On a ' +
|
|
1290
|
+
'coding run, done is refused while the checkout has uncommitted files or unpushed ' +
|
|
1291
|
+
'commits: commit and push first.',
|
|
1185
1292
|
inputSchema: {
|
|
1186
1293
|
type: 'object',
|
|
1187
1294
|
properties: {
|
|
@@ -1192,6 +1299,21 @@ const TOOLS = [
|
|
|
1192
1299
|
},
|
|
1193
1300
|
handler: async (config, args) => {
|
|
1194
1301
|
const { runId, project } = await requireRun(config);
|
|
1302
|
+
|
|
1303
|
+
// R345: a finished run is committed and pushed. Asked HERE, before the
|
|
1304
|
+
// message is posted, because this is the one moment the session that
|
|
1305
|
+
// made the files is still there to say what they are — the daemon's own
|
|
1306
|
+
// check at the end can only commit them blind or hold them.
|
|
1307
|
+
if (args.kind === 'done') {
|
|
1308
|
+
const run = await api(config, `/api/projects/${project}/runs/${runId}`).catch(() => null);
|
|
1309
|
+
if (run && run.branch && COMMITS_ITS_WORK.has(run.profile)) {
|
|
1310
|
+
const left = await leftOver(process.env.CAWDEV_WORKSPACE ?? process.cwd(), run.branch);
|
|
1311
|
+
if (isUnfinished(left)) {
|
|
1312
|
+
return notDoneYet(left);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1195
1317
|
const message = await api(config, `/api/projects/${project}/runs/${runId}/messages`, {
|
|
1196
1318
|
method: 'POST',
|
|
1197
1319
|
body: { kind: args.kind.toUpperCase(), body: args.body },
|
|
@@ -2107,6 +2229,15 @@ function originWord(origin) {
|
|
|
2107
2229
|
}
|
|
2108
2230
|
}
|
|
2109
2231
|
|
|
2232
|
+
/** The first 600 characters of an epic's body, cut at a word — R336. */
|
|
2233
|
+
function epicExcerpt(body) {
|
|
2234
|
+
const text = (body ?? '').trim();
|
|
2235
|
+
if (text.length <= 600) return text;
|
|
2236
|
+
const cut = text.slice(0, 600);
|
|
2237
|
+
const space = cut.lastIndexOf(' ');
|
|
2238
|
+
return `${(space > 0 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2110
2241
|
function formatEntry(entry, { brief, comments, plan }) {
|
|
2111
2242
|
const lines = [`${refOf(entry)} — ${entry.title}`, ` status: ${entry.statusDisplay}`];
|
|
2112
2243
|
if (entry.branch) lines.push(` branch: ${entry.branch}`);
|
|
@@ -2117,6 +2248,17 @@ function formatEntry(entry, { brief, comments, plan }) {
|
|
|
2117
2248
|
// R315. Sorted for the same reason the export is: a reader's shape must not
|
|
2118
2249
|
// depend on the set's own insertion order.
|
|
2119
2250
|
if (entry.tags?.length) lines.push(` tags: ${[...entry.tags].sort().map((t) => `#${t}`).join(' ')}`);
|
|
2251
|
+
// R331. A username, never the email.
|
|
2252
|
+
if (entry.assignee) lines.push(` assignee: ${entry.assignee.username}`);
|
|
2253
|
+
// R335. Not printed at MEDIUM, as the export does not write it.
|
|
2254
|
+
if (entry.priority && entry.priority !== 'MEDIUM') {
|
|
2255
|
+
lines.push(` priority: ${entry.priority}`);
|
|
2256
|
+
}
|
|
2257
|
+
// R351. Both printed only when the card carries them, as the export does.
|
|
2258
|
+
if (entry.due) lines.push(` due: ${entry.due}`);
|
|
2259
|
+
if (entry.estimate) lines.push(` estimate: ${entry.estimate}`);
|
|
2260
|
+
// R358. Printed until the card ships; `version` says where it did.
|
|
2261
|
+
if (entry.targetVersion && !entry.version) lines.push(` target: ${entry.targetVersion}`);
|
|
2120
2262
|
// R257. Always printed when the card has one, so a reader's shape does not
|
|
2121
2263
|
// depend on which tool printed the card; a closed sprint says so.
|
|
2122
2264
|
if (entry.sprint) {
|
|
@@ -2142,6 +2284,14 @@ function formatEntry(entry, { brief, comments, plan }) {
|
|
|
2142
2284
|
.join(', ')}`,
|
|
2143
2285
|
);
|
|
2144
2286
|
}
|
|
2287
|
+
// R336. The epic this card sits under, and the cards under this one.
|
|
2288
|
+
if (entry.parent) lines.push(` parent: ${entry.parent.ref} — ${entry.parent.title}`);
|
|
2289
|
+
if (entry.children?.length) {
|
|
2290
|
+
const progress = entry.progress ? `${entry.progress.done} of ${entry.progress.total} done — ` : '';
|
|
2291
|
+
lines.push(
|
|
2292
|
+
` children: ${progress}${entry.children.map((c) => `${c.ref} (${c.status})`).join(', ')}`,
|
|
2293
|
+
);
|
|
2294
|
+
}
|
|
2145
2295
|
// Said in a survey, where the comments themselves are not fetched: an entry
|
|
2146
2296
|
// with an argument attached should be visibly different from one without,
|
|
2147
2297
|
// even in a list. Omitted at zero rather than written as "comments: 0".
|
package/package.json
CHANGED
package/runner/README.md
CHANGED
|
@@ -131,7 +131,7 @@ is for scripts and for a machine with no daemon to attach to:
|
|
|
131
131
|
|
|
132
132
|
```
|
|
133
133
|
cawdev config a menu, the same Select widget as everywhere else
|
|
134
|
-
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
|
|
135
135
|
cawdev config add-workspace another checkout for a project already served
|
|
136
136
|
cawdev config remove-project <slug> stop serving a project; its checkouts stay on disk
|
|
137
137
|
cawdev config agent enable|disable <claude|agy|codex>
|
|
@@ -146,6 +146,18 @@ travels beside the secret since this card, and a machine set up before it
|
|
|
146
146
|
falls back to a lookup by label the first time this runs, after which it
|
|
147
147
|
remembers.
|
|
148
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
|
+
|
|
149
161
|
**What takes effect when** (R294). Three settings a running daemon takes
|
|
150
162
|
without a restart — `agentCommands`, `grantable`, `acceptsRulesFromConsole`,
|
|
151
163
|
the `HOT_SWAPPABLE` list in `tools/lib/runner-config.mjs`, spelled once —
|
|
@@ -1098,6 +1110,27 @@ settled TEST stage to scan after. The log line is
|
|
|
1098
1110
|
`quality: <branch> gate OK · 0 new problems`, or `quality: could not scan
|
|
1099
1111
|
<branch>: <why>`.
|
|
1100
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
|
+
|
|
1101
1134
|
## What a project's rules can ask of it (R40)
|
|
1102
1135
|
|
|
1103
1136
|
A project may decide that a finished run **pushes its branch**, **opens a pull
|
package/runner/attach.mjs
CHANGED
|
@@ -765,7 +765,7 @@ export function configMenu(file, runner = {}, gone = new Set()) {
|
|
|
765
765
|
kind: 'config',
|
|
766
766
|
title: `this machine — ${runner.name ?? file.name ?? 'cawdev'}`,
|
|
767
767
|
rows: [
|
|
768
|
-
{ id: 'add-project', label: 'Add a project', hint: `${serving}${pending}` },
|
|
768
|
+
{ id: 'add-project', label: 'Add a project — or create one on cawdev', hint: `${serving}${pending}` },
|
|
769
769
|
{ id: 'add-workspace', label: 'Add a workspace to a project', hint: checkouts || 'no projects to add one to' },
|
|
770
770
|
...[...new Set([...KNOWN_AGENTS, ...agents])].map((command) => (agents.includes(command)
|
|
771
771
|
? { id: `agent:${command}`, label: `Disable ${command}`, hint: 'spawned here now' }
|
|
@@ -2632,7 +2632,7 @@ export class Attached {
|
|
|
2632
2632
|
* comes back absolute; configure.mjs inspects it again before acting.
|
|
2633
2633
|
*/
|
|
2634
2634
|
async askPath({ question, suggestions, slug, gitUrl, inspect = inspectPath }) {
|
|
2635
|
-
|
|
2635
|
+
// The question is the picker's title; printing it as well put it on screen twice.
|
|
2636
2636
|
const rows = [];
|
|
2637
2637
|
for (const each of suggestions) {
|
|
2638
2638
|
const seen = await inspect(each.path, { gitUrl });
|
package/runner/cawdev.mjs
CHANGED
|
@@ -60,7 +60,7 @@ const USAGE = `
|
|
|
60
60
|
|
|
61
61
|
cawdev config a menu: add a project, add a
|
|
62
62
|
workspace, agents, console rules
|
|
63
|
-
cawdev config add-project serve one more project you can write to
|
|
63
|
+
cawdev config add-project serve one more project you can write to, or create one
|
|
64
64
|
cawdev config add-workspace another checkout for a project you
|
|
65
65
|
already serve — R47's concurrency
|
|
66
66
|
cawdev config remove-project <slug> stop serving a project (its checkouts
|
|
@@ -464,7 +464,7 @@ export async function pickAction(ask, say, pick = askOne) {
|
|
|
464
464
|
const picked = await pick(ask, say, new Select({
|
|
465
465
|
title: 'What do you want to change on this machine?',
|
|
466
466
|
rows: [
|
|
467
|
-
{ id: 'add-project', label: 'Add a project' },
|
|
467
|
+
{ id: 'add-project', label: 'Add a project — or create one on cawdev' },
|
|
468
468
|
{ id: 'add-workspace', label: 'Add a workspace to a project' },
|
|
469
469
|
{ id: 'remove-project', label: 'Stop serving a project' },
|
|
470
470
|
{ id: 'agent-enable', label: `Enable an agent (${KNOWN_AGENTS.join(' / ')})` },
|
package/runner/configure.mjs
CHANGED
|
@@ -181,13 +181,22 @@ async function settleCheckout({ path, slug, gitUrl, ask, say, ink, clone, inspec
|
|
|
181
181
|
await cloneAside(ask, clone, gitUrl, path);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/** The row of Add a project that makes a new project rather than picking one. */
|
|
185
|
+
export const CREATE_A_PROJECT = 'create:new';
|
|
186
|
+
|
|
184
187
|
/**
|
|
185
|
-
* Add one project this session can serve to an already-configured machine
|
|
188
|
+
* Add one project this session can serve to an already-configured machine —
|
|
189
|
+
* or CREATE one on cawdev and serve it, from the same list.
|
|
186
190
|
*
|
|
187
191
|
* Clones it, then widens the existing token's grants to cover it — via
|
|
188
192
|
* {@link resolveTokenId}, or by minting a fresh token when that comes back
|
|
189
193
|
* empty (an older machine's first run through this command, which is also
|
|
190
194
|
* what teaches it the id for next time).
|
|
195
|
+
*
|
|
196
|
+
* The list is ALWAYS shown, one project or six: going straight to "where
|
|
197
|
+
* should cawdev live?" when one was left answered a question nobody had been
|
|
198
|
+
* asked, about a project the screen never named. Its last row is "Create a
|
|
199
|
+
* new project on cawdev", see {@link askForANewProject}.
|
|
191
200
|
*/
|
|
192
201
|
export async function addProject({
|
|
193
202
|
configPath, file, url, session: given = null,
|
|
@@ -206,30 +215,37 @@ export async function addProject({
|
|
|
206
215
|
const already = new Set(slugsOf(file));
|
|
207
216
|
const projects = servable(await session.request('/api/projects'))
|
|
208
217
|
.filter((project) => !already.has(project.slug));
|
|
209
|
-
if (!projects.length) {
|
|
210
|
-
throw new Error('Every project you can serve is already configured on this machine.');
|
|
211
|
-
}
|
|
212
218
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
const picked = await pick(ask, say, new Select({
|
|
220
|
+
title: projects.length
|
|
221
|
+
? 'Which project should this machine also serve?'
|
|
222
|
+
: 'Every project you can serve is already here — create a new one?',
|
|
223
|
+
rows: [
|
|
224
|
+
...projects.map((candidate) => ({
|
|
218
225
|
id: candidate.slug, label: candidate.name, hint: candidate.slug,
|
|
219
226
|
})),
|
|
227
|
+
{ id: CREATE_A_PROJECT, label: 'Create a new project on cawdev', hint: `on ${url} — a name and its git repository` },
|
|
228
|
+
],
|
|
229
|
+
}));
|
|
230
|
+
|
|
231
|
+
let project;
|
|
232
|
+
let path;
|
|
233
|
+
if (picked.row.id === CREATE_A_PROJECT) {
|
|
234
|
+
({ project, path } = await askForANewProject({
|
|
235
|
+
file, url, session, ask, say, ink, pick, askPath, inspect, clone,
|
|
220
236
|
}));
|
|
237
|
+
} else {
|
|
221
238
|
project = projects.find((candidate) => candidate.slug === picked.row.id);
|
|
239
|
+
path = await askPath({
|
|
240
|
+
ask, say, pick, inspect,
|
|
241
|
+
question: `Where should ${project.slug} live on this machine?`,
|
|
242
|
+
suggestions: suggestProjectPaths(file, project.slug),
|
|
243
|
+
slug: project.slug,
|
|
244
|
+
gitUrl: project.gitUrl ?? null,
|
|
245
|
+
});
|
|
246
|
+
await settleCheckout({ path, slug: project.slug, gitUrl: project.gitUrl ?? null, ask, say, ink, clone, inspect });
|
|
222
247
|
}
|
|
223
248
|
|
|
224
|
-
const path = await askPath({
|
|
225
|
-
ask, say, pick, inspect,
|
|
226
|
-
question: `Where should ${project.slug} live on this machine?`,
|
|
227
|
-
suggestions: suggestProjectPaths(file, project.slug),
|
|
228
|
-
slug: project.slug,
|
|
229
|
-
gitUrl: project.gitUrl ?? null,
|
|
230
|
-
});
|
|
231
|
-
await settleCheckout({ path, slug: project.slug, gitUrl: project.gitUrl ?? null, ask, say, ink, clone, inspect });
|
|
232
|
-
|
|
233
249
|
const grants = {};
|
|
234
250
|
for (const slug of [...already, project.slug]) {
|
|
235
251
|
grants[slug] = ['runner:operate'];
|
|
@@ -250,9 +266,71 @@ export async function addProject({
|
|
|
250
266
|
|
|
251
267
|
await write(configPath, next);
|
|
252
268
|
say(` ${ink.success('✓')} ${ink.muted('Now serving')} ${ink.text(project.slug)} ${ink.muted('at')} ${ink.accent(path)}`);
|
|
269
|
+
if (project.created) {
|
|
270
|
+
// The rest of the console's setup walk — rules, members, the brief — is
|
|
271
|
+
// decisions about the project, not about this machine, and lives there.
|
|
272
|
+
say(` ${ink.muted('The rest of its setup (rules, members, the CTO Interview):')} ${ink.accent(`${url}/projects/${project.slug}/setup`)}`);
|
|
273
|
+
}
|
|
253
274
|
return next;
|
|
254
275
|
}
|
|
255
276
|
|
|
277
|
+
/**
|
|
278
|
+
* "Create a new project on cawdev": a name, the repository, the checkout —
|
|
279
|
+
* and only then the project, so a clone that fails leaves nothing on the
|
|
280
|
+
* platform. The slug is the SERVER's (R290, `<username>-<name>`); it is shown
|
|
281
|
+
* before anything is made and the answer to the POST is what is used.
|
|
282
|
+
*
|
|
283
|
+
* An empty git URL is allowed when the path is already a checkout: its
|
|
284
|
+
* `origin` is the repository, which is the commonest case — somebody with the
|
|
285
|
+
* code on their disk who wants cawdev to know about it.
|
|
286
|
+
*/
|
|
287
|
+
async function askForANewProject({ file, url, session, ask, say, ink, pick, askPath, inspect, clone }) {
|
|
288
|
+
const name = String(await ask.line(' Name of the new project ')).trim();
|
|
289
|
+
if (!name) {
|
|
290
|
+
throw new Error('A project needs a name. Nothing changed.');
|
|
291
|
+
}
|
|
292
|
+
const suggested = await session.request(`/api/projects/slug-suggestion?name=${encodeURIComponent(name)}`)
|
|
293
|
+
.then((answer) => answer?.slug)
|
|
294
|
+
.catch(() => null);
|
|
295
|
+
const slug = suggested || name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project';
|
|
296
|
+
say(` ${ink.muted('It will be')} ${ink.text(slug)} ${ink.muted('on')} ${ink.accent(url)}`);
|
|
297
|
+
|
|
298
|
+
const typedUrl = String(await ask.line(
|
|
299
|
+
' Its git repository URL — blank if the checkout is already on this machine ',
|
|
300
|
+
)).trim();
|
|
301
|
+
const path = await askPath({
|
|
302
|
+
ask, say, pick, inspect,
|
|
303
|
+
question: `Where should ${slug} live on this machine?`,
|
|
304
|
+
suggestions: suggestProjectPaths(file, slug),
|
|
305
|
+
slug,
|
|
306
|
+
gitUrl: typedUrl || null,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
let gitUrl = typedUrl || null;
|
|
310
|
+
if (!gitUrl) {
|
|
311
|
+
const seen = await inspect(path, { gitUrl: null });
|
|
312
|
+
if (seen.kind !== 'checkout' || !seen.remote) {
|
|
313
|
+
throw new Error(`${path} is ${describe(seen, slug)} — with no git URL, the path must be a checkout `
|
|
314
|
+
+ 'with an origin. Nothing changed.');
|
|
315
|
+
}
|
|
316
|
+
gitUrl = seen.remote;
|
|
317
|
+
say(` ${ink.muted('Its repository, from that checkout\'s origin:')} ${ink.accent(gitUrl)}`);
|
|
318
|
+
}
|
|
319
|
+
await settleCheckout({ path, slug, gitUrl, ask, say, ink, clone, inspect });
|
|
320
|
+
|
|
321
|
+
if (!await confirm(ask, `Create ${name} on ${url}`)) {
|
|
322
|
+
throw new Error(`Nothing created on cawdev; the checkout at ${path} stays.`);
|
|
323
|
+
}
|
|
324
|
+
let created;
|
|
325
|
+
try {
|
|
326
|
+
created = await session.request('/api/projects', { method: 'POST', body: { name, gitUrl } });
|
|
327
|
+
} catch (failure) {
|
|
328
|
+
throw new Error(`cawdev refused to create ${name}: ${failure.message}. The checkout at ${path} stays.`);
|
|
329
|
+
}
|
|
330
|
+
say(` ${ink.success('✓')} ${ink.muted('Created')} ${ink.text(created.name ?? name)} ${ink.muted('as')} ${ink.text(created.slug)}`);
|
|
331
|
+
return { project: { ...created, created: true }, path };
|
|
332
|
+
}
|
|
333
|
+
|
|
256
334
|
/** Add another checkout for a project this machine already serves — R47. */
|
|
257
335
|
export async function addWorkspace({
|
|
258
336
|
configPath, file,
|
package/runner/runner.mjs
CHANGED
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
drivingPath, isAlive, killedByRestart, leftBehind, memoryMaxOf, probeScope, readDriving,
|
|
53
53
|
restartSummary, scoped, snapshotOf, writeDriving,
|
|
54
54
|
} from './restart.mjs';
|
|
55
|
+
import { COMMITS_ITS_WORK, settleBranch } from '../lib/finish-check.mjs';
|
|
55
56
|
|
|
56
57
|
// --- configuration -----------------------------------------------------------
|
|
57
58
|
|
|
@@ -6771,7 +6772,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6771
6772
|
const why = `Stopped at the ${stage.stage} gate: ${decision.reason}. Nothing was `
|
|
6772
6773
|
+ 'refused — start the run again when somebody can read it.';
|
|
6773
6774
|
await skipRest(config, run, lifecycle, at + 1, why);
|
|
6774
|
-
await finish(config, run, 'FINISHED', why);
|
|
6775
|
+
await finish(config, run, 'FINISHED', why, cwd);
|
|
6775
6776
|
return;
|
|
6776
6777
|
}
|
|
6777
6778
|
if (attempt === 1) {
|
|
@@ -6780,7 +6781,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6780
6781
|
// stages after it, and stages left PENDING on a run that is over read
|
|
6781
6782
|
// as a lifecycle still going.
|
|
6782
6783
|
await skipRest(config, run, lifecycle, at + 1, why);
|
|
6783
|
-
await finish(config, run, 'FINISHED', why);
|
|
6784
|
+
await finish(config, run, 'FINISHED', why, cwd);
|
|
6784
6785
|
return;
|
|
6785
6786
|
}
|
|
6786
6787
|
// Refused once: the reason is the point of refusing, so it goes into the
|
|
@@ -6817,7 +6818,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6817
6818
|
const current = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}`)
|
|
6818
6819
|
.catch(() => null);
|
|
6819
6820
|
if (current?.live) {
|
|
6820
|
-
await finish(config, run, 'FINISHED', last?.text ?? 'The lifecycle finished.');
|
|
6821
|
+
await finish(config, run, 'FINISHED', last?.text ?? 'The lifecycle finished.', cwd);
|
|
6821
6822
|
}
|
|
6822
6823
|
await settleActions(config, run, cwd);
|
|
6823
6824
|
}
|
|
@@ -7968,7 +7969,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7968
7969
|
const summary = signal
|
|
7969
7970
|
? `The agent was terminated (${signal}).`
|
|
7970
7971
|
: `The agent exited with code ${code} without reporting. ${lastText}`.trim();
|
|
7971
|
-
await finish(config, run, code === 0 ? 'FINISHED' : 'FAILED', summary);
|
|
7972
|
+
await finish(config, run, code === 0 ? 'FINISHED' : 'FAILED', summary, cwd);
|
|
7972
7973
|
}
|
|
7973
7974
|
// R112. A STAGE ending is not the RUN ending. The loop decides that —
|
|
7974
7975
|
// it is the only thing that knows whether there is another stage — and
|
|
@@ -8613,7 +8614,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8613
8614
|
.catch(() => null);
|
|
8614
8615
|
if (current?.live && !stage) {
|
|
8615
8616
|
await finish(config, run, code === 0 && result?.status === 'SUCCESS' ? 'FINISHED' : 'FAILED',
|
|
8616
|
-
text);
|
|
8617
|
+
text, cwd);
|
|
8617
8618
|
}
|
|
8618
8619
|
// R112: not between stages — see the same line on the Claude Code path.
|
|
8619
8620
|
if (!stage) {
|
|
@@ -8917,7 +8918,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8917
8918
|
const current = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}`)
|
|
8918
8919
|
.catch(() => null);
|
|
8919
8920
|
if (current?.live && !stage) {
|
|
8920
|
-
await finish(config, run, code === 0 && ended && !failed ? 'FINISHED' : 'FAILED', text);
|
|
8921
|
+
await finish(config, run, code === 0 && ended && !failed ? 'FINISHED' : 'FAILED', text, cwd);
|
|
8921
8922
|
}
|
|
8922
8923
|
// R112: not between stages — see the same line on the Claude Code path.
|
|
8923
8924
|
if (!stage) {
|
|
@@ -8976,7 +8977,32 @@ function reportStageUsage(config, run, stageName, totals) {
|
|
|
8976
8977
|
log(` could not record what the ${stageName} stage used: ${failure.message}`));
|
|
8977
8978
|
}
|
|
8978
8979
|
|
|
8979
|
-
|
|
8980
|
+
/**
|
|
8981
|
+
* Ends a run on the platform.
|
|
8982
|
+
*
|
|
8983
|
+
* R345: a coding run reaches FINISHED only committed and pushed. Given the
|
|
8984
|
+
* checkout, a FINISHED for a profile that commits its work settles the branch
|
|
8985
|
+
* first — commits what the session left (unless it looks like a credential),
|
|
8986
|
+
* pushes without force — and becomes FAILED, with the reason in the summary
|
|
8987
|
+
* and the checkout held for Carry on (R80), when that cannot be done. Before
|
|
8988
|
+
* this, R325's run finished with a file modified and nothing pushed, and the
|
|
8989
|
+
* next run in that checkout refused to start on top of it.
|
|
8990
|
+
*/
|
|
8991
|
+
async function finish(config, run, state, summary, cwd = null) {
|
|
8992
|
+
if (state === 'FINISHED' && cwd && run.branch && COMMITS_ITS_WORK.has(run.profile)) {
|
|
8993
|
+
const settled = await settleBranch(cwd, run.branch, {
|
|
8994
|
+
push: pushBranch,
|
|
8995
|
+
label: run.entryRef ?? 'cawdev',
|
|
8996
|
+
}).catch((failure) => ({ ok: false, reason: `Checking the branch failed: ${failure.message}` }));
|
|
8997
|
+
if (!settled.ok) {
|
|
8998
|
+
log(` not finished: ${settled.reason}`);
|
|
8999
|
+
state = 'FAILED';
|
|
9000
|
+
summary = `${settled.reason}\n\n${summary ?? ''}`.trim();
|
|
9001
|
+
} else if (settled.note) {
|
|
9002
|
+
log(` ${settled.note}`);
|
|
9003
|
+
summary = `${summary ?? ''}\n\n${settled.note}`.trim();
|
|
9004
|
+
}
|
|
9005
|
+
}
|
|
8980
9006
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
|
|
8981
9007
|
method: 'POST',
|
|
8982
9008
|
body: { state, summary },
|