@event4u/agent-config 9.10.0 → 9.11.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 (121) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +59 -0
  3. package/README.md +12 -15
  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/rules/design-fidelity.md +1 -1
  7. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +15 -2
  8. package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
  9. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  10. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +30 -30
  11. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
  12. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +20 -0
  13. package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +20 -1
  14. package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +179 -0
  15. package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +155 -4
  16. package/dist/agent-src/templates/scripts/work_engine/state.ts +21 -4
  17. package/dist/cli/initRouting.js +74 -11
  18. package/dist/cli/initRouting.js.map +1 -1
  19. package/dist/cli/main.js +14 -3
  20. package/dist/cli/main.js.map +1 -1
  21. package/dist/cli-delegate/chunk-4SV3XGFA.js +6910 -0
  22. package/dist/cli-delegate/chunk-736JCWLM.js +1104 -0
  23. package/dist/cli-delegate/chunk-7DSAMZFU.js +356 -0
  24. package/dist/cli-delegate/chunk-7HQ52DHU.js +74 -0
  25. package/dist/cli-delegate/chunk-BJGXZNPM.js +266 -0
  26. package/dist/cli-delegate/chunk-FTAISGDQ.js +117 -0
  27. package/dist/cli-delegate/chunk-GME3Q44L.js +82 -0
  28. package/dist/cli-delegate/chunk-GRRPEPRN.js +336 -0
  29. package/dist/cli-delegate/chunk-HJ6DAJHR.js +502 -0
  30. package/dist/cli-delegate/chunk-IR7P7JWA.js +872 -0
  31. package/dist/cli-delegate/chunk-J55WI3RR.js +47 -0
  32. package/dist/cli-delegate/chunk-MCJYCQPF.js +35 -0
  33. package/dist/cli-delegate/chunk-NDJLNC6Q.js +169 -0
  34. package/dist/cli-delegate/chunk-OMIVVCG2.js +26 -0
  35. package/dist/cli-delegate/chunk-OY53J6II.js +7336 -0
  36. package/dist/cli-delegate/chunk-RDGRHAAB.js +259 -0
  37. package/dist/cli-delegate/chunk-TFG57JJM.js +1969 -0
  38. package/dist/cli-delegate/chunk-XX6TOPD7.js +715 -0
  39. package/dist/cli-delegate/chunk-YMFO6FDN.js +10603 -0
  40. package/dist/cli-delegate/cmd_analyze_session.js +294 -0
  41. package/dist/cli-delegate/cmd_conformance.js +646 -0
  42. package/dist/cli-delegate/cmd_converge.js +326 -0
  43. package/dist/cli-delegate/cmd_doctor.js +144 -0
  44. package/dist/cli-delegate/cmd_explain.js +2508 -0
  45. package/dist/cli-delegate/cmd_export.js +296 -0
  46. package/dist/cli-delegate/cmd_fleet.js +332 -0
  47. package/dist/cli-delegate/cmd_memory_get.js +3878 -0
  48. package/dist/cli-delegate/cmd_migrate.js +11 -0
  49. package/dist/cli-delegate/cmd_preflight.js +270 -0
  50. package/dist/cli-delegate/cmd_prune.js +444 -0
  51. package/dist/cli-delegate/cmd_refresh.js +28 -0
  52. package/dist/cli-delegate/cmd_settings_check.js +574 -0
  53. package/dist/cli-delegate/cmd_settings_migrate.js +223 -0
  54. package/dist/cli-delegate/cmd_sync.js +264 -0
  55. package/dist/cli-delegate/cmd_uninstall.js +656 -0
  56. package/dist/cli-delegate/cmd_update.js +425 -0
  57. package/dist/cli-delegate/cmd_upgrade.js +444 -0
  58. package/dist/cli-delegate/cmd_validate.js +305 -0
  59. package/dist/cli-delegate/cmd_versions.js +370 -0
  60. package/dist/discovery/deprecation-report.md +1 -1
  61. package/dist/discovery/discovery-manifest.json +14 -7
  62. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  63. package/dist/discovery/discovery-manifest.summary.md +2 -2
  64. package/dist/discovery/orphan-report.md +1 -1
  65. package/dist/discovery/packs.json +12 -7
  66. package/dist/discovery/trust-report.md +1 -1
  67. package/dist/discovery/workspaces.json +4 -3
  68. package/dist/install/emit_host_rules_cli.js +2 -1
  69. package/dist/install/emit_host_rules_cli.js.map +1 -1
  70. package/dist/install/install.mjs +410 -378
  71. package/dist/install/rule_scope.js +5 -1
  72. package/dist/install/rule_scope.js.map +1 -1
  73. package/dist/install/toolDetection.js +40 -12
  74. package/dist/install/toolDetection.js.map +1 -1
  75. package/dist/install/wizard-plan.js +2 -2
  76. package/dist/install/wizard-plan.js.map +1 -1
  77. package/dist/mcp/registry-manifest.json +1 -1
  78. package/dist/mcp/server.json +2 -2
  79. package/dist/mcp/server.mjs +1614 -765
  80. package/dist/router.json +1 -1
  81. package/package.json +4 -2
  82. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +30 -30
  83. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
  84. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +20 -0
  85. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +20 -1
  86. package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +179 -0
  87. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +155 -4
  88. package/src/agent-src/templates/scripts/work_engine/state.ts +21 -4
  89. package/src/config/discovery/packs.yml +7 -0
  90. package/src/config/evaluator-budgets.json +1 -1
  91. package/src/config/pack-size-budget.json +27 -0
  92. package/src/install/emit_host_rules_cli.ts +2 -1
  93. package/src/install/rule_scope.ts +5 -1
  94. package/src/install/toolDetection.ts +39 -8
  95. package/src/install/wizard-plan.ts +2 -2
  96. package/src/scripts/_cli/cmd_conformance.ts +2 -1
  97. package/src/scripts/_cli/cmd_converge.ts +2 -1
  98. package/src/scripts/_cli/cmd_doctor.ts +214 -13
  99. package/src/scripts/_cli/cmd_export.ts +2 -1
  100. package/src/scripts/_cli/cmd_fleet.ts +2 -1
  101. package/src/scripts/_cli/cmd_migrate.ts +2 -1
  102. package/src/scripts/_cli/cmd_preflight.ts +2 -1
  103. package/src/scripts/_cli/cmd_refresh.ts +2 -1
  104. package/src/scripts/_cli/cmd_update.ts +2 -1
  105. package/src/scripts/_cli/cmd_upgrade.ts +2 -1
  106. package/src/scripts/_cli/cmd_versions.ts +2 -1
  107. package/src/scripts/_cli/detection_report.ts +480 -0
  108. package/src/scripts/_dispatch.bash +52 -14
  109. package/src/scripts/_lib/changelog_eras.ts +103 -9
  110. package/src/scripts/_lib/environment_detector.ts +441 -0
  111. package/src/scripts/_lib/package_root.ts +72 -0
  112. package/src/scripts/ai_council/config.ts +15 -7
  113. package/src/scripts/ai_council/modes.ts +68 -2
  114. package/src/scripts/ai_council/transport_resolver.ts +331 -0
  115. package/src/scripts/check_bundle_path_leakage.ts +10 -4
  116. package/src/scripts/check_dependency_floors.ts +153 -0
  117. package/src/scripts/check_pack_size.ts +188 -0
  118. package/src/scripts/council_cli.ts +6 -2
  119. package/src/scripts/evaluator_umbrella.sh +34 -1
  120. package/src/scripts/lint_eval_fixture_citations.ts +113 -0
  121. package/src/scripts/lint_ui_stack_bundles.ts +170 -0
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * check_pack_size — enforce the published-tarball payload budget
4
+ * (road-to-zero-ceremony-install Phase 4).
5
+ *
6
+ * Two metrics, both ungated before this script existed:
7
+ * 1. COMPRESSED tarball size — absolute max, plus the >regression_pct creep
8
+ * rule (fails even under the absolute budget, so slow rot cannot hide
9
+ * under a generous cap).
10
+ * 2. PER-SKILL share of the skills subtree — no single skill may silently
11
+ * reclaim space freed elsewhere. Named exceptions carry a reason.
12
+ *
13
+ * The UNPACKED size is deliberately NOT re-gated here: it is already owned by
14
+ * `evaluator-budgets.unpacked_size_mb`, measured at release time on the BUILT
15
+ * artifact. Two gates for one lever is how conflicting numbers start.
16
+ *
17
+ * Usage:
18
+ * ./scripts-run src/scripts/check_pack_size [--json]
19
+ * ./scripts-run src/scripts/check_pack_size --pack-json <file> # test/offline
20
+ *
21
+ * Exit codes: 0 green · 1 over budget · 2 misuse / unreadable input.
22
+ */
23
+
24
+ import { execFileSync } from 'node:child_process';
25
+ import * as fs from 'node:fs';
26
+ import * as path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..');
30
+ const BUDGET_PATH = path.join(REPO_ROOT, 'src', 'config', 'pack-size-budget.json');
31
+ const SKILL_PREFIX = 'dist/agent-src/skills/';
32
+
33
+ export interface PackFile {
34
+ path: string;
35
+ size: number;
36
+ }
37
+
38
+ export interface PackResult {
39
+ size: number;
40
+ unpackedSize: number;
41
+ files: PackFile[];
42
+ }
43
+
44
+ export interface PackSizeBudget {
45
+ regression_pct: number;
46
+ budgets: Record<string, { max: number; last_measured: number; method: string }>;
47
+ per_skill_share: {
48
+ max_pct: number;
49
+ basis: string;
50
+ rationale: string;
51
+ exceptions: Record<string, { max_pct: number; measured_pct: number; reason: string }>;
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Parse `npm pack --json` stdout. npm runs the `prepare` lifecycle script even
57
+ * under `--ignore-scripts` on some versions, and this repo's `prepare` prints a
58
+ * banner — straight into the stream we parse. Slice from the first `[` so a
59
+ * lifecycle banner is tolerated instead of becoming a SyntaxError.
60
+ */
61
+ export function parsePackJson(stdout: string): PackResult {
62
+ const start = stdout.indexOf('[');
63
+ const parsed = JSON.parse(start >= 0 ? stdout.slice(start) : stdout) as PackResult[];
64
+ const first = parsed[0];
65
+ if (first === undefined) throw new Error('npm pack --json returned an empty array');
66
+ return first;
67
+ }
68
+
69
+ /** Bytes per skill under `dist/agent-src/skills/`, plus the subtree total. */
70
+ export function skillBytes(files: readonly PackFile[]): { perSkill: Record<string, number>; total: number } {
71
+ const perSkill: Record<string, number> = {};
72
+ let total = 0;
73
+ for (const file of files) {
74
+ if (!file.path.startsWith(SKILL_PREFIX)) continue;
75
+ const name = file.path.slice(SKILL_PREFIX.length).split('/')[0];
76
+ if (name === undefined || name === '') continue;
77
+ perSkill[name] = (perSkill[name] ?? 0) + file.size;
78
+ total += file.size;
79
+ }
80
+ return { perSkill, total };
81
+ }
82
+
83
+ /** Every violation, as human-readable lines. Empty array means green. */
84
+ export function evaluate(budget: PackSizeBudget, pack: PackResult): string[] {
85
+ const errors: string[] = [];
86
+
87
+ const packedMb = pack.size / 1e6;
88
+ const entry = budget.budgets['packed_size_mb'];
89
+ if (entry === undefined) {
90
+ errors.push('packed_size_mb: missing from the budget file — gate would pass vacuously');
91
+ } else if (packedMb > entry.max) {
92
+ errors.push(`packed_size_mb: measured ${packedMb.toFixed(3)} exceeds budget ${entry.max}`);
93
+ } else {
94
+ const pct = budget.regression_pct;
95
+ const ceiling = entry.last_measured * (1 + pct / 100);
96
+ if (entry.last_measured > 0 && packedMb > ceiling) {
97
+ errors.push(
98
+ `packed_size_mb: measured ${packedMb.toFixed(3)} regressed >${pct}% vs ` +
99
+ `last_measured ${entry.last_measured} (ceiling ${ceiling.toFixed(3)}) — ` +
100
+ 'fails even under the absolute budget',
101
+ );
102
+ }
103
+ }
104
+
105
+ const { perSkill, total } = skillBytes(pack.files);
106
+ if (total === 0) {
107
+ errors.push(`per_skill_share: no files under ${SKILL_PREFIX} — gate would pass vacuously`);
108
+ return errors;
109
+ }
110
+ const { max_pct: defaultMax, exceptions } = budget.per_skill_share;
111
+ for (const [name, bytes] of Object.entries(perSkill)) {
112
+ const share = (bytes / total) * 100;
113
+ const exception = exceptions[name];
114
+ const cap = exception?.max_pct ?? defaultMax;
115
+ if (share > cap) {
116
+ errors.push(
117
+ exception === undefined
118
+ ? `per_skill_share: ${name} is ${share.toFixed(2)}% of the skills payload, over the ${cap}% cap — ` +
119
+ 'shrink it, or add a named exception with a reason'
120
+ : `per_skill_share: ${name} is ${share.toFixed(2)}%, over its own exception cap of ${cap}% — ` +
121
+ 'the exception is not a blank cheque; shrink it or raise the cap with a reason',
122
+ );
123
+ }
124
+ }
125
+ for (const name of Object.keys(exceptions)) {
126
+ if (!(name in perSkill)) {
127
+ errors.push(`per_skill_share: exception for '${name}' is stale — no such skill ships`);
128
+ }
129
+ }
130
+ return errors;
131
+ }
132
+
133
+ function readPack(argv: readonly string[]): PackResult {
134
+ const idx = argv.indexOf('--pack-json');
135
+ if (idx >= 0) {
136
+ const file = argv[idx + 1];
137
+ if (file === undefined) throw new Error('--pack-json needs a path');
138
+ return parsePackJson(fs.readFileSync(file, 'utf-8'));
139
+ }
140
+ const stdout = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts', '--silent'], {
141
+ cwd: REPO_ROOT,
142
+ encoding: 'utf-8',
143
+ maxBuffer: 64 * 1024 * 1024,
144
+ stdio: ['ignore', 'pipe', 'ignore'],
145
+ });
146
+ return parsePackJson(stdout);
147
+ }
148
+
149
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
150
+ let budget: PackSizeBudget;
151
+ let pack: PackResult;
152
+ try {
153
+ budget = JSON.parse(fs.readFileSync(BUDGET_PATH, 'utf-8')) as PackSizeBudget;
154
+ } catch (err) {
155
+ process.stderr.write(`❌ pack size: cannot read ${BUDGET_PATH}: ${String(err)}\n`);
156
+ return 2;
157
+ }
158
+ try {
159
+ pack = readPack(argv);
160
+ } catch (err) {
161
+ process.stderr.write(`❌ pack size: npm pack failed: ${String(err)}\n`);
162
+ return 2;
163
+ }
164
+
165
+ const errors = evaluate(budget, pack);
166
+ const { perSkill, total } = skillBytes(pack.files);
167
+ if (argv.includes('--json')) {
168
+ process.stdout.write(
169
+ `${JSON.stringify({ packed_mb: pack.size / 1e6, skills_total_bytes: total, skills: Object.keys(perSkill).length, errors }, null, 2)}\n`,
170
+ );
171
+ return errors.length > 0 ? 1 : 0;
172
+ }
173
+ if (errors.length > 0) {
174
+ for (const e of errors) process.stderr.write(`❌ pack size: ${e}\n`);
175
+ return 1;
176
+ }
177
+ process.stdout.write(
178
+ `✅ pack size within budget — ${(pack.size / 1e6).toFixed(3)} MB packed, ` +
179
+ `${Object.keys(perSkill).length} skills, largest share ` +
180
+ `${Math.max(...Object.values(perSkill).map((b) => (b / total) * 100)).toFixed(2)}%\n`,
181
+ );
182
+ return 0;
183
+ }
184
+
185
+ const _selfPath = fileURLToPath(import.meta.url);
186
+ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(_selfPath)) {
187
+ process.exit(main());
188
+ }
@@ -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"
@@ -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());
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Lint the UI-track verb → skill-bundle map.
4
+ *
5
+ * The stack-dispatched UI steps halt with a directive verb
6
+ * (`ui-apply-<stack>` and friends). Those verbs are agent-interpreted, not
7
+ * skill paths — only 2 of the engine's 11 literal verbs name a real skill — so
8
+ * the mapping from verb to the skills that implement it is a separate fact.
9
+ * It used to live only in a prose table in `docs/contracts/ui-track-flow.md`,
10
+ * unverified, and two of its four rows were wrong: `vue` named the verb itself
11
+ * (no skill at all) and `plain` — the fallback for every unrecognised project —
12
+ * named `blade-ui`, a `packs: [laravel]` skill that a non-Laravel consumer does
13
+ * not have installed.
14
+ *
15
+ * Two assertions, both decidable in this repo:
16
+ *
17
+ * 1. RESOLUTION — every bundle member is a real skill (`src/skills/<n>/SKILL.md`).
18
+ * 2. PACK REACH — a lane declared `pack_agnostic` names only skills that ship
19
+ * in a stack-neutral pack. This is the assertion that actually matters: CI
20
+ * here cannot see a consumer's installed pack combination, so "the skill
21
+ * exists in our tree" would have passed the `plain → blade-ui` defect
22
+ * unchanged.
23
+ *
24
+ * Exit codes: 0 clean, 1 one or more findings.
25
+ */
26
+
27
+ import * as fs from 'node:fs';
28
+ import * as path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ import {
32
+ STACK_BUNDLES,
33
+ type StackBundle,
34
+ } from '../agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.js';
35
+
36
+ const QUIET = process.argv.includes('--quiet');
37
+
38
+ const _HERE = fileURLToPath(import.meta.url);
39
+ const REPO = path.resolve(path.dirname(_HERE), '..', '..');
40
+ const SKILLS_DIR = path.join(REPO, 'src', 'skills');
41
+
42
+ /**
43
+ * Packs that ship regardless of the project's framework. A `pack_agnostic`
44
+ * lane may only draw on these — anything else is unavailable to some consumer.
45
+ */
46
+ const STACK_NEUTRAL_PACKS: ReadonlySet<string> = new Set(['engineering-base', 'core', 'meta']);
47
+
48
+ const findings: string[] = [];
49
+
50
+ function skill_path(name: string): string {
51
+ return path.join(SKILLS_DIR, name, 'SKILL.md');
52
+ }
53
+
54
+ /** Return the `packs:` list from a skill's frontmatter, or `null` if absent. */
55
+ function packs_of(name: string): string[] | null {
56
+ let text: string;
57
+ try {
58
+ text = fs.readFileSync(skill_path(name), { encoding: 'utf-8' });
59
+ } catch {
60
+ return null;
61
+ }
62
+ // Frontmatter only — a `packs:` mention in the body is not a declaration.
63
+ const end = text.indexOf('\n---', 4);
64
+ const front = end === -1 ? text : text.slice(0, end);
65
+ // `[ \t]*`, not `\s*`: `\s` matches a newline, so the greedy form swallowed
66
+ // the line break and captured the first block-list item as if it were an
67
+ // inline value — which made every block-list skill parse as "no packs".
68
+ const match = /^packs:[ \t]*(.*)$/m.exec(front);
69
+ if (match === null) return null;
70
+ const inline = (match[1] ?? '').trim();
71
+ if (inline.startsWith('[')) {
72
+ return inline
73
+ .replace(/^\[|\]$/g, '')
74
+ .split(',')
75
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
76
+ .filter((s) => s !== '');
77
+ }
78
+ // Block list: consume the ` - name` lines that follow. `match[0]` stops
79
+ // before the newline, so the first split element is the empty tail of the
80
+ // matched line itself — dropping it is required, not cosmetic: treating it
81
+ // as a non-item ended the loop immediately and made every block-list skill
82
+ // parse as "no packs", which is how this linter first shipped green against
83
+ // the very defect it exists to catch.
84
+ const rest = front.slice(match.index + match[0].length).split('\n').slice(1);
85
+ const out: string[] = [];
86
+ for (const line of rest) {
87
+ const item = /^\s*-\s*(.+?)\s*$/.exec(line);
88
+ if (item === null) break;
89
+ out.push((item[1] ?? '').replace(/^['"]|['"]$/g, ''));
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function check_lane(lane: string, bundle: StackBundle): void {
95
+ const members = [...bundle.build, ...bundle.review];
96
+ if (members.length === 0) {
97
+ findings.push(`${lane}: bundle is empty — a lane must name at least one skill.`);
98
+ return;
99
+ }
100
+ for (const name of new Set(members)) {
101
+ // 1. RESOLUTION
102
+ if (!fs.existsSync(skill_path(name))) {
103
+ findings.push(
104
+ `${lane}: bundle member \`${name}\` has no ` +
105
+ `\`src/skills/${name}/SKILL.md\` — the verb would resolve to nothing.`,
106
+ );
107
+ continue;
108
+ }
109
+ // 2. PACK REACH
110
+ if (!bundle.pack_agnostic) continue;
111
+ const packs = packs_of(name);
112
+ if (packs === null) {
113
+ findings.push(
114
+ `${lane}: bundle member \`${name}\` declares no \`packs:\` — ` +
115
+ 'a pack-agnostic lane cannot verify it ships everywhere.',
116
+ );
117
+ continue;
118
+ }
119
+ const offenders = packs.filter((p) => !STACK_NEUTRAL_PACKS.has(p));
120
+ if (offenders.length > 0) {
121
+ findings.push(
122
+ `${lane}: lane is \`pack_agnostic\` but member \`${name}\` ships only in ` +
123
+ `[${offenders.join(', ')}] — a consumer without that pack gets nothing. ` +
124
+ 'This is the `plain → blade-ui` defect class.',
125
+ );
126
+ }
127
+ }
128
+ }
129
+
130
+ function main(): number {
131
+ const lanes = Object.keys(STACK_BUNDLES).sort();
132
+ if (lanes.length === 0) {
133
+ process.stderr.write('lint-ui-stack-bundles: STACK_BUNDLES is empty — nothing to check.\n');
134
+ return 1;
135
+ }
136
+ for (const lane of lanes) {
137
+ check_lane(lane, STACK_BUNDLES[lane] as StackBundle);
138
+ }
139
+
140
+ // A lane usable as a fallback must be pack-agnostic, else the fallback
141
+ // itself is the thing that fails for an unpredictable consumer.
142
+ const plain = STACK_BUNDLES['plain'];
143
+ if (plain === undefined) {
144
+ findings.push('plain: missing — it is the documented DEFAULT_DIRECTIVE lane.');
145
+ } else if (!plain.pack_agnostic) {
146
+ findings.push(
147
+ 'plain: the fallback lane must be `pack_agnostic` — every unrecognised ' +
148
+ 'project lands here and its packs are unknown.',
149
+ );
150
+ }
151
+
152
+ if (findings.length > 0) {
153
+ for (const f of findings) {
154
+ process.stdout.write(`lint-ui-stack-bundles: ${f}\n`);
155
+ }
156
+ process.stderr.write(
157
+ `lint-ui-stack-bundles: ${findings.length} finding(s) across ${lanes.length} lane(s).\n`,
158
+ );
159
+ return 1;
160
+ }
161
+ if (!QUIET) {
162
+ process.stdout.write(
163
+ `lint-ui-stack-bundles: ${lanes.length} lane(s) clean — every bundle member ` +
164
+ 'resolves, and every pack-agnostic lane stays stack-neutral.\n',
165
+ );
166
+ }
167
+ return 0;
168
+ }
169
+
170
+ process.exit(main());