@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.
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +23 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/SECURITY.md +7 -0
- package/bin/code-kit.mjs +398 -0
- package/hooks/guard-bash.mjs +223 -0
- package/hooks/guard-paths.mjs +70 -0
- package/hooks/hooks.json +71 -0
- package/hooks/lane-audit.mjs +58 -0
- package/hooks/lib/adapters/context-graph.mjs +34 -0
- package/hooks/lib/adapters/index.mjs +34 -0
- package/hooks/lib/baseline.mjs +23 -0
- package/hooks/lib/checks.mjs +67 -0
- package/hooks/lib/config.mjs +211 -0
- package/hooks/lib/dependencies.mjs +129 -0
- package/hooks/lib/diff.mjs +122 -0
- package/hooks/lib/glob.mjs +42 -0
- package/hooks/lib/hook.mjs +19 -0
- package/hooks/lib/layers.mjs +57 -0
- package/hooks/lib/next.mjs +197 -0
- package/hooks/lib/plan.mjs +206 -0
- package/hooks/lib/rules.mjs +313 -0
- package/hooks/lib/trace.mjs +69 -0
- package/hooks/lib/verify.mjs +146 -0
- package/hooks/post-edit-check.mjs +40 -0
- package/hooks/stop-check.mjs +84 -0
- package/package.json +41 -0
- package/skills/check/SKILL.md +19 -0
- package/skills/dispatch/SKILL.md +83 -0
- package/skills/init/SKILL.md +151 -0
- package/skills/next/SKILL.md +52 -0
- package/skills/review/SKILL.md +57 -0
- package/skills/spec-check/SKILL.md +27 -0
- package/skills/spec-design/SKILL.md +137 -0
- package/skills/status/SKILL.md +42 -0
- package/templates/agent.md +29 -0
- package/templates/brief.md +45 -0
- package/templates/claude-md.md +23 -0
- package/templates/github-workflow.yml +23 -0
- package/templates/plan.md +30 -0
- package/templates/spec.md +52 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Who may write where, from the project's config. Shared by every hook.
|
|
2
|
+
//
|
|
3
|
+
// Actors:
|
|
4
|
+
// - a lane agent (its agent_type is a lane's "agent") writes only that lane's paths;
|
|
5
|
+
// - any other subagent (research, planning, review) is read-only;
|
|
6
|
+
// - the main session in a worktree with a .lane file acts as that lane (a person running a lane);
|
|
7
|
+
// - the main session otherwise is the lead, who writes the lead's paths and delegates the rest.
|
|
8
|
+
// Protected paths are recorded in the approval log whenever they are committed; only the lead may
|
|
9
|
+
// write them without a person's approval.
|
|
10
|
+
//
|
|
11
|
+
// Approvals live in the project's main checkout, so a lane working in its own worktree sees the ones a
|
|
12
|
+
// person gives at the lead's terminal. One for a lane (`<lane>/<name>`) serves that lane only; one
|
|
13
|
+
// without a lane serves every actor.
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
18
|
+
import { DEPENDENCY_APPROVAL, isDependencyFile } from './dependencies.mjs';
|
|
19
|
+
import { globToRegExp, matchesAny } from './glob.mjs';
|
|
20
|
+
|
|
21
|
+
export const APPROVAL_LOG = '.claude/approval-log.jsonl';
|
|
22
|
+
export const APPROVALS_DIR = '.claude/approvals';
|
|
23
|
+
export const APPROVAL_MINUTES = 60;
|
|
24
|
+
// Marks a worktree as a lane's, for a person running that lane; the lead creates it, and it is never work.
|
|
25
|
+
export const LANE_FILE = '.lane';
|
|
26
|
+
const NEVER_WRITABLE = [`${APPROVALS_DIR}/**`, APPROVAL_LOG];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The commands a person runs to approve `names` for `actor`, and what that allows. For a lane the
|
|
30
|
+
* approval is that lane's; the folder is created here so the command works as written.
|
|
31
|
+
*/
|
|
32
|
+
export function approvalHowTo(names, actor, root, allows = 'these edits') {
|
|
33
|
+
const dir = approvalsDir(root);
|
|
34
|
+
const scoped = actor?.kind === 'lane' ? join(dir, actor.lane) : dir;
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(scoped, { recursive: true });
|
|
37
|
+
} catch {
|
|
38
|
+
// the command then fails visibly for the person, which says the same thing
|
|
39
|
+
}
|
|
40
|
+
const shown = relativeTo(projectDir(), scoped);
|
|
41
|
+
const where = shown.startsWith('..') ? scoped : shown;
|
|
42
|
+
const lines = [names].flat().map((n) => ` ! echo "<what you are approving>" > ${where}/${n}`);
|
|
43
|
+
const whom = actor?.kind === 'lane' ? `the ${actor.lane} lane` : 'any agent';
|
|
44
|
+
return (
|
|
45
|
+
`${lines.join('\n')}\n` +
|
|
46
|
+
`(a person runs it; the reason is recorded in ${APPROVAL_LOG}, and it allows ${allows} for ${whom}, for ${APPROVAL_MINUTES} minutes)`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `path` with symlinks resolved, even if it doesn't exist yet (macOS /var → /private/var). */
|
|
51
|
+
export function realPath(path) {
|
|
52
|
+
let dir = resolve(path);
|
|
53
|
+
const rest = [];
|
|
54
|
+
while (!existsSync(dir)) {
|
|
55
|
+
rest.unshift(dir.slice(dirname(dir).length + 1));
|
|
56
|
+
dir = dirname(dir);
|
|
57
|
+
}
|
|
58
|
+
return join(realpathSync(dir), ...rest);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The session's project: the folder Claude Code was opened in. */
|
|
62
|
+
export const projectDir = () => realPath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
63
|
+
|
|
64
|
+
/** The git worktree that contains `path` (walking up to an existing directory), or null. */
|
|
65
|
+
export function worktreeRoot(path) {
|
|
66
|
+
let dir = resolve(path);
|
|
67
|
+
while (!existsSync(dir)) dir = dirname(dir);
|
|
68
|
+
if (statSync(dir).isFile()) dir = dirname(dir);
|
|
69
|
+
try {
|
|
70
|
+
const top = execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
73
|
+
});
|
|
74
|
+
return realPath(top.trim());
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The project's worktrees: the main checkout plus any `git worktree add` or agent worktrees. */
|
|
81
|
+
export function projectWorktrees(project = projectDir()) {
|
|
82
|
+
try {
|
|
83
|
+
return execFileSync('git', ['-C', project, 'worktree', 'list', '--porcelain'], {
|
|
84
|
+
encoding: 'utf8',
|
|
85
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
86
|
+
})
|
|
87
|
+
.split('\n')
|
|
88
|
+
.filter((l) => l.startsWith('worktree '))
|
|
89
|
+
.map((l) => realPath(l.slice('worktree '.length)));
|
|
90
|
+
} catch {
|
|
91
|
+
return [project];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The worktree of this project that the session's cwd is in, or null when it is elsewhere. */
|
|
96
|
+
export function sessionRoot(input, project = projectDir()) {
|
|
97
|
+
const root = worktreeRoot(input.cwd || project);
|
|
98
|
+
return root && projectWorktrees(project).includes(root) ? root : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The checkout whose changes are this actor's own. A subagent works where its cwd is (often its own
|
|
103
|
+
* worktree). The main session owns the checkout it was opened in, even when its shell has wandered
|
|
104
|
+
* into an agent's worktree, whose in-progress edits are that agent's, not the lead's.
|
|
105
|
+
*/
|
|
106
|
+
export function actorRoot(input, project = projectDir()) {
|
|
107
|
+
if (input.agent_type) return sessionRoot(input, project);
|
|
108
|
+
return worktreeRoot(project) ?? project;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const relativeTo = (root, path) =>
|
|
112
|
+
relative(realPath(root), realPath(resolve(root, path)))
|
|
113
|
+
.split('\\')
|
|
114
|
+
.join('/');
|
|
115
|
+
|
|
116
|
+
export function currentBranch(root) {
|
|
117
|
+
return execFileSync('git', ['-C', root, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
118
|
+
encoding: 'utf8',
|
|
119
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
120
|
+
}).trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Who is acting: { kind: 'lead' | 'lane' | 'readonly', lane?, label }. */
|
|
124
|
+
export function actorFor(input, root, config) {
|
|
125
|
+
const agent = input.agent_type;
|
|
126
|
+
if (agent) {
|
|
127
|
+
const lane = Object.keys(config.lanes).find((l) => config.lanes[l].agent === agent);
|
|
128
|
+
return lane
|
|
129
|
+
? { kind: 'lane', lane, label: `${agent} (${lane} lane)` }
|
|
130
|
+
: { kind: 'readonly', label: agent };
|
|
131
|
+
}
|
|
132
|
+
const laneFile = join(root, LANE_FILE);
|
|
133
|
+
if (existsSync(laneFile)) {
|
|
134
|
+
const lane = readFileSync(laneFile, 'utf8').trim();
|
|
135
|
+
return { kind: 'lane', lane, label: `${lane} lane worktree` };
|
|
136
|
+
}
|
|
137
|
+
return { kind: 'lead', label: 'lead session' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Where approvals live: the main checkout's approvals folder, shared by all of its worktrees. */
|
|
141
|
+
const approvalDirs = new Map();
|
|
142
|
+
export function approvalsDir(root) {
|
|
143
|
+
if (!approvalDirs.has(root))
|
|
144
|
+
approvalDirs.set(root, join(projectWorktrees(root)[0] ?? root, APPROVALS_DIR));
|
|
145
|
+
return approvalDirs.get(root);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function ownedPaths(actor, config) {
|
|
149
|
+
if (actor.kind === 'lead') return config.lead.paths;
|
|
150
|
+
if (actor.kind === 'lane') return config.lanes[actor.lane]?.paths ?? [];
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether `rel` is the actor's own to write: its paths, or the paths any actor may write. */
|
|
155
|
+
export function owns(actor, rel, config) {
|
|
156
|
+
if (actor.kind === 'readonly') return false;
|
|
157
|
+
if (matchesAny(rel, config.anyActor)) return true;
|
|
158
|
+
const excluded = actor.kind === 'lane' ? config.lanes[actor.lane]?.exclude : [];
|
|
159
|
+
return matchesAny(rel, ownedPaths(actor, config)) && !matchesAny(rel, excluded);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** An approval file's { reason, grantedAt } while fresh and explained, else null. */
|
|
163
|
+
function inForce(file) {
|
|
164
|
+
if (!existsSync(file) || !statSync(file).isFile()) return null;
|
|
165
|
+
const { mtimeMs } = statSync(file);
|
|
166
|
+
if (Date.now() - mtimeMs >= APPROVAL_MINUTES * 60_000) return null;
|
|
167
|
+
const reason = readFileSync(file, 'utf8').trim();
|
|
168
|
+
return reason ? { reason, grantedAt: new Date(mtimeMs).toISOString() } : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The approval in force for `name` and `actor` (the actor's lane's own, or one for everyone), or null. */
|
|
172
|
+
export function approval(root, name, actor) {
|
|
173
|
+
const dir = approvalsDir(root);
|
|
174
|
+
const lane = actor?.kind === 'lane' ? inForce(join(dir, actor.lane, name)) : null;
|
|
175
|
+
return lane ?? inForce(join(dir, name));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The dependency approvals in force for `actor`: [{ name, reason, grantedAt }]. */
|
|
179
|
+
export function dependencyGrants(root, actor) {
|
|
180
|
+
if (actor?.kind === 'readonly') return [];
|
|
181
|
+
const dir = approvalsDir(root);
|
|
182
|
+
const names = (d) => {
|
|
183
|
+
try {
|
|
184
|
+
return readdirSync(d).filter((n) => n.startsWith(DEPENDENCY_APPROVAL));
|
|
185
|
+
} catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const scoped = actor?.kind === 'lane' ? names(join(dir, actor.lane)) : [];
|
|
190
|
+
return [...new Set([...scoped, ...names(dir)])]
|
|
191
|
+
.map((name) => ({ name, ...approval(root, name, actor) }))
|
|
192
|
+
.filter((g) => g.reason);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Whether a dependency approval lets `actor` change `rel`: a manifest or lockfile no other lane owns,
|
|
197
|
+
* changed while the actor has a dependency approval in force. Installing a package rewrites them.
|
|
198
|
+
*/
|
|
199
|
+
export function dependencyChange(actor, rel, root, config) {
|
|
200
|
+
if (!isDependencyFile(rel) || actor.kind === 'readonly') return false;
|
|
201
|
+
const owner = laneOf(rel, config);
|
|
202
|
+
if (owner && !(actor.kind === 'lane' && owner === actor.lane)) return false;
|
|
203
|
+
return dependencyGrants(root, actor).length > 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The protected entry a repo-relative path falls under, or undefined. The log and state are not. */
|
|
207
|
+
export function protectedEntry(rel, config) {
|
|
208
|
+
if (rel === APPROVAL_LOG || matchesAny(rel, ['.claude/state/**'])) return undefined;
|
|
209
|
+
return config.protected.find((p) => globToRegExp(p.glob).test(rel));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const SECRETS = (rel) =>
|
|
213
|
+
/(^|\/)\.env(\.|$)/.test(rel) ||
|
|
214
|
+
/(^|\/)secrets?\//.test(rel) ||
|
|
215
|
+
/\.(pem|p8|p12|keystore|jks)$/.test(rel);
|
|
216
|
+
|
|
217
|
+
/** Why `actor` may not write `rel` (relative to worktree `root`), or null if allowed. */
|
|
218
|
+
export function writeProblem(actor, rel, root, config) {
|
|
219
|
+
const approvals = approvalsDir(root);
|
|
220
|
+
const target = realPath(resolve(root, rel));
|
|
221
|
+
if (target === approvals || target.startsWith(`${approvals}/`))
|
|
222
|
+
return `${rel} is an approval. Only a person creates approvals, with a \`!\` shell command.`;
|
|
223
|
+
if (rel.startsWith('..'))
|
|
224
|
+
return outsideAllowed(actor, rel, root, config) ? null : `${rel} is outside the repository.`;
|
|
225
|
+
if (SECRETS(rel))
|
|
226
|
+
return `${rel} may hold secrets. Secrets never go in files an agent writes; use the secret store.`;
|
|
227
|
+
if (rel === APPROVAL_LOG)
|
|
228
|
+
return `${rel} is the approval audit log. Only the commit hook appends to it; nobody edits it.`;
|
|
229
|
+
if (matchesAny(rel, NEVER_WRITABLE))
|
|
230
|
+
return `${rel} is an approval. Only a person creates approvals, with a \`!\` shell command.`;
|
|
231
|
+
// A package a person approved may change the manifests and lockfiles its install rewrites.
|
|
232
|
+
if (dependencyChange(actor, rel, root, config)) return null;
|
|
233
|
+
const guarded = protectedEntry(rel, config);
|
|
234
|
+
// The lead writes protected files without an approval: the person reviews them in the pull request,
|
|
235
|
+
// and every committed change is in the audit log. Anyone else needs a person's approval as well.
|
|
236
|
+
if (guarded && actor.kind !== 'lead' && !approval(root, guarded.approval, actor)) {
|
|
237
|
+
return `${rel} is ${guarded.why} and needs a person's approval. Ask the lead (a person) to run\n${approvalHowTo(guarded.approval, actor, root)}`;
|
|
238
|
+
}
|
|
239
|
+
if (actor.kind === 'readonly') return `${actor.label} is read-only; it may not write ${rel}.`;
|
|
240
|
+
if (matchesAny(rel, config.anyActor)) return null;
|
|
241
|
+
if (!owns(actor, rel, config)) return ownershipProblem(actor, rel, config);
|
|
242
|
+
const specs = actor.kind === 'lane' && config.docs?.specs;
|
|
243
|
+
return (specs && specCheckProblem(rel, root)) || screenProblem(rel, root, config);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function outsideAllowed(actor, rel, root, config) {
|
|
247
|
+
if (actor.kind !== 'lead') return false;
|
|
248
|
+
const target = resolve(root, rel);
|
|
249
|
+
return config.lead.outside.some((g) =>
|
|
250
|
+
globToRegExp(resolve(root, g.replace(/^~(?=\/)/, homedir()))).test(target),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function ownershipProblem(actor, rel, config) {
|
|
255
|
+
const owner = ownerOf(rel, config);
|
|
256
|
+
const where = owner ? ` (owned by the ${owner}).` : '.';
|
|
257
|
+
const next =
|
|
258
|
+
actor.kind === 'lead'
|
|
259
|
+
? `\nDelegate it: use the ${owner ?? 'matching'} agent, ideally in its own worktree.`
|
|
260
|
+
: `\nWrite a short change request for the lead instead, and stop.`;
|
|
261
|
+
return `The ${actor.label} may not write ${rel}${where}${next}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The lane whose paths hold `rel`, or null. */
|
|
265
|
+
export function laneOf(rel, config) {
|
|
266
|
+
return (
|
|
267
|
+
Object.entries(config.lanes).find(
|
|
268
|
+
([, { paths, exclude }]) => matchesAny(rel, paths) && !matchesAny(rel, exclude),
|
|
269
|
+
)?.[0] ?? null
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function ownerOf(rel, config) {
|
|
274
|
+
const lane = laneOf(rel, config);
|
|
275
|
+
if (lane) return `${lane} lane / ${config.lanes[lane].agent}`;
|
|
276
|
+
return matchesAny(rel, config.lead.paths) ? 'lead' : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Lane work starts with the spec-check skill: its report for the branch must exist first.
|
|
280
|
+
export function specCheckPath(root) {
|
|
281
|
+
return `.claude/state/spec-check/${currentBranch(root).replace(/[^\w.-]+/g, '_')}.md`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function specCheckProblem(rel, root) {
|
|
285
|
+
let report;
|
|
286
|
+
try {
|
|
287
|
+
report = specCheckPath(root);
|
|
288
|
+
} catch {
|
|
289
|
+
return null; // not a git worktree: nothing to key the report on
|
|
290
|
+
}
|
|
291
|
+
if (rel === report) return null;
|
|
292
|
+
const file = join(root, report);
|
|
293
|
+
if (existsSync(file) && /\|\s*Requirement\s*\|/i.test(readFileSync(file, 'utf8'))) return null;
|
|
294
|
+
return `Run the spec-check skill before writing code on this branch. Save its report (the requirement table, questions and recommendation) to ${report}, then continue.`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Screens are designed first: a screen file can be written once the register lists it.
|
|
298
|
+
export function screenProblem(rel, root, config) {
|
|
299
|
+
const gate = config.designGate;
|
|
300
|
+
if (!gate) return null;
|
|
301
|
+
const isScreen = gate.screens.some(
|
|
302
|
+
(s) => globToRegExp(s.glob).test(rel) && !(s.not && new RegExp(s.not).test(rel)),
|
|
303
|
+
);
|
|
304
|
+
if (!isScreen) return null;
|
|
305
|
+
let screens;
|
|
306
|
+
try {
|
|
307
|
+
screens = JSON.parse(readFileSync(join(root, gate.register), 'utf8')).screens ?? [];
|
|
308
|
+
} catch {
|
|
309
|
+
return `${gate.register} is missing or invalid, so no screen can be built yet.`;
|
|
310
|
+
}
|
|
311
|
+
if (screens.some((s) => s.file === rel)) return null;
|
|
312
|
+
return `${rel} is a screen, and its design is not approved yet.\n${gate.howTo}\nOnce a person approves it, the lead adds { "file": "${rel}", ... } to ${gate.register}.`;
|
|
313
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What a file is for, in the project's own terms: the requirements it delivers (from the stories that
|
|
2
|
+
// changed it), the lane that owns it, and its layer with what that layer may import. This is the contract
|
|
3
|
+
// other tools read (`code-kit trace <path> --json`); Context Graph's adapter uses it to anchor a file's
|
|
4
|
+
// card in the spec and to show the layer rule before an agent drafts an edit.
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { matchesAny } from './glob.mjs';
|
|
7
|
+
import { layerOf } from './layers.mjs';
|
|
8
|
+
import { readRequirements, readStories } from './plan.mjs';
|
|
9
|
+
import { ownerOf } from './rules.mjs';
|
|
10
|
+
|
|
11
|
+
const git = (root, ...args) => {
|
|
12
|
+
try {
|
|
13
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
16
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
17
|
+
});
|
|
18
|
+
} catch {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Story ids named by the commits that changed `rel`, and by the current branch if it is a story branch. */
|
|
24
|
+
export function storiesOf(root, rel) {
|
|
25
|
+
const ids = new Set();
|
|
26
|
+
for (const m of git(root, 'log', '--format=%B', '--', rel).matchAll(/\bST-\d+\b/g)) ids.add(m[0]);
|
|
27
|
+
const branch = git(root, 'rev-parse', '--abbrev-ref', 'HEAD').trim();
|
|
28
|
+
const onBranch = /^[\w-]+\/(st-\d+)$/i.exec(branch)?.[1];
|
|
29
|
+
if (onBranch) ids.add(onBranch.toUpperCase());
|
|
30
|
+
return [...ids];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** { path, owner, lane, layer, requirements: [{ id, title, spec, stories }] } for one repository path. */
|
|
34
|
+
export function traceFile(root, rel, config) {
|
|
35
|
+
const laneName = Object.keys(config.lanes).find((l) => {
|
|
36
|
+
const { paths, exclude } = config.lanes[l];
|
|
37
|
+
return matchesAny(rel, paths) && !matchesAny(rel, exclude);
|
|
38
|
+
});
|
|
39
|
+
const layer = layerOf(rel, config.layers);
|
|
40
|
+
const out = {
|
|
41
|
+
path: rel,
|
|
42
|
+
owner: ownerOf(rel, config),
|
|
43
|
+
lane: laneName ? { name: laneName, agent: config.lanes[laneName].agent } : null,
|
|
44
|
+
layer: layer
|
|
45
|
+
? { name: layer.name, mayImport: layer.mayImport, denyPackages: layer.denyPackages ?? [] }
|
|
46
|
+
: null,
|
|
47
|
+
requirements: [],
|
|
48
|
+
};
|
|
49
|
+
const { specs, plan } = config.docs ?? {};
|
|
50
|
+
if (!specs || !plan) return out;
|
|
51
|
+
const stories = readStories(root, plan);
|
|
52
|
+
const requirements = new Map(
|
|
53
|
+
readRequirements(root, specs)
|
|
54
|
+
.filter((r) => r.file !== plan && !r.removed)
|
|
55
|
+
.map((r) => [r.id, r]),
|
|
56
|
+
);
|
|
57
|
+
const byReq = new Map();
|
|
58
|
+
for (const id of storiesOf(root, rel)) {
|
|
59
|
+
const story = stories.find((s) => s.id === id);
|
|
60
|
+
for (const req of story?.requirements ?? []) {
|
|
61
|
+
const r = requirements.get(req);
|
|
62
|
+
if (!r) continue;
|
|
63
|
+
if (!byReq.has(req)) byReq.set(req, { id: req, title: r.title, spec: r.file, stories: [] });
|
|
64
|
+
byReq.get(req).stories.push(id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
out.requirements = [...byReq.values()];
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The rules for a branch's changes as a whole, for CI and the lead's review: the hooks only see work
|
|
2
|
+
// done in Claude Code sessions, so a person's commits or another tool would otherwise go unchecked.
|
|
3
|
+
// Everything is judged from git (base...HEAD), except the checks, which run in the checkout.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { BASELINE_FILE, loadBaseline, newProblems } from './baseline.mjs';
|
|
8
|
+
import { runChecks } from './checks.mjs';
|
|
9
|
+
import { CONFIG_FILE } from './config.mjs';
|
|
10
|
+
import { isDependencyFile } from './dependencies.mjs';
|
|
11
|
+
import { matchesAny } from './glob.mjs';
|
|
12
|
+
import { layerProblems } from './layers.mjs';
|
|
13
|
+
import { APPROVAL_LOG, SECRETS, laneOf, ownerOf, protectedEntry, screenProblem } from './rules.mjs';
|
|
14
|
+
|
|
15
|
+
const git = (root, ...args) =>
|
|
16
|
+
execFileSync('git', ['-C', root, ...args], {
|
|
17
|
+
encoding: 'utf8',
|
|
18
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
19
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** A file's content at `ref`, or null when it doesn't exist there. */
|
|
23
|
+
function showAt(root, ref, rel) {
|
|
24
|
+
try {
|
|
25
|
+
return git(root, 'show', `${ref}:${rel}`);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The lane a branch belongs to by its name (`<lane>/<story>`), or null. */
|
|
32
|
+
export function laneOfBranch(branch, config) {
|
|
33
|
+
const lane = branch?.split('/')[0];
|
|
34
|
+
return lane && config.lanes[lane] ? lane : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Files changed on this branch since it left `base`. */
|
|
38
|
+
export function branchChanges(root, base) {
|
|
39
|
+
return git(root, 'diff', '--name-only', '--no-renames', `${base}...HEAD`)
|
|
40
|
+
.split('\n')
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Entries appended to the approval log since commit `from`, plus problems if it was rewritten. */
|
|
45
|
+
function logEntries(root, base) {
|
|
46
|
+
const before = showAt(root, base, APPROVAL_LOG) ?? '';
|
|
47
|
+
const after = showAt(root, 'HEAD', APPROVAL_LOG) ?? '';
|
|
48
|
+
if (!after.startsWith(before))
|
|
49
|
+
return {
|
|
50
|
+
entries: [],
|
|
51
|
+
problems: [`${APPROVAL_LOG} was edited, not appended to; it is append-only.`],
|
|
52
|
+
};
|
|
53
|
+
const entries = [];
|
|
54
|
+
const problems = [];
|
|
55
|
+
for (const line of after.slice(before.length).split('\n').filter(Boolean)) {
|
|
56
|
+
try {
|
|
57
|
+
entries.push(JSON.parse(line));
|
|
58
|
+
} catch {
|
|
59
|
+
problems.push(`${APPROVAL_LOG} has an entry that isn't valid JSON: ${line.slice(0, 80)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { entries, problems };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every problem with the branch, grouped: { ownership, protected, layers, baseline, design, checks }.
|
|
67
|
+
* `lane` holds the branch to that lane's paths; `checks: false` leaves the project's checks to CI's
|
|
68
|
+
* own steps.
|
|
69
|
+
*/
|
|
70
|
+
export function verifyBranch({ root, base, config, lane = null, checks = true }) {
|
|
71
|
+
// Where the branch left the base: the base may have moved on since (new log entries, a smaller baseline).
|
|
72
|
+
const from = git(root, 'merge-base', base, 'HEAD').trim();
|
|
73
|
+
const changed = branchChanges(root, base);
|
|
74
|
+
const present = changed.filter((f) => existsSync(join(root, f)));
|
|
75
|
+
const found = { ownership: [], protected: [], layers: [], baseline: [], design: [], checks: [] };
|
|
76
|
+
const { entries, problems: logProblems } = logEntries(root, from);
|
|
77
|
+
found.protected.push(...logProblems);
|
|
78
|
+
const logged = new Map(entries.map((e) => [e.file, e]));
|
|
79
|
+
|
|
80
|
+
for (const f of changed) {
|
|
81
|
+
if (SECRETS(f))
|
|
82
|
+
found.ownership.push(`${f} may hold secrets; secrets never go in the repository.`);
|
|
83
|
+
if (f === APPROVAL_LOG) continue;
|
|
84
|
+
const guarded = protectedEntry(f, config);
|
|
85
|
+
if (guarded && !logged.has(f))
|
|
86
|
+
found.protected.push(
|
|
87
|
+
`${f} is ${guarded.why}, but no approval-log entry records this change. Protected files are committed through Claude Code with code-kit, which logs them.`,
|
|
88
|
+
);
|
|
89
|
+
if (matchesAny(f, config.anyActor)) continue;
|
|
90
|
+
if (lane) {
|
|
91
|
+
const { paths, exclude } = config.lanes[lane];
|
|
92
|
+
const own = matchesAny(f, paths) && !matchesAny(f, exclude);
|
|
93
|
+
const entry = logged.get(f);
|
|
94
|
+
// A manifest or lockfile no other lane owns, logged under a person's dependency approval.
|
|
95
|
+
const dependency =
|
|
96
|
+
isDependencyFile(f) &&
|
|
97
|
+
entry?.approval === 'dependency' &&
|
|
98
|
+
[null, lane].includes(laneOf(f, config));
|
|
99
|
+
const approved = (guarded && entry?.approval === guarded.approval) || dependency;
|
|
100
|
+
if (!own && !approved)
|
|
101
|
+
found.ownership.push(
|
|
102
|
+
`${f}: the ${lane} lane doesn't own it (owner: ${ownerOf(f, config) ?? 'nobody'}).`,
|
|
103
|
+
);
|
|
104
|
+
} else if (!ownerOf(f, config)) {
|
|
105
|
+
found.ownership.push(`${f}: nobody owns it, so nobody may change it.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const baseline = loadBaseline(root);
|
|
110
|
+
for (const f of present) {
|
|
111
|
+
found.layers.push(
|
|
112
|
+
...newProblems(f, layerProblems(f, readFileSync(join(root, f), 'utf8'), config), baseline),
|
|
113
|
+
);
|
|
114
|
+
const screen = screenProblem(f, root, config);
|
|
115
|
+
if (screen) found.design.push(screen.split('\n')[0]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The baseline only shrinks once the kit is adopted (the adopting change may record it).
|
|
119
|
+
if (showAt(root, from, CONFIG_FILE) !== null) {
|
|
120
|
+
let before = {};
|
|
121
|
+
try {
|
|
122
|
+
before = JSON.parse(showAt(root, from, BASELINE_FILE) ?? '{}').layers ?? {};
|
|
123
|
+
} catch {
|
|
124
|
+
// an unreadable baseline excused nothing
|
|
125
|
+
}
|
|
126
|
+
for (const [f, problems] of Object.entries(baseline.layers))
|
|
127
|
+
for (const p of problems)
|
|
128
|
+
if (!(before[f] ?? []).includes(p))
|
|
129
|
+
found.baseline.push(`${BASELINE_FILE} gained ${p}; it only shrinks.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (checks) {
|
|
133
|
+
const actor = lane
|
|
134
|
+
? { kind: 'lane', lane, label: `${lane} lane branch` }
|
|
135
|
+
: { kind: 'lead', label: 'lead' };
|
|
136
|
+
const failure = runChecks(config, changed, root, actor);
|
|
137
|
+
if (failure)
|
|
138
|
+
found.checks.push(
|
|
139
|
+
`${failure.title}\n${String(failure.output ?? '')
|
|
140
|
+
.split('\n')
|
|
141
|
+
.slice(-40)
|
|
142
|
+
.join('\n')}`.trim(),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return { changed, found };
|
|
146
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse (Edit|MultiEdit|Write): the layer rules for the edited file, then the project's own
|
|
3
|
+
// per-file checks (lint, format); failures are fed back to Claude.
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { matchesAny } from './lib/glob.mjs';
|
|
8
|
+
import { loadBaseline, newProblems } from './lib/baseline.mjs';
|
|
9
|
+
import { block, start } from './lib/hook.mjs';
|
|
10
|
+
import { layerProblems } from './lib/layers.mjs';
|
|
11
|
+
import { projectWorktrees, relativeTo, worktreeRoot } from './lib/rules.mjs';
|
|
12
|
+
|
|
13
|
+
const { input, project, config, error } = start();
|
|
14
|
+
const target = input.tool_input?.file_path;
|
|
15
|
+
if (error || !target) process.exit(0);
|
|
16
|
+
|
|
17
|
+
const found = worktreeRoot(target);
|
|
18
|
+
const root = found && projectWorktrees(project).includes(found) ? found : null;
|
|
19
|
+
if (!root) process.exit(0); // another repository, or Claude's memory: not this project's code
|
|
20
|
+
const rel = relativeTo(root, target);
|
|
21
|
+
if (!existsSync(join(root, rel))) process.exit(0);
|
|
22
|
+
|
|
23
|
+
// Violations recorded in the baseline (brownfield code as it was adopted) don't block; new ones do.
|
|
24
|
+
const layers = layerProblems(rel, readFileSync(join(root, rel), 'utf8'), config);
|
|
25
|
+
const problems = newProblems(rel, layers, loadBaseline(project));
|
|
26
|
+
const quoted = `'${rel.replace(/'/g, `'\\''`)}'`;
|
|
27
|
+
for (const check of config.postEdit) {
|
|
28
|
+
if (!matchesAny(rel, check.files) || matchesAny(rel, check.exclude)) continue;
|
|
29
|
+
const res = spawnSync(check.run.replaceAll('{file}', quoted), {
|
|
30
|
+
cwd: root,
|
|
31
|
+
shell: true,
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
timeout: 80_000,
|
|
34
|
+
});
|
|
35
|
+
if (res.status === 127) continue; // the tool isn't installed yet (fresh clone)
|
|
36
|
+
if (res.status !== 0)
|
|
37
|
+
problems.push(`${check.run.replaceAll('{file}', rel)}:\n${res.stdout}${res.stderr}`.trim());
|
|
38
|
+
}
|
|
39
|
+
if (problems.length) block(`Fix these before continuing:\n\n${problems.join('\n\n')}`);
|
|
40
|
+
process.exit(0);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Stop / SubagentStop: before an actor finishes, run the checks that prove the work rather than
|
|
3
|
+
// asking it to. Read-only agents are skipped. Order: ownership audit, layer rules on changed files,
|
|
4
|
+
// then each of the project's checks whose files changed; a lane finishes with its work committed.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { loadBaseline, newProblems } from './lib/baseline.mjs';
|
|
8
|
+
import { runChecks } from './lib/checks.mjs';
|
|
9
|
+
import { block, start } from './lib/hook.mjs';
|
|
10
|
+
import { layerProblems } from './lib/layers.mjs';
|
|
11
|
+
import { actorFor, actorRoot } from './lib/rules.mjs';
|
|
12
|
+
import { auditProblems, changedFiles } from './lane-audit.mjs';
|
|
13
|
+
|
|
14
|
+
const { input, project, config, error } = start();
|
|
15
|
+
|
|
16
|
+
// Stop hooks share one `stop_hook_active` flag, so another plugin's block would make this check skip
|
|
17
|
+
// itself. Instead this hook remembers its own last block per session and agent. The same problem
|
|
18
|
+
// reported MAX_REPEATS times in a row means no progress: say so and let the finish through rather than
|
|
19
|
+
// loop. A different problem means progress, and counts from one again.
|
|
20
|
+
const MAX_REPEATS = 3;
|
|
21
|
+
const memory = join(
|
|
22
|
+
project,
|
|
23
|
+
'.claude/state/stop-blocks',
|
|
24
|
+
`${input.session_id ?? 'session'}-${input.agent_id ?? 'main'}`.replace(/[^\w.-]+/g, '_'),
|
|
25
|
+
);
|
|
26
|
+
const lastBlock = () => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(memory, 'utf8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return { message: null, count: 0 };
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const passed = () => {
|
|
34
|
+
rmSync(memory, { force: true });
|
|
35
|
+
process.exit(0);
|
|
36
|
+
};
|
|
37
|
+
const blockCounted = (message) => {
|
|
38
|
+
const last = lastBlock();
|
|
39
|
+
const count = last.message === message ? last.count + 1 : 1;
|
|
40
|
+
if (count > MAX_REPEATS) passed();
|
|
41
|
+
mkdirSync(join(memory, '..'), { recursive: true });
|
|
42
|
+
writeFileSync(memory, JSON.stringify({ message, count }));
|
|
43
|
+
const final =
|
|
44
|
+
count === MAX_REPEATS
|
|
45
|
+
? `\n\n(code-kit has reported this ${count} times without progress; it won't block on it again. Tell the lead what is still failing.)`
|
|
46
|
+
: '';
|
|
47
|
+
block(`${message}${final}`);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (error) blockCounted(error);
|
|
51
|
+
|
|
52
|
+
const root = actorRoot(input, project);
|
|
53
|
+
if (!root) passed();
|
|
54
|
+
const actor = actorFor(input, root, config);
|
|
55
|
+
if (actor.kind === 'readonly') passed();
|
|
56
|
+
|
|
57
|
+
const fail = (title, out = '') =>
|
|
58
|
+
blockCounted(`${title}\n\n${String(out).split('\n').slice(-60).join('\n')}`.trim());
|
|
59
|
+
|
|
60
|
+
const audit = auditProblems({ ...input, cwd: root }, config);
|
|
61
|
+
if (audit.length)
|
|
62
|
+
fail('Uncommitted changes break the ownership rules; revert them:', audit.join('\n'));
|
|
63
|
+
|
|
64
|
+
const changed = changedFiles(root);
|
|
65
|
+
const baseline = loadBaseline(project);
|
|
66
|
+
const layers = changed
|
|
67
|
+
.filter((f) => existsSync(join(root, f)))
|
|
68
|
+
.flatMap((f) =>
|
|
69
|
+
newProblems(f, layerProblems(f, readFileSync(join(root, f), 'utf8'), config), baseline),
|
|
70
|
+
);
|
|
71
|
+
if (layers.length)
|
|
72
|
+
fail('Imports break the layer rules (.claude/code-kit.json › layers):', layers.join('\n'));
|
|
73
|
+
|
|
74
|
+
const failure = runChecks(config, changed, root, actor);
|
|
75
|
+
if (failure) fail(failure.title, failure.output);
|
|
76
|
+
|
|
77
|
+
// A lane hands back committed work on its own branch; the lead reviews and merges it.
|
|
78
|
+
if (actor.kind === 'lane' && changed.length) {
|
|
79
|
+
fail(
|
|
80
|
+
"Commit your work on your branch (stage only your lane's files; the message names the task, what changed and the proof), then finish:",
|
|
81
|
+
changed.join('\n'),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
passed();
|