@event4u/agent-config 9.36.0 → 10.0.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.
Files changed (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +57 -0
  3. package/agents/templates/.ai-council.yml.example +31 -51
  4. package/dist/agent-src/commands/analyze/conformance.md +15 -4
  5. package/dist/agent-src/rules/session-canary.md +28 -0
  6. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  7. package/dist/cli-delegate/{chunk-SFGARMAB.js → chunk-2H5SE7DU.js} +24 -4
  8. package/dist/cli-delegate/{chunk-U7CMAIOC.js → chunk-3JUTT26I.js} +1 -1
  9. package/dist/cli-delegate/{chunk-JP53LNVT.js → chunk-HJSKTMIF.js} +6 -14
  10. package/dist/cli-delegate/{chunk-QEEB4LHO.js → chunk-LRXJK6GN.js} +1 -1
  11. package/dist/cli-delegate/{chunk-NR7HSN3P.js → chunk-NX5AXSLD.js} +3 -3
  12. package/dist/cli-delegate/{chunk-AG47LAGQ.js → chunk-TVGXH7VA.js} +1 -1
  13. package/dist/cli-delegate/{chunk-55RI5UXK.js → chunk-XT2YTJCZ.js} +22 -18
  14. package/dist/cli-delegate/{chunk-JEJABGKB.js → chunk-XTC5ARB6.js} +16 -12
  15. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  16. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  17. package/dist/cli-delegate/cmd_conformance.js +1037 -196
  18. package/dist/cli-delegate/cmd_doctor.js +3 -3
  19. package/dist/cli-delegate/cmd_explain.js +2 -2
  20. package/dist/cli-delegate/cmd_export.js +4 -4
  21. package/dist/cli-delegate/cmd_handoff.js +3 -3
  22. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  23. package/dist/cli-delegate/cmd_migrate.js +2 -2
  24. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  25. package/dist/cli-delegate/cmd_prune.js +4 -4
  26. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  27. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  28. package/dist/cli-delegate/cmd_session_recycle.js +1 -1
  29. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  30. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  31. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  32. package/dist/cli-delegate/cmd_sync.js +4 -4
  33. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  34. package/dist/cli-delegate/cmd_update.js +1 -1
  35. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  36. package/dist/cli-delegate/cmd_validate.js +4 -4
  37. package/dist/cli-delegate/cmd_versions.js +1 -1
  38. package/dist/discovery/deprecation-report.md +1 -1
  39. package/dist/discovery/discovery-manifest.json +4 -4
  40. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  41. package/dist/discovery/discovery-manifest.summary.md +1 -1
  42. package/dist/discovery/dormancy-report.md +1 -1
  43. package/dist/discovery/orphan-report.md +1 -1
  44. package/dist/discovery/packs.json +2 -2
  45. package/dist/discovery/trust-report.md +1 -1
  46. package/dist/discovery/workspaces.json +2 -2
  47. package/dist/hooks/dispatch.js +125 -89
  48. package/dist/install/install.mjs +22 -26
  49. package/dist/mcp/registry-manifest.json +1 -1
  50. package/dist/mcp/server.json +2 -2
  51. package/dist/mcp/server.mjs +1556 -613
  52. package/dist/scripts/_lib/agent_settings.js +39 -25
  53. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  54. package/dist/scripts/condense.js +15 -2
  55. package/dist/scripts/condense.js.map +1 -1
  56. package/dist/server/routes/wizard.js +23 -3
  57. package/dist/server/routes/wizard.js.map +1 -1
  58. package/dist/server/schemas/settings.js +6 -6
  59. package/dist/server/schemas/settings.js.map +1 -1
  60. package/docs/contracts/settings-classes.md +14 -9
  61. package/package.json +1 -1
  62. package/src/config/agent-settings.template.yml +6 -36
  63. package/src/config/ci-local-parity.yml +44 -0
  64. package/src/scripts/_lib/agent_settings.ts +39 -25
  65. package/src/scripts/_lib/environment_detector.ts +52 -1
  66. package/src/scripts/ai_council/config.ts +62 -24
  67. package/src/scripts/before_complete_hook.ts +39 -0
  68. package/src/scripts/check_bridge_derivation.ts +89 -3
  69. package/src/scripts/check_ci_local_parity.ts +74 -0
  70. package/src/scripts/check_references.ts +15 -1
  71. package/src/scripts/condense.ts +16 -2
  72. package/src/scripts/conformance_scan.ts +200 -9
  73. package/src/scripts/council_cli.ts +95 -3
  74. package/src/scripts/hook_manifest.yaml +12 -6
  75. package/src/scripts/hooks/block_no_verify.ts +82 -9
  76. package/src/scripts/hooks/turn_end_gate_hook.ts +138 -105
  77. package/src/scripts/probe_promissory_closing.ts +180 -0
  78. package/src/scripts/probe_session_canary.ts +180 -0
  79. package/src/server/schemas/settings.ts +6 -14
@@ -220,6 +220,50 @@ export interface DetectEnvironmentOptions {
220
220
  * hardened env and a 2s timeout; pass `() => null` to skip probing.
221
221
  */
222
222
  readonly probeVersion?: VersionProbe;
223
+ /**
224
+ * macOS Keychain presence probe for the Claude Code subscription
225
+ * credential. Omit to shell out to `security find-generic-password`;
226
+ * pass `() => false` to skip it entirely — every test that is not
227
+ * specifically about this probe should, so a developer machine's real
228
+ * Keychain never decides a test outcome.
229
+ */
230
+ readonly probeKeychain?: KeychainProbe;
231
+ }
232
+
233
+ /** `(service) => present?` — never returns the secret, only whether it exists. */
234
+ export type KeychainProbe = (service: string) => boolean;
235
+
236
+ /** Keychain service name Claude Code writes its subscription credential under. */
237
+ export const CLAUDE_KEYCHAIN_SERVICE = 'Claude Code-credentials';
238
+
239
+ /**
240
+ * Is the Claude Code subscription credential in the macOS login Keychain?
241
+ *
242
+ * Claude Code stores its OAuth credential in the Keychain on macOS and only
243
+ * falls back to `~/.claude/.credentials.json` elsewhere. Looking for the file
244
+ * alone therefore reports "no subscription" on the platform where the
245
+ * subscription is most likely to exist — which sent every macOS anthropic
246
+ * member down the metered `api` rung while a paid Claude subscription sat
247
+ * unused on the same machine. That is the defect this probe closes.
248
+ *
249
+ * `-w` is deliberately NOT passed: without it `security` prints attributes and
250
+ * never the password, so the secret cannot reach a log, a report, or a crash
251
+ * dump. Presence is read from the exit code alone.
252
+ */
253
+ function defaultProbeKeychain(service: string): boolean {
254
+ if (process.platform !== 'darwin') return false;
255
+ try {
256
+ const res = spawnSync('security', ['find-generic-password', '-s', service], {
257
+ timeout: VERSION_PROBE_TIMEOUT_MS,
258
+ encoding: 'utf-8',
259
+ windowsHide: true,
260
+ env: hardenedSpawnEnv(),
261
+ });
262
+ if (res.error !== undefined || res.signal !== null) return false;
263
+ return res.status === 0;
264
+ } catch {
265
+ return false;
266
+ }
223
267
  }
224
268
 
225
269
  /**
@@ -298,6 +342,7 @@ function cliSubscriptionEvidence(
298
342
  home: string,
299
343
  env: EnvMap,
300
344
  claudeConfigDir: string,
345
+ probeKeychain: KeychainProbe,
301
346
  ): { source: AuthSource; evidence: string } | null {
302
347
  if (provider === 'anthropic') {
303
348
  const configDir = claudeConfigDir;
@@ -305,6 +350,11 @@ function cliSubscriptionEvidence(
305
350
  const p = path.join(configDir, rel);
306
351
  if (isFile(p)) return { source: 'cli-subscription', evidence: p };
307
352
  }
353
+ // macOS keeps the credential in the Keychain instead of the file above.
354
+ // The evidence string is a locator, never the secret.
355
+ if (probeKeychain(CLAUDE_KEYCHAIN_SERVICE)) {
356
+ return { source: 'cli-subscription', evidence: `keychain:${CLAUDE_KEYCHAIN_SERVICE}` };
357
+ }
308
358
  return null;
309
359
  }
310
360
  if (provider === 'openai') {
@@ -359,11 +409,12 @@ function detectAuth(opts: DetectEnvironmentOptions): {
359
409
  opts.home === undefined && opts.env === undefined
360
410
  ? claude_config_dir()
361
411
  : ((env['CLAUDE_CONFIG_DIR'] ?? '').trim() || path.join(home, '.claude'));
412
+ const probeKeychain = opts.probeKeychain ?? defaultProbeKeychain;
362
413
  const auth: DetectedAuth[] = [];
363
414
  const keys: DetectedKey[] = [];
364
415
 
365
416
  for (const provider of knownProviders()) {
366
- const cli = cliSubscriptionEvidence(provider, home, env, claudeConfigDir);
417
+ const cli = cliSubscriptionEvidence(provider, home, env, claudeConfigDir, probeKeychain);
367
418
  if (cli !== null) {
368
419
  auth.push({ provider, source: cli.source, evidence: cli.evidence });
369
420
  }
@@ -639,6 +639,20 @@ export interface CouncilConfig {
639
639
  readonly low_impact: LowImpactConfig;
640
640
  readonly lens_overrides: LensOverridesConfig;
641
641
  readonly source_path: PathLike | null;
642
+ /**
643
+ * Transport keys the config file still carries and the loader deliberately
644
+ * IGNORED — dotted paths, e.g. `defaults.mode`, `members.anthropic.mode`.
645
+ *
646
+ * Transport is resolved per machine per invocation, never configured: the
647
+ * chain is `cli → api → unavailable`, and an airgapped host forces the
648
+ * `api` rung. Deleting the keys from the schema outright would have made
649
+ * every pre-existing installation fail to load, so a stale key is
650
+ * accepted, ignored, and reported once — the migration IS the ignore.
651
+ *
652
+ * Empty on a config that never carried one, which is the shape the current
653
+ * template ships.
654
+ */
655
+ readonly ignored_transport_keys: readonly string[];
642
656
  }
643
657
 
644
658
  // ── default factories (dataclass defaults) ─────────────────────────
@@ -796,7 +810,11 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
796
810
  throw new CouncilConfigError('`enabled` must be a bool.');
797
811
  }
798
812
 
799
- const defaults = _build_defaults(_asDict(_getOr(raw, 'defaults', {})));
813
+ const ignored_transport_keys: string[] = [];
814
+ const defaults = _build_defaults(
815
+ _asDict(_getOr(raw, 'defaults', {})),
816
+ ignored_transport_keys,
817
+ );
800
818
  const cost_budget = _build_cost_budget(
801
819
  _asDict(_getOr(raw, 'cost_budget', {})),
802
820
  );
@@ -809,7 +827,12 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
809
827
  for (const [name, cfg] of Object.entries(members_raw)) {
810
828
  members.set(
811
829
  name,
812
- _build_member(name, _asDict(_orEmpty(cfg)), defaults.mode),
830
+ _build_member(
831
+ name,
832
+ _asDict(_orEmpty(cfg)),
833
+ defaults.mode,
834
+ ignored_transport_keys,
835
+ ),
813
836
  );
814
837
  }
815
838
 
@@ -920,6 +943,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
920
943
  low_impact,
921
944
  lens_overrides,
922
945
  source_path,
946
+ ignored_transport_keys,
923
947
  };
924
948
  }
925
949
 
@@ -1508,23 +1532,36 @@ function _build_consensus_scoring(d: Dict): ConsensusScoringConfig {
1508
1532
 
1509
1533
  const _VALID_MEMBER_MODES: ReadonlySet<string> = new Set(['cli', 'api']);
1510
1534
 
1511
- function _build_defaults(d: Dict): DefaultsConfig {
1535
+ function _build_defaults(d: Dict, ignored: string[] = []): DefaultsConfig {
1512
1536
  if (!_isDict(d)) {
1513
1537
  throw new CouncilConfigError('`defaults` must be a mapping.');
1514
1538
  }
1515
- // road-to-always-on-orchestration Phase 3.1: CLI-first is the shipped
1516
- // default (was `api`) — see the module docstring rule 2. `manual` and a
1517
- // pinned `api`/`cli` remain valid overrides at every layer resolve_mode
1518
- // checks; this only changes what a config that never sets `mode` gets.
1519
- const mode = _get(d, 'mode', 'auto');
1520
- if (!(_isStr(mode) && _VALID_MODES.has(mode))) {
1521
- throw new CouncilConfigError(
1522
- `defaults.mode=${_pyRepr(mode)} not in ${_sortedListRepr(_VALID_MODES)}.`,
1523
- );
1524
- }
1539
+ // Transport is RESOLVED, never configured.
1540
+ //
1541
+ // The CLI-first flip (road-to-always-on-orchestration Phase 3.1) changed
1542
+ // only what a config that OMITS `mode` receives. Every installation that
1543
+ // spelled the key out — which the wizard did, and which every pre-flip
1544
+ // template shipped — kept its old `api` value forever, silently, and went
1545
+ // on paying per token while a subscription CLI sat unused on the same
1546
+ // machine. A default that only fires on an absent key does not migrate
1547
+ // anybody; it just makes the docs read better than the behaviour.
1548
+ //
1549
+ // So the key is no longer read. `auto` is the only value this loader
1550
+ // produces, and `transport_resolver` picks the concrete rung per machine
1551
+ // per invocation (`cli → api → unavailable`, airgap forcing `api`). A file
1552
+ // still carrying `mode:` loads fine and is reported via
1553
+ // `ignored_transport_keys` — see `CouncilConfig`.
1554
+ // `null` as the sentinel, not `undefined`: `_get`'s default parameter is
1555
+ // typed `Json`, which excludes `undefined`. A config spelling `mode: null`
1556
+ // is indistinguishable from an absent key here, and that is correct — a
1557
+ // null transport is not a pin worth reporting.
1558
+ if (_get(d, 'mode', null) !== null) {
1559
+ ignored.push('defaults.mode');
1560
+ }
1561
+ const mode = 'auto';
1525
1562
  // `member_mode` (step-9 P8 · U1) — global preference for solo /
1526
- // CLI-mode invocations. Narrower set than `defaults.mode` because
1527
- // `manual` makes no sense as a per-member dispatch default.
1563
+ // CLI-mode invocations. Narrower set than the retired `defaults.mode`
1564
+ // because `manual` makes no sense as a per-member dispatch default.
1528
1565
  const member_mode = _get(d, 'member_mode', 'cli');
1529
1566
  if (!(_isStr(member_mode) && _VALID_MEMBER_MODES.has(member_mode))) {
1530
1567
  throw new CouncilConfigError(
@@ -1726,6 +1763,7 @@ function _build_member(
1726
1763
  // a future caller that omits the third argument observes the same
1727
1764
  // doctrine rather than silently reverting to the retired `api` default.
1728
1765
  default_mode = 'auto',
1766
+ ignored: string[] = [],
1729
1767
  ): MemberConfig {
1730
1768
  if (!_VALID_PROVIDERS.has(name)) {
1731
1769
  throw new CouncilConfigError(
@@ -1738,15 +1776,15 @@ function _build_member(
1738
1776
  const model = _pyTruthy(modelVal) ? (modelVal as string) : '';
1739
1777
  const api_key_ref_raw = _get(cfg, 'api_key_ref', null);
1740
1778
  const api_key_ref = api_key_ref_raw === undefined ? null : api_key_ref_raw;
1741
- const member_mode_raw = _get(cfg, 'mode', null);
1742
- const member_mode = member_mode_raw === undefined ? null : member_mode_raw;
1743
- if (member_mode !== null && !(_isStr(member_mode) && _VALID_MODES.has(member_mode))) {
1744
- throw new CouncilConfigError(
1745
- `members.${name}.mode=${_pyRepr(member_mode)} not in ` +
1746
- `${_sortedListRepr(_VALID_MODES)}.`,
1747
- );
1748
- }
1749
- const effective_mode = member_mode !== null ? (member_mode as string) : default_mode;
1779
+ // Per-member transport is no longer configurable either — same reasoning
1780
+ // as `_build_defaults`. A member that pinned `mode: api` would reintroduce
1781
+ // exactly the silent-spend path the global key was removed for, one
1782
+ // provider at a time. Recorded as ignored, never read.
1783
+ if (_get(cfg, 'mode', null) !== null) {
1784
+ ignored.push(`members.${name}.mode`);
1785
+ }
1786
+ const member_mode: string | null = null;
1787
+ const effective_mode = default_mode;
1750
1788
  const binary_raw = _get(cfg, 'binary', null);
1751
1789
  const binary = binary_raw === undefined ? null : binary_raw;
1752
1790
  if (binary !== null) {
@@ -113,6 +113,14 @@ function _empty_state(): StateDict {
113
113
  verified_this_turn: false,
114
114
  ci_saw_pending: false,
115
115
  nonevidence_this_turn: 0,
116
+ // Round 7 § Phase 1 — the last CI read, SESSION-scoped on purpose. Every
117
+ // other counter here is turn-scoped because a stale POSITIVE would wrongly
118
+ // vouch ("I verified"). This one is a NEGATIVE ("CI was not settled"), and a
119
+ // stale negative only ever refuses more often — so surviving the turn
120
+ // boundary preserves the freshness invariant rather than bypassing it. It
121
+ // has to survive it: the measured failure is a completion claim in a LATER
122
+ // turn than the poll it rests on.
123
+ ci_last: null,
116
124
  checked_at: _now(),
117
125
  };
118
126
  }
@@ -303,6 +311,16 @@ function _update(state: StateDict, event: string, envelope: StateDict): StateDic
303
311
  // Session boundary — reset session-scoped counters.
304
312
  state["session_id"] = session_id;
305
313
  state["verifications_this_session"] = 0;
314
+ // R2 finding 1 (high). `ci_last` is SESSION-scoped, and until this line
315
+ // nothing cleared it: `_reset_turn` clears the turn-scoped witness,
316
+ // `_empty_state` sets it null only for a state file that does not exist yet,
317
+ // and `_load_state` merges the persisted value over that default. So session
318
+ // A polled CI, saw pending, and ended; session B — a doc-only session that
319
+ // never touched CI — said "Fertig" and was REFUSED, contradicting both the
320
+ // consumer's own comment ("a session that never polled CI must never be
321
+ // refused for it") and the negative case the roadmap pinned. Session-scoped
322
+ // has to mean cleared at the boundary, or it means never cleared.
323
+ state["ci_last"] = null;
306
324
  state = _reset_turn(state, session_id);
307
325
  }
308
326
 
@@ -338,6 +356,27 @@ function _update(state: StateDict, event: string, envelope: StateDict): StateDic
338
356
  } else {
339
357
  counts = false; // unrecognised or unreadable — never read as settled
340
358
  }
359
+ // Round 7 § 1.1 — the session-scoped negative the turn-end consumer reads.
360
+ //
361
+ // R2 finding 3 (medium), and it was the sharpest one: this said `settled:
362
+ // pending === 0` and CLAIMED "the same discrimination `counts` makes
363
+ // above". It was not. `counts` requires `!vacuous && ci_saw_pending`;
364
+ // `pending === 0` alone drops both. A post-push poll that reads a stale
365
+ // all-pass table returns pending 0 — verbatim the FC-3b failure documented
366
+ // twenty lines up — and recorded `settled: true`, so the completion
367
+ // detector went SILENT in exactly the premature-claim case it exists for.
368
+ // A detector defeated by the false settle it was built to catch is worse
369
+ // than no detector, because it reports coverage.
370
+ //
371
+ // Now it reuses `counts` itself, which is that discrimination rather than
372
+ // a paraphrase of it. `pending === null` (unreadable, and "no checks
373
+ // reported") stays not-a-settle by construction.
374
+ state["ci_last"] = {
375
+ at: _now(),
376
+ command: cmd.slice(0, 512),
377
+ pending,
378
+ settled: pending === 0 && counts === true,
379
+ };
341
380
  }
342
381
 
343
382
  state["last_verification"] = {
@@ -16,6 +16,11 @@
16
16
  * changed entry means the tree on disk did not derive from the current
17
17
  * source (hand-edit, stale generation, or a nondeterministic generator).
18
18
  *
19
+ * Both legs are scoped to the roots this checkout's generator actually writes:
20
+ * a root whose owning tool is deactivated in `agents/.agent-tools.yml` has no
21
+ * regeneration path, so nothing found there is drift it could repair. Skipped
22
+ * roots are named on stderr, never dropped silently.
23
+ *
19
24
  * Run AFTER `task generate-tools` (CI: the sync-consistency job). Exit 1 on
20
25
  * the first drifted artefact, naming it.
21
26
  */
@@ -26,6 +31,7 @@ import * as path from 'node:path';
26
31
  import { fileURLToPath } from 'node:url';
27
32
 
28
33
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
34
+ import { active_tools_at } from './condense.js';
29
35
 
30
36
  const _HERE = fileURLToPath(import.meta.url);
31
37
  const REPO_ROOT = path.join(path.dirname(_HERE), '..', '..');
@@ -50,6 +56,59 @@ export const BRIDGE_ROOTS: readonly string[] = [
50
56
  /** Repo-relative prefixes a bridge symlink may resolve into. */
51
57
  const DERIVATION_SOURCES = ['dist/agent-src', 'src', 'AGENTS.md'] as const;
52
58
 
59
+ /**
60
+ * Bridge root → the tool id whose activation makes the generator maintain it.
61
+ *
62
+ * A root ABSENT from this map is maintained unconditionally, and `.augment` is
63
+ * the one that matters: `project_to_augment()` carries no tool gate, so mapping
64
+ * it to `augment` would silence the audit on a tree that is regenerated on
65
+ * every run — a strict weakening. Absent-means-unconditional keeps that the
66
+ * default, so a future bridge root has to opt IN to being skippable.
67
+ */
68
+ const ROOT_TOOL_ID: Readonly<Record<string, string>> = {
69
+ '.claude/rules': 'claude-code',
70
+ '.claude/skills': 'claude-code',
71
+ '.claude/agents': 'claude-code',
72
+ '.claude/personas': 'claude-code',
73
+ '.claude/user-types': 'claude-code',
74
+ '.cursor/rules': 'cursor',
75
+ '.cursor/commands': 'cursor',
76
+ '.windsurf/rules': 'windsurf',
77
+ '.windsurf/workflows': 'windsurf',
78
+ '.windsurfrules': 'windsurf',
79
+ '.clinerules': 'cline',
80
+ 'GEMINI.md': 'gemini',
81
+ };
82
+
83
+ /**
84
+ * Split `roots` into the ones this checkout's generator maintains and the ones
85
+ * it does not, given the active tool set (`null` = all tools active).
86
+ *
87
+ * Auditing a root whose owning tool is inactive is not a drift check: the
88
+ * generator never writes it, so whatever sits there is an unmaintained fossil
89
+ * with no regeneration path. Reporting it leaves hand-deletion as the only
90
+ * compliant action, which is exactly what `check_rule_projection_integrity`
91
+ * rejected as "not a gate" after the same masked-config shape broke the 9.27.0
92
+ * release. This one broke 9.36.0 the same way: `agents/.agent-tools.yml` is
93
+ * committed with all eight tools but carries `skip-worktree` locally and reads
94
+ * `tools: []`, so `.claude/skills/` had not been regenerated since 2026-07-05
95
+ * and still held symlinks to a directory renamed since.
96
+ */
97
+ export function partition_roots(
98
+ roots: readonly string[],
99
+ active: ReadonlySet<string> | null,
100
+ ): { audited: string[]; skipped: string[] } {
101
+ if (active === null) return { audited: [...roots], skipped: [] };
102
+ const audited: string[] = [];
103
+ const skipped: string[] = [];
104
+ for (const r of roots) {
105
+ const tool = ROOT_TOOL_ID[r];
106
+ if (tool === undefined || active.has(tool)) audited.push(r);
107
+ else skipped.push(r);
108
+ }
109
+ return { audited, skipped };
110
+ }
111
+
53
112
  function _walk(p: string, out: string[]): void {
54
113
  const st = fs.lstatSync(p);
55
114
  if (st.isSymbolicLink() || st.isFile()) {
@@ -132,7 +191,22 @@ export function validate_symlinks(root: string, repoRoot: string): string[] {
132
191
 
133
192
  export function run(repoRoot: string): string[] {
134
193
  const errors: string[] = [];
135
- const roots = BRIDGE_ROOTS.map((r) => path.join(repoRoot, r));
194
+ const { audited, skipped } = partition_roots(BRIDGE_ROOTS, active_tools_at(repoRoot));
195
+
196
+ // LOUD, never a silent narrowing: the audited surface just shrank, and a
197
+ // reader who cannot see which roots dropped out cannot distinguish this run
198
+ // from a full one — that indistinguishability is the false green
199
+ // `_lib/scan_scope.ts` exists to prevent.
200
+ if (skipped.length > 0) {
201
+ process.stderr.write(
202
+ `⚠️ check_bridge_derivation: ${skipped.length} bridge root(s) NOT audited — ` +
203
+ `agents/.agent-tools.yml deactivates the owning tool, so the generator does ` +
204
+ `not write them in this checkout and their contents cannot be attributed as ` +
205
+ `drift: ${skipped.join(', ')}. CI activates all eight tools.\n`,
206
+ );
207
+ }
208
+
209
+ const roots = audited.map((r) => path.join(repoRoot, r));
136
210
 
137
211
  const before: Record<string, Record<string, string>> = {};
138
212
  for (const r of roots) before[r] = snapshot_tree(r);
@@ -141,11 +215,17 @@ export function run(repoRoot: string): string[] {
141
215
  // there is nothing to diff and nothing to validate, and the check reports a
142
216
  // fixpoint it never tested. Thrown, not returned — `errors` is the drift
143
217
  // channel, and a dead scope is not drift.
218
+ //
219
+ // No `allowEmpty` twin of the sibling gate's is needed here, and that is a
220
+ // property of the map rather than an omission: `.augment` is maintained
221
+ // unconditionally, so `audited` is never empty and zero entries always means
222
+ // a genuinely dead surface (no `dist/agent-src/` at all), not a deactivated
223
+ // tool. Map `.augment` to a tool and this reasoning stops holding.
144
224
  assertScanned({
145
225
  gate: 'check_bridge_derivation',
146
226
  scanned: Object.values(before).reduce((n, snap) => n + Object.keys(snap).length, 0),
147
227
  units: 'bridge entries',
148
- roots: BRIDGE_ROOTS,
228
+ roots: audited,
149
229
  });
150
230
 
151
231
  // Regenerate in a child process so MODULE_STATE resolves exactly as the
@@ -188,6 +268,12 @@ if (isMain) {
188
268
  for (const e of errors) process.stderr.write(`❌ ${e}\n`);
189
269
  process.exit(1);
190
270
  }
191
- process.stdout.write('✅ bridge trees derive from src/ (symlink targets valid, regeneration is a fixpoint)\n');
271
+ // Name the count rather than "bridge trees": with a tool deactivated the
272
+ // green covers fewer roots than the reader would assume from a bare tick.
273
+ const n = partition_roots(BRIDGE_ROOTS, active_tools_at(root)).audited.length;
274
+ process.stdout.write(
275
+ `✅ ${n}/${BRIDGE_ROOTS.length} bridge roots derive from src/ ` +
276
+ '(symlink targets valid, regeneration is a fixpoint)\n',
277
+ );
192
278
  process.exit(0);
193
279
  }
@@ -38,6 +38,27 @@ const HOOK = path.join(REPO, 'src', 'scripts', 'install-hooks.sh');
38
38
  /** Roots of the local closure. `ci` is the local mirror; the hook is what a push runs. */
39
39
  const LOCAL_ROOTS = ['ci', 'consistency'] as const;
40
40
 
41
+ /**
42
+ * Round 7 § Phase 3 — the third dimension, and why two were not enough.
43
+ *
44
+ * `task ci` INCLUDES `preflight` (`Taskfile.yml:87`), so the two roots above
45
+ * answer "does the local MIRROR match CI". The pass an agent is actually told to
46
+ * run before pushing is `task preflight`, a deliberate subset of `ci` — its own
47
+ * docstring excludes `check_enforcement_coverage` at a measured 30.7 s because
48
+ * "a pre-push gate that doubles the hook teaches people to skip it".
49
+ *
50
+ * That makes `CI ∩ ci \ preflight` invisible to this gate by construction, and it
51
+ * is exactly where round 7's measured "green locally, red remotely" cycles came
52
+ * from: the Thin-Root char cap, the ratchet living in the Node-Tests shard, and
53
+ * the derived-page freshness check.
54
+ *
55
+ * Reported, not gated. The subset is intentional, so failing on it would demand
56
+ * that `preflight` grow into `ci` — the opposite of what its author decided. What
57
+ * this dimension removes is the IMPLICATION that a green preflight means CI will
58
+ * be green.
59
+ */
60
+ const PREFLIGHT_ROOTS = ['preflight'] as const;
61
+
41
62
  export interface Declared {
42
63
  id: string;
43
64
  class: string;
@@ -197,6 +218,19 @@ export interface ParityReport {
197
218
  undeclared_ci_only: string[];
198
219
  undeclared_local_only: string[];
199
220
  stale_declarations: string[];
221
+ /** Round 7 § Phase 3 — `task preflight`'s own closure. */
222
+ preflight_gates: string[];
223
+ /**
224
+ * `CI ∩ local \ (preflight ∪ pre-push hook)` — gates a push's remote run
225
+ * enforces, the local mirror covers, and NOTHING a push runs locally reaches.
226
+ * Report-only: the measured size of "green locally, red remotely", not a
227
+ * failure.
228
+ *
229
+ * The hook's own closure is in the subtrahend per R2 finding 4: subtracting
230
+ * preflight alone counted gates the pre-push hook already runs, which cannot
231
+ * fail after a push. That read 221; the honest figure is 209.
232
+ */
233
+ ci_not_in_preflight: string[];
200
234
  }
201
235
 
202
236
  export function analyse(repo = REPO, manifest?: Manifest): ParityReport {
@@ -231,12 +265,38 @@ export function analyse(repo = REPO, manifest?: Manifest): ParityReport {
231
265
  ...[...declaredLocal].filter((g) => localGates.has(g) && ciGates.has(g)),
232
266
  ].sort();
233
267
 
268
+ // Round 7 § 3.1 — the third dimension. Derived the same way as the other two,
269
+ // so it cannot drift from them by construction.
270
+ //
271
+ // R2 finding 4 (medium): the first version subtracted only the `preflight`
272
+ // closure from `localGates`, and `localGates` includes every gate reachable
273
+ // from `install-hooks.sh` — which the PRE-PUSH hook runs. A gate that runs
274
+ // before the push lands cannot produce a "green locally, red remotely" cycle,
275
+ // so counting it inflated the number and mislabelled it, in a step whose own
276
+ // text calls the number "the deliverable, not an adjective". The subtrahend is
277
+ // therefore everything a push already runs: preflight PLUS the hook's own
278
+ // closure.
279
+ const preflightGates = local_closure(PREFLIGHT_ROOTS, tasks);
280
+ const prePush = new Set(preflightGates);
281
+ if (fs.existsSync(HOOK)) {
282
+ const hook = fs.readFileSync(HOOK, 'utf-8');
283
+ for (const g of extract_gates(hook)) prePush.add(g);
284
+ for (const t of extract_tasks(hook)) {
285
+ for (const g of local_closure([t], tasks)) prePush.add(g);
286
+ }
287
+ }
288
+ const ci_not_in_preflight = [...ciGates]
289
+ .filter((g) => localGates.has(g) && !prePush.has(g))
290
+ .sort();
291
+
234
292
  return {
235
293
  ci_gates: [...ciGates].sort(),
236
294
  local_gates: [...localGates].sort(),
237
295
  undeclared_ci_only,
238
296
  undeclared_local_only,
239
297
  stale_declarations,
298
+ preflight_gates: [...preflightGates].sort(),
299
+ ci_not_in_preflight,
240
300
  };
241
301
  }
242
302
 
@@ -292,6 +352,20 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
292
352
  for (const g of r.stale_declarations) process.stdout.write(` - ${g}\n`);
293
353
  }
294
354
 
355
+ // Round 7 § 3.2 — printed on EVERY path, pass or fail, because the number is
356
+ // the deliverable. A green parity verdict above says the local MIRROR matches
357
+ // CI; it says nothing about the pass a contributor is told to run, and reading
358
+ // the green line as if it did is the implication this block removes.
359
+ process.stdout.write(
360
+ `ℹ️ preflight: ${String(r.preflight_gates.length)} gate(s) of the ${String(r.local_gates.length)} local ones; ` +
361
+ `${String(r.ci_not_in_preflight.length)} CI-enforced gate(s) run NEITHER in \`task preflight\` NOR\n` +
362
+ ' anywhere else the pre-push hook reaches — i.e. the set that can only fail after\n' +
363
+ ' a push. `task ci` INCLUDES preflight, so the parity verdict above cannot see it.\n' +
364
+ ' Report-only: the subset is deliberate (see the preflight docstring). A green\n' +
365
+ ' preflight is therefore not a prediction that CI will be green.\n',
366
+ );
367
+ for (const g of r.ci_not_in_preflight) process.stdout.write(` · ${g}\n`);
368
+
295
369
  if (!failed) {
296
370
  process.stdout.write(
297
371
  `✅ CI ↔ local parity: ${String(r.ci_gates.length)} CI gate(s), ${String(r.local_gates.length)} local, ` +
@@ -57,7 +57,21 @@ function makeBrokenRef(
57
57
 
58
58
  const SCAN_DIRS = ['dist/agent-src', 'agents'];
59
59
  const SKIP_DIRS = [
60
- 'agents/roadmaps/archive', // archived roadmaps have historical refs
60
+ // Archived roadmaps have historical refs — a link to a file that existed when
61
+ // the plan was written is not drift, so the exclusion is right on the merits.
62
+ //
63
+ // ROUND 7, DOWNGRADE (2026-08-12): it also means this checker is the ONLY thing
64
+ // that would see a class of real rot, and cannot. Measured: **530 dead relative
65
+ // links across 147 of 466 archived roadmaps**. The cause is not history — it is
66
+ // that `archive_completed_roadmaps` moves a file one directory deeper and never
67
+ // re-depths its outgoing relative links, so a link that resolved before the move
68
+ // is broken BY the move. Two tools, one silence: the writer does not rewrite, and
69
+ // the only checker that would notice is scoped away from where it lands.
70
+ //
71
+ // Not repaired here: the fix is a bulk edit across 147 files plus a re-depth pass
72
+ // in the sweep, which is its own change. Recorded so the exclusion is read as a
73
+ // known gap with a number rather than as coverage.
74
+ 'agents/roadmaps/archive',
61
75
  'agents/runtime/council/sessions', // per-user audit trail (gitignored), captured provider output
62
76
  'agents/runtime/council/responses', // paired council output (gitignored), captured provider output
63
77
  'agents/runtime/council/questions', // design Q&A trail — forward-refs to planned artifacts
@@ -359,8 +359,18 @@ const _ALL_TOOLS: ReadonlySet<string> = new Set([
359
359
  ]);
360
360
  void _ALL_TOOLS;
361
361
 
362
- function _active_tools(): ReadonlySet<string> | null {
363
- const tools_file = path.join(MODULE_STATE.PROJECT_ROOT, 'agents', '.agent-tools.yml');
362
+ /**
363
+ * Tool ids selected by `agents/.agent-tools.yml` under `root`, or `null` when
364
+ * the file is absent / malformed / carries no `tools:` list — `null` means
365
+ * "all tools active", never "none".
366
+ *
367
+ * Root-parameterised and exported so a consumer auditing a checkout other than
368
+ * `MODULE_STATE.PROJECT_ROOT` (a gate run with `--root`) reads the selection
369
+ * this generator actually honours, instead of re-implementing these four
370
+ * fallbacks and drifting from them.
371
+ */
372
+ export function active_tools_at(root: string): ReadonlySet<string> | null {
373
+ const tools_file = path.join(root, 'agents', '.agent-tools.yml');
364
374
  if (!_exists(tools_file)) {
365
375
  return null;
366
376
  }
@@ -380,6 +390,10 @@ function _active_tools(): ReadonlySet<string> | null {
380
390
  return new Set(tools.filter((t): t is string => typeof t === 'string'));
381
391
  }
382
392
 
393
+ function _active_tools(): ReadonlySet<string> | null {
394
+ return active_tools_at(MODULE_STATE.PROJECT_ROOT);
395
+ }
396
+
383
397
  function _tool_active(tool_id: string): boolean {
384
398
  const active = _active_tools();
385
399
  return active === null ? true : active.has(tool_id);