@warren-dean/code-kit 0.6.0 → 0.8.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,20 +25,39 @@
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 { storyMap, trail } from '../hooks/lib/storymap.mjs';
57
+ import { journal } from '../hooks/lib/journal.mjs';
58
+ import { dequeue, enqueue, lastMerged, readQueue, settle, waiting } from '../hooks/lib/queue.mjs';
41
59
  import { SETTINGS, parseSetting, settingOf, withSetting } from '../hooks/lib/harness.mjs';
60
+ import { readLoop, writeLoop } from '../hooks/lib/loop-state.mjs';
42
61
  import {
43
62
  APPROVAL_MINUTES,
44
63
  approvalLifetime,
@@ -80,6 +99,8 @@ const laneName = option('--lane');
80
99
  const intoName = option('--into');
81
100
  const via = option('--via');
82
101
  const sessionId = option('--session');
102
+ const minutesGiven = option('--minutes');
103
+ const answerGiven = option('--answer');
83
104
  const FLAGS = ['--write', '--no-checks', '--json', '--delegated', '--person'];
84
105
  const flag = (name) => args.includes(name);
85
106
  const writeBaseline = flag('--write');
@@ -123,6 +144,12 @@ function checkJson() {
123
144
  report.adapters = config.adapters;
124
145
  report.docs = config.docs ?? null;
125
146
  report.harness = config.harness;
147
+ // The harness settings the person chose, as opposed to defaults: the mod asks before acting on autonomy.
148
+ report.harnessSet = Object.keys(SETTINGS).filter((k) => settingOf(raw.harness, k) !== undefined);
149
+ // Delegation is the base's, by the rules committed there: a branch can't grant itself merges.
150
+ const delegation = mergeDelegation('.', config, file);
151
+ report.delegatesMerge = delegation.delegated;
152
+ report.firstMerge = delegation.first;
126
153
  out(JSON.stringify(report, null, 2));
127
154
  }
128
155
 
@@ -302,29 +329,27 @@ const VERIFY_GROUPS = {
302
329
  // merges, and only when `verify` passes on that branch, checks included. A person merges with git.
303
330
  // With --person, the person merges (the mod passes it for a press in its pane): the same verify first,
304
331
  // 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
- */
332
+ /** The config committed on `ref`, or null when it has none yet; an invalid one stops here. */
309
333
  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.`);
334
+ const { config, error } = committedConfig('.', ref, file);
335
+ if (error) die(error);
336
+ return config ?? null;
337
+ }
338
+
339
+ /** Why the first merge into `into`, which has no config committed yet, is the person's. */
340
+ const firstMerge = (into, branch) =>
341
+ `${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.`;
342
+
343
+ /** Why a merge didn't happen: `refused` (the rules), `verify` (problems) or `conflict`. */
344
+ class MergeStop extends Error {
345
+ constructor(kind, message) {
346
+ super(message);
347
+ this.kind = kind;
320
348
  }
321
- const problems = validate(raw);
322
- if (problems.length) die(`${file} on ${ref} is invalid:\n ${problems.join('\n ')}`);
323
- return effectiveConfig(raw, '.');
324
349
  }
325
350
 
326
- function merge(branch) {
327
- const { config } = load();
351
+ /** Who merges, from the flags: the lead within the delegated rules, or the person. */
352
+ function mergeMode() {
328
353
  const byPerson = flag('--person');
329
354
  if (!flag('--delegated') && !byPerson)
330
355
  die(
@@ -332,23 +357,46 @@ function merge(branch) {
332
357
  );
333
358
  if (flag('--delegated') && byPerson)
334
359
  die("A merge is either the lead's (--delegated) or the person's (--person), not both.");
360
+ return byPerson;
361
+ }
362
+
363
+ function merge(branch) {
364
+ const { config } = load();
365
+ const byPerson = mergeMode();
366
+ try {
367
+ out(mergeOne(branch, config, byPerson).message);
368
+ } catch (e) {
369
+ if (e instanceof MergeStop) die(e.message);
370
+ throw e;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Verifies `branch`, checks included, and merges it into the base (`--into`, or the first protected
376
+ * branch). Returns { into, message }; throws a MergeStop when nothing was merged.
377
+ */
378
+ function mergeOne(branch, config, byPerson) {
379
+ const refuse = (message) => {
380
+ throw new MergeStop('refused', message);
381
+ };
335
382
  const into = intoName ?? config.branches.protected[0];
336
383
  // Whether the lead may merge, and which branches are protected, are the target's rules.
337
- const target = configAt(into);
384
+ // A person may make the first merge, under the rules it brings; the lead may not.
385
+ const target = configAt(into) ?? (byPerson ? config : refuse(firstMerge(into, branch)));
338
386
  if (!byPerson && !target.approvals.delegate?.merge)
339
- die(
387
+ refuse(
340
388
  `${into} doesn't delegate merges to the lead ("approvals.delegate.merge" isn't true in its ${file}). A person merges after review.`,
341
389
  );
342
390
  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.`);
391
+ refuse(`${into} isn't a protected branch; merge into it with git.`);
392
+ if (target.branches.protected.includes(branch)) refuse(`${branch} is itself a protected branch.`);
345
393
  const root = resolve('.');
346
394
  const g = (cwd, ...a) =>
347
395
  execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
348
396
  try {
349
397
  g(root, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`);
350
398
  } catch {
351
- die(`There is no branch ${branch}.`);
399
+ refuse(`There is no branch ${branch}.`);
352
400
  }
353
401
  const checkedOut = (b) =>
354
402
  g(root, 'worktree', 'list', '--porcelain')
@@ -360,7 +408,7 @@ function merge(branch) {
360
408
  fail(`${b} has uncommitted changes in ${dir}; commit or stash them first.`);
361
409
  };
362
410
  const temporary = [];
363
- // die() exits at once, so every way out of here removes the temporary worktrees first.
411
+ // A stop is thrown at once, so every way out of here removes the temporary worktrees first.
364
412
  const cleanup = () => {
365
413
  for (const dir of temporary.splice(0)) {
366
414
  try {
@@ -370,9 +418,9 @@ function merge(branch) {
370
418
  }
371
419
  }
372
420
  };
373
- const fail = (message) => {
421
+ const fail = (message, kind = 'refused') => {
374
422
  cleanup();
375
- die(message);
423
+ throw new MergeStop(kind, message);
376
424
  };
377
425
  const worktree = (b, detach) => {
378
426
  const dir = mkdtempSync(join(tmpdir(), 'code-kit-merge-'));
@@ -400,6 +448,7 @@ function merge(branch) {
400
448
  if (problems.length)
401
449
  fail(
402
450
  `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.`,
451
+ 'verify',
403
452
  );
404
453
  const mergeIn = checkedOut(into) ?? worktree(into, false);
405
454
  clean(mergeIn, into);
@@ -422,16 +471,302 @@ function merge(branch) {
422
471
  }
423
472
  fail(
424
473
  `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()}`,
474
+ 'conflict',
425
475
  );
426
476
  }
427
- out(
428
- `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}.`,
429
- );
477
+ // A lane agent's worktree has done its work once its branch is merged: removed when it holds
478
+ // nothing uncommitted (the branch stays), so finished lanes don't pile up checkouts.
479
+ let removed = '';
480
+ const agentTree = checkedOut(branch);
481
+ if (
482
+ agentTree?.includes('/.claude/worktrees/') &&
483
+ !g(agentTree, 'status', '--porcelain').trim()
484
+ ) {
485
+ try {
486
+ g(root, 'worktree', 'remove', agentTree);
487
+ removed = ` Removed its worktree, ${agentTree}.`;
488
+ } catch {
489
+ // in use, or locked: left as it is
490
+ }
491
+ }
492
+ return {
493
+ into,
494
+ message: `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}.${removed}`,
495
+ };
430
496
  } finally {
431
497
  cleanup();
432
498
  }
433
499
  }
434
500
 
501
+ /**
502
+ * The merge queue (spec 09): `queue` lists it; `queue add <branch>` puts a branch the lead's review
503
+ * passed at the back; `queue drop <branch>` takes one out; `queue merge --delegated|--person` merges
504
+ * the head, verified against the base as it is now. A head that conflicts or fails verify is sent
505
+ * back to its lane (recorded as `sent-back` records it) and leaves the queue; the next one is then head.
506
+ */
507
+ function queue() {
508
+ const { config } = load();
509
+ const root = resolve('.');
510
+ const [sub, branch] = rest;
511
+ if (sub === 'add' || sub === 'drop') {
512
+ if (!branch) die(`code-kit queue ${sub} <branch>`);
513
+ if (sub === 'drop')
514
+ return out(
515
+ dequeue(root, branch)
516
+ ? `Took ${branch} out of the merge queue.`
517
+ : `${branch} isn't waiting in the merge queue.`,
518
+ );
519
+ try {
520
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], {
521
+ stdio: 'ignore',
522
+ });
523
+ } catch {
524
+ die(`There is no branch ${branch}.`);
525
+ }
526
+ if (!enqueue(root, branch)) return out(`${branch} is already in the merge queue.`);
527
+ const place = waiting(readQueue(root)).length;
528
+ return out(`Queued ${branch} to merge${place > 1 ? `, ${place - 1} ahead of it` : ', next'}.`);
529
+ }
530
+ if (sub === 'merge') {
531
+ const byPerson = mergeMode();
532
+ const entries = readQueue(root);
533
+ const head = waiting(entries)[0];
534
+ if (!head) return out('The merge queue is empty.');
535
+ try {
536
+ const { message } = mergeOne(head.branch, config, byPerson);
537
+ settle(root, head.branch, 'merged');
538
+ const left = waiting(readQueue(root));
539
+ return out(
540
+ `${message}${left.length ? ` Next in the queue: ${left[0].branch}.` : ' The queue is empty.'}`,
541
+ );
542
+ } catch (e) {
543
+ if (!(e instanceof MergeStop)) throw e;
544
+ // MQ-3: a conflict or a failed verify goes back to the lane; a refusal leaves the queue as it is.
545
+ if (e.kind === 'refused') die(e.message);
546
+ const into = intoName ?? config.branches.protected[0];
547
+ const after = lastMerged(entries);
548
+ const why =
549
+ e.kind === 'conflict'
550
+ ? `conflicts with ${into}${after ? ` after ${after}` : ''}`
551
+ : `fails verify against ${into}${after ? ` after ${after}` : ''}`;
552
+ const sha = execFileSync('git', ['rev-parse', '--verify', `${head.branch}^{commit}`], {
553
+ encoding: 'utf8',
554
+ }).trim();
555
+ recordSentBack(root, head.branch, sha, why);
556
+ settle(root, head.branch, 'sent back', why);
557
+ die(
558
+ `${e.message}\n${head.branch} is sent back to its lane (${why}) and leaves the queue; its fix rejoins after the next review.`,
559
+ );
560
+ }
561
+ }
562
+ if (sub !== undefined)
563
+ die(
564
+ 'code-kit queue [--json] | queue add <branch> | queue drop <branch> | queue merge --delegated|--person',
565
+ );
566
+ const entries = readQueue(root);
567
+ if (flag('--json')) return out(JSON.stringify(entries, null, 2));
568
+ const left = waiting(entries);
569
+ if (!left.length) out('The merge queue is empty.');
570
+ else out(`${left.length} waiting to merge, in order:`);
571
+ left.forEach((e, i) => out(` ${i + 1}. ${e.branch} (passed review ${e.passed})`));
572
+ }
573
+
574
+ /**
575
+ * MAP-1: a story's code: the files it changed by layer, with the imports between them, and what the
576
+ * active adapters know of each (its card, its rules). `map <id> --json` is what the pane draws.
577
+ */
578
+ function mapCommand(id) {
579
+ const { config, raw } = load();
580
+ const { specs, plan } = config.docs ?? {};
581
+ if (!specs || !plan)
582
+ die('map needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
583
+ const from = base(config, false);
584
+ const { stories } = buildStatus('.', config, from, tracked());
585
+ const s = stories.find((x) => x.id === id.toUpperCase());
586
+ if (!s) die(`There is no story ${id} in ${plan}.`);
587
+ const map = storyMap(resolve('.'), s, config, from);
588
+ const facts = adapterFacts(raw, resolve('.'), { paths: map.files.map((f) => f.path) });
589
+ for (const f of map.files)
590
+ f.adapters = Object.fromEntries(
591
+ facts.filter((a) => a.files?.[f.path]).map((a) => [a.name, a.files[f.path]]),
592
+ );
593
+ map.open = facts.find((a) => a.open)?.open ?? null;
594
+ if (flag('--json')) return out(JSON.stringify(map, null, 2));
595
+ out(`${s.id} ${s.title} (${map.files.length} file(s)):`);
596
+ for (const layer of map.layers) {
597
+ out(` ${layer}`);
598
+ for (const f of map.files.filter((x) => x.layer === layer))
599
+ out(` ${f.path}${f.imports.length ? ` → ${f.imports.join(', ')}` : ''}`);
600
+ }
601
+ }
602
+
603
+ /** JOURNAL-1: the build's lineage, from the request to each merge, in plain sentences. */
604
+ function journalCommand() {
605
+ const { config } = load();
606
+ const { specs, plan } = config.docs ?? {};
607
+ if (!specs || !plan)
608
+ die('journal needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
609
+ const from = base(config, false);
610
+ const events = journal(
611
+ resolve('.'),
612
+ buildStatus('.', config, from, tracked()),
613
+ from,
614
+ config.docs,
615
+ );
616
+ if (flag('--json')) return out(JSON.stringify({ base: from, events }, null, 2));
617
+ for (const e of events)
618
+ out(`${new Date(e.at).toISOString().slice(0, 16).replace('T', ' ')} ${e.text}`);
619
+ }
620
+
621
+ /** TRAIL-1: each requirement to its stories, the code they changed and the tests that name it. */
622
+ function trailCommand() {
623
+ const { config } = load();
624
+ const { specs, plan } = config.docs ?? {};
625
+ if (!specs || !plan)
626
+ die('trail needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
627
+ const from = base(config, false);
628
+ const status = buildStatus('.', config, from, tracked());
629
+ const rows = trail(resolve('.'), status, from);
630
+ if (flag('--json')) return out(JSON.stringify({ base: from, requirements: rows }, null, 2));
631
+ for (const r of rows)
632
+ out(
633
+ `${pad(r.id, 10)} ${pad(r.stories.map((s) => s.id).join(', ') || '-', 14)} ${pad(`${r.files.length} file(s)`, 10)} ${r.tests.length ? r.tests.join(', ') : 'no test names it'}`,
634
+ );
635
+ }
636
+
637
+ /** When each story's work happened, for the mod's timelines (VIEW-2). */
638
+ function timeline() {
639
+ const { config } = load();
640
+ const { specs, plan } = config.docs ?? {};
641
+ if (!specs || !plan)
642
+ die(
643
+ 'timeline needs docs.specs and docs.plan in the config. The spec-design skill writes both.',
644
+ );
645
+ const from = base(config, false);
646
+ const { stories } = buildStatus('.', config, from, tracked());
647
+ const times = storyTimes(resolve('.'), stories, from);
648
+ if (flag('--json'))
649
+ return out(JSON.stringify({ base: from, now: Date.now(), stories: times }, null, 2));
650
+ const when = (ms) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 16) : '-');
651
+ for (const s of times)
652
+ out(
653
+ `${s.id} ${s.branch}: started ${when(s.started)}, last commit ${when(s.lastCommit)}, merged ${when(s.merged)}`,
654
+ );
655
+ }
656
+
657
+ /** The most of a story's diff `story` prints; past it the text is cut and says so. */
658
+ const DIFF_KEPT = 256 * 1024;
659
+
660
+ /**
661
+ * One story's facts, for the mod's drill-down (VIEW-7): its requirements with the spec-check report's
662
+ * rows, its branch's diff against the base, and verify's problems on it (without the checks, which
663
+ * merge runs), where it's checked out or in a temporary worktree.
664
+ */
665
+ function story(id) {
666
+ const { config } = load();
667
+ const { specs, plan } = config.docs ?? {};
668
+ if (!specs || !plan)
669
+ die('story needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
670
+ const from = base(config, false);
671
+ const { stories, requirements } = buildStatus('.', config, from, tracked());
672
+ const s = stories.find((x) => x.id === id.toUpperCase());
673
+ if (!s) die(`There is no story ${id} in ${plan}.`);
674
+ const report = {
675
+ id: s.id,
676
+ title: s.title,
677
+ lane: s.lane,
678
+ state: s.state,
679
+ base: from,
680
+ branch: s.branchExists ? s.branch : null,
681
+ worktree: s.worktree,
682
+ requirements: s.requirements.map((rid) => {
683
+ const q = requirements.find((x) => x.id === rid);
684
+ return {
685
+ id: rid,
686
+ title: q?.title ?? '',
687
+ spec: q?.file ?? null,
688
+ state: q?.state ?? 'no spec',
689
+ tests: q?.tests ?? [],
690
+ };
691
+ }),
692
+ specCheck: null,
693
+ diff: null,
694
+ verify: null,
695
+ adapters: [],
696
+ };
697
+ if (s.branchExists) {
698
+ const g = (...a) => execFileSync('git', a, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
699
+ const name = `.claude/state/spec-check/${s.branch.replace(/[^\w.-]+/g, '_')}.md`;
700
+ const found = [s.worktree, '.']
701
+ .filter(Boolean)
702
+ .map((d) => join(resolve(d), name))
703
+ .find((f) => existsSync(f));
704
+ if (found) report.specCheck = { file: found, rows: specCheckRows(readFileSync(found, 'utf8')) };
705
+ const range = `${from}...${s.branch}`;
706
+ const files = g('diff', '--numstat', range)
707
+ .split('\n')
708
+ .filter(Boolean)
709
+ .map((line) => {
710
+ const [added, removed, path] = line.split('\t');
711
+ return { path, added: Number(added) || 0, removed: Number(removed) || 0 };
712
+ });
713
+ const text = g('diff', range);
714
+ // JOIN-3: what the active adapters know of the story's files (rules on them, agreed and proposed).
715
+ report.adapters = adapterFacts(load().raw, resolve('.'), {
716
+ paths: files.map((f) => f.path),
717
+ }).map(({ name, files: facts, open }) => ({ name, files: facts ?? {}, open }));
718
+ report.diff = {
719
+ files,
720
+ text:
721
+ text.length > DIFF_KEPT
722
+ ? text.slice(0, text.lastIndexOf('\ndiff --git', DIFF_KEPT) + 1 || DIFF_KEPT)
723
+ : text,
724
+ truncated: text.length > DIFF_KEPT,
725
+ };
726
+ // verify, without checks, where the branch is checked out; a temporary worktree otherwise.
727
+ let dir = s.worktree;
728
+ let temporary = null;
729
+ if (!dir) {
730
+ temporary = mkdtempSync(join(tmpdir(), 'code-kit-story-'));
731
+ rmSync(temporary, { recursive: true });
732
+ g('worktree', 'add', '--quiet', '--detach', temporary, s.branch);
733
+ dir = temporary;
734
+ }
735
+ try {
736
+ const lane = laneOfBranch(s.branch, config);
737
+ const { found: problems } = verifyBranch({
738
+ root: dir,
739
+ base: from,
740
+ config,
741
+ lane,
742
+ checks: false,
743
+ });
744
+ report.verify = {
745
+ checks: false,
746
+ where: temporary ? 'a temporary worktree' : dir,
747
+ problems: Object.fromEntries(
748
+ Object.entries(VERIFY_GROUPS).map(([key, title]) => [title, problems[key] ?? []]),
749
+ ),
750
+ };
751
+ } finally {
752
+ if (temporary) g('worktree', 'remove', '--force', temporary);
753
+ }
754
+ }
755
+ if (flag('--json')) return out(JSON.stringify(report, null, 2));
756
+ out(
757
+ `${report.id} ${report.title} (${report.lane}, ${report.state})${report.branch ? ` on ${report.branch}` : ''}`,
758
+ );
759
+ for (const r of report.requirements) out(` ${r.id} ${r.title}: ${r.state}`);
760
+ if (report.diff) out(`${report.diff.files.length} file(s) changed against ${from}.`);
761
+ const count = report.verify ? Object.values(report.verify.problems).flat().length : 0;
762
+ if (report.verify)
763
+ out(
764
+ count
765
+ ? `verify (without checks): ${count} problem(s).`
766
+ : 'verify (without checks): no problems.',
767
+ );
768
+ }
769
+
435
770
  // Open approval requests and the approvals in force, for the person and the mod's band.
436
771
  function requests() {
437
772
  load();
@@ -481,6 +816,8 @@ function settings() {
481
816
  value: settingOf(config.harness, key),
482
817
  default: s.default,
483
818
  about: s.about,
819
+ // Chosen in the config, or the default standing in.
820
+ set: settingOf(raw.harness, key) !== undefined,
484
821
  }));
485
822
  if (flag('--json')) return out(JSON.stringify(rows, null, 2));
486
823
  for (const r of rows)
@@ -534,6 +871,35 @@ function stops() {
534
871
  out(`${s.agentType ?? 'the lead'}${s.session ? ` (session ${s.session})` : ''}: ${s.title}`);
535
872
  }
536
873
 
874
+ // Hold and ask (spec 06): a call the mod holds waits here, in the mod's `$.process.run`, for the
875
+ // person's answer, which the band writes with `hold <id> --answer approved|refused` into
876
+ // .claude/state/held/<id>. It prints `approved`, `refused` or `timed out` (after hold.minutes).
877
+ async function hold(id) {
878
+ const { config } = load();
879
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id))
880
+ die(`Not a held call's id: ${id}. Give the tool call's id, as the code-kit mod does.`);
881
+ const dir = resolve('.claude/state/held');
882
+ const answerFile = join(dir, id);
883
+ const answer = answerGiven;
884
+ if (answer !== undefined) {
885
+ if (!['approved', 'refused'].includes(answer)) die('--answer takes "approved" or "refused".');
886
+ mkdirSync(dir, { recursive: true });
887
+ writeFileSync(answerFile, `${answer}\n`);
888
+ return out(`The held call ${id} is ${answer}.`);
889
+ }
890
+ const minutes = Number(minutesGiven ?? config.harness.hold.minutes);
891
+ const until = Date.now() + minutes * 60000;
892
+ while (Date.now() < until) {
893
+ if (existsSync(answerFile)) {
894
+ const given = readFileSync(answerFile, 'utf8').trim();
895
+ rmSync(answerFile, { force: true });
896
+ return out(given === 'approved' ? 'approved' : 'refused');
897
+ }
898
+ await new Promise((done) => setTimeout(done, 200));
899
+ }
900
+ out('timed out');
901
+ }
902
+
537
903
  function verify() {
538
904
  const { config } = load();
539
905
  const from = base(config, true);
@@ -658,6 +1024,9 @@ function next() {
658
1024
 
659
1025
  function adapters() {
660
1026
  const { raw } = load();
1027
+ // JOIN-1: with --json, what the active adapters know of the lanes' work in a session (--session).
1028
+ if (flag('--json'))
1029
+ return out(JSON.stringify(adapterFacts(raw, resolve('.'), { session: sessionId }), null, 2));
661
1030
  const active = activeAdapters(raw, '.').map((a) => a.name);
662
1031
  for (const a of ADAPTERS) {
663
1032
  const off = raw.adapters?.[a.name] === false;
@@ -721,11 +1090,15 @@ async function approve(given) {
721
1090
  `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
1091
  );
723
1092
  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)
1093
+ // A press in a pane: code-kit's own (pane), or another tool's that shares the project, by its name.
1094
+ if (via !== undefined && !/^[a-z][a-z0-9-]{0,39}$/.test(via))
1095
+ die(
1096
+ '--via takes "pane" (a press in the code-kit pane) or the name of the tool whose pane it was, such as context-graph.',
1097
+ );
1098
+ if (via !== undefined && delegated)
727
1099
  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;
1100
+ if (via !== undefined)
1101
+ for (const n of names) marks[n] = via === 'pane' ? PANE : `(approved in the ${via} pane)`;
729
1102
  if (delegated) {
730
1103
  const rules = config.approvals.delegate;
731
1104
  if (!rules)
@@ -762,13 +1135,43 @@ async function approve(given) {
762
1135
  );
763
1136
  }
764
1137
 
1138
+ /**
1139
+ * The lead loop's pause (LOOP-6): `loop` says whether the person paused it; `loop pause|resume`, the
1140
+ * person's act from the code-kit band, records it, so other tools' background work pauses with it.
1141
+ */
1142
+ function loopCommand() {
1143
+ const root = resolve('.');
1144
+ const [sub] = rest;
1145
+ if (sub === 'pause' || sub === 'resume') {
1146
+ writeLoop(root, sub === 'pause');
1147
+ return out(sub === 'pause' ? 'The lead loop is paused.' : 'The lead loop is running.');
1148
+ }
1149
+ if (sub !== undefined) die('code-kit loop [--json] | loop pause | loop resume');
1150
+ const state = readLoop(root);
1151
+ out(
1152
+ flag('--json')
1153
+ ? JSON.stringify(state)
1154
+ : state.paused
1155
+ ? `The lead loop is paused (since ${state.at}).`
1156
+ : 'The lead loop is running.',
1157
+ );
1158
+ }
1159
+
765
1160
  const commands = {
766
1161
  check,
1162
+ loop: loopCommand,
767
1163
  approve: () => (rest.length ? approve(rest) : usage()),
768
1164
  merge: () => (rest.length === 1 ? merge(rest[0]) : usage()),
769
1165
  requests,
770
1166
  stops,
771
1167
  settings,
1168
+ hold: () => (rest.length === 1 ? hold(rest[0]) : usage()),
1169
+ queue,
1170
+ story: () => (rest.length === 1 ? story(rest[0]) : usage()),
1171
+ map: () => (rest.length === 1 ? mapCommand(rest[0]) : usage()),
1172
+ trail: trailCommand,
1173
+ journal: journalCommand,
1174
+ timeline,
772
1175
  'sent-back': () => (rest.length === 1 ? sentBackCommand(rest[0]) : usage()),
773
1176
  trace: () => (rest.length ? trace(rest) : usage()),
774
1177
  adapters,
@@ -789,7 +1192,12 @@ function usage() {
789
1192
  ' | approve <name>... --reason "…" [--lane name] [--config file]\n' +
790
1193
  ' | merge <branch> --delegated|--person [--into branch] | requests [--json]\n' +
791
1194
  ' | stops [--session id] [--json] | sent-back <branch> [--reason "…"]\n' +
792
- ' | settings [--json] | settings set <key> <value> --reason "…" [--via pane]',
1195
+ ' | settings [--json] | settings set <key> <value> --reason "…" [--via pane]\n' +
1196
+ ' | hold <id> [--minutes N] | hold <id> --answer approved|refused\n' +
1197
+ ' | queue [--json] | queue add|drop <branch> | queue merge --delegated|--person\n' +
1198
+ ' | loop [--json] | loop pause|resume\n' +
1199
+ ' | map <story> [--json] | trail [--json] | journal [--json]\n' +
1200
+ ' | story <id> [--json] | timeline [--json]',
793
1201
  );
794
1202
  }
795
1203
  await (commands[command] ?? usage)();