@warren-dean/code-kit 0.7.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.
@@ -47,12 +47,18 @@ const field = (lines, name) =>
47
47
  .find(Boolean)?.[1]
48
48
  .trim() ?? '';
49
49
 
50
- /** Every story in the plan: [{ id, title, lane, requirements, dependsOn, status }]. */
50
+ /**
51
+ * Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, milestone }]. A story's
52
+ * milestone is the `## ` heading above it ("Milestone 5: Your own key"), or null.
53
+ */
51
54
  export function readStories(root, planPath) {
52
55
  const stories = [];
53
56
  for (const file of markdownFiles(root, planPath)) {
54
57
  const lines = readFileSync(join(root, file), 'utf8').split('\n');
58
+ let milestone = null;
55
59
  lines.forEach((line, i) => {
60
+ const heading = line.match(/^##\s+(.+?)\s*$/);
61
+ if (heading) milestone = heading[1];
56
62
  const m = line.match(STORY_HEADING);
57
63
  if (!m) return;
58
64
  const end = lines.findIndex((l, j) => j > i && /^#{1,3}\s/.test(l));
@@ -67,6 +73,7 @@ export function readStories(root, planPath) {
67
73
  noRequirements: /^none\b/i.test(field(body, 'Requirements')),
68
74
  dependsOn: [...field(body, 'Depends on').matchAll(/\bST-\d+\b/g)].map((r) => r[0]),
69
75
  status: STATES.includes(status) ? status : 'todo',
76
+ milestone,
70
77
  });
71
78
  });
72
79
  }
@@ -0,0 +1,155 @@
1
+ // A story's code as the pane draws it (docs/specs/14-mission-and-maps.md): the files it changed, by the
2
+ // layer each sits in, with the imports between them (MAP-1); and each requirement's trail to its
3
+ // stories, their files and the tests that name it (TRAIL-1). Read from git, the config and the plan.
4
+ import { execFileSync } from 'node:child_process';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { join, posix } from 'node:path';
7
+ import { matchesAny } from './glob.mjs';
8
+ import { CODE, importsOf, layerOf, targetOf } from './layers.mjs';
9
+
10
+ const git = (root, ...args) => {
11
+ try {
12
+ return execFileSync('git', ['-C', root, ...args], {
13
+ encoding: 'utf8',
14
+ stdio: ['ignore', 'pipe', 'ignore'],
15
+ maxBuffer: 64 * 1024 * 1024,
16
+ });
17
+ } catch {
18
+ return '';
19
+ }
20
+ };
21
+
22
+ /** Files that are the build's own code: not the plan, the specs, or the tools' state. */
23
+ const KIT = /^(\.claude|\.ctx|\.github)\//;
24
+ const isCode = (rel) => CODE.test(rel) && !KIT.test(rel);
25
+
26
+ /**
27
+ * The code files a story changed: those in commits whose message names it (on any branch, so a merged
28
+ * story still has its files) and, while its branch is open, its diff against the base. Only files that
29
+ * still exist, in path order.
30
+ */
31
+ export function storyFiles(root, story, from) {
32
+ const found = new Set();
33
+ const id = story.id;
34
+ const log = git(
35
+ root,
36
+ 'log',
37
+ '--all',
38
+ '--no-merges',
39
+ '-E',
40
+ // POSIX ERE has no \b: "ST-1" mustn't match "ST-12".
41
+ `--grep=(^|[^A-Za-z0-9-])${id}([^0-9]|$)`,
42
+ '--format=',
43
+ '--name-only',
44
+ );
45
+ for (const f of log.split('\n')) if (f) found.add(f);
46
+ if (story.branchExists && story.branch)
47
+ for (const f of git(root, 'diff', '--name-only', `${from}...${story.branch}`).split('\n'))
48
+ if (f) found.add(f);
49
+ return [...found].filter((f) => isCode(f) && existsSync(join(root, f))).sort();
50
+ }
51
+
52
+ const EXTENSIONS = [
53
+ '',
54
+ '.ts',
55
+ '.tsx',
56
+ '.mts',
57
+ '.js',
58
+ '.jsx',
59
+ '.mjs',
60
+ '/index.ts',
61
+ '/index.tsx',
62
+ '/index.js',
63
+ ];
64
+
65
+ /** The file in `files` an import specifier in `rel` points at, or null. */
66
+ function resolveIn(spec, rel, aliases, files) {
67
+ const target = targetOf(spec, rel, aliases);
68
+ if (!target) return null;
69
+ // `./x.js` in TypeScript source names `./x.ts`.
70
+ const bare = target.replace(/\.(js|mjs|cjs)$/, '');
71
+ for (const ext of EXTENSIONS) {
72
+ for (const candidate of [`${target}${ext}`, `${bare}${ext}`])
73
+ if (files.has(candidate)) return candidate;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** The lane that owns `rel`, or `lead`, or null. */
79
+ export function laneOfFile(rel, config) {
80
+ const lane = Object.keys(config.lanes).find((name) => {
81
+ const { paths, exclude = [] } = config.lanes[name];
82
+ return matchesAny(rel, paths) && !matchesAny(rel, exclude);
83
+ });
84
+ if (lane) return lane;
85
+ return matchesAny(
86
+ rel,
87
+ config.lead.paths.filter((p) => p !== '*'),
88
+ )
89
+ ? 'lead'
90
+ : null;
91
+ }
92
+
93
+ const TEST = /(^|\/)(tests?|e2e|__tests__)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
94
+
95
+ /**
96
+ * MAP-1: a story's code: { story, layers, files: [{ path, layer, lane, imports }] }. `layers` are the
97
+ * config's layers in their order, then `tests` and `other` where any file falls there; each file's
98
+ * imports are the story's other files it imports.
99
+ */
100
+ export function storyMap(root, story, config, from) {
101
+ const paths = storyFiles(root, story, from);
102
+ const set = new Set(paths);
103
+ const aliases = config.importAliases ?? {};
104
+ const files = paths.map((rel) => {
105
+ let source = '';
106
+ try {
107
+ source = readFileSync(join(root, rel), 'utf8');
108
+ } catch {
109
+ // unreadable: no imports
110
+ }
111
+ const imports = [
112
+ ...new Set(
113
+ importsOf(source)
114
+ .map((spec) => resolveIn(spec, rel, aliases, set))
115
+ .filter((t) => t && t !== rel),
116
+ ),
117
+ ];
118
+ const layer = TEST.test(rel) ? 'tests' : (layerOf(rel, config.layers)?.name ?? 'other');
119
+ return { path: rel, layer, lane: laneOfFile(rel, config), imports };
120
+ });
121
+ const used = new Set(files.map((f) => f.layer));
122
+ const layers = [...config.layers.map((l) => l.name), 'tests', 'other'].filter((l) => used.has(l));
123
+ return {
124
+ story: { id: story.id, title: story.title, lane: story.lane, state: story.state },
125
+ layers,
126
+ files,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * TRAIL-1: each requirement's trail: { id, title, spec, state, stories: [{ id, state, lane }],
132
+ * files, tests }. Files are the code its stories changed, tests aside; tests are the files that name it.
133
+ */
134
+ export function trail(root, status, from) {
135
+ const byStory = new Map();
136
+ const filesOf = (s) => {
137
+ if (!byStory.has(s.id)) byStory.set(s.id, storyFiles(root, s, from));
138
+ return byStory.get(s.id);
139
+ };
140
+ return status.requirements
141
+ .filter((r) => !r.removed)
142
+ .map((r) => {
143
+ const stories = status.stories.filter((s) => s.requirements.includes(r.id));
144
+ const files = [...new Set(stories.flatMap((s) => filesOf(s)).filter((f) => !TEST.test(f)))];
145
+ return {
146
+ id: r.id,
147
+ title: r.title,
148
+ spec: r.file ? posix.basename(r.file) : null,
149
+ state: r.state,
150
+ stories: stories.map((s) => ({ id: s.id, state: s.state, lane: s.lane })),
151
+ files,
152
+ tests: [...(r.tests ?? [])],
153
+ };
154
+ });
155
+ }
@@ -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
+ ? '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
+ }