@warren-dean/code-kit 0.3.4

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.
Files changed (42) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +23 -0
  3. package/LICENSE +21 -0
  4. package/README.md +369 -0
  5. package/SECURITY.md +7 -0
  6. package/bin/code-kit.mjs +398 -0
  7. package/hooks/guard-bash.mjs +223 -0
  8. package/hooks/guard-paths.mjs +70 -0
  9. package/hooks/hooks.json +71 -0
  10. package/hooks/lane-audit.mjs +58 -0
  11. package/hooks/lib/adapters/context-graph.mjs +34 -0
  12. package/hooks/lib/adapters/index.mjs +34 -0
  13. package/hooks/lib/baseline.mjs +23 -0
  14. package/hooks/lib/checks.mjs +67 -0
  15. package/hooks/lib/config.mjs +211 -0
  16. package/hooks/lib/dependencies.mjs +129 -0
  17. package/hooks/lib/diff.mjs +122 -0
  18. package/hooks/lib/glob.mjs +42 -0
  19. package/hooks/lib/hook.mjs +19 -0
  20. package/hooks/lib/layers.mjs +57 -0
  21. package/hooks/lib/next.mjs +197 -0
  22. package/hooks/lib/plan.mjs +206 -0
  23. package/hooks/lib/rules.mjs +313 -0
  24. package/hooks/lib/trace.mjs +69 -0
  25. package/hooks/lib/verify.mjs +146 -0
  26. package/hooks/post-edit-check.mjs +40 -0
  27. package/hooks/stop-check.mjs +84 -0
  28. package/package.json +41 -0
  29. package/skills/check/SKILL.md +19 -0
  30. package/skills/dispatch/SKILL.md +83 -0
  31. package/skills/init/SKILL.md +151 -0
  32. package/skills/next/SKILL.md +52 -0
  33. package/skills/review/SKILL.md +57 -0
  34. package/skills/spec-check/SKILL.md +27 -0
  35. package/skills/spec-design/SKILL.md +137 -0
  36. package/skills/status/SKILL.md +42 -0
  37. package/templates/agent.md +29 -0
  38. package/templates/brief.md +45 -0
  39. package/templates/claude-md.md +23 -0
  40. package/templates/github-workflow.yml +23 -0
  41. package/templates/plan.md +30 -0
  42. package/templates/spec.md +52 -0
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env node
2
+ // Run from the project root: node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs" <command>
3
+ // check validate the config and summarise it
4
+ // who <path>... who may write each path, and its layer
5
+ // unowned tracked files nobody may write
6
+ // diff what a draft changes against the live config, and whose files move
7
+ // baseline [--write] layer violations in the code as it is (brownfield adoption); --write records them
8
+ // graph [--depth N] which folders import which (to find the layers in existing code)
9
+ // verify [--base ref] [--branch name] [--no-checks]
10
+ // the rules for everything this branch changed since base (for CI and review)
11
+ // status [--base ref] [--json]
12
+ // each story and requirement in the plan: built, in review, ready, blocked, tested
13
+ // adapters other tools detected in the project, what each adds to the rules, and its setup
14
+ // trace <path> [--json] what a file is for: the requirements it delivers, its lane, its layer rules
15
+ // next [--base ref] [--json]
16
+ // the step to take now (spec-design, init, dispatch, review…), from the project's state
17
+ // --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
18
+ import { execFileSync } from 'node:child_process';
19
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { posix, resolve } from 'node:path';
21
+ import { BASELINE_FILE, loadBaseline } from '../hooks/lib/baseline.mjs';
22
+ import { CONFIG_FILE, effectiveConfig, validate } from '../hooks/lib/config.mjs';
23
+ import { diffConfigs, ownershipMoves } from '../hooks/lib/diff.mjs';
24
+ import { CODE, importsOf, layerOf, layerProblems, targetOf } from '../hooks/lib/layers.mjs';
25
+ import { ADAPTERS, activeAdapters } from '../hooks/lib/adapters/index.mjs';
26
+ import { nextStep } from '../hooks/lib/next.mjs';
27
+ import { traceFile } from '../hooks/lib/trace.mjs';
28
+ import { buildStatus } from '../hooks/lib/plan.mjs';
29
+ import { currentBranch, ownerOf } from '../hooks/lib/rules.mjs';
30
+ import { laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
31
+
32
+ const out = (s) => process.stdout.write(`${s}\n`);
33
+ const die = (s) => {
34
+ process.stderr.write(`${s}\n`);
35
+ process.exit(1);
36
+ };
37
+
38
+ const args = process.argv.slice(2);
39
+ const option = (name) => {
40
+ const at = args.indexOf(name);
41
+ return at >= 0 ? args.splice(at, 2)[1] : undefined;
42
+ };
43
+ const file = option('--config') ?? CONFIG_FILE;
44
+ const depth = Number(option('--depth') ?? 2);
45
+ const baseRef = option('--base');
46
+ const branchName = option('--branch');
47
+ const FLAGS = ['--write', '--no-checks', '--json'];
48
+ const flag = (name) => args.includes(name);
49
+ const writeBaseline = flag('--write');
50
+ const [command, ...rest] = args.filter((a) => !FLAGS.includes(a));
51
+
52
+ const tracked = () =>
53
+ execFileSync('git', ['ls-files'], { encoding: 'utf8' }).split('\n').filter(Boolean);
54
+ const read = (path) => readFileSync(resolve(path), 'utf8');
55
+
56
+ function load(path = file) {
57
+ let raw;
58
+ try {
59
+ raw = JSON.parse(read(path));
60
+ } catch (e) {
61
+ die(`${path}: ${e.message}`);
62
+ }
63
+ const problems = validate(raw);
64
+ if (problems.length) die(`${path} is invalid:\n ${problems.join('\n ')}`);
65
+ return { raw, config: effectiveConfig(raw, '.') };
66
+ }
67
+
68
+ function check() {
69
+ const { raw } = load();
70
+ out(`${file} is valid.`);
71
+ out(`Lead: ${raw.lead.paths.join(', ')}`);
72
+ for (const [name, lane] of Object.entries(raw.lanes))
73
+ out(`Lane ${name} (${lane.agent}): ${lane.paths.join(', ')}`);
74
+ for (const l of raw.layers ?? []) out(`Layer ${l.name} → ${l.mayImport.join(', ') || 'nothing'}`);
75
+ for (const c of raw.checks ?? []) out(`Check ${c.name}: ${c.run.join(' && ')}`);
76
+ if (!raw.docs?.specs) out('No docs.specs: lanes are not held to a spec-check report.');
77
+ for (const name of load().config.adapters) out(`Adapter ${name}: active (see \`adapters\`)`);
78
+ }
79
+
80
+ function who(paths) {
81
+ const { config } = load();
82
+ for (const p of paths) {
83
+ const layer = layerOf(p, config.layers);
84
+ out(`${p}: ${ownerOf(p, config) ?? 'nobody'}${layer ? ` · layer ${layer.name}` : ''}`);
85
+ }
86
+ }
87
+
88
+ function unowned() {
89
+ const { config } = load();
90
+ const files = tracked();
91
+ const nobody = files.filter((p) => !ownerOf(p, config) && !p.startsWith('.claude/'));
92
+ nobody.forEach(out);
93
+ out(`${nobody.length} of ${files.length} tracked files have no owner.`);
94
+ }
95
+
96
+ function diff() {
97
+ if (file === CONFIG_FILE)
98
+ die('Pass the draft to compare: diff --config .claude/code-kit.draft.json');
99
+ const live = load(CONFIG_FILE);
100
+ const draft = load(file);
101
+ const lines = diffConfigs(live.raw, draft.raw);
102
+ const moves = ownershipMoves(tracked(), live.config, draft.config);
103
+ if (lines.length === 0 && moves.length === 0) return out('No changes.');
104
+ lines.forEach(out);
105
+ const byRoute = Map.groupBy(moves, (m) => `${m.from} → ${m.to}`);
106
+ for (const [route, files] of byRoute) {
107
+ out(
108
+ `Owner ${route}: ${files.length} file(s), e.g. ${files
109
+ .slice(0, 3)
110
+ .map((m) => m.file)
111
+ .join(', ')}`,
112
+ );
113
+ }
114
+ }
115
+
116
+ /** Every layer violation in tracked code, by file. */
117
+ function violations(config) {
118
+ const found = {};
119
+ for (const rel of tracked().filter((f) => CODE.test(f) && existsSync(f))) {
120
+ const problems = layerProblems(rel, read(rel), config);
121
+ if (problems.length) found[rel] = problems;
122
+ }
123
+ return found;
124
+ }
125
+
126
+ /** What to record: everything at adoption; afterwards only what was already recorded (it only shrinks). */
127
+ function toRecord(found) {
128
+ if (!existsSync(BASELINE_FILE)) return { keep: found, refused: [] };
129
+ const before = loadBaseline('.').layers;
130
+ const keep = {};
131
+ const refused = [];
132
+ for (const [rel, problems] of Object.entries(found)) {
133
+ const known = problems.filter((p) => (before[rel] ?? []).includes(p));
134
+ if (known.length) keep[rel] = known;
135
+ refused.push(...problems.filter((p) => !known.includes(p)));
136
+ }
137
+ return { keep, refused };
138
+ }
139
+
140
+ function baseline() {
141
+ const { config } = load();
142
+ const found = violations(config);
143
+ const count = Object.values(found).flat().length;
144
+ Object.values(found).flat().forEach(out);
145
+ out(`${count} violation(s) in ${Object.keys(found).length} file(s).`);
146
+ if (!writeBaseline) return;
147
+ const { keep, refused } = toRecord(found);
148
+ const body = { recordedAt: new Date().toISOString(), layers: keep };
149
+ writeFileSync(BASELINE_FILE, `${JSON.stringify(body, null, 2)}\n`);
150
+ out(
151
+ `Recorded ${Object.values(keep).flat().length} in ${BASELINE_FILE}: these don't block; new ones do.`,
152
+ );
153
+ if (refused.length) {
154
+ die(`Not recorded (the baseline only shrinks); fix these instead:\n ${refused.join('\n ')}`);
155
+ }
156
+ }
157
+
158
+ /** Workspace packages by name (package.json "name" → its folder), plus the config's aliases. */
159
+ function aliases() {
160
+ const found = {};
161
+ for (const p of tracked().filter((f) => f.endsWith('/package.json'))) {
162
+ try {
163
+ const name = JSON.parse(read(p)).name;
164
+ if (name) found[name] = posix.dirname(p);
165
+ } catch {
166
+ // not a readable manifest
167
+ }
168
+ }
169
+ const configured = existsSync(file) ? (JSON.parse(read(file)).importAliases ?? {}) : {};
170
+ return { ...found, ...configured };
171
+ }
172
+
173
+ function graph() {
174
+ const known = aliases();
175
+ const files = tracked();
176
+ // An import names a file without its extension, or a folder (its index): group files by their folder.
177
+ const stems = new Set(files.map((f) => f.replace(/\.[^./]+$/, '')));
178
+ const folderOf = (p) => (stems.has(p) || files.includes(p) ? posix.dirname(p) : p);
179
+ const folder = (p) => folderOf(p).split('/').slice(0, depth).join('/');
180
+ const edges = new Map();
181
+ for (const rel of files.filter((f) => CODE.test(f) && existsSync(f))) {
182
+ for (const spec of importsOf(read(rel))) {
183
+ const target = targetOf(spec, rel, known);
184
+ const real =
185
+ target &&
186
+ (stems.has(target) || files.some((f) => f === target || f.startsWith(`${target}/`)));
187
+ if (!real) continue; // a package, or a path that doesn't exist (e.g. text inside a test fixture)
188
+ const edge = `${folder(rel)} → ${folder(target)}`;
189
+ if (folder(rel) !== folder(target)) edges.set(edge, (edges.get(edge) ?? 0) + 1);
190
+ }
191
+ }
192
+ [...edges]
193
+ .sort((a, b) => b[1] - a[1])
194
+ .forEach(([edge, n]) => out(`${String(n).padStart(5)} ${edge}`));
195
+ out(`${edges.size} folder-to-folder dependencies at depth ${depth}.`);
196
+ }
197
+
198
+ const refExists = (ref) => {
199
+ try {
200
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', ref], { stdio: 'ignore' });
201
+ return true;
202
+ } catch {
203
+ return false;
204
+ }
205
+ };
206
+
207
+ /** The branch work merges into: --base, else the first protected branch (on origin first when `remote`); null if none exists. */
208
+ function findBase(config, remote) {
209
+ if (baseRef) return refExists(baseRef) ? baseRef : die(`--base ${baseRef} is not a known ref.`);
210
+ const name = config.branches.protected[0];
211
+ const order = remote ? [`origin/${name}`, name] : [name, `origin/${name}`];
212
+ return order.find(refExists) ?? null;
213
+ }
214
+
215
+ function base(config, remote) {
216
+ const name = config.branches.protected[0];
217
+ return (
218
+ findBase(config, remote) ?? die(`Neither ${name} nor origin/${name} exists; pass --base <ref>.`)
219
+ );
220
+ }
221
+
222
+ const VERIFY_GROUPS = {
223
+ ownership: 'Ownership',
224
+ protected: 'Protected paths and the approval log',
225
+ layers: 'Layer rules',
226
+ baseline: 'Baseline',
227
+ design: 'Designs before screens',
228
+ checks: 'Checks',
229
+ };
230
+
231
+ function verify() {
232
+ const { config } = load();
233
+ const from = base(config, true);
234
+ let branch = branchName;
235
+ if (!branch) {
236
+ try {
237
+ branch = currentBranch('.');
238
+ } catch {
239
+ branch = null;
240
+ }
241
+ }
242
+ const lane = laneOfBranch(branch, config);
243
+ const { changed, found } = verifyBranch({
244
+ root: resolve('.'),
245
+ base: from,
246
+ config,
247
+ lane,
248
+ checks: !flag('--no-checks'),
249
+ });
250
+ out(
251
+ `${changed.length} file(s) changed since ${from}${lane ? `, held to the ${lane} lane (branch ${branch})` : ''}.`,
252
+ );
253
+ let count = 0;
254
+ for (const [key, title] of Object.entries(VERIFY_GROUPS)) {
255
+ if (!found[key].length) continue;
256
+ count += found[key].length;
257
+ out(`\n${title}:`);
258
+ for (const p of found[key]) out(` ${p.split('\n').join('\n ')}`);
259
+ }
260
+ if (count) die(`\n${count} problem(s). Fix them on this branch.`);
261
+ out('No problems.');
262
+ }
263
+
264
+ const pad = (s, n) => String(s).padEnd(n);
265
+
266
+ function status() {
267
+ const { config } = load();
268
+ const { specs, plan } = config.docs ?? {};
269
+ if (!specs || !plan)
270
+ die('status needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
271
+ const from = base(config, false);
272
+ const {
273
+ stories,
274
+ requirements: byRequirement,
275
+ problems,
276
+ drift,
277
+ } = buildStatus('.', config, from, tracked());
278
+
279
+ if (flag('--json')) {
280
+ out(
281
+ JSON.stringify(
282
+ { base: from, stories, requirements: byRequirement, problems, drift },
283
+ null,
284
+ 2,
285
+ ),
286
+ );
287
+ return;
288
+ }
289
+ out(`Stories (against ${from}):`);
290
+ for (const s of stories) {
291
+ const detail =
292
+ s.state === 'blocked'
293
+ ? `waits on ${s.waitingOn.join(', ')}`
294
+ : s.state === 'review'
295
+ ? `${s.ahead} commit(s) on ${s.branch}${s.worktree ? ` · ${s.worktree}` : ''}`
296
+ : s.branchExists
297
+ ? s.branch
298
+ : '';
299
+ out(
300
+ ` ${pad(s.id, 7)} ${pad(s.state, 12)} ${pad(s.lane || '-', 10)} ${pad(s.requirements.join(', '), 24)} ${detail}`.trimEnd(),
301
+ );
302
+ }
303
+ out('\nRequirements:');
304
+ for (const r of byRequirement) {
305
+ const tests = r.tests.length ? `tested (${r.tests.join(', ')})` : 'no test names it';
306
+ out(
307
+ ` ${pad(r.id, 10)} ${pad(r.state, 12)} ${pad(r.stories.join(', ') || '-', 14)} ${r.removed ? '' : tests}`.trimEnd(),
308
+ );
309
+ }
310
+ const counts = Map.groupBy(byRequirement, (r) => r.state);
311
+ const live = byRequirement.filter((r) => !r.removed);
312
+ out(
313
+ `\n${live.length} requirement(s): ${[...counts]
314
+ .filter(([k]) => k !== 'removed')
315
+ .map(([k, v]) => `${v.length} ${k}`)
316
+ .join(', ')}; ${live.filter((r) => r.tests.length).length} named by a test.`,
317
+ );
318
+ const ready = stories.filter((s) => s.state === 'ready').map((s) => s.id);
319
+ if (ready.length) out(`Ready to dispatch: ${ready.join(', ')}`);
320
+ if (drift.length) {
321
+ out('\nThe plan is out of date:');
322
+ for (const d of drift) out(` ${d.story}: the plan says ${d.plan}, git says ${d.actual}`);
323
+ }
324
+ if (problems.length) {
325
+ out('\nGaps between the specs and the plan:');
326
+ problems.forEach((p) => out(` ${p}`));
327
+ }
328
+ }
329
+
330
+ function next() {
331
+ const step = nextStep(resolve('.'), (config) => findBase(config, false));
332
+ if (flag('--json')) return out(JSON.stringify(step, null, 2));
333
+ const command = (s, args) => `/code-kit:${s}${args ? ` ${args}` : ''}`;
334
+ const actionable = !['wait', 'done'].includes(step.step);
335
+ out(actionable ? `Next: ${command(step.step, step.args)}` : `Next: ${step.step}`);
336
+ out(`Why: ${step.why}`);
337
+ for (const t of step.then) out(`Then: ${command(t.step, t.args)}`);
338
+ for (const a of step.attention) out(`Note: ${a}`);
339
+ }
340
+
341
+ function adapters() {
342
+ const { raw } = load();
343
+ const active = activeAdapters(raw, '.').map((a) => a.name);
344
+ for (const a of ADAPTERS) {
345
+ const off = raw.adapters?.[a.name] === false;
346
+ const state = active.includes(a.name)
347
+ ? 'active'
348
+ : off
349
+ ? 'switched off in the config'
350
+ : 'not detected';
351
+ out(`${a.name}: ${state}`);
352
+ if (!active.includes(a.name)) continue;
353
+ const c = a.config ?? {};
354
+ if (c.lead?.length) out(` lead writes: ${c.lead.join(', ')}`);
355
+ if (c.anyActor?.length) out(` anyone writes: ${c.anyActor.join(', ')}`);
356
+ for (const p of c.protected ?? [])
357
+ out(` protected: ${p.glob} (approval "${p.approval}": ${p.why})`);
358
+ for (const b of a.shell?.block ?? []) out(` blocked command: /${b.pattern}/ — ${b.why}`);
359
+ for (const s of a.setup ?? []) out(` setup: ${s}`);
360
+ }
361
+ }
362
+
363
+ function trace(paths) {
364
+ const { config } = load();
365
+ const traces = paths.map((p) => traceFile(resolve('.'), p.replace(/^\.\//, ''), config));
366
+ if (flag('--json')) return out(JSON.stringify(traces.length === 1 ? traces[0] : traces, null, 2));
367
+ for (const t of traces) {
368
+ out(t.path);
369
+ out(
370
+ ` owner: ${t.owner ?? 'nobody'}${t.layer ? ` · layer ${t.layer.name} → ${t.layer.mayImport.join(', ') || 'only itself'}` : ''}`,
371
+ );
372
+ if (!t.requirements.length) out(' no requirements traced (no commit names a story)');
373
+ for (const r of t.requirements)
374
+ out(` ${r.id} ${r.title} (${r.spec}; ${r.stories.join(', ')})`);
375
+ }
376
+ }
377
+
378
+ const commands = {
379
+ check,
380
+ trace: () => (rest.length ? trace(rest) : usage()),
381
+ adapters,
382
+ next,
383
+ verify,
384
+ status,
385
+ unowned,
386
+ diff,
387
+ baseline,
388
+ graph,
389
+ who: () => (rest.length ? who(rest) : usage()),
390
+ };
391
+ function usage() {
392
+ die(
393
+ 'Usage: code-kit check | who <path>... | unowned | diff | baseline [--write] | graph [--depth N]\n' +
394
+ ' | verify [--base ref] [--branch name] [--no-checks] | status [--base ref] [--json]\n' +
395
+ ' | next [--base ref] [--json] | adapters | trace <path>... [--json] [--config file]',
396
+ );
397
+ }
398
+ (commands[command] ?? usage)();
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse (Bash): blocks destructive and hook-skipping commands, work on protected branches, new
3
+ // dependencies a person hasn't approved, approval forgery, kit edits through the shell by anyone but
4
+ // the lead, the project's own blocked and lane-restricted commands; records protected files in the
5
+ // approval log on commit and scans commits for secrets. Shell writes are also checked afterwards by
6
+ // lane-audit.mjs.
7
+ import { appendFileSync } from 'node:fs';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { join, resolve } from 'node:path';
10
+ import { addedPackages, dependencyApproval } from './lib/dependencies.mjs';
11
+ import { block, start } from './lib/hook.mjs';
12
+ import {
13
+ APPROVAL_LOG,
14
+ actorFor,
15
+ approval,
16
+ approvalHowTo,
17
+ currentBranch,
18
+ dependencyChange,
19
+ dependencyGrants,
20
+ owns,
21
+ protectedEntry,
22
+ sessionRoot,
23
+ } from './lib/rules.mjs';
24
+
25
+ const { input, project, config, error } = start();
26
+ const cmd = String(input.tool_input?.command || '');
27
+
28
+ const ALWAYS = [
29
+ [/\bgit\s+push\b.*(--force\b|\s-f\b|--force-with-lease\b)/, 'Force-push is not allowed.'],
30
+ [/--no-verify\b/, 'Skipping git hooks is not allowed.'],
31
+ [
32
+ /\brm\s+-rf?\s+(\/|~|\$HOME|\.\.?)(\s|$)/,
33
+ 'Refusing a destructive delete of a root, home or parent directory.',
34
+ ],
35
+ [/\b(curl|wget)\b[^|]*\|\s*(sh|bash|zsh)\b/, 'Piping downloads into a shell is not allowed.'],
36
+ [/\.claude\/approvals/, 'Approvals are created only by a person, with a `!` shell command.'],
37
+ [
38
+ /approval-log\.jsonl/,
39
+ 'The approval audit log is appended only by the commit hook; nobody edits it.',
40
+ ],
41
+ ];
42
+ for (const [re, why] of ALWAYS) {
43
+ if (re.test(cmd)) block(`Blocked: ${why}\nCommand: ${cmd}`);
44
+ }
45
+ const packages = addedPackages(cmd);
46
+ if (error) {
47
+ if (/\bgit\s+commit\b/.test(cmd)) block(`Blocked: ${error}\nFix the config before committing.`);
48
+ if (packages.length)
49
+ block(`Blocked: ${error}\nNew dependencies wait until the config is valid.\nCommand: ${cmd}`);
50
+ process.exit(0);
51
+ }
52
+
53
+ const branches = config.branches.protected
54
+ .map((b) => b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
55
+ .join('|');
56
+ if (new RegExp(`\\bgit\\s+push\\b.*\\b(${branches})\\b`).test(cmd)) {
57
+ block(
58
+ `Blocked: push to your own branch; a person merges to ${config.branches.protected[0]} after review.\nCommand: ${cmd}`,
59
+ );
60
+ }
61
+ for (const { pattern, why } of config.shell.block) {
62
+ if (new RegExp(pattern).test(cmd)) block(`Blocked: ${why}\nCommand: ${cmd}`);
63
+ }
64
+
65
+ const sessionIn = sessionRoot(input, project);
66
+ const actor = actorFor(input, sessionIn ?? project, config);
67
+
68
+ // Each new package needs a person's approval, for this actor; it also covers the manifest and lockfile
69
+ // changes the install makes (lib/rules.mjs › dependencyChange).
70
+ if (packages.length) {
71
+ if (actor.kind === 'readonly')
72
+ block(`Blocked: ${actor.label} is read-only; it may not add dependencies.\nCommand: ${cmd}`);
73
+ const unapproved = packages.filter(
74
+ (p) => !approval(sessionIn ?? project, dependencyApproval(p), actor),
75
+ );
76
+ if (unapproved.length) {
77
+ const ask =
78
+ actor.kind === 'lead'
79
+ ? 'Ask the person to approve it by running'
80
+ : "Write a short change request for the lead (the package, why, and the lane's story), and stop. The lead asks the person to run";
81
+ block(
82
+ `Blocked: a new dependency (${unapproved.join(', ')}) needs a person's approval. ${ask}\n${approvalHowTo(
83
+ unapproved.map(dependencyApproval),
84
+ actor,
85
+ sessionIn ?? project,
86
+ 'installing it, and the manifest and lockfile changes that makes,',
87
+ )}\nCommand: ${cmd}`,
88
+ );
89
+ }
90
+ }
91
+ for (const { pattern, lanes, why } of config.shell.restricted) {
92
+ const allowed = actor.kind === 'lead' || (actor.kind === 'lane' && lanes.includes(actor.lane));
93
+ if (!allowed && new RegExp(pattern).test(cmd))
94
+ block(`Blocked: the ${actor.label} may not run this. ${why}\nCommand: ${cmd}`);
95
+ }
96
+
97
+ // Git commands run against the repository they name (`git -C dir`) or the one the shell is in. Another
98
+ // repository (not this project or one of its worktrees) follows its own rules.
99
+ const gitDir = cmd.match(/\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/)?.[1]?.replace(/^["']|["']$/g, '');
100
+ const root = gitDir
101
+ ? sessionRoot({ cwd: resolve(input.cwd || project, gitDir) }, project)
102
+ : sessionIn;
103
+ if (!root) process.exit(0);
104
+
105
+ let branch = '';
106
+ try {
107
+ branch = currentBranch(root);
108
+ } catch {
109
+ // not a git repository yet
110
+ }
111
+ if (
112
+ /\bgit\s+(commit|merge|cherry-pick|rebase|revert)\b/.test(cmd) &&
113
+ config.branches.protected.includes(branch)
114
+ ) {
115
+ block(
116
+ `Blocked: never commit or merge on ${branch}. Work on a branch; a person merges after review.`,
117
+ );
118
+ }
119
+
120
+ // Kit files edited through the shell follow the same rule as edits through Write/Edit.
121
+ const touchesKit =
122
+ /\.claude\/(hooks|agents|skills|settings[^/]*\.json|README\.md|code-kit\.json)/.test(cmd);
123
+ // a real redirect into a file; `2>&1` and `>/dev/null` are not writes
124
+ const writes =
125
+ /((?<![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)/.test(
126
+ cmd,
127
+ );
128
+ if (touchesKit && writes && actor.kind !== 'lead' && !approval(root, 'kit', actor)) {
129
+ block(
130
+ `Blocked: the .claude kit is changed only by the lead or with a person's approval. Ask the lead to run\n${approvalHowTo('kit', actor, root)}`,
131
+ );
132
+ }
133
+
134
+ /** Files this commit will include: staged now, plus what the same command stages first. */
135
+ function committedFiles() {
136
+ const git = (...args) =>
137
+ spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' }).stdout ?? '';
138
+ const names = (out) => out.split('\n').filter(Boolean);
139
+ const staged = names(git('diff', '--cached', '--name-only'));
140
+ // This hook runs before the command, so `git add … && git commit` and `git commit -a` have not staged yet.
141
+ const changed = names(git('status', '--porcelain', '-uall', '--no-renames')).map((l) =>
142
+ l.slice(3),
143
+ );
144
+ const commitAll = /\bgit\s+commit\b[^;&|]*\s(-a|--all|-[b-zB-Z]*a[a-zA-Z]*)(\s|$)/.test(cmd);
145
+ const addAll = /\bgit\s+add\b[^;&|]*\s(-A|--all|\.)(\s|$)/.test(cmd);
146
+ const addArgs = [...cmd.matchAll(/\bgit\s+add\s+([^;&|]+)/g)]
147
+ .flatMap((m) => m[1].trim().split(/\s+/))
148
+ .filter((a) => a && !a.startsWith('-'));
149
+ const added = changed.filter(
150
+ (f) =>
151
+ commitAll ||
152
+ addAll ||
153
+ addArgs.some((a) => f === a || f.startsWith(`${a.replace(/\/$/, '')}/`)),
154
+ );
155
+ return { files: [...new Set([...staged, ...added])], git };
156
+ }
157
+
158
+ // Every committed protected file is recorded in the audit log, with the person's reason when an
159
+ // approval is in force. So is a lane's manifest or lockfile change, outside its own paths, made under a
160
+ // dependency approval: that entry is how `code-kit verify` accepts it. The log is staged into the same
161
+ // commit, so record and change land together.
162
+ function recordApprovals() {
163
+ const { files, git } = committedFiles();
164
+ const lines = [];
165
+ const record = (file, fields) =>
166
+ lines.push(
167
+ JSON.stringify({
168
+ at: new Date().toISOString(),
169
+ ...fields,
170
+ file,
171
+ branch,
172
+ actor: actor.label,
173
+ session: input.session_id ?? null,
174
+ }),
175
+ );
176
+ for (const file of files) {
177
+ const entry = protectedEntry(file, config);
178
+ const granted = entry && approval(root, entry.approval, actor);
179
+ const byDependency = actor.kind === 'lane' && dependencyChange(actor, file, root, config);
180
+ if (entry && !granted && actor.kind !== 'lead' && !byDependency) {
181
+ block(
182
+ `Blocked: ${file} is ${entry.why}; committing it needs a person's approval in force. Ask the lead to run\n${approvalHowTo(entry.approval, actor, root)}`,
183
+ );
184
+ }
185
+ if (granted)
186
+ record(file, {
187
+ approval: entry.approval,
188
+ reason: granted.reason,
189
+ grantedAt: granted.grantedAt,
190
+ });
191
+ else if (byDependency && (entry || !owns(actor, file, config))) {
192
+ const grants = dependencyGrants(root, actor);
193
+ record(file, {
194
+ approval: 'dependency',
195
+ packages: grants.map((g) => g.name),
196
+ reason: grants.map((g) => g.reason).join('; '),
197
+ grantedAt: grants[0].grantedAt,
198
+ });
199
+ } else if (entry)
200
+ record(file, {
201
+ approval: 'lead',
202
+ reason: 'Lead change; reviewed in the pull request',
203
+ grantedAt: null,
204
+ });
205
+ }
206
+ if (lines.length === 0) return;
207
+ appendFileSync(join(root, APPROVAL_LOG), `${lines.join('\n')}\n`);
208
+ git('add', APPROVAL_LOG);
209
+ }
210
+
211
+ if (/\bgit\s+commit\b/.test(cmd)) {
212
+ recordApprovals();
213
+ const scan = spawnSync('gitleaks', ['protect', '--staged', '--redact', '--no-banner'], {
214
+ cwd: root,
215
+ encoding: 'utf8',
216
+ });
217
+ if (scan.error?.code !== 'ENOENT' && scan.status === 1) {
218
+ block(
219
+ `Blocked: gitleaks found possible secrets in staged changes.\n${scan.stdout}${scan.stderr}`,
220
+ );
221
+ }
222
+ }
223
+ process.exit(0);
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse (Edit|MultiEdit|Write|NotebookEdit): who may write where, secret files, approvals,
3
+ // spec-check before lane work, approved designs before screens (lib/rules.mjs), and the layer rules for
4
+ // the file as it would be after the edit, so a layer is never broken on disk even for a moment.
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { loadBaseline, newProblems } from './lib/baseline.mjs';
8
+ import { CONFIG_FILE } from './lib/config.mjs';
9
+ import { layerProblems } from './lib/layers.mjs';
10
+ import { block, start } from './lib/hook.mjs';
11
+ import {
12
+ actorFor,
13
+ projectWorktrees,
14
+ relativeTo,
15
+ worktreeRoot,
16
+ writeProblem,
17
+ } from './lib/rules.mjs';
18
+
19
+ const { input, project, config, error } = start();
20
+ const target = input.tool_input?.file_path || input.tool_input?.notebook_path;
21
+ if (!target) process.exit(0);
22
+
23
+ const found = worktreeRoot(target);
24
+ const root = found && projectWorktrees(project).includes(found) ? found : project;
25
+ const rel = relativeTo(root, target);
26
+
27
+ if (error) {
28
+ // The rules can't be read, so nothing is allowed except the lead repairing them.
29
+ if (rel === CONFIG_FILE && !input.agent_type) process.exit(0);
30
+ block(
31
+ `Blocked: ${error}\nThe lead fixes ${CONFIG_FILE} first; nothing else can be written until it is valid.`,
32
+ );
33
+ }
34
+
35
+ const problem = writeProblem(actorFor(input, root, config), rel, root, config);
36
+ if (problem) block(`Blocked: ${problem}`);
37
+
38
+ /** The file's text after this call, when the call carries enough to know it. */
39
+ function textAfter(before) {
40
+ const ti = input.tool_input ?? {};
41
+ const apply = (text, edits) =>
42
+ edits.reduce((t, e) => {
43
+ if (!e.old_string) return t;
44
+ return e.replace_all
45
+ ? t.split(e.old_string).join(e.new_string ?? '')
46
+ : t.replace(e.old_string, () => e.new_string ?? '');
47
+ }, text);
48
+ if (input.tool_name === 'Write') return typeof ti.content === 'string' ? ti.content : null;
49
+ if (before === null) return null;
50
+ if (input.tool_name === 'Edit') return apply(before, [ti]);
51
+ if (input.tool_name === 'MultiEdit') return apply(before, ti.edits ?? []);
52
+ return null;
53
+ }
54
+
55
+ // Only what this edit introduces is refused: a file that already breaks a rule (the baseline, or older
56
+ // code) can still be edited, as long as the edit adds no new violation.
57
+ const file = join(root, rel);
58
+ const before = existsSync(file) ? readFileSync(file, 'utf8') : null;
59
+ const after = textAfter(before);
60
+ if (after !== null) {
61
+ const existing = new Set(before === null ? [] : layerProblems(rel, before, config));
62
+ const added = newProblems(rel, layerProblems(rel, after, config), loadBaseline(project)).filter(
63
+ (p) => !existing.has(p),
64
+ );
65
+ if (added.length)
66
+ block(
67
+ `Blocked: this edit would break the layer rules (.claude/code-kit.json › layers):\n ${added.join('\n ')}\nDepend only on the layers listed, or ask the lead to change the rule.`,
68
+ );
69
+ }
70
+ process.exit(0);