@event4u/agent-config 5.8.0 → 5.10.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 (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +33 -225
  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_doctor.py +177 -14
  31. package/scripts/_dispatch.bash +11 -0
  32. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  33. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  34. package/scripts/ai-video/lib/probe-audio.sh +20 -5
  35. package/scripts/check_release_published.py +145 -0
  36. package/scripts/profile_use.py +125 -0
  37. package/scripts/release.py +54 -31
  38. package/dist/ui/assets/index-5lFqAKL0.js +0 -40
  39. package/dist/ui/assets/index-5lFqAKL0.js.map +0 -1
@@ -23,8 +23,16 @@ mcp-mode · mcp-beta-readiness · offline-readiness · python-runtime ·
23
23
  tier-usage-readiness · council-cli · unsupported-combos ·
24
24
  wizard-state.
25
25
  Each emits a structured ``{id, status, message, remedy}`` record with
26
- ``status`` ∈ ``ok`` / ``warn`` / ``fail`` (rendered ``✅`` / ``⚠️`` /
27
- ``❌``). ``--check <id>`` runs a single check.
26
+ ``status`` ∈ ``ok`` / ``warn`` / ``fail`` / ``skipped`` (rendered
27
+ ``✅`` / ``⚠️`` / ``❌`` / ``⏭️``). ``--check <id>`` runs a single check.
28
+
29
+ Checks are split by scope (:data:`GLOBAL_CHECK_IDS` vs
30
+ :data:`MANIFEST_REQUIRED_CHECK_IDS`) so an ADR-020 global-only consumer
31
+ (bridge marker present, no ``agents/installed-tools.lock``) gets a
32
+ green-capable report instead of a hard bail: the global checks run, the
33
+ manifest-required checks report ``skipped``, and ``bridge-drift`` returns
34
+ a scope-aware "drift not applicable" verdict. See :func:`main` for the
35
+ no-manifest branch and exit-code contract.
28
36
 
29
37
  Repair affordances: ``--repair wizard-state`` resets a malformed or
30
38
  orphaned ``state/wizard-state.json`` under the user-global root (the
@@ -455,6 +463,41 @@ CHECK_IDS = (
455
463
  "wizard-state",
456
464
  )
457
465
 
466
+ #: Checks that need only the project root (or no input at all) and run
467
+ #: regardless of whether a project lockfile exists. Under ADR-020
468
+ #: global-only (bridge marker present, no ``installed-tools.lock``) these
469
+ #: still produce a real verdict. ``scope`` lives here because
470
+ #: :func:`_check_scope` reads only ``project_root`` — the roadmap prose
471
+ #: that grouped it with the manifest checks predates this code reality
472
+ #: (AI council, claude-sonnet-4-5 + gpt-4o, design lens, 2026-06-02).
473
+ GLOBAL_CHECK_IDS: frozenset[str] = frozenset({
474
+ "scope",
475
+ "global-binary",
476
+ "mcp-mode",
477
+ "mcp-beta-readiness",
478
+ "offline-readiness",
479
+ "python-runtime",
480
+ "tier-usage-readiness",
481
+ "council-cli",
482
+ "wizard-state",
483
+ })
484
+
485
+ #: Checks that genuinely cannot run without the project manifest. Without
486
+ #: a lockfile they report ``skipped`` rather than a misleading verdict.
487
+ #: ``bridge-drift`` is deliberately **not** here: it is scope-aware —
488
+ #: a manifest-derived drift roll-up when the lockfile exists, and a
489
+ #: "drift not applicable (global-only consumer)" verdict when it does
490
+ #: not (computed in :func:`_check_bridge_drift_no_manifest`, keeping
491
+ #: :func:`_check_bridge_drift` a pure roll-up per the council's SRP point).
492
+ MANIFEST_REQUIRED_CHECK_IDS: frozenset[str] = frozenset({
493
+ "manifest-integrity",
494
+ "lockfile-freshness",
495
+ "unsupported-combos",
496
+ })
497
+
498
+ #: Project-root-relative path of the ADR-020 global-only consumer marker.
499
+ BRIDGE_MARKER_RELATIVE = "agents/.event4u-bridge.yml"
500
+
458
501
  #: Repair targets that ``--repair <id>`` accepts. Each id maps to a
459
502
  #: function in :func:`_run_repair` that resets the named artefact and
460
503
  #: returns an exit code. Additive set: introduce by adding a new id
@@ -476,7 +519,7 @@ MCP_BETA_GATES: tuple[tuple[str, str], ...] = (
476
519
 
477
520
  #: Visible status → glyph map. ``warn`` keeps a trailing space so the
478
521
  #: rendered output stays in a single visual column with the other glyphs.
479
- STATUS_SYMBOLS = {"ok": "✅", "warn": "⚠️ ", "fail": "❌"}
522
+ STATUS_SYMBOLS = {"ok": "✅", "warn": "⚠️ ", "fail": "❌", "skipped": "⏭️ "}
480
523
 
481
524
  #: Minimum Python interpreter the CLI targets. Bumped in lockstep with
482
525
  #: ``from __future__ import annotations`` + PEP-604 syntax usage.
@@ -1159,6 +1202,131 @@ def _run_checks(
1159
1202
  return out
1160
1203
 
1161
1204
 
1205
+ def _skipped_manifest_check(check_id: str) -> dict[str, Any]:
1206
+ """A ``skipped`` verdict for a manifest-required check with no lockfile.
1207
+
1208
+ Explicit machine-readable record (not omitted, not ``null``) so the
1209
+ ``--json`` ``checks`` array keeps a stable shape for a global-only
1210
+ consumer — a council convergence point (2026-06-02).
1211
+ """
1212
+ return {
1213
+ "id": check_id, "status": "skipped",
1214
+ "message": "requires a project lockfile (agents/installed-tools.lock)",
1215
+ "remedy": "run `agent-config init` to create a project lockfile, "
1216
+ "then re-run this check",
1217
+ }
1218
+
1219
+
1220
+ def _check_bridge_drift_no_manifest(bridge_present: bool) -> dict[str, Any]:
1221
+ """Scope-aware ``bridge-drift`` verdict when no project manifest exists.
1222
+
1223
+ Kept out of :func:`_check_bridge_drift` (which stays a pure
1224
+ manifest-derived roll-up) per the council's single-responsibility
1225
+ point. A global-only consumer has no distributed tools to drift, so
1226
+ a bridge-present repo reports ``ok`` ("not applicable"); a repo with
1227
+ neither lockfile nor bridge marker reports ``skipped``.
1228
+ """
1229
+ if bridge_present:
1230
+ return {
1231
+ "id": "bridge-drift", "status": "ok",
1232
+ "message": "no project lockfile → distributed-tool drift not "
1233
+ "applicable (global-only consumer)",
1234
+ "remedy": "",
1235
+ }
1236
+ return {
1237
+ "id": "bridge-drift", "status": "skipped",
1238
+ "message": "no project lockfile and no bridge marker → drift check "
1239
+ "not applicable",
1240
+ "remedy": "run `agent-config init` (project install) or "
1241
+ "`agent-config refresh --project` (global-only consumer)",
1242
+ }
1243
+
1244
+
1245
+ def _run_checks_no_manifest(
1246
+ project_root: Path,
1247
+ bridge_present: bool,
1248
+ only: str | None = None,
1249
+ ) -> list[dict[str, Any]]:
1250
+ """Run the registry with no project manifest available.
1251
+
1252
+ Mirrors :func:`_run_checks` and preserves :data:`CHECK_IDS` order.
1253
+ Global checks (:data:`GLOBAL_CHECK_IDS`) run unchanged; manifest-required
1254
+ checks (:data:`MANIFEST_REQUIRED_CHECK_IDS`) report ``skipped``;
1255
+ ``bridge-drift`` gets the scope-aware no-manifest verdict.
1256
+ """
1257
+ runners: dict[str, Any] = {
1258
+ "scope": lambda: _check_scope(project_root),
1259
+ "global-binary": lambda: _check_global_binary(project_root),
1260
+ "manifest-integrity": lambda: _skipped_manifest_check("manifest-integrity"),
1261
+ "lockfile-freshness": lambda: _skipped_manifest_check("lockfile-freshness"),
1262
+ "bridge-drift": lambda: _check_bridge_drift_no_manifest(bridge_present),
1263
+ "mcp-mode": lambda: _check_mcp_mode(project_root),
1264
+ "mcp-beta-readiness": lambda: _check_mcp_beta_readiness(project_root),
1265
+ "offline-readiness": lambda: _check_offline_readiness(),
1266
+ "python-runtime": lambda: _check_python_runtime(),
1267
+ "tier-usage-readiness": lambda: _check_tier_usage_readiness(project_root),
1268
+ "council-cli": lambda: _check_council_cli(project_root),
1269
+ "unsupported-combos": lambda: _skipped_manifest_check("unsupported-combos"),
1270
+ "wizard-state": _check_wizard_state,
1271
+ }
1272
+ out: list[dict[str, Any]] = []
1273
+ for cid in CHECK_IDS:
1274
+ if only is not None and cid != only:
1275
+ continue
1276
+ out.append(runners[cid]())
1277
+ return out
1278
+
1279
+
1280
+ def _run_no_manifest(
1281
+ opts: argparse.Namespace,
1282
+ project_root: Path,
1283
+ origin: str,
1284
+ bridge_present: bool,
1285
+ ) -> int:
1286
+ """Handle the no-project-lockfile path without the old hard bail.
1287
+
1288
+ Exit-code contract (council-defined, 2026-06-02):
1289
+
1290
+ * ``0`` — bare report for a recognised global-only consumer
1291
+ (bridge marker present), or a single global ``--check`` that passed.
1292
+ * ``1`` — a runnable health check requested via ``--check`` failed.
1293
+ * ``2`` — a requested ``--check`` cannot run (manifest-required check
1294
+ with no lockfile, or ``bridge-drift`` with neither lockfile nor
1295
+ bridge marker), or a bare report in an **uninitialised** repo
1296
+ (neither lockfile nor bridge marker) — preserves the spirit of the
1297
+ pre-existing "run init" signal while still printing a real report.
1298
+ """
1299
+ checks = _run_checks_no_manifest(project_root, bridge_present, only=opts.check)
1300
+ fail_check = any(c["status"] == "fail" for c in checks)
1301
+ skipped_requested = opts.check is not None and any(
1302
+ c["id"] == opts.check and c["status"] == "skipped" for c in checks
1303
+ )
1304
+
1305
+ if opts.json:
1306
+ _emit_json(project_root, [], [], [], [], checks=checks, origin=origin)
1307
+ elif opts.check is None:
1308
+ print(f" 📍 project_root: {project_root} (origin: {origin})")
1309
+ if bridge_present:
1310
+ print(" ℹ️ global-only consumer: bridge marker present, no "
1311
+ "project lockfile (expected under ADR-020)")
1312
+ print(" project-manifest checks are skipped — they apply only "
1313
+ "to project-local distributed tools")
1314
+ else:
1315
+ print(" ⚠️ no project lockfile and no bridge marker at "
1316
+ f"{project_root}", file=sys.stderr)
1317
+ print(" run `agent-config init` (project install) or "
1318
+ "`agent-config refresh --project` (global-only consumer)",
1319
+ file=sys.stderr)
1320
+ _emit_checks_text(checks)
1321
+ else:
1322
+ _emit_checks_text(checks)
1323
+
1324
+ if opts.check is not None:
1325
+ if skipped_requested:
1326
+ return 2
1327
+ return 1 if fail_check else 0
1328
+ return 0 if bridge_present else 2
1329
+
1162
1330
 
1163
1331
  def _emit_json(
1164
1332
  project_root: Path,
@@ -1371,17 +1539,12 @@ def main(argv: list[str] | None = None) -> int:
1371
1539
  manifest_pth = installed_tools.manifest_path(project_root)
1372
1540
  manifest = installed_tools.read_manifest(manifest_pth)
1373
1541
  if manifest is None:
1374
- print(
1375
- f"❌ doctor: no project lockfile at {manifest_pth}",
1376
- file=sys.stderr,
1377
- )
1378
- print(
1379
- f" project_root: {project_root} (origin: {origin})",
1380
- file=sys.stderr,
1381
- )
1382
- print(" run `./agent-config init` to create one",
1383
- file=sys.stderr)
1384
- return 2
1542
+ # No project lockfile. Under ADR-020 global-only this is a
1543
+ # legitimate state, not an error — run the lockfile-independent
1544
+ # checks and report consumer state instead of a hard bail. The
1545
+ # per-scope split + exit-code contract live in _run_no_manifest.
1546
+ bridge_present = (project_root / BRIDGE_MARKER_RELATIVE).is_file()
1547
+ return _run_no_manifest(opts, project_root, origin, bridge_present)
1385
1548
 
1386
1549
  records, known = _collect_manifest_entries(project_root, manifest)
1387
1550
  missing, modified, tag_drift = _classify(records)
@@ -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 "$@" ;;
@@ -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
  }
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Release-published drift gate.
4
+
5
+ Catches the "release merged to main but never tagged/published" failure
6
+ mode — where ``main``'s ``package.json`` claims a version that has no
7
+ matching git tag, and npm's ``latest`` therefore lags behind main. This
8
+ is the backstop that would have surfaced the 5.8.0-stuck-on-5.7.0 state
9
+ the moment it happened, instead of weeks later.
10
+
11
+ Two independent invariants:
12
+
13
+ 1. **Tag invariant** (always checkable, no network): the version in
14
+ ``package.json`` MUST have a matching git tag (local or remote).
15
+ 2. **npm invariant** (``--check-npm``, network): ``npm view <pkg>
16
+ dist-tags.latest`` MUST equal the ``package.json`` version.
17
+
18
+ Scope guard: this only makes sense on the release trunk. Off ``main``
19
+ (e.g. a feature branch, or a ``release/X.Y.Z`` branch mid-flight where
20
+ the bump legitimately precedes the tag) the gate **no-ops** unless
21
+ ``--strict`` is combined with an explicit on-main signal. The daily
22
+ scheduled workflow runs ``--strict --check-npm`` on ``main``; the local
23
+ ``task check-release-published`` runs the tag invariant only.
24
+
25
+ Exit codes: 0 = pass / no-op · 1 = drift detected · 3 = internal error.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import re
33
+ import subprocess
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$")
38
+ REPO_ROOT = Path(__file__).resolve().parent.parent
39
+ PACKAGE_JSON = REPO_ROOT / "package.json"
40
+ MAIN_BRANCH = "main"
41
+
42
+
43
+ def _git(*args: str) -> tuple[int, str]:
44
+ proc = subprocess.run(["git", *args], capture_output=True, text=True, check=False)
45
+ return proc.returncode, (proc.stdout or "").strip()
46
+
47
+
48
+ def _package_version() -> str:
49
+ data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))
50
+ return str(data["version"])
51
+
52
+
53
+ def _package_name() -> str:
54
+ data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))
55
+ return str(data["name"])
56
+
57
+
58
+ def _tag_exists(tag: str) -> bool:
59
+ rc, out = _git("tag", "-l", tag)
60
+ if rc == 0 and tag in out.splitlines():
61
+ return True
62
+ rc, _ = _git("ls-remote", "--exit-code", "--tags", "origin", tag)
63
+ return rc == 0
64
+
65
+
66
+ def _on_main() -> bool:
67
+ # Local checkout, CI push ref, or CI scheduled ref all map to main.
68
+ ref = os.environ.get("GITHUB_REF", "")
69
+ if ref in ("refs/heads/main", "refs/heads/master"):
70
+ return True
71
+ rc, head = _git("rev-parse", "--abbrev-ref", "HEAD")
72
+ return rc == 0 and head == MAIN_BRANCH
73
+
74
+
75
+ def _npm_latest(pkg: str) -> str | None:
76
+ proc = subprocess.run(
77
+ ["npm", "view", pkg, "dist-tags.latest"],
78
+ capture_output=True, text=True, check=False,
79
+ )
80
+ if proc.returncode != 0:
81
+ return None
82
+ return (proc.stdout or "").strip() or None
83
+
84
+
85
+ def main(argv: list[str] | None = None) -> int:
86
+ ap = argparse.ArgumentParser(description="Release-published drift gate.")
87
+ ap.add_argument("--strict", action="store_true",
88
+ help="Fail on drift (default: informational, exit 0).")
89
+ ap.add_argument("--check-npm", action="store_true",
90
+ help="Also assert npm dist-tags.latest == package.json version (network).")
91
+ ap.add_argument("--require-main", action="store_true",
92
+ help="Only enforce when on main; no-op elsewhere (default for scheduled).")
93
+ args = ap.parse_args(argv)
94
+
95
+ try:
96
+ version = _package_version()
97
+ except (OSError, KeyError, json.JSONDecodeError) as exc:
98
+ print(f"❌ cannot read package.json version: {exc}", file=sys.stderr)
99
+ return 3
100
+ if not SEMVER_RE.match(version):
101
+ print(f"❌ package.json version is not semver: {version!r}", file=sys.stderr)
102
+ return 3
103
+
104
+ if args.require_main and not _on_main():
105
+ print(f"ℹ️ not on {MAIN_BRANCH} — release-published gate skipped.")
106
+ return 0
107
+
108
+ problems: list[str] = []
109
+
110
+ if not _tag_exists(version):
111
+ problems.append(
112
+ f"package.json is {version} but no git tag {version} exists "
113
+ f"(local or origin) — the release was bumped/merged but never "
114
+ f"tagged. Complete it: tag the release-merge commit and push "
115
+ f"(triggers publish-npm.yml), e.g. `git tag {version} && git "
116
+ f"push origin {version}`."
117
+ )
118
+
119
+ if args.check_npm:
120
+ pkg = _package_name()
121
+ latest = _npm_latest(pkg)
122
+ if latest is None:
123
+ print(f"⚠️ could not read npm dist-tags.latest for {pkg} "
124
+ f"(network/registry) — npm invariant not checked.", file=sys.stderr)
125
+ elif latest != version:
126
+ problems.append(
127
+ f"npm {pkg}@latest is {latest} but package.json is {version} "
128
+ f"— the published release lags main. Check publish-npm.yml "
129
+ f"for tag {version}, or re-dispatch it."
130
+ )
131
+
132
+ if not problems:
133
+ suffix = " + npm" if args.check_npm else ""
134
+ print(f"✅ release-published: {version} is tagged{suffix} and in sync.")
135
+ return 0
136
+
137
+ header = "❌ Release-published drift:" if args.strict else "⚠️ Release-published drift (warn-only):"
138
+ print(header, file=sys.stderr)
139
+ for p in problems:
140
+ print(f" - {p}", file=sys.stderr)
141
+ return 1 if args.strict else 0
142
+
143
+
144
+ if __name__ == "__main__":
145
+ raise SystemExit(main())
@@ -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())