@warren-dean/code-kit 0.21.0 → 0.22.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 +12 -0
- package/README.md +46 -23
- package/bin/code-kit.mjs +269 -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/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 +117 -40
- 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/templates/claude-md.md +3 -1
- package/templates/plan.md +2 -1
package/hooks/lane-audit.mjs
CHANGED
|
@@ -79,6 +79,26 @@ function takeNote(input) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* SESS-5: the files git said a command's checkout, merge or rebase would overwrite, when it refused for
|
|
84
|
+
* uncommitted work: [] when it didn't.
|
|
85
|
+
*/
|
|
86
|
+
export function overwrittenFiles(response) {
|
|
87
|
+
const text = typeof response === 'string' ? response : JSON.stringify(response ?? '');
|
|
88
|
+
const said = text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
|
89
|
+
const files = [];
|
|
90
|
+
for (const m of said.matchAll(
|
|
91
|
+
/(?:local changes to the following files|untracked working tree files) would be (?:overwritten|removed) by [\w-]+:\n((?:\t[^\n]+\n?)+)/g,
|
|
92
|
+
))
|
|
93
|
+
files.push(
|
|
94
|
+
...m[1]
|
|
95
|
+
.split('\n')
|
|
96
|
+
.map((l) => l.trim())
|
|
97
|
+
.filter(Boolean),
|
|
98
|
+
);
|
|
99
|
+
return [...new Set(files)];
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
export function auditProblems(input, config) {
|
|
83
103
|
const root = actorRoot(input);
|
|
84
104
|
if (!root) return [];
|
|
@@ -99,8 +119,25 @@ export function auditProblems(input, config) {
|
|
|
99
119
|
|
|
100
120
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
101
121
|
const { block, start } = await import('./lib/hook.mjs');
|
|
102
|
-
const { input, config, error } = start();
|
|
122
|
+
const { input, project, config, error } = start();
|
|
103
123
|
if (error) process.exit(0); // guard-paths and stop-check report an invalid config
|
|
124
|
+
// SESS-5: git refused because of uncommitted files another session wrote: say whose they are.
|
|
125
|
+
const overwritten = overwrittenFiles(input.tool_response);
|
|
126
|
+
if (overwritten.length) {
|
|
127
|
+
const { memberOf, writersOf } = await import('./lib/sessions.mjs');
|
|
128
|
+
const whose = Object.entries(writersOf(project, overwritten, input.session_id ?? null));
|
|
129
|
+
if (whose.length)
|
|
130
|
+
block(
|
|
131
|
+
`Git refused because of uncommitted files another session wrote in this checkout:\n ${whose
|
|
132
|
+
.map(
|
|
133
|
+
([f, w]) =>
|
|
134
|
+
`${f}: ${w.map((s) => `${memberOf(s)}${s.task ? `, working on "${s.task.title}"` : ''}${s.live ? '' : ' (ended)'}`).join('; ')}`,
|
|
135
|
+
)
|
|
136
|
+
.join(
|
|
137
|
+
'\n ',
|
|
138
|
+
)}\nAsk that session to commit or stash them; don't stash or discard another session's work. Better, work in a worktree of your own, where no other session's files can block you.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
104
141
|
const problems = auditProblems(input, config);
|
|
105
142
|
if (problems.length) {
|
|
106
143
|
block(
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -96,20 +96,38 @@ export function withDefaults(c) {
|
|
|
96
96
|
tests: c.tests ?? [],
|
|
97
97
|
regression: { prepare: c.regression?.prepare ?? null },
|
|
98
98
|
branches: { protected: c.branches?.protected ?? ['main', 'master'] },
|
|
99
|
-
approvals: {
|
|
99
|
+
approvals: {
|
|
100
|
+
lead: c.approvals?.lead === true,
|
|
101
|
+
delegate: delegateRules(c.approvals?.delegate, c.protected ?? []),
|
|
102
|
+
},
|
|
100
103
|
harness: harnessSettings(c.harness),
|
|
101
104
|
};
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
/** WHO-1: the approvals the person keeps when the config says nothing: product, design, CI and the kit. */
|
|
108
|
+
export const PERSON_KEEPS = ['spec', 'design', 'ci', 'kit'];
|
|
109
|
+
|
|
104
110
|
/**
|
|
105
111
|
* The rules within which the lead approves on its own (`approvals.delegate`), or null when it may not:
|
|
106
|
-
* the protected approvals it may grant,
|
|
112
|
+
* the protected approvals it may grant, the dependency rule packages must meet, merges, and weakened
|
|
113
|
+
* tests on a lane's branch. WHO-1: with no `approvals.delegate`, the lead decides everything technical:
|
|
114
|
+
* every protected approval but the person's (PERSON_KEEPS), dependencies within the registry's
|
|
115
|
+
* defaults, merges and weakened tests. `false` keeps everything for the person.
|
|
107
116
|
*/
|
|
108
|
-
function delegateRules(
|
|
109
|
-
if (
|
|
117
|
+
function delegateRules(given, protectedPaths = []) {
|
|
118
|
+
if (given === false) return null;
|
|
119
|
+
const d = given ?? {
|
|
120
|
+
protected: [...new Set(protectedPaths.map((p) => p.approval))].filter(
|
|
121
|
+
(a) => a && !PERSON_KEEPS.includes(a),
|
|
122
|
+
),
|
|
123
|
+
merge: true,
|
|
124
|
+
tests: true,
|
|
125
|
+
dependencies: {},
|
|
126
|
+
};
|
|
110
127
|
return {
|
|
111
128
|
protected: d.protected ?? [],
|
|
112
129
|
merge: d.merge === true,
|
|
130
|
+
tests: d.tests === true,
|
|
113
131
|
dependencies: d.dependencies
|
|
114
132
|
? {
|
|
115
133
|
licences: d.dependencies.licences ?? [
|
|
@@ -149,6 +167,129 @@ const isRegExp = (v) => {
|
|
|
149
167
|
* security reviewer (ONB-7, spec 17). A project set up before (no `setup`, or a lower one) is told what
|
|
150
168
|
* re-running init adds.
|
|
151
169
|
*/
|
|
170
|
+
/**
|
|
171
|
+
* WHO-3: who decides each approval, as the Settings tab's Approvals rows: one per protected approval
|
|
172
|
+
* (but the kit, which is always the person's), dependencies with the registry's rules, merges and
|
|
173
|
+
* weakened tests. Each row: { key, value, default, about, choices?, section: 'Approvals' }.
|
|
174
|
+
*/
|
|
175
|
+
export function approvalSettings(config) {
|
|
176
|
+
const rules = config.approvals.delegate;
|
|
177
|
+
const who = (lead) => (lead ? 'lead' : 'person');
|
|
178
|
+
const names = [...new Set(config.protected.map((p) => p.approval))].filter((n) => n !== 'kit');
|
|
179
|
+
const fallback = delegateRules(undefined, config.protected) ?? {};
|
|
180
|
+
const deps = rules?.dependencies ?? delegateRules({ dependencies: {} }).dependencies;
|
|
181
|
+
const row = (key, value, dflt, about, choices) => ({
|
|
182
|
+
key,
|
|
183
|
+
value,
|
|
184
|
+
default: dflt,
|
|
185
|
+
about,
|
|
186
|
+
section: 'Approvals',
|
|
187
|
+
...(choices ? { choices } : {}),
|
|
188
|
+
});
|
|
189
|
+
return [
|
|
190
|
+
...names.map((n) =>
|
|
191
|
+
row(
|
|
192
|
+
`approvals.${n}`,
|
|
193
|
+
who(rules?.protected.includes(n)),
|
|
194
|
+
who(fallback.protected?.includes(n)),
|
|
195
|
+
`Who approves a lane's change to what's protected as "${n}"`,
|
|
196
|
+
['lead', 'person'],
|
|
197
|
+
),
|
|
198
|
+
),
|
|
199
|
+
row(
|
|
200
|
+
'approvals.dependencies',
|
|
201
|
+
who(Boolean(rules?.dependencies)),
|
|
202
|
+
'lead',
|
|
203
|
+
"Who approves a lane's new package: the lead within the registry's rules, or you",
|
|
204
|
+
['lead', 'person'],
|
|
205
|
+
),
|
|
206
|
+
row(
|
|
207
|
+
'approvals.dependencies.licences',
|
|
208
|
+
deps.licences.join(','),
|
|
209
|
+
'MIT,Apache-2.0,BSD-2-Clause,BSD-3-Clause,ISC',
|
|
210
|
+
'The licences a package the lead approves may have, comma-separated',
|
|
211
|
+
),
|
|
212
|
+
row(
|
|
213
|
+
'approvals.dependencies.minWeeklyDownloads',
|
|
214
|
+
deps.minWeeklyDownloads,
|
|
215
|
+
10_000,
|
|
216
|
+
'The weekly downloads a package the lead approves must have, at least',
|
|
217
|
+
),
|
|
218
|
+
row(
|
|
219
|
+
'approvals.dependencies.maxMonthsSinceRelease',
|
|
220
|
+
deps.maxMonthsSinceRelease,
|
|
221
|
+
18,
|
|
222
|
+
"Months since a package's last release, at most, for the lead to approve it",
|
|
223
|
+
),
|
|
224
|
+
row(
|
|
225
|
+
'approvals.dependencies.allowInstallScripts',
|
|
226
|
+
Array.isArray(deps.allowInstallScripts)
|
|
227
|
+
? deps.allowInstallScripts.join(',')
|
|
228
|
+
: deps.allowInstallScripts,
|
|
229
|
+
false,
|
|
230
|
+
'Install scripts a package the lead approves may run: true, false, or kinds (binary, build, other)',
|
|
231
|
+
),
|
|
232
|
+
row(
|
|
233
|
+
'approvals.merge',
|
|
234
|
+
who(Boolean(rules?.merge)),
|
|
235
|
+
'lead',
|
|
236
|
+
'Who merges a reviewed branch into the base: merging is not releasing',
|
|
237
|
+
['lead', 'person'],
|
|
238
|
+
),
|
|
239
|
+
row(
|
|
240
|
+
'approvals.tests',
|
|
241
|
+
who(Boolean(rules?.tests)),
|
|
242
|
+
'lead',
|
|
243
|
+
"Who approves a weakened test on a lane's branch, after review; on the lead's own branches it is always you",
|
|
244
|
+
['lead', 'person'],
|
|
245
|
+
),
|
|
246
|
+
];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** WHO-3: the raw config with one approval setting changed (a copy), the rules written out in full. */
|
|
250
|
+
export function withApprovalSetting(raw, config, key, text) {
|
|
251
|
+
const rules = config.approvals.delegate;
|
|
252
|
+
const deps = rules?.dependencies ?? null;
|
|
253
|
+
const delegate = {
|
|
254
|
+
protected: [...(rules?.protected ?? [])],
|
|
255
|
+
merge: Boolean(rules?.merge),
|
|
256
|
+
tests: Boolean(rules?.tests),
|
|
257
|
+
...(deps ? { dependencies: { ...deps } } : {}),
|
|
258
|
+
};
|
|
259
|
+
const lead = text === 'lead' ? true : text === 'person' ? false : null;
|
|
260
|
+
const rule = key.replace(/^approvals\./, '');
|
|
261
|
+
const depRule = rule.startsWith('dependencies.') ? rule.slice('dependencies.'.length) : null;
|
|
262
|
+
if (depRule) {
|
|
263
|
+
const base = delegate.dependencies ?? delegateRules({ dependencies: {} }).dependencies;
|
|
264
|
+
const list = String(text)
|
|
265
|
+
.split(',')
|
|
266
|
+
.map((x) => x.trim())
|
|
267
|
+
.filter(Boolean);
|
|
268
|
+
const value =
|
|
269
|
+
depRule === 'licences'
|
|
270
|
+
? list
|
|
271
|
+
: depRule === 'allowInstallScripts'
|
|
272
|
+
? text === 'true' || (text === 'false' ? false : list)
|
|
273
|
+
: /^\d+$/.test(text)
|
|
274
|
+
? Number(text)
|
|
275
|
+
: text;
|
|
276
|
+
delegate.dependencies = { ...base, [depRule]: value };
|
|
277
|
+
} else if (lead === null) return null;
|
|
278
|
+
else if (rule === 'dependencies')
|
|
279
|
+
lead
|
|
280
|
+
? (delegate.dependencies = deps ?? delegateRules({ dependencies: {} }).dependencies)
|
|
281
|
+
: delete delegate.dependencies;
|
|
282
|
+
else if (rule === 'merge' || rule === 'tests') delegate[rule] = lead;
|
|
283
|
+
else if (config.protected.some((p) => p.approval === rule) && rule !== 'kit')
|
|
284
|
+
delegate.protected = lead
|
|
285
|
+
? [...new Set([...delegate.protected, rule])]
|
|
286
|
+
: delegate.protected.filter((n) => n !== rule);
|
|
287
|
+
else return null;
|
|
288
|
+
const copy = structuredClone(raw);
|
|
289
|
+
copy.approvals = { ...(copy.approvals ?? {}), delegate };
|
|
290
|
+
return copy;
|
|
291
|
+
}
|
|
292
|
+
|
|
152
293
|
export const SETUP_VERSION = 3;
|
|
153
294
|
|
|
154
295
|
/** Every problem with a parsed config, as readable sentences; empty when it is valid. */
|
|
@@ -236,9 +377,11 @@ export function validate(c) {
|
|
|
236
377
|
const isCount = (v) => v === undefined || (Number.isInteger(v) && v >= 0);
|
|
237
378
|
need(
|
|
238
379
|
d === undefined ||
|
|
380
|
+
d === false ||
|
|
239
381
|
(isObj(d) &&
|
|
240
382
|
(d.protected === undefined || isList(d.protected)) &&
|
|
241
383
|
[undefined, true, false].includes(d.merge) &&
|
|
384
|
+
[undefined, true, false].includes(d.tests) &&
|
|
242
385
|
(dep === undefined ||
|
|
243
386
|
(isObj(dep) &&
|
|
244
387
|
(dep.licences === undefined || isList(dep.licences)) &&
|
|
@@ -247,7 +390,7 @@ export function validate(c) {
|
|
|
247
390
|
([undefined, true, false].includes(dep.allowInstallScripts) ||
|
|
248
391
|
(isList(dep.allowInstallScripts) &&
|
|
249
392
|
dep.allowInstallScripts.every((k) => ['binary', 'build', 'other'].includes(k))))))),
|
|
250
|
-
'"approvals.delegate" must be { protected?: [approval names], merge?: true|false, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts?: true|false|["binary","build","other"] } }',
|
|
393
|
+
'"approvals.delegate" must be false, or { protected?: [approval names], merge?: true|false, tests?: true|false, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts?: true|false|["binary","build","other"] } }',
|
|
251
394
|
);
|
|
252
395
|
need(
|
|
253
396
|
c.branches === undefined || isList(c.branches?.protected),
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Decisions owned by the session that raised them (docs/specs/23-the-team.md, OWN-1 to OWN-4). What the
|
|
2
|
+
// person settles comes from two logs code-kit already keeps, each record now carrying its session: the
|
|
3
|
+
// lead's questions (lib/questions.mjs) and the approvals a refusal asked for (lib/rules.mjs). This reads
|
|
4
|
+
// both as one list, each decision with its member, subject and context, so a session sees, asks and
|
|
5
|
+
// answers only its own, and the others only a count. A decision from before sessions were recorded
|
|
6
|
+
// belongs to no session and shows in any.
|
|
7
|
+
import { openQuestions } from './questions.mjs';
|
|
8
|
+
import { openRequests } from './rules.mjs';
|
|
9
|
+
import { memberOf, readSession, sessions, shortSession } from './sessions.mjs';
|
|
10
|
+
|
|
11
|
+
/** The stories that are done: a decision about one of them has lost its cause (OWN-4). */
|
|
12
|
+
const doneIn = (stories) =>
|
|
13
|
+
new Set((stories ?? []).filter((s) => s.state === 'done').map((s) => s.id));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every open decision, oldest first: [{ id, kind, session, member, subject, what, context, options,
|
|
17
|
+
* blocks, count, opened, source }]. `kind` is `answer` (a question for the person) or `approve`.
|
|
18
|
+
*/
|
|
19
|
+
export function openDecisions(root, { stories = [] } = {}, now = Date.now()) {
|
|
20
|
+
const done = doneIn(stories);
|
|
21
|
+
const byId = new Map(sessions(root, now).map((s) => [s.id, s]));
|
|
22
|
+
const member = (session) =>
|
|
23
|
+
session ? (memberOf(byId.get(session)) ?? shortSession(session)) : null;
|
|
24
|
+
const questions = openQuestions(root)
|
|
25
|
+
.filter((q) => !(q.story && done.has(q.story)))
|
|
26
|
+
.map((q) => ({
|
|
27
|
+
id: q.id,
|
|
28
|
+
kind: 'answer',
|
|
29
|
+
session: q.session ?? null,
|
|
30
|
+
member: member(q.session),
|
|
31
|
+
subject: q.story ?? null,
|
|
32
|
+
what: q.question,
|
|
33
|
+
context: q.why ?? null,
|
|
34
|
+
options: q.options ?? [],
|
|
35
|
+
blocks: Boolean(q.blocks),
|
|
36
|
+
count: q.count ?? 1,
|
|
37
|
+
opened: q.at,
|
|
38
|
+
source: q,
|
|
39
|
+
}));
|
|
40
|
+
const requests = openRequests(root, now).map((r) => ({
|
|
41
|
+
id: `R:${r.at}`,
|
|
42
|
+
kind: 'approve',
|
|
43
|
+
session: r.session ?? null,
|
|
44
|
+
member: member(r.session),
|
|
45
|
+
subject: r.lane ?? null,
|
|
46
|
+
what: r.what,
|
|
47
|
+
context: r.why ?? null,
|
|
48
|
+
options: [],
|
|
49
|
+
blocks: true,
|
|
50
|
+
count: 1,
|
|
51
|
+
opened: r.at,
|
|
52
|
+
source: r,
|
|
53
|
+
}));
|
|
54
|
+
return [...questions, ...requests].sort((a, b) => Date.parse(a.opened) - Date.parse(b.opened));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The sessions whose decisions `session` answers for: its own, and those whose task it took over. */
|
|
58
|
+
function ownedBy(root, session) {
|
|
59
|
+
const me = readSession(root, session);
|
|
60
|
+
return new Set([session, ...(me?.took ?? [])]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* OWN-1: the decisions `session` sees and answers ({ mine }), and the count waiting in each other live
|
|
65
|
+
* session ({ elsewhere: [{ session, member, count }] }). A decision of a session that has ended stays with
|
|
66
|
+
* it: it isn't handed to another session's lead.
|
|
67
|
+
*/
|
|
68
|
+
export function decisionsFor(root, session, context = {}, now = Date.now()) {
|
|
69
|
+
const all = openDecisions(root, context, now);
|
|
70
|
+
const own = ownedBy(root, session);
|
|
71
|
+
const live = new Set(
|
|
72
|
+
sessions(root, now)
|
|
73
|
+
.filter((s) => s.live)
|
|
74
|
+
.map((s) => s.id),
|
|
75
|
+
);
|
|
76
|
+
const mine = all.filter((d) => !d.session || own.has(d.session));
|
|
77
|
+
const counts = new Map();
|
|
78
|
+
for (const d of all)
|
|
79
|
+
if (d.session && !own.has(d.session) && live.has(d.session))
|
|
80
|
+
counts.set(d.session, (counts.get(d.session) ?? 0) + 1);
|
|
81
|
+
return {
|
|
82
|
+
mine,
|
|
83
|
+
elsewhere: [...counts].map(([s, count]) => ({
|
|
84
|
+
session: s,
|
|
85
|
+
member: all.find((d) => d.session === s)?.member ?? shortSession(s),
|
|
86
|
+
count,
|
|
87
|
+
})),
|
|
88
|
+
};
|
|
89
|
+
}
|
package/hooks/lib/harness.mjs
CHANGED
|
@@ -71,6 +71,13 @@ export const SETTINGS = {
|
|
|
71
71
|
about:
|
|
72
72
|
"The context a lane agent may reach before it's asked to wrap up and hand its story to a fresh agent; 0 for no limit",
|
|
73
73
|
},
|
|
74
|
+
'slices.open': {
|
|
75
|
+
default: 1,
|
|
76
|
+
problem: (v) =>
|
|
77
|
+
Number.isInteger(v) && v >= 1 && v <= 10 ? null : 'must be a whole number from 1 to 10',
|
|
78
|
+
about:
|
|
79
|
+
"How many slices are in focus at once: a free lane builds an open slice's stories first, and may start the next slice only while fewer are open",
|
|
80
|
+
},
|
|
74
81
|
'background.pauseAtPercent': {
|
|
75
82
|
default: 80,
|
|
76
83
|
problem: (v) =>
|
package/hooks/lib/hook.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { loadConfig } from './config.mjs';
|
|
4
4
|
import { projectDir } from './rules.mjs';
|
|
5
|
+
import { seeSession } from './sessions.mjs';
|
|
5
6
|
|
|
6
7
|
/** Exit 2 with a message: Claude Code blocks the call and shows Claude the reason. */
|
|
7
8
|
export function block(message) {
|
|
@@ -15,5 +16,11 @@ export function start() {
|
|
|
15
16
|
const project = projectDir();
|
|
16
17
|
const { config, error } = loadConfig(project);
|
|
17
18
|
if (!config && !error) process.exit(0);
|
|
19
|
+
// SESS-1: the session acting is still there; one code-kit hadn't seen starts here.
|
|
20
|
+
try {
|
|
21
|
+
seeSession(project, input.session_id);
|
|
22
|
+
} catch {
|
|
23
|
+
// a read-only checkout: the session goes unrecorded
|
|
24
|
+
}
|
|
18
25
|
return { input, project, config, error };
|
|
19
26
|
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from './standards.mjs';
|
|
15
15
|
|
|
16
16
|
/** The version of code-kit's instructions: raised whenever the templates' rules change. */
|
|
17
|
-
export const INSTRUCTIONS_VERSION =
|
|
17
|
+
export const INSTRUCTIONS_VERSION = 7;
|
|
18
18
|
|
|
19
19
|
const TEMPLATES = fileURLToPath(new URL('../../templates/', import.meta.url));
|
|
20
20
|
const BEGIN = (v) => `<!-- code-kit:begin v${v} -->`;
|
|
@@ -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';
|