@event4u/agent-config 9.35.0 → 9.36.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 (78) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +61 -165
  3. package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
  4. package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
  5. package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
  6. package/dist/agent-src/rules/improve-before-implement.md +1 -1
  7. package/dist/agent-src/rules/ui-audit-gate.md +35 -0
  8. package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
  9. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  10. package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
  11. package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
  12. package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
  13. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  14. package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
  15. package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
  16. package/dist/agent-src/templates/AGENTS.md +1 -1
  17. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  18. package/dist/cli-delegate/{chunk-ZAXH3ZWU.js → chunk-JP53LNVT.js} +9 -0
  19. package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
  20. package/dist/cli-delegate/{chunk-H6F2CQFI.js → chunk-NR7HSN3P.js} +2 -2
  21. package/dist/cli-delegate/{chunk-2TGMGLBL.js → chunk-SFGARMAB.js} +11 -1
  22. package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-U7CMAIOC.js} +7 -2
  23. package/dist/cli-delegate/cmd_conformance.js +3 -3
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_export.js +3 -3
  26. package/dist/cli-delegate/cmd_migrate.js +1 -1
  27. package/dist/cli-delegate/cmd_prune.js +3 -3
  28. package/dist/cli-delegate/cmd_refresh.js +1 -1
  29. package/dist/cli-delegate/cmd_session_recycle.js +8 -1
  30. package/dist/cli-delegate/cmd_settings_migrate.js +3 -3
  31. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  32. package/dist/cli-delegate/cmd_sync.js +3 -3
  33. package/dist/cli-delegate/cmd_uninstall.js +3 -3
  34. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  35. package/dist/cli-delegate/cmd_validate.js +3 -3
  36. package/dist/discovery/deprecation-report.md +1 -1
  37. package/dist/discovery/discovery-manifest.json +12 -12
  38. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  39. package/dist/discovery/discovery-manifest.summary.md +1 -1
  40. package/dist/discovery/dormancy-report.md +1 -1
  41. package/dist/discovery/orphan-report.md +1 -1
  42. package/dist/discovery/packs.json +2 -2
  43. package/dist/discovery/trust-report.md +1 -1
  44. package/dist/discovery/workspaces.json +2 -2
  45. package/dist/hooks/dispatch.js +530 -323
  46. package/dist/install/install.mjs +18 -4
  47. package/dist/mcp/registry-manifest.json +1 -1
  48. package/dist/mcp/server.json +2 -2
  49. package/dist/mcp/server.mjs +25 -5
  50. package/dist/router.json +1 -1
  51. package/dist/server/schemas/settings.js +5 -0
  52. package/dist/server/schemas/settings.js.map +1 -1
  53. package/docs/contracts/settings-classes.md +4 -2
  54. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
  55. package/package.json +2 -2
  56. package/src/agent-src/templates/AGENTS.md +1 -1
  57. package/src/config/agent-settings.template.yml +32 -0
  58. package/src/config/gate-coverage.yml +12 -0
  59. package/src/scripts/_cli/cmd_doctor.ts +32 -0
  60. package/src/scripts/_cli/cmd_migrate.ts +26 -3
  61. package/src/scripts/_cli/cmd_refresh.ts +29 -0
  62. package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
  63. package/src/scripts/_lib/hook_settings.ts +79 -0
  64. package/src/scripts/_lib/ui_surface.ts +97 -0
  65. package/src/scripts/capture_skill_catalogue.ts +483 -0
  66. package/src/scripts/hook_manifest.yaml +18 -3
  67. package/src/scripts/hooks/concern_registry.ts +2 -0
  68. package/src/scripts/hooks/design_slop_hook.ts +1 -1
  69. package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
  70. package/src/scripts/lint_roadmap_complexity.ts +60 -0
  71. package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
  72. package/src/scripts/report_consultation_rate.ts +358 -0
  73. package/src/scripts/report_skill_activation.ts +5 -1
  74. package/src/server/schemas/settings.ts +9 -0
  75. /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
  76. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
  77. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
  78. /package/dist/agent-src/skills/react-shadcn-ui/{reference → references}/registry.md +0 -0
@@ -180,8 +180,8 @@ dispatch runs, not WHETHER the layer exists, so they keep their own C rows.
180
180
  |---|---|
181
181
  | A — preference | 27 |
182
182
  | B — consent | 3 |
183
- | C — guarded | 112 |
184
- | **Total** | **142** |
183
+ | C — guarded | 114 |
184
+ | **Total** | **144** |
185
185
 
186
186
  The total is every leaf in the template, where *leaf* means anything that is not
187
187
  a **non-empty** map. An empty map (like the former `subagents.host_capabilities: {}`) is a real
@@ -236,6 +236,7 @@ Rows follow template order, so a diff against the template reads straight down.
236
236
  | `project.pr_template` | C | `.github/pull_request_template.md` | filesystem path whose contents reach an outbound PR |
237
237
  | `project.upstream_repo` | C | `""` | destination of outbound improvement PRs |
238
238
  | `project.improvement_pr_branch_prefix` | A | `improve/agent-` | branch-name cosmetics |
239
+ | `project.audience` | C | `public` | C-test 4 — it governs the agent's own reasoning discipline: `self` makes the § 8-pre demand gate inert. Who a project is built for is a fact only its maintainer knows, so the agent never infers it and never asks; hand-edit or the GUI write route. The default is today's behaviour, so an install that never sets it is unchanged |
239
240
  | `github.pr_reply_method` | A | `create_review_comment` | picks between two endpoints of one operation |
240
241
  | `augment.rules_use_symlinks` | A | `false` | reversible install mechanics |
241
242
  | `eloquent.access_style` | A | `getters_setters` | code convention |
@@ -324,6 +325,7 @@ Rows follow template order, so a diff against the template reads straight down.
324
325
  | `hooks.injection_scan.enabled` | C | `false` | the prompt-injection scanner |
325
326
  | `hooks.rtk_wrap.enabled` | C | `false` | configures code that runs on every tool call |
326
327
  | `hooks.design_slop.enabled` | C | `false` | configures code that runs on every tool call |
328
+ | `hooks.ui_route_nudge.enabled` | C | `false` | configures code that runs on every tool call |
327
329
  | `hooks.code_graph.enabled` | C | `false` | configures code that runs on every tool call |
328
330
  | `hooks.turn_end_gate.enabled` | C | `false` | arms the only concern that can REFUSE a turn-end; an agent must never be able to switch its own delivery gate on |
329
331
  | `hooks.turn_end_gate.promissory` | C | `true` | which refusal the turn-end gate may raise; inert while the master switch is off |
@@ -108,6 +108,22 @@ feature" ask, before checks 8a–8c. It is a three-question advisor, NOT a
108
108
  product-management framework — surface it in one short block, then proceed on
109
109
  the user's answer (never block; `improve-before-implement`'s golden rule holds).
110
110
 
111
+ **Read the addressee before the questions.** The ladder below measures **market
112
+ demand**, and market demand is a meaningful quantity only where a market is
113
+ intended. Who the project is built for is `project.audience` — resolve it with
114
+ `agent-config settings:get project.audience`, which reports the value and the
115
+ file it came from, rather than opening one file. An absent value resolves to
116
+ `public`: this package has no defaults layer that supplies one at read time, so
117
+ absent means *nobody said*, and the conservative reading of that is the shipped
118
+ default.
119
+
120
+ | `project.audience` | What 8-pre does |
121
+ |---|---|
122
+ | `self` | **Inert.** One line — "demand gate skipped (audience: self)" — then straight to 8a. No deferral, no demand question. |
123
+ | `internal` | Question 2 only ("what breaks without it?"). Questions 1 and 3 are already answered — the requester is the team. |
124
+ | `client` | The requester is the client, not a segment. Question 2 survives; 1 and 3 are answered by the engagement. |
125
+ | `public` | **Today's behaviour, unchanged** — all three questions and the full ladder. |
126
+
111
127
  Three questions:
112
128
 
113
129
  1. **Who requested this?** (a real user / segment, or an internal hunch?)
@@ -118,16 +134,42 @@ Map the answers to a compressed feature-demand hierarchy and recommend:
118
134
 
119
135
  | Level | Signal | Recommendation |
120
136
  |---|---|---|
137
+ | L-self | The maintainer needs it for their own work — no user population is intended | **Build** |
121
138
  | L0 | Founder/agent anxiety — "it feels missing" | **Defer** — validate demand first |
122
139
  | L1 | One anecdotal request, no pattern | **Defer** — watch for repetition |
123
140
  | L2 | Repeated requests, no measured impact | **Validate** — instrument before building |
124
141
  | L3 | Blocks activation/retention for a real segment | **Build** |
125
142
  | L4 | Users are churning / deals lost without it | **Build now** |
126
143
 
127
- Build only at **L3–L4**; L0–L2 get a defer/validate recommendation with the
128
- one missing evidence named. This is advisory — the user decides; a "just build
129
- it" answer proceeds immediately to 8a. No network, no case-memory API (the
130
- source's remote lookup is dropped — a lethal-trifecta egress concern).
144
+ Build at **L-self**, **L3**, or **L4**; L0–L2 get a defer/validate
145
+ recommendation with the one missing evidence named.
146
+
147
+ `L-self` is not a weaker L3. L3 and L4 are defined exclusively over third-party
148
+ users — "a real segment", "users are churning" — so a project with no intended
149
+ user population cannot reach either, not because it is bad but because the scale
150
+ has no value for it. Its ceiling on the L0–L4 ladder is L0, whose recommendation
151
+ is *defer*, which would defer a single-user tool forever. `L-self` is the level
152
+ that ladder is missing, not a discount on the ones it has.
153
+
154
+ **The artefact consequence.** At `L-self`, or at `audience: self` / `internal`,
155
+ never write a roadmap gate, exit criterion, or opening condition that names an
156
+ external user population, a market, or an external measurement. A gate whose
157
+ condition depends on a quantity the project cannot produce is not a gate — it is
158
+ a permanent no in the shape of an answer. This is where the advisory reading
159
+ actually breaks: in conversation "never block" holds, but when a roadmap needs a
160
+ falsifiable opening condition the agent reaches for the only vocabulary it has
161
+ for *should this exist*, and an advisory ladder becomes a number, then a gate.
162
+
163
+ This is advisory — the user decides; a "just build it" answer proceeds
164
+ immediately to 8a. No network, no case-memory API (the source's remote lookup is
165
+ dropped — a lethal-trifecta egress concern).
166
+
167
+ **Enforcement, honestly.** Nothing reads `project.audience` to change what this
168
+ section does. § 8-pre is prose and the branch table above is model-carried —
169
+ the same honesty boundary `security-sensitive-stop` and `untrusted-input-defense`
170
+ state for their own obligations. The one deterministic backstop sits downstream,
171
+ where the damage actually lands: `lint_roadmap_complexity` warns when a roadmap
172
+ gate rests on an external population.
131
173
 
132
174
  ### 8a. Is the request clear?
133
175
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "9.35.0",
4
+ "version": "9.36.0",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -68,7 +68,7 @@
68
68
  "build:install-bundle": "esbuild src/scripts/install.ts --bundle --platform=node --format=esm --target=node20 --define:__AGENT_CONFIG_BUNDLE__=true --outfile=dist/install/install.mjs --banner:js=\"import { createRequire as __acCreateRequire } from 'node:module'; const require = globalThis.require ?? __acCreateRequire(import.meta.url);\"",
69
69
  "build:hooks": "esbuild src/scripts/hooks/dispatch_entry.ts --bundle --platform=node --format=esm --target=node20 --define:__AGENT_CONFIG_BUNDLE__=true --outfile=dist/hooks/dispatch.js --banner:js=\"import { createRequire as __acCreateRequire } from 'node:module'; const require = globalThis.require ?? __acCreateRequire(import.meta.url); const __acPath = require('node:path'); const __acFs = require('node:fs'); const __acUrl = require('node:url'); const __acReal = (p) => { try { return __acFs.realpathSync(p); } catch { return p; } }; const __acSelf = __acReal(__acUrl.fileURLToPath(import.meta.url)); if (process.argv[1] !== undefined && __acReal(__acPath.resolve(process.argv[1])) === __acSelf) { globalThis.__AC_HOOKS_BUNDLE_DIRECT = true; process.argv = [process.argv[0], __acSelf + '.__direct__', ...process.argv.slice(2)]; }\"",
70
70
  "build:mcp-bundle": "esbuild src/scripts/mcp_server/__main__.ts --bundle --platform=node --format=esm --target=node20 --define:__AGENT_CONFIG_BUNDLE__=true --outfile=dist/mcp/server.mjs --banner:js=\"import { createRequire as __acCreateRequire } from 'node:module'; const require = globalThis.require ?? __acCreateRequire(import.meta.url); const __acPath = require('node:path'); const __acFs = require('node:fs'); const __acUrl = require('node:url'); const __acReal = (p) => { try { return __acFs.realpathSync(p); } catch { return p; } }; const __acSelf = __acReal(__acUrl.fileURLToPath(import.meta.url)); if (process.argv[1] !== undefined && __acReal(__acPath.resolve(process.argv[1])) === __acSelf) { globalThis.__AC_MCP_BUNDLE_DIRECT = true; process.argv = [process.argv[0], __acSelf + '.__direct__', ...process.argv.slice(2)]; }\"",
71
- "build:cli-delegate": "esbuild src/scripts/_cli/cmd_*.ts --bundle --splitting --platform=node --format=esm --target=node20 --define:__AGENT_CONFIG_BUNDLE__=true --outdir=dist/cli-delegate --banner:js=\"import { createRequire as __acCreateRequire } from 'node:module'; const require = globalThis.require ?? __acCreateRequire(import.meta.url);\"",
71
+ "build:cli-delegate": "esbuild src/scripts/_cli/cmd_*.ts --bundle --splitting --platform=node --format=esm --target=node20 --define:__AGENT_CONFIG_BUNDLE__=true --define:__AGENT_CONFIG_CLI_DELEGATE__=true --outdir=dist/cli-delegate --banner:js=\"import { createRequire as __acCreateRequire } from 'node:module'; const require = globalThis.require ?? __acCreateRequire(import.meta.url);\"",
72
72
  "build:ui": "vite build",
73
73
  "tokens:build": "node tokens/build-ac.mjs && node tokens/build-as.mjs",
74
74
  "tokens:check": "node tokens/build-ac.mjs --check && node tokens/build-as.mjs --check && node tokens/check-contrast.mjs",
@@ -14,7 +14,7 @@
14
14
  ## Pointers
15
15
 
16
16
  - **Filling out this AGENTS.md** — section templates, capability bullets, multi-agent entry flow, monorepo layout: [`consumer-agents-md-guide`](.augment/contexts/contracts/consumer-agents-md-guide.md).
17
- - **Behavior rules (always active)** — [`.augment/rules/`](.augment/rules/). Kernel rules are **immutable**; tighten only via the override registry.
17
+ - **Behavior rules** (9 always-on; rest routed) — [`.augment/rules/`](.augment/rules/). Kernel rules are **immutable**; tighten only via the override registry.
18
18
  - **Skills (on-demand expertise)** — domain skills surfaced by description, invoked on trigger match: [`.augment/skills/`](.augment/skills/).
19
19
  - **Commands (workflows)** — slash-commands the agent runs end-to-end (`/work`, `/create-pr`, …): [`.augment/commands/`](.augment/commands/).
20
20
  - **Project-specific docs** — your own architecture notes, roadmaps, sessions, contexts: [`agents/`](agents/).
@@ -355,6 +355,24 @@ project:
355
355
  # Branch prefix for improvement PRs
356
356
  improvement_pr_branch_prefix: "improve/agent-"
357
357
 
358
+ # Who this project is built for. Read by the demand gate
359
+ # (docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md
360
+ # § 8-pre), whose L0-L4 ladder measures MARKET demand and is therefore
361
+ # meaningless where no market is intended.
362
+ # self -> a tool its maintainer builds for themselves. The demand gate
363
+ # is inert; work is classified L-self (build), never deferred
364
+ # for lack of a user population that is not wanted.
365
+ # internal -> a team / company internal tool. Only "what breaks without
366
+ # it?" survives; the requester is known.
367
+ # client -> built for a named client. The client is the requester, not a
368
+ # market segment.
369
+ # public -> a product with an intended market. Full three-question gate
370
+ # and the full ladder.
371
+ # Default `public` = the behaviour every install had before this key existed.
372
+ # Absent resolves to `public` for the same reason: this package has no
373
+ # defaults layer, so absent means nobody said.
374
+ audience: public
375
+
358
376
  # --- GitHub integration ---
359
377
  github:
360
378
  # API method for replying to PR review comments
@@ -1248,6 +1266,20 @@ hooks:
1248
1266
  design_slop:
1249
1267
  enabled: false
1250
1268
 
1269
+ # PreToolUse UI-route nudge (road-to-frontend-skill-application Phase 4).
1270
+ # Default-OFF. When enabled, a Write/Edit to a UI surface with no design
1271
+ # consultation latched this session WARNS (exit 2, never blocks) naming the
1272
+ # route: run existing-ui-audit, then the fe-design loop. A read or search
1273
+ # touching fe-design / existing-ui-audit / design-review / design-intelligence
1274
+ # latches consultation and silences it for the rest of the session, so the
1275
+ # agent that did the right thing is never nagged. Anti-loop: at most 2 nudges
1276
+ # per session. It does NOT read the two UI rules: the UI-surface decision
1277
+ # comes from _lib/ui_surface.ts, so this runs parallel to them rather than
1278
+ # consuming their triggers. Host-limited (pre_tool_use fires on ~2-3 hosts) —
1279
+ # elsewhere the obligation stays model-carried, as the two UI rules declare.
1280
+ ui_route_nudge:
1281
+ enabled: false
1282
+
1251
1283
  # PreToolUse code-graph nudge (ADR-124 Phase 4). Default-OFF. When enabled
1252
1284
  # AND a native code-graph engine has built a cache (or the repo ships a
1253
1285
  # graph.json / SCIP index), WARNS once per session (exit 2, never blocks) as
@@ -168,6 +168,18 @@ gates:
168
168
  corpus: "src/rules/*.md frontmatters (111 at baseline)"
169
169
  status: enforced
170
170
 
171
+ - id: lint_rule_skill_pack_reach
172
+ argv: ["--quiet"]
173
+ min_scanned: 90
174
+ corpus: "src/rules/*.md frontmatters joined against src/skills packs and the pack registry (116 rules at baseline)"
175
+ status: enforced
176
+ note: >
177
+ Advisory on findings, enforced on COVERAGE: the gate exits 0 while its
178
+ unreachable-route set is non-empty (12 at introduction), so only this
179
+ floor can catch it going blind. A collapse below 90 would mean it stopped
180
+ reading src/rules, which is exactly the shape that would make "0 findings"
181
+ read as clean.
182
+
171
183
  - id: check_condensation
172
184
  argv: []
173
185
  min_scanned: 380
@@ -3591,10 +3591,42 @@ function main(argv: string[] | null = null): number {
3591
3591
 
3592
3592
  // --- CLI entry ---
3593
3593
 
3594
+ /**
3595
+ * Defined by `build:cli-delegate` only — see `cmd_session_recycle.ts` for why a
3596
+ * single "am I inside a bundle" flag cannot answer "may I run".
3597
+ */
3598
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
3599
+ declare const __AGENT_CONFIG_CLI_DELEGATE__: boolean | undefined;
3600
+
3594
3601
  function _isCliEntry(): boolean {
3602
+ const bundled = typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
3603
+ const cliDelegate =
3604
+ typeof __AGENT_CONFIG_CLI_DELEGATE__ !== 'undefined' && __AGENT_CONFIG_CLI_DELEGATE__;
3605
+ // Inlined into the installer / hook / MCP bundle: never auto-run. This
3606
+ // file previously carried no such guard while its comment described the
3607
+ // pairing, so the four siblings documented one mechanism and implemented
3608
+ // two.
3609
+ if (bundled && !cliDelegate) {
3610
+ return false;
3611
+ }
3595
3612
  if (process.argv[1] === undefined) {
3596
3613
  return false;
3597
3614
  }
3615
+ if (cliDelegate) {
3616
+ // `--splitting` moves this module's body into a shared chunk, so the URL
3617
+ // comparison below weighs the CHUNK against `argv[1]` and never matches.
3618
+ // `agent-config doctor` shipped producing zero bytes and exit 0 for
3619
+ // exactly that reason — a diagnostic reporting success while saying
3620
+ // nothing. The invoked file name is the reliable signal inside this
3621
+ // bundle; the delegate smoke test keeps the literal honest.
3622
+ if (path.basename(process.argv[1], '.js') === 'cmd_doctor') {
3623
+ return true;
3624
+ }
3625
+ // A miss falls THROUGH to the realpath comparison below rather than
3626
+ // returning false: a symlinked or renamed invocation is exactly the
3627
+ // case that fallback exists for, and swallowing it here would rebuild
3628
+ // the silent no-op this change removes.
3629
+ }
3598
3630
  const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
3599
3631
  if (import.meta.url === argvUrl) {
3600
3632
  return true;
@@ -932,14 +932,37 @@ export function main(argv: string[] | null = null, options: MainOptions = {}): n
932
932
  // build's `--define`) disables the auto-run there; under tsx/node it is
933
933
  // undefined, so `typeof` short-circuits and the standalone CLI guard fires
934
934
  // normally.
935
+ // The sentinel above is true in EVERY esbuild target, `build:cli-delegate`
936
+ // included — and there this file is the entry, so refusing on it refused to run
937
+ // the bundle built to run it: `dist/cli-delegate/cmd_migrate.js` produced zero
938
+ // bytes and exit 0, and `agent-config migrate` reported success while doing
939
+ // nothing. `__AGENT_CONFIG_CLI_DELEGATE__` is defined by that one build only, so
940
+ // the pair distinguishes "inlined into the installer" (never run) from "this
941
+ // bundle's own target" (run, subject to the entry check below).
935
942
  declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
936
- const _bundled =
937
- typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
943
+ declare const __AGENT_CONFIG_CLI_DELEGATE__: boolean | undefined;
944
+ const _inForeignBundle =
945
+ typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' &&
946
+ __AGENT_CONFIG_BUNDLE__ &&
947
+ !(typeof __AGENT_CONFIG_CLI_DELEGATE__ !== 'undefined' && __AGENT_CONFIG_CLI_DELEGATE__);
938
948
  const _HERE = fileURLToPath(import.meta.url);
939
949
  function _isCliEntry(): boolean {
940
950
  if (process.argv[1] === undefined) {
941
951
  return false;
942
952
  }
953
+ if (typeof __AGENT_CONFIG_CLI_DELEGATE__ !== 'undefined' && __AGENT_CONFIG_CLI_DELEGATE__) {
954
+ // `--splitting` moves this module's body into a shared chunk, so the URL
955
+ // comparison below weighs the CHUNK against `argv[1]` and never matches —
956
+ // `agent-config migrate` shipped producing zero bytes and exit 0. The
957
+ // invoked file name is the reliable signal inside this bundle.
958
+ if (path.basename(process.argv[1], '.js') === 'cmd_migrate') {
959
+ return true;
960
+ }
961
+ // A miss falls THROUGH to the realpath comparison below rather than
962
+ // returning false: a symlinked or renamed invocation is exactly the
963
+ // case that fallback exists for, and swallowing it here would rebuild
964
+ // the silent no-op this change removes.
965
+ }
943
966
  const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
944
967
  if (import.meta.url === argvUrl) {
945
968
  return true;
@@ -958,7 +981,7 @@ function _isCliEntry(): boolean {
958
981
  }
959
982
  }
960
983
 
961
- if (!_bundled && (_isCliEntry() || process.argv[1] === _HERE)) {
984
+ if (!_inForeignBundle && (_isCliEntry() || process.argv[1] === _HERE)) {
962
985
  try {
963
986
  process.exitCode = main(process.argv.slice(2));
964
987
  } catch (exc) {
@@ -449,10 +449,39 @@ export function main(argv: string[] | null = null, options: MainOptions = {}): n
449
449
 
450
450
  // --- CLI entry ---
451
451
 
452
+ /**
453
+ * Defined by `build:cli-delegate` only — see `cmd_session_recycle.ts` for why the
454
+ * URL comparison below cannot decide this inside that bundle.
455
+ */
456
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
457
+ declare const __AGENT_CONFIG_CLI_DELEGATE__: boolean | undefined;
458
+
452
459
  function _isCliEntry(): boolean {
460
+ const bundled = typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
461
+ const cliDelegate =
462
+ typeof __AGENT_CONFIG_CLI_DELEGATE__ !== 'undefined' && __AGENT_CONFIG_CLI_DELEGATE__;
463
+ // Inlined into the installer / hook / MCP bundle: never auto-run. This
464
+ // file previously carried no such guard while its comment described the
465
+ // pairing, so the four siblings documented one mechanism and implemented
466
+ // two.
467
+ if (bundled && !cliDelegate) {
468
+ return false;
469
+ }
453
470
  if (process.argv[1] === undefined) {
454
471
  return false;
455
472
  }
473
+ if (cliDelegate) {
474
+ // Found by running every delegate bundle, not by reading: `--splitting`
475
+ // had moved this body into a shared chunk too, so `agent-config refresh`
476
+ // was a silent exit 0 like doctor, migrate and session:recycle.
477
+ if (path.basename(process.argv[1], '.js') === 'cmd_refresh') {
478
+ return true;
479
+ }
480
+ // A miss falls THROUGH to the realpath comparison below rather than
481
+ // returning false: a symlinked or renamed invocation is exactly the
482
+ // case that fallback exists for, and swallowing it here would rebuild
483
+ // the silent no-op this change removes.
484
+ }
456
485
  const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
457
486
  if (import.meta.url === argvUrl) {
458
487
  return true;
@@ -252,11 +252,49 @@ export function main(argv: string[] = process.argv.slice(2)): number {
252
252
  return result.code;
253
253
  }
254
254
 
255
- // Bundle-safety: never auto-run when inlined into an esbuild bundle.
255
+ /**
256
+ * Bundle-safety: never auto-run when inlined into SOMEONE ELSE'S bundle.
257
+ *
258
+ * `__AGENT_CONFIG_BUNDLE__` alone cannot express that. All four esbuild targets
259
+ * define it, `build:cli-delegate` included — and that bundle's entry points ARE
260
+ * these files, so a guard refusing on the flag refuses to run the very bundle
261
+ * built to run it. That shipped: `dist/cli-delegate/cmd_session_recycle.js`
262
+ * produced zero bytes and exit 0 on every installed copy, so
263
+ * `agent-config session:recycle` reported success while writing no envelope.
264
+ * `cmd_doctor` and `cmd_migrate` carried the same guard and were dead the same
265
+ * way.
266
+ *
267
+ * `__AGENT_CONFIG_CLI_DELEGATE__` is defined by that one build only, so the pair
268
+ * says what a single flag could not: inlined elsewhere (install / hook / MCP
269
+ * bundle) → never run; this bundle's own target → run, and let the `argv[1]`
270
+ * comparison below decide whether THIS module is the entry or a shared chunk
271
+ * pulled in beside it.
272
+ */
256
273
  declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
274
+ declare const __AGENT_CONFIG_CLI_DELEGATE__: boolean | undefined;
257
275
  function _isCliEntry(): boolean {
258
- if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) return false;
276
+ const bundled = typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
277
+ const cliDelegate =
278
+ typeof __AGENT_CONFIG_CLI_DELEGATE__ !== 'undefined' && __AGENT_CONFIG_CLI_DELEGATE__;
279
+ if (bundled && !cliDelegate) return false;
259
280
  if (process.argv[1] === undefined) return false;
281
+ if (cliDelegate) {
282
+ // `--splitting` turns the entry file into a re-export shim and moves this
283
+ // module's body into a shared chunk, where `import.meta.url` is the
284
+ // CHUNK's url and can never equal `argv[1]`. The comparison below then
285
+ // silently never fires — which is why two sibling commands shipped dead
286
+ // while others worked by accident, purely by where esbuild happened to
287
+ // place their code. Inside this bundle the invoked file name is the only
288
+ // reliable signal; the smoke test over every delegate bundle is what
289
+ // keeps this literal honest if the file is ever renamed.
290
+ if (path.basename(process.argv[1], '.js') === 'cmd_session_recycle') {
291
+ return true;
292
+ }
293
+ // A miss falls THROUGH to the realpath comparison below rather than
294
+ // returning false: a symlinked or renamed invocation is exactly the
295
+ // case that fallback exists for, and swallowing it here would rebuild
296
+ // the silent no-op this change removes.
297
+ }
260
298
  if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
261
299
  try {
262
300
  return (
@@ -0,0 +1,79 @@
1
+ /**
2
+ * hook_settings — read a `hooks.<section>.enabled` flag out of
3
+ * `.agent-settings.yml` without dragging a YAML parser into a hook.
4
+ *
5
+ * Every default-OFF hook needs the same two-level lookup, and the tree already
6
+ * carries two hand-rolled copies of it (`code_graph_nudge_hook.enabled` and the
7
+ * equivalent inside `design_slop_hook`). Adding a third copy for the route
8
+ * nudge is what this file prevents; the two existing copies are pre-existing
9
+ * duplication and are deliberately left alone — folding them in is a separate,
10
+ * behaviour-neutral change, not something to smuggle into a feature diff.
11
+ *
12
+ * Fail-closed on the flag, fail-open on the read: an unreadable or malformed
13
+ * settings file returns `false`, which keeps a default-OFF hook off. A hook
14
+ * that turned itself ON because it could not read a file would be the worst
15
+ * possible failure mode for an opt-in nudge.
16
+ */
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+
20
+ const SETTINGS_FILE = '.agent-settings.yml';
21
+
22
+ /**
23
+ * True when `.agent-settings.yml` under `root` carries
24
+ * `hooks:\n <section>:\n enabled: true`.
25
+ *
26
+ * Deliberately indentation-shaped rather than a real parse: the same shape the
27
+ * existing hooks rely on, and a hook must never fail a tool call because a
28
+ * dependency could not load.
29
+ */
30
+ export function hookSectionEnabled(root: string, section: string): boolean {
31
+ const file = path.join(root, SETTINGS_FILE);
32
+ let text: string;
33
+ try {
34
+ if (!fs.statSync(file).isFile()) return false;
35
+ text = fs.readFileSync(file, 'utf-8');
36
+ } catch {
37
+ return false;
38
+ }
39
+
40
+ const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ const sectionPattern = new RegExp(`^(\\s+)${escaped}\\s*:\\s*$`);
42
+ let inHooks = false;
43
+ /** Indent width of the matched section header; null while outside it. */
44
+ let sectionIndent: number | null = null;
45
+
46
+ const indentOf = (line: string): number => line.length - line.replace(/^\s+/, '').length;
47
+
48
+ for (const raw of text.split(/\r\n|\r|\n/)) {
49
+ const line = raw.replace(/\s+$/, '');
50
+ if (!line || line.replace(/^\s+/, '').startsWith('#')) continue;
51
+
52
+ // A non-indented line closes both scopes and may open `hooks:`.
53
+ if (!(line.startsWith(' ') || line.startsWith('\t'))) {
54
+ inHooks = /^hooks\s*:\s*$/.test(line);
55
+ sectionIndent = null;
56
+ continue;
57
+ }
58
+
59
+ if (inHooks) {
60
+ const opened = sectionPattern.exec(line);
61
+ if (opened) {
62
+ // Remember the ACTUAL indent rather than assuming two spaces.
63
+ // The previous version closed the section on any line indented
64
+ // <= 3, so on a 4-space file a sibling section never closed and
65
+ // a default-OFF hook could read another section's enabled: true.
66
+ sectionIndent = opened[1]!.length;
67
+ continue;
68
+ }
69
+ // A key at or above the section header's own depth closes it.
70
+ if (sectionIndent !== null && indentOf(line) <= sectionIndent) {
71
+ sectionIndent = null;
72
+ }
73
+ }
74
+
75
+ if (sectionIndent !== null && /^\s+enabled\s*:\s*true\b/.test(line)) return true;
76
+ }
77
+
78
+ return false;
79
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ui_surface — the ONE definition of "this path is a UI surface".
3
+ *
4
+ * Three consumers need the same answer and were about to disagree about it:
5
+ * the anti-slop PreToolUse hook (which had the only copy), the UI-turn
6
+ * definition the catalogue analyzer counts against, and the route nudge.
7
+ * A UI-turn rate measured against one extension set and a nudge fired against
8
+ * another would produce a consultation rate that no fix could ever move,
9
+ * because the denominator and the trigger would be different populations.
10
+ *
11
+ * `.blade.php` is deliberately part of the set and was absent from the hook's
12
+ * original regex: a two-part extension does not match a `\.(…)$` alternation,
13
+ * so every Blade template — the single largest UI surface in a Laravel
14
+ * consumer — read as a non-UI file. That is why `isUiPath` matches on the
15
+ * path tail rather than on `path.extname`, which returns `.php` for
16
+ * `page.blade.php` and would silently reproduce the same hole.
17
+ */
18
+
19
+ /** Single-segment UI extensions, without the leading dot. */
20
+ export const UI_EXTENSIONS = [
21
+ 'html',
22
+ 'htm',
23
+ 'css',
24
+ 'scss',
25
+ 'sass',
26
+ 'less',
27
+ 'vue',
28
+ 'svelte',
29
+ 'astro',
30
+ 'jsx',
31
+ 'tsx',
32
+ ] as const;
33
+
34
+ /**
35
+ * Multi-segment UI suffixes. These cannot be expressed as an extension
36
+ * alternation — `path.extname('x.blade.php')` is `.php`.
37
+ */
38
+ export const UI_COMPOUND_SUFFIXES = ['.blade.php'] as const;
39
+
40
+ /** Equivalent to the historical hook regex, plus the compound suffixes. */
41
+ export const UI_EXT = new RegExp(
42
+ `(\\.(${UI_EXTENSIONS.join('|')})|${UI_COMPOUND_SUFFIXES.map((s) => s.replace(/\./g, '\\.')).join('|')})$`,
43
+ 'i',
44
+ );
45
+
46
+ /** True when `p` names a file on a UI surface. Case-insensitive. */
47
+ export function isUiPath(p: string): boolean {
48
+ return UI_EXT.test(p);
49
+ }
50
+
51
+ /**
52
+ * Directory fragments that mark a UI tree across frameworks. The two UI rules
53
+ * carried only the Laravel pair (`resources/views/`, `resources/js/`), so a
54
+ * React, Vue, Next or Svelte consumer writing to `src/components/` matched no
55
+ * path trigger at all. Kept as fragments, not anchored prefixes: a consumer's
56
+ * UI tree is rarely at the repo root.
57
+ *
58
+ * `app/` is deliberately NOT in this list although it is the Next.js router
59
+ * directory. In a Laravel consumer `app/` is the entire backend, so the
60
+ * fragment would fire on every controller, model and job — a trigger that
61
+ * over-fires on the largest directory of a whole ecosystem is worse than the
62
+ * gap it closes. Next.js route files are caught by extension instead
63
+ * (`page.tsx`, `layout.tsx` match `isUiPath`), which is the precise signal.
64
+ */
65
+ export const UI_PATH_FRAGMENTS = [
66
+ 'resources/views/',
67
+ 'resources/js/',
68
+ // `components/` already subsumes `src/components/` under substring
69
+ // matching — listing both here would be dead weight. The rules' own
70
+ // `path_prefix` list keeps both because matching there is anchored.
71
+ 'components/',
72
+ 'pages/',
73
+ ] as const;
74
+
75
+ /**
76
+ * Fragments that sit INSIDE a UI tree and are not UI. Checked first, because a
77
+ * substring fragment cannot express "pages/ but not pages/api/" on its own —
78
+ * and `pages/api/` is server-only Next.js code, the exact over-fire the `app/`
79
+ * exclusion above exists to avoid. Leaving it in would have put backend files
80
+ * in the pre-registered UI-turn denominator, which is worse than the nudge
81
+ * firing on them: a rate is harder to un-break than a warning.
82
+ */
83
+ export const UI_TREE_EXCLUSIONS = ['pages/api/'] as const;
84
+
85
+ /**
86
+ * True when `p` sits inside a conventional UI tree.
87
+ *
88
+ * Note this does NOT require a UI extension — that is the point: a `.js` file
89
+ * under `resources/js/` is UI. The cost is that a non-code file in a UI tree
90
+ * (a README, a fixture) also matches; callers that need the narrower answer
91
+ * combine this with `isUiPath`.
92
+ */
93
+ export function isUiTreePath(p: string): boolean {
94
+ const normalized = p.replace(/\\/g, '/').toLowerCase();
95
+ if (UI_TREE_EXCLUSIONS.some((fragment) => normalized.includes(fragment))) return false;
96
+ return UI_PATH_FRAGMENTS.some((fragment) => normalized.includes(fragment));
97
+ }