@warren-dean/code-kit 0.8.0 → 0.9.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 +29 -0
- package/README.md +65 -38
- package/bin/code-kit.mjs +355 -7
- package/hooks/guard-bash.mjs +69 -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 +88 -6
- package/hooks/lib/plan.mjs +3 -1
- package/hooks/lib/storymap.mjs +62 -0
- package/hooks/lib/team.mjs +851 -0
- package/hooks/lib/teamfacts.mjs +187 -0
- package/hooks/mod/codemap.mjs +16 -1
- package/hooks/mod/decisions.mjs +25 -2
- package/hooks/mod/loop.mjs +23 -3
- package/hooks/mod/panes.mjs +99 -50
- package/hooks/mod/register.mjs +70 -1
- package/hooks/mod/register.test.ts +251 -9
- package/hooks/mod/teamview.mjs +243 -0
- package/hooks/mod/view.mjs +10 -8
- 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) {
|
|
@@ -336,9 +364,82 @@ function configAt(ref) {
|
|
|
336
364
|
return config ?? null;
|
|
337
365
|
}
|
|
338
366
|
|
|
367
|
+
/**
|
|
368
|
+
* SETUP-2: a project set up before setup committed its rules onto the base has none there, so nothing
|
|
369
|
+
* could be merged by the lead. `queue rules` commits the rules as committed on this branch (the ones the
|
|
370
|
+
* person approved) onto the base, as setup now does, without checking the base out: a commit of the base
|
|
371
|
+
* with those files changed, the base moved to it. Only while the base has no rules, and only the lead.
|
|
372
|
+
*/
|
|
373
|
+
function rulesOntoBase(root, config) {
|
|
374
|
+
const into = intoName ?? config.branches.protected[0];
|
|
375
|
+
const git = (args, opts = {}) =>
|
|
376
|
+
execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', ...opts }).trim();
|
|
377
|
+
const head = git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
378
|
+
if (head === into)
|
|
379
|
+
die(
|
|
380
|
+
`You're on ${into}: commit ${file} here as setup does (\`git add ${file} && git commit\`), which the hooks allow while ${into} has no rules.`,
|
|
381
|
+
);
|
|
382
|
+
if (configAt(into))
|
|
383
|
+
return out(
|
|
384
|
+
`${into} already has code-kit's rules committed; the merge queue merges under them.`,
|
|
385
|
+
);
|
|
386
|
+
if (!configAt('HEAD'))
|
|
387
|
+
die(
|
|
388
|
+
`${head} has no ${file} committed. Commit the rules the person approved first, then run this again.`,
|
|
389
|
+
);
|
|
390
|
+
const used = git(['worktree', 'list', '--porcelain'])
|
|
391
|
+
.split('\n')
|
|
392
|
+
.some((l) => l === `branch refs/heads/${into}`);
|
|
393
|
+
if (used)
|
|
394
|
+
die(
|
|
395
|
+
`${into} is checked out in a worktree, so moving it here would leave that checkout behind. Commit ${file} in that checkout instead (the hooks allow it while ${into} has no rules).`,
|
|
396
|
+
);
|
|
397
|
+
// The rules, and the baseline of existing violations they were approved with.
|
|
398
|
+
const files = [file, BASELINE_FILE].filter((f) => {
|
|
399
|
+
try {
|
|
400
|
+
execFileSync('git', ['-C', root, 'cat-file', '-e', `HEAD:${f}`], { stdio: 'ignore' });
|
|
401
|
+
return true;
|
|
402
|
+
} catch {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
const index = join(tmpdir(), `code-kit-rules-${process.pid}`);
|
|
407
|
+
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
408
|
+
try {
|
|
409
|
+
git(['read-tree', into], { env });
|
|
410
|
+
for (const f of files)
|
|
411
|
+
git(
|
|
412
|
+
['update-index', '--add', '--cacheinfo', `100644,${git(['rev-parse', `HEAD:${f}`])},${f}`],
|
|
413
|
+
{
|
|
414
|
+
env,
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
const tree = git(['write-tree'], { env });
|
|
418
|
+
const before = git(['rev-parse', into]);
|
|
419
|
+
const commit = git(
|
|
420
|
+
[
|
|
421
|
+
'commit-tree',
|
|
422
|
+
tree,
|
|
423
|
+
'-p',
|
|
424
|
+
before,
|
|
425
|
+
'-m',
|
|
426
|
+
`code-kit's rules onto ${into}, as the person approved them on ${head}\n\nFrom now on ${into}'s committed rules decide who merges into it.`,
|
|
427
|
+
],
|
|
428
|
+
{ env },
|
|
429
|
+
);
|
|
430
|
+
git(['update-ref', `refs/heads/${into}`, commit, before]);
|
|
431
|
+
} finally {
|
|
432
|
+
rmSync(index, { force: true });
|
|
433
|
+
}
|
|
434
|
+
const now = configAt(into);
|
|
435
|
+
out(
|
|
436
|
+
`Committed ${files.join(' and ')} onto ${into}, from ${head}. ${now?.approvals.delegate?.merge ? 'The lead merges the queue from now on (code-kit queue merge --delegated).' : `${into}'s rules don't delegate merges, so the person merges the queue.`}`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
339
440
|
/** Why the first merge into `into`, which has no config committed yet, is the person's. */
|
|
340
441
|
const firstMerge = (into, branch) =>
|
|
341
|
-
`${into} has no ${file} committed yet, so it delegates nothing
|
|
442
|
+
`${into} has no ${file} committed yet, so it delegates nothing. Put the approved rules onto it first: \`node "${process.argv[1]}" queue rules\`. Then merge ${branch} again; from then on ${into}'s committed rules decide who merges.`;
|
|
342
443
|
|
|
343
444
|
/** Why a merge didn't happen: `refused` (the rules), `verify` (problems) or `conflict`. */
|
|
344
445
|
class MergeStop extends Error {
|
|
@@ -474,6 +575,10 @@ function mergeOne(branch, config, byPerson) {
|
|
|
474
575
|
'conflict',
|
|
475
576
|
);
|
|
476
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 });
|
|
477
582
|
// A lane agent's worktree has done its work once its branch is merged: removed when it holds
|
|
478
583
|
// nothing uncommitted (the branch stays), so finished lanes don't pile up checkouts.
|
|
479
584
|
let removed = '';
|
|
@@ -527,6 +632,7 @@ function queue() {
|
|
|
527
632
|
const place = waiting(readQueue(root)).length;
|
|
528
633
|
return out(`Queued ${branch} to merge${place > 1 ? `, ${place - 1} ahead of it` : ', next'}.`);
|
|
529
634
|
}
|
|
635
|
+
if (sub === 'rules') return rulesOntoBase(root, config);
|
|
530
636
|
if (sub === 'merge') {
|
|
531
637
|
const byPerson = mergeMode();
|
|
532
638
|
const entries = readQueue(root);
|
|
@@ -561,7 +667,7 @@ function queue() {
|
|
|
561
667
|
}
|
|
562
668
|
if (sub !== undefined)
|
|
563
669
|
die(
|
|
564
|
-
'code-kit queue [--json] | queue add <branch> | queue drop <branch> | queue merge --delegated|--person',
|
|
670
|
+
'code-kit queue [--json] | queue add <branch> | queue drop <branch> | queue rules | queue merge --delegated|--person',
|
|
565
671
|
);
|
|
566
672
|
const entries = readQueue(root);
|
|
567
673
|
if (flag('--json')) return out(JSON.stringify(entries, null, 2));
|
|
@@ -577,6 +683,18 @@ function queue() {
|
|
|
577
683
|
*/
|
|
578
684
|
function mapCommand(id) {
|
|
579
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
|
+
}
|
|
580
698
|
const { specs, plan } = config.docs ?? {};
|
|
581
699
|
if (!specs || !plan)
|
|
582
700
|
die('map needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
|
|
@@ -1135,6 +1253,230 @@ async function approve(given) {
|
|
|
1135
1253
|
);
|
|
1136
1254
|
}
|
|
1137
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
|
+
|
|
1138
1480
|
/**
|
|
1139
1481
|
* The lead loop's pause (LOOP-6): `loop` says whether the person paused it; `loop pause|resume`, the
|
|
1140
1482
|
* person's act from the code-kit band, records it, so other tools' background work pauses with it.
|
|
@@ -1167,6 +1509,8 @@ const commands = {
|
|
|
1167
1509
|
settings,
|
|
1168
1510
|
hold: () => (rest.length === 1 ? hold(rest[0]) : usage()),
|
|
1169
1511
|
queue,
|
|
1512
|
+
team: teamCommand,
|
|
1513
|
+
intend: intendCommand,
|
|
1170
1514
|
story: () => (rest.length === 1 ? story(rest[0]) : usage()),
|
|
1171
1515
|
map: () => (rest.length === 1 ? mapCommand(rest[0]) : usage()),
|
|
1172
1516
|
trail: trailCommand,
|
|
@@ -1194,10 +1538,14 @@ function usage() {
|
|
|
1194
1538
|
' | stops [--session id] [--json] | sent-back <branch> [--reason "…"]\n' +
|
|
1195
1539
|
' | settings [--json] | settings set <key> <value> --reason "…" [--via pane]\n' +
|
|
1196
1540
|
' | hold <id> [--minutes N] | hold <id> --answer approved|refused\n' +
|
|
1197
|
-
' | queue [--json] | queue add|drop <branch> | queue merge --delegated|--person\n' +
|
|
1541
|
+
' | queue [--json] | queue add|drop <branch> | queue rules | queue merge --delegated|--person\n' +
|
|
1198
1542
|
' | loop [--json] | loop pause|resume\n' +
|
|
1199
|
-
' | map <story
|
|
1200
|
-
' | 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>',
|
|
1201
1549
|
);
|
|
1202
1550
|
}
|
|
1203
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,
|
|
@@ -74,6 +76,7 @@ const packages = addedPackages(cmd);
|
|
|
74
76
|
const approves = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+approve\b`));
|
|
75
77
|
const delegatedMerge = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+(?:queue\s+)?merge\b`));
|
|
76
78
|
const queues = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+queue\s+(?:add|drop)\b`));
|
|
79
|
+
const rulesOntoBase = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+queue\s+rules\b`));
|
|
77
80
|
const commits = runs(cmd, new RegExp(String.raw`^${GIT}commit\b`));
|
|
78
81
|
if (error) {
|
|
79
82
|
if (commits) block(`Blocked: ${error}\nFix the config before committing.`);
|
|
@@ -165,6 +168,16 @@ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(pause|resume)\b`)))
|
|
|
165
168
|
block(
|
|
166
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}`,
|
|
167
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
|
+
);
|
|
168
181
|
// The harness settings are the person's to change, from the pane or their terminal.
|
|
169
182
|
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+settings\s+set\b`)))
|
|
170
183
|
block(
|
|
@@ -174,6 +187,10 @@ if (queues && actor.kind !== 'lead')
|
|
|
174
187
|
block(
|
|
175
188
|
`Blocked: only the lead puts branches in the merge queue, after its review passes them. Finish your story and commit it on your branch; the lead reviews it.\nCommand: ${cmd}`,
|
|
176
189
|
);
|
|
190
|
+
if (rulesOntoBase && actor.kind !== 'lead')
|
|
191
|
+
block(
|
|
192
|
+
`Blocked: only the lead puts code-kit's rules onto the protected branch. Finish your story and commit it on your branch; the lead takes it from there.\nCommand: ${cmd}`,
|
|
193
|
+
);
|
|
177
194
|
if (delegatedMerge && actor.kind !== 'lead')
|
|
178
195
|
block(
|
|
179
196
|
`Blocked: only the lead merges into a protected branch. Finish your story and commit it on your branch; the lead reviews and merges it.\nCommand: ${cmd}`,
|
|
@@ -261,6 +278,58 @@ if (writesHistory && config.branches.protected.includes(branch) && !settingUp) {
|
|
|
261
278
|
);
|
|
262
279
|
}
|
|
263
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
|
+
|
|
264
333
|
// Kit files edited through the shell follow the same rule as edits through Write/Edit: a command that
|
|
265
334
|
// names a kit file and writes, judged one simple command at a time, so a redirect elsewhere in the line
|
|
266
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
|
|