@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
package/bin/code-kit.mjs
CHANGED
|
@@ -34,6 +34,13 @@
|
|
|
34
34
|
// settings [--json] | settings set <key> <value> --reason … [--via pane]
|
|
35
35
|
// the harness settings; set is the person's own change
|
|
36
36
|
// record approvals a person gave in chat (with approvals.lead on, the lead runs it)
|
|
37
|
+
// decisions [--json] [--all] what waits on the person in this session, and a count of each other's
|
|
38
|
+
// who which session this is, whether it leads, and its task (spec 23)
|
|
39
|
+
// sessions [--json] | sessions lead the sessions working the project; make this one the lead
|
|
40
|
+
// task "<what>" --touches globs | task done | task take <session>
|
|
41
|
+
// a working session's ad hoc task and the paths it leases
|
|
42
|
+
// lanes [--json] | lanes release <lane>
|
|
43
|
+
// the lanes running a story now, one each across sessions (spec 23)
|
|
37
44
|
// --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
|
|
38
45
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
39
46
|
import {
|
|
@@ -52,10 +59,12 @@ import { BASELINE_FILE, loadBaseline } from '../hooks/lib/baseline.mjs';
|
|
|
52
59
|
import { matchesAny } from '../hooks/lib/glob.mjs';
|
|
53
60
|
import {
|
|
54
61
|
CONFIG_FILE,
|
|
62
|
+
approvalSettings,
|
|
55
63
|
committedConfig,
|
|
56
64
|
effectiveConfig,
|
|
57
65
|
mergeDelegation,
|
|
58
66
|
validate,
|
|
67
|
+
withApprovalSetting,
|
|
59
68
|
} from '../hooks/lib/config.mjs';
|
|
60
69
|
import { diffConfigs, ownershipMoves } from '../hooks/lib/diff.mjs';
|
|
61
70
|
import { CODE, importsOf, layerOf, layerProblems, targetOf } from '../hooks/lib/layers.mjs';
|
|
@@ -133,6 +142,20 @@ import { dependencyRuleProblems, npmFacts, packageOf } from '../hooks/lib/regist
|
|
|
133
142
|
import { heldTo, laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
|
|
134
143
|
import { skippedLine } from '../hooks/lib/checks.mjs';
|
|
135
144
|
import { TESTS_APPROVAL } from '../hooks/lib/regression.mjs';
|
|
145
|
+
import { laneClaim, laneClaims, releaseLane, shortSession } from '../hooks/lib/laneclaims.mjs';
|
|
146
|
+
import { slicesOf } from '../hooks/lib/schedule.mjs';
|
|
147
|
+
import {
|
|
148
|
+
leadSession,
|
|
149
|
+
makeLead,
|
|
150
|
+
memberOf,
|
|
151
|
+
readSession,
|
|
152
|
+
seeSession,
|
|
153
|
+
sessions,
|
|
154
|
+
setTask,
|
|
155
|
+
takeTask,
|
|
156
|
+
} from '../hooks/lib/sessions.mjs';
|
|
157
|
+
import { globsOverlap } from '../hooks/lib/teamfacts.mjs';
|
|
158
|
+
import { decisionsFor, openDecisions } from '../hooks/lib/decisionstore.mjs';
|
|
136
159
|
import {
|
|
137
160
|
appliesTo,
|
|
138
161
|
projectStandards,
|
|
@@ -166,6 +189,10 @@ const laneName = option('--lane');
|
|
|
166
189
|
const intoName = option('--into');
|
|
167
190
|
const via = option('--via');
|
|
168
191
|
const sessionId = option('--session');
|
|
192
|
+
// SESS-1: the session calling, as Claude Code tells every command an agent runs, unless --session says.
|
|
193
|
+
const callerSession = sessionId ?? process.env.CLAUDE_CODE_SESSION_ID ?? null;
|
|
194
|
+
// A provider's command hears which session is calling, for that session's own live link.
|
|
195
|
+
if (callerSession) process.env.CODE_KIT_SESSION = callerSession;
|
|
169
196
|
const minutesGiven = option('--minutes');
|
|
170
197
|
const answerGiven = option('--answer');
|
|
171
198
|
const leaseGiven = option('--lease');
|
|
@@ -279,6 +306,23 @@ function check() {
|
|
|
279
306
|
}
|
|
280
307
|
}
|
|
281
308
|
|
|
309
|
+
/** SESS-1: which session the caller is, whether it leads the project, and its task. */
|
|
310
|
+
function whoAmI() {
|
|
311
|
+
load();
|
|
312
|
+
const root = resolve('.');
|
|
313
|
+
const me = callerSession ? readSession(root, callerSession) : null;
|
|
314
|
+
if (!me)
|
|
315
|
+
return out(
|
|
316
|
+
callerSession
|
|
317
|
+
? `Session ${shortSession(callerSession)}, which code-kit hasn't recorded yet.`
|
|
318
|
+
: 'Not run from a Claude Code session, so no member of the team.',
|
|
319
|
+
);
|
|
320
|
+
const lead = leadSession(root);
|
|
321
|
+
out(
|
|
322
|
+
`${memberOf(me)}: ${me.role === 'lead' ? 'the lead session' : `a working session${lead ? ` (${memberOf(lead)} leads)` : ' (no session leads now)'}`}${me.task ? `, working on "${me.task.title}"` : ''}.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
282
326
|
function who(paths) {
|
|
283
327
|
const { config } = load();
|
|
284
328
|
for (const p of paths) {
|
|
@@ -1021,7 +1065,7 @@ function requests() {
|
|
|
1021
1065
|
const open = openRequests(resolve('.')).map((r, i) => ({
|
|
1022
1066
|
...r,
|
|
1023
1067
|
n: i + 1,
|
|
1024
|
-
by: decidedBy(r.names, config),
|
|
1068
|
+
by: decidedBy(r.names, config, r.lane ? { kind: 'lane', lane: r.lane } : { kind: 'lead' }),
|
|
1025
1069
|
}));
|
|
1026
1070
|
const granted = approvalsInForce(resolve('.'));
|
|
1027
1071
|
// CARD-6: every approval given, newest first.
|
|
@@ -1045,6 +1089,159 @@ function requests() {
|
|
|
1045
1089
|
);
|
|
1046
1090
|
}
|
|
1047
1091
|
|
|
1092
|
+
/**
|
|
1093
|
+
* OWN-1: the decisions waiting on the person in this session (the lead's questions, the approvals its
|
|
1094
|
+
* refusals asked for), and a count of those in each other session; `--all` lists every session's.
|
|
1095
|
+
*/
|
|
1096
|
+
function decisionsCommand() {
|
|
1097
|
+
const { config } = load();
|
|
1098
|
+
const root = resolve('.');
|
|
1099
|
+
if (rest.length) die('code-kit decisions [--json] [--all]');
|
|
1100
|
+
const stories =
|
|
1101
|
+
config.docs?.specs && config.docs?.plan
|
|
1102
|
+
? buildStatus('.', config, base(config, false), tracked()).stories
|
|
1103
|
+
: [];
|
|
1104
|
+
if (flag('--all')) {
|
|
1105
|
+
const all = openDecisions(root, { stories });
|
|
1106
|
+
if (flag('--json')) return out(JSON.stringify(all, null, 2));
|
|
1107
|
+
if (!all.length) return out('Nothing waits on the person.');
|
|
1108
|
+
for (const d of all)
|
|
1109
|
+
out(
|
|
1110
|
+
`${pad(d.member ?? 'any session', 30)} ${pad(d.kind, 8)} ${d.what}${d.count > 1 ? ` (asked ${d.count} times)` : ''}`,
|
|
1111
|
+
);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
const r = decisionsFor(root, callerSession, { stories });
|
|
1115
|
+
if (flag('--json')) return out(JSON.stringify(r, null, 2));
|
|
1116
|
+
if (!r.mine.length) out('Nothing waits on the person in this session.');
|
|
1117
|
+
for (const d of r.mine)
|
|
1118
|
+
out(`${pad(d.kind, 8)} ${d.what}${d.count > 1 ? ` (asked ${d.count} times)` : ''}`);
|
|
1119
|
+
for (const e of r.elsewhere)
|
|
1120
|
+
out(`${e.count} decision${e.count === 1 ? '' : 's'} in session ${e.member}, answered there.`);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* SESS-1, SESS-2: the sessions working the project, which one leads, and each one's task; `beat` (the mod,
|
|
1125
|
+
* while a session is open) says the session is still there; `lead` makes the caller's session the lead.
|
|
1126
|
+
*/
|
|
1127
|
+
function sessionsCommand() {
|
|
1128
|
+
load();
|
|
1129
|
+
const root = resolve('.');
|
|
1130
|
+
const [sub] = rest;
|
|
1131
|
+
if (sub === 'beat' || sub === 'lead') {
|
|
1132
|
+
if (!callerSession) die(`code-kit sessions ${sub} needs the session: --session <id>.`);
|
|
1133
|
+
if (sub === 'beat') return seeSession(root, callerSession) && out('Seen.');
|
|
1134
|
+
const was = leadSession(root);
|
|
1135
|
+
makeLead(root, callerSession);
|
|
1136
|
+
return out(
|
|
1137
|
+
`This session (${shortSession(callerSession)}) leads the project now${was && was.id !== callerSession ? `; ${shortSession(was.id)} is a working session` : ''}.`,
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
if (sub !== undefined) die('code-kit sessions [--json] | sessions lead | sessions beat');
|
|
1141
|
+
const all = sessions(root).filter((s) => s.live || s.task);
|
|
1142
|
+
if (flag('--json'))
|
|
1143
|
+
return out(
|
|
1144
|
+
JSON.stringify(
|
|
1145
|
+
all.map((s) => ({ ...s, member: memberOf(s) })),
|
|
1146
|
+
null,
|
|
1147
|
+
2,
|
|
1148
|
+
),
|
|
1149
|
+
);
|
|
1150
|
+
if (!all.length) return out('No session is working this project.');
|
|
1151
|
+
for (const s of all)
|
|
1152
|
+
out(
|
|
1153
|
+
`${pad(memberOf(s), 30)} ${pad(s.live ? s.role : 'ended', 8)} ${s.task ? `${s.task.title}${s.task.paths?.length ? ` (${s.task.paths.join(', ')})` : ''}${s.live ? '' : ', stale'}` : '-'}${s.id === callerSession ? ' ← this session' : ''}`,
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* SESS-4: an ad hoc task, for a session working without a story: recorded with the paths it leases, so
|
|
1159
|
+
* the other sessions' agents leave them alone; `done` ends it; `take <session>` moves a stale one here.
|
|
1160
|
+
*/
|
|
1161
|
+
function taskCommand() {
|
|
1162
|
+
load();
|
|
1163
|
+
const root = resolve('.');
|
|
1164
|
+
const [sub] = rest;
|
|
1165
|
+
if (!callerSession)
|
|
1166
|
+
die(
|
|
1167
|
+
'code-kit task needs the session: run it from Claude Code (CLAUDE_CODE_SESSION_ID), or pass --session <id>.',
|
|
1168
|
+
);
|
|
1169
|
+
if (sub === 'done') {
|
|
1170
|
+
const had = readSession(root, callerSession)?.task;
|
|
1171
|
+
if (!had) return out('This session has no task.');
|
|
1172
|
+
setTask(root, callerSession, null);
|
|
1173
|
+
return out(`Done: "${had.title}". Its paths are free for other sessions.`);
|
|
1174
|
+
}
|
|
1175
|
+
if (sub === 'take') {
|
|
1176
|
+
if (rest.length !== 2) die('code-kit task take <session>');
|
|
1177
|
+
const r = takeTask(root, rest[1], callerSession);
|
|
1178
|
+
if (!r.ok) die(r.why);
|
|
1179
|
+
return out(
|
|
1180
|
+
`This session took over "${r.task.title}" from ${memberOf(r.from)}${r.task.paths?.length ? `, leasing ${r.task.paths.join(', ')}` : ''}.`,
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
if (sub === undefined) {
|
|
1184
|
+
const s = readSession(root, callerSession);
|
|
1185
|
+
if (flag('--json')) return out(JSON.stringify(s?.task ?? null));
|
|
1186
|
+
return out(
|
|
1187
|
+
s?.task
|
|
1188
|
+
? `"${s.task.title}"${s.task.paths?.length ? `, leasing ${s.task.paths.join(', ')}` : ''}.`
|
|
1189
|
+
: 'This session has no task.',
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
if (rest.length !== 1)
|
|
1193
|
+
die(
|
|
1194
|
+
'code-kit task "<what you\'re doing>" --touches "<globs>" | task done | task take <session>',
|
|
1195
|
+
);
|
|
1196
|
+
const paths = (touchesGiven ?? '')
|
|
1197
|
+
.split(',')
|
|
1198
|
+
.map((g) => g.trim())
|
|
1199
|
+
.filter(Boolean);
|
|
1200
|
+
const clash = sessions(root).find(
|
|
1201
|
+
(s) =>
|
|
1202
|
+
s.live &&
|
|
1203
|
+
s.id !== callerSession &&
|
|
1204
|
+
s.task?.paths?.some((p) => paths.some((q) => globsOverlap(p, q))),
|
|
1205
|
+
);
|
|
1206
|
+
if (clash)
|
|
1207
|
+
die(
|
|
1208
|
+
`Another session (${memberOf(clash)}) is working on "${clash.task.title}", leasing ${clash.task.paths.join(', ')}. Pick paths it doesn't lease, or wait until it's done.`,
|
|
1209
|
+
);
|
|
1210
|
+
const branch = currentBranch(root) ?? null;
|
|
1211
|
+
setTask(root, callerSession, { title: sub.trim(), paths, branch });
|
|
1212
|
+
out(
|
|
1213
|
+
`This session is working on "${sub.trim()}"${paths.length ? `, leasing ${paths.join(', ')}` : ''}. \`code-kit task done\` when it's finished.`,
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* SCHED-1: the lanes claimed now, one story each across every session, or one lane released: for an
|
|
1219
|
+
* agent the lead saw stop without finishing, so its lane can take the next story.
|
|
1220
|
+
*/
|
|
1221
|
+
function lanesCommand() {
|
|
1222
|
+
const { config } = load();
|
|
1223
|
+
const root = resolve('.');
|
|
1224
|
+
if (rest[0] === 'release') {
|
|
1225
|
+
const lane = rest[1];
|
|
1226
|
+
if (!lane || rest.length !== 2) die('code-kit lanes release <lane>');
|
|
1227
|
+
if (!config.lanes[lane])
|
|
1228
|
+
die(`There is no lane "${lane}". Lanes: ${Object.keys(config.lanes).join(', ')}.`);
|
|
1229
|
+
const held = laneClaim(root, lane);
|
|
1230
|
+
if (!releaseLane(root, lane)) return out(`The ${lane} lane isn't claimed.`);
|
|
1231
|
+
return out(
|
|
1232
|
+
`Released the ${lane} lane${held.story ? ` (it was running ${held.story})` : ''}. Its next story can start.`,
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
if (rest.length) die('code-kit lanes [--json] | lanes release <lane>');
|
|
1236
|
+
const claims = laneClaims(root);
|
|
1237
|
+
if (flag('--json')) return out(JSON.stringify(claims, null, 2));
|
|
1238
|
+
if (!claims.length) return out('No lane is running a story.');
|
|
1239
|
+
for (const c of claims)
|
|
1240
|
+
out(
|
|
1241
|
+
`${c.lane.padEnd(12)} ${(c.story ?? 'a story').padEnd(8)} session ${shortSession(c.session) ?? '?'}, since ${c.since.slice(0, 16).replace('T', ' ')} UTC${c.stale ? ' (stale: no agent seen to finish in 12 hours)' : ''}`,
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1048
1245
|
/**
|
|
1049
1246
|
* The harness settings (`harness` in the config): listed, or one changed as the person's own act. The
|
|
1050
1247
|
* change is written to the config and given a kit approval with the person's reason, so the commit that
|
|
@@ -1056,33 +1253,50 @@ function settings() {
|
|
|
1056
1253
|
const [, key, value] = rest;
|
|
1057
1254
|
if (!key || value === undefined)
|
|
1058
1255
|
die('code-kit settings set <key> <value> --reason "<why>" [--via pane]');
|
|
1059
|
-
|
|
1060
|
-
|
|
1256
|
+
const approvalKey = key.startsWith('approvals.');
|
|
1257
|
+
if (!(key in SETTINGS) && !approvalKey)
|
|
1258
|
+
die(
|
|
1259
|
+
`${key} isn't a setting. They are: ${Object.keys(SETTINGS).join(', ')}, and approvals.<name> (see code-kit settings).`,
|
|
1260
|
+
);
|
|
1061
1261
|
if (!reason?.trim()) die('Give a reason with --reason "…": the approval log keeps it.');
|
|
1062
|
-
|
|
1262
|
+
// WHO-3: who decides an approval is the person's setting too, written out in full.
|
|
1263
|
+
const changed = approvalKey
|
|
1264
|
+
? withApprovalSetting(raw, config, key, value)
|
|
1265
|
+
: withSetting(raw, key, parseSetting(key, value));
|
|
1266
|
+
if (!changed)
|
|
1267
|
+
die(
|
|
1268
|
+
`${key} isn't an approval setting, or ${JSON.stringify(value)} isn't one of its values. See code-kit settings.`,
|
|
1269
|
+
);
|
|
1063
1270
|
const problems = validate(changed);
|
|
1064
1271
|
if (problems.length) die(`Nothing changed: ${problems.join('; ')}`);
|
|
1065
1272
|
writeFileSync(resolve(file), `${JSON.stringify(changed, null, 2)}\n`);
|
|
1066
1273
|
const mark =
|
|
1067
1274
|
via === 'pane' ? '(changed in the code-kit pane)' : '(changed with code-kit settings)';
|
|
1068
1275
|
grantApprovals(resolve('.'), ['kit'], null, reason, { kit: mark });
|
|
1276
|
+
const now = approvalKey
|
|
1277
|
+
? approvalSettings(effectiveConfig(changed, resolve('.'))).find((r) => r.key === key)?.value
|
|
1278
|
+
: settingOf(changed.harness, key);
|
|
1069
1279
|
return out(
|
|
1070
|
-
`Set harness.${key} to ${JSON.stringify(
|
|
1280
|
+
`Set ${approvalKey ? key : `harness.${key}`} to ${JSON.stringify(now)}. Commit ${file} to keep it; the approval log will carry your reason.`,
|
|
1071
1281
|
);
|
|
1072
1282
|
}
|
|
1073
1283
|
if (rest.length) die('code-kit settings [--json] | settings set <key> <value> --reason "<why>"');
|
|
1074
|
-
const rows =
|
|
1075
|
-
key,
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1284
|
+
const rows = [
|
|
1285
|
+
...Object.entries(SETTINGS).map(([key, s]) => ({
|
|
1286
|
+
key,
|
|
1287
|
+
value: settingOf(config.harness, key),
|
|
1288
|
+
default: s.default,
|
|
1289
|
+
about: s.about,
|
|
1290
|
+
section: 'Harness',
|
|
1291
|
+
// Chosen in the config, or the default standing in.
|
|
1292
|
+
set: settingOf(raw.harness, key) !== undefined,
|
|
1293
|
+
})),
|
|
1294
|
+
...approvalSettings(config),
|
|
1295
|
+
];
|
|
1082
1296
|
if (flag('--json')) return out(JSON.stringify(rows, null, 2));
|
|
1083
1297
|
for (const r of rows)
|
|
1084
1298
|
out(
|
|
1085
|
-
`${r.key.padEnd(
|
|
1299
|
+
`${r.key.padEnd(44)} ${JSON.stringify(r.value).padEnd(14)} ${r.value === r.default ? '' : `(default ${JSON.stringify(r.default)}) `}${r.about}`,
|
|
1086
1300
|
);
|
|
1087
1301
|
}
|
|
1088
1302
|
|
|
@@ -1264,7 +1478,14 @@ function status() {
|
|
|
1264
1478
|
if (flag('--json')) {
|
|
1265
1479
|
out(
|
|
1266
1480
|
JSON.stringify(
|
|
1267
|
-
{
|
|
1481
|
+
{
|
|
1482
|
+
base: from,
|
|
1483
|
+
stories,
|
|
1484
|
+
slices: slicesOf(stories),
|
|
1485
|
+
requirements: byRequirement,
|
|
1486
|
+
problems,
|
|
1487
|
+
drift,
|
|
1488
|
+
},
|
|
1268
1489
|
null,
|
|
1269
1490
|
2,
|
|
1270
1491
|
),
|
|
@@ -1282,9 +1503,18 @@ function status() {
|
|
|
1282
1503
|
? s.branch
|
|
1283
1504
|
: '';
|
|
1284
1505
|
out(
|
|
1285
|
-
` ${pad(s.id, 7)} ${pad(s.state, 12)} ${pad(s.lane || '-', 10)} ${pad(s.requirements.join(', '), 24)} ${detail}`.trimEnd(),
|
|
1506
|
+
` ${pad(s.id, 7)} ${pad(s.state, 12)} ${pad(s.lane || '-', 10)} ${pad(s.requirements.join(', '), 24)} ${detail}${s.slice ? `${detail ? ' · ' : ''}slice ${s.slice}` : ''}`.trimEnd(),
|
|
1286
1507
|
);
|
|
1287
1508
|
}
|
|
1509
|
+
// SCHED-2: the slices, in plan order, and which are open.
|
|
1510
|
+
const slices = slicesOf(stories);
|
|
1511
|
+
if (slices.length) {
|
|
1512
|
+
out('\nSlices:');
|
|
1513
|
+
for (const sl of slices)
|
|
1514
|
+
out(
|
|
1515
|
+
` ${pad(sl.name, 24)} ${pad(sl.done ? 'done' : sl.open ? 'open' : 'not started', 12)} ${sl.stories.join(', ')}`,
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1288
1518
|
out('\nRequirements:');
|
|
1289
1519
|
for (const r of byRequirement) {
|
|
1290
1520
|
const tests = r.tests.length ? `tested (${r.tests.join(', ')})` : 'no test names it';
|
|
@@ -1404,7 +1634,7 @@ async function approve(given) {
|
|
|
1404
1634
|
const rules = config.approvals.delegate;
|
|
1405
1635
|
if (!rules)
|
|
1406
1636
|
die(
|
|
1407
|
-
'This project
|
|
1637
|
+
'This project keeps every approval for the person ("approvals.delegate" is false). Ask the person.',
|
|
1408
1638
|
);
|
|
1409
1639
|
// HELM-3: the lead approves a package for a story it has read, not by default.
|
|
1410
1640
|
const packages = names.filter((n) => n.startsWith(DEPENDENCY_APPROVAL));
|
|
@@ -1421,8 +1651,16 @@ async function approve(given) {
|
|
|
1421
1651
|
: null;
|
|
1422
1652
|
const outside = [];
|
|
1423
1653
|
for (const name of names) {
|
|
1654
|
+
// WHO-2: a weakened test on a lane's branch, after the lead's review; on its own, the person's.
|
|
1424
1655
|
if (name === TESTS_APPROVAL) {
|
|
1425
|
-
outside.push(`${name}:
|
|
1656
|
+
if (!rules.tests) outside.push(`${name}: weakened tests aren't delegated`);
|
|
1657
|
+
else if (!laneName)
|
|
1658
|
+
outside.push(
|
|
1659
|
+
`${name}: on your own branch a weakened test is the person's; you approve one only for a lane, with --lane`,
|
|
1660
|
+
);
|
|
1661
|
+
else if (!storyGiven)
|
|
1662
|
+
outside.push(`${name}: name the story whose branch weakens the test, with --story ST-n`);
|
|
1663
|
+
else marks[name] = delegatedMark(`tests, reviewed by the lead, for ${story.id}`);
|
|
1426
1664
|
continue;
|
|
1427
1665
|
}
|
|
1428
1666
|
if (name.startsWith(DEPENDENCY_APPROVAL)) {
|
|
@@ -1943,6 +2181,7 @@ function askCommand() {
|
|
|
1943
2181
|
if (flag('--blocks') && !storyGiven) die('--blocks holds a story: name it with --story ST-n.');
|
|
1944
2182
|
const r = askQuestion(root, {
|
|
1945
2183
|
by: 'lead',
|
|
2184
|
+
session: callerSession,
|
|
1946
2185
|
question,
|
|
1947
2186
|
why: whyGiven ?? null,
|
|
1948
2187
|
story: storyGiven ?? null,
|
|
@@ -2034,7 +2273,9 @@ function loopCommand() {
|
|
|
2034
2273
|
const r = claimLead(root, sessionId, { take: flag('--take') });
|
|
2035
2274
|
if (!r.ok)
|
|
2036
2275
|
die(
|
|
2037
|
-
|
|
2276
|
+
r.lead
|
|
2277
|
+
? `Another session (${shortSession(r.lead.session)}, seen ${r.lead.at}) leads this project and runs its loop. Leave it there, or lead from here with --take.`
|
|
2278
|
+
: 'No session leads this project now, and this is a working session: the loop runs only in the lead session. Lead from here with --take.',
|
|
2038
2279
|
);
|
|
2039
2280
|
return out('This session runs the lead loop.');
|
|
2040
2281
|
}
|
|
@@ -2043,9 +2284,11 @@ function loopCommand() {
|
|
|
2043
2284
|
'code-kit loop [--json] | loop pause | loop resume | loop lead --session <id> [--take] | loop release --session <id>',
|
|
2044
2285
|
);
|
|
2045
2286
|
const state = readLoop(root);
|
|
2287
|
+
// SESS-2: with --session, whether that session leads the project or works in it.
|
|
2288
|
+
const role = sessionId ? (readSession(root, sessionId)?.role ?? null) : null;
|
|
2046
2289
|
out(
|
|
2047
2290
|
flag('--json')
|
|
2048
|
-
? JSON.stringify(state)
|
|
2291
|
+
? JSON.stringify({ ...state, ...(sessionId ? { role } : {}) })
|
|
2049
2292
|
: `${state.paused ? `The lead loop is paused (since ${state.at}).` : 'The lead loop is running.'}${state.lead ? ` Session ${state.lead.session} runs it.` : ''}`,
|
|
2050
2293
|
);
|
|
2051
2294
|
}
|
|
@@ -2247,6 +2490,10 @@ const commands = {
|
|
|
2247
2490
|
requests,
|
|
2248
2491
|
stops,
|
|
2249
2492
|
settings,
|
|
2493
|
+
lanes: lanesCommand,
|
|
2494
|
+
sessions: sessionsCommand,
|
|
2495
|
+
decisions: decisionsCommand,
|
|
2496
|
+
task: taskCommand,
|
|
2250
2497
|
hold: () => (rest.length === 1 ? hold(rest[0]) : usage()),
|
|
2251
2498
|
queue,
|
|
2252
2499
|
team: teamCommand,
|
|
@@ -2269,7 +2516,7 @@ const commands = {
|
|
|
2269
2516
|
diff,
|
|
2270
2517
|
baseline,
|
|
2271
2518
|
graph,
|
|
2272
|
-
who: () => (rest.length ? who(rest) :
|
|
2519
|
+
who: () => (rest.length ? who(rest) : whoAmI()),
|
|
2273
2520
|
};
|
|
2274
2521
|
function usage() {
|
|
2275
2522
|
die(
|
|
@@ -2288,6 +2535,9 @@ function usage() {
|
|
|
2288
2535
|
' | report [--json] | report send|discard <id>\n' +
|
|
2289
2536
|
' | stops [--session id] [--json] | sent-back <branch> [--reason "…"]\n' +
|
|
2290
2537
|
' | settings [--json] | settings set <key> <value> --reason "…" [--via pane]\n' +
|
|
2538
|
+
' | lanes [--json] | lanes release <lane> | sessions [--json] | sessions lead\n' +
|
|
2539
|
+
' | who | task "<what>" --touches globs | task done | task take <session>\n' +
|
|
2540
|
+
' | decisions [--json] [--all]\n' +
|
|
2291
2541
|
' | hold <id> [--minutes N] | hold <id> --answer approved|refused\n' +
|
|
2292
2542
|
' | queue [--json] | queue add|drop <branch> | queue rules | queue merge --delegated|--person\n' +
|
|
2293
2543
|
' | loop [--json] | loop pause|resume | loop lead|release --session id [--take]\n' +
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse on Agent (docs/specs/23-the-team.md, SCHED-1): a lane runs one story at a time, across every
|
|
3
|
+
// session in the project. Starting a lane's agent claims the lane; a second agent for a claimed lane is
|
|
4
|
+
// refused, naming the story and the session that has it. Any other agent starts as before.
|
|
5
|
+
import { block, start } from './lib/hook.mjs';
|
|
6
|
+
import { CLI } from './lib/rules.mjs';
|
|
7
|
+
import { claimLane, shortSession } from './lib/laneclaims.mjs';
|
|
8
|
+
import { memberOf, readSession } from './lib/sessions.mjs';
|
|
9
|
+
|
|
10
|
+
const { input, project, config, error } = start();
|
|
11
|
+
// A broken config is reported by the other hooks; it doesn't stop agents starting.
|
|
12
|
+
if (error) process.exit(0);
|
|
13
|
+
const call = input.tool_input ?? {};
|
|
14
|
+
const lane = Object.entries(config.lanes).find(([, l]) => l.agent === call.subagent_type)?.[0];
|
|
15
|
+
if (!lane) process.exit(0);
|
|
16
|
+
const story =
|
|
17
|
+
String(call.description ?? '')
|
|
18
|
+
.match(/\bST-\d+\b/i)?.[0]
|
|
19
|
+
?.toUpperCase() ?? null;
|
|
20
|
+
const session = input.session_id ?? null;
|
|
21
|
+
const claimed = claimLane(project, { lane, story, session });
|
|
22
|
+
if (!claimed.ok) {
|
|
23
|
+
const c = claimed.claim;
|
|
24
|
+
// SESS-1: the session by its member and, where it has one, its task.
|
|
25
|
+
const holder = readSession(project, c.session);
|
|
26
|
+
const where =
|
|
27
|
+
c.session && c.session === session
|
|
28
|
+
? 'in this session'
|
|
29
|
+
: c.session
|
|
30
|
+
? `in another session (${holder ? memberOf(holder) : shortSession(c.session)}${holder?.task ? `, working on "${holder.task.title}"` : ''})`
|
|
31
|
+
: 'here';
|
|
32
|
+
block(
|
|
33
|
+
`Blocked: the ${lane} lane is already running ${c.story ?? 'a story'} ${where}, since ${c.since.slice(11, 16)} UTC. A lane runs one story at a time across the project: two agents in one lane build on the same files in their worktrees and conflict at merge.\n` +
|
|
34
|
+
`Start ${story ?? 'this story'} when ${c.story ?? 'that story'} is finished, or dispatch a ready story in another lane meanwhile. If that agent has stopped and the lane is free, release it first:\n node "${CLI}" lanes release ${lane}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
delegatedMark,
|
|
40
40
|
dependencyChange,
|
|
41
41
|
dependencyGrants,
|
|
42
|
+
leadDecides,
|
|
42
43
|
useApproval,
|
|
43
44
|
owns,
|
|
44
45
|
protectedEntry,
|
|
@@ -217,10 +218,14 @@ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(pause|resume)\b`)))
|
|
|
217
218
|
block(
|
|
218
219
|
`Blocked: pausing or resuming the lead loop is the person's own act, from the code-kit band or their terminal. Ask them, and stop.\nCommand: ${cmd}`,
|
|
219
220
|
);
|
|
220
|
-
// LOOP-10: which session
|
|
221
|
-
|
|
221
|
+
// LOOP-10, SESS-2: which session leads is recorded by code-kit's hooks and band, and choosing it is
|
|
222
|
+
// the person's own act.
|
|
223
|
+
if (
|
|
224
|
+
runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(lead|release)\b`)) ||
|
|
225
|
+
runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+sessions\s+(lead|beat)\b`))
|
|
226
|
+
)
|
|
222
227
|
block(
|
|
223
|
-
`Blocked: which session
|
|
228
|
+
`Blocked: which session leads the project is recorded by code-kit, and choosing it is the person's own act (Lead from here, in the code-kit band, or \`code-kit sessions lead\` in their terminal). Carry on with your work.\nCommand: ${cmd}`,
|
|
224
229
|
);
|
|
225
230
|
// TEAM-9: handing someone's claim to someone else is the tech lead's own decision too.
|
|
226
231
|
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+team\s+reassign\b`)))
|
|
@@ -282,7 +287,7 @@ if (delegatedMerge && !config.approvals.delegate?.merge)
|
|
|
282
287
|
const delegating = /\s--delegated\b/.test(cmd);
|
|
283
288
|
if (approves && delegating && !config.approvals.delegate)
|
|
284
289
|
block(
|
|
285
|
-
`Blocked: this project
|
|
290
|
+
`Blocked: this project keeps every approval for the person ("approvals.delegate" is false). Ask the person to approve it with the \`!\` command from the refusal.\nCommand: ${cmd}`,
|
|
286
291
|
);
|
|
287
292
|
if (approves && !delegating && !forPerson)
|
|
288
293
|
block(
|
|
@@ -311,7 +316,9 @@ if (packages.length) {
|
|
|
311
316
|
// HELM-2, HELM-3: a lane asks the lead, which judges the package on its merits where it decides.
|
|
312
317
|
const names = unapproved.map(dependencyApproval);
|
|
313
318
|
const whose =
|
|
314
|
-
decidedBy(names, config) === 'lead'
|
|
319
|
+
decidedBy(names, config, actor) === 'lead'
|
|
320
|
+
? "is the lead's decision"
|
|
321
|
+
: "needs a person's approval";
|
|
315
322
|
block(
|
|
316
323
|
`Blocked: a new dependency (${unapproved.join(', ')}) ${whose}. ${askHowTo(names, actor, sessionIn ?? project, config, { allows: 'installing it, and the manifest and lockfile changes that makes,' })}\nCommand: ${cmd}`,
|
|
317
324
|
);
|
|
@@ -520,7 +527,7 @@ function recordApprovals() {
|
|
|
520
527
|
if (entry && !granted && actor.kind !== 'lead' && !byDependency) {
|
|
521
528
|
recordRequest(root, actor, entry.approval, file, entry.why);
|
|
522
529
|
block(
|
|
523
|
-
`Blocked: ${file} is ${entry.why}; committing it needs ${decidedBy(entry.approval, config) === 'lead' ? "the lead's" : "a person's"} approval in force.${noteFor(root, config, { kind: 'protected', name: entry.approval, glob: entry.glob })} ${askHowTo(entry.approval, actor, root, config)}`,
|
|
530
|
+
`Blocked: ${file} is ${entry.why}; committing it needs ${decidedBy(entry.approval, config, actor) === 'lead' ? "the lead's" : "a person's"} approval in force.${noteFor(root, config, { kind: 'protected', name: entry.approval, glob: entry.glob })} ${askHowTo(entry.approval, actor, root, config)}`,
|
|
524
531
|
);
|
|
525
532
|
}
|
|
526
533
|
if (granted)
|
|
@@ -545,8 +552,9 @@ function recordApprovals() {
|
|
|
545
552
|
grantedAt: null,
|
|
546
553
|
});
|
|
547
554
|
}
|
|
548
|
-
// REG-2: a commit that weakens a test waits for
|
|
549
|
-
//
|
|
555
|
+
// REG-2: a commit that weakens a test waits for a `tests` approval: WHO-2, the lead's on a lane's
|
|
556
|
+
// branch where tests are delegated, otherwise the person's (always on the lead's own). It's recorded in
|
|
557
|
+
// the log, so verify accepts the branch, and used up by this commit.
|
|
550
558
|
const weakened = commitWeakenings(
|
|
551
559
|
root,
|
|
552
560
|
files.filter((f) => !arrivesWithMerge(root, f, { staged: true })),
|
|
@@ -558,16 +566,20 @@ function recordApprovals() {
|
|
|
558
566
|
if (!granted) {
|
|
559
567
|
const [w, ...more] = weakened;
|
|
560
568
|
recordRequest(root, actor, TESTS_APPROVAL, w.file, `A weakened test: ${said(w)}.`);
|
|
569
|
+
const leads = leadDecides(TESTS_APPROVAL, config, actor);
|
|
561
570
|
const personOnly = { ...config, approvals: { ...config.approvals, delegate: null } };
|
|
562
571
|
block(
|
|
563
|
-
`Blocked: ${w.file} is a weakened test (${said(w)}); committing it needs a person's approval in force.
|
|
572
|
+
`Blocked: ${w.file} is a weakened test (${said(w)}); committing it needs ${leads ? "the lead's" : "a person's"} approval in force.
|
|
564
573
|
` +
|
|
565
574
|
(more.length
|
|
566
575
|
? `Also weakened: ${more.map((x) => `${x.file} (${said(x)})`).join('; ')}.
|
|
567
576
|
`
|
|
568
577
|
: '') +
|
|
569
|
-
`A weakened test can hide a regression. Fix the code so the test passes as it was.
|
|
570
|
-
|
|
578
|
+
`A weakened test can hide a regression. Fix the code so the test passes as it was. ` +
|
|
579
|
+
(leads
|
|
580
|
+
? `If the test itself is wrong, the lead decides after reviewing it: ${askHowTo(TESTS_APPROVAL, actor, root, config, { allows: 'weakening tests in one commit', need: 'the test, what you weakened and why the test was wrong, and the story' })}`
|
|
581
|
+
: `If the test itself is wrong, ask the person: ${actor.kind === 'lead' ? "on your own branch nobody else reviews it, so you can't approve it." : "the lead can't approve it here."} They run
|
|
582
|
+
${approvalHowTo(TESTS_APPROVAL, actor, root, 'weakening tests in one commit', personOnly)}`),
|
|
571
583
|
);
|
|
572
584
|
}
|
|
573
585
|
for (const w of weakened)
|
package/hooks/guard-paths.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { brokenLayerRule, layerProblems } from './lib/layers.mjs';
|
|
|
10
10
|
import { noteFor } from './lib/standards.mjs';
|
|
11
11
|
import { block, start } from './lib/hook.mjs';
|
|
12
12
|
import { teamOf, teamProvider, teamRefusal, whoami } from './lib/team.mjs';
|
|
13
|
+
import { sessionWriteProblem } from './lib/sessions.mjs';
|
|
13
14
|
import { spawnSync } from 'node:child_process';
|
|
14
15
|
import {
|
|
15
16
|
actorFor,
|
|
@@ -38,6 +39,16 @@ if (error) {
|
|
|
38
39
|
const problem = writeProblem(actorFor(input, root, config), rel, root, config);
|
|
39
40
|
if (problem) block(`Blocked: ${problem}`);
|
|
40
41
|
|
|
42
|
+
// SESS-4, SESS-5: another session's task leases the path, or a working session writes where it shouldn't.
|
|
43
|
+
const shared = sessionWriteProblem({
|
|
44
|
+
project,
|
|
45
|
+
root,
|
|
46
|
+
rel,
|
|
47
|
+
actor: actorFor(input, root, config),
|
|
48
|
+
session: input.session_id ?? null,
|
|
49
|
+
});
|
|
50
|
+
if (shared) block(`Blocked: ${shared}`);
|
|
51
|
+
|
|
41
52
|
// TEAM-1, TEAM-4: in a team, the tech lead's paths, other people's lanes, claims and leases.
|
|
42
53
|
const team = teamOf(config);
|
|
43
54
|
if (team) {
|
package/hooks/hooks.json
CHANGED
|
@@ -22,6 +22,16 @@
|
|
|
22
22
|
"timeout": 30
|
|
23
23
|
}
|
|
24
24
|
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"matcher": "Agent|Task",
|
|
28
|
+
"hooks": [
|
|
29
|
+
{
|
|
30
|
+
"type": "command",
|
|
31
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/guard-agent.mjs\"",
|
|
32
|
+
"timeout": 10
|
|
33
|
+
}
|
|
34
|
+
]
|
|
25
35
|
}
|
|
26
36
|
],
|
|
27
37
|
"PostToolUse": [
|
|
@@ -53,6 +63,11 @@
|
|
|
53
63
|
"type": "command",
|
|
54
64
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/team-session.mjs\" start",
|
|
55
65
|
"timeout": 10
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"type": "command",
|
|
69
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session.mjs\" start",
|
|
70
|
+
"timeout": 10
|
|
56
71
|
}
|
|
57
72
|
]
|
|
58
73
|
}
|
|
@@ -64,6 +79,11 @@
|
|
|
64
79
|
"type": "command",
|
|
65
80
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/team-session.mjs\" end",
|
|
66
81
|
"timeout": 10
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"type": "command",
|
|
85
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session.mjs\" end",
|
|
86
|
+
"timeout": 10
|
|
67
87
|
}
|
|
68
88
|
]
|
|
69
89
|
}
|