@warren-dean/code-kit 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +23 -0
  3. package/LICENSE +21 -0
  4. package/README.md +369 -0
  5. package/SECURITY.md +7 -0
  6. package/bin/code-kit.mjs +398 -0
  7. package/hooks/guard-bash.mjs +223 -0
  8. package/hooks/guard-paths.mjs +70 -0
  9. package/hooks/hooks.json +71 -0
  10. package/hooks/lane-audit.mjs +58 -0
  11. package/hooks/lib/adapters/context-graph.mjs +34 -0
  12. package/hooks/lib/adapters/index.mjs +34 -0
  13. package/hooks/lib/baseline.mjs +23 -0
  14. package/hooks/lib/checks.mjs +67 -0
  15. package/hooks/lib/config.mjs +211 -0
  16. package/hooks/lib/dependencies.mjs +129 -0
  17. package/hooks/lib/diff.mjs +122 -0
  18. package/hooks/lib/glob.mjs +42 -0
  19. package/hooks/lib/hook.mjs +19 -0
  20. package/hooks/lib/layers.mjs +57 -0
  21. package/hooks/lib/next.mjs +197 -0
  22. package/hooks/lib/plan.mjs +206 -0
  23. package/hooks/lib/rules.mjs +313 -0
  24. package/hooks/lib/trace.mjs +69 -0
  25. package/hooks/lib/verify.mjs +146 -0
  26. package/hooks/post-edit-check.mjs +40 -0
  27. package/hooks/stop-check.mjs +84 -0
  28. package/package.json +41 -0
  29. package/skills/check/SKILL.md +19 -0
  30. package/skills/dispatch/SKILL.md +83 -0
  31. package/skills/init/SKILL.md +151 -0
  32. package/skills/next/SKILL.md +52 -0
  33. package/skills/review/SKILL.md +57 -0
  34. package/skills/spec-check/SKILL.md +27 -0
  35. package/skills/spec-design/SKILL.md +137 -0
  36. package/skills/status/SKILL.md +42 -0
  37. package/templates/agent.md +29 -0
  38. package/templates/brief.md +45 -0
  39. package/templates/claude-md.md +23 -0
  40. package/templates/github-workflow.yml +23 -0
  41. package/templates/plan.md +30 -0
  42. package/templates/spec.md +52 -0
@@ -0,0 +1,122 @@
1
+ // What a proposed config changes compared with the live one, in words a person can approve:
2
+ // lanes, layers and rules added, removed or changed, and which files would change owner.
3
+ import { ownerOf } from './rules.mjs';
4
+
5
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
6
+ const lists = (a = [], b = []) => ({
7
+ added: b.filter((x) => !a.includes(x)),
8
+ removed: a.filter((x) => !b.includes(x)),
9
+ });
10
+
11
+ /** "+ x, y; - z" for a list field, or null when it didn't change. */
12
+ function listChange(name, a, b) {
13
+ const { added, removed } = lists(a, b);
14
+ const parts = [
15
+ added.length && `+ ${added.join(', ')}`,
16
+ removed.length && `- ${removed.join(', ')}`,
17
+ ].filter(Boolean);
18
+ return parts.length ? `${name} ${parts.join('; ')}` : null;
19
+ }
20
+
21
+ /** Lines for a keyed collection: `+` added, `-` removed, `~` changed (with `describe` for the detail). */
22
+ function keyed(label, before = {}, after = {}, describe) {
23
+ const lines = [];
24
+ for (const key of Object.keys(after)) {
25
+ if (!(key in before)) lines.push(`+ ${label} ${key}: ${describe.summary(after[key])}`);
26
+ else if (!same(before[key], after[key]))
27
+ lines.push(`~ ${label} ${key}: ${describe.change(before[key], after[key])}`);
28
+ }
29
+ for (const key of Object.keys(before)) if (!(key in after)) lines.push(`- ${label} ${key}`);
30
+ return lines;
31
+ }
32
+
33
+ const byName = (items = [], key = 'name') => Object.fromEntries(items.map((i) => [i[key], i]));
34
+
35
+ const fieldChanges = (a, b, fields) =>
36
+ fields
37
+ .map((f) =>
38
+ Array.isArray(a[f]) || Array.isArray(b[f])
39
+ ? listChange(f, a[f], b[f])
40
+ : same(a[f], b[f])
41
+ ? null
42
+ : `${f} ${JSON.stringify(a[f])} → ${JSON.stringify(b[f])}`,
43
+ )
44
+ .filter(Boolean)
45
+ .join('; ');
46
+
47
+ const LANE = {
48
+ summary: (l) =>
49
+ `${l.agent}, ${l.paths.join(', ')}${l.exclude?.length ? ` (except ${l.exclude.join(', ')})` : ''}`,
50
+ change: (a, b) => fieldChanges(a, b, ['agent', 'paths', 'exclude']),
51
+ };
52
+ const LAYER = {
53
+ summary: (l) => `${l.paths.join(', ')} → may import ${l.mayImport.join(', ') || 'nothing'}`,
54
+ change: (a, b) => fieldChanges(a, b, ['paths', 'mayImport', 'denyPackages']),
55
+ };
56
+ const CHECK = {
57
+ summary: (c) => `${c.run.join(' && ')} when ${c.files.join(', ')} change`,
58
+ change: (a, b) =>
59
+ fieldChanges(a, b, [
60
+ 'files',
61
+ 'exclude',
62
+ 'run',
63
+ 'each',
64
+ 'owners',
65
+ 'ifMissing',
66
+ 'timeoutSeconds',
67
+ ]),
68
+ };
69
+ const RULE = {
70
+ summary: (r) => r.why ?? r.glob ?? JSON.stringify(r),
71
+ change: (a, b) => `${JSON.stringify(a)} → ${JSON.stringify(b)}`,
72
+ };
73
+
74
+ /** Every difference between two raw configs (as written in the file, before defaults). */
75
+ export function diffConfigs(before, after) {
76
+ const lines = [
77
+ ...keyed('lane', before.lanes, after.lanes, LANE),
78
+ ...keyed('layer', byName(before.layers), byName(after.layers), LAYER),
79
+ ...keyed('check', byName(before.checks), byName(after.checks), CHECK),
80
+ ...keyed('protected', byName(before.protected, 'glob'), byName(after.protected, 'glob'), RULE),
81
+ ...keyed(
82
+ 'blocked command',
83
+ byName(before.shell?.block, 'pattern'),
84
+ byName(after.shell?.block, 'pattern'),
85
+ RULE,
86
+ ),
87
+ ...keyed(
88
+ 'restricted command',
89
+ byName(before.shell?.restricted, 'pattern'),
90
+ byName(after.shell?.restricted, 'pattern'),
91
+ RULE,
92
+ ),
93
+ ...keyed('per-file check', byName(before.postEdit, 'run'), byName(after.postEdit, 'run'), RULE),
94
+ ];
95
+ for (const [label, a, b] of [
96
+ ['lead paths', before.lead?.paths, after.lead?.paths],
97
+ ['lead outside paths', before.lead?.outside, after.lead?.outside],
98
+ ['any-actor paths', before.anyActor, after.anyActor],
99
+ ['protected branches', before.branches?.protected, after.branches?.protected],
100
+ ]) {
101
+ const change = listChange('', a, b);
102
+ if (change) lines.push(`~ ${label}:${change}`);
103
+ }
104
+ for (const key of ['docs', 'designGate', 'importAliases']) {
105
+ if (!same(before[key], after[key]))
106
+ lines.push(
107
+ `~ ${key}: ${JSON.stringify(before[key] ?? null)} → ${JSON.stringify(after[key] ?? null)}`,
108
+ );
109
+ }
110
+ return lines;
111
+ }
112
+
113
+ /** Files whose owner differs between two configs (with defaults applied): { file, from, to }. */
114
+ export function ownershipMoves(files, before, after) {
115
+ return files
116
+ .map((file) => ({
117
+ file,
118
+ from: ownerOf(file, before) ?? 'nobody',
119
+ to: ownerOf(file, after) ?? 'nobody',
120
+ }))
121
+ .filter((m) => m.from !== m.to);
122
+ }
@@ -0,0 +1,42 @@
1
+ // Path globs as used in .claude/code-kit.json: `*` within one segment, `**` across segments,
2
+ // `**/` for zero or more directories, `?` for one character and `{a,b}` for alternatives.
3
+
4
+ function source(glob) {
5
+ let out = '';
6
+ for (let i = 0; i < glob.length; i++) {
7
+ const c = glob[i];
8
+ if (c === '*' && glob[i + 1] === '*') {
9
+ i++;
10
+ if (glob[i + 1] === '/') {
11
+ i++;
12
+ out += '(?:.*/)?';
13
+ } else {
14
+ out += '.*';
15
+ }
16
+ } else if (c === '*') {
17
+ out += '[^/]*';
18
+ } else if (c === '?') {
19
+ out += '[^/]';
20
+ } else if (c === '{' && glob.indexOf('}', i) > i) {
21
+ const end = glob.indexOf('}', i);
22
+ out += `(?:${glob
23
+ .slice(i + 1, end)
24
+ .split(',')
25
+ .map(source)
26
+ .join('|')})`;
27
+ i = end;
28
+ } else {
29
+ out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ const cache = new Map();
36
+
37
+ export function globToRegExp(glob) {
38
+ if (!cache.has(glob)) cache.set(glob, new RegExp(`^${source(glob)}$`));
39
+ return cache.get(glob);
40
+ }
41
+
42
+ export const matchesAny = (path, globs = []) => globs.some((g) => globToRegExp(g).test(path));
@@ -0,0 +1,19 @@
1
+ // What every hook starts with: the tool call from stdin, the project and its config.
2
+ import { readFileSync } from 'node:fs';
3
+ import { loadConfig } from './config.mjs';
4
+ import { projectDir } from './rules.mjs';
5
+
6
+ /** Exit 2 with a message: Claude Code blocks the call and shows Claude the reason. */
7
+ export function block(message) {
8
+ process.stderr.write(`${message}\n`);
9
+ process.exit(2);
10
+ }
11
+
12
+ /** The hook input, project and config; exits quietly when the project does not use the kit. */
13
+ export function start() {
14
+ const input = JSON.parse(readFileSync(0, 'utf8'));
15
+ const project = projectDir();
16
+ const { config, error } = loadConfig(project);
17
+ if (!config && !error) process.exit(0);
18
+ return { input, project, config, error };
19
+ }
@@ -0,0 +1,57 @@
1
+ // Layer rules: each layer lists the layers it may depend on, and optionally packages it may not use.
2
+ // Imports are read from JavaScript and TypeScript source; a file outside every layer is not checked.
3
+ import { posix } from 'node:path';
4
+ import { matchesAny } from './glob.mjs';
5
+
6
+ export const CODE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
7
+
8
+ const SPECIFIERS = [
9
+ /\bfrom\s*['"]([^'"\n]+)['"]/g, // import … from 'x', export … from 'x'
10
+ /^\s*import\s*['"]([^'"\n]+)['"]/gm, // import 'x'
11
+ /\b(?:require|import)\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g, // require('x'), import('x')
12
+ ];
13
+
14
+ export function importsOf(source) {
15
+ const found = new Set();
16
+ for (const re of SPECIFIERS) for (const m of source.matchAll(re)) found.add(m[1]);
17
+ return [...found];
18
+ }
19
+
20
+ export const layerOf = (rel, layers) => layers.find((l) => matchesAny(rel, l.paths));
21
+
22
+ /** The repository path an import points at, or null for a package. */
23
+ export function targetOf(spec, rel, aliases) {
24
+ if (spec.startsWith('./') || spec.startsWith('../'))
25
+ return posix.normalize(posix.join(posix.dirname(rel), spec));
26
+ const prefix = Object.keys(aliases)
27
+ .sort((a, b) => b.length - a.length)
28
+ .find((a) => spec === a || spec.startsWith(`${a}/`));
29
+ return prefix === undefined
30
+ ? null
31
+ : posix.normalize(posix.join(aliases[prefix], spec.slice(prefix.length)));
32
+ }
33
+
34
+ /** Every import in `source` (the file at repo path `rel`) that breaks the layer rules. */
35
+ export function layerProblems(rel, source, config) {
36
+ if (!CODE.test(rel) || config.layers.length === 0) return [];
37
+ const layer = layerOf(rel, config.layers);
38
+ if (!layer) return [];
39
+ const allowed = new Set([layer.name, ...layer.mayImport]);
40
+ const problems = [];
41
+ for (const spec of importsOf(source)) {
42
+ const target = targetOf(spec, rel, config.importAliases);
43
+ if (target === null) {
44
+ if (matchesAny(spec, layer.denyPackages))
45
+ problems.push(`${rel} (${layer.name}) may not import the package ${spec}.`);
46
+ continue;
47
+ }
48
+ const to = layerOf(target, config.layers) ?? layerOf(`${target}/index`, config.layers);
49
+ if (to && !allowed.has(to.name)) {
50
+ const may = layer.mayImport.length ? layer.mayImport.join(', ') : 'no other layer';
51
+ problems.push(
52
+ `${rel} (${layer.name}) imports ${spec} (${to.name}); ${layer.name} may depend on ${may}.`,
53
+ );
54
+ }
55
+ }
56
+ return problems;
57
+ }
@@ -0,0 +1,197 @@
1
+ // What to do next, from the project's state alone: no docs → spec-design; a spec still being shaped →
2
+ // spec-design; a ready spec or existing code without the kit → init; then, from the plan and git:
3
+ // review finished stories, dispatch ready ones, wait on running ones, or plan the next milestone.
4
+ import { execFileSync } from 'node:child_process';
5
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
+ import { join, posix } from 'node:path';
7
+ import { loadConfig } from './config.mjs';
8
+ import { buildStatus, readStories } from './plan.mjs';
9
+
10
+ export const DRAFT_FILE = '.claude/code-kit.draft.json';
11
+ export const BRIEF_STATES = ['framed', 'explored', 'scoped', 'specified', 'planned', 'ready'];
12
+
13
+ const git = (root, ...args) => {
14
+ try {
15
+ return execFileSync('git', ['-C', root, ...args], {
16
+ encoding: 'utf8',
17
+ stdio: ['ignore', 'pipe', 'ignore'],
18
+ }).trim();
19
+ } catch {
20
+ return null;
21
+ }
22
+ };
23
+
24
+ /** The spec-design brief: { path, status, openQuestions: [question, ...] }, or null. */
25
+ export function readBrief(root, config) {
26
+ const specs = config?.docs?.specs;
27
+ const candidates = [specs && `${posix.dirname(specs)}/brief.md`, 'docs/brief.md'].filter(Boolean);
28
+ const path = candidates.find((p) => existsSync(join(root, p)));
29
+ if (!path) return null;
30
+ const lines = readFileSync(join(root, path), 'utf8').split('\n');
31
+ const status = lines
32
+ .map((l) => l.match(/^\*\*Status:\*\*\s*([a-z ]+?)\s*$/i)?.[1]?.toLowerCase())
33
+ .find((s) => BRIEF_STATES.includes(s));
34
+ const start = lines.findIndex((l) => /^##\s+Open questions/i.test(l));
35
+ const section = start < 0 ? [] : lines.slice(start + 1);
36
+ const end = section.findIndex((l) => /^##\s/.test(l));
37
+ const openQuestions = section
38
+ .slice(0, end < 0 ? undefined : end)
39
+ .filter((l) => /^\|/.test(l) && !/^\|\s*-/.test(l) && !/^\|\s*Question\s*\|/i.test(l))
40
+ .map((l) => l.split('|')[1].trim())
41
+ .filter((q) => q && !q.startsWith('{{'));
42
+ return { path, status: status ?? null, openQuestions };
43
+ }
44
+
45
+ const hasMarkdown = (dir) => {
46
+ try {
47
+ return readdirSync(dir, { recursive: true }).some((f) => String(f).endsWith('.md'));
48
+ } catch {
49
+ return false;
50
+ }
51
+ };
52
+
53
+ /**
54
+ * { step, why, args, then, attention }. `step` is a skill (spec-design, init, check, dispatch,
55
+ * review), `wait` (lanes are building) or `done`. `base(config)` resolves the branch lanes merge into.
56
+ */
57
+ export function nextStep(root, base) {
58
+ const attention = [];
59
+ const result = (step, why, extra = {}) => ({
60
+ step,
61
+ why,
62
+ args: '',
63
+ then: [],
64
+ attention,
65
+ ...extra,
66
+ });
67
+ const isRepo = git(root, 'rev-parse', '--is-inside-work-tree') === 'true';
68
+ if (!isRepo)
69
+ attention.push('This folder is not a git repository yet; run `git init` before init.');
70
+ const files = isRepo ? (git(root, 'ls-files') ?? '').split('\n').filter(Boolean) : [];
71
+
72
+ const { config, error } = loadConfig(root);
73
+ if (error)
74
+ return result(
75
+ 'check',
76
+ `The config is invalid, so nothing but the config can be written: ${error.split('\n')[0]}`,
77
+ );
78
+ const brief = readBrief(root, config);
79
+ if (brief?.openQuestions.length)
80
+ attention.push(`${brief.openQuestions.length} open question(s) in ${brief.path}.`);
81
+
82
+ if (!config) {
83
+ if (existsSync(join(root, DRAFT_FILE)))
84
+ return result(
85
+ 'init',
86
+ 'A draft config is waiting for your approval; nothing is enforced until you approve it.',
87
+ );
88
+ if (brief && brief.status !== 'ready')
89
+ return result(
90
+ 'spec-design',
91
+ `The spec is ${brief.status ? `at "${brief.status}"` : 'started'}; spec-design picks up from there.`,
92
+ );
93
+ if (brief)
94
+ return result('init', 'The spec is ready; init turns it into enforced lanes and layers.', {
95
+ args: posix.dirname(brief.path),
96
+ });
97
+ const code = files.filter(
98
+ (f) =>
99
+ !f.startsWith('docs/') &&
100
+ !f.endsWith('.md') &&
101
+ !f.split('/').some((p) => p.startsWith('.')),
102
+ );
103
+ if (code.length)
104
+ return result(
105
+ 'init',
106
+ `This is existing code (${code.length} tracked files) without code-kit; init works the rules out from the code.`,
107
+ );
108
+ if (hasMarkdown(join(root, 'docs')))
109
+ return result(
110
+ 'init',
111
+ 'There are docs but no code-kit yet; init drafts the rules from them.',
112
+ {
113
+ args: 'docs/',
114
+ },
115
+ );
116
+ return result(
117
+ 'spec-design',
118
+ 'Nothing here yet; spec-design turns your idea into a specification.',
119
+ );
120
+ }
121
+
122
+ const { specs, plan } = config.docs ?? {};
123
+ if (!specs || !plan)
124
+ return result(
125
+ 'spec-design',
126
+ 'code-kit is on, but there are no specs and plan to build from; spec-design writes them.',
127
+ );
128
+ if (brief?.status && !['planned', 'ready'].includes(brief.status))
129
+ return result(
130
+ 'spec-design',
131
+ `The spec is at "${brief.status}"; spec-design picks up from there.`,
132
+ );
133
+ if (!readStories(root, plan).length)
134
+ return result(
135
+ 'spec-design',
136
+ `${plan} has no stories in the \`### ST-n\` form yet; spec-design writes the plan.`,
137
+ );
138
+
139
+ const from = base(config);
140
+ if (!from)
141
+ attention.push('The base branch has no commits yet, so story branches are read against HEAD.');
142
+ const status = buildStatus(root, config, from ?? 'HEAD', files);
143
+ if (status.problems.length)
144
+ attention.push(
145
+ `${status.problems.length} gap(s) between the specs and the plan (see \`code-kit status\`).`,
146
+ );
147
+ if (status.drift.length)
148
+ attention.push(
149
+ `The plan's status lines are out of date for ${status.drift.map((d) => d.story).join(', ')}.`,
150
+ );
151
+
152
+ const ids = (state) => status.stories.filter((s) => s.state === state).map((s) => s.id);
153
+ const [review, building, blocked] = ['review', 'in progress', 'blocked'].map(ids);
154
+ // A ready story with a gap (unknown lane, a requirement no spec defines) waits for the plan's fix.
155
+ const defined = new Set(status.requirements.map((r) => r.id));
156
+ const buildable = (s) =>
157
+ Boolean(config.lanes[s.lane]) &&
158
+ s.requirements.length > 0 &&
159
+ s.requirements.every((r) => defined.has(r));
160
+ const readyStories = status.stories.filter((s) => s.state === 'ready');
161
+ const ready = readyStories.filter(buildable).map((s) => s.id);
162
+ const unbuildable = readyStories.filter((s) => !buildable(s)).map((s) => s.id);
163
+ if (unbuildable.length)
164
+ attention.push(
165
+ `${unbuildable.join(', ')} can't be dispatched until the plan's gaps are fixed.`,
166
+ );
167
+ const then = [];
168
+ if (review.length && ready.length) then.push({ step: 'dispatch', args: ready.join(' ') });
169
+ if (review.length)
170
+ return result('review', `${review.join(', ')} finished and wait for review.`, {
171
+ args: review.join(' '),
172
+ then,
173
+ });
174
+ if (ready.length)
175
+ return result('dispatch', `${ready.join(', ')} can start: their dependencies are done.`, {
176
+ args: ready.join(' '),
177
+ });
178
+ if (building.length)
179
+ return result(
180
+ 'wait',
181
+ `${building.join(', ')} ${building.length === 1 ? 'is' : 'are'} being built; nothing else can start until one finishes. A lane that stopped without finishing is dispatched again.`,
182
+ );
183
+ if (unbuildable.length)
184
+ return result(
185
+ 'spec-design',
186
+ `${unbuildable.join(', ')} ${unbuildable.length === 1 ? 'is' : 'are'} next, but the plan has gaps to fix first.`,
187
+ );
188
+ if (blocked.length)
189
+ return result(
190
+ 'spec-design',
191
+ `${blocked.join(', ')} wait on stories that can't finish: the plan has a dependency cycle or a missing story.`,
192
+ );
193
+ return result(
194
+ 'done',
195
+ `All ${status.stories.length} stories are done; spec-design plans the next milestone or feature.`,
196
+ );
197
+ }
@@ -0,0 +1,206 @@
1
+ // Requirements and stories, read from the project's docs. Specs (docs.specs) hold requirements as
2
+ // headings `### BOOK-4 Short name` (`(removed)` at the end drops one); the plan (docs.plan) holds
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`.
5
+ import { execFileSync } from 'node:child_process';
6
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ export const REQUIREMENT = /\b(?!ST-)([A-Z][A-Z0-9]*-\d+)\b/g;
10
+ const STORY_HEADING = /^###\s+(ST-\d+)\b\s*(.*)$/;
11
+ const REQUIREMENT_HEADING = /^###\s+((?!ST-)[A-Z][A-Z0-9]*-\d+)\b\s*(.*)$/;
12
+ export const STATES = ['todo', 'in progress', 'review', 'done'];
13
+ // Test files, by the usual conventions. Tests name the requirement IDs they prove.
14
+ const TEST =
15
+ /(^|\/)(tests?|__tests__|e2e)\/|\.(test|spec)\.[^/]+$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$/;
16
+
17
+ const markdownFiles = (root, path) => {
18
+ const full = join(root, path);
19
+ if (!existsSync(full)) return [];
20
+ if (statSync(full).isFile()) return path.endsWith('.md') ? [path] : [];
21
+ return readdirSync(full, { recursive: true })
22
+ .map((f) => join(path, String(f)).split('\\').join('/'))
23
+ .filter((f) => f.endsWith('.md') && statSync(join(root, f)).isFile())
24
+ .sort();
25
+ };
26
+
27
+ /** Every requirement in the specs: [{ id, title, file, removed }]. */
28
+ export function readRequirements(root, specsPath) {
29
+ const found = [];
30
+ for (const file of markdownFiles(root, specsPath)) {
31
+ for (const line of readFileSync(join(root, file), 'utf8').split('\n')) {
32
+ const m = line.match(REQUIREMENT_HEADING);
33
+ if (m)
34
+ found.push({ id: m[1], title: m[2].trim(), file, removed: /\(removed\)\s*$/i.test(m[2]) });
35
+ }
36
+ }
37
+ return found;
38
+ }
39
+
40
+ const field = (lines, name) =>
41
+ lines
42
+ .map((l) => l.match(new RegExp(`^\\*\\*${name}:\\*\\*\\s*(.*)$`, 'i')))
43
+ .find(Boolean)?.[1]
44
+ .trim() ?? '';
45
+
46
+ /** Every story in the plan: [{ id, title, lane, requirements, dependsOn, status }]. */
47
+ export function readStories(root, planPath) {
48
+ const stories = [];
49
+ for (const file of markdownFiles(root, planPath)) {
50
+ const lines = readFileSync(join(root, file), 'utf8').split('\n');
51
+ lines.forEach((line, i) => {
52
+ const m = line.match(STORY_HEADING);
53
+ if (!m) return;
54
+ const end = lines.findIndex((l, j) => j > i && /^#{1,3}\s/.test(l));
55
+ const body = lines.slice(i + 1, end < 0 ? undefined : end);
56
+ const status = field(body, 'Status').toLowerCase();
57
+ stories.push({
58
+ id: m[1],
59
+ title: m[2].trim(),
60
+ file,
61
+ lane: field(body, 'Lane'),
62
+ requirements: [...field(body, 'Requirements').matchAll(REQUIREMENT)].map((r) => r[1]),
63
+ dependsOn: [...field(body, 'Depends on').matchAll(/\bST-\d+\b/g)].map((r) => r[0]),
64
+ status: STATES.includes(status) ? status : 'todo',
65
+ });
66
+ });
67
+ }
68
+ return stories;
69
+ }
70
+
71
+ export const storyBranch = (story) => `${story.lane}/${story.id.toLowerCase()}`;
72
+
73
+ /** Requirement IDs each tracked test file names: { id: [file, ...] }. */
74
+ export function testedRequirements(root, files, specsPath) {
75
+ const tested = {};
76
+ for (const file of files) {
77
+ if (!TEST.test(file) || file.endsWith('.md') || file.startsWith(`${specsPath}/`)) continue;
78
+ let body;
79
+ try {
80
+ body = readFileSync(join(root, file), 'utf8');
81
+ } catch {
82
+ continue; // deleted in the working tree, or not a text file
83
+ }
84
+ for (const [, id] of body.matchAll(REQUIREMENT)) (tested[id] ??= new Set()).add(file);
85
+ }
86
+ return Object.fromEntries(Object.entries(tested).map(([id, s]) => [id, [...s]]));
87
+ }
88
+
89
+ const git = (root, ...args) => {
90
+ try {
91
+ return execFileSync('git', ['-C', root, ...args], {
92
+ encoding: 'utf8',
93
+ stdio: ['ignore', 'pipe', 'ignore'],
94
+ }).trim();
95
+ } catch {
96
+ return null;
97
+ }
98
+ };
99
+
100
+ /** The ref of a branch, local first, then on origin; null when neither exists. */
101
+ function branchRef(root, branch) {
102
+ for (const ref of [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`])
103
+ if (git(root, 'rev-parse', '--verify', '--quiet', ref) !== null) return ref;
104
+ return null;
105
+ }
106
+
107
+ /** Worktree path of each checked-out branch: { branch: path }. */
108
+ function worktrees(root) {
109
+ const out = git(root, 'worktree', 'list', '--porcelain') ?? '';
110
+ const found = {};
111
+ let path;
112
+ for (const line of out.split('\n')) {
113
+ if (line.startsWith('worktree ')) path = line.slice('worktree '.length);
114
+ if (line.startsWith('branch refs/heads/'))
115
+ found[line.slice('branch refs/heads/'.length)] = path;
116
+ }
117
+ return found;
118
+ }
119
+
120
+ /**
121
+ * Where each story is. The plan's `done` stands (the lead marks it after merging, and merged
122
+ * branches are often deleted or squashed). Otherwise git decides: a branch with commits not in
123
+ * `base` is in review; one level with it is in progress, or done once the plan had it in review.
124
+ * Without a branch, the plan's status stands. A todo story is `blocked` until every story it
125
+ * depends on is done, then `ready`.
126
+ */
127
+ export function storyStates(root, stories, base) {
128
+ const trees = worktrees(root);
129
+ const states = stories.map((story) => {
130
+ const branch = storyBranch(story);
131
+ const ref = story.lane ? branchRef(root, branch) : null;
132
+ const ahead = ref ? Number(git(root, 'rev-list', '--count', `${base}..${ref}`) ?? 0) : 0;
133
+ let state = story.status;
134
+ if (ref && story.status !== 'done') {
135
+ if (ahead > 0) state = 'review';
136
+ else state = story.status === 'review' ? 'done' : 'in progress';
137
+ }
138
+ return {
139
+ ...story,
140
+ branch,
141
+ branchExists: Boolean(ref),
142
+ ahead,
143
+ worktree: trees[branch] ?? null,
144
+ state,
145
+ waitingOn: [],
146
+ };
147
+ });
148
+ const done = new Set(states.filter((s) => s.state === 'done').map((s) => s.id));
149
+ for (const s of states) {
150
+ if (s.state !== 'todo') continue;
151
+ s.waitingOn = s.dependsOn.filter((d) => !done.has(d));
152
+ s.state = s.waitingOn.length ? 'blocked' : 'ready';
153
+ }
154
+ return states;
155
+ }
156
+
157
+ /** Gaps between specs, plan and tests, as sentences. */
158
+ export function traceProblems(requirements, stories, lanes) {
159
+ const problems = [];
160
+ const ids = new Set(requirements.map((r) => r.id));
161
+ const seen = new Set();
162
+ for (const r of requirements) {
163
+ if (seen.has(r.id)) problems.push(`${r.id} is defined more than once (${r.file})`);
164
+ seen.add(r.id);
165
+ }
166
+ const storyIds = new Set();
167
+ for (const s of stories) {
168
+ if (storyIds.has(s.id)) problems.push(`${s.id} is in the plan more than once`);
169
+ storyIds.add(s.id);
170
+ 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`);
173
+ for (const r of s.requirements)
174
+ if (!ids.has(r)) problems.push(`${s.id} cites ${r}, which no spec defines`);
175
+ for (const d of s.dependsOn)
176
+ if (!stories.some((o) => o.id === d))
177
+ problems.push(`${s.id} depends on ${d}, which isn't in the plan`);
178
+ }
179
+ const covered = new Set(stories.flatMap((s) => s.requirements));
180
+ for (const r of requirements)
181
+ if (!r.removed && !covered.has(r.id)) problems.push(`${r.id} is in no story (${r.file})`);
182
+ return problems;
183
+ }
184
+
185
+ /** The whole picture for `code-kit status` and `code-kit next`: stories, requirements, gaps, drift. */
186
+ export function buildStatus(root, config, base, files) {
187
+ const { specs, plan } = config.docs;
188
+ const requirements = readRequirements(root, specs).filter((r) => r.file !== plan);
189
+ const stories = storyStates(root, readStories(root, plan), base);
190
+ const tested = testedRequirements(root, files, specs);
191
+ const problems = traceProblems(requirements, stories, Object.keys(config.lanes));
192
+ const byRequirement = requirements.map((r) => {
193
+ const covering = stories.filter((s) => s.requirements.includes(r.id));
194
+ let state = 'no story';
195
+ if (r.removed) state = 'removed';
196
+ else if (covering.length && covering.every((s) => s.state === 'done')) state = 'done';
197
+ else if (covering.some((s) => ['in progress', 'review', 'done'].includes(s.state)))
198
+ state = 'in progress';
199
+ else if (covering.length) state = 'todo';
200
+ return { ...r, state, stories: covering.map((s) => s.id), tests: tested[r.id] ?? [] };
201
+ });
202
+ const drift = stories
203
+ .filter((s) => !['ready', 'blocked'].includes(s.state) && s.state !== s.status)
204
+ .map((s) => ({ story: s.id, plan: s.status, actual: s.state }));
205
+ return { stories, requirements: byRequirement, problems, drift };
206
+ }