@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +34 -29
- package/bin/code-kit.mjs +240 -8
- package/hooks/guard-bash.mjs +45 -3
- package/hooks/guard-paths.mjs +3 -2
- package/hooks/hooks.json +22 -0
- package/hooks/lib/adapters/hub/client.mjs +723 -0
- package/hooks/lib/adapters/hub/index.mjs +25 -0
- package/hooks/lib/adapters/index.mjs +35 -0
- package/hooks/lib/checks.mjs +5 -1
- package/hooks/lib/config.mjs +1 -1
- package/hooks/lib/instructions.mjs +46 -13
- package/hooks/lib/layers.mjs +11 -0
- package/hooks/lib/plan.mjs +24 -0
- package/hooks/lib/rules.mjs +2 -1
- package/hooks/lib/snapshot.mjs +424 -0
- package/hooks/lib/standards.mjs +450 -0
- package/hooks/lib/team.mjs +55 -9
- package/hooks/lib/teamfacts.mjs +10 -6
- package/hooks/lib/verify.mjs +23 -1
- package/hooks/mod/register.mjs +35 -0
- package/hooks/mod/register.test.ts +49 -4
- package/hooks/mod/review.mjs +1 -0
- package/hooks/mod/teamview.mjs +8 -2
- package/hooks/mod/view.mjs +36 -0
- package/hooks/post-edit-check.mjs +5 -1
- package/hooks/stop-check.mjs +11 -3
- package/hooks/team-session.mjs +57 -0
- package/package.json +1 -1
- package/skills/init/SKILL.md +16 -2
- package/skills/next/SKILL.md +1 -1
- package/skills/review/SKILL.md +2 -1
- package/skills/spec-design/SKILL.md +8 -6
- package/skills/team-init/SKILL.md +3 -3
- package/templates/agent.md +1 -0
- package/templates/claude-md.md +2 -0
|
@@ -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) {
|
package/hooks/lib/checks.mjs
CHANGED
|
@@ -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
|
};
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
/**
|
|
29
|
-
function
|
|
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
|
-
...
|
|
94
|
-
.
|
|
95
|
-
|
|
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:
|
|
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);
|
package/hooks/lib/layers.mjs
CHANGED
|
@@ -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
|
+
}
|
package/hooks/lib/plan.mjs
CHANGED
|
@@ -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, ...] }. */
|
package/hooks/lib/rules.mjs
CHANGED
|
@@ -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);
|