@warren-dean/code-kit 0.21.0 → 0.22.1
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 +20 -0
- package/README.md +46 -23
- package/bin/code-kit.mjs +271 -21
- package/hooks/guard-agent.mjs +36 -0
- package/hooks/guard-bash.mjs +23 -11
- package/hooks/guard-paths.mjs +11 -0
- package/hooks/hooks.json +20 -0
- package/hooks/lane-audit.mjs +38 -1
- package/hooks/lib/adapters/hub/client.mjs +177 -25
- package/hooks/lib/config.mjs +148 -5
- package/hooks/lib/decisionstore.mjs +89 -0
- package/hooks/lib/harness.mjs +7 -0
- package/hooks/lib/hook.mjs +7 -0
- package/hooks/lib/instructions.mjs +1 -1
- package/hooks/lib/laneclaims.mjs +89 -0
- package/hooks/lib/loop-state.mjs +20 -2
- package/hooks/lib/next.mjs +34 -1
- package/hooks/lib/plan.mjs +3 -1
- package/hooks/lib/questions.mjs +23 -5
- package/hooks/lib/regression.mjs +2 -1
- package/hooks/lib/rules.mjs +18 -12
- package/hooks/lib/schedule.mjs +86 -0
- package/hooks/lib/sessions.mjs +223 -0
- package/hooks/lib/snapshot.mjs +9 -3
- package/hooks/mod/decisions.mjs +1 -28
- package/hooks/mod/loop.mjs +8 -0
- package/hooks/mod/mission.mjs +21 -19
- package/hooks/mod/register.mjs +118 -41
- package/hooks/mod/register.test.ts +244 -73
- package/hooks/mod/view.mjs +98 -47
- package/hooks/post-edit-check.mjs +3 -0
- package/hooks/session.mjs +24 -0
- package/hooks/stop-check.mjs +12 -0
- package/package.json +1 -1
- package/skills/decide/SKILL.md +1 -1
- package/skills/dispatch/SKILL.md +2 -2
- package/skills/init/SKILL.md +1 -1
- package/skills/lead/SKILL.md +28 -0
- package/skills/next/SKILL.md +1 -1
- package/skills/spec-design/SKILL.md +2 -1
- package/skills/team-init/SKILL.md +1 -1
- package/templates/claude-md.md +3 -1
- package/templates/plan.md +2 -1
package/hooks/lane-audit.mjs
CHANGED
|
@@ -79,6 +79,26 @@ function takeNote(input) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* SESS-5: the files git said a command's checkout, merge or rebase would overwrite, when it refused for
|
|
84
|
+
* uncommitted work: [] when it didn't.
|
|
85
|
+
*/
|
|
86
|
+
export function overwrittenFiles(response) {
|
|
87
|
+
const text = typeof response === 'string' ? response : JSON.stringify(response ?? '');
|
|
88
|
+
const said = text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
|
89
|
+
const files = [];
|
|
90
|
+
for (const m of said.matchAll(
|
|
91
|
+
/(?:local changes to the following files|untracked working tree files) would be (?:overwritten|removed) by [\w-]+:\n((?:\t[^\n]+\n?)+)/g,
|
|
92
|
+
))
|
|
93
|
+
files.push(
|
|
94
|
+
...m[1]
|
|
95
|
+
.split('\n')
|
|
96
|
+
.map((l) => l.trim())
|
|
97
|
+
.filter(Boolean),
|
|
98
|
+
);
|
|
99
|
+
return [...new Set(files)];
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
export function auditProblems(input, config) {
|
|
83
103
|
const root = actorRoot(input);
|
|
84
104
|
if (!root) return [];
|
|
@@ -99,8 +119,25 @@ export function auditProblems(input, config) {
|
|
|
99
119
|
|
|
100
120
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
101
121
|
const { block, start } = await import('./lib/hook.mjs');
|
|
102
|
-
const { input, config, error } = start();
|
|
122
|
+
const { input, project, config, error } = start();
|
|
103
123
|
if (error) process.exit(0); // guard-paths and stop-check report an invalid config
|
|
124
|
+
// SESS-5: git refused because of uncommitted files another session wrote: say whose they are.
|
|
125
|
+
const overwritten = overwrittenFiles(input.tool_response);
|
|
126
|
+
if (overwritten.length) {
|
|
127
|
+
const { memberOf, writersOf } = await import('./lib/sessions.mjs');
|
|
128
|
+
const whose = Object.entries(writersOf(project, overwritten, input.session_id ?? null));
|
|
129
|
+
if (whose.length)
|
|
130
|
+
block(
|
|
131
|
+
`Git refused because of uncommitted files another session wrote in this checkout:\n ${whose
|
|
132
|
+
.map(
|
|
133
|
+
([f, w]) =>
|
|
134
|
+
`${f}: ${w.map((s) => `${memberOf(s)}${s.task ? `, working on "${s.task.title}"` : ''}${s.live ? '' : ' (ended)'}`).join('; ')}`,
|
|
135
|
+
)
|
|
136
|
+
.join(
|
|
137
|
+
'\n ',
|
|
138
|
+
)}\nAsk that session to commit or stash them; don't stash or discard another session's work. Better, work in a worktree of your own, where no other session's files can block you.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
104
141
|
const problems = auditProblems(input, config);
|
|
105
142
|
if (problems.length) {
|
|
106
143
|
block(
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
chmodSync,
|
|
18
18
|
existsSync,
|
|
19
19
|
mkdirSync,
|
|
20
|
+
readdirSync,
|
|
20
21
|
readFileSync,
|
|
21
22
|
renameSync,
|
|
22
23
|
statSync,
|
|
@@ -104,6 +105,40 @@ const mainRoot = (cwd) => {
|
|
|
104
105
|
const stateDir = (root) => join(mainRoot(root), '.claude', 'state');
|
|
105
106
|
const hubDown = (root) => readJson(join(stateDir(root), 'hub-down.json'), {});
|
|
106
107
|
|
|
108
|
+
/** The Claude Code session calling: code-kit passes it on, and Claude Code tells every command an agent runs. */
|
|
109
|
+
const sessionOf = () =>
|
|
110
|
+
String(process.env.CODE_KIT_SESSION ?? process.env.CLAUDE_CODE_SESSION_ID ?? '').replace(
|
|
111
|
+
/[^\w-]/g,
|
|
112
|
+
'',
|
|
113
|
+
) || null;
|
|
114
|
+
const streamFileOf = (root, session) => join(stateDir(root), `hub-stream-${session}.json`);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A session's live link: up while its daemon runs and its event stream is connected. Each session holds
|
|
118
|
+
* its own, so one session's link never makes another look live.
|
|
119
|
+
*/
|
|
120
|
+
function linkOf(root, session) {
|
|
121
|
+
const daemon = readJson(join(stateDir(root), `hub-daemon-${session}.json`));
|
|
122
|
+
const stream = readJson(streamFileOf(root, session), {});
|
|
123
|
+
return {
|
|
124
|
+
up: Boolean(daemon?.pid && alive(daemon.pid) && stream.connected),
|
|
125
|
+
why: stream.why ?? null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The sessions on this checkout whose live link is up. */
|
|
130
|
+
function linkedSessions(root) {
|
|
131
|
+
let names = [];
|
|
132
|
+
try {
|
|
133
|
+
names = readdirSync(stateDir(root));
|
|
134
|
+
} catch {
|
|
135
|
+
// no state yet
|
|
136
|
+
}
|
|
137
|
+
return names
|
|
138
|
+
.map((n) => n.match(/^hub-daemon-([\w-]+)\.json$/)?.[1])
|
|
139
|
+
.filter((s) => s && linkOf(root, s).up);
|
|
140
|
+
}
|
|
141
|
+
|
|
107
142
|
// --- talking to the hub ------------------------------------------------------------------------
|
|
108
143
|
|
|
109
144
|
async function hub(method, path, body, { timeoutMs = 15_000 } = {}) {
|
|
@@ -153,8 +188,7 @@ export async function provide(op, input, root = process.cwd()) {
|
|
|
153
188
|
// The daemon keeps the state fresh from the stream: read it from disk unless asked for fresh.
|
|
154
189
|
if (op === 'state' && !input.fresh) {
|
|
155
190
|
const kept = readJson(join(stateDir(root), 'team.json'));
|
|
156
|
-
|
|
157
|
-
if (kept?.hub && live.connected && Date.now() - Date.parse(kept.at) < 5 * 60_000)
|
|
191
|
+
if (kept?.hub && linkedSessions(root).length && Date.now() - Date.parse(kept.at) < 5 * 60_000)
|
|
158
192
|
return named(kept, root);
|
|
159
193
|
}
|
|
160
194
|
const { team: _team, ...body } = input;
|
|
@@ -189,20 +223,23 @@ export async function provide(op, input, root = process.cwd()) {
|
|
|
189
223
|
}
|
|
190
224
|
|
|
191
225
|
/**
|
|
192
|
-
* The state names what coordinates the team, for code-kit's Team pane: the hub, whether
|
|
193
|
-
*
|
|
226
|
+
* The state names what coordinates the team, for code-kit's Team pane: the hub, whether the calling
|
|
227
|
+
* session's own link is live (any session's, when no session is calling), and who this machine is
|
|
228
|
+
* signed in as.
|
|
194
229
|
*/
|
|
195
230
|
function named(state, root) {
|
|
196
|
-
const
|
|
231
|
+
const session = sessionOf();
|
|
232
|
+
const link = session ? linkOf(root, session) : { up: linkedSessions(root).length > 0 };
|
|
197
233
|
return {
|
|
198
234
|
...state,
|
|
199
235
|
provider: {
|
|
200
236
|
name: 'code-kit hub',
|
|
201
|
-
live:
|
|
237
|
+
live: link.up,
|
|
202
238
|
account: credentials()?.login ?? null,
|
|
203
|
-
detail:
|
|
239
|
+
detail: link.up
|
|
204
240
|
? 'claims, leases, intents and presence as they happen; every push checked for drift'
|
|
205
|
-
: (
|
|
241
|
+
: (link.why ??
|
|
242
|
+
"this session has no live link, so the team's state is read on each refresh; a session links as it starts"),
|
|
206
243
|
},
|
|
207
244
|
};
|
|
208
245
|
}
|
|
@@ -449,6 +486,132 @@ async function connect(root) {
|
|
|
449
486
|
console.log(
|
|
450
487
|
`This repository now coordinates through the hub (${state.claims.length} claims, ${state.intents.length} open intents). Commit .claude/code-kit.json and .mcp.json so the team does too.`,
|
|
451
488
|
);
|
|
489
|
+
// The session that ran connect links now, rather than at its next start.
|
|
490
|
+
const session = sessionOf();
|
|
491
|
+
if (session) {
|
|
492
|
+
startDaemon(root, session, claudeOf(), url);
|
|
493
|
+
console.log(
|
|
494
|
+
'This session is linked to the hub now; other sessions link as they start, or run connect in them.',
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The Claude Code process this command runs under, for the link to end with it; null when there's none. */
|
|
500
|
+
function claudeOf() {
|
|
501
|
+
let pid = process.ppid;
|
|
502
|
+
for (let i = 0; i < 8 && pid > 1; i += 1) {
|
|
503
|
+
const line = psOf(pid);
|
|
504
|
+
if (!line) return null;
|
|
505
|
+
if (/(^|\/)claude(\s|$)/.test(line.command)) return pid;
|
|
506
|
+
pid = line.ppid;
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
function psOf(pid) {
|
|
511
|
+
try {
|
|
512
|
+
const out = execFileSync('ps', ['-o', 'ppid=,command=', '-p', String(pid)], {
|
|
513
|
+
encoding: 'utf8',
|
|
514
|
+
}).trim();
|
|
515
|
+
const m = out.match(/^(\d+)\s+(.*)$/);
|
|
516
|
+
return m ? { ppid: Number(m[1]), command: m[2] } : null;
|
|
517
|
+
} catch {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* `team provider status`: whether this repository is connected to the hub, one check a line, each failing
|
|
524
|
+
* one saying what to do, then the answer. Connected means this machine is signed in, the config here and
|
|
525
|
+
* on the base branch name the hub, and the hub can see the repository with nothing left to set up; live
|
|
526
|
+
* means the calling session's own link is up. True when connected.
|
|
527
|
+
*/
|
|
528
|
+
async function status(root) {
|
|
529
|
+
const lines = [];
|
|
530
|
+
const check = (ok, text) => (lines.push(` ${ok ? '✓' : '✗'} ${text}`), ok);
|
|
531
|
+
const repo = repoOf(originOf(root));
|
|
532
|
+
const cred = credentials();
|
|
533
|
+
const url = (() => {
|
|
534
|
+
try {
|
|
535
|
+
return hubUrl();
|
|
536
|
+
} catch {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
})();
|
|
540
|
+
const names = (config) => Boolean(url) && config?.team?.provider?.hub?.replace(/\/$/, '') === url;
|
|
541
|
+
const signed = check(
|
|
542
|
+
Boolean(cred?.token),
|
|
543
|
+
cred?.token
|
|
544
|
+
? `this machine is signed in as ${cred.login}`
|
|
545
|
+
: "this machine isn't signed in: run /code-kit:team-init (it signs in through GitHub)",
|
|
546
|
+
);
|
|
547
|
+
const here = check(
|
|
548
|
+
names(readJson(join(root, '.claude', 'code-kit.json'))),
|
|
549
|
+
names(readJson(join(root, '.claude', 'code-kit.json')))
|
|
550
|
+
? 'the config here names the hub'
|
|
551
|
+
: 'the config here doesn\'t name the hub: set "team": { "provider": { "hub": "' +
|
|
552
|
+
(url ?? '<the hub>') +
|
|
553
|
+
'" } } in .claude/code-kit.json',
|
|
554
|
+
);
|
|
555
|
+
const base =
|
|
556
|
+
git(root, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD') ??
|
|
557
|
+
(git(root, 'rev-parse', '--verify', '-q', 'origin/main') ? 'origin/main' : null);
|
|
558
|
+
const committed = (() => {
|
|
559
|
+
if (!base) return false;
|
|
560
|
+
try {
|
|
561
|
+
return names(JSON.parse(git(root, 'show', `${base}:.claude/code-kit.json`) ?? ''));
|
|
562
|
+
} catch {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
})();
|
|
566
|
+
check(
|
|
567
|
+
committed,
|
|
568
|
+
committed
|
|
569
|
+
? `${base}'s config names the hub, so the team's sessions use it`
|
|
570
|
+
: `${base ?? 'the base branch'}'s config doesn't name the hub yet (as last fetched): commit the team section and merge it, so the hub knows the tech lead and every teammate's session uses it`,
|
|
571
|
+
);
|
|
572
|
+
const mcp = readJson(join(root, '.mcp.json'), {})?.mcpServers?.['code-kit-hub'];
|
|
573
|
+
check(
|
|
574
|
+
Boolean(url) && mcp?.url === `${url}/mcp`,
|
|
575
|
+
mcp
|
|
576
|
+
? "the project's MCP servers include the hub's"
|
|
577
|
+
: "the project's MCP servers don't include the hub's: run /code-kit:team-init, and commit .mcp.json",
|
|
578
|
+
);
|
|
579
|
+
let seen = false;
|
|
580
|
+
if (signed && repo && url) {
|
|
581
|
+
const state = await provide('state', { fresh: true }, root);
|
|
582
|
+
const setup = Array.isArray(state.setup) ? state.setup : [];
|
|
583
|
+
seen = check(
|
|
584
|
+
!state.unreachable && state.ok !== false,
|
|
585
|
+
state.unreachable || state.ok === false
|
|
586
|
+
? `the hub can't use ${repo}: ${state.why}`
|
|
587
|
+
: `the hub sees ${repo}`,
|
|
588
|
+
);
|
|
589
|
+
// What the hub says is left to set up doesn't stop the connection: it's shown, not failed.
|
|
590
|
+
for (const line of seen ? setup : []) lines.push(` ! ${line}`);
|
|
591
|
+
} else if (!repo)
|
|
592
|
+
check(false, "this repository has no GitHub origin, so the hub can't be used here");
|
|
593
|
+
const session = sessionOf();
|
|
594
|
+
const linked = linkedSessions(root);
|
|
595
|
+
const live = session ? linkOf(root, session).up : linked.length > 0;
|
|
596
|
+
check(
|
|
597
|
+
live,
|
|
598
|
+
session
|
|
599
|
+
? live
|
|
600
|
+
? 'this session is linked: claims, intents and presence arrive as they happen'
|
|
601
|
+
: "this session isn't linked yet: run /code-kit:team-init, or start a new session, once the checks above pass"
|
|
602
|
+
: `${linked.length} session${linked.length === 1 ? '' : 's'} on this checkout linked`,
|
|
603
|
+
);
|
|
604
|
+
const connected = signed && here && committed && seen;
|
|
605
|
+
console.log(`code-kit hub${url ? ` (${url})` : ''}${repo ? `, for ${repo}` : ''}:`);
|
|
606
|
+
for (const line of lines) console.log(line);
|
|
607
|
+
console.log(
|
|
608
|
+
connected
|
|
609
|
+
? live || !session
|
|
610
|
+
? 'Connected.'
|
|
611
|
+
: "Connected, but this session isn't linked yet."
|
|
612
|
+
: 'Not connected yet: fix each ✗ above, in order.',
|
|
613
|
+
);
|
|
614
|
+
return connected;
|
|
452
615
|
}
|
|
453
616
|
|
|
454
617
|
// --- the daemon (HUB-5): the stream, the team's state on disk, presence --------------------------
|
|
@@ -457,7 +620,7 @@ async function daemon({ root, session, watch }) {
|
|
|
457
620
|
const dir = stateDir(root);
|
|
458
621
|
const repo = repoOf(originOf(root));
|
|
459
622
|
if (!repo || !credentials()?.token) return;
|
|
460
|
-
const streamFile =
|
|
623
|
+
const streamFile = streamFileOf(root, session);
|
|
461
624
|
const eventsFile = join(dir, 'team-events.jsonl');
|
|
462
625
|
writeJson(join(dir, `hub-daemon-${session}.json`), { pid: process.pid, started: Date.now() });
|
|
463
626
|
let lastId = readJson(streamFile, {}).lastId ?? 0;
|
|
@@ -498,10 +661,10 @@ async function daemon({ root, session, watch }) {
|
|
|
498
661
|
stopping = true;
|
|
499
662
|
clearInterval(tick);
|
|
500
663
|
await hub('POST', `/v1/repos/${repo}/leave`, { session }, { timeoutMs: 3000 }).catch(() => {});
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
664
|
+
for (const file of [streamFile, join(dir, `hub-daemon-${session}.json`)])
|
|
665
|
+
try {
|
|
666
|
+
unlinkSync(file);
|
|
667
|
+
} catch {}
|
|
505
668
|
process.exit(0);
|
|
506
669
|
};
|
|
507
670
|
process.on('SIGTERM', stop);
|
|
@@ -670,18 +833,7 @@ async function main(argv) {
|
|
|
670
833
|
console.log('Signed out of code-kit hub on this machine.');
|
|
671
834
|
return 0;
|
|
672
835
|
}
|
|
673
|
-
if (cmd === 'status')
|
|
674
|
-
const cred = credentials();
|
|
675
|
-
console.log(
|
|
676
|
-
cred?.token ? `Signed in to ${cred.url} as ${cred.login}.` : `Not signed in: run ${SIGN_IN}.`,
|
|
677
|
-
);
|
|
678
|
-
const repo = repoOf(originOf(process.cwd()));
|
|
679
|
-
if (repo && cred?.token) {
|
|
680
|
-
const live = readJson(join(stateDir(process.cwd()), 'hub-stream.json'), {});
|
|
681
|
-
console.log(`${repo}: ${live.connected ? 'live' : (live.why ?? 'not connected')}`);
|
|
682
|
-
}
|
|
683
|
-
return 0;
|
|
684
|
-
}
|
|
836
|
+
if (cmd === 'status') return status(process.cwd()).then((ok) => (ok ? 0 : 1));
|
|
685
837
|
if (cmd === 'connect') return connect(process.cwd()).then(() => 0);
|
|
686
838
|
if (cmd === 'drift-model') {
|
|
687
839
|
const choice = args[0];
|
package/hooks/lib/config.mjs
CHANGED
|
@@ -96,20 +96,38 @@ export function withDefaults(c) {
|
|
|
96
96
|
tests: c.tests ?? [],
|
|
97
97
|
regression: { prepare: c.regression?.prepare ?? null },
|
|
98
98
|
branches: { protected: c.branches?.protected ?? ['main', 'master'] },
|
|
99
|
-
approvals: {
|
|
99
|
+
approvals: {
|
|
100
|
+
lead: c.approvals?.lead === true,
|
|
101
|
+
delegate: delegateRules(c.approvals?.delegate, c.protected ?? []),
|
|
102
|
+
},
|
|
100
103
|
harness: harnessSettings(c.harness),
|
|
101
104
|
};
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
/** WHO-1: the approvals the person keeps when the config says nothing: product, design, CI and the kit. */
|
|
108
|
+
export const PERSON_KEEPS = ['spec', 'design', 'ci', 'kit'];
|
|
109
|
+
|
|
104
110
|
/**
|
|
105
111
|
* The rules within which the lead approves on its own (`approvals.delegate`), or null when it may not:
|
|
106
|
-
* the protected approvals it may grant,
|
|
112
|
+
* the protected approvals it may grant, the dependency rule packages must meet, merges, and weakened
|
|
113
|
+
* tests on a lane's branch. WHO-1: with no `approvals.delegate`, the lead decides everything technical:
|
|
114
|
+
* every protected approval but the person's (PERSON_KEEPS), dependencies within the registry's
|
|
115
|
+
* defaults, merges and weakened tests. `false` keeps everything for the person.
|
|
107
116
|
*/
|
|
108
|
-
function delegateRules(
|
|
109
|
-
if (
|
|
117
|
+
function delegateRules(given, protectedPaths = []) {
|
|
118
|
+
if (given === false) return null;
|
|
119
|
+
const d = given ?? {
|
|
120
|
+
protected: [...new Set(protectedPaths.map((p) => p.approval))].filter(
|
|
121
|
+
(a) => a && !PERSON_KEEPS.includes(a),
|
|
122
|
+
),
|
|
123
|
+
merge: true,
|
|
124
|
+
tests: true,
|
|
125
|
+
dependencies: {},
|
|
126
|
+
};
|
|
110
127
|
return {
|
|
111
128
|
protected: d.protected ?? [],
|
|
112
129
|
merge: d.merge === true,
|
|
130
|
+
tests: d.tests === true,
|
|
113
131
|
dependencies: d.dependencies
|
|
114
132
|
? {
|
|
115
133
|
licences: d.dependencies.licences ?? [
|
|
@@ -149,6 +167,129 @@ const isRegExp = (v) => {
|
|
|
149
167
|
* security reviewer (ONB-7, spec 17). A project set up before (no `setup`, or a lower one) is told what
|
|
150
168
|
* re-running init adds.
|
|
151
169
|
*/
|
|
170
|
+
/**
|
|
171
|
+
* WHO-3: who decides each approval, as the Settings tab's Approvals rows: one per protected approval
|
|
172
|
+
* (but the kit, which is always the person's), dependencies with the registry's rules, merges and
|
|
173
|
+
* weakened tests. Each row: { key, value, default, about, choices?, section: 'Approvals' }.
|
|
174
|
+
*/
|
|
175
|
+
export function approvalSettings(config) {
|
|
176
|
+
const rules = config.approvals.delegate;
|
|
177
|
+
const who = (lead) => (lead ? 'lead' : 'person');
|
|
178
|
+
const names = [...new Set(config.protected.map((p) => p.approval))].filter((n) => n !== 'kit');
|
|
179
|
+
const fallback = delegateRules(undefined, config.protected) ?? {};
|
|
180
|
+
const deps = rules?.dependencies ?? delegateRules({ dependencies: {} }).dependencies;
|
|
181
|
+
const row = (key, value, dflt, about, choices) => ({
|
|
182
|
+
key,
|
|
183
|
+
value,
|
|
184
|
+
default: dflt,
|
|
185
|
+
about,
|
|
186
|
+
section: 'Approvals',
|
|
187
|
+
...(choices ? { choices } : {}),
|
|
188
|
+
});
|
|
189
|
+
return [
|
|
190
|
+
...names.map((n) =>
|
|
191
|
+
row(
|
|
192
|
+
`approvals.${n}`,
|
|
193
|
+
who(rules?.protected.includes(n)),
|
|
194
|
+
who(fallback.protected?.includes(n)),
|
|
195
|
+
`Who approves a lane's change to what's protected as "${n}"`,
|
|
196
|
+
['lead', 'person'],
|
|
197
|
+
),
|
|
198
|
+
),
|
|
199
|
+
row(
|
|
200
|
+
'approvals.dependencies',
|
|
201
|
+
who(Boolean(rules?.dependencies)),
|
|
202
|
+
'lead',
|
|
203
|
+
"Who approves a lane's new package: the lead within the registry's rules, or you",
|
|
204
|
+
['lead', 'person'],
|
|
205
|
+
),
|
|
206
|
+
row(
|
|
207
|
+
'approvals.dependencies.licences',
|
|
208
|
+
deps.licences.join(','),
|
|
209
|
+
'MIT,Apache-2.0,BSD-2-Clause,BSD-3-Clause,ISC',
|
|
210
|
+
'The licences a package the lead approves may have, comma-separated',
|
|
211
|
+
),
|
|
212
|
+
row(
|
|
213
|
+
'approvals.dependencies.minWeeklyDownloads',
|
|
214
|
+
deps.minWeeklyDownloads,
|
|
215
|
+
10_000,
|
|
216
|
+
'The weekly downloads a package the lead approves must have, at least',
|
|
217
|
+
),
|
|
218
|
+
row(
|
|
219
|
+
'approvals.dependencies.maxMonthsSinceRelease',
|
|
220
|
+
deps.maxMonthsSinceRelease,
|
|
221
|
+
18,
|
|
222
|
+
"Months since a package's last release, at most, for the lead to approve it",
|
|
223
|
+
),
|
|
224
|
+
row(
|
|
225
|
+
'approvals.dependencies.allowInstallScripts',
|
|
226
|
+
Array.isArray(deps.allowInstallScripts)
|
|
227
|
+
? deps.allowInstallScripts.join(',')
|
|
228
|
+
: deps.allowInstallScripts,
|
|
229
|
+
false,
|
|
230
|
+
'Install scripts a package the lead approves may run: true, false, or kinds (binary, build, other)',
|
|
231
|
+
),
|
|
232
|
+
row(
|
|
233
|
+
'approvals.merge',
|
|
234
|
+
who(Boolean(rules?.merge)),
|
|
235
|
+
'lead',
|
|
236
|
+
'Who merges a reviewed branch into the base: merging is not releasing',
|
|
237
|
+
['lead', 'person'],
|
|
238
|
+
),
|
|
239
|
+
row(
|
|
240
|
+
'approvals.tests',
|
|
241
|
+
who(Boolean(rules?.tests)),
|
|
242
|
+
'lead',
|
|
243
|
+
"Who approves a weakened test on a lane's branch, after review; on the lead's own branches it is always you",
|
|
244
|
+
['lead', 'person'],
|
|
245
|
+
),
|
|
246
|
+
];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** WHO-3: the raw config with one approval setting changed (a copy), the rules written out in full. */
|
|
250
|
+
export function withApprovalSetting(raw, config, key, text) {
|
|
251
|
+
const rules = config.approvals.delegate;
|
|
252
|
+
const deps = rules?.dependencies ?? null;
|
|
253
|
+
const delegate = {
|
|
254
|
+
protected: [...(rules?.protected ?? [])],
|
|
255
|
+
merge: Boolean(rules?.merge),
|
|
256
|
+
tests: Boolean(rules?.tests),
|
|
257
|
+
...(deps ? { dependencies: { ...deps } } : {}),
|
|
258
|
+
};
|
|
259
|
+
const lead = text === 'lead' ? true : text === 'person' ? false : null;
|
|
260
|
+
const rule = key.replace(/^approvals\./, '');
|
|
261
|
+
const depRule = rule.startsWith('dependencies.') ? rule.slice('dependencies.'.length) : null;
|
|
262
|
+
if (depRule) {
|
|
263
|
+
const base = delegate.dependencies ?? delegateRules({ dependencies: {} }).dependencies;
|
|
264
|
+
const list = String(text)
|
|
265
|
+
.split(',')
|
|
266
|
+
.map((x) => x.trim())
|
|
267
|
+
.filter(Boolean);
|
|
268
|
+
const value =
|
|
269
|
+
depRule === 'licences'
|
|
270
|
+
? list
|
|
271
|
+
: depRule === 'allowInstallScripts'
|
|
272
|
+
? text === 'true' || (text === 'false' ? false : list)
|
|
273
|
+
: /^\d+$/.test(text)
|
|
274
|
+
? Number(text)
|
|
275
|
+
: text;
|
|
276
|
+
delegate.dependencies = { ...base, [depRule]: value };
|
|
277
|
+
} else if (lead === null) return null;
|
|
278
|
+
else if (rule === 'dependencies')
|
|
279
|
+
lead
|
|
280
|
+
? (delegate.dependencies = deps ?? delegateRules({ dependencies: {} }).dependencies)
|
|
281
|
+
: delete delegate.dependencies;
|
|
282
|
+
else if (rule === 'merge' || rule === 'tests') delegate[rule] = lead;
|
|
283
|
+
else if (config.protected.some((p) => p.approval === rule) && rule !== 'kit')
|
|
284
|
+
delegate.protected = lead
|
|
285
|
+
? [...new Set([...delegate.protected, rule])]
|
|
286
|
+
: delegate.protected.filter((n) => n !== rule);
|
|
287
|
+
else return null;
|
|
288
|
+
const copy = structuredClone(raw);
|
|
289
|
+
copy.approvals = { ...(copy.approvals ?? {}), delegate };
|
|
290
|
+
return copy;
|
|
291
|
+
}
|
|
292
|
+
|
|
152
293
|
export const SETUP_VERSION = 3;
|
|
153
294
|
|
|
154
295
|
/** Every problem with a parsed config, as readable sentences; empty when it is valid. */
|
|
@@ -236,9 +377,11 @@ export function validate(c) {
|
|
|
236
377
|
const isCount = (v) => v === undefined || (Number.isInteger(v) && v >= 0);
|
|
237
378
|
need(
|
|
238
379
|
d === undefined ||
|
|
380
|
+
d === false ||
|
|
239
381
|
(isObj(d) &&
|
|
240
382
|
(d.protected === undefined || isList(d.protected)) &&
|
|
241
383
|
[undefined, true, false].includes(d.merge) &&
|
|
384
|
+
[undefined, true, false].includes(d.tests) &&
|
|
242
385
|
(dep === undefined ||
|
|
243
386
|
(isObj(dep) &&
|
|
244
387
|
(dep.licences === undefined || isList(dep.licences)) &&
|
|
@@ -247,7 +390,7 @@ export function validate(c) {
|
|
|
247
390
|
([undefined, true, false].includes(dep.allowInstallScripts) ||
|
|
248
391
|
(isList(dep.allowInstallScripts) &&
|
|
249
392
|
dep.allowInstallScripts.every((k) => ['binary', 'build', 'other'].includes(k))))))),
|
|
250
|
-
'"approvals.delegate" must be { protected?: [approval names], merge?: true|false, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts?: true|false|["binary","build","other"] } }',
|
|
393
|
+
'"approvals.delegate" must be false, or { protected?: [approval names], merge?: true|false, tests?: true|false, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts?: true|false|["binary","build","other"] } }',
|
|
251
394
|
);
|
|
252
395
|
need(
|
|
253
396
|
c.branches === undefined || isList(c.branches?.protected),
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Decisions owned by the session that raised them (docs/specs/23-the-team.md, OWN-1 to OWN-4). What the
|
|
2
|
+
// person settles comes from two logs code-kit already keeps, each record now carrying its session: the
|
|
3
|
+
// lead's questions (lib/questions.mjs) and the approvals a refusal asked for (lib/rules.mjs). This reads
|
|
4
|
+
// both as one list, each decision with its member, subject and context, so a session sees, asks and
|
|
5
|
+
// answers only its own, and the others only a count. A decision from before sessions were recorded
|
|
6
|
+
// belongs to no session and shows in any.
|
|
7
|
+
import { openQuestions } from './questions.mjs';
|
|
8
|
+
import { openRequests } from './rules.mjs';
|
|
9
|
+
import { memberOf, readSession, sessions, shortSession } from './sessions.mjs';
|
|
10
|
+
|
|
11
|
+
/** The stories that are done: a decision about one of them has lost its cause (OWN-4). */
|
|
12
|
+
const doneIn = (stories) =>
|
|
13
|
+
new Set((stories ?? []).filter((s) => s.state === 'done').map((s) => s.id));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every open decision, oldest first: [{ id, kind, session, member, subject, what, context, options,
|
|
17
|
+
* blocks, count, opened, source }]. `kind` is `answer` (a question for the person) or `approve`.
|
|
18
|
+
*/
|
|
19
|
+
export function openDecisions(root, { stories = [] } = {}, now = Date.now()) {
|
|
20
|
+
const done = doneIn(stories);
|
|
21
|
+
const byId = new Map(sessions(root, now).map((s) => [s.id, s]));
|
|
22
|
+
const member = (session) =>
|
|
23
|
+
session ? (memberOf(byId.get(session)) ?? shortSession(session)) : null;
|
|
24
|
+
const questions = openQuestions(root)
|
|
25
|
+
.filter((q) => !(q.story && done.has(q.story)))
|
|
26
|
+
.map((q) => ({
|
|
27
|
+
id: q.id,
|
|
28
|
+
kind: 'answer',
|
|
29
|
+
session: q.session ?? null,
|
|
30
|
+
member: member(q.session),
|
|
31
|
+
subject: q.story ?? null,
|
|
32
|
+
what: q.question,
|
|
33
|
+
context: q.why ?? null,
|
|
34
|
+
options: q.options ?? [],
|
|
35
|
+
blocks: Boolean(q.blocks),
|
|
36
|
+
count: q.count ?? 1,
|
|
37
|
+
opened: q.at,
|
|
38
|
+
source: q,
|
|
39
|
+
}));
|
|
40
|
+
const requests = openRequests(root, now).map((r) => ({
|
|
41
|
+
id: `R:${r.at}`,
|
|
42
|
+
kind: 'approve',
|
|
43
|
+
session: r.session ?? null,
|
|
44
|
+
member: member(r.session),
|
|
45
|
+
subject: r.lane ?? null,
|
|
46
|
+
what: r.what,
|
|
47
|
+
context: r.why ?? null,
|
|
48
|
+
options: [],
|
|
49
|
+
blocks: true,
|
|
50
|
+
count: 1,
|
|
51
|
+
opened: r.at,
|
|
52
|
+
source: r,
|
|
53
|
+
}));
|
|
54
|
+
return [...questions, ...requests].sort((a, b) => Date.parse(a.opened) - Date.parse(b.opened));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The sessions whose decisions `session` answers for: its own, and those whose task it took over. */
|
|
58
|
+
function ownedBy(root, session) {
|
|
59
|
+
const me = readSession(root, session);
|
|
60
|
+
return new Set([session, ...(me?.took ?? [])]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* OWN-1: the decisions `session` sees and answers ({ mine }), and the count waiting in each other live
|
|
65
|
+
* session ({ elsewhere: [{ session, member, count }] }). A decision of a session that has ended stays with
|
|
66
|
+
* it: it isn't handed to another session's lead.
|
|
67
|
+
*/
|
|
68
|
+
export function decisionsFor(root, session, context = {}, now = Date.now()) {
|
|
69
|
+
const all = openDecisions(root, context, now);
|
|
70
|
+
const own = ownedBy(root, session);
|
|
71
|
+
const live = new Set(
|
|
72
|
+
sessions(root, now)
|
|
73
|
+
.filter((s) => s.live)
|
|
74
|
+
.map((s) => s.id),
|
|
75
|
+
);
|
|
76
|
+
const mine = all.filter((d) => !d.session || own.has(d.session));
|
|
77
|
+
const counts = new Map();
|
|
78
|
+
for (const d of all)
|
|
79
|
+
if (d.session && !own.has(d.session) && live.has(d.session))
|
|
80
|
+
counts.set(d.session, (counts.get(d.session) ?? 0) + 1);
|
|
81
|
+
return {
|
|
82
|
+
mine,
|
|
83
|
+
elsewhere: [...counts].map(([s, count]) => ({
|
|
84
|
+
session: s,
|
|
85
|
+
member: all.find((d) => d.session === s)?.member ?? shortSession(s),
|
|
86
|
+
count,
|
|
87
|
+
})),
|
|
88
|
+
};
|
|
89
|
+
}
|
package/hooks/lib/harness.mjs
CHANGED
|
@@ -71,6 +71,13 @@ export const SETTINGS = {
|
|
|
71
71
|
about:
|
|
72
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
73
|
},
|
|
74
|
+
'slices.open': {
|
|
75
|
+
default: 1,
|
|
76
|
+
problem: (v) =>
|
|
77
|
+
Number.isInteger(v) && v >= 1 && v <= 10 ? null : 'must be a whole number from 1 to 10',
|
|
78
|
+
about:
|
|
79
|
+
"How many slices are in focus at once: a free lane builds an open slice's stories first, and may start the next slice only while fewer are open",
|
|
80
|
+
},
|
|
74
81
|
'background.pauseAtPercent': {
|
|
75
82
|
default: 80,
|
|
76
83
|
problem: (v) =>
|
package/hooks/lib/hook.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { loadConfig } from './config.mjs';
|
|
4
4
|
import { projectDir } from './rules.mjs';
|
|
5
|
+
import { seeSession } from './sessions.mjs';
|
|
5
6
|
|
|
6
7
|
/** Exit 2 with a message: Claude Code blocks the call and shows Claude the reason. */
|
|
7
8
|
export function block(message) {
|
|
@@ -15,5 +16,11 @@ export function start() {
|
|
|
15
16
|
const project = projectDir();
|
|
16
17
|
const { config, error } = loadConfig(project);
|
|
17
18
|
if (!config && !error) process.exit(0);
|
|
19
|
+
// SESS-1: the session acting is still there; one code-kit hadn't seen starts here.
|
|
20
|
+
try {
|
|
21
|
+
seeSession(project, input.session_id);
|
|
22
|
+
} catch {
|
|
23
|
+
// a read-only checkout: the session goes unrecorded
|
|
24
|
+
}
|
|
18
25
|
return { input, project, config, error };
|
|
19
26
|
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from './standards.mjs';
|
|
15
15
|
|
|
16
16
|
/** The version of code-kit's instructions: raised whenever the templates' rules change. */
|
|
17
|
-
export const INSTRUCTIONS_VERSION =
|
|
17
|
+
export const INSTRUCTIONS_VERSION = 7;
|
|
18
18
|
|
|
19
19
|
const TEMPLATES = fileURLToPath(new URL('../../templates/', import.meta.url));
|
|
20
20
|
const BEGIN = (v) => `<!-- code-kit:begin v${v} -->`;
|