@event4u/agent-config 9.11.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 (77) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +52 -0
  3. package/README.md +8 -1
  4. package/dist/agent-src/commands/fix/ci.md +29 -0
  5. package/dist/agent-src/commands/pr/create.md +60 -0
  6. package/dist/agent-src/contexts/authority/kernel-rule-edits.md +18 -2
  7. package/dist/agent-src/rules/design-fidelity.md +1 -1
  8. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +1 -0
  9. package/dist/agent-src/skills/design-intelligence/SKILL.md +27 -1
  10. package/dist/agent-src/skills/design-intelligence/data/font-pairings-reference.csv +74 -74
  11. package/dist/agent-src/skills/design-intelligence/data/manifest.json +2 -0
  12. package/dist/agent-src/skills/design-tokens/scripts/tokens.ts +0 -7
  13. package/dist/agent-src/skills/typography-system/SKILL.md +55 -11
  14. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +180 -0
  15. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  16. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +9 -5
  17. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +9 -6
  18. package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +9 -5
  19. package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +182 -29
  20. package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +311 -24
  21. package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
  22. package/dist/cli-delegate/{chunk-IR7P7JWA.js → chunk-A3HJ6NTI.js} +271 -59
  23. package/dist/cli-delegate/chunk-EJYIGZ3T.js +125 -0
  24. package/dist/cli-delegate/{chunk-RDGRHAAB.js → chunk-HYWSRNUX.js} +6 -86
  25. package/dist/cli-delegate/{chunk-YMFO6FDN.js → chunk-QFKQLQ7X.js} +313 -81
  26. package/dist/cli-delegate/{chunk-4SV3XGFA.js → chunk-TEGJU2JX.js} +7 -9
  27. package/dist/cli-delegate/chunk-TJIAM5FW.js +133 -0
  28. package/dist/cli-delegate/cmd_conformance.js +4 -7
  29. package/dist/cli-delegate/cmd_doctor.js +3 -5
  30. package/dist/cli-delegate/cmd_explain.js +153 -85
  31. package/dist/cli-delegate/cmd_export.js +5 -3
  32. package/dist/cli-delegate/cmd_fleet.js +2 -1
  33. package/dist/cli-delegate/cmd_preflight.js +6 -123
  34. package/dist/cli-delegate/cmd_prune.js +5 -3
  35. package/dist/cli-delegate/cmd_settings_migrate.js +5 -3
  36. package/dist/cli-delegate/cmd_sync.js +5 -3
  37. package/dist/cli-delegate/cmd_uninstall.js +6 -5
  38. package/dist/cli-delegate/cmd_validate.js +5 -3
  39. package/dist/discovery/deprecation-report.md +1 -1
  40. package/dist/discovery/discovery-manifest.json +36 -14
  41. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  42. package/dist/discovery/discovery-manifest.summary.md +3 -3
  43. package/dist/discovery/orphan-report.md +1 -1
  44. package/dist/discovery/packs.json +6 -5
  45. package/dist/discovery/trust-report.md +2 -2
  46. package/dist/discovery/workspaces.json +5 -4
  47. package/dist/install/install.mjs +605 -412
  48. package/dist/install/ruleInScope.js +123 -0
  49. package/dist/install/ruleInScope.js.map +1 -0
  50. package/dist/install/rule_scope.js +2 -2
  51. package/dist/install/rule_scope.js.map +1 -1
  52. package/dist/install/wizard-plan.js +10 -1
  53. package/dist/install/wizard-plan.js.map +1 -1
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +405 -2429
  57. package/dist/scripts/condense.js +6 -22
  58. package/dist/scripts/condense.js.map +1 -1
  59. package/docs/guidelines/design-fidelity-mechanics.md +14 -0
  60. package/package.json +1 -1
  61. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +9 -5
  62. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +9 -6
  63. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +9 -5
  64. package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +182 -29
  65. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +311 -24
  66. package/src/install/ruleInScope.ts +133 -0
  67. package/src/install/rule_scope.ts +2 -2
  68. package/src/install/wizard-plan.ts +11 -1
  69. package/src/scripts/_lib/global_deploy_inventory.ts +38 -2
  70. package/src/scripts/check_installer_import_purity.ts +295 -0
  71. package/src/scripts/condense.ts +7 -28
  72. package/src/scripts/install-hooks.sh +36 -4
  73. package/src/scripts/install.ts +210 -5
  74. package/src/scripts/lint_ui_stack_bundles.ts +31 -0
  75. package/dist/cli-delegate/chunk-7DSAMZFU.js +0 -356
  76. package/dist/cli-delegate/chunk-BJGXZNPM.js +0 -266
  77. package/dist/cli-delegate/chunk-TFG57JJM.js +0 -1969
@@ -122,6 +122,49 @@ const _UNMODELLED_MARKERS: ReadonlyArray<string> = [
122
122
  */
123
123
  type Manifest = { [key: string]: unknown };
124
124
 
125
+ /**
126
+ * The independent axes a frontend stack actually varies along.
127
+ *
128
+ * A flat label forces all-or-nothing matches: `livewire ∧ flux` was never the
129
+ * bug, it was the symptom of collapsing three axes into one enum value. The
130
+ * measured proof is Nuxt — `nuxt` is on the unmodelled-marker list, but the
131
+ * project is labelled `vue` because Vue is a Nuxt dependency and matched first.
132
+ * No ordering of one list can express "Nuxt implies Vue but is not Vue".
133
+ *
134
+ * Axes are **additive**: the flat `frontend` label keeps its existing
135
+ * computation untouched, so the eight shipped labels are byte-stable by
136
+ * construction rather than by test. Dispatch composition reads the axes.
137
+ *
138
+ * `'none'` means "this axis is genuinely absent" (a Blade app has no JS
139
+ * component library); `'unknown'` means "not recognised", which is a different
140
+ * fact and must not be conflated with absence.
141
+ */
142
+ export interface StackAxes {
143
+ /** Template / view layer — what markup is authored in. */
144
+ readonly view: string;
145
+ /** Reactivity layer — what drives updates. */
146
+ readonly reactivity: string;
147
+ /** Component library on top of the reactivity layer. */
148
+ readonly component_lib: string;
149
+ /** Styling system. */
150
+ readonly css: string;
151
+ /**
152
+ * Meta-framework wrapping the reactivity layer (Nuxt over Vue, Next over
153
+ * React, Astro over anything). Its own axis precisely because it does not
154
+ * replace the layer underneath — which is what the flat label got wrong.
155
+ */
156
+ readonly meta: string;
157
+ }
158
+
159
+ /** Every axis unresolved — the shape for a project with no signals at all. */
160
+ const _EMPTY_AXES: StackAxes = {
161
+ view: 'none',
162
+ reactivity: 'none',
163
+ component_lib: 'none',
164
+ css: 'none',
165
+ meta: 'none',
166
+ };
167
+
125
168
  /**
126
169
  * Outcome of one detection pass.
127
170
  *
@@ -138,10 +181,28 @@ type Manifest = { [key: string]: unknown };
138
181
  export class StackResult {
139
182
  readonly frontend: string;
140
183
  readonly mtime: number;
141
-
142
- constructor(args: { frontend: string; mtime: number }) {
184
+ /** Multi-axis view of the same detection. See {@link StackAxes}. */
185
+ readonly axes: StackAxes;
186
+ /**
187
+ * Conflicting signals on one axis, e.g. `['reactivity: react + vue']`.
188
+ *
189
+ * Non-empty means the project is genuinely two things and the detector
190
+ * refuses to pick. Guessing a priority order is the worse property for a
191
+ * global package: asking costs one turn, a wrong silent pick costs the
192
+ * whole run.
193
+ */
194
+ readonly ambiguity: ReadonlyArray<string>;
195
+
196
+ constructor(args: {
197
+ frontend: string;
198
+ mtime: number;
199
+ axes?: StackAxes;
200
+ ambiguity?: ReadonlyArray<string>;
201
+ }) {
143
202
  this.frontend = args.frontend;
144
203
  this.mtime = args.mtime;
204
+ this.axes = args.axes ?? _EMPTY_AXES;
205
+ this.ambiguity = args.ambiguity ?? [];
145
206
  }
146
207
  }
147
208
 
@@ -164,34 +225,50 @@ export function detect_stack(project_root: string): StackResult {
164
225
  const components_json = _is_file(path.join(project_root, 'components.json'));
165
226
  const mtime = latest_manifest_mtime(project_root);
166
227
 
228
+ // Axes are computed once and attached to every result below. The label
229
+ // chain that follows is UNCHANGED — that is deliberate, so the eight
230
+ // shipped labels stay byte-stable by construction rather than by test.
231
+ // Composition reads `axes`; only the ambiguity guard alters a label.
232
+ const axes = _detect_axes(composer, pkg, components_json, project_root);
233
+ const ambiguity = _detect_ambiguity(composer, pkg);
234
+ const _r = (frontend: string): StackResult =>
235
+ new StackResult({ frontend, mtime, axes, ambiguity });
236
+
237
+ // Conflicting signals on one axis: refuse rather than let the priority
238
+ // chain below silently pick a winner. Measured baseline: react + vue in
239
+ // one manifest resolved to `react` with no warning at all.
240
+ if (ambiguity.length > 0) {
241
+ return _r(UNSUPPORTED_STACK);
242
+ }
243
+
167
244
  if (_is_blade_livewire_flux(composer)) {
168
- return new StackResult({ frontend: 'blade-livewire-flux', mtime });
245
+ return _r('blade-livewire-flux');
169
246
  }
170
247
 
171
248
  // Filament before bare Livewire: it pulls Livewire in transitively, so a
172
249
  // Filament project would otherwise be labelled by its dependency rather
173
250
  // than by the framework the developer actually works in.
174
251
  if (_has_package(composer, ['require', 'require-dev'], _FILAMENT_PACKAGE)) {
175
- return new StackResult({ frontend: 'filament', mtime });
252
+ return _r('filament');
176
253
  }
177
254
 
178
255
  // Livewire WITHOUT Flux is the common Laravel frontend, not an unknown
179
256
  // one. It used to fail the `&&` above and land in `plain`.
180
257
  if (_has_package(composer, ['require', 'require-dev'], _LIVEWIRE_PACKAGE)) {
181
- return new StackResult({ frontend: 'blade-livewire', mtime });
258
+ return _r('blade-livewire');
182
259
  }
183
260
 
184
261
  if (_is_react_shadcn(pkg, components_json)) {
185
- return new StackResult({ frontend: 'react-shadcn', mtime });
262
+ return _r('react-shadcn');
186
263
  }
187
264
 
188
265
  // React without Radix/shadcn is a served stack, not an unknown one.
189
266
  if (_has_package(pkg, _PKG_DEP_KEYS, 'react')) {
190
- return new StackResult({ frontend: 'react', mtime });
267
+ return _r('react');
191
268
  }
192
269
 
193
270
  if (_has_vue(pkg)) {
194
- return new StackResult({ frontend: 'vue', mtime });
271
+ return _r('vue');
195
272
  }
196
273
 
197
274
  // A recognised-but-unmodelled framework is NOT `plain`. Refusing here is
@@ -199,7 +276,7 @@ export function detect_stack(project_root: string): StackResult {
199
276
  // is what let a Svelte or Inertia project receive Tailwind-only tooling
200
277
  // and no warning.
201
278
  if (_has_unmodelled_marker(composer, pkg)) {
202
- return new StackResult({ frontend: UNSUPPORTED_STACK, mtime });
279
+ return _r(UNSUPPORTED_STACK);
203
280
  }
204
281
 
205
282
  // Monorepo: no manifest at the root, but one sits in a workspace directory.
@@ -209,11 +286,36 @@ export function detect_stack(project_root: string): StackResult {
209
286
  //
210
287
  // A repo with no manifest ANYWHERE stays `plain`: that is greenfield, and
211
288
  // the scaffold path depends on it.
212
- if (mtime === 0.0 && _has_nested_manifest(project_root)) {
213
- return new StackResult({ frontend: UNSUPPORTED_STACK, mtime });
289
+ if (mtime === 0.0) {
290
+ const roots = _nested_frontend_roots(project_root);
291
+ if (roots.length === 1) {
292
+ // Unambiguous scope: descend once and keep the workspace's own
293
+ // axes. Refusing here would punish the common monorepo shape for a
294
+ // scope decision the layout already makes.
295
+ const inner = detect_stack(roots[0] as string);
296
+ return new StackResult({
297
+ frontend: inner.frontend,
298
+ mtime: inner.mtime,
299
+ axes: inner.axes,
300
+ ambiguity: inner.ambiguity,
301
+ });
302
+ }
303
+ if (roots.length > 1) {
304
+ // Several frontend roots: the caller owns scope selection, so name
305
+ // them and let the step ask. Picking the first would be the silent
306
+ // guess this detector exists to avoid.
307
+ return new StackResult({
308
+ frontend: UNSUPPORTED_STACK,
309
+ mtime,
310
+ axes,
311
+ ambiguity: [
312
+ `workspace roots: ${roots.map((r) => path.basename(r)).join(' + ')}`,
313
+ ],
314
+ });
315
+ }
214
316
  }
215
317
 
216
- return new StackResult({ frontend: DEFAULT_STACK, mtime });
318
+ return _r(DEFAULT_STACK);
217
319
  }
218
320
 
219
321
  /**
@@ -226,9 +328,8 @@ export function detect_stack(project_root: string): StackResult {
226
328
  * sentinel rather than a missing-file error.
227
329
  */
228
330
  export function latest_manifest_mtime(project_root: string): number {
229
- const candidates = ['composer.json', 'package.json'];
230
331
  const mtimes: number[] = [];
231
- for (const name of candidates) {
332
+ for (const name of _CACHE_KEY_FILES) {
232
333
  const p = path.join(project_root, name);
233
334
  if (_is_file(p)) {
234
335
  mtimes.push(_stat_mtime(p));
@@ -237,6 +338,26 @@ export function latest_manifest_mtime(project_root: string): number {
237
338
  return mtimes.length > 0 ? Math.max(...mtimes) : 0.0;
238
339
  }
239
340
 
341
+ /**
342
+ * Files whose mtime invalidates a cached detection.
343
+ *
344
+ * The two manifests are not sufficient: the signal table also reads marker
345
+ * files, so adding `components.json` or a `nuxt.config.ts` changes the detected
346
+ * axes while leaving both manifests untouched — and the cache would have served
347
+ * the pre-marker answer indefinitely.
348
+ *
349
+ * `components.json` is listed first so a shadcn adoption, the most common of
350
+ * these, is picked up.
351
+ */
352
+ const _CACHE_KEY_FILES: ReadonlyArray<string> = [
353
+ 'composer.json',
354
+ 'package.json',
355
+ 'components.json',
356
+ 'nuxt.config.ts',
357
+ 'nuxt.config.js',
358
+ 'astro.config.mjs',
359
+ ];
360
+
240
361
  /**
241
362
  * Read a JSON manifest, returning `{}` on any error.
242
363
  *
@@ -298,18 +419,181 @@ function _has_package(
298
419
  return name in _all_dependencies(manifest, ...sections);
299
420
  }
300
421
 
422
+ /**
423
+ * Signal table, one entry per axis value.
424
+ *
425
+ * `first match wins` applies **per axis only** — collapsing that across axes is
426
+ * the original defect. A Nuxt project matches `meta: nuxt` AND
427
+ * `reactivity: vue`; both are true, and the flat label could hold only one.
428
+ *
429
+ * Order within an axis is specificity-descending: a marker file beats a bare
430
+ * dependency, and a wrapper is listed before what it wraps.
431
+ */
432
+ const _AXIS_SIGNALS: ReadonlyArray<{
433
+ readonly axis: keyof StackAxes;
434
+ readonly value: string;
435
+ readonly npm?: ReadonlyArray<string>;
436
+ readonly npm_prefix?: ReadonlyArray<string>;
437
+ readonly composer?: ReadonlyArray<string>;
438
+ readonly files?: ReadonlyArray<string>;
439
+ }> = [
440
+ // meta — wrappers, checked before the layer they wrap
441
+ { axis: 'meta', value: 'nextjs', npm: ['next'] },
442
+ { axis: 'meta', value: 'nuxt', npm: ['nuxt'], files: ['nuxt.config.ts', 'nuxt.config.js'] },
443
+ { axis: 'meta', value: 'astro', npm: ['astro'], files: ['astro.config.mjs'] },
444
+ { axis: 'meta', value: 'remix', npm: ['@remix-run/react'] },
445
+ { axis: 'meta', value: 'sveltekit', npm: ['@sveltejs/kit'] },
446
+ { axis: 'meta', value: 'filament', composer: ['filament/filament'] },
447
+
448
+ // reactivity
449
+ { axis: 'reactivity', value: 'react', npm: ['react'] },
450
+ { axis: 'reactivity', value: 'vue', npm: ['vue'] },
451
+ { axis: 'reactivity', value: 'svelte', npm: ['svelte'] },
452
+ { axis: 'reactivity', value: 'angular', npm: ['@angular/core'] },
453
+ { axis: 'reactivity', value: 'solid', npm: ['solid-js'] },
454
+ { axis: 'reactivity', value: 'qwik', npm: ['@builder.io/qwik'] },
455
+ { axis: 'reactivity', value: 'livewire', composer: ['livewire/livewire'] },
456
+ { axis: 'reactivity', value: 'alpine', npm: ['alpinejs'] },
457
+ { axis: 'reactivity', value: 'htmx', npm: ['htmx.org'] },
458
+
459
+ // view
460
+ { axis: 'view', value: 'svelte-sfc', npm: ['svelte'] },
461
+ { axis: 'view', value: 'vue-sfc', npm: ['vue'] },
462
+ // Angular templates are HTML with its own directive syntax, not JSX.
463
+ // Listing it under `jsx` was wrong and would have handed a React idiom to
464
+ // an Angular project through the Phase-3 composition.
465
+ { axis: 'view', value: 'angular-html', npm: ['@angular/core'] },
466
+ { axis: 'view', value: 'astro', npm: ['astro'] },
467
+ { axis: 'view', value: 'jsx', npm: ['react'] },
468
+ { axis: 'view', value: 'blade', composer: ['laravel/framework'] },
469
+
470
+ // component_lib — marker file beats dependency
471
+ { axis: 'component_lib', value: 'shadcn', npm: ['shadcn-ui', 'shadcn'], files: ['components.json'] },
472
+ { axis: 'component_lib', value: 'radix', npm_prefix: ['@radix-ui/'] },
473
+ { axis: 'component_lib', value: 'flux', composer: ['livewire/flux'] },
474
+ { axis: 'component_lib', value: 'nuxt-ui', npm: ['@nuxt/ui'] },
475
+ { axis: 'component_lib', value: 'vuetify', npm: ['vuetify'] },
476
+ { axis: 'component_lib', value: 'mui', npm: ['@mui/material'] },
477
+
478
+ // css
479
+ { axis: 'css', value: 'tailwind', npm: ['tailwindcss'] },
480
+ { axis: 'css', value: 'bootstrap', npm: ['bootstrap'] },
481
+ ];
482
+
483
+ /** Axes where two signals are a genuine conflict rather than a stack. */
484
+ const _AMBIGUOUS_AXES: ReadonlyArray<keyof StackAxes> = ['reactivity'];
485
+
486
+ /**
487
+ * Client-side SPA frameworks — two of these in one manifest is a real conflict.
488
+ *
489
+ * Everything else on the reactivity axis co-exists legitimately: Alpine and htmx
490
+ * are progressive-enhancement layers, and Livewire is server-driven, so
491
+ * Laravel+Livewire with a React widget is an ordinary stack, not an ambiguity.
492
+ * Treating any two reactivity signals as a conflict refused those projects —
493
+ * caught by the existing blade-wins-over-react precedence test.
494
+ */
495
+ const _EXCLUSIVE_REACTIVITY: ReadonlySet<string> = new Set([
496
+ 'react',
497
+ 'vue',
498
+ 'svelte',
499
+ 'angular',
500
+ 'solid',
501
+ 'qwik',
502
+ ]);
503
+
504
+ /** Resolve every axis independently from the manifests plus marker files. */
505
+ function _detect_axes(
506
+ composer: Manifest,
507
+ pkg: Manifest,
508
+ components_json: boolean,
509
+ project_root: string,
510
+ ): StackAxes {
511
+ const npm = _all_dependencies(pkg, ..._PKG_DEP_KEYS);
512
+ const php = _all_dependencies(composer, 'require', 'require-dev');
513
+ const npm_names = Object.keys(npm);
514
+ const out: Record<string, string> = { ..._EMPTY_AXES };
515
+ const decided = new Set<string>();
516
+
517
+ for (const sig of _AXIS_SIGNALS) {
518
+ if (decided.has(sig.axis)) continue;
519
+ const hit =
520
+ (sig.npm ?? []).some((n) => n in npm) ||
521
+ (sig.npm_prefix ?? []).some((p) => npm_names.some((n) => n.startsWith(p))) ||
522
+ (sig.composer ?? []).some((n) => n in php) ||
523
+ (sig.files ?? []).some(
524
+ (f) =>
525
+ (f === 'components.json' && components_json) ||
526
+ _is_file(path.join(project_root, f)),
527
+ );
528
+ if (hit) {
529
+ out[sig.axis] = sig.value;
530
+ decided.add(sig.axis);
531
+ }
532
+ }
533
+
534
+ // An unresolved axis on a project that HAS manifests is "not recognised" —
535
+ // a different fact from "absent", and conflating them is what made a
536
+ // refusal indistinguishable from a genuinely plain project.
537
+ const has_manifest = npm_names.length > 0 || Object.keys(php).length > 0;
538
+ if (has_manifest) {
539
+ for (const axis of ['view', 'reactivity'] as const) {
540
+ if (!decided.has(axis)) out[axis] = 'unknown';
541
+ }
542
+ }
543
+ return out as unknown as StackAxes;
544
+ }
545
+
546
+ /** Report axes carrying two mutually exclusive signals. */
547
+ function _detect_ambiguity(composer: Manifest, pkg: Manifest): string[] {
548
+ const npm = _all_dependencies(pkg, ..._PKG_DEP_KEYS);
549
+ const php = _all_dependencies(composer, 'require', 'require-dev');
550
+ const found: string[] = [];
551
+ for (const axis of _AMBIGUOUS_AXES) {
552
+ const hits = _AXIS_SIGNALS.filter(
553
+ (sig) =>
554
+ sig.axis === axis &&
555
+ ((sig.npm ?? []).some((n) => n in npm) ||
556
+ (sig.composer ?? []).some((n) => n in php)),
557
+ )
558
+ .map((sig) => sig.value)
559
+ .filter((v) => _EXCLUSIVE_REACTIVITY.has(v));
560
+ if (hits.length > 1) {
561
+ found.push(`${axis}: ${hits.join(' + ')}`);
562
+ }
563
+ }
564
+ return found;
565
+ }
566
+
301
567
  /** Workspace directories a monorepo conventionally puts its packages in. */
302
568
  const _WORKSPACE_DIRS: ReadonlyArray<string> = ['packages', 'apps', 'services', 'libs'];
303
569
 
304
570
  /**
305
- * True when no root manifest exists but a workspace directory holds one.
571
+ * Return the workspace roots that carry a manifest, sorted.
306
572
  *
307
- * Deliberately shallow — one `readdir` per conventional workspace directory,
308
- * one `stat` per child. It answers "was this called at the wrong scope?", not
309
- * "where is the real project", which stays the caller's job.
573
+ * Reads the declarative `workspaces` field when present and falls back to the
574
+ * conventional directories. Deliberately shallow — one `readdir` per candidate
575
+ * directory, one `stat` per child.
576
+ *
577
+ * Empty means "not a monorepo". Exactly one means the scope is unambiguous and
578
+ * detection can descend into it. More than one is a question for the caller,
579
+ * not a guess for the detector.
310
580
  */
311
- function _has_nested_manifest(project_root: string): boolean {
312
- for (const dir of _WORKSPACE_DIRS) {
581
+ function _nested_frontend_roots(project_root: string): string[] {
582
+ const roots: string[] = [];
583
+ const seen = new Set<string>();
584
+ // `workspaces` is the declarative answer when present; the conventional
585
+ // directories are the fallback for repos that do not declare them.
586
+ const root_pkg = _read_json(path.join(project_root, 'package.json'));
587
+ const declared = root_pkg['workspaces'];
588
+ const globs: string[] = Array.isArray(declared)
589
+ ? (declared as unknown[]).filter((g): g is string => typeof g === 'string')
590
+ : [];
591
+ const dirs = new Set<string>(_WORKSPACE_DIRS);
592
+ for (const g of globs) {
593
+ const head = g.split('/')[0];
594
+ if (head !== undefined && head !== '' && !head.includes('*')) dirs.add(head);
595
+ }
596
+ for (const dir of dirs) {
313
597
  const base = path.join(project_root, dir);
314
598
  let entries: string[];
315
599
  try {
@@ -318,15 +602,18 @@ function _has_nested_manifest(project_root: string): boolean {
318
602
  continue;
319
603
  }
320
604
  for (const entry of entries) {
605
+ const child = path.join(base, entry);
606
+ if (seen.has(child)) continue;
321
607
  if (
322
- _is_file(path.join(base, entry, 'composer.json')) ||
323
- _is_file(path.join(base, entry, 'package.json'))
608
+ _is_file(path.join(child, 'package.json')) ||
609
+ _is_file(path.join(child, 'composer.json'))
324
610
  ) {
325
- return true;
611
+ seen.add(child);
612
+ roots.push(child);
326
613
  }
327
614
  }
328
615
  }
329
- return false;
616
+ return roots.sort();
330
617
  }
331
618
 
332
619
  /** True when either manifest names a framework we recognise but do not model. */
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The rule-scoping predicate — the ONE decision both pipelines consume.
3
+ *
4
+ * ## Why this module exists (and must stay side-effect-free)
5
+ *
6
+ * `rule_in_scope` used to live in `src/scripts/condense.ts`, and
7
+ * `src/install/rule_scope.ts` imported it from there deliberately, so install
8
+ * semantics could never drift from projection semantics. That intent is right;
9
+ * the location was not.
10
+ *
11
+ * `condense.ts` is maintainer-only CLI tooling and ends in a module-level
12
+ * self-invoke:
13
+ *
14
+ * const isMain = _isCliEntry();
15
+ * if (isMain) { process.exit(main()); }
16
+ *
17
+ * `_isCliEntry()` compares `import.meta.url` against `process.argv[1]`. That is
18
+ * correct for a file run directly — but when esbuild BUNDLES the module into
19
+ * `dist/install/install.mjs`, `import.meta.url` becomes the bundle's own URL,
20
+ * which *is* `argv[1]` when a consumer runs `node dist/install/install.mjs`.
21
+ * The guard then fires inside the installer, `condense.main()` runs, finds no
22
+ * maintainer source tree in a consumer checkout, prints "No source directory
23
+ * found" and calls `process.exit(1)` — killing the install.
24
+ *
25
+ * That is not hypothetical: it reached CI on all four
26
+ * `smoke-public-install` legs the moment the installer began importing
27
+ * `rule_scope.ts` (road-to-consistent-rule-scoping, 2026-07-31).
28
+ *
29
+ * So the predicate lives here instead: **no I/O at module load, no CLI entry,
30
+ * no `process.exit`, and nothing that assumes a maintainer source tree.**
31
+ * `condense.ts` re-exports it for its four in-repo consumers; the install path
32
+ * imports it directly and never pulls the projection pipeline along.
33
+ *
34
+ * Lives under `src/install/` rather than `src/shared/` because it reads the file
35
+ * it classifies: an ESLint boundary keeps `src/shared/**` free of Node built-ins,
36
+ * and the install layer is where credentialled filesystem work already belongs.
37
+ *
38
+ * Keep it that way. Anything added to this file ships inside the consumer
39
+ * installer bundle.
40
+ */
41
+
42
+ import * as fs from 'node:fs';
43
+
44
+ import * as YAML from 'yaml';
45
+
46
+ /** `yaml.safe_load` semantics: parse failure and `undefined` both → `null`. */
47
+ function parseYaml(text: string): unknown {
48
+ try {
49
+ const data = YAML.parse(text, { version: '1.1' });
50
+ return data === undefined ? null : data;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** Python `str.strip()` — whitespace from both ends, Unicode-aware. */
57
+ function strip(s: string): string {
58
+ return s.replace(/^\s+/u, '').replace(/\s+$/u, '');
59
+ }
60
+
61
+ /** Python `str.lstrip("\n")` — leading newlines only. */
62
+ function lstripNewlines(s: string): string {
63
+ return s.replace(/^\n+/, '');
64
+ }
65
+
66
+ /**
67
+ * Split `---`-delimited frontmatter from the body.
68
+ *
69
+ * Returns `[{}, content]` for anything that is not well-formed frontmatter —
70
+ * an absent block, an unterminated block, or a scalar/sequence document. The
71
+ * caller then sees an untagged artefact, which every axis below fails safe on.
72
+ */
73
+ export function parseFrontmatter(content: string): [Record<string, unknown>, string] {
74
+ if (!content.startsWith('---')) {
75
+ return [{}, content];
76
+ }
77
+ const end = content.indexOf('\n---', 3);
78
+ if (end === -1) {
79
+ return [{}, content];
80
+ }
81
+ const raw = strip(content.slice(3, end));
82
+ const body = lstripNewlines(content.slice(end + 4));
83
+ let meta: unknown = parseYaml(raw);
84
+ if (meta === null || meta === undefined) {
85
+ meta = {};
86
+ }
87
+ if (typeof meta === 'object' && meta !== null && !Array.isArray(meta)) {
88
+ return [meta as Record<string, unknown>, body];
89
+ }
90
+ return [{}, body];
91
+ }
92
+
93
+ /**
94
+ * Does the rule at `source_path` project / install under the given scope?
95
+ *
96
+ * Three independent axes — `workspaces`, `packs`, `roles`. A `null` axis means
97
+ * "unset", i.e. that axis does not filter. Two fail-safe rules make the
98
+ * predicate over-ship rather than under-ship:
99
+ *
100
+ * - a KERNEL rule (`type: always` / `alwaysApply: true`) always ships;
101
+ * - an artefact carrying NO tags on a configured axis ships anyway — an
102
+ * untagged rule is unclassified, not excluded.
103
+ *
104
+ * Reads the file. Never throws for a missing frontmatter block; a genuinely
105
+ * unreadable path is the caller's problem (both call sites pre-check).
106
+ */
107
+ export function rule_in_scope(
108
+ source_path: string,
109
+ scope: readonly string[] | null,
110
+ pack_scope: readonly string[] | null = null,
111
+ role_scope: readonly string[] | null = null,
112
+ ): boolean {
113
+ if (scope === null && pack_scope === null && role_scope === null) {
114
+ return true;
115
+ }
116
+ const [meta] = parseFrontmatter(fs.readFileSync(source_path, 'utf-8'));
117
+ if (meta['type'] === 'always' || meta['alwaysApply'] === true) {
118
+ return true; // kernel always projects
119
+ }
120
+ const axis = (key: string, configured: readonly string[] | null): boolean => {
121
+ if (configured === null) {
122
+ return true;
123
+ }
124
+ const values = Array.isArray(meta[key])
125
+ ? (meta[key] as unknown[]).map((w) => String(w))
126
+ : [];
127
+ if (values.length === 0) {
128
+ return true; // untagged → fail safe: ship it
129
+ }
130
+ return values.some((v) => configured.includes(v));
131
+ };
132
+ return axis('workspaces', scope) && axis('packs', pack_scope) && axis('roles', role_scope);
133
+ }
@@ -6,7 +6,7 @@
6
6
  * Pipeline B (which is the `.augment/` projection). Earlier drafts called it
7
7
  * "Pipeline B" and collided with that name; use "the consumer install" here.
8
8
  *
9
- * The projection filter (`rule_in_scope`, condense.ts) covered only the
9
+ * The projection filter (`rule_in_scope`, now `shared/ruleInScope.ts`) covered only the
10
10
  * maintainer projection; the consumer install pipeline shipped rules
11
11
  * unfiltered (dead `EXCLUDE_RULES` in install.sh; no exclude at all on the
12
12
  * global payload). This module is the ONE scoping surface both install
@@ -32,7 +32,7 @@
32
32
  import * as fs from 'node:fs';
33
33
  import * as path from 'node:path';
34
34
 
35
- import { rule_in_scope } from '../scripts/condense.js';
35
+ import { rule_in_scope } from './ruleInScope.js';
36
36
 
37
37
  /**
38
38
  * Rules excluded from EVERY consumer install path regardless of scope —
@@ -91,6 +91,16 @@ const CLAUDE_SKILL_BUNDLE: ReadonlyArray<readonly [string, string]> = [
91
91
  * — they either follow a marker-only convention (handled by bridges in
92
92
  * Phase A6) or have no markdown convention at all.
93
93
  */
94
+ /**
95
+ * The one deploy-source row that carries install-time rule scoping.
96
+ *
97
+ * Exported so the CLI global path (`scripts/install.ts::_deploy_global_content`)
98
+ * keys its filter off the SAME string this expander does. Two paths agreeing on
99
+ * the predicate but disagreeing on which source it applies to would reproduce
100
+ * exactly the divergence rule scoping exists to prevent.
101
+ */
102
+ export const RULE_SOURCE_REL = 'dist/agent-src/rules';
103
+
94
104
  export const GLOBAL_DEPLOY_SOURCES: Readonly<Record<string, ReadonlyArray<readonly [string, string]>>> = {
95
105
  'claude-code': CLAUDE_SKILL_BUNDLE,
96
106
  augment: [
@@ -195,7 +205,7 @@ export function expandWizardSources(inputs: WizardExpandInputs): PlanSource[] {
195
205
  // Rule sources get the install-time scoping filter — the same
196
206
  // predicate the projection path uses (Phase 1b: the consumer
197
207
  // install may never drift from Pipeline A, the source projection).
198
- const isRuleSource = srcRel === 'dist/agent-src/rules';
208
+ const isRuleSource = srcRel === RULE_SOURCE_REL;
199
209
  out.push({
200
210
  toolId,
201
211
  srcDir,