@event4u/agent-config 2.8.0 → 2.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.
Files changed (49) hide show
  1. package/.agent-src/personas/engineering-manager.md +133 -0
  2. package/.agent-src/personas/finance-partner.md +129 -0
  3. package/.agent-src/personas/people-strategist.md +126 -0
  4. package/.agent-src/personas/strategist.md +129 -0
  5. package/.agent-src/skills/build-buy-partner/SKILL.md +145 -0
  6. package/.agent-src/skills/comp-banding/SKILL.md +160 -0
  7. package/.agent-src/skills/competitive-moat-analysis/SKILL.md +152 -0
  8. package/.agent-src/skills/contracts-cognition/SKILL.md +147 -0
  9. package/.agent-src/skills/data-handling-judgment/SKILL.md +155 -0
  10. package/.agent-src/skills/forecasting/SKILL.md +164 -0
  11. package/.agent-src/skills/hiring-loop-design/SKILL.md +167 -0
  12. package/.agent-src/skills/market-entry-analysis/SKILL.md +144 -0
  13. package/.agent-src/skills/onboarding-program/SKILL.md +157 -0
  14. package/.agent-src/skills/one-on-one-cadence/SKILL.md +161 -0
  15. package/.agent-src/skills/org-design/SKILL.md +158 -0
  16. package/.agent-src/skills/perf-feedback-craft/SKILL.md +157 -0
  17. package/.agent-src/skills/privacy-review/SKILL.md +160 -0
  18. package/.agent-src/skills/runway-cognition/SKILL.md +136 -0
  19. package/.agent-src/skills/scenario-modeling/SKILL.md +139 -0
  20. package/.agent-src/skills/throughput-vs-morale-tradeoff/SKILL.md +165 -0
  21. package/.agent-src/skills/unit-economics-modeling/SKILL.md +54 -7
  22. package/.agent-src/skills/vision-articulation/SKILL.md +146 -0
  23. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  24. package/.agent-src/templates/scripts/telemetry/settings.py +65 -0
  25. package/.agent-src/templates/scripts/tier_usage_report.py +183 -0
  26. package/.claude-plugin/marketplace.json +18 -1
  27. package/AGENTS.md +1 -1
  28. package/CHANGELOG.md +106 -0
  29. package/README.md +3 -3
  30. package/docs/architecture.md +37 -11
  31. package/docs/catalog.md +22 -4
  32. package/docs/contracts/adr-forecast-construction-shape.md +89 -0
  33. package/docs/contracts/adr-wing4-context-spine.md +125 -0
  34. package/docs/contracts/command-clusters.md +41 -0
  35. package/docs/contracts/command-surface-tiers.md +25 -9
  36. package/docs/contracts/context-spine.md +8 -0
  37. package/docs/contracts/mcp-beta-criteria.md +129 -0
  38. package/docs/guidelines/wing4-handoff.md +127 -0
  39. package/docs/mcp-server.md +1 -1
  40. package/package.json +1 -1
  41. package/scripts/_cli/cmd_doctor.py +527 -14
  42. package/scripts/_cli/cmd_validate.py +10 -0
  43. package/scripts/agent-config +19 -18
  44. package/scripts/install.py +5 -0
  45. package/scripts/lint_context_spine_usage.py +1 -0
  46. package/scripts/mcp_server/__init__.py +1 -0
  47. package/scripts/mcp_server/server.py +4 -3
  48. package/scripts/schemas/skill.schema.json +2 -2
  49. package/scripts/skill_linter.py +107 -3
@@ -1,8 +1,11 @@
1
- """``agent-config doctor`` — manifest filesystem drift report.
1
+ """``agent-config doctor`` — install + manifest health report.
2
2
 
3
- Phase 4 of road-to-multi-package-coexistence. Read-only sibling to
4
- ``prune``/``validate``: walks the project manifest and the on-disk
5
- deploy roots, then produces four categories:
3
+ Phase 4 of road-to-multi-package-coexistence (drift detection) and
4
+ Phase 2 of road-to-surface-discipline (diagnostic hub). Read-only
5
+ sibling to ``prune``/``validate``: walks the project manifest and the
6
+ on-disk deploy roots, runs a battery of health checks, and produces:
7
+
8
+ Drift categories (manifest ↔ filesystem):
6
9
 
7
10
  * ``missing`` — manifest entry has a ``path`` that is **not** on disk.
8
11
  * ``modified`` — manifest entry records a ``sha256`` that does not
@@ -14,9 +17,18 @@ deploy roots, then produces four categories:
14
17
  (P5.2). Hand-edited tags or accidental cross-package writes show up
15
18
  here; files without frontmatter are skipped (P5.1 contract).
16
19
 
17
- Exit codes: ``0`` (clean) · ``1`` (drift) · ``2`` (error such as
18
- "manifest missing"). Both human and ``--json`` output emit the four
19
- category lists. Every entry carries a one-line ``fix`` hint (P4.3).
20
+ Health checks (nine categories, see :data:`CHECK_IDS`):
21
+ scope · manifest-integrity · lockfile-freshness · bridge-drift ·
22
+ mcp-mode · mcp-beta-readiness · offline-readiness · python-runtime ·
23
+ unsupported-combos.
24
+ Each emits a structured ``{id, status, message, remedy}`` record with
25
+ ``status`` ∈ ``ok`` / ``warn`` / ``fail`` (rendered ``✅`` / ``⚠️`` /
26
+ ``❌``). ``--check <id>`` runs a single check.
27
+
28
+ Exit codes: ``0`` (clean) · ``1`` (drift or any ``fail`` check) · ``2``
29
+ (error such as "manifest missing"). Both human and ``--json`` output
30
+ emit the drift category lists and the structured checks array. Every
31
+ drift entry carries a one-line ``fix`` hint (P4.3).
20
32
  """
21
33
  from __future__ import annotations
22
34
 
@@ -258,20 +270,498 @@ def _foreign_records(
258
270
  return out
259
271
 
260
272
 
273
+ # ---------------------------------------------------------------------------
274
+ # Health checks (Phase 2 of road-to-surface-discipline).
275
+ # ---------------------------------------------------------------------------
276
+
277
+ #: Ordered registry of structured health-check identifiers. The order
278
+ #: is preserved in text and JSON output. Adding a check requires both
279
+ #: a runner below and an entry in :func:`_run_checks`.
280
+ CHECK_IDS = (
281
+ "scope",
282
+ "manifest-integrity",
283
+ "lockfile-freshness",
284
+ "bridge-drift",
285
+ "mcp-mode",
286
+ "mcp-beta-readiness",
287
+ "offline-readiness",
288
+ "python-runtime",
289
+ "tier-usage-readiness",
290
+ "unsupported-combos",
291
+ )
292
+
293
+ #: Six gates that govern the MCP `experimental → beta` promotion. The
294
+ #: artefact path under each gate id mirrors `tests/test_mcp_beta_gates.py`
295
+ #: and the contract in `docs/contracts/mcp-beta-criteria.md`.
296
+ MCP_BETA_GATES: tuple[tuple[str, str], ...] = (
297
+ ("gate-1-external-client", "tests/mcp/external-clients"),
298
+ ("gate-2-bearer-auth", "tests/mcp/auth"),
299
+ ("gate-3-parity-smoke", "tests/mcp/parity"),
300
+ ("gate-4-healthz-load", "tests/mcp/load/healthz.k6.js"),
301
+ ("gate-5-rate-limit", "docs/contracts/mcp-rate-limit.md"),
302
+ ("gate-6-no-drift", ".github/workflows/mcp-no-drift.yml"),
303
+ )
304
+
305
+ #: Visible status → glyph map. ``warn`` keeps a trailing space so the
306
+ #: rendered output stays in a single visual column with the other glyphs.
307
+ STATUS_SYMBOLS = {"ok": "✅", "warn": "⚠️ ", "fail": "❌"}
308
+
309
+ #: Minimum Python interpreter the CLI targets. Bumped in lockstep with
310
+ #: ``from __future__ import annotations`` + PEP-604 syntax usage.
311
+ MIN_PYTHON = (3, 10)
312
+
313
+
314
+ def _check_scope(project_root: Path) -> dict[str, Any]:
315
+ """Distinguish a standalone project root from a monorepo workspace.
316
+
317
+ Surfaces ``warn`` when ``project_root`` looks like a monorepo root
318
+ so the operator knows to run ``doctor`` per-package; otherwise
319
+ reports the project as a standalone target.
320
+ """
321
+ for marker in ("pnpm-workspace.yaml", "lerna.json"):
322
+ if (project_root / marker).exists():
323
+ return {
324
+ "id": "scope", "status": "warn",
325
+ "message": f"monorepo root detected ({marker})",
326
+ "remedy": "run `agent-config doctor` from each workspace package",
327
+ }
328
+ pkg = project_root / "package.json"
329
+ if pkg.exists():
330
+ try:
331
+ data = json.loads(pkg.read_text(encoding="utf-8"))
332
+ if data.get("workspaces"):
333
+ return {
334
+ "id": "scope", "status": "warn",
335
+ "message": "package.json declares workspaces (monorepo root)",
336
+ "remedy": "run `agent-config doctor` from each workspace package",
337
+ }
338
+ except (OSError, ValueError):
339
+ pass
340
+ return {
341
+ "id": "scope", "status": "ok",
342
+ "message": "standalone project root",
343
+ "remedy": "",
344
+ }
345
+
346
+
347
+ def _check_manifest_integrity(manifest: dict[str, Any]) -> dict[str, Any]:
348
+ """Verify the manifest carries a writer version and a known schema."""
349
+ schema = manifest.get("schema_version")
350
+ version = manifest.get("agent_config_version")
351
+ if not version:
352
+ return {
353
+ "id": "manifest-integrity", "status": "warn",
354
+ "message": "manifest lacks `agent_config_version`",
355
+ "remedy": "re-run `./agent-config init` to record the writer version",
356
+ }
357
+ if schema not in installed_tools.SCHEMA_VERSIONS_SUPPORTED:
358
+ return {
359
+ "id": "manifest-integrity", "status": "warn",
360
+ "message": f"unknown schema_version: {schema!r}",
361
+ "remedy": "upgrade @event4u/agent-config to a writer that "
362
+ "recognises this schema",
363
+ }
364
+ return {
365
+ "id": "manifest-integrity", "status": "ok",
366
+ "message": f"schema v{schema}, written by agent-config {version}",
367
+ "remedy": "",
368
+ }
369
+
370
+
371
+ def _package_root() -> Path:
372
+ """Resolve the @event4u/agent-config package root (this repo)."""
373
+ return Path(__file__).resolve().parents[2]
374
+
375
+
376
+ def _current_package_version() -> str | None:
377
+ """Read ``version`` from this package's ``package.json``; ``None`` on error."""
378
+ try:
379
+ data = json.loads(
380
+ (_package_root() / "package.json").read_text(encoding="utf-8"),
381
+ )
382
+ v = data.get("version")
383
+ if isinstance(v, str) and v.strip():
384
+ return v.strip()
385
+ except (OSError, ValueError):
386
+ pass
387
+ return None
388
+
389
+
390
+ def _check_lockfile_freshness(manifest: dict[str, Any]) -> dict[str, Any]:
391
+ """Compare the manifest writer version against the current package."""
392
+ recorded = manifest.get("agent_config_version")
393
+ current = _current_package_version()
394
+ if not recorded:
395
+ return {
396
+ "id": "lockfile-freshness", "status": "warn",
397
+ "message": "manifest has no writer version recorded",
398
+ "remedy": "re-run `./agent-config init` to refresh the manifest",
399
+ }
400
+ if current is None:
401
+ return {
402
+ "id": "lockfile-freshness", "status": "warn",
403
+ "message": f"manifest written by {recorded}; current package version unknown",
404
+ "remedy": "verify the package install (package.json missing or unreadable)",
405
+ }
406
+ if recorded != current:
407
+ return {
408
+ "id": "lockfile-freshness", "status": "warn",
409
+ "message": f"manifest writer {recorded} != current package {current}",
410
+ "remedy": "re-run `./agent-config sync` to refresh the manifest "
411
+ "against the current package",
412
+ }
413
+ return {
414
+ "id": "lockfile-freshness", "status": "ok",
415
+ "message": f"manifest and package both at {current}",
416
+ "remedy": "",
417
+ }
418
+
419
+
420
+ def _check_bridge_drift(
421
+ missing: list[dict[str, Any]],
422
+ modified: list[dict[str, Any]],
423
+ foreign: list[dict[str, Any]],
424
+ tag_drift: list[dict[str, Any]],
425
+ ) -> dict[str, Any]:
426
+ """Roll the four drift category counts into a single health verdict."""
427
+ total = len(missing) + len(modified) + len(foreign) + len(tag_drift)
428
+ if total == 0:
429
+ return {
430
+ "id": "bridge-drift", "status": "ok",
431
+ "message": "manifest matches filesystem (no drift)",
432
+ "remedy": "",
433
+ }
434
+ parts = []
435
+ if missing:
436
+ parts.append(f"{len(missing)} missing")
437
+ if modified:
438
+ parts.append(f"{len(modified)} modified")
439
+ if foreign:
440
+ parts.append(f"{len(foreign)} foreign")
441
+ if tag_drift:
442
+ parts.append(f"{len(tag_drift)} tag-drift")
443
+ return {
444
+ "id": "bridge-drift", "status": "fail",
445
+ "message": f"{total} drift item(s): {', '.join(parts)}",
446
+ "remedy": "see the drift section below or run `./agent-config sync`",
447
+ }
448
+
449
+
450
+ def _check_mcp_mode(project_root: Path) -> dict[str, Any]:
451
+ """Detect which MCP config file the project advertises, if any."""
452
+ candidates = (
453
+ (".cursor/mcp.json", "cursor"),
454
+ (".ai/mcp/mcp.json", "ai-mcp"),
455
+ ("mcp.json", "root"),
456
+ )
457
+ found: list[str] = []
458
+ for rel, label in candidates:
459
+ path = project_root / rel
460
+ if not path.exists():
461
+ continue
462
+ try:
463
+ json.loads(path.read_text(encoding="utf-8"))
464
+ found.append(f"{label} ({rel})")
465
+ except (OSError, ValueError):
466
+ return {
467
+ "id": "mcp-mode", "status": "warn",
468
+ "message": f"MCP config at {rel} is not valid JSON",
469
+ "remedy": f"fix or remove `{rel}` (see docs/architecture.md § MCP)",
470
+ }
471
+ if not found:
472
+ return {
473
+ "id": "mcp-mode", "status": "ok",
474
+ "message": "no MCP config present (MCP Beta off)",
475
+ "remedy": "",
476
+ }
477
+ return {
478
+ "id": "mcp-mode", "status": "ok",
479
+ "message": f"MCP config detected: {', '.join(found)}",
480
+ "remedy": "",
481
+ }
482
+
483
+
484
+ def _check_offline_readiness() -> dict[str, Any]:
485
+ """Verify the verified-offline install entrypoint ships with the package."""
486
+ script = _package_root() / "scripts" / "hermetic-install.sh"
487
+ if not script.exists():
488
+ return {
489
+ "id": "offline-readiness", "status": "warn",
490
+ "message": "scripts/hermetic-install.sh not found in package",
491
+ "remedy": "reinstall @event4u/agent-config or pull missing files",
492
+ }
493
+ return {
494
+ "id": "offline-readiness", "status": "ok",
495
+ "message": "verified-offline install entrypoint present",
496
+ "remedy": "",
497
+ }
498
+
499
+
500
+ def _check_python_runtime() -> dict[str, Any]:
501
+ """Confirm the interpreter is at least :data:`MIN_PYTHON`."""
502
+ cur = sys.version_info[:2]
503
+ need = MIN_PYTHON
504
+ if cur < need:
505
+ return {
506
+ "id": "python-runtime", "status": "fail",
507
+ "message": f"python {cur[0]}.{cur[1]} below required {need[0]}.{need[1]}",
508
+ "remedy": f"install python >= {need[0]}.{need[1]} and re-run",
509
+ }
510
+ return {
511
+ "id": "python-runtime", "status": "ok",
512
+ "message": f"python {cur[0]}.{cur[1]} meets {need[0]}.{need[1]}+ requirement",
513
+ "remedy": "",
514
+ }
515
+
516
+
517
+ def _check_mcp_beta_readiness(project_root: Path) -> dict[str, Any]:
518
+ """Report the MCP `experimental → beta` promotion gate sheet.
519
+
520
+ Walks the six gates in :data:`MCP_BETA_GATES` and asks whether the
521
+ artefact named by each gate (test directory, k6 script, contract
522
+ doc, or workflow file) is present. Green when all six pass, warn
523
+ when any are missing — the gate stays red until evidence lands.
524
+ Mirrors `tests/test_mcp_beta_gates.py` so the doctor verdict and
525
+ the pytest sheet cannot drift.
526
+ """
527
+ pending: list[str] = []
528
+ for gate_id, rel in MCP_BETA_GATES:
529
+ if not (project_root / rel).exists():
530
+ pending.append(f"{gate_id} ({rel})")
531
+ if not pending:
532
+ return {
533
+ "id": "mcp-beta-readiness", "status": "ok",
534
+ "message": "all 6 MCP beta gates green — promotion authorized",
535
+ "remedy": "",
536
+ }
537
+ return {
538
+ "id": "mcp-beta-readiness", "status": "warn",
539
+ "message": (
540
+ f"{len(pending)}/6 MCP beta gate(s) pending: "
541
+ f"{', '.join(pending)}"
542
+ ),
543
+ "remedy": (
544
+ "produce the artefacts listed in "
545
+ "docs/contracts/mcp-beta-criteria.md (one per gate); "
546
+ "do not flip `experimental` wording until all 6 are green"
547
+ ),
548
+ }
549
+
550
+
551
+ def _check_tier_usage_readiness(project_root: Path) -> dict[str, Any]:
552
+ """Report whether tier-usage telemetry can drive empirical retiering.
553
+
554
+ Phase 5 of road-to-surface-discipline. Three terminal states:
555
+
556
+ * **ok** — telemetry on, log present, at least one record survived
557
+ the privacy floor.
558
+ * **warn (disabled)** — telemetry off; no signal collected.
559
+ Default-off doctrine; user opt-in is the unlock.
560
+ * **warn (no data)** — telemetry on but the log is absent / empty;
561
+ retiering decisions still rely on operator judgement until
562
+ enough records accumulate.
563
+ * **fail (poisoned)** — every record was rejected by the privacy
564
+ floor; the report would refuse to render. The log needs a manual
565
+ inspection.
566
+
567
+ Contract: ``docs/contracts/command-clusters.md`` § tier-usage signal.
568
+ """
569
+ settings_file = project_root / ".agent-settings.yml"
570
+ log_path = project_root / ".agent-tier-usage.jsonl"
571
+ enabled = False
572
+ if settings_file.is_file():
573
+ try:
574
+ import yaml # type: ignore[import-not-found]
575
+ except ImportError:
576
+ yaml = None # type: ignore[assignment]
577
+ if yaml is not None:
578
+ try:
579
+ raw = yaml.safe_load(settings_file.read_text(encoding="utf-8")) or {}
580
+ except Exception:
581
+ raw = {}
582
+ tele = (raw.get("telemetry") if isinstance(raw, dict) else None) or {}
583
+ tu = tele.get("tier_usage") if isinstance(tele, dict) else None
584
+ if isinstance(tu, dict):
585
+ output = tu.get("output") if isinstance(tu.get("output"), dict) else {}
586
+ if isinstance(output.get("path"), str) and output["path"].strip():
587
+ log_path = project_root / output["path"].strip()
588
+ val = tu.get("enabled")
589
+ enabled = bool(val) if isinstance(val, bool) else (
590
+ isinstance(val, str) and val.strip().lower() in ("true", "yes", "on", "1")
591
+ )
592
+
593
+ if not enabled:
594
+ return {
595
+ "id": "tier-usage-readiness", "status": "warn",
596
+ "message": (
597
+ "tier-usage telemetry disabled — empirical retiering "
598
+ "decisions fall back to operator judgement"
599
+ ),
600
+ "remedy": (
601
+ "set `telemetry.tier_usage.enabled: true` in "
602
+ ".agent-settings.yml (default-off; opt-in)"
603
+ ),
604
+ }
605
+ if not log_path.exists():
606
+ return {
607
+ "id": "tier-usage-readiness", "status": "warn",
608
+ "message": (
609
+ f"tier-usage telemetry on but {log_path.name} not yet "
610
+ "written — no signal accumulated"
611
+ ),
612
+ "remedy": (
613
+ "run any tracked command to seed the log; the dispatcher "
614
+ "writes one record per invocation"
615
+ ),
616
+ }
617
+ total = 0
618
+ valid = 0
619
+ allowed_fields = {"ts_bucket", "command", "tier", "outcome", "user_hash"}
620
+ allowed_outcomes = {"success", "error", "blocked"}
621
+ try:
622
+ with log_path.open("r", encoding="utf-8") as fh:
623
+ for line in fh:
624
+ line = line.strip()
625
+ if not line:
626
+ continue
627
+ total += 1
628
+ try:
629
+ obj = json.loads(line)
630
+ except json.JSONDecodeError:
631
+ continue
632
+ if not isinstance(obj, dict):
633
+ continue
634
+ if not set(obj.keys()).issubset(allowed_fields):
635
+ continue
636
+ if (
637
+ isinstance(obj.get("command"), str) and obj["command"]
638
+ and isinstance(obj.get("tier"), int) and obj["tier"] in (0, 1, 2, 3)
639
+ and obj.get("outcome") in allowed_outcomes
640
+ and isinstance(obj.get("user_hash"), str) and len(obj["user_hash"]) == 16
641
+ and isinstance(obj.get("ts_bucket"), str)
642
+ ):
643
+ valid += 1
644
+ except OSError as exc:
645
+ return {
646
+ "id": "tier-usage-readiness", "status": "fail",
647
+ "message": f"cannot read {log_path.name}: {exc}",
648
+ "remedy": "fix permissions on the tier-usage log",
649
+ }
650
+ if total > 0 and valid == 0:
651
+ return {
652
+ "id": "tier-usage-readiness", "status": "fail",
653
+ "message": (
654
+ f"{total} record(s) in {log_path.name} but 0 passed the "
655
+ "privacy floor — report would refuse to render"
656
+ ),
657
+ "remedy": (
658
+ "inspect the log; the dispatcher is writing records the "
659
+ "contract forbids (paths, argv, message bodies)"
660
+ ),
661
+ }
662
+ if valid == 0:
663
+ return {
664
+ "id": "tier-usage-readiness", "status": "warn",
665
+ "message": f"{log_path.name} present but empty — no signal yet",
666
+ "remedy": "run any tracked command to seed the log",
667
+ }
668
+ return {
669
+ "id": "tier-usage-readiness", "status": "ok",
670
+ "message": (
671
+ f"{valid} record(s) past the privacy floor in {log_path.name} "
672
+ "— retiering signal available"
673
+ ),
674
+ "remedy": "",
675
+ }
676
+
677
+
678
+ def _check_unsupported_combos(manifest: dict[str, Any]) -> dict[str, Any]:
679
+ """Flag tools whose ``scope`` violates the global-only or project-only rules."""
680
+ global_only = {"droid", "qoder"}
681
+ bad: list[str] = []
682
+ for tool in manifest.get("tools") or []:
683
+ name = str(tool.get("name", ""))
684
+ scope = tool.get("scope")
685
+ if name in global_only and scope != "global":
686
+ bad.append(f"{name} (scope={scope}, requires global)")
687
+ if bad:
688
+ return {
689
+ "id": "unsupported-combos", "status": "fail",
690
+ "message": f"{len(bad)} tool(s) with unsupported scope: {', '.join(bad)}",
691
+ "remedy": "re-install the listed tools with `--global --force`",
692
+ }
693
+ return {
694
+ "id": "unsupported-combos", "status": "ok",
695
+ "message": "all installed tools use supported scopes",
696
+ "remedy": "",
697
+ }
698
+
699
+
700
+ def _run_checks(
701
+ project_root: Path,
702
+ manifest: dict[str, Any],
703
+ drift: dict[str, list[dict[str, Any]]],
704
+ only: str | None = None,
705
+ ) -> list[dict[str, Any]]:
706
+ """Run the health-check registry and return ordered structured results.
707
+
708
+ ``only`` filters to a single check id; ``None`` runs the full set.
709
+ """
710
+ runners: dict[str, Any] = {
711
+ "scope": lambda: _check_scope(project_root),
712
+ "manifest-integrity": lambda: _check_manifest_integrity(manifest),
713
+ "lockfile-freshness": lambda: _check_lockfile_freshness(manifest),
714
+ "bridge-drift": lambda: _check_bridge_drift(
715
+ drift["missing"], drift["modified"],
716
+ drift["foreign"], drift["tag_drift"],
717
+ ),
718
+ "mcp-mode": lambda: _check_mcp_mode(project_root),
719
+ "mcp-beta-readiness": lambda: _check_mcp_beta_readiness(project_root),
720
+ "offline-readiness": lambda: _check_offline_readiness(),
721
+ "python-runtime": lambda: _check_python_runtime(),
722
+ "tier-usage-readiness": lambda: _check_tier_usage_readiness(project_root),
723
+ "unsupported-combos": lambda: _check_unsupported_combos(manifest),
724
+ }
725
+ out: list[dict[str, Any]] = []
726
+ for cid in CHECK_IDS:
727
+ if only is not None and cid != only:
728
+ continue
729
+ out.append(runners[cid]())
730
+ return out
731
+
732
+
733
+
261
734
  def _emit_json(
262
735
  project_root: Path,
263
736
  missing: list[dict[str, Any]],
264
737
  modified: list[dict[str, Any]],
265
738
  foreign: list[dict[str, Any]],
266
739
  tag_drift: list[dict[str, Any]],
740
+ checks: list[dict[str, Any]] | None = None,
267
741
  ) -> None:
268
- print(json.dumps({
742
+ payload: dict[str, Any] = {
269
743
  "project_root": str(project_root),
270
744
  "missing": missing,
271
745
  "modified": modified,
272
746
  "foreign": foreign,
273
747
  "tag_drift": tag_drift,
274
- }, indent=2))
748
+ }
749
+ if checks is not None:
750
+ payload["checks"] = checks
751
+ print(json.dumps(payload, indent=2))
752
+
753
+
754
+ def _emit_checks_text(checks: list[dict[str, Any]]) -> None:
755
+ """Render the health-check block above the drift section."""
756
+ if not checks:
757
+ return
758
+ print("checks:")
759
+ for c in checks:
760
+ sym = STATUS_SYMBOLS.get(c["status"], "?")
761
+ print(f" {sym} {c['id']}: {c['message']}")
762
+ if c["status"] != "ok" and c.get("remedy"):
763
+ print(f" fix: {c['remedy']}")
764
+ print("")
275
765
 
276
766
 
277
767
  def _emit_text(
@@ -310,14 +800,20 @@ def _parse(argv: list[str]) -> argparse.Namespace:
310
800
  parser = argparse.ArgumentParser(
311
801
  prog="agent-config doctor",
312
802
  description=(
313
- "Read-only manifest ↔ filesystem drift report. Surfaces "
314
- "missing, modified, foreign, and tag-drift files."
803
+ "Manifest ↔ filesystem drift report plus install health "
804
+ "checks. See --check for the individual check ids."
315
805
  ),
316
806
  )
317
807
  parser.add_argument("--project", default=None,
318
808
  help="project root (default: cwd)")
319
809
  parser.add_argument("--json", action="store_true",
320
810
  help="emit a JSON report instead of human text")
811
+ parser.add_argument(
812
+ "--check", default=None, metavar="ID",
813
+ choices=list(CHECK_IDS),
814
+ help=("run a single health check by id "
815
+ f"({' · '.join(CHECK_IDS)})"),
816
+ )
321
817
  return parser.parse_args(argv)
322
818
 
323
819
 
@@ -338,12 +834,29 @@ def main(argv: list[str] | None = None) -> int:
338
834
  foreign = _foreign_records(
339
835
  project_root, _scan_foreign(project_root, manifest, known),
340
836
  )
837
+ drift_groups = {
838
+ "missing": missing, "modified": modified,
839
+ "foreign": foreign, "tag_drift": tag_drift,
840
+ }
841
+ checks = _run_checks(project_root, manifest, drift_groups, only=opts.check)
842
+ fail_check = any(c["status"] == "fail" for c in checks)
341
843
 
342
844
  if opts.json:
343
- _emit_json(project_root, missing, modified, foreign, tag_drift)
845
+ _emit_json(
846
+ project_root, missing, modified, foreign, tag_drift,
847
+ checks=checks,
848
+ )
344
849
  else:
345
- _emit_text(project_root, missing, modified, foreign, tag_drift)
346
-
850
+ _emit_checks_text(checks)
851
+ if opts.check is None:
852
+ _emit_text(project_root, missing, modified, foreign, tag_drift)
853
+
854
+ # Full-suite mode: exit code reflects drift only; failing health
855
+ # checks are surfaced visually but do not change the rc, preserving
856
+ # the original drift-detection contract. ``--check <id>`` mode is
857
+ # explicit and propagates the single check's verdict.
858
+ if opts.check is not None:
859
+ return 1 if fail_check else 0
347
860
  return 1 if (missing or modified or foreign or tag_drift) else 0
348
861
 
349
862
 
@@ -130,6 +130,7 @@ def main(argv: list[str]) -> int:
130
130
  if data is None:
131
131
  _emit(opts.quiet, f"❌ No manifest found at {manifest}")
132
132
  _emit(opts.quiet, " Run `./agent-config init --tools=<id>` to create one.")
133
+ _emit(opts.quiet, " Diagnose: `./agent-config doctor --check manifest-integrity`")
133
134
  return 1
134
135
 
135
136
  entries = list(data.get("tools") or [])
@@ -157,6 +158,15 @@ def main(argv: list[str]) -> int:
157
158
  _emit(opts.quiet, "")
158
159
  _emit(opts.quiet, "Run `./agent-config sync` to replay missing bridges, or")
159
160
  _emit(opts.quiet, "`./agent-config init --tools=<id> --force` to refresh the manifest.")
161
+ # Deeplink: route per-kind to the matching `doctor` check so users can
162
+ # copy-paste even though `doctor` is Tier-1 and absent from --help.
163
+ kinds = {issue["kind"] for issue in issues}
164
+ if "version_drift" in kinds:
165
+ _emit(opts.quiet, "Diagnose: `./agent-config doctor --check lockfile-freshness`")
166
+ if kinds & {"marker_missing", "scope_divergence"}:
167
+ _emit(opts.quiet, "Diagnose: `./agent-config doctor --check bridge-drift`")
168
+ if "manifest_corrupt" in kinds:
169
+ _emit(opts.quiet, "Diagnose: `./agent-config doctor --check manifest-integrity`")
160
170
  return 1
161
171
 
162
172