@warren-dean/code-kit 0.21.0 → 0.22.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 +20 -0
- package/README.md +46 -23
- package/bin/code-kit.mjs +271 -21
- package/hooks/guard-agent.mjs +36 -0
- package/hooks/guard-bash.mjs +23 -11
- package/hooks/guard-paths.mjs +11 -0
- package/hooks/hooks.json +20 -0
- package/hooks/lane-audit.mjs +38 -1
- package/hooks/lib/adapters/hub/client.mjs +177 -25
- package/hooks/lib/config.mjs +148 -5
- package/hooks/lib/decisionstore.mjs +89 -0
- package/hooks/lib/harness.mjs +7 -0
- package/hooks/lib/hook.mjs +7 -0
- package/hooks/lib/instructions.mjs +1 -1
- package/hooks/lib/laneclaims.mjs +89 -0
- package/hooks/lib/loop-state.mjs +20 -2
- package/hooks/lib/next.mjs +34 -1
- package/hooks/lib/plan.mjs +3 -1
- package/hooks/lib/questions.mjs +23 -5
- package/hooks/lib/regression.mjs +2 -1
- package/hooks/lib/rules.mjs +18 -12
- package/hooks/lib/schedule.mjs +86 -0
- package/hooks/lib/sessions.mjs +223 -0
- package/hooks/lib/snapshot.mjs +9 -3
- package/hooks/mod/decisions.mjs +1 -28
- package/hooks/mod/loop.mjs +8 -0
- package/hooks/mod/mission.mjs +21 -19
- package/hooks/mod/register.mjs +118 -41
- package/hooks/mod/register.test.ts +244 -73
- package/hooks/mod/view.mjs +98 -47
- package/hooks/post-edit-check.mjs +3 -0
- package/hooks/session.mjs +24 -0
- package/hooks/stop-check.mjs +12 -0
- package/package.json +1 -1
- package/skills/decide/SKILL.md +1 -1
- package/skills/dispatch/SKILL.md +2 -2
- package/skills/init/SKILL.md +1 -1
- package/skills/lead/SKILL.md +28 -0
- package/skills/next/SKILL.md +1 -1
- package/skills/spec-design/SKILL.md +2 -1
- package/skills/team-init/SKILL.md +1 -1
- package/templates/claude-md.md +3 -1
- package/templates/plan.md +2 -1
package/hooks/mod/register.mjs
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
parkWatch,
|
|
71
71
|
readOutcome,
|
|
72
72
|
restartPrompt,
|
|
73
|
+
stalledPrompt,
|
|
73
74
|
stallDue,
|
|
74
75
|
stepKey,
|
|
75
76
|
stepLabel,
|
|
@@ -143,6 +144,13 @@ const loop = {
|
|
|
143
144
|
// LOOP-10: whether this session runs the loop, the session that does when another does, and when this
|
|
144
145
|
// one last renewed its lease.
|
|
145
146
|
const lease = { mine: false, other: null, renewed: -Infinity };
|
|
147
|
+
// SESS-2: this session's role in the project, `lead` or `working`, or null where code-kit has no record.
|
|
148
|
+
let role = null;
|
|
149
|
+
/** SESS-3: a working session, or one another session leads, stands by: no loop, dialogs or notifications. */
|
|
150
|
+
const standingBy = () => !lease.mine && (Boolean(lease.other) || role === 'working');
|
|
151
|
+
/** SESS-1: how often the mod says the session is still there. */
|
|
152
|
+
const BEAT_MS = 60000;
|
|
153
|
+
let beaten = -Infinity;
|
|
146
154
|
const LEASE_RENEW_MS = 30000;
|
|
147
155
|
// LOOP-11: the lead's last reply, read once before the loop next prompts it: { turnId, text, aborted, sent, read }
|
|
148
156
|
let lastReply = null;
|
|
@@ -152,8 +160,13 @@ const offered = new Set();
|
|
|
152
160
|
let offering = false;
|
|
153
161
|
// NOTIFY-1: the decisions waiting at the last look, by key; null until the first look.
|
|
154
162
|
let notified = null;
|
|
155
|
-
/** LOOP-8: the times in a row the lead is prompted with one step before
|
|
156
|
-
const ASK_LIMIT =
|
|
163
|
+
/** LOOP-8, WHO-4: the times in a row the lead is prompted with one step before it shows as a note. */
|
|
164
|
+
const ASK_LIMIT = 3;
|
|
165
|
+
/** OWN-2: the calls Claude Code refused that the Lanes pane remembers. */
|
|
166
|
+
const DENIED_KEPT = 20;
|
|
167
|
+
/** SCHED-1: how often, and how long after its claim, a lane with no agent running is freed. */
|
|
168
|
+
const LANES_FREE_MS = 60000;
|
|
169
|
+
let lanesFreed = -Infinity;
|
|
157
170
|
let leadTurn = null; // the lead's running turn, if any
|
|
158
171
|
const activity = new Map(); // agent id → { at, inFlight, nudged, stopped, lane, story }
|
|
159
172
|
const restarts = new Map(); // story → times the loop restarted it
|
|
@@ -259,7 +272,8 @@ export function register(on) {
|
|
|
259
272
|
const firstLine = (ran) => (ran.stderr || ran.stdout).trim().split('\n')[0];
|
|
260
273
|
// A session starts with the loop running: a pause recorded by an earlier one no longer holds. A pause
|
|
261
274
|
// by a session that still runs the loop (LOOP-10) is that session's, and stays.
|
|
262
|
-
const recorded = await json('loop').catch(() => null);
|
|
275
|
+
const recorded = await json('loop', '--session', session).catch(() => null);
|
|
276
|
+
role = recorded?.role ?? null;
|
|
263
277
|
if (recorded?.paused && (!recorded.lead || recorded.lead.session === session))
|
|
264
278
|
await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
|
|
265
279
|
lease.other = recorded?.lead && recorded.lead.session !== session ? recorded.lead : null;
|
|
@@ -288,9 +302,18 @@ export function register(on) {
|
|
|
288
302
|
const status = valid ? await json('status') : null;
|
|
289
303
|
const nextStep = valid ? await json('next') : null;
|
|
290
304
|
const all = valid ? await json('requests') : null;
|
|
305
|
+
// OWN-1: this session's decisions, and how many wait in each other session; null from a code-kit
|
|
306
|
+
// that can't say, where every open decision shows.
|
|
307
|
+
const owned = valid
|
|
308
|
+
? await json('decisions', '--session', session).catch(() => null)
|
|
309
|
+
: null;
|
|
310
|
+
const mine = (kind) =>
|
|
311
|
+
(owned?.mine ?? []).filter((d) => d.kind === kind).map((d) => d.source);
|
|
312
|
+
const ownRequest = (r) =>
|
|
313
|
+
!owned || mine('approve').some((o) => o.at === r.at && o.what === r.what);
|
|
291
314
|
// HELM-6: the person sees only their own decisions; the lead decides the rest.
|
|
292
315
|
const requests = all
|
|
293
|
-
? { ...all, open: (all.open ?? []).filter((r) => r.by !== 'lead') }
|
|
316
|
+
? { ...all, open: (all.open ?? []).filter((r) => r.by !== 'lead' && ownRequest(r)) }
|
|
294
317
|
: null;
|
|
295
318
|
const stops = valid ? ((await json('stops', '--session', session)) ?? []) : [];
|
|
296
319
|
const queue = valid ? ((await json('queue')) ?? []) : [];
|
|
@@ -302,11 +325,13 @@ export function register(on) {
|
|
|
302
325
|
? ((await json('adapters', '--session', session)) ?? [])
|
|
303
326
|
: [];
|
|
304
327
|
// TEAM-7: the team's claims, intents and pull requests, where the project is a team's.
|
|
305
|
-
const team = valid && check.team ? await json('team') : null;
|
|
328
|
+
const team = valid && check.team ? await json('team', '--session', session) : null;
|
|
306
329
|
// ASK-2: the lead's questions waiting for the person; REPORT-3: bug reports waiting to be sent.
|
|
307
|
-
const questions = valid
|
|
308
|
-
?
|
|
309
|
-
:
|
|
330
|
+
const questions = !valid
|
|
331
|
+
? []
|
|
332
|
+
: owned
|
|
333
|
+
? mine('answer')
|
|
334
|
+
: ((await json('ask')) ?? []).filter((q) => q.state === 'open');
|
|
310
335
|
const reports = valid
|
|
311
336
|
? ((await json('report')) ?? []).filter((r) => r.state === 'draft')
|
|
312
337
|
: [];
|
|
@@ -319,9 +344,16 @@ export function register(on) {
|
|
|
319
344
|
}
|
|
320
345
|
// LOOP-10: while another session runs the loop, whether it still does (its lease lapses as it ends).
|
|
321
346
|
if (!lease.mine) {
|
|
322
|
-
const
|
|
347
|
+
const recorded = await json('loop', '--session', session).catch(() => null);
|
|
348
|
+
const lead = recorded?.lead ?? null;
|
|
323
349
|
lease.other = lead && lead.session !== session ? lead : null;
|
|
350
|
+
role = recorded?.role ?? role;
|
|
324
351
|
}
|
|
352
|
+
// SESS-4: a working session's own task, for its band.
|
|
353
|
+
const task =
|
|
354
|
+
valid && role === 'working'
|
|
355
|
+
? await json('task', '--session', session).catch(() => null)
|
|
356
|
+
: null;
|
|
325
357
|
// The agents at work; none where the session has none bound (claude -p before it mounts).
|
|
326
358
|
const atWork = agentsAtWork(await $.agent.list().catch(() => []));
|
|
327
359
|
model = {
|
|
@@ -339,6 +371,11 @@ export function register(on) {
|
|
|
339
371
|
// ASK-4: the brief's open questions, less those put off this session.
|
|
340
372
|
brief: (nextStep?.brief ?? []).filter((b) => !briefLater.has(b.question)),
|
|
341
373
|
base: status?.base ?? null,
|
|
374
|
+
// SESS-3, SESS-4: a working session's band holds its own, and its task.
|
|
375
|
+
working: role === 'working',
|
|
376
|
+
task,
|
|
377
|
+
// OWN-1: what waits in the other sessions, as counts.
|
|
378
|
+
elsewhere: owned?.elsewhere ?? [],
|
|
342
379
|
};
|
|
343
380
|
if (model.state.kind === 'ok') await act.registerReviewer();
|
|
344
381
|
await act.readMaps();
|
|
@@ -706,28 +743,6 @@ export function register(on) {
|
|
|
706
743
|
await act.reload();
|
|
707
744
|
return true;
|
|
708
745
|
},
|
|
709
|
-
// PERM-1: a call Claude Code refused, which only the person can run or allow.
|
|
710
|
-
runDenied: async (line) => {
|
|
711
|
-
await $.prompt.fill({ text: `! ${line.denial.command}`, mode: 'replace' }).catch(() => {});
|
|
712
|
-
},
|
|
713
|
-
deniedDone: async (line) => {
|
|
714
|
-
denied.delete(line.denial.id);
|
|
715
|
-
$.ui.invalidate('ui.render');
|
|
716
|
-
$.prompt
|
|
717
|
-
.submit({
|
|
718
|
-
text: `The person ran it themselves, or allowed it: ${line.denial.command ?? line.denial.what} (Claude Code had refused it for ${line.denial.who}). Check it worked, and carry on.`,
|
|
719
|
-
})
|
|
720
|
-
.catch(() => {});
|
|
721
|
-
},
|
|
722
|
-
deniedNo: async (line) => {
|
|
723
|
-
denied.delete(line.denial.id);
|
|
724
|
-
$.ui.invalidate('ui.render');
|
|
725
|
-
$.prompt
|
|
726
|
-
.submit({
|
|
727
|
-
text: `The person won't run ${line.denial.command ?? line.denial.what} now (Claude Code refused it for ${line.denial.who}). Don't try to get around it: carry on with other work, and if a story can't go on without it, ask with code-kit ask --blocks.`,
|
|
728
|
-
})
|
|
729
|
-
.catch(() => {});
|
|
730
|
-
},
|
|
731
746
|
// REPORT-3: the person sends a drafted report to code-kit's GitHub, or discards it.
|
|
732
747
|
sendReport: async (line) => {
|
|
733
748
|
const ran = await $.process.run(['node', cli, 'report', 'send', line.report.id], {
|
|
@@ -1206,7 +1221,7 @@ export function register(on) {
|
|
|
1206
1221
|
// LEAN-4: past compact.at, the lead's context is compacted between turns, milestone or not.
|
|
1207
1222
|
if (!fresh.length) return act.compactAtThreshold();
|
|
1208
1223
|
const mode = harness().compact?.milestone ?? 'ask';
|
|
1209
|
-
if (mode === 'off' || offering || !where.interactive || (
|
|
1224
|
+
if (mode === 'off' || offering || !where.interactive || standingBy()) {
|
|
1210
1225
|
for (const m of fresh) loop.milestones.add(m);
|
|
1211
1226
|
return act.compactAtThreshold();
|
|
1212
1227
|
}
|
|
@@ -1246,7 +1261,7 @@ export function register(on) {
|
|
|
1246
1261
|
// LEAN-4: the lead's context compacted at once when it's past compact.at; true when it took over.
|
|
1247
1262
|
compactAtThreshold: async () => {
|
|
1248
1263
|
const at = harness().compact?.at ?? 40;
|
|
1249
|
-
if (!at || offering || !where.interactive || (
|
|
1264
|
+
if (!at || offering || !where.interactive || standingBy()) return false;
|
|
1250
1265
|
const percent = (await $.session.usage().catch(() => null))?.context?.percent ?? 0;
|
|
1251
1266
|
if (percent < Math.max(at, COMPACT_FLOOR)) return false;
|
|
1252
1267
|
const box = await $.prompt.read().catch(() => null);
|
|
@@ -1394,7 +1409,9 @@ export function register(on) {
|
|
|
1394
1409
|
const first = notified === null;
|
|
1395
1410
|
notified = new Set(cards.map(decisionKey));
|
|
1396
1411
|
if (first || !fresh.length || harness().notify?.on === false) return;
|
|
1397
|
-
|
|
1412
|
+
// OWN-5: a working session raises its own decisions, which are all its band holds; a session
|
|
1413
|
+
// another one leads raises none of the project's.
|
|
1414
|
+
if (standingBy() && role !== 'working') return;
|
|
1398
1415
|
const one =
|
|
1399
1416
|
fresh.length === 1
|
|
1400
1417
|
? fresh[0]
|
|
@@ -1408,7 +1425,7 @@ export function register(on) {
|
|
|
1408
1425
|
},
|
|
1409
1426
|
offer: async () => {
|
|
1410
1427
|
if (offering || leadTurn || loop.sent || !where.interactive) return;
|
|
1411
|
-
if (
|
|
1428
|
+
if (standingBy() && role !== 'working') return;
|
|
1412
1429
|
const now = await $.clock.now();
|
|
1413
1430
|
const choice = cardsNow(now)
|
|
1414
1431
|
.map((card) => ({ card, choice: nativeChoice(card) }))
|
|
@@ -1532,8 +1549,9 @@ export function register(on) {
|
|
|
1532
1549
|
if (key === loop.lastKey && print === loop.lastPrint) return;
|
|
1533
1550
|
loop.lastKey = key;
|
|
1534
1551
|
loop.lastPrint = print;
|
|
1535
|
-
// LOOP-8: a step the lead was asked for
|
|
1536
|
-
// moving elsewhere (lanes committing) doesn't make a
|
|
1552
|
+
// LOOP-8, WHO-4: a step the lead was asked for three times and that is still next didn't take:
|
|
1553
|
+
// the project moving elsewhere (lanes committing) doesn't make a fourth prompt useful. It shows
|
|
1554
|
+
// as a note; the person may ask again.
|
|
1537
1555
|
if (loop.asked.times >= ASK_LIMIT) {
|
|
1538
1556
|
if (loop.proposal?.label !== label) {
|
|
1539
1557
|
loop.proposal = { prompt, label, kind, target, key, repeated: loop.asked.times };
|
|
@@ -1627,6 +1645,36 @@ export function register(on) {
|
|
|
1627
1645
|
await act.record('resume', story);
|
|
1628
1646
|
$.ui.invalidate('ui.render');
|
|
1629
1647
|
},
|
|
1648
|
+
// SESS-1: the session is still there, while it's open: the other sessions see it live.
|
|
1649
|
+
beat: async () => {
|
|
1650
|
+
const at = await $.clock.now();
|
|
1651
|
+
if (beaten === -Infinity) beaten = at;
|
|
1652
|
+
if (at - beaten < BEAT_MS || model.state?.kind !== 'ok') return;
|
|
1653
|
+
beaten = at;
|
|
1654
|
+
await $.process.run(['node', cli, 'sessions', 'beat', '--session', session], { cwd });
|
|
1655
|
+
},
|
|
1656
|
+
// SCHED-1: a lane this session's agent claimed, with no agent of that lane still running here for a
|
|
1657
|
+
// minute (stopped with TaskStop, or gone without its finish), is released for its next story.
|
|
1658
|
+
freeLanes: async () => {
|
|
1659
|
+
const at = await $.clock.now();
|
|
1660
|
+
// The first look only starts the clock: a session's first minute frees nothing.
|
|
1661
|
+
if (lanesFreed === -Infinity) lanesFreed = at;
|
|
1662
|
+
if (at - lanesFreed < LANES_FREE_MS || model.state?.kind !== 'ok') return;
|
|
1663
|
+
lanesFreed = at;
|
|
1664
|
+
const claims = ((await json('lanes')) ?? []).filter((c) => c.session === session);
|
|
1665
|
+
if (!claims.length) return;
|
|
1666
|
+
const running = new Set(
|
|
1667
|
+
((await $.agent.list().catch(() => [])) ?? [])
|
|
1668
|
+
.filter((a) => a.status === 'running')
|
|
1669
|
+
.map((a) => a.type),
|
|
1670
|
+
);
|
|
1671
|
+
for (const c of claims) {
|
|
1672
|
+
const type = model.check?.lanes?.[c.lane]?.agent;
|
|
1673
|
+
if (!type || running.has(type) || at - Date.parse(c.since) < LANES_FREE_MS) continue;
|
|
1674
|
+
await $.process.run(['node', cli, 'lanes', 'release', c.lane], { cwd }).catch(() => {});
|
|
1675
|
+
await act.record('free', `${c.lane}${c.story ? ` · ${c.story}` : ''}`);
|
|
1676
|
+
}
|
|
1677
|
+
},
|
|
1630
1678
|
// LOOP-3: each lane agent at work, by the time since its last tool call.
|
|
1631
1679
|
stalls: async () => {
|
|
1632
1680
|
const h = harness();
|
|
@@ -1642,6 +1690,12 @@ export function register(on) {
|
|
|
1642
1690
|
const a = activityOf(agent.id, now);
|
|
1643
1691
|
a.lane = lane;
|
|
1644
1692
|
a.story = attribution(agent.id, agent, model.check, model.state).story ?? lane;
|
|
1693
|
+
// WHO-4: the lead dispatched the stalled story again: a new agent on it ends the note.
|
|
1694
|
+
const flag = flagged.get(a.story);
|
|
1695
|
+
if (flag && flag.agentId !== agent.id) {
|
|
1696
|
+
flagged.delete(a.story);
|
|
1697
|
+
restarts.set(a.story, 0);
|
|
1698
|
+
}
|
|
1645
1699
|
// A held call, or a long command still running, isn't quiet.
|
|
1646
1700
|
if (a.inFlight > 0 || waitingOnPerson.has(agent.id)) {
|
|
1647
1701
|
a.at = now;
|
|
@@ -1677,7 +1731,20 @@ export function register(on) {
|
|
|
1677
1731
|
await act.record('restart', `${lane} · ${a.story} (${n}/${h.stall.maxRestarts})`);
|
|
1678
1732
|
} else if (due === 'flag') {
|
|
1679
1733
|
a.stopped = true;
|
|
1680
|
-
|
|
1734
|
+
const count = (restarts.get(a.story) ?? 0) + 1;
|
|
1735
|
+
flagged.set(a.story, { lane, story: a.story, count, agentId: agent.id });
|
|
1736
|
+
// WHO-4: the lead sorts it out; the person sees a note.
|
|
1737
|
+
loop.pending.push({
|
|
1738
|
+
story: a.story,
|
|
1739
|
+
prompt: stalledPrompt({
|
|
1740
|
+
type: agent.type,
|
|
1741
|
+
id: agent.id,
|
|
1742
|
+
lane,
|
|
1743
|
+
story: a.story,
|
|
1744
|
+
restarts: restarts.get(a.story) ?? 0,
|
|
1745
|
+
last: a.tool ?? null,
|
|
1746
|
+
}),
|
|
1747
|
+
});
|
|
1681
1748
|
await act.record('flag', `${lane} · ${a.story}`);
|
|
1682
1749
|
}
|
|
1683
1750
|
if (due) $.ui.invalidate('ui.render');
|
|
@@ -1874,7 +1941,9 @@ export function register(on) {
|
|
|
1874
1941
|
if (lease.mine) await act.lead();
|
|
1875
1942
|
if (!leadTurn) act.offer().catch(() => {});
|
|
1876
1943
|
await act.notifyNew().catch(() => {});
|
|
1944
|
+
await act.beat().catch(() => {});
|
|
1877
1945
|
await act.stalls();
|
|
1946
|
+
await act.freeLanes().catch(() => {});
|
|
1878
1947
|
await act.animate();
|
|
1879
1948
|
const now = await act.fingerprint();
|
|
1880
1949
|
const paneOpen = (await $.ui.panes()).some((p) => [PANE_ID, TEAM_ID].includes(p.id));
|
|
@@ -1946,6 +2015,8 @@ export function register(on) {
|
|
|
1946
2015
|
e.tool_name === 'Bash' && typeof input.command === 'string' ? input.command : null;
|
|
1947
2016
|
const path = input.file_path ?? input.notebook_path ?? null;
|
|
1948
2017
|
const lane = e.agent_id ? activity.get(e.agent_id)?.lane : null;
|
|
2018
|
+
// OWN-2: only the latest few are kept: each is activity, not something waiting on the person.
|
|
2019
|
+
if (denied.size >= DENIED_KEPT) denied.delete(denied.keys().next().value);
|
|
1949
2020
|
denied.set(e.tool_use_id ?? `${e.tool_name}-${denied.size}`, {
|
|
1950
2021
|
id: e.tool_use_id ?? `${e.tool_name}-${denied.size}`,
|
|
1951
2022
|
agentId: e.agent_id ?? null,
|
|
@@ -2248,14 +2319,21 @@ export function register(on) {
|
|
|
2248
2319
|
const state =
|
|
2249
2320
|
h.autonomy === 'off'
|
|
2250
2321
|
? 'off'
|
|
2251
|
-
:
|
|
2252
|
-
?
|
|
2322
|
+
: standingBy()
|
|
2323
|
+
? lease.other
|
|
2324
|
+
? 'elsewhere'
|
|
2325
|
+
: 'unled'
|
|
2253
2326
|
: loop.done
|
|
2254
2327
|
? 'done'
|
|
2255
2328
|
: loop.paused
|
|
2256
2329
|
? 'paused'
|
|
2257
2330
|
: 'running';
|
|
2258
2331
|
const laneNotes = {};
|
|
2332
|
+
// OWN-2: the latest call Claude Code refused in each lane, as its activity.
|
|
2333
|
+
for (const d of denied.values()) {
|
|
2334
|
+
const lane = d.agentId ? (activity.get(d.agentId)?.lane ?? null) : 'lead';
|
|
2335
|
+
if (lane) laneNotes[lane] = `Claude Code refused ${d.what}`;
|
|
2336
|
+
}
|
|
2259
2337
|
for (const a of activity.values()) {
|
|
2260
2338
|
if (!a.lane) continue;
|
|
2261
2339
|
const n = restarts.get(a.story) ?? 0;
|
|
@@ -2463,7 +2541,6 @@ export function register(on) {
|
|
|
2463
2541
|
nextStep: loop.nextStep,
|
|
2464
2542
|
combined: combinedNow(),
|
|
2465
2543
|
team: model.team ?? null,
|
|
2466
|
-
denied: [...denied.values()],
|
|
2467
2544
|
});
|
|
2468
2545
|
|
|
2469
2546
|
on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
|