@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.
@@ -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.7",
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.7",
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 approvals a person gave in chat
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
- | `branches.protected` | no | Default `["main", "master"]` |
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 { APPROVAL_MINUTES, currentBranch, grantApprovals, ownerOf } from '../hooks/lib/rules.mjs';
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
- function approve(names) {
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
- if (!reason?.trim()) die('approve needs --reason "<what the person approved, in their words>".');
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
- grantApprovals(resolve('.'), names, laneName, reason);
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)();
@@ -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 an approval a person gives in chat. Write a short change request for the lead (what needs approving, and why), and stop.\nCommand: ${cmd}`,
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
- if (approves && !forPerson)
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 let the lead record approvals ("approvals.lead" is off). Ask the person to run the \`!\` command from the refusal, or to turn "approvals": { "lead": true } on in the config.\nCommand: ${cmd}`,
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
- : "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";
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),
@@ -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',
@@ -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
- const ready = readyStories.filter(buildable).map((s) => s.id);
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',
@@ -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 (!lanes.includes(s.lane)) problems.push(`${s.id} names an unknown lane "${s.lane}"`);
172
- if (!s.requirements.length) problems.push(`${s.id} cites no requirements`);
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
+ }
@@ -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.7",
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",
@@ -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
 
@@ -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 (the lead's own stories), then work that can run in parallel.
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