@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/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 { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
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 { CONFIG_FILE, effectiveConfig, validate } from '../hooks/lib/config.mjs';
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
- let raw;
311
- try {
312
- raw = JSON.parse(
313
- execFileSync('git', ['show', `${ref}:${file}`], {
314
- encoding: 'utf8',
315
- stdio: ['ignore', 'pipe', 'ignore'],
316
- }),
317
- );
318
- } catch {
319
- die(`${ref} has no readable ${file}; a merge into it follows the rules committed there.`);
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
- function merge(branch) {
327
- const { config } = load();
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
- const target = configAt(into);
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
- die(
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
- die(`${into} isn't a protected branch; merge into it with git.`);
344
- if (target.branches.protected.includes(branch)) die(`${branch} is itself a protected 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
- die(`There is no branch ${branch}.`);
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
- // die() exits at once, so every way out of here removes the temporary worktrees first.
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
- die(message);
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
- out(
428
- `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}.`,
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
- if (via !== undefined && via !== 'pane')
725
- die('--via takes only "pane": the code-kit mod passes it for a press in its pane.');
726
- if (via === 'pane' && delegated)
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 === 'pane') for (const n of names) marks[n] = PANE;
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)();
@@ -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
- [/\.claude\/approvals/, 'Approvals are created only by a person, with a `!` shell command.', cmd],
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
- 'The approval audit log is appended only by the commit hook; nobody edits it.',
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
- if (!new RegExp(pattern).test(cmd) || (person && forPerson)) continue;
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
- const touchesKit =
229
- /\.claude\/(hooks|agents|skills|settings[^/]*\.json|README\.md|code-kit\.json)/.test(cmd);
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 writes =
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)/.test(
233
- cmd,
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,