@warren-dean/code-kit 0.17.0 → 0.19.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 +24 -0
- package/README.md +37 -30
- package/bin/code-kit.mjs +127 -1
- package/hooks/guard-bash.mjs +33 -2
- package/hooks/lib/harness.mjs +23 -2
- package/hooks/lib/instructions.mjs +126 -0
- package/hooks/lib/next.mjs +46 -1
- package/hooks/lib/rules.mjs +8 -2
- package/hooks/mod/decisions.mjs +29 -0
- package/hooks/mod/loop.mjs +36 -6
- package/hooks/mod/panes.mjs +2 -0
- package/hooks/mod/register.mjs +151 -10
- package/hooks/mod/register.test.ts +156 -4
- package/hooks/stop-check.mjs +12 -1
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +2 -0
- package/skills/init/SKILL.md +4 -2
- package/skills/next/SKILL.md +2 -0
- package/skills/review/SKILL.md +2 -2
- package/skills/spec-check/SKILL.md +1 -1
- package/templates/agent.md +4 -2
- package/templates/claude-md.md +8 -7
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// ONB-8: the parts of a project's instructions that are code-kit's own: the lead's section of CLAUDE.md
|
|
2
|
+
// and the "Always:" rules of each lane agent. Each is rendered from code-kit's templates and the
|
|
3
|
+
// project's config, and kept between markers, so a newer code-kit can tell they're out of date and bring
|
|
4
|
+
// them up to date exactly, leaving everything the project wrote around them as it is.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
/** The version of code-kit's instructions: raised whenever the templates' rules change. */
|
|
10
|
+
export const INSTRUCTIONS_VERSION = 5;
|
|
11
|
+
|
|
12
|
+
const TEMPLATES = fileURLToPath(new URL('../../templates/', import.meta.url));
|
|
13
|
+
const BEGIN = (v) => `<!-- code-kit:begin v${v} -->`;
|
|
14
|
+
const END = '<!-- code-kit:end -->';
|
|
15
|
+
const MARKED = /<!-- code-kit:begin v(\d+) -->\n[\s\S]*?<!-- code-kit:end -->/;
|
|
16
|
+
const CI_WORKFLOW = '.github/workflows/code-kit.yml';
|
|
17
|
+
|
|
18
|
+
/** `{{if key}}…{{end}}` kept where `facts[key]`, dropped otherwise; `{{key}}` replaced by `facts[key]`. */
|
|
19
|
+
function fill(text, facts) {
|
|
20
|
+
return text
|
|
21
|
+
.replace(/^([ \t]*- )?\{\{if ([\w.]+)\}\}([\s\S]*?)\{\{end\}\}\n?/gm, (_, item, key, body) =>
|
|
22
|
+
facts[key] ? `${item ?? ''}${body}\n` : '',
|
|
23
|
+
)
|
|
24
|
+
.replace(/\{\{([\w.]+)\}\}/g, (whole, key) => (key in facts ? String(facts[key]) : whole))
|
|
25
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the templates need from the project: everything the config and the repository say. */
|
|
29
|
+
function factsOf(root, config) {
|
|
30
|
+
const delegated = new Set(config.approvals?.delegate?.protected ?? []);
|
|
31
|
+
const kept = [];
|
|
32
|
+
for (const p of config.protected ?? []) {
|
|
33
|
+
if (p.approval === 'kit' || delegated.has(p.approval)) continue;
|
|
34
|
+
if (!kept.some((k) => k.approval === p.approval)) kept.push(p);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
layers: (config.layers ?? []).map((l) => l.name).join(', ') || 'none set',
|
|
38
|
+
designGate: config.designGate ? '; designs approved before screens' : '',
|
|
39
|
+
personKeeps: kept.length
|
|
40
|
+
? kept.map((p) => `\`${p.approval}\` (${p.why})`).join(', ')
|
|
41
|
+
: 'changes to the rules themselves',
|
|
42
|
+
'approvals.lead': Boolean(config.approvals?.lead),
|
|
43
|
+
'harness.report.on': Boolean(config.harness?.report?.on),
|
|
44
|
+
ci: existsSync(join(root, CI_WORKFLOW)),
|
|
45
|
+
'docs.specs': config.docs?.specs ?? 'the specs',
|
|
46
|
+
'docs.plan': config.docs?.plan ?? 'the plan',
|
|
47
|
+
'docs.principles': config.docs?.principles ?? "the project's principles",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const marked = (body) => `${BEGIN(INSTRUCTIONS_VERSION)}\n${body.trim()}\n${END}`;
|
|
52
|
+
|
|
53
|
+
/** The lead's section of CLAUDE.md, between its markers. */
|
|
54
|
+
export function leadSection(root, config) {
|
|
55
|
+
return marked(fill(readFileSync(join(TEMPLATES, 'claude-md.md'), 'utf8'), factsOf(root, config)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A lane agent's "Always:" rules, between their markers. */
|
|
59
|
+
export function agentRules(root, config) {
|
|
60
|
+
const template = readFileSync(join(TEMPLATES, 'agent.md'), 'utf8');
|
|
61
|
+
return marked(fill(template.slice(template.indexOf('Always:')), factsOf(root, config)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether two renderings say the same, whatever their whitespace and list indentation. */
|
|
65
|
+
const same = (a, b) => a.replace(/\s+/g, ' ').trim() === b.replace(/\s+/g, ' ').trim();
|
|
66
|
+
|
|
67
|
+
const LEAD_LEGACY = /^## Enforced, not advised\s*$/;
|
|
68
|
+
const AGENT_LEGACY = /^Always:\s*$/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Where a file's code-kit part stands: `current`; `stale` (marked, from an older code-kit or an older
|
|
72
|
+
* config); `legacy` (written before the markers, often tailored by hand, so merged once by the lead, not
|
|
73
|
+
* overwritten); or `missing` (none yet).
|
|
74
|
+
*/
|
|
75
|
+
function stateOf(text, block, legacy) {
|
|
76
|
+
const m = MARKED.exec(text);
|
|
77
|
+
// A formatter that rewraps the project's markdown doesn't make code-kit's part out of date.
|
|
78
|
+
if (m) return { state: same(m[0], block) ? 'current' : 'stale', version: Number(m[1]) };
|
|
79
|
+
if (text.split('\n').some((l) => legacy.test(l))) return { state: 'legacy', version: 0 };
|
|
80
|
+
return { state: 'missing', version: null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Each file holding code-kit's instructions: `{ file, kind, state, version, block, next }`, `next` being
|
|
85
|
+
* the file brought up to date (null for a legacy one, which the lead merges). The lead's CLAUDE.md, and
|
|
86
|
+
* the agent file of every lane whose agent file exists.
|
|
87
|
+
*/
|
|
88
|
+
export function instructionsState(root, config) {
|
|
89
|
+
const lead = leadSection(root, config);
|
|
90
|
+
const rules = agentRules(root, config);
|
|
91
|
+
const files = [
|
|
92
|
+
{ file: 'CLAUDE.md', kind: 'lead', block: lead, legacy: LEAD_LEGACY },
|
|
93
|
+
...[...new Set(Object.values(config.lanes ?? {}).map((l) => l.agent))]
|
|
94
|
+
.map((agent) => ({
|
|
95
|
+
file: `.claude/agents/${agent}.md`,
|
|
96
|
+
kind: 'lane',
|
|
97
|
+
block: rules,
|
|
98
|
+
legacy: AGENT_LEGACY,
|
|
99
|
+
}))
|
|
100
|
+
.filter((f) => existsSync(join(root, f.file))),
|
|
101
|
+
];
|
|
102
|
+
return files.map(({ file, kind, block, legacy }) => {
|
|
103
|
+
const path = join(root, file);
|
|
104
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
105
|
+
const { state, version } = stateOf(text, block, legacy);
|
|
106
|
+
const next =
|
|
107
|
+
state === 'stale'
|
|
108
|
+
? text.replace(MARKED, block)
|
|
109
|
+
: state === 'missing'
|
|
110
|
+
? `${text.replace(/\s*$/, '')}${text.trim() ? '\n\n' : ''}${block}\n`
|
|
111
|
+
: null;
|
|
112
|
+
return { file, kind, state, version, block, next };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Brings every marked or missing part of code-kit's instructions up to date; the files it changed. */
|
|
117
|
+
export function writeInstructions(root, config) {
|
|
118
|
+
const changed = [];
|
|
119
|
+
for (const f of instructionsState(root, config)) {
|
|
120
|
+
if (!f.next) continue;
|
|
121
|
+
mkdirSync(dirname(join(root, f.file)), { recursive: true });
|
|
122
|
+
writeFileSync(join(root, f.file), f.next);
|
|
123
|
+
changed.push(f.file);
|
|
124
|
+
}
|
|
125
|
+
return changed;
|
|
126
|
+
}
|
package/hooks/lib/next.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What to do next, from the project's state alone: no docs → spec-design; a spec still being shaped →
|
|
2
2
|
// spec-design; a ready spec or existing code without the kit → init; then, from the plan and git:
|
|
3
3
|
// review finished stories, dispatch ready ones, wait on running ones, or plan the next milestone.
|
|
4
|
-
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
5
5
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { join, posix } from 'node:path';
|
|
7
7
|
import { loadConfig, mergeDelegation } from './config.mjs';
|
|
@@ -10,6 +10,7 @@ import { pendingSetup } from './adapters/index.mjs';
|
|
|
10
10
|
import { CONFIG_FILE, SETUP_VERSION } from './config.mjs';
|
|
11
11
|
import { readQueue, waiting } from './queue.mjs';
|
|
12
12
|
import { decidedBy, laneOf, openRequests } from './rules.mjs';
|
|
13
|
+
import { instructionsState } from './instructions.mjs';
|
|
13
14
|
import { SECURITY_AGENT_FILE } from './security.mjs';
|
|
14
15
|
|
|
15
16
|
export const DRAFT_FILE = '.claude/code-kit.draft.json';
|
|
@@ -253,6 +254,30 @@ export function nextStep(root, base) {
|
|
|
253
254
|
attention.push(
|
|
254
255
|
`${unbuildable.join(', ')} can't be dispatched until the plan's gaps are fixed.`,
|
|
255
256
|
);
|
|
257
|
+
// LEAN-6: worktrees whose story is merged and which hold nothing uncommitted are only disk space.
|
|
258
|
+
const leftovers = mergedWorktrees(root, config);
|
|
259
|
+
if (leftovers)
|
|
260
|
+
attention.push(
|
|
261
|
+
`${leftovers} worktree${leftovers === 1 ? ' is' : 's are'} merged and clean; \`code-kit worktrees prune\` removes them, keeping their reports.`,
|
|
262
|
+
);
|
|
263
|
+
// ONB-8: code-kit's own instructions to the lead and the lanes, brought up to date before the next step:
|
|
264
|
+
// a newer code-kit's rules reach the agents only through them.
|
|
265
|
+
const instructions = instructionsState(root, config);
|
|
266
|
+
const behind = instructions.filter((f) => ['stale', 'legacy'].includes(f.state));
|
|
267
|
+
// A file with no code-kit part may be one the project keeps without it: said, not insisted on.
|
|
268
|
+
const none = instructions.filter((f) => f.state === 'missing');
|
|
269
|
+
if (none.length)
|
|
270
|
+
attention.push(
|
|
271
|
+
`${none.map((f) => f.file).join(', ')} ${none.length === 1 ? 'has' : 'have'} none of code-kit's instructions; \`code-kit instructions --write\` adds them.`,
|
|
272
|
+
);
|
|
273
|
+
if (behind.length) {
|
|
274
|
+
const legacy = behind.filter((f) => f.state === 'legacy');
|
|
275
|
+
return result(
|
|
276
|
+
'instructions',
|
|
277
|
+
`code-kit's instructions in ${behind.map((f) => f.file).join(', ')} are behind this code-kit${legacy.length ? `; ${legacy.length === behind.length ? 'they were' : `${legacy.map((f) => f.file).join(', ')} were`} written before code-kit marked its part, so merge ${legacy.length === 1 ? 'it' : 'them'} once by hand, keeping the project's own lines` : ''}.`,
|
|
278
|
+
{ args: legacy.length ? 'merge' : 'write' },
|
|
279
|
+
);
|
|
280
|
+
}
|
|
256
281
|
// HELM-4: a lane waiting on something the lead decides goes first: the lane can't go on without it.
|
|
257
282
|
const forLeadToDecide = openRequests(root).flatMap((r, i) =>
|
|
258
283
|
r.lane && decidedBy(r.names, config) === 'lead' ? [{ ...r, n: i + 1 }] : [],
|
|
@@ -466,3 +491,23 @@ export function missingSkills(root) {
|
|
|
466
491
|
}
|
|
467
492
|
return out;
|
|
468
493
|
}
|
|
494
|
+
|
|
495
|
+
/** LEAN-6: how many of the project's worktrees have a merged branch and nothing uncommitted. */
|
|
496
|
+
function mergedWorktrees(root, config) {
|
|
497
|
+
const list = git(root, 'worktree', 'list', '--porcelain');
|
|
498
|
+
if (!list) return 0;
|
|
499
|
+
const from = config.branches?.protected?.[0];
|
|
500
|
+
if (!from || !git(root, 'rev-parse', '--verify', '-q', from)) return 0;
|
|
501
|
+
let n = 0;
|
|
502
|
+
for (const block of list.split('\n\n').slice(1)) {
|
|
503
|
+
const path = /^worktree (.+)$/m.exec(block)?.[1];
|
|
504
|
+
const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1];
|
|
505
|
+
if (!path || !branch) continue;
|
|
506
|
+
const merged =
|
|
507
|
+
spawnSync('git', ['merge-base', '--is-ancestor', branch, from], { cwd: root }).status === 0;
|
|
508
|
+
if (!merged) continue;
|
|
509
|
+
const status = spawnSync('git', ['-C', path, 'status', '--porcelain'], { encoding: 'utf8' });
|
|
510
|
+
if (status.status === 0 && !status.stdout.trim()) n++;
|
|
511
|
+
}
|
|
512
|
+
return n;
|
|
513
|
+
}
|
package/hooks/lib/rules.mjs
CHANGED
|
@@ -205,8 +205,9 @@ export function approvalsGiven(root) {
|
|
|
205
205
|
export function realPath(path) {
|
|
206
206
|
let dir = resolve(path);
|
|
207
207
|
const rest = [];
|
|
208
|
+
// basename, not a slice after dirname: dirname('/x') is '/', so slicing past it cut the name's first letter.
|
|
208
209
|
while (!existsSync(dir)) {
|
|
209
|
-
rest.unshift(
|
|
210
|
+
rest.unshift(basename(dir));
|
|
210
211
|
dir = dirname(dir);
|
|
211
212
|
}
|
|
212
213
|
return join(realpathSync(dir), ...rest);
|
|
@@ -775,9 +776,14 @@ export function writeProblem(actor, rel, root, config) {
|
|
|
775
776
|
const readonlyProblem = (actor, rel) =>
|
|
776
777
|
`${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.`;
|
|
777
778
|
|
|
779
|
+
// Claude Code's own temporary folders (a session's scratchpad, its tool output): no repository's files.
|
|
780
|
+
const CLAUDE_TEMP = ['/private/tmp/claude-*/**', '/tmp/claude-*/**'];
|
|
781
|
+
|
|
778
782
|
function outsideAllowed(actor, rel, root, config) {
|
|
779
|
-
if (actor.kind !== 'lead') return false;
|
|
780
783
|
const target = resolve(root, rel);
|
|
784
|
+
// A lane's scratch files go where Claude Code's own do, outside every repository.
|
|
785
|
+
if (actor.kind === 'lane') return CLAUDE_TEMP.some((g) => globToRegExp(g).test(target));
|
|
786
|
+
if (actor.kind !== 'lead') return false;
|
|
781
787
|
return config.lead.outside.some((g) =>
|
|
782
788
|
globToRegExp(resolve(root, g.replace(/^~(?=\/)/, homedir()))).test(target),
|
|
783
789
|
);
|
package/hooks/mod/decisions.mjs
CHANGED
|
@@ -437,3 +437,32 @@ export function nativeChoice(card) {
|
|
|
437
437
|
card.kind === 'question' ? 'answerTyped' : card.kind === 'waiting' ? 'answerLeadTyped' : null,
|
|
438
438
|
};
|
|
439
439
|
}
|
|
440
|
+
|
|
441
|
+
/** NOTIFY-1: a decision's identity across refreshes: its kind and what it asks. */
|
|
442
|
+
export const decisionKey = (card) => `${card.kind}|${card.headline}`;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* NOTIFY-1: the decisions that weren't waiting at the last look, each as a system notification: what it
|
|
446
|
+
* asks, headed by what kind of decision it is. `seen` holds the keys from the last look.
|
|
447
|
+
*/
|
|
448
|
+
export function newDecisions(cards, seen) {
|
|
449
|
+
return cards
|
|
450
|
+
.filter((c) => NOTIFY_TITLE[c.kind] && !seen.has(decisionKey(c)))
|
|
451
|
+
.map((c) => ({
|
|
452
|
+
key: decisionKey(c),
|
|
453
|
+
title: `code-kit: ${NOTIFY_TITLE[c.kind] ?? 'a decision for you'}`,
|
|
454
|
+
text: `${c.headline}${c.story ? ` (${c.story.id})` : ''}`,
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const NOTIFY_TITLE = {
|
|
459
|
+
held: 'an approval to give',
|
|
460
|
+
approvals: 'an approval to give',
|
|
461
|
+
question: 'the lead asks you',
|
|
462
|
+
brief: 'a question in your brief',
|
|
463
|
+
waiting: 'the lead waits on you',
|
|
464
|
+
stalled: 'a lane stalled',
|
|
465
|
+
denied: 'Claude Code refused a call',
|
|
466
|
+
proposal: 'a step to approve',
|
|
467
|
+
queue: 'a merge for you',
|
|
468
|
+
};
|
package/hooks/mod/loop.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/** The steps the loop hands the lead; the rest (spec-design, init, check) wait for the person. */
|
|
6
6
|
const LEAD_STEPS = new Set([
|
|
7
|
+
'instructions',
|
|
7
8
|
'decide',
|
|
8
9
|
'dispatch',
|
|
9
10
|
'review',
|
|
@@ -96,13 +97,16 @@ export function parkWatch(id, on = [], stories = []) {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/**
|
|
99
|
-
* LOOP-13: where the watched stories stand, as one string
|
|
100
|
-
* whether an agent is at work on it
|
|
100
|
+
* LOOP-13: where the watched stories stand, as one string. The parked story itself (the first) counts
|
|
101
|
+
* its state, its branch's commit and whether an agent is at work on it; each story it waits on counts its
|
|
102
|
+
* state alone, so the commits of a story still being built don't wake the one waiting for it to finish.
|
|
103
|
+
* A parked story wakes when this changes.
|
|
101
104
|
*/
|
|
102
105
|
export function storiesSignature(ids, stories = [], heads = new Map(), inFlight = new Set()) {
|
|
103
106
|
return ids
|
|
104
|
-
.map((id) => {
|
|
107
|
+
.map((id, i) => {
|
|
105
108
|
const s = stories.find((x) => x.id === id);
|
|
109
|
+
if (i > 0) return `${id}:${s?.state ?? '?'}`;
|
|
106
110
|
return `${id}:${s?.state ?? '?'}:${s?.branch ? (heads.get(s.branch) ?? '') : ''}:${inFlight.has(id) ? 'busy' : ''}`;
|
|
107
111
|
})
|
|
108
112
|
.join('|');
|
|
@@ -134,6 +138,14 @@ export function leadPrompt(step, { cli = 'code-kit' } = {}) {
|
|
|
134
138
|
// SETUP-2: the base has no rules yet; the lead puts the approved ones there, then merges as usual.
|
|
135
139
|
if (step.step === 'rules')
|
|
136
140
|
return `Put code-kit's approved rules onto ${ids[0] ?? 'the base'}: run node "${cli}" queue rules. It commits the rules as committed on this branch, without switching branches; after it, merge the queue as usual.`;
|
|
141
|
+
// ONB-8: code-kit's instructions brought up to date, then committed like any change to the kit.
|
|
142
|
+
if (step.step === 'instructions') {
|
|
143
|
+
const how =
|
|
144
|
+
step.args === 'merge'
|
|
145
|
+
? `Run node "${cli}" instructions to see which files, then merge each one: replace its old code-kit part with what node "${cli}" instructions show lead (CLAUDE.md) or show lane (an agent file) prints, and keep every line the project wrote itself outside the markers, under a heading of its own. Then run node "${cli}" instructions --write for the rest.`
|
|
146
|
+
: `Run node "${cli}" instructions --write.`;
|
|
147
|
+
return `${step.why ?? "code-kit's instructions are behind."} ${how} Commit the change on a branch and merge it like your own stories, so new lane agents start with the current rules.`;
|
|
148
|
+
}
|
|
137
149
|
// HELM-4: a lane waits on the lead's decision; the decide skill takes each request on its merits.
|
|
138
150
|
if (step.step === 'decide')
|
|
139
151
|
return ids.length
|
|
@@ -175,6 +187,7 @@ export function stepLabel(step) {
|
|
|
175
187
|
const verb =
|
|
176
188
|
{
|
|
177
189
|
decide: 'Decide requests',
|
|
190
|
+
instructions: 'Update the instructions',
|
|
178
191
|
dispatch: 'Dispatch',
|
|
179
192
|
review: 'Review',
|
|
180
193
|
lead: 'Build',
|
|
@@ -327,10 +340,27 @@ export function milestonesDone(stories = []) {
|
|
|
327
340
|
return [...by].filter(([, list]) => list.every((s) => s.state === 'done')).map(([m]) => m);
|
|
328
341
|
}
|
|
329
342
|
|
|
330
|
-
/** LEAN-3: what the lead's summary keeps when its context is compacted after a milestone. */
|
|
331
|
-
export const compactInstructions = (milestone) =>
|
|
343
|
+
/** LEAN-3, LEAN-4: what the lead's summary keeps when its context is compacted, after a milestone or not. */
|
|
344
|
+
export const compactInstructions = (milestone = null) =>
|
|
332
345
|
[
|
|
333
|
-
|
|
346
|
+
milestone
|
|
347
|
+
? `${milestone} is done; the build goes on with the next milestone.`
|
|
348
|
+
: "The lead's context is compacted between turns; the build goes on.",
|
|
334
349
|
'Keep: the milestone just finished, in a line; every story in flight now, with its lane, its branch and where it stands (building, in review, in the merge queue, waiting and on what); the decisions and questions waiting on the person; what the person decided or asked for in this session that still applies; and the rules and preferences they set.',
|
|
335
350
|
"Drop: the finished stories' details, lane reports and hand-backs, command output, diffs and file contents. Each is in git, the plan or the lanes' report files, to read again when it's needed.",
|
|
336
351
|
].join('\n');
|
|
352
|
+
|
|
353
|
+
/** LEAN-5: the context a model request carried: what it sent, read from the cache, and wrote to it. */
|
|
354
|
+
export const requestContext = (usage) =>
|
|
355
|
+
(usage?.input_tokens ?? 0) +
|
|
356
|
+
(usage?.cache_read_input_tokens ?? 0) +
|
|
357
|
+
(usage?.cache_creation_input_tokens ?? 0);
|
|
358
|
+
|
|
359
|
+
/** LEAN-5: what a lane agent past its context limit is told. */
|
|
360
|
+
export const wrapUpText = (limit) =>
|
|
361
|
+
`Your context is past ${Math.round(limit / 1000)}k tokens, and every step from here rereads all of it. Finish the step you're on, commit your work, write your report, and hand back now. The lead carries the story on with a fresh agent that reads your report.`;
|
|
362
|
+
|
|
363
|
+
/** LEAN-5, LEAN-6: the lead's prompt to carry a story on with a fresh agent once its agent handed back at the limit. */
|
|
364
|
+
export function continuePrompt({ type, story, branch, cli = 'code-kit' }) {
|
|
365
|
+
return `${type} handed ${story} back at its context limit, with its work committed${branch ? ` on ${branch}` : ''}. Carry the story on with a fresh agent: run node "${cli}" worktrees release ${branch ?? '<branch>'} (it keeps the commits and moves the report to the main checkout), then dispatch ${story} again with the same brief, adding "Read your earlier report first: .claude/state/reports/${(branch ?? '<branch>').replace(/[^\w.-]+/g, '_')}.md in the main checkout": /code-kit:dispatch ${story}`;
|
|
366
|
+
}
|
package/hooks/mod/panes.mjs
CHANGED
|
@@ -120,6 +120,8 @@ export function laneActions(lane, { queue = [], held = [], requests = [] } = {})
|
|
|
120
120
|
const head = queue.find((e) => ['waiting', 'merging'].includes(e.state));
|
|
121
121
|
if (head && head.branch === lane.branch) acts.push({ id: 'merge', label: 'Merge', hotkey: 'm' });
|
|
122
122
|
if (lane.active && lane.agent) {
|
|
123
|
+
// WATCH-1: the lane agent's own transcript, in the main view.
|
|
124
|
+
acts.push({ id: 'watch', label: 'Watch', hotkey: 'w' });
|
|
123
125
|
acts.push({ id: 'nudge', label: 'Nudge', hotkey: 'n' });
|
|
124
126
|
acts.push({ id: 'stop', label: 'Stop', hotkey: 'x' });
|
|
125
127
|
}
|
package/hooks/mod/register.mjs
CHANGED
|
@@ -61,6 +61,9 @@ import {
|
|
|
61
61
|
COMPACT_FLOOR,
|
|
62
62
|
PARK_MS,
|
|
63
63
|
compactInstructions,
|
|
64
|
+
continuePrompt,
|
|
65
|
+
requestContext,
|
|
66
|
+
wrapUpText,
|
|
64
67
|
milestonesDone,
|
|
65
68
|
parkWatch,
|
|
66
69
|
readOutcome,
|
|
@@ -74,7 +77,7 @@ import {
|
|
|
74
77
|
withoutInFlight,
|
|
75
78
|
} from './loop.mjs';
|
|
76
79
|
import { goalOf, nextText } from './mission.mjs';
|
|
77
|
-
import { contextOf, decisionCards, nativeChoice } from './decisions.mjs';
|
|
80
|
+
import { contextOf, decisionCards, decisionKey, nativeChoice, newDecisions } from './decisions.mjs';
|
|
78
81
|
import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
|
|
79
82
|
import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
|
|
80
83
|
import { harnessSettings } from '../lib/harness.mjs';
|
|
@@ -144,6 +147,8 @@ let turnQuestions = []; // the open questions as the lead's running turn started
|
|
|
144
147
|
// DECIDE-4: decisions asked in Claude Code's own question dialog this session, and whether one is open.
|
|
145
148
|
const offered = new Set();
|
|
146
149
|
let offering = false;
|
|
150
|
+
// NOTIFY-1: the decisions waiting at the last look, by key; null until the first look.
|
|
151
|
+
let notified = null;
|
|
147
152
|
/** LOOP-8: the times in a row the lead is prompted with one step before the person is asked. */
|
|
148
153
|
const ASK_LIMIT = 2;
|
|
149
154
|
let leadTurn = null; // the lead's running turn, if any
|
|
@@ -1047,6 +1052,23 @@ export function register(on) {
|
|
|
1047
1052
|
const who = attribution(agentId, agentsSeen.get(agentId) ?? null, model.check, model.state);
|
|
1048
1053
|
ledger = withUsage(ledger, who, usage);
|
|
1049
1054
|
$.ui.invalidate('ui.render');
|
|
1055
|
+
// LEAN-5: a lane agent past its context limit is told to wrap up; its story goes on fresh.
|
|
1056
|
+
const limit = harness().agents.lanes.contextLimit;
|
|
1057
|
+
const agent = agentId ? agentsSeen.get(agentId) : null;
|
|
1058
|
+
const lane = Object.entries(model.check?.lanes ?? {}).find(
|
|
1059
|
+
([, l]) => l.agent === agent?.type,
|
|
1060
|
+
);
|
|
1061
|
+
if (!limit || !lane || requestContext(usage) < limit) return;
|
|
1062
|
+
const a = activityOf(agentId, await $.clock.now());
|
|
1063
|
+
if (a.overflow) return;
|
|
1064
|
+
a.overflow = true;
|
|
1065
|
+
a.lane = lane[0];
|
|
1066
|
+
a.story = who.story ?? a.story ?? lane[0];
|
|
1067
|
+
await act.record(
|
|
1068
|
+
'wrap up',
|
|
1069
|
+
`${lane[0]} · ${a.story} at ${Math.round(requestContext(usage) / 1000)}k`,
|
|
1070
|
+
);
|
|
1071
|
+
await $.session.send({ to: { agentId }, text: wrapUpText(limit) }).catch(() => {});
|
|
1050
1072
|
},
|
|
1051
1073
|
// --- the lead loop (spec 05) ---------------------------------------------------------------
|
|
1052
1074
|
record: async (kind, target, prompt) => {
|
|
@@ -1063,10 +1085,19 @@ export function register(on) {
|
|
|
1063
1085
|
) => {
|
|
1064
1086
|
loop.started = true;
|
|
1065
1087
|
loop.proposal = null;
|
|
1066
|
-
|
|
1088
|
+
const sent = { prompt, key, label, kind, target, before: new Map() };
|
|
1089
|
+
loop.sent = sent;
|
|
1067
1090
|
// LOOP-12: a design story is handed to the lead once; the person designs it with the lead.
|
|
1068
1091
|
if (kind === 'design')
|
|
1069
1092
|
for (const id of String(target).split(/\s+/).filter(Boolean)) loop.handedDesign.add(id);
|
|
1093
|
+
// LOOP-13: where the step's stories stood when the lead was prompted, to tell after its turn
|
|
1094
|
+
// whether it moved them.
|
|
1095
|
+
// A design is made with the person over many turns (LOOP-12), so it's never counted as unmoved.
|
|
1096
|
+
const ids = kind === 'design' ? [] : stepStories({ kind, target });
|
|
1097
|
+
if (ids.length) {
|
|
1098
|
+
const seen = await act.storiesNow();
|
|
1099
|
+
for (const id of ids) sent.before.set(id, seen.sig([id]));
|
|
1100
|
+
}
|
|
1070
1101
|
await act.record(kind, target, prompt);
|
|
1071
1102
|
$.ui.invalidate('ui.render');
|
|
1072
1103
|
$.prompt.submit({ text: prompt }).catch((err) => {
|
|
@@ -1141,6 +1172,16 @@ export function register(on) {
|
|
|
1141
1172
|
}
|
|
1142
1173
|
$.ui.invalidate('ui.render');
|
|
1143
1174
|
},
|
|
1175
|
+
// LOOP-13: the stories of the step the lead was prompted with that its turn left where they stood.
|
|
1176
|
+
parkUnmoved: async (sent, reason) => {
|
|
1177
|
+
if (!sent?.before?.size) return;
|
|
1178
|
+
const seen = await act.storiesNow();
|
|
1179
|
+
await act.park(
|
|
1180
|
+
[...sent.before]
|
|
1181
|
+
.filter(([id, sig]) => !loop.parked.has(id) && seen.sig([id]) === sig)
|
|
1182
|
+
.map(([id]) => ({ id, on: [], why: reason ?? "the lead's turn didn't move it" })),
|
|
1183
|
+
);
|
|
1184
|
+
},
|
|
1144
1185
|
// LOOP-13: a parked story wakes when anything it watches moves, or after PARK_MS with nothing moving.
|
|
1145
1186
|
unpark: async () => {
|
|
1146
1187
|
if (!loop.parked.size) return;
|
|
@@ -1156,14 +1197,15 @@ export function register(on) {
|
|
|
1156
1197
|
const done = milestonesDone(model.state?.stories ?? []);
|
|
1157
1198
|
if (loop.milestones === null) {
|
|
1158
1199
|
loop.milestones = new Set(done);
|
|
1159
|
-
return
|
|
1200
|
+
return act.compactAtThreshold();
|
|
1160
1201
|
}
|
|
1161
1202
|
const fresh = done.filter((m) => !loop.milestones.has(m));
|
|
1162
|
-
|
|
1203
|
+
// LEAN-4: past compact.at, the lead's context is compacted between turns, milestone or not.
|
|
1204
|
+
if (!fresh.length) return act.compactAtThreshold();
|
|
1163
1205
|
const mode = harness().compact?.milestone ?? 'ask';
|
|
1164
1206
|
if (mode === 'off' || offering || !where.interactive || (lease.other && !lease.mine)) {
|
|
1165
1207
|
for (const m of fresh) loop.milestones.add(m);
|
|
1166
|
-
return
|
|
1208
|
+
return act.compactAtThreshold();
|
|
1167
1209
|
}
|
|
1168
1210
|
const box = await $.prompt.read().catch(() => null);
|
|
1169
1211
|
if (box?.text?.trim()) return false;
|
|
@@ -1198,6 +1240,25 @@ export function register(on) {
|
|
|
1198
1240
|
});
|
|
1199
1241
|
return true;
|
|
1200
1242
|
},
|
|
1243
|
+
// LEAN-4: the lead's context compacted at once when it's past compact.at; true when it took over.
|
|
1244
|
+
compactAtThreshold: async () => {
|
|
1245
|
+
const at = harness().compact?.at ?? 40;
|
|
1246
|
+
if (!at || offering || !where.interactive || (lease.other && !lease.mine)) return false;
|
|
1247
|
+
const percent = (await $.session.usage().catch(() => null))?.context?.percent ?? 0;
|
|
1248
|
+
if (percent < Math.max(at, COMPACT_FLOOR)) return false;
|
|
1249
|
+
const box = await $.prompt.read().catch(() => null);
|
|
1250
|
+
if (box?.text?.trim()) return false;
|
|
1251
|
+
Promise.resolve()
|
|
1252
|
+
.then(() => $.session.compact({ instructions: compactInstructions() }))
|
|
1253
|
+
.catch((err) => ({ skip: String(err?.message ?? err) }))
|
|
1254
|
+
.then(async (r) => {
|
|
1255
|
+
if (r?.skip) notice = `The lead's context wasn't compacted: ${r.skip}`;
|
|
1256
|
+
await act.record('compact', `at ${Math.round(percent)}%`);
|
|
1257
|
+
$.ui.invalidate('ui.render');
|
|
1258
|
+
await act.step('resume').catch(() => {});
|
|
1259
|
+
});
|
|
1260
|
+
return true;
|
|
1261
|
+
},
|
|
1201
1262
|
// LOOP-11: the lead's last reply, read once before the loop prompts it again. A reply that waits on
|
|
1202
1263
|
// the person, or a turn they stopped, holds the loop until they speak; a step the lead didn't take
|
|
1203
1264
|
// holds that step while it's still next.
|
|
@@ -1236,10 +1297,16 @@ export function register(on) {
|
|
|
1236
1297
|
$.ui.invalidate('ui.render');
|
|
1237
1298
|
return;
|
|
1238
1299
|
}
|
|
1239
|
-
if (!reply.text.trim()) return;
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1300
|
+
if (!reply.text.trim()) return act.parkUnmoved(sent, null);
|
|
1301
|
+
// LEAN-6: the reply and the prompt it answered are enough to read it, so a small model reads
|
|
1302
|
+
// them, instead of a fork over the lead's whole conversation on its own model at every turn's end.
|
|
1303
|
+
const said = await $.model
|
|
1304
|
+
.complete({
|
|
1305
|
+
model: 'sonnet',
|
|
1306
|
+
prompt: outcomePrompt({ prompt: sent?.prompt ?? null, answer: reply.text }),
|
|
1307
|
+
maxTokens: 600,
|
|
1308
|
+
})
|
|
1309
|
+
.catch(() => ({ isAnswered: false }));
|
|
1243
1310
|
const heard = said.isAnswered ? readOutcome(said.text) : null;
|
|
1244
1311
|
// Spec 05's data: what came of the step, once known.
|
|
1245
1312
|
const record = sent ? loop.steps.findLast((x) => x.prompt === sent.prompt) : null;
|
|
@@ -1259,6 +1326,10 @@ export function register(on) {
|
|
|
1259
1326
|
})),
|
|
1260
1327
|
);
|
|
1261
1328
|
}
|
|
1329
|
+
// LOOP-13: a story the lead's turn left exactly where it stood wasn't taken, whatever the reply
|
|
1330
|
+
// reads as; it's parked like one the lead said waits, so the loop doesn't prompt it again until
|
|
1331
|
+
// it moves.
|
|
1332
|
+
if (heard?.outcome !== 'needs-person') await act.parkUnmoved(sent, heard?.reason);
|
|
1262
1333
|
if (heard?.outcome === 'needs-person')
|
|
1263
1334
|
loop.waiting = {
|
|
1264
1335
|
kind: 'person',
|
|
@@ -1310,6 +1381,28 @@ export function register(on) {
|
|
|
1310
1381
|
// DECIDE-4: the next decision the person settles with a choice, asked in Claude Code's own question
|
|
1311
1382
|
// dialog while the lead is idle. Each is asked once: dismissed, it stays in the band and the
|
|
1312
1383
|
// Decisions tab. Never in a session another one runs the loop for, nor over what the person types.
|
|
1384
|
+
// NOTIFY-1: what newly needs the person, as a system notification, from the session that runs the
|
|
1385
|
+
// loop. What waited before this session looked isn't news. On a Claude Code without $.ui.notify,
|
|
1386
|
+
// the call fails and nothing is raised.
|
|
1387
|
+
notifyNew: async () => {
|
|
1388
|
+
if (model.state?.kind !== 'ok' || !where.interactive) return;
|
|
1389
|
+
const cards = cardsNow(await $.clock.now());
|
|
1390
|
+
const fresh = newDecisions(cards, notified ?? new Set());
|
|
1391
|
+
const first = notified === null;
|
|
1392
|
+
notified = new Set(cards.map(decisionKey));
|
|
1393
|
+
if (first || !fresh.length || harness().notify?.on === false) return;
|
|
1394
|
+
if (lease.other && !lease.mine) return;
|
|
1395
|
+
const one =
|
|
1396
|
+
fresh.length === 1
|
|
1397
|
+
? fresh[0]
|
|
1398
|
+
: {
|
|
1399
|
+
title: `code-kit: ${fresh.length} things need you`,
|
|
1400
|
+
text: fresh.map((f) => f.text).join('; '),
|
|
1401
|
+
};
|
|
1402
|
+
await Promise.resolve()
|
|
1403
|
+
.then(() => $.ui.notify(one.text, { title: one.title }))
|
|
1404
|
+
.catch(() => {});
|
|
1405
|
+
},
|
|
1313
1406
|
offer: async () => {
|
|
1314
1407
|
if (offering || leadTurn || loop.sent || !where.interactive) return;
|
|
1315
1408
|
if (lease.other && !lease.mine) return;
|
|
@@ -1631,6 +1724,24 @@ export function register(on) {
|
|
|
1631
1724
|
((await $.agent.list().catch(() => [])) ?? []).find(
|
|
1632
1725
|
(a) => a.status === 'running' && a.type === lane.agent,
|
|
1633
1726
|
) ?? null,
|
|
1727
|
+
// WATCH-1: the person's press puts the lane agent's transcript in the main view, as the tasks list
|
|
1728
|
+
// does; the tasks list (or Escape there) brings the main conversation back.
|
|
1729
|
+
watchLane: async (lane) => {
|
|
1730
|
+
const agent = await act.runningAgentOf(lane);
|
|
1731
|
+
if (!agent) {
|
|
1732
|
+
notice = `No agent is at work in the ${lane.name} lane to watch.`;
|
|
1733
|
+
$.ui.invalidate('ui.render');
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
const r = await Promise.resolve()
|
|
1737
|
+
.then(() => $.ui.view({ agentId: agent.id }))
|
|
1738
|
+
.catch(() => ({
|
|
1739
|
+
deny: 'this Claude Code shows an agent only from its tasks list (2.1.301 or later shows it from here)',
|
|
1740
|
+
}));
|
|
1741
|
+
notice = r?.deny ? `Couldn't show the ${lane.name} lane's agent: ${r.deny}.` : null;
|
|
1742
|
+
if (!r?.deny) await act.record('watch', `${lane.name} · ${agent.type}`);
|
|
1743
|
+
$.ui.invalidate('ui.render');
|
|
1744
|
+
},
|
|
1634
1745
|
nudgeLane: async (lane) => {
|
|
1635
1746
|
const agent = await act.runningAgentOf(lane);
|
|
1636
1747
|
if (!agent) return;
|
|
@@ -1737,6 +1848,7 @@ export function register(on) {
|
|
|
1737
1848
|
return act.review({ story: { ...lane.story, branch: lane.branch } });
|
|
1738
1849
|
if (id === 'merge') return act.mergeQueue({ branch: lane.branch });
|
|
1739
1850
|
if (id === 'sendBack') return act.sendBack(lane);
|
|
1851
|
+
if (id === 'watch') return act.watchLane(lane);
|
|
1740
1852
|
if (id === 'nudge') return act.nudgeLane(lane);
|
|
1741
1853
|
if (id === 'stop') return act.stopLane(lane);
|
|
1742
1854
|
},
|
|
@@ -1758,6 +1870,7 @@ export function register(on) {
|
|
|
1758
1870
|
// LOOP-10: the lead session renews its lease; DECIDE-4: with the lead idle, a decision waiting.
|
|
1759
1871
|
if (lease.mine) await act.lead();
|
|
1760
1872
|
if (!leadTurn) act.offer().catch(() => {});
|
|
1873
|
+
await act.notifyNew().catch(() => {});
|
|
1761
1874
|
await act.stalls();
|
|
1762
1875
|
await act.animate();
|
|
1763
1876
|
const now = await act.fingerprint();
|
|
@@ -1954,6 +2067,16 @@ export function register(on) {
|
|
|
1954
2067
|
// SET-4: a lane agent starts on the lane model the person set, unless the lead's call names one or
|
|
1955
2068
|
// the lane's agent file sets its own.
|
|
1956
2069
|
on('agent.spawn', async ($, e, next) => {
|
|
2070
|
+
// LEAN-7: the security reviewer runs on the reviewers' model, unless the call or its file names one.
|
|
2071
|
+
const reviewerModel = harness().agents.reviewer.model;
|
|
2072
|
+
if (e.subagentType === 'security-reviewer' && reviewerModel && !e.model) {
|
|
2073
|
+
const file = where
|
|
2074
|
+
? await $.fs.read(`${where.cwd}/.claude/agents/security-reviewer.md`).catch(() => null)
|
|
2075
|
+
: null;
|
|
2076
|
+
const own = typeof file === 'string' ? agentFileModel(file) : null;
|
|
2077
|
+
if (!own || own === 'inherit') return next({ ...e, model: reviewerModel });
|
|
2078
|
+
return next(e);
|
|
2079
|
+
}
|
|
1957
2080
|
const setting = harness().agents.lanes.model;
|
|
1958
2081
|
const lanes = model.check?.lanes ?? {};
|
|
1959
2082
|
if (!setting || e.model || !Object.values(lanes).some((l) => l.agent === e.subagentType))
|
|
@@ -2011,7 +2134,25 @@ export function register(on) {
|
|
|
2011
2134
|
const res = await next(e);
|
|
2012
2135
|
// LOOP-3: a lane agent that has handed back its work isn't stalled while it waits; a message that
|
|
2013
2136
|
// starts it again shows as its next tool call.
|
|
2014
|
-
if (e.agentId)
|
|
2137
|
+
if (e.agentId) {
|
|
2138
|
+
const a = activityOf(e.agentId, await $.clock.now());
|
|
2139
|
+
a.handedBack = true;
|
|
2140
|
+
// LEAN-5: an agent that handed back at its limit: the lead carries its story on with a fresh one.
|
|
2141
|
+
if (a.overflow && !a.continued && act) {
|
|
2142
|
+
a.continued = true;
|
|
2143
|
+
const agent = agentsSeen.get(e.agentId);
|
|
2144
|
+
const story = (model.state?.stories ?? []).find((s) => s.id === a.story);
|
|
2145
|
+
loop.pending.push({
|
|
2146
|
+
story: a.story,
|
|
2147
|
+
prompt: continuePrompt({
|
|
2148
|
+
type: agent?.type ?? 'The lane agent',
|
|
2149
|
+
story: a.story,
|
|
2150
|
+
branch: story?.branch ?? null,
|
|
2151
|
+
cli: where?.cli,
|
|
2152
|
+
}),
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2015
2156
|
// REVW-3: a subagent's last turn may be the reviewer's report.
|
|
2016
2157
|
if (e.agentId && act) await act.reviewed(e.agentId, e.answer).catch(() => {});
|
|
2017
2158
|
// LOOP-1: the lead is idle; the loop's next step, if one is due (submitted from turn.complete,
|