@warren-dean/code-kit 0.10.0 → 0.11.1
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 +18 -0
- package/README.md +33 -31
- package/bin/code-kit.mjs +99 -3
- package/hooks/guard-bash.mjs +8 -0
- package/hooks/lib/rules.mjs +214 -4
- package/hooks/lib/team.mjs +62 -7
- package/hooks/mod/decisions.mjs +5 -1
- package/hooks/mod/loop.mjs +49 -0
- package/hooks/mod/panes.mjs +42 -49
- package/hooks/mod/register.mjs +122 -14
- package/hooks/mod/register.test.ts +283 -15
- package/hooks/mod/teamview.mjs +469 -74
- package/hooks/mod/view.mjs +59 -11
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +1 -1
- package/skills/init/SKILL.md +2 -1
- package/skills/review/SKILL.md +1 -1
- package/skills/team-init/SKILL.md +59 -0
package/hooks/lib/rules.mjs
CHANGED
|
@@ -62,6 +62,8 @@ export const RELAYED = '(given in chat, recorded by the lead)';
|
|
|
62
62
|
export const PANE = '(approved in the code-kit pane)';
|
|
63
63
|
/** Refusals a person's approval would allow, for the mod's band and `code-kit requests`. */
|
|
64
64
|
export const REQUESTS_FILE = '.claude/state/requests.jsonl';
|
|
65
|
+
/** Every approval given through code-kit, and every one used up, kept for the history (CARD-6). */
|
|
66
|
+
export const GIVEN_FILE = '.claude/state/approvals-given.jsonl';
|
|
65
67
|
|
|
66
68
|
/** The command the lead runs to record approvals a person gave in chat. */
|
|
67
69
|
/** Marks an approval the lead granted on its own, within `approvals.delegate`; `rule` names the rule. */
|
|
@@ -107,11 +109,52 @@ export function approvalHowTo(names, actor, root, allows = 'these edits', config
|
|
|
107
109
|
export function grantApprovals(root, names, lane, reason, marks = {}) {
|
|
108
110
|
const dir = lane ? join(approvalsDir(root), lane) : approvalsDir(root);
|
|
109
111
|
mkdirSync(dir, { recursive: true });
|
|
110
|
-
|
|
112
|
+
const at = new Date().toISOString();
|
|
113
|
+
const files = names.map((name) => {
|
|
111
114
|
const file = join(dir, name);
|
|
112
115
|
writeFileSync(file, `${reason.trim()} ${marks[name] ?? RELAYED}\n`);
|
|
113
116
|
return file;
|
|
114
117
|
});
|
|
118
|
+
noteGiven(
|
|
119
|
+
root,
|
|
120
|
+
names.map((name) => ({
|
|
121
|
+
at,
|
|
122
|
+
name,
|
|
123
|
+
...(lane ? { lane } : {}),
|
|
124
|
+
reason: `${reason.trim()} ${marks[name] ?? RELAYED}`,
|
|
125
|
+
})),
|
|
126
|
+
);
|
|
127
|
+
return files;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const givenFile = (root) => join(projectWorktrees(root)[0] ?? root, GIVEN_FILE);
|
|
131
|
+
|
|
132
|
+
function noteGiven(root, entries) {
|
|
133
|
+
try {
|
|
134
|
+
const file = givenFile(root);
|
|
135
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
136
|
+
appendFileSync(file, entries.map((e) => `${JSON.stringify(e)}\n`).join(''));
|
|
137
|
+
} catch {
|
|
138
|
+
// the approval stands; only its history misses it
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The approvals given through code-kit (and those used up), oldest first. */
|
|
143
|
+
export function approvalsGiven(root) {
|
|
144
|
+
try {
|
|
145
|
+
return readFileSync(givenFile(root), 'utf8')
|
|
146
|
+
.split('\n')
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.flatMap((l) => {
|
|
149
|
+
try {
|
|
150
|
+
return [JSON.parse(l)];
|
|
151
|
+
} catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
115
158
|
}
|
|
116
159
|
|
|
117
160
|
/** `path` with symlinks resolved, even if it doesn't exist yet (macOS /var → /private/var). */
|
|
@@ -249,8 +292,21 @@ export function useApproval(root, name, actor) {
|
|
|
249
292
|
for (const file of [
|
|
250
293
|
...(actor?.kind === 'lane' ? [join(dir, actor.lane, name)] : []),
|
|
251
294
|
join(dir, name),
|
|
252
|
-
])
|
|
253
|
-
|
|
295
|
+
]) {
|
|
296
|
+
const a = inForce(file);
|
|
297
|
+
if (!a) continue;
|
|
298
|
+
// Kept in the history, and as the answer to the request it was given for, once its file is gone.
|
|
299
|
+
noteGiven(root, [
|
|
300
|
+
{
|
|
301
|
+
at: a.grantedAt,
|
|
302
|
+
name,
|
|
303
|
+
...(file !== join(dir, name) ? { lane: actor.lane } : {}),
|
|
304
|
+
reason: a.reason,
|
|
305
|
+
usedAt: new Date().toISOString(),
|
|
306
|
+
},
|
|
307
|
+
]);
|
|
308
|
+
return rmSync(file, { force: true });
|
|
309
|
+
}
|
|
254
310
|
}
|
|
255
311
|
|
|
256
312
|
const requestsFile = (root) => join(projectWorktrees(root)[0] ?? root, REQUESTS_FILE);
|
|
@@ -283,6 +339,9 @@ export function readRequests(root) {
|
|
|
283
339
|
* never changes the refusal, so any failure here is ignored.
|
|
284
340
|
*/
|
|
285
341
|
export function recordRequest(root, actor, names, what, why, now = Date.now()) {
|
|
342
|
+
// A read-only agent (a reviewer) is never approved to write: what it changed goes back, not to the
|
|
343
|
+
// person as a request.
|
|
344
|
+
if (actor?.kind === 'readonly') return;
|
|
286
345
|
try {
|
|
287
346
|
const request = {
|
|
288
347
|
at: new Date(now).toISOString(),
|
|
@@ -307,16 +366,161 @@ export function recordRequest(root, actor, names, what, why, now = Date.now()) {
|
|
|
307
366
|
/** Requests from the last 60 minutes that an approval in force doesn't yet answer, newest first. */
|
|
308
367
|
export function openRequests(root, now = Date.now()) {
|
|
309
368
|
const open = [];
|
|
369
|
+
const given = approvalsGiven(root);
|
|
310
370
|
for (const r of readRequests(root).reverse()) {
|
|
311
371
|
if (now - Date.parse(r.at) >= APPROVAL_MINUTES * 60_000) continue;
|
|
312
372
|
if (open.some((o) => sameRequest(o, r))) continue;
|
|
373
|
+
// From before read-only agents stopped asking: only the lead and the lanes are ever approved.
|
|
374
|
+
if (!r.lane && r.actor !== 'lead session') continue;
|
|
313
375
|
const actor = r.lane ? { kind: 'lane', lane: r.lane } : { kind: 'lead' };
|
|
314
376
|
if (r.names.every((n) => approval(root, n, actor))) continue;
|
|
377
|
+
// Answered already: approved after it was asked (since expired, or used up by the commit it
|
|
378
|
+
// allowed), or the file it named was committed since by someone who could.
|
|
379
|
+
if (r.names.every((n) => grantedSince(root, given, n, r))) continue;
|
|
380
|
+
if (committedSince(root, r)) continue;
|
|
315
381
|
open.push(r);
|
|
316
382
|
}
|
|
317
383
|
return open;
|
|
318
384
|
}
|
|
319
385
|
|
|
386
|
+
/** Whether `name` was approved for the request's lane (or for everyone) at or after it was asked. */
|
|
387
|
+
function grantedSince(root, given, name, r) {
|
|
388
|
+
const asked = Date.parse(r.at);
|
|
389
|
+
if (
|
|
390
|
+
given.some(
|
|
391
|
+
(g) => g.name === name && (!g.lane || g.lane === r.lane) && Date.parse(g.at) >= asked,
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
return true;
|
|
395
|
+
const dir = approvalsDir(root);
|
|
396
|
+
return [...(r.lane ? [join(dir, r.lane, name)] : []), join(dir, name)].some((file) => {
|
|
397
|
+
try {
|
|
398
|
+
return statSync(file).isFile() && statSync(file).mtimeMs >= asked;
|
|
399
|
+
} catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Whether the file a request named has been committed, on any branch, since it was asked. */
|
|
406
|
+
function committedSince(root, r) {
|
|
407
|
+
const path = String(r.what);
|
|
408
|
+
if (/\s/.test(path) || (!path.includes('/') && !path.includes('.'))) return false;
|
|
409
|
+
try {
|
|
410
|
+
const out = execFileSync(
|
|
411
|
+
'git',
|
|
412
|
+
[
|
|
413
|
+
'-C',
|
|
414
|
+
root,
|
|
415
|
+
'log',
|
|
416
|
+
'--all',
|
|
417
|
+
'-1',
|
|
418
|
+
'--format=%ct',
|
|
419
|
+
`--since=@${Math.floor(Date.parse(r.at) / 1000)}`,
|
|
420
|
+
'--',
|
|
421
|
+
path,
|
|
422
|
+
],
|
|
423
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
424
|
+
);
|
|
425
|
+
return Boolean(out.trim());
|
|
426
|
+
} catch {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* CARD-6: every approval given, newest first: { at, name, lane, reason, by, files, branches, used,
|
|
433
|
+
* expired }. From the approvals given through code-kit, the approvals on disk (a person's own `!` echo
|
|
434
|
+
* included), and the audit log in every worktree, so an approval shows before its branch merges.
|
|
435
|
+
*/
|
|
436
|
+
export function approvalHistory(root, now = Date.now()) {
|
|
437
|
+
const grants = [];
|
|
438
|
+
const near = (a, b) => Math.abs(Date.parse(a) - Date.parse(b)) < 2000;
|
|
439
|
+
const add = (g) => {
|
|
440
|
+
const found = grants.find(
|
|
441
|
+
(x) => x.name === g.name && (x.lane ?? null) === (g.lane ?? null) && near(x.at, g.at),
|
|
442
|
+
);
|
|
443
|
+
if (found) {
|
|
444
|
+
if (g.usedAt) found.used = true;
|
|
445
|
+
return found;
|
|
446
|
+
}
|
|
447
|
+
const grant = {
|
|
448
|
+
at: g.at,
|
|
449
|
+
name: g.name,
|
|
450
|
+
lane: g.lane ?? null,
|
|
451
|
+
reason: g.reason ?? '',
|
|
452
|
+
files: [],
|
|
453
|
+
branches: [],
|
|
454
|
+
used: Boolean(g.usedAt),
|
|
455
|
+
};
|
|
456
|
+
grants.push(grant);
|
|
457
|
+
return grant;
|
|
458
|
+
};
|
|
459
|
+
for (const g of approvalsGiven(root)) add(g);
|
|
460
|
+
const dir = approvalsDir(root);
|
|
461
|
+
const look = (folder, lane) => {
|
|
462
|
+
let names = [];
|
|
463
|
+
try {
|
|
464
|
+
names = readdirSync(folder);
|
|
465
|
+
} catch {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
for (const name of names) {
|
|
469
|
+
const file = join(folder, name);
|
|
470
|
+
const st = statSync(file);
|
|
471
|
+
if (st.isDirectory()) {
|
|
472
|
+
if (!lane) look(file, name);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
add({
|
|
476
|
+
at: new Date(st.mtimeMs).toISOString(),
|
|
477
|
+
name,
|
|
478
|
+
lane,
|
|
479
|
+
reason: readFileSync(file, 'utf8').trim(),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
look(dir, null);
|
|
484
|
+
const seen = new Set();
|
|
485
|
+
for (const tree of projectWorktrees(root).length ? projectWorktrees(root) : [root]) {
|
|
486
|
+
let lines = [];
|
|
487
|
+
try {
|
|
488
|
+
lines = readFileSync(join(tree, APPROVAL_LOG), 'utf8').split('\n').filter(Boolean);
|
|
489
|
+
} catch {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
for (const line of lines) {
|
|
493
|
+
if (seen.has(line)) continue;
|
|
494
|
+
seen.add(line);
|
|
495
|
+
let e;
|
|
496
|
+
try {
|
|
497
|
+
e = JSON.parse(line);
|
|
498
|
+
} catch {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (!e.approval || e.approval === 'lead') continue;
|
|
502
|
+
const grant =
|
|
503
|
+
grants.find((x) => x.name === e.approval && near(x.at, e.grantedAt ?? e.at)) ??
|
|
504
|
+
add({ at: e.grantedAt ?? e.at, name: e.approval, reason: e.reason });
|
|
505
|
+
if (e.file && !grant.files.includes(e.file)) grant.files.push(e.file);
|
|
506
|
+
if (e.branch && !grant.branches.includes(e.branch)) grant.branches.push(e.branch);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return grants
|
|
510
|
+
.map((g) => ({
|
|
511
|
+
...g,
|
|
512
|
+
by: /within the delegated rules/.test(g.reason)
|
|
513
|
+
? 'the lead, within your delegated rules'
|
|
514
|
+
: /given in chat, recorded by the lead/.test(g.reason)
|
|
515
|
+
? 'you, in chat'
|
|
516
|
+
: /in the [\w -]+ pane/.test(g.reason)
|
|
517
|
+
? 'you, in the pane'
|
|
518
|
+
: 'you',
|
|
519
|
+
expired: g.used || now - Date.parse(g.at) >= approvalMinutes(g.name) * 60_000,
|
|
520
|
+
}))
|
|
521
|
+
.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
|
522
|
+
}
|
|
523
|
+
|
|
320
524
|
/** Approvals in force now: [{ name, lane?, reason, grantedAt, minutesLeft }]. */
|
|
321
525
|
export function approvalsInForce(root, now = Date.now()) {
|
|
322
526
|
const dir = approvalsDir(root);
|
|
@@ -434,6 +638,8 @@ export function writeProblem(actor, rel, root, config) {
|
|
|
434
638
|
// A package a person approved may change the manifests and lockfiles its install rewrites.
|
|
435
639
|
if (dependencyChange(actor, rel, root, config)) return null;
|
|
436
640
|
const guarded = protectedEntry(rel, config);
|
|
641
|
+
if (actor.kind === 'readonly' && (guarded || !matchesAny(rel, config.anyActor)))
|
|
642
|
+
return readonlyProblem(actor, rel);
|
|
437
643
|
// The lead writes protected files without an approval: the person reviews them in the pull request,
|
|
438
644
|
// and every committed change is in the audit log. Anyone else needs a person's approval as well.
|
|
439
645
|
if (guarded && actor.kind !== 'lead' && !approval(root, guarded.approval, actor)) {
|
|
@@ -441,12 +647,16 @@ export function writeProblem(actor, rel, root, config) {
|
|
|
441
647
|
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
648
|
}
|
|
443
649
|
if (matchesAny(rel, config.anyActor)) return null;
|
|
444
|
-
if (actor.kind === 'readonly') return
|
|
650
|
+
if (actor.kind === 'readonly') return readonlyProblem(actor, rel);
|
|
445
651
|
if (!owns(actor, rel, config)) return ownershipProblem(actor, rel, config);
|
|
446
652
|
const specs = actor.kind === 'lane' && config.docs?.specs;
|
|
447
653
|
return (specs && specCheckProblem(rel, root)) || screenProblem(rel, root, config);
|
|
448
654
|
}
|
|
449
655
|
|
|
656
|
+
/** A read-only agent's write, refused, with how to undo one a command it ran made (a build, a generator). */
|
|
657
|
+
const readonlyProblem = (actor, rel) =>
|
|
658
|
+
`${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.`;
|
|
659
|
+
|
|
450
660
|
function outsideAllowed(actor, rel, root, config) {
|
|
451
661
|
if (actor.kind !== 'lead') return false;
|
|
452
662
|
const target = resolve(root, rel);
|
package/hooks/lib/team.mjs
CHANGED
|
@@ -60,9 +60,16 @@ export function teamProblems(c) {
|
|
|
60
60
|
if (
|
|
61
61
|
t.provider !== undefined &&
|
|
62
62
|
t.provider !== 'github' &&
|
|
63
|
-
!(
|
|
63
|
+
!(
|
|
64
|
+
isObj(t.provider) &&
|
|
65
|
+
typeof t.provider.command === 'string' &&
|
|
66
|
+
t.provider.command.trim() &&
|
|
67
|
+
(t.provider.fallback === undefined || t.provider.fallback === 'github')
|
|
68
|
+
)
|
|
64
69
|
)
|
|
65
|
-
p.push(
|
|
70
|
+
p.push(
|
|
71
|
+
'"team.provider" must be "github" or { "command": "<the provider\'s command>", "fallback"?: "github" }',
|
|
72
|
+
);
|
|
66
73
|
}
|
|
67
74
|
for (const [name, lane] of Object.entries(isObj(c.lanes) ? c.lanes : {})) {
|
|
68
75
|
if (!isObj(lane) || lane.owners === undefined) continue;
|
|
@@ -148,11 +155,44 @@ function claimOf(message) {
|
|
|
148
155
|
}
|
|
149
156
|
}
|
|
150
157
|
|
|
151
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* The provider for this project's team: GitHub's, or the config's command. A command with
|
|
160
|
+
* `fallback: "github"` (a hosted provider, such as the code-kit teams hub) hands each operation it
|
|
161
|
+
* can't reach to the GitHub provider, so claims are still checked while it's down (TEAM-8).
|
|
162
|
+
*/
|
|
152
163
|
export function teamProvider(root, team) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
if (team.provider === 'github') return githubProvider(root, team);
|
|
165
|
+
const command = commandProvider(root, team, team.provider.command);
|
|
166
|
+
if (team.provider.fallback !== 'github') return command;
|
|
167
|
+
const github = githubProvider(root, team);
|
|
168
|
+
const down = (r) => Boolean(r?.unreachable) || r?.reachable === false;
|
|
169
|
+
const either =
|
|
170
|
+
(op) =>
|
|
171
|
+
(...args) => {
|
|
172
|
+
const r = command[op](...args);
|
|
173
|
+
if (!down(r) && r !== null) return r;
|
|
174
|
+
const g = github[op](...args);
|
|
175
|
+
// The state says it came through GitHub, and why, for the band.
|
|
176
|
+
return op === 'state' && g
|
|
177
|
+
? { ...g, fallback: r?.why ?? "The team provider can't be reached." }
|
|
178
|
+
: g;
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
...Object.fromEntries(
|
|
182
|
+
[
|
|
183
|
+
'whoami',
|
|
184
|
+
'claim',
|
|
185
|
+
'release',
|
|
186
|
+
'state',
|
|
187
|
+
'intend',
|
|
188
|
+
'settle',
|
|
189
|
+
'reassign',
|
|
190
|
+
'intent',
|
|
191
|
+
'since',
|
|
192
|
+
].map((op) => [op, either(op)]),
|
|
193
|
+
),
|
|
194
|
+
forget: () => (command.forget(), github.forget()),
|
|
195
|
+
};
|
|
156
196
|
}
|
|
157
197
|
|
|
158
198
|
function githubProvider(root, team) {
|
|
@@ -402,11 +442,26 @@ function githubProvider(root, team) {
|
|
|
402
442
|
'number,title,author,headRefName,isDraft,updatedAt,url',
|
|
403
443
|
]) ?? 'null',
|
|
404
444
|
);
|
|
445
|
+
const who = me();
|
|
405
446
|
const state = {
|
|
406
447
|
at: new Date().toISOString(),
|
|
407
448
|
reachable: Boolean(claims),
|
|
408
449
|
github: Boolean(intents && pulls),
|
|
409
|
-
me:
|
|
450
|
+
me: who,
|
|
451
|
+
// What coordinates the team, for the Team pane: GitHub itself, through refs and discussions.
|
|
452
|
+
provider: {
|
|
453
|
+
name: 'GitHub',
|
|
454
|
+
live: false,
|
|
455
|
+
detail: `claims are refs on origin, intents are discussions in "${team.intents}"`,
|
|
456
|
+
},
|
|
457
|
+
// TEAM-13: what the team still needs set up for its claims and intents to work.
|
|
458
|
+
setup: [
|
|
459
|
+
...(who ? [] : ["gh isn't signed in on this machine: run gh auth login."]),
|
|
460
|
+
...(claims ? [] : ["The shared repository (origin) can't be reached."]),
|
|
461
|
+
...(found && !found.category
|
|
462
|
+
? [noCategory(found).replace(/, then raise the intent again\.$/, '.')]
|
|
463
|
+
: []),
|
|
464
|
+
],
|
|
410
465
|
techLead: team.techLead,
|
|
411
466
|
owners: team.owners,
|
|
412
467
|
staleDays: team.staleDays,
|
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([
|
|
@@ -16,6 +17,7 @@ export const DECISION_KINDS = new Set([
|
|
|
16
17
|
'teamCollision',
|
|
17
18
|
'teamIntent',
|
|
18
19
|
'teamReview',
|
|
20
|
+
'teamDrift',
|
|
19
21
|
]);
|
|
20
22
|
|
|
21
23
|
/** What each answer does, in the person's words. */
|
|
@@ -72,6 +74,8 @@ function whyOf(line, check) {
|
|
|
72
74
|
if (line.kind === 'teamIntent')
|
|
73
75
|
return "You're the tech lead: a change of direction is agreed or dropped by you, and until then everyone builds on what was agreed.";
|
|
74
76
|
if (line.kind === 'teamReview') return "It's in a lane you own, so its review is yours.";
|
|
77
|
+
if (line.kind === 'teamDrift')
|
|
78
|
+
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.";
|
|
75
79
|
return null;
|
|
76
80
|
}
|
|
77
81
|
|
|
@@ -148,7 +152,7 @@ export function decisionCards(lines, context = {}) {
|
|
|
148
152
|
})),
|
|
149
153
|
doing:
|
|
150
154
|
line.held?.command ??
|
|
151
|
-
line.request
|
|
155
|
+
(line.request ? askWords(line.request, line.files ?? [line.request.what]) : null) ??
|
|
152
156
|
(line.proposal?.label ? `Next step: ${line.proposal.label}` : null) ??
|
|
153
157
|
(line.intent
|
|
154
158
|
? `Intent #${line.intent.id}: ${line.intent.about}${line.intent.touches.length ? ` (touches ${line.intent.touches.join(', ')})` : ''}`
|
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
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Panes v2 (docs/specs/11-panes.md): the Lanes pane's frame. A counts header, tabs, what needs the
|
|
2
2
|
// person, the tab's body and the keys, as pure functions of what the mod already holds. The Lanes
|
|
3
3
|
// tab's body is view.mjs's lanesPane; the Queue and Usage tabs reuse its sections.
|
|
4
|
-
import { teamTab } from './teamview.mjs';
|
|
5
4
|
import { trailRows as trailRowsDrawn, trailSvg } from './trail.mjs';
|
|
6
5
|
import {
|
|
7
6
|
MAP_LEGEND,
|
|
@@ -32,7 +31,6 @@ export const TABS = [
|
|
|
32
31
|
{ id: 'map', label: 'Trail', hotkey: '5' },
|
|
33
32
|
{ id: 'queue', label: 'Queue', hotkey: '6' },
|
|
34
33
|
{ id: 'usage', label: 'Usage', hotkey: '7' },
|
|
35
|
-
{ id: 'team', label: 'Team', hotkey: '8' },
|
|
36
34
|
];
|
|
37
35
|
|
|
38
36
|
/** The pane's own state, kept by the mod between draws. */
|
|
@@ -246,8 +244,7 @@ export function lanesFrame(view, els, on) {
|
|
|
246
244
|
key: 'tabs',
|
|
247
245
|
flexDirection: 'row',
|
|
248
246
|
columnGap: 1,
|
|
249
|
-
|
|
250
|
-
children: TABS.filter((t) => t.id !== 'team' || view.team).map((t) =>
|
|
247
|
+
children: TABS.map((t) =>
|
|
251
248
|
Button({
|
|
252
249
|
key: `tab-${t.id}`,
|
|
253
250
|
label: t.label,
|
|
@@ -272,52 +269,48 @@ export function lanesFrame(view, els, on) {
|
|
|
272
269
|
|
|
273
270
|
const selected = state.lanes.find((l) => l.name === ui.selected) ?? null;
|
|
274
271
|
const body =
|
|
275
|
-
ui.tab === '
|
|
276
|
-
?
|
|
277
|
-
: ui.tab === '
|
|
278
|
-
?
|
|
279
|
-
: ui.tab === '
|
|
280
|
-
?
|
|
281
|
-
: ui.tab === '
|
|
282
|
-
?
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
: ui.tab === '
|
|
289
|
-
?
|
|
290
|
-
:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}),
|
|
308
|
-
];
|
|
272
|
+
ui.tab === 'decisions'
|
|
273
|
+
? decisionsTab(view.cards ?? [], ui, els, on)
|
|
274
|
+
: ui.tab === 'journal'
|
|
275
|
+
? journalTab(view.journal ?? [], els, view.now)
|
|
276
|
+
: ui.tab === 'code'
|
|
277
|
+
? codeTab(view, els, on)
|
|
278
|
+
: ui.tab === 'map'
|
|
279
|
+
? mapTab(view.status, ui.cell, els, on, view.trail, {
|
|
280
|
+
surface: view.surface,
|
|
281
|
+
columns: view.columns,
|
|
282
|
+
})
|
|
283
|
+
: ui.tab === 'queue'
|
|
284
|
+
? queueSection(loop, text, Box)
|
|
285
|
+
: ui.tab === 'usage'
|
|
286
|
+
? usageSection(view.usage, plan, text, Box)
|
|
287
|
+
: [
|
|
288
|
+
lanesPane(state, els, view.usage, plan, loop, {
|
|
289
|
+
filter: ui.filter,
|
|
290
|
+
search: ui.search,
|
|
291
|
+
selected: ui.selected,
|
|
292
|
+
live: view.live ?? {},
|
|
293
|
+
art: view.art ?? null,
|
|
294
|
+
detail: selected
|
|
295
|
+
? {
|
|
296
|
+
agent: selected.agent ?? null,
|
|
297
|
+
owns: view.paths?.[selected.name] ?? [],
|
|
298
|
+
stories: laneStories(selected, state),
|
|
299
|
+
}
|
|
300
|
+
: null,
|
|
301
|
+
combined: view.combined ?? null,
|
|
302
|
+
}),
|
|
303
|
+
];
|
|
309
304
|
const empty =
|
|
310
|
-
ui.tab === '
|
|
311
|
-
? '
|
|
312
|
-
: ui.tab === '
|
|
313
|
-
? 'Nothing
|
|
314
|
-
: ui.tab === '
|
|
315
|
-
? '
|
|
316
|
-
: ui.tab === '
|
|
317
|
-
? '
|
|
318
|
-
:
|
|
319
|
-
? 'No usage measured yet this session.'
|
|
320
|
-
: null;
|
|
305
|
+
ui.tab === 'decisions'
|
|
306
|
+
? 'Nothing needs you. The lead is handling the build.'
|
|
307
|
+
: ui.tab === 'journal'
|
|
308
|
+
? 'Nothing recorded yet: the journal starts with the brief.'
|
|
309
|
+
: ui.tab === 'queue'
|
|
310
|
+
? 'The merge queue is empty.'
|
|
311
|
+
: ui.tab === 'usage'
|
|
312
|
+
? 'No usage measured yet this session.'
|
|
313
|
+
: null;
|
|
321
314
|
|
|
322
315
|
// VIEW-6: j/k move between lanes; the selected lane's acts; f filters.
|
|
323
316
|
const opening = selected ? storyToOpen(selected, state) : null;
|