@warren-dean/code-kit 0.21.0 → 0.22.1
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 +20 -0
- package/README.md +46 -23
- package/bin/code-kit.mjs +271 -21
- package/hooks/guard-agent.mjs +36 -0
- package/hooks/guard-bash.mjs +23 -11
- package/hooks/guard-paths.mjs +11 -0
- package/hooks/hooks.json +20 -0
- package/hooks/lane-audit.mjs +38 -1
- package/hooks/lib/adapters/hub/client.mjs +177 -25
- package/hooks/lib/config.mjs +148 -5
- package/hooks/lib/decisionstore.mjs +89 -0
- package/hooks/lib/harness.mjs +7 -0
- package/hooks/lib/hook.mjs +7 -0
- package/hooks/lib/instructions.mjs +1 -1
- package/hooks/lib/laneclaims.mjs +89 -0
- package/hooks/lib/loop-state.mjs +20 -2
- package/hooks/lib/next.mjs +34 -1
- package/hooks/lib/plan.mjs +3 -1
- package/hooks/lib/questions.mjs +23 -5
- package/hooks/lib/regression.mjs +2 -1
- package/hooks/lib/rules.mjs +18 -12
- package/hooks/lib/schedule.mjs +86 -0
- package/hooks/lib/sessions.mjs +223 -0
- package/hooks/lib/snapshot.mjs +9 -3
- package/hooks/mod/decisions.mjs +1 -28
- package/hooks/mod/loop.mjs +8 -0
- package/hooks/mod/mission.mjs +21 -19
- package/hooks/mod/register.mjs +118 -41
- package/hooks/mod/register.test.ts +244 -73
- package/hooks/mod/view.mjs +98 -47
- package/hooks/post-edit-check.mjs +3 -0
- package/hooks/session.mjs +24 -0
- package/hooks/stop-check.mjs +12 -0
- package/package.json +1 -1
- package/skills/decide/SKILL.md +1 -1
- package/skills/dispatch/SKILL.md +2 -2
- package/skills/init/SKILL.md +1 -1
- package/skills/lead/SKILL.md +28 -0
- package/skills/next/SKILL.md +1 -1
- package/skills/spec-design/SKILL.md +2 -1
- package/skills/team-init/SKILL.md +1 -1
- package/templates/claude-md.md +3 -1
- package/templates/plan.md +2 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// One agent per lane, across every session in the project (docs/specs/23-the-team.md, SCHED-1). Two
|
|
2
|
+
// agents in one lane build on overlapping files in their worktrees and conflict at merge; the plan's
|
|
3
|
+
// graph is for running different lanes at once. Starting a lane's agent claims the lane, and the claim
|
|
4
|
+
// ends when the agent finishes, its session ends, or the lead releases a lane whose agent has stopped.
|
|
5
|
+
// Kept in the main checkout, one file per lane, so every session and worktree on the machine sees them,
|
|
6
|
+
// and two agents started in the same moment can't both take a lane: the file is created exclusively.
|
|
7
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { projectWorktrees } from './rules.mjs';
|
|
10
|
+
|
|
11
|
+
export const LANE_CLAIMS_DIR = '.claude/state/lanes';
|
|
12
|
+
/** A claim this old is taken to be left over from an agent nobody saw stop. */
|
|
13
|
+
export const LANE_CLAIM_STALE_MS = 12 * 3600_000;
|
|
14
|
+
|
|
15
|
+
const dirOf = (root) => join(projectWorktrees(root)[0] ?? root, LANE_CLAIMS_DIR);
|
|
16
|
+
const fileOf = (root, lane) => join(dirOf(root), `${lane.replace(/[^\w.-]/g, '_')}.json`);
|
|
17
|
+
|
|
18
|
+
/** A session's id as people read it: its first 8 characters. */
|
|
19
|
+
export const shortSession = (session) => (session ? String(session).slice(0, 8) : null);
|
|
20
|
+
|
|
21
|
+
function read(root, lane) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(fileOf(root, lane), 'utf8'));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const withAge = (claim, now) =>
|
|
30
|
+
claim && { ...claim, stale: now - Date.parse(claim.since) >= LANE_CLAIM_STALE_MS };
|
|
31
|
+
|
|
32
|
+
/** Every lane claimed now: [{ lane, story, session, since, stale }], by lane. */
|
|
33
|
+
export function laneClaims(root, now = Date.now()) {
|
|
34
|
+
let files = [];
|
|
35
|
+
try {
|
|
36
|
+
files = readdirSync(dirOf(root)).filter((f) => f.endsWith('.json'));
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
return files
|
|
41
|
+
.map((f) => {
|
|
42
|
+
try {
|
|
43
|
+
return withAge(JSON.parse(readFileSync(join(dirOf(root), f), 'utf8')), now);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
.filter((c) => c?.lane)
|
|
49
|
+
.sort((a, b) => a.lane.localeCompare(b.lane));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The lane's claim, or null. */
|
|
53
|
+
export const laneClaim = (root, lane, now = Date.now()) => withAge(read(root, lane), now);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* SCHED-1: claims `lane` for `story` in `session`: { ok: true } or { ok: false, claim } naming who has it.
|
|
57
|
+
* The session already holding it for the same story (a restart, a send-back) takes it again; so does
|
|
58
|
+
* anyone, from a stale claim.
|
|
59
|
+
*/
|
|
60
|
+
export function claimLane(root, { lane, story = null, session = null }, now = Date.now()) {
|
|
61
|
+
const record = { lane, story, session, since: new Date(now).toISOString() };
|
|
62
|
+
mkdirSync(dirOf(root), { recursive: true });
|
|
63
|
+
try {
|
|
64
|
+
writeFileSync(fileOf(root, lane), `${JSON.stringify(record)}\n`, { flag: 'wx' });
|
|
65
|
+
return { ok: true };
|
|
66
|
+
} catch {
|
|
67
|
+
// held already: by whom, and whether it may be taken over
|
|
68
|
+
}
|
|
69
|
+
const held = laneClaim(root, lane, now);
|
|
70
|
+
const same = held && held.session === session && (held.story === story || !held.story);
|
|
71
|
+
if (held && !same && !held.stale) return { ok: false, claim: held };
|
|
72
|
+
writeFileSync(fileOf(root, lane), `${JSON.stringify(record)}\n`);
|
|
73
|
+
return { ok: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Ends the lane's claim; with `session`, only that session's. True when one ended. */
|
|
77
|
+
export function releaseLane(root, lane, { session = null } = {}) {
|
|
78
|
+
const held = read(root, lane);
|
|
79
|
+
if (!held || (session && held.session !== session)) return false;
|
|
80
|
+
rmSync(fileOf(root, lane), { force: true });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Ends every claim a session holds, as it ends: the lanes it named. */
|
|
85
|
+
export function releaseSession(root, session, now = Date.now()) {
|
|
86
|
+
const ended = laneClaims(root, now).filter((c) => c.session === session);
|
|
87
|
+
for (const c of ended) releaseLane(root, c.lane, { session });
|
|
88
|
+
return ended.map((c) => c.lane);
|
|
89
|
+
}
|
package/hooks/lib/loop-state.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
import { projectWorktrees } from './rules.mjs';
|
|
8
|
+
import { leadSession, makeLead, readSession } from './sessions.mjs';
|
|
8
9
|
|
|
9
10
|
export const LOOP_FILE = '.claude/state/loop.json';
|
|
10
11
|
/** LOOP-10: a lead session renews its lease well within this; one not renewed for this long has ended. */
|
|
@@ -34,10 +35,19 @@ const liveLead = (state, now) => {
|
|
|
34
35
|
return now - Date.parse(lead.at) < LEAD_LEASE_MS ? { session: lead.session, at: lead.at } : null;
|
|
35
36
|
};
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* { paused, at, lead }: not paused when nothing was recorded; `lead` is the live lead session or null.
|
|
40
|
+
* SESS-2: where sessions are recorded, the lead session is the lead; the lease is for a session code-kit's
|
|
41
|
+
* session hook never saw.
|
|
42
|
+
*/
|
|
38
43
|
export function readLoop(root, now = Date.now()) {
|
|
39
44
|
const state = readRaw(root);
|
|
40
|
-
|
|
45
|
+
const lead = leadSession(root, now);
|
|
46
|
+
return {
|
|
47
|
+
paused: state.paused === true,
|
|
48
|
+
at: state.at ?? null,
|
|
49
|
+
lead: lead ? { session: lead.id, at: lead.seen } : liveLead(state, now),
|
|
50
|
+
};
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
export function writeLoop(root, paused, now = Date.now()) {
|
|
@@ -49,6 +59,14 @@ export function writeLoop(root, paused, now = Date.now()) {
|
|
|
49
59
|
* person moved the loop here): { ok: true } or { ok: false, lead } naming the session that has it.
|
|
50
60
|
*/
|
|
51
61
|
export function claimLead(root, session, { take = false } = {}, now = Date.now()) {
|
|
62
|
+
// SESS-2: the loop runs in the lead session; Lead from here (`take`) makes this session the lead.
|
|
63
|
+
if (readSession(root, session)) {
|
|
64
|
+
if (take) makeLead(root, session, now);
|
|
65
|
+
const lead = leadSession(root, now);
|
|
66
|
+
return lead?.id === session
|
|
67
|
+
? { ok: true, lead: null }
|
|
68
|
+
: { ok: false, lead: lead ? { session: lead.id, at: lead.seen } : null };
|
|
69
|
+
}
|
|
52
70
|
const state = readRaw(root);
|
|
53
71
|
const lead = liveLead(state, now);
|
|
54
72
|
if (lead && lead.session !== session && !take) return { ok: false, lead };
|
package/hooks/lib/next.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { readQueue, waiting } from './queue.mjs';
|
|
|
12
12
|
import { decidedBy, laneOf, openRequests } from './rules.mjs';
|
|
13
13
|
import { instructionsState } from './instructions.mjs';
|
|
14
14
|
import { SECURITY_AGENT_FILE } from './security.mjs';
|
|
15
|
+
import { laneClaims } from './laneclaims.mjs';
|
|
16
|
+
import { pickPerLane } from './schedule.mjs';
|
|
15
17
|
|
|
16
18
|
export const DRAFT_FILE = '.claude/code-kit.draft.json';
|
|
17
19
|
export const BRIEF_STATES = ['framed', 'explored', 'scoped', 'specified', 'planned', 'ready'];
|
|
@@ -280,7 +282,9 @@ export function nextStep(root, base) {
|
|
|
280
282
|
}
|
|
281
283
|
// HELM-4: a lane waiting on something the lead decides goes first: the lane can't go on without it.
|
|
282
284
|
const forLeadToDecide = openRequests(root).flatMap((r, i) =>
|
|
283
|
-
r.lane && decidedBy(r.names, config
|
|
285
|
+
r.lane && decidedBy(r.names, config, { kind: 'lane', lane: r.lane }) === 'lead'
|
|
286
|
+
? [{ ...r, n: i + 1 }]
|
|
287
|
+
: [],
|
|
284
288
|
);
|
|
285
289
|
if (forLeadToDecide.length)
|
|
286
290
|
return result(
|
|
@@ -311,6 +315,29 @@ export function nextStep(root, base) {
|
|
|
311
315
|
`${queued[0]} passed review and is next in the merge queue${queued.length > 1 ? ` (then ${queued.slice(1).join(', ')})` : ''}.`,
|
|
312
316
|
{ args: queued[0] },
|
|
313
317
|
);
|
|
318
|
+
// SCHED-1, SCHED-2: one story for each free lane, across every session; the open slice's first.
|
|
319
|
+
const busy = new Set([
|
|
320
|
+
...laneClaims(root)
|
|
321
|
+
.filter((c) => !c.stale)
|
|
322
|
+
.map((c) => c.lane),
|
|
323
|
+
...status.stories.filter((s) => s.state === 'in progress' && s.lane).map((s) => s.lane),
|
|
324
|
+
]);
|
|
325
|
+
const sched = pickPerLane(status.stories, {
|
|
326
|
+
fixes,
|
|
327
|
+
ready,
|
|
328
|
+
busy,
|
|
329
|
+
open: config.harness.slices.open,
|
|
330
|
+
});
|
|
331
|
+
for (const list of [fixes, ready])
|
|
332
|
+
list.splice(0, list.length, ...list.filter((id) => sched.pick.includes(id)));
|
|
333
|
+
for (const a of sched.ahead)
|
|
334
|
+
attention.push(
|
|
335
|
+
`${a.id} is ahead of its slice (${a.slice}): its lane has nothing ready in the open slice.`,
|
|
336
|
+
);
|
|
337
|
+
if (sched.waiting.length)
|
|
338
|
+
attention.push(
|
|
339
|
+
`${sched.waiting.join(', ')} ${sched.waiting.length === 1 ? 'waits' : 'wait'} for ${sched.waiting.length === 1 ? 'its lane' : 'their lanes'}: a lane runs one story at a time.`,
|
|
340
|
+
);
|
|
314
341
|
const then = [];
|
|
315
342
|
// A sent-back story goes back to its lane with the review's findings, before new work starts.
|
|
316
343
|
const toDispatch = [...fixes, ...ready];
|
|
@@ -358,6 +385,12 @@ export function nextStep(root, base) {
|
|
|
358
385
|
'update',
|
|
359
386
|
`${missingWords} Re-run /code-kit:init before the next story: it shows the person the difference before anything changes.`,
|
|
360
387
|
);
|
|
388
|
+
// SCHED-1: ready stories whose lanes are all running one.
|
|
389
|
+
if (!building.length && sched.waiting.length)
|
|
390
|
+
return result(
|
|
391
|
+
'wait',
|
|
392
|
+
`${sched.waiting.join(', ')} ${sched.waiting.length === 1 ? 'waits' : 'wait'} for ${sched.waiting.length === 1 ? 'its lane' : 'their lanes'}, running another story; a lane runs one story at a time.`,
|
|
393
|
+
);
|
|
361
394
|
if (building.length)
|
|
362
395
|
return result(
|
|
363
396
|
'wait',
|
package/hooks/lib/plan.mjs
CHANGED
|
@@ -52,7 +52,7 @@ const field = (lines, name) =>
|
|
|
52
52
|
.trim() ?? '';
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, fixes, milestone }]. A story's
|
|
55
|
+
* Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, fixes, milestone, slice }]. A story's
|
|
56
56
|
* milestone is the `## ` heading above it ("Milestone 5: Your own key"), or null.
|
|
57
57
|
*/
|
|
58
58
|
export function readStories(root, planPath) {
|
|
@@ -80,6 +80,8 @@ export function readStories(root, planPath) {
|
|
|
80
80
|
dependsOn: [...field(body, 'Depends on').matchAll(/\bST-\d+\b/g)].map((r) => r[0]),
|
|
81
81
|
status: STATES.includes(status) ? status : 'todo',
|
|
82
82
|
milestone,
|
|
83
|
+
// SCHED-2: the slice it belongs to, a feature built across lanes, or null.
|
|
84
|
+
slice: field(body, 'Slice') || null,
|
|
83
85
|
});
|
|
84
86
|
});
|
|
85
87
|
}
|
package/hooks/lib/questions.mjs
CHANGED
|
@@ -35,16 +35,25 @@ function append(root, record) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Every question, oldest first: { id, at, by, question, why, story, blocks, options: [{ label, effect }],
|
|
38
|
+
* Every question, oldest first: { id, at, by, session, count, question, why, story, blocks, options: [{ label, effect }],
|
|
39
39
|
* state: 'open' | 'answered' | 'withdrawn', answer: { text, at, by, via } | null, withdrawn: { at, reason } | null }.
|
|
40
40
|
*/
|
|
41
41
|
export function readQuestions(root) {
|
|
42
42
|
const byId = new Map();
|
|
43
43
|
for (const r of records(root)) {
|
|
44
44
|
if (r.kind === 'ask')
|
|
45
|
-
byId.set(r.id, {
|
|
45
|
+
byId.set(r.id, {
|
|
46
|
+
...r,
|
|
47
|
+
kind: undefined,
|
|
48
|
+
session: r.session ?? null,
|
|
49
|
+
count: 1,
|
|
50
|
+
state: 'open',
|
|
51
|
+
answer: null,
|
|
52
|
+
withdrawn: null,
|
|
53
|
+
});
|
|
46
54
|
const q = byId.get(r.id);
|
|
47
55
|
if (!q || q.state !== 'open') continue;
|
|
56
|
+
if (r.kind === 'again') q.count += 1;
|
|
48
57
|
if (r.kind === 'answer') {
|
|
49
58
|
q.state = 'answered';
|
|
50
59
|
q.answer = { text: r.answer, at: r.at, by: r.by, via: r.via };
|
|
@@ -73,20 +82,29 @@ export function parseOption(text) {
|
|
|
73
82
|
*/
|
|
74
83
|
export function askQuestion(
|
|
75
84
|
root,
|
|
76
|
-
{ by, question, why = null, story = null, blocks = false, options = [] },
|
|
85
|
+
{ by, question, why = null, story = null, blocks = false, options = [], session = null },
|
|
77
86
|
now = Date.now(),
|
|
78
87
|
) {
|
|
79
88
|
const text = String(question).trim();
|
|
89
|
+
// OWN-3: the same question still open in the same session is one decision, asked again.
|
|
80
90
|
const open = openQuestions(root).find(
|
|
81
|
-
(q) =>
|
|
91
|
+
(q) =>
|
|
92
|
+
q.question === text &&
|
|
93
|
+
(q.story ?? null) === (story ?? null) &&
|
|
94
|
+
(q.session ?? null) === (session ?? null),
|
|
82
95
|
);
|
|
83
|
-
if (open)
|
|
96
|
+
if (open) {
|
|
97
|
+
append(root, { kind: 'again', id: open.id, at: new Date(now).toISOString() });
|
|
98
|
+
return { question: readQuestions(root).find((q) => q.id === open.id), existed: true };
|
|
99
|
+
}
|
|
84
100
|
const n = readQuestions(root).length + 1;
|
|
85
101
|
const record = {
|
|
86
102
|
kind: 'ask',
|
|
87
103
|
id: `Q-${n}`,
|
|
88
104
|
at: new Date(now).toISOString(),
|
|
89
105
|
by,
|
|
106
|
+
// OWN-1: the session that asked it: it's asked, answered and heard there only.
|
|
107
|
+
session: session ?? null,
|
|
90
108
|
question: text,
|
|
91
109
|
why: why?.trim() || null,
|
|
92
110
|
story: story ?? null,
|
package/hooks/lib/regression.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Regression (docs/specs/18-regression.md). REG-1: a fix comes with a test that fails without it, as the
|
|
2
2
|
// bug, and passes with it. REG-2: a test isn't weakened (deleted, skipped, cut down, its snapshot
|
|
3
|
-
// rewritten) without
|
|
3
|
+
// rewritten) without a `tests` approval: the lead's on a lane's branch where tests are delegated (spec 23,
|
|
4
|
+
// WHO-2), the person's otherwise and always on the lead's own. The commit hook
|
|
4
5
|
// refuses a weakening commit; verify finds what a branch weakened, from git.
|
|
5
6
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
6
7
|
import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
|
package/hooks/lib/rules.mjs
CHANGED
|
@@ -81,19 +81,21 @@ export const delegateCommand = (names, lane) =>
|
|
|
81
81
|
}>"`;
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* HELM-1: whether the lead grants approval `name` itself, by the project's delegated rules.
|
|
85
|
-
*
|
|
84
|
+
* HELM-1, WHO-1: whether the lead grants approval `name` itself, by the project's delegated rules. A
|
|
85
|
+
* package is the lead's where dependencies are delegated. WHO-2: a weakened test is the lead's on a lane's
|
|
86
|
+
* branch, where tests are delegated, and the person's on the lead's own: nobody else reviews those.
|
|
86
87
|
*/
|
|
87
|
-
export function leadDecides(name, config) {
|
|
88
|
+
export function leadDecides(name, config, actor) {
|
|
88
89
|
const rules = config?.approvals?.delegate;
|
|
89
|
-
if (!rules
|
|
90
|
+
if (!rules) return false;
|
|
91
|
+
if (name === 'tests') return rules.tests === true && actor?.kind === 'lane';
|
|
90
92
|
if (String(name).startsWith(DEPENDENCY_APPROVAL)) return Boolean(rules.dependencies);
|
|
91
93
|
return (rules.protected ?? []).includes(name);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
/** HELM-2: whose decision approvals `names` are: the lead's when it may grant every one, else the person's. */
|
|
95
|
-
export const decidedBy = (names, config) =>
|
|
96
|
-
[names].flat().every((n) => leadDecides(n, config)) ? 'lead' : 'person';
|
|
97
|
+
export const decidedBy = (names, config, actor) =>
|
|
98
|
+
[names].flat().every((n) => leadDecides(n, config, actor)) ? 'lead' : 'person';
|
|
97
99
|
|
|
98
100
|
/**
|
|
99
101
|
* HELM-2: what an actor refused for `names` does next, after the refusal's first line. A lane asks the
|
|
@@ -108,7 +110,7 @@ export function askHowTo(names, actor, root, config, { allows, need } = {}) {
|
|
|
108
110
|
(pkg
|
|
109
111
|
? 'the package, the story and requirement that need it, what the project already has that you checked, and anything lighter that would do'
|
|
110
112
|
: 'the file, the story and requirement that need the change, and what you checked first');
|
|
111
|
-
if (decidedBy(names, config) === 'lead') {
|
|
113
|
+
if (decidedBy(names, config, actor) === 'lead') {
|
|
112
114
|
if (actor?.kind === 'lead')
|
|
113
115
|
return `You decide this as the lead. Approve it only if the story's requirements need it${pkg ? ', nothing the project already depends on does the job, and it fits the principles and the layer it goes in' : ' and it keeps to the spec and the architecture'}:\n Decided by the lead: ${delegateCommand(names)}`;
|
|
114
116
|
return `The lead decides this, not the person. Hand the lead a change request and stop: ${brief}. The lead approves it, or tells you what to use instead.\n Decided by the lead: ${delegateCommand(names, lane)}`;
|
|
@@ -278,19 +280,21 @@ export function currentBranch(root) {
|
|
|
278
280
|
|
|
279
281
|
/** Who is acting: { kind: 'lead' | 'lane' | 'readonly', lane?, label }. */
|
|
280
282
|
export function actorFor(input, root, config) {
|
|
283
|
+
// OWN-1: the session it acts in, whose decisions what it's refused become.
|
|
284
|
+
const session = input.session_id ?? null;
|
|
281
285
|
const agent = input.agent_type;
|
|
282
286
|
if (agent) {
|
|
283
287
|
const lane = Object.keys(config.lanes).find((l) => config.lanes[l].agent === agent);
|
|
284
288
|
return lane
|
|
285
|
-
? { kind: 'lane', lane, label: `${agent} (${lane} lane)
|
|
286
|
-
: { kind: 'readonly', label: agent };
|
|
289
|
+
? { kind: 'lane', lane, label: `${agent} (${lane} lane)`, session }
|
|
290
|
+
: { kind: 'readonly', label: agent, session };
|
|
287
291
|
}
|
|
288
292
|
const laneFile = join(root, LANE_FILE);
|
|
289
293
|
if (existsSync(laneFile)) {
|
|
290
294
|
const lane = readFileSync(laneFile, 'utf8').trim();
|
|
291
|
-
return { kind: 'lane', lane, label: `${lane} lane worktree
|
|
295
|
+
return { kind: 'lane', lane, label: `${lane} lane worktree`, session };
|
|
292
296
|
}
|
|
293
|
-
return { kind: 'lead', label: 'lead session' };
|
|
297
|
+
return { kind: 'lead', label: 'lead session', session };
|
|
294
298
|
}
|
|
295
299
|
|
|
296
300
|
/** Where approvals live: the main checkout's approvals folder, shared by all of its worktrees. */
|
|
@@ -358,6 +362,7 @@ export function useApproval(root, name, actor) {
|
|
|
358
362
|
const requestsFile = (root) => join(projectWorktrees(root)[0] ?? root, REQUESTS_FILE);
|
|
359
363
|
const sameRequest = (a, b) =>
|
|
360
364
|
a.actor === b.actor &&
|
|
365
|
+
(a.session ?? null) === (b.session ?? null) &&
|
|
361
366
|
a.what === b.what &&
|
|
362
367
|
[...a.names].sort().join() === [...b.names].sort().join();
|
|
363
368
|
|
|
@@ -423,6 +428,7 @@ export function recordRequest(root, actor, names, what, why, now = Date.now(), e
|
|
|
423
428
|
at: new Date(now).toISOString(),
|
|
424
429
|
actor: actor?.label ?? 'unknown',
|
|
425
430
|
...(actor?.kind === 'lane' ? { lane: actor.lane } : {}),
|
|
431
|
+
...(actor?.session ? { session: actor.session } : {}),
|
|
426
432
|
names: [names].flat(),
|
|
427
433
|
what: String(what).slice(0, 200),
|
|
428
434
|
why: String(why).slice(0, 300),
|
|
@@ -761,7 +767,7 @@ export function writeProblem(actor, rel, root, config) {
|
|
|
761
767
|
if (guarded && actor.kind !== 'lead' && !approval(root, guarded.approval, actor)) {
|
|
762
768
|
recordRequest(root, actor, guarded.approval, rel, guarded.why);
|
|
763
769
|
const who =
|
|
764
|
-
decidedBy(guarded.approval, config) === 'lead'
|
|
770
|
+
decidedBy(guarded.approval, config, actor) === 'lead'
|
|
765
771
|
? "the lead's approval"
|
|
766
772
|
: "a person's approval";
|
|
767
773
|
return `${rel} is ${guarded.why} and needs ${who}.${noteFor(root, config, { kind: 'protected', name: guarded.approval, glob: guarded.glob })} ${askHowTo(guarded.approval, actor, root, config)}`;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// What starts next, lane by lane (docs/specs/23-the-team.md, SCHED-1 and SCHED-2), the pure parts. A lane
|
|
2
|
+
// runs one story at a time; the plan's graph is for running different lanes at once. Slices (a story's
|
|
3
|
+
// `**Slice:** <name>`, a feature that can be shown working across lanes) set the order, not a gate: a free
|
|
4
|
+
// lane takes a ready story in an open slice first, then one in the next slice while fewer than
|
|
5
|
+
// `harness.slices.open` are open, then any story outside a slice, and only then one ahead of its slice.
|
|
6
|
+
|
|
7
|
+
/** States a story is in once its work has started: its slice is open from then until all of it is done. */
|
|
8
|
+
const STARTED = new Set(['in progress', 'review', 'sent back', 'waiting on you', 'done']);
|
|
9
|
+
|
|
10
|
+
/** Each slice in plan order: [{ name, stories, open, done }]. */
|
|
11
|
+
export function slicesOf(stories) {
|
|
12
|
+
const byName = new Map();
|
|
13
|
+
for (const s of stories) {
|
|
14
|
+
if (!s.slice) continue;
|
|
15
|
+
if (!byName.has(s.slice)) byName.set(s.slice, []);
|
|
16
|
+
byName.get(s.slice).push(s);
|
|
17
|
+
}
|
|
18
|
+
return [...byName].map(([name, own]) => {
|
|
19
|
+
const done = own.every((s) => s.state === 'done');
|
|
20
|
+
return {
|
|
21
|
+
name,
|
|
22
|
+
stories: own.map((s) => s.id),
|
|
23
|
+
done,
|
|
24
|
+
open: !done && own.some((s) => STARTED.has(s.state)),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* SCHED-1, SCHED-2: at most one story for each free lane. `fixes` (sent back, which go first in their
|
|
31
|
+
* lane) and `ready` are story ids; `busy` the lanes running a story now. Returns { pick, ahead, waiting,
|
|
32
|
+
* opened }: the ids to dispatch, those of them ahead of their slice ([{ id, slice }]), the ready or sent-back
|
|
33
|
+
* ids left for a lane's next turn, and the slices a pick opens.
|
|
34
|
+
*/
|
|
35
|
+
export function pickPerLane(stories, { fixes = [], ready = [], busy = new Set(), open = 1 } = {}) {
|
|
36
|
+
const order = new Map(stories.map((s, i) => [s.id, i]));
|
|
37
|
+
const byId = new Map(stories.map((s) => [s.id, s]));
|
|
38
|
+
const slices = slicesOf(stories);
|
|
39
|
+
const opened = new Set(slices.filter((s) => s.open).map((s) => s.name));
|
|
40
|
+
const startOrder = [];
|
|
41
|
+
let room = Math.max(0, open - opened.size);
|
|
42
|
+
// The next slice in plan order that isn't open or done: the one a free lane may open.
|
|
43
|
+
const nextSlice = () => slices.find((s) => !s.done && !opened.has(s.name))?.name ?? null;
|
|
44
|
+
const rank = (id) => {
|
|
45
|
+
const slice = byId.get(id)?.slice ?? null;
|
|
46
|
+
if (slice && opened.has(slice)) return 0;
|
|
47
|
+
if (slice && room > 0 && slice === nextSlice()) return 1;
|
|
48
|
+
return slice ? 3 : 2;
|
|
49
|
+
};
|
|
50
|
+
const candidates = [...new Set([...fixes, ...ready])].filter((id) => byId.get(id)?.lane);
|
|
51
|
+
const lanes = [...new Set(candidates.map((id) => byId.get(id).lane))].sort(
|
|
52
|
+
(a, b) =>
|
|
53
|
+
Math.min(...candidates.filter((id) => byId.get(id).lane === a).map((id) => order.get(id))) -
|
|
54
|
+
Math.min(...candidates.filter((id) => byId.get(id).lane === b).map((id) => order.get(id))),
|
|
55
|
+
);
|
|
56
|
+
const pick = [];
|
|
57
|
+
const ahead = [];
|
|
58
|
+
// Lanes with something in an open slice choose first, so the feature in progress is never passed over.
|
|
59
|
+
const firstRank = (lane) =>
|
|
60
|
+
Math.min(...candidates.filter((id) => byId.get(id).lane === lane).map(rank));
|
|
61
|
+
for (const lane of [...lanes].sort((a, b) => firstRank(a) - firstRank(b))) {
|
|
62
|
+
if (busy.has(lane)) continue;
|
|
63
|
+
const own = candidates.filter((id) => byId.get(id).lane === lane);
|
|
64
|
+
const fix = own.find((id) => fixes.includes(id));
|
|
65
|
+
const chosen =
|
|
66
|
+
fix ??
|
|
67
|
+
[...own].sort((a, b) => rank(a) - rank(b) || order.get(a) - order.get(b)).find(Boolean);
|
|
68
|
+
if (!chosen) continue;
|
|
69
|
+
const r = fix ? null : rank(chosen);
|
|
70
|
+
const slice = byId.get(chosen).slice ?? null;
|
|
71
|
+
if (r === 1) {
|
|
72
|
+
opened.add(slice);
|
|
73
|
+
startOrder.push(slice);
|
|
74
|
+
room -= 1;
|
|
75
|
+
}
|
|
76
|
+
if (r === 3) ahead.push({ id: chosen, slice });
|
|
77
|
+
pick.push(chosen);
|
|
78
|
+
}
|
|
79
|
+
pick.sort((a, b) => order.get(a) - order.get(b));
|
|
80
|
+
return {
|
|
81
|
+
pick,
|
|
82
|
+
ahead,
|
|
83
|
+
waiting: candidates.filter((id) => !pick.includes(id)),
|
|
84
|
+
opened: startOrder,
|
|
85
|
+
};
|
|
86
|
+
}
|