@warren-dean/code-kit 0.19.0 → 0.20.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 +10 -0
- package/README.md +4 -1
- package/bin/code-kit.mjs +145 -4
- package/hooks/hooks.json +22 -0
- package/hooks/lib/adapters/hub/client.mjs +708 -0
- package/hooks/lib/adapters/hub/index.mjs +25 -0
- package/hooks/lib/adapters/index.mjs +35 -0
- package/hooks/lib/plan.mjs +24 -0
- package/hooks/lib/snapshot.mjs +352 -0
- package/hooks/lib/team.mjs +46 -7
- package/hooks/lib/teamfacts.mjs +10 -6
- package/hooks/mod/register.mjs +35 -0
- package/hooks/mod/register.test.ts +45 -4
- package/hooks/mod/teamview.mjs +8 -2
- package/hooks/mod/view.mjs +36 -0
- package/hooks/team-session.mjs +57 -0
- package/package.json +1 -1
- package/skills/team-init/SKILL.md +3 -3
|
@@ -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/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, ...] }. */
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// HUBR-2: what a project reports to its team provider (docs/specs/21-hub-adapter-and-reports.md): a
|
|
2
|
+
// snapshot of facts code-kit already has (status, timeline, trail, journal's approvals, the merge queue,
|
|
3
|
+
// usage, the rules), as metadata only. SNAPSHOT_SCHEMA is the allow-list: every field a snapshot may
|
|
4
|
+
// hold, and what kind of value it is. A provider validates against the same list, and `validSnapshot`
|
|
5
|
+
// refuses anything else, so file contents, diffs, prompts and messages have nowhere to go.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { APPROVAL_LOG } from './rules.mjs';
|
|
11
|
+
import { approverOf } from './journal.mjs';
|
|
12
|
+
import { readQueue } from './queue.mjs';
|
|
13
|
+
import { sendBacks } from './reviews.mjs';
|
|
14
|
+
import { readMilestones, specCheckRows } from './plan.mjs';
|
|
15
|
+
import { storyTimes } from './timeline.mjs';
|
|
16
|
+
import { trail } from './storymap.mjs';
|
|
17
|
+
|
|
18
|
+
export const SNAPSHOT_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
/** A story's stage, as a report names it. */
|
|
21
|
+
export const STAGES = [
|
|
22
|
+
'planned',
|
|
23
|
+
'claimed',
|
|
24
|
+
'building',
|
|
25
|
+
'review',
|
|
26
|
+
'sent-back',
|
|
27
|
+
'queued',
|
|
28
|
+
'merged',
|
|
29
|
+
'done',
|
|
30
|
+
];
|
|
31
|
+
export const AGENT_KINDS = ['lead', 'lane', 'reviewer', 'security-reviewer', 'background'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The allow-list. A leaf is a kind: `id` (ST-4, API-2), `handle` (a GitHub login), `name` (a lane, a
|
|
35
|
+
* layer, a milestone, an approval: one line, at most 120 characters), `title` (one line, at most 200),
|
|
36
|
+
* `line` (a send-back's reason: one line, at most 200), `path` (a file path or glob: one line, at most
|
|
37
|
+
* 300), `ms` (a time in milliseconds), `date` (YYYY-MM-DD), `count` (a whole number, 0 or more),
|
|
38
|
+
* `bool`, `enum:a|b`. A trailing `?` allows null. An array holds one shape; an object holds only the
|
|
39
|
+
* keys it lists.
|
|
40
|
+
*/
|
|
41
|
+
export const SNAPSHOT_SCHEMA = {
|
|
42
|
+
version: 'count',
|
|
43
|
+
project: { repository: 'name?', base: 'name', codeKit: 'name', at: 'ms' },
|
|
44
|
+
milestones: [{ name: 'name', target: 'date?', stories: ['id'] }],
|
|
45
|
+
stories: [
|
|
46
|
+
{
|
|
47
|
+
id: 'id',
|
|
48
|
+
title: 'title',
|
|
49
|
+
lane: 'name?',
|
|
50
|
+
milestone: 'name?',
|
|
51
|
+
requirements: ['id'],
|
|
52
|
+
dependsOn: ['id'],
|
|
53
|
+
stage: `enum:${STAGES.join('|')}`,
|
|
54
|
+
since: {
|
|
55
|
+
building: 'ms?',
|
|
56
|
+
review: 'ms?',
|
|
57
|
+
sentBack: 'ms?',
|
|
58
|
+
queued: 'ms?',
|
|
59
|
+
merged: 'ms?',
|
|
60
|
+
},
|
|
61
|
+
claimedBy: 'handle?',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
sendBacks: [{ story: 'id', at: 'ms', reason: 'line?' }],
|
|
65
|
+
requirements: [
|
|
66
|
+
{
|
|
67
|
+
id: 'id',
|
|
68
|
+
stories: ['id'],
|
|
69
|
+
built: ['path'],
|
|
70
|
+
tested: ['path'],
|
|
71
|
+
check: 'enum:done|partial|missing|conflicts|none',
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
approvals: [{ what: 'name', path: 'path?', who: 'enum:person|lead', at: 'ms', branch: 'name?' }],
|
|
75
|
+
usage: [
|
|
76
|
+
{
|
|
77
|
+
story: 'id?',
|
|
78
|
+
kind: `enum:${AGENT_KINDS.join('|')}`,
|
|
79
|
+
input: 'count',
|
|
80
|
+
output: 'count',
|
|
81
|
+
cache: 'count',
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
rules: {
|
|
85
|
+
layers: [{ name: 'name', mayImport: ['name'] }],
|
|
86
|
+
protected: [{ glob: 'path', approval: 'name' }],
|
|
87
|
+
delegated: { protected: ['name'], dependencies: 'bool', merge: 'bool' },
|
|
88
|
+
designGate: 'bool',
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const ONE_LINE = (max) => (v) => typeof v === 'string' && v.length <= max && !/[\r\n]/.test(v);
|
|
93
|
+
const KINDS = {
|
|
94
|
+
id: (v) => typeof v === 'string' && /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(v),
|
|
95
|
+
handle: (v) => typeof v === 'string' && /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(v),
|
|
96
|
+
name: ONE_LINE(120),
|
|
97
|
+
title: ONE_LINE(200),
|
|
98
|
+
line: ONE_LINE(200),
|
|
99
|
+
path: ONE_LINE(300),
|
|
100
|
+
ms: (v) => Number.isInteger(v) && v > 0,
|
|
101
|
+
date: (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v),
|
|
102
|
+
count: (v) => Number.isInteger(v) && v >= 0,
|
|
103
|
+
bool: (v) => typeof v === 'boolean',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** What's wrong with a snapshot against the allow-list, as `path: problem` lines; empty when valid. */
|
|
107
|
+
export function snapshotProblems(value, shape = SNAPSHOT_SCHEMA, at = 'snapshot') {
|
|
108
|
+
if (typeof shape === 'string') {
|
|
109
|
+
const optional = shape.endsWith('?');
|
|
110
|
+
const kind = optional ? shape.slice(0, -1) : shape;
|
|
111
|
+
if (value === null || value === undefined) return optional ? [] : [`${at}: missing`];
|
|
112
|
+
if (kind.startsWith('enum:'))
|
|
113
|
+
return kind.slice(5).split('|').includes(value) ? [] : [`${at}: not one of ${kind.slice(5)}`];
|
|
114
|
+
return KINDS[kind](value) ? [] : [`${at}: not a ${kind}`];
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(shape)) {
|
|
117
|
+
if (!Array.isArray(value)) return [`${at}: not a list`];
|
|
118
|
+
return value.flatMap((v, i) => snapshotProblems(v, shape[0], `${at}[${i}]`));
|
|
119
|
+
}
|
|
120
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
121
|
+
return [`${at}: not an object`];
|
|
122
|
+
const extra = Object.keys(value).filter((k) => !(k in shape));
|
|
123
|
+
return [
|
|
124
|
+
...extra.map((k) => `${at}.${k}: not a field a report may hold`),
|
|
125
|
+
...Object.entries(shape).flatMap(([k, s]) => snapshotProblems(value[k], s, `${at}.${k}`)),
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The snapshot's hash, leaving out when it was made: equal snapshots aren't sent twice. */
|
|
130
|
+
export function snapshotHash(snapshot, parts = null) {
|
|
131
|
+
const { project, ...rest } = snapshot;
|
|
132
|
+
const kept = parts ? Object.fromEntries(parts.map((p) => [p, rest[p]])) : rest;
|
|
133
|
+
return createHash('sha256')
|
|
134
|
+
.update(JSON.stringify({ project: { ...project, at: 0 }, ...kept }))
|
|
135
|
+
.digest('hex');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The parts whose change is sent at once (HUBR-3); usage alone waits for the ten minutes. */
|
|
139
|
+
export const EVENT_PARTS = [
|
|
140
|
+
'milestones',
|
|
141
|
+
'stories',
|
|
142
|
+
'sendBacks',
|
|
143
|
+
'requirements',
|
|
144
|
+
'approvals',
|
|
145
|
+
'rules',
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
const git = (root, ...args) => {
|
|
149
|
+
try {
|
|
150
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
151
|
+
encoding: 'utf8',
|
|
152
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
153
|
+
}).trim();
|
|
154
|
+
} catch {
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** owner/name of the repository's GitHub origin, or null. */
|
|
160
|
+
export function repositoryOf(root) {
|
|
161
|
+
const remote = git(root, 'config', '--get', 'remote.origin.url');
|
|
162
|
+
const m = remote.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
163
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The main checkout, where the lanes' worktrees share their state. */
|
|
167
|
+
export function mainCheckout(root) {
|
|
168
|
+
const common = git(root, 'rev-parse', '--path-format=absolute', '--git-common-dir');
|
|
169
|
+
return common ? dirname(common) : root;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const USAGE_DIR = '.claude/state/usage';
|
|
173
|
+
|
|
174
|
+
/** The usage every session recorded (USE-1's ledgers, written by the mod): [{ story, kind, … }]. */
|
|
175
|
+
export function recordedUsage(root) {
|
|
176
|
+
const dir = join(mainCheckout(root), USAGE_DIR);
|
|
177
|
+
const sums = new Map();
|
|
178
|
+
let files = [];
|
|
179
|
+
try {
|
|
180
|
+
files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
181
|
+
} catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
for (const f of files) {
|
|
185
|
+
let entries = [];
|
|
186
|
+
try {
|
|
187
|
+
entries = Object.values(JSON.parse(readFileSync(join(dir, f), 'utf8')).entries ?? {});
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
for (const e of entries) {
|
|
192
|
+
if (!AGENT_KINDS.includes(e.kind)) continue;
|
|
193
|
+
const story = KINDS.id(e.story) ? e.story : null;
|
|
194
|
+
const key = `${story}|${e.kind}`;
|
|
195
|
+
const was = sums.get(key) ?? { story, kind: e.kind, input: 0, output: 0, cache: 0 };
|
|
196
|
+
for (const k of ['input', 'output', 'cache'])
|
|
197
|
+
was[k] += Number.isInteger(e[k]) && e[k] > 0 ? e[k] : 0;
|
|
198
|
+
sums.set(key, was);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return [...sums.values()];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The spec-check report's status for each requirement of a story, from its branch's report. */
|
|
205
|
+
function checkedRows(root, story) {
|
|
206
|
+
const name = `${story.branch.replace(/[^\w.-]+/g, '_')}.md`;
|
|
207
|
+
for (const dir of [story.worktree, root].filter(Boolean)) {
|
|
208
|
+
const file = join(dir, '.claude/state/spec-check', name);
|
|
209
|
+
if (existsSync(file)) return specCheckRows(readFileSync(file, 'utf8'));
|
|
210
|
+
}
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const CHECKS = ['done', 'partial', 'missing', 'conflicts'];
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The snapshot (HUBR-2) from what code-kit already computed: `status` is buildStatus's, `base` the
|
|
218
|
+
* branch stories merge into, `claims` the team's claims as last read, `version` code-kit's.
|
|
219
|
+
*/
|
|
220
|
+
export function buildSnapshot({
|
|
221
|
+
root,
|
|
222
|
+
config,
|
|
223
|
+
status,
|
|
224
|
+
base,
|
|
225
|
+
claims = [],
|
|
226
|
+
version,
|
|
227
|
+
now = Date.now(),
|
|
228
|
+
}) {
|
|
229
|
+
const times = new Map(storyTimes(root, status.stories, base).map((t) => [t.id, t]));
|
|
230
|
+
const queued = new Map(
|
|
231
|
+
readQueue(root)
|
|
232
|
+
.filter((e) => e.state === 'waiting' || e.state === 'merging')
|
|
233
|
+
.map((e) => [e.branch, Date.parse(e.passed)]),
|
|
234
|
+
);
|
|
235
|
+
const backs = sendBacks(root);
|
|
236
|
+
const claimed = new Map(claims.map((c) => [c.story, c.by]));
|
|
237
|
+
const ms = (v) => (Number.isInteger(v) && v > 0 ? v : null);
|
|
238
|
+
const line = (s) => (s ? String(s).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null);
|
|
239
|
+
|
|
240
|
+
const stories = status.stories.map((s) => {
|
|
241
|
+
const t = times.get(s.id) ?? {};
|
|
242
|
+
const myBacks = backs.filter((b) => b.branch === s.branch);
|
|
243
|
+
const lastBack = myBacks.length ? Date.parse(myBacks.at(-1).at) : null;
|
|
244
|
+
let stage = 'planned';
|
|
245
|
+
if (s.state === 'done') stage = s.status === 'done' ? 'done' : 'merged';
|
|
246
|
+
else if (s.state === 'sent back') stage = 'sent-back';
|
|
247
|
+
else if (s.state === 'review') stage = queued.has(s.branch) ? 'queued' : 'review';
|
|
248
|
+
else if (s.state === 'in progress') stage = 'building';
|
|
249
|
+
else if (claimed.has(s.id)) stage = 'claimed';
|
|
250
|
+
const reached = STAGES.indexOf(stage);
|
|
251
|
+
return {
|
|
252
|
+
id: s.id,
|
|
253
|
+
title: line(s.title) ?? s.id,
|
|
254
|
+
lane: s.lane || null,
|
|
255
|
+
milestone: s.milestone ? line(s.milestone).slice(0, 120) : null,
|
|
256
|
+
requirements: s.requirements,
|
|
257
|
+
dependsOn: s.dependsOn,
|
|
258
|
+
stage,
|
|
259
|
+
since: {
|
|
260
|
+
building: reached >= STAGES.indexOf('building') ? ms(t.started) : null,
|
|
261
|
+
review:
|
|
262
|
+
reached >= STAGES.indexOf('review') && stage !== 'sent-back' ? ms(t.lastCommit) : null,
|
|
263
|
+
sentBack: ms(lastBack),
|
|
264
|
+
queued: stage === 'queued' ? ms(queued.get(s.branch)) : null,
|
|
265
|
+
merged: ms(t.merged),
|
|
266
|
+
},
|
|
267
|
+
claimedBy: claimed.get(s.id) ?? null,
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const targets = Object.fromEntries(
|
|
272
|
+
readMilestones(root, config.docs.plan).map((m) => [m.name, m.target]),
|
|
273
|
+
);
|
|
274
|
+
const milestones = [...new Set(status.stories.map((s) => s.milestone).filter(Boolean))].map(
|
|
275
|
+
(m) => ({
|
|
276
|
+
name: line(m).slice(0, 120),
|
|
277
|
+
target: targets[m] ?? null,
|
|
278
|
+
stories: status.stories.filter((s) => s.milestone === m).map((s) => s.id),
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const byBranch = new Map(status.stories.map((s) => [s.branch, s.id]));
|
|
283
|
+
const sendBackList = backs
|
|
284
|
+
.filter((b) => byBranch.has(b.branch) && ms(Date.parse(b.at)))
|
|
285
|
+
.map((b) => ({ story: byBranch.get(b.branch), at: Date.parse(b.at), reason: line(b.reason) }));
|
|
286
|
+
|
|
287
|
+
const checks = new Map();
|
|
288
|
+
for (const s of status.stories)
|
|
289
|
+
if (s.branchExists)
|
|
290
|
+
for (const row of checkedRows(root, s))
|
|
291
|
+
if (CHECKS.includes(row.status)) checks.set(row.requirement, row.status);
|
|
292
|
+
const requirements = trail(root, status, base).map((r) => ({
|
|
293
|
+
id: r.id,
|
|
294
|
+
stories: r.stories.map((s) => s.id),
|
|
295
|
+
built: r.files.filter((f) => KINDS.path(f)),
|
|
296
|
+
tested: r.tests.filter((f) => KINDS.path(f)),
|
|
297
|
+
check: checks.get(r.id) ?? 'none',
|
|
298
|
+
}));
|
|
299
|
+
|
|
300
|
+
const approvals = [];
|
|
301
|
+
const seen = new Set();
|
|
302
|
+
for (const raw of git(root, 'show', `${base}:${APPROVAL_LOG}`).split('\n').filter(Boolean)) {
|
|
303
|
+
let e;
|
|
304
|
+
try {
|
|
305
|
+
e = JSON.parse(raw);
|
|
306
|
+
} catch {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (!e.approval || e.approval === 'lead') continue;
|
|
310
|
+
const at = Date.parse(e.grantedAt ?? e.at);
|
|
311
|
+
const key = `${e.approval}|${e.file ?? ''}|${at}`;
|
|
312
|
+
if (seen.has(key) || !ms(at) || !KINDS.name(e.approval)) continue;
|
|
313
|
+
seen.add(key);
|
|
314
|
+
approvals.push({
|
|
315
|
+
what: e.approval,
|
|
316
|
+
path: KINDS.path(e.file) ? e.file : null,
|
|
317
|
+
who: approverOf(e),
|
|
318
|
+
at,
|
|
319
|
+
branch: KINDS.name(e.branch) ? e.branch : null,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const delegate = config.approvals?.delegate ?? null;
|
|
324
|
+
const snapshot = {
|
|
325
|
+
version: SNAPSHOT_VERSION,
|
|
326
|
+
project: {
|
|
327
|
+
repository: repositoryOf(root),
|
|
328
|
+
base,
|
|
329
|
+
codeKit: String(version),
|
|
330
|
+
at: now,
|
|
331
|
+
},
|
|
332
|
+
milestones,
|
|
333
|
+
stories,
|
|
334
|
+
sendBacks: sendBackList,
|
|
335
|
+
requirements,
|
|
336
|
+
approvals,
|
|
337
|
+
usage: recordedUsage(root),
|
|
338
|
+
rules: {
|
|
339
|
+
layers: (config.layers ?? []).map((l) => ({ name: l.name, mayImport: l.mayImport ?? [] })),
|
|
340
|
+
protected: (config.protected ?? [])
|
|
341
|
+
.filter((p) => KINDS.path(p.glob) && KINDS.name(p.approval))
|
|
342
|
+
.map((p) => ({ glob: p.glob, approval: p.approval })),
|
|
343
|
+
delegated: {
|
|
344
|
+
protected: delegate?.protected ?? [],
|
|
345
|
+
dependencies: Boolean(delegate?.dependencies),
|
|
346
|
+
merge: Boolean(delegate?.merge),
|
|
347
|
+
},
|
|
348
|
+
designGate: Boolean(config.designGate),
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
return snapshot;
|
|
352
|
+
}
|
package/hooks/lib/team.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
4
4
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { matchesAny } from './glob.mjs';
|
|
7
|
+
import { providerAdapterOf } from './adapters/index.mjs';
|
|
7
8
|
import {
|
|
8
9
|
isHandle,
|
|
9
10
|
isStoryId,
|
|
@@ -57,19 +58,24 @@ export function teamProblems(c) {
|
|
|
57
58
|
);
|
|
58
59
|
if (t.intents !== undefined && !(typeof t.intents === 'string' && t.intents.trim()))
|
|
59
60
|
p.push('"team.intents" must name the discussion category intents go in');
|
|
61
|
+
const adapter = providerAdapterOf(t.provider);
|
|
60
62
|
if (
|
|
61
63
|
t.provider !== undefined &&
|
|
62
64
|
t.provider !== 'github' &&
|
|
65
|
+
!(adapter && !adapter.problem) &&
|
|
63
66
|
!(
|
|
64
67
|
isObj(t.provider) &&
|
|
68
|
+
!adapter &&
|
|
65
69
|
typeof t.provider.command === 'string' &&
|
|
66
70
|
t.provider.command.trim() &&
|
|
67
71
|
(t.provider.fallback === undefined || t.provider.fallback === 'github')
|
|
68
72
|
)
|
|
69
73
|
)
|
|
70
74
|
p.push(
|
|
71
|
-
'"team.provider" must be "github" or { "command": "<the provider\'s command>", "fallback"?: "github" }',
|
|
75
|
+
'"team.provider" must be "github" or { "command": "<the provider\'s command>", "fallback"?: "github" }, or a hosted provider in preview: { "<its name>": "<its https address>" }',
|
|
72
76
|
);
|
|
77
|
+
if (t.report !== undefined && typeof t.report !== 'boolean')
|
|
78
|
+
p.push('"team.report" must be true or false');
|
|
73
79
|
}
|
|
74
80
|
for (const [name, lane] of Object.entries(isObj(c.lanes) ? c.lanes : {})) {
|
|
75
81
|
if (!isObj(lane) || lane.owners === undefined) continue;
|
|
@@ -86,12 +92,26 @@ export function teamProblems(c) {
|
|
|
86
92
|
*/
|
|
87
93
|
export function teamOf(raw) {
|
|
88
94
|
if (!isObj(raw?.team)) return null;
|
|
95
|
+
// HUBR-6: a hosted provider's adapter runs as a command, with GitHub as its fallback.
|
|
96
|
+
const adapter = providerAdapterOf(raw.team.provider);
|
|
97
|
+
const hosted = adapter && !adapter.problem ? adapter : null;
|
|
89
98
|
return {
|
|
90
99
|
techLead: raw.team.techLead,
|
|
91
100
|
staleDays: raw.team.staleDays ?? 3,
|
|
92
|
-
|
|
101
|
+
// HUBR-7: a hosted provider keeps intents; Discussions only when the team mirrors them there.
|
|
102
|
+
intents: raw.team.intents ?? (hosted ? null : 'Intents'),
|
|
93
103
|
board: raw.team.board ?? null,
|
|
94
|
-
|
|
104
|
+
// HUBR-4: whether the project reports its facts to a provider that isn't GitHub.
|
|
105
|
+
report: raw.team.report !== false,
|
|
106
|
+
provider: hosted
|
|
107
|
+
? {
|
|
108
|
+
command: hosted.adapter.command(hosted.url),
|
|
109
|
+
fallback: 'github',
|
|
110
|
+
adapter: hosted.adapter.key,
|
|
111
|
+
url: hosted.url,
|
|
112
|
+
name: hosted.adapter.label,
|
|
113
|
+
}
|
|
114
|
+
: (raw.team.provider ?? 'github'),
|
|
95
115
|
owners: Object.fromEntries(
|
|
96
116
|
Object.entries(raw.lanes ?? {})
|
|
97
117
|
.filter(([, l]) => Array.isArray(l?.owners))
|
|
@@ -139,6 +159,9 @@ export function whoami(root, now = Date.now()) {
|
|
|
139
159
|
// the config, called with the operation's name and its input as JSON on stdin, answering JSON.
|
|
140
160
|
|
|
141
161
|
const CLAIMS = 'refs/code-kit/claims';
|
|
162
|
+
/** HUBR-7: what an intent waits on while the hosted provider that keeps intents is down. */
|
|
163
|
+
const KEPT =
|
|
164
|
+
"This team's intents are kept by its hosted provider, which can't be reached: raise or settle it again when it's back, or set team.intents to mirror intents to a GitHub Discussions category.";
|
|
142
165
|
const SEEN = 'refs/code-kit/seen-claims';
|
|
143
166
|
const TEAM_STATE = '.claude/state/team.json';
|
|
144
167
|
const STATE_SECONDS = 60;
|
|
@@ -157,7 +180,7 @@ function claimOf(message) {
|
|
|
157
180
|
|
|
158
181
|
/**
|
|
159
182
|
* The provider for this project's team: GitHub's, or the config's command. A command with
|
|
160
|
-
* `fallback: "github"` (a hosted provider, such as
|
|
183
|
+
* `fallback: "github"` (a hosted provider, such as code-kit hub) hands each operation it
|
|
161
184
|
* can't reach to the GitHub provider, so claims are still checked while it's down (TEAM-8).
|
|
162
185
|
*/
|
|
163
186
|
export function teamProvider(root, team) {
|
|
@@ -189,6 +212,7 @@ export function teamProvider(root, team) {
|
|
|
189
212
|
'reassign',
|
|
190
213
|
'intent',
|
|
191
214
|
'since',
|
|
215
|
+
'report',
|
|
192
216
|
].map((op) => [op, either(op)]),
|
|
193
217
|
),
|
|
194
218
|
forget: () => (command.forget(), github.forget()),
|
|
@@ -223,8 +247,14 @@ function githubProvider(root, team) {
|
|
|
223
247
|
return null;
|
|
224
248
|
}
|
|
225
249
|
};
|
|
226
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* The repository's discussions, the Intents category among its categories: null when unreadable.
|
|
252
|
+
* HUBR-7: a team whose hosted provider keeps its intents, and that mirrors none to Discussions, has
|
|
253
|
+
* none here to read (`kept`).
|
|
254
|
+
*/
|
|
227
255
|
const discussions = () => {
|
|
256
|
+
if (!team.intents)
|
|
257
|
+
return { repoId: null, enabled: false, category: null, intents: [], kept: true };
|
|
228
258
|
const d = graphql(
|
|
229
259
|
`
|
|
230
260
|
query ($owner: String!, $name: String!) {
|
|
@@ -452,13 +482,15 @@ function githubProvider(root, team) {
|
|
|
452
482
|
provider: {
|
|
453
483
|
name: 'GitHub',
|
|
454
484
|
live: false,
|
|
455
|
-
detail:
|
|
485
|
+
detail: team.intents
|
|
486
|
+
? `claims are refs on origin, intents are discussions in "${team.intents}"`
|
|
487
|
+
: 'claims are refs on origin; intents wait for the hosted provider',
|
|
456
488
|
},
|
|
457
489
|
// TEAM-13: what the team still needs set up for its claims and intents to work.
|
|
458
490
|
setup: [
|
|
459
491
|
...(who ? [] : ["gh isn't signed in on this machine: run gh auth login."]),
|
|
460
492
|
...(claims ? [] : ["The shared repository (origin) can't be reached."]),
|
|
461
|
-
...(found && !found.category
|
|
493
|
+
...(found && !found.category && !found.kept
|
|
462
494
|
? [noCategory(found).replace(/, then raise the intent again\.$/, '.')]
|
|
463
495
|
: []),
|
|
464
496
|
],
|
|
@@ -489,6 +521,7 @@ function githubProvider(root, team) {
|
|
|
489
521
|
if (!by) return { ok: false, why: 'Sign in to GitHub first (gh auth login).' };
|
|
490
522
|
const found = discussions();
|
|
491
523
|
if (!found) return unreachable("GitHub can't be reached, so the intent wasn't raised.");
|
|
524
|
+
if (found.kept) return unreachable(KEPT);
|
|
492
525
|
if (!found.category) return { ok: false, why: noCategory(found) };
|
|
493
526
|
const body = `${touches.length ? `Touches: ${touches.join(', ')}\n\n` : ''}Raised with code-kit by @${by} before any code, so the team sees it first. The tech lead agrees, drops or marks it a duplicate here.`;
|
|
494
527
|
const made = graphql(
|
|
@@ -585,6 +618,7 @@ function githubProvider(root, team) {
|
|
|
585
618
|
},
|
|
586
619
|
/** TEAM-10: one intent with its comments, for its decision record; null when unreadable. */
|
|
587
620
|
intent(id) {
|
|
621
|
+
if (!team.intents) return null;
|
|
588
622
|
const d = graphql(
|
|
589
623
|
`
|
|
590
624
|
query ($owner: String!, $name: String!, $number: Int!) {
|
|
@@ -627,6 +661,7 @@ function githubProvider(root, team) {
|
|
|
627
661
|
},
|
|
628
662
|
/** TEAM-5: the tech lead agrees an intent (resolved), drops it (outdated) or marks it a duplicate. */
|
|
629
663
|
settle(id, outcome, why = '') {
|
|
664
|
+
if (!team.intents) return unreachable(KEPT);
|
|
630
665
|
const by = me();
|
|
631
666
|
if (by !== team.techLead)
|
|
632
667
|
return {
|
|
@@ -689,6 +724,8 @@ function githubProvider(root, team) {
|
|
|
689
724
|
provider.forget();
|
|
690
725
|
return { ok: true, id: Number(id), outcome };
|
|
691
726
|
},
|
|
727
|
+
// HUBR-1: the free team mode has no server to report to; nothing is written anywhere.
|
|
728
|
+
report: () => ({ ok: true, sent: false }),
|
|
692
729
|
forget: () => writeCache(root, null),
|
|
693
730
|
};
|
|
694
731
|
return provider;
|
|
@@ -739,6 +776,8 @@ function commandProvider(root, team, command) {
|
|
|
739
776
|
const r = call('since', { at });
|
|
740
777
|
return Array.isArray(r) ? r : null;
|
|
741
778
|
},
|
|
779
|
+
// HUBR-1: the project's facts, on stdin as the operation's input.
|
|
780
|
+
report: (snapshot) => call('report', { snapshot }),
|
|
742
781
|
forget: () => writeCache(root, null),
|
|
743
782
|
};
|
|
744
783
|
}
|
package/hooks/lib/teamfacts.mjs
CHANGED
|
@@ -122,17 +122,21 @@ export function decisionRecord(d, techLead) {
|
|
|
122
122
|
.replace(/^Touches:.*$/m, '')
|
|
123
123
|
.replace(/Raised with code-kit by @\S+ before any code[^\n]*/, '')
|
|
124
124
|
.trim();
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
.
|
|
125
|
+
// Who settled it: the provider says (a lane owner whose paths it touches may agree it with a hosted
|
|
126
|
+
// provider), or the tech lead, who alone settles intents through GitHub.
|
|
127
|
+
const by = d.settledBy ?? d.settled_by ?? techLead;
|
|
128
|
+
const agreed =
|
|
129
|
+
(d.comments ?? [])
|
|
130
|
+
.filter((c) => c.author?.login === by && /^Agreed by @/.test(c.body))
|
|
131
|
+
.at(-1)
|
|
132
|
+
?.body.replace(/^Agreed by @[\w-]+(?:, [^:.]*)?(: |\.)?/, '')
|
|
133
|
+
.trim() || (typeof d.why === 'string' ? d.why.trim() : '');
|
|
130
134
|
return [
|
|
131
135
|
`# ${d.title}`,
|
|
132
136
|
'',
|
|
133
137
|
'**Status:** agreed',
|
|
134
138
|
`**Date:** ${date}`,
|
|
135
|
-
`**Raised by:** @${d.author?.login ?? 'someone'} · **Agreed by:** @${
|
|
139
|
+
`**Raised by:** @${d.author?.login ?? 'someone'} · **Agreed by:** @${by}`,
|
|
136
140
|
`**Discussion:** ${d.url}`,
|
|
137
141
|
'',
|
|
138
142
|
'## What changes, and why',
|