@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.
- package/.agent-src/personas/engineering-manager.md +133 -0
- package/.agent-src/personas/finance-partner.md +129 -0
- package/.agent-src/personas/people-strategist.md +126 -0
- package/.agent-src/personas/strategist.md +129 -0
- package/.agent-src/skills/build-buy-partner/SKILL.md +145 -0
- package/.agent-src/skills/comp-banding/SKILL.md +160 -0
- package/.agent-src/skills/competitive-moat-analysis/SKILL.md +152 -0
- package/.agent-src/skills/contracts-cognition/SKILL.md +147 -0
- package/.agent-src/skills/data-handling-judgment/SKILL.md +155 -0
- package/.agent-src/skills/forecasting/SKILL.md +164 -0
- package/.agent-src/skills/hiring-loop-design/SKILL.md +167 -0
- package/.agent-src/skills/market-entry-analysis/SKILL.md +144 -0
- package/.agent-src/skills/onboarding-program/SKILL.md +157 -0
- package/.agent-src/skills/one-on-one-cadence/SKILL.md +161 -0
- package/.agent-src/skills/org-design/SKILL.md +158 -0
- package/.agent-src/skills/perf-feedback-craft/SKILL.md +157 -0
- package/.agent-src/skills/privacy-review/SKILL.md +160 -0
- package/.agent-src/skills/runway-cognition/SKILL.md +136 -0
- package/.agent-src/skills/scenario-modeling/SKILL.md +139 -0
- package/.agent-src/skills/throughput-vs-morale-tradeoff/SKILL.md +165 -0
- package/.agent-src/skills/unit-economics-modeling/SKILL.md +54 -7
- package/.agent-src/skills/vision-articulation/SKILL.md +146 -0
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.agent-src/templates/scripts/telemetry/settings.py +65 -0
- package/.agent-src/templates/scripts/tier_usage_report.py +183 -0
- package/.claude-plugin/marketplace.json +18 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +106 -0
- package/README.md +3 -3
- package/docs/architecture.md +37 -11
- package/docs/catalog.md +22 -4
- package/docs/contracts/adr-forecast-construction-shape.md +89 -0
- package/docs/contracts/adr-wing4-context-spine.md +125 -0
- package/docs/contracts/command-clusters.md +41 -0
- package/docs/contracts/command-surface-tiers.md +25 -9
- package/docs/contracts/context-spine.md +8 -0
- package/docs/contracts/mcp-beta-criteria.md +129 -0
- package/docs/guidelines/wing4-handoff.md +127 -0
- package/docs/mcp-server.md +1 -1
- package/package.json +1 -1
- package/scripts/_cli/cmd_doctor.py +527 -14
- package/scripts/_cli/cmd_validate.py +10 -0
- package/scripts/agent-config +19 -18
- package/scripts/install.py +5 -0
- package/scripts/lint_context_spine_usage.py +1 -0
- package/scripts/mcp_server/__init__.py +1 -0
- package/scripts/mcp_server/server.py +4 -3
- package/scripts/schemas/skill.schema.json +2 -2
- package/scripts/skill_linter.py +107 -3
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
"""``agent-config doctor`` —
|
|
1
|
+
"""``agent-config doctor`` — install + manifest health report.
|
|
2
2
|
|
|
3
|
-
Phase 4 of road-to-multi-package-coexistence
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
"
|
|
314
|
-
"
|
|
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(
|
|
845
|
+
_emit_json(
|
|
846
|
+
project_root, missing, modified, foreign, tag_drift,
|
|
847
|
+
checks=checks,
|
|
848
|
+
)
|
|
344
849
|
else:
|
|
345
|
-
|
|
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
|
|