@warren-dean/code-kit 0.19.0 → 0.21.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.
@@ -0,0 +1,25 @@
1
+ // code-kit hub as a team provider adapter (code-kit spec 21, HUBR-6): chosen by
2
+ // `"team": { "provider": { "hub": "<https address>" } }`, with GitHub as its fallback. Its client
3
+ // (client.mjs) is code-kit's provider command, the session's live link (the event stream and the team's
4
+ // state on disk) and the person's sign-in. In preview (HUBR-8): nothing outside this folder names it,
5
+ // and code-kit only says its name for a project whose config does.
6
+ import { spawnSync } from 'node:child_process';
7
+ import { dirname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { recordPresence, startDaemon, stopDaemon } from './client.mjs';
10
+
11
+ const CLIENT = join(dirname(fileURLToPath(import.meta.url)), 'client.mjs');
12
+ const quoted = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
13
+
14
+ export default {
15
+ key: 'hub',
16
+ label: 'code-kit hub',
17
+ command: (url) => `node ${quoted(CLIENT)} --url ${quoted(url)}`,
18
+ session: {
19
+ start: (root, session, watch, url) => startDaemon(root, session, watch, url),
20
+ end: (root, session) => stopDaemon(root, session),
21
+ },
22
+ presence: (root, session, agents) => recordPresence(root, session, agents),
23
+ run: (args, url) =>
24
+ spawnSync(process.execPath, [CLIENT, '--url', url, ...args], { stdio: 'inherit' }).status ?? 1,
25
+ };
@@ -18,9 +18,44 @@
18
18
  // files(root, paths) → { [path]: { rules } }, open: { command } (its view of a file) }
19
19
  //
20
20
  // The core only ever calls applyAdapters() and activeAdapters(); it never names a tool.
21
+ //
22
+ // Team provider adapters (HUBR-6) are the other kind: a hosted team provider's client, chosen when the
23
+ // config's `team.provider` names it, as `{ "<key>": "<its https address>" }`. Each says:
24
+ //
25
+ // key the provider's key in `team.provider`
26
+ // label what code-kit calls it, only when a config names it (HUBR-8)
27
+ // command (url) => the provider command code-kit runs for each operation (TEAM-2)
28
+ // session { start(root, session, watch, url), end(root, session) }: the session's live link
29
+ // presence (root, session, agents) => void: the session's agents, for the provider
30
+ // run (args, url) => exit code: the client's own commands (sign in, connect, status), for
31
+ // `code-kit team provider …`
32
+ //
33
+ // The core only ever calls providerAdapterOf(); it never names a provider.
21
34
  import contextGraph from './context-graph.mjs';
35
+ import hub from './hub/index.mjs';
22
36
 
23
37
  export const ADAPTERS = [contextGraph];
38
+ export const PROVIDERS = [hub];
39
+
40
+ const isAddress = (v) => typeof v === 'string' && /^https?:\/\/[^\s]+$/.test(v);
41
+
42
+ /**
43
+ * The team provider adapter `team.provider` names, with its address: { adapter, url }, or null when the
44
+ * provider isn't an adapter's (GitHub, or a command). `{ problem }` when it names one wrongly.
45
+ */
46
+ export function providerAdapterOf(provider) {
47
+ if (provider === null || typeof provider !== 'object' || Array.isArray(provider)) return null;
48
+ const adapter = PROVIDERS.find((p) => p.key in provider);
49
+ if (!adapter) return null;
50
+ const extra = Object.keys(provider).filter((k) => k !== adapter.key && k !== 'fallback');
51
+ if (
52
+ !isAddress(provider[adapter.key]) ||
53
+ extra.length ||
54
+ ![undefined, 'github'].includes(provider.fallback)
55
+ )
56
+ return { problem: true };
57
+ return { adapter, url: provider[adapter.key].replace(/\/$/, '') };
58
+ }
24
59
 
25
60
  /** Adapters detected in `root` and not switched off by the config's `adapters` map. */
26
61
  export function activeAdapters(raw, root) {
@@ -16,13 +16,14 @@ function directories(check, files, root) {
16
16
  }
17
17
 
18
18
  /**
19
- * The first failure as { title, output }, or null. `actor` decides `owners`; the lead always may. A
19
+ * The first failure as { check, title, output }, or null. `actor` decides `owners`; the lead always may. A
20
20
  * command that isn't installed skips the rest of the check, unless `ifMissing` is "fail", and is
21
21
  * added to `skipped` as { check, command, where }, so a pass never hides a check that didn't run.
22
22
  */
23
23
  function runCheck(check, files, root, actor, skipped) {
24
24
  if (check.owners && !(actor.kind === 'lead' || check.owners.includes(actor.lane))) {
25
25
  return {
26
+ check: check.name,
26
27
  title: `The ${actor.label} changed ${check.name} files it does not own:`,
27
28
  output: files.join('\n'),
28
29
  };
@@ -39,6 +40,7 @@ function runCheck(check, files, root, actor, skipped) {
39
40
  if (res.status === 127) {
40
41
  if (check.ifMissing === 'fail')
41
42
  return {
43
+ check: check.name,
42
44
  title: `${check.name}: \`${command}\` is not installed${where}. Install it so the check can run.`,
43
45
  };
44
46
  skipped.push({ check: check.name, command, where: where.replace(/^ in /, '') || null });
@@ -46,10 +48,12 @@ function runCheck(check, files, root, actor, skipped) {
46
48
  }
47
49
  if (res.error || res.status === null)
48
50
  return {
51
+ check: check.name,
49
52
  title: `${check.name}: \`${command}\` did not finish${where} (${res.error?.code ?? res.signal}).`,
50
53
  };
51
54
  if (res.status !== 0)
52
55
  return {
56
+ check: check.name,
53
57
  title: `${check.name}: \`${command}\` fails${where}. Fix it:`,
54
58
  output: res.stdout + res.stderr,
55
59
  };
@@ -174,7 +174,7 @@ export function validate(c) {
174
174
  );
175
175
  // A brownfield project may have no specs yet: then there is no spec-check gate.
176
176
  need(c.docs === undefined || isObj(c.docs), '"docs" must be an object');
177
- for (const k of ['specs', 'principles', 'plan', 'decisions'])
177
+ for (const k of ['specs', 'principles', 'plan', 'decisions', 'standards'])
178
178
  need(c.docs?.[k] === undefined || isStr(c.docs[k]), `"docs.${k}" must be a path`);
179
179
  need(isObj(c.lead) && isList(c.lead?.paths), '"lead.paths" must be a list of globs');
180
180
  need(
@@ -5,9 +5,16 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import {
9
+ appliesTo,
10
+ laneLayers,
11
+ projectStandards,
12
+ standardLine,
13
+ standardsFile,
14
+ } from './standards.mjs';
8
15
 
9
16
  /** The version of code-kit's instructions: raised whenever the templates' rules change. */
10
- export const INSTRUCTIONS_VERSION = 5;
17
+ export const INSTRUCTIONS_VERSION = 6;
11
18
 
12
19
  const TEMPLATES = fileURLToPath(new URL('../../templates/', import.meta.url));
13
20
  const BEGIN = (v) => `<!-- code-kit:begin v${v} -->`;
@@ -25,8 +32,20 @@ function fill(text, facts) {
25
32
  .replace(/\n{3,}/g, '\n\n');
26
33
  }
27
34
 
28
- /** What the templates need from the project: everything the config and the repository say. */
29
- function factsOf(root, config) {
35
+ /** The standards that apply (spec 22), or none when the file can't be read. */
36
+ function standardsOf(root, config) {
37
+ try {
38
+ return projectStandards(root, config).standards;
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ /**
45
+ * What the templates need from the project: everything the config and the repository say. With `lane`,
46
+ * the standards are the ones for that lane's layers (STD-3).
47
+ */
48
+ function factsOf(root, config, lane = null) {
30
49
  const delegated = new Set(config.approvals?.delegate?.protected ?? []);
31
50
  const kept = [];
32
51
  for (const p of config.protected ?? []) {
@@ -45,9 +64,24 @@ function factsOf(root, config) {
45
64
  'docs.specs': config.docs?.specs ?? 'the specs',
46
65
  'docs.plan': config.docs?.plan ?? 'the plan',
47
66
  'docs.principles': config.docs?.principles ?? "the project's principles",
67
+ 'docs.standards': standardsFile(config),
68
+ standards: laneStandards(root, config, lane),
69
+ 'standards.all': standardsOf(root, config)
70
+ .map((s) => `${s.id} ${s.title}`)
71
+ .join('; '),
48
72
  };
49
73
  }
50
74
 
75
+ /** A lane's standards as the lines of a nested list, or '' when none apply. */
76
+ function laneStandards(root, config, lane) {
77
+ if (!lane) return '';
78
+ const layers = (config.layers ?? []).length ? laneLayers(config.lanes?.[lane], config) : null;
79
+ const lines = standardsOf(root, config)
80
+ .filter((s) => appliesTo(s, layers))
81
+ .map((s) => `\n - ${standardLine(s)}`);
82
+ return lines.join('');
83
+ }
84
+
51
85
  const marked = (body) => `${BEGIN(INSTRUCTIONS_VERSION)}\n${body.trim()}\n${END}`;
52
86
 
53
87
  /** The lead's section of CLAUDE.md, between its markers. */
@@ -55,10 +89,10 @@ export function leadSection(root, config) {
55
89
  return marked(fill(readFileSync(join(TEMPLATES, 'claude-md.md'), 'utf8'), factsOf(root, config)));
56
90
  }
57
91
 
58
- /** A lane agent's "Always:" rules, between their markers. */
59
- export function agentRules(root, config) {
92
+ /** A lane agent's "Always:" rules, between their markers: with `lane`, its layers' standards too. */
93
+ export function agentRules(root, config, lane = null) {
60
94
  const template = readFileSync(join(TEMPLATES, 'agent.md'), 'utf8');
61
- return marked(fill(template.slice(template.indexOf('Always:')), factsOf(root, config)));
95
+ return marked(fill(template.slice(template.indexOf('Always:')), factsOf(root, config, lane)));
62
96
  }
63
97
 
64
98
  /** Whether two renderings say the same, whatever their whitespace and list indentation. */
@@ -87,17 +121,16 @@ function stateOf(text, block, legacy) {
87
121
  */
88
122
  export function instructionsState(root, config) {
89
123
  const lead = leadSection(root, config);
90
- const rules = agentRules(root, config);
91
124
  const files = [
92
125
  { file: 'CLAUDE.md', kind: 'lead', block: lead, legacy: LEAD_LEGACY },
93
- ...[...new Set(Object.values(config.lanes ?? {}).map((l) => l.agent))]
94
- .map((agent) => ({
95
- file: `.claude/agents/${agent}.md`,
126
+ ...Object.entries(config.lanes ?? {})
127
+ .filter(([, l]) => existsSync(join(root, `.claude/agents/${l.agent}.md`)))
128
+ .map(([name, l]) => ({
129
+ file: `.claude/agents/${l.agent}.md`,
96
130
  kind: 'lane',
97
- block: rules,
131
+ block: agentRules(root, config, name),
98
132
  legacy: AGENT_LEGACY,
99
- }))
100
- .filter((f) => existsSync(join(root, f.file))),
133
+ })),
101
134
  ];
102
135
  return files.map(({ file, kind, block, legacy }) => {
103
136
  const path = join(root, file);
@@ -55,3 +55,14 @@ export function layerProblems(rel, source, config) {
55
55
  }
56
56
  return problems;
57
57
  }
58
+
59
+ /**
60
+ * The rule a layer problem broke, read back from its sentence (as layerProblems words it):
61
+ * { kind: 'layers', from, to } or { kind: 'layers', from, package }. Null for any other text.
62
+ */
63
+ export function brokenLayerRule(problem) {
64
+ const pkg = String(problem).match(/ \(([^()]+)\) may not import the package (\S+)\.$/);
65
+ if (pkg) return { kind: 'layers', from: pkg[1], package: pkg[2] };
66
+ const layer = String(problem).match(/ \(([^()]+)\) imports \S+ \(([^()]+)\); /);
67
+ return layer ? { kind: 'layers', from: layer[1], to: layer[2] } : null;
68
+ }
@@ -86,6 +86,30 @@ export function readStories(root, planPath) {
86
86
  return stories;
87
87
  }
88
88
 
89
+ /**
90
+ * HUBR-5: each milestone in the plan, in order: [{ name, target }], `name` the `## ` heading a story sits
91
+ * under, `target` the date a `**Target:** YYYY-MM-DD` line under that heading gives, before its first
92
+ * story, or null.
93
+ */
94
+ export function readMilestones(root, planPath) {
95
+ const found = [];
96
+ for (const file of markdownFiles(root, planPath)) {
97
+ let current = null;
98
+ for (const line of readFileSync(join(root, file), 'utf8').split('\n')) {
99
+ const heading = line.match(/^##\s+(.+?)\s*$/);
100
+ if (heading) {
101
+ current = { name: heading[1], target: null, open: true };
102
+ found.push(current);
103
+ continue;
104
+ }
105
+ if (/^###\s/.test(line) && current) current.open = false;
106
+ const target = line.match(/^\*\*Target:\*\*\s*(\d{4}-\d{2}-\d{2})\s*$/i);
107
+ if (target && current?.open && !current.target) current.target = target[1];
108
+ }
109
+ }
110
+ return found.map(({ name, target }) => ({ name, target }));
111
+ }
112
+
89
113
  export const storyBranch = (story) => `${story.lane}/${story.id.toLowerCase()}`;
90
114
 
91
115
  /** Requirement IDs each tracked test file names: { id: [file, ...] }. */
@@ -31,6 +31,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { DEPENDENCY_APPROVAL, isDependencyFile } from './dependencies.mjs';
33
33
  import { globToRegExp, matchesAny } from './glob.mjs';
34
+ import { noteFor } from './standards.mjs';
34
35
 
35
36
  export const APPROVAL_LOG = '.claude/approval-log.jsonl';
36
37
  export const APPROVALS_DIR = '.claude/approvals';
@@ -763,7 +764,7 @@ export function writeProblem(actor, rel, root, config) {
763
764
  decidedBy(guarded.approval, config) === 'lead'
764
765
  ? "the lead's approval"
765
766
  : "a person's approval";
766
- return `${rel} is ${guarded.why} and needs ${who}. ${askHowTo(guarded.approval, actor, root, config)}`;
767
+ return `${rel} is ${guarded.why} and needs ${who}.${noteFor(root, config, { kind: 'protected', name: guarded.approval, glob: guarded.glob })} ${askHowTo(guarded.approval, actor, root, config)}`;
767
768
  }
768
769
  if (matchesAny(rel, config.anyActor)) return null;
769
770
  if (actor.kind === 'readonly') return readonlyProblem(actor, rel);