@warren-dean/code-kit 0.6.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 +37 -0
- package/README.md +19 -9
- package/bin/code-kit.mjs +353 -39
- package/hooks/guard-bash.mjs +30 -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 +43 -2
- package/hooks/lib/dependencies.mjs +26 -9
- package/hooks/lib/harness.mjs +2 -2
- 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/loop.mjs +68 -0
- package/hooks/mod/panes.mjs +943 -0
- package/hooks/mod/register.mjs +978 -15
- package/hooks/mod/register.test.ts +1449 -14
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/view.mjs +430 -38
- 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
|
@@ -25,19 +25,35 @@
|
|
|
25
25
|
// record approvals a person gave in chat (with approvals.lead on, the lead runs it)
|
|
26
26
|
// --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
|
|
27
27
|
import { execFileSync } from 'node:child_process';
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
mkdtempSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
rmSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from 'node:fs';
|
|
29
37
|
import { tmpdir } from 'node:os';
|
|
30
38
|
import { join, posix, resolve } from 'node:path';
|
|
31
39
|
import { BASELINE_FILE, loadBaseline } from '../hooks/lib/baseline.mjs';
|
|
32
40
|
import { matchesAny } from '../hooks/lib/glob.mjs';
|
|
33
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
CONFIG_FILE,
|
|
43
|
+
committedConfig,
|
|
44
|
+
effectiveConfig,
|
|
45
|
+
mergeDelegation,
|
|
46
|
+
validate,
|
|
47
|
+
} from '../hooks/lib/config.mjs';
|
|
34
48
|
import { diffConfigs, ownershipMoves } from '../hooks/lib/diff.mjs';
|
|
35
49
|
import { CODE, importsOf, layerOf, layerProblems, targetOf } from '../hooks/lib/layers.mjs';
|
|
36
|
-
import { ADAPTERS, activeAdapters } from '../hooks/lib/adapters/index.mjs';
|
|
50
|
+
import { ADAPTERS, activeAdapters, adapterFacts } from '../hooks/lib/adapters/index.mjs';
|
|
37
51
|
import { nextStep } from '../hooks/lib/next.mjs';
|
|
38
52
|
import { traceFile } from '../hooks/lib/trace.mjs';
|
|
39
|
-
import { buildStatus } from '../hooks/lib/plan.mjs';
|
|
53
|
+
import { buildStatus, specCheckRows } from '../hooks/lib/plan.mjs';
|
|
40
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';
|
|
41
57
|
import { SETTINGS, parseSetting, settingOf, withSetting } from '../hooks/lib/harness.mjs';
|
|
42
58
|
import {
|
|
43
59
|
APPROVAL_MINUTES,
|
|
@@ -80,6 +96,8 @@ const laneName = option('--lane');
|
|
|
80
96
|
const intoName = option('--into');
|
|
81
97
|
const via = option('--via');
|
|
82
98
|
const sessionId = option('--session');
|
|
99
|
+
const minutesGiven = option('--minutes');
|
|
100
|
+
const answerGiven = option('--answer');
|
|
83
101
|
const FLAGS = ['--write', '--no-checks', '--json', '--delegated', '--person'];
|
|
84
102
|
const flag = (name) => args.includes(name);
|
|
85
103
|
const writeBaseline = flag('--write');
|
|
@@ -123,6 +141,12 @@ function checkJson() {
|
|
|
123
141
|
report.adapters = config.adapters;
|
|
124
142
|
report.docs = config.docs ?? null;
|
|
125
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;
|
|
126
150
|
out(JSON.stringify(report, null, 2));
|
|
127
151
|
}
|
|
128
152
|
|
|
@@ -302,29 +326,27 @@ const VERIFY_GROUPS = {
|
|
|
302
326
|
// merges, and only when `verify` passes on that branch, checks included. A person merges with git.
|
|
303
327
|
// With --person, the person merges (the mod passes it for a press in its pane): the same verify first,
|
|
304
328
|
// with no delegation needed. The hooks refuse --person from every agent.
|
|
305
|
-
/**
|
|
306
|
-
* The config as committed on `ref`: what governs merging into it, so a branch's own config can't grant
|
|
307
|
-
* itself a merge its target doesn't allow.
|
|
308
|
-
*/
|
|
329
|
+
/** The config committed on `ref`, or null when it has none yet; an invalid one stops here. */
|
|
309
330
|
function configAt(ref) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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;
|
|
320
345
|
}
|
|
321
|
-
const problems = validate(raw);
|
|
322
|
-
if (problems.length) die(`${file} on ${ref} is invalid:\n ${problems.join('\n ')}`);
|
|
323
|
-
return effectiveConfig(raw, '.');
|
|
324
346
|
}
|
|
325
347
|
|
|
326
|
-
|
|
327
|
-
|
|
348
|
+
/** Who merges, from the flags: the lead within the delegated rules, or the person. */
|
|
349
|
+
function mergeMode() {
|
|
328
350
|
const byPerson = flag('--person');
|
|
329
351
|
if (!flag('--delegated') && !byPerson)
|
|
330
352
|
die(
|
|
@@ -332,23 +354,46 @@ function merge(branch) {
|
|
|
332
354
|
);
|
|
333
355
|
if (flag('--delegated') && byPerson)
|
|
334
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
|
+
};
|
|
335
379
|
const into = intoName ?? config.branches.protected[0];
|
|
336
380
|
// Whether the lead may merge, and which branches are protected, are the target's rules.
|
|
337
|
-
|
|
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)));
|
|
338
383
|
if (!byPerson && !target.approvals.delegate?.merge)
|
|
339
|
-
|
|
384
|
+
refuse(
|
|
340
385
|
`${into} doesn't delegate merges to the lead ("approvals.delegate.merge" isn't true in its ${file}). A person merges after review.`,
|
|
341
386
|
);
|
|
342
387
|
if (!target.branches.protected.includes(into))
|
|
343
|
-
|
|
344
|
-
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.`);
|
|
345
390
|
const root = resolve('.');
|
|
346
391
|
const g = (cwd, ...a) =>
|
|
347
392
|
execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
348
393
|
try {
|
|
349
394
|
g(root, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`);
|
|
350
395
|
} catch {
|
|
351
|
-
|
|
396
|
+
refuse(`There is no branch ${branch}.`);
|
|
352
397
|
}
|
|
353
398
|
const checkedOut = (b) =>
|
|
354
399
|
g(root, 'worktree', 'list', '--porcelain')
|
|
@@ -360,7 +405,7 @@ function merge(branch) {
|
|
|
360
405
|
fail(`${b} has uncommitted changes in ${dir}; commit or stash them first.`);
|
|
361
406
|
};
|
|
362
407
|
const temporary = [];
|
|
363
|
-
//
|
|
408
|
+
// A stop is thrown at once, so every way out of here removes the temporary worktrees first.
|
|
364
409
|
const cleanup = () => {
|
|
365
410
|
for (const dir of temporary.splice(0)) {
|
|
366
411
|
try {
|
|
@@ -370,9 +415,9 @@ function merge(branch) {
|
|
|
370
415
|
}
|
|
371
416
|
}
|
|
372
417
|
};
|
|
373
|
-
const fail = (message) => {
|
|
418
|
+
const fail = (message, kind = 'refused') => {
|
|
374
419
|
cleanup();
|
|
375
|
-
|
|
420
|
+
throw new MergeStop(kind, message);
|
|
376
421
|
};
|
|
377
422
|
const worktree = (b, detach) => {
|
|
378
423
|
const dir = mkdtempSync(join(tmpdir(), 'code-kit-merge-'));
|
|
@@ -400,6 +445,7 @@ function merge(branch) {
|
|
|
400
445
|
if (problems.length)
|
|
401
446
|
fail(
|
|
402
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',
|
|
403
449
|
);
|
|
404
450
|
const mergeIn = checkedOut(into) ?? worktree(into, false);
|
|
405
451
|
clean(mergeIn, into);
|
|
@@ -422,16 +468,239 @@ function merge(branch) {
|
|
|
422
468
|
}
|
|
423
469
|
fail(
|
|
424
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',
|
|
425
472
|
);
|
|
426
473
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
+
};
|
|
430
493
|
} finally {
|
|
431
494
|
cleanup();
|
|
432
495
|
}
|
|
433
496
|
}
|
|
434
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
|
+
|
|
435
704
|
// Open approval requests and the approvals in force, for the person and the mod's band.
|
|
436
705
|
function requests() {
|
|
437
706
|
load();
|
|
@@ -481,6 +750,8 @@ function settings() {
|
|
|
481
750
|
value: settingOf(config.harness, key),
|
|
482
751
|
default: s.default,
|
|
483
752
|
about: s.about,
|
|
753
|
+
// Chosen in the config, or the default standing in.
|
|
754
|
+
set: settingOf(raw.harness, key) !== undefined,
|
|
484
755
|
}));
|
|
485
756
|
if (flag('--json')) return out(JSON.stringify(rows, null, 2));
|
|
486
757
|
for (const r of rows)
|
|
@@ -534,6 +805,35 @@ function stops() {
|
|
|
534
805
|
out(`${s.agentType ?? 'the lead'}${s.session ? ` (session ${s.session})` : ''}: ${s.title}`);
|
|
535
806
|
}
|
|
536
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
|
+
|
|
537
837
|
function verify() {
|
|
538
838
|
const { config } = load();
|
|
539
839
|
const from = base(config, true);
|
|
@@ -658,6 +958,9 @@ function next() {
|
|
|
658
958
|
|
|
659
959
|
function adapters() {
|
|
660
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));
|
|
661
964
|
const active = activeAdapters(raw, '.').map((a) => a.name);
|
|
662
965
|
for (const a of ADAPTERS) {
|
|
663
966
|
const off = raw.adapters?.[a.name] === false;
|
|
@@ -721,11 +1024,15 @@ async function approve(given) {
|
|
|
721
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.`,
|
|
722
1025
|
);
|
|
723
1026
|
const marks = {};
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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)
|
|
727
1033
|
die("An approval is either the person's (--via pane) or the lead's (--delegated), not both.");
|
|
728
|
-
if (via
|
|
1034
|
+
if (via !== undefined)
|
|
1035
|
+
for (const n of names) marks[n] = via === 'pane' ? PANE : `(approved in the ${via} pane)`;
|
|
729
1036
|
if (delegated) {
|
|
730
1037
|
const rules = config.approvals.delegate;
|
|
731
1038
|
if (!rules)
|
|
@@ -769,6 +1076,10 @@ const commands = {
|
|
|
769
1076
|
requests,
|
|
770
1077
|
stops,
|
|
771
1078
|
settings,
|
|
1079
|
+
hold: () => (rest.length === 1 ? hold(rest[0]) : usage()),
|
|
1080
|
+
queue,
|
|
1081
|
+
story: () => (rest.length === 1 ? story(rest[0]) : usage()),
|
|
1082
|
+
timeline,
|
|
772
1083
|
'sent-back': () => (rest.length === 1 ? sentBackCommand(rest[0]) : usage()),
|
|
773
1084
|
trace: () => (rest.length ? trace(rest) : usage()),
|
|
774
1085
|
adapters,
|
|
@@ -789,7 +1100,10 @@ function usage() {
|
|
|
789
1100
|
' | approve <name>... --reason "…" [--lane name] [--config file]\n' +
|
|
790
1101
|
' | merge <branch> --delegated|--person [--into branch] | requests [--json]\n' +
|
|
791
1102
|
' | stops [--session id] [--json] | sent-back <branch> [--reason "…"]\n' +
|
|
792
|
-
' | settings [--json] | settings set <key> <value> --reason "…" [--via pane]'
|
|
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]',
|
|
793
1107
|
);
|
|
794
1108
|
}
|
|
795
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.'
|
|
@@ -145,6 +156,10 @@ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+settings\s+set\b`)))
|
|
|
145
156
|
block(
|
|
146
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}`,
|
|
147
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
|
+
);
|
|
148
163
|
if (delegatedMerge && actor.kind !== 'lead')
|
|
149
164
|
block(
|
|
150
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}`,
|
|
@@ -224,15 +239,16 @@ if (writesHistory && config.branches.protected.includes(branch)) {
|
|
|
224
239
|
);
|
|
225
240
|
}
|
|
226
241
|
|
|
227
|
-
// Kit files edited through the shell follow the same rule as edits through Write/Edit
|
|
228
|
-
|
|
229
|
-
|
|
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)/;
|
|
230
247
|
// a real redirect into a file; `2>&1` and `>/dev/null` are not writes
|
|
231
|
-
const
|
|
232
|
-
/((?<![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)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
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)) {
|
|
236
252
|
recordRequest(
|
|
237
253
|
root,
|
|
238
254
|
actor,
|