@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +50 -0
- package/README.md +26 -10
- package/bin/code-kit.mjs +398 -39
- package/hooks/guard-bash.mjs +35 -14
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +75 -6
- package/hooks/lib/adapters/index.mjs +18 -0
- package/hooks/lib/config.mjs +46 -2
- package/hooks/lib/dependencies.mjs +26 -9
- package/hooks/lib/harness.mjs +131 -0
- package/hooks/lib/next.mjs +34 -3
- package/hooks/lib/plan.mjs +29 -0
- 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/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/hold.mjs +25 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/panes.mjs +943 -0
- package/hooks/mod/register.mjs +1102 -14
- package/hooks/mod/register.test.ts +1747 -13
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/view.mjs +763 -36
- package/package.json +1 -1
- package/skills/init/SKILL.md +5 -4
- package/skills/review/SKILL.md +8 -1
package/hooks/lib/reviews.mjs
CHANGED
|
@@ -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
|
+
}
|
package/hooks/lib/rules.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/hooks/lib/shell.mjs
CHANGED
|
@@ -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 +=
|
|
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.`;
|