@warren-dean/code-kit 0.3.7 → 0.3.9
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/README.md +41 -22
- package/bin/code-kit.mjs +65 -9
- package/hooks/guard-bash.mjs +13 -4
- package/hooks/lib/config.mjs +41 -1
- package/hooks/lib/next.mjs +14 -4
- package/hooks/lib/plan.mjs +9 -3
- package/hooks/lib/registry.mjs +63 -0
- package/hooks/lib/rules.mjs +13 -3
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +1 -1
- package/skills/next/SKILL.md +2 -0
- package/skills/spec-design/SKILL.md +2 -2
- package/templates/plan.md +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"name": "code-kit",
|
|
10
10
|
"source": "./",
|
|
11
11
|
"description": "Keeps AI coding agents inside your project's rules, from idea to merged code: spec design, lanes, layer rules, protected paths with an approval log, spec-check and design gates, proof-before-finish checks, dispatch, review, build status and CI verification, configured per project from its docs",
|
|
12
|
-
"version": "0.3.
|
|
12
|
+
"version": "0.3.9",
|
|
13
13
|
"category": "development",
|
|
14
14
|
"tags": ["guardrails", "architecture", "lanes", "layers", "spec-driven", "ci", "multi-agent"],
|
|
15
15
|
"license": "MIT"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-kit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "Keeps AI coding agents inside your project's rules, from idea to merged code: spec design, lanes, layer rules, protected paths with an approval log, spec-check and design gates, proof-before-finish checks, dispatch, review, build status and CI verification, configured per project from its docs",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Warren Langeveldt"
|
package/README.md
CHANGED
|
@@ -181,7 +181,7 @@ spec-design works an idea into a specification with you before any code exists.
|
|
|
181
181
|
|
|
182
182
|
It writes `docs/brief.md` (with the decision log and open questions), `docs/specs/NN-<area>.md`, `docs/architecture.md`, `docs/principles.md` and `docs/plan.md`. Those are exactly what `/code-kit:init docs/` turns into lanes and layers, and what lanes spec-check against. Run it again to resume where it left off, to change a decision, or to spec a new feature in an existing codebase.
|
|
183
183
|
|
|
184
|
-
Requirements are headings (`### BOOK-4 Cancel a booking`). Stories are `### ST-12` headings in the plan with `**Lane:**`, `**Requirements:**`, `**Depends on:**` and `**Status:**` lines (`templates/plan.md`). Those two formats are what the build loop below runs on.
|
|
184
|
+
Requirements are headings (`### BOOK-4 Cancel a booking`). Stories are `### ST-12` headings in the plan with `**Lane:**`, `**Requirements:**`, `**Depends on:**` and `**Status:**` lines (`templates/plan.md`). The lane `lead` marks the lead's own stories, such as contracts and spikes, which the lead builds itself instead of dispatching; `**Requirements:** none` marks a story that delivers no requirement. Those two formats are what the build loop below runs on.
|
|
185
185
|
|
|
186
186
|
## Set up a project
|
|
187
187
|
|
|
@@ -309,7 +309,7 @@ code-kit verify [--base ref] [--branch name] [--no-checks] # a branch's changes
|
|
|
309
309
|
code-kit status [--base ref] [--json] # stories and requirements against the spec
|
|
310
310
|
code-kit next [--base ref] [--json] # the step to take now, from the project's state
|
|
311
311
|
code-kit trace <path>... [--json] # what a file is for: requirements, lane, layer rules
|
|
312
|
-
code-kit approve <name>... --reason "…" [--lane name] # record
|
|
312
|
+
code-kit approve <name>... --reason "…" [--lane name] [--delegated] # record a person's approval from chat, or (--delegated) grant one within the delegated rules; a package name stands for its dep- approval
|
|
313
313
|
```
|
|
314
314
|
|
|
315
315
|
`verify` compares the branch with where it left `--base` (default `origin/main`, then `main`). A branch named `<lane>/…`, or given as `--branch`, is held to that lane. `--no-checks` leaves the project's checks to CI's own steps. `status` reads `docs.specs` and `docs.plan`.
|
|
@@ -318,26 +318,27 @@ code-kit approve <name>... --reason "…" [--lane name] # record approvals a pe
|
|
|
318
318
|
|
|
319
319
|
`.claude/code-kit.json`. Globs use `*` (one segment), `**` (any depth), `**/` (zero or more folders), `?` and `{a,b}`. Patterns under `shell` are regular expressions.
|
|
320
320
|
|
|
321
|
-
| Field | Required | What it is
|
|
322
|
-
| ------------------------------ | ----------------- |
|
|
323
|
-
| `version` | yes | `1`
|
|
324
|
-
| `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch
|
|
325
|
-
| `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan
|
|
326
|
-
| `lead.paths` | yes | What the main session writes. `.claude/**` is always added.
|
|
327
|
-
| `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included.
|
|
328
|
-
| `anyActor` | no | Paths every writer may change (e.g. the lockfile). `.claude/state/**` is always included.
|
|
329
|
-
| `lanes.<name>` | yes (may be `{}`) | `{ agent, paths, exclude? }`: the agent name maps a subagent to its lane. A worktree with a `.lane` file holding the lane name makes the main session that lane.
|
|
330
|
-
| `protected[]` | no | `{ glob, approval, why }`: logged on every commit. Anyone but the lead needs a person's approval (see [Approvals](#approvals)). `.claude/**` is always protected as `kit`.
|
|
331
|
-
| `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them
|
|
332
|
-
| `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself.
|
|
333
|
-
| `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths
|
|
334
|
-
| `shell.block[]` | no | `{ pattern, why, person? }`: never run. `person: true` marks a person's act, which the lead may do for them with `approvals.lead` on
|
|
335
|
-
| `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes
|
|
336
|
-
| `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped.
|
|
337
|
-
| `checks[]` | no | `{ name, files, exclude?, run[], each?, owners?, ifMissing?, timeoutSeconds? }`: before finishing, when matching files changed. `each` runs the commands in every changed folder matching that glob. `owners` limits who may change the files (the lead always may). `ifMissing: "fail"` makes a missing tool an error (the default is to skip).
|
|
338
|
-
| `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected. See [Adapters](#adapters).
|
|
339
|
-
| `approvals.lead` | no | `true` lets the lead act on what the person says in chat, for when `!` isn't available (on a phone, say): it records their approvals with `code-kit approve`, and runs `shell.block` commands marked `person`. Default `false`. See [Approvals](#approvals).
|
|
340
|
-
| `
|
|
321
|
+
| Field | Required | What it is |
|
|
322
|
+
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
323
|
+
| `version` | yes | `1` |
|
|
324
|
+
| `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch |
|
|
325
|
+
| `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan |
|
|
326
|
+
| `lead.paths` | yes | What the main session writes. `.claude/**` is always added. |
|
|
327
|
+
| `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included. |
|
|
328
|
+
| `anyActor` | no | Paths every writer may change (e.g. the lockfile). `.claude/state/**` is always included. |
|
|
329
|
+
| `lanes.<name>` | yes (may be `{}`) | `{ agent, paths, exclude? }`: the agent name maps a subagent to its lane. A worktree with a `.lane` file holding the lane name makes the main session that lane. |
|
|
330
|
+
| `protected[]` | no | `{ glob, approval, why }`: logged on every commit. Anyone but the lead needs a person's approval (see [Approvals](#approvals)). `.claude/**` is always protected as `kit`. |
|
|
331
|
+
| `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them |
|
|
332
|
+
| `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself. |
|
|
333
|
+
| `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths |
|
|
334
|
+
| `shell.block[]` | no | `{ pattern, why, person? }`: never run. `person: true` marks a person's act, which the lead may do for them with `approvals.lead` on |
|
|
335
|
+
| `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes |
|
|
336
|
+
| `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped. |
|
|
337
|
+
| `checks[]` | no | `{ name, files, exclude?, run[], each?, owners?, ifMissing?, timeoutSeconds? }`: before finishing, when matching files changed. `each` runs the commands in every changed folder matching that glob. `owners` limits who may change the files (the lead always may). `ifMissing: "fail"` makes a missing tool an error (the default is to skip). |
|
|
338
|
+
| `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected. See [Adapters](#adapters). |
|
|
339
|
+
| `approvals.lead` | no | `true` lets the lead act on what the person says in chat, for when `!` isn't available (on a phone, say): it records their approvals with `code-kit approve`, and runs `shell.block` commands marked `person`. Default `false`. See [Approvals](#approvals). |
|
|
340
|
+
| `approvals.delegate` | no | `{ protected?: [approval names], dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts? } }`: approvals the lead may grant on its own, with `code-kit approve --delegated`. Dependencies are checked against the npm registry; the defaults are MIT, Apache-2.0, BSD and ISC, 10,000 weekly downloads, a release within 18 months, and no install scripts. See [Approvals](#approvals). |
|
|
341
|
+
| `branches.protected` | no | Default `["main", "master"]` |
|
|
341
342
|
|
|
342
343
|
Add `.claude/approvals/`, `.claude/state/` and `.claude/code-kit.draft.json` to `.gitignore`, and `.claude/approval-log.jsonl merge=union` to `.gitattributes`, so merging branches that both logged changes keeps every entry. Commit `.claude/code-kit.baseline.json` if the project has one.
|
|
343
344
|
|
|
@@ -370,6 +371,24 @@ code-kit approve dep-dayjs --lane web --reason "Yes, add dayjs for ST-12"
|
|
|
370
371
|
- The lead may also run `shell.block` commands marked `person: true`, such as Context Graph's ratification trailer.
|
|
371
372
|
- It trusts the lead to act only on the person's own words. Leave it off when the lead runs without a person watching.
|
|
372
373
|
|
|
374
|
+
### Delegated
|
|
375
|
+
|
|
376
|
+
For a lead that runs without a person watching, such as an autonomous team, `approvals.delegate` lets the lead approve some things on its own authority, within rules the person sets in the config:
|
|
377
|
+
|
|
378
|
+
```json
|
|
379
|
+
"approvals": { "delegate": { "protected": ["design"], "dependencies": { "minWeeklyDownloads": 10000 } } }
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
code-kit approve date-fns --lane web --delegated --reason "Date formatting for ST-12"
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
- **Protected approvals:** the lead may grant those named in `protected`, and no others.
|
|
387
|
+
- **Dependencies:** each package is checked against the npm registry. Its licence must be in `licences`; it must have at least `minWeeklyDownloads` downloads last week and a release within `maxMonthsSinceRelease` months; and installing it must run no scripts, unless `allowInstallScripts` is true. Packages from other ecosystems, and anything the registry can't confirm, go to the person.
|
|
388
|
+
- **All or nothing:** if any name is outside the rules, nothing is approved, and the command says which rule each one broke, so the lead asks the person.
|
|
389
|
+
- **The record:** the approval reads `(approved by the lead within the delegated rules: <rule>)` after the reason. The approval log keeps it when the change is committed.
|
|
390
|
+
- **Who:** only the lead. A lane asks the lead with a change request, and its refusals say the lead may approve within the rules. Without `approvals.delegate`, `--delegated` is refused.
|
|
391
|
+
|
|
373
392
|
## Adapters
|
|
374
393
|
|
|
375
394
|
Other tools share a project with code-kit and keep files of their own. An adapter tells code-kit about one such tool, without the core knowing its name. Each adapter lives in `hooks/lib/adapters/`, and declares:
|
package/bin/code-kit.mjs
CHANGED
|
@@ -28,10 +28,23 @@ import { ADAPTERS, activeAdapters } from '../hooks/lib/adapters/index.mjs';
|
|
|
28
28
|
import { nextStep } from '../hooks/lib/next.mjs';
|
|
29
29
|
import { traceFile } from '../hooks/lib/trace.mjs';
|
|
30
30
|
import { buildStatus } from '../hooks/lib/plan.mjs';
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
APPROVAL_MINUTES,
|
|
33
|
+
currentBranch,
|
|
34
|
+
delegatedMark,
|
|
35
|
+
grantApprovals,
|
|
36
|
+
ownerOf,
|
|
37
|
+
} from '../hooks/lib/rules.mjs';
|
|
38
|
+
import { DEPENDENCY_APPROVAL, dependencyApproval } from '../hooks/lib/dependencies.mjs';
|
|
39
|
+
import { dependencyRuleProblems, npmFacts, packageOf } from '../hooks/lib/registry.mjs';
|
|
32
40
|
import { laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
|
|
33
41
|
|
|
34
42
|
const out = (s) => process.stdout.write(`${s}\n`);
|
|
43
|
+
// Piped into `head` and the like: stop quietly when the reader closes.
|
|
44
|
+
process.stdout.on('error', (e) => {
|
|
45
|
+
if (e.code === 'EPIPE') process.exit(0);
|
|
46
|
+
throw e;
|
|
47
|
+
});
|
|
35
48
|
const die = (s) => {
|
|
36
49
|
process.stderr.write(`${s}\n`);
|
|
37
50
|
process.exit(1);
|
|
@@ -48,7 +61,7 @@ const baseRef = option('--base');
|
|
|
48
61
|
const branchName = option('--branch');
|
|
49
62
|
const reason = option('--reason');
|
|
50
63
|
const laneName = option('--lane');
|
|
51
|
-
const FLAGS = ['--write', '--no-checks', '--json'];
|
|
64
|
+
const FLAGS = ['--write', '--no-checks', '--json', '--delegated'];
|
|
52
65
|
const flag = (name) => args.includes(name);
|
|
53
66
|
const writeBaseline = flag('--write');
|
|
54
67
|
const [command, ...rest] = args.filter((a) => !FLAGS.includes(a));
|
|
@@ -379,21 +392,64 @@ function trace(paths) {
|
|
|
379
392
|
}
|
|
380
393
|
}
|
|
381
394
|
|
|
382
|
-
// Each name is an approval a refusal named: a protected path's, `kit`, or `dep-<package>`.
|
|
383
|
-
|
|
395
|
+
// Each name is an approval a refusal named: a protected path's, `kit`, or `dep-<package>`. A package
|
|
396
|
+
// name (`zod`, `@scope/pkg`) stands for its `dep-` approval. With --delegated, the lead approves on its
|
|
397
|
+
// own authority, and only what the project's delegated rules allow.
|
|
398
|
+
async function approve(given) {
|
|
384
399
|
const { config } = load();
|
|
385
|
-
|
|
400
|
+
const delegated = flag('--delegated');
|
|
401
|
+
if (!reason?.trim())
|
|
402
|
+
die(
|
|
403
|
+
delegated
|
|
404
|
+
? 'approve needs --reason "<why it\'s needed, for which story>".'
|
|
405
|
+
: 'approve needs --reason "<what the person approved, in their words>".',
|
|
406
|
+
);
|
|
386
407
|
if (laneName !== undefined && !config.lanes[laneName])
|
|
387
408
|
die(`There is no lane "${laneName}". Lanes: ${Object.keys(config.lanes).join(', ')}.`);
|
|
409
|
+
const known = new Set(['kit', ...config.protected.map((p) => p.approval)]);
|
|
410
|
+
const isPackage = (n) => /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(n);
|
|
411
|
+
const names = given.map((n) =>
|
|
412
|
+
n.startsWith(DEPENDENCY_APPROVAL) || known.has(n) || !isPackage(n) ? n : dependencyApproval(n),
|
|
413
|
+
);
|
|
388
414
|
const bad = names.filter((n) => !/^[a-z0-9@][a-z0-9@+._-]*$/.test(n));
|
|
389
415
|
if (bad.length)
|
|
390
416
|
die(
|
|
391
|
-
`Not an approval name: ${bad.join(', ')}. Use the name the refusal gave, e.g. kit or dep-zod.`,
|
|
417
|
+
`Not an approval or package name: ${bad.join(', ')}. Use the name the refusal gave, e.g. kit or dep-zod, or the package's name.`,
|
|
392
418
|
);
|
|
393
|
-
|
|
419
|
+
const marks = {};
|
|
420
|
+
if (delegated) {
|
|
421
|
+
const rules = config.approvals.delegate;
|
|
422
|
+
if (!rules)
|
|
423
|
+
die(
|
|
424
|
+
'This project delegates no approvals to the lead ("approvals.delegate" isn\'t set). Ask the person.',
|
|
425
|
+
);
|
|
426
|
+
const outside = [];
|
|
427
|
+
for (const name of names) {
|
|
428
|
+
if (name.startsWith(DEPENDENCY_APPROVAL)) {
|
|
429
|
+
if (!rules.dependencies) {
|
|
430
|
+
outside.push(`${name}: dependencies aren't delegated`);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const pkg = packageOf(name);
|
|
434
|
+
const problems = dependencyRuleProblems(await npmFacts(pkg), rules.dependencies);
|
|
435
|
+
if (problems.length) outside.push(`${name}: ${problems.join('; ')}`);
|
|
436
|
+
else marks[name] = delegatedMark(`dependencies (${pkg})`);
|
|
437
|
+
} else if (rules.protected.includes(name)) marks[name] = delegatedMark(`protected: ${name}`);
|
|
438
|
+
else
|
|
439
|
+
outside.push(
|
|
440
|
+
`${name}: not among the delegated approvals (${rules.protected.join(', ') || 'none'})`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (outside.length)
|
|
444
|
+
die(
|
|
445
|
+
`Nothing was approved. These are outside the project's delegated rules, so the person decides:\n ${outside.join('\n ')}\nAsk the person, with the \`!\` command from the refusal.`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
grantApprovals(resolve('.'), names, laneName, reason, marks);
|
|
394
449
|
const whom = laneName ? `the ${laneName} lane` : 'any agent';
|
|
450
|
+
const renamed = given.flatMap((g, i) => (g === names[i] ? [] : [`${g} → ${names[i]}`]));
|
|
395
451
|
out(
|
|
396
|
-
`Approved ${names.join(', ')} for ${whom}, for ${APPROVAL_MINUTES} minutes: ${reason.trim()}`,
|
|
452
|
+
`Approved ${names.join(', ')} for ${whom}, for ${APPROVAL_MINUTES} minutes${delegated ? ', within the delegated rules' : ''}: ${reason.trim()}${renamed.length ? `\n(${renamed.join(', ')})` : ''}`,
|
|
397
453
|
);
|
|
398
454
|
}
|
|
399
455
|
|
|
@@ -419,4 +475,4 @@ function usage() {
|
|
|
419
475
|
' | approve <name>... --reason "…" [--lane name] [--config file]',
|
|
420
476
|
);
|
|
421
477
|
}
|
|
422
|
-
(commands[command] ?? usage)();
|
|
478
|
+
await (commands[command] ?? usage)();
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -78,11 +78,18 @@ for (const { pattern, why, person } of config.shell.block) {
|
|
|
78
78
|
|
|
79
79
|
if (approves && actor.kind !== 'lead')
|
|
80
80
|
block(
|
|
81
|
-
`Blocked: only the lead records
|
|
81
|
+
`Blocked: only the lead records or grants approvals. Write a short change request for the lead (what needs approving, and why), and stop.\nCommand: ${cmd}`,
|
|
82
82
|
);
|
|
83
|
-
|
|
83
|
+
// The lead approves on its own only with --delegated, and only where the config delegates; the
|
|
84
|
+
// command itself refuses anything outside the rules.
|
|
85
|
+
const delegating = /\s--delegated\b/.test(cmd);
|
|
86
|
+
if (approves && delegating && !config.approvals.delegate)
|
|
84
87
|
block(
|
|
85
|
-
`Blocked: this project doesn't
|
|
88
|
+
`Blocked: this project doesn't delegate approvals to the lead ("approvals.delegate" isn't set). Ask the person to approve it with the \`!\` command from the refusal.\nCommand: ${cmd}`,
|
|
89
|
+
);
|
|
90
|
+
if (approves && !delegating && !forPerson)
|
|
91
|
+
block(
|
|
92
|
+
`Blocked: this project doesn't let the lead record approvals ("approvals.lead" is off).${config.approvals.delegate ? ' Within the delegated rules, approve it yourself with --delegated;' : ''} Ask the person to run the \`!\` command from the refusal, or to turn "approvals": { "lead": true } on in the config.\nCommand: ${cmd}`,
|
|
86
93
|
);
|
|
87
94
|
|
|
88
95
|
// Each new package needs a person's approval, for this actor; it also covers the manifest and lockfile
|
|
@@ -97,7 +104,9 @@ if (packages.length) {
|
|
|
97
104
|
const ask =
|
|
98
105
|
actor.kind === 'lead'
|
|
99
106
|
? 'Ask the person to approve it by running'
|
|
100
|
-
:
|
|
107
|
+
: config.approvals.delegate
|
|
108
|
+
? "Write a short change request for the lead (the package, why, and the lane's story), and stop. The lead approves it within the project's delegated rules, or asks the person to run"
|
|
109
|
+
: "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";
|
|
101
110
|
block(
|
|
102
111
|
`Blocked: a new dependency (${unapproved.join(', ')}) needs a person's approval. ${ask}\n${approvalHowTo(
|
|
103
112
|
unapproved.map(dependencyApproval),
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -47,7 +47,32 @@ export function withDefaults(c) {
|
|
|
47
47
|
postEdit: c.postEdit ?? [],
|
|
48
48
|
checks: c.checks ?? [],
|
|
49
49
|
branches: { protected: c.branches?.protected ?? ['main', 'master'] },
|
|
50
|
-
approvals: { lead: c.approvals?.lead === true },
|
|
50
|
+
approvals: { lead: c.approvals?.lead === true, delegate: delegateRules(c.approvals?.delegate) },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The rules within which the lead approves on its own (`approvals.delegate`), or null when it may not:
|
|
56
|
+
* the protected approvals it may grant, and the dependency rule packages must meet.
|
|
57
|
+
*/
|
|
58
|
+
function delegateRules(d) {
|
|
59
|
+
if (!d) return null;
|
|
60
|
+
return {
|
|
61
|
+
protected: d.protected ?? [],
|
|
62
|
+
dependencies: d.dependencies
|
|
63
|
+
? {
|
|
64
|
+
licences: d.dependencies.licences ?? [
|
|
65
|
+
'MIT',
|
|
66
|
+
'Apache-2.0',
|
|
67
|
+
'BSD-2-Clause',
|
|
68
|
+
'BSD-3-Clause',
|
|
69
|
+
'ISC',
|
|
70
|
+
],
|
|
71
|
+
minWeeklyDownloads: d.dependencies.minWeeklyDownloads ?? 10_000,
|
|
72
|
+
maxMonthsSinceRelease: d.dependencies.maxMonthsSinceRelease ?? 18,
|
|
73
|
+
allowInstallScripts: d.dependencies.allowInstallScripts === true,
|
|
74
|
+
}
|
|
75
|
+
: null,
|
|
51
76
|
};
|
|
52
77
|
}
|
|
53
78
|
|
|
@@ -120,6 +145,21 @@ export function validate(c) {
|
|
|
120
145
|
(isObj(c.approvals) && [undefined, true, false].includes(c.approvals.lead)),
|
|
121
146
|
'"approvals.lead" must be true or false',
|
|
122
147
|
);
|
|
148
|
+
const d = c.approvals?.delegate;
|
|
149
|
+
const dep = d?.dependencies;
|
|
150
|
+
const isCount = (v) => v === undefined || (Number.isInteger(v) && v >= 0);
|
|
151
|
+
need(
|
|
152
|
+
d === undefined ||
|
|
153
|
+
(isObj(d) &&
|
|
154
|
+
(d.protected === undefined || isList(d.protected)) &&
|
|
155
|
+
(dep === undefined ||
|
|
156
|
+
(isObj(dep) &&
|
|
157
|
+
(dep.licences === undefined || isList(dep.licences)) &&
|
|
158
|
+
isCount(dep.minWeeklyDownloads) &&
|
|
159
|
+
isCount(dep.maxMonthsSinceRelease) &&
|
|
160
|
+
[undefined, true, false].includes(dep.allowInstallScripts)))),
|
|
161
|
+
'"approvals.delegate" must be { protected?: [approval names], dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts? } }',
|
|
162
|
+
);
|
|
123
163
|
need(
|
|
124
164
|
c.branches === undefined || isList(c.branches?.protected),
|
|
125
165
|
'"branches.protected" must be a list of branch names',
|
package/hooks/lib/next.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { join, posix } from 'node:path';
|
|
7
7
|
import { loadConfig } from './config.mjs';
|
|
8
|
-
import { buildStatus, readStories } from './plan.mjs';
|
|
8
|
+
import { buildStatus, readStories, LEAD } from './plan.mjs';
|
|
9
9
|
|
|
10
10
|
export const DRAFT_FILE = '.claude/code-kit.draft.json';
|
|
11
11
|
export const BRIEF_STATES = ['framed', 'explored', 'scoped', 'specified', 'planned', 'ready'];
|
|
@@ -154,11 +154,13 @@ export function nextStep(root, base) {
|
|
|
154
154
|
// A ready story with a gap (unknown lane, a requirement no spec defines) waits for the plan's fix.
|
|
155
155
|
const defined = new Set(status.requirements.map((r) => r.id));
|
|
156
156
|
const buildable = (s) =>
|
|
157
|
-
Boolean(config.lanes[s.lane]) &&
|
|
158
|
-
s.requirements.length > 0 &&
|
|
157
|
+
(Boolean(config.lanes[s.lane]) || s.lane === LEAD) &&
|
|
158
|
+
(s.requirements.length > 0 || s.noRequirements) &&
|
|
159
159
|
s.requirements.every((r) => defined.has(r));
|
|
160
160
|
const readyStories = status.stories.filter((s) => s.state === 'ready');
|
|
161
|
-
|
|
161
|
+
// The lead's own stories are built by the lead, not dispatched to a lane agent.
|
|
162
|
+
const forLead = readyStories.filter((s) => buildable(s) && s.lane === LEAD).map((s) => s.id);
|
|
163
|
+
const ready = readyStories.filter((s) => buildable(s) && s.lane !== LEAD).map((s) => s.id);
|
|
162
164
|
const unbuildable = readyStories.filter((s) => !buildable(s)).map((s) => s.id);
|
|
163
165
|
if (unbuildable.length)
|
|
164
166
|
attention.push(
|
|
@@ -166,6 +168,7 @@ export function nextStep(root, base) {
|
|
|
166
168
|
);
|
|
167
169
|
const then = [];
|
|
168
170
|
if (review.length && ready.length) then.push({ step: 'dispatch', args: ready.join(' ') });
|
|
171
|
+
if (forLead.length) then.push({ step: 'lead', args: forLead.join(' ') });
|
|
169
172
|
if (review.length)
|
|
170
173
|
return result('review', `${review.join(', ')} finished and wait for review.`, {
|
|
171
174
|
args: review.join(' '),
|
|
@@ -174,7 +177,14 @@ export function nextStep(root, base) {
|
|
|
174
177
|
if (ready.length)
|
|
175
178
|
return result('dispatch', `${ready.join(', ')} can start: their dependencies are done.`, {
|
|
176
179
|
args: ready.join(' '),
|
|
180
|
+
then,
|
|
177
181
|
});
|
|
182
|
+
if (forLead.length)
|
|
183
|
+
return result(
|
|
184
|
+
'lead',
|
|
185
|
+
`${forLead.join(', ')} ${forLead.length === 1 ? 'is' : 'are'} the lead's own: build ${forLead.length === 1 ? 'it' : 'them'} on ${forLead.map((id) => `lead/${id.toLowerCase()}`).join(', ')}, then review like any branch.`,
|
|
186
|
+
{ args: forLead.join(' ') },
|
|
187
|
+
);
|
|
178
188
|
if (building.length)
|
|
179
189
|
return result(
|
|
180
190
|
'wait',
|
package/hooks/lib/plan.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Requirements and stories, read from the project's docs. Specs (docs.specs) hold requirements as
|
|
2
2
|
// headings `### BOOK-4 Short name` (`(removed)` at the end drops one); the plan (docs.plan) holds
|
|
3
3
|
// stories as `### ST-12 Title` followed by `**Lane:**`, `**Requirements:**`, `**Depends on:**` and
|
|
4
|
-
// `**Status:**` lines. A lane builds a story on the branch `<lane>/st-12`.
|
|
4
|
+
// `**Status:**` lines. A lane builds a story on the branch `<lane>/st-12`. The lane `lead` marks the
|
|
5
|
+
// lead's own stories (contracts, foundations, spikes), which the lead builds rather than dispatches;
|
|
6
|
+
// `**Requirements:** none` marks a story that delivers no requirement, such as a spike.
|
|
5
7
|
import { execFileSync } from 'node:child_process';
|
|
6
8
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
9
|
import { join } from 'node:path';
|
|
@@ -10,6 +12,7 @@ export const REQUIREMENT = /\b(?!ST-)([A-Z][A-Z0-9]*-\d+)\b/g;
|
|
|
10
12
|
const STORY_HEADING = /^###\s+(ST-\d+)\b\s*(.*)$/;
|
|
11
13
|
const REQUIREMENT_HEADING = /^###\s+((?!ST-)[A-Z][A-Z0-9]*-\d+)\b\s*(.*)$/;
|
|
12
14
|
export const STATES = ['todo', 'in progress', 'review', 'done'];
|
|
15
|
+
export const LEAD = 'lead';
|
|
13
16
|
// Test files, by the usual conventions. Tests name the requirement IDs they prove.
|
|
14
17
|
const TEST =
|
|
15
18
|
/(^|\/)(tests?|__tests__|e2e)\/|\.(test|spec)\.[^/]+$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$/;
|
|
@@ -60,6 +63,7 @@ export function readStories(root, planPath) {
|
|
|
60
63
|
file,
|
|
61
64
|
lane: field(body, 'Lane'),
|
|
62
65
|
requirements: [...field(body, 'Requirements').matchAll(REQUIREMENT)].map((r) => r[1]),
|
|
66
|
+
noRequirements: /^none\b/i.test(field(body, 'Requirements')),
|
|
63
67
|
dependsOn: [...field(body, 'Depends on').matchAll(/\bST-\d+\b/g)].map((r) => r[0]),
|
|
64
68
|
status: STATES.includes(status) ? status : 'todo',
|
|
65
69
|
});
|
|
@@ -168,8 +172,10 @@ export function traceProblems(requirements, stories, lanes) {
|
|
|
168
172
|
if (storyIds.has(s.id)) problems.push(`${s.id} is in the plan more than once`);
|
|
169
173
|
storyIds.add(s.id);
|
|
170
174
|
if (!s.lane) problems.push(`${s.id} has no **Lane:**`);
|
|
171
|
-
else if (
|
|
172
|
-
|
|
175
|
+
else if (s.lane !== LEAD && !lanes.includes(s.lane))
|
|
176
|
+
problems.push(`${s.id} names an unknown lane "${s.lane}"`);
|
|
177
|
+
if (!s.requirements.length && !s.noRequirements)
|
|
178
|
+
problems.push(`${s.id} cites no requirements (write "none" if it delivers none)`);
|
|
173
179
|
for (const r of s.requirements)
|
|
174
180
|
if (!ids.has(r)) problems.push(`${s.id} cites ${r}, which no spec defines`);
|
|
175
181
|
for (const d of s.dependsOn)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What the npm registry says about a package, for the lead's delegated dependency approvals: the
|
|
2
|
+
// licence, how often it's downloaded, when it was last released, and whether installing it runs
|
|
3
|
+
// scripts. The rule itself is a pure function, so it's tested without the network.
|
|
4
|
+
import { DEPENDENCY_APPROVAL } from './dependencies.mjs';
|
|
5
|
+
|
|
6
|
+
const REGISTRY = () => process.env.CODE_KIT_NPM_REGISTRY ?? 'https://registry.npmjs.org';
|
|
7
|
+
const DOWNLOADS = () => process.env.CODE_KIT_NPM_DOWNLOADS ?? 'https://api.npmjs.org';
|
|
8
|
+
|
|
9
|
+
/** The package an approval name stands for: `dep-@scope+pkg` → `@scope/pkg`. */
|
|
10
|
+
export const packageOf = (approvalName) =>
|
|
11
|
+
approvalName.slice(DEPENDENCY_APPROVAL.length).replace(/\+/g, '/');
|
|
12
|
+
|
|
13
|
+
const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall'];
|
|
14
|
+
|
|
15
|
+
/** { licence, weeklyDownloads, lastRelease (ISO), installScripts } for `pkg`, or { error }. */
|
|
16
|
+
export async function npmFacts(pkg) {
|
|
17
|
+
const name = pkg.startsWith('@')
|
|
18
|
+
? `@${encodeURIComponent(pkg.slice(1))}`
|
|
19
|
+
: encodeURIComponent(pkg);
|
|
20
|
+
try {
|
|
21
|
+
const meta = await fetch(`${REGISTRY()}/${name}`, {
|
|
22
|
+
headers: { accept: 'application/json' },
|
|
23
|
+
});
|
|
24
|
+
if (meta.status === 404) return { error: `${pkg} isn't on the npm registry` };
|
|
25
|
+
if (!meta.ok) return { error: `the npm registry answered ${meta.status} for ${pkg}` };
|
|
26
|
+
const doc = await meta.json();
|
|
27
|
+
const latest = doc['dist-tags']?.latest;
|
|
28
|
+
const version = latest ? doc.versions?.[latest] : undefined;
|
|
29
|
+
const licence = version?.license ?? doc.license;
|
|
30
|
+
const dl = await fetch(`${DOWNLOADS()}/downloads/point/last-week/${name}`);
|
|
31
|
+
const weeklyDownloads = dl.ok ? ((await dl.json()).downloads ?? 0) : 0;
|
|
32
|
+
return {
|
|
33
|
+
licence: typeof licence === 'string' ? licence : (licence?.type ?? 'unknown'),
|
|
34
|
+
weeklyDownloads,
|
|
35
|
+
lastRelease: (latest && doc.time?.[latest]) ?? doc.time?.modified ?? null,
|
|
36
|
+
installScripts: INSTALL_SCRIPTS.some((s) => version?.scripts?.[s]),
|
|
37
|
+
};
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return { error: `couldn't reach the npm registry (${e.message})` };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Why `facts` falls outside `rule`, as sentences; empty when it's within it. */
|
|
44
|
+
export function dependencyRuleProblems(facts, rule, now = Date.now()) {
|
|
45
|
+
if (facts.error) return [facts.error];
|
|
46
|
+
const problems = [];
|
|
47
|
+
if (!rule.licences.includes(facts.licence))
|
|
48
|
+
problems.push(`its licence (${facts.licence}) isn't one of ${rule.licences.join(', ')}`);
|
|
49
|
+
if (facts.weeklyDownloads < rule.minWeeklyDownloads)
|
|
50
|
+
problems.push(
|
|
51
|
+
`it had ${facts.weeklyDownloads} downloads last week, under ${rule.minWeeklyDownloads}`,
|
|
52
|
+
);
|
|
53
|
+
const months = facts.lastRelease
|
|
54
|
+
? (now - Date.parse(facts.lastRelease)) / (30.44 * 24 * 3600 * 1000)
|
|
55
|
+
: Infinity;
|
|
56
|
+
if (months > rule.maxMonthsSinceRelease)
|
|
57
|
+
problems.push(
|
|
58
|
+
`its last release was ${Number.isFinite(months) ? `${Math.floor(months)} months` : 'never'} ago, over ${rule.maxMonthsSinceRelease}`,
|
|
59
|
+
);
|
|
60
|
+
if (facts.installScripts && !rule.allowInstallScripts)
|
|
61
|
+
problems.push('installing it runs scripts');
|
|
62
|
+
return problems;
|
|
63
|
+
}
|
package/hooks/lib/rules.mjs
CHANGED
|
@@ -40,6 +40,13 @@ export const CLI = fileURLToPath(new URL('../../bin/code-kit.mjs', import.meta.u
|
|
|
40
40
|
export const RELAYED = '(given in chat, recorded by the lead)';
|
|
41
41
|
|
|
42
42
|
/** The command the lead runs to record approvals a person gave in chat. */
|
|
43
|
+
/** Marks an approval the lead granted on its own, within `approvals.delegate`; `rule` names the rule. */
|
|
44
|
+
export const delegatedMark = (rule) => `(approved by the lead within the delegated rules: ${rule})`;
|
|
45
|
+
|
|
46
|
+
/** The command the lead runs to approve `names` itself, within the delegated rules. */
|
|
47
|
+
export const delegateCommand = (names, lane) =>
|
|
48
|
+
`node "${CLI}" approve ${[names].flat().join(' ')}${lane ? ` --lane ${lane}` : ''} --delegated --reason "<why, for this story>"`;
|
|
49
|
+
|
|
43
50
|
export const relayCommand = (names, lane) =>
|
|
44
51
|
`node "${CLI}" approve ${[names].flat().join(' ')}${lane ? ` --lane ${lane}` : ''} --reason "<the person's words>"`;
|
|
45
52
|
|
|
@@ -63,19 +70,22 @@ export function approvalHowTo(names, actor, root, allows = 'these edits', config
|
|
|
63
70
|
const relay = config?.approvals?.lead
|
|
64
71
|
? `\nOr, if the person approves in chat (no \`!\` on a phone), the lead records their words:\n ${relayCommand(names, actor?.kind === 'lane' ? actor.lane : undefined)}`
|
|
65
72
|
: '';
|
|
73
|
+
const delegated = config?.approvals?.delegate
|
|
74
|
+
? `\nOr the lead approves it itself, if it's within the project's delegated rules (the command refuses anything outside them):\n ${delegateCommand(names, actor?.kind === 'lane' ? actor.lane : undefined)}`
|
|
75
|
+
: '';
|
|
66
76
|
return (
|
|
67
77
|
`${lines.join('\n')}\n` +
|
|
68
|
-
`(a person runs it; the reason is recorded in ${APPROVAL_LOG}, and it allows ${allows} for ${whom}, for ${APPROVAL_MINUTES} minutes)${relay}`
|
|
78
|
+
`(a person runs it; the reason is recorded in ${APPROVAL_LOG}, and it allows ${allows} for ${whom}, for ${APPROVAL_MINUTES} minutes)${relay}${delegated}`
|
|
69
79
|
);
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
/** Records approvals a person gave in chat: the same files a `!` command writes, marked as relayed. */
|
|
73
|
-
export function grantApprovals(root, names, lane, reason) {
|
|
83
|
+
export function grantApprovals(root, names, lane, reason, marks = {}) {
|
|
74
84
|
const dir = lane ? join(approvalsDir(root), lane) : approvalsDir(root);
|
|
75
85
|
mkdirSync(dir, { recursive: true });
|
|
76
86
|
return names.map((name) => {
|
|
77
87
|
const file = join(dir, name);
|
|
78
|
-
writeFileSync(file, `${reason.trim()} ${RELAYED}\n`);
|
|
88
|
+
writeFileSync(file, `${reason.trim()} ${marks[name] ?? RELAYED}\n`);
|
|
79
89
|
return file;
|
|
80
90
|
});
|
|
81
91
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warren-dean/code-kit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Warren Langeveldt",
|
package/skills/dispatch/SKILL.md
CHANGED
|
@@ -17,7 +17,7 @@ The CLI is `node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs"`, run from the project
|
|
|
17
17
|
node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs" status
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
A story is **ready** when its dependencies are done and nobody has started it. `status` also lists gaps between the specs and the plan; a story with a gap (an unknown lane, a requirement no spec defines) isn't dispatched until the lead fixes the plan.
|
|
20
|
+
A story is **ready** when its dependencies are done and nobody has started it. `status` also lists gaps between the specs and the plan; a story with a gap (an unknown lane, a requirement no spec defines) isn't dispatched until the lead fixes the plan. Stories in the `lead` lane are never dispatched: the lead builds them itself.
|
|
21
21
|
|
|
22
22
|
If the project has no `docs.plan`, or the plan has no stories in the `### ST-n` form (`${CLAUDE_PLUGIN_ROOT}/templates/plan.md`), stop and suggest the spec-design skill.
|
|
23
23
|
|
package/skills/next/SKILL.md
CHANGED
|
@@ -26,6 +26,7 @@ It returns `step`, `why`, `args`, `then` (steps that can follow straight after)
|
|
|
26
26
|
| Config, but no specs, plan or stories | `spec-design` |
|
|
27
27
|
| Stories finished on their branches | `review`, then `dispatch` what's ready |
|
|
28
28
|
| Stories ready | `dispatch` |
|
|
29
|
+
| The lead's own stories ready (`**Lane:** lead`) | `lead`: the lead builds them itself |
|
|
29
30
|
| Stories being built, nothing else can start | wait |
|
|
30
31
|
| Ready stories with gaps in the plan, or a dependency cycle | `spec-design`, to fix the plan |
|
|
31
32
|
| Every story done | done: `status`, then `spec-design` for the next milestone |
|
|
@@ -36,6 +37,7 @@ Tell the person in one or two lines what you found and what you're about to run,
|
|
|
36
37
|
|
|
37
38
|
Run the named skill with the Skill tool: `code-kit:<step>`, with `args` (and the person's argument, if they gave one). Follow that skill in full. This skill only chooses; it never does a step's work itself.
|
|
38
39
|
|
|
40
|
+
- **`lead`:** there's no skill to run. Build each story yourself, on the branch `lead/st-<n>`: spec-check it first if it cites requirements, do the work, commit, then run `code-kit:review` on the branch like any other. A spike's result goes in the docs it names.
|
|
39
41
|
- **`wait`:** say which stories are being built and on which branches, then stop. When a lane agent finishes, its notification arrives in this session; run this skill again then. If a story has been in progress with no agent running (the session ended, or the agent stopped without committing), dispatch it again.
|
|
40
42
|
- **`done`:** give the `status` summary in a few lines, then offer to run spec-design for the next milestone or feature.
|
|
41
43
|
|
|
@@ -104,8 +104,8 @@ These are what `/code-kit:init` turns into enforced rules, so be concrete.
|
|
|
104
104
|
- **`docs/plan.md`**, from `${CLAUDE_PLUGIN_ROOT}/templates/plan.md`:
|
|
105
105
|
- the **work split**: lanes (for example web, api, data), the paths each will own, the agent name for it and the command that proves its work, so work can run in parallel without overlap;
|
|
106
106
|
- milestones, each a usable increment;
|
|
107
|
-
- stories, each small enough for one lane to finish on one branch. Each story is a `### ST-<n> Title` heading with `**Lane:**`, `**Requirements:**` (the IDs it delivers), `**Depends on:**` and `**Status:** todo` lines. `code-kit status` and the dispatch skill read them. A story that needs two lanes is two stories, with a dependency between them;
|
|
108
|
-
- sequencing: contracts and shared foundations first
|
|
107
|
+
- stories, each small enough for one lane to finish on one branch. Each story is a `### ST-<n> Title` heading with `**Lane:**`, `**Requirements:**` (the IDs it delivers, or `none` for a spike or a foundation that delivers none), `**Depends on:**` and `**Status:** todo` lines. `code-kit status` and the dispatch skill read them. A story that needs two lanes is two stories, with a dependency between them;
|
|
108
|
+
- sequencing: contracts and shared foundations first, as the lead's own stories (`**Lane:** lead`, built by the lead rather than dispatched), then work that can run in parallel.
|
|
109
109
|
|
|
110
110
|
`Status: planned`.
|
|
111
111
|
|
package/templates/plan.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Plan
|
|
2
2
|
|
|
3
|
-
`code-kit status` reads this file. Keep each story's heading and its four `**…:**` lines in this form.
|
|
3
|
+
`code-kit status` reads this file. Keep each story's heading and its four `**…:**` lines in this form. Use `**Lane:** lead` for the lead's own stories (contracts, foundations, spikes), and `**Requirements:** none` for a story that delivers no requirement.
|
|
4
4
|
|
|
5
5
|
## Lanes
|
|
6
6
|
|