@warren-dean/code-kit 0.8.0 → 0.9.0

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.
@@ -5,7 +5,9 @@ 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, mergeDelegation } from './config.mjs';
8
- import { buildStatus, readStories, LEAD } from './plan.mjs';
8
+ import { buildStatus, readStories, DESIGN, LEAD } from './plan.mjs';
9
+ import { pendingSetup } from './adapters/index.mjs';
10
+ import { CONFIG_FILE, SETUP_VERSION } from './config.mjs';
9
11
  import { readQueue, waiting } from './queue.mjs';
10
12
 
11
13
  export const DRAFT_FILE = '.claude/code-kit.draft.json';
@@ -138,6 +140,23 @@ export function nextStep(root, base) {
138
140
  `${plan} has no stories in the \`### ST-n\` form yet; spec-design writes the plan.`,
139
141
  );
140
142
 
143
+ // ONB-3: a companion the config wants is set up before any coding starts.
144
+ let raw = null;
145
+ try {
146
+ raw = JSON.parse(readFileSync(join(root, CONFIG_FILE), 'utf8'));
147
+ } catch {
148
+ // read and validated above; nothing to add
149
+ }
150
+ const [setup] = pendingSetup(raw, root);
151
+ if (setup) return result('setup', setup.why, { args: setup.skill });
152
+ // ONB-5: a project set up before setup did all of this is told what re-running init adds.
153
+ if ((raw?.setup ?? 1) < SETUP_VERSION)
154
+ attention.push(
155
+ "This project's code-kit setup predates what init now sets up: project skills with each lane agent, designs approved before screens are built, a QA lane for end-to-end tests, and companion tools set up before coding. Re-run /code-kit:init to add what's missing; it keeps everything that's still right and shows you the difference first.",
156
+ );
157
+ // ONB-1: a lane agent naming a skill that doesn't exist works without the recipe it was given.
158
+ for (const missing of missingSkills(root)) attention.push(missing);
159
+
141
160
  const from = base(config);
142
161
  if (!from)
143
162
  attention.push('The base branch has no commits yet, so story branches are read against HEAD.');
@@ -163,13 +182,17 @@ export function nextStep(root, base) {
163
182
  // A ready story with a gap (unknown lane, a requirement no spec defines) waits for the plan's fix.
164
183
  const defined = new Set(status.requirements.map((r) => r.id));
165
184
  const buildable = (s) =>
166
- (Boolean(config.lanes[s.lane]) || s.lane === LEAD) &&
185
+ (Boolean(config.lanes[s.lane]) || s.lane === LEAD || s.lane === DESIGN) &&
167
186
  (s.requirements.length > 0 || s.noRequirements) &&
168
187
  s.requirements.every((r) => defined.has(r));
169
188
  const readyStories = status.stories.filter((s) => s.state === 'ready');
170
189
  // The lead's own stories are built by the lead, not dispatched to a lane agent.
171
190
  const forLead = readyStories.filter((s) => buildable(s) && s.lane === LEAD).map((s) => s.id);
172
- const ready = readyStories.filter((s) => buildable(s) && s.lane !== LEAD).map((s) => s.id);
191
+ const ready = readyStories
192
+ .filter((s) => buildable(s) && s.lane !== LEAD && s.lane !== DESIGN)
193
+ .map((s) => s.id);
194
+ // ONB-2: design stories, designed with the person before any screen that depends on them is built.
195
+ const forDesign = readyStories.filter((s) => buildable(s) && s.lane === DESIGN).map((s) => s.id);
173
196
  const unbuildable = readyStories.filter((s) => !buildable(s)).map((s) => s.id);
174
197
  if (unbuildable.length)
175
198
  attention.push(
@@ -180,11 +203,15 @@ export function nextStep(root, base) {
180
203
  // the person does, and always the first merge, which brings code-kit's rules onto the base.
181
204
  const delegation = queued.length ? mergeDelegation(root, config) : { delegated: false };
182
205
  const leadMerges = delegation.delegated && config.harness.autonomy === 'autonomous';
206
+ // SETUP-2: a base with no rules committed (a project set up before setup committed them there): the
207
+ // lead puts the approved rules onto it, and the queue merges under them from then on.
183
208
  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.`,
209
+ return result(
210
+ 'rules',
211
+ `${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.`,
212
+ { args: delegation.into },
186
213
  );
187
- else if (queued.length && !leadMerges)
214
+ if (queued.length && !leadMerges)
188
215
  attention.push(
189
216
  `${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
217
  );
@@ -197,6 +224,7 @@ export function nextStep(root, base) {
197
224
  const then = [];
198
225
  // A sent-back story goes back to its lane with the review's findings, before new work starts.
199
226
  const toDispatch = [...fixes, ...ready];
227
+ if (review.length && forDesign.length) then.push({ step: 'design', args: forDesign.join(' ') });
200
228
  if (review.length && toDispatch.length)
201
229
  then.push({ step: 'dispatch', args: toDispatch.join(' ') });
202
230
  if (forLead.length) then.push({ step: 'lead', args: forLead.join(' ') });
@@ -205,6 +233,18 @@ export function nextStep(root, base) {
205
233
  args: review.join(' '),
206
234
  then,
207
235
  });
236
+ if (forDesign.length)
237
+ return result(
238
+ 'design',
239
+ `${forDesign.join(', ')} ${forDesign.length === 1 ? 'designs' : 'design'} the screens before any code is written for them; the person approves each design.`,
240
+ {
241
+ args: forDesign.join(' '),
242
+ then: [
243
+ ...(toDispatch.length ? [{ step: 'dispatch', args: toDispatch.join(' ') }] : []),
244
+ ...(forLead.length ? [{ step: 'lead', args: forLead.join(' ') }] : []),
245
+ ],
246
+ },
247
+ );
208
248
  if (fixes.length)
209
249
  return result(
210
250
  'dispatch',
@@ -227,6 +267,12 @@ export function nextStep(root, base) {
227
267
  'wait',
228
268
  `${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
269
  );
270
+ // Stories waiting on branches in the merge queue wait for the merge, not on a broken plan.
271
+ if (queued.length)
272
+ return result(
273
+ 'wait',
274
+ `${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.`,
275
+ );
230
276
  if (unbuildable.length)
231
277
  return result(
232
278
  'spec-design',
@@ -247,3 +293,39 @@ export function nextStep(root, base) {
247
293
  `All ${status.stories.length} stories are done; spec-design plans the next milestone or feature.`,
248
294
  );
249
295
  }
296
+
297
+ /** The code-kit skills every agent may name without a project copy. */
298
+ const KIT_SKILLS = (() => {
299
+ try {
300
+ return new Set(readdirSync(new URL('../../skills/', import.meta.url)));
301
+ } catch {
302
+ return new Set();
303
+ }
304
+ })();
305
+
306
+ /**
307
+ * ONB-1: lane agents naming skills under **Skills to use** that don't exist, in words: ["web-engineer
308
+ * names a skill that doesn't exist: new-screen"]. Plugin skills (`plugin:skill`) and code-kit's own are
309
+ * left alone.
310
+ */
311
+ export function missingSkills(root) {
312
+ const dir = join(root, '.claude/agents');
313
+ if (!existsSync(dir)) return [];
314
+ const out = [];
315
+ for (const file of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
316
+ const line = readFileSync(join(dir, file), 'utf8').match(/^\*\*Skills to use:\*\*(.*)$/m)?.[1];
317
+ if (!line) continue;
318
+ const missing = line
319
+ .split(',')
320
+ .map((n) => n.replace(/[`*]/g, '').trim())
321
+ .filter((n) => /^[a-z0-9][a-z0-9-]*$/.test(n))
322
+ .filter(
323
+ (n) => !KIT_SKILLS.has(n) && !existsSync(join(root, '.claude/skills', n, 'SKILL.md')),
324
+ );
325
+ if (missing.length)
326
+ out.push(
327
+ `${file.replace(/\.md$/, '')} names ${missing.length === 1 ? "a skill that doesn't" : "skills that don't"} exist: ${missing.join(', ')}. Write ${missing.length === 1 ? 'it' : 'them'} from the architecture's recipes (re-run init), or take ${missing.length === 1 ? 'it' : 'them'} off the agent.`,
328
+ );
329
+ }
330
+ return out;
331
+ }
@@ -14,6 +14,8 @@ const STORY_HEADING = /^###\s+(ST-\d+)\b\s*(.*)$/;
14
14
  const REQUIREMENT_HEADING = /^###\s+((?!ST-)[A-Z][A-Z0-9]*-\d+)\b\s*(.*)$/;
15
15
  export const STATES = ['todo', 'in progress', 'review', 'done'];
16
16
  export const LEAD = 'lead';
17
+ /** ONB-2: the pseudo-lane for design stories, which the lead designs with the person before any screen is built. */
18
+ export const DESIGN = 'design';
17
19
  // Test files, by the usual conventions. Tests name the requirement IDs they prove.
18
20
  const TEST =
19
21
  /(^|\/)(tests?|__tests__|e2e)\/|\.(test|spec)\.[^/]+$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$/;
@@ -185,7 +187,7 @@ export function traceProblems(requirements, stories, lanes) {
185
187
  if (storyIds.has(s.id)) problems.push(`${s.id} is in the plan more than once`);
186
188
  storyIds.add(s.id);
187
189
  if (!s.lane) problems.push(`${s.id} has no **Lane:**`);
188
- else if (s.lane !== LEAD && !lanes.includes(s.lane))
190
+ else if (s.lane !== LEAD && s.lane !== DESIGN && !lanes.includes(s.lane))
189
191
  problems.push(`${s.id} names an unknown lane "${s.lane}"`);
190
192
  if (!s.requirements.length && !s.noRequirements)
191
193
  problems.push(`${s.id} cites no requirements (write "none" if it delivers none)`);
@@ -153,3 +153,65 @@ export function trail(root, status, from) {
153
153
  };
154
154
  });
155
155
  }
156
+
157
+ /** The code map of no story in particular: the whole project. */
158
+ export const PROJECT = 'project';
159
+
160
+ /**
161
+ * MAP-5: the whole project's code, for when no story is at work: its folders (`depth` deep, shallower
162
+ * when that makes more than `most`) by the layer most of their files sit in, with the imports between
163
+ * folders. The same shape as a story's map, each node a folder with its file count.
164
+ */
165
+ export function projectMap(root, config, tracked, { depth = 3, most = 60 } = {}) {
166
+ const code = tracked.filter(isCode);
167
+ const set = new Set(code);
168
+ const aliases = config.importAliases ?? {};
169
+ const folderAt = (d) => (rel) => {
170
+ const parts = rel.split('/');
171
+ return parts.length === 1 ? '.' : parts.slice(0, Math.min(d, parts.length - 1)).join('/');
172
+ };
173
+ let d = depth;
174
+ while (d > 1 && new Set(code.map(folderAt(d))).size > most) d -= 1;
175
+ const folderOf = folderAt(d);
176
+ const nodes = new Map();
177
+ for (const rel of code) {
178
+ const path = folderOf(rel);
179
+ const node = nodes.get(path) ?? {
180
+ path,
181
+ lane: laneOfFile(rel, config),
182
+ layers: new Map(),
183
+ imports: new Set(),
184
+ files: 0,
185
+ };
186
+ nodes.set(path, node);
187
+ const layer = TEST.test(rel) ? 'tests' : (layerOf(rel, config.layers)?.name ?? 'other');
188
+ node.layers.set(layer, (node.layers.get(layer) ?? 0) + 1);
189
+ node.files += 1;
190
+ let source = '';
191
+ try {
192
+ source = readFileSync(join(root, rel), 'utf8');
193
+ } catch {
194
+ // unreadable: no imports
195
+ }
196
+ for (const spec of importsOf(source)) {
197
+ const target = resolveIn(spec, rel, aliases, set);
198
+ if (target && folderOf(target) !== path) node.imports.add(folderOf(target));
199
+ }
200
+ }
201
+ const files = [...nodes.values()]
202
+ .map((n) => ({
203
+ path: n.path,
204
+ layer: [...n.layers.entries()].sort((a, b) => b[1] - a[1])[0][0],
205
+ lane: n.lane,
206
+ imports: [...n.imports].sort(),
207
+ files: n.files,
208
+ }))
209
+ .sort((a, b) => a.path.localeCompare(b.path));
210
+ const used = new Set(files.map((f) => f.layer));
211
+ const layers = [...config.layers.map((l) => l.name), 'tests', 'other'].filter((l) => used.has(l));
212
+ return {
213
+ story: { id: PROJECT, title: 'The whole project', lane: null, state: null },
214
+ layers,
215
+ files,
216
+ };
217
+ }