@warren-dean/code-kit 0.7.0 → 0.8.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.
@@ -0,0 +1,328 @@
1
+ // The code map (docs/specs/14-mission-and-maps.md, MAP-1 to MAP-4), the pure parts: what an agent's
2
+ // tool call touches, which files glow and for whom, the map laid out by layer, and the drawing of it:
3
+ // box-drawing text for the terminal, an SVG elsewhere. Its data is `code-kit map <story> --json`.
4
+
5
+ /** Calls that read a file, and calls that change one. */
6
+ const READS = new Set(['Read', 'NotebookRead']);
7
+ const EDITS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
8
+
9
+ /**
10
+ * MAP-2: the repository path a tool call reads or edits, and which: { path, verb } or null. Paths in
11
+ * an agent's worktree (`.claude/worktrees/<id>/…`) are the same repository path.
12
+ */
13
+ export function touchOf(call, cwd = '') {
14
+ const verb = READS.has(call?.tool) ? 'read' : EDITS.has(call?.tool) ? 'edit' : null;
15
+ const raw = call?.file_path ?? call?.notebook_path ?? call?.path;
16
+ if (!verb || typeof raw !== 'string') return null;
17
+ let path = raw;
18
+ const tree = path.match(/\/\.claude\/worktrees\/[^/]+\/(.+)$/);
19
+ if (tree) path = tree[1];
20
+ else if (cwd && path.startsWith(`${cwd}/`)) path = path.slice(cwd.length + 1);
21
+ return path.startsWith('/') ? null : { path, verb };
22
+ }
23
+
24
+ /** How long a touched file glows after the call. */
25
+ export const GLOW_MS = 20000;
26
+
27
+ /** MAP-2: the files glowing now: { [path]: { lane, verb } }, the latest touch of each winning. */
28
+ export function glowOf(touches, now, windowMs = GLOW_MS) {
29
+ const out = {};
30
+ for (const t of [...touches].sort((a, b) => a.at - b.at))
31
+ if (now - t.at <= windowMs) out[t.path] = { lane: t.lane, verb: t.verb };
32
+ return out;
33
+ }
34
+
35
+ /** A file's card as another tool knows it: current, stale or missing, or null when none says. */
36
+ export function cardOf(file) {
37
+ for (const facts of Object.values(file.adapters ?? {})) if (facts?.card) return facts.card;
38
+ return null;
39
+ }
40
+
41
+ /** The name a node shows: the file's name, and its folder when another file shares the name. */
42
+ const nameOf = (path, all) => {
43
+ const base = path.split('/').pop();
44
+ const twins = all.filter((p) => p.split('/').pop() === base);
45
+ if (twins.length < 2) return base;
46
+ const parts = path.split('/');
47
+ return parts.slice(-2).join('/');
48
+ };
49
+
50
+ const NODE_H = 4;
51
+ const ROW_GAP = 1;
52
+ const COL_GAP = 6;
53
+ const GUTTER = 3;
54
+
55
+ /**
56
+ * MAP-1: the map laid out in character cells: a column per layer (in the map's order), its files
57
+ * stacked; { columns: [{ layer, x, width }], nodes: { [path]: { x, y, w, h, name, layer } }, width,
58
+ * height }. `width` is the room there is; columns share it, each between 16 and 30 cells.
59
+ */
60
+ export function layout(map, width = 100) {
61
+ const layers = map.layers.filter((l) => map.files.some((f) => f.layer === l));
62
+ const n = Math.max(1, layers.length);
63
+ const colW = Math.max(16, Math.min(30, Math.floor((width - COL_GAP * (n - 1)) / n)));
64
+ const all = map.files.map((f) => f.path);
65
+ // A gutter of GUTTER cells left of each column carries the imports between files of one layer.
66
+ const columns = layers.map((layer, i) => ({
67
+ layer,
68
+ x: GUTTER + i * (colW + COL_GAP),
69
+ width: colW,
70
+ }));
71
+ const nodes = {};
72
+ let height = 2;
73
+ for (const c of columns) {
74
+ const files = map.files.filter((f) => f.layer === c.layer);
75
+ files.forEach((f, j) => {
76
+ const y = 2 + j * (NODE_H + ROW_GAP);
77
+ nodes[f.path] = { x: c.x, y, w: colW, h: NODE_H, name: nameOf(f.path, all), layer: c.layer };
78
+ height = Math.max(height, y + NODE_H);
79
+ });
80
+ }
81
+ return { columns, nodes, width: columns.length ? columns.at(-1).x + colW : 0, height };
82
+ }
83
+
84
+ /** The words under a node's name: what's happening to it, or its card. */
85
+ function statusOf(path, file, glow) {
86
+ const g = glow[path];
87
+ if (g)
88
+ return {
89
+ text: `${g.lane} ${g.verb === 'edit' ? 'editing' : 'reading'}`,
90
+ style: g.verb === 'edit' ? 'editing' : 'reading',
91
+ lane: g.lane,
92
+ };
93
+ const card = cardOf(file);
94
+ if (!card) return { text: '', style: 'dim' };
95
+ // A card is the file's why: what it's for, what it relies on, what it must keep true.
96
+ if (card.state === 'current') return { text: '✓ why written', style: 'ok', card: 'current' };
97
+ if (card.state === 'stale') return { text: 'why out of date', style: 'warn', card: 'stale' };
98
+ return { text: 'no why yet', style: 'warn', card: 'missing' };
99
+ }
100
+
101
+ const fit = (s, n) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s);
102
+
103
+ /**
104
+ * MAP-1 to MAP-3 in the terminal: the map as rows of styled runs, [[{ text, style, lane? }]]. Styles:
105
+ * dim (layer names, edges), node, selected, reading and editing (a glowing box's border, with the
106
+ * lane), fill-read and fill-edit (its inside, filled with the lane's colour), ok, warn. Edges are
107
+ * drawn between neighbouring columns; the others are named in the selected file's detail.
108
+ */
109
+ export function mapRows(map, { glow = {}, selected = null, width = 100 } = {}) {
110
+ const L = layout(map, width);
111
+ if (!L.columns.length) return [];
112
+ const W = L.width;
113
+ const grid = Array.from({ length: L.height }, () =>
114
+ Array.from({ length: W }, () => ({ ch: ' ', style: 'dim' })),
115
+ );
116
+ const put = (x, y, ch, style, lane) => {
117
+ if (y < 0 || y >= L.height || x < 0 || x >= W) return;
118
+ grid[y][x] = { ch, style, ...(lane ? { lane } : {}) };
119
+ };
120
+ for (const c of L.columns) {
121
+ const label = fit(c.layer, c.width);
122
+ [...label].forEach((ch, i) => put(c.x + i, 0, ch, 'dim'));
123
+ }
124
+ // Edges first, so the boxes draw over their ends: from the importer's side to the imported's.
125
+ const byPath = Object.fromEntries(map.files.map((f) => [f.path, f]));
126
+ const colOf = (p) => L.columns.findIndex((c) => c.layer === L.nodes[p].layer);
127
+ const joints = new Map();
128
+ const line = (x, y, dir) => {
129
+ const key = `${x},${y}`;
130
+ const had = joints.get(key) ?? new Set();
131
+ for (const d of dir) had.add(d);
132
+ joints.set(key, had);
133
+ };
134
+ for (const f of map.files)
135
+ for (const target of f.imports) {
136
+ if (!L.nodes[target]) continue;
137
+ const a = L.nodes[f.path];
138
+ const b = L.nodes[target];
139
+ const ca = colOf(f.path);
140
+ const cb = colOf(target);
141
+ // MAP-1: within one layer, a bracket in the column's gutter joins the two files.
142
+ if (ca === cb) {
143
+ const x = a.x - 2;
144
+ const ya = a.y + 1;
145
+ const yb = b.y + 1;
146
+ line(a.x - 1, ya, 'we');
147
+ line(x, ya, yb > ya ? 'es' : 'ne');
148
+ for (let y = Math.min(ya, yb) + 1; y < Math.max(ya, yb); y++) line(x, y, 'ns');
149
+ line(x, yb, yb > ya ? 'ne' : 'es');
150
+ line(b.x - 1, yb, 'we');
151
+ continue;
152
+ }
153
+ if (Math.abs(ca - cb) !== 1) continue;
154
+ const left = ca < cb ? a : b;
155
+ const right = ca < cb ? b : a;
156
+ const ya = left.y + 1;
157
+ const yb = right.y + 1;
158
+ const x0 = left.x + left.w;
159
+ const x1 = right.x - 1;
160
+ const mid = x0 + Math.floor((x1 - x0) / 2);
161
+ for (let x = x0; x <= mid; x++)
162
+ line(x, ya, x === mid ? (yb === ya ? 'we' : yb > ya ? 'ws' : 'wn') : 'we');
163
+ for (let y = Math.min(ya, yb) + 1; y < Math.max(ya, yb); y++) line(mid, y, 'ns');
164
+ if (yb !== ya) line(mid, yb, yb > ya ? 'ne' : 'se');
165
+ for (let x = mid + 1; x <= x1; x++) line(x, yb, 'we');
166
+ }
167
+ const JOINT = {
168
+ we: '─',
169
+ ns: '│',
170
+ ne: '└',
171
+ es: '┌',
172
+ sw: '┐',
173
+ nw: '┘',
174
+ nes: '├',
175
+ nsw: '┤',
176
+ esw: '┬',
177
+ new: '┴',
178
+ nesw: '┼',
179
+ };
180
+ for (const [key, dirs] of joints) {
181
+ const [x, y] = key.split(',').map(Number);
182
+ const k = ['n', 'e', 's', 'w'].filter((d) => dirs.has(d)).join('');
183
+ put(x, y, JOINT[k] ?? '─', 'dim');
184
+ }
185
+ for (const [path, node] of Object.entries(L.nodes)) {
186
+ const file = byPath[path];
187
+ const status = statusOf(path, file, glow);
188
+ const box = path === selected ? 'selected' : glow[path] ? status.style : 'node';
189
+ const lane = glow[path]?.lane;
190
+ const { x, y, w } = node;
191
+ const edge =
192
+ glow[path]?.verb === 'edit' ? ['┏', '┓', '┗', '┛', '━', '┃'] : ['╭', '╮', '╰', '╯', '─', '│'];
193
+ put(x, y, edge[0], box, lane);
194
+ put(x + w - 1, y, edge[1], box, lane);
195
+ put(x, y + 3, edge[2], box, lane);
196
+ put(x + w - 1, y + 3, edge[3], box, lane);
197
+ for (let i = 1; i < w - 1; i++) {
198
+ put(x + i, y, edge[4], box, lane);
199
+ put(x + i, y + 3, edge[4], box, lane);
200
+ }
201
+ // MAP-2: a glowing file fills with its lane's colour, its name and status written over it.
202
+ const fill = lane ? `fill-${glow[path].verb}` : null;
203
+ for (const row of [1, 2]) {
204
+ put(x, y + row, edge[5], box, lane);
205
+ put(x + w - 1, y + row, edge[5], box, lane);
206
+ for (let i = 1; i < w - 1; i++) put(x + i, y + row, ' ', fill ?? 'node', lane);
207
+ }
208
+ [...fit(node.name, w - 4)].forEach((ch, i) =>
209
+ put(x + 2 + i, y + 1, ch, fill ?? (path === selected ? 'selected' : 'node'), lane),
210
+ );
211
+ [...fit(status.text, w - 4)].forEach((ch, i) =>
212
+ put(x + 2 + i, y + 2, ch, fill ?? status.style, status.lane ?? lane),
213
+ );
214
+ }
215
+ // Runs: neighbouring cells of one style (and lane) as one piece of text.
216
+ return grid.map((row) => {
217
+ const runs = [];
218
+ for (const cell of row) {
219
+ const last = runs.at(-1);
220
+ if (last && last.style === cell.style && last.lane === cell.lane) last.text += cell.ch;
221
+ else
222
+ runs.push({ text: cell.ch, style: cell.style, ...(cell.lane ? { lane: cell.lane } : {}) });
223
+ }
224
+ return runs;
225
+ });
226
+ }
227
+
228
+ const esc = (s) =>
229
+ String(s).replace(
230
+ /[&<>"]/g,
231
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c],
232
+ );
233
+
234
+ /**
235
+ * MAP-1 to MAP-3 on the remote surfaces: the map as an SVG, every import drawn, glowing files pulsing
236
+ * in their lane's colour. `tints` gives each lane's colour; `selected` is outlined.
237
+ */
238
+ export function mapSvg(map, { glow = {}, selected = null, tints = {}, width = 100 } = {}) {
239
+ const L = layout(map, width);
240
+ const CW = 7.2;
241
+ const RH = 16;
242
+ const W = Math.max(200, Math.round(L.width * CW) + 20);
243
+ const H = Math.round(L.height * RH) + 20;
244
+ const box = (p) => {
245
+ const n = L.nodes[p];
246
+ return { x: 10 + n.x * CW, y: 10 + n.y * RH, w: n.w * CW, h: 3 * RH };
247
+ };
248
+ const parts = [];
249
+ for (const c of L.columns)
250
+ parts.push(
251
+ `<text x="${10 + c.x * CW}" y="${10 + RH * 0.8}" fill="#7f8c8d" font-size="11">${esc(c.layer)}</text>`,
252
+ );
253
+ for (const f of map.files)
254
+ for (const target of f.imports) {
255
+ if (!L.nodes[target]) continue;
256
+ const a = box(f.path);
257
+ const b = box(target);
258
+ const fromRight = a.x > b.x;
259
+ const x1 = fromRight ? a.x : a.x + a.w;
260
+ const x2 = fromRight ? b.x + b.w : b.x;
261
+ const y1 = a.y + a.h / 2;
262
+ const y2 = b.y + b.h / 2;
263
+ const mx = (x1 + x2) / 2;
264
+ const live = glow[f.path] || glow[target];
265
+ const color = live ? (tints[(glow[f.path] ?? glow[target]).lane] ?? '#5fb3b3') : '#3b4c50';
266
+ parts.push(
267
+ `<path d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}" fill="none" stroke="${color}" stroke-width="${live ? 1.6 : 1}"/>`,
268
+ );
269
+ }
270
+ const byPath = Object.fromEntries(map.files.map((f) => [f.path, f]));
271
+ for (const [path, node] of Object.entries(L.nodes)) {
272
+ const b = box(path);
273
+ const status = statusOf(path, byPath[path], glow);
274
+ const g = glow[path];
275
+ const tint = g ? (tints[g.lane] ?? '#5fb3b3') : null;
276
+ if (tint)
277
+ parts.push(
278
+ `<rect x="${b.x - 4}" y="${b.y - 4}" width="${b.w + 8}" height="${b.h + 8}" rx="6" fill="${tint}" opacity="${g.verb === 'edit' ? 0.4 : 0.22}"><animate attributeName="opacity" values="${g.verb === 'edit' ? '0.4;0.15;0.4' : '0.22;0.08;0.22'}" dur="1.2s" repeatCount="indefinite"/></rect>`,
279
+ );
280
+ const stroke =
281
+ path === selected
282
+ ? '#8fb8ff'
283
+ : status.style === 'ok'
284
+ ? '#78c98a'
285
+ : status.style === 'warn'
286
+ ? '#e3b450'
287
+ : '#3b4c50';
288
+ parts.push(
289
+ `<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="4" fill="${path === selected ? '#1d3a52' : '#0f1618'}" stroke="${stroke}"${status.card === 'missing' ? ' stroke-dasharray="4 3"' : ''}/>`,
290
+ );
291
+ parts.push(
292
+ `<text x="${b.x + 8}" y="${b.y + 18}" fill="#cfd9da" font-size="12">${esc(fit(node.name, node.w - 2))}</text>`,
293
+ );
294
+ const color =
295
+ tint ?? (status.style === 'ok' ? '#78c98a' : status.style === 'warn' ? '#e3b450' : '#6e8286');
296
+ parts.push(
297
+ `<text x="${b.x + 8}" y="${b.y + 36}" fill="${color}" font-size="11">${esc(status.text)}</text>`,
298
+ );
299
+ }
300
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="ui-monospace, Menlo, monospace">${parts.join('')}</svg>`;
301
+ }
302
+
303
+ /** MAP-3: the selected file's neighbours in the story: what it imports and what imports it. */
304
+ export function neighboursOf(map, path) {
305
+ const file = map.files.find((f) => f.path === path);
306
+ return {
307
+ imports: file?.imports ?? [],
308
+ importers: map.files.filter((f) => f.imports.includes(path)).map((f) => f.path),
309
+ };
310
+ }
311
+
312
+ /** The story the code map opens on: the one asked for, else the selected lane's, else one at work. */
313
+ export function mapStoryOf(state, selectedLane, asked = null) {
314
+ if (asked) return asked;
315
+ const lane = state?.lanes?.find((l) => l.name === selectedLane);
316
+ if (lane?.story) return lane.story.id;
317
+ const busy = state?.lanes?.find((l) => l.story && ['building', 'in review'].includes(l.state));
318
+ return busy?.story.id ?? state?.stories?.find((s) => s.state !== 'done')?.id ?? null;
319
+ }
320
+
321
+ /** MAP-2, MAP-3: the map's legend: what the colours and words mean, a card being the file's why. */
322
+ export const MAP_LEGEND = [
323
+ { text: '■ a lane at work', style: 'glow' },
324
+ { text: '✓ why written', style: 'ok' },
325
+ { text: 'why out of date', style: 'warn' },
326
+ { text: 'no why yet', style: 'warn' },
327
+ { text: "a file's why is its card, kept in git by Context Graph", style: 'dim' },
328
+ ];
@@ -0,0 +1,146 @@
1
+ // Decisions (docs/specs/15-decisions-and-journal.md, DECIDE-2 and DECIDE-3), the pure parts: what reaches
2
+ // the person, each with its lineage. The band's "you" lines say what needs them; a decision card says why:
3
+ // what they asked for, the story and requirements it belongs to, what the agent was doing, what was already
4
+ // settled, why the lead couldn't settle this itself, and each option with what happens next.
5
+
6
+ /** The kinds of band line that are the person's to decide; the rest are notes. */
7
+ export const DECISION_KINDS = new Set([
8
+ 'held',
9
+ 'approvals',
10
+ 'stalled',
11
+ 'proposal',
12
+ 'queue',
13
+ 'review',
14
+ 'notice',
15
+ ]);
16
+
17
+ /** What each answer does, in the person's words. */
18
+ const EFFECT = {
19
+ approveHeld: 'The held call runs now, and the agent carries on.',
20
+ refuseHeld: 'The agent is told no and has to find another way; the lead hears why.',
21
+ approve: "You confirm it with your reason; it's logged, and the agent can go ahead.",
22
+ resumeStall: 'The lead dispatches the story again, with the same brief, on the same branch.',
23
+ lanes: 'Opens the Lanes pane, to look before deciding.',
24
+ go: 'The lead is asked to take this step now.',
25
+ keepGoing: 'The build keeps moving between your prompts. You can pause it any time.',
26
+ askEach: 'Each step waits here for your Go.',
27
+ loopOff: 'Nothing happens between your prompts; you drive the build.',
28
+ mergeQueue: "It's verified against main as it is now and merged; after that the lead merges.",
29
+ review: 'The lead reviews it against its requirements now.',
30
+ merge: "It's verified and merged now, by you.",
31
+ dismiss: 'Clears this message.',
32
+ };
33
+
34
+ /** Why a line needs the person, when the line itself doesn't say. */
35
+ function whyOf(line, check) {
36
+ if (line.kind === 'held') {
37
+ const c = line.held;
38
+ const rule = c.refusal ? firstSentence(c.refusal) : 'It needs an approval only you can give.';
39
+ const limits = limitsWhy(c.outside);
40
+ return limits ? `${rule} ${limits}` : rule;
41
+ }
42
+ if (line.kind === 'approvals')
43
+ return line.request?.why ?? 'It needs an approval only you can give.';
44
+ if (line.kind === 'stalled')
45
+ return `The lead restarted it ${line.stall.count - 1} time${line.stall.count === 2 ? '' : 's'} and it stalled again, so it stopped trying.`;
46
+ if (line.kind === 'proposal' && line.proposal?.first)
47
+ return "You haven't chosen yet how much the lead does between your prompts.";
48
+ if (line.kind === 'proposal' && line.proposal?.repeated)
49
+ return `The lead was asked to do this ${line.proposal.repeated} times and it's still the next step, so something stopped it.`;
50
+ if (line.kind === 'proposal') return 'You asked to approve each step before the lead takes it.';
51
+ if (line.kind === 'queue')
52
+ return check?.firstMerge
53
+ ? 'The loop is off, so merging is yours. main has no rules committed yet: code-kit queue rules puts them there.'
54
+ : "Your rules don't let the lead merge into main, so merges are yours.";
55
+ if (line.kind === 'review') return 'The loop is off, so reviewing finished work is yours.';
56
+ return null;
57
+ }
58
+
59
+ /** A refusal's first sentence: what the rule is, without the instructions meant for the agent. */
60
+ const firstSentence = (text) => text.match(/^.*?[.!?](?=\s|$)/)?.[0] ?? text;
61
+
62
+ /**
63
+ * Why the lead couldn't approve it itself, from `code-kit approve --delegated`'s refusal: nothing
64
+ * delegated, or the delegated rules each name it missed ("dep-x: fewer than 1000 weekly downloads").
65
+ */
66
+ export function limitsWhy(outside) {
67
+ if (!outside) return null;
68
+ if (/delegates no approvals/.test(outside))
69
+ return "You haven't let the lead approve these itself.";
70
+ const misses = outside
71
+ .split('\n')
72
+ .filter((l) => /^\s{2}\S/.test(l))
73
+ .map((l) => l.trim());
74
+ return misses.length
75
+ ? `It's outside the limits you set for the lead: ${misses.join('; ')}.`
76
+ : null;
77
+ }
78
+
79
+ /** The story a line belongs to: the held call's or request's lane's story, the stalled story, the queued branch's. */
80
+ function storyOf(line, state) {
81
+ const stories = state?.stories ?? [];
82
+ const lane = line.held?.lane ?? line.request?.lane ?? line.stall?.lane ?? null;
83
+ if (line.story) return stories.find((s) => s.id === line.story.id) ?? line.story;
84
+ if (line.stall?.story) return stories.find((s) => s.id === line.stall.story) ?? null;
85
+ if (line.branch) return stories.find((s) => s.branch === line.branch) ?? null;
86
+ if (lane) {
87
+ const row = state?.lanes?.find((l) => l.name === lane);
88
+ if (row?.story) return stories.find((s) => s.id === row.story.id) ?? row.story;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * DECIDE-2: a card for each line that needs the person, in the band's order: { id, kind, title, asked,
95
+ * story, requirements, doing, settled, why, options }. `context`: { state, status (for requirement
96
+ * titles), check, goal (the milestone), request (what the person asked for), journal (events),
97
+ * inForce (approvals in force) }.
98
+ */
99
+ export function decisionCards(lines, context = {}) {
100
+ const { state, status, check, goal = null, request = null, journal = [], inForce = [] } = context;
101
+ const titles = new Map((status?.requirements ?? []).map((r) => [r.id, r.title]));
102
+ return lines
103
+ .filter((l) => DECISION_KINDS.has(l.kind) && !l.mission)
104
+ .map((line, i) => {
105
+ const story = storyOf(line, state);
106
+ const lane = line.held?.lane ?? line.request?.lane ?? line.stall?.lane ?? story?.lane ?? null;
107
+ const settled = [
108
+ ...journal.filter((e) => story && e.story === story.id).map((e) => e.text),
109
+ ...inForce
110
+ .filter((a) => !lane || !a.lane || a.lane === lane)
111
+ .slice(0, 3)
112
+ .map(
113
+ (a) =>
114
+ `${a.name} is approved${a.lane ? ` for the ${a.lane} lane` : ''}${a.reason ? `: ${a.reason}` : ''}`,
115
+ ),
116
+ ].slice(-5);
117
+ return {
118
+ id: `${line.kind}-${i}`,
119
+ kind: line.kind,
120
+ line,
121
+ title: line.text,
122
+ asked: [request, goal].filter(Boolean).join(' · ') || null,
123
+ story: story
124
+ ? { id: story.id, title: story.title ?? '', branch: story.branch ?? null }
125
+ : null,
126
+ requirements: (story?.requirements ?? []).map((id) => ({
127
+ id,
128
+ title: titles.get(id) ?? '',
129
+ })),
130
+ doing:
131
+ line.held?.command ??
132
+ line.request?.what ??
133
+ (line.proposal?.label ? `Next step: ${line.proposal.label}` : null),
134
+ settled,
135
+ why: whyOf(line, check),
136
+ options: line.actions.map((a) => ({ ...a, effect: EFFECT[a.id] ?? null })),
137
+ };
138
+ });
139
+ }
140
+
141
+ /** The band's short context for a decision line: "for ST-23 The original panel (ORIG-3)". */
142
+ export function contextOf(card) {
143
+ if (!card.story) return null;
144
+ const reqs = card.requirements.map((r) => r.id).join(', ');
145
+ return `for ${card.story.id} ${card.story.title}${reqs ? ` (${reqs})` : ''}`;
146
+ }
@@ -3,7 +3,7 @@
3
3
  // existing skills, and the hooks judge every call as before.
4
4
 
5
5
  /** The steps the loop hands the lead; the rest (spec-design, init, check) wait for the person. */
6
- const LEAD_STEPS = new Set(['dispatch', 'review', 'lead', 'merge']);
6
+ const LEAD_STEPS = new Set(['dispatch', 'review', 'lead', 'merge', 'rules']);
7
7
 
8
8
  /**
9
9
  * The prompt for a step from `code-kit next --json` (LOOP-1), or null when it isn't the lead's to
@@ -19,6 +19,9 @@ export function leadPrompt(step, { cli = 'code-kit' } = {}) {
19
19
  return ids.length
20
20
  ? `Merge ${ids[0]}, next in the merge queue: run node "${cli}" queue merge --delegated. It verifies the branch against the base as it is now, and sends it back to its lane if it conflicts or fails.`
21
21
  : null;
22
+ // SETUP-2: the base has no rules yet; the lead puts the approved ones there, then merges as usual.
23
+ if (step.step === 'rules')
24
+ 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.`;
22
25
  if (step.step === 'dispatch')
23
26
  return ids.length
24
27
  ? `Dispatch ${ids.join(', ')}: run /code-kit:dispatch ${ids.join(' ')}.`
@@ -40,8 +43,9 @@ export const stepKey = (step) => `${step.step}:${step.args ?? ''}`;
40
43
  export function stepLabel(step) {
41
44
  const ids = (step.args ?? '').split(/\s+/).filter(Boolean).join(', ');
42
45
  const verb =
43
- { dispatch: 'Dispatch', review: 'Review', lead: 'Build', merge: 'Merge' }[step.step] ??
44
- step.step;
46
+ { dispatch: 'Dispatch', review: 'Review', lead: 'Build', merge: 'Merge', rules: 'Rules onto' }[
47
+ step.step
48
+ ] ?? step.step;
45
49
  return ids ? `${verb} ${ids}` : verb;
46
50
  }
47
51
 
@@ -0,0 +1,116 @@
1
+ // The mission band (docs/specs/14-mission-and-maps.md, MISSION-1 to MISSION-3), the pure parts: the
2
+ // goal, what the lead is doing now, the loop's next step, each agent at work with its tool, and a line
3
+ // from another tool only when it has something to say. The band draws these above what needs the person.
4
+ import { stepLabel } from './loop.mjs';
5
+
6
+ /**
7
+ * MISSION-1: the milestone being built: the plan's first milestone with a story not done (else its
8
+ * last), with how many of its stories are done: { name, done, total }, or null when the plan has none.
9
+ */
10
+ export function goalOf(stories = []) {
11
+ const named = stories.filter((s) => s.milestone);
12
+ if (!named.length) return null;
13
+ const open = named.find((s) => s.state !== 'done') ?? named.at(-1);
14
+ const mine = named.filter((s) => s.milestone === open.milestone);
15
+ return {
16
+ name: open.milestone,
17
+ done: mine.filter((s) => s.state === 'done').length,
18
+ total: mine.length,
19
+ };
20
+ }
21
+
22
+ /** The words for the loop's next step, from `code-kit next --json`. */
23
+ export function nextText(step) {
24
+ if (!step) return null;
25
+ if (step.step === 'done') return 'nothing: every story is done';
26
+ if (step.step === 'wait') return `waiting: ${step.why}`;
27
+ const then = (step.then ?? []).map((t) => stepLabel(t));
28
+ const label = ['spec-design', 'init', 'check'].includes(step.step)
29
+ ? `${step.step} (yours to run)`
30
+ : stepLabel(step);
31
+ return `${label}${then.length ? `, then ${then.join(', ')}` : ''}`;
32
+ }
33
+
34
+ /** At most this many agents get a line of their own; the rest are counted. */
35
+ const AGENT_LINES = 5;
36
+
37
+ /**
38
+ * MISSION-1 to MISSION-3: the band's mission rows, or none while nothing is happening (no lead turn,
39
+ * no agent at work, the loop not started). Each row is { kind, mission: true, label, text, ... }:
40
+ * goal; now (with Pause or Resume while the loop runs); next; agents, with one entry per lane at work
41
+ * ({ lane, tool, ago, level }); and a line per adapter fact worth the person's attention.
42
+ */
43
+ export function missionLines({
44
+ state,
45
+ loop = null,
46
+ leadBusy = false,
47
+ live = {},
48
+ nextStep = null,
49
+ combined = null,
50
+ }) {
51
+ if (state?.kind !== 'ok') return [];
52
+ const working = state.lanes.filter((l) => l.name !== 'lead' && l.active && live[l.name]);
53
+ const loopOn = Boolean(loop?.started) && !['off', 'stopped'].includes(loop?.state);
54
+ if (!leadBusy && !working.length && !loopOn) return [];
55
+ const rows = [];
56
+ const goal = goalOf(state.stories);
57
+ if (goal)
58
+ rows.push({
59
+ kind: 'goal',
60
+ label: 'goal',
61
+ text: `${goal.name} · ${goal.done} of ${goal.total} ${goal.total === 1 ? 'story' : 'stories'} done`,
62
+ });
63
+ const lead = leadBusy
64
+ ? `lead ${live.lead?.tool ? `at work: ${live.lead.tool}` : 'at work'}`
65
+ : 'lead idle';
66
+ const loopWord =
67
+ loop?.state === 'paused'
68
+ ? 'loop paused'
69
+ : loop?.state === 'off'
70
+ ? 'loop off'
71
+ : loop?.state === 'done'
72
+ ? 'milestone done'
73
+ : loopOn
74
+ ? `loop ${loop.autonomy === 'propose' ? 'proposing' : 'on'}${loop.last ? ` · last: ${loop.last}` : ''}`
75
+ : null;
76
+ rows.push({
77
+ kind: 'now',
78
+ label: 'now',
79
+ text: [lead, loopWord].filter(Boolean).join(' · '),
80
+ actions:
81
+ loop?.state === 'paused'
82
+ ? [{ id: 'resume', label: 'Resume' }]
83
+ : loopOn && loop?.state === 'running'
84
+ ? [{ id: 'pause', label: 'Pause' }]
85
+ : [],
86
+ });
87
+ const next = nextText(nextStep);
88
+ if (next && loop?.state !== 'done') rows.push({ kind: 'next', label: 'next', text: next });
89
+ if (working.length)
90
+ rows.push({
91
+ kind: 'agents',
92
+ label: 'agents',
93
+ text: `${working.length} at work`,
94
+ agents: working.slice(0, AGENT_LINES).map((l) => ({
95
+ lane: l.name,
96
+ tool: live[l.name].tool,
97
+ ago: live[l.name].ago,
98
+ level: live[l.name].level ?? null,
99
+ })),
100
+ more: Math.max(0, working.length - AGENT_LINES),
101
+ });
102
+ // MISSION-3: another tool's line only when it isn't zero: edits made before the file was understood.
103
+ const unread = Object.entries(combined ?? {}).filter(([, f]) => f.without > 0);
104
+ if (unread.length)
105
+ rows.push({
106
+ kind: 'facts',
107
+ label: 'context',
108
+ text: unread
109
+ .map(
110
+ ([lane, f]) =>
111
+ `${lane} edited ${f.without} file${f.without === 1 ? '' : 's'} without understanding ${f.without === 1 ? 'it' : 'them'}`,
112
+ )
113
+ .join(' · '),
114
+ });
115
+ return rows.map((r) => ({ actions: [], ...r, mission: true }));
116
+ }