@warren-dean/code-kit 0.18.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 +23 -0
- package/README.md +32 -27
- package/bin/code-kit.mjs +224 -5
- package/hooks/guard-bash.mjs +8 -0
- 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/harness.mjs +17 -2
- package/hooks/lib/instructions.mjs +1 -1
- package/hooks/lib/next.mjs +27 -1
- 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/loop.mjs +20 -3
- package/hooks/mod/register.mjs +116 -7
- package/hooks/mod/register.test.ts +118 -6
- 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/dispatch/SKILL.md +2 -0
- package/skills/review/SKILL.md +2 -2
- package/skills/spec-check/SKILL.md +1 -1
- package/skills/team-init/SKILL.md +3 -3
- package/templates/agent.md +3 -1
|
@@ -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/harness.mjs
CHANGED
|
@@ -47,12 +47,13 @@ export const SETTINGS = {
|
|
|
47
47
|
about: 'A background reviewer for each finished branch',
|
|
48
48
|
},
|
|
49
49
|
'agents.reviewer.model': {
|
|
50
|
-
default:
|
|
50
|
+
default: 'sonnet',
|
|
51
51
|
problem: (v) =>
|
|
52
52
|
v === null || (typeof v === 'string' && v.length > 0)
|
|
53
53
|
? null
|
|
54
54
|
: `must be a model (${MODELS.join(', ')}, or an id), or null for the session's`,
|
|
55
|
-
about:
|
|
55
|
+
about:
|
|
56
|
+
"The reviewers' model (code-kit's reviewer and the security reviewer); none means the session's",
|
|
56
57
|
},
|
|
57
58
|
'agents.lanes.model': {
|
|
58
59
|
default: null,
|
|
@@ -63,12 +64,26 @@ export const SETTINGS = {
|
|
|
63
64
|
about:
|
|
64
65
|
"The lane agents' model, set as each is dispatched; none means the session's. A lane's agent file with its own model keeps it",
|
|
65
66
|
},
|
|
67
|
+
'agents.lanes.contextLimit': {
|
|
68
|
+
default: 250000,
|
|
69
|
+
problem: (v) =>
|
|
70
|
+
Number.isInteger(v) && v >= 0 ? null : 'must be a whole number of tokens, 0 for no limit',
|
|
71
|
+
about:
|
|
72
|
+
"The context a lane agent may reach before it's asked to wrap up and hand its story to a fresh agent; 0 for no limit",
|
|
73
|
+
},
|
|
66
74
|
'background.pauseAtPercent': {
|
|
67
75
|
default: 80,
|
|
68
76
|
problem: (v) =>
|
|
69
77
|
Number.isFinite(v) && v > 0 && v <= 100 ? null : 'must be a percentage from 1 to 100',
|
|
70
78
|
about: "The plan's 5-hour use at which background agents pause",
|
|
71
79
|
},
|
|
80
|
+
'compact.at': {
|
|
81
|
+
default: 40,
|
|
82
|
+
problem: (v) =>
|
|
83
|
+
Number.isFinite(v) && v >= 0 && v <= 100 ? null : 'must be a percentage from 0 (off) to 100',
|
|
84
|
+
about:
|
|
85
|
+
"How full the lead's context may get, as a share of its window, before it's compacted between turns; 0 for never",
|
|
86
|
+
},
|
|
72
87
|
'compact.milestone': {
|
|
73
88
|
default: 'ask',
|
|
74
89
|
problem: (v) => (COMPACT.includes(v) ? null : `must be one of ${COMPACT.join(', ')}`),
|
|
@@ -7,7 +7,7 @@ import { dirname, join } from 'node:path';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
|
|
9
9
|
/** The version of code-kit's instructions: raised whenever the templates' rules change. */
|
|
10
|
-
export const INSTRUCTIONS_VERSION =
|
|
10
|
+
export const INSTRUCTIONS_VERSION = 5;
|
|
11
11
|
|
|
12
12
|
const TEMPLATES = fileURLToPath(new URL('../../templates/', import.meta.url));
|
|
13
13
|
const BEGIN = (v) => `<!-- code-kit:begin v${v} -->`;
|
package/hooks/lib/next.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What to do next, from the project's state alone: no docs → spec-design; a spec still being shaped →
|
|
2
2
|
// spec-design; a ready spec or existing code without the kit → init; then, from the plan and git:
|
|
3
3
|
// review finished stories, dispatch ready ones, wait on running ones, or plan the next milestone.
|
|
4
|
-
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { execFileSync, spawnSync } 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';
|
|
@@ -254,6 +254,12 @@ export function nextStep(root, base) {
|
|
|
254
254
|
attention.push(
|
|
255
255
|
`${unbuildable.join(', ')} can't be dispatched until the plan's gaps are fixed.`,
|
|
256
256
|
);
|
|
257
|
+
// LEAN-6: worktrees whose story is merged and which hold nothing uncommitted are only disk space.
|
|
258
|
+
const leftovers = mergedWorktrees(root, config);
|
|
259
|
+
if (leftovers)
|
|
260
|
+
attention.push(
|
|
261
|
+
`${leftovers} worktree${leftovers === 1 ? ' is' : 's are'} merged and clean; \`code-kit worktrees prune\` removes them, keeping their reports.`,
|
|
262
|
+
);
|
|
257
263
|
// ONB-8: code-kit's own instructions to the lead and the lanes, brought up to date before the next step:
|
|
258
264
|
// a newer code-kit's rules reach the agents only through them.
|
|
259
265
|
const instructions = instructionsState(root, config);
|
|
@@ -485,3 +491,23 @@ export function missingSkills(root) {
|
|
|
485
491
|
}
|
|
486
492
|
return out;
|
|
487
493
|
}
|
|
494
|
+
|
|
495
|
+
/** LEAN-6: how many of the project's worktrees have a merged branch and nothing uncommitted. */
|
|
496
|
+
function mergedWorktrees(root, config) {
|
|
497
|
+
const list = git(root, 'worktree', 'list', '--porcelain');
|
|
498
|
+
if (!list) return 0;
|
|
499
|
+
const from = config.branches?.protected?.[0];
|
|
500
|
+
if (!from || !git(root, 'rev-parse', '--verify', '-q', from)) return 0;
|
|
501
|
+
let n = 0;
|
|
502
|
+
for (const block of list.split('\n\n').slice(1)) {
|
|
503
|
+
const path = /^worktree (.+)$/m.exec(block)?.[1];
|
|
504
|
+
const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1];
|
|
505
|
+
if (!path || !branch) continue;
|
|
506
|
+
const merged =
|
|
507
|
+
spawnSync('git', ['merge-base', '--is-ancestor', branch, from], { cwd: root }).status === 0;
|
|
508
|
+
if (!merged) continue;
|
|
509
|
+
const status = spawnSync('git', ['-C', path, 'status', '--porcelain'], { encoding: 'utf8' });
|
|
510
|
+
if (status.status === 0 && !status.stdout.trim()) n++;
|
|
511
|
+
}
|
|
512
|
+
return n;
|
|
513
|
+
}
|
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
|
+
}
|