@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/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 { ADAPTERS, activeAdapters, adapterFacts } from '../hooks/lib/adapters/index.mjs';
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
- return out(rest[1] === 'lead' ? leadSection(root, config) : agentRules(root, config));
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` 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.',
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' +
@@ -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(`Blocked: ${why}${hint}\nCommand: ${cmd}`);
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
- const entry = protectedEntry(file, config);
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') {
@@ -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": [