@warren-dean/code-kit 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +34 -29
- package/bin/code-kit.mjs +240 -8
- package/hooks/guard-bash.mjs +45 -3
- package/hooks/guard-paths.mjs +3 -2
- package/hooks/hooks.json +22 -0
- package/hooks/lib/adapters/hub/client.mjs +723 -0
- package/hooks/lib/adapters/hub/index.mjs +25 -0
- package/hooks/lib/adapters/index.mjs +35 -0
- package/hooks/lib/checks.mjs +5 -1
- package/hooks/lib/config.mjs +1 -1
- package/hooks/lib/instructions.mjs +46 -13
- package/hooks/lib/layers.mjs +11 -0
- package/hooks/lib/plan.mjs +24 -0
- package/hooks/lib/rules.mjs +2 -1
- package/hooks/lib/snapshot.mjs +424 -0
- package/hooks/lib/standards.mjs +450 -0
- package/hooks/lib/team.mjs +55 -9
- package/hooks/lib/teamfacts.mjs +10 -6
- package/hooks/lib/verify.mjs +23 -1
- package/hooks/mod/register.mjs +35 -0
- package/hooks/mod/register.test.ts +49 -4
- package/hooks/mod/review.mjs +1 -0
- package/hooks/mod/teamview.mjs +8 -2
- package/hooks/mod/view.mjs +36 -0
- package/hooks/post-edit-check.mjs +5 -1
- package/hooks/stop-check.mjs +11 -3
- package/hooks/team-session.mjs +57 -0
- package/package.json +1 -1
- package/skills/init/SKILL.md +16 -2
- package/skills/next/SKILL.md +1 -1
- package/skills/review/SKILL.md +2 -1
- package/skills/spec-design/SKILL.md +8 -6
- package/skills/team-init/SKILL.md +3 -3
- package/templates/agent.md +1 -0
- package/templates/claude-md.md +2 -0
package/bin/code-kit.mjs
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
// report [--json] | report send|discard <id> the person sends a draft to code-kit's GitHub, or not
|
|
30
30
|
// stops [--session id] [--json] finish checks that refused an agent's last stop, still failing
|
|
31
31
|
// sent-back <branch> [--reason …] records that a review sent the branch back at its current commit
|
|
32
|
+
// standards [--json] [--for <path>...]
|
|
33
|
+
// the project's standards, with an organisation's, and their problems
|
|
32
34
|
// settings [--json] | settings set <key> <value> --reason … [--via pane]
|
|
33
35
|
// the harness settings; set is the person's own change
|
|
34
36
|
// record approvals a person gave in chat (with approvals.lead on, the lead runs it)
|
|
@@ -57,7 +59,20 @@ import {
|
|
|
57
59
|
} from '../hooks/lib/config.mjs';
|
|
58
60
|
import { diffConfigs, ownershipMoves } from '../hooks/lib/diff.mjs';
|
|
59
61
|
import { CODE, importsOf, layerOf, layerProblems, targetOf } from '../hooks/lib/layers.mjs';
|
|
60
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
ADAPTERS,
|
|
64
|
+
activeAdapters,
|
|
65
|
+
adapterFacts,
|
|
66
|
+
providerAdapterOf,
|
|
67
|
+
} from '../hooks/lib/adapters/index.mjs';
|
|
68
|
+
import {
|
|
69
|
+
EVENT_PARTS,
|
|
70
|
+
USAGE_DIR,
|
|
71
|
+
buildSnapshot,
|
|
72
|
+
mainCheckout,
|
|
73
|
+
snapshotHash,
|
|
74
|
+
snapshotProblems,
|
|
75
|
+
} from '../hooks/lib/snapshot.mjs';
|
|
61
76
|
import { nextStep } from '../hooks/lib/next.mjs';
|
|
62
77
|
import { discardReport, draftReport, readReports, sendReport } from '../hooks/lib/report.mjs';
|
|
63
78
|
import {
|
|
@@ -92,6 +107,7 @@ import {
|
|
|
92
107
|
claimChanges,
|
|
93
108
|
storyOfBranch,
|
|
94
109
|
syncBoard,
|
|
110
|
+
readCache,
|
|
95
111
|
teamOf,
|
|
96
112
|
teamProvider,
|
|
97
113
|
whoami,
|
|
@@ -117,6 +133,13 @@ import { dependencyRuleProblems, npmFacts, packageOf } from '../hooks/lib/regist
|
|
|
117
133
|
import { heldTo, laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
|
|
118
134
|
import { skippedLine } from '../hooks/lib/checks.mjs';
|
|
119
135
|
import { TESTS_APPROVAL } from '../hooks/lib/regression.mjs';
|
|
136
|
+
import {
|
|
137
|
+
appliesTo,
|
|
138
|
+
projectStandards,
|
|
139
|
+
readInherited,
|
|
140
|
+
standardLine,
|
|
141
|
+
writeInherited,
|
|
142
|
+
} from '../hooks/lib/standards.mjs';
|
|
120
143
|
|
|
121
144
|
const out = (s) => process.stdout.write(`${s}\n`);
|
|
122
145
|
// Piped into `head` and the like: stop quietly when the reader closes.
|
|
@@ -172,6 +195,8 @@ const FLAGS = [
|
|
|
172
195
|
'--off',
|
|
173
196
|
'--blocks',
|
|
174
197
|
'--take',
|
|
198
|
+
'--dry-run',
|
|
199
|
+
'--for',
|
|
175
200
|
];
|
|
176
201
|
const flag = (name) => args.includes(name);
|
|
177
202
|
const writeBaseline = flag('--write');
|
|
@@ -242,7 +267,7 @@ function check() {
|
|
|
242
267
|
if (team) {
|
|
243
268
|
const me = whoami(resolve('.'));
|
|
244
269
|
out(
|
|
245
|
-
`Team: ${team.techLead} is the tech lead; a claim is stale after ${team.staleDays} working day${team.staleDays === 1 ? '' : 's'} without a push; provider ${team.provider === 'github' ? 'GitHub' : team.provider.command}.`,
|
|
270
|
+
`Team: ${team.techLead} is the tech lead; a claim is stale after ${team.staleDays} working day${team.staleDays === 1 ? '' : 's'} without a push; provider ${team.provider === 'github' ? 'GitHub' : (team.provider.name ?? team.provider.command)}.`,
|
|
246
271
|
);
|
|
247
272
|
for (const [lane, owners] of Object.entries(team.owners))
|
|
248
273
|
out(`Lane ${lane} is owned by ${owners.join(', ')}`);
|
|
@@ -412,6 +437,7 @@ const VERIFY_GROUPS = {
|
|
|
412
437
|
design: 'Designs before screens',
|
|
413
438
|
tests: 'Weakened tests',
|
|
414
439
|
regression: 'Fixes and their tests',
|
|
440
|
+
standards: 'Standards',
|
|
415
441
|
checks: 'Checks',
|
|
416
442
|
};
|
|
417
443
|
|
|
@@ -1461,8 +1487,12 @@ function teamCommand() {
|
|
|
1461
1487
|
die(
|
|
1462
1488
|
'This project has no "team" in its config, so it has no claims or owners. Add "team": { "techLead": "<GitHub handle>" } to share them.',
|
|
1463
1489
|
);
|
|
1490
|
+
// HUBR-6: a hosted provider's own commands (sign in, connect, status), through its adapter.
|
|
1491
|
+
if (rest[0] === 'provider') return providerCommand(raw);
|
|
1464
1492
|
const provider = teamProvider(resolve('.'), team);
|
|
1465
1493
|
const [sub, story] = rest;
|
|
1494
|
+
if (sub === 'report') return teamReport(team, provider);
|
|
1495
|
+
if (sub === 'pulse') return pulseCommand(raw, team, provider);
|
|
1466
1496
|
const holderWords = (h) =>
|
|
1467
1497
|
`${h.story} is claimed by ${h.by} (${ago(h.at)}). Ask them, or the tech lead (${team.techLead}), to release it.`;
|
|
1468
1498
|
if (sub === 'claim') {
|
|
@@ -1577,7 +1607,7 @@ function teamCommand() {
|
|
|
1577
1607
|
}
|
|
1578
1608
|
if (sub !== undefined)
|
|
1579
1609
|
die(
|
|
1580
|
-
'code-kit team [--json] [--fresh] | team init … | team since [--json] [--peek] | team claim|keep|release <story> | team reassign <story> <handle>',
|
|
1610
|
+
'code-kit team [--json] [--fresh] | team init … | team since [--json] [--peek] | team claim|keep|release <story> | team reassign <story> <handle> | team report [--dry-run] [--json]',
|
|
1581
1611
|
);
|
|
1582
1612
|
const state = provider.state({ fresh: flag('--fresh') });
|
|
1583
1613
|
if (flag('--json')) return out(JSON.stringify(state, null, 2));
|
|
@@ -1608,6 +1638,134 @@ function teamCommand() {
|
|
|
1608
1638
|
out(` #${p.number} ${p.title} (${p.by}, ${p.branch}${p.draft ? ', draft' : ''})`);
|
|
1609
1639
|
}
|
|
1610
1640
|
|
|
1641
|
+
// --- what a project reports (docs/specs/21-hub-adapter-and-reports.md) ----------------------------
|
|
1642
|
+
|
|
1643
|
+
const REPORT_SENT = '.claude/state/report-sent.json';
|
|
1644
|
+
const REPORT_MINUTES = 10;
|
|
1645
|
+
|
|
1646
|
+
/** HUBR-2: the snapshot, from what status, timeline, trail, the journal and the config already know. */
|
|
1647
|
+
function snapshotNow(team) {
|
|
1648
|
+
const { config } = load();
|
|
1649
|
+
if (!config.docs?.specs || !config.docs?.plan) return null;
|
|
1650
|
+
const from = base(config, false);
|
|
1651
|
+
const status = buildStatus('.', config, from, tracked());
|
|
1652
|
+
const claims = (readCache(resolve('.')) ?? {}).claims ?? [];
|
|
1653
|
+
return buildSnapshot({
|
|
1654
|
+
root: resolve('.'),
|
|
1655
|
+
config,
|
|
1656
|
+
status,
|
|
1657
|
+
base: from,
|
|
1658
|
+
claims,
|
|
1659
|
+
version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version,
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* HUBR-3: sends the snapshot when what happened changed (stages, approvals, send-backs, merges), or when
|
|
1665
|
+
* only usage changed and ten minutes have passed; never the same snapshot twice. A provider that can't
|
|
1666
|
+
* be reached is tried again with the next change. `{ sent, why }`.
|
|
1667
|
+
*/
|
|
1668
|
+
function sendSnapshot(team, provider, snapshot, { force = false } = {}) {
|
|
1669
|
+
if (team.provider === 'github') return { sent: false, why: 'GitHub keeps no reports.' };
|
|
1670
|
+
if (!team.report) return { sent: false, why: 'team.report is false.' };
|
|
1671
|
+
const problems = snapshotProblems(snapshot);
|
|
1672
|
+
if (problems.length) return { sent: false, why: `The report isn't valid: ${problems[0]}.` };
|
|
1673
|
+
const file = join(mainCheckout(resolve('.')), REPORT_SENT);
|
|
1674
|
+
let last = {};
|
|
1675
|
+
try {
|
|
1676
|
+
last = JSON.parse(readFileSync(file, 'utf8'));
|
|
1677
|
+
} catch {
|
|
1678
|
+
// nothing sent yet
|
|
1679
|
+
}
|
|
1680
|
+
const events = snapshotHash(snapshot, EVENT_PARTS);
|
|
1681
|
+
const all = snapshotHash(snapshot);
|
|
1682
|
+
const due =
|
|
1683
|
+
force ||
|
|
1684
|
+
events !== last.events ||
|
|
1685
|
+
(all !== last.all && Date.now() - (last.at ?? 0) >= REPORT_MINUTES * 60_000);
|
|
1686
|
+
if (!due) return { sent: false, why: 'Nothing new to report.' };
|
|
1687
|
+
const r = provider.report(snapshot);
|
|
1688
|
+
if (!r || r.ok === false)
|
|
1689
|
+
return { sent: false, why: r?.why ?? 'The provider refused the report.' };
|
|
1690
|
+
mkdirSync(join(mainCheckout(resolve('.')), '.claude/state'), { recursive: true });
|
|
1691
|
+
writeFileSync(file, `${JSON.stringify({ events, all, at: Date.now() })}\n`);
|
|
1692
|
+
return { sent: true };
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** HUBR-2, HUBR-4: `team report` sends what's new; `--dry-run` prints exactly what would be sent. */
|
|
1696
|
+
function teamReport(team, provider) {
|
|
1697
|
+
const snapshot = snapshotNow(team);
|
|
1698
|
+
if (!snapshot)
|
|
1699
|
+
die(
|
|
1700
|
+
'A report needs docs.specs and docs.plan in the config. The spec-design skill writes both.',
|
|
1701
|
+
);
|
|
1702
|
+
if (flag('--dry-run')) return out(JSON.stringify(snapshot, null, 2));
|
|
1703
|
+
const r = sendSnapshot(team, provider, snapshot, { force: true });
|
|
1704
|
+
if (flag('--json')) return out(JSON.stringify(r, null, 2));
|
|
1705
|
+
out(
|
|
1706
|
+
r.sent
|
|
1707
|
+
? `Reported this project's facts to ${team.provider.name ?? 'the team provider'}.`
|
|
1708
|
+
: r.why,
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* The mod's pulse (HUBR-3, HUBR-6), every 30 seconds while a session runs, only for a hosted provider:
|
|
1714
|
+
* stdin is `{ agents, usage }`, the session's agents by tool and branch and its token ledger (USE-1).
|
|
1715
|
+
* Keeps both for the provider's client and the report, and reports when something new happened.
|
|
1716
|
+
*/
|
|
1717
|
+
async function pulseCommand(raw, team, provider) {
|
|
1718
|
+
const hosted = providerAdapterOf(raw.team?.provider);
|
|
1719
|
+
if (!hosted || hosted.problem || !sessionId) return;
|
|
1720
|
+
let input = {};
|
|
1721
|
+
try {
|
|
1722
|
+
let raw = '';
|
|
1723
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
1724
|
+
input = raw.trim() ? JSON.parse(raw) : {};
|
|
1725
|
+
} catch {
|
|
1726
|
+
input = {};
|
|
1727
|
+
}
|
|
1728
|
+
const root = resolve('.');
|
|
1729
|
+
const id = sessionId.replace(/[^\w-]/g, '');
|
|
1730
|
+
if (input.agents) hosted.adapter.presence(root, id, input.agents);
|
|
1731
|
+
if (input.usage && typeof input.usage === 'object') {
|
|
1732
|
+
const dir = join(mainCheckout(root), USAGE_DIR);
|
|
1733
|
+
mkdirSync(dir, { recursive: true });
|
|
1734
|
+
writeFileSync(
|
|
1735
|
+
join(dir, `${id}.json`),
|
|
1736
|
+
`${JSON.stringify({ at: Date.now(), entries: input.usage })}\n`,
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
// STD-6: the organisation's standards, for the hooks and instructions, read every ten minutes.
|
|
1740
|
+
const inherited = readInherited(root);
|
|
1741
|
+
if (!inherited.at || Date.now() - Date.parse(inherited.at) >= REPORT_MINUTES * 60_000) {
|
|
1742
|
+
const r = provider.standards();
|
|
1743
|
+
if (r && r.ok !== false && Array.isArray(r.standards)) writeInherited(root, r);
|
|
1744
|
+
}
|
|
1745
|
+
// Building the snapshot reads git: at most once a minute.
|
|
1746
|
+
const built = join(mainCheckout(root), '.claude/state/report-built.json');
|
|
1747
|
+
let at = 0;
|
|
1748
|
+
try {
|
|
1749
|
+
at = JSON.parse(readFileSync(built, 'utf8')).at ?? 0;
|
|
1750
|
+
} catch {
|
|
1751
|
+
// never built
|
|
1752
|
+
}
|
|
1753
|
+
if (Date.now() - at < 60_000) return;
|
|
1754
|
+
writeFileSync(built, `${JSON.stringify({ at: Date.now() })}\n`);
|
|
1755
|
+
const snapshot = snapshotNow(team);
|
|
1756
|
+
if (snapshot) sendSnapshot(team, provider, snapshot);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/** HUBR-6: `team provider signin|signout|status|connect|drift-model …`, run by the provider's client. */
|
|
1760
|
+
function providerCommand(raw) {
|
|
1761
|
+
const hosted = providerAdapterOf(raw.team?.provider);
|
|
1762
|
+
if (!hosted || hosted.problem)
|
|
1763
|
+
die(
|
|
1764
|
+
"This team's provider isn't a hosted one: claims and intents go through GitHub, and there's nothing to sign in to.",
|
|
1765
|
+
);
|
|
1766
|
+
process.exit(hosted.adapter.run(rest.slice(1), hosted.url));
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1611
1769
|
/**
|
|
1612
1770
|
* TEAM-13: `team init` sets the project up as a team's, or (`--off`) as one developer's again. Without
|
|
1613
1771
|
* `--write` it shows the team section it would write; with it, it writes the config and says what the
|
|
@@ -1671,7 +1829,7 @@ function teamInit(raw) {
|
|
|
1671
1829
|
if (flag('--json'))
|
|
1672
1830
|
return out(JSON.stringify({ written: true, ...section, me: state.me ?? null, setup }, null, 2));
|
|
1673
1831
|
out(
|
|
1674
|
-
`Wrote the team to ${file}: ${team.techLead} is the tech lead; a claim is stale after ${team.staleDays} working day${team.staleDays === 1 ? '' : 's'} without a push; intents go in "${team.intents}".`,
|
|
1832
|
+
`Wrote the team to ${file}: ${team.techLead} is the tech lead; a claim is stale after ${team.staleDays} working day${team.staleDays === 1 ? '' : 's'} without a push; ${team.intents ? `intents go in "${team.intents}"` : `${team.provider.name ?? 'the provider'} keeps intents`}.`,
|
|
1675
1833
|
);
|
|
1676
1834
|
for (const [lane, owners] of Object.entries(team.owners))
|
|
1677
1835
|
out(`Lane ${lane}: ${owners.join(', ')}`);
|
|
@@ -1900,8 +2058,11 @@ function instructionsCommand() {
|
|
|
1900
2058
|
const { config } = load();
|
|
1901
2059
|
const root = resolve('.');
|
|
1902
2060
|
if (rest[0] === 'show') {
|
|
1903
|
-
if (!['lead', 'lane'].includes(rest[1])) die('code-kit instructions show lead|lane');
|
|
1904
|
-
|
|
2061
|
+
if (!['lead', 'lane'].includes(rest[1])) die('code-kit instructions show lead|lane [<lane>]');
|
|
2062
|
+
if (rest[2] && !config.lanes[rest[2]])
|
|
2063
|
+
die(`No lane is called ${rest[2]}: ${Object.keys(config.lanes).join(', ') || 'none'}.`);
|
|
2064
|
+
// A named lane's rules carry the standards for its layers (spec 22, STD-3).
|
|
2065
|
+
return out(rest[1] === 'lead' ? leadSection(root, config) : agentRules(root, config, rest[2]));
|
|
1905
2066
|
}
|
|
1906
2067
|
const changed = flag('--write') ? writeInstructions(root, config) : [];
|
|
1907
2068
|
const files = instructionsState(root, config);
|
|
@@ -1927,7 +2088,7 @@ function instructionsCommand() {
|
|
|
1927
2088
|
for (const f of files) out(` ${f.file}: ${said[f.state]}`);
|
|
1928
2089
|
if (files.some((f) => f.state === 'legacy'))
|
|
1929
2090
|
out(
|
|
1930
|
-
'\nTo merge one: replace its old code-kit part (in CLAUDE.md from `## Enforced, not advised` to the line about changing the rules; in an agent file from `Always:` to the end of its list) with what `code-kit instructions show lead` or `show lane
|
|
2091
|
+
'\nTo merge one: replace its old code-kit part (in CLAUDE.md from `## Enforced, not advised` to the line about changing the rules; in an agent file from `Always:` to the end of its list) with what `code-kit instructions show lead` or `show lane <lane>` prints. Keep every line the project wrote itself (its own rules, its layer order, its docs) outside the markers, under a heading of its own, so a later refresh never touches them.',
|
|
1931
2092
|
);
|
|
1932
2093
|
}
|
|
1933
2094
|
|
|
@@ -2006,8 +2167,78 @@ function worktreesCommand() {
|
|
|
2006
2167
|
);
|
|
2007
2168
|
}
|
|
2008
2169
|
|
|
2170
|
+
/**
|
|
2171
|
+
* Spec 22: `standards` lists the standards that apply (the project's file, merged with what an
|
|
2172
|
+
* organisation's team provider supplies) and what's wrong with them (STD-1, STD-6); `--for <path>...`
|
|
2173
|
+
* keeps the ones for those files' layers, as a review reads them (STD-4). Exits 1 on a problem.
|
|
2174
|
+
*/
|
|
2175
|
+
function standardsCommand() {
|
|
2176
|
+
const { raw, config } = load();
|
|
2177
|
+
const root = resolve('.');
|
|
2178
|
+
const team = teamOf(raw);
|
|
2179
|
+
let fetched = null;
|
|
2180
|
+
if (team && team.provider !== 'github') {
|
|
2181
|
+
const r = teamProvider(root, team).standards();
|
|
2182
|
+
if (r && r.ok !== false && Array.isArray(r.standards)) writeInherited(root, r);
|
|
2183
|
+
else fetched = r?.why ?? "The team provider didn't answer.";
|
|
2184
|
+
}
|
|
2185
|
+
const found = projectStandards(root, config);
|
|
2186
|
+
const { waivers } = readInherited(root);
|
|
2187
|
+
const files = flag('--for') ? rest : [];
|
|
2188
|
+
const layersOf = (f) =>
|
|
2189
|
+
config.layers.length ? [layerOf(f, config.layers)?.name].filter(Boolean) : null;
|
|
2190
|
+
const standards = files.length
|
|
2191
|
+
? found.standards.filter((s) => files.some((f) => appliesTo(s, layersOf(f))))
|
|
2192
|
+
: found.standards;
|
|
2193
|
+
if (flag('--json')) {
|
|
2194
|
+
out(
|
|
2195
|
+
JSON.stringify(
|
|
2196
|
+
{
|
|
2197
|
+
file: found.file,
|
|
2198
|
+
exists: found.exists,
|
|
2199
|
+
version: found.version,
|
|
2200
|
+
...(files.length ? { for: files } : {}),
|
|
2201
|
+
standards,
|
|
2202
|
+
waivers,
|
|
2203
|
+
problems: found.problems,
|
|
2204
|
+
...(fetched ? { provider: fetched } : {}),
|
|
2205
|
+
},
|
|
2206
|
+
null,
|
|
2207
|
+
2,
|
|
2208
|
+
),
|
|
2209
|
+
);
|
|
2210
|
+
if (found.problems.length) process.exit(1);
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
if (!found.exists && !found.standards.length)
|
|
2214
|
+
return out(
|
|
2215
|
+
`This project has no standards yet: ${found.file} doesn't exist. /code-kit:init suggests them from the specs or the code, and the person approves each.`,
|
|
2216
|
+
);
|
|
2217
|
+
if (fetched)
|
|
2218
|
+
out(`The organisation's standards weren't refreshed (${fetched}); using the last ones read.`);
|
|
2219
|
+
const inherited = found.standards.filter((s) => s.origin !== 'own').length;
|
|
2220
|
+
out(
|
|
2221
|
+
`${found.standards.length} standard${found.standards.length === 1 ? '' : 's'} apply${files.length ? `; ${standards.length} to ${files.join(', ')}` : ''}: ${found.standards.length - inherited} from ${found.file}${inherited ? `, ${inherited} from the organisation${found.version ? ` (version ${found.version})` : ''}` : ''}.`,
|
|
2222
|
+
);
|
|
2223
|
+
for (const s of standards) {
|
|
2224
|
+
const rule = s.applied === 'enforced' && s.enforcedBy ? ` · enforced by ${s.enforcedBy}` : '';
|
|
2225
|
+
const where = s.layers?.length ? ` · ${s.layers.join(', ')}` : '';
|
|
2226
|
+
const origin = s.origin === 'own' ? '' : ` · ${s.origin}`;
|
|
2227
|
+
out(` ${standardLine({ ...s, origin: 'own', waiver: null })}${where}${rule}${origin}`);
|
|
2228
|
+
if (s.waiver)
|
|
2229
|
+
out(
|
|
2230
|
+
` waived${s.waiver.paths?.length ? ` for ${s.waiver.paths.join(', ')}` : ''}${s.waiver.expires ? ` until ${s.waiver.expires}` : ''}${s.waiver.approvedBy ? `, approved by ${s.waiver.approvedBy}` : ''}${s.waiver.reason ? `: ${s.waiver.reason}` : ''}`,
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
if (!found.problems.length) return;
|
|
2234
|
+
out(`\nProblems:`);
|
|
2235
|
+
for (const p of found.problems) out(` ${p}`);
|
|
2236
|
+
process.exit(1);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2009
2239
|
const commands = {
|
|
2010
2240
|
check,
|
|
2241
|
+
standards: standardsCommand,
|
|
2011
2242
|
worktrees: worktreesCommand,
|
|
2012
2243
|
instructions: instructionsCommand,
|
|
2013
2244
|
loop: loopCommand,
|
|
@@ -2046,7 +2277,8 @@ function usage() {
|
|
|
2046
2277
|
' | verify [--base ref] [--branch name] [--no-checks] | status [--base ref] [--json]\n' +
|
|
2047
2278
|
' | security [--base ref] [--branch name] [--json]\n' +
|
|
2048
2279
|
' | next [--base ref] [--json] | adapters | trace <path>... [--json]\n' +
|
|
2049
|
-
' | instructions [--write] [--json] | instructions show lead|lane\n' +
|
|
2280
|
+
' | instructions [--write] [--json] | instructions show lead|lane [<lane>]\n' +
|
|
2281
|
+
' | standards [--json] [--for <path>...]\n' +
|
|
2050
2282
|
' | worktrees [--json] | worktrees release <branch> | worktrees prune\n' +
|
|
2051
2283
|
' | approve <name>... --reason "…" [--lane name] [--config file]\n' +
|
|
2052
2284
|
' | merge <branch> --delegated|--person [--into branch] | requests [--json] | requests drop <n>|all\n' +
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -21,6 +21,12 @@ import { laneOfBranch, storyOfBranch, teamOf, teamProvider, whoami } from './lib
|
|
|
21
21
|
import { matchesAny } from './lib/glob.mjs';
|
|
22
22
|
import { noteBefore } from './lane-audit.mjs';
|
|
23
23
|
import { TESTS_APPROVAL, commitWeakenings } from './lib/regression.mjs';
|
|
24
|
+
import {
|
|
25
|
+
STANDARDS_APPROVAL,
|
|
26
|
+
noteFor,
|
|
27
|
+
standardsFile,
|
|
28
|
+
standardsWeakenings,
|
|
29
|
+
} from './lib/standards.mjs';
|
|
24
30
|
import {
|
|
25
31
|
APPROVAL_LOG,
|
|
26
32
|
actorFor,
|
|
@@ -30,6 +36,7 @@ import {
|
|
|
30
36
|
askHowTo,
|
|
31
37
|
currentBranch,
|
|
32
38
|
decidedBy,
|
|
39
|
+
delegatedMark,
|
|
33
40
|
dependencyChange,
|
|
34
41
|
dependencyGrants,
|
|
35
42
|
useApproval,
|
|
@@ -176,7 +183,9 @@ for (const { pattern, why, person, command } of config.shell.block) {
|
|
|
176
183
|
person && actor.kind === 'lead'
|
|
177
184
|
? '\nTo let the lead run it when the person says so in chat, a person sets "approvals": { "lead": true } in the config.'
|
|
178
185
|
: '';
|
|
179
|
-
block(
|
|
186
|
+
block(
|
|
187
|
+
`Blocked: ${why}${noteFor(project, config, { kind: 'shell', name: why })}${hint}\nCommand: ${cmd}`,
|
|
188
|
+
);
|
|
180
189
|
}
|
|
181
190
|
|
|
182
191
|
if (approves && actor.kind !== 'lead')
|
|
@@ -471,16 +480,47 @@ function recordApprovals() {
|
|
|
471
480
|
session: input.session_id ?? null,
|
|
472
481
|
}),
|
|
473
482
|
);
|
|
483
|
+
// STD-5: removing a standard, or weakening a required one, is the person's own call, never the lead's,
|
|
484
|
+
// delegated or not. The commit waits for their `standards` approval, which records what it weakened.
|
|
485
|
+
const stdFile = standardsFile(config);
|
|
486
|
+
const stdWeak =
|
|
487
|
+
files.includes(stdFile) && !arrivesWithMerge(root, stdFile, { staged: true })
|
|
488
|
+
? standardsWeakenings(
|
|
489
|
+
git('show', `HEAD:${stdFile}`),
|
|
490
|
+
existsSync(join(root, stdFile)) ? readFileSync(join(root, stdFile), 'utf8') : '',
|
|
491
|
+
)
|
|
492
|
+
: [];
|
|
493
|
+
if (stdWeak.length) {
|
|
494
|
+
const granted = approval(root, STANDARDS_APPROVAL, actor);
|
|
495
|
+
if (!granted || granted.reason.includes(delegatedMark('').slice(0, -1))) {
|
|
496
|
+
recordRequest(
|
|
497
|
+
root,
|
|
498
|
+
actor,
|
|
499
|
+
STANDARDS_APPROVAL,
|
|
500
|
+
stdFile,
|
|
501
|
+
`Standards weakened: ${stdWeak.join('; ')}.`,
|
|
502
|
+
);
|
|
503
|
+
const personOnly = { ...config, approvals: { ...config.approvals, delegate: null } };
|
|
504
|
+
block(
|
|
505
|
+
`Blocked: this commit weakens the project's standards in ${stdFile}: ${stdWeak.join('; ')}. Removing a standard, or weakening a required one, is the person's call, never the lead's. Ask them, with what the standard costs the project and your recommendation; they approve it with\n${approvalHowTo(STANDARDS_APPROVAL, actor, root, 'weakening the standards in one commit', personOnly)}`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
474
509
|
for (const file of files) {
|
|
475
510
|
// Concluding a merge: what arrives as the other branch has it was logged there, with its approvals.
|
|
476
511
|
if (arrivesWithMerge(root, file, { staged: true })) continue;
|
|
477
|
-
|
|
512
|
+
// A weakened standards file is the person's even where the config doesn't protect it.
|
|
513
|
+
const entry =
|
|
514
|
+
protectedEntry(file, config) ??
|
|
515
|
+
(file === stdFile && stdWeak.length
|
|
516
|
+
? { approval: STANDARDS_APPROVAL, why: "the project's standards" }
|
|
517
|
+
: null);
|
|
478
518
|
const granted = entry && approval(root, entry.approval, actor);
|
|
479
519
|
const byDependency = actor.kind === 'lane' && dependencyChange(actor, file, root, config);
|
|
480
520
|
if (entry && !granted && actor.kind !== 'lead' && !byDependency) {
|
|
481
521
|
recordRequest(root, actor, entry.approval, file, entry.why);
|
|
482
522
|
block(
|
|
483
|
-
`Blocked: ${file} is ${entry.why}; committing it needs ${decidedBy(entry.approval, config) === 'lead' ? "the lead's" : "a person's"} approval in force. ${askHowTo(entry.approval, actor, root, config)}`,
|
|
523
|
+
`Blocked: ${file} is ${entry.why}; committing it needs ${decidedBy(entry.approval, config) === 'lead' ? "the lead's" : "a person's"} approval in force.${noteFor(root, config, { kind: 'protected', name: entry.approval, glob: entry.glob })} ${askHowTo(entry.approval, actor, root, config)}`,
|
|
484
524
|
);
|
|
485
525
|
}
|
|
486
526
|
if (granted)
|
|
@@ -488,6 +528,7 @@ function recordApprovals() {
|
|
|
488
528
|
approval: entry.approval,
|
|
489
529
|
reason: granted.reason,
|
|
490
530
|
grantedAt: granted.grantedAt,
|
|
531
|
+
...(file === stdFile && stdWeak.length ? { weakened: stdWeak } : {}),
|
|
491
532
|
});
|
|
492
533
|
else if (byDependency && (entry || !owns(actor, file, config))) {
|
|
493
534
|
const grants = dependencyGrants(root, actor);
|
|
@@ -538,6 +579,7 @@ ${approvalHowTo(TESTS_APPROVAL, actor, root, 'weakening tests in one commit', pe
|
|
|
538
579
|
});
|
|
539
580
|
useApproval(root, TESTS_APPROVAL, actor);
|
|
540
581
|
}
|
|
582
|
+
if (stdWeak.length) useApproval(root, STANDARDS_APPROVAL, actor);
|
|
541
583
|
// A dependency approval is used up by the commit that adds its package to a manifest or lockfile.
|
|
542
584
|
const manifests = files.filter((f) => isDependencyFile(f));
|
|
543
585
|
if (manifests.length && actor.kind !== 'readonly') {
|
package/hooks/guard-paths.mjs
CHANGED
|
@@ -6,7 +6,8 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { loadBaseline, newProblems } from './lib/baseline.mjs';
|
|
8
8
|
import { CONFIG_FILE } from './lib/config.mjs';
|
|
9
|
-
import { layerProblems } from './lib/layers.mjs';
|
|
9
|
+
import { brokenLayerRule, layerProblems } from './lib/layers.mjs';
|
|
10
|
+
import { noteFor } from './lib/standards.mjs';
|
|
10
11
|
import { block, start } from './lib/hook.mjs';
|
|
11
12
|
import { teamOf, teamProvider, teamRefusal, whoami } from './lib/team.mjs';
|
|
12
13
|
import { spawnSync } from 'node:child_process';
|
|
@@ -83,7 +84,7 @@ if (after !== null) {
|
|
|
83
84
|
);
|
|
84
85
|
if (added.length)
|
|
85
86
|
block(
|
|
86
|
-
`Blocked: this edit would break the layer rules (.claude/code-kit.json › layers):\n ${added.join('\n ')}\nDepend only on the layers listed, or ask the lead to change the rule
|
|
87
|
+
`Blocked: this edit would break the layer rules (.claude/code-kit.json › layers):\n ${added.join('\n ')}\nDepend only on the layers listed, or ask the lead to change the rule.${noteFor(root, config, added.map(brokenLayerRule))}`,
|
|
87
88
|
);
|
|
88
89
|
}
|
|
89
90
|
process.exit(0);
|
package/hooks/hooks.json
CHANGED
|
@@ -46,6 +46,28 @@
|
|
|
46
46
|
]
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
+
"SessionStart": [
|
|
50
|
+
{
|
|
51
|
+
"hooks": [
|
|
52
|
+
{
|
|
53
|
+
"type": "command",
|
|
54
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/team-session.mjs\" start",
|
|
55
|
+
"timeout": 10
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"SessionEnd": [
|
|
61
|
+
{
|
|
62
|
+
"hooks": [
|
|
63
|
+
{
|
|
64
|
+
"type": "command",
|
|
65
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/team-session.mjs\" end",
|
|
66
|
+
"timeout": 10
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
],
|
|
49
71
|
"Stop": [
|
|
50
72
|
{
|
|
51
73
|
"hooks": [
|