@warren-dean/code-kit 0.8.1 → 0.9.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 +29 -0
- package/README.md +63 -37
- package/bin/code-kit.mjs +278 -4
- package/hooks/guard-bash.mjs +64 -0
- package/hooks/guard-paths.mjs +19 -0
- package/hooks/lib/adapters/context-graph.mjs +5 -0
- package/hooks/lib/adapters/index.mjs +12 -0
- package/hooks/lib/config.mjs +15 -1
- package/hooks/lib/next.mjs +75 -3
- package/hooks/lib/plan.mjs +3 -1
- package/hooks/lib/storymap.mjs +62 -0
- package/hooks/lib/team.mjs +943 -0
- package/hooks/lib/teamfacts.mjs +187 -0
- package/hooks/mod/codemap.mjs +16 -1
- package/hooks/mod/decisions.mjs +24 -1
- package/hooks/mod/loop.mjs +20 -4
- package/hooks/mod/panes.mjs +99 -50
- package/hooks/mod/register.mjs +70 -1
- package/hooks/mod/register.test.ts +231 -2
- package/hooks/mod/teamview.mjs +243 -0
- package/hooks/mod/view.mjs +5 -1
- package/package.json +1 -1
- package/skills/design/SKILL.md +56 -0
- package/skills/dispatch/SKILL.md +9 -1
- package/skills/init/SKILL.md +19 -6
- package/skills/next/SKILL.md +3 -0
- package/skills/review/SKILL.md +1 -0
- package/skills/spec-design/SKILL.md +9 -3
- package/templates/plan.md +1 -1
package/bin/code-kit.mjs
CHANGED
|
@@ -53,11 +53,20 @@ import { traceFile } from '../hooks/lib/trace.mjs';
|
|
|
53
53
|
import { buildStatus, specCheckRows } from '../hooks/lib/plan.mjs';
|
|
54
54
|
import { recordSentBack } from '../hooks/lib/reviews.mjs';
|
|
55
55
|
import { storyTimes } from '../hooks/lib/timeline.mjs';
|
|
56
|
-
import { storyMap, trail } from '../hooks/lib/storymap.mjs';
|
|
56
|
+
import { PROJECT, projectMap, storyMap, trail } from '../hooks/lib/storymap.mjs';
|
|
57
57
|
import { journal } from '../hooks/lib/journal.mjs';
|
|
58
58
|
import { dequeue, enqueue, lastMerged, readQueue, settle, waiting } from '../hooks/lib/queue.mjs';
|
|
59
59
|
import { SETTINGS, parseSetting, settingOf, withSetting } from '../hooks/lib/harness.mjs';
|
|
60
60
|
import { readLoop, writeLoop } from '../hooks/lib/loop-state.mjs';
|
|
61
|
+
import {
|
|
62
|
+
claimChanges,
|
|
63
|
+
storyOfBranch,
|
|
64
|
+
syncBoard,
|
|
65
|
+
teamOf,
|
|
66
|
+
teamProvider,
|
|
67
|
+
whoami,
|
|
68
|
+
} from '../hooks/lib/team.mjs';
|
|
69
|
+
import { codeowners, decisionRecord, slugOf } from '../hooks/lib/teamfacts.mjs';
|
|
61
70
|
import {
|
|
62
71
|
APPROVAL_MINUTES,
|
|
63
72
|
approvalLifetime,
|
|
@@ -101,7 +110,9 @@ const via = option('--via');
|
|
|
101
110
|
const sessionId = option('--session');
|
|
102
111
|
const minutesGiven = option('--minutes');
|
|
103
112
|
const answerGiven = option('--answer');
|
|
104
|
-
const
|
|
113
|
+
const leaseGiven = option('--lease');
|
|
114
|
+
const touchesGiven = option('--touches');
|
|
115
|
+
const FLAGS = ['--write', '--no-checks', '--json', '--delegated', '--person', '--fresh', '--peek'];
|
|
105
116
|
const flag = (name) => args.includes(name);
|
|
106
117
|
const writeBaseline = flag('--write');
|
|
107
118
|
const [command, ...rest] = args.filter((a) => !FLAGS.includes(a));
|
|
@@ -148,6 +159,9 @@ function checkJson() {
|
|
|
148
159
|
report.harnessSet = Object.keys(SETTINGS).filter((k) => settingOf(raw.harness, k) !== undefined);
|
|
149
160
|
// Delegation is the base's, by the rules committed there: a branch can't grant itself merges.
|
|
150
161
|
const delegation = mergeDelegation('.', config, file);
|
|
162
|
+
// TEAM-1: the team, with its defaults, and who the developer at this machine is.
|
|
163
|
+
report.team = teamOf(raw);
|
|
164
|
+
report.me = report.team ? whoami(resolve('.')) : null;
|
|
151
165
|
report.delegatesMerge = delegation.delegated;
|
|
152
166
|
report.firstMerge = delegation.first;
|
|
153
167
|
out(JSON.stringify(report, null, 2));
|
|
@@ -164,6 +178,20 @@ function check() {
|
|
|
164
178
|
for (const c of raw.checks ?? []) out(`Check ${c.name}: ${c.run.join(' && ')}`);
|
|
165
179
|
if (!raw.docs?.specs) out('No docs.specs: lanes are not held to a spec-check report.');
|
|
166
180
|
for (const name of load().config.adapters) out(`Adapter ${name}: active (see \`adapters\`)`);
|
|
181
|
+
const team = teamOf(raw);
|
|
182
|
+
if (team) {
|
|
183
|
+
const me = whoami(resolve('.'));
|
|
184
|
+
out(
|
|
185
|
+
`Team: ${team.techLead} is the tech lead; a claim is stale after ${team.staleDays} working day${team.staleDays === 1 ? '' : 's'} without a push; provider ${team.provider === 'github' ? 'GitHub' : team.provider.command}.`,
|
|
186
|
+
);
|
|
187
|
+
for (const [lane, owners] of Object.entries(team.owners))
|
|
188
|
+
out(`Lane ${lane} is owned by ${owners.join(', ')}`);
|
|
189
|
+
out(
|
|
190
|
+
me
|
|
191
|
+
? `You are ${me}${me === team.techLead ? ', the tech lead' : ''}.`
|
|
192
|
+
: "You aren't signed in to GitHub (gh auth login), so claims and owners can't be checked.",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
167
195
|
}
|
|
168
196
|
|
|
169
197
|
function who(paths) {
|
|
@@ -547,6 +575,10 @@ function mergeOne(branch, config, byPerson) {
|
|
|
547
575
|
'conflict',
|
|
548
576
|
);
|
|
549
577
|
}
|
|
578
|
+
// TEAM-1: a merged story's claim ends, whoever merged it.
|
|
579
|
+
const team = teamOf(config);
|
|
580
|
+
const story = storyOfBranch(branch);
|
|
581
|
+
if (team && story) teamProvider(resolve('.'), team).release(story, { merged: true });
|
|
550
582
|
// A lane agent's worktree has done its work once its branch is merged: removed when it holds
|
|
551
583
|
// nothing uncommitted (the branch stays), so finished lanes don't pile up checkouts.
|
|
552
584
|
let removed = '';
|
|
@@ -651,6 +683,18 @@ function queue() {
|
|
|
651
683
|
*/
|
|
652
684
|
function mapCommand(id) {
|
|
653
685
|
const { config, raw } = load();
|
|
686
|
+
// MAP-5: the whole project, by folder, for when no story is at work.
|
|
687
|
+
if (id === PROJECT) {
|
|
688
|
+
const map = projectMap(resolve('.'), config, tracked());
|
|
689
|
+
if (flag('--json')) return out(JSON.stringify(map, null, 2));
|
|
690
|
+
out(`The whole project (${map.files.length} folder(s)):`);
|
|
691
|
+
for (const layer of map.layers) {
|
|
692
|
+
out(` ${layer}`);
|
|
693
|
+
for (const f of map.files.filter((x) => x.layer === layer))
|
|
694
|
+
out(` ${f.path} (${f.files})${f.imports.length ? ` → ${f.imports.join(', ')}` : ''}`);
|
|
695
|
+
}
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
654
698
|
const { specs, plan } = config.docs ?? {};
|
|
655
699
|
if (!specs || !plan)
|
|
656
700
|
die('map needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
|
|
@@ -1209,6 +1253,230 @@ async function approve(given) {
|
|
|
1209
1253
|
);
|
|
1210
1254
|
}
|
|
1211
1255
|
|
|
1256
|
+
/** How long ago an ISO time was, in words: "3 hours ago". */
|
|
1257
|
+
function ago(iso) {
|
|
1258
|
+
const minutes = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60000));
|
|
1259
|
+
if (minutes < 1) return 'just now';
|
|
1260
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
|
1261
|
+
const hours = Math.round(minutes / 60);
|
|
1262
|
+
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
1263
|
+
return `${Math.round(hours / 24)} days ago`;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* The team (TEAM-2, TEAM-3, TEAM-8): `team` says who's on it and what's claimed, intended and open;
|
|
1268
|
+
* `team claim <story> [--lease globs]` and `team release <story>` change a claim through the provider.
|
|
1269
|
+
*/
|
|
1270
|
+
function teamCommand() {
|
|
1271
|
+
const { raw } = load();
|
|
1272
|
+
const team = teamOf(raw);
|
|
1273
|
+
if (!team)
|
|
1274
|
+
die(
|
|
1275
|
+
'This project has no "team" in its config, so it has no claims or owners. Add "team": { "techLead": "<GitHub handle>" } to share them.',
|
|
1276
|
+
);
|
|
1277
|
+
const provider = teamProvider(resolve('.'), team);
|
|
1278
|
+
const [sub, story] = rest;
|
|
1279
|
+
const holderWords = (h) =>
|
|
1280
|
+
`${h.story} is claimed by ${h.by} (${ago(h.at)}). Ask them, or the tech lead (${team.techLead}), to release it.`;
|
|
1281
|
+
if (sub === 'claim') {
|
|
1282
|
+
if (!story) die('code-kit team claim <story> [--lease "<glob>,<glob>"]');
|
|
1283
|
+
const lease = (leaseGiven ?? '')
|
|
1284
|
+
.split(',')
|
|
1285
|
+
.map((g) => g.trim())
|
|
1286
|
+
.filter(Boolean);
|
|
1287
|
+
const r = provider.claim(story, lease);
|
|
1288
|
+
if (r.holder) die(holderWords(r.holder));
|
|
1289
|
+
if (!r.ok) die(r.why);
|
|
1290
|
+
return out(
|
|
1291
|
+
`Claimed ${story} as ${r.claim.by}${lease.length ? `, leasing ${lease.join(', ')}` : ''}.`,
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
// TEAM-12: the plan mirrored onto the team's GitHub Project.
|
|
1295
|
+
if (sub === 'board') {
|
|
1296
|
+
if (!team.board)
|
|
1297
|
+
die(
|
|
1298
|
+
'This team has no board. Add "board": { "owner": "<user or organisation>", "number": <the Project\'s number> } to "team" in the config.',
|
|
1299
|
+
);
|
|
1300
|
+
const { config, raw: rawConfig } = load();
|
|
1301
|
+
if (!rawConfig.docs?.plan)
|
|
1302
|
+
die('The board mirrors the plan, and this project has no docs.plan.');
|
|
1303
|
+
const status = buildStatus('.', config, base(config, false), tracked());
|
|
1304
|
+
const state = provider.state();
|
|
1305
|
+
const r = syncBoard(resolve('.'), team, status.stories, state.claims ?? []);
|
|
1306
|
+
if (!r.ok) die(r.why);
|
|
1307
|
+
return out(
|
|
1308
|
+
`Board ${team.board.owner}/${team.board.number} synced: ${r.created} item${r.created === 1 ? '' : 's'} added, ${r.edited} field${r.edited === 1 ? '' : 's'} changed.`,
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
// TEAM-11: GitHub's CODEOWNERS from the config, so GitHub asks the right people to review.
|
|
1312
|
+
if (sub === 'codeowners') {
|
|
1313
|
+
const text = codeowners({
|
|
1314
|
+
techLead: team.techLead,
|
|
1315
|
+
leadPaths: raw.lead.paths,
|
|
1316
|
+
lanes: raw.lanes,
|
|
1317
|
+
});
|
|
1318
|
+
if (!flag('--write')) return out(text.trimEnd());
|
|
1319
|
+
mkdirSync(resolve('.github'), { recursive: true });
|
|
1320
|
+
writeFileSync(resolve('.github/CODEOWNERS'), text);
|
|
1321
|
+
return out(
|
|
1322
|
+
"Wrote .github/CODEOWNERS. Commit it, and require code owners' review in a ruleset on the protected branch, so GitHub holds every pull request to it.",
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
// TEAM-9: the holder keeps a stale claim (the clock starts again); the tech lead reassigns one.
|
|
1326
|
+
if (sub === 'keep') {
|
|
1327
|
+
if (!story) die('code-kit team keep <story>');
|
|
1328
|
+
const r = provider.claim(story);
|
|
1329
|
+
if (r.holder) die(holderWords(r.holder));
|
|
1330
|
+
if (!r.ok) die(r.why);
|
|
1331
|
+
return out(
|
|
1332
|
+
`Kept ${story}: it isn't stale until ${team.staleDays} more working day${team.staleDays === 1 ? '' : 's'} without a push.`,
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
if (sub === 'reassign') {
|
|
1336
|
+
const to = rest[2];
|
|
1337
|
+
if (!story || !to) die('code-kit team reassign <story> <GitHub handle>');
|
|
1338
|
+
const r = provider.reassign(story, to);
|
|
1339
|
+
if (!r.ok) die(r.why);
|
|
1340
|
+
return out(`Reassigned ${story} from ${r.from} to ${r.to}, with its lease.`);
|
|
1341
|
+
}
|
|
1342
|
+
if (sub === 'release') {
|
|
1343
|
+
if (!story) die('code-kit team release <story>');
|
|
1344
|
+
const r = provider.release(story);
|
|
1345
|
+
if (!r.ok) die(r.why ?? holderWords(r.holder));
|
|
1346
|
+
return out(
|
|
1347
|
+
r.released ? `Released ${story} (${r.claim.by}'s claim).` : `${story} isn't claimed.`,
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
// TEAM-6: what everyone did since you last looked; reading it marks it read, unless --peek.
|
|
1351
|
+
if (sub === 'since') {
|
|
1352
|
+
const root = resolve('.');
|
|
1353
|
+
const seenFile = join(root, '.claude/state/team-seen.json');
|
|
1354
|
+
let seen = null;
|
|
1355
|
+
try {
|
|
1356
|
+
seen = JSON.parse(readFileSync(seenFile, 'utf8'));
|
|
1357
|
+
} catch {
|
|
1358
|
+
// never looked: the last day
|
|
1359
|
+
}
|
|
1360
|
+
const from = seen ? Date.parse(seen.at) : Date.now() - 24 * 3600_000;
|
|
1361
|
+
const state = provider.state({ fresh: true });
|
|
1362
|
+
const activity = provider.since(from);
|
|
1363
|
+
const changes =
|
|
1364
|
+
seen && state.reachable !== false ? claimChanges(seen.claims, state.claims) : [];
|
|
1365
|
+
const all = [...(activity ?? []), ...changes].sort((a, b) => a.at - b.at);
|
|
1366
|
+
const people = {};
|
|
1367
|
+
for (const e of all) (people[e.by] ??= []).push(e);
|
|
1368
|
+
if (!flag('--peek')) {
|
|
1369
|
+
mkdirSync(join(root, '.claude/state'), { recursive: true });
|
|
1370
|
+
writeFileSync(
|
|
1371
|
+
seenFile,
|
|
1372
|
+
JSON.stringify({ at: new Date().toISOString(), claims: state.claims ?? [] }) + '\n',
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
if (flag('--json'))
|
|
1376
|
+
return out(
|
|
1377
|
+
JSON.stringify(
|
|
1378
|
+
{ since: new Date(from).toISOString(), github: activity !== null, people },
|
|
1379
|
+
null,
|
|
1380
|
+
2,
|
|
1381
|
+
),
|
|
1382
|
+
);
|
|
1383
|
+
out(
|
|
1384
|
+
`Since ${seen ? ago(seen.at) : 'yesterday'}${activity === null ? " (GitHub's activity couldn't be read; claims only)" : ''}:`,
|
|
1385
|
+
);
|
|
1386
|
+
if (!all.length) return out(' Nothing new.');
|
|
1387
|
+
for (const [who, list] of Object.entries(people))
|
|
1388
|
+
out(` ${who}${who === state.me ? ' (you)' : ''}: ${list.map((e) => e.text).join('; ')}.`);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
if (sub !== undefined)
|
|
1392
|
+
die(
|
|
1393
|
+
'code-kit team [--json] [--fresh] | team since [--json] [--peek] | team claim|keep|release <story> | team reassign <story> <handle>',
|
|
1394
|
+
);
|
|
1395
|
+
const state = provider.state({ fresh: flag('--fresh') });
|
|
1396
|
+
if (flag('--json')) return out(JSON.stringify(state, null, 2));
|
|
1397
|
+
const you = state.me
|
|
1398
|
+
? `You are ${state.me}${state.me === team.techLead ? ', the tech lead' : ''}.`
|
|
1399
|
+
: "You aren't signed in to GitHub (gh auth login).";
|
|
1400
|
+
out('Team mode is a preview: report anything that looks wrong.');
|
|
1401
|
+
out(`Tech lead: ${team.techLead}. ${you}`);
|
|
1402
|
+
if (state.reachable === false)
|
|
1403
|
+
out(
|
|
1404
|
+
`${state.why ?? "The shared repository can't be reached"}: claims and leases aren't checked until it can be; every other rule still holds.`,
|
|
1405
|
+
);
|
|
1406
|
+
if (state.github === false) out("GitHub's issues and pull requests couldn't be read (gh).");
|
|
1407
|
+
for (const [lane, owners] of Object.entries(team.owners))
|
|
1408
|
+
out(`Lane ${lane}: ${owners.join(', ')}`);
|
|
1409
|
+
out(state.claims.length ? 'Claims:' : 'Nothing is claimed.');
|
|
1410
|
+
for (const c of state.claims)
|
|
1411
|
+
out(
|
|
1412
|
+
` ${c.story} by ${c.by}, ${ago(c.at)}${c.stale ? ` · stale, nothing since ${ago(c.active)}` : ''}${c.lease.length ? ` · leases ${c.lease.join(', ')}` : ''}`,
|
|
1413
|
+
);
|
|
1414
|
+
if (state.intents.length) out('Intents:');
|
|
1415
|
+
for (const i of state.intents)
|
|
1416
|
+
out(
|
|
1417
|
+
` #${i.id} ${i.about} (${i.by})${i.touches.length ? ` · touches ${i.touches.join(', ')}` : ''}`,
|
|
1418
|
+
);
|
|
1419
|
+
if (state.pulls.length) out('Open pull requests:');
|
|
1420
|
+
for (const p of state.pulls)
|
|
1421
|
+
out(` #${p.number} ${p.title} (${p.by}, ${p.branch}${p.draft ? ', draft' : ''})`);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* TEAM-5: `intend "<what and why>" [--touches globs]` raises an intent before any code, so the team sees
|
|
1426
|
+
* a change of direction first; `intend agree|drop|duplicate <id> [--reason "…"]` is the tech lead's answer.
|
|
1427
|
+
*/
|
|
1428
|
+
function intendCommand() {
|
|
1429
|
+
const { raw } = load();
|
|
1430
|
+
const team = teamOf(raw);
|
|
1431
|
+
if (!team) die('This project has no "team" in its config, so intents have nowhere to go.');
|
|
1432
|
+
const provider = teamProvider(resolve('.'), team);
|
|
1433
|
+
const [first, id] = rest;
|
|
1434
|
+
// TEAM-10: an agreed intent, written where agents read it.
|
|
1435
|
+
if (first === 'record') {
|
|
1436
|
+
if (!/^\d+$/.test(id ?? '')) die('code-kit intend record <discussion number>');
|
|
1437
|
+
const d = provider.intent(Number(id));
|
|
1438
|
+
if (!d) die("GitHub can't be reached, so the intent can't be read.");
|
|
1439
|
+
if (d.missing) die(`There's no intent #${id} in ${team.intents}.`);
|
|
1440
|
+
if (!(d.closed && d.stateReason === 'RESOLVED'))
|
|
1441
|
+
die(`Intent #${id} isn't agreed yet: only an agreed intent becomes a decision record.`);
|
|
1442
|
+
const dir = raw.docs?.decisions ?? 'docs/decisions';
|
|
1443
|
+
mkdirSync(resolve(dir), { recursive: true });
|
|
1444
|
+
const existing = readdirSync(resolve(dir)).filter((f) => f.endsWith('.md'));
|
|
1445
|
+
const already = existing.find((f) => read(join(dir, f)).includes(`**Discussion:** ${d.url}`));
|
|
1446
|
+
if (already) return out(`Intent #${id} is already recorded: ${join(dir, already)}`);
|
|
1447
|
+
const last = Math.max(0, ...existing.map((f) => Number(f.match(/^(\d+)-/)?.[1] ?? 0)));
|
|
1448
|
+
const path = join(dir, `${String(last + 1).padStart(4, '0')}-${slugOf(d.title)}.md`);
|
|
1449
|
+
writeFileSync(resolve(path), decisionRecord(d, team.techLead));
|
|
1450
|
+
return out(
|
|
1451
|
+
`Wrote ${path}. Commit it on a branch (lead/decision-${id}), push it and open a pull request, so it's reviewed and merged like any change; once merged, every agent reads it.`,
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
if (first === 'agree' || first === 'drop' || first === 'duplicate') {
|
|
1455
|
+
if (!/^\d+$/.test(id ?? '')) die(`code-kit intend ${first} <issue number> [--reason "…"]`);
|
|
1456
|
+
const outcome = { agree: 'agreed', drop: 'dropped', duplicate: 'duplicate' }[first];
|
|
1457
|
+
const r = provider.settle(Number(id), outcome, reason ?? '');
|
|
1458
|
+
if (!r.ok) die(r.why);
|
|
1459
|
+
return out(
|
|
1460
|
+
first === 'agree'
|
|
1461
|
+
? `Agreed intent #${id}: it's how the team goes now. Record it so every agent builds on it: code-kit intend record ${id}`
|
|
1462
|
+
: first === 'drop'
|
|
1463
|
+
? `Dropped intent #${id}.`
|
|
1464
|
+
: `Marked intent #${id} a duplicate.`,
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
const about = rest.join(' ').trim();
|
|
1468
|
+
if (!about) die('code-kit intend "<what you\'ll change, and why>" [--touches "<glob>,<glob>"]');
|
|
1469
|
+
const touches = (touchesGiven ?? '')
|
|
1470
|
+
.split(',')
|
|
1471
|
+
.map((t) => t.trim())
|
|
1472
|
+
.filter(Boolean);
|
|
1473
|
+
const r = provider.intend(about, touches);
|
|
1474
|
+
if (!r.ok) die(r.why);
|
|
1475
|
+
out(
|
|
1476
|
+
`Raised intent #${r.intent.id}: ${about}${touches.length ? ` (touches ${touches.join(', ')})` : ''}. The team sees it now; the tech lead (${team.techLead}) agrees or drops it. ${r.intent.url}`,
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1212
1480
|
/**
|
|
1213
1481
|
* The lead loop's pause (LOOP-6): `loop` says whether the person paused it; `loop pause|resume`, the
|
|
1214
1482
|
* person's act from the code-kit band, records it, so other tools' background work pauses with it.
|
|
@@ -1241,6 +1509,8 @@ const commands = {
|
|
|
1241
1509
|
settings,
|
|
1242
1510
|
hold: () => (rest.length === 1 ? hold(rest[0]) : usage()),
|
|
1243
1511
|
queue,
|
|
1512
|
+
team: teamCommand,
|
|
1513
|
+
intend: intendCommand,
|
|
1244
1514
|
story: () => (rest.length === 1 ? story(rest[0]) : usage()),
|
|
1245
1515
|
map: () => (rest.length === 1 ? mapCommand(rest[0]) : usage()),
|
|
1246
1516
|
trail: trailCommand,
|
|
@@ -1270,8 +1540,12 @@ function usage() {
|
|
|
1270
1540
|
' | hold <id> [--minutes N] | hold <id> --answer approved|refused\n' +
|
|
1271
1541
|
' | queue [--json] | queue add|drop <branch> | queue rules | queue merge --delegated|--person\n' +
|
|
1272
1542
|
' | loop [--json] | loop pause|resume\n' +
|
|
1273
|
-
' | map <story
|
|
1274
|
-
' | story <id> [--json] | timeline [--json]'
|
|
1543
|
+
' | map <story>|project [--json] | trail [--json] | journal [--json]\n' +
|
|
1544
|
+
' | story <id> [--json] | timeline [--json]\n' +
|
|
1545
|
+
' | team [--json] [--fresh] | team since [--json] [--peek]\n' +
|
|
1546
|
+
' | team claim <story> [--lease globs] | team keep|release <story>\n' +
|
|
1547
|
+
' | team reassign <story> <handle> | team codeowners [--write] | team board\n' +
|
|
1548
|
+
' | intend "<what and why>" [--touches globs] | intend agree|drop|duplicate <id> [--reason "…"] | intend record <id>',
|
|
1275
1549
|
);
|
|
1276
1550
|
}
|
|
1277
1551
|
await (commands[command] ?? usage)();
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { addedPackages, dependencyApproval, isDependencyFile } from './lib/depen
|
|
|
12
12
|
import { CODE_KIT, GIT, codeOf, commandsOf, runs } from './lib/shell.mjs';
|
|
13
13
|
import { block, start } from './lib/hook.mjs';
|
|
14
14
|
import { CONFIG_FILE } from './lib/config.mjs';
|
|
15
|
+
import { laneOfBranch, storyOfBranch, teamOf, teamProvider, whoami } from './lib/team.mjs';
|
|
16
|
+
import { matchesAny } from './lib/glob.mjs';
|
|
15
17
|
import { noteBefore } from './lane-audit.mjs';
|
|
16
18
|
import {
|
|
17
19
|
APPROVAL_LOG,
|
|
@@ -166,6 +168,16 @@ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(pause|resume)\b`)))
|
|
|
166
168
|
block(
|
|
167
169
|
`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}`,
|
|
168
170
|
);
|
|
171
|
+
// TEAM-9: handing someone's claim to someone else is the tech lead's own decision too.
|
|
172
|
+
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+team\s+reassign\b`)))
|
|
173
|
+
block(
|
|
174
|
+
`Blocked: reassigning a claim is the tech lead's own decision, from their terminal or the code-kit pane. Tell them who holds it, since when, and who you'd suggest, and stop.\nCommand: ${cmd}`,
|
|
175
|
+
);
|
|
176
|
+
// TEAM-5: agreeing or dropping an intent is the tech lead's own decision, from their terminal or the pane.
|
|
177
|
+
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+intend\s+(agree|drop|duplicate)\b`)))
|
|
178
|
+
block(
|
|
179
|
+
`Blocked: agreeing or dropping an intent is the tech lead's own decision, from their terminal or the code-kit pane. Tell them what you'd recommend and why, and stop.\nCommand: ${cmd}`,
|
|
180
|
+
);
|
|
169
181
|
// The harness settings are the person's to change, from the pane or their terminal.
|
|
170
182
|
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+settings\s+set\b`)))
|
|
171
183
|
block(
|
|
@@ -266,6 +278,58 @@ if (writesHistory && config.branches.protected.includes(branch) && !settingUp) {
|
|
|
266
278
|
);
|
|
267
279
|
}
|
|
268
280
|
|
|
281
|
+
// TEAM-1, TEAM-4: in a team, making a story's branch claims the story (the dispatch), refused when the
|
|
282
|
+
// lane is someone else's or the story is already claimed; a commit on it grows the claim's lease with
|
|
283
|
+
// what the branch changes. When the shared repository can't be reached, nothing here is checked.
|
|
284
|
+
const team = teamOf(config);
|
|
285
|
+
if (team) {
|
|
286
|
+
const me = whoami(project);
|
|
287
|
+
const made = commandsOf(cmd)
|
|
288
|
+
.map((c) =>
|
|
289
|
+
c.text.match(
|
|
290
|
+
new RegExp(
|
|
291
|
+
String.raw`^${GIT}(?:switch\s+(?:-c|--create)|checkout\s+-b|worktree\s+add\b.*?\s-b)\s+(\S+)`,
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
.find(Boolean)?.[1];
|
|
296
|
+
const story = storyOfBranch(made);
|
|
297
|
+
if (story && me) {
|
|
298
|
+
const lane = laneOfBranch(made);
|
|
299
|
+
const owners = team.owners[lane];
|
|
300
|
+
if (owners && !owners.includes(me))
|
|
301
|
+
block(
|
|
302
|
+
`Blocked: the ${lane} lane is ${owners.join(' and ')}'s, so ${story} is theirs to dispatch. Leave it for them, or ask the tech lead (${team.techLead}) to change the lane's owners.\nCommand: ${cmd}`,
|
|
303
|
+
);
|
|
304
|
+
const claimed = teamProvider(project, team).claim(story);
|
|
305
|
+
if (claimed.holder)
|
|
306
|
+
block(
|
|
307
|
+
`Blocked: ${story} is already claimed by ${claimed.holder.by}, so someone is building it. Pick another story, or ask ${claimed.holder.by} or the tech lead (${team.techLead}) to release it.\nCommand: ${cmd}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
// The branch by name, which also works before its first commit.
|
|
311
|
+
const named = spawnSync('git', ['-C', root, 'symbolic-ref', '--short', '-q', 'HEAD'], {
|
|
312
|
+
encoding: 'utf8',
|
|
313
|
+
}).stdout?.trim();
|
|
314
|
+
const mine = storyOfBranch(named || branch);
|
|
315
|
+
if (commits && mine && me) {
|
|
316
|
+
const provider = teamProvider(project, team);
|
|
317
|
+
const claim = provider.state().claims?.find((c) => c.story === mine && c.by === me);
|
|
318
|
+
const base = config.branches.protected[0];
|
|
319
|
+
const since = spawnSync('git', ['-C', root, 'merge-base', 'HEAD', base], { encoding: 'utf8' });
|
|
320
|
+
const changed = (args) =>
|
|
321
|
+
spawnSync('git', ['-C', root, 'diff', '--name-only', ...args], { encoding: 'utf8' })
|
|
322
|
+
.stdout.split('\n')
|
|
323
|
+
.filter(Boolean);
|
|
324
|
+
const files = [
|
|
325
|
+
...(since.status === 0 ? changed([since.stdout.trim()]) : []),
|
|
326
|
+
...changed(['--cached']),
|
|
327
|
+
];
|
|
328
|
+
const grown = files.filter((f) => !matchesAny(f, claim?.lease ?? []));
|
|
329
|
+
if (claim && grown.length) provider.claim(mine, grown);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
269
333
|
// Kit files edited through the shell follow the same rule as edits through Write/Edit: a command that
|
|
270
334
|
// names a kit file and writes, judged one simple command at a time, so a redirect elsewhere in the line
|
|
271
335
|
// (`npm install > /tmp/log; ls .claude/skills`) isn't taken for a write to the kit. A write the shell
|
package/hooks/guard-paths.mjs
CHANGED
|
@@ -8,6 +8,8 @@ import { loadBaseline, newProblems } from './lib/baseline.mjs';
|
|
|
8
8
|
import { CONFIG_FILE } from './lib/config.mjs';
|
|
9
9
|
import { layerProblems } from './lib/layers.mjs';
|
|
10
10
|
import { block, start } from './lib/hook.mjs';
|
|
11
|
+
import { teamOf, teamProvider, teamRefusal, whoami } from './lib/team.mjs';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
11
13
|
import {
|
|
12
14
|
actorFor,
|
|
13
15
|
projectWorktrees,
|
|
@@ -35,6 +37,23 @@ if (error) {
|
|
|
35
37
|
const problem = writeProblem(actorFor(input, root, config), rel, root, config);
|
|
36
38
|
if (problem) block(`Blocked: ${problem}`);
|
|
37
39
|
|
|
40
|
+
// TEAM-1, TEAM-4: in a team, the tech lead's paths, other people's lanes, claims and leases.
|
|
41
|
+
const team = teamOf(config);
|
|
42
|
+
if (team) {
|
|
43
|
+
const branch = spawnSync('git', ['-C', root, 'symbolic-ref', '--short', '-q', 'HEAD'], {
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
}).stdout?.trim();
|
|
46
|
+
const refusal = teamRefusal({
|
|
47
|
+
actor: actorFor(input, root, config),
|
|
48
|
+
rel,
|
|
49
|
+
branch,
|
|
50
|
+
config,
|
|
51
|
+
me: whoami(project),
|
|
52
|
+
state: teamProvider(project, team).state(),
|
|
53
|
+
});
|
|
54
|
+
if (refusal) block(`Blocked: ${refusal}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
/** The file's text after this call, when the call carries enough to know it. */
|
|
39
58
|
function textAfter(before) {
|
|
40
59
|
const ti = input.tool_input ?? {};
|
|
@@ -120,6 +120,11 @@ export default {
|
|
|
120
120
|
/** The command that opens Context Graph's pane on a file, given its path. */
|
|
121
121
|
open: { command: 'graph' },
|
|
122
122
|
},
|
|
123
|
+
// ONB-3: set up before the coding loop, so every lane's first edit already has the why behind it.
|
|
124
|
+
onboard: {
|
|
125
|
+
skill: 'context-graph:init',
|
|
126
|
+
why: 'Context Graph is installed but has no graph in this project yet. Set it up before any code is written, so every lane works from the why behind each file from its first edit.',
|
|
127
|
+
},
|
|
123
128
|
setup: [
|
|
124
129
|
'.gitignore: .ctx/archive/, .ctx/bench/, .ctx/models/ and .ctx/serve.json (ctx working files, never committed)',
|
|
125
130
|
'.gitattributes: .ctx/decisions.ctx merge=union and .ctx/cards.ctx merge=union (lanes record decisions and cards on parallel branches; merging keeps them all)',
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// shell.block (`person: true` marks a person's act the lead may do for them under
|
|
12
12
|
// approvals.lead); restricted: commands only the lead and the listed lanes may run
|
|
13
13
|
// setup lines for init to apply or tell the person: ignore files, merge drivers, instructions
|
|
14
|
+
// onboard { skill, why }: the tool's own setup skill, which `next` runs before any coding when the
|
|
15
|
+
// config wants the tool ("adapters": { "<name>": true }) but it isn't set up here yet
|
|
14
16
|
// facts what the tool knows of the lanes' work, for the mod's combined view: { available(root),
|
|
15
17
|
// lanes(root, { session }) → { [agent type or 'lead']: { withoutUnderstanding, cardsOwed } },
|
|
16
18
|
// files(root, paths) → { [path]: { rules } }, open: { command } (its view of a file) }
|
|
@@ -40,6 +42,16 @@ export function adapterFacts(raw, root, { session = null, paths = null } = {}) {
|
|
|
40
42
|
}));
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* ONB-3: tools the config wants (`"adapters": { "<name>": true }`) that aren't set up in `root` yet,
|
|
47
|
+
* with each one's setup skill: [{ name, skill, why }].
|
|
48
|
+
*/
|
|
49
|
+
export function pendingSetup(raw, root) {
|
|
50
|
+
return ADAPTERS.filter(
|
|
51
|
+
(a) => raw?.adapters?.[a.name] === true && a.onboard && !a.detect(root),
|
|
52
|
+
).map((a) => ({ name: a.name, ...a.onboard }));
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
/** The effective config with every active adapter's contributions merged in. */
|
|
44
56
|
export function applyAdapters(config, raw, root) {
|
|
45
57
|
const active = activeAdapters(raw, root);
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import { existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { harnessProblems, harnessSettings } from './harness.mjs';
|
|
6
|
+
import { teamProblems } from './team.mjs';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import { ADAPTERS, applyAdapters } from './adapters/index.mjs';
|
|
8
9
|
|
|
@@ -140,6 +141,13 @@ const isRegExp = (v) => {
|
|
|
140
141
|
}
|
|
141
142
|
};
|
|
142
143
|
|
|
144
|
+
/**
|
|
145
|
+
* ONB-5: the version of init's setup. 2 adds project skills with the lane agents, the design gate for
|
|
146
|
+
* lanes with screens, a QA lane for end-to-end tests, and companions set up before coding (spec 17). A
|
|
147
|
+
* project set up before (no `setup`, or a lower one) is told what re-running init adds.
|
|
148
|
+
*/
|
|
149
|
+
export const SETUP_VERSION = 2;
|
|
150
|
+
|
|
143
151
|
/** Every problem with a parsed config, as readable sentences; empty when it is valid. */
|
|
144
152
|
export function validate(c) {
|
|
145
153
|
if (!isObj(c)) return ['the config must be a JSON object'];
|
|
@@ -156,9 +164,14 @@ export function validate(c) {
|
|
|
156
164
|
);
|
|
157
165
|
};
|
|
158
166
|
need(c.version === 1, '"version" must be 1');
|
|
167
|
+
need(
|
|
168
|
+
c.setup === undefined ||
|
|
169
|
+
(Number.isInteger(c.setup) && c.setup >= 1 && c.setup <= SETUP_VERSION),
|
|
170
|
+
`"setup" must be a whole number from 1 to ${SETUP_VERSION}: the version of init's setup the project has`,
|
|
171
|
+
);
|
|
159
172
|
// A brownfield project may have no specs yet: then there is no spec-check gate.
|
|
160
173
|
need(c.docs === undefined || isObj(c.docs), '"docs" must be an object');
|
|
161
|
-
for (const k of ['specs', 'principles', 'plan'])
|
|
174
|
+
for (const k of ['specs', 'principles', 'plan', 'decisions'])
|
|
162
175
|
need(c.docs?.[k] === undefined || isStr(c.docs[k]), `"docs.${k}" must be a path`);
|
|
163
176
|
need(isObj(c.lead) && isList(c.lead?.paths), '"lead.paths" must be a list of globs');
|
|
164
177
|
need(
|
|
@@ -232,6 +245,7 @@ export function validate(c) {
|
|
|
232
245
|
'"branches.protected" must be a list of branch names',
|
|
233
246
|
);
|
|
234
247
|
p.push(...harnessProblems(c.harness));
|
|
248
|
+
p.push(...teamProblems(c));
|
|
235
249
|
return p;
|
|
236
250
|
}
|
|
237
251
|
|
package/hooks/lib/next.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import { execFileSync } from 'node:child_process';
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { join, posix } from 'node:path';
|
|
7
7
|
import { loadConfig, mergeDelegation } from './config.mjs';
|
|
8
|
-
import { buildStatus, readStories, LEAD } from './plan.mjs';
|
|
8
|
+
import { buildStatus, readStories, DESIGN, LEAD } from './plan.mjs';
|
|
9
|
+
import { pendingSetup } from './adapters/index.mjs';
|
|
10
|
+
import { CONFIG_FILE, SETUP_VERSION } from './config.mjs';
|
|
9
11
|
import { readQueue, waiting } from './queue.mjs';
|
|
10
12
|
|
|
11
13
|
export const DRAFT_FILE = '.claude/code-kit.draft.json';
|
|
@@ -138,6 +140,23 @@ export function nextStep(root, base) {
|
|
|
138
140
|
`${plan} has no stories in the \`### ST-n\` form yet; spec-design writes the plan.`,
|
|
139
141
|
);
|
|
140
142
|
|
|
143
|
+
// ONB-3: a companion the config wants is set up before any coding starts.
|
|
144
|
+
let raw = null;
|
|
145
|
+
try {
|
|
146
|
+
raw = JSON.parse(readFileSync(join(root, CONFIG_FILE), 'utf8'));
|
|
147
|
+
} catch {
|
|
148
|
+
// read and validated above; nothing to add
|
|
149
|
+
}
|
|
150
|
+
const [setup] = pendingSetup(raw, root);
|
|
151
|
+
if (setup) return result('setup', setup.why, { args: setup.skill });
|
|
152
|
+
// ONB-5: a project set up before setup did all of this is told what re-running init adds.
|
|
153
|
+
if ((raw?.setup ?? 1) < SETUP_VERSION)
|
|
154
|
+
attention.push(
|
|
155
|
+
"This project's code-kit setup predates what init now sets up: project skills with each lane agent, designs approved before screens are built, a QA lane for end-to-end tests, and companion tools set up before coding. Re-run /code-kit:init to add what's missing; it keeps everything that's still right and shows you the difference first.",
|
|
156
|
+
);
|
|
157
|
+
// ONB-1: a lane agent naming a skill that doesn't exist works without the recipe it was given.
|
|
158
|
+
for (const missing of missingSkills(root)) attention.push(missing);
|
|
159
|
+
|
|
141
160
|
const from = base(config);
|
|
142
161
|
if (!from)
|
|
143
162
|
attention.push('The base branch has no commits yet, so story branches are read against HEAD.');
|
|
@@ -163,13 +182,17 @@ export function nextStep(root, base) {
|
|
|
163
182
|
// A ready story with a gap (unknown lane, a requirement no spec defines) waits for the plan's fix.
|
|
164
183
|
const defined = new Set(status.requirements.map((r) => r.id));
|
|
165
184
|
const buildable = (s) =>
|
|
166
|
-
(Boolean(config.lanes[s.lane]) || s.lane === LEAD) &&
|
|
185
|
+
(Boolean(config.lanes[s.lane]) || s.lane === LEAD || s.lane === DESIGN) &&
|
|
167
186
|
(s.requirements.length > 0 || s.noRequirements) &&
|
|
168
187
|
s.requirements.every((r) => defined.has(r));
|
|
169
188
|
const readyStories = status.stories.filter((s) => s.state === 'ready');
|
|
170
189
|
// The lead's own stories are built by the lead, not dispatched to a lane agent.
|
|
171
190
|
const forLead = readyStories.filter((s) => buildable(s) && s.lane === LEAD).map((s) => s.id);
|
|
172
|
-
const ready = readyStories
|
|
191
|
+
const ready = readyStories
|
|
192
|
+
.filter((s) => buildable(s) && s.lane !== LEAD && s.lane !== DESIGN)
|
|
193
|
+
.map((s) => s.id);
|
|
194
|
+
// ONB-2: design stories, designed with the person before any screen that depends on them is built.
|
|
195
|
+
const forDesign = readyStories.filter((s) => buildable(s) && s.lane === DESIGN).map((s) => s.id);
|
|
173
196
|
const unbuildable = readyStories.filter((s) => !buildable(s)).map((s) => s.id);
|
|
174
197
|
if (unbuildable.length)
|
|
175
198
|
attention.push(
|
|
@@ -201,6 +224,7 @@ export function nextStep(root, base) {
|
|
|
201
224
|
const then = [];
|
|
202
225
|
// A sent-back story goes back to its lane with the review's findings, before new work starts.
|
|
203
226
|
const toDispatch = [...fixes, ...ready];
|
|
227
|
+
if (review.length && forDesign.length) then.push({ step: 'design', args: forDesign.join(' ') });
|
|
204
228
|
if (review.length && toDispatch.length)
|
|
205
229
|
then.push({ step: 'dispatch', args: toDispatch.join(' ') });
|
|
206
230
|
if (forLead.length) then.push({ step: 'lead', args: forLead.join(' ') });
|
|
@@ -209,6 +233,18 @@ export function nextStep(root, base) {
|
|
|
209
233
|
args: review.join(' '),
|
|
210
234
|
then,
|
|
211
235
|
});
|
|
236
|
+
if (forDesign.length)
|
|
237
|
+
return result(
|
|
238
|
+
'design',
|
|
239
|
+
`${forDesign.join(', ')} ${forDesign.length === 1 ? 'designs' : 'design'} the screens before any code is written for them; the person approves each design.`,
|
|
240
|
+
{
|
|
241
|
+
args: forDesign.join(' '),
|
|
242
|
+
then: [
|
|
243
|
+
...(toDispatch.length ? [{ step: 'dispatch', args: toDispatch.join(' ') }] : []),
|
|
244
|
+
...(forLead.length ? [{ step: 'lead', args: forLead.join(' ') }] : []),
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
);
|
|
212
248
|
if (fixes.length)
|
|
213
249
|
return result(
|
|
214
250
|
'dispatch',
|
|
@@ -257,3 +293,39 @@ export function nextStep(root, base) {
|
|
|
257
293
|
`All ${status.stories.length} stories are done; spec-design plans the next milestone or feature.`,
|
|
258
294
|
);
|
|
259
295
|
}
|
|
296
|
+
|
|
297
|
+
/** The code-kit skills every agent may name without a project copy. */
|
|
298
|
+
const KIT_SKILLS = (() => {
|
|
299
|
+
try {
|
|
300
|
+
return new Set(readdirSync(new URL('../../skills/', import.meta.url)));
|
|
301
|
+
} catch {
|
|
302
|
+
return new Set();
|
|
303
|
+
}
|
|
304
|
+
})();
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* ONB-1: lane agents naming skills under **Skills to use** that don't exist, in words: ["web-engineer
|
|
308
|
+
* names a skill that doesn't exist: new-screen"]. Plugin skills (`plugin:skill`) and code-kit's own are
|
|
309
|
+
* left alone.
|
|
310
|
+
*/
|
|
311
|
+
export function missingSkills(root) {
|
|
312
|
+
const dir = join(root, '.claude/agents');
|
|
313
|
+
if (!existsSync(dir)) return [];
|
|
314
|
+
const out = [];
|
|
315
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
|
|
316
|
+
const line = readFileSync(join(dir, file), 'utf8').match(/^\*\*Skills to use:\*\*(.*)$/m)?.[1];
|
|
317
|
+
if (!line) continue;
|
|
318
|
+
const missing = line
|
|
319
|
+
.split(',')
|
|
320
|
+
.map((n) => n.replace(/[`*]/g, '').trim())
|
|
321
|
+
.filter((n) => /^[a-z0-9][a-z0-9-]*$/.test(n))
|
|
322
|
+
.filter(
|
|
323
|
+
(n) => !KIT_SKILLS.has(n) && !existsSync(join(root, '.claude/skills', n, 'SKILL.md')),
|
|
324
|
+
);
|
|
325
|
+
if (missing.length)
|
|
326
|
+
out.push(
|
|
327
|
+
`${file.replace(/\.md$/, '')} names ${missing.length === 1 ? "a skill that doesn't" : "skills that don't"} exist: ${missing.join(', ')}. Write ${missing.length === 1 ? 'it' : 'them'} from the architecture's recipes (re-run init), or take ${missing.length === 1 ? 'it' : 'them'} off the agent.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|