@warren-dean/code-kit 0.12.2 → 0.14.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 +21 -0
- package/README.md +113 -40
- package/SECURITY.md +11 -2
- package/bin/code-kit.mjs +87 -23
- package/hooks/guard-bash.mjs +40 -0
- package/hooks/lib/checks.mjs +19 -6
- package/hooks/lib/config.mjs +8 -0
- package/hooks/lib/loop-state.mjs +49 -9
- package/hooks/lib/next.mjs +34 -8
- package/hooks/lib/plan.mjs +4 -2
- package/hooks/lib/queue.mjs +25 -6
- package/hooks/lib/regression.mjs +321 -0
- package/hooks/lib/verify.mjs +53 -7
- package/hooks/mod/decisions.mjs +99 -4
- package/hooks/mod/loop.mjs +75 -1
- package/hooks/mod/mission.mjs +19 -15
- package/hooks/mod/panes.mjs +11 -1
- package/hooks/mod/register.mjs +361 -35
- package/hooks/mod/register.test.ts +508 -70
- package/hooks/mod/view.mjs +161 -28
- package/hooks/stop-check.mjs +1 -1
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +1 -0
- package/skills/review/SKILL.md +3 -1
- package/skills/spec-design/SKILL.md +1 -1
- package/templates/agent.md +2 -0
- package/templates/claude-md.md +1 -1
- package/templates/plan.md +1 -1
package/bin/code-kit.mjs
CHANGED
|
@@ -79,7 +79,7 @@ import { PROJECT, projectMap, storyMap, trail } from '../hooks/lib/storymap.mjs'
|
|
|
79
79
|
import { journal } from '../hooks/lib/journal.mjs';
|
|
80
80
|
import { dequeue, enqueue, lastMerged, readQueue, settle, waiting } from '../hooks/lib/queue.mjs';
|
|
81
81
|
import { SETTINGS, parseSetting, settingOf, withSetting } from '../hooks/lib/harness.mjs';
|
|
82
|
-
import { readLoop, writeLoop } from '../hooks/lib/loop-state.mjs';
|
|
82
|
+
import { claimLead, readLoop, releaseLead, writeLoop } from '../hooks/lib/loop-state.mjs';
|
|
83
83
|
import {
|
|
84
84
|
claimChanges,
|
|
85
85
|
storyOfBranch,
|
|
@@ -106,6 +106,8 @@ import {
|
|
|
106
106
|
import { DEPENDENCY_APPROVAL, dependencyApproval } from '../hooks/lib/dependencies.mjs';
|
|
107
107
|
import { dependencyRuleProblems, npmFacts, packageOf } from '../hooks/lib/registry.mjs';
|
|
108
108
|
import { heldTo, laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
|
|
109
|
+
import { skippedLine } from '../hooks/lib/checks.mjs';
|
|
110
|
+
import { TESTS_APPROVAL } from '../hooks/lib/regression.mjs';
|
|
109
111
|
|
|
110
112
|
const out = (s) => process.stdout.write(`${s}\n`);
|
|
111
113
|
// Piped into `head` and the like: stop quietly when the reader closes.
|
|
@@ -160,6 +162,7 @@ const FLAGS = [
|
|
|
160
162
|
'--peek',
|
|
161
163
|
'--off',
|
|
162
164
|
'--blocks',
|
|
165
|
+
'--take',
|
|
163
166
|
];
|
|
164
167
|
const flag = (name) => args.includes(name);
|
|
165
168
|
const writeBaseline = flag('--write');
|
|
@@ -398,6 +401,8 @@ const VERIFY_GROUPS = {
|
|
|
398
401
|
layers: 'Layer rules',
|
|
399
402
|
baseline: 'Baseline',
|
|
400
403
|
design: 'Designs before screens',
|
|
404
|
+
tests: 'Weakened tests',
|
|
405
|
+
regression: 'Fixes and their tests',
|
|
401
406
|
checks: 'Checks',
|
|
402
407
|
};
|
|
403
408
|
|
|
@@ -522,9 +527,11 @@ function merge(branch) {
|
|
|
522
527
|
|
|
523
528
|
/**
|
|
524
529
|
* Verifies `branch`, checks included, and merges it into the base (`--into`, or the first protected
|
|
525
|
-
* branch).
|
|
530
|
+
* branch). With `commit`, the commit the lead's review passed, it verifies and merges exactly that
|
|
531
|
+
* commit, and refuses if the branch has moved on from it. Returns { into, message }; throws a
|
|
532
|
+
* MergeStop when nothing was merged.
|
|
526
533
|
*/
|
|
527
|
-
function mergeOne(branch, config, byPerson) {
|
|
534
|
+
function mergeOne(branch, config, byPerson, commit = null) {
|
|
528
535
|
const refuse = (message) => {
|
|
529
536
|
throw new MergeStop('refused', message);
|
|
530
537
|
};
|
|
@@ -583,13 +590,20 @@ function mergeOne(branch, config, byPerson) {
|
|
|
583
590
|
// temporary worktree, where checks that need an install fail rather than being skipped.
|
|
584
591
|
const verifyIn = checkedOut(branch) ?? worktree(branch, true);
|
|
585
592
|
clean(verifyIn, branch);
|
|
593
|
+
const tip = g(verifyIn, 'rev-parse', 'HEAD').trim();
|
|
594
|
+
if (commit && tip !== commit)
|
|
595
|
+
fail(
|
|
596
|
+
`Nothing was merged: ${branch} has moved on since its review passed (${commit.slice(0, 7)} → ${tip.slice(0, 7)}). Review it again, then queue it.`,
|
|
597
|
+
'moved',
|
|
598
|
+
);
|
|
586
599
|
const lane = laneOfBranch(branch, config);
|
|
587
|
-
const { changed, found } = verifyBranch({
|
|
600
|
+
const { changed, found, skipped } = verifyBranch({
|
|
588
601
|
root: verifyIn,
|
|
589
602
|
base: into,
|
|
590
603
|
config,
|
|
591
604
|
lane,
|
|
592
605
|
checks: true,
|
|
606
|
+
branch,
|
|
593
607
|
});
|
|
594
608
|
const problems = Object.entries(VERIFY_GROUPS).flatMap(([key, title]) =>
|
|
595
609
|
found[key].map((p) => `${title}: ${p.split('\n').join('\n ')}`),
|
|
@@ -609,8 +623,8 @@ function mergeOne(branch, config, byPerson) {
|
|
|
609
623
|
'-m',
|
|
610
624
|
`Merge ${branch} into ${into}`,
|
|
611
625
|
'-m',
|
|
612
|
-
`Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}) and merged ${byPerson ? 'by the person, from the code-kit pane' : 'by the lead within the delegated rules'}
|
|
613
|
-
|
|
626
|
+
`Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}) at ${tip.slice(0, 7)}${commit ? ', the commit the review passed,' : ''} and merged ${byPerson ? 'by the person, from the code-kit pane' : 'by the lead within the delegated rules'}.${skipped.length ? ` ${notRun(skipped)}` : ''}`,
|
|
627
|
+
tip,
|
|
614
628
|
);
|
|
615
629
|
} catch (e) {
|
|
616
630
|
try {
|
|
@@ -644,7 +658,7 @@ function mergeOne(branch, config, byPerson) {
|
|
|
644
658
|
}
|
|
645
659
|
return {
|
|
646
660
|
into,
|
|
647
|
-
message: `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}.${removed}`,
|
|
661
|
+
message: `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}, at ${tip.slice(0, 7)}.${skipped.length ? ` ${notRun(skipped)}` : ''}${removed}`,
|
|
648
662
|
};
|
|
649
663
|
} finally {
|
|
650
664
|
cleanup();
|
|
@@ -669,16 +683,24 @@ function queue() {
|
|
|
669
683
|
? `Took ${branch} out of the merge queue.`
|
|
670
684
|
: `${branch} isn't waiting in the merge queue.`,
|
|
671
685
|
);
|
|
686
|
+
let commit;
|
|
672
687
|
try {
|
|
673
|
-
execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
674
|
-
|
|
675
|
-
|
|
688
|
+
commit = execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
689
|
+
encoding: 'utf8',
|
|
690
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
691
|
+
}).trim();
|
|
676
692
|
} catch {
|
|
677
693
|
die(`There is no branch ${branch}.`);
|
|
678
694
|
}
|
|
679
|
-
|
|
695
|
+
// The commit the review passed: the queue merges exactly it, and a branch that moves is reviewed again.
|
|
696
|
+
const added = enqueue(root, branch, commit);
|
|
697
|
+
if (!added) return out(`${branch} is already in the merge queue, at ${commit.slice(0, 7)}.`);
|
|
698
|
+
if (added === 'updated')
|
|
699
|
+
return out(`${branch} keeps its place in the merge queue, now at ${commit.slice(0, 7)}.`);
|
|
680
700
|
const place = waiting(readQueue(root)).length;
|
|
681
|
-
return out(
|
|
701
|
+
return out(
|
|
702
|
+
`Queued ${branch} at ${commit.slice(0, 7)} to merge${place > 1 ? `, ${place - 1} ahead of it` : ', next'}.`,
|
|
703
|
+
);
|
|
682
704
|
}
|
|
683
705
|
if (sub === 'rules') return rulesOntoBase(root, config);
|
|
684
706
|
if (sub === 'merge') {
|
|
@@ -687,7 +709,7 @@ function queue() {
|
|
|
687
709
|
const head = waiting(entries)[0];
|
|
688
710
|
if (!head) return out('The merge queue is empty.');
|
|
689
711
|
try {
|
|
690
|
-
const { message } = mergeOne(head.branch, config, byPerson);
|
|
712
|
+
const { message } = mergeOne(head.branch, config, byPerson, head.commit ?? null);
|
|
691
713
|
settle(root, head.branch, 'merged');
|
|
692
714
|
const left = waiting(readQueue(root));
|
|
693
715
|
return out(
|
|
@@ -697,6 +719,12 @@ function queue() {
|
|
|
697
719
|
if (!(e instanceof MergeStop)) throw e;
|
|
698
720
|
// MQ-3: a conflict or a failed verify goes back to the lane; a refusal leaves the queue as it is.
|
|
699
721
|
if (e.kind === 'refused') die(e.message);
|
|
722
|
+
// A branch that moved after its review leaves the queue for the lead to review it again; it
|
|
723
|
+
// isn't sent back, since nothing is wrong with it yet.
|
|
724
|
+
if (e.kind === 'moved') {
|
|
725
|
+
settle(root, head.branch, 'moved', 'changed after its review');
|
|
726
|
+
die(`${e.message}\n${head.branch} leaves the queue until then.`);
|
|
727
|
+
}
|
|
700
728
|
const into = intoName ?? config.branches.protected[0];
|
|
701
729
|
const after = lastMerged(entries);
|
|
702
730
|
const why =
|
|
@@ -722,7 +750,11 @@ function queue() {
|
|
|
722
750
|
const left = waiting(entries);
|
|
723
751
|
if (!left.length) out('The merge queue is empty.');
|
|
724
752
|
else out(`${left.length} waiting to merge, in order:`);
|
|
725
|
-
left.forEach((e, i) =>
|
|
753
|
+
left.forEach((e, i) =>
|
|
754
|
+
out(
|
|
755
|
+
` ${i + 1}. ${e.branch} (passed review ${e.passed}${e.commit ? ` at ${e.commit.slice(0, 7)}` : ''})`,
|
|
756
|
+
),
|
|
757
|
+
);
|
|
726
758
|
}
|
|
727
759
|
|
|
728
760
|
/**
|
|
@@ -906,6 +938,7 @@ function story(id) {
|
|
|
906
938
|
config,
|
|
907
939
|
lane,
|
|
908
940
|
checks: false,
|
|
941
|
+
branch: s.branch,
|
|
909
942
|
});
|
|
910
943
|
report.verify = {
|
|
911
944
|
checks: false,
|
|
@@ -1099,18 +1132,21 @@ function verify() {
|
|
|
1099
1132
|
}
|
|
1100
1133
|
}
|
|
1101
1134
|
const lane = laneOfBranch(branch, config);
|
|
1102
|
-
const { changed, found } = verifyBranch({
|
|
1135
|
+
const { changed, found, skipped } = verifyBranch({
|
|
1103
1136
|
root: resolve('.'),
|
|
1104
1137
|
base: from,
|
|
1105
1138
|
config,
|
|
1106
1139
|
lane,
|
|
1107
1140
|
checks: !flag('--no-checks'),
|
|
1141
|
+
// REG-1 is code-kit's own, so CI's --no-checks still proves a fix.
|
|
1142
|
+
fixes: true,
|
|
1143
|
+
branch,
|
|
1108
1144
|
});
|
|
1109
1145
|
if (flag('--json')) {
|
|
1110
1146
|
const count = Object.values(found).reduce((n, list) => n + list.length, 0);
|
|
1111
1147
|
out(
|
|
1112
1148
|
JSON.stringify(
|
|
1113
|
-
{ base: from, branch, lane, changed: changed.length, found, problems: count },
|
|
1149
|
+
{ base: from, branch, lane, changed: changed.length, found, problems: count, skipped },
|
|
1114
1150
|
null,
|
|
1115
1151
|
2,
|
|
1116
1152
|
),
|
|
@@ -1128,10 +1164,15 @@ function verify() {
|
|
|
1128
1164
|
out(`\n${title}:`);
|
|
1129
1165
|
for (const p of found[key]) out(` ${p.split('\n').join('\n ')}`);
|
|
1130
1166
|
}
|
|
1167
|
+
if (skipped.length) out(`\n${notRun(skipped)}`);
|
|
1131
1168
|
if (count) die(`\n${count} problem(s). Fix them on this branch.`);
|
|
1132
|
-
out('No problems.');
|
|
1169
|
+
out(skipped.length ? 'No problems in what ran.' : 'No problems.');
|
|
1133
1170
|
}
|
|
1134
1171
|
|
|
1172
|
+
/** The checks that didn't run, for verify's and a merge's report. */
|
|
1173
|
+
const notRun = (skipped) =>
|
|
1174
|
+
`Not run, because a tool isn't installed here: ${skipped.map(skippedLine).join('; ')}. Install it where verify runs, or set "ifMissing": "fail" on the check so a missing tool fails it.`;
|
|
1175
|
+
|
|
1135
1176
|
/**
|
|
1136
1177
|
* ONB-7: whether a branch's changes need the security reviewer, and why: sign-in, permissions, outside
|
|
1137
1178
|
* input, personal data, encryption, payments, secrets, commands built from strings, new dependencies,
|
|
@@ -1299,7 +1340,7 @@ async function approve(given) {
|
|
|
1299
1340
|
);
|
|
1300
1341
|
if (laneName !== undefined && !config.lanes[laneName])
|
|
1301
1342
|
die(`There is no lane "${laneName}". Lanes: ${Object.keys(config.lanes).join(', ')}.`);
|
|
1302
|
-
const known = new Set(['kit', ...config.protected.map((p) => p.approval)]);
|
|
1343
|
+
const known = new Set(['kit', TESTS_APPROVAL, ...config.protected.map((p) => p.approval)]);
|
|
1303
1344
|
const isPackage = (n) => /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(n);
|
|
1304
1345
|
const names = given.map((n) =>
|
|
1305
1346
|
n.startsWith(DEPENDENCY_APPROVAL) || known.has(n) || !isPackage(n) ? n : dependencyApproval(n),
|
|
@@ -1327,6 +1368,10 @@ async function approve(given) {
|
|
|
1327
1368
|
);
|
|
1328
1369
|
const outside = [];
|
|
1329
1370
|
for (const name of names) {
|
|
1371
|
+
if (name === TESTS_APPROVAL) {
|
|
1372
|
+
outside.push(`${name}: weakening a test is the person's to allow, and is never delegated`);
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1330
1375
|
if (name.startsWith(DEPENDENCY_APPROVAL)) {
|
|
1331
1376
|
if (!rules.dependencies) {
|
|
1332
1377
|
outside.push(`${name}: dependencies aren't delegated`);
|
|
@@ -1770,6 +1815,8 @@ function reportCommand() {
|
|
|
1770
1815
|
/**
|
|
1771
1816
|
* The lead loop's pause (LOOP-6): `loop` says whether the person paused it; `loop pause|resume`, the
|
|
1772
1817
|
* person's act from the code-kit band, records it, so other tools' background work pauses with it.
|
|
1818
|
+
* LOOP-10: `loop lead` takes or renews the lease of the one session that runs the loop, and `loop
|
|
1819
|
+
* release` gives it up; the mod runs both, and the hooks refuse all four to agents.
|
|
1773
1820
|
*/
|
|
1774
1821
|
function loopCommand() {
|
|
1775
1822
|
const root = resolve('.');
|
|
@@ -1778,14 +1825,31 @@ function loopCommand() {
|
|
|
1778
1825
|
writeLoop(root, sub === 'pause');
|
|
1779
1826
|
return out(sub === 'pause' ? 'The lead loop is paused.' : 'The lead loop is running.');
|
|
1780
1827
|
}
|
|
1781
|
-
|
|
1828
|
+
// LOOP-10: one session in the project runs the loop; the mod takes and renews the lease.
|
|
1829
|
+
if (sub === 'lead' || sub === 'release') {
|
|
1830
|
+
if (!sessionId) die(`code-kit loop ${sub} needs the session: --session <id>.`);
|
|
1831
|
+
if (sub === 'release')
|
|
1832
|
+
return out(
|
|
1833
|
+
releaseLead(root, sessionId)
|
|
1834
|
+
? 'This session no longer runs the lead loop.'
|
|
1835
|
+
: "This session wasn't running the lead loop.",
|
|
1836
|
+
);
|
|
1837
|
+
const r = claimLead(root, sessionId, { take: flag('--take') });
|
|
1838
|
+
if (!r.ok)
|
|
1839
|
+
die(
|
|
1840
|
+
`Another session (${r.lead.session}, renewed ${r.lead.at}) runs the lead loop in this project. Leave it there, or take it over with --take.`,
|
|
1841
|
+
);
|
|
1842
|
+
return out('This session runs the lead loop.');
|
|
1843
|
+
}
|
|
1844
|
+
if (sub !== undefined)
|
|
1845
|
+
die(
|
|
1846
|
+
'code-kit loop [--json] | loop pause | loop resume | loop lead --session <id> [--take] | loop release --session <id>',
|
|
1847
|
+
);
|
|
1782
1848
|
const state = readLoop(root);
|
|
1783
1849
|
out(
|
|
1784
1850
|
flag('--json')
|
|
1785
1851
|
? JSON.stringify(state)
|
|
1786
|
-
: state.paused
|
|
1787
|
-
? `The lead loop is paused (since ${state.at}).`
|
|
1788
|
-
: 'The lead loop is running.',
|
|
1852
|
+
: `${state.paused ? `The lead loop is paused (since ${state.at}).` : 'The lead loop is running.'}${state.lead ? ` Session ${state.lead.session} runs it.` : ''}`,
|
|
1789
1853
|
);
|
|
1790
1854
|
}
|
|
1791
1855
|
|
|
@@ -1837,7 +1901,7 @@ function usage() {
|
|
|
1837
1901
|
' | settings [--json] | settings set <key> <value> --reason "…" [--via pane]\n' +
|
|
1838
1902
|
' | hold <id> [--minutes N] | hold <id> --answer approved|refused\n' +
|
|
1839
1903
|
' | queue [--json] | queue add|drop <branch> | queue rules | queue merge --delegated|--person\n' +
|
|
1840
|
-
' | loop [--json] | loop pause|resume\n' +
|
|
1904
|
+
' | loop [--json] | loop pause|resume | loop lead|release --session id [--take]\n' +
|
|
1841
1905
|
' | map <story>|project [--json] | trail [--json] | journal [--json]\n' +
|
|
1842
1906
|
' | story <id> [--json] | timeline [--json]\n' +
|
|
1843
1907
|
' | team [--json] [--fresh] | team since [--json] [--peek]\n' +
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { CONFIG_FILE } from './lib/config.mjs';
|
|
|
15
15
|
import { laneOfBranch, storyOfBranch, teamOf, teamProvider, whoami } from './lib/team.mjs';
|
|
16
16
|
import { matchesAny } from './lib/glob.mjs';
|
|
17
17
|
import { noteBefore } from './lane-audit.mjs';
|
|
18
|
+
import { TESTS_APPROVAL, commitWeakenings } from './lib/regression.mjs';
|
|
18
19
|
import {
|
|
19
20
|
APPROVAL_LOG,
|
|
20
21
|
actorFor,
|
|
@@ -185,6 +186,11 @@ if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(pause|resume)\b`)))
|
|
|
185
186
|
block(
|
|
186
187
|
`Blocked: pausing or resuming the lead loop is the person's own act, from the code-kit band or their terminal. Ask them, and stop.\nCommand: ${cmd}`,
|
|
187
188
|
);
|
|
189
|
+
// LOOP-10: which session runs the loop is the mod's to record, and moving it the person's.
|
|
190
|
+
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(lead|release)\b`)))
|
|
191
|
+
block(
|
|
192
|
+
`Blocked: which session runs the lead loop is recorded by the code-kit band, and moving it is the person's own act (Lead from here, in the band). Carry on with your work.\nCommand: ${cmd}`,
|
|
193
|
+
);
|
|
188
194
|
// TEAM-9: handing someone's claim to someone else is the tech lead's own decision too.
|
|
189
195
|
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+team\s+reassign\b`)))
|
|
190
196
|
block(
|
|
@@ -452,6 +458,40 @@ function recordApprovals() {
|
|
|
452
458
|
grantedAt: null,
|
|
453
459
|
});
|
|
454
460
|
}
|
|
461
|
+
// REG-2: a commit that weakens a test waits for the person's `tests` approval, which nobody else can
|
|
462
|
+
// give; it's recorded in the log, so verify accepts the branch, and used up by this commit.
|
|
463
|
+
const weakened = commitWeakenings(
|
|
464
|
+
root,
|
|
465
|
+
files.filter((f) => !arrivesWithMerge(root, f, { staged: true })),
|
|
466
|
+
config,
|
|
467
|
+
);
|
|
468
|
+
if (weakened.length) {
|
|
469
|
+
const granted = approval(root, TESTS_APPROVAL, actor);
|
|
470
|
+
const said = (w) => `it ${w.reasons.join(', and ')}`;
|
|
471
|
+
if (!granted) {
|
|
472
|
+
const [w, ...more] = weakened;
|
|
473
|
+
recordRequest(root, actor, TESTS_APPROVAL, w.file, `A weakened test: ${said(w)}.`);
|
|
474
|
+
const personOnly = { ...config, approvals: { ...config.approvals, delegate: null } };
|
|
475
|
+
block(
|
|
476
|
+
`Blocked: ${w.file} is a weakened test (${said(w)}); committing it needs a person's approval in force.
|
|
477
|
+
` +
|
|
478
|
+
(more.length
|
|
479
|
+
? `Also weakened: ${more.map((x) => `${x.file} (${said(x)})`).join('; ')}.
|
|
480
|
+
`
|
|
481
|
+
: '') +
|
|
482
|
+
`A weakened test can hide a regression. Fix the code so the test passes as it was. If the test itself is wrong, ask the person: neither the lead nor a lane can approve it. They run
|
|
483
|
+
${approvalHowTo(TESTS_APPROVAL, actor, root, 'weakening tests in one commit', personOnly)}`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
for (const w of weakened)
|
|
487
|
+
record(w.file, {
|
|
488
|
+
approval: TESTS_APPROVAL,
|
|
489
|
+
reason: granted.reason,
|
|
490
|
+
grantedAt: granted.grantedAt,
|
|
491
|
+
weakened: w.reasons,
|
|
492
|
+
});
|
|
493
|
+
useApproval(root, TESTS_APPROVAL, actor);
|
|
494
|
+
}
|
|
455
495
|
// A dependency approval is used up by the commit that adds its package to a manifest or lockfile.
|
|
456
496
|
const manifests = files.filter((f) => isDependencyFile(f));
|
|
457
497
|
if (manifests.length && actor.kind !== 'readonly') {
|
package/hooks/lib/checks.mjs
CHANGED
|
@@ -15,8 +15,12 @@ function directories(check, files, root) {
|
|
|
15
15
|
return [...new Set(dirs)].map((d) => join(root, d)).filter((d) => existsSync(d));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The first failure as { title, output }, or null. `actor` decides `owners`; the lead always may. A
|
|
20
|
+
* command that isn't installed skips the rest of the check, unless `ifMissing` is "fail", and is
|
|
21
|
+
* added to `skipped` as { check, command, where }, so a pass never hides a check that didn't run.
|
|
22
|
+
*/
|
|
23
|
+
function runCheck(check, files, root, actor, skipped) {
|
|
20
24
|
if (check.owners && !(actor.kind === 'lead' || check.owners.includes(actor.lane))) {
|
|
21
25
|
return {
|
|
22
26
|
title: `The ${actor.label} changed ${check.name} files it does not own:`,
|
|
@@ -37,6 +41,7 @@ function runCheck(check, files, root, actor) {
|
|
|
37
41
|
return {
|
|
38
42
|
title: `${check.name}: \`${command}\` is not installed${where}. Install it so the check can run.`,
|
|
39
43
|
};
|
|
44
|
+
skipped.push({ check: check.name, command, where: where.replace(/^ in /, '') || null });
|
|
40
45
|
return null; // not installed yet (fresh clone): the check can't run
|
|
41
46
|
}
|
|
42
47
|
if (res.error || res.status === null)
|
|
@@ -53,15 +58,23 @@ function runCheck(check, files, root, actor) {
|
|
|
53
58
|
return null;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Runs every check whose files changed, in order: { failure, skipped }, where `failure` is the first
|
|
63
|
+
* failure or null, and `skipped` lists the checks that couldn't run because a tool isn't installed.
|
|
64
|
+
*/
|
|
57
65
|
export function runChecks(config, changed, root, actor) {
|
|
66
|
+
const skipped = [];
|
|
58
67
|
for (const check of config.checks) {
|
|
59
68
|
const files = changed.filter(
|
|
60
69
|
(f) => matchesAny(f, check.files) && !matchesAny(f, check.exclude),
|
|
61
70
|
);
|
|
62
71
|
if (!files.length) continue;
|
|
63
|
-
const failure = runCheck(check, files, root, actor);
|
|
64
|
-
if (failure) return failure;
|
|
72
|
+
const failure = runCheck(check, files, root, actor, skipped);
|
|
73
|
+
if (failure) return { failure, skipped };
|
|
65
74
|
}
|
|
66
|
-
return null;
|
|
75
|
+
return { failure: null, skipped };
|
|
67
76
|
}
|
|
77
|
+
|
|
78
|
+
/** A skipped check in a sentence: "typecheck (`tsc` isn't installed in apps/web)". */
|
|
79
|
+
export const skippedLine = (s) =>
|
|
80
|
+
`${s.check} (\`${s.command}\` isn't installed${s.where ? ` in ${s.where}` : ''})`;
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -93,6 +93,8 @@ export function withDefaults(c) {
|
|
|
93
93
|
shell: { block: c.shell?.block ?? [], restricted: c.shell?.restricted ?? [] },
|
|
94
94
|
postEdit: c.postEdit ?? [],
|
|
95
95
|
checks: c.checks ?? [],
|
|
96
|
+
tests: c.tests ?? [],
|
|
97
|
+
regression: { prepare: c.regression?.prepare ?? null },
|
|
96
98
|
branches: { protected: c.branches?.protected ?? ['main', 'master'] },
|
|
97
99
|
approvals: { lead: c.approvals?.lead === true, delegate: delegateRules(c.approvals?.delegate) },
|
|
98
100
|
harness: harnessSettings(c.harness),
|
|
@@ -211,6 +213,12 @@ export function validate(c) {
|
|
|
211
213
|
need(e.exclude === undefined || isList(e.exclude), `${at}.exclude must be a list of globs`);
|
|
212
214
|
});
|
|
213
215
|
each('checks', (e, at) => validateCheck(e, at, lanes, need));
|
|
216
|
+
need(c.tests === undefined || isList(c.tests), '"tests" must be a list of globs: the test files');
|
|
217
|
+
need(
|
|
218
|
+
c.regression === undefined ||
|
|
219
|
+
(isObj(c.regression) && (c.regression.prepare === undefined || isStr(c.regression.prepare))),
|
|
220
|
+
'"regression" must be { prepare?: a command run in a fresh checkout before a fix\'s tests }',
|
|
221
|
+
);
|
|
214
222
|
const known = ADAPTERS.map((a) => a.name);
|
|
215
223
|
need(
|
|
216
224
|
c.adapters === undefined ||
|
package/hooks/lib/loop-state.mjs
CHANGED
|
@@ -1,26 +1,66 @@
|
|
|
1
|
-
//
|
|
2
|
-
// tools sharing the project can hold their own background work while it's paused
|
|
3
|
-
//
|
|
1
|
+
// The lead loop's state, kept beside the merge queue (spec 05): whether the person has paused it
|
|
2
|
+
// (LOOP-6), so other tools sharing the project can hold their own background work while it's paused;
|
|
3
|
+
// and which session runs it (LOOP-10), so a second session opened in the project never prompts its
|
|
4
|
+
// lead too. The mod records both through the CLI.
|
|
4
5
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
6
|
import { dirname, join } from 'node:path';
|
|
6
7
|
import { projectWorktrees } from './rules.mjs';
|
|
7
8
|
|
|
8
9
|
export const LOOP_FILE = '.claude/state/loop.json';
|
|
10
|
+
/** LOOP-10: a lead session renews its lease well within this; one not renewed for this long has ended. */
|
|
11
|
+
export const LEAD_LEASE_MS = 90000;
|
|
9
12
|
|
|
10
13
|
const loopFile = (root) => join(projectWorktrees(root)[0] ?? root, LOOP_FILE);
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
export function readLoop(root) {
|
|
15
|
+
function readRaw(root) {
|
|
14
16
|
try {
|
|
15
17
|
const state = JSON.parse(readFileSync(loopFile(root), 'utf8'));
|
|
16
|
-
return
|
|
18
|
+
return state && typeof state === 'object' ? state : {};
|
|
17
19
|
} catch {
|
|
18
|
-
return {
|
|
20
|
+
return {};
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
function writeRaw(root, state) {
|
|
23
25
|
const file = loopFile(root);
|
|
24
26
|
mkdirSync(dirname(file), { recursive: true });
|
|
25
|
-
writeFileSync(file, `${JSON.stringify(
|
|
27
|
+
writeFileSync(file, `${JSON.stringify(state)}\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The session holding the lead lease, while it holds it: { session, at }, or null. */
|
|
31
|
+
const liveLead = (state, now) => {
|
|
32
|
+
const lead = state.lead;
|
|
33
|
+
if (!lead?.session || !lead.at) return null;
|
|
34
|
+
return now - Date.parse(lead.at) < LEAD_LEASE_MS ? { session: lead.session, at: lead.at } : null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** { paused, at, lead }: not paused when nothing was recorded; `lead` is the live lead session or null. */
|
|
38
|
+
export function readLoop(root, now = Date.now()) {
|
|
39
|
+
const state = readRaw(root);
|
|
40
|
+
return { paused: state.paused === true, at: state.at ?? null, lead: liveLead(state, now) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeLoop(root, paused, now = Date.now()) {
|
|
44
|
+
writeRaw(root, { ...readRaw(root), paused, at: new Date(now).toISOString() });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* LOOP-10: `session` takes or renews the lead lease. Another live session keeps it unless `take` (the
|
|
49
|
+
* person moved the loop here): { ok: true } or { ok: false, lead } naming the session that has it.
|
|
50
|
+
*/
|
|
51
|
+
export function claimLead(root, session, { take = false } = {}, now = Date.now()) {
|
|
52
|
+
const state = readRaw(root);
|
|
53
|
+
const lead = liveLead(state, now);
|
|
54
|
+
if (lead && lead.session !== session && !take) return { ok: false, lead };
|
|
55
|
+
writeRaw(root, { ...state, lead: { session, at: new Date(now).toISOString() } });
|
|
56
|
+
return { ok: true, lead: null };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** LOOP-10: the lead session ends: its lease goes, so the next session to act takes the loop at once. */
|
|
60
|
+
export function releaseLead(root, session) {
|
|
61
|
+
const state = readRaw(root);
|
|
62
|
+
if (state.lead?.session !== session) return false;
|
|
63
|
+
const { lead: _lead, ...rest } = state;
|
|
64
|
+
writeRaw(root, rest);
|
|
65
|
+
return true;
|
|
26
66
|
}
|
package/hooks/lib/next.mjs
CHANGED
|
@@ -26,7 +26,10 @@ const git = (root, ...args) => {
|
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* The spec-design brief: { path, status, openQuestions: [question, ...], open: [{ question, owner,
|
|
31
|
+
* blocks }] }, or null. `open` reads the table's Owner and Blocks columns where it has them.
|
|
32
|
+
*/
|
|
30
33
|
export function readBrief(root, config) {
|
|
31
34
|
const specs = config?.docs?.specs;
|
|
32
35
|
const candidates = [specs && `${posix.dirname(specs)}/brief.md`, 'docs/brief.md'].filter(Boolean);
|
|
@@ -39,12 +42,27 @@ export function readBrief(root, config) {
|
|
|
39
42
|
const start = lines.findIndex((l) => /^##\s+Open questions/i.test(l));
|
|
40
43
|
const section = start < 0 ? [] : lines.slice(start + 1);
|
|
41
44
|
const end = section.findIndex((l) => /^##\s/.test(l));
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
const rows = section.slice(0, end < 0 ? undefined : end).filter((l) => /^\|/.test(l));
|
|
46
|
+
const cells = (l) =>
|
|
47
|
+
l
|
|
48
|
+
.split('|')
|
|
49
|
+
.slice(1, -1)
|
|
50
|
+
.map((c) => c.trim());
|
|
51
|
+
const header = rows.find((l) => /^\|\s*Question\s*\|/i.test(l));
|
|
52
|
+
const columns = header ? cells(header).map((c) => c.toLowerCase()) : ['question'];
|
|
53
|
+
const at = (name) => columns.findIndex((c) => c.startsWith(name));
|
|
54
|
+
const open = rows
|
|
55
|
+
.filter((l) => l !== header && !/^\|\s*-/.test(l))
|
|
56
|
+
.map((l) => {
|
|
57
|
+
const c = cells(l);
|
|
58
|
+
return {
|
|
59
|
+
question: c[0] ?? '',
|
|
60
|
+
owner: at('owner') > 0 ? c[at('owner')] || null : null,
|
|
61
|
+
blocks: at('block') > 0 ? c[at('block')] || null : null,
|
|
62
|
+
};
|
|
63
|
+
})
|
|
64
|
+
.filter((q) => q.question && !q.question.startsWith('{{'));
|
|
65
|
+
return { path, status: status ?? null, openQuestions: open.map((q) => q.question), open };
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
const hasMarkdown = (dir) => {
|
|
@@ -87,7 +105,15 @@ export function nextStep(root, base) {
|
|
|
87
105
|
const brief = readBrief(root, config);
|
|
88
106
|
if (brief?.openQuestions.length) {
|
|
89
107
|
attention.push(`${brief.openQuestions.length} open question(s) in ${brief.path}.`);
|
|
90
|
-
|
|
108
|
+
// A question the brief gives to the lead alone (Owner: Lead) is the lead's to settle, not the person's.
|
|
109
|
+
briefQuestions = brief.open
|
|
110
|
+
.filter(
|
|
111
|
+
(q) =>
|
|
112
|
+
!q.owner ||
|
|
113
|
+
!/\blead\b/i.test(q.owner) ||
|
|
114
|
+
/owner|person|you|product|client/i.test(q.owner),
|
|
115
|
+
)
|
|
116
|
+
.map((q) => ({ ...q, path: brief.path }));
|
|
91
117
|
}
|
|
92
118
|
|
|
93
119
|
if (!config) {
|
package/hooks/lib/plan.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Requirements and stories, read from the project's docs. Specs (docs.specs) hold requirements as
|
|
2
2
|
// headings `### BOOK-4 Short name` (`(removed)` at the end drops one); the plan (docs.plan) holds
|
|
3
3
|
// stories as `### ST-12 Title` followed by `**Lane:**`, `**Requirements:**`, `**Depends on:**` and
|
|
4
|
-
// `**Status:**` lines
|
|
4
|
+
// `**Status:**` lines, and for a fix, `**Fixes:**`. A lane builds a story on the branch `<lane>/st-12`. The lane `lead` marks the
|
|
5
5
|
// lead's own stories (contracts, foundations, spikes), which the lead builds rather than dispatches;
|
|
6
6
|
// `**Requirements:** none` marks a story that delivers no requirement, such as a spike.
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
@@ -52,7 +52,7 @@ const field = (lines, name) =>
|
|
|
52
52
|
.trim() ?? '';
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, milestone }]. A story's
|
|
55
|
+
* Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, fixes, milestone }]. A story's
|
|
56
56
|
* milestone is the `## ` heading above it ("Milestone 5: Your own key"), or null.
|
|
57
57
|
*/
|
|
58
58
|
export function readStories(root, planPath) {
|
|
@@ -75,6 +75,8 @@ export function readStories(root, planPath) {
|
|
|
75
75
|
lane: field(body, 'Lane'),
|
|
76
76
|
requirements: [...field(body, 'Requirements').matchAll(REQUIREMENT)].map((r) => r[1]),
|
|
77
77
|
noRequirements: /^none\b/i.test(field(body, 'Requirements')),
|
|
78
|
+
// REG-1: what a fix story fixes, or null.
|
|
79
|
+
fixes: field(body, 'Fixes') || null,
|
|
78
80
|
dependsOn: [...field(body, 'Depends on').matchAll(/\bST-\d+\b/g)].map((r) => r[0]),
|
|
79
81
|
status: STATES.includes(status) ? status : 'todo',
|
|
80
82
|
milestone,
|
package/hooks/lib/queue.mjs
CHANGED
|
@@ -11,7 +11,10 @@ const DONE_KEPT = 20;
|
|
|
11
11
|
|
|
12
12
|
const queueFile = (root) => join(projectWorktrees(root)[0] ?? root, QUEUE_FILE);
|
|
13
13
|
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* The queue's entries: [{ branch, commit, passed, state, at?, reason? }], waiting ones in merge order.
|
|
16
|
+
* `commit` is the one the lead's review passed; entries queued before 0.13.0 have none.
|
|
17
|
+
*/
|
|
15
18
|
export function readQueue(root) {
|
|
16
19
|
try {
|
|
17
20
|
const entries = JSON.parse(readFileSync(queueFile(root), 'utf8'));
|
|
@@ -35,13 +38,26 @@ function writeQueue(root, entries) {
|
|
|
35
38
|
/** The branches still to merge, in order (MQ-1). */
|
|
36
39
|
export const waiting = (entries) => entries.filter((e) => ['waiting', 'merging'].includes(e.state));
|
|
37
40
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Adds a branch the lead's review passed at `commit`, at the back; a branch already waiting keeps its
|
|
43
|
+
* place, with the commit its latest review passed.
|
|
44
|
+
*/
|
|
45
|
+
export function enqueue(root, branch, commit, now = Date.now()) {
|
|
40
46
|
const entries = readQueue(root);
|
|
41
|
-
|
|
47
|
+
const queued = waiting(entries).find((e) => e.branch === branch);
|
|
48
|
+
if (queued) {
|
|
49
|
+
if (queued.commit === commit) return false;
|
|
50
|
+
writeQueue(
|
|
51
|
+
root,
|
|
52
|
+
entries.map((e) =>
|
|
53
|
+
e === queued ? { ...e, commit, passed: new Date(now).toISOString() } : e,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
return 'updated';
|
|
57
|
+
}
|
|
42
58
|
writeQueue(root, [
|
|
43
59
|
...entries.filter((e) => e.branch !== branch),
|
|
44
|
-
{ branch, passed: new Date(now).toISOString(), state: 'waiting' },
|
|
60
|
+
{ branch, commit, passed: new Date(now).toISOString(), state: 'waiting' },
|
|
45
61
|
]);
|
|
46
62
|
return true;
|
|
47
63
|
}
|
|
@@ -57,7 +73,10 @@ export function dequeue(root, branch) {
|
|
|
57
73
|
return true;
|
|
58
74
|
}
|
|
59
75
|
|
|
60
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Sets a waiting branch's state: merging, merged, sent back (with why), moved (it changed after its
|
|
78
|
+
* review, so it's reviewed again), or waiting again.
|
|
79
|
+
*/
|
|
61
80
|
export function settle(root, branch, state, reason = null, now = Date.now()) {
|
|
62
81
|
const entries = readQueue(root).map((e) =>
|
|
63
82
|
e.branch === branch && ['waiting', 'merging'].includes(e.state)
|