@warren-dean/code-kit 0.5.0 → 0.7.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 +50 -0
- package/README.md +26 -10
- package/bin/code-kit.mjs +398 -39
- package/hooks/guard-bash.mjs +35 -14
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +75 -6
- package/hooks/lib/adapters/index.mjs +18 -0
- package/hooks/lib/config.mjs +46 -2
- package/hooks/lib/dependencies.mjs +26 -9
- package/hooks/lib/harness.mjs +131 -0
- package/hooks/lib/next.mjs +34 -3
- package/hooks/lib/plan.mjs +29 -0
- package/hooks/lib/queue.mjs +75 -0
- package/hooks/lib/reviews.mjs +18 -0
- package/hooks/lib/rules.mjs +8 -4
- package/hooks/lib/shell.mjs +1 -1
- package/hooks/lib/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/hold.mjs +25 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/panes.mjs +943 -0
- package/hooks/mod/register.mjs +1102 -14
- package/hooks/mod/register.test.ts +1747 -13
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/view.mjs +763 -36
- package/package.json +1 -1
- package/skills/init/SKILL.md +5 -4
- package/skills/review/SKILL.md +8 -1
package/bin/code-kit.mjs
CHANGED
|
@@ -20,22 +20,41 @@
|
|
|
20
20
|
// requests [--json] open approval requests and approvals in force
|
|
21
21
|
// stops [--session id] [--json] finish checks that refused an agent's last stop, still failing
|
|
22
22
|
// sent-back <branch> [--reason …] records that a review sent the branch back at its current commit
|
|
23
|
+
// settings [--json] | settings set <key> <value> --reason … [--via pane]
|
|
24
|
+
// the harness settings; set is the person's own change
|
|
23
25
|
// record approvals a person gave in chat (with approvals.lead on, the lead runs it)
|
|
24
26
|
// --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
|
|
25
27
|
import { execFileSync } from 'node:child_process';
|
|
26
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
mkdtempSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
rmSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from 'node:fs';
|
|
27
37
|
import { tmpdir } from 'node:os';
|
|
28
38
|
import { join, posix, resolve } from 'node:path';
|
|
29
39
|
import { BASELINE_FILE, loadBaseline } from '../hooks/lib/baseline.mjs';
|
|
30
40
|
import { matchesAny } from '../hooks/lib/glob.mjs';
|
|
31
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
CONFIG_FILE,
|
|
43
|
+
committedConfig,
|
|
44
|
+
effectiveConfig,
|
|
45
|
+
mergeDelegation,
|
|
46
|
+
validate,
|
|
47
|
+
} from '../hooks/lib/config.mjs';
|
|
32
48
|
import { diffConfigs, ownershipMoves } from '../hooks/lib/diff.mjs';
|
|
33
49
|
import { CODE, importsOf, layerOf, layerProblems, targetOf } from '../hooks/lib/layers.mjs';
|
|
34
|
-
import { ADAPTERS, activeAdapters } from '../hooks/lib/adapters/index.mjs';
|
|
50
|
+
import { ADAPTERS, activeAdapters, adapterFacts } from '../hooks/lib/adapters/index.mjs';
|
|
35
51
|
import { nextStep } from '../hooks/lib/next.mjs';
|
|
36
52
|
import { traceFile } from '../hooks/lib/trace.mjs';
|
|
37
|
-
import { buildStatus } from '../hooks/lib/plan.mjs';
|
|
53
|
+
import { buildStatus, specCheckRows } from '../hooks/lib/plan.mjs';
|
|
38
54
|
import { recordSentBack } from '../hooks/lib/reviews.mjs';
|
|
55
|
+
import { storyTimes } from '../hooks/lib/timeline.mjs';
|
|
56
|
+
import { dequeue, enqueue, lastMerged, readQueue, settle, waiting } from '../hooks/lib/queue.mjs';
|
|
57
|
+
import { SETTINGS, parseSetting, settingOf, withSetting } from '../hooks/lib/harness.mjs';
|
|
39
58
|
import {
|
|
40
59
|
APPROVAL_MINUTES,
|
|
41
60
|
approvalLifetime,
|
|
@@ -77,6 +96,8 @@ const laneName = option('--lane');
|
|
|
77
96
|
const intoName = option('--into');
|
|
78
97
|
const via = option('--via');
|
|
79
98
|
const sessionId = option('--session');
|
|
99
|
+
const minutesGiven = option('--minutes');
|
|
100
|
+
const answerGiven = option('--answer');
|
|
80
101
|
const FLAGS = ['--write', '--no-checks', '--json', '--delegated', '--person'];
|
|
81
102
|
const flag = (name) => args.includes(name);
|
|
82
103
|
const writeBaseline = flag('--write');
|
|
@@ -119,6 +140,13 @@ function checkJson() {
|
|
|
119
140
|
);
|
|
120
141
|
report.adapters = config.adapters;
|
|
121
142
|
report.docs = config.docs ?? null;
|
|
143
|
+
report.harness = config.harness;
|
|
144
|
+
// The harness settings the person chose, as opposed to defaults: the mod asks before acting on autonomy.
|
|
145
|
+
report.harnessSet = Object.keys(SETTINGS).filter((k) => settingOf(raw.harness, k) !== undefined);
|
|
146
|
+
// Delegation is the base's, by the rules committed there: a branch can't grant itself merges.
|
|
147
|
+
const delegation = mergeDelegation('.', config, file);
|
|
148
|
+
report.delegatesMerge = delegation.delegated;
|
|
149
|
+
report.firstMerge = delegation.first;
|
|
122
150
|
out(JSON.stringify(report, null, 2));
|
|
123
151
|
}
|
|
124
152
|
|
|
@@ -298,29 +326,27 @@ const VERIFY_GROUPS = {
|
|
|
298
326
|
// merges, and only when `verify` passes on that branch, checks included. A person merges with git.
|
|
299
327
|
// With --person, the person merges (the mod passes it for a press in its pane): the same verify first,
|
|
300
328
|
// with no delegation needed. The hooks refuse --person from every agent.
|
|
301
|
-
/**
|
|
302
|
-
* The config as committed on `ref`: what governs merging into it, so a branch's own config can't grant
|
|
303
|
-
* itself a merge its target doesn't allow.
|
|
304
|
-
*/
|
|
329
|
+
/** The config committed on `ref`, or null when it has none yet; an invalid one stops here. */
|
|
305
330
|
function configAt(ref) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
331
|
+
const { config, error } = committedConfig('.', ref, file);
|
|
332
|
+
if (error) die(error);
|
|
333
|
+
return config ?? null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Why the first merge into `into`, which has no config committed yet, is the person's. */
|
|
337
|
+
const firstMerge = (into, branch) =>
|
|
338
|
+
`${into} has no ${file} committed yet, so it delegates nothing: the first merge, which brings code-kit's rules onto ${into}, is the person's. Ask them to merge ${branch} from the code-kit band (Merge), or to run \`! node "${process.argv[1]}" queue merge --person\`. From then on, ${into}'s committed rules decide who merges.`;
|
|
339
|
+
|
|
340
|
+
/** Why a merge didn't happen: `refused` (the rules), `verify` (problems) or `conflict`. */
|
|
341
|
+
class MergeStop extends Error {
|
|
342
|
+
constructor(kind, message) {
|
|
343
|
+
super(message);
|
|
344
|
+
this.kind = kind;
|
|
316
345
|
}
|
|
317
|
-
const problems = validate(raw);
|
|
318
|
-
if (problems.length) die(`${file} on ${ref} is invalid:\n ${problems.join('\n ')}`);
|
|
319
|
-
return effectiveConfig(raw, '.');
|
|
320
346
|
}
|
|
321
347
|
|
|
322
|
-
|
|
323
|
-
|
|
348
|
+
/** Who merges, from the flags: the lead within the delegated rules, or the person. */
|
|
349
|
+
function mergeMode() {
|
|
324
350
|
const byPerson = flag('--person');
|
|
325
351
|
if (!flag('--delegated') && !byPerson)
|
|
326
352
|
die(
|
|
@@ -328,23 +354,46 @@ function merge(branch) {
|
|
|
328
354
|
);
|
|
329
355
|
if (flag('--delegated') && byPerson)
|
|
330
356
|
die("A merge is either the lead's (--delegated) or the person's (--person), not both.");
|
|
357
|
+
return byPerson;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function merge(branch) {
|
|
361
|
+
const { config } = load();
|
|
362
|
+
const byPerson = mergeMode();
|
|
363
|
+
try {
|
|
364
|
+
out(mergeOne(branch, config, byPerson).message);
|
|
365
|
+
} catch (e) {
|
|
366
|
+
if (e instanceof MergeStop) die(e.message);
|
|
367
|
+
throw e;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Verifies `branch`, checks included, and merges it into the base (`--into`, or the first protected
|
|
373
|
+
* branch). Returns { into, message }; throws a MergeStop when nothing was merged.
|
|
374
|
+
*/
|
|
375
|
+
function mergeOne(branch, config, byPerson) {
|
|
376
|
+
const refuse = (message) => {
|
|
377
|
+
throw new MergeStop('refused', message);
|
|
378
|
+
};
|
|
331
379
|
const into = intoName ?? config.branches.protected[0];
|
|
332
380
|
// Whether the lead may merge, and which branches are protected, are the target's rules.
|
|
333
|
-
|
|
381
|
+
// A person may make the first merge, under the rules it brings; the lead may not.
|
|
382
|
+
const target = configAt(into) ?? (byPerson ? config : refuse(firstMerge(into, branch)));
|
|
334
383
|
if (!byPerson && !target.approvals.delegate?.merge)
|
|
335
|
-
|
|
384
|
+
refuse(
|
|
336
385
|
`${into} doesn't delegate merges to the lead ("approvals.delegate.merge" isn't true in its ${file}). A person merges after review.`,
|
|
337
386
|
);
|
|
338
387
|
if (!target.branches.protected.includes(into))
|
|
339
|
-
|
|
340
|
-
if (target.branches.protected.includes(branch))
|
|
388
|
+
refuse(`${into} isn't a protected branch; merge into it with git.`);
|
|
389
|
+
if (target.branches.protected.includes(branch)) refuse(`${branch} is itself a protected branch.`);
|
|
341
390
|
const root = resolve('.');
|
|
342
391
|
const g = (cwd, ...a) =>
|
|
343
392
|
execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
344
393
|
try {
|
|
345
394
|
g(root, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`);
|
|
346
395
|
} catch {
|
|
347
|
-
|
|
396
|
+
refuse(`There is no branch ${branch}.`);
|
|
348
397
|
}
|
|
349
398
|
const checkedOut = (b) =>
|
|
350
399
|
g(root, 'worktree', 'list', '--porcelain')
|
|
@@ -356,7 +405,7 @@ function merge(branch) {
|
|
|
356
405
|
fail(`${b} has uncommitted changes in ${dir}; commit or stash them first.`);
|
|
357
406
|
};
|
|
358
407
|
const temporary = [];
|
|
359
|
-
//
|
|
408
|
+
// A stop is thrown at once, so every way out of here removes the temporary worktrees first.
|
|
360
409
|
const cleanup = () => {
|
|
361
410
|
for (const dir of temporary.splice(0)) {
|
|
362
411
|
try {
|
|
@@ -366,9 +415,9 @@ function merge(branch) {
|
|
|
366
415
|
}
|
|
367
416
|
}
|
|
368
417
|
};
|
|
369
|
-
const fail = (message) => {
|
|
418
|
+
const fail = (message, kind = 'refused') => {
|
|
370
419
|
cleanup();
|
|
371
|
-
|
|
420
|
+
throw new MergeStop(kind, message);
|
|
372
421
|
};
|
|
373
422
|
const worktree = (b, detach) => {
|
|
374
423
|
const dir = mkdtempSync(join(tmpdir(), 'code-kit-merge-'));
|
|
@@ -396,6 +445,7 @@ function merge(branch) {
|
|
|
396
445
|
if (problems.length)
|
|
397
446
|
fail(
|
|
398
447
|
`Nothing was merged. code-kit verify found ${problems.length} problem(s) on ${branch} (checked in ${verifyIn}):\n ${problems.join('\n ')}\nSend it back to its lane, or fix it, then merge again.`,
|
|
448
|
+
'verify',
|
|
399
449
|
);
|
|
400
450
|
const mergeIn = checkedOut(into) ?? worktree(into, false);
|
|
401
451
|
clean(mergeIn, into);
|
|
@@ -418,16 +468,239 @@ function merge(branch) {
|
|
|
418
468
|
}
|
|
419
469
|
fail(
|
|
420
470
|
`Nothing was merged: ${branch} doesn't merge cleanly into ${into}. Merge ${into} into ${branch} and resolve it there, then merge again.\n${String(e.stderr ?? e.message).trim()}`,
|
|
471
|
+
'conflict',
|
|
421
472
|
);
|
|
422
473
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
474
|
+
// A lane agent's worktree has done its work once its branch is merged: removed when it holds
|
|
475
|
+
// nothing uncommitted (the branch stays), so finished lanes don't pile up checkouts.
|
|
476
|
+
let removed = '';
|
|
477
|
+
const agentTree = checkedOut(branch);
|
|
478
|
+
if (
|
|
479
|
+
agentTree?.includes('/.claude/worktrees/') &&
|
|
480
|
+
!g(agentTree, 'status', '--porcelain').trim()
|
|
481
|
+
) {
|
|
482
|
+
try {
|
|
483
|
+
g(root, 'worktree', 'remove', agentTree);
|
|
484
|
+
removed = ` Removed its worktree, ${agentTree}.`;
|
|
485
|
+
} catch {
|
|
486
|
+
// in use, or locked: left as it is
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
into,
|
|
491
|
+
message: `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}.${removed}`,
|
|
492
|
+
};
|
|
426
493
|
} finally {
|
|
427
494
|
cleanup();
|
|
428
495
|
}
|
|
429
496
|
}
|
|
430
497
|
|
|
498
|
+
/**
|
|
499
|
+
* The merge queue (spec 09): `queue` lists it; `queue add <branch>` puts a branch the lead's review
|
|
500
|
+
* passed at the back; `queue drop <branch>` takes one out; `queue merge --delegated|--person` merges
|
|
501
|
+
* the head, verified against the base as it is now. A head that conflicts or fails verify is sent
|
|
502
|
+
* back to its lane (recorded as `sent-back` records it) and leaves the queue; the next one is then head.
|
|
503
|
+
*/
|
|
504
|
+
function queue() {
|
|
505
|
+
const { config } = load();
|
|
506
|
+
const root = resolve('.');
|
|
507
|
+
const [sub, branch] = rest;
|
|
508
|
+
if (sub === 'add' || sub === 'drop') {
|
|
509
|
+
if (!branch) die(`code-kit queue ${sub} <branch>`);
|
|
510
|
+
if (sub === 'drop')
|
|
511
|
+
return out(
|
|
512
|
+
dequeue(root, branch)
|
|
513
|
+
? `Took ${branch} out of the merge queue.`
|
|
514
|
+
: `${branch} isn't waiting in the merge queue.`,
|
|
515
|
+
);
|
|
516
|
+
try {
|
|
517
|
+
execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
518
|
+
stdio: 'ignore',
|
|
519
|
+
});
|
|
520
|
+
} catch {
|
|
521
|
+
die(`There is no branch ${branch}.`);
|
|
522
|
+
}
|
|
523
|
+
if (!enqueue(root, branch)) return out(`${branch} is already in the merge queue.`);
|
|
524
|
+
const place = waiting(readQueue(root)).length;
|
|
525
|
+
return out(`Queued ${branch} to merge${place > 1 ? `, ${place - 1} ahead of it` : ', next'}.`);
|
|
526
|
+
}
|
|
527
|
+
if (sub === 'merge') {
|
|
528
|
+
const byPerson = mergeMode();
|
|
529
|
+
const entries = readQueue(root);
|
|
530
|
+
const head = waiting(entries)[0];
|
|
531
|
+
if (!head) return out('The merge queue is empty.');
|
|
532
|
+
try {
|
|
533
|
+
const { message } = mergeOne(head.branch, config, byPerson);
|
|
534
|
+
settle(root, head.branch, 'merged');
|
|
535
|
+
const left = waiting(readQueue(root));
|
|
536
|
+
return out(
|
|
537
|
+
`${message}${left.length ? ` Next in the queue: ${left[0].branch}.` : ' The queue is empty.'}`,
|
|
538
|
+
);
|
|
539
|
+
} catch (e) {
|
|
540
|
+
if (!(e instanceof MergeStop)) throw e;
|
|
541
|
+
// MQ-3: a conflict or a failed verify goes back to the lane; a refusal leaves the queue as it is.
|
|
542
|
+
if (e.kind === 'refused') die(e.message);
|
|
543
|
+
const into = intoName ?? config.branches.protected[0];
|
|
544
|
+
const after = lastMerged(entries);
|
|
545
|
+
const why =
|
|
546
|
+
e.kind === 'conflict'
|
|
547
|
+
? `conflicts with ${into}${after ? ` after ${after}` : ''}`
|
|
548
|
+
: `fails verify against ${into}${after ? ` after ${after}` : ''}`;
|
|
549
|
+
const sha = execFileSync('git', ['rev-parse', '--verify', `${head.branch}^{commit}`], {
|
|
550
|
+
encoding: 'utf8',
|
|
551
|
+
}).trim();
|
|
552
|
+
recordSentBack(root, head.branch, sha, why);
|
|
553
|
+
settle(root, head.branch, 'sent back', why);
|
|
554
|
+
die(
|
|
555
|
+
`${e.message}\n${head.branch} is sent back to its lane (${why}) and leaves the queue; its fix rejoins after the next review.`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (sub !== undefined)
|
|
560
|
+
die(
|
|
561
|
+
'code-kit queue [--json] | queue add <branch> | queue drop <branch> | queue merge --delegated|--person',
|
|
562
|
+
);
|
|
563
|
+
const entries = readQueue(root);
|
|
564
|
+
if (flag('--json')) return out(JSON.stringify(entries, null, 2));
|
|
565
|
+
const left = waiting(entries);
|
|
566
|
+
if (!left.length) out('The merge queue is empty.');
|
|
567
|
+
else out(`${left.length} waiting to merge, in order:`);
|
|
568
|
+
left.forEach((e, i) => out(` ${i + 1}. ${e.branch} (passed review ${e.passed})`));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** When each story's work happened, for the mod's timelines (VIEW-2). */
|
|
572
|
+
function timeline() {
|
|
573
|
+
const { config } = load();
|
|
574
|
+
const { specs, plan } = config.docs ?? {};
|
|
575
|
+
if (!specs || !plan)
|
|
576
|
+
die(
|
|
577
|
+
'timeline needs docs.specs and docs.plan in the config. The spec-design skill writes both.',
|
|
578
|
+
);
|
|
579
|
+
const from = base(config, false);
|
|
580
|
+
const { stories } = buildStatus('.', config, from, tracked());
|
|
581
|
+
const times = storyTimes(resolve('.'), stories, from);
|
|
582
|
+
if (flag('--json'))
|
|
583
|
+
return out(JSON.stringify({ base: from, now: Date.now(), stories: times }, null, 2));
|
|
584
|
+
const when = (ms) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 16) : '-');
|
|
585
|
+
for (const s of times)
|
|
586
|
+
out(
|
|
587
|
+
`${s.id} ${s.branch}: started ${when(s.started)}, last commit ${when(s.lastCommit)}, merged ${when(s.merged)}`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** The most of a story's diff `story` prints; past it the text is cut and says so. */
|
|
592
|
+
const DIFF_KEPT = 256 * 1024;
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* One story's facts, for the mod's drill-down (VIEW-7): its requirements with the spec-check report's
|
|
596
|
+
* rows, its branch's diff against the base, and verify's problems on it (without the checks, which
|
|
597
|
+
* merge runs), where it's checked out or in a temporary worktree.
|
|
598
|
+
*/
|
|
599
|
+
function story(id) {
|
|
600
|
+
const { config } = load();
|
|
601
|
+
const { specs, plan } = config.docs ?? {};
|
|
602
|
+
if (!specs || !plan)
|
|
603
|
+
die('story needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
|
|
604
|
+
const from = base(config, false);
|
|
605
|
+
const { stories, requirements } = buildStatus('.', config, from, tracked());
|
|
606
|
+
const s = stories.find((x) => x.id === id.toUpperCase());
|
|
607
|
+
if (!s) die(`There is no story ${id} in ${plan}.`);
|
|
608
|
+
const report = {
|
|
609
|
+
id: s.id,
|
|
610
|
+
title: s.title,
|
|
611
|
+
lane: s.lane,
|
|
612
|
+
state: s.state,
|
|
613
|
+
base: from,
|
|
614
|
+
branch: s.branchExists ? s.branch : null,
|
|
615
|
+
worktree: s.worktree,
|
|
616
|
+
requirements: s.requirements.map((rid) => {
|
|
617
|
+
const q = requirements.find((x) => x.id === rid);
|
|
618
|
+
return {
|
|
619
|
+
id: rid,
|
|
620
|
+
title: q?.title ?? '',
|
|
621
|
+
spec: q?.file ?? null,
|
|
622
|
+
state: q?.state ?? 'no spec',
|
|
623
|
+
tests: q?.tests ?? [],
|
|
624
|
+
};
|
|
625
|
+
}),
|
|
626
|
+
specCheck: null,
|
|
627
|
+
diff: null,
|
|
628
|
+
verify: null,
|
|
629
|
+
adapters: [],
|
|
630
|
+
};
|
|
631
|
+
if (s.branchExists) {
|
|
632
|
+
const g = (...a) => execFileSync('git', a, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
633
|
+
const name = `.claude/state/spec-check/${s.branch.replace(/[^\w.-]+/g, '_')}.md`;
|
|
634
|
+
const found = [s.worktree, '.']
|
|
635
|
+
.filter(Boolean)
|
|
636
|
+
.map((d) => join(resolve(d), name))
|
|
637
|
+
.find((f) => existsSync(f));
|
|
638
|
+
if (found) report.specCheck = { file: found, rows: specCheckRows(readFileSync(found, 'utf8')) };
|
|
639
|
+
const range = `${from}...${s.branch}`;
|
|
640
|
+
const files = g('diff', '--numstat', range)
|
|
641
|
+
.split('\n')
|
|
642
|
+
.filter(Boolean)
|
|
643
|
+
.map((line) => {
|
|
644
|
+
const [added, removed, path] = line.split('\t');
|
|
645
|
+
return { path, added: Number(added) || 0, removed: Number(removed) || 0 };
|
|
646
|
+
});
|
|
647
|
+
const text = g('diff', range);
|
|
648
|
+
// JOIN-3: what the active adapters know of the story's files (rules on them, agreed and proposed).
|
|
649
|
+
report.adapters = adapterFacts(load().raw, resolve('.'), {
|
|
650
|
+
paths: files.map((f) => f.path),
|
|
651
|
+
}).map(({ name, files: facts, open }) => ({ name, files: facts ?? {}, open }));
|
|
652
|
+
report.diff = {
|
|
653
|
+
files,
|
|
654
|
+
text:
|
|
655
|
+
text.length > DIFF_KEPT
|
|
656
|
+
? text.slice(0, text.lastIndexOf('\ndiff --git', DIFF_KEPT) + 1 || DIFF_KEPT)
|
|
657
|
+
: text,
|
|
658
|
+
truncated: text.length > DIFF_KEPT,
|
|
659
|
+
};
|
|
660
|
+
// verify, without checks, where the branch is checked out; a temporary worktree otherwise.
|
|
661
|
+
let dir = s.worktree;
|
|
662
|
+
let temporary = null;
|
|
663
|
+
if (!dir) {
|
|
664
|
+
temporary = mkdtempSync(join(tmpdir(), 'code-kit-story-'));
|
|
665
|
+
rmSync(temporary, { recursive: true });
|
|
666
|
+
g('worktree', 'add', '--quiet', '--detach', temporary, s.branch);
|
|
667
|
+
dir = temporary;
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
const lane = laneOfBranch(s.branch, config);
|
|
671
|
+
const { found: problems } = verifyBranch({
|
|
672
|
+
root: dir,
|
|
673
|
+
base: from,
|
|
674
|
+
config,
|
|
675
|
+
lane,
|
|
676
|
+
checks: false,
|
|
677
|
+
});
|
|
678
|
+
report.verify = {
|
|
679
|
+
checks: false,
|
|
680
|
+
where: temporary ? 'a temporary worktree' : dir,
|
|
681
|
+
problems: Object.fromEntries(
|
|
682
|
+
Object.entries(VERIFY_GROUPS).map(([key, title]) => [title, problems[key] ?? []]),
|
|
683
|
+
),
|
|
684
|
+
};
|
|
685
|
+
} finally {
|
|
686
|
+
if (temporary) g('worktree', 'remove', '--force', temporary);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (flag('--json')) return out(JSON.stringify(report, null, 2));
|
|
690
|
+
out(
|
|
691
|
+
`${report.id} ${report.title} (${report.lane}, ${report.state})${report.branch ? ` on ${report.branch}` : ''}`,
|
|
692
|
+
);
|
|
693
|
+
for (const r of report.requirements) out(` ${r.id} ${r.title}: ${r.state}`);
|
|
694
|
+
if (report.diff) out(`${report.diff.files.length} file(s) changed against ${from}.`);
|
|
695
|
+
const count = report.verify ? Object.values(report.verify.problems).flat().length : 0;
|
|
696
|
+
if (report.verify)
|
|
697
|
+
out(
|
|
698
|
+
count
|
|
699
|
+
? `verify (without checks): ${count} problem(s).`
|
|
700
|
+
: 'verify (without checks): no problems.',
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
431
704
|
// Open approval requests and the approvals in force, for the person and the mod's band.
|
|
432
705
|
function requests() {
|
|
433
706
|
load();
|
|
@@ -446,6 +719,47 @@ function requests() {
|
|
|
446
719
|
);
|
|
447
720
|
}
|
|
448
721
|
|
|
722
|
+
/**
|
|
723
|
+
* The harness settings (`harness` in the config): listed, or one changed as the person's own act. The
|
|
724
|
+
* change is written to the config and given a kit approval with the person's reason, so the commit that
|
|
725
|
+
* keeps it logs their reason. The hooks refuse `settings set` from every agent.
|
|
726
|
+
*/
|
|
727
|
+
function settings() {
|
|
728
|
+
const { raw, config } = load();
|
|
729
|
+
if (rest[0] === 'set') {
|
|
730
|
+
const [, key, value] = rest;
|
|
731
|
+
if (!key || value === undefined)
|
|
732
|
+
die('code-kit settings set <key> <value> --reason "<why>" [--via pane]');
|
|
733
|
+
if (!(key in SETTINGS))
|
|
734
|
+
die(`${key} isn't a harness setting. They are: ${Object.keys(SETTINGS).join(', ')}.`);
|
|
735
|
+
if (!reason?.trim()) die('Give a reason with --reason "…": the approval log keeps it.');
|
|
736
|
+
const changed = withSetting(raw, key, parseSetting(key, value));
|
|
737
|
+
const problems = validate(changed);
|
|
738
|
+
if (problems.length) die(`Nothing changed: ${problems.join('; ')}`);
|
|
739
|
+
writeFileSync(resolve(file), `${JSON.stringify(changed, null, 2)}\n`);
|
|
740
|
+
const mark =
|
|
741
|
+
via === 'pane' ? '(changed in the code-kit pane)' : '(changed with code-kit settings)';
|
|
742
|
+
grantApprovals(resolve('.'), ['kit'], null, reason, { kit: mark });
|
|
743
|
+
return out(
|
|
744
|
+
`Set harness.${key} to ${JSON.stringify(settingOf(changed.harness, key))}. Commit ${file} to keep it; the approval log will carry your reason.`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
if (rest.length) die('code-kit settings [--json] | settings set <key> <value> --reason "<why>"');
|
|
748
|
+
const rows = Object.entries(SETTINGS).map(([key, s]) => ({
|
|
749
|
+
key,
|
|
750
|
+
value: settingOf(config.harness, key),
|
|
751
|
+
default: s.default,
|
|
752
|
+
about: s.about,
|
|
753
|
+
// Chosen in the config, or the default standing in.
|
|
754
|
+
set: settingOf(raw.harness, key) !== undefined,
|
|
755
|
+
}));
|
|
756
|
+
if (flag('--json')) return out(JSON.stringify(rows, null, 2));
|
|
757
|
+
for (const r of rows)
|
|
758
|
+
out(
|
|
759
|
+
`${r.key.padEnd(26)} ${JSON.stringify(r.value).padEnd(14)} ${r.value === r.default ? '' : `(default ${JSON.stringify(r.default)}) `}${r.about}`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
449
763
|
/** Records the review's send-back of `branch` at its current commit (lib/reviews.mjs). */
|
|
450
764
|
function sentBackCommand(branch) {
|
|
451
765
|
load();
|
|
@@ -491,6 +805,35 @@ function stops() {
|
|
|
491
805
|
out(`${s.agentType ?? 'the lead'}${s.session ? ` (session ${s.session})` : ''}: ${s.title}`);
|
|
492
806
|
}
|
|
493
807
|
|
|
808
|
+
// Hold and ask (spec 06): a call the mod holds waits here, in the mod's `$.process.run`, for the
|
|
809
|
+
// person's answer, which the band writes with `hold <id> --answer approved|refused` into
|
|
810
|
+
// .claude/state/held/<id>. It prints `approved`, `refused` or `timed out` (after hold.minutes).
|
|
811
|
+
async function hold(id) {
|
|
812
|
+
const { config } = load();
|
|
813
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id))
|
|
814
|
+
die(`Not a held call's id: ${id}. Give the tool call's id, as the code-kit mod does.`);
|
|
815
|
+
const dir = resolve('.claude/state/held');
|
|
816
|
+
const answerFile = join(dir, id);
|
|
817
|
+
const answer = answerGiven;
|
|
818
|
+
if (answer !== undefined) {
|
|
819
|
+
if (!['approved', 'refused'].includes(answer)) die('--answer takes "approved" or "refused".');
|
|
820
|
+
mkdirSync(dir, { recursive: true });
|
|
821
|
+
writeFileSync(answerFile, `${answer}\n`);
|
|
822
|
+
return out(`The held call ${id} is ${answer}.`);
|
|
823
|
+
}
|
|
824
|
+
const minutes = Number(minutesGiven ?? config.harness.hold.minutes);
|
|
825
|
+
const until = Date.now() + minutes * 60000;
|
|
826
|
+
while (Date.now() < until) {
|
|
827
|
+
if (existsSync(answerFile)) {
|
|
828
|
+
const given = readFileSync(answerFile, 'utf8').trim();
|
|
829
|
+
rmSync(answerFile, { force: true });
|
|
830
|
+
return out(given === 'approved' ? 'approved' : 'refused');
|
|
831
|
+
}
|
|
832
|
+
await new Promise((done) => setTimeout(done, 200));
|
|
833
|
+
}
|
|
834
|
+
out('timed out');
|
|
835
|
+
}
|
|
836
|
+
|
|
494
837
|
function verify() {
|
|
495
838
|
const { config } = load();
|
|
496
839
|
const from = base(config, true);
|
|
@@ -615,6 +958,9 @@ function next() {
|
|
|
615
958
|
|
|
616
959
|
function adapters() {
|
|
617
960
|
const { raw } = load();
|
|
961
|
+
// JOIN-1: with --json, what the active adapters know of the lanes' work in a session (--session).
|
|
962
|
+
if (flag('--json'))
|
|
963
|
+
return out(JSON.stringify(adapterFacts(raw, resolve('.'), { session: sessionId }), null, 2));
|
|
618
964
|
const active = activeAdapters(raw, '.').map((a) => a.name);
|
|
619
965
|
for (const a of ADAPTERS) {
|
|
620
966
|
const off = raw.adapters?.[a.name] === false;
|
|
@@ -678,11 +1024,15 @@ async function approve(given) {
|
|
|
678
1024
|
`Not an approval or package name: ${bad.join(', ')}. Use the name the refusal gave, e.g. kit or dep-zod, or the package's name.`,
|
|
679
1025
|
);
|
|
680
1026
|
const marks = {};
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
1027
|
+
// A press in a pane: code-kit's own (pane), or another tool's that shares the project, by its name.
|
|
1028
|
+
if (via !== undefined && !/^[a-z][a-z0-9-]{0,39}$/.test(via))
|
|
1029
|
+
die(
|
|
1030
|
+
'--via takes "pane" (a press in the code-kit pane) or the name of the tool whose pane it was, such as context-graph.',
|
|
1031
|
+
);
|
|
1032
|
+
if (via !== undefined && delegated)
|
|
684
1033
|
die("An approval is either the person's (--via pane) or the lead's (--delegated), not both.");
|
|
685
|
-
if (via
|
|
1034
|
+
if (via !== undefined)
|
|
1035
|
+
for (const n of names) marks[n] = via === 'pane' ? PANE : `(approved in the ${via} pane)`;
|
|
686
1036
|
if (delegated) {
|
|
687
1037
|
const rules = config.approvals.delegate;
|
|
688
1038
|
if (!rules)
|
|
@@ -725,6 +1075,11 @@ const commands = {
|
|
|
725
1075
|
merge: () => (rest.length === 1 ? merge(rest[0]) : usage()),
|
|
726
1076
|
requests,
|
|
727
1077
|
stops,
|
|
1078
|
+
settings,
|
|
1079
|
+
hold: () => (rest.length === 1 ? hold(rest[0]) : usage()),
|
|
1080
|
+
queue,
|
|
1081
|
+
story: () => (rest.length === 1 ? story(rest[0]) : usage()),
|
|
1082
|
+
timeline,
|
|
728
1083
|
'sent-back': () => (rest.length === 1 ? sentBackCommand(rest[0]) : usage()),
|
|
729
1084
|
trace: () => (rest.length ? trace(rest) : usage()),
|
|
730
1085
|
adapters,
|
|
@@ -744,7 +1099,11 @@ function usage() {
|
|
|
744
1099
|
' | next [--base ref] [--json] | adapters | trace <path>... [--json]\n' +
|
|
745
1100
|
' | approve <name>... --reason "…" [--lane name] [--config file]\n' +
|
|
746
1101
|
' | merge <branch> --delegated|--person [--into branch] | requests [--json]\n' +
|
|
747
|
-
' | stops [--session id] [--json] | sent-back <branch> [--reason "…"]'
|
|
1102
|
+
' | stops [--session id] [--json] | sent-back <branch> [--reason "…"]\n' +
|
|
1103
|
+
' | settings [--json] | settings set <key> <value> --reason "…" [--via pane]\n' +
|
|
1104
|
+
' | hold <id> [--minutes N] | hold <id> --answer approved|refused\n' +
|
|
1105
|
+
' | queue [--json] | queue add|drop <branch> | queue merge --delegated|--person\n' +
|
|
1106
|
+
' | story <id> [--json] | timeline [--json]',
|
|
748
1107
|
);
|
|
749
1108
|
}
|
|
750
1109
|
await (commands[command] ?? usage)();
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -9,8 +9,9 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
10
|
import { join, resolve } from 'node:path';
|
|
11
11
|
import { addedPackages, dependencyApproval, isDependencyFile } from './lib/dependencies.mjs';
|
|
12
|
-
import { CODE_KIT, GIT, codeOf, runs } from './lib/shell.mjs';
|
|
12
|
+
import { CODE_KIT, GIT, codeOf, commandsOf, runs } from './lib/shell.mjs';
|
|
13
13
|
import { block, start } from './lib/hook.mjs';
|
|
14
|
+
import { noteBefore } from './lane-audit.mjs';
|
|
14
15
|
import {
|
|
15
16
|
APPROVAL_LOG,
|
|
16
17
|
actorFor,
|
|
@@ -54,10 +55,14 @@ const ALWAYS = [
|
|
|
54
55
|
'Piping downloads into a shell is not allowed.',
|
|
55
56
|
codeOf(cmd),
|
|
56
57
|
],
|
|
57
|
-
[
|
|
58
|
+
[
|
|
59
|
+
/\.claude\/approvals/,
|
|
60
|
+
"Approvals are created only by a person, with a `!` shell command. A shell command may not name them, even to read them; read them with the Read tool (or Glob to list them), which can't write.",
|
|
61
|
+
cmd,
|
|
62
|
+
],
|
|
58
63
|
[
|
|
59
64
|
/approval-log\.jsonl/,
|
|
60
|
-
|
|
65
|
+
"The approval audit log is appended only by the commit hook; nobody edits it. A shell command may not name it, even to read it; read it with the Read tool, which can't write.",
|
|
61
66
|
cmd,
|
|
62
67
|
],
|
|
63
68
|
];
|
|
@@ -66,7 +71,8 @@ for (const [re, why, text] of ALWAYS) {
|
|
|
66
71
|
}
|
|
67
72
|
const packages = addedPackages(cmd);
|
|
68
73
|
const approves = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+approve\b`));
|
|
69
|
-
const delegatedMerge = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+merge\b`));
|
|
74
|
+
const delegatedMerge = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+(?:queue\s+)?merge\b`));
|
|
75
|
+
const queues = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+queue\s+(?:add|drop)\b`));
|
|
70
76
|
const commits = runs(cmd, new RegExp(String.raw`^${GIT}commit\b`));
|
|
71
77
|
if (error) {
|
|
72
78
|
if (commits) block(`Blocked: ${error}\nFix the config before committing.`);
|
|
@@ -119,11 +125,16 @@ if (runs(cmd, new RegExp(String.raw`^${GIT}push\b.*\b(${branches})\b`))) {
|
|
|
119
125
|
|
|
120
126
|
const sessionIn = sessionRoot(input, project);
|
|
121
127
|
const actor = actorFor(input, sessionIn ?? project, config);
|
|
128
|
+
// A read-only agent's command is judged afterwards by what it changed (lane-audit).
|
|
129
|
+
if (actor.kind === 'readonly') noteBefore(input);
|
|
122
130
|
// The lead acts for the person only where the config says the person may approve from chat.
|
|
123
131
|
const forPerson = actor.kind === 'lead' && config.approvals.lead;
|
|
124
132
|
|
|
125
|
-
for (const { pattern, why, person } of config.shell.block) {
|
|
126
|
-
|
|
133
|
+
for (const { pattern, why, person, command } of config.shell.block) {
|
|
134
|
+
// `command: true`: the pattern names a command, matched from the start of each one the line runs, so a
|
|
135
|
+
// heredoc or a quoted message that mentions it isn't refused. Otherwise it reads the whole text.
|
|
136
|
+
const matched = command ? runs(cmd, new RegExp(pattern)) : new RegExp(pattern).test(cmd);
|
|
137
|
+
if (!matched || (person && forPerson)) continue;
|
|
127
138
|
const hint =
|
|
128
139
|
person && actor.kind === 'lead'
|
|
129
140
|
? '\nTo let the lead run it when the person says so in chat, a person sets "approvals": { "lead": true } in the config.'
|
|
@@ -140,6 +151,15 @@ if ((delegatedMerge && /\s--person\b/.test(cmd)) || (approves && /\s--via\b/.tes
|
|
|
140
151
|
block(
|
|
141
152
|
`Blocked: merging or approving as the person is the person's own act, from the code-kit pane or their terminal. Ask them, and stop.\nCommand: ${cmd}`,
|
|
142
153
|
);
|
|
154
|
+
// The harness settings are the person's to change, from the pane or their terminal.
|
|
155
|
+
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+settings\s+set\b`)))
|
|
156
|
+
block(
|
|
157
|
+
`Blocked: changing the harness settings is the person's own act, from the code-kit pane (/harness) or their terminal. Tell them what you'd change and why, and stop.\nCommand: ${cmd}`,
|
|
158
|
+
);
|
|
159
|
+
if (queues && actor.kind !== 'lead')
|
|
160
|
+
block(
|
|
161
|
+
`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}`,
|
|
162
|
+
);
|
|
143
163
|
if (delegatedMerge && actor.kind !== 'lead')
|
|
144
164
|
block(
|
|
145
165
|
`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}`,
|
|
@@ -219,15 +239,16 @@ if (writesHistory && config.branches.protected.includes(branch)) {
|
|
|
219
239
|
);
|
|
220
240
|
}
|
|
221
241
|
|
|
222
|
-
// Kit files edited through the shell follow the same rule as edits through Write/Edit
|
|
223
|
-
|
|
224
|
-
|
|
242
|
+
// Kit files edited through the shell follow the same rule as edits through Write/Edit: a command that
|
|
243
|
+
// names a kit file and writes, judged one simple command at a time, so a redirect elsewhere in the line
|
|
244
|
+
// (`npm install > /tmp/log; ls .claude/skills`) isn't taken for a write to the kit. A write the shell
|
|
245
|
+
// hides (a script, a variable) is still caught after the call, as a change outside the actor's paths.
|
|
246
|
+
const KIT_PATH = /\.claude\/(hooks|agents|skills|settings[^/]*\.json|README\.md|code-kit\.json)/;
|
|
225
247
|
// a real redirect into a file; `2>&1` and `>/dev/null` are not writes
|
|
226
|
-
const
|
|
227
|
-
/((?<![0-9&])>>?\s*(?!&|\/dev\/null)\S|\btee\b|\bsed\s+-i|\bperl\s+-i|\bmv\b|\bcp\b|\brm\b|\bpython3?\b|\bnode\s+-e|\btruncate\b|\bchmod\b|\bln\b)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (touchesKit && writes && actor.kind !== 'lead' && !approval(root, 'kit', actor)) {
|
|
248
|
+
const WRITES =
|
|
249
|
+
/((?<![0-9&])>>?\s*(?!&|\/dev\/null)\S|\btee\b|\bsed\s+-i|\bperl\s+-i|\bmv\b|\bcp\b|\brm\b|\bpython3?\b|\bnode\s+-e|\btruncate\b|\bchmod\b|\bln\b)/;
|
|
250
|
+
const kitWrite = commandsOf(cmd).some((c) => KIT_PATH.test(c.text) && WRITES.test(c.text));
|
|
251
|
+
if (kitWrite && actor.kind !== 'lead' && !approval(root, 'kit', actor)) {
|
|
231
252
|
recordRequest(
|
|
232
253
|
root,
|
|
233
254
|
actor,
|