@warren-dean/code-kit 0.6.0 → 0.7.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.
@@ -0,0 +1,77 @@
1
+ // When each story's work happened (docs/specs/11-panes.md, VIEW-2), from git and the project's own
2
+ // records: when its branch was made, its commits, its merge into the base, its send-backs and the
3
+ // approvals its commits logged. The mod draws these as a bar per lane on a shared time axis.
4
+ import { execFileSync } from 'node:child_process';
5
+ import { APPROVAL_LOG } from './rules.mjs';
6
+ import { sendBacks } from './reviews.mjs';
7
+
8
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9
+
10
+ /**
11
+ * For each story with a branch, or done: { id, lane, branch, state, started, lastCommit, merged,
12
+ * sentBack: [ms], approvals: [{ at, names }] }, times in milliseconds or null.
13
+ */
14
+ export function storyTimes(root, stories, base) {
15
+ const git = (...args) => {
16
+ try {
17
+ return execFileSync('git', ['-C', root, ...args], {
18
+ encoding: 'utf8',
19
+ stdio: ['ignore', 'pipe', 'ignore'],
20
+ maxBuffer: 32 * 1024 * 1024,
21
+ }).trim();
22
+ } catch {
23
+ return '';
24
+ }
25
+ };
26
+ const lines = (text) => text.split('\n').filter(Boolean);
27
+ const merges = lines(git('log', '--merges', '--format=%ct%x09%s', base)).map((l) => {
28
+ const [t, ...subject] = l.split('\t');
29
+ return { at: Number(t) * 1000, subject: subject.join('\t') };
30
+ });
31
+ const backs = sendBacks(root);
32
+ const logged = (ref) => new Set(lines(git('show', `${ref}:${APPROVAL_LOG}`)));
33
+ const onBase = logged(base);
34
+ return stories
35
+ .filter((s) => s.branchExists || s.state === 'done')
36
+ .map((s) => {
37
+ const b = s.branch;
38
+ // The reflog's oldest entry is the branch's making, when it's still there.
39
+ const made = s.branchExists
40
+ ? lines(git('reflog', 'show', '--format=%ct', `refs/heads/${b}`))
41
+ : [];
42
+ const commits = s.branchExists
43
+ ? lines(git('log', '--format=%ct', `${base}..${b}`)).map((t) => Number(t) * 1000)
44
+ : [];
45
+ const named = new RegExp(`(^|[\\s'"])${escape(b)}($|[\\s'"])`);
46
+ const merged = merges.find((m) => named.test(m.subject))?.at ?? null;
47
+ const approvals = s.branchExists
48
+ ? [...logged(b)]
49
+ .filter((l) => !onBase.has(l))
50
+ .flatMap((l) => {
51
+ try {
52
+ const e = JSON.parse(l);
53
+ return e.grantedAt || e.at
54
+ ? [{ at: Date.parse(e.grantedAt ?? e.at), names: [e.approval].filter(Boolean) }]
55
+ : [];
56
+ } catch {
57
+ return [];
58
+ }
59
+ })
60
+ : [];
61
+ return {
62
+ id: s.id,
63
+ lane: s.lane,
64
+ branch: b,
65
+ state: s.state,
66
+ started: made.length
67
+ ? Number(made.at(-1)) * 1000
68
+ : commits.length
69
+ ? Math.min(...commits)
70
+ : null,
71
+ lastCommit: commits.length ? Math.max(...commits) : null,
72
+ merged,
73
+ sentBack: backs.filter((x) => x.branch === b).map((x) => Date.parse(x.at)),
74
+ approvals,
75
+ };
76
+ });
77
+ }
@@ -0,0 +1,200 @@
1
+ // Panes v2's pictures (docs/specs/11-panes.md, VIEW-2 and VIEW-3): a timeline bar per lane on a
2
+ // shared time axis, and a small pixel character per lane agent whose pose shows its state. The
3
+ // terminal draws characters as half-block cells (a Raster, repainted in place with $.ui.blit while
4
+ // the agent works); Desktop draws them, and the timelines, as SVG. Every state has a glyph too.
5
+
6
+ // --- the characters (VIEW-3) ---------------------------------------------------------------------
7
+
8
+ /** Each pose, 6 by 6 pixels: H the lane's tint, E eyes, X stalled eyes, L closed eyes, C a crown. */
9
+ const POSES = {
10
+ working: [
11
+ ['..HH..', '.HEEH.', '..HH..', '.HHHH.', 'H.HH.H', '.H..H.'],
12
+ ['..HH..', '.HEEH.', '..HH..', 'HHHHHH', '..HH..', '..HH..'],
13
+ ],
14
+ waiting: [['..HH.H', '.HEEHH', '..HH..', '.HHH..', '..HH..', '.H..H.']],
15
+ quiet: [['......', '..HH..', '.HLLH.', '..HH..', '.HHHH.', '.H..H.']],
16
+ stalled: [['..HH..', '.HXXH.', '..HH..', '.HHHH.', '..HH..', '.H..H.']],
17
+ done: [['H.HH.H', 'HHEEHH', '..HH..', '..HH..', '..HH..', '.H..H.']],
18
+ idle: [['......', '..HH..', '.HLLH.', '..HH..', '.HHHH.', '.H..H.']],
19
+ };
20
+ /** What a pose reads as without its picture, and in words. */
21
+ export const POSE_GLYPH = {
22
+ working: '⚙',
23
+ waiting: '?',
24
+ quiet: '…',
25
+ stalled: '✗',
26
+ done: '✓',
27
+ idle: '·',
28
+ };
29
+
30
+ /** Each lane's tint, fixed by its place in the config so the person learns them; the lead's is gold. */
31
+ const TINTS = [0x4aa3ff, 0x5cc85c, 0xd070e0, 0x40c8c8, 0xff9a40, 0xff6070, 0xa0a0ff];
32
+ export function tintOf(lane, lanes) {
33
+ if (lane === 'lead') return 0xd8a840;
34
+ const i = lanes.filter((l) => l !== 'lead').indexOf(lane);
35
+ return TINTS[(i < 0 ? 0 : i) % TINTS.length];
36
+ }
37
+
38
+ const dim = (rgb) =>
39
+ (((rgb >> 16) & 0xff) >> 1) * 0x10000 + (((rgb >> 8) & 0xff) >> 1) * 0x100 + ((rgb & 0xff) >> 1);
40
+
41
+ /** The pose's frame as a grid of colours (null for none), the lead crowned. */
42
+ export function sprite(pose, frame, tint, { lead = false } = {}) {
43
+ const frames = POSES[pose] ?? POSES.idle;
44
+ const rows = frames[frame % frames.length].map((r) => [...r]);
45
+ if (lead) rows[0] = [...'C.CC.C'];
46
+ const body = pose === 'quiet' || pose === 'idle' ? dim(tint) : tint;
47
+ const colour = { H: body, E: 0x202020, X: 0xff3030, L: 0x707070, C: 0xffcc00 };
48
+ return rows.map((r) => r.map((c) => colour[c] ?? null));
49
+ }
50
+
51
+ const DEFAULT = 0x01000000;
52
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
53
+ function base64(bytes) {
54
+ let out = '';
55
+ for (let i = 0; i < bytes.length; i += 3) {
56
+ const [a, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]];
57
+ const n = (a << 16) | (b << 8) | c;
58
+ out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
59
+ out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '=';
60
+ out += i + 2 < bytes.length ? B64[n & 63] : '=';
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * A sprite as a Raster's cells (VIEW-3): two pixels a cell, the upper as the half block's colour and
67
+ * the lower as its background. 6 columns by 3 rows.
68
+ */
69
+ export function rasterCells(grid) {
70
+ const words = [];
71
+ for (let y = 0; y < grid.length; y += 2)
72
+ for (let x = 0; x < grid[y].length; x++) {
73
+ const top = grid[y][x];
74
+ const bottom = grid[y + 1]?.[x] ?? null;
75
+ if (top === null && bottom === null) words.push(0x20, DEFAULT, DEFAULT);
76
+ else if (top === null) words.push(0x2584, bottom, DEFAULT);
77
+ else words.push(0x2580, top, bottom ?? DEFAULT);
78
+ }
79
+ const bytes = [];
80
+ for (const w of words)
81
+ bytes.push(w & 0xff, (w >>> 8) & 0xff, (w >>> 16) & 0xff, (w >>> 24) & 0xff);
82
+ return base64(bytes);
83
+ }
84
+
85
+ const hex = (rgb) => `#${rgb.toString(16).padStart(6, '0')}`;
86
+
87
+ /** A sprite as SVG for Desktop, the working pose's frames alternating (SMIL), 4 px a pixel. */
88
+ export function spriteSvg(pose, tint, { lead = false } = {}) {
89
+ const frames = (POSES[pose] ?? POSES.idle).map((_, f) => sprite(pose, f, tint, { lead }));
90
+ const rects = (grid) =>
91
+ grid
92
+ .flatMap((row, y) =>
93
+ row.map((c, x) =>
94
+ c === null
95
+ ? ''
96
+ : `<rect x="${x * 4}" y="${y * 4}" width="4" height="4" fill="${hex(c)}"/>`,
97
+ ),
98
+ )
99
+ .join('');
100
+ const groups = frames.map((g, f) =>
101
+ frames.length > 1
102
+ ? `<g visibility="${f ? 'hidden' : 'visible'}"><animate attributeName="visibility" values="${f ? 'hidden;visible' : 'visible;hidden'}" dur="0.8s" repeatCount="indefinite" calcMode="discrete"/>${rects(g)}</g>`
103
+ : `<g>${rects(g)}</g>`,
104
+ );
105
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" shape-rendering="crispEdges">${groups.join('')}</svg>`;
106
+ }
107
+
108
+ /**
109
+ * A lane's pose (VIEW-3): stalled when flagged or quiet past the restart time, waiting while a call
110
+ * of its is held or its request is open, working while its agent is at work, quiet when it has
111
+ * gone quiet, done once its branch is finished, idle otherwise.
112
+ */
113
+ export function poseOf(
114
+ lane,
115
+ { live = null, waiting = false, flagged = false, shown = lane.state } = {},
116
+ ) {
117
+ if (flagged || live?.level === 'stalled') return 'stalled';
118
+ if (waiting) return 'waiting';
119
+ if (live?.level === 'quiet') return 'quiet';
120
+ if (lane.active) return 'working';
121
+ if (['in review', 'queued'].includes(shown)) return 'done';
122
+ return 'idle';
123
+ }
124
+
125
+ // --- the timelines (VIEW-2) ----------------------------------------------------------------------
126
+
127
+ /** Each cell kind's glyph and colour: the glyph alone reads in a terminal without colour. */
128
+ export const BAR = {
129
+ building: { glyph: '█', color: 'blue' },
130
+ review: { glyph: '▒', color: 'yellow' },
131
+ sentBack: { glyph: '↩', color: 'magenta' },
132
+ approval: { glyph: '◆', color: 'cyan' },
133
+ merged: { glyph: '✓', color: 'green' },
134
+ none: { glyph: ' ', color: undefined },
135
+ };
136
+
137
+ /** Where the shared axis starts: the earliest start among the lanes' stories, at most a day back. */
138
+ export function axisStart(times, now) {
139
+ const starts = times.map((t) => t.started).filter((t) => t !== null && t !== undefined);
140
+ return starts.length ? Math.max(Math.min(...starts), now - 24 * 3600000) : now - 3600000;
141
+ }
142
+
143
+ /**
144
+ * A story's bar as `width` cells from `start` to `now` (VIEW-2): building from its start, in review
145
+ * from its last commit once finished, ending at its merge or now; its send-backs, approvals and
146
+ * merge marked where they fell. [kind], a kind of BAR's.
147
+ */
148
+ export function barCells(t, { start, now, width }) {
149
+ const span = Math.max(1, now - start) / width;
150
+ const finished =
151
+ ['review', 'in review', 'queued', 'done'].includes(t.state) && t.lastCommit !== null;
152
+ const end = t.merged ?? now;
153
+ const cells = [];
154
+ for (let i = 0; i < width; i++) {
155
+ const mid = start + (i + 0.5) * span;
156
+ if (t.started === null || mid < t.started || mid > end + span / 2) cells.push('none');
157
+ else if (finished && mid >= t.lastCommit) cells.push('review');
158
+ else cells.push('building');
159
+ }
160
+ const mark = (at, kind) => {
161
+ if (at === null || at === undefined || at < start || at > now) return;
162
+ cells[Math.min(width - 1, Math.floor((at - start) / span))] = kind;
163
+ };
164
+ for (const at of t.sentBack ?? []) mark(at, 'sentBack');
165
+ for (const a of t.approvals ?? []) mark(a.at, 'approval');
166
+ mark(t.merged, 'merged');
167
+ return cells;
168
+ }
169
+
170
+ /** Runs of the same kind, to draw as one Text each: [{ kind, n }]. */
171
+ export function runs(cells) {
172
+ const out = [];
173
+ for (const kind of cells) {
174
+ if (out.at(-1)?.kind === kind) out.at(-1).n += 1;
175
+ else out.push({ kind, n: 1 });
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /** The bars as SVG for Desktop: a row per lane, the same kinds and colours. */
181
+ export function timelineSvg(rows, { width = 480, rowHeight = 10 } = {}) {
182
+ const FILL = {
183
+ building: '#4aa3ff',
184
+ review: '#e0c040',
185
+ sentBack: '#d070e0',
186
+ approval: '#40c8c8',
187
+ merged: '#5cc85c',
188
+ };
189
+ const cell = width / Math.max(1, rows[0]?.cells.length ?? 1);
190
+ const rects = rows
191
+ .flatMap((r, y) =>
192
+ r.cells.map((k, x) =>
193
+ k === 'none'
194
+ ? ''
195
+ : `<rect x="${(x * cell).toFixed(1)}" y="${y * (rowHeight + 4)}" width="${cell.toFixed(1)}" height="${rowHeight}" fill="${FILL[k]}"/>`,
196
+ ),
197
+ )
198
+ .join('');
199
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${rows.length * (rowHeight + 4)}" shape-rendering="crispEdges">${rects}</svg>`;
200
+ }
@@ -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.`;