@event4u/agent-config 9.10.0 → 9.12.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 (142) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +111 -0
  3. package/README.md +20 -16
  4. package/agents/templates/.ai-council.yml.example +481 -0
  5. package/agents/templates/.ai-video.xml.example +247 -0
  6. package/dist/agent-src/commands/fix/ci.md +29 -0
  7. package/dist/agent-src/commands/pr/create.md +60 -0
  8. package/dist/agent-src/contexts/authority/kernel-rule-edits.md +18 -2
  9. package/dist/agent-src/rules/design-fidelity.md +2 -2
  10. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +1 -0
  11. package/dist/agent-src/skills/design-intelligence/SKILL.md +27 -1
  12. package/dist/agent-src/skills/design-intelligence/data/font-pairings-reference.csv +74 -74
  13. package/dist/agent-src/skills/design-intelligence/data/manifest.json +2 -0
  14. package/dist/agent-src/skills/design-tokens/scripts/tokens.ts +0 -7
  15. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +15 -2
  16. package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
  17. package/dist/agent-src/skills/typography-system/SKILL.md +55 -11
  18. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +180 -0
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +34 -30
  21. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
  22. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +23 -0
  23. package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
  24. package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
  25. package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
  26. package/dist/agent-src/templates/scripts/work_engine/state.ts +21 -4
  27. package/dist/cli/initRouting.js +74 -11
  28. package/dist/cli/initRouting.js.map +1 -1
  29. package/dist/cli/main.js +14 -3
  30. package/dist/cli/main.js.map +1 -1
  31. package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
  32. package/dist/cli-delegate/chunk-736JCWLM.js +1104 -0
  33. package/dist/cli-delegate/chunk-7HQ52DHU.js +74 -0
  34. package/dist/cli-delegate/chunk-A3HJ6NTI.js +1084 -0
  35. package/dist/cli-delegate/chunk-EJYIGZ3T.js +125 -0
  36. package/dist/cli-delegate/chunk-FTAISGDQ.js +117 -0
  37. package/dist/cli-delegate/chunk-GME3Q44L.js +82 -0
  38. package/dist/cli-delegate/chunk-GRRPEPRN.js +336 -0
  39. package/dist/cli-delegate/chunk-HJ6DAJHR.js +502 -0
  40. package/dist/cli-delegate/chunk-HYWSRNUX.js +179 -0
  41. package/dist/cli-delegate/chunk-J55WI3RR.js +47 -0
  42. package/dist/cli-delegate/chunk-MCJYCQPF.js +35 -0
  43. package/dist/cli-delegate/chunk-NDJLNC6Q.js +169 -0
  44. package/dist/cli-delegate/chunk-OMIVVCG2.js +26 -0
  45. package/dist/cli-delegate/chunk-OY53J6II.js +7336 -0
  46. package/dist/cli-delegate/chunk-QFKQLQ7X.js +10835 -0
  47. package/dist/cli-delegate/chunk-TEGJU2JX.js +6908 -0
  48. package/dist/cli-delegate/chunk-TJIAM5FW.js +133 -0
  49. package/dist/cli-delegate/chunk-XX6TOPD7.js +715 -0
  50. package/dist/cli-delegate/cmd_analyze_session.js +294 -0
  51. package/dist/cli-delegate/cmd_conformance.js +643 -0
  52. package/dist/cli-delegate/cmd_converge.js +326 -0
  53. package/dist/cli-delegate/cmd_doctor.js +142 -0
  54. package/dist/cli-delegate/cmd_explain.js +2576 -0
  55. package/dist/cli-delegate/cmd_export.js +298 -0
  56. package/dist/cli-delegate/cmd_fleet.js +333 -0
  57. package/dist/cli-delegate/cmd_memory_get.js +3878 -0
  58. package/dist/cli-delegate/cmd_migrate.js +11 -0
  59. package/dist/cli-delegate/cmd_preflight.js +153 -0
  60. package/dist/cli-delegate/cmd_prune.js +446 -0
  61. package/dist/cli-delegate/cmd_refresh.js +28 -0
  62. package/dist/cli-delegate/cmd_settings_check.js +574 -0
  63. package/dist/cli-delegate/cmd_settings_migrate.js +225 -0
  64. package/dist/cli-delegate/cmd_sync.js +266 -0
  65. package/dist/cli-delegate/cmd_uninstall.js +657 -0
  66. package/dist/cli-delegate/cmd_update.js +425 -0
  67. package/dist/cli-delegate/cmd_upgrade.js +444 -0
  68. package/dist/cli-delegate/cmd_validate.js +307 -0
  69. package/dist/cli-delegate/cmd_versions.js +370 -0
  70. package/dist/discovery/deprecation-report.md +1 -1
  71. package/dist/discovery/discovery-manifest.json +45 -16
  72. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  73. package/dist/discovery/discovery-manifest.summary.md +3 -3
  74. package/dist/discovery/orphan-report.md +1 -1
  75. package/dist/discovery/packs.json +13 -7
  76. package/dist/discovery/trust-report.md +2 -2
  77. package/dist/discovery/workspaces.json +6 -4
  78. package/dist/install/emit_host_rules_cli.js +2 -1
  79. package/dist/install/emit_host_rules_cli.js.map +1 -1
  80. package/dist/install/install.mjs +634 -409
  81. package/dist/install/ruleInScope.js +123 -0
  82. package/dist/install/ruleInScope.js.map +1 -0
  83. package/dist/install/rule_scope.js +7 -3
  84. package/dist/install/rule_scope.js.map +1 -1
  85. package/dist/install/toolDetection.js +40 -12
  86. package/dist/install/toolDetection.js.map +1 -1
  87. package/dist/install/wizard-plan.js +12 -3
  88. package/dist/install/wizard-plan.js.map +1 -1
  89. package/dist/mcp/registry-manifest.json +3 -3
  90. package/dist/mcp/server.json +2 -2
  91. package/dist/mcp/server.mjs +1553 -2728
  92. package/dist/router.json +1 -1
  93. package/dist/scripts/condense.js +6 -22
  94. package/dist/scripts/condense.js.map +1 -1
  95. package/docs/guidelines/design-fidelity-mechanics.md +14 -0
  96. package/package.json +4 -2
  97. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +34 -30
  98. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
  99. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +23 -0
  100. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
  101. package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
  102. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
  103. package/src/agent-src/templates/scripts/work_engine/state.ts +21 -4
  104. package/src/config/discovery/packs.yml +7 -0
  105. package/src/config/evaluator-budgets.json +1 -1
  106. package/src/config/pack-size-budget.json +27 -0
  107. package/src/install/emit_host_rules_cli.ts +2 -1
  108. package/src/install/ruleInScope.ts +133 -0
  109. package/src/install/rule_scope.ts +7 -3
  110. package/src/install/toolDetection.ts +39 -8
  111. package/src/install/wizard-plan.ts +13 -3
  112. package/src/scripts/_cli/cmd_conformance.ts +2 -1
  113. package/src/scripts/_cli/cmd_converge.ts +2 -1
  114. package/src/scripts/_cli/cmd_doctor.ts +214 -13
  115. package/src/scripts/_cli/cmd_export.ts +2 -1
  116. package/src/scripts/_cli/cmd_fleet.ts +2 -1
  117. package/src/scripts/_cli/cmd_migrate.ts +2 -1
  118. package/src/scripts/_cli/cmd_preflight.ts +2 -1
  119. package/src/scripts/_cli/cmd_refresh.ts +2 -1
  120. package/src/scripts/_cli/cmd_update.ts +2 -1
  121. package/src/scripts/_cli/cmd_upgrade.ts +2 -1
  122. package/src/scripts/_cli/cmd_versions.ts +2 -1
  123. package/src/scripts/_cli/detection_report.ts +480 -0
  124. package/src/scripts/_dispatch.bash +52 -14
  125. package/src/scripts/_lib/changelog_eras.ts +103 -9
  126. package/src/scripts/_lib/environment_detector.ts +441 -0
  127. package/src/scripts/_lib/global_deploy_inventory.ts +38 -2
  128. package/src/scripts/_lib/package_root.ts +72 -0
  129. package/src/scripts/ai_council/config.ts +15 -7
  130. package/src/scripts/ai_council/modes.ts +68 -2
  131. package/src/scripts/ai_council/transport_resolver.ts +331 -0
  132. package/src/scripts/check_bundle_path_leakage.ts +10 -4
  133. package/src/scripts/check_dependency_floors.ts +153 -0
  134. package/src/scripts/check_installer_import_purity.ts +295 -0
  135. package/src/scripts/check_pack_size.ts +188 -0
  136. package/src/scripts/condense.ts +7 -28
  137. package/src/scripts/council_cli.ts +6 -2
  138. package/src/scripts/evaluator_umbrella.sh +34 -1
  139. package/src/scripts/install-hooks.sh +36 -4
  140. package/src/scripts/install.ts +210 -5
  141. package/src/scripts/lint_eval_fixture_citations.ts +113 -0
  142. package/src/scripts/lint_ui_stack_bundles.ts +201 -0
@@ -69,7 +69,7 @@ import {
69
69
  resolve_config_path,
70
70
  } from './ai_council/config.js';
71
71
  import { AuthCache, select_solo_member } from './ai_council/solo_dispatch.js';
72
- import { InvalidModeError, resolve_mode } from './ai_council/modes.js';
72
+ import { InvalidModeError, resolve_global_mode, resolve_mode } from './ai_council/modes.js';
73
73
  import { appendEvent } from './ai_council/events_log.js';
74
74
  import {
75
75
  type ClassificationResult,
@@ -564,7 +564,11 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
564
564
  );
565
565
  }
566
566
  const members_cfg = (ai['members'] as Dict) || {};
567
- const global_mode = ai['mode'] as string | null | undefined;
567
+ // Accepts BOTH the synthesized flat `mode` and the raw `defaults.mode`
568
+ // shape — see modes.ts::resolve_global_mode. Reading only the flat key
569
+ // dropped the configured default for every caller that hands this
570
+ // exported function a raw `.ai-council.yml` dict.
571
+ const global_mode = resolve_global_mode(ai);
568
572
  const cli_budget_cfg = _isDict(ai) ? ((ai['cli_call_budget'] as Dict) || {}) : {};
569
573
  const cli_caps = _isDict(cli_budget_cfg)
570
574
  ? ((cli_budget_cfg['max_calls_per_day'] as Dict) || {})
@@ -65,11 +65,38 @@ UNPACKED_BYTES="$(npm pack --dry-run --json --ignore-scripts --silent 2>/dev/nul
65
65
  step "headless consumer install from the tarball"
66
66
  WORK="$(mktemp -d)"
67
67
  trap 'rm -rf "$WORK"' EXIT
68
+ COLD_START_T0="$(node -e 'console.log(Date.now())')"
68
69
  ( cd "$WORK" && npm init -y >/dev/null 2>&1 \
69
70
  && npm install --omit=dev --no-audit --no-fund --silent "$ROOT/$TARBALL" )
70
71
  PKG_DIR="$WORK/node_modules/@event4u/agent-config"
71
72
  [[ -d "$PKG_DIR" ]] || { echo "❌ install did not produce the package dir" >&2; exit 1; }
72
73
 
74
+ # ------------------------------------------- 3b. cold start: install → doctor
75
+ # The bare-machine wall-clock a first-time user lives through, measured end to
76
+ # end from `npm install` to `doctor` having run. This is EVIDENCE, never a
77
+ # promise, and deliberately NOT a budget entry: it is dominated by network and
78
+ # registry latency, which are not ours to guarantee. A gate on it would flap on
79
+ # runner weather and teach the reader to ignore the line
80
+ # (road-to-zero-ceremony-install Phase 6).
81
+ step "cold start: install → doctor (evidence, not a gate)"
82
+ COLD_START_MS="$(node -e '
83
+ const {execFileSync}=require("node:child_process");
84
+ const [bin, t0] = process.argv.slice(1);
85
+ try { execFileSync(bin, ["doctor"], {stdio:"ignore"}); }
86
+ catch { /* doctor may report findings; the run still bounds the wall-clock */ }
87
+ console.log(Date.now() - Number(t0));' "$WORK/node_modules/.bin/agent-config" "$COLD_START_T0")"
88
+ COLD_START_CONDITIONS="$(uname -s)/$(uname -m) · node $(node --version) · npm $(npm --version) · npm install --omit=dev from a LOCAL tarball (no registry round-trip for the package itself; a real first-touch install adds registry latency)"
89
+ printf 'cold_start_install_to_doctor_ms=%s\n' "$COLD_START_MS"
90
+ printf 'cold_start_conditions=%s\n' "$COLD_START_CONDITIONS"
91
+ if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
92
+ {
93
+ printf '### Cold start — install → doctor\n\n'
94
+ printf '**%s ms**\n\n' "$COLD_START_MS"
95
+ printf 'Conditions: %s\n\n' "$COLD_START_CONDITIONS"
96
+ printf 'Evidence, not a promise — no shipped claim states this as a guarantee.\n'
97
+ } >> "$GITHUB_STEP_SUMMARY"
98
+ fi
99
+
73
100
  # --------------------------------------------------------- 4. staleness lints
74
101
  step "staleness lints (pre-migration refs)"
75
102
  ./scripts-run src/scripts/lint_pre_migration_refs
@@ -128,10 +155,16 @@ const m={
128
155
  mcp_boot_to_initialize_ms: Number(process.argv[6]),
129
156
  cli_help_command_count: Number(process.argv[7]),
130
157
  mcp_public_tool_count: Number(process.argv[8]),
158
+ // Evidence-only: carried in the artifact with its conditions, never gated.
159
+ // check_evaluator_budgets iterates the BUDGET entries, so an extra
160
+ // measurement is recorded and ignored by the gate — by design.
161
+ cold_start_install_to_doctor_ms: Number(process.argv[9]),
162
+ cold_start_conditions: process.argv[10],
131
163
  };
132
164
  fs.writeFileSync(process.argv[1], JSON.stringify(m, null, 2));
133
165
  console.log(JSON.stringify(m, null, 1));' \
134
- "$MEASURE_TMP" "$UNPACKED_BYTES" "$NM_MB" "$DEP_COUNT" "$CLI_MS" "$MCP_MS" "$CLI_COUNT" "$MCP_TOOLS"
166
+ "$MEASURE_TMP" "$UNPACKED_BYTES" "$NM_MB" "$DEP_COUNT" "$CLI_MS" "$MCP_MS" "$CLI_COUNT" "$MCP_TOOLS" \
167
+ "$COLD_START_MS" "$COLD_START_CONDITIONS"
135
168
 
136
169
  step "budget gate (absolute + >regression_pct creep)"
137
170
  ./scripts-run src/scripts/check_evaluator_budgets --measurements "$MEASURE_TMP"
@@ -51,6 +51,27 @@ else
51
51
  fi
52
52
  fi
53
53
 
54
+ # road-to-local-ci-trust Phase 2: the CI-only repo-content gates, as one task.
55
+ # Deliberately placed AFTER the consistency block above — `task consistency`
56
+ # runs `task sync`, which generates the `.augment/` projection. Several
57
+ # preflight gates read that tree, so on a FRESH worktree (this repo's standard
58
+ # workflow) running preflight first would fail with "produced by regeneration
59
+ # but absent before" — red for a reason the contributor did not cause, which is
60
+ # exactly the anti-pattern this gate set exists to avoid. Measured 15s against
61
+ # the 25s `pre_push_budget_seconds` ceiling in src/config/ci-local-parity.yml.
62
+ # `check_enforcement_coverage` (30.7s) stays out of preflight by design.
63
+ echo "🔍 Preflight — the CI-only repo-content gates..."
64
+ if [ "${AGENT_CONFIG_SKIP_PREPUSH_PREFLIGHT:-}" = "1" ]; then
65
+ echo "⏭️ skipped via AGENT_CONFIG_SKIP_PREPUSH_PREFLIGHT=1"
66
+ elif ! command -v task >/dev/null 2>&1; then
67
+ echo "⚠️ 'task' not found — skipping preflight for this push."
68
+ elif ! task preflight; then
69
+ echo "❌ Preflight failed. These gates run in CI too, so pushing now buys a"
70
+ echo " red run and a fixup re-push. Fix the failures above, or"
71
+ echo " AGENT_CONFIG_SKIP_PREPUSH_PREFLIGHT=1 for a genuine WIP push."
72
+ exit 1
73
+ fi
74
+
54
75
  echo "🔍 Checking for leftover conflict markers / unmerged paths..."
55
76
  if ! ./scripts-run src/scripts/check_no_conflict_markers --quiet; then
56
77
  echo "❌ Conflict markers or unmerged paths present. Resolve them (e.g. 'git checkout HEAD -- <file>' or finish the merge), then re-push."
@@ -127,11 +148,22 @@ if git diff --cached --name-only | grep -qE '^agents/roadmaps(-progress\.md|/)';
127
148
  ./scripts-run .augment/scripts/update_roadmap_progress --check
128
149
  rstatus=$?
129
150
  if [ $rstatus -ne 0 ]; then
151
+ # The check fails for THREE distinct reasons and prints the real one
152
+ # above: a stale dashboard, an unresolved `[~]` deferred item (Iron
153
+ # Law 3), or a completed-but-unarchived roadmap. Naming only the first
154
+ # sent a maintainer chasing a regeneration that was already correct —
155
+ # and a safety gate whose text points at the wrong cause trains people
156
+ # to distrust the gate, which costs more than the wrong message.
130
157
  echo ""
131
- echo "❌ Commit blocked — agents/roadmaps-progress.md is stale."
132
- echo " Run './agent-config roadmap:progress' (or"
133
- echo " './scripts-run .augment/scripts/update_roadmap_progress'),"
134
- echo " stage agents/roadmaps-progress.md, then re-commit."
158
+ echo "❌ Commit blocked — the roadmap dashboard gate failed."
159
+ echo " The cause is printed above. The three it reports:"
160
+ echo " · dashboard stale → './agent-config roadmap:progress',"
161
+ echo " then stage agents/roadmaps-progress.md"
162
+ echo " · Iron Law 3 → unresolved '[~]' deferred items. Surface"
163
+ echo " them and ask the user; NOT the agent's"
164
+ echo " to clear (roadmap-management § 4b)"
165
+ echo " · completed roadmap → 'git mv' it into agents/roadmaps/archive/,"
166
+ echo " then regenerate"
135
167
  echo " To bypass for an unrelated WIP commit: git commit --no-verify"
136
168
  exit 1
137
169
  fi
@@ -82,6 +82,13 @@ import * as user_global_paths from './_lib/user_global_paths.js';
82
82
  import * as claude_desktop_bundler from './_lib/claude_desktop_bundler.js';
83
83
  import * as claude_settings_hooks from './_lib/claude_settings_hooks.js';
84
84
  import { find_project_root_with_anchor, load_agent_settings } from './_lib/agent_settings.js';
85
+ import {
86
+ LEGACY_ALL,
87
+ ruleFileArrives,
88
+ ruleScopeFromSettings,
89
+ type RuleScope,
90
+ } from '../install/rule_scope.js';
91
+ import { RULE_SOURCE_REL } from '../install/wizard-plan.js';
85
92
  import { flattenSurface, computeSurfaceDelta, type SettingsSurface } from '../shared/settingsSurface.js';
86
93
  import { settingsSchema } from '../server/schemas/settings.js';
87
94
  import { zodToJsonSchema } from 'zod-to-json-schema';
@@ -2730,17 +2737,71 @@ function _inject_package_tag(
2730
2737
  }
2731
2738
  }
2732
2739
 
2740
+ /**
2741
+ * Would dereferencing `entry` escape the package root?
2742
+ *
2743
+ * `_copy_dir_dereferencing_symlinks` copies the REALPATH of every entry, so a
2744
+ * symlink in the shipped tree pointing outside the package would copy an
2745
+ * arbitrary readable file into the deploy destination — and those destinations
2746
+ * are agent-readable instruction dirs (`~/.claude/rules/`, `~/.codeium/…`). A
2747
+ * tampered tarball carrying `rules/x.md -> ~/.ssh/id_rsa` would land that file
2748
+ * where an agent reads rules.
2749
+ *
2750
+ * Defence-in-depth for `docs/threat-model.md` row b (supply-chain compromise):
2751
+ * the package is the install-time trust anchor, so this is not the primary
2752
+ * control — but the check is one comparison and `package_root` was already
2753
+ * threaded through, so there is no reason to ship without it.
2754
+ *
2755
+ * Both sides are realpath'd before comparing, so a macOS `/var` → `/private/var`
2756
+ * root does not read as an escape. A `null` package_root means the caller did
2757
+ * not scope the copy (several do) — unchanged, permissive behaviour there.
2758
+ */
2759
+ function _escapes_package_root(resolved: string, package_root: string | null): boolean {
2760
+ if (package_root === null) return false;
2761
+ let root: string;
2762
+ try {
2763
+ root = fs.realpathSync(package_root);
2764
+ } catch {
2765
+ // Unresolvable root — cannot judge containment, so do not block the copy.
2766
+ return false;
2767
+ }
2768
+ return !global_deploy_inventory.is_ancestor(root, resolved);
2769
+ }
2770
+
2771
+ /**
2772
+ * Copy `src` → `dest`, dereferencing symlinks, returning
2773
+ * `[written, skipped, written_paths]`.
2774
+ *
2775
+ * `file_filter` is an optional predicate over the SOURCE path; `false` means the
2776
+ * file is not part of this install and is neither copied nor counted. It MUST be
2777
+ * the same predicate handed to `global_deploy_inventory.expected_deploy_files`
2778
+ * for the same source — that function computes what the reaper considers
2779
+ * "should exist", so filtering one side only either leaves excluded files behind
2780
+ * forever or deletes files the copy just wrote.
2781
+ */
2733
2782
  function _copy_dir_dereferencing_symlinks(
2734
2783
  src: string,
2735
2784
  dest: string,
2736
2785
  force: boolean,
2737
2786
  package_root: string | null = null,
2787
+ file_filter: global_deploy_inventory.FileFilter | null = null,
2738
2788
  ): [number, number, string[]] {
2739
2789
  let written = 0;
2740
2790
  let skipped = 0;
2741
2791
  const written_paths: string[] = [];
2742
2792
  if (!pathExists(src)) return [0, 0, written_paths];
2743
2793
  if (!isDir(src)) {
2794
+ if (file_filter !== null && !file_filter(src)) return [0, 0, written_paths];
2795
+ let resolved_src = src;
2796
+ try {
2797
+ resolved_src = fs.realpathSync(src);
2798
+ } catch {
2799
+ resolved_src = src;
2800
+ }
2801
+ if (_escapes_package_root(resolved_src, package_root)) {
2802
+ warn(`refused: ${src} dereferences outside the package root — not copied`);
2803
+ return [0, 0, written_paths];
2804
+ }
2744
2805
  mkdirp(path.dirname(dest));
2745
2806
  const decision = _resolve_file_conflict(dest, force);
2746
2807
  if (decision === 'skip') return [0, 1, written_paths];
@@ -2784,6 +2845,13 @@ function _copy_dir_dereferencing_symlinks(
2784
2845
  } catch {
2785
2846
  resolvedIsDir = false;
2786
2847
  }
2848
+ // Containment is checked on the RESOLVED path, before either the
2849
+ // recursion or the copy — a symlinked directory escaping the root would
2850
+ // otherwise pull its whole subtree in.
2851
+ if (_escapes_package_root(resolved, package_root)) {
2852
+ warn(`refused: ${entry} dereferences outside the package root — not copied`);
2853
+ continue;
2854
+ }
2787
2855
  if (resolvedIsDir) {
2788
2856
  mkdirp(target);
2789
2857
  const [sub_w, sub_s, sub_p] = _copy_dir_dereferencing_symlinks(
@@ -2791,12 +2859,20 @@ function _copy_dir_dereferencing_symlinks(
2791
2859
  target,
2792
2860
  force,
2793
2861
  package_root,
2862
+ file_filter,
2794
2863
  );
2795
2864
  written += sub_w;
2796
2865
  skipped += sub_s;
2797
2866
  written_paths.push(...sub_p);
2798
2867
  continue;
2799
2868
  }
2869
+ // The predicate sees the SOURCE path (the rule-scope predicate reads the
2870
+ // file's own frontmatter tags). Skipped files are counted as neither
2871
+ // written nor skipped-as-identical: they are not part of this install,
2872
+ // so the inventory twin must omit them too or the reaper drifts.
2873
+ if (file_filter !== null && !file_filter(entry)) {
2874
+ continue;
2875
+ }
2800
2876
  const decision = _resolve_file_conflict(target, force);
2801
2877
  if (decision === 'skip') {
2802
2878
  skipped += 1;
@@ -2949,6 +3025,10 @@ function _deploy_global_content(
2949
3025
  lockfile_path: string,
2950
3026
  ): Record<string, DeployResult> {
2951
3027
  const results: Record<string, DeployResult> = {};
3028
+ // Resolved ONCE per deploy, not per tool: every tool installs the same rule
3029
+ // tree, and re-reading settings per tool could yield different answers
3030
+ // mid-run if the file changed underneath.
3031
+ const rule_scope = _resolve_global_rule_scope(package_root);
2952
3032
  for (const tool_id of [...tools].sort()) {
2953
3033
  if (tool_id === 'claude-desktop') {
2954
3034
  results[tool_id] = _deploy_claude_desktop(force, package_root, lockfile_path);
@@ -2975,13 +3055,30 @@ function _deploy_global_content(
2975
3055
  for (const [src_rel, dest_sub] of plan) {
2976
3056
  const src = path.join(package_root, src_rel);
2977
3057
  const dest = dest_sub ? path.join(anchor, dest_sub) : anchor;
2978
- const [w, s, paths] = _copy_dir_dereferencing_symlinks(src, dest, force, package_root);
3058
+ // ONE filter instance, handed to BOTH the copy and the inventory.
3059
+ // `expected_deploy_files` is what the reaper treats as "should
3060
+ // exist": filtering only the copy would leave previously-installed
3061
+ // maintainer rules on disk forever, and filtering only the
3062
+ // inventory would delete rules the copy just wrote. Both, or
3063
+ // neither — never one.
3064
+ const rule_filter = _rule_filter_for_source(src_rel, rule_scope);
3065
+ const [w, s, paths] = _copy_dir_dereferencing_symlinks(
3066
+ src,
3067
+ dest,
3068
+ force,
3069
+ package_root,
3070
+ rule_filter,
3071
+ );
2979
3072
  written_total += w;
2980
3073
  skipped_total += s;
2981
3074
  written_paths.push(...paths);
2982
3075
  current_files = setUnion(
2983
3076
  current_files,
2984
- global_deploy_inventory.expected_deploy_files(src, dest_sub ? dest_sub : ''),
3077
+ global_deploy_inventory.expected_deploy_files(
3078
+ src,
3079
+ dest_sub ? dest_sub : '',
3080
+ rule_filter,
3081
+ ),
2985
3082
  );
2986
3083
  }
2987
3084
  // Flat-command → skill-wrapper re-projection for Claude Code
@@ -3091,6 +3188,9 @@ function _preview_global_reap(
3091
3188
  ): Record<string, string[]> {
3092
3189
  const inventory = global_deploy_inventory.load_inventory();
3093
3190
  const preview: Record<string, string[]> = {};
3191
+ // The preview must compute the SAME expected set the real deploy will, or
3192
+ // `--dry-run` under-reports the rules a scoped upgrade is about to reap.
3193
+ const rule_scope = _resolve_global_rule_scope(package_root);
3094
3194
  for (const tool_id of [...tools].sort()) {
3095
3195
  const plan = GLOBAL_DEPLOY_SOURCES[tool_id];
3096
3196
  if (plan === undefined) continue;
@@ -3102,7 +3202,11 @@ function _preview_global_reap(
3102
3202
  const src = path.join(package_root, src_rel);
3103
3203
  current_files = setUnion(
3104
3204
  current_files,
3105
- global_deploy_inventory.expected_deploy_files(src, dest_sub ? dest_sub : ''),
3205
+ global_deploy_inventory.expected_deploy_files(
3206
+ src,
3207
+ dest_sub ? dest_sub : '',
3208
+ _rule_filter_for_source(src_rel, rule_scope),
3209
+ ),
3106
3210
  );
3107
3211
  }
3108
3212
  let would_reap: string[] = [];
@@ -3334,11 +3438,25 @@ function _compute_active_pack_ids(
3334
3438
  * artefact of any kind.
3335
3439
  */
3336
3440
  function _resolve_global_settings_doc(): Record<string, unknown> | null {
3441
+ const p = _resolve_global_settings_path();
3442
+ return p === null ? null : _load_yaml_doc(p);
3443
+ }
3444
+
3445
+ /**
3446
+ * The path `_resolve_global_settings_doc` would read, or `null` when no global
3447
+ * settings artefact exists at all.
3448
+ *
3449
+ * Split out because "absent" and "present but unparseable" are different facts
3450
+ * and one caller must tell them apart: `_load_yaml_doc` maps BOTH to `{}`, which
3451
+ * silently turns a YAML typo into legacy-all rule scoping — defeating a scoping
3452
+ * decision the user made, with no signal. See `_resolve_global_rule_scope`.
3453
+ */
3454
+ function _resolve_global_settings_path(): string | null {
3337
3455
  const root = user_global_paths.event4u_root();
3338
3456
  const canonical = path.join(root, 'settings', SETTINGS_FILE);
3339
- if (pathExists(canonical)) return _load_yaml_doc(canonical);
3457
+ if (pathExists(canonical)) return canonical;
3340
3458
  const legacy = path.join(root, SETTINGS_FILE);
3341
- if (pathExists(legacy)) return _load_yaml_doc(legacy);
3459
+ if (pathExists(legacy)) return legacy;
3342
3460
  return null;
3343
3461
  }
3344
3462
 
@@ -3369,6 +3487,84 @@ function _resolve_scoped_projection(
3369
3487
  return { mode, active_packs };
3370
3488
  }
3371
3489
 
3490
+ /**
3491
+ * Resolve the rule scope governing THIS global deploy — the same way the wizard
3492
+ * does (`src/server/routes/install.ts::_resolveRuleScope`), so the two global
3493
+ * paths cannot ship different rule sets for the same settings.
3494
+ *
3495
+ * Settings resolution mirrors `_resolve_scoped_projection` above: an existing
3496
+ * global settings doc is authoritative, and only a genuinely fresh machine falls
3497
+ * through to the packaged template. Any read or parse failure resolves to
3498
+ * `LEGACY_ALL` — over-shipping is the safe direction, and the compat exclusion
3499
+ * (`source-of-truth.md`) still applies even then.
3500
+ */
3501
+ function _resolve_global_rule_scope(package_root: string): RuleScope {
3502
+ const settings_path = _resolve_global_settings_path();
3503
+
3504
+ // No global settings artefact at all — a genuinely fresh machine. Fall
3505
+ // through to the packaged template silently: there is no user decision to
3506
+ // contradict, and this is the documented upgrade-compat path.
3507
+ if (settings_path === null) {
3508
+ try {
3509
+ return ruleScopeFromSettings(_load_default_settings(package_root));
3510
+ } catch {
3511
+ return LEGACY_ALL;
3512
+ }
3513
+ }
3514
+
3515
+ // A doc EXISTS, so the user has expressed a configuration. Failing to read
3516
+ // it is not the same as not having one: falling back to legacy-all here
3517
+ // ships the maintainer-only rules the user may have deliberately scoped out,
3518
+ // and `_load_yaml_doc` would report that as an indistinguishable `{}`. So
3519
+ // parse it explicitly and be LOUD when it does not parse — over-shipping
3520
+ // stays the safe direction, but it must not be a silent one.
3521
+ let text: string;
3522
+ try {
3523
+ text = readText(settings_path);
3524
+ } catch (e) {
3525
+ warn(
3526
+ `could not read ${settings_path} (${String(e)}) — rule scoping falls back ` +
3527
+ 'to legacy-all, so ALL rules including maintainer-only ones will be ' +
3528
+ 'installed. Fix the file to restore scoping.',
3529
+ );
3530
+ return LEGACY_ALL;
3531
+ }
3532
+ const parsed = yamlSafeLoad(text);
3533
+ if (!_isPlainObject(parsed)) {
3534
+ warn(
3535
+ `${settings_path} is not a YAML mapping — rule scoping falls back to ` +
3536
+ 'legacy-all, so ALL rules including maintainer-only ones will be ' +
3537
+ 'installed. Fix the file to restore scoping.',
3538
+ );
3539
+ return LEGACY_ALL;
3540
+ }
3541
+ try {
3542
+ return ruleScopeFromSettings(parsed as Record<string, unknown>);
3543
+ } catch (e) {
3544
+ warn(
3545
+ `could not derive rule scope from ${settings_path} (${String(e)}) — ` +
3546
+ 'falling back to legacy-all; ALL rules will be installed.',
3547
+ );
3548
+ return LEGACY_ALL;
3549
+ }
3550
+ }
3551
+
3552
+ /**
3553
+ * The rule-scope filter for one `GLOBAL_DEPLOY_SOURCES` row, or `null` when the
3554
+ * row is not the rules tree.
3555
+ *
3556
+ * Keyed on `src_rel === RULE_SOURCE_REL` exactly as `expandWizardSources` keys
3557
+ * it — the two must agree on WHICH source gets filtered, not only on the
3558
+ * predicate.
3559
+ */
3560
+ function _rule_filter_for_source(
3561
+ src_rel: string,
3562
+ scope: RuleScope,
3563
+ ): global_deploy_inventory.FileFilter | null {
3564
+ if (src_rel !== RULE_SOURCE_REL) return null;
3565
+ return (srcFile: string): boolean => ruleFileArrives(srcFile, scope);
3566
+ }
3567
+
3372
3568
  /**
3373
3569
  * Prune every skill/command artefact whose `packs:` frontmatter is
3374
3570
  * non-empty AND has no intersection with `active_ids`. Untagged artefacts
@@ -5035,5 +5231,14 @@ export {
5035
5231
  _compute_active_pack_ids,
5036
5232
  _resolve_global_settings_doc,
5037
5233
  _resolve_scoped_projection,
5234
+ // road-to-consistent-rule-scoping Phase 2: the CLI global deploy path had
5235
+ // no direct test — the existing rule-scoping suite covers the PLAN path
5236
+ // (`expandWizardSources`) only, which is why the two diverged unnoticed.
5237
+ _deploy_global_content,
5238
+ // road-to-local-ci-trust: exported for the symlink-confinement tests
5239
+ // (PR #1076 review-gate finding).
5240
+ _copy_dir_dereferencing_symlinks,
5241
+ _preview_global_reap,
5242
+ _resolve_global_rule_scope,
5038
5243
  };
5039
5244
  export type { Options, PackRecord, DeployResult };
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Every design-artifact eval fixture id must be cited by some surface.
4
+ *
5
+ * The fixtures file used to assert that the lifecycle contract's branch table
6
+ * "references these ids". It cites nine of them; the rest are gated elsewhere
7
+ * (the design-fidelity mechanics guideline, the lane-matrix test) — and a
8
+ * handful were cited by nothing at all. An uncited fixture is an eval nobody
9
+ * runs and no contract depends on, which is exactly the shape of coverage that
10
+ * looks real in a count and is absent in practice.
11
+ *
12
+ * This checks citation, not correctness: an id must appear outside its own
13
+ * definition. Which surface cites it is the author's call.
14
+ *
15
+ * Exit codes: 0 every id cited, 1 one or more uncited.
16
+ */
17
+
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const QUIET = process.argv.includes('--quiet');
23
+
24
+ const _HERE = fileURLToPath(import.meta.url);
25
+ const REPO = path.resolve(path.dirname(_HERE), '..', '..');
26
+ const FIXTURES = path.join(REPO, 'tests', 'design-artifacts', 'eval-fixtures.md');
27
+
28
+ /** Surfaces allowed to satisfy a citation. */
29
+ const SCAN_ROOTS: readonly string[] = [
30
+ path.join(REPO, 'docs', 'contracts'),
31
+ path.join(REPO, 'docs', 'guidelines'),
32
+ path.join(REPO, 'src', 'skills'),
33
+ path.join(REPO, 'src', 'rules'),
34
+ path.join(REPO, 'tests', 'scripts'),
35
+ ];
36
+
37
+ const SCAN_EXTS: ReadonlySet<string> = new Set(['.md', '.ts']);
38
+
39
+ function walk(dir: string, out: string[]): void {
40
+ let entries: fs.Dirent[];
41
+ try {
42
+ entries = fs.readdirSync(dir, { withFileTypes: true });
43
+ } catch {
44
+ return;
45
+ }
46
+ for (const entry of entries) {
47
+ const p = path.join(dir, entry.name);
48
+ if (entry.isDirectory()) {
49
+ walk(p, out);
50
+ } else if (SCAN_EXTS.has(path.extname(entry.name))) {
51
+ out.push(p);
52
+ }
53
+ }
54
+ }
55
+
56
+ function main(): number {
57
+ let fixtureText: string;
58
+ try {
59
+ fixtureText = fs.readFileSync(FIXTURES, { encoding: 'utf-8' });
60
+ } catch {
61
+ process.stderr.write(`lint-eval-fixture-citations: cannot read ${FIXTURES}\n`);
62
+ return 1;
63
+ }
64
+
65
+ // Ids are declared as `### daf-...` headings or as leading table cells.
66
+ const ids = new Set<string>();
67
+ for (const m of fixtureText.matchAll(/^###\s+(daf-[a-z0-9-]+)\s*$/gm)) {
68
+ ids.add(m[1] as string);
69
+ }
70
+ for (const m of fixtureText.matchAll(/^\|\s*`(daf-[a-z0-9-]+)`\s*\|/gm)) {
71
+ ids.add(m[1] as string);
72
+ }
73
+ if (ids.size === 0) {
74
+ process.stderr.write('lint-eval-fixture-citations: no fixture ids found — parser drift.\n');
75
+ return 1;
76
+ }
77
+
78
+ const files: string[] = [];
79
+ for (const root of SCAN_ROOTS) {
80
+ walk(root, files);
81
+ }
82
+ const haystack = files
83
+ .map((f) => {
84
+ try {
85
+ return fs.readFileSync(f, { encoding: 'utf-8' });
86
+ } catch {
87
+ return '';
88
+ }
89
+ })
90
+ .join('\n');
91
+
92
+ const uncited = [...ids].filter((id) => !haystack.includes(id)).sort();
93
+ if (uncited.length > 0) {
94
+ for (const id of uncited) {
95
+ process.stdout.write(
96
+ `lint-eval-fixture-citations: \`${id}\` is cited by no contract, ` +
97
+ 'guideline, skill, rule, or test — an eval nobody runs.\n',
98
+ );
99
+ }
100
+ process.stderr.write(
101
+ `lint-eval-fixture-citations: ${uncited.length} of ${ids.size} fixture id(s) uncited.\n`,
102
+ );
103
+ return 1;
104
+ }
105
+ if (!QUIET) {
106
+ process.stdout.write(
107
+ `lint-eval-fixture-citations: ${ids.size} fixture id(s), all cited.\n`,
108
+ );
109
+ }
110
+ return 0;
111
+ }
112
+
113
+ process.exit(main());