@warren-dean/code-kit 0.4.1 → 0.5.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 +18 -0
- package/README.md +26 -25
- package/bin/code-kit.mjs +69 -14
- package/hooks/guard-bash.mjs +79 -29
- package/hooks/lane-audit.mjs +17 -6
- package/hooks/lib/config.mjs +30 -5
- package/hooks/lib/diff.mjs +5 -1
- package/hooks/lib/next.mjs +13 -2
- package/hooks/lib/plan.mjs +18 -7
- package/hooks/lib/registry.mjs +37 -3
- package/hooks/lib/reviews.mjs +39 -0
- package/hooks/lib/rules.mjs +66 -4
- package/hooks/lib/shell.mjs +94 -0
- package/hooks/lib/trace.mjs +4 -2
- package/hooks/lib/verify.mjs +74 -3
- package/hooks/mod/register.test.ts +5 -3
- package/hooks/mod/view.mjs +13 -4
- package/package.json +1 -1
- package/skills/init/SKILL.md +10 -2
- package/skills/review/SKILL.md +9 -1
- package/templates/agent.md +1 -1
package/hooks/lib/config.mjs
CHANGED
|
@@ -31,6 +31,9 @@ export function loadConfig(projectDir) {
|
|
|
31
31
|
/** The rules the hooks enforce: the project's config, the kit's defaults, and any active adapters. */
|
|
32
32
|
export const effectiveConfig = (raw, root) => applyAdapters(withDefaults(raw), raw, root);
|
|
33
33
|
|
|
34
|
+
/** An anyActor entry's glob: entries are globs, or { glob, reviewer } naming who reviews changes to it. */
|
|
35
|
+
export const anyActorGlob = (e) => (typeof e === 'string' ? e : e?.glob);
|
|
36
|
+
|
|
34
37
|
/** The engine's fixed rules, merged into the project's: the kit and Claude's own files belong to the lead. */
|
|
35
38
|
export function withDefaults(c) {
|
|
36
39
|
return {
|
|
@@ -39,7 +42,11 @@ export function withDefaults(c) {
|
|
|
39
42
|
paths: [...c.lead.paths, '.claude/**'],
|
|
40
43
|
outside: [...(c.lead.outside ?? []), ...CLAUDE_OWN],
|
|
41
44
|
},
|
|
42
|
-
anyActor: [...(c.anyActor ?? []), '.claude/state/**'],
|
|
45
|
+
anyActor: [...(c.anyActor ?? []).map(anyActorGlob), '.claude/state/**'],
|
|
46
|
+
// Who reviews changes to files any agent may write; the lead unless an entry names a lane.
|
|
47
|
+
anyActorReviewers: (c.anyActor ?? [])
|
|
48
|
+
.filter((e) => typeof e === 'object' && e?.reviewer)
|
|
49
|
+
.map((e) => ({ glob: e.glob, reviewer: e.reviewer })),
|
|
43
50
|
protected: [KIT, ...(c.protected ?? []).filter((p) => p.glob !== KIT.glob)],
|
|
44
51
|
layers: c.layers ?? [],
|
|
45
52
|
importAliases: c.importAliases ?? {},
|
|
@@ -71,7 +78,12 @@ function delegateRules(d) {
|
|
|
71
78
|
],
|
|
72
79
|
minWeeklyDownloads: d.dependencies.minWeeklyDownloads ?? 10_000,
|
|
73
80
|
maxMonthsSinceRelease: d.dependencies.maxMonthsSinceRelease ?? 18,
|
|
74
|
-
|
|
81
|
+
// true (any), false (none), or the kinds allowed: binary, build (lib/registry.mjs).
|
|
82
|
+
allowInstallScripts:
|
|
83
|
+
d.dependencies.allowInstallScripts === true ||
|
|
84
|
+
(Array.isArray(d.dependencies.allowInstallScripts)
|
|
85
|
+
? d.dependencies.allowInstallScripts
|
|
86
|
+
: false),
|
|
75
87
|
}
|
|
76
88
|
: null,
|
|
77
89
|
};
|
|
@@ -113,9 +125,20 @@ export function validate(c) {
|
|
|
113
125
|
c.lead?.outside === undefined || isList(c.lead.outside),
|
|
114
126
|
'"lead.outside" must be a list of globs',
|
|
115
127
|
);
|
|
116
|
-
need(c.anyActor === undefined || isList(c.anyActor), '"anyActor" must be a list of globs');
|
|
117
128
|
validateLanes(c, need);
|
|
118
129
|
const lanes = Object.keys(isObj(c.lanes) ? c.lanes : {});
|
|
130
|
+
need(
|
|
131
|
+
c.anyActor === undefined ||
|
|
132
|
+
(Array.isArray(c.anyActor) &&
|
|
133
|
+
c.anyActor.every(
|
|
134
|
+
(e) =>
|
|
135
|
+
isStr(e) ||
|
|
136
|
+
(isObj(e) &&
|
|
137
|
+
isStr(e.glob) &&
|
|
138
|
+
(e.reviewer === undefined || e.reviewer === 'lead' || lanes.includes(e.reviewer))),
|
|
139
|
+
)),
|
|
140
|
+
'"anyActor" must be a list of globs, or { "glob", "reviewer" } with the lead or a lane as reviewer',
|
|
141
|
+
);
|
|
119
142
|
each('protected', (e, at) => {
|
|
120
143
|
need(isStr(e.glob) && isStr(e.why), `${at} needs "glob" and "why"`);
|
|
121
144
|
need(
|
|
@@ -159,8 +182,10 @@ export function validate(c) {
|
|
|
159
182
|
(dep.licences === undefined || isList(dep.licences)) &&
|
|
160
183
|
isCount(dep.minWeeklyDownloads) &&
|
|
161
184
|
isCount(dep.maxMonthsSinceRelease) &&
|
|
162
|
-
[undefined, true, false].includes(dep.allowInstallScripts)
|
|
163
|
-
|
|
185
|
+
([undefined, true, false].includes(dep.allowInstallScripts) ||
|
|
186
|
+
(isList(dep.allowInstallScripts) &&
|
|
187
|
+
dep.allowInstallScripts.every((k) => ['binary', 'build', 'other'].includes(k))))))),
|
|
188
|
+
'"approvals.delegate" must be { protected?: [approval names], merge?: true|false, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts?: true|false|["binary","build","other"] } }',
|
|
164
189
|
);
|
|
165
190
|
need(
|
|
166
191
|
c.branches === undefined || isList(c.branches?.protected),
|
package/hooks/lib/diff.mjs
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// lanes, layers and rules added, removed or changed, and which files would change owner.
|
|
3
3
|
import { ownerOf } from './rules.mjs';
|
|
4
4
|
|
|
5
|
+
/** An anyActor entry in words: its glob, with its reviewer when named. */
|
|
6
|
+
const anyActorText = (e) =>
|
|
7
|
+
typeof e === 'string' ? e : `${e.glob} (reviewed by ${e.reviewer ?? 'the lead'})`;
|
|
8
|
+
|
|
5
9
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
6
10
|
const lists = (a = [], b = []) => ({
|
|
7
11
|
added: b.filter((x) => !a.includes(x)),
|
|
@@ -95,7 +99,7 @@ export function diffConfigs(before, after) {
|
|
|
95
99
|
for (const [label, a, b] of [
|
|
96
100
|
['lead paths', before.lead?.paths, after.lead?.paths],
|
|
97
101
|
['lead outside paths', before.lead?.outside, after.lead?.outside],
|
|
98
|
-
['any-actor paths', before.anyActor, after.anyActor],
|
|
102
|
+
['any-actor paths', before.anyActor?.map(anyActorText), after.anyActor?.map(anyActorText)],
|
|
99
103
|
['protected branches', before.branches?.protected, after.branches?.protected],
|
|
100
104
|
]) {
|
|
101
105
|
const change = listChange('', a, b);
|
package/hooks/lib/next.mjs
CHANGED
|
@@ -150,7 +150,9 @@ export function nextStep(root, base) {
|
|
|
150
150
|
);
|
|
151
151
|
|
|
152
152
|
const ids = (state) => status.stories.filter((s) => s.state === state).map((s) => s.id);
|
|
153
|
-
const [review, building, blocked] = ['review', 'in progress', 'blocked'].map(
|
|
153
|
+
const [review, building, blocked, fixes] = ['review', 'in progress', 'blocked', 'sent back'].map(
|
|
154
|
+
ids,
|
|
155
|
+
);
|
|
154
156
|
// A ready story with a gap (unknown lane, a requirement no spec defines) waits for the plan's fix.
|
|
155
157
|
const defined = new Set(status.requirements.map((r) => r.id));
|
|
156
158
|
const buildable = (s) =>
|
|
@@ -167,13 +169,22 @@ export function nextStep(root, base) {
|
|
|
167
169
|
`${unbuildable.join(', ')} can't be dispatched until the plan's gaps are fixed.`,
|
|
168
170
|
);
|
|
169
171
|
const then = [];
|
|
170
|
-
|
|
172
|
+
// A sent-back story goes back to its lane with the review's findings, before new work starts.
|
|
173
|
+
const toDispatch = [...fixes, ...ready];
|
|
174
|
+
if (review.length && toDispatch.length)
|
|
175
|
+
then.push({ step: 'dispatch', args: toDispatch.join(' ') });
|
|
171
176
|
if (forLead.length) then.push({ step: 'lead', args: forLead.join(' ') });
|
|
172
177
|
if (review.length)
|
|
173
178
|
return result('review', `${review.join(', ')} finished and wait for review.`, {
|
|
174
179
|
args: review.join(' '),
|
|
175
180
|
then,
|
|
176
181
|
});
|
|
182
|
+
if (fixes.length)
|
|
183
|
+
return result(
|
|
184
|
+
'dispatch',
|
|
185
|
+
`${fixes.join(', ')} ${fixes.length === 1 ? 'was' : 'were'} sent back and ${fixes.length === 1 ? 'waits' : 'wait'} for the lane's fix: dispatch ${fixes.length === 1 ? 'it' : 'each'} again with the review's findings${ready.length ? `, then ${ready.join(', ')}` : ''}.`,
|
|
186
|
+
{ args: toDispatch.join(' '), then },
|
|
187
|
+
);
|
|
177
188
|
if (ready.length)
|
|
178
189
|
return result('dispatch', `${ready.join(', ')} can start: their dependencies are done.`, {
|
|
179
190
|
args: ready.join(' '),
|
package/hooks/lib/plan.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
+
import { sentBack } from './reviews.mjs';
|
|
10
11
|
|
|
11
12
|
export const REQUIREMENT = /\b(?!ST-)([A-Z][A-Z0-9]*-\d+)\b/g;
|
|
12
13
|
const STORY_HEADING = /^###\s+(ST-\d+)\b\s*(.*)$/;
|
|
@@ -125,18 +126,22 @@ function worktrees(root) {
|
|
|
125
126
|
* Where each story is. The plan's `done` stands (the lead marks it after merging, and merged
|
|
126
127
|
* branches are often deleted or squashed). Otherwise git decides: a branch with commits not in
|
|
127
128
|
* `base` is in review; one level with it is in progress, or done once the plan had it in review.
|
|
128
|
-
*
|
|
129
|
-
*
|
|
129
|
+
* A branch still at the commit a review sent back is `sent back`, waiting for its lane's fix.
|
|
130
|
+
* Without a branch, the plan's status stands. A story is `blocked` while a story it depends on
|
|
131
|
+
* isn't done, whatever its branch holds; a todo story whose dependencies are done is `ready`.
|
|
130
132
|
*/
|
|
131
133
|
export function storyStates(root, stories, base) {
|
|
132
134
|
const trees = worktrees(root);
|
|
135
|
+
const backs = sentBack(root);
|
|
133
136
|
const states = stories.map((story) => {
|
|
134
137
|
const branch = storyBranch(story);
|
|
135
138
|
const ref = story.lane ? branchRef(root, branch) : null;
|
|
136
139
|
const ahead = ref ? Number(git(root, 'rev-list', '--count', `${base}..${ref}`) ?? 0) : 0;
|
|
137
140
|
let state = story.status;
|
|
138
141
|
if (ref && story.status !== 'done') {
|
|
139
|
-
|
|
142
|
+
const head = backs[branch] ? git(root, 'rev-parse', ref) : null;
|
|
143
|
+
if (head && head === backs[branch].sha) state = 'sent back';
|
|
144
|
+
else if (ahead > 0) state = 'review';
|
|
140
145
|
else state = story.status === 'review' ? 'done' : 'in progress';
|
|
141
146
|
}
|
|
142
147
|
return {
|
|
@@ -151,9 +156,10 @@ export function storyStates(root, stories, base) {
|
|
|
151
156
|
});
|
|
152
157
|
const done = new Set(states.filter((s) => s.state === 'done').map((s) => s.id));
|
|
153
158
|
for (const s of states) {
|
|
154
|
-
if (s.state
|
|
159
|
+
if (s.state === 'done') continue;
|
|
155
160
|
s.waitingOn = s.dependsOn.filter((d) => !done.has(d));
|
|
156
|
-
|
|
161
|
+
if (s.waitingOn.length) s.state = 'blocked';
|
|
162
|
+
else if (s.state === 'todo') s.state = 'ready';
|
|
157
163
|
}
|
|
158
164
|
return states;
|
|
159
165
|
}
|
|
@@ -200,13 +206,18 @@ export function buildStatus(root, config, base, files) {
|
|
|
200
206
|
let state = 'no story';
|
|
201
207
|
if (r.removed) state = 'removed';
|
|
202
208
|
else if (covering.length && covering.every((s) => s.state === 'done')) state = 'done';
|
|
203
|
-
else if (covering.some((s) => ['in progress', 'review', 'done'].includes(s.state)))
|
|
209
|
+
else if (covering.some((s) => ['in progress', 'review', 'sent back', 'done'].includes(s.state)))
|
|
204
210
|
state = 'in progress';
|
|
205
211
|
else if (covering.length) state = 'todo';
|
|
206
212
|
return { ...r, state, stories: covering.map((s) => s.id), tests: tested[r.id] ?? [] };
|
|
207
213
|
});
|
|
208
214
|
const drift = stories
|
|
209
|
-
.filter(
|
|
215
|
+
.filter(
|
|
216
|
+
(s) =>
|
|
217
|
+
!['ready', 'blocked'].includes(s.state) &&
|
|
218
|
+
s.state !== s.status &&
|
|
219
|
+
!(s.state === 'sent back' && s.status === 'in progress'),
|
|
220
|
+
)
|
|
210
221
|
.map((s) => ({ story: s.id, plan: s.status, actual: s.state }));
|
|
211
222
|
return { stories, requirements: byRequirement, problems, drift };
|
|
212
223
|
}
|
package/hooks/lib/registry.mjs
CHANGED
|
@@ -12,7 +12,35 @@ export const packageOf = (approvalName) =>
|
|
|
12
12
|
|
|
13
13
|
const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall'];
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// Kinds of install script, for the rule's allowInstallScripts:
|
|
16
|
+
// binary fetches a prebuilt binary for this platform (esbuild, sharp): a prebuild tool, or a script
|
|
17
|
+
// beside optional dependencies built per platform;
|
|
18
|
+
// build compiles native code from source (node-gyp);
|
|
19
|
+
// other anything else: code the package runs on install.
|
|
20
|
+
const BINARY_TOOLS =
|
|
21
|
+
/\b(prebuild-install|node-pre-gyp|node-gyp-build|prebuildify|napi-postinstall)\b/;
|
|
22
|
+
const PLATFORM = /(darwin|linux|win32|windows|freebsd|android|musl|arm64|x64|ia32)/;
|
|
23
|
+
|
|
24
|
+
/** The kind of install script a package version runs (binary, build or other), or null for none. */
|
|
25
|
+
export function installScriptKind(version) {
|
|
26
|
+
const scripts = INSTALL_SCRIPTS.map((s) => version?.scripts?.[s]).filter(Boolean);
|
|
27
|
+
if (!scripts.length) return null;
|
|
28
|
+
const text = scripts.join(' && ');
|
|
29
|
+
const perPlatform = Object.keys(version?.optionalDependencies ?? {}).filter((d) =>
|
|
30
|
+
PLATFORM.test(d),
|
|
31
|
+
);
|
|
32
|
+
if (BINARY_TOOLS.test(text) || perPlatform.length >= 2) return 'binary';
|
|
33
|
+
if (/\bnode-gyp\b|\bcmake-js\b/.test(text)) return 'build';
|
|
34
|
+
return 'other';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const KIND_WORDS = {
|
|
38
|
+
binary: 'downloads a prebuilt binary',
|
|
39
|
+
build: 'compiles native code',
|
|
40
|
+
other: 'runs its own code',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** { licence, weeklyDownloads, lastRelease (ISO), installScripts, installScriptKind } for `pkg`, or { error }. */
|
|
16
44
|
export async function npmFacts(pkg) {
|
|
17
45
|
const name = pkg.startsWith('@')
|
|
18
46
|
? `@${encodeURIComponent(pkg.slice(1))}`
|
|
@@ -34,6 +62,7 @@ export async function npmFacts(pkg) {
|
|
|
34
62
|
weeklyDownloads,
|
|
35
63
|
lastRelease: (latest && doc.time?.[latest]) ?? doc.time?.modified ?? null,
|
|
36
64
|
installScripts: INSTALL_SCRIPTS.some((s) => version?.scripts?.[s]),
|
|
65
|
+
installScriptKind: installScriptKind(version),
|
|
37
66
|
};
|
|
38
67
|
} catch (e) {
|
|
39
68
|
return { error: `couldn't reach the npm registry (${e.message})` };
|
|
@@ -57,7 +86,12 @@ export function dependencyRuleProblems(facts, rule, now = Date.now()) {
|
|
|
57
86
|
problems.push(
|
|
58
87
|
`its last release was ${Number.isFinite(months) ? `${Math.floor(months)} months` : 'never'} ago, over ${rule.maxMonthsSinceRelease}`,
|
|
59
88
|
);
|
|
60
|
-
if (facts.installScripts
|
|
61
|
-
|
|
89
|
+
if (facts.installScripts) {
|
|
90
|
+
const kind = facts.installScriptKind ?? 'other';
|
|
91
|
+
const allowed =
|
|
92
|
+
rule.allowInstallScripts === true ||
|
|
93
|
+
(Array.isArray(rule.allowInstallScripts) && rule.allowInstallScripts.includes(kind));
|
|
94
|
+
if (!allowed) problems.push(`installing it runs a script that ${KIND_WORDS[kind]}`);
|
|
95
|
+
}
|
|
62
96
|
return problems;
|
|
63
97
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The lead's send-backs: which commit of a branch a review sent back. Until the branch moves past it,
|
|
2
|
+
// status reads the story as waiting for its lane's fix, not as finished again (and next proposes the
|
|
3
|
+
// fix, not a second review of the same commits). Kept beside the requests, in the main checkout.
|
|
4
|
+
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { projectWorktrees } from './rules.mjs';
|
|
7
|
+
|
|
8
|
+
export const REVIEWS_FILE = '.claude/state/reviews.jsonl';
|
|
9
|
+
const reviewsFile = (root) => join(projectWorktrees(root)[0] ?? root, REVIEWS_FILE);
|
|
10
|
+
|
|
11
|
+
/** Records that the review of `branch` at commit `sha` sent it back, and why. */
|
|
12
|
+
export function recordSentBack(root, branch, sha, reason, now = Date.now()) {
|
|
13
|
+
const file = reviewsFile(root);
|
|
14
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
15
|
+
appendFileSync(
|
|
16
|
+
file,
|
|
17
|
+
`${JSON.stringify({ at: new Date(now).toISOString(), branch, sha, verdict: 'sent back', reason: reason ?? null })}\n`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The latest send-back of each branch: { branch: { at, sha, reason } }. */
|
|
22
|
+
export function sentBack(root) {
|
|
23
|
+
const found = {};
|
|
24
|
+
let lines = [];
|
|
25
|
+
try {
|
|
26
|
+
lines = readFileSync(reviewsFile(root), 'utf8').split('\n').filter(Boolean);
|
|
27
|
+
} catch {
|
|
28
|
+
return found;
|
|
29
|
+
}
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
try {
|
|
32
|
+
const r = JSON.parse(line);
|
|
33
|
+
if (r.verdict === 'sent back' && r.branch && r.sha) found[r.branch] = r;
|
|
34
|
+
} catch {
|
|
35
|
+
// a line being written, or not one of ours
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return found;
|
|
39
|
+
}
|
package/hooks/lib/rules.mjs
CHANGED
|
@@ -20,12 +20,13 @@ import {
|
|
|
20
20
|
readFileSync,
|
|
21
21
|
readdirSync,
|
|
22
22
|
realpathSync,
|
|
23
|
+
rmSync,
|
|
23
24
|
statSync,
|
|
24
25
|
writeFileSync,
|
|
25
26
|
} from 'node:fs';
|
|
26
27
|
import { execFileSync } from 'node:child_process';
|
|
27
28
|
import { homedir } from 'node:os';
|
|
28
|
-
import { dirname, join, relative, resolve } from 'node:path';
|
|
29
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
29
30
|
import { fileURLToPath } from 'node:url';
|
|
30
31
|
import { DEPENDENCY_APPROVAL, isDependencyFile } from './dependencies.mjs';
|
|
31
32
|
import { globToRegExp, matchesAny } from './glob.mjs';
|
|
@@ -33,6 +34,23 @@ import { globToRegExp, matchesAny } from './glob.mjs';
|
|
|
33
34
|
export const APPROVAL_LOG = '.claude/approval-log.jsonl';
|
|
34
35
|
export const APPROVALS_DIR = '.claude/approvals';
|
|
35
36
|
export const APPROVAL_MINUTES = 60;
|
|
37
|
+
// A dependency approval lasts until the lane commits the install it allows (the story may resume
|
|
38
|
+
// hours later), and at most a week, so a forgotten one doesn't linger.
|
|
39
|
+
export const DEPENDENCY_APPROVAL_DAYS = 7;
|
|
40
|
+
|
|
41
|
+
/** How long an approval named `name` lasts, in minutes. */
|
|
42
|
+
export const approvalMinutes = (name) =>
|
|
43
|
+
String(name).startsWith(DEPENDENCY_APPROVAL)
|
|
44
|
+
? DEPENDENCY_APPROVAL_DAYS * 24 * 60
|
|
45
|
+
: APPROVAL_MINUTES;
|
|
46
|
+
|
|
47
|
+
/** How long approvals for `names` last, in words. */
|
|
48
|
+
export function approvalLifetime(names) {
|
|
49
|
+
const list = [names].flat();
|
|
50
|
+
return list.every((n) => String(n).startsWith(DEPENDENCY_APPROVAL))
|
|
51
|
+
? `until the install is committed, at most ${DEPENDENCY_APPROVAL_DAYS} days`
|
|
52
|
+
: `for ${APPROVAL_MINUTES} minutes`;
|
|
53
|
+
}
|
|
36
54
|
// Marks a worktree as a lane's, for a person running that lane; the lead creates it, and it is never work.
|
|
37
55
|
export const LANE_FILE = '.lane';
|
|
38
56
|
const NEVER_WRITABLE = [`${APPROVALS_DIR}/**`, APPROVAL_LOG];
|
|
@@ -80,7 +98,7 @@ export function approvalHowTo(names, actor, root, allows = 'these edits', config
|
|
|
80
98
|
: '';
|
|
81
99
|
return (
|
|
82
100
|
`${lines.join('\n')}\n` +
|
|
83
|
-
`(a person runs it; the reason is recorded in ${APPROVAL_LOG}, and it allows ${allows} for ${whom},
|
|
101
|
+
`(a person runs it; the reason is recorded in ${APPROVAL_LOG}, and it allows ${allows} for ${whom}, ${approvalLifetime(names)})${relay}${delegated}`
|
|
84
102
|
);
|
|
85
103
|
}
|
|
86
104
|
|
|
@@ -211,7 +229,7 @@ export function owns(actor, rel, config) {
|
|
|
211
229
|
function inForce(file) {
|
|
212
230
|
if (!existsSync(file) || !statSync(file).isFile()) return null;
|
|
213
231
|
const { mtimeMs } = statSync(file);
|
|
214
|
-
if (Date.now() - mtimeMs >=
|
|
232
|
+
if (Date.now() - mtimeMs >= approvalMinutes(basename(file)) * 60_000) return null;
|
|
215
233
|
const reason = readFileSync(file, 'utf8').trim();
|
|
216
234
|
return reason ? { reason, grantedAt: new Date(mtimeMs).toISOString() } : null;
|
|
217
235
|
}
|
|
@@ -223,6 +241,16 @@ export function approval(root, name, actor) {
|
|
|
223
241
|
return lane ?? inForce(join(dir, name));
|
|
224
242
|
}
|
|
225
243
|
|
|
244
|
+
/** Uses up the approval in force for `name` and `actor`: a dependency approval, once its install is committed. */
|
|
245
|
+
export function useApproval(root, name, actor) {
|
|
246
|
+
const dir = approvalsDir(root);
|
|
247
|
+
for (const file of [
|
|
248
|
+
...(actor?.kind === 'lane' ? [join(dir, actor.lane, name)] : []),
|
|
249
|
+
join(dir, name),
|
|
250
|
+
])
|
|
251
|
+
if (inForce(file)) return rmSync(file, { force: true });
|
|
252
|
+
}
|
|
253
|
+
|
|
226
254
|
const requestsFile = (root) => join(projectWorktrees(root)[0] ?? root, REQUESTS_FILE);
|
|
227
255
|
const sameRequest = (a, b) =>
|
|
228
256
|
a.actor === b.actor &&
|
|
@@ -306,7 +334,7 @@ export function approvalsInForce(root, now = Date.now()) {
|
|
|
306
334
|
}
|
|
307
335
|
const a = inForce(file);
|
|
308
336
|
if (!a) continue;
|
|
309
|
-
const left =
|
|
337
|
+
const left = approvalMinutes(name) - (now - Date.parse(a.grantedAt)) / 60_000;
|
|
310
338
|
found.push({
|
|
311
339
|
name,
|
|
312
340
|
...(lane ? { lane } : {}),
|
|
@@ -336,6 +364,33 @@ export function dependencyGrants(root, actor) {
|
|
|
336
364
|
.filter((g) => g.reason);
|
|
337
365
|
}
|
|
338
366
|
|
|
367
|
+
/**
|
|
368
|
+
* During a merge, whether `rel` is exactly as the incoming branch (MERGE_HEAD) has it: then its change
|
|
369
|
+
* arrives with that branch's history, approvals and audit-log entries, not from the actor concluding
|
|
370
|
+
* the merge. `staged` compares what will be committed; otherwise the working tree. A file both sides
|
|
371
|
+
* delete counts too. verify still holds the branch to its rules afterwards.
|
|
372
|
+
*/
|
|
373
|
+
export function arrivesWithMerge(root, rel, { staged = false } = {}) {
|
|
374
|
+
const run = (...args) => {
|
|
375
|
+
try {
|
|
376
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
377
|
+
encoding: 'utf8',
|
|
378
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
379
|
+
}).trim();
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
if (run('rev-parse', '--verify', '--quiet', 'MERGE_HEAD') === null) return false;
|
|
385
|
+
const incoming = run('rev-parse', '--verify', '--quiet', `MERGE_HEAD:${rel}`);
|
|
386
|
+
const mine = staged
|
|
387
|
+
? run('rev-parse', '--verify', '--quiet', `:${rel}`)
|
|
388
|
+
: existsSync(join(root, rel))
|
|
389
|
+
? run('hash-object', '--', rel)
|
|
390
|
+
: null;
|
|
391
|
+
return incoming === mine;
|
|
392
|
+
}
|
|
393
|
+
|
|
339
394
|
/**
|
|
340
395
|
* Whether a dependency approval lets `actor` change `rel`: a manifest or lockfile no other lane owns,
|
|
341
396
|
* changed while the actor has a dependency approval in force. Installing a package rewrites them.
|
|
@@ -415,6 +470,13 @@ export function laneOf(rel, config) {
|
|
|
415
470
|
);
|
|
416
471
|
}
|
|
417
472
|
|
|
473
|
+
/** Who reviews a change to a file any agent may write: the lane an anyActor entry names, else the lead. Null for other files. */
|
|
474
|
+
export function reviewerOf(rel, config) {
|
|
475
|
+
if (!matchesAny(rel, config.anyActor)) return null;
|
|
476
|
+
const named = (config.anyActorReviewers ?? []).find((e) => matchesAny(rel, [e.glob]));
|
|
477
|
+
return named?.reviewer ?? 'lead';
|
|
478
|
+
}
|
|
479
|
+
|
|
418
480
|
export function ownerOf(rel, config) {
|
|
419
481
|
const lane = laneOf(rel, config);
|
|
420
482
|
if (lane) return `${lane} lane / ${config.lanes[lane].agent}`;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// What a shell command runs, as distinct from the text it carries. Heredoc bodies and quoted strings
|
|
2
|
+
// are data: a commit message, a card's --text, a grep pattern. So rules that recognise a command
|
|
3
|
+
// (`git commit`, `code-kit merge`) look only at the simple commands it runs, each from its own start.
|
|
4
|
+
// Rules about text the command carries (a commit trailer, a path in a redirect) still read it whole.
|
|
5
|
+
|
|
6
|
+
/** The command with each heredoc's body taken out: the lines after `<<WORD` up to the line `WORD`. */
|
|
7
|
+
export function withoutHeredocs(cmd) {
|
|
8
|
+
const out = [];
|
|
9
|
+
let until = null;
|
|
10
|
+
for (const line of String(cmd).split('\n')) {
|
|
11
|
+
if (until) {
|
|
12
|
+
if (line.trim() === until) until = null;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
out.push(line);
|
|
16
|
+
// `<<WORD`, `<<-WORD`, `<<'WORD'` or `<<"WORD"`; not a `<<<` here-string, whose text is on the line.
|
|
17
|
+
const m = line.match(/(?<!<)<<-?\s*(['"]?)([\w.-]+)\1(?!<)/);
|
|
18
|
+
if (m && !line.includes('<<<')) until = m[2];
|
|
19
|
+
}
|
|
20
|
+
return out.join('\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `text` with every quoted character replaced by `_`, the quotes kept: same length, nothing inside to match. */
|
|
24
|
+
export function maskQuotes(text) {
|
|
25
|
+
let out = '';
|
|
26
|
+
let quote = null;
|
|
27
|
+
for (let i = 0; i < text.length; i++) {
|
|
28
|
+
const c = text[i];
|
|
29
|
+
if (quote) {
|
|
30
|
+
if (c === '\\' && quote === '"' && i + 1 < text.length) {
|
|
31
|
+
out += '__';
|
|
32
|
+
i++;
|
|
33
|
+
} else if (c === quote) {
|
|
34
|
+
quote = null;
|
|
35
|
+
out += c;
|
|
36
|
+
} else out += c === '\n' ? '\n' : '_';
|
|
37
|
+
} else if (c === '\\' && i + 1 < text.length) {
|
|
38
|
+
out += '__';
|
|
39
|
+
i++;
|
|
40
|
+
} else {
|
|
41
|
+
if (c === '"' || c === "'") quote = c;
|
|
42
|
+
out += c;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SEPARATOR = /[;&|\n()`]/;
|
|
49
|
+
const LEADING =
|
|
50
|
+
/^(?:(?:[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+)|(?:sudo|exec|time|command|nohup|env)\s+|!\s+)+/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The simple commands `cmd` runs, in order: split at `;`, `&`, `|`, newlines, parentheses and
|
|
54
|
+
* backticks outside quotes, heredoc bodies left out, each without leading `VAR=value` assignments.
|
|
55
|
+
* Each is `{ text, code }`: `text` as written, `code` with its quoted text masked.
|
|
56
|
+
*/
|
|
57
|
+
export function commandsOf(cmd) {
|
|
58
|
+
const text = withoutHeredocs(cmd);
|
|
59
|
+
const mask = maskQuotes(text);
|
|
60
|
+
const found = [];
|
|
61
|
+
let start = 0;
|
|
62
|
+
const take = (end) => {
|
|
63
|
+
const raw = text.slice(start, end);
|
|
64
|
+
const lead = raw.length - raw.trimStart().length;
|
|
65
|
+
const body = raw.trim();
|
|
66
|
+
const skip = body.match(LEADING)?.[0].length ?? 0;
|
|
67
|
+
const from = start + lead + skip;
|
|
68
|
+
const piece = text.slice(from, start + lead + body.length);
|
|
69
|
+
if (piece) found.push({ text: piece, code: mask.slice(from, from + piece.length) });
|
|
70
|
+
};
|
|
71
|
+
for (let i = 0; i < mask.length; i++)
|
|
72
|
+
if (SEPARATOR.test(mask[i])) {
|
|
73
|
+
take(i);
|
|
74
|
+
start = i + 1;
|
|
75
|
+
}
|
|
76
|
+
take(mask.length);
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether `cmd` runs a command matching `pattern`, which is anchored at the command's start. */
|
|
81
|
+
export function runs(cmd, pattern, { quoted = true } = {}) {
|
|
82
|
+
return commandsOf(cmd).some((c) => pattern.test(quoted ? c.text : c.code));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The whole command with heredoc bodies out and quoted text masked: for rules that span commands (`curl … | sh`). */
|
|
86
|
+
export function codeOf(cmd) {
|
|
87
|
+
return maskQuotes(withoutHeredocs(cmd));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `git`, with any `-C dir` and `-c key=value` before its subcommand. */
|
|
91
|
+
export const GIT = String.raw`git\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)\s+)*`;
|
|
92
|
+
|
|
93
|
+
/** code-kit's command line however it's run: installed, through node (its path quoted or not), or npx. */
|
|
94
|
+
export const CODE_KIT = String.raw`(?:code-kit|node\s+(?:"[^"]*code-kit(?:\.mjs)?"|'[^']*code-kit(?:\.mjs)?'|\S*code-kit(?:\.mjs)?)|npx\s+(?:--yes\s+|-y\s+)?@warren-dean\/code-kit(?:@\S+)?)`;
|
package/hooks/lib/trace.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
6
6
|
import { matchesAny } from './glob.mjs';
|
|
7
7
|
import { layerOf } from './layers.mjs';
|
|
8
8
|
import { readRequirements, readStories } from './plan.mjs';
|
|
9
|
-
import { ownerOf } from './rules.mjs';
|
|
9
|
+
import { ownerOf, reviewerOf } from './rules.mjs';
|
|
10
10
|
|
|
11
11
|
const git = (root, ...args) => {
|
|
12
12
|
try {
|
|
@@ -30,7 +30,7 @@ export function storiesOf(root, rel) {
|
|
|
30
30
|
return [...ids];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** { path, owner, lane, layer, requirements: [{ id, title, spec, stories }] } for one repository path. */
|
|
33
|
+
/** { path, owner, reviewer, lane, layer, requirements: [{ id, title, spec, stories }] } for one repository path. */
|
|
34
34
|
export function traceFile(root, rel, config) {
|
|
35
35
|
const laneName = Object.keys(config.lanes).find((l) => {
|
|
36
36
|
const { paths, exclude } = config.lanes[l];
|
|
@@ -40,6 +40,8 @@ export function traceFile(root, rel, config) {
|
|
|
40
40
|
const out = {
|
|
41
41
|
path: rel,
|
|
42
42
|
owner: ownerOf(rel, config),
|
|
43
|
+
// For a file any agent may write: who reviews changes to it.
|
|
44
|
+
reviewer: reviewerOf(rel, config),
|
|
43
45
|
lane: laneName ? { name: laneName, agent: config.lanes[laneName].agent } : null,
|
|
44
46
|
layer: layer
|
|
45
47
|
? { name: layer.name, mayImport: layer.mayImport, denyPackages: layer.denyPackages ?? [] }
|
package/hooks/lib/verify.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The rules for a branch's changes as a whole, for CI and the lead's review: the hooks only see work
|
|
2
2
|
// done in Claude Code sessions, so a person's commits or another tool would otherwise go unchecked.
|
|
3
3
|
// Everything is judged from git (base...HEAD), except the checks, which run in the checkout.
|
|
4
|
-
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
5
5
|
import { existsSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { BASELINE_FILE, loadBaseline, newProblems } from './baseline.mjs';
|
|
@@ -10,7 +10,15 @@ import { CONFIG_FILE } from './config.mjs';
|
|
|
10
10
|
import { isDependencyFile } from './dependencies.mjs';
|
|
11
11
|
import { matchesAny } from './glob.mjs';
|
|
12
12
|
import { layerProblems } from './layers.mjs';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
APPROVAL_LOG,
|
|
15
|
+
SECRETS,
|
|
16
|
+
laneOf,
|
|
17
|
+
ownerOf,
|
|
18
|
+
owns,
|
|
19
|
+
protectedEntry,
|
|
20
|
+
screenProblem,
|
|
21
|
+
} from './rules.mjs';
|
|
14
22
|
|
|
15
23
|
const git = (root, ...args) =>
|
|
16
24
|
execFileSync('git', ['-C', root, ...args], {
|
|
@@ -31,9 +39,30 @@ function showAt(root, ref, rel) {
|
|
|
31
39
|
/** The lane a branch belongs to by its name (`<lane>/<story>`), or null. */
|
|
32
40
|
export function laneOfBranch(branch, config) {
|
|
33
41
|
const lane = branch?.split('/')[0];
|
|
42
|
+
if (lane === 'lead') return 'lead';
|
|
34
43
|
return lane && config.lanes[lane] ? lane : null;
|
|
35
44
|
}
|
|
36
45
|
|
|
46
|
+
/** What verify holds a branch to, in words: a lane's paths, or the lead's. */
|
|
47
|
+
export const heldTo = (lane) => (lane === 'lead' ? "the lead's paths" : `the ${lane} lane`);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Files changed by commits made on the branch itself (first parents, merges left out): on the lead's
|
|
51
|
+
* branch, what the lead wrote, as distinct from lane work it merged in after review.
|
|
52
|
+
*/
|
|
53
|
+
function directChanges(root, from) {
|
|
54
|
+
const out = git(
|
|
55
|
+
root,
|
|
56
|
+
'log',
|
|
57
|
+
'--first-parent',
|
|
58
|
+
'--no-merges',
|
|
59
|
+
'--format=',
|
|
60
|
+
'--name-only',
|
|
61
|
+
`${from}..HEAD`,
|
|
62
|
+
);
|
|
63
|
+
return new Set(out.split('\n').filter(Boolean));
|
|
64
|
+
}
|
|
65
|
+
|
|
37
66
|
/** Files changed on this branch since it left `base`. */
|
|
38
67
|
export function branchChanges(root, base) {
|
|
39
68
|
return git(root, 'diff', '--name-only', '--no-renames', `${base}...HEAD`)
|
|
@@ -96,6 +125,33 @@ function signedByPerson(root, from, file, signedBy) {
|
|
|
96
125
|
return messages.length > 0 && messages.every((m) => line.test(m));
|
|
97
126
|
}
|
|
98
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Secrets anywhere in the branch's history, not only in the files as they end up: a secret committed
|
|
130
|
+
* and then deleted is still in the commits that merge. Names that look like secret files, from every
|
|
131
|
+
* commit; and, where gitleaks is installed, what it finds in the commits' content.
|
|
132
|
+
*/
|
|
133
|
+
function historySecrets(root, from, changed) {
|
|
134
|
+
const problems = [];
|
|
135
|
+
const touched = git(root, 'log', '--format=', '--name-only', `${from}..HEAD`)
|
|
136
|
+
.split('\n')
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
for (const f of new Set(touched))
|
|
139
|
+
if (SECRETS(f) && !changed.includes(f))
|
|
140
|
+
problems.push(
|
|
141
|
+
`${f} may hold secrets, and it's in this branch's history even though it's gone now. Rewrite the history without it before it merges.`,
|
|
142
|
+
);
|
|
143
|
+
const scan = spawnSync(
|
|
144
|
+
'gitleaks',
|
|
145
|
+
['detect', '--source', root, `--log-opts=${from}..HEAD`, '--redact', '--no-banner'],
|
|
146
|
+
{ encoding: 'utf8' },
|
|
147
|
+
);
|
|
148
|
+
if (scan.error?.code !== 'ENOENT' && scan.status === 1)
|
|
149
|
+
problems.push(
|
|
150
|
+
`gitleaks found possible secrets in this branch's commits:\n${`${scan.stdout}${scan.stderr}`.trim()}`,
|
|
151
|
+
);
|
|
152
|
+
return problems;
|
|
153
|
+
}
|
|
154
|
+
|
|
99
155
|
/**
|
|
100
156
|
* Every problem with the branch, grouped: { ownership, protected, layers, baseline, design, checks }.
|
|
101
157
|
* `lane` holds the branch to that lane's paths; `checks: false` leaves the project's checks to CI's
|
|
@@ -110,7 +166,9 @@ export function verifyBranch({ root, base, config, lane = null, checks = true })
|
|
|
110
166
|
const { entries, problems: logProblems } = logEntries(root, from);
|
|
111
167
|
found.protected.push(...logProblems);
|
|
112
168
|
const logged = new Map(entries.map((e) => [e.file, e]));
|
|
169
|
+
const direct = lane === 'lead' ? directChanges(root, from) : null;
|
|
113
170
|
|
|
171
|
+
found.ownership.push(...historySecrets(root, from, changed));
|
|
114
172
|
for (const f of changed) {
|
|
115
173
|
if (SECRETS(f))
|
|
116
174
|
found.ownership.push(`${f} may hold secrets; secrets never go in the repository.`);
|
|
@@ -122,7 +180,20 @@ export function verifyBranch({ root, base, config, lane = null, checks = true })
|
|
|
122
180
|
`${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.`,
|
|
123
181
|
);
|
|
124
182
|
if (matchesAny(f, config.anyActor)) continue;
|
|
125
|
-
if (lane) {
|
|
183
|
+
if (lane === 'lead') {
|
|
184
|
+
// The lead's branch is held to the lead's paths for what it changes itself; lane work reaches it
|
|
185
|
+
// by merging the lane's branch, which verify held to that lane.
|
|
186
|
+
if (!direct.has(f)) continue;
|
|
187
|
+
const entry = logged.get(f);
|
|
188
|
+
const approved =
|
|
189
|
+
(guarded && entry?.approval === guarded.approval) ||
|
|
190
|
+
signed ||
|
|
191
|
+
(isDependencyFile(f) && entry?.approval === 'dependency');
|
|
192
|
+
if (!owns({ kind: 'lead' }, f, config) && !approved)
|
|
193
|
+
found.ownership.push(
|
|
194
|
+
`${f}: changed by a commit on the lead's branch, but the lead doesn't own it (owner: ${ownerOf(f, config) ?? 'nobody'}). Lane work comes in by merging the lane's reviewed branch.`,
|
|
195
|
+
);
|
|
196
|
+
} else if (lane) {
|
|
126
197
|
const { paths, exclude } = config.lanes[lane];
|
|
127
198
|
const own = matchesAny(f, paths) && !matchesAny(f, exclude);
|
|
128
199
|
const entry = logged.get(f);
|