@event4u/agent-config 4.3.0 → 4.6.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 (67) hide show
  1. package/.agent-src/commands/agents/user/init.md +7 -13
  2. package/.agent-src/commands/agents/user/show.md +4 -4
  3. package/.agent-src/commands/post-as/me.md +6 -6
  4. package/.agent-src/contexts/execution/autonomy-mechanics.md +1 -1
  5. package/.agent-src/contexts/execution/cheap-question-mechanics.md +4 -0
  6. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  7. package/.claude-plugin/marketplace.json +1 -1
  8. package/CHANGELOG.md +28 -227
  9. package/config/discovery/packs.yml +9 -1
  10. package/config/discovery/workspaces.yml +14 -1
  11. package/dist/cli/agent-config.js +15 -10
  12. package/dist/cli/agent-config.js.map +1 -1
  13. package/dist/cli/commands/uiServe.js +27 -11
  14. package/dist/cli/commands/uiServe.js.map +1 -1
  15. package/dist/cli/registry.js +1 -1
  16. package/dist/cli/registry.js.map +1 -1
  17. package/dist/discovery/deprecation-report.md +1 -1
  18. package/dist/discovery/discovery-manifest.json +51 -8
  19. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  20. package/dist/discovery/discovery-manifest.summary.md +3 -3
  21. package/dist/discovery/orphan-report.md +1 -1
  22. package/dist/discovery/packs.json +25 -4
  23. package/dist/discovery/trust-report.md +1 -1
  24. package/dist/discovery/workspaces.json +4 -4
  25. package/dist/install/selectedTools.js +52 -0
  26. package/dist/install/selectedTools.js.map +1 -0
  27. package/dist/install/toolDetection.js +104 -0
  28. package/dist/install/toolDetection.js.map +1 -0
  29. package/dist/mcp/registry-manifest.json +3 -3
  30. package/dist/server/app.js +36 -0
  31. package/dist/server/app.js.map +1 -1
  32. package/dist/server/routes/ping.js +17 -0
  33. package/dist/server/routes/ping.js.map +1 -1
  34. package/dist/server/routes/wizard.js +280 -34
  35. package/dist/server/routes/wizard.js.map +1 -1
  36. package/dist/server/serverInfo.js +54 -0
  37. package/dist/server/serverInfo.js.map +1 -0
  38. package/dist/shared/userMd/formAdapter.js +1 -5
  39. package/dist/shared/userMd/formAdapter.js.map +1 -1
  40. package/dist/shared/userMd/schema.js +2 -1
  41. package/dist/shared/userMd/schema.js.map +1 -1
  42. package/dist/ui/assets/index-BAJQeVdX.js +40 -0
  43. package/dist/ui/assets/index-BAJQeVdX.js.map +1 -0
  44. package/dist/ui/assets/index-BbWWuFrF.css +1 -0
  45. package/dist/ui/index.html +2 -2
  46. package/docs/archive/CHANGELOG-pre-4.0.0.md +80 -0
  47. package/docs/archive/CHANGELOG-pre-4.5.0.md +289 -0
  48. package/docs/contracts/agent-user-schema.md +1 -3
  49. package/docs/contracts/discovery-manifest.schema.json +3 -1
  50. package/docs/contracts/gui-wizard.md +103 -8
  51. package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +27 -0
  52. package/docs/decisions/ADR-021-deployment-shape.md +2 -2
  53. package/docs/deploy/connector-setup.md +2 -2
  54. package/docs/deploy/policy-cookbook.md +2 -2
  55. package/docs/examples/agent-user.example.md +0 -1
  56. package/package.json +1 -1
  57. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  58. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  59. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  60. package/scripts/build_discovery_manifest.py +4 -0
  61. package/scripts/condense.py +56 -1
  62. package/scripts/condense_memory.py +8 -2
  63. package/scripts/install.py +89 -43
  64. package/scripts/lint_discovery_vocabulary.py +8 -0
  65. package/dist/ui/assets/index-BDAhhpDV.js +0 -40
  66. package/dist/ui/assets/index-BDAhhpDV.js.map +0 -1
  67. package/dist/ui/assets/index-BXZILUxe.css +0 -1
@@ -90,7 +90,12 @@ Versioned under `/api/v1/`. Selected routes:
90
90
  | POST | `/api/v1/wizard/state` | Persist state between steps |
91
91
  | GET | `/api/v1/wizard/manifest` | Locked discovery-manifest (extended mode) |
92
92
  | GET | `/api/v1/wizard/auto-detect` | Project-signal evidence for the `ai-tools` step (extended mode) |
93
+ | GET | `/api/v1/wizard/detect-tools` | Native AI-tool presence (home/app/`$PATH`) for Step-1 pre-select + badge |
94
+ | GET | `/api/v1/wizard/detect-rtk` | rtk presence + per-OS install hint (Editor-and-tooling step) |
95
+ | GET | `/api/v1/wizard/ai-council` | AI-council scalar subset + provider key presence (extended mode) |
96
+ | POST | `/api/v1/wizard/ai-council` | Comment-preserving scalar merge into `.ai-council.yml` |
93
97
  | POST | `/api/v1/wizard/finish` | 2PC commit of settings + user-identity |
98
+ | POST | `/api/v1/shutdown` | Browser-close shutdown beacon (`navigator.sendBeacon` target; real-serve only) |
94
99
  | POST | `/api/v1/wizard/apply` | **Single real-apply route.** `dry_run:true` → buffered plan preview; otherwise SSE-streams `scripts/install.py --apply-payload` |
95
100
  | GET | `/api/v1/install/detect` | Scope + project shape + tool presence |
96
101
  | POST | `/api/v1/install/plan` | Plan preview (per-tool file counts + conflicts) for the Review step |
@@ -107,6 +112,36 @@ an `Origin` allow-list (browser-issued requests), and a per-server bearer
107
112
  token (`Authorization: Bearer <token>`, minted at boot, surfaced in the
108
113
  `?token=` URL). A bad token / Host / Origin returns `403`.
109
114
 
115
+ ### Browser-lifecycle shutdown
116
+
117
+ In real-serve (`runUiServe`), the server stops itself when the browser that
118
+ drives it goes away — the local process should not outlive its only client:
119
+
120
+ - The SPA ([`src/ui/serverLifecycle.ts`](../../src/ui/serverLifecycle.ts))
121
+ heartbeats `GET /api/v1/ping` every 30s while the tab is visible and the
122
+ user has interacted within the last 30 min. On `pagehide` (window/tab
123
+ close) it fires `navigator.sendBeacon('/api/v1/shutdown?token=…')` (the
124
+ token rides as a query param because `sendBeacon` cannot set headers); and
125
+ once the user has been idle for 30 min it fires the same beacon instead of
126
+ a ping, so the server stops even with the tab still open.
127
+ - The server (`createApp` `idleShutdown` option, passed only by `runUiServe`)
128
+ exits on that beacon, and — as a backstop for crashes where neither beacon
129
+ is delivered — via an idle timer that **arms only after the first authed
130
+ request** (so headless / `--allow-headless` manual-connect servers are
131
+ never killed before the operator attaches) and fires after 30 min of
132
+ silence.
133
+
134
+ On boot, `runUiServe` records `{pid, port, url}` to
135
+ `~/.event4u/agent-config/local-server.json`
136
+ ([`src/server/serverInfo.ts`](../../src/server/serverInfo.ts)) and removes it
137
+ on graceful exit. A fresh `agent-config init` (via `scripts/install.py`
138
+ `_kill_stale_wizard_server`) reads that record, terminates a still-running
139
+ prior instance, and starts a new server — so init always lands on step 1.
140
+
141
+ `createApp` is inert (no watchdog, no `/api/v1/shutdown` route) unless
142
+ `idleShutdown` is supplied, so the in-process test harness
143
+ ([`tests/server/helpers.ts`](../../tests/server/helpers.ts)) is unaffected.
144
+
110
145
  ## Real apply — single source of truth
111
146
 
112
147
  `POST /api/v1/wizard/apply` is the only write path:
@@ -185,16 +220,76 @@ effect):
185
220
 
186
221
  | `extendedSteps` | Steps | Layout |
187
222
  |---|---|---|
188
- | `false` | 7 | `editor → personality → cost → roadmap-quality → memory → user-md → review` |
189
- | `true` | 9 | `ai-tools → packs → editor → personality → cost → roadmap-quality → memory → user-md → review` |
223
+ | `false` | 9 | `welcome → editor → personality → cost → roadmap-quality → memory → ai-council → user-md → review` |
224
+ | `true` | 13 | `welcome → ai-tools → roles → packs → editor → personality → cost → roadmap-quality → memory → ai-council → user-md → modules → review` |
225
+
226
+ The project-specific `modules` step (writes `.agent-project-settings.yml`, not
227
+ the global `.agent-settings.yml`) sits at the **end** of the extended flow,
228
+ just before `review` — global/user settings come first, the project step
229
+ comes last. Because it is no longer part of the install-only lead,
230
+ `setup` mode now walks it too (the lead it skips is `ai-tools → roles →
231
+ packs`).
232
+
233
+ The `welcome` step (Step 1, both modes) collects **name + language** up front —
234
+ pulled out of the user-md step so the agent knows who it's talking to before
235
+ anything else. Name pre-fills from the OS account (`GET /api/v1/ping`
236
+ `systemUser`) when empty; language pre-fills from the browser locale
237
+ (`navigator.language`) when no `.agent-user.yml` exists yet. In install mode
238
+ the user-md step hides its name + language fields (collected here); setup mode
239
+ skips the welcome step (it lands on the first settings step) and keeps those
240
+ fields in the user-md form.
241
+
242
+ The `roles` step presents the discovery **workspaces** as the *area*
243
+ (Engineering, Product, Finance, Founder, GTM, Ops, …; the maintainer workspace
244
+ is hidden) — each tile shows the area label, then advisory `example_roles`
245
+ (e.g. Engineering → "Developer, CTO"; Finance → "CFO") and the description. The
246
+ selected workspace ids become `.agent-user.yml` `role[]` (the example roles are
247
+ UI hints, not the stored value) and recommend each domain's `default_packs` on
248
+ the packs step. In install mode the user-md
249
+ step therefore hides its role field (collected here instead); setup mode keeps
250
+ the role field since it skips the roles step.
251
+
252
+ The `ai-council` step (road-to-wizard-ux-improvements § Phase 8) configures the
253
+ wizard-controlled scalar subset of `.ai-council.yml` (enable, per-member
254
+ enable + low-impact, global transport mode, debate rounds, cost budget, the
255
+ non-locked `decision_resolution` classes) via `GET`/`POST /api/v1/wizard/ai-council`;
256
+ the file is written with comment-preserving `replaceScalar` edits.
190
257
 
191
258
  The step shapes themselves are declared in
192
- [`src/ui/wizard/steps.ts`](../../src/ui/wizard/steps.ts) — the two
193
- prepended lead steps (`ai-tools`, `packs`) carry no `paths` and use
194
- dedicated renderers in `WizardPage.tsx`. `getWizardSteps({ extended })`
195
- is the single resolver; the UI consumes the active list via
196
- `getActiveSteps()` / `activeTotalSteps()` so a server toggle takes
197
- effect on the next reload without a code change.
259
+ [`src/ui/wizard/steps.ts`](../../src/ui/wizard/steps.ts) — the always-first
260
+ `welcome` step plus the four extended-only lead steps (`ai-tools`, `roles`,
261
+ `packs`, `modules`) carry no `paths` and use dedicated renderers in
262
+ `WizardPage.tsx`.
263
+ `getWizardSteps({ extended })` is the single resolver; the UI consumes the
264
+ active list via `getActiveSteps()` / `activeTotalSteps()` so a server toggle
265
+ takes effect on the next reload without a code change.
266
+
267
+ ### AI-tools / roles / packs selection rules (Steps 2-4)
268
+
269
+ - **AI-tools pre-selection.** `detect-tools` returns `tools` (installed on the
270
+ machine) and `configured` (the user's prior selection, persisted to
271
+ `~/.event4u/agent-config/wizard-tools.json` on each real apply). On a repeat
272
+ run the wizard pre-selects exactly the `configured` tools; only on a genuine
273
+ first run (no prior selection) does it fall back to pre-selecting every
274
+ installed tool.
275
+ - **Roles → packs recommendation.** Each selected role contributes its
276
+ workspace `default_packs`; the union pre-selects packs on Step 3 (plus
277
+ auto-detected project packs). The recommendation stops clobbering the
278
+ selection once the user manually edits a pack. Each pack tile also badges
279
+ the workspace(s) it belongs to (from the pack's `workspaces`), highlighting
280
+ the badges that match a role the user picked on Step 2.
281
+ - **Step 2 framework persistence.** A language tile (`cluster`, e.g. PHP)
282
+ gates its framework children in the UI but never destroys their stored
283
+ selection. Turning a language off disables — but keeps checked — its
284
+ children, so a PHP off→on round-trip restores the exact Laravel-on /
285
+ Symfony-off choice. `resolveSelectedPacks()` filters disabled children at
286
+ submit time, so a remembered-but-gated framework is not installed.
287
+ - **No-autodetect packs.** Some packs are never pre-selected from project
288
+ signals (`python` — a non-engineer may have python installed but not need
289
+ it). The pack stays available to tick manually.
290
+ - **Empty-selection gate.** The AI-tools, roles, and packs steps each block
291
+ Next until at least one effective selection exists (≥ 1 tool, ≥ 1 role, and
292
+ ≥ 1 installable pack respectively).
198
293
 
199
294
  ### `GET /api/v1/wizard/state` payload
200
295
 
@@ -219,6 +219,33 @@ examples instead. No ADR-014 issued.
219
219
 
220
220
  Driven by [`agents/roadmaps/monorepo-phase-1-frontmatter-metadata.md`](../../agents/roadmaps/monorepo-phase-1-frontmatter-metadata.md).
221
221
 
222
+ ### 2026-05-27 — Additive advisory `cluster:` key on packs
223
+
224
+ Pack entries in [`config/discovery/packs.yml`](../../config/discovery/packs.yml)
225
+ may carry an optional `cluster: <language-id>` field. It groups a framework
226
+ pack under a programming-language tile in the setup wizard's capability-packs
227
+ step (e.g. `laravel: cluster: php`, `react: cluster: typescript`). Like
228
+ `requires_hint`, it is **advisory only** — the installer does not act on it; it
229
+ drives the wizard UI's collapsible language→framework grouping. The value must
230
+ be a known pack id (and not self-referential), enforced by
231
+ [`scripts/lint_discovery_vocabulary.py`](../../scripts/lint_discovery_vocabulary.py)
232
+ and emitted into the discovery manifest. Additive, no vocabulary rename.
233
+
234
+ Driven by [`agents/roadmaps/road-to-wizard-ux-improvements.md`](../../agents/roadmaps/road-to-wizard-ux-improvements.md) § Phase 4 (AI-council-resolved: reuse `packs.yml` as the single source of truth rather than a second mapping file).
235
+
236
+ ### 2026-05-27 — Additive advisory `example_roles:` key on workspaces
237
+
238
+ Workspace entries in [`config/discovery/workspaces.yml`](../../config/discovery/workspaces.yml)
239
+ may carry an optional `example_roles: [<title>, …]` list of illustrative job
240
+ titles (e.g. `engineering: [Developer, CTO]`, `finance: [CFO]`). The wizard's
241
+ roles step shows the workspace as the *area* and these titles as examples under
242
+ it. **Advisory and free-form** — NOT a closed vocabulary: the stored
243
+ `.agent-user.yml` `role[]` is the workspace id, never these examples; nothing
244
+ acts on the strings beyond display. Emitted into the discovery manifest;
245
+ allowed by [`discovery-manifest.schema.json`](../contracts/discovery-manifest.schema.json).
246
+ Additive, no vocabulary rename. (Same change cleaned the `finance` label from
247
+ "Finance / CFO" to "Finance".)
248
+
222
249
  ## Cross-references
223
250
 
224
251
  - [ADR-007 — Agent Discovery Scopes](ADR-007-agent-discovery-scopes.md):
@@ -25,7 +25,7 @@ Companion artefacts:
25
25
  - Roadmap: [`agents/roadmaps/road-to-internal-ai-os-deployment.md`](../../agents/roadmaps/road-to-internal-ai-os-deployment.md)
26
26
  - Artefacts: [`packages/core/deploy/`](../../packages/core/deploy/)
27
27
  - Env contract: [`docs/deploy/env-vars.md`](../deploy/env-vars.md)
28
- - Council question (drafted, not invoked — no keys): [`agents/tmp/council-question-deployment-shape.md`](../../agents/tmp/old/council-question-deployment-shape.md)
28
+ - Council question (drafted, not invoked — no keys): [`agents/tmp/council-question-deployment-shape.md`](../../agents/old/council-question-deployment-shape.md)
29
29
  - Predecessor ADR: [`ADR-016`](ADR-016-installer-architecture.md) — installer architecture (agent-mode protocol the GUI server wraps).
30
30
 
31
31
  ## Context
@@ -141,7 +141,7 @@ orchestrator-agnostic.
141
141
  ## Open questions (council-deferred)
142
142
 
143
143
  The accompanying council question file
144
- [`agents/tmp/council-question-deployment-shape.md`](../../agents/tmp/old/council-question-deployment-shape.md)
144
+ [`agents/tmp/council-question-deployment-shape.md`](../../agents/old/council-question-deployment-shape.md)
145
145
  has not yet been run (no provider keys configured). A maintainer with
146
146
  keys should run it and either ratify or supersede this ADR.
147
147
 
@@ -6,7 +6,7 @@
6
6
  > and Phase 3 (central policy) shipping first.
7
7
  >
8
8
  > Open design questions live in
9
- > [`agents/tmp/council-question-connector-scope.md`](../../agents/tmp/old/council-question-connector-scope.md).
9
+ > [`agents/tmp/council-question-connector-scope.md`](../../agents/old/council-question-connector-scope.md).
10
10
 
11
11
  ## Audience
12
12
 
@@ -124,6 +124,6 @@ All of the above land in Phase 5, contingent on Phases 2 + 3.
124
124
  ## Cross-references
125
125
 
126
126
  - 🚧 Reserved ADR slot: `docs/decisions/ADR-025-connector-scope.md`.
127
- - Council question: [`agents/tmp/council-question-connector-scope.md`](../../agents/tmp/old/council-question-connector-scope.md).
127
+ - Council question: [`agents/tmp/council-question-connector-scope.md`](../../agents/old/council-question-connector-scope.md).
128
128
  - Quickstart: [`quickstart.md`](quickstart.md).
129
129
  - Policy cookbook: [`policy-cookbook.md`](policy-cookbook.md).
@@ -7,7 +7,7 @@
7
7
  > before code lands. Every section below is normative-once-shipped.
8
8
  >
9
9
  > Open design questions live in
10
- > [`agents/tmp/council-question-central-policy.md`](../../agents/tmp/old/council-question-central-policy.md).
10
+ > [`agents/tmp/council-question-central-policy.md`](../../agents/old/council-question-central-policy.md).
11
11
 
12
12
  ## Audience
13
13
 
@@ -125,6 +125,6 @@ All of the above land in Phase 3. Until then, per-user
125
125
  ## Cross-references
126
126
 
127
127
  - 🚧 Reserved ADR slot: `docs/decisions/ADR-023-central-policy.md`.
128
- - Council question: [`agents/tmp/council-question-central-policy.md`](../../agents/tmp/old/council-question-central-policy.md).
128
+ - Council question: [`agents/tmp/council-question-central-policy.md`](../../agents/old/council-question-central-policy.md).
129
129
  - Env contract: [`env-vars.md`](env-vars.md) (`POLICY_PATH`).
130
130
  - Quickstart: [`quickstart.md`](quickstart.md).
@@ -8,7 +8,6 @@ role:
8
8
  - founder
9
9
  - engineer
10
10
  style:
11
- formality: "informal"
12
11
  pace: "pragmatic"
13
12
  voice_sample: |
14
13
  Mach das einfach. Wenn unklar, frag im Council. Tokenverbrauch ist ok,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "4.3.0",
3
+ "version": "4.6.0",
4
4
  "description": "Universal AI Agent OS \u2014 audited skills, governance rules, commands, and templates for AI coding tools (Claude Code, Cursor, Windsurf, Copilot).",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -284,6 +284,7 @@ def _build(strict: bool) -> tuple[dict[str, Any], list[dict[str, Any]]]:
284
284
  "description": w["description"],
285
285
  "default_packs": list(w.get("default_packs") or []),
286
286
  **({"optional_packs": list(w["optional_packs"])} if w.get("optional_packs") else {}),
287
+ **({"example_roles": list(w["example_roles"])} if w.get("example_roles") else {}),
287
288
  }
288
289
  for w in workspaces
289
290
  ]
@@ -302,6 +303,8 @@ def _build(strict: bool) -> tuple[dict[str, Any], list[dict[str, Any]]]:
302
303
  }
303
304
  if p.get("requires_hint"):
304
305
  item["requires_hint"] = list(p["requires_hint"])
306
+ if p.get("cluster"):
307
+ item["cluster"] = p["cluster"]
305
308
  pk_out.append(item)
306
309
 
307
310
  if strict and unassigned:
@@ -546,6 +549,7 @@ def _packs_view(manifest: dict[str, Any]) -> dict[str, Any]:
546
549
  "description": p["description"],
547
550
  "workspaces": list(p.get("workspaces", [])),
548
551
  "requires_hint": list(p.get("requires_hint", [])),
552
+ "cluster": p.get("cluster"),
549
553
  "trust_level_default": p.get("trust_level_default"),
550
554
  "artefact_count": len(ids),
551
555
  "artefacts": ids,
@@ -1060,6 +1060,59 @@ def generate_user_type_symlinks() -> int:
1060
1060
  return total
1061
1061
 
1062
1062
 
1063
+ def generate_plugin_hooks() -> int:
1064
+ """Generate ``hooks/hooks.json`` at the plugin root from the hook manifest.
1065
+
1066
+ Claude Code plugins auto-discover hooks at ``<plugin-root>/hooks/hooks.json``
1067
+ (Plugins reference). The agent-config plugin's source is the repo root
1068
+ (``.claude-plugin/marketplace.json``), so the file lands at
1069
+ ``PROJECT_ROOT/hooks/hooks.json``.
1070
+
1071
+ Delivering the Claude lifecycle hooks via **plugin scope** — instead of
1072
+ writing them into the shared ``.claude/settings.json`` ``hooks`` array —
1073
+ means no shared ``hooks`` array in any settings file, so there is no
1074
+ collision with a neighbour tool's hooks or with a developer's
1075
+ ``settings.local.json``. Claude Code merges plugin-scope and
1076
+ settings-scope hooks and dedups by command string.
1077
+
1078
+ The command is rooted at ``$CLAUDE_PROJECT_DIR`` so it resolves from
1079
+ plugin scope regardless of cwd; the universal dispatcher then reads
1080
+ ``scripts/hook_manifest.yaml`` at runtime to fan out to the active
1081
+ concerns (each a no-op when its feature is disabled).
1082
+ """
1083
+ manifest_path = PROJECT_ROOT / "scripts" / "hook_manifest.yaml"
1084
+ if not manifest_path.exists():
1085
+ print(" ⚠️ scripts/hook_manifest.yaml not found — skipping plugin hooks",
1086
+ file=sys.stderr)
1087
+ return 0
1088
+
1089
+ manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
1090
+ claude_events = manifest.get("platforms", {}).get("claude", {}) or {}
1091
+ aliases = manifest.get("native_event_aliases", {}).get("claude", {}) or {}
1092
+ # Reverse the native→agent-config map so we can emit native event names.
1093
+ ac_to_native = {ac: native for native, ac in aliases.items()}
1094
+
1095
+ hooks: dict[str, list] = {}
1096
+ for ac_event, concerns in claude_events.items():
1097
+ if not concerns:
1098
+ continue
1099
+ native = ac_to_native.get(ac_event)
1100
+ if native is None:
1101
+ continue
1102
+ command = (
1103
+ '"$CLAUDE_PROJECT_DIR"/agent-config dispatch:hook '
1104
+ f"--platform claude --event {ac_event} --native-event {native}"
1105
+ )
1106
+ hooks[native] = [{"hooks": [{"type": "command", "command": command}]}]
1107
+
1108
+ hooks_dir = PROJECT_ROOT / "hooks"
1109
+ hooks_dir.mkdir(parents=True, exist_ok=True)
1110
+ out = hooks_dir / "hooks.json"
1111
+ out.write_text(json.dumps({"hooks": hooks}, indent=2) + "\n", encoding="utf-8")
1112
+ info(f" ✅ Generated hooks/hooks.json ({len(hooks)} Claude plugin hooks)")
1113
+ return len(hooks)
1114
+
1115
+
1063
1116
  def generate_tools() -> None:
1064
1117
  """Generate all tool-specific directories and files.
1065
1118
 
@@ -1074,6 +1127,7 @@ def generate_tools() -> None:
1074
1127
  generate_gemini_md()
1075
1128
  skills = generate_claude_skills() if _tool_active("claude-code") else 0
1076
1129
  commands = generate_claude_commands() if _tool_active("claude-code") else 0
1130
+ plugin_hooks = generate_plugin_hooks() if _tool_active("claude-code") else 0
1077
1131
  personas = generate_persona_symlinks()
1078
1132
  user_types = generate_user_type_symlinks()
1079
1133
  cursor_mdc = generate_cursor_mdc_rules() if _tool_active("cursor") else 0
@@ -1082,7 +1136,8 @@ def generate_tools() -> None:
1082
1136
  windsurf_wf = generate_windsurf_workflows() if _tool_active("windsurf") else 0
1083
1137
  summary = (
1084
1138
  f"✅ generate-tools — rules={rules} skills={skills} "
1085
- f"commands={commands} personas={personas} user_types={user_types} "
1139
+ f"commands={commands} plugin_hooks={plugin_hooks} "
1140
+ f"personas={personas} user_types={user_types} "
1086
1141
  f"cursor_mdc={cursor_mdc} windsurf_rules={windsurf_modern} "
1087
1142
  f"cursor_commands={cursor_cmds} windsurf_workflows={windsurf_wf} "
1088
1143
  f"windsurfrules={windsurfrules}"
@@ -37,6 +37,11 @@ RE_NUMBERED = re.compile(r"^>?\s*\d+\.\s")
37
37
  RE_STATUS = re.compile(r"^\s*(?:❌|⚠️|✅)")
38
38
  RE_IRONLAW = re.compile(r"^[A-Z][A-Z0-9 ,.\-_/']{3,}$")
39
39
  RE_BACKTICK_SPAN = re.compile(r"`[^`\n]+`")
40
+ # Spans frozen byte-for-byte inside prose lines. Backtick spans first so a
41
+ # slash-bearing span inside backticks is captured whole; then any non-whitespace
42
+ # run containing `/` or `\` — bare URLs, markdown link/image targets, and file
43
+ # paths whose segments (`is`, `the`, `a`) would otherwise be eaten as drop-tokens.
44
+ RE_PRESERVE_SPAN = re.compile(r"`[^`\n]+`|\S*[/\\]\S*")
40
45
  RE_FRONTMATTER = re.compile(r"^---\s*$")
41
46
  WORD_RE = re.compile(r"\b[A-Za-z]+\b")
42
47
  DROP_TOKENS = {"the", "a", "an", "is", "are", "was", "were", "be", "been",
@@ -50,10 +55,11 @@ def _condense_words(text: str) -> str:
50
55
 
51
56
 
52
57
  def _condense_prose_line(line: str) -> str:
53
- """Condense a prose line; preserve backtick-spans byte-for-byte."""
58
+ """Condense a prose line; preserve backtick spans, URLs, link targets, and
59
+ slash-bearing paths byte-for-byte (see ``RE_PRESERVE_SPAN``)."""
54
60
  parts: list[str] = []
55
61
  last = 0
56
- for span in RE_BACKTICK_SPAN.finditer(line):
62
+ for span in RE_PRESERVE_SPAN.finditer(line):
57
63
  parts.append(_condense_words(line[last:span.start()]))
58
64
  parts.append(span.group(0))
59
65
  last = span.end()
@@ -32,6 +32,7 @@ import os
32
32
  import re
33
33
  import shlex
34
34
  import shutil
35
+ import signal
35
36
  import subprocess
36
37
  import sys
37
38
  import threading
@@ -1082,52 +1083,25 @@ def ensure_augment_user_hooks(package_root: Path, force: bool) -> list[dict[str,
1082
1083
  # concern fires on the same logical surface across platforms — the
1083
1084
  # contract from agents/settings/contexts/hardening-pattern.md § Cross-platform
1084
1085
  # parity.
1085
- CLAUDE_DISPATCHER_BINDINGS = (
1086
- ("session_start", "SessionStart"),
1087
- ("session_end", "SessionEnd"),
1088
- ("stop", "Stop"),
1089
- ("user_prompt_submit", "UserPromptSubmit"),
1090
- ("post_tool_use", "PostToolUse"),
1091
- )
1092
-
1093
-
1094
- def _claude_dispatch_block(ac_event: str, native: str) -> dict:
1095
- """Single hook entry routing the event through the universal dispatcher."""
1096
- return {
1097
- "hooks": [
1098
- {
1099
- "type": "command",
1100
- "command": (
1101
- f"./agent-config dispatch:hook "
1102
- f"--platform claude --event {ac_event} "
1103
- f"--native-event {native}"
1104
- ),
1105
- },
1106
- ],
1107
- }
1108
-
1109
-
1110
1086
  def ensure_claude_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
1111
- """Deploy .claude/settings.json with plugin enablement and the Phase 7
1112
- universal dispatcher hooks.
1113
-
1114
- Each Claude Code lifecycle event is wired to a single
1115
- `./agent-config dispatch:hook` invocation. The dispatcher reads
1116
- scripts/hook_manifest.yaml at runtime and runs the resolved concern
1117
- chain concerns are no-ops when the relevant feature is disabled
1118
- in .agent-settings.yml. Idempotent: reruns merge cleanly without
1119
- duplicating entries (deep_merge replaces hook arrays rather than
1120
- appending).
1087
+ """Deploy .claude/settings.json with plugin enablement only.
1088
+
1089
+ Claude lifecycle hooks are delivered via **plugin scope**
1090
+ (`hooks/hooks.json`, generated by `condense.generate_plugin_hooks`), not
1091
+ written into the shared `.claude/settings.json` `hooks` array. Writing
1092
+ them here would monopolise that array and collide with any neighbour
1093
+ tool's hooks or a developer's `settings.local.json` Claude Code merges
1094
+ plugin-scope and settings-scope hooks at runtime and dedups by command
1095
+ string, so plugin delivery needs no `hooks` block here.
1096
+
1097
+ The plugin id matches `.claude-plugin/marketplace.json`
1098
+ (`<plugin>@<marketplace>` = `agent-config@event4u-agent-config`) and the
1099
+ documented install command in docs/installation.md. Idempotent:
1100
+ `enabledPlugins` is a dict-merge, so the key coexists with any other
1101
+ plugin a neighbour tool enabled.
1121
1102
  """
1122
- per_event: dict[str, list] = {}
1123
- for ac_event, native in CLAUDE_DISPATCHER_BINDINGS:
1124
- per_event.setdefault(native, []).append(
1125
- _claude_dispatch_block(ac_event, native)
1126
- )
1127
-
1128
1103
  bridge = {
1129
- "enabledPlugins": {"agent-conf@event4u": True},
1130
- "hooks": per_event,
1104
+ "enabledPlugins": {"agent-config@event4u-agent-config": True},
1131
1105
  }
1132
1106
  return merge_json_file(
1133
1107
  project_root / ".claude" / "settings.json", bridge, force, ".claude/settings.json",
@@ -4335,6 +4309,75 @@ def _wizard_cli_dist(project_root: Path) -> Path | None:
4335
4309
  return cli if cli.exists() else None
4336
4310
 
4337
4311
 
4312
+ def _server_info_path() -> Path:
4313
+ """Path of the running-server record written by `ui:serve`."""
4314
+ return Path.home() / ".event4u" / "agent-config" / "local-server.json"
4315
+
4316
+
4317
+ def _pid_is_agent_config(pid: int) -> bool:
4318
+ """Best-effort check that `pid` is one of our wizard servers.
4319
+
4320
+ Guards against signalling an unrelated process that recycled the pid.
4321
+ Uses `ps` (POSIX); on platforms without it we conservatively return
4322
+ False so we never kill the wrong process.
4323
+ """
4324
+ try:
4325
+ out = subprocess.run( # noqa: S603,S607 - fixed argv, pid is an int
4326
+ ["ps", "-p", str(pid), "-o", "command="],
4327
+ capture_output=True,
4328
+ text=True,
4329
+ timeout=5,
4330
+ check=False,
4331
+ )
4332
+ except (OSError, subprocess.SubprocessError):
4333
+ return False
4334
+ return "agent-config" in out.stdout.lower()
4335
+
4336
+
4337
+ def _kill_stale_wizard_server() -> None:
4338
+ """Terminate a previously-launched wizard server, if one is recorded.
4339
+
4340
+ `agent-config init` should always start fresh: a stale server (left
4341
+ from an earlier run) is stopped so the new instance owns the port and
4342
+ the wizard re-enters at step 1. Best-effort — every failure is ignored.
4343
+ """
4344
+ path = _server_info_path()
4345
+ try:
4346
+ info = json.loads(path.read_text(encoding="utf-8"))
4347
+ except (OSError, ValueError):
4348
+ return
4349
+ pid = info.get("pid")
4350
+ if not isinstance(pid, int):
4351
+ path.unlink(missing_ok=True)
4352
+ return
4353
+ try:
4354
+ os.kill(pid, 0) # liveness probe
4355
+ except OSError:
4356
+ path.unlink(missing_ok=True) # already gone
4357
+ return
4358
+ if not _pid_is_agent_config(pid):
4359
+ return # pid reused by an unrelated process — leave it alone
4360
+ try:
4361
+ os.kill(pid, signal.SIGTERM)
4362
+ except OSError:
4363
+ path.unlink(missing_ok=True)
4364
+ return
4365
+ # Wait up to ~3s for a graceful exit, then force-kill.
4366
+ for _ in range(30):
4367
+ try:
4368
+ os.kill(pid, 0)
4369
+ except OSError:
4370
+ break
4371
+ time.sleep(0.1)
4372
+ else:
4373
+ try:
4374
+ os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
4375
+ except OSError:
4376
+ pass
4377
+ path.unlink(missing_ok=True)
4378
+ print("(Stopped the previous wizard server.)")
4379
+
4380
+
4338
4381
  def _wizard_spawn(project_root: Path) -> int:
4339
4382
  """Spawn the wizard, await readiness, hand off to the child.
4340
4383
 
@@ -4343,6 +4386,9 @@ def _wizard_spawn(project_root: Path) -> int:
4343
4386
  Never raises into the parent — every error surfaces as a printed
4344
4387
  fallback line and a 0 return.
4345
4388
  """
4389
+ # Always start fresh: stop any server left running by a prior init.
4390
+ _kill_stale_wizard_server()
4391
+
4346
4392
  cli = _wizard_cli_dist(project_root)
4347
4393
  if cli is None:
4348
4394
  print(
@@ -101,6 +101,14 @@ def lint(quiet: bool) -> int:
101
101
  for hint in pk.get("requires_hint", []) or []:
102
102
  if hint not in pack_ids:
103
103
  errors.append(f"packs.yml '{pid}'.requires_hint → unknown pack '{hint}'")
104
+ # cluster (road-to-wizard-ux-improvements § Phase 4): advisory wizard
105
+ # grouping; the value must be a known pack id and not self-referential.
106
+ cluster = pk.get("cluster")
107
+ if cluster is not None:
108
+ if cluster not in pack_ids:
109
+ errors.append(f"packs.yml '{pid}'.cluster → unknown pack '{cluster}'")
110
+ elif cluster == pid:
111
+ errors.append(f"packs.yml '{pid}'.cluster → must not reference itself")
104
112
 
105
113
  # 4. Bidirectional integrity (council HIGH fold-in).
106
114
  pack_by_id = {pk.get("id"): pk for pk in packs}