@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/hooks/guard-bash.mjs
CHANGED
|
@@ -9,8 +9,10 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
10
|
import { join, resolve } from 'node:path';
|
|
11
11
|
import { addedPackages, dependencyApproval, isDependencyFile } from './lib/dependencies.mjs';
|
|
12
|
-
import { CODE_KIT, GIT, codeOf, runs } from './lib/shell.mjs';
|
|
12
|
+
import { CODE_KIT, GIT, codeOf, commandsOf, runs } from './lib/shell.mjs';
|
|
13
13
|
import { block, start } from './lib/hook.mjs';
|
|
14
|
+
import { CONFIG_FILE } from './lib/config.mjs';
|
|
15
|
+
import { noteBefore } from './lane-audit.mjs';
|
|
14
16
|
import {
|
|
15
17
|
APPROVAL_LOG,
|
|
16
18
|
actorFor,
|
|
@@ -54,10 +56,14 @@ const ALWAYS = [
|
|
|
54
56
|
'Piping downloads into a shell is not allowed.',
|
|
55
57
|
codeOf(cmd),
|
|
56
58
|
],
|
|
57
|
-
[
|
|
59
|
+
[
|
|
60
|
+
/\.claude\/approvals/,
|
|
61
|
+
"Approvals are created only by a person, with a `!` shell command. A shell command may not name them, even to read them; read them with the Read tool (or Glob to list them), which can't write.",
|
|
62
|
+
cmd,
|
|
63
|
+
],
|
|
58
64
|
[
|
|
59
65
|
/approval-log\.jsonl/,
|
|
60
|
-
|
|
66
|
+
"The approval audit log is appended only by the commit hook; nobody edits it. A shell command may not name it, even to read it; read it with the Read tool, which can't write.",
|
|
61
67
|
cmd,
|
|
62
68
|
],
|
|
63
69
|
];
|
|
@@ -66,7 +72,8 @@ for (const [re, why, text] of ALWAYS) {
|
|
|
66
72
|
}
|
|
67
73
|
const packages = addedPackages(cmd);
|
|
68
74
|
const approves = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+approve\b`));
|
|
69
|
-
const delegatedMerge = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+merge\b`));
|
|
75
|
+
const delegatedMerge = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+(?:queue\s+)?merge\b`));
|
|
76
|
+
const queues = runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+queue\s+(?:add|drop)\b`));
|
|
70
77
|
const commits = runs(cmd, new RegExp(String.raw`^${GIT}commit\b`));
|
|
71
78
|
if (error) {
|
|
72
79
|
if (commits) block(`Blocked: ${error}\nFix the config before committing.`);
|
|
@@ -83,6 +90,14 @@ const branches = config.branches.protected
|
|
|
83
90
|
// The branch is read before the command runs, so a command that switches to a protected branch and then
|
|
84
91
|
// commits or merges in the same breath is judged by what it does, not by where it starts. Moving a
|
|
85
92
|
// protected branch directly (branch -f, update-ref) is a commit on it by another name.
|
|
93
|
+
/** Whether `file` is committed at HEAD in the repository at `root`. */
|
|
94
|
+
function committedOnHead(root, file) {
|
|
95
|
+
return (
|
|
96
|
+
spawnSync('git', ['-C', root, 'cat-file', '-e', `HEAD:${file}`], { stdio: 'ignore' }).status ===
|
|
97
|
+
0
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
86
101
|
const writesHistory = runs(
|
|
87
102
|
cmd,
|
|
88
103
|
new RegExp(String.raw`^${GIT}(commit|merge|cherry-pick|rebase|revert|am)\b`),
|
|
@@ -119,11 +134,16 @@ if (runs(cmd, new RegExp(String.raw`^${GIT}push\b.*\b(${branches})\b`))) {
|
|
|
119
134
|
|
|
120
135
|
const sessionIn = sessionRoot(input, project);
|
|
121
136
|
const actor = actorFor(input, sessionIn ?? project, config);
|
|
137
|
+
// A read-only agent's command is judged afterwards by what it changed (lane-audit).
|
|
138
|
+
if (actor.kind === 'readonly') noteBefore(input);
|
|
122
139
|
// The lead acts for the person only where the config says the person may approve from chat.
|
|
123
140
|
const forPerson = actor.kind === 'lead' && config.approvals.lead;
|
|
124
141
|
|
|
125
|
-
for (const { pattern, why, person } of config.shell.block) {
|
|
126
|
-
|
|
142
|
+
for (const { pattern, why, person, command } of config.shell.block) {
|
|
143
|
+
// `command: true`: the pattern names a command, matched from the start of each one the line runs, so a
|
|
144
|
+
// heredoc or a quoted message that mentions it isn't refused. Otherwise it reads the whole text.
|
|
145
|
+
const matched = command ? runs(cmd, new RegExp(pattern)) : new RegExp(pattern).test(cmd);
|
|
146
|
+
if (!matched || (person && forPerson)) continue;
|
|
127
147
|
const hint =
|
|
128
148
|
person && actor.kind === 'lead'
|
|
129
149
|
? '\nTo let the lead run it when the person says so in chat, a person sets "approvals": { "lead": true } in the config.'
|
|
@@ -140,11 +160,20 @@ if ((delegatedMerge && /\s--person\b/.test(cmd)) || (approves && /\s--via\b/.tes
|
|
|
140
160
|
block(
|
|
141
161
|
`Blocked: merging or approving as the person is the person's own act, from the code-kit pane or their terminal. Ask them, and stop.\nCommand: ${cmd}`,
|
|
142
162
|
);
|
|
163
|
+
// Pausing and resuming the lead loop is the person's, from the band or their terminal.
|
|
164
|
+
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(pause|resume)\b`)))
|
|
165
|
+
block(
|
|
166
|
+
`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}`,
|
|
167
|
+
);
|
|
143
168
|
// The harness settings are the person's to change, from the pane or their terminal.
|
|
144
169
|
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+settings\s+set\b`)))
|
|
145
170
|
block(
|
|
146
171
|
`Blocked: changing the harness settings is the person's own act, from the code-kit pane (/harness) or their terminal. Tell them what you'd change and why, and stop.\nCommand: ${cmd}`,
|
|
147
172
|
);
|
|
173
|
+
if (queues && actor.kind !== 'lead')
|
|
174
|
+
block(
|
|
175
|
+
`Blocked: only the lead puts branches in the merge queue, after its review passes them. Finish your story and commit it on your branch; the lead reviews it.\nCommand: ${cmd}`,
|
|
176
|
+
);
|
|
148
177
|
if (delegatedMerge && actor.kind !== 'lead')
|
|
149
178
|
block(
|
|
150
179
|
`Blocked: only the lead merges into a protected branch. Finish your story and commit it on your branch; the lead reviews and merges it.\nCommand: ${cmd}`,
|
|
@@ -218,21 +247,30 @@ try {
|
|
|
218
247
|
} catch {
|
|
219
248
|
// not a git repository yet
|
|
220
249
|
}
|
|
221
|
-
|
|
250
|
+
// SETUP-1: the one exception is setting up. Until the protected branch has the rules committed, the
|
|
251
|
+
// lead commits the setup the person approved straight onto it, so no first merge needs anyone; from
|
|
252
|
+
// that commit on, the branch is protected by the rules it now holds.
|
|
253
|
+
const settingUp =
|
|
254
|
+
actor.kind === 'lead' &&
|
|
255
|
+
runs(cmd, new RegExp(String.raw`^${GIT}commit\b`)) &&
|
|
256
|
+
!runs(cmd, new RegExp(String.raw`^${GIT}(merge|cherry-pick|rebase|revert|am)\b`)) &&
|
|
257
|
+
!committedOnHead(root, CONFIG_FILE);
|
|
258
|
+
if (writesHistory && config.branches.protected.includes(branch) && !settingUp) {
|
|
222
259
|
block(
|
|
223
|
-
`Blocked: never commit or merge on ${branch}. Work on a branch;
|
|
260
|
+
`Blocked: never commit or merge on ${branch}. Work on a branch; the lead merges it after review (or the person, where merges aren't delegated).`,
|
|
224
261
|
);
|
|
225
262
|
}
|
|
226
263
|
|
|
227
|
-
// Kit files edited through the shell follow the same rule as edits through Write/Edit
|
|
228
|
-
|
|
229
|
-
|
|
264
|
+
// Kit files edited through the shell follow the same rule as edits through Write/Edit: a command that
|
|
265
|
+
// names a kit file and writes, judged one simple command at a time, so a redirect elsewhere in the line
|
|
266
|
+
// (`npm install > /tmp/log; ls .claude/skills`) isn't taken for a write to the kit. A write the shell
|
|
267
|
+
// hides (a script, a variable) is still caught after the call, as a change outside the actor's paths.
|
|
268
|
+
const KIT_PATH = /\.claude\/(hooks|agents|skills|settings[^/]*\.json|README\.md|code-kit\.json)/;
|
|
230
269
|
// a real redirect into a file; `2>&1` and `>/dev/null` are not writes
|
|
231
|
-
const
|
|
232
|
-
/((?<![0-9&])>>?\s*(?!&|\/dev\/null)\S|\btee\b|\bsed\s+-i|\bperl\s+-i|\bmv\b|\bcp\b|\brm\b|\bpython3?\b|\bnode\s+-e|\btruncate\b|\bchmod\b|\bln\b)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (touchesKit && writes && actor.kind !== 'lead' && !approval(root, 'kit', actor)) {
|
|
270
|
+
const WRITES =
|
|
271
|
+
/((?<![0-9&])>>?\s*(?!&|\/dev\/null)\S|\btee\b|\bsed\s+-i|\bperl\s+-i|\bmv\b|\bcp\b|\brm\b|\bpython3?\b|\bnode\s+-e|\btruncate\b|\bchmod\b|\bln\b)/;
|
|
272
|
+
const kitWrite = commandsOf(cmd).some((c) => KIT_PATH.test(c.text) && WRITES.test(c.text));
|
|
273
|
+
if (kitWrite && actor.kind !== 'lead' && !approval(root, 'kit', actor)) {
|
|
236
274
|
recordRequest(
|
|
237
275
|
root,
|
|
238
276
|
actor,
|
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))
|
|
@@ -1,35 +1,58 @@
|
|
|
1
|
-
// Context Graph (ctx) keeps a project's engineering context in `.ctx/`:
|
|
2
|
-
//
|
|
3
|
-
// each file's card (its why) in cards.ctx.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// lead act on what the person says in chat (`approvals.lead`). A
|
|
8
|
-
// `Ctx-Ratified-By: <ratifier> (delegated)`, is the lead's own act under
|
|
9
|
-
// rules: only the lead may write that trailer
|
|
10
|
-
// Ratifying with `--commit`,
|
|
11
|
-
//
|
|
1
|
+
// Context Graph (ctx) keeps a project's engineering context in `.ctx/`: rules and concepts in graph.ctx
|
|
2
|
+
// (proposals in proposals.ctx), settings in config.toml, the decisions agents record as they work in
|
|
3
|
+
// decisions.ctx, and each file's card (its why) in cards.ctx. It runs in the background, so every agent
|
|
4
|
+
// writes its cards, its decisions and the guidance it finds; the settings are the lead's, and a change
|
|
5
|
+
// to them goes through the approval log. A rule that blocks edits, or a concept, needs a person's
|
|
6
|
+
// signature, a commit trailer, and Context Graph's own gate checks it, so no Claude actor may write that
|
|
7
|
+
// trailer, unless the project lets the lead act on what the person says in chat (`approvals.lead`). A
|
|
8
|
+
// delegated ratification, signed `Ctx-Ratified-By: <ratifier> (delegated)`, is the lead's own act under
|
|
9
|
+
// the repository's [delegate] rules: only the lead may write that trailer.
|
|
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',
|
|
20
41
|
detect: (root) => existsSync(join(root, '.ctx')),
|
|
21
42
|
config: {
|
|
22
43
|
lead: ['.ctx/**'],
|
|
23
|
-
|
|
44
|
+
// Context Graph runs in the background: every agent writes its cards and decisions, and the guidance
|
|
45
|
+
// rules it finds (which inform and never block). A rule that blocks edits, or a concept, still needs
|
|
46
|
+
// a person's signed commit, and Context Graph's own gate checks that on every pull request.
|
|
47
|
+
anyActor: ['.ctx/decisions.ctx', '.ctx/cards.ctx', '.ctx/graph.ctx', '.ctx/proposals.ctx'],
|
|
24
48
|
protected: [
|
|
49
|
+
// Its settings are the person's: a change is logged, or signed when they commit it themselves.
|
|
25
50
|
{
|
|
26
|
-
glob: '.ctx/
|
|
51
|
+
glob: '.ctx/config.toml',
|
|
27
52
|
approval: 'ctx',
|
|
28
|
-
why: "Context Graph's
|
|
29
|
-
|
|
30
|
-
signedBy: '^Ctx-Ratified-By:(?![^\\n]*\\(delegated\\))\\s*\\S',
|
|
53
|
+
why: "Context Graph's settings",
|
|
54
|
+
signedBy: PERSON_SIGNED,
|
|
31
55
|
},
|
|
32
|
-
{ glob: '.ctx/config.toml', approval: 'ctx', why: "Context Graph's settings" },
|
|
33
56
|
],
|
|
34
57
|
},
|
|
35
58
|
shell: {
|
|
@@ -41,7 +64,8 @@ export default {
|
|
|
41
64
|
},
|
|
42
65
|
{
|
|
43
66
|
pattern: PERSONS_ACT,
|
|
44
|
-
|
|
67
|
+
command: true,
|
|
68
|
+
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
69
|
},
|
|
46
70
|
],
|
|
47
71
|
restricted: [
|
|
@@ -52,6 +76,50 @@ export default {
|
|
|
52
76
|
},
|
|
53
77
|
],
|
|
54
78
|
},
|
|
79
|
+
// What Context Graph knows of the lanes' work (the combined lane view, docs/specs/13-combined.md).
|
|
80
|
+
facts: {
|
|
81
|
+
/** Whether ctx's command line runs here. */
|
|
82
|
+
available: (root) => ctx(root, ['agents', '--session', 'none']) !== null,
|
|
83
|
+
/**
|
|
84
|
+
* Per agent type in a session (null for the main session): the edits made without understanding,
|
|
85
|
+
* each with the files still unread, and the cards owed.
|
|
86
|
+
*/
|
|
87
|
+
lanes: (root, { session }) => {
|
|
88
|
+
const agents = session ? (ctx(root, ['agents', '--session', session]) ?? []) : [];
|
|
89
|
+
const byType = {};
|
|
90
|
+
for (const a of Array.isArray(agents) ? agents : []) {
|
|
91
|
+
const key = a.agentType ?? 'lead';
|
|
92
|
+
const f = (byType[key] ??= { withoutUnderstanding: [], cardsOwed: [] });
|
|
93
|
+
for (const e of a.edited ?? [])
|
|
94
|
+
if (!e.understood) f.withoutUnderstanding.push({ path: e.path, unread: e.missing ?? [] });
|
|
95
|
+
for (const c of a.cardsOwed ?? []) if (!f.cardsOwed.includes(c)) f.cardsOwed.push(c);
|
|
96
|
+
}
|
|
97
|
+
return byType;
|
|
98
|
+
},
|
|
99
|
+
/** Per file: the rules that apply to it, agreed and proposed, and its card: current, stale or missing, with its text. */
|
|
100
|
+
files: (root, paths) =>
|
|
101
|
+
Object.fromEntries(
|
|
102
|
+
paths.map((path) => {
|
|
103
|
+
const f = ctx(root, ['file', path]);
|
|
104
|
+
return [
|
|
105
|
+
path,
|
|
106
|
+
{
|
|
107
|
+
card: f?.card
|
|
108
|
+
? { state: f.card.fresh === false ? 'stale' : 'current', text: f.card.text ?? '' }
|
|
109
|
+
: { state: 'missing', text: '' },
|
|
110
|
+
rules: (f?.rules ?? []).map((r) => ({
|
|
111
|
+
id: r.id,
|
|
112
|
+
text: r.text,
|
|
113
|
+
mode: r.mode,
|
|
114
|
+
proposed: r.mode === 'G?',
|
|
115
|
+
})),
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
/** The command that opens Context Graph's pane on a file, given its path. */
|
|
121
|
+
open: { command: 'graph' },
|
|
122
|
+
},
|
|
55
123
|
setup: [
|
|
56
124
|
'.gitignore: .ctx/archive/, .ctx/bench/, .ctx/models/ and .ctx/serve.json (ctx working files, never committed)',
|
|
57
125
|
'.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,5 +1,6 @@
|
|
|
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';
|
|
4
5
|
import { harnessProblems, harnessSettings } from './harness.mjs';
|
|
5
6
|
import { join } from 'node:path';
|
|
@@ -29,6 +30,43 @@ export function loadConfig(projectDir) {
|
|
|
29
30
|
return { config: effectiveConfig(raw, projectDir) };
|
|
30
31
|
}
|
|
31
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
|
+
|
|
32
70
|
/** The rules the hooks enforce: the project's config, the kit's defaults, and any active adapters. */
|
|
33
71
|
export const effectiveConfig = (raw, root) => applyAdapters(withDefaults(raw), raw, root);
|
|
34
72
|
|
|
@@ -252,10 +290,13 @@ function validateLayers(c, need, each) {
|
|
|
252
290
|
function validateShell(s, lanes, need) {
|
|
253
291
|
if (!need(isObj(s), '"shell" must be an object')) return;
|
|
254
292
|
const rule = (r) => isObj(r) && isRegExp(r.pattern) && isStr(r.why);
|
|
255
|
-
const blockRule = (r) =>
|
|
293
|
+
const blockRule = (r) =>
|
|
294
|
+
rule(r) &&
|
|
295
|
+
[undefined, true, false].includes(r.person) &&
|
|
296
|
+
[undefined, true, false].includes(r.command);
|
|
256
297
|
need(
|
|
257
298
|
s.block === undefined || (Array.isArray(s.block) && s.block.every(blockRule)),
|
|
258
|
-
'"shell.block" must be a list of { pattern, why, person? }',
|
|
299
|
+
'"shell.block" must be a list of { pattern, why, person?, command? }',
|
|
259
300
|
);
|
|
260
301
|
const restricted =
|
|
261
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) {
|
package/hooks/lib/harness.mjs
CHANGED
|
@@ -15,9 +15,9 @@ export const SETTINGS = {
|
|
|
15
15
|
'hold.minutes': {
|
|
16
16
|
default: 2,
|
|
17
17
|
problem: (v) =>
|
|
18
|
-
Number.isFinite(v) && v >= 0 && v <=
|
|
18
|
+
Number.isFinite(v) && v >= 0 && v <= 10
|
|
19
19
|
? null
|
|
20
|
-
: 'must be a number of minutes from 0 (off) to
|
|
20
|
+
: 'must be a number of minutes from 0 (off) to 10',
|
|
21
21
|
about: 'How long a call waiting on your approval is held before it is refused',
|
|
22
22
|
},
|
|
23
23
|
'stall.nudgeMinutes': {
|