@warren-dean/code-kit 0.3.6 → 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 +58 -23
- package/bin/code-kit.mjs +84 -4
- package/hooks/guard-bash.mjs +37 -7
- package/hooks/lib/adapters/context-graph.mjs +3 -1
- package/hooks/lib/adapters/index.mjs +2 -1
- package/hooks/lib/config.mjs +49 -2
- 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 +49 -6
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +1 -1
- package/skills/init/SKILL.md +2 -0
- package/skills/next/SKILL.md +3 -1
- package/skills/spec-design/SKILL.md +2 -2
- package/templates/claude-md.md +1 -0
- 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,6 +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] [--delegated] # record a person's approval from chat, or (--delegated) grant one within the delegated rules; a package name stands for its dep- approval
|
|
312
313
|
```
|
|
313
314
|
|
|
314
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`.
|
|
@@ -317,25 +318,27 @@ code-kit trace <path>... [--json] # what a file is for: requirements, lane,
|
|
|
317
318
|
|
|
318
319
|
`.claude/code-kit.json`. Globs use `*` (one segment), `**` (any depth), `**/` (zero or more folders), `?` and `{a,b}`. Patterns under `shell` are regular expressions.
|
|
319
320
|
|
|
320
|
-
| Field | Required | What it is
|
|
321
|
-
| ------------------------------ | ----------------- |
|
|
322
|
-
| `version` | yes | `1`
|
|
323
|
-
| `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch
|
|
324
|
-
| `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan
|
|
325
|
-
| `lead.paths` | yes | What the main session writes. `.claude/**` is always added.
|
|
326
|
-
| `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included.
|
|
327
|
-
| `anyActor` | no | Paths every writer may change (e.g. the lockfile). `.claude/state/**` is always included.
|
|
328
|
-
| `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.
|
|
329
|
-
| `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`.
|
|
330
|
-
| `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them
|
|
331
|
-
| `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself.
|
|
332
|
-
| `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths
|
|
333
|
-
| `shell.block[]` | no | `{ pattern, why }`: never run
|
|
334
|
-
| `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes
|
|
335
|
-
| `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped.
|
|
336
|
-
| `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).
|
|
337
|
-
| `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected. See [Adapters](#adapters).
|
|
338
|
-
| `
|
|
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"]` |
|
|
339
342
|
|
|
340
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.
|
|
341
344
|
|
|
@@ -354,6 +357,38 @@ echo "Date formatting for ST-12" > .claude/approvals/web/dep-dayjs #
|
|
|
354
357
|
- **Worktrees:** approvals live in the main checkout, so a lane working in its own worktree sees the ones given at the lead's terminal.
|
|
355
358
|
- **Lifetime:** 60 minutes. Agents can't create, edit or even mention approvals or the log.
|
|
356
359
|
|
|
360
|
+
### From chat
|
|
361
|
+
|
|
362
|
+
On a phone, or anywhere the `!` prefix arrives as plain text, a person can't run the command. With `"approvals": { "lead": true }` in the config (init asks), the person says what they approve in chat, and the lead records it:
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
code-kit approve dep-dayjs --lane web --reason "Yes, add dayjs for ST-12"
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
- It writes the same approval a `!` command would, for the same 60 minutes, with `(given in chat, recorded by the lead)` after the reason, so the log shows how it was given.
|
|
369
|
+
- Only the lead may run it. A lane or read-only agent is refused, and so is the lead while the setting is off or the config is invalid.
|
|
370
|
+
- With the setting on, refusals name this command alongside the `!` one.
|
|
371
|
+
- The lead may also run `shell.block` commands marked `person: true`, such as Context Graph's ratification trailer.
|
|
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.
|
|
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
|
+
|
|
357
392
|
## Adapters
|
|
358
393
|
|
|
359
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:
|
|
@@ -364,9 +399,9 @@ Other tools share a project with code-kit and keep files of their own. An adapte
|
|
|
364
399
|
|
|
365
400
|
While the tool is detected, its additions are merged into the effective config, like the kit's own defaults. Run `code-kit adapters` to see them, or switch one off with `"adapters": { "<name>": false }`.
|
|
366
401
|
|
|
367
|
-
| Adapter | Detected by | Adds
|
|
368
|
-
| --------------- | ----------- |
|
|
369
|
-
| `context-graph` | `.ctx/` | `.ctx/**` for the lead. `.ctx/decisions.ctx` and `.ctx/cards.ctx` for any actor, since every agent records decisions and file cards. `graph.ctx` and `config.toml` are protected (approval `ctx`). No Claude actor may write a `Ctx-Ratified-By` trailer: ratifying is a person's act. |
|
|
402
|
+
| Adapter | Detected by | Adds |
|
|
403
|
+
| --------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
404
|
+
| `context-graph` | `.ctx/` | `.ctx/**` for the lead. `.ctx/decisions.ctx` and `.ctx/cards.ctx` for any actor, since every agent records decisions and file cards. `graph.ctx` and `config.toml` are protected (approval `ctx`). No Claude actor may write a `Ctx-Ratified-By` trailer: ratifying is a person's act (with `approvals.lead`, the lead may add it when the person says so in chat). |
|
|
370
405
|
|
|
371
406
|
Its setup:
|
|
372
407
|
|
package/bin/code-kit.mjs
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// trace <path> [--json] what a file is for: the requirements it delivers, its lane, its layer rules
|
|
15
15
|
// next [--base ref] [--json]
|
|
16
16
|
// the step to take now (spec-design, init, dispatch, review…), from the project's state
|
|
17
|
+
// approve <name>... --reason "…" [--lane name]
|
|
18
|
+
// record approvals a person gave in chat (with approvals.lead on, the lead runs it)
|
|
17
19
|
// --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
|
|
18
20
|
import { execFileSync } from 'node:child_process';
|
|
19
21
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -26,10 +28,23 @@ import { ADAPTERS, activeAdapters } from '../hooks/lib/adapters/index.mjs';
|
|
|
26
28
|
import { nextStep } from '../hooks/lib/next.mjs';
|
|
27
29
|
import { traceFile } from '../hooks/lib/trace.mjs';
|
|
28
30
|
import { buildStatus } from '../hooks/lib/plan.mjs';
|
|
29
|
-
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';
|
|
30
40
|
import { laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
|
|
31
41
|
|
|
32
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
|
+
});
|
|
33
48
|
const die = (s) => {
|
|
34
49
|
process.stderr.write(`${s}\n`);
|
|
35
50
|
process.exit(1);
|
|
@@ -44,7 +59,9 @@ const file = option('--config') ?? CONFIG_FILE;
|
|
|
44
59
|
const depth = Number(option('--depth') ?? 2);
|
|
45
60
|
const baseRef = option('--base');
|
|
46
61
|
const branchName = option('--branch');
|
|
47
|
-
const
|
|
62
|
+
const reason = option('--reason');
|
|
63
|
+
const laneName = option('--lane');
|
|
64
|
+
const FLAGS = ['--write', '--no-checks', '--json', '--delegated'];
|
|
48
65
|
const flag = (name) => args.includes(name);
|
|
49
66
|
const writeBaseline = flag('--write');
|
|
50
67
|
const [command, ...rest] = args.filter((a) => !FLAGS.includes(a));
|
|
@@ -375,8 +392,70 @@ function trace(paths) {
|
|
|
375
392
|
}
|
|
376
393
|
}
|
|
377
394
|
|
|
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) {
|
|
399
|
+
const { config } = load();
|
|
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
|
+
);
|
|
407
|
+
if (laneName !== undefined && !config.lanes[laneName])
|
|
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
|
+
);
|
|
414
|
+
const bad = names.filter((n) => !/^[a-z0-9@][a-z0-9@+._-]*$/.test(n));
|
|
415
|
+
if (bad.length)
|
|
416
|
+
die(
|
|
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.`,
|
|
418
|
+
);
|
|
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);
|
|
449
|
+
const whom = laneName ? `the ${laneName} lane` : 'any agent';
|
|
450
|
+
const renamed = given.flatMap((g, i) => (g === names[i] ? [] : [`${g} → ${names[i]}`]));
|
|
451
|
+
out(
|
|
452
|
+
`Approved ${names.join(', ')} for ${whom}, for ${APPROVAL_MINUTES} minutes${delegated ? ', within the delegated rules' : ''}: ${reason.trim()}${renamed.length ? `\n(${renamed.join(', ')})` : ''}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
378
456
|
const commands = {
|
|
379
457
|
check,
|
|
458
|
+
approve: () => (rest.length ? approve(rest) : usage()),
|
|
380
459
|
trace: () => (rest.length ? trace(rest) : usage()),
|
|
381
460
|
adapters,
|
|
382
461
|
next,
|
|
@@ -392,7 +471,8 @@ function usage() {
|
|
|
392
471
|
die(
|
|
393
472
|
'Usage: code-kit check | who <path>... | unowned | diff | baseline [--write] | graph [--depth N]\n' +
|
|
394
473
|
' | verify [--base ref] [--branch name] [--no-checks] | status [--base ref] [--json]\n' +
|
|
395
|
-
' | next [--base ref] [--json] | adapters | trace <path>... [--json]
|
|
474
|
+
' | next [--base ref] [--json] | adapters | trace <path>... [--json]\n' +
|
|
475
|
+
' | approve <name>... --reason "…" [--lane name] [--config file]',
|
|
396
476
|
);
|
|
397
477
|
}
|
|
398
|
-
(commands[command] ?? usage)();
|
|
478
|
+
await (commands[command] ?? usage)();
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// dependencies a person hasn't approved, approval forgery, kit edits through the shell by anyone but
|
|
4
4
|
// the lead, the project's own blocked and lane-restricted commands; records protected files in the
|
|
5
5
|
// approval log on commit and scans commits for secrets. Shell writes are also checked afterwards by
|
|
6
|
-
// lane-audit.mjs.
|
|
6
|
+
// lane-audit.mjs. With `approvals.lead` on, the lead may act for the person on what they say in chat:
|
|
7
|
+
// record their approvals (`code-kit approve`) and run blocked commands marked `person`.
|
|
7
8
|
import { appendFileSync } from 'node:fs';
|
|
8
9
|
import { spawnSync } from 'node:child_process';
|
|
9
10
|
import { join, resolve } from 'node:path';
|
|
@@ -43,8 +44,10 @@ for (const [re, why] of ALWAYS) {
|
|
|
43
44
|
if (re.test(cmd)) block(`Blocked: ${why}\nCommand: ${cmd}`);
|
|
44
45
|
}
|
|
45
46
|
const packages = addedPackages(cmd);
|
|
47
|
+
const approves = /\bcode-kit(\.mjs)?["']?\s+approve\b/.test(cmd);
|
|
46
48
|
if (error) {
|
|
47
49
|
if (/\bgit\s+commit\b/.test(cmd)) block(`Blocked: ${error}\nFix the config before committing.`);
|
|
50
|
+
if (approves) block(`Blocked: ${error}\nApprovals wait until the config is valid.`);
|
|
48
51
|
if (packages.length)
|
|
49
52
|
block(`Blocked: ${error}\nNew dependencies wait until the config is valid.\nCommand: ${cmd}`);
|
|
50
53
|
process.exit(0);
|
|
@@ -58,12 +61,36 @@ if (new RegExp(`\\bgit\\s+push\\b.*\\b(${branches})\\b`).test(cmd)) {
|
|
|
58
61
|
`Blocked: push to your own branch; a person merges to ${config.branches.protected[0]} after review.\nCommand: ${cmd}`,
|
|
59
62
|
);
|
|
60
63
|
}
|
|
61
|
-
for (const { pattern, why } of config.shell.block) {
|
|
62
|
-
if (new RegExp(pattern).test(cmd)) block(`Blocked: ${why}\nCommand: ${cmd}`);
|
|
63
|
-
}
|
|
64
64
|
|
|
65
65
|
const sessionIn = sessionRoot(input, project);
|
|
66
66
|
const actor = actorFor(input, sessionIn ?? project, config);
|
|
67
|
+
// The lead acts for the person only where the config says the person may approve from chat.
|
|
68
|
+
const forPerson = actor.kind === 'lead' && config.approvals.lead;
|
|
69
|
+
|
|
70
|
+
for (const { pattern, why, person } of config.shell.block) {
|
|
71
|
+
if (!new RegExp(pattern).test(cmd) || (person && forPerson)) continue;
|
|
72
|
+
const hint =
|
|
73
|
+
person && actor.kind === 'lead'
|
|
74
|
+
? '\nTo let the lead run it when the person says so in chat, a person sets "approvals": { "lead": true } in the config.'
|
|
75
|
+
: '';
|
|
76
|
+
block(`Blocked: ${why}${hint}\nCommand: ${cmd}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (approves && actor.kind !== 'lead')
|
|
80
|
+
block(
|
|
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
|
+
);
|
|
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)
|
|
87
|
+
block(
|
|
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}`,
|
|
93
|
+
);
|
|
67
94
|
|
|
68
95
|
// Each new package needs a person's approval, for this actor; it also covers the manifest and lockfile
|
|
69
96
|
// changes the install makes (lib/rules.mjs › dependencyChange).
|
|
@@ -77,13 +104,16 @@ if (packages.length) {
|
|
|
77
104
|
const ask =
|
|
78
105
|
actor.kind === 'lead'
|
|
79
106
|
? 'Ask the person to approve it by running'
|
|
80
|
-
:
|
|
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";
|
|
81
110
|
block(
|
|
82
111
|
`Blocked: a new dependency (${unapproved.join(', ')}) needs a person's approval. ${ask}\n${approvalHowTo(
|
|
83
112
|
unapproved.map(dependencyApproval),
|
|
84
113
|
actor,
|
|
85
114
|
sessionIn ?? project,
|
|
86
115
|
'installing it, and the manifest and lockfile changes that makes,',
|
|
116
|
+
config,
|
|
87
117
|
)}\nCommand: ${cmd}`,
|
|
88
118
|
);
|
|
89
119
|
}
|
|
@@ -127,7 +157,7 @@ const writes =
|
|
|
127
157
|
);
|
|
128
158
|
if (touchesKit && writes && actor.kind !== 'lead' && !approval(root, 'kit', actor)) {
|
|
129
159
|
block(
|
|
130
|
-
`Blocked: the .claude kit is changed only by the lead or with a person's approval. Ask the lead to run\n${approvalHowTo('kit', actor, root)}`,
|
|
160
|
+
`Blocked: the .claude kit is changed only by the lead or with a person's approval. Ask the lead to run\n${approvalHowTo('kit', actor, root, undefined, config)}`,
|
|
131
161
|
);
|
|
132
162
|
}
|
|
133
163
|
|
|
@@ -179,7 +209,7 @@ function recordApprovals() {
|
|
|
179
209
|
const byDependency = actor.kind === 'lane' && dependencyChange(actor, file, root, config);
|
|
180
210
|
if (entry && !granted && actor.kind !== 'lead' && !byDependency) {
|
|
181
211
|
block(
|
|
182
|
-
`Blocked: ${file} is ${entry.why}; committing it needs a person's approval in force. Ask the lead to run\n${approvalHowTo(entry.approval, actor, root)}`,
|
|
212
|
+
`Blocked: ${file} is ${entry.why}; committing it needs a person's approval in force. Ask the lead to run\n${approvalHowTo(entry.approval, actor, root, undefined, config)}`,
|
|
183
213
|
);
|
|
184
214
|
}
|
|
185
215
|
if (granted)
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// each file's card (its why) in cards.ctx. Every agent records decisions and cards for the files it
|
|
4
4
|
// edits, so those two files are writable by any actor; the rules and settings
|
|
5
5
|
// are the lead's, and changes to them go through the approval log. Ratifying a rule is a person's act,
|
|
6
|
-
// signed with a commit trailer, so no Claude actor may write that trailer
|
|
6
|
+
// signed with a commit trailer, so no Claude actor may write that trailer, unless the project lets the
|
|
7
|
+
// lead act on what the person says in chat (`approvals.lead`).
|
|
7
8
|
import { existsSync } from 'node:fs';
|
|
8
9
|
import { join } from 'node:path';
|
|
9
10
|
|
|
@@ -23,6 +24,7 @@ export default {
|
|
|
23
24
|
{
|
|
24
25
|
pattern: 'Ctx-Ratified-By\\s*[:=]',
|
|
25
26
|
why: "Ratifying a Context Graph rule is a person's act: they add the Ctx-Ratified-By trailer themselves, with a `!` git command.",
|
|
27
|
+
person: true,
|
|
26
28
|
},
|
|
27
29
|
],
|
|
28
30
|
},
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// name the tool, and the key that switches it off in the config: "adapters": { "<name>": false }
|
|
5
5
|
// detect (root) => true when the tool is in use in this project
|
|
6
6
|
// config { lead?, anyActor?, protected? } merged into the effective config, like the kit's defaults
|
|
7
|
-
// shell { block? } commands refused for every Claude actor, merged into shell.block
|
|
7
|
+
// shell { block? } commands refused for every Claude actor, merged into shell.block; `person: true`
|
|
8
|
+
// marks a person's act the lead may do for them under approvals.lead
|
|
8
9
|
// setup lines for init to apply or tell the person: ignore files, merge drivers, instructions
|
|
9
10
|
//
|
|
10
11
|
// The core only ever calls applyAdapters() and activeAdapters(); it never names a tool.
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -47,6 +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, 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,
|
|
50
76
|
};
|
|
51
77
|
}
|
|
52
78
|
|
|
@@ -114,6 +140,26 @@ export function validate(c) {
|
|
|
114
140
|
Object.entries(c.adapters).every(([k, v]) => known.includes(k) && typeof v === 'boolean')),
|
|
115
141
|
`"adapters" must map adapter names (${known.join(', ')}) to true or false`,
|
|
116
142
|
);
|
|
143
|
+
need(
|
|
144
|
+
c.approvals === undefined ||
|
|
145
|
+
(isObj(c.approvals) && [undefined, true, false].includes(c.approvals.lead)),
|
|
146
|
+
'"approvals.lead" must be true or false',
|
|
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
|
+
);
|
|
117
163
|
need(
|
|
118
164
|
c.branches === undefined || isList(c.branches?.protected),
|
|
119
165
|
'"branches.protected" must be a list of branch names',
|
|
@@ -176,9 +222,10 @@ function validateLayers(c, need, each) {
|
|
|
176
222
|
function validateShell(s, lanes, need) {
|
|
177
223
|
if (!need(isObj(s), '"shell" must be an object')) return;
|
|
178
224
|
const rule = (r) => isObj(r) && isRegExp(r.pattern) && isStr(r.why);
|
|
225
|
+
const blockRule = (r) => rule(r) && [undefined, true, false].includes(r.person);
|
|
179
226
|
need(
|
|
180
|
-
s.block === undefined || (Array.isArray(s.block) && s.block.every(
|
|
181
|
-
'"shell.block" must be a list of { pattern, why }',
|
|
227
|
+
s.block === undefined || (Array.isArray(s.block) && s.block.every(blockRule)),
|
|
228
|
+
'"shell.block" must be a list of { pattern, why, person? }',
|
|
182
229
|
);
|
|
183
230
|
const restricted =
|
|
184
231
|
s.restricted === undefined ||
|
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
|
@@ -10,11 +10,22 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Approvals live in the project's main checkout, so a lane working in its own worktree sees the ones a
|
|
12
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
|
-
|
|
13
|
+
// without a lane serves every actor. A person gives one with a `!` shell command, or, where the config
|
|
14
|
+
// sets `approvals.lead` (a person on a phone has no `!`), by saying so in chat for the lead to record
|
|
15
|
+
// with `code-kit approve`.
|
|
16
|
+
import {
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
readdirSync,
|
|
21
|
+
realpathSync,
|
|
22
|
+
statSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from 'node:fs';
|
|
15
25
|
import { execFileSync } from 'node:child_process';
|
|
16
26
|
import { homedir } from 'node:os';
|
|
17
27
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
18
29
|
import { DEPENDENCY_APPROVAL, isDependencyFile } from './dependencies.mjs';
|
|
19
30
|
import { globToRegExp, matchesAny } from './glob.mjs';
|
|
20
31
|
|
|
@@ -24,12 +35,27 @@ export const APPROVAL_MINUTES = 60;
|
|
|
24
35
|
// Marks a worktree as a lane's, for a person running that lane; the lead creates it, and it is never work.
|
|
25
36
|
export const LANE_FILE = '.lane';
|
|
26
37
|
const NEVER_WRITABLE = [`${APPROVALS_DIR}/**`, APPROVAL_LOG];
|
|
38
|
+
export const CLI = fileURLToPath(new URL('../../bin/code-kit.mjs', import.meta.url));
|
|
39
|
+
// Marks an approval the lead recorded from chat, so the log shows how it was given.
|
|
40
|
+
export const RELAYED = '(given in chat, recorded by the lead)';
|
|
41
|
+
|
|
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
|
+
|
|
50
|
+
export const relayCommand = (names, lane) =>
|
|
51
|
+
`node "${CLI}" approve ${[names].flat().join(' ')}${lane ? ` --lane ${lane}` : ''} --reason "<the person's words>"`;
|
|
27
52
|
|
|
28
53
|
/**
|
|
29
54
|
* 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.
|
|
55
|
+
* approval is that lane's; the folder is created here so the command works as written. With
|
|
56
|
+
* `approvals.lead` on, the lead may record it instead, once the person approves in chat.
|
|
31
57
|
*/
|
|
32
|
-
export function approvalHowTo(names, actor, root, allows = 'these edits') {
|
|
58
|
+
export function approvalHowTo(names, actor, root, allows = 'these edits', config) {
|
|
33
59
|
const dir = approvalsDir(root);
|
|
34
60
|
const scoped = actor?.kind === 'lane' ? join(dir, actor.lane) : dir;
|
|
35
61
|
try {
|
|
@@ -41,12 +67,29 @@ export function approvalHowTo(names, actor, root, allows = 'these edits') {
|
|
|
41
67
|
const where = shown.startsWith('..') ? scoped : shown;
|
|
42
68
|
const lines = [names].flat().map((n) => ` ! echo "<what you are approving>" > ${where}/${n}`);
|
|
43
69
|
const whom = actor?.kind === 'lane' ? `the ${actor.lane} lane` : 'any agent';
|
|
70
|
+
const relay = config?.approvals?.lead
|
|
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)}`
|
|
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
|
+
: '';
|
|
44
76
|
return (
|
|
45
77
|
`${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)`
|
|
78
|
+
`(a person runs it; the reason is recorded in ${APPROVAL_LOG}, and it allows ${allows} for ${whom}, for ${APPROVAL_MINUTES} minutes)${relay}${delegated}`
|
|
47
79
|
);
|
|
48
80
|
}
|
|
49
81
|
|
|
82
|
+
/** Records approvals a person gave in chat: the same files a `!` command writes, marked as relayed. */
|
|
83
|
+
export function grantApprovals(root, names, lane, reason, marks = {}) {
|
|
84
|
+
const dir = lane ? join(approvalsDir(root), lane) : approvalsDir(root);
|
|
85
|
+
mkdirSync(dir, { recursive: true });
|
|
86
|
+
return names.map((name) => {
|
|
87
|
+
const file = join(dir, name);
|
|
88
|
+
writeFileSync(file, `${reason.trim()} ${marks[name] ?? RELAYED}\n`);
|
|
89
|
+
return file;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
50
93
|
/** `path` with symlinks resolved, even if it doesn't exist yet (macOS /var → /private/var). */
|
|
51
94
|
export function realPath(path) {
|
|
52
95
|
let dir = resolve(path);
|
|
@@ -234,7 +277,7 @@ export function writeProblem(actor, rel, root, config) {
|
|
|
234
277
|
// The lead writes protected files without an approval: the person reviews them in the pull request,
|
|
235
278
|
// and every committed change is in the audit log. Anyone else needs a person's approval as well.
|
|
236
279
|
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)}`;
|
|
280
|
+
return `${rel} is ${guarded.why} and needs a person's approval. Ask the lead (a person) to run\n${approvalHowTo(guarded.approval, actor, root, undefined, config)}`;
|
|
238
281
|
}
|
|
239
282
|
if (actor.kind === 'readonly') return `${actor.label} is read-only; it may not write ${rel}.`;
|
|
240
283
|
if (matchesAny(rel, config.anyActor)) return null;
|
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/init/SKILL.md
CHANGED
|
@@ -67,6 +67,7 @@ Write `.claude/code-kit.draft.json`. Follow `${CLAUDE_PLUGIN_ROOT}/README.md`
|
|
|
67
67
|
- **`shell.restricted`:** commands that affect a shared local service, limited to the lanes that own it.
|
|
68
68
|
- **`postEdit`:** the project's existing per-file linters and formatters, with `{file}`.
|
|
69
69
|
- **`checks`:** the typecheck, test, architecture and database commands, each scoped by `files`. Only include commands that pass on the current code, or ones the person chose to keep.
|
|
70
|
+
- **`approvals.lead`:** ask the person whether they'll ever approve from a phone or another chat-only client, where a `!` command arrives as plain text. If so, set it to `true`: the lead then records approvals they give in chat (`code-kit approve`), and runs the `shell.block` commands marked `person: true` when they say so. Mark a `shell.block` command `person: true` only if the person wants to be able to have the lead run it on their say-so. Leave the setting out if the lead will run unattended.
|
|
70
71
|
|
|
71
72
|
Prove the draft:
|
|
72
73
|
|
|
@@ -102,6 +103,7 @@ Show the person:
|
|
|
102
103
|
1. the layers and what each may depend on, and for brownfield the existing violations the baseline will record;
|
|
103
104
|
2. the lanes, their agents and their paths;
|
|
104
105
|
3. protected paths, blocked and restricted commands, and checks (including any left out because they fail today);
|
|
106
|
+
and whether the lead may record approvals given in chat (`approvals.lead`);
|
|
105
107
|
4. the active adapters and what each adds, from `adapters`;
|
|
106
108
|
5. every **question**: where the docs or the code are silent, ambiguous or contradictory. Don't guess; a wrong owner or layer blocks real work.
|
|
107
109
|
|
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
|
|
|
@@ -44,7 +46,7 @@ Run the named skill with the Skill tool: `code-kit:<step>`, with `args` (and the
|
|
|
44
46
|
After a step finishes, run `next --json` again and carry on with the new step, without asking, as long as nothing needs the person. Stop and hand over when:
|
|
45
47
|
|
|
46
48
|
- a skill asks the person questions (spec-design's rounds, spec-check's blocking questions, review's scope questions);
|
|
47
|
-
- something needs the person's approval (init's draft, an approval for a protected path);
|
|
49
|
+
- something needs the person's approval (init's draft, an approval for a protected path). If the config has `approvals.lead`, ask for it in chat: when they approve in their own message, record it with the `code-kit approve` command the refusal names (their words as the reason) and carry on;
|
|
48
50
|
- a person has to act: merge a pull request to a protected branch, add a CI secret;
|
|
49
51
|
- the step is `wait` or `done`;
|
|
50
52
|
- the same step comes back twice in a row with nothing changed. Report what's stuck instead of looping.
|
|
@@ -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/claude-md.md
CHANGED
|
@@ -7,6 +7,7 @@ The code-kit plugin enforces the rules in `.claude/code-kit.json` on every tool
|
|
|
7
7
|
- spec-check before lane code{{; designs approved before screens}};
|
|
8
8
|
- an audit log of every change to protected paths (`.claude/approval-log.jsonl`), which the person reviews in the pull request;
|
|
9
9
|
- a person's approval for every new dependency, per package and per lane. When a lane asks for one, give the person the package, the reason and the approval command from the refusal, and resume the lane once they've run it;
|
|
10
|
+
{{If approvals.lead is on: - approvals from chat. When the person approves in their own message (a `!` command doesn't work on a phone), record it with the `code-kit approve` command from the refusal, with their words as the reason. Never record an approval they haven't given, and never on a lane's say-so;}}
|
|
10
11
|
- the project's checks before any agent finishes.
|
|
11
12
|
|
|
12
13
|
If a hook blocks you, fix the cause; never work around it.
|
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
|
|