@event4u/agent-config 5.9.0 → 5.10.1

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 (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +38 -234
  3. package/README.md +13 -6
  4. package/config/agent-settings.template.yml +17 -0
  5. package/config/gitignore-block.txt +7 -0
  6. package/dist/cli/registry.js +1 -0
  7. package/dist/cli/registry.js.map +1 -1
  8. package/dist/discovery/deprecation-report.md +1 -1
  9. package/dist/discovery/discovery-manifest.json +1 -1
  10. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  11. package/dist/discovery/discovery-manifest.summary.md +1 -1
  12. package/dist/discovery/orphan-report.md +1 -1
  13. package/dist/discovery/packs.json +1 -1
  14. package/dist/discovery/trust-report.md +1 -1
  15. package/dist/discovery/workspaces.json +1 -1
  16. package/dist/mcp/registry-manifest.json +1 -1
  17. package/dist/server/schemas/settings.js +4 -0
  18. package/dist/server/schemas/settings.js.map +1 -1
  19. package/dist/ui/assets/index-DcAWIwwY.js +40 -0
  20. package/dist/ui/assets/index-DcAWIwwY.js.map +1 -0
  21. package/dist/ui/index.html +1 -1
  22. package/docs/archive/CHANGELOG-pre-5.9.0.md +270 -0
  23. package/docs/decisions/ADR-040-execution-model-projection-time-filtering.md +244 -0
  24. package/docs/decisions/INDEX.md +1 -0
  25. package/docs/distribution/registries.md +1 -0
  26. package/docs/profiles.md +8 -0
  27. package/docs/wizard.md +25 -8
  28. package/package.json +1 -1
  29. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  30. package/scripts/_cli/cmd_refresh.py +7 -1
  31. package/scripts/_cli/cmd_upgrade.py +31 -1
  32. package/scripts/_dispatch.bash +11 -0
  33. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  34. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  35. package/scripts/_lib/cli_wrapper.py +64 -0
  36. package/scripts/ai-video/lib/probe-audio.sh +20 -5
  37. package/scripts/hook_manifest.yaml +16 -7
  38. package/scripts/profile_use.py +125 -0
  39. package/scripts/wrapper_freshness_hook.py +86 -0
  40. package/dist/ui/assets/index-5lFqAKL0.js +0 -40
  41. package/dist/ui/assets/index-5lFqAKL0.js.map +0 -1
@@ -84,7 +84,7 @@ def _is_source_repo(project_root: Path) -> bool:
84
84
  def _refresh_project(project_root: Path, out, err) -> int:
85
85
  # Imported lazily: scripts.install is large and only needed for --project.
86
86
  from scripts import install as installer
87
- from scripts._lib import installed_lock
87
+ from scripts._lib import cli_wrapper, installed_lock
88
88
 
89
89
  if _is_source_repo(project_root):
90
90
  print("ℹ️ refresh --project skipped: this is the agent-config package "
@@ -114,6 +114,12 @@ def _refresh_project(project_root: Path, out, err) -> int:
114
114
  encoding="utf-8")
115
115
  print(f"✅ overrides scaffold: {overrides}", file=out)
116
116
 
117
+ # Re-stamp the ``./agent-config`` wrapper from the canonical template so
118
+ # an older, fallback-less wrapper cannot linger and break the hooks.
119
+ wrapper = cli_wrapper.install_cli_wrapper(project_root)
120
+ if wrapper is not None:
121
+ print(f"✅ ./agent-config wrapper refreshed: {wrapper}", file=out)
122
+
117
123
  rc = _sync_gitignore(project_root, out, err)
118
124
  if rc != 0:
119
125
  return rc
@@ -8,12 +8,16 @@ goal: it installs the latest published package globally and re-runs the
8
8
  global install so new skills / rules / hooks reach every consumer at
9
9
  once.
10
10
 
11
- Two side effects, in order:
11
+ Side effects, in order:
12
12
 
13
13
  1. ``npm install -g @event4u/agent-config@latest`` — refresh the global
14
14
  binary on PATH (the binary the Claude plugin hook resolves).
15
15
  2. ``agent-config global`` (→ ``install.py --global``) — refresh the
16
16
  global root (``~/.event4u/agent-config/``) + regenerate plugin hooks.
17
+ 3. If run from inside a consumer project that already has a
18
+ ``./agent-config`` wrapper, re-stamp that wrapper from the canonical
19
+ template so an older, fallback-less copy cannot linger and break the
20
+ hooks. Skipped in the source repo and never creates a wrapper.
17
21
 
18
22
  The **Claude marketplace plugin** updates on Claude Code's own cadence,
19
23
  independent of npm; ``upgrade`` cannot drive it. ``agent-config doctor``
@@ -72,12 +76,36 @@ def _agent_config_bin() -> str:
72
76
  Path(__file__).resolve().parents[2] / "agent-config")
73
77
 
74
78
 
79
+ def _maybe_refresh_project_wrapper(project_root: Path, out, err) -> None:
80
+ """Re-stamp the project-local ``./agent-config`` wrapper after a global
81
+ upgrade — but only when the upgrade was run from inside a consumer
82
+ project that *already* has a wrapper. Never creates one (that is an
83
+ install action, out of scope), never touches the source repo.
84
+
85
+ This closes the gap where ``upgrade`` refreshed the global binary while
86
+ an older, fallback-less project wrapper kept breaking the Claude hooks.
87
+ """
88
+ from scripts._lib import cli_wrapper
89
+ from scripts._cli.cmd_refresh import _is_source_repo
90
+
91
+ if _is_source_repo(project_root):
92
+ return
93
+ if not (project_root / "agent-config").is_file():
94
+ return # not a consumer project root (or never installed) — leave it
95
+ if not cli_wrapper.needs_refresh(project_root):
96
+ return
97
+ wrapper = cli_wrapper.install_cli_wrapper(project_root)
98
+ if wrapper is not None:
99
+ print(f"✅ refreshed stale project wrapper: {wrapper}", file=out)
100
+
101
+
75
102
  def main(
76
103
  argv: Optional[list[str]] = None,
77
104
  *,
78
105
  runner: Runner = _default_runner,
79
106
  fetcher=update_check.fetch_latest_from_npm,
80
107
  installed: Optional[str] = None,
108
+ project_root: Optional[Path] = None,
81
109
  out=sys.stdout,
82
110
  err=sys.stderr,
83
111
  ) -> int:
@@ -128,6 +156,8 @@ def main(
128
156
  f"{' '.join(cmd)}", file=err)
129
157
  return 1
130
158
 
159
+ _maybe_refresh_project_wrapper(project_root or Path.cwd(), out, err)
160
+
131
161
  print("✅ agent-config upgraded. Run `agent-config doctor` to verify "
132
162
  "PATH + plugin parity.", file=out)
133
163
  return 0
@@ -782,6 +782,16 @@ cmd_council() {
782
782
  exec env PYTHONPATH="$PACKAGE_ROOT" python3 "$script" "$sub" "$@"
783
783
  }
784
784
 
785
+ # `use --profile=<id>` — switch the active experience/profile. Writes
786
+ # profile.id into the canonical .agent-settings.yml; the explicit
787
+ # profile-switch seam named by ADR-040 (road-to-6.0.0-a Step 8).
788
+ cmd_use() {
789
+ require_python3
790
+ local script
791
+ script="$(resolve_script "scripts/profile_use.py")" || return 1
792
+ exec env PYTHONPATH="$PACKAGE_ROOT" python3 "$script" "$@"
793
+ }
794
+
785
795
  # `agent-config update` — flip the agent_config_version pin in
786
796
  # .agent-settings.yml. See scripts/_cli/cmd_update.py (P3.1 of
787
797
  # road-to-portable-runtime-and-update-check.md).
@@ -928,6 +938,7 @@ main() {
928
938
  mcp:check) cmd_mcp_check "$@" ;;
929
939
  mcp:setup) cmd_mcp_setup "$@" ;;
930
940
  mcp:run) cmd_mcp_run "$@" ;;
941
+ use) cmd_use "$@" ;;
931
942
  roadmap:progress) cmd_roadmap_progress "$@" ;;
932
943
  roadmap:progress-check) cmd_roadmap_progress_check "$@" ;;
933
944
  hooks:install) cmd_hooks_install "$@" ;;
@@ -0,0 +1,64 @@
1
+ """Install / refresh the consumer-facing ``./agent-config`` wrapper.
2
+
3
+ Python counterpart to ``install_cli_wrapper`` in ``scripts/install.sh``.
4
+ The wrapper is gitignored and meant to be regenerated on every install,
5
+ but the normal update cadence (``upgrade``, ``refresh --project``) never
6
+ re-ran the bash installer — so an older, fallback-less wrapper could
7
+ linger in a consumer project and break every Claude hook (the hook
8
+ resolves the master CLI *through* this wrapper). These helpers let the
9
+ update commands re-stamp the wrapper from the canonical template.
10
+
11
+ The template is the single source of truth (``templates/agent-config-wrapper.sh``);
12
+ the installer copies it verbatim with no substitution, so refreshing is a
13
+ plain copy + ``chmod``.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import shutil
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ # scripts/_lib/cli_wrapper.py → parents[2] is the package root.
22
+ _PACKAGE_ROOT = Path(__file__).resolve().parents[2]
23
+ _TEMPLATE = _PACKAGE_ROOT / "templates" / "agent-config-wrapper.sh"
24
+
25
+
26
+ def template_path() -> Path:
27
+ """Absolute path to the canonical wrapper template."""
28
+ return _TEMPLATE
29
+
30
+
31
+ def _target(project_root: Path) -> Path:
32
+ return Path(project_root) / "agent-config"
33
+
34
+
35
+ def needs_refresh(project_root: Path) -> bool:
36
+ """True when the project wrapper is missing or differs from the template.
37
+
38
+ Returns ``False`` when the template itself is unavailable (corrupt /
39
+ maintainer-only checkout) — there is nothing to refresh *to*.
40
+ """
41
+ if not _TEMPLATE.is_file():
42
+ return False
43
+ target = _target(project_root)
44
+ if not target.is_file():
45
+ return True
46
+ try:
47
+ return target.read_text(encoding="utf-8") != _TEMPLATE.read_text(
48
+ encoding="utf-8")
49
+ except OSError:
50
+ return True
51
+
52
+
53
+ def install_cli_wrapper(project_root: Path) -> Optional[Path]:
54
+ """Copy the canonical wrapper template to ``<project_root>/agent-config``.
55
+
56
+ Returns the written target path, or ``None`` when the template is
57
+ missing (corrupt package / maintainer checkout without templates).
58
+ """
59
+ if not _TEMPLATE.is_file():
60
+ return None
61
+ target = _target(project_root)
62
+ shutil.copyfile(_TEMPLATE, target)
63
+ target.chmod(0o755)
64
+ return target
@@ -36,6 +36,14 @@
36
36
  # 2 usage / file missing
37
37
  # 3 required tool missing (ffprobe / ffmpeg)
38
38
  # 4 no audio stream in the file
39
+ #
40
+ # Runtime requirements (trust boundary — AI-council note 2026-06-02):
41
+ # - ffmpeg + ffprobe on PATH (enforced, exit 3).
42
+ # - POSIX awk. Both GNU awk (CI/Linux) and BSD awk (macOS) are supported;
43
+ # the window arrays are passed via ENVIRON, not -v, because BSD awk
44
+ # rejects literal newlines in a -v value. The honesty invariant
45
+ # (interval <=> warning) is regression-guarded by
46
+ # tests/test_probe_audio.py::test_corpus_sweep_honesty_invariant.
39
47
 
40
48
  set -euo pipefail
41
49
 
@@ -99,7 +107,9 @@ sil_bounds="$(ffmpeg -hide_banner -nostats -i "${song}" \
99
107
  /silence_start/ { for(i=1;i<=NF;i++) if($i=="silence_start:") s=$(i+1) }
100
108
  /silence_end/ { for(i=1;i<=NF;i++) if($i=="silence_end:") { e=$(i+1); printf "%.3f\n", (s+e)/2 } }
101
109
  ' 2>/dev/null || true)"
102
- n_sil="$(printf '%s' "${sil_bounds}" | sed '/^$/d' | wc -l | tr -d ' ')"
110
+ # Trailing \n so `wc -l` counts the last line: command substitution strips
111
+ # the trailing newline, and two boundaries without it count as one.
112
+ n_sil="$(printf '%s\n' "${sil_bounds}" | sed '/^$/d' | wc -l | tr -d ' ')"
103
113
 
104
114
  # --- 4. choose method + build section boundaries ------------------------
105
115
  # A method needs >= 3 sections (>= 2 internal boundaries) to count as
@@ -125,7 +135,7 @@ if [ -z "${method}" ]; then
125
135
  prev=en[k]
126
136
  }
127
137
  }')"
128
- n_rms="$(printf '%s' "${rms_bounds}" | sed '/^$/d' | wc -l | tr -d ' ')"
138
+ n_rms="$(printf '%s\n' "${rms_bounds}" | sed '/^$/d' | wc -l | tr -d ' ')"
129
139
  if [ "${n_rms}" -ge 2 ]; then
130
140
  method="rms"
131
141
  boundaries="$(printf '%s\n' "${rms_bounds}" | sed '/^$/d' | sort -n | uniq)"
@@ -145,10 +155,15 @@ fi
145
155
  # = mean of the RMS windows whose start falls inside it.
146
156
  printf '%s' "${boundaries}" \
147
157
  | sed '/^$/d' \
148
- | awk -v d="${duration}" -v method="${method}" -v warning="${warning}" \
149
- -v wins="$(printf '%b' "${win_starts}")" -v ens="$(printf '%b' "${win_energy}")" '
158
+ | _PROBE_WINS="$(printf '%b' "${win_starts}")" _PROBE_ENS="$(printf '%b' "${win_energy}")" \
159
+ awk -v d="${duration}" -v method="${method}" -v warning="${warning}" '
150
160
  BEGIN {
151
- nw=split(wins, ws, "\n"); split(ens, es, "\n")
161
+ # Read the window arrays from the environment, not -v: BSD awk (macOS)
162
+ # rejects literal newlines inside a -v value ("newline in string"),
163
+ # while GNU awk (CI/Linux) tolerates them. ENVIRON is POSIX and
164
+ # portable across both. (Surfaced 2026-06-02 once ffmpeg landed on a
165
+ # macOS authoring host — CI on gawk never hit it.)
166
+ nw=split(ENVIRON["_PROBE_WINS"], ws, "\n"); split(ENVIRON["_PROBE_ENS"], es, "\n")
152
167
  # build edges
153
168
  ne=0; edges[ne++]=0
154
169
  }
@@ -62,16 +62,25 @@ concerns:
62
62
  script: scripts/profile_staleness_hook.py
63
63
  args: []
64
64
  fail_closed: false
65
+ # Defense-in-depth twin of the update-command wrapper refresh
66
+ # (cmd_upgrade / cmd_refresh). On session_start, re-stamps a stale
67
+ # project-local ./agent-config wrapper from the canonical template so
68
+ # an outdated, fallback-less copy cannot keep breaking the hooks.
69
+ # Never creates a wrapper, never touches the source repo, fail-open.
70
+ wrapper-freshness:
71
+ script: scripts/wrapper_freshness_hook.py
72
+ args: []
73
+ fail_closed: false
65
74
 
66
75
  platforms:
67
76
  augment:
68
- session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness]
77
+ session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
69
78
  session_end: [chat-history]
70
79
  stop: [chat-history, verify-before-complete]
71
80
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff]
72
81
 
73
82
  claude:
74
- session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness]
83
+ session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
75
84
  session_end: [chat-history]
76
85
  stop: [chat-history, verify-before-complete]
77
86
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
@@ -92,7 +101,7 @@ platforms:
92
101
  # Decision matrix + upstream blockers tracked in
93
102
  # agents/settings/contexts/chat-history-platform-hooks.md § Cowork.
94
103
  cowork:
95
- session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness]
104
+ session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
96
105
  session_end: [chat-history]
97
106
  stop: [chat-history, verify-before-complete]
98
107
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
@@ -106,7 +115,7 @@ platforms:
106
115
  # IDE-only — CLI-only users fall back to /checkpoint per
107
116
  # agents/settings/contexts/chat-history-platform-hooks.md.
108
117
  cursor:
109
- session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness]
118
+ session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
110
119
  session_end: [chat-history]
111
120
  stop: [chat-history, verify-before-complete]
112
121
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
@@ -121,7 +130,7 @@ platforms:
121
130
  # both map to session_start. TaskCancel maps to stop because the
122
131
  # session is interrupted with partial state (mirrors Augment Stop).
123
132
  cline:
124
- session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness]
133
+ session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
125
134
  session_end: [chat-history]
126
135
  stop: [chat-history, verify-before-complete]
127
136
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
@@ -140,7 +149,7 @@ platforms:
140
149
  # surface to record verification commands; documented limitation).
141
150
  # minimal-safe-diff is omitted entirely on Windsurf for the same reason.
142
151
  windsurf:
143
- session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, profile-staleness]
152
+ session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, profile-staleness, wrapper-freshness]
144
153
  stop: [chat-history, verify-before-complete]
145
154
  user_prompt_submit: [chat-history, verify-before-complete]
146
155
 
@@ -155,7 +164,7 @@ platforms:
155
164
  # turn-check semantics. AfterAgent fires when the agent loop ends
156
165
  # — this is our `stop` slot.
157
166
  gemini:
158
- session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness]
167
+ session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
159
168
  session_end: [chat-history]
160
169
  stop: [chat-history, verify-before-complete]
161
170
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """`agent-config use --profile=<id>` — switch the active experience.
3
+
4
+ The explicit profile-switch entry point named by the Execution-Model ADR
5
+ (`docs/decisions/ADR-040-execution-model-projection-time-filtering.md`) and
6
+ wired in `road-to-6.0.0-a-positioning-and-validation` Phase 2 / Step 8.
7
+
8
+ In 6.0.0-A this writes `profile.id` into the canonical project
9
+ `.agent-settings.yml` and prints what changed — it does **NOT** narrow what
10
+ gets projected into the tool trees. Pack-scoped surfacing (projection-time
11
+ filtering) activates in 6.0.0-B behind a staged, opt-in rollout. This command
12
+ is the stable seam that 6.0.0-B hooks projection into.
13
+
14
+ Comment-preserving: the canonical settings file is hand-editable and richly
15
+ commented, so the write is a surgical text edit of the `profile:` block, not a
16
+ yaml round-trip that would strip comments.
17
+
18
+ CLI:
19
+ agent-config use --profile=<id>
20
+ agent-config use --profile <id>
21
+
22
+ Valid ids (the six seed profiles, docs/contracts/profile-system.md):
23
+ developer · content_creator · founder · agency · finance · ops
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import re
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
33
+ from _lib.agent_settings import ( # noqa: E402
34
+ canonical_settings_write_path,
35
+ find_project_root,
36
+ )
37
+
38
+ VALID_PROFILES = (
39
+ "developer",
40
+ "content_creator",
41
+ "founder",
42
+ "agency",
43
+ "finance",
44
+ "ops",
45
+ )
46
+
47
+ # Matches a top-level `profile:` block and the `id:` leaf under it. The id
48
+ # value may be bare, single-, or double-quoted; we only rewrite the value.
49
+ _PROFILE_ID_RE = re.compile(
50
+ r"(?m)^(?P<head>profile:[ \t]*\n(?:[ \t]+#[^\n]*\n|[ \t]*\n)*"
51
+ r"[ \t]+id:[ \t]*)(?P<val>[^\n#]*)",
52
+ )
53
+
54
+
55
+ def _resolve_write_path() -> Path:
56
+ cwd = Path.cwd()
57
+ root = find_project_root(cwd) or cwd
58
+ return canonical_settings_write_path(root)
59
+
60
+
61
+ def _set_profile_id(text: str, profile_id: str) -> tuple[str, str | None]:
62
+ """Return (new_text, previous_id). Append a block if none exists."""
63
+ m = _PROFILE_ID_RE.search(text)
64
+ if m:
65
+ previous = m.group("val").strip().strip("'\"") or None
66
+ new_text = text[: m.start("val")] + profile_id + text[m.end("val") :]
67
+ return new_text, previous
68
+ # No profile block — append one. Keep a single trailing newline.
69
+ block = f"\n# --- Profile (experience) ---\nprofile:\n id: {profile_id}\n"
70
+ sep = "" if text.endswith("\n") else "\n"
71
+ return text + sep + block, None
72
+
73
+
74
+ def main(argv: list[str] | None = None) -> int:
75
+ parser = argparse.ArgumentParser(
76
+ prog="agent-config use",
77
+ description="Switch the active experience/profile (writes profile.id).",
78
+ )
79
+ parser.add_argument(
80
+ "--profile",
81
+ metavar="ID",
82
+ help=f"Experience to switch to. One of: {', '.join(VALID_PROFILES)}.",
83
+ )
84
+ args = parser.parse_args(argv)
85
+
86
+ if not args.profile:
87
+ print(
88
+ "❌ `use` requires --profile=<id>. Valid: "
89
+ + " · ".join(VALID_PROFILES),
90
+ file=sys.stderr,
91
+ )
92
+ return 2
93
+
94
+ profile_id = args.profile.strip()
95
+ if profile_id not in VALID_PROFILES:
96
+ print(
97
+ f"❌ unknown profile `{profile_id}`. Valid: "
98
+ + " · ".join(VALID_PROFILES),
99
+ file=sys.stderr,
100
+ )
101
+ return 2
102
+
103
+ path = _resolve_write_path()
104
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
105
+ new_text, previous = _set_profile_id(text, profile_id)
106
+
107
+ if previous == profile_id and path.exists():
108
+ print(f"✅ Already on experience `{profile_id}` — no change ({path}).")
109
+ return 0
110
+
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ path.write_text(new_text, encoding="utf-8")
113
+
114
+ arrow = f"`{previous}` → `{profile_id}`" if previous else f"`{profile_id}`"
115
+ print(f"✅ Experience set to {arrow} in {path}.")
116
+ print(
117
+ "ℹ️ 6.0.0-A records the choice only — it does not yet narrow what is "
118
+ "projected into .claude/ .cursor/ .augment/. Pack-scoped surfacing "
119
+ "(ADR-040) activates in 6.0.0-B behind a staged, opt-in rollout."
120
+ )
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__":
125
+ raise SystemExit(main())
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """session_start concern — keep the project-local ``./agent-config`` fresh.
3
+
4
+ Defense-in-depth twin of the update-command refresh (``upgrade`` /
5
+ ``refresh --project``). On every session_start the dispatcher runs this in
6
+ the consumer workspace; if a ``./agent-config`` wrapper exists there and
7
+ differs from the canonical template, it is re-stamped so an outdated,
8
+ fallback-less copy cannot keep breaking the hooks.
9
+
10
+ Bootstrapping note: this can only heal a wrapper functional enough to
11
+ invoke the dispatcher in the first place (the current template's global +
12
+ npx fallbacks guarantee that). A *completely* broken wrapper never reaches
13
+ this concern — that recovery path is ``agent-config upgrade`` /
14
+ ``refresh --project``.
15
+
16
+ Contract: never creates a wrapper where none exists (that is an install
17
+ action); never touches the agent-config source repo; always fail-open
18
+ (exit 0) — hook self-heal must not block a session.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ # Make `scripts` importable when invoked as a bare script path.
29
+ _REPO = Path(__file__).resolve().parent.parent
30
+ if str(_REPO) not in sys.path:
31
+ sys.path.insert(0, str(_REPO))
32
+
33
+ EXIT_ALLOW = 0
34
+
35
+
36
+ def _project_root() -> Path:
37
+ env = os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get(
38
+ "AGENT_CONFIG_PROJECT_DIR")
39
+ if env:
40
+ return Path(env)
41
+ return Path.cwd()
42
+
43
+
44
+ def main(argv: list[str] | None = None) -> int:
45
+ ap = argparse.ArgumentParser(
46
+ description="Project ./agent-config wrapper freshness (session_start).")
47
+ ap.add_argument("--root", default=None)
48
+ args, _ = ap.parse_known_args(argv)
49
+
50
+ # Drain stdin (the dispatcher passes a JSON envelope); we do not need it.
51
+ try:
52
+ if not sys.stdin.isatty():
53
+ sys.stdin.read()
54
+ except Exception:
55
+ pass
56
+
57
+ try:
58
+ from scripts._lib import cli_wrapper
59
+ from scripts._cli.cmd_refresh import _is_source_repo
60
+ except Exception: # pragma: no cover — defensive; never block the loop
61
+ return EXIT_ALLOW
62
+
63
+ root = Path(args.root) if args.root else _project_root()
64
+ try:
65
+ if _is_source_repo(root):
66
+ return EXIT_ALLOW
67
+ if not (root / "agent-config").is_file():
68
+ return EXIT_ALLOW # no wrapper here — never create one
69
+ if not cli_wrapper.needs_refresh(root):
70
+ return EXIT_ALLOW
71
+ wrapper = cli_wrapper.install_cli_wrapper(root)
72
+ except Exception:
73
+ return EXIT_ALLOW # fail-open — never block the session
74
+
75
+ if wrapper is not None:
76
+ print(f"[wrapper] re-stamped stale ./agent-config at {wrapper}",
77
+ file=sys.stderr)
78
+ sys.stdout.write(json.dumps({
79
+ "decision": "allow",
80
+ "reason": f"refreshed stale ./agent-config wrapper at {wrapper}",
81
+ }))
82
+ return EXIT_ALLOW
83
+
84
+
85
+ if __name__ == "__main__":
86
+ raise SystemExit(main())