@warren-dean/code-kit 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +50 -0
- package/README.md +26 -10
- package/bin/code-kit.mjs +398 -39
- package/hooks/guard-bash.mjs +35 -14
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +75 -6
- package/hooks/lib/adapters/index.mjs +18 -0
- package/hooks/lib/config.mjs +46 -2
- package/hooks/lib/dependencies.mjs +26 -9
- package/hooks/lib/harness.mjs +131 -0
- package/hooks/lib/next.mjs +34 -3
- package/hooks/lib/plan.mjs +29 -0
- package/hooks/lib/queue.mjs +75 -0
- package/hooks/lib/reviews.mjs +18 -0
- package/hooks/lib/rules.mjs +8 -4
- package/hooks/lib/shell.mjs +1 -1
- package/hooks/lib/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/hold.mjs +25 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/panes.mjs +943 -0
- package/hooks/mod/register.mjs +1102 -14
- package/hooks/mod/register.test.ts +1747 -13
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/view.mjs +763 -36
- package/package.json +1 -1
- package/skills/init/SKILL.md +5 -4
- package/skills/review/SKILL.md +8 -1
package/hooks/lane-audit.mjs
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// PostToolUse (Bash) and part of Stop: shell commands can write files without going through the
|
|
3
3
|
// path guard, so after each one every uncommitted change in the worktree is checked against the
|
|
4
4
|
// same rules (lib/rules.mjs). Anything the actor may not write is reported for revert.
|
|
5
|
-
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
7
8
|
import { join } from 'node:path';
|
|
8
9
|
import {
|
|
9
10
|
APPROVAL_LOG,
|
|
@@ -39,12 +40,54 @@ export function changedFiles(root) {
|
|
|
39
40
|
.filter((f) => f !== LANE_FILE);
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// A read-only agent (a reviewer, a card writer) works in a checkout it shares, usually the lead's,
|
|
44
|
+
// whose uncommitted work isn't the agent's. So its shell commands are judged by what each one changed:
|
|
45
|
+
// the uncommitted files are noted before the command runs (guard-bash) and compared after it.
|
|
46
|
+
const snapshotFile = (id) => join(tmpdir(), 'code-kit-shell', `${id}.json`);
|
|
47
|
+
const signature = (root, rel) => {
|
|
48
|
+
try {
|
|
49
|
+
const s = statSync(join(root, rel));
|
|
50
|
+
return `${s.size}:${s.mtimeMs}`;
|
|
51
|
+
} catch {
|
|
52
|
+
return 'gone';
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Notes the uncommitted files and their state before a read-only agent's command runs. */
|
|
57
|
+
export function noteBefore(input) {
|
|
58
|
+
const root = actorRoot(input);
|
|
59
|
+
if (!root || !input.tool_use_id) return;
|
|
60
|
+
const before = Object.fromEntries(changedFiles(root).map((rel) => [rel, signature(root, rel)]));
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(join(tmpdir(), 'code-kit-shell'), { recursive: true });
|
|
63
|
+
writeFileSync(snapshotFile(input.tool_use_id), JSON.stringify(before));
|
|
64
|
+
} catch {
|
|
65
|
+
// without a note, every uncommitted change is judged, as for any actor
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What was noted before the command, taken once; null when nothing was. */
|
|
70
|
+
function takeNote(input) {
|
|
71
|
+
if (!input.tool_use_id) return null;
|
|
72
|
+
const file = snapshotFile(input.tool_use_id);
|
|
73
|
+
try {
|
|
74
|
+
const before = JSON.parse(readFileSync(file, 'utf8'));
|
|
75
|
+
rmSync(file, { force: true });
|
|
76
|
+
return before;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
export function auditProblems(input, config) {
|
|
43
83
|
const root = actorRoot(input);
|
|
44
84
|
if (!root) return [];
|
|
45
85
|
const actor = actorFor(input, root, config);
|
|
86
|
+
const before = actor.kind === 'readonly' ? takeNote(input) : null;
|
|
46
87
|
return (
|
|
47
88
|
changedFiles(root)
|
|
89
|
+
// A read-only agent answers for what its command changed, not for the checkout it shares.
|
|
90
|
+
.filter((rel) => !before || before[rel] !== signature(root, rel))
|
|
48
91
|
.filter((rel) => !(rel === APPROVAL_LOG && appendedOnly(root, rel)))
|
|
49
92
|
// Mid-merge, the incoming branch's changes are its own, not this actor's writes.
|
|
50
93
|
.filter((rel) => !arrivesWithMerge(root, rel))
|
|
@@ -7,13 +7,34 @@
|
|
|
7
7
|
// lead act on what the person says in chat (`approvals.lead`). A delegated ratification, signed
|
|
8
8
|
// `Ctx-Ratified-By: <ratifier> (delegated)`, is the lead's own act under the repository's [delegate]
|
|
9
9
|
// rules: only the lead may write that trailer, and Context Graph's gate checks the kinds it covers.
|
|
10
|
-
// Ratifying with `--commit`,
|
|
11
|
-
//
|
|
10
|
+
// Ratifying with `--commit`, dropping a proposal and changing the harness settings are the person's acts
|
|
11
|
+
// in ctx too, so no actor runs
|
|
12
|
+
// them; only a command the line runs counts, so a heredoc or quoted text that mentions one isn't refused.
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
12
14
|
import { existsSync } from 'node:fs';
|
|
13
15
|
import { join } from 'node:path';
|
|
14
16
|
|
|
17
|
+
/** ctx's JSON for `args`, run in `root`, or null where ctx isn't installed or fails. */
|
|
18
|
+
function ctx(root, args) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(
|
|
21
|
+
execFileSync('ctx', [...args, '--json'], {
|
|
22
|
+
cwd: root,
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
25
|
+
timeout: 30000,
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
const CTX = String.raw`(?:ctx|node\s+["']?[^\s"']*ctx\.mjs["']?|npx\s+(?:--yes\s+)?@warren-dean/context-graph(?:@\S+)?)`;
|
|
16
|
-
const PERSONS_ACT = String.raw
|
|
34
|
+
const PERSONS_ACT = String.raw`^${CTX}\s+(?:ratify\b[^;&|\n]*\s--commit\b|drop\b|settings\s+set\b)`;
|
|
35
|
+
|
|
36
|
+
/** A commit the person signed with their ratification trailer; agents may not write it. */
|
|
37
|
+
const PERSON_SIGNED = '^Ctx-Ratified-By:(?![^\\n]*\\(delegated\\))\\s*\\S';
|
|
17
38
|
|
|
18
39
|
export default {
|
|
19
40
|
name: 'context-graph',
|
|
@@ -27,9 +48,15 @@ export default {
|
|
|
27
48
|
approval: 'ctx',
|
|
28
49
|
why: "Context Graph's rules and concepts",
|
|
29
50
|
// A person's ratification (`ctx ratify --commit`, `ctx drop --commit`) is signed, not logged.
|
|
30
|
-
signedBy:
|
|
51
|
+
signedBy: PERSON_SIGNED,
|
|
52
|
+
},
|
|
53
|
+
// Setting Context Graph up is ratified the same way: the person commits it from their terminal.
|
|
54
|
+
{
|
|
55
|
+
glob: '.ctx/config.toml',
|
|
56
|
+
approval: 'ctx',
|
|
57
|
+
why: "Context Graph's settings",
|
|
58
|
+
signedBy: PERSON_SIGNED,
|
|
31
59
|
},
|
|
32
|
-
{ glob: '.ctx/config.toml', approval: 'ctx', why: "Context Graph's settings" },
|
|
33
60
|
],
|
|
34
61
|
},
|
|
35
62
|
shell: {
|
|
@@ -41,7 +68,8 @@ export default {
|
|
|
41
68
|
},
|
|
42
69
|
{
|
|
43
70
|
pattern: PERSONS_ACT,
|
|
44
|
-
|
|
71
|
+
command: true,
|
|
72
|
+
why: "Ratifying a Context Graph proposal with --commit, dropping one, and changing Context Graph's harness settings are the person's own acts: they run them themselves, with a `!` command, or from the Context Graph pane.",
|
|
45
73
|
},
|
|
46
74
|
],
|
|
47
75
|
restricted: [
|
|
@@ -52,6 +80,47 @@ export default {
|
|
|
52
80
|
},
|
|
53
81
|
],
|
|
54
82
|
},
|
|
83
|
+
// What Context Graph knows of the lanes' work (the combined lane view, docs/specs/13-combined.md).
|
|
84
|
+
facts: {
|
|
85
|
+
/** Whether ctx's command line runs here. */
|
|
86
|
+
available: (root) => ctx(root, ['agents', '--session', 'none']) !== null,
|
|
87
|
+
/**
|
|
88
|
+
* Per agent type in a session (null for the main session): the edits made without understanding,
|
|
89
|
+
* each with the files still unread, and the cards owed.
|
|
90
|
+
*/
|
|
91
|
+
lanes: (root, { session }) => {
|
|
92
|
+
const agents = session ? (ctx(root, ['agents', '--session', session]) ?? []) : [];
|
|
93
|
+
const byType = {};
|
|
94
|
+
for (const a of Array.isArray(agents) ? agents : []) {
|
|
95
|
+
const key = a.agentType ?? 'lead';
|
|
96
|
+
const f = (byType[key] ??= { withoutUnderstanding: [], cardsOwed: [] });
|
|
97
|
+
for (const e of a.edited ?? [])
|
|
98
|
+
if (!e.understood) f.withoutUnderstanding.push({ path: e.path, unread: e.missing ?? [] });
|
|
99
|
+
for (const c of a.cardsOwed ?? []) if (!f.cardsOwed.includes(c)) f.cardsOwed.push(c);
|
|
100
|
+
}
|
|
101
|
+
return byType;
|
|
102
|
+
},
|
|
103
|
+
/** Per file: the rules that apply to it, agreed and proposed. */
|
|
104
|
+
files: (root, paths) =>
|
|
105
|
+
Object.fromEntries(
|
|
106
|
+
paths.map((path) => {
|
|
107
|
+
const f = ctx(root, ['file', path]);
|
|
108
|
+
return [
|
|
109
|
+
path,
|
|
110
|
+
{
|
|
111
|
+
rules: (f?.rules ?? []).map((r) => ({
|
|
112
|
+
id: r.id,
|
|
113
|
+
text: r.text,
|
|
114
|
+
mode: r.mode,
|
|
115
|
+
proposed: r.mode === 'G?',
|
|
116
|
+
})),
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
/** The command that opens Context Graph's pane on a file, given its path. */
|
|
122
|
+
open: { command: 'graph' },
|
|
123
|
+
},
|
|
55
124
|
setup: [
|
|
56
125
|
'.gitignore: .ctx/archive/, .ctx/bench/, .ctx/models/ and .ctx/serve.json (ctx working files, never committed)',
|
|
57
126
|
'.gitattributes: .ctx/decisions.ctx merge=union and .ctx/cards.ctx merge=union (lanes record decisions and cards on parallel branches; merging keeps them all)',
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
// shell.block (`person: true` marks a person's act the lead may do for them under
|
|
12
12
|
// approvals.lead); restricted: commands only the lead and the listed lanes may run
|
|
13
13
|
// setup lines for init to apply or tell the person: ignore files, merge drivers, instructions
|
|
14
|
+
// facts what the tool knows of the lanes' work, for the mod's combined view: { available(root),
|
|
15
|
+
// lanes(root, { session }) → { [agent type or 'lead']: { withoutUnderstanding, cardsOwed } },
|
|
16
|
+
// files(root, paths) → { [path]: { rules } }, open: { command } (its view of a file) }
|
|
14
17
|
//
|
|
15
18
|
// The core only ever calls applyAdapters() and activeAdapters(); it never names a tool.
|
|
16
19
|
import contextGraph from './context-graph.mjs';
|
|
@@ -22,6 +25,21 @@ export function activeAdapters(raw, root) {
|
|
|
22
25
|
return ADAPTERS.filter((a) => raw.adapters?.[a.name] !== false && a.detect(root));
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
/**
|
|
29
|
+
* What the active adapters that know of the lanes' work report (JOIN-1): [{ name, lanes?, files?, open }],
|
|
30
|
+
* only those whose tool is available here. `paths` asks for those files' facts too.
|
|
31
|
+
*/
|
|
32
|
+
export function adapterFacts(raw, root, { session = null, paths = null } = {}) {
|
|
33
|
+
return activeAdapters(raw, root)
|
|
34
|
+
.filter((a) => a.facts && a.facts.available(root))
|
|
35
|
+
.map((a) => ({
|
|
36
|
+
name: a.name,
|
|
37
|
+
...(a.facts.lanes ? { lanes: a.facts.lanes(root, { session }) } : {}),
|
|
38
|
+
...(paths && a.facts.files ? { files: a.facts.files(root, paths) } : {}),
|
|
39
|
+
open: a.facts.open ?? null,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
/** The effective config with every active adapter's contributions merged in. */
|
|
26
44
|
export function applyAdapters(config, raw, root) {
|
|
27
45
|
const active = activeAdapters(raw, root);
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// The project's rules live in .claude/code-kit.json. The hooks read it from the project the session
|
|
2
2
|
// was opened in; a project without one is not governed by the kit, and an invalid one fails closed.
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
3
4
|
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { harnessProblems, harnessSettings } from './harness.mjs';
|
|
4
6
|
import { join } from 'node:path';
|
|
5
7
|
import { ADAPTERS, applyAdapters } from './adapters/index.mjs';
|
|
6
8
|
|
|
@@ -28,6 +30,43 @@ export function loadConfig(projectDir) {
|
|
|
28
30
|
return { config: effectiveConfig(raw, projectDir) };
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The config as committed on `ref`: what governs merging into it, so a branch's own config can't grant
|
|
35
|
+
* itself a merge its target doesn't allow. `{ config }`, `{ error }` when it's invalid, or `{}` when
|
|
36
|
+
* `ref` has none committed yet (the first merge, which brings code-kit's rules onto it).
|
|
37
|
+
*/
|
|
38
|
+
export function committedConfig(root, ref, file = CONFIG_FILE) {
|
|
39
|
+
let text;
|
|
40
|
+
try {
|
|
41
|
+
text = execFileSync('git', ['-C', root, 'show', `${ref}:${file}`], {
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = JSON.parse(text);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return { error: `${file} on ${ref} is not valid JSON: ${e.message}` };
|
|
53
|
+
}
|
|
54
|
+
const problems = validate(raw);
|
|
55
|
+
if (problems.length)
|
|
56
|
+
return { error: `${file} on ${ref} is invalid:\n ${problems.join('\n ')}` };
|
|
57
|
+
return { config: effectiveConfig(raw, root) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether the lead may merge into the base (the first protected branch), by the rules committed there:
|
|
62
|
+
* `{ into, delegated, first }`, `first` when the base has no config committed yet.
|
|
63
|
+
*/
|
|
64
|
+
export function mergeDelegation(root, config, file = CONFIG_FILE) {
|
|
65
|
+
const into = config.branches.protected[0];
|
|
66
|
+
const { config: target, error } = committedConfig(root, into, file);
|
|
67
|
+
return { into, delegated: Boolean(target?.approvals.delegate?.merge), first: !target && !error };
|
|
68
|
+
}
|
|
69
|
+
|
|
31
70
|
/** The rules the hooks enforce: the project's config, the kit's defaults, and any active adapters. */
|
|
32
71
|
export const effectiveConfig = (raw, root) => applyAdapters(withDefaults(raw), raw, root);
|
|
33
72
|
|
|
@@ -55,6 +94,7 @@ export function withDefaults(c) {
|
|
|
55
94
|
checks: c.checks ?? [],
|
|
56
95
|
branches: { protected: c.branches?.protected ?? ['main', 'master'] },
|
|
57
96
|
approvals: { lead: c.approvals?.lead === true, delegate: delegateRules(c.approvals?.delegate) },
|
|
97
|
+
harness: harnessSettings(c.harness),
|
|
58
98
|
};
|
|
59
99
|
}
|
|
60
100
|
|
|
@@ -191,6 +231,7 @@ export function validate(c) {
|
|
|
191
231
|
c.branches === undefined || isList(c.branches?.protected),
|
|
192
232
|
'"branches.protected" must be a list of branch names',
|
|
193
233
|
);
|
|
234
|
+
p.push(...harnessProblems(c.harness));
|
|
194
235
|
return p;
|
|
195
236
|
}
|
|
196
237
|
|
|
@@ -249,10 +290,13 @@ function validateLayers(c, need, each) {
|
|
|
249
290
|
function validateShell(s, lanes, need) {
|
|
250
291
|
if (!need(isObj(s), '"shell" must be an object')) return;
|
|
251
292
|
const rule = (r) => isObj(r) && isRegExp(r.pattern) && isStr(r.why);
|
|
252
|
-
const blockRule = (r) =>
|
|
293
|
+
const blockRule = (r) =>
|
|
294
|
+
rule(r) &&
|
|
295
|
+
[undefined, true, false].includes(r.person) &&
|
|
296
|
+
[undefined, true, false].includes(r.command);
|
|
253
297
|
need(
|
|
254
298
|
s.block === undefined || (Array.isArray(s.block) && s.block.every(blockRule)),
|
|
255
|
-
'"shell.block" must be a list of { pattern, why, person? }',
|
|
299
|
+
'"shell.block" must be a list of { pattern, why, person?, command? }',
|
|
256
300
|
);
|
|
257
301
|
const restricted =
|
|
258
302
|
s.restricted === undefined ||
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// New dependencies: which packages a shell command adds, and which files installing them changes.
|
|
2
2
|
// Each package needs a person's approval (`dep-<package>`), which also covers those files.
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
|
+
import { commandsOf } from './shell.mjs';
|
|
4
5
|
|
|
5
6
|
// What installing a package rewrites: manifests and lockfiles, wherever they sit.
|
|
6
7
|
const DEPENDENCY_FILES = new Set([
|
|
@@ -86,22 +87,38 @@ const MANAGERS = {
|
|
|
86
87
|
};
|
|
87
88
|
MANAGERS.pip3 = MANAGERS.pip;
|
|
88
89
|
|
|
89
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* The packages `cmd` adds as dependencies, in order: none for a bare install or a requirements file.
|
|
92
|
+
* Only the commands it runs count: quoted text and heredoc bodies are data (an echo, a commit message),
|
|
93
|
+
* except a shell's `-c` script, which runs.
|
|
94
|
+
*/
|
|
90
95
|
export function addedPackages(cmd) {
|
|
91
96
|
const found = [];
|
|
92
|
-
for (const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
97
|
+
for (const { text, code } of commandsOf(cmd)) {
|
|
98
|
+
// Words split where the quote-masked code has spaces, so a quoted word stays one word.
|
|
99
|
+
const words = [...code.matchAll(/\S+/g)].map((m) => ({
|
|
100
|
+
quoted: /^["']/.test(m[0]),
|
|
101
|
+
word: text.slice(m.index, m.index + m[0].length).replace(/^["']|["']$/g, ''),
|
|
102
|
+
}));
|
|
103
|
+
words.forEach(({ word, quoted }, i) => {
|
|
104
|
+
if (quoted) return;
|
|
105
|
+
if (SHELLS.has(basename(word)) && words[i + 1]?.word === '-c' && words[i + 2])
|
|
106
|
+
found.push(...addedPackages(words[i + 2].word));
|
|
107
|
+
const manager = MANAGERS[basename(word)];
|
|
108
|
+
if (manager)
|
|
109
|
+
found.push(
|
|
110
|
+
...packagesAfter(
|
|
111
|
+
words.slice(i + 1).map((w) => w.word),
|
|
112
|
+
manager,
|
|
113
|
+
),
|
|
114
|
+
);
|
|
100
115
|
});
|
|
101
116
|
}
|
|
102
117
|
return [...new Set(found)];
|
|
103
118
|
}
|
|
104
119
|
|
|
120
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh']);
|
|
121
|
+
|
|
105
122
|
// The words after a package manager's name: its subcommand must be one that adds packages, and the
|
|
106
123
|
// words after that, other than flags and their values, are the packages.
|
|
107
124
|
function packagesAfter(words, manager) {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The harness's settings (docs/specs/12-settings.md): the `harness` section of .claude/code-kit.json,
|
|
2
|
+
// what the mod's loop, hold, reviewer and usage read. Defaults fill what the section leaves out; a value
|
|
3
|
+
// out of range is a config problem like any other.
|
|
4
|
+
|
|
5
|
+
export const AUTONOMY = ['autonomous', 'propose', 'off'];
|
|
6
|
+
export const MODELS = ['haiku', 'sonnet', 'opus'];
|
|
7
|
+
|
|
8
|
+
/** Each setting by its dotted key: its default, and why a value is wrong (or null when it's right). */
|
|
9
|
+
export const SETTINGS = {
|
|
10
|
+
autonomy: {
|
|
11
|
+
default: 'autonomous',
|
|
12
|
+
problem: (v) => (AUTONOMY.includes(v) ? null : `must be one of ${AUTONOMY.join(', ')}`),
|
|
13
|
+
about: 'How the lead loop acts: on its own, by proposing each step, or not at all',
|
|
14
|
+
},
|
|
15
|
+
'hold.minutes': {
|
|
16
|
+
default: 2,
|
|
17
|
+
problem: (v) =>
|
|
18
|
+
Number.isFinite(v) && v >= 0 && v <= 10
|
|
19
|
+
? null
|
|
20
|
+
: 'must be a number of minutes from 0 (off) to 10',
|
|
21
|
+
about: 'How long a call waiting on your approval is held before it is refused',
|
|
22
|
+
},
|
|
23
|
+
'stall.nudgeMinutes': {
|
|
24
|
+
default: 5,
|
|
25
|
+
problem: (v) =>
|
|
26
|
+
Number.isFinite(v) && v >= 1 ? null : 'must be a number of minutes, at least 1',
|
|
27
|
+
about: 'Minutes without a tool call before a lane agent is nudged',
|
|
28
|
+
},
|
|
29
|
+
'stall.restartMinutes': {
|
|
30
|
+
default: 10,
|
|
31
|
+
problem: (v) =>
|
|
32
|
+
Number.isFinite(v) && v >= 1 ? null : 'must be a number of minutes, at least 1',
|
|
33
|
+
about: 'Minutes without a tool call before a lane agent is restarted',
|
|
34
|
+
},
|
|
35
|
+
'stall.maxRestarts': {
|
|
36
|
+
default: 2,
|
|
37
|
+
problem: (v) =>
|
|
38
|
+
Number.isInteger(v) && v >= 0 && v <= 10 ? null : 'must be a whole number from 0 to 10',
|
|
39
|
+
about: 'Restarts on one story before the lane is flagged for you',
|
|
40
|
+
},
|
|
41
|
+
'agents.reviewer.on': {
|
|
42
|
+
default: false,
|
|
43
|
+
problem: (v) => (typeof v === 'boolean' ? null : 'must be true or false'),
|
|
44
|
+
about: 'A background reviewer for each finished branch',
|
|
45
|
+
},
|
|
46
|
+
'agents.reviewer.model': {
|
|
47
|
+
default: null,
|
|
48
|
+
problem: (v) =>
|
|
49
|
+
v === null || (typeof v === 'string' && v.length > 0)
|
|
50
|
+
? null
|
|
51
|
+
: `must be a model (${MODELS.join(', ')}, or an id), or null for the session's`,
|
|
52
|
+
about: "The reviewer's model; none means the session's",
|
|
53
|
+
},
|
|
54
|
+
'background.pauseAtPercent': {
|
|
55
|
+
default: 80,
|
|
56
|
+
problem: (v) =>
|
|
57
|
+
Number.isFinite(v) && v > 0 && v <= 100 ? null : 'must be a percentage from 1 to 100',
|
|
58
|
+
about: "The plan's 5-hour use at which background agents pause",
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const get = (obj, key) =>
|
|
63
|
+
key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj);
|
|
64
|
+
const set = (obj, key, value) => {
|
|
65
|
+
const parts = key.split('.');
|
|
66
|
+
let o = obj;
|
|
67
|
+
for (const k of parts.slice(0, -1)) o = o[k] && typeof o[k] === 'object' ? o[k] : (o[k] = {});
|
|
68
|
+
o[parts.at(-1)] = value;
|
|
69
|
+
return obj;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Every key the section holds, as dotted keys: `stall.nudgeMinutes`, `agents.reviewer.on`… */
|
|
73
|
+
function keysOf(obj, prefix = '') {
|
|
74
|
+
return Object.entries(obj ?? {}).flatMap(([k, v]) =>
|
|
75
|
+
v && typeof v === 'object' && !Array.isArray(v) && !(`${prefix}${k}` in SETTINGS)
|
|
76
|
+
? keysOf(v, `${prefix}${k}.`)
|
|
77
|
+
: [`${prefix}${k}`],
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The settings in force: the section's values over the defaults, as a nested object. A value that's
|
|
82
|
+
* wrong (reported by `harnessProblems`) gives way to the default. */
|
|
83
|
+
export function harnessSettings(section) {
|
|
84
|
+
const out = {};
|
|
85
|
+
for (const [key, s] of Object.entries(SETTINGS)) {
|
|
86
|
+
const v = section && typeof section === 'object' ? get(section, key) : undefined;
|
|
87
|
+
set(out, key, v === undefined || s.problem(v) ? s.default : v);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The section's problems, in the config's words. */
|
|
93
|
+
export function harnessProblems(section) {
|
|
94
|
+
if (section === undefined) return [];
|
|
95
|
+
if (!section || typeof section !== 'object' || Array.isArray(section))
|
|
96
|
+
return ['"harness" must be an object'];
|
|
97
|
+
const p = [];
|
|
98
|
+
for (const key of keysOf(section)) {
|
|
99
|
+
if (!(key in SETTINGS)) {
|
|
100
|
+
p.push(
|
|
101
|
+
`"harness.${key}" isn't a harness setting (they are ${Object.keys(SETTINGS).join(', ')})`,
|
|
102
|
+
);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const why = SETTINGS[key].problem(get(section, key));
|
|
106
|
+
if (why) p.push(`"harness.${key}" ${why}`);
|
|
107
|
+
}
|
|
108
|
+
const h = harnessSettings(section);
|
|
109
|
+
if (h.stall.restartMinutes <= h.stall.nudgeMinutes)
|
|
110
|
+
p.push('"harness.stall.restartMinutes" must be more than "harness.stall.nudgeMinutes"');
|
|
111
|
+
return p;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A value typed on the command line, as the setting's type: numbers, true and false, null, or text. */
|
|
115
|
+
export function parseSetting(key, text) {
|
|
116
|
+
const d = SETTINGS[key]?.default;
|
|
117
|
+
if (text === 'null') return null;
|
|
118
|
+
if (typeof d === 'boolean' || text === 'true' || text === 'false')
|
|
119
|
+
return text === 'true' ? true : text === 'false' ? false : text;
|
|
120
|
+
if (typeof d === 'number' && /^-?\d+(\.\d+)?$/.test(text)) return Number(text);
|
|
121
|
+
return text;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The raw config with one harness setting changed (a copy). */
|
|
125
|
+
export function withSetting(raw, key, value) {
|
|
126
|
+
const copy = structuredClone(raw);
|
|
127
|
+
copy.harness = set(copy.harness ?? {}, key, value);
|
|
128
|
+
return copy;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { get as settingOf };
|
package/hooks/lib/next.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { join, posix } from 'node:path';
|
|
7
|
-
import { loadConfig } from './config.mjs';
|
|
7
|
+
import { loadConfig, mergeDelegation } from './config.mjs';
|
|
8
8
|
import { buildStatus, readStories, LEAD } from './plan.mjs';
|
|
9
|
+
import { readQueue, waiting } from './queue.mjs';
|
|
9
10
|
|
|
10
11
|
export const DRAFT_FILE = '.claude/code-kit.draft.json';
|
|
11
12
|
export const BRIEF_STATES = ['framed', 'explored', 'scoped', 'specified', 'planned', 'ready'];
|
|
@@ -52,7 +53,8 @@ const hasMarkdown = (dir) => {
|
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* { step, why, args, then, attention }. `step` is a skill (spec-design, init, check, dispatch,
|
|
55
|
-
* review), `
|
|
56
|
+
* review), `merge` (the head of the merge queue, where the lead merges), `wait` (lanes are
|
|
57
|
+
* building) or `done`. `base(config)` resolves the branch lanes merge into.
|
|
56
58
|
*/
|
|
57
59
|
export function nextStep(root, base) {
|
|
58
60
|
const attention = [];
|
|
@@ -149,7 +151,12 @@ export function nextStep(root, base) {
|
|
|
149
151
|
`The plan's status lines are out of date for ${status.drift.map((d) => d.story).join(', ')}.`,
|
|
150
152
|
);
|
|
151
153
|
|
|
152
|
-
|
|
154
|
+
// A branch already in the merge queue has passed its review: it isn't reviewed again.
|
|
155
|
+
const queued = waiting(readQueue(root)).map((e) => e.branch);
|
|
156
|
+
const ids = (state) =>
|
|
157
|
+
status.stories
|
|
158
|
+
.filter((s) => s.state === state && !(state === 'review' && queued.includes(s.branch)))
|
|
159
|
+
.map((s) => s.id);
|
|
153
160
|
const [review, building, blocked, fixes] = ['review', 'in progress', 'blocked', 'sent back'].map(
|
|
154
161
|
ids,
|
|
155
162
|
);
|
|
@@ -168,6 +175,25 @@ export function nextStep(root, base) {
|
|
|
168
175
|
attention.push(
|
|
169
176
|
`${unbuildable.join(', ')} can't be dispatched until the plan's gaps are fixed.`,
|
|
170
177
|
);
|
|
178
|
+
// The merge queue (spec 09): branches the lead's review passed. Under autonomy, where the base's
|
|
179
|
+
// committed rules delegate merges, the lead merges the head first, so dependents see it; otherwise
|
|
180
|
+
// the person does, and always the first merge, which brings code-kit's rules onto the base.
|
|
181
|
+
const delegation = queued.length ? mergeDelegation(root, config) : { delegated: false };
|
|
182
|
+
const leadMerges = delegation.delegated && config.harness.autonomy === 'autonomous';
|
|
183
|
+
if (queued.length && delegation.first)
|
|
184
|
+
attention.push(
|
|
185
|
+
`${queued[0]} passed review and is next to merge. ${delegation.into} has no code-kit config committed yet, so this first merge, which brings the rules onto it, is the person's (code-kit queue merge --person, or Merge in the code-kit band); after it, the lead merges.`,
|
|
186
|
+
);
|
|
187
|
+
else if (queued.length && !leadMerges)
|
|
188
|
+
attention.push(
|
|
189
|
+
`${queued.join(', ')} passed review and ${queued.length === 1 ? 'waits' : 'wait'} for the person to merge (code-kit queue merge --person, or Merge in the code-kit band).`,
|
|
190
|
+
);
|
|
191
|
+
if (queued.length && leadMerges)
|
|
192
|
+
return result(
|
|
193
|
+
'merge',
|
|
194
|
+
`${queued[0]} passed review and is next in the merge queue${queued.length > 1 ? ` (then ${queued.slice(1).join(', ')})` : ''}.`,
|
|
195
|
+
{ args: queued[0] },
|
|
196
|
+
);
|
|
171
197
|
const then = [];
|
|
172
198
|
// A sent-back story goes back to its lane with the review's findings, before new work starts.
|
|
173
199
|
const toDispatch = [...fixes, ...ready];
|
|
@@ -211,6 +237,11 @@ export function nextStep(root, base) {
|
|
|
211
237
|
'spec-design',
|
|
212
238
|
`${blocked.join(', ')} wait on stories that can't finish: the plan has a dependency cycle or a missing story.`,
|
|
213
239
|
);
|
|
240
|
+
if (queued.length)
|
|
241
|
+
return result(
|
|
242
|
+
'wait',
|
|
243
|
+
`${queued.join(', ')} ${queued.length === 1 ? 'waits' : 'wait'} to be merged.`,
|
|
244
|
+
);
|
|
214
245
|
return result(
|
|
215
246
|
'done',
|
|
216
247
|
`All ${status.stories.length} stories are done; spec-design plans the next milestone or feature.`,
|
package/hooks/lib/plan.mjs
CHANGED
|
@@ -221,3 +221,32 @@ export function buildStatus(root, config, base, files) {
|
|
|
221
221
|
.map((s) => ({ story: s.id, plan: s.status, actual: s.state }));
|
|
222
222
|
return { stories, requirements: byRequirement, problems, drift };
|
|
223
223
|
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The rows of a spec-check report's table (skills/spec-check): [{ requirement, status, where, note }],
|
|
227
|
+
* the first column's requirement id, the status as written (done, partial, missing or conflicts).
|
|
228
|
+
*/
|
|
229
|
+
export function specCheckRows(text) {
|
|
230
|
+
const lines = String(text).split('\n');
|
|
231
|
+
const head = lines.findIndex((l) => /^\s*\|\s*Requirement\s*\|/i.test(l));
|
|
232
|
+
if (head < 0) return [];
|
|
233
|
+
const rows = [];
|
|
234
|
+
for (const line of lines.slice(head + 1)) {
|
|
235
|
+
if (!/^\s*\|/.test(line)) break;
|
|
236
|
+
const cells = line
|
|
237
|
+
.trim()
|
|
238
|
+
.replace(/^\||\|$/g, '')
|
|
239
|
+
.split('|')
|
|
240
|
+
.map((c) => c.trim());
|
|
241
|
+
if (cells.every((c) => /^:?-+:?$/.test(c) || c === '')) continue;
|
|
242
|
+
const [requirement = '', status = '', where = '', note = ''] = cells;
|
|
243
|
+
rows.push({
|
|
244
|
+
requirement: requirement.match(REQUIREMENT_ID)?.[0] ?? requirement,
|
|
245
|
+
status: status.toLowerCase(),
|
|
246
|
+
where,
|
|
247
|
+
note,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return rows;
|
|
251
|
+
}
|
|
252
|
+
const REQUIREMENT_ID = /\b[A-Z][A-Z0-9]*-\d+\b/;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The merge queue (docs/specs/09-merge-queue.md): branches that passed the lead's review, merged one at
|
|
2
|
+
// a time in the order they passed, each verified against the base as it is then. Kept in the main
|
|
3
|
+
// checkout, beside the requests and reviews, so every worktree and a restarted session see the same.
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { projectWorktrees } from './rules.mjs';
|
|
7
|
+
|
|
8
|
+
export const QUEUE_FILE = '.claude/state/merge-queue.json';
|
|
9
|
+
/** Merged and sent-back entries kept for the pane, newest last. */
|
|
10
|
+
const DONE_KEPT = 20;
|
|
11
|
+
|
|
12
|
+
const queueFile = (root) => join(projectWorktrees(root)[0] ?? root, QUEUE_FILE);
|
|
13
|
+
|
|
14
|
+
/** The queue's entries: [{ branch, passed, state, at?, reason? }], waiting ones in merge order. */
|
|
15
|
+
export function readQueue(root) {
|
|
16
|
+
try {
|
|
17
|
+
const entries = JSON.parse(readFileSync(queueFile(root), 'utf8'));
|
|
18
|
+
return Array.isArray(entries) ? entries.filter((e) => e && typeof e.branch === 'string') : [];
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeQueue(root, entries) {
|
|
25
|
+
const done = entries.filter((e) => !['waiting', 'merging'].includes(e.state));
|
|
26
|
+
const kept = [
|
|
27
|
+
...done.slice(-DONE_KEPT),
|
|
28
|
+
...entries.filter((e) => ['waiting', 'merging'].includes(e.state)),
|
|
29
|
+
];
|
|
30
|
+
const file = queueFile(root);
|
|
31
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
32
|
+
writeFileSync(file, `${JSON.stringify(kept, null, 2)}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The branches still to merge, in order (MQ-1). */
|
|
36
|
+
export const waiting = (entries) => entries.filter((e) => ['waiting', 'merging'].includes(e.state));
|
|
37
|
+
|
|
38
|
+
/** Adds a branch the lead's review passed, at the back; a branch already waiting keeps its place. */
|
|
39
|
+
export function enqueue(root, branch, now = Date.now()) {
|
|
40
|
+
const entries = readQueue(root);
|
|
41
|
+
if (waiting(entries).some((e) => e.branch === branch)) return false;
|
|
42
|
+
writeQueue(root, [
|
|
43
|
+
...entries.filter((e) => e.branch !== branch),
|
|
44
|
+
{ branch, passed: new Date(now).toISOString(), state: 'waiting' },
|
|
45
|
+
]);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Takes a waiting branch out of the queue. */
|
|
50
|
+
export function dequeue(root, branch) {
|
|
51
|
+
const entries = readQueue(root);
|
|
52
|
+
if (!waiting(entries).some((e) => e.branch === branch)) return false;
|
|
53
|
+
writeQueue(
|
|
54
|
+
root,
|
|
55
|
+
entries.filter((e) => !(e.branch === branch && ['waiting', 'merging'].includes(e.state))),
|
|
56
|
+
);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Sets a waiting branch's state: merging, merged, sent back (with why), or waiting again. */
|
|
61
|
+
export function settle(root, branch, state, reason = null, now = Date.now()) {
|
|
62
|
+
const entries = readQueue(root).map((e) =>
|
|
63
|
+
e.branch === branch && ['waiting', 'merging'].includes(e.state)
|
|
64
|
+
? { ...e, state, at: new Date(now).toISOString(), ...(reason ? { reason } : {}) }
|
|
65
|
+
: e,
|
|
66
|
+
);
|
|
67
|
+
// Merged and sent-back entries move behind the waiting ones' history, in the order they settled.
|
|
68
|
+
const settled = entries.filter((e) => e.branch === branch && e.state === state);
|
|
69
|
+
writeQueue(root, [...entries.filter((e) => !settled.includes(e)), ...settled]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The branch merged last, for a send-back's reason ("conflicts with main after web/st-4"). */
|
|
73
|
+
export function lastMerged(entries) {
|
|
74
|
+
return [...entries].reverse().find((e) => e.state === 'merged')?.branch ?? null;
|
|
75
|
+
}
|