@warren-dean/code-kit 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +20 -0
- package/README.md +17 -8
- package/bin/code-kit.mjs +173 -5
- package/hooks/guard-bash.mjs +17 -0
- package/hooks/lib/harness.mjs +6 -0
- package/hooks/lib/next.mjs +19 -2
- package/hooks/lib/plan.mjs +16 -4
- package/hooks/lib/questions.mjs +140 -0
- package/hooks/lib/report.mjs +246 -0
- package/hooks/lib/rules.mjs +262 -15
- package/hooks/mod/art.mjs +2 -2
- package/hooks/mod/decisions.mjs +119 -4
- package/hooks/mod/loop.mjs +49 -0
- package/hooks/mod/panes.mjs +3 -1
- package/hooks/mod/register.mjs +201 -3
- package/hooks/mod/register.test.ts +312 -3
- package/hooks/mod/view.mjs +159 -11
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +1 -1
- package/skills/init/SKILL.md +2 -1
- package/skills/next/SKILL.md +14 -0
- package/skills/review/SKILL.md +1 -1
- package/templates/agent.md +1 -0
- package/templates/claude-md.md +3 -0
package/hooks/lib/rules.mjs
CHANGED
|
@@ -35,6 +35,8 @@ import { globToRegExp, matchesAny } from './glob.mjs';
|
|
|
35
35
|
export const APPROVAL_LOG = '.claude/approval-log.jsonl';
|
|
36
36
|
export const APPROVALS_DIR = '.claude/approvals';
|
|
37
37
|
export const APPROVAL_MINUTES = 60;
|
|
38
|
+
// CARD-7: a request waits until it's answered or dropped, and at most a week, so a forgotten one doesn't linger.
|
|
39
|
+
export const REQUEST_DAYS = 7;
|
|
38
40
|
// A dependency approval lasts until the lane commits the install it allows (the story may resume
|
|
39
41
|
// hours later), and at most a week, so a forgotten one doesn't linger.
|
|
40
42
|
export const DEPENDENCY_APPROVAL_DAYS = 7;
|
|
@@ -62,6 +64,8 @@ export const RELAYED = '(given in chat, recorded by the lead)';
|
|
|
62
64
|
export const PANE = '(approved in the code-kit pane)';
|
|
63
65
|
/** Refusals a person's approval would allow, for the mod's band and `code-kit requests`. */
|
|
64
66
|
export const REQUESTS_FILE = '.claude/state/requests.jsonl';
|
|
67
|
+
/** Every approval given through code-kit, and every one used up, kept for the history (CARD-6). */
|
|
68
|
+
export const GIVEN_FILE = '.claude/state/approvals-given.jsonl';
|
|
65
69
|
|
|
66
70
|
/** The command the lead runs to record approvals a person gave in chat. */
|
|
67
71
|
/** Marks an approval the lead granted on its own, within `approvals.delegate`; `rule` names the rule. */
|
|
@@ -107,11 +111,52 @@ export function approvalHowTo(names, actor, root, allows = 'these edits', config
|
|
|
107
111
|
export function grantApprovals(root, names, lane, reason, marks = {}) {
|
|
108
112
|
const dir = lane ? join(approvalsDir(root), lane) : approvalsDir(root);
|
|
109
113
|
mkdirSync(dir, { recursive: true });
|
|
110
|
-
|
|
114
|
+
const at = new Date().toISOString();
|
|
115
|
+
const files = names.map((name) => {
|
|
111
116
|
const file = join(dir, name);
|
|
112
117
|
writeFileSync(file, `${reason.trim()} ${marks[name] ?? RELAYED}\n`);
|
|
113
118
|
return file;
|
|
114
119
|
});
|
|
120
|
+
noteGiven(
|
|
121
|
+
root,
|
|
122
|
+
names.map((name) => ({
|
|
123
|
+
at,
|
|
124
|
+
name,
|
|
125
|
+
...(lane ? { lane } : {}),
|
|
126
|
+
reason: `${reason.trim()} ${marks[name] ?? RELAYED}`,
|
|
127
|
+
})),
|
|
128
|
+
);
|
|
129
|
+
return files;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const givenFile = (root) => join(projectWorktrees(root)[0] ?? root, GIVEN_FILE);
|
|
133
|
+
|
|
134
|
+
function noteGiven(root, entries) {
|
|
135
|
+
try {
|
|
136
|
+
const file = givenFile(root);
|
|
137
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
138
|
+
appendFileSync(file, entries.map((e) => `${JSON.stringify(e)}\n`).join(''));
|
|
139
|
+
} catch {
|
|
140
|
+
// the approval stands; only its history misses it
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The approvals given through code-kit (and those used up), oldest first. */
|
|
145
|
+
export function approvalsGiven(root) {
|
|
146
|
+
try {
|
|
147
|
+
return readFileSync(givenFile(root), 'utf8')
|
|
148
|
+
.split('\n')
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
.flatMap((l) => {
|
|
151
|
+
try {
|
|
152
|
+
return [JSON.parse(l)];
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
115
160
|
}
|
|
116
161
|
|
|
117
162
|
/** `path` with symlinks resolved, even if it doesn't exist yet (macOS /var → /private/var). */
|
|
@@ -249,8 +294,21 @@ export function useApproval(root, name, actor) {
|
|
|
249
294
|
for (const file of [
|
|
250
295
|
...(actor?.kind === 'lane' ? [join(dir, actor.lane, name)] : []),
|
|
251
296
|
join(dir, name),
|
|
252
|
-
])
|
|
253
|
-
|
|
297
|
+
]) {
|
|
298
|
+
const a = inForce(file);
|
|
299
|
+
if (!a) continue;
|
|
300
|
+
// Kept in the history, and as the answer to the request it was given for, once its file is gone.
|
|
301
|
+
noteGiven(root, [
|
|
302
|
+
{
|
|
303
|
+
at: a.grantedAt,
|
|
304
|
+
name,
|
|
305
|
+
...(file !== join(dir, name) ? { lane: actor.lane } : {}),
|
|
306
|
+
reason: a.reason,
|
|
307
|
+
usedAt: new Date().toISOString(),
|
|
308
|
+
},
|
|
309
|
+
]);
|
|
310
|
+
return rmSync(file, { force: true });
|
|
311
|
+
}
|
|
254
312
|
}
|
|
255
313
|
|
|
256
314
|
const requestsFile = (root) => join(projectWorktrees(root)[0] ?? root, REQUESTS_FILE);
|
|
@@ -259,8 +317,8 @@ const sameRequest = (a, b) =>
|
|
|
259
317
|
a.what === b.what &&
|
|
260
318
|
[...a.names].sort().join() === [...b.names].sort().join();
|
|
261
319
|
|
|
262
|
-
/** Every
|
|
263
|
-
|
|
320
|
+
/** Every line of the requests file: the requests, and the drops (`{ drop: true, of }`). */
|
|
321
|
+
function requestRecords(root) {
|
|
264
322
|
try {
|
|
265
323
|
return readFileSync(requestsFile(root), 'utf8')
|
|
266
324
|
.split('\n')
|
|
@@ -277,12 +335,45 @@ export function readRequests(root) {
|
|
|
277
335
|
}
|
|
278
336
|
}
|
|
279
337
|
|
|
338
|
+
/** Every request recorded, oldest first; unreadable lines are skipped. */
|
|
339
|
+
export const readRequests = (root) => requestRecords(root).filter((r) => !r.drop);
|
|
340
|
+
|
|
341
|
+
/** CARD-7: whether a request was dropped (the person dismissed it, or the lead said it's no longer needed). */
|
|
342
|
+
const droppedIn = (drops, r) => drops.some((d) => d.of === r.at && sameRequest(d, r));
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* CARD-7: drops open requests: `which` is a request's number in `openRequests`' order (1 is the
|
|
346
|
+
* newest), or 'all'. By the person (`by: 'person'`, from the pane) or the lead, once nobody needs it.
|
|
347
|
+
*/
|
|
348
|
+
export function dropRequests(root, which, { by, reason = null }, now = Date.now()) {
|
|
349
|
+
const open = openRequests(root, now);
|
|
350
|
+
const picked = which === 'all' ? open : [open[Number(which) - 1]].filter(Boolean);
|
|
351
|
+
if (!picked.length)
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
why: open.length
|
|
355
|
+
? `There's no open request ${which}: there are ${open.length} (code-kit requests lists them, newest first).`
|
|
356
|
+
: 'No approval requests are open.',
|
|
357
|
+
};
|
|
358
|
+
const file = requestsFile(root);
|
|
359
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
360
|
+
for (const r of picked)
|
|
361
|
+
appendFileSync(
|
|
362
|
+
file,
|
|
363
|
+
`${JSON.stringify({ drop: true, of: r.at, actor: r.actor, names: r.names, what: r.what, by, reason, at: new Date(now).toISOString() })}\n`,
|
|
364
|
+
);
|
|
365
|
+
return { ok: true, dropped: picked };
|
|
366
|
+
}
|
|
367
|
+
|
|
280
368
|
/**
|
|
281
369
|
* Records that `actor` was refused something the approvals `names` would allow, once: the same
|
|
282
|
-
* actor, names and command or file
|
|
283
|
-
*
|
|
370
|
+
* actor, names and command or file while one is still open count as one request. Recording never
|
|
371
|
+
* changes the refusal, so any failure here is ignored.
|
|
284
372
|
*/
|
|
285
373
|
export function recordRequest(root, actor, names, what, why, now = Date.now()) {
|
|
374
|
+
// A read-only agent (a reviewer) is never approved to write: what it changed goes back, not to the
|
|
375
|
+
// person as a request.
|
|
376
|
+
if (actor?.kind === 'readonly') return;
|
|
286
377
|
try {
|
|
287
378
|
const request = {
|
|
288
379
|
at: new Date(now).toISOString(),
|
|
@@ -292,10 +383,7 @@ export function recordRequest(root, actor, names, what, why, now = Date.now()) {
|
|
|
292
383
|
what: String(what).slice(0, 200),
|
|
293
384
|
why: String(why).slice(0, 300),
|
|
294
385
|
};
|
|
295
|
-
|
|
296
|
-
(r) => now - Date.parse(r.at) < APPROVAL_MINUTES * 60_000,
|
|
297
|
-
);
|
|
298
|
-
if (fresh.some((r) => sameRequest(r, request))) return;
|
|
386
|
+
if (openRequests(root, now).some((r) => sameRequest(r, request))) return;
|
|
299
387
|
const file = requestsFile(root);
|
|
300
388
|
mkdirSync(dirname(file), { recursive: true });
|
|
301
389
|
appendFileSync(file, `${JSON.stringify(request)}\n`);
|
|
@@ -304,19 +392,172 @@ export function recordRequest(root, actor, names, what, why, now = Date.now()) {
|
|
|
304
392
|
}
|
|
305
393
|
}
|
|
306
394
|
|
|
307
|
-
/**
|
|
395
|
+
/**
|
|
396
|
+
* CARD-7: requests nobody has answered yet, newest first: each waits until an approval answers it, its
|
|
397
|
+
* file is committed by someone who could, or it's dropped, and at most REQUEST_DAYS.
|
|
398
|
+
*/
|
|
308
399
|
export function openRequests(root, now = Date.now()) {
|
|
309
400
|
const open = [];
|
|
401
|
+
const given = approvalsGiven(root);
|
|
402
|
+
const drops = requestRecords(root).filter((r) => r.drop);
|
|
403
|
+
// The newest of the same request speaks for it: an older copy doesn't come back once that's dropped.
|
|
404
|
+
const seen = [];
|
|
310
405
|
for (const r of readRequests(root).reverse()) {
|
|
311
|
-
if (now - Date.parse(r.at) >=
|
|
312
|
-
if (
|
|
406
|
+
if (now - Date.parse(r.at) >= REQUEST_DAYS * 24 * 60 * 60_000) continue;
|
|
407
|
+
if (seen.some((o) => sameRequest(o, r))) continue;
|
|
408
|
+
seen.push(r);
|
|
409
|
+
if (droppedIn(drops, r)) continue;
|
|
410
|
+
// From before read-only agents stopped asking: only the lead and the lanes are ever approved.
|
|
411
|
+
if (!r.lane && r.actor !== 'lead session') continue;
|
|
313
412
|
const actor = r.lane ? { kind: 'lane', lane: r.lane } : { kind: 'lead' };
|
|
314
413
|
if (r.names.every((n) => approval(root, n, actor))) continue;
|
|
414
|
+
// Answered already: approved after it was asked (since expired, or used up by the commit it
|
|
415
|
+
// allowed), or the file it named was committed since by someone who could.
|
|
416
|
+
if (r.names.every((n) => grantedSince(root, given, n, r))) continue;
|
|
417
|
+
if (committedSince(root, r)) continue;
|
|
315
418
|
open.push(r);
|
|
316
419
|
}
|
|
317
420
|
return open;
|
|
318
421
|
}
|
|
319
422
|
|
|
423
|
+
/** Whether `name` was approved for the request's lane (or for everyone) at or after it was asked. */
|
|
424
|
+
function grantedSince(root, given, name, r) {
|
|
425
|
+
const asked = Date.parse(r.at);
|
|
426
|
+
if (
|
|
427
|
+
given.some(
|
|
428
|
+
(g) => g.name === name && (!g.lane || g.lane === r.lane) && Date.parse(g.at) >= asked,
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
return true;
|
|
432
|
+
const dir = approvalsDir(root);
|
|
433
|
+
return [...(r.lane ? [join(dir, r.lane, name)] : []), join(dir, name)].some((file) => {
|
|
434
|
+
try {
|
|
435
|
+
return statSync(file).isFile() && statSync(file).mtimeMs >= asked;
|
|
436
|
+
} catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Whether the file a request named has been committed, on any branch, since it was asked. */
|
|
443
|
+
function committedSince(root, r) {
|
|
444
|
+
const path = String(r.what);
|
|
445
|
+
if (/\s/.test(path) || (!path.includes('/') && !path.includes('.'))) return false;
|
|
446
|
+
try {
|
|
447
|
+
const out = execFileSync(
|
|
448
|
+
'git',
|
|
449
|
+
[
|
|
450
|
+
'-C',
|
|
451
|
+
root,
|
|
452
|
+
'log',
|
|
453
|
+
'--all',
|
|
454
|
+
'-1',
|
|
455
|
+
'--format=%ct',
|
|
456
|
+
`--since=@${Math.floor(Date.parse(r.at) / 1000)}`,
|
|
457
|
+
'--',
|
|
458
|
+
path,
|
|
459
|
+
],
|
|
460
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
461
|
+
);
|
|
462
|
+
return Boolean(out.trim());
|
|
463
|
+
} catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* CARD-6: every approval given, newest first: { at, name, lane, reason, by, files, branches, used,
|
|
470
|
+
* expired }. From the approvals given through code-kit, the approvals on disk (a person's own `!` echo
|
|
471
|
+
* included), and the audit log in every worktree, so an approval shows before its branch merges.
|
|
472
|
+
*/
|
|
473
|
+
export function approvalHistory(root, now = Date.now()) {
|
|
474
|
+
const grants = [];
|
|
475
|
+
const near = (a, b) => Math.abs(Date.parse(a) - Date.parse(b)) < 2000;
|
|
476
|
+
const add = (g) => {
|
|
477
|
+
const found = grants.find(
|
|
478
|
+
(x) => x.name === g.name && (x.lane ?? null) === (g.lane ?? null) && near(x.at, g.at),
|
|
479
|
+
);
|
|
480
|
+
if (found) {
|
|
481
|
+
if (g.usedAt) found.used = true;
|
|
482
|
+
return found;
|
|
483
|
+
}
|
|
484
|
+
const grant = {
|
|
485
|
+
at: g.at,
|
|
486
|
+
name: g.name,
|
|
487
|
+
lane: g.lane ?? null,
|
|
488
|
+
reason: g.reason ?? '',
|
|
489
|
+
files: [],
|
|
490
|
+
branches: [],
|
|
491
|
+
used: Boolean(g.usedAt),
|
|
492
|
+
};
|
|
493
|
+
grants.push(grant);
|
|
494
|
+
return grant;
|
|
495
|
+
};
|
|
496
|
+
for (const g of approvalsGiven(root)) add(g);
|
|
497
|
+
const dir = approvalsDir(root);
|
|
498
|
+
const look = (folder, lane) => {
|
|
499
|
+
let names = [];
|
|
500
|
+
try {
|
|
501
|
+
names = readdirSync(folder);
|
|
502
|
+
} catch {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
for (const name of names) {
|
|
506
|
+
const file = join(folder, name);
|
|
507
|
+
const st = statSync(file);
|
|
508
|
+
if (st.isDirectory()) {
|
|
509
|
+
if (!lane) look(file, name);
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
add({
|
|
513
|
+
at: new Date(st.mtimeMs).toISOString(),
|
|
514
|
+
name,
|
|
515
|
+
lane,
|
|
516
|
+
reason: readFileSync(file, 'utf8').trim(),
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
look(dir, null);
|
|
521
|
+
const seen = new Set();
|
|
522
|
+
for (const tree of projectWorktrees(root).length ? projectWorktrees(root) : [root]) {
|
|
523
|
+
let lines = [];
|
|
524
|
+
try {
|
|
525
|
+
lines = readFileSync(join(tree, APPROVAL_LOG), 'utf8').split('\n').filter(Boolean);
|
|
526
|
+
} catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
for (const line of lines) {
|
|
530
|
+
if (seen.has(line)) continue;
|
|
531
|
+
seen.add(line);
|
|
532
|
+
let e;
|
|
533
|
+
try {
|
|
534
|
+
e = JSON.parse(line);
|
|
535
|
+
} catch {
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (!e.approval || e.approval === 'lead') continue;
|
|
539
|
+
const grant =
|
|
540
|
+
grants.find((x) => x.name === e.approval && near(x.at, e.grantedAt ?? e.at)) ??
|
|
541
|
+
add({ at: e.grantedAt ?? e.at, name: e.approval, reason: e.reason });
|
|
542
|
+
if (e.file && !grant.files.includes(e.file)) grant.files.push(e.file);
|
|
543
|
+
if (e.branch && !grant.branches.includes(e.branch)) grant.branches.push(e.branch);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return grants
|
|
547
|
+
.map((g) => ({
|
|
548
|
+
...g,
|
|
549
|
+
by: /within the delegated rules/.test(g.reason)
|
|
550
|
+
? 'the lead, within your delegated rules'
|
|
551
|
+
: /given in chat, recorded by the lead/.test(g.reason)
|
|
552
|
+
? 'you, in chat'
|
|
553
|
+
: /in the [\w -]+ pane/.test(g.reason)
|
|
554
|
+
? 'you, in the pane'
|
|
555
|
+
: 'you',
|
|
556
|
+
expired: g.used || now - Date.parse(g.at) >= approvalMinutes(g.name) * 60_000,
|
|
557
|
+
}))
|
|
558
|
+
.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
|
559
|
+
}
|
|
560
|
+
|
|
320
561
|
/** Approvals in force now: [{ name, lane?, reason, grantedAt, minutesLeft }]. */
|
|
321
562
|
export function approvalsInForce(root, now = Date.now()) {
|
|
322
563
|
const dir = approvalsDir(root);
|
|
@@ -434,6 +675,8 @@ export function writeProblem(actor, rel, root, config) {
|
|
|
434
675
|
// A package a person approved may change the manifests and lockfiles its install rewrites.
|
|
435
676
|
if (dependencyChange(actor, rel, root, config)) return null;
|
|
436
677
|
const guarded = protectedEntry(rel, config);
|
|
678
|
+
if (actor.kind === 'readonly' && (guarded || !matchesAny(rel, config.anyActor)))
|
|
679
|
+
return readonlyProblem(actor, rel);
|
|
437
680
|
// The lead writes protected files without an approval: the person reviews them in the pull request,
|
|
438
681
|
// and every committed change is in the audit log. Anyone else needs a person's approval as well.
|
|
439
682
|
if (guarded && actor.kind !== 'lead' && !approval(root, guarded.approval, actor)) {
|
|
@@ -441,12 +684,16 @@ export function writeProblem(actor, rel, root, config) {
|
|
|
441
684
|
return `${rel} is ${guarded.why} and needs a person's approval. Ask the lead (a person) to run\n${approvalHowTo(guarded.approval, actor, root, undefined, config)}`;
|
|
442
685
|
}
|
|
443
686
|
if (matchesAny(rel, config.anyActor)) return null;
|
|
444
|
-
if (actor.kind === 'readonly') return
|
|
687
|
+
if (actor.kind === 'readonly') return readonlyProblem(actor, rel);
|
|
445
688
|
if (!owns(actor, rel, config)) return ownershipProblem(actor, rel, config);
|
|
446
689
|
const specs = actor.kind === 'lane' && config.docs?.specs;
|
|
447
690
|
return (specs && specCheckProblem(rel, root)) || screenProblem(rel, root, config);
|
|
448
691
|
}
|
|
449
692
|
|
|
693
|
+
/** A read-only agent's write, refused, with how to undo one a command it ran made (a build, a generator). */
|
|
694
|
+
const readonlyProblem = (actor, rel) =>
|
|
695
|
+
`${actor.label} is read-only; it may not write ${rel}. If a command you ran changed it (a build, a formatter or a code generator), put it back with git restore -- ${rel} (or delete it if it's new), and run such commands only in a throwaway worktree of the branch.`;
|
|
696
|
+
|
|
450
697
|
function outsideAllowed(actor, rel, root, config) {
|
|
451
698
|
if (actor.kind !== 'lead') return false;
|
|
452
699
|
const target = resolve(root, rel);
|
package/hooks/mod/art.mjs
CHANGED
|
@@ -107,7 +107,7 @@ export function spriteSvg(pose, tint, { lead = false } = {}) {
|
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
109
|
* A lane's pose (VIEW-3): stalled when flagged or quiet past the restart time, waiting while a call
|
|
110
|
-
* of its is held
|
|
110
|
+
* of its is held, its request is open or its story waits on the person's answer, working while its agent is at work, quiet when it has
|
|
111
111
|
* gone quiet, done once its branch is finished, idle otherwise.
|
|
112
112
|
*/
|
|
113
113
|
export function poseOf(
|
|
@@ -115,7 +115,7 @@ export function poseOf(
|
|
|
115
115
|
{ live = null, waiting = false, flagged = false, shown = lane.state } = {},
|
|
116
116
|
) {
|
|
117
117
|
if (flagged || live?.level === 'stalled') return 'stalled';
|
|
118
|
-
if (waiting) return 'waiting';
|
|
118
|
+
if (waiting || shown === 'waiting on you') return 'waiting';
|
|
119
119
|
if (live?.level === 'quiet') return 'quiet';
|
|
120
120
|
if (lane.active) return 'working';
|
|
121
121
|
if (['in review', 'queued'].includes(shown)) return 'done';
|
package/hooks/mod/decisions.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// the person, each with its lineage. The band's "you" lines say what needs them; a decision card says why:
|
|
3
3
|
// what they asked for, the story and requirements it belongs to, what the agent was doing, what was already
|
|
4
4
|
// settled, why the lead couldn't settle this itself, and each option with what happens next.
|
|
5
|
+
import { askWords } from './view.mjs';
|
|
5
6
|
|
|
6
7
|
/** The kinds of band line that are the person's to decide; the rest are notes. */
|
|
7
8
|
export const DECISION_KINDS = new Set([
|
|
@@ -17,6 +18,10 @@ export const DECISION_KINDS = new Set([
|
|
|
17
18
|
'teamIntent',
|
|
18
19
|
'teamReview',
|
|
19
20
|
'teamDrift',
|
|
21
|
+
'question',
|
|
22
|
+
'brief',
|
|
23
|
+
'denied',
|
|
24
|
+
'report',
|
|
20
25
|
]);
|
|
21
26
|
|
|
22
27
|
/** What each answer does, in the person's words. */
|
|
@@ -40,8 +45,97 @@ const EFFECT = {
|
|
|
40
45
|
'The issue closes as agreed, with your words; then put it in the specs or decisions so every agent builds on it.',
|
|
41
46
|
dropIntent: 'The issue closes as dropped, and the team carries on as agreed before.',
|
|
42
47
|
team: 'Opens the Team tab: who is building what, and what changed since you last looked.',
|
|
48
|
+
openDecision: 'Opens it here, with what each answer does.',
|
|
49
|
+
dropRequest: "Nobody needs it any more: it stops waiting, and nothing's approved.",
|
|
50
|
+
answer: 'Type your answer; the lead is told it and carries on.',
|
|
51
|
+
answerBrief: "Type your answer; the lead records it in the brief's decisions and builds on it.",
|
|
52
|
+
laterBrief: 'It waits; it comes back next session.',
|
|
53
|
+
runDenied:
|
|
54
|
+
'Puts it in your prompt after !, so you run it yourself; press Enter to run it, then Done it.',
|
|
55
|
+
deniedDone: 'The lead is told you ran it, or allowed it, and carries on.',
|
|
56
|
+
deniedNo: "The lead is told you won't run it now, and carries on with other work.",
|
|
57
|
+
sendReport:
|
|
58
|
+
"Files it on code-kit's GitHub under your account (a comment instead, if it's filed already).",
|
|
59
|
+
discardReport: "It's not sent, and goes.",
|
|
43
60
|
};
|
|
44
61
|
|
|
62
|
+
/**
|
|
63
|
+
* CARD-8: one card per decision. A band line can stand for several (approvals waiting, held calls, the
|
|
64
|
+
* lead's questions, the brief's, Claude Code's refusals, reports); each becomes its own line here, with
|
|
65
|
+
* the decision it's about, so each card's options answer that one.
|
|
66
|
+
*/
|
|
67
|
+
function eachDecision(line) {
|
|
68
|
+
if (line.kind === 'approvals' && line.asks?.length > 1)
|
|
69
|
+
return line.asks.map((a) => ({
|
|
70
|
+
...line,
|
|
71
|
+
text: `Approve ${askWords(a.request, a.files)}`,
|
|
72
|
+
request: a.request,
|
|
73
|
+
files: a.files,
|
|
74
|
+
}));
|
|
75
|
+
if (line.kind === 'approvals')
|
|
76
|
+
return [{ ...line, text: `Approve ${askWords(line.request, line.files)}` }];
|
|
77
|
+
if (line.kind === 'held' && line.calls?.length > 1)
|
|
78
|
+
return line.calls.map((c) => ({ ...line, text: c.text, held: c.held }));
|
|
79
|
+
if (line.kind === 'question')
|
|
80
|
+
return (line.questions ?? [line.question]).map((q) => ({
|
|
81
|
+
...line,
|
|
82
|
+
text: q.question,
|
|
83
|
+
question: q,
|
|
84
|
+
}));
|
|
85
|
+
if (line.kind === 'brief')
|
|
86
|
+
return (line.briefs ?? [line.brief]).map((b) => ({
|
|
87
|
+
...line,
|
|
88
|
+
text: `From your brief: ${b.question}`,
|
|
89
|
+
brief: b,
|
|
90
|
+
}));
|
|
91
|
+
if (line.kind === 'denied')
|
|
92
|
+
return (line.denials ?? [line.denial]).map((d) => ({
|
|
93
|
+
...line,
|
|
94
|
+
text: `Claude Code refused ${d.who}'s ${d.what}`,
|
|
95
|
+
denial: d,
|
|
96
|
+
actions: [
|
|
97
|
+
...(d.command ? [{ id: 'runDenied', label: 'Put it in the prompt' }] : []),
|
|
98
|
+
{ id: 'deniedDone', label: 'Done it' },
|
|
99
|
+
{ id: 'deniedNo', label: 'Not now' },
|
|
100
|
+
],
|
|
101
|
+
}));
|
|
102
|
+
if (line.kind === 'report')
|
|
103
|
+
return (line.reports ?? [line.report]).map((r) => ({ ...line, text: r.title, report: r }));
|
|
104
|
+
return [line];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A card's options: the line's own actions, or, for the kinds a card answers differently, its own. */
|
|
108
|
+
function optionsOf(line) {
|
|
109
|
+
if (line.kind === 'approvals')
|
|
110
|
+
return [
|
|
111
|
+
{ id: 'approve', label: 'Approve…' },
|
|
112
|
+
{ id: 'dropRequest', label: 'Not needed' },
|
|
113
|
+
];
|
|
114
|
+
// ASK-2: each of the lead's options answers it at once; any answer can be typed instead.
|
|
115
|
+
if (line.kind === 'question')
|
|
116
|
+
return [
|
|
117
|
+
...line.question.options.map((o, i) => ({
|
|
118
|
+
id: `answerWith-${i}`,
|
|
119
|
+
act: 'answerWith',
|
|
120
|
+
label: o.label,
|
|
121
|
+
effect: o.effect,
|
|
122
|
+
line: { ...line, choice: o.label },
|
|
123
|
+
})),
|
|
124
|
+
{ id: 'answer', label: line.question.options.length ? 'Something else…' : 'Answer…' },
|
|
125
|
+
];
|
|
126
|
+
if (line.kind === 'brief')
|
|
127
|
+
return [
|
|
128
|
+
{ id: 'answerBrief', label: 'Answer…' },
|
|
129
|
+
{ id: 'laterBrief', label: 'Later' },
|
|
130
|
+
];
|
|
131
|
+
if (line.kind === 'report')
|
|
132
|
+
return [
|
|
133
|
+
{ id: 'sendReport', label: 'Send' },
|
|
134
|
+
{ id: 'discardReport', label: 'Discard' },
|
|
135
|
+
];
|
|
136
|
+
return line.actions;
|
|
137
|
+
}
|
|
138
|
+
|
|
45
139
|
/** Why a line needs the person, when the line itself doesn't say. */
|
|
46
140
|
function whyOf(line, check) {
|
|
47
141
|
if (line.kind === 'held') {
|
|
@@ -75,6 +169,17 @@ function whyOf(line, check) {
|
|
|
75
169
|
if (line.kind === 'teamReview') return "It's in a lane you own, so its review is yours.";
|
|
76
170
|
if (line.kind === 'teamDrift')
|
|
77
171
|
return "The team's drift check found your work building on something that has since changed, or heading into someone else's: settle it before it merges.";
|
|
172
|
+
if (line.kind === 'question')
|
|
173
|
+
return (
|
|
174
|
+
line.question.why ??
|
|
175
|
+
"The lead can't settle this itself: it's yours to decide, and the lead builds on your answer."
|
|
176
|
+
);
|
|
177
|
+
if (line.kind === 'brief')
|
|
178
|
+
return `It's an open question in ${line.brief.path}: what gets built depends on your answer.`;
|
|
179
|
+
if (line.kind === 'denied')
|
|
180
|
+
return `Claude Code's own permission check refused it${line.denial.reason ? ` (${line.denial.reason})` : ''}, which code-kit can't approve past: only you can run it or allow it.`;
|
|
181
|
+
if (line.kind === 'report')
|
|
182
|
+
return "code-kit got in an agent's way. It's sent under your GitHub account to a public repository, so read what it says first.";
|
|
78
183
|
return null;
|
|
79
184
|
}
|
|
80
185
|
|
|
@@ -102,6 +207,7 @@ export function limitsWhy(outside) {
|
|
|
102
207
|
function storyOf(line, state) {
|
|
103
208
|
const stories = state?.stories ?? [];
|
|
104
209
|
const lane = line.held?.lane ?? line.request?.lane ?? line.stall?.lane ?? null;
|
|
210
|
+
if (line.question?.story) return stories.find((s) => s.id === line.question.story) ?? null;
|
|
105
211
|
if (line.story) return stories.find((s) => s.id === line.story.id) ?? line.story;
|
|
106
212
|
if (line.stall?.story) return stories.find((s) => s.id === line.stall.story) ?? null;
|
|
107
213
|
if (line.branch) return stories.find((s) => s.branch === line.branch) ?? null;
|
|
@@ -123,7 +229,8 @@ export function decisionCards(lines, context = {}) {
|
|
|
123
229
|
const titles = new Map((status?.requirements ?? []).map((r) => [r.id, r.title]));
|
|
124
230
|
return lines
|
|
125
231
|
.filter((l) => DECISION_KINDS.has(l.kind) && !l.mission)
|
|
126
|
-
.map((line
|
|
232
|
+
.flatMap((source) => eachDecision(source).map((line) => ({ source, line })))
|
|
233
|
+
.map(({ source, line }, i) => {
|
|
127
234
|
const story = storyOf(line, state);
|
|
128
235
|
const lane = line.held?.lane ?? line.request?.lane ?? line.stall?.lane ?? story?.lane ?? null;
|
|
129
236
|
const settled = [
|
|
@@ -140,6 +247,8 @@ export function decisionCards(lines, context = {}) {
|
|
|
140
247
|
id: `${line.kind}-${i}`,
|
|
141
248
|
kind: line.kind,
|
|
142
249
|
line,
|
|
250
|
+
// The band line it came from: one line can stand for several decisions (CARD-8).
|
|
251
|
+
source,
|
|
143
252
|
title: line.text,
|
|
144
253
|
asked: [request, goal].filter(Boolean).join(' · ') || null,
|
|
145
254
|
story: story
|
|
@@ -151,15 +260,21 @@ export function decisionCards(lines, context = {}) {
|
|
|
151
260
|
})),
|
|
152
261
|
doing:
|
|
153
262
|
line.held?.command ??
|
|
154
|
-
line.request
|
|
263
|
+
(line.request ? askWords(line.request, line.files ?? [line.request.what]) : null) ??
|
|
155
264
|
(line.proposal?.label ? `Next step: ${line.proposal.label}` : null) ??
|
|
156
265
|
(line.intent
|
|
157
266
|
? `Intent #${line.intent.id}: ${line.intent.about}${line.intent.touches.length ? ` (touches ${line.intent.touches.join(', ')})` : ''}`
|
|
158
267
|
: null) ??
|
|
159
|
-
(line.pull ? `Pull request #${line.pull.number} on ${line.pull.branch}` : null)
|
|
268
|
+
(line.pull ? `Pull request #${line.pull.number} on ${line.pull.branch}` : null) ??
|
|
269
|
+
(line.question?.blocks ? `${line.question.story} waits on your answer.` : null) ??
|
|
270
|
+
(line.denial ? (line.denial.command ?? line.denial.what) : null) ??
|
|
271
|
+
(line.report ? line.report.body : null),
|
|
160
272
|
settled,
|
|
161
273
|
why: whyOf(line, check),
|
|
162
|
-
options: line.
|
|
274
|
+
options: optionsOf(line).map((a) => ({
|
|
275
|
+
...a,
|
|
276
|
+
effect: a.effect ?? EFFECT[a.act ?? a.id] ?? null,
|
|
277
|
+
})),
|
|
163
278
|
};
|
|
164
279
|
});
|
|
165
280
|
}
|
package/hooks/mod/loop.mjs
CHANGED
|
@@ -14,6 +14,55 @@ const LEAD_STEPS = new Set([
|
|
|
14
14
|
'update',
|
|
15
15
|
]);
|
|
16
16
|
|
|
17
|
+
/** The steps that name stories, which an agent already at work on one shouldn't be asked to start again. */
|
|
18
|
+
const STORY_STEPS = new Set(['dispatch', 'review', 'lead', 'design']);
|
|
19
|
+
const AT_WORK = new Set(['pending', 'running', 'waiting']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* LOOP-9: the stories agents are at work on now, by the task each was started with: a lane agent is
|
|
23
|
+
* started as "ST-12 booking form", a reviewer as "Review web/st-12". `code-kit next` reads only the
|
|
24
|
+
* branches, so it can't tell a branch still being built, or already under review, from one waiting.
|
|
25
|
+
*/
|
|
26
|
+
export function storiesInFlight(agents) {
|
|
27
|
+
const ids = new Set();
|
|
28
|
+
for (const a of agents ?? []) {
|
|
29
|
+
if (!AT_WORK.has(a.status)) continue;
|
|
30
|
+
const text = `${a.description ?? ''}`;
|
|
31
|
+
for (const m of text.matchAll(/\bST-(\d+)\b/gi)) ids.add(`ST-${m[1]}`);
|
|
32
|
+
for (const m of text.matchAll(/\b[\w-]+\/st-(\d+)\b/gi)) ids.add(`ST-${m[1]}`);
|
|
33
|
+
}
|
|
34
|
+
return ids;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* LOOP-9: the step from `code-kit next`, without the stories already in flight. A step left with none
|
|
39
|
+
* gives way to the next one `next` listed; with nothing left, the loop waits, saying for what, until an
|
|
40
|
+
* agent finishes (which moves the project on).
|
|
41
|
+
*/
|
|
42
|
+
export function withoutInFlight(step, inFlight) {
|
|
43
|
+
if (!step || !inFlight?.size) return step;
|
|
44
|
+
const chain = [step, ...(step.then ?? [])].map(({ then: _then, ...s }) => s);
|
|
45
|
+
const kept = [];
|
|
46
|
+
const busy = [];
|
|
47
|
+
for (const s of chain) {
|
|
48
|
+
if (!STORY_STEPS.has(s.step)) {
|
|
49
|
+
kept.push(s);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const ids = (s.args ?? '').split(/\s+/).filter(Boolean);
|
|
53
|
+
const free = ids.filter((id) => !inFlight.has(id.toUpperCase()));
|
|
54
|
+
busy.push(...ids.filter((id) => inFlight.has(id.toUpperCase())));
|
|
55
|
+
if (free.length) kept.push({ ...s, args: free.join(' ') });
|
|
56
|
+
}
|
|
57
|
+
if (kept.length) return { ...kept[0], ...(kept.length > 1 ? { then: kept.slice(1) } : {}) };
|
|
58
|
+
const names = [...new Set(busy)];
|
|
59
|
+
return {
|
|
60
|
+
step: 'wait',
|
|
61
|
+
why: `${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} still with ${names.length === 1 ? 'its agent' : 'their agents'}; the loop goes on when one finishes`,
|
|
62
|
+
inFlight: names,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
17
66
|
/**
|
|
18
67
|
* The prompt for a step from `code-kit next --json` (LOOP-1), or null when it isn't the lead's to
|
|
19
68
|
* take. Every ready story is named: the dispatch skill decides how many lanes go (LOOP-2). A prompt
|
package/hooks/mod/panes.mjs
CHANGED
|
@@ -43,6 +43,7 @@ const GLYPH = {
|
|
|
43
43
|
'in review': '◐',
|
|
44
44
|
queued: '◆',
|
|
45
45
|
'sent back': '↩',
|
|
46
|
+
'waiting on you': '?',
|
|
46
47
|
blocked: '■',
|
|
47
48
|
idle: '·',
|
|
48
49
|
ready: '○',
|
|
@@ -64,6 +65,7 @@ export function countsOf(state, queue = []) {
|
|
|
64
65
|
['in review', n('in review')],
|
|
65
66
|
['queued', n('queued')],
|
|
66
67
|
['sent back', n('sent back')],
|
|
68
|
+
['waiting on you', n('waiting on you')],
|
|
67
69
|
['blocked', n('blocked')],
|
|
68
70
|
['ready', state.ready.length],
|
|
69
71
|
['merged', (state.stories ?? []).filter((s) => s.state === 'done').length],
|
|
@@ -1338,7 +1340,7 @@ export function decisionsTab(cards, ui, { Box, Text, Button }, on) {
|
|
|
1338
1340
|
label: o.label,
|
|
1339
1341
|
...(o.hotkey ? { hotkey: o.hotkey } : {}),
|
|
1340
1342
|
variant: 'primary',
|
|
1341
|
-
onPress: () => on.onNeed(o.id, card.line),
|
|
1343
|
+
onPress: () => on.onNeed(o.act ?? o.id, o.line ?? card.line),
|
|
1342
1344
|
}),
|
|
1343
1345
|
...(o.effect ? [text(o.effect, { dimColor: true, wrap: 'wrap' })] : []),
|
|
1344
1346
|
],
|