@warren-dean/code-kit 0.7.0 → 0.8.1

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.
@@ -0,0 +1,224 @@
1
+ // The journal (docs/specs/15-decisions-and-journal.md, JOURNAL-1): the build's lineage in plain
2
+ // sentences, oldest first: what the person asked for, the decisions that shaped it, the specification,
3
+ // the rules being switched on, each story handed out, sent back and merged, each approval and who gave
4
+ // it, and each milestone finished. Read from git and the project's own records, so it's the same for
5
+ // every session and survives a restart.
6
+ import { execFileSync } from 'node:child_process';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { APPROVAL_LOG } from './rules.mjs';
10
+ import { sendBacks } from './reviews.mjs';
11
+ import { storyTimes } from './timeline.mjs';
12
+
13
+ const gitIn =
14
+ (root) =>
15
+ (...args) => {
16
+ try {
17
+ return execFileSync('git', ['-C', root, ...args], {
18
+ encoding: 'utf8',
19
+ stdio: ['ignore', 'pipe', 'ignore'],
20
+ maxBuffer: 32 * 1024 * 1024,
21
+ }).trim();
22
+ } catch {
23
+ return '';
24
+ }
25
+ };
26
+
27
+ /** When a file was first committed, in ms, or null. */
28
+ const addedAt = (git, file) => {
29
+ const t = git('log', '--diff-filter=A', '--format=%ct', '--', file)
30
+ .split('\n')
31
+ .filter(Boolean)
32
+ .at(-1);
33
+ return t ? Number(t) * 1000 : null;
34
+ };
35
+
36
+ /** The brief's title and its problem, in a sentence or two: what the person asked for. */
37
+ export function briefRequest(text) {
38
+ const title = text.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
39
+ const section = (name) => {
40
+ const m = text.match(
41
+ new RegExp(`^##\\s+${name}\\s*\\n([\\s\\S]*?)(?=^##\\s|$(?![\\s\\S]))`, 'm'),
42
+ );
43
+ return m ? m[1].trim() : '';
44
+ };
45
+ const firstParagraph = (s) =>
46
+ s
47
+ .split(/\n\s*\n/)[0]
48
+ .replace(/\s+/g, ' ')
49
+ .trim();
50
+ const problem = firstParagraph(section('Problem')) || firstParagraph(section('Outcome'));
51
+ return { title, problem: problem || null };
52
+ }
53
+
54
+ /** The brief's decision log: [{ date, decision, why }], from its `## Decision log` table. */
55
+ export function briefDecisions(text) {
56
+ const m = text.match(/^##\s+Decision log\s*\n([\s\S]*?)(?=^##\s|$(?![\s\S]))/m);
57
+ if (!m) return [];
58
+ return m[1]
59
+ .split('\n')
60
+ .filter((l) => /^\|/.test(l) && !/^\|\s*-/.test(l) && !/^\|\s*Date\s*\|/i.test(l))
61
+ .map((l) => l.split('|').map((c) => c.trim()))
62
+ .map((c) => ({ date: c[1], decision: c[2], why: c[3] ?? '' }))
63
+ .filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d.date) && d.decision);
64
+ }
65
+
66
+ /** Who gave an approval, from the reason its log entry carries: the person, or the lead within limits. */
67
+ export function approverOf(entry) {
68
+ const reason = String(entry.reason ?? '');
69
+ if (/approved by the lead within the delegated rules/.test(reason)) return 'lead';
70
+ if (entry.approval === 'lead') return 'lead';
71
+ return 'person';
72
+ }
73
+
74
+ /** The words an approval's name stands for: "dep-pdfjs-dist" → "adding the package pdfjs-dist". */
75
+ export function approvalWords(name) {
76
+ if (name.startsWith('dep-')) return `adding the package ${name.slice(4)}`;
77
+ if (name === 'dependency') return 'adding a new package';
78
+ if (name === 'kit') return "a change to code-kit's own rules or agents";
79
+ if (name === 'merge') return 'a merge';
80
+ return `a change to the protected ${name.replace(/-/g, ' ')}`;
81
+ }
82
+
83
+ /** The person's own words, without the note the hooks add about how it was given. */
84
+ const ownWords = (reason) =>
85
+ String(reason ?? '')
86
+ .replace(
87
+ /\s*\((given in chat, recorded by the lead|approved in the [\w ]+ pane|approved by the lead within the delegated rules[^)]*)\)\s*$/,
88
+ '',
89
+ )
90
+ .trim();
91
+
92
+ /**
93
+ * JOURNAL-1: the events, oldest first: [{ at, kind, text, story? }]. Kinds: request, decision, spec,
94
+ * setup, dispatch, sentBack, merge, approval, milestone. `status` is buildStatus's; `base` the branch
95
+ * stories merge into; `docs` the config's docs (brief beside the specs).
96
+ */
97
+ export function journal(root, status, base, docs = {}) {
98
+ const git = gitIn(root);
99
+ const events = [];
100
+ const at = (iso) => (iso ? Date.parse(`${iso}T12:00:00Z`) : null);
101
+ // What the person asked for, and the decisions the brief records.
102
+ const briefPath = ['docs/brief.md', docs.specs ? join(docs.specs, '..', 'brief.md') : null]
103
+ .filter(Boolean)
104
+ .find((p) => existsSync(join(root, p)));
105
+ if (briefPath) {
106
+ const text = readFileSync(join(root, briefPath), 'utf8');
107
+ const { title, problem } = briefRequest(text);
108
+ const when = addedAt(git, briefPath);
109
+ if (title || problem)
110
+ events.push({
111
+ at: when,
112
+ kind: 'request',
113
+ text: `You asked for ${title ? `“${title}”` : 'a build'}${problem ? `: ${problem}` : ''}`,
114
+ });
115
+ // The decisions were made while the brief was written: they follow the request, in the log's order.
116
+ briefDecisions(text).forEach((d, n) =>
117
+ events.push({
118
+ at: (when ?? at(d.date)) + n + 1,
119
+ kind: 'decision',
120
+ text: `Decided: ${d.decision}${d.why ? `, because ${d.why.replace(/\.$/, '').replace(/^./, (c) => c.toLowerCase())}` : ''}.`,
121
+ }),
122
+ );
123
+ }
124
+ // The specification and the plan.
125
+ const reqs = status.requirements.filter((r) => !r.removed).length;
126
+ const milestones = [...new Set(status.stories.map((s) => s.milestone).filter(Boolean))];
127
+ if (docs.plan && status.stories.length)
128
+ events.push({
129
+ at: addedAt(git, docs.plan),
130
+ kind: 'spec',
131
+ text: `The specification was written: ${reqs} requirement${reqs === 1 ? '' : 's'}, split into ${status.stories.length} stor${status.stories.length === 1 ? 'y' : 'ies'}${milestones.length ? ` across ${milestones.length} milestone${milestones.length === 1 ? '' : 's'}` : ''}.`,
132
+ });
133
+ const switchedOn = addedAt(git, '.claude/code-kit.json');
134
+ if (switchedOn)
135
+ events.push({
136
+ at: switchedOn,
137
+ kind: 'setup',
138
+ text: "code-kit's rules were switched on, as you approved them.",
139
+ });
140
+ // Each story: handed out, sent back, merged.
141
+ const times = storyTimes(root, status.stories, base);
142
+ const byId = new Map(status.stories.map((s) => [s.id, s]));
143
+ const backs = sendBacks(root);
144
+ for (const t of times) {
145
+ const s = byId.get(t.id);
146
+ const name = `${t.id} ${s?.title ?? ''}`.trim();
147
+ const reqText = s?.requirements?.length ? ` (${s.requirements.join(', ')})` : '';
148
+ if (t.started)
149
+ events.push({
150
+ at: t.started,
151
+ kind: 'dispatch',
152
+ story: t.id,
153
+ text:
154
+ t.lane === 'lead'
155
+ ? `The lead started ${name}${reqText} itself.`
156
+ : `${name}${reqText} was handed to the ${t.lane} lane.`,
157
+ });
158
+ for (const b of backs.filter((x) => x.branch === t.branch))
159
+ events.push({
160
+ at: Date.parse(b.at),
161
+ kind: 'sentBack',
162
+ story: t.id,
163
+ text:
164
+ t.lane === 'lead'
165
+ ? `${t.id} needed more work from the lead${b.reason ? `: ${b.reason}` : '.'}`
166
+ : `The lead sent ${t.id} back to the ${t.lane} lane${b.reason ? `: ${b.reason}` : '.'}`,
167
+ });
168
+ if (t.merged)
169
+ events.push({
170
+ at: t.merged,
171
+ kind: 'merge',
172
+ story: t.id,
173
+ text: `${name} was merged into ${base}.`,
174
+ });
175
+ }
176
+ // Approvals on the base, once each (one approval can log several files), and who gave them.
177
+ const seen = new Set();
178
+ for (const line of git('show', `${base}:${APPROVAL_LOG}`).split('\n').filter(Boolean)) {
179
+ let e;
180
+ try {
181
+ e = JSON.parse(line);
182
+ } catch {
183
+ continue;
184
+ }
185
+ if (!e.approval || e.approval === 'lead') continue;
186
+ const key = `${e.approval}|${e.grantedAt ?? e.at}`;
187
+ if (seen.has(key)) continue;
188
+ seen.add(key);
189
+ const who = approverOf(e);
190
+ const words = ownWords(e.reason);
191
+ events.push({
192
+ at: Date.parse(e.grantedAt ?? e.at),
193
+ kind: 'approval',
194
+ who,
195
+ text:
196
+ who === 'lead'
197
+ ? `The lead approved ${approvalWords(e.approval)}, within the limits you set${words ? `: ${words}` : '.'}`
198
+ : `You approved ${approvalWords(e.approval)}${words ? `: “${words}”` : '.'}`,
199
+ });
200
+ }
201
+ // Milestones finished: when the last of their stories merged.
202
+ for (const m of milestones) {
203
+ const mine = status.stories.filter((s) => s.milestone === m);
204
+ if (!mine.every((s) => s.state === 'done')) continue;
205
+ const last = Math.max(...mine.map((s) => times.find((t) => t.id === s.id)?.merged ?? 0));
206
+ if (last)
207
+ events.push({
208
+ at: last + 1,
209
+ kind: 'milestone',
210
+ text: `${m} was finished: all ${mine.length} of its stories merged.`,
211
+ });
212
+ }
213
+ // The request, its decisions, the spec and the switch-on come first, whatever the commits' times: work
214
+ // can start on a branch before the brief that asked for it is committed.
215
+ const ORDER = ['request', 'decision', 'spec', 'setup'];
216
+ const opening = events.filter((e) => ORDER.includes(e.kind));
217
+ const rest = events.filter((e) => !ORDER.includes(e.kind) && e.at);
218
+ const firstWork = rest.length ? Math.min(...rest.map((e) => e.at)) : Infinity;
219
+ const opened = opening
220
+ .filter((e) => e.at)
221
+ .sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind) || a.at - b.at)
222
+ .map((e, n, all) => ({ ...e, at: Math.min(e.at, firstWork - (all.length - n)) }));
223
+ return [...opened, ...rest.sort((a, b) => a.at - b.at)];
224
+ }
@@ -0,0 +1,26 @@
1
+ // Whether the person has paused the lead loop (spec 05, LOOP-6), kept beside the merge queue so other
2
+ // tools sharing the project can hold their own background work while it's paused. The mod records it
3
+ // through the CLI; a session that starts begins unpaused.
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { projectWorktrees } from './rules.mjs';
7
+
8
+ export const LOOP_FILE = '.claude/state/loop.json';
9
+
10
+ const loopFile = (root) => join(projectWorktrees(root)[0] ?? root, LOOP_FILE);
11
+
12
+ /** { paused, at }: not paused when nothing was recorded. */
13
+ export function readLoop(root) {
14
+ try {
15
+ const state = JSON.parse(readFileSync(loopFile(root), 'utf8'));
16
+ return { paused: state?.paused === true, at: state?.at ?? null };
17
+ } catch {
18
+ return { paused: false, at: null };
19
+ }
20
+ }
21
+
22
+ export function writeLoop(root, paused, now = Date.now()) {
23
+ const file = loopFile(root);
24
+ mkdirSync(dirname(file), { recursive: true });
25
+ writeFileSync(file, `${JSON.stringify({ paused, at: new Date(now).toISOString() })}\n`);
26
+ }
@@ -180,11 +180,15 @@ export function nextStep(root, base) {
180
180
  // the person does, and always the first merge, which brings code-kit's rules onto the base.
181
181
  const delegation = queued.length ? mergeDelegation(root, config) : { delegated: false };
182
182
  const leadMerges = delegation.delegated && config.harness.autonomy === 'autonomous';
183
+ // SETUP-2: a base with no rules committed (a project set up before setup committed them there): the
184
+ // lead puts the approved rules onto it, and the queue merges under them from then on.
183
185
  if (queued.length && delegation.first)
184
- attention.push(
185
- `${queued[0]} passed review and is next to merge. ${delegation.into} has no code-kit config committed yet, so this first merge, which brings the rules onto it, is the person's (code-kit queue merge --person, or Merge in the code-kit band); after it, the lead merges.`,
186
+ return result(
187
+ 'rules',
188
+ `${queued[0]} passed review and is next to merge, but ${delegation.into} has no code-kit rules committed yet. Put the rules the person approved onto it (code-kit queue rules); then the queue merges under them.`,
189
+ { args: delegation.into },
186
190
  );
187
- else if (queued.length && !leadMerges)
191
+ if (queued.length && !leadMerges)
188
192
  attention.push(
189
193
  `${queued.join(', ')} passed review and ${queued.length === 1 ? 'waits' : 'wait'} for the person to merge (code-kit queue merge --person, or Merge in the code-kit band).`,
190
194
  );
@@ -227,6 +231,12 @@ export function nextStep(root, base) {
227
231
  'wait',
228
232
  `${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.`,
229
233
  );
234
+ // Stories waiting on branches in the merge queue wait for the merge, not on a broken plan.
235
+ if (queued.length)
236
+ return result(
237
+ 'wait',
238
+ `${queued.join(', ')} passed review and ${queued.length === 1 ? 'waits' : 'wait'} to be merged; the stories that depend on ${queued.length === 1 ? 'it' : 'them'} start after.`,
239
+ );
230
240
  if (unbuildable.length)
231
241
  return result(
232
242
  'spec-design',
@@ -47,12 +47,18 @@ const field = (lines, name) =>
47
47
  .find(Boolean)?.[1]
48
48
  .trim() ?? '';
49
49
 
50
- /** Every story in the plan: [{ id, title, lane, requirements, dependsOn, status }]. */
50
+ /**
51
+ * Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, milestone }]. A story's
52
+ * milestone is the `## ` heading above it ("Milestone 5: Your own key"), or null.
53
+ */
51
54
  export function readStories(root, planPath) {
52
55
  const stories = [];
53
56
  for (const file of markdownFiles(root, planPath)) {
54
57
  const lines = readFileSync(join(root, file), 'utf8').split('\n');
58
+ let milestone = null;
55
59
  lines.forEach((line, i) => {
60
+ const heading = line.match(/^##\s+(.+?)\s*$/);
61
+ if (heading) milestone = heading[1];
56
62
  const m = line.match(STORY_HEADING);
57
63
  if (!m) return;
58
64
  const end = lines.findIndex((l, j) => j > i && /^#{1,3}\s/.test(l));
@@ -67,6 +73,7 @@ export function readStories(root, planPath) {
67
73
  noRequirements: /^none\b/i.test(field(body, 'Requirements')),
68
74
  dependsOn: [...field(body, 'Depends on').matchAll(/\bST-\d+\b/g)].map((r) => r[0]),
69
75
  status: STATES.includes(status) ? status : 'todo',
76
+ milestone,
70
77
  });
71
78
  });
72
79
  }
@@ -0,0 +1,155 @@
1
+ // A story's code as the pane draws it (docs/specs/14-mission-and-maps.md): the files it changed, by the
2
+ // layer each sits in, with the imports between them (MAP-1); and each requirement's trail to its
3
+ // stories, their files and the tests that name it (TRAIL-1). Read from git, the config and the plan.
4
+ import { execFileSync } from 'node:child_process';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { join, posix } from 'node:path';
7
+ import { matchesAny } from './glob.mjs';
8
+ import { CODE, importsOf, layerOf, targetOf } from './layers.mjs';
9
+
10
+ const git = (root, ...args) => {
11
+ try {
12
+ return execFileSync('git', ['-C', root, ...args], {
13
+ encoding: 'utf8',
14
+ stdio: ['ignore', 'pipe', 'ignore'],
15
+ maxBuffer: 64 * 1024 * 1024,
16
+ });
17
+ } catch {
18
+ return '';
19
+ }
20
+ };
21
+
22
+ /** Files that are the build's own code: not the plan, the specs, or the tools' state. */
23
+ const KIT = /^(\.claude|\.ctx|\.github)\//;
24
+ const isCode = (rel) => CODE.test(rel) && !KIT.test(rel);
25
+
26
+ /**
27
+ * The code files a story changed: those in commits whose message names it (on any branch, so a merged
28
+ * story still has its files) and, while its branch is open, its diff against the base. Only files that
29
+ * still exist, in path order.
30
+ */
31
+ export function storyFiles(root, story, from) {
32
+ const found = new Set();
33
+ const id = story.id;
34
+ const log = git(
35
+ root,
36
+ 'log',
37
+ '--all',
38
+ '--no-merges',
39
+ '-E',
40
+ // POSIX ERE has no \b: "ST-1" mustn't match "ST-12".
41
+ `--grep=(^|[^A-Za-z0-9-])${id}([^0-9]|$)`,
42
+ '--format=',
43
+ '--name-only',
44
+ );
45
+ for (const f of log.split('\n')) if (f) found.add(f);
46
+ if (story.branchExists && story.branch)
47
+ for (const f of git(root, 'diff', '--name-only', `${from}...${story.branch}`).split('\n'))
48
+ if (f) found.add(f);
49
+ return [...found].filter((f) => isCode(f) && existsSync(join(root, f))).sort();
50
+ }
51
+
52
+ const EXTENSIONS = [
53
+ '',
54
+ '.ts',
55
+ '.tsx',
56
+ '.mts',
57
+ '.js',
58
+ '.jsx',
59
+ '.mjs',
60
+ '/index.ts',
61
+ '/index.tsx',
62
+ '/index.js',
63
+ ];
64
+
65
+ /** The file in `files` an import specifier in `rel` points at, or null. */
66
+ function resolveIn(spec, rel, aliases, files) {
67
+ const target = targetOf(spec, rel, aliases);
68
+ if (!target) return null;
69
+ // `./x.js` in TypeScript source names `./x.ts`.
70
+ const bare = target.replace(/\.(js|mjs|cjs)$/, '');
71
+ for (const ext of EXTENSIONS) {
72
+ for (const candidate of [`${target}${ext}`, `${bare}${ext}`])
73
+ if (files.has(candidate)) return candidate;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** The lane that owns `rel`, or `lead`, or null. */
79
+ export function laneOfFile(rel, config) {
80
+ const lane = Object.keys(config.lanes).find((name) => {
81
+ const { paths, exclude = [] } = config.lanes[name];
82
+ return matchesAny(rel, paths) && !matchesAny(rel, exclude);
83
+ });
84
+ if (lane) return lane;
85
+ return matchesAny(
86
+ rel,
87
+ config.lead.paths.filter((p) => p !== '*'),
88
+ )
89
+ ? 'lead'
90
+ : null;
91
+ }
92
+
93
+ const TEST = /(^|\/)(tests?|e2e|__tests__)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
94
+
95
+ /**
96
+ * MAP-1: a story's code: { story, layers, files: [{ path, layer, lane, imports }] }. `layers` are the
97
+ * config's layers in their order, then `tests` and `other` where any file falls there; each file's
98
+ * imports are the story's other files it imports.
99
+ */
100
+ export function storyMap(root, story, config, from) {
101
+ const paths = storyFiles(root, story, from);
102
+ const set = new Set(paths);
103
+ const aliases = config.importAliases ?? {};
104
+ const files = paths.map((rel) => {
105
+ let source = '';
106
+ try {
107
+ source = readFileSync(join(root, rel), 'utf8');
108
+ } catch {
109
+ // unreadable: no imports
110
+ }
111
+ const imports = [
112
+ ...new Set(
113
+ importsOf(source)
114
+ .map((spec) => resolveIn(spec, rel, aliases, set))
115
+ .filter((t) => t && t !== rel),
116
+ ),
117
+ ];
118
+ const layer = TEST.test(rel) ? 'tests' : (layerOf(rel, config.layers)?.name ?? 'other');
119
+ return { path: rel, layer, lane: laneOfFile(rel, config), imports };
120
+ });
121
+ const used = new Set(files.map((f) => f.layer));
122
+ const layers = [...config.layers.map((l) => l.name), 'tests', 'other'].filter((l) => used.has(l));
123
+ return {
124
+ story: { id: story.id, title: story.title, lane: story.lane, state: story.state },
125
+ layers,
126
+ files,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * TRAIL-1: each requirement's trail: { id, title, spec, state, stories: [{ id, state, lane }],
132
+ * files, tests }. Files are the code its stories changed, tests aside; tests are the files that name it.
133
+ */
134
+ export function trail(root, status, from) {
135
+ const byStory = new Map();
136
+ const filesOf = (s) => {
137
+ if (!byStory.has(s.id)) byStory.set(s.id, storyFiles(root, s, from));
138
+ return byStory.get(s.id);
139
+ };
140
+ return status.requirements
141
+ .filter((r) => !r.removed)
142
+ .map((r) => {
143
+ const stories = status.stories.filter((s) => s.requirements.includes(r.id));
144
+ const files = [...new Set(stories.flatMap((s) => filesOf(s)).filter((f) => !TEST.test(f)))];
145
+ return {
146
+ id: r.id,
147
+ title: r.title,
148
+ spec: r.file ? posix.basename(r.file) : null,
149
+ state: r.state,
150
+ stories: stories.map((s) => ({ id: s.id, state: s.state, lane: s.lane })),
151
+ files,
152
+ tests: [...(r.tests ?? [])],
153
+ };
154
+ });
155
+ }