@warren-dean/code-kit 0.5.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.
@@ -37,3 +37,21 @@ export function sentBack(root) {
37
37
  }
38
38
  return found;
39
39
  }
40
+
41
+ /** Every send-back recorded, oldest first: [{ at, branch, sha, reason }]. */
42
+ export function sendBacks(root) {
43
+ let lines = [];
44
+ try {
45
+ lines = readFileSync(reviewsFile(root), 'utf8').split('\n').filter(Boolean);
46
+ } catch {
47
+ return [];
48
+ }
49
+ return lines.flatMap((line) => {
50
+ try {
51
+ const r = JSON.parse(line);
52
+ return r.verdict === 'sent back' && r.branch ? [r] : [];
53
+ } catch {
54
+ return [];
55
+ }
56
+ });
57
+ }
@@ -2,7 +2,8 @@
2
2
  //
3
3
  // Actors:
4
4
  // - a lane agent (its agent_type is a lane's "agent") writes only that lane's paths;
5
- // - any other subagent (research, planning, review) is read-only;
5
+ // - any other subagent (research, planning, review) is read-only, but for the paths any actor may write
6
+ // (a card or a decision it records, code-kit's own state);
6
7
  // - the main session in a worktree with a .lane file acts as that lane (a person running a lane);
7
8
  // - the main session otherwise is the lead, who writes the lead's paths and delegates the rest.
8
9
  // Protected paths are recorded in the approval log whenever they are committed; only the lead may
@@ -219,8 +220,9 @@ function ownedPaths(actor, config) {
219
220
 
220
221
  /** Whether `rel` is the actor's own to write: its paths, or the paths any actor may write. */
221
222
  export function owns(actor, rel, config) {
222
- if (actor.kind === 'readonly') return false;
223
+ // The paths any actor may write are a read-only agent's too: a card or a decision it records.
223
224
  if (matchesAny(rel, config.anyActor)) return true;
225
+ if (actor.kind === 'readonly') return false;
224
226
  const excluded = actor.kind === 'lane' ? config.lanes[actor.lane]?.exclude : [];
225
227
  return matchesAny(rel, ownedPaths(actor, config)) && !matchesAny(rel, excluded);
226
228
  }
@@ -408,8 +410,10 @@ export function protectedEntry(rel, config) {
408
410
  return config.protected.find((p) => globToRegExp(p.glob).test(rel));
409
411
  }
410
412
 
413
+ /** Files that may hold secrets. An env file's template (`.env.example`, `.env.local.sample`) names
414
+ * the variables without their values, so it's committed like any other file. */
411
415
  export const SECRETS = (rel) =>
412
- /(^|\/)\.env(\.|$)/.test(rel) ||
416
+ (/(^|\/)\.env(\.|$)/.test(rel) && !/\.(example|sample|template|dist)$/.test(rel)) ||
413
417
  /(^|\/)secrets?\//.test(rel) ||
414
418
  /\.(pem|p8|p12|keystore|jks)$/.test(rel);
415
419
 
@@ -436,8 +440,8 @@ export function writeProblem(actor, rel, root, config) {
436
440
  recordRequest(root, actor, guarded.approval, rel, guarded.why);
437
441
  return `${rel} is ${guarded.why} and needs a person's approval. Ask the lead (a person) to run\n${approvalHowTo(guarded.approval, actor, root, undefined, config)}`;
438
442
  }
439
- if (actor.kind === 'readonly') return `${actor.label} is read-only; it may not write ${rel}.`;
440
443
  if (matchesAny(rel, config.anyActor)) return null;
444
+ if (actor.kind === 'readonly') return `${actor.label} is read-only; it may not write ${rel}.`;
441
445
  if (!owns(actor, rel, config)) return ownershipProblem(actor, rel, config);
442
446
  const specs = actor.kind === 'lane' && config.docs?.specs;
443
447
  return (specs && specCheckProblem(rel, root)) || screenProblem(rel, root, config);
@@ -33,7 +33,7 @@ export function maskQuotes(text) {
33
33
  } else if (c === quote) {
34
34
  quote = null;
35
35
  out += c;
36
- } else out += c === '\n' ? '\n' : '_';
36
+ } else out += '_'; // a newline inside quotes too: it ends no command
37
37
  } else if (c === '\\' && i + 1 < text.length) {
38
38
  out += '__';
39
39
  i++;
@@ -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,25 @@
1
+ // Hold and ask (spec 06), the pure parts. The hooks stay the only judge: the mod lets a call run,
2
+ // and when the result is a refusal a person's approval would allow, it holds the call, asks, writes the
3
+ // approval and retries the same call (docs/spikes/harness.md, spike B). This reads such a refusal.
4
+ import { refusalCard } from './view.mjs';
5
+
6
+ /** The kinds of refusal a person's approval would allow (HOLD-1): a new package, a protected file, the kit. */
7
+ const HOLDABLE = new Set(['dependency', 'approval', 'kit']);
8
+
9
+ /**
10
+ * From a refused call's result text, what would let it through: { kind, names, lane, what, title }, or
11
+ * null when no approval a person can give would allow it (another lane's files, force-push, the log…).
12
+ */
13
+ export function holdable(resultText) {
14
+ const card = refusalCard(resultText);
15
+ if (!card || !HOLDABLE.has(card.kind) || !card.request?.names.length) return null;
16
+ return { kind: card.kind, title: card.title, ...card.request };
17
+ }
18
+
19
+ /** A tool call's result as text, whichever shape the engine gave it. */
20
+ export function resultText(result) {
21
+ if (!result) return '';
22
+ if (typeof result.deny === 'string') return result.deny;
23
+ if (typeof result.text === 'string') return result.text;
24
+ return typeof result.result === 'string' ? result.result : '';
25
+ }
@@ -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.`;