@warren-dean/code-kit 0.6.0 → 0.8.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 +62 -0
- package/README.md +31 -10
- package/bin/code-kit.mjs +447 -39
- package/hooks/guard-bash.mjs +54 -16
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +87 -19
- package/hooks/lib/adapters/index.mjs +18 -0
- package/hooks/lib/config.mjs +43 -2
- package/hooks/lib/dependencies.mjs +26 -9
- package/hooks/lib/harness.mjs +2 -2
- package/hooks/lib/journal.mjs +224 -0
- package/hooks/lib/loop-state.mjs +26 -0
- package/hooks/lib/next.mjs +34 -3
- package/hooks/lib/plan.mjs +37 -1
- package/hooks/lib/queue.mjs +75 -0
- package/hooks/lib/reviews.mjs +18 -0
- package/hooks/lib/rules.mjs +8 -4
- package/hooks/lib/shell.mjs +1 -1
- package/hooks/lib/storymap.mjs +155 -0
- package/hooks/lib/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/codemap.mjs +328 -0
- package/hooks/mod/decisions.mjs +146 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/mission.mjs +116 -0
- package/hooks/mod/panes.mjs +1378 -0
- package/hooks/mod/register.mjs +1172 -15
- package/hooks/mod/register.test.ts +1868 -15
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/trail.mjs +152 -0
- package/hooks/mod/view.mjs +658 -56
- package/package.json +1 -1
- package/skills/init/SKILL.md +6 -5
- package/skills/review/SKILL.md +8 -1
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
|
+
? 'main has no rules committed yet, so this first merge is yours; after it, the lead merges.'
|
|
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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The lead loop (spec 05), the pure parts: what to prompt the lead with for a step `code-kit next`
|
|
2
|
+
// reports, and what a quiet lane agent is due. The mod watches and prompts; the lead acts, with the
|
|
3
|
+
// existing skills, and the hooks judge every call as before.
|
|
4
|
+
|
|
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']);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The prompt for a step from `code-kit next --json` (LOOP-1), or null when it isn't the lead's to
|
|
10
|
+
* take. Every ready story is named: the dispatch skill decides how many lanes go (LOOP-2). A prompt
|
|
11
|
+
* names the skill rather than starting with it: Claude Code refuses a mod's prompt that begins with
|
|
12
|
+
* `/`, which would run a command as the person.
|
|
13
|
+
*/
|
|
14
|
+
export function leadPrompt(step, { cli = 'code-kit' } = {}) {
|
|
15
|
+
if (!step || !LEAD_STEPS.has(step.step)) return null;
|
|
16
|
+
const ids = (step.args ?? '').split(/\s+/).filter(Boolean);
|
|
17
|
+
// MQ-2: the head of the merge queue, where the lead merges within the delegated rules.
|
|
18
|
+
if (step.step === 'merge')
|
|
19
|
+
return ids.length
|
|
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
|
+
: null;
|
|
22
|
+
if (step.step === 'dispatch')
|
|
23
|
+
return ids.length
|
|
24
|
+
? `Dispatch ${ids.join(', ')}: run /code-kit:dispatch ${ids.join(' ')}.`
|
|
25
|
+
: null;
|
|
26
|
+
// The review skill takes one story, or none for every story in review.
|
|
27
|
+
if (step.step === 'review')
|
|
28
|
+
return ids.length === 1
|
|
29
|
+
? `Review ${ids[0]}: run /code-kit:review ${ids[0]}.`
|
|
30
|
+
: `Review ${ids.join(', ')}: run /code-kit:review.`;
|
|
31
|
+
if (!ids.length) return null;
|
|
32
|
+
const branches = ids.map((id) => `lead/${id.toLowerCase()}`).join(', ');
|
|
33
|
+
return `Build ${ids.join(', ')} yourself: ${ids.length === 1 ? "it is the lead's own story" : "they are the lead's own stories"}. Work on ${branches}, then review ${ids.length === 1 ? 'it' : 'them'} like any branch.`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A step's identity: the loop never submits the same step twice while the project stands still. */
|
|
37
|
+
export const stepKey = (step) => `${step.step}:${step.args ?? ''}`;
|
|
38
|
+
|
|
39
|
+
/** The band's words for a step (LOOP-5): "Dispatch ST-7, ST-9". */
|
|
40
|
+
export function stepLabel(step) {
|
|
41
|
+
const ids = (step.args ?? '').split(/\s+/).filter(Boolean).join(', ');
|
|
42
|
+
const verb =
|
|
43
|
+
{ dispatch: 'Dispatch', review: 'Review', lead: 'Build', merge: 'Merge' }[step.step] ??
|
|
44
|
+
step.step;
|
|
45
|
+
return ids ? `${verb} ${ids}` : verb;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What a lane agent with no tool call for `quietMs` is due (LOOP-3): 'nudge' at stall.nudgeMinutes,
|
|
50
|
+
* 'restart' at stall.restartMinutes, 'flag' once its story has been restarted stall.maxRestarts
|
|
51
|
+
* times, or null. `agent` holds what was done already: { nudged, stopped }.
|
|
52
|
+
*/
|
|
53
|
+
export function stallDue(quietMs, agent, restarts, stall) {
|
|
54
|
+
const minutes = quietMs / 60000;
|
|
55
|
+
if (minutes >= stall.restartMinutes && !agent.stopped)
|
|
56
|
+
return restarts >= stall.maxRestarts ? 'flag' : 'restart';
|
|
57
|
+
if (minutes >= stall.nudgeMinutes && !agent.nudged) return 'nudge';
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The lead's prompt to stop a stalled agent and dispatch its story again (LOOP-3). */
|
|
62
|
+
export function restartPrompt({ type, id, story, minutes }) {
|
|
63
|
+
return `${type} (agent ${id}) on ${story} has made no tool call for ${minutes} minutes. Stop it with TaskStop, then dispatch ${story} again with the same brief on the same branch: /code-kit:dispatch ${story}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The message a quiet agent gets (LOOP-3). */
|
|
67
|
+
export const nudgeText = (minutes) =>
|
|
68
|
+
`No tool call for ${minutes} minutes: report where you are, or carry on.`;
|
|
@@ -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
|
+
}
|