@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +33 -225
- package/README.md +13 -6
- package/config/agent-settings.template.yml +17 -0
- package/config/gitignore-block.txt +7 -0
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/server/schemas/settings.js +4 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/index-DcAWIwwY.js +40 -0
- package/dist/ui/assets/index-DcAWIwwY.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/archive/CHANGELOG-pre-5.9.0.md +270 -0
- package/docs/decisions/ADR-040-execution-model-projection-time-filtering.md +244 -0
- package/docs/decisions/INDEX.md +1 -0
- package/docs/distribution/registries.md +1 -0
- package/docs/profiles.md +8 -0
- package/docs/wizard.md +25 -8
- package/package.json +1 -1
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_cli/cmd_doctor.py +177 -14
- package/scripts/_dispatch.bash +11 -0
- package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
- package/scripts/ai-video/lib/probe-audio.sh +20 -5
- package/scripts/check_release_published.py +145 -0
- package/scripts/profile_use.py +125 -0
- package/scripts/release.py +54 -31
- package/dist/ui/assets/index-5lFqAKL0.js +0 -40
- 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``
|
|
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
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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)
|
package/scripts/_dispatch.bash
CHANGED
|
@@ -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 "$@" ;;
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
-
|
|
|
149
|
-
|
|
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
|
-
|
|
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())
|