@event4u/agent-config 4.7.2 → 4.9.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.
@@ -1083,6 +1083,49 @@ def ensure_augment_user_hooks(package_root: Path, force: bool) -> list[dict[str,
1083
1083
  # concern fires on the same logical surface across platforms — the
1084
1084
  # contract from agents/settings/contexts/hardening-pattern.md § Cross-platform
1085
1085
  # parity.
1086
+ # Canonical Claude Code plugin id — must match `.claude-plugin/marketplace.json`
1087
+ # (`<plugin>@<marketplace>` = `agent-config` + `event4u-agent-config`) and the
1088
+ # install command documented in `docs/installation.md`.
1089
+ CLAUDE_PLUGIN_ID = "agent-config@event4u-agent-config"
1090
+
1091
+ # Stale plugin ids written by pre-4.x installer versions. The bridge removes
1092
+ # any of these from `enabledPlugins` on rerun so the canonical id alone
1093
+ # survives. Claude Code silently ignores unresolvable ids (no marketplace
1094
+ # match), so a stale entry leaves the plugin inactive without an error path
1095
+ # the user can see — the heal is the only feedback loop.
1096
+ CLAUDE_LEGACY_PLUGIN_IDS: tuple[str, ...] = (
1097
+ "agent-conf@event4u", # abbreviated form — never matched a real marketplace
1098
+ "agent-config@event4u", # pre-marketplace-rename form (missing `-agent-config` suffix)
1099
+ )
1100
+
1101
+
1102
+ def _heal_legacy_claude_plugin_ids(path: Path) -> list[str]:
1103
+ """Remove known-stale plugin ids from `.claude/settings.json` in place.
1104
+
1105
+ Reads the existing settings file, drops any `enabledPlugins` key whose
1106
+ id appears in `CLAUDE_LEGACY_PLUGIN_IDS`, and writes the file back
1107
+ when anything changed. Returns the list of removed ids so the caller
1108
+ can surface a `success(...)` per heal and treat the operation as a
1109
+ forced refresh for the subsequent `merge_json_file` call.
1110
+
1111
+ No-ops when the file is absent, malformed, or carries no stale ids.
1112
+ The canonical id is left untouched.
1113
+ """
1114
+ if not path.exists():
1115
+ return []
1116
+ data = read_json_file(path)
1117
+ enabled = data.get("enabledPlugins")
1118
+ if not isinstance(enabled, dict):
1119
+ return []
1120
+ removed = [pid for pid in CLAUDE_LEGACY_PLUGIN_IDS if pid in enabled]
1121
+ if not removed:
1122
+ return []
1123
+ for pid in removed:
1124
+ del enabled[pid]
1125
+ write_json_file(path, data)
1126
+ return removed
1127
+
1128
+
1086
1129
  def ensure_claude_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
1087
1130
  """Deploy .claude/settings.json with plugin enablement only.
1088
1131
 
@@ -1099,12 +1142,22 @@ def ensure_claude_bridge(project_root: Path, force: bool) -> list[dict[str, Any]
1099
1142
  documented install command in docs/installation.md. Idempotent:
1100
1143
  `enabledPlugins` is a dict-merge, so the key coexists with any other
1101
1144
  plugin a neighbour tool enabled.
1145
+
1146
+ Stale-id heal: before the merge, any pre-4.x ids listed in
1147
+ `CLAUDE_LEGACY_PLUGIN_IDS` are removed from `enabledPlugins`. A heal
1148
+ self-authorises the corrective merge — the merge runs with effective
1149
+ force so the canonical id lands in the same install, even without
1150
+ `--force` on the CLI.
1102
1151
  """
1152
+ target = project_root / ".claude" / "settings.json"
1153
+ healed = _heal_legacy_claude_plugin_ids(target)
1154
+ for pid in healed:
1155
+ success(f".claude/settings.json: removed stale plugin id `{pid}`")
1103
1156
  bridge = {
1104
- "enabledPlugins": {"agent-config@event4u-agent-config": True},
1157
+ "enabledPlugins": {CLAUDE_PLUGIN_ID: True},
1105
1158
  }
1106
1159
  return merge_json_file(
1107
- project_root / ".claude" / "settings.json", bridge, force, ".claude/settings.json",
1160
+ target, bridge, force or bool(healed), ".claude/settings.json",
1108
1161
  )
1109
1162
 
1110
1163
 
@@ -2215,6 +2268,17 @@ PROJECT_BRIDGE_MARKERS = {
2215
2268
  _CLAUDE_SKILL_BUNDLE: list[tuple[str, str]] = [
2216
2269
  (".agent-src/rules", "rules"),
2217
2270
  (".agent-src/skills", "skills"),
2271
+ # Commands ship to the native Claude Code user-scope slash-command
2272
+ # surface: `~/.claude/commands/<cluster>/<sub>.md` resolves as
2273
+ # `/<cluster>:<sub>` per Claude Code's filesystem-channel convention
2274
+ # (verified empirically 2026-05-28: top-level + nested + rich
2275
+ # frontmatter all route; heavyweight commands carrying
2276
+ # `disable-model-invocation: true` stay invokable when typed but are
2277
+ # hidden from auto-complete — desired UX). Council session
2278
+ # 2026-05-28 (claude-sonnet-4-5 + gpt-4o, design mode) verdict
2279
+ # Option B (native slash-only). See
2280
+ # `agents/runtime/council/responses/claude-code-distribution.json`.
2281
+ (".agent-src/commands", "commands"),
2218
2282
  (".agent-src/personas", "personas"),
2219
2283
  ]
2220
2284
  GLOBAL_DEPLOY_SOURCES: dict[str, list[tuple[str, str]]] = {
@@ -2956,20 +3020,94 @@ MIGRATE_LEGACY_YAML_FILES = (".agent-settings.yml", ".agent-user.yml")
2956
3020
  MIGRATE_LEGACY_TOOL_DIRS = (".augment", ".claude", ".cursor")
2957
3021
 
2958
3022
 
3023
+ # Package identity used by the maintainer auto-detect. Matches the npm
3024
+ # package name declared in ``package.json`` at the agent-config source
3025
+ # repo root. Refreshing this value requires a coordinated rename
3026
+ # (package.json + this constant + the publish pipeline).
3027
+ AGENT_CONFIG_PACKAGE_NAME = "@event4u/agent-config"
3028
+
3029
+
3030
+ def _is_agent_config_source_repo(project_root: Path) -> tuple[bool, str]:
3031
+ """Return ``(is_source_repo, signature)`` for the maintainer auto-detect.
3032
+
3033
+ Phase Q1 of road-to-claude-code-global-distribution (council Option D —
3034
+ Hybrid auto-detect): treat any of these high-specificity signatures as
3035
+ proof that ``project_root`` is the agent-config source repo, not a
3036
+ consumer project. Hits skip the ADR-020 migration prompt automatically
3037
+ so a maintainer running the wizard does not get their working tree
3038
+ moved into ``.legacy-pre-global-only/``.
3039
+
3040
+ Signatures, in order of cost (cheap-first short-circuit):
3041
+
3042
+ 1. ``package.json`` declares ``"name": "@event4u/agent-config"``.
3043
+ Strongest signal — the npm-published identity of this repo.
3044
+ 2. ``.agent-src.uncondensed/`` exists at ``project_root`` (legacy
3045
+ layout) OR under ``packages/*/`` (current layout). Both shapes
3046
+ are unique to the source repo.
3047
+ 3. ``scripts/install.py`` exists at ``project_root`` AND the file
3048
+ name matches this module (``__file__``). Self-referential — if
3049
+ the installer code path is reading itself, the cwd is the repo
3050
+ that owns the installer.
3051
+
3052
+ The user can force consumer behaviour via ``AGENT_CONFIG_CONSUMER_MODE=1``
3053
+ when testing the wizard's consumer flow from inside the maintainer
3054
+ checkout (end-to-end QA path).
3055
+ """
3056
+ if os.environ.get("AGENT_CONFIG_CONSUMER_MODE") == "1":
3057
+ return False, "consumer-mode-override"
3058
+
3059
+ pkg_json = project_root / "package.json"
3060
+ if pkg_json.is_file():
3061
+ try:
3062
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
3063
+ except (json.JSONDecodeError, OSError):
3064
+ data = {}
3065
+ if isinstance(data, dict) and data.get("name") == AGENT_CONFIG_PACKAGE_NAME:
3066
+ return True, "package.json:name"
3067
+
3068
+ if (project_root / ".agent-src.uncondensed").is_dir():
3069
+ return True, ".agent-src.uncondensed/"
3070
+ packages_dir = project_root / "packages"
3071
+ if packages_dir.is_dir():
3072
+ for child in packages_dir.iterdir():
3073
+ if (child / ".agent-src.uncondensed").is_dir():
3074
+ return True, f"packages/{child.name}/.agent-src.uncondensed/"
3075
+
3076
+ installer_self = project_root / "scripts" / "install.py"
3077
+ try:
3078
+ if installer_self.is_file() and installer_self.resolve() == Path(__file__).resolve():
3079
+ return True, "scripts/install.py (self)"
3080
+ except OSError:
3081
+ pass
3082
+
3083
+ return False, ""
3084
+
3085
+
2959
3086
  def _detect_legacy_for_migration(project_root: Path) -> list[str]:
2960
3087
  """Return a sorted list of legacy artefact relpaths present in ``project_root``.
2961
3088
 
2962
3089
  Skipped (returns ``[]``) when:
2963
3090
 
2964
3091
  - ``AGENT_CONFIG_DEV_MODE=1`` is set (maintainer dogfood loop),
2965
- - the project root IS the agent-config source repo
2966
- (``.agent-src.uncondensed/`` present),
3092
+ - ``project_root`` IS the agent-config source repo per
3093
+ :func:`_is_agent_config_source_repo` (council Option D auto-detect),
2967
3094
  - the bridge marker already exists (project is already global-only).
2968
3095
  """
2969
3096
  if os.environ.get("AGENT_CONFIG_DEV_MODE") == "1":
2970
3097
  return []
2971
- if (project_root / ".agent-src.uncondensed").is_dir():
3098
+
3099
+ is_source, signature = _is_agent_config_source_repo(project_root)
3100
+ if is_source:
3101
+ if not QUIET:
3102
+ warn(
3103
+ "Maintainer mode auto-detected — agent-config source repo "
3104
+ f"(signature: {signature}). Skipping ADR-020 migration "
3105
+ "prompt; the working tree stays intact. Set "
3106
+ "AGENT_CONFIG_CONSUMER_MODE=1 to override for end-to-end "
3107
+ "consumer-flow testing."
3108
+ )
2972
3109
  return []
3110
+
2973
3111
  if (project_root / CONSUMER_BRIDGE_MARKER_RELPATH).is_file():
2974
3112
  return []
2975
3113
 
@@ -3478,10 +3616,65 @@ def _deploy_global_content(
3478
3616
  written_total += w
3479
3617
  skipped_total += s
3480
3618
  written_paths.extend(paths)
3619
+ # Phase 5 (road-to-claude-code-global-distribution): postcheck.
3620
+ # Every entry in the deploy plan must end with the destination
3621
+ # subpath populated — directory exists AND is non-empty. A
3622
+ # silent partial deploy (no exception raised, no files written
3623
+ # for one of the bundle entries) is the silent-failure class
3624
+ # this phase exists to surface.
3625
+ missing_targets = _verify_deploy_targets(anchor, plan)
3626
+ if missing_targets:
3627
+ if not QUIET:
3628
+ warn(
3629
+ f"{tool_id}: deploy postcheck failed — "
3630
+ f"missing/empty: {', '.join(missing_targets)}"
3631
+ )
3632
+ _emit_progress({
3633
+ "type": "verify_failed",
3634
+ "tool": tool_id,
3635
+ "missing": missing_targets,
3636
+ })
3637
+ results[tool_id] = (
3638
+ written_total, skipped_total, "deploy_failed", written_paths,
3639
+ )
3640
+ continue
3641
+ _emit_progress({"type": "verified", "tool": tool_id})
3481
3642
  results[tool_id] = (written_total, skipped_total, "deployed", written_paths)
3482
3643
  return results
3483
3644
 
3484
3645
 
3646
+ def _verify_deploy_targets(
3647
+ anchor: Path, plan: list[tuple[str, str]],
3648
+ ) -> list[str]:
3649
+ """Return the deploy-plan destination subpaths that did NOT materialise.
3650
+
3651
+ A deploy plan entry ``(src_rel, dest_sub)`` is verified by checking
3652
+ that ``anchor / dest_sub`` (or ``anchor`` when ``dest_sub`` is empty)
3653
+ exists as a directory AND contains at least one entry. An empty
3654
+ directory counts as a failure — the agent-config bundle never
3655
+ legitimately ships an empty subtree, so "empty after deploy" means
3656
+ the copy step silently produced nothing.
3657
+
3658
+ Returns the list of failing ``dest_sub`` values (empty string
3659
+ rewritten to ``"."`` for log clarity). An empty return list means
3660
+ every expected target is populated.
3661
+ """
3662
+ missing: list[str] = []
3663
+ for _, dest_sub in plan:
3664
+ target = anchor / dest_sub if dest_sub else anchor
3665
+ label = dest_sub or "."
3666
+ if not target.is_dir():
3667
+ missing.append(label)
3668
+ continue
3669
+ try:
3670
+ next(target.iterdir())
3671
+ except StopIteration:
3672
+ missing.append(label)
3673
+ except OSError:
3674
+ missing.append(label)
3675
+ return missing
3676
+
3677
+
3485
3678
  def install_global(
3486
3679
  tools: set[str],
3487
3680
  force: bool,
@@ -3525,20 +3718,34 @@ def install_global(
3525
3718
  installed_version = lock_mod.current_package_version()
3526
3719
  read_path = lock_mod.lockfile_path()
3527
3720
  write_path = lock_mod.lockfile_write_path()
3528
- ok, recorded = lock_mod.check_version(installed_version, path=read_path)
3529
-
3530
- if not ok and not force:
3721
+ _, recorded = lock_mod.check_version(installed_version, path=read_path)
3722
+ classification = lock_mod.classify_mismatch(installed_version, recorded)
3723
+
3724
+ # Phase 2 (roadmap road-to-claude-code-global-distribution.md): a stale
3725
+ # lockfile recording a *lower* (or unparseable legacy) version is the
3726
+ # upgrade path — auto-heal by claiming the new version slot and
3727
+ # continuing the install. Only a recorded version *higher* than the
3728
+ # running package is treated as a downgrade and still requires --force.
3729
+ # This kills the silent-refusal trap where users on pre-2.x (recorded:
3730
+ # 1.42.0) installs hit `install.py:3530` and exit 1 without ever
3731
+ # touching `~/.claude/`.
3732
+ if classification == "downgrade" and not force:
3531
3733
  if not QUIET:
3532
3734
  print()
3533
- warn("Refusing global install: lockfile version mismatch.")
3735
+ warn("Refusing global install: lockfile records a newer version.")
3534
3736
  info(f" Lockfile: {read_path}")
3535
3737
  info(f" Recorded version: {recorded}")
3536
3738
  info(f" Current package: {installed_version}")
3537
- info(" Fix: run `agent-config update`")
3538
- info(" Override: re-run with `--force` (replaces the lockfile)")
3739
+ info(" Fix: upgrade the package, or re-run with `--force`")
3539
3740
  print()
3540
3741
  return 1
3541
3742
 
3743
+ if classification in ("upgrade", "unparseable") and not QUIET:
3744
+ info(
3745
+ f"🔄 Upgrading lockfile from {recorded} to {installed_version}, "
3746
+ "redeploying tools"
3747
+ )
3748
+
3542
3749
  if not QUIET:
3543
3750
  print()
3544
3751
  info("Agent Config — Global (user-scope) install [ADR-007]")
@@ -3549,6 +3756,13 @@ def install_global(
3549
3756
  continue
3550
3757
  print(f" {tool_id:<15} → {anchor}")
3551
3758
 
3759
+ # Claim the version slot BEFORE the deploy (Phase 2 Step 2 of the
3760
+ # road-to-claude-code-global-distribution roadmap). The deploy can
3761
+ # fail mid-way; the lockfile must stay on the new version regardless
3762
+ # so subsequent re-runs do not relitigate the upgrade refusal. A
3763
+ # partial deploy retries cleanly on the next invocation — the
3764
+ # lockfile staying stuck on an ancient version is the worse failure
3765
+ # mode this Phase exists to eliminate.
3552
3766
  existing = lock_mod.read_lockfile(path=read_path) or {}
3553
3767
  existing_tools = list(existing.get("tools", []))
3554
3768
  merged_tools = sorted(set(existing_tools) | set(tools))
@@ -3566,6 +3780,28 @@ def install_global(
3566
3780
  package_root = _resolve_package_root_for_global()
3567
3781
  deploy_results = _deploy_global_content(tools, force, package_root, written)
3568
3782
 
3783
+ # Phase 5 (road-to-claude-code-global-distribution): postcheck-driven
3784
+ # lockfile correction. A tool whose deploy postcheck failed
3785
+ # (status="deploy_failed") MUST NOT remain in the lockfile's tools
3786
+ # list — claiming "tool X is installed" without the content on disk
3787
+ # is the silent-failure class this phase exists to surface. Tools
3788
+ # already recorded by a prior successful install stay (the
3789
+ # `failed_tools` set only filters this run's newly-attempted tools).
3790
+ failed_tools = {
3791
+ tool_id
3792
+ for tool_id, (_, _, status, _) in deploy_results.items()
3793
+ if status == "deploy_failed"
3794
+ }
3795
+ if failed_tools:
3796
+ corrected_tools = sorted(set(merged_tools) - failed_tools)
3797
+ if corrected_tools != merged_tools:
3798
+ lock_mod.write_lockfile(installed_version, corrected_tools, path=write_path)
3799
+ if not QUIET:
3800
+ warn(
3801
+ "Lockfile corrected after deploy postcheck — dropped "
3802
+ f"{', '.join(sorted(failed_tools))} (verification failed)."
3803
+ )
3804
+
3569
3805
  # NDJSON progress for the wizard --apply-payload real-apply bridge. One
3570
3806
  # `file` frame per deployed tool unit (coarse, per AI-council 2026-05-27);
3571
3807
  # the GUI maps these to its SSE progress frames. No-op under normal CLI