@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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +62 -0
- package/README.md +31 -10
- package/bin/code-kit.mjs +447 -39
- package/hooks/guard-bash.mjs +54 -16
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +87 -19
- 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/journal.mjs +224 -0
- package/hooks/lib/loop-state.mjs +26 -0
- package/hooks/lib/next.mjs +34 -3
- package/hooks/lib/plan.mjs +37 -1
- 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/storymap.mjs +155 -0
- package/hooks/lib/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/codemap.mjs +328 -0
- package/hooks/mod/decisions.mjs +146 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/mission.mjs +116 -0
- package/hooks/mod/panes.mjs +1378 -0
- package/hooks/mod/register.mjs +1172 -15
- package/hooks/mod/register.test.ts +1868 -15
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/trail.mjs +152 -0
- package/hooks/mod/view.mjs +658 -56
- package/package.json +1 -1
- package/skills/init/SKILL.md +6 -5
- package/skills/review/SKILL.md +8 -1
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 {
|
|
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 { 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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
if (target.branches.protected.includes(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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
|
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)();
|