@event4u/agent-config 1.28.0 → 1.31.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 (52) hide show
  1. package/.agent-src/commands/agents/audit.md +101 -197
  2. package/.agent-src/commands/{copilot-agents → agents}/init.md +18 -10
  3. package/.agent-src/commands/agents/optimize.md +181 -0
  4. package/.agent-src/commands/agents.md +19 -12
  5. package/.agent-src/commands/optimize/agents-dir.md +111 -0
  6. package/.agent-src/commands/optimize.md +10 -8
  7. package/.agent-src/contexts/communication/rules-auto/guidelines-mechanics.md +6 -0
  8. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +2 -3
  9. package/.agent-src/contexts/contracts/agents-md-anatomy.md +132 -0
  10. package/.agent-src/skills/agents-md-thin-root/SKILL.md +8 -1
  11. package/.agent-src/skills/async-python-patterns/SKILL.md +147 -0
  12. package/.agent-src/skills/command-writing/SKILL.md +49 -0
  13. package/.agent-src/skills/copilot-agents-optimization/SKILL.md +3 -3
  14. package/.agent-src/skills/defense-in-depth/SKILL.md +152 -0
  15. package/.agent-src/skills/error-handling-patterns/SKILL.md +134 -0
  16. package/.agent-src/skills/mcp-builder/SKILL.md +108 -0
  17. package/.agent-src/skills/prompt-engineering-patterns/SKILL.md +145 -0
  18. package/.agent-src/skills/repomix-packer/SKILL.md +135 -0
  19. package/.agent-src/skills/roadmap-writing/SKILL.md +9 -0
  20. package/.agent-src/skills/rule-writing/SKILL.md +21 -0
  21. package/.agent-src/skills/secrets-management/SKILL.md +142 -0
  22. package/.agent-src/skills/skill-writing/SKILL.md +19 -0
  23. package/.agent-src/skills/testing-anti-patterns/SKILL.md +152 -0
  24. package/.agent-src/templates/AGENTS.md +9 -10
  25. package/.claude-plugin/marketplace.json +12 -7
  26. package/AGENTS.md +1 -2
  27. package/CHANGELOG.md +113 -0
  28. package/CONTRIBUTING.md +90 -0
  29. package/README.md +3 -3
  30. package/docs/architecture.md +3 -3
  31. package/docs/catalog.md +19 -13
  32. package/docs/contracts/command-clusters.md +20 -3
  33. package/docs/contracts/file-ownership-matrix.json +511 -48
  34. package/docs/contracts/package-self-orientation.md +1 -1
  35. package/docs/getting-started.md +1 -1
  36. package/docs/guidelines/code-clarity.md +95 -0
  37. package/docs/guidelines/php/general.md +8 -0
  38. package/docs/guidelines/php/php-coding-patterns.md +1 -0
  39. package/docs/skills-catalog.md +27 -3
  40. package/llms.txt +26 -2
  41. package/package.json +1 -1
  42. package/scripts/chat_history.py +166 -36
  43. package/scripts/check_command_count_messaging.py +12 -3
  44. package/scripts/check_portability.py +1 -0
  45. package/scripts/lint_agents_md.py +33 -0
  46. package/scripts/release.py +77 -2
  47. package/scripts/skill_linter.py +10 -3
  48. package/.agent-src/commands/agents/cleanup.md +0 -194
  49. package/.agent-src/commands/agents/prepare.md +0 -141
  50. package/.agent-src/commands/copilot-agents/optimize.md +0 -255
  51. package/.agent-src/commands/copilot-agents.md +0 -44
  52. package/.agent-src/commands/optimize/agents.md +0 -144
@@ -52,6 +52,13 @@ _WS_RE = re.compile(r"\s+")
52
52
  SESSION_ID_LEN = 16
53
53
  SESSION_ID_UNKNOWN = "<unknown>"
54
54
  SESSION_ID_LEGACY = "<legacy>"
55
+ # Sentinel for entries without an ``agent`` field — legacy rows or
56
+ # direct ``append`` calls that bypassed the platform-hook surface.
57
+ # Used by the ``agent`` filter on :func:`read_entries`, the AGENTS
58
+ # column in :func:`list_sessions`, and the ``agents`` aggregation in
59
+ # :func:`status` so cross-agent setups can spot unattributed traffic
60
+ # without a separate query.
61
+ AGENT_UNKNOWN = "<unknown>"
55
62
 
56
63
  # Per-entry-type text-length caps. 0 = full text, no whitespace collapse,
57
64
  # verbatim. N > 0 = collapse whitespace then slice to N chars and append a
@@ -405,14 +412,21 @@ def clear(*, path: Path | None = None) -> None:
405
412
 
406
413
  def read_entries(last: int | None = None, *,
407
414
  path: Path | None = None,
408
- session: str | None = None) -> list[dict[str, Any]]:
415
+ session: str | None = None,
416
+ agent: str | None = None) -> list[dict[str, Any]]:
409
417
  """Return entries (excluding the header) as a list of dicts.
410
418
 
411
419
  `last=None` returns all entries; `last=N` returns the trailing N.
412
420
  `session=None` keeps legacy "return everything" behaviour; an explicit
413
421
  string filters by exact match on each entry's `s` field. The `last`
414
- slice is applied **after** the session filter so callers always get
415
- the trailing N within the selected session.
422
+ slice is applied **after** the session/agent filters so callers
423
+ always get the trailing N within the selected scope.
424
+
425
+ `agent=None` returns entries from every agent. An explicit string
426
+ matches the body row's ``agent`` field; the sentinel ``<unknown>``
427
+ matches entries without an ``agent`` field (legacy or
428
+ unattributed). Filtering is exact-match — no glob, no regex.
429
+
416
430
  Malformed lines are skipped silently.
417
431
  """
418
432
  p = path or file_path()
@@ -434,6 +448,11 @@ def read_entries(last: int | None = None, *,
434
448
  entries.append(obj)
435
449
  if session is not None:
436
450
  entries = [e for e in entries if e.get("s") == session]
451
+ if agent is not None:
452
+ if agent == AGENT_UNKNOWN:
453
+ entries = [e for e in entries if not e.get("agent")]
454
+ else:
455
+ entries = [e for e in entries if e.get("agent") == agent]
437
456
  if last is not None and last >= 0:
438
457
  entries = entries[-last:]
439
458
  return entries
@@ -485,7 +504,7 @@ def list_sessions(path: Path | None = None,
485
504
  b = buckets.get(sid)
486
505
  if b is None:
487
506
  b = {"id": sid, "count": 0, "first_ts": None,
488
- "last_ts": None, "preview": ""}
507
+ "last_ts": None, "preview": "", "agents": []}
489
508
  if summary:
490
509
  b["_head"] = []
491
510
  b["_tail"] = deque(maxlen=5)
@@ -511,6 +530,10 @@ def list_sessions(path: Path | None = None,
511
530
  sid = SESSION_ID_LEGACY
512
531
  b = _bucket(sid)
513
532
  b["count"] += 1
533
+ agent = obj.get("agent") if isinstance(obj.get("agent"), str) else None
534
+ tag = agent if agent else AGENT_UNKNOWN
535
+ if tag not in b["agents"]:
536
+ b["agents"].append(tag)
514
537
  ts = obj.get("ts")
515
538
  if isinstance(ts, str) and ts:
516
539
  if b["first_ts"] is None or ts < b["first_ts"]:
@@ -536,6 +559,7 @@ def list_sessions(path: Path | None = None,
536
559
  out: list[dict[str, Any]] = []
537
560
  for b in buckets.values():
538
561
  b.pop("_preview_from", None)
562
+ b["agents"] = sorted(b["agents"])
539
563
  if summary:
540
564
  head = b.pop("_head", [])
541
565
  tail = list(b.pop("_tail", ()))
@@ -550,16 +574,38 @@ def status(*, path: Path | None = None) -> dict[str, Any]:
550
574
  if not p.is_file():
551
575
  return {"exists": False, "path": str(p)}
552
576
  header = read_header(p)
553
- size = p.stat().st_size
577
+ entry_count = 0
578
+ per_agent: dict[str, int] = {}
554
579
  with p.open(encoding="utf-8") as fh:
555
- entry_count = sum(1 for _ in fh) - (1 if header else 0)
580
+ for i, line in enumerate(fh):
581
+ line = line.strip()
582
+ if not line:
583
+ continue
584
+ try:
585
+ obj = json.loads(line)
586
+ except json.JSONDecodeError:
587
+ continue
588
+ if not isinstance(obj, dict):
589
+ continue
590
+ if i == 0 and obj.get("t") == "header":
591
+ continue
592
+ entry_count += 1
593
+ agent = obj.get("agent") if isinstance(obj.get("agent"), str) else None
594
+ tag = agent if agent else AGENT_UNKNOWN
595
+ per_agent[tag] = per_agent.get(tag, 0) + 1
596
+ size = p.stat().st_size
597
+ agents = {
598
+ "total": len(per_agent),
599
+ "per_agent": dict(sorted(per_agent.items())),
600
+ }
556
601
  return {
557
602
  "exists": True,
558
603
  "path": str(p),
559
604
  "size_bytes": size,
560
605
  "size_kb": round(size / 1024, 1),
561
- "entries": max(entry_count, 0),
606
+ "entries": entry_count,
562
607
  "header": header,
608
+ "agents": agents,
563
609
  }
564
610
 
565
611
 
@@ -905,7 +951,8 @@ def hook_append(event: str, *,
905
951
  session_id: str | None = None,
906
952
  payload: dict[str, Any] | None = None,
907
953
  path: Path | None = None,
908
- settings_path: Path | None = None) -> dict[str, Any]:
954
+ settings_path: Path | None = None,
955
+ dry_run: bool = False) -> dict[str, Any]:
909
956
  """Platform-hook entry point — stateless append per session tag.
910
957
 
911
958
  Designed for ``SessionStart``, ``UserPromptSubmit``, ``PostToolUse``,
@@ -931,6 +978,21 @@ def hook_append(event: str, *,
931
978
  Cadence-aware: events that don't match ``chat_history.frequency``
932
979
  are silently skipped. ``enabled: false`` short-circuits to a noop.
933
980
 
981
+ Session-rotation guardrail: when an incoming ``session_start`` (or
982
+ any event) introduces an ``s`` that differs from the most recent
983
+ body entry's ``s`` on a non-empty file, a one-line warning is
984
+ written to stderr so cross-agent setups see the rotation intent
985
+ before it lands. The append still proceeds — the warning is
986
+ advisory, not a block.
987
+
988
+ ``dry_run=True`` resolves cadence + entry shape but skips every
989
+ file write (header init, append, prune). The returned dict carries
990
+ ``dry_run: True`` plus a ``would_action`` mirroring what the live
991
+ invocation would have reported, and (for events that survive the
992
+ cadence filter) an ``entry_preview`` of the body row that would
993
+ have been written. Smoke tests use this to verify the resolved
994
+ chain without rotating the live session.
995
+
934
996
  Returns a structured dict the CLI emits as JSON. Never raises for
935
997
  non-fatal control-plane states (cadence skip, disabled,
936
998
  unknown-session) — these surface as ``action`` values so hooks
@@ -940,11 +1002,29 @@ def hook_append(event: str, *,
940
1002
  raise ValueError(f"event must be one of {sorted(VALID_HOOK_EVENTS)}")
941
1003
  sp = settings_path or Path(DEFAULT_SETTINGS_FILE)
942
1004
  if not _read_chat_history_enabled(sp):
943
- return {"action": "disabled", "event": event}
1005
+ return {"action": "disabled", "event": event,
1006
+ **({"dry_run": True} if dry_run else {})}
944
1007
  p = path or file_path()
945
1008
  payload = payload or {}
946
1009
  s_tag = derive_session_tag(session_id) if session_id else SESSION_ID_UNKNOWN
947
1010
 
1011
+ # Detect session rotation BEFORE any header init so the warning
1012
+ # fires against the on-disk state the agent actually sees, not the
1013
+ # post-init state. Empty / missing file → no rotation (nothing to
1014
+ # rotate from); unknown s_tag → no rotation (anonymous appends
1015
+ # can't be attributed to a new session).
1016
+ prior_s = _last_body_session_id(p) if p.is_file() else SESSION_ID_UNKNOWN
1017
+ is_new_session = (
1018
+ s_tag != SESSION_ID_UNKNOWN
1019
+ and prior_s != SESSION_ID_UNKNOWN
1020
+ and prior_s != s_tag
1021
+ )
1022
+ if is_new_session:
1023
+ sys.stderr.write(
1024
+ f"chat-history session_rotation event={event} "
1025
+ f"prior_s={prior_s} new_s={s_tag}\n"
1026
+ )
1027
+
948
1028
  # Lazily initialise the v4 header on first use so callers don't
949
1029
  # have to invoke `init` separately. Reset is still an explicit
950
1030
  # operation via reset_with_entries / clear. When the file already
@@ -953,23 +1033,15 @@ def hook_append(event: str, *,
953
1033
  # this branch, v3 headers parse as non-None and the lazy-init path
954
1034
  # never fires, leaving the file in a mixed v3-header / v4-body
955
1035
  # state forever.
956
- if not p.is_file() or read_header(p) is None:
957
- freq = _read_chat_history_frequency(sp)
958
- init(freq=freq, path=p)
959
- else:
960
- migrate_header(p, freq=_read_chat_history_frequency(sp))
961
-
962
- # Detect session change BEFORE appending so the new entry's `s`
963
- # doesn't shadow the previous one. Actual prune fires AFTER the
964
- # append so the cap is enforced against the post-append body
965
- # (otherwise the effective cap would be max_sessions + 1).
966
- is_new_session = (
967
- s_tag != SESSION_ID_UNKNOWN
968
- and _last_body_session_id(p) != s_tag
969
- )
1036
+ if not dry_run:
1037
+ if not p.is_file() or read_header(p) is None:
1038
+ freq = _read_chat_history_frequency(sp)
1039
+ init(freq=freq, path=p)
1040
+ else:
1041
+ migrate_header(p, freq=_read_chat_history_frequency(sp))
970
1042
 
971
1043
  def _maybe_prune() -> None:
972
- if not is_new_session:
1044
+ if dry_run or not is_new_session:
973
1045
  return
974
1046
  max_n = _read_chat_history_max_sessions(sp)
975
1047
  try:
@@ -979,15 +1051,30 @@ def hook_append(event: str, *,
979
1051
 
980
1052
  if event == "session_start":
981
1053
  _maybe_prune()
982
- return {"action": "session_start_noop", "event": event, "s": s_tag}
1054
+ action = "session_start_noop"
1055
+ out = {"action": "dry_run" if dry_run else action,
1056
+ "event": event, "s": s_tag}
1057
+ if dry_run:
1058
+ out["would_action"] = action
1059
+ out["dry_run"] = True
1060
+ return out
983
1061
  if event == "session_end":
984
1062
  _maybe_prune()
985
- return {"action": "session_end_noop", "event": event, "s": s_tag}
1063
+ action = "session_end_noop"
1064
+ out = {"action": "dry_run" if dry_run else action,
1065
+ "event": event, "s": s_tag}
1066
+ if dry_run:
1067
+ out["would_action"] = action
1068
+ out["dry_run"] = True
1069
+ return out
986
1070
 
987
1071
  freq = _read_chat_history_frequency(sp)
988
1072
  if event not in CADENCE_EVENTS.get(freq, frozenset()):
989
- return {"action": "skipped_cadence", "event": event,
990
- "frequency": freq}
1073
+ out = {"action": "skipped_cadence", "event": event,
1074
+ "frequency": freq}
1075
+ if dry_run:
1076
+ out["dry_run"] = True
1077
+ return out
991
1078
 
992
1079
  entry_type = HOOK_EVENT_ENTRY_TYPE.get(event, "agent")
993
1080
  limits = _read_text_limits(sp)
@@ -1004,6 +1091,12 @@ def hook_append(event: str, *,
1004
1091
  for k in ("agent", "source", "phase", "decision"):
1005
1092
  if payload.get(k):
1006
1093
  entry[k] = str(payload[k])
1094
+ if dry_run:
1095
+ preview = dict(entry)
1096
+ preview["s"] = s_tag
1097
+ return {"action": "dry_run", "would_action": "appended",
1098
+ "event": event, "type": entry_type, "s": s_tag,
1099
+ "entry_preview": preview, "dry_run": True}
1007
1100
  append(entry, path=p, session=s_tag)
1008
1101
  _maybe_prune()
1009
1102
  return {"action": "appended", "event": event,
@@ -1285,7 +1378,8 @@ def _extract_session_id(payload: dict[str, Any]) -> str:
1285
1378
  def hook_dispatch(platform: str, raw_json: str, *,
1286
1379
  event_override: str | None = None,
1287
1380
  path: Path | None = None,
1288
- settings_path: Path | None = None) -> dict[str, Any]:
1381
+ settings_path: Path | None = None,
1382
+ dry_run: bool = False) -> dict[str, Any]:
1289
1383
  """Read a platform's stdin JSON, translate to our hook vocabulary, dispatch.
1290
1384
 
1291
1385
  Used by ``chat_history.py hook-dispatch --platform <name>`` so
@@ -1300,6 +1394,10 @@ def hook_dispatch(platform: str, raw_json: str, *,
1300
1394
  16-char ``s`` tag carried on every body entry. No sidecar, no
1301
1395
  ownership, no auto-adopt — multi-session coexistence is implicit
1302
1396
  via the ``s`` field.
1397
+
1398
+ ``dry_run=True`` propagates to :func:`hook_append` so the resolved
1399
+ chain (platform → raw_event → mapped event → cadence) is reported
1400
+ without writing the live history file.
1303
1401
  """
1304
1402
  if platform not in PLATFORM_EVENT_MAP:
1305
1403
  raise ValueError(
@@ -1366,10 +1464,12 @@ def hook_dispatch(platform: str, raw_json: str, *,
1366
1464
  "agent": platform,
1367
1465
  },
1368
1466
  path=path, settings_path=settings_path,
1467
+ dry_run=dry_run,
1369
1468
  )
1370
1469
 
1371
1470
  return hook_append(event, session_id=session_id, payload=hook_payload,
1372
- path=path, settings_path=settings_path)
1471
+ path=path, settings_path=settings_path,
1472
+ dry_run=dry_run)
1373
1473
 
1374
1474
 
1375
1475
  def _cmd_init(args) -> int:
@@ -1398,6 +1498,7 @@ def _cmd_hook_append(args) -> int:
1398
1498
  session_id=args.session_id,
1399
1499
  payload=payload,
1400
1500
  settings_path=settings_path,
1501
+ dry_run=getattr(args, "dry_run", False),
1401
1502
  )
1402
1503
  except ValueError as exc:
1403
1504
  print(f"error: {exc}", file=sys.stderr)
@@ -1415,6 +1516,7 @@ def _cmd_hook_dispatch(args) -> int:
1415
1516
  raw,
1416
1517
  event_override=args.event,
1417
1518
  settings_path=settings_path,
1519
+ dry_run=getattr(args, "dry_run", False),
1418
1520
  )
1419
1521
  except ValueError as exc:
1420
1522
  print(f"error: {exc}", file=sys.stderr)
@@ -1487,10 +1589,17 @@ def _cmd_clear(_args) -> int:
1487
1589
 
1488
1590
  def _cmd_read(args) -> int:
1489
1591
  last = None if args.all else args.last
1592
+ agent = args.agent
1490
1593
  if args.all:
1491
- entries = read_entries(last=last, session=None)
1594
+ entries = read_entries(last=last, session=None, agent=agent)
1492
1595
  elif args.session is not None:
1493
- entries = read_entries(last=last, session=args.session)
1596
+ entries = read_entries(last=last, session=args.session, agent=agent)
1597
+ elif agent is not None:
1598
+ # Agent filter without --session implies "across the most recent
1599
+ # session"; honour the current-session scope of the default
1600
+ # `read` semantics so the filter narrows, never widens.
1601
+ sid = _last_body_session_id(file_path())
1602
+ entries = read_entries(last=last, session=sid, agent=agent)
1494
1603
  else:
1495
1604
  entries = read_entries_for_current(last=last)
1496
1605
  print(json.dumps(entries, ensure_ascii=False, indent=2))
@@ -1509,21 +1618,23 @@ def _cmd_sessions(args) -> int:
1509
1618
  print("(no sessions)")
1510
1619
  return 0
1511
1620
  last_col = "SUMMARY" if args.summary else "PREVIEW"
1512
- rows = [("ID", "COUNT", "LAST_TS", last_col)]
1621
+ rows = [("ID", "COUNT", "AGENTS", "LAST_TS", last_col)]
1513
1622
  for s in sessions:
1514
1623
  last_val = s.get("summary") if args.summary else s.get("preview")
1624
+ agents_val = ",".join(s.get("agents") or []) or "-"
1515
1625
  rows.append((
1516
1626
  s["id"],
1517
1627
  str(s["count"]),
1628
+ agents_val,
1518
1629
  s["last_ts"] or "-",
1519
1630
  last_val or "-",
1520
1631
  ))
1521
- widths = [max(len(r[i]) for r in rows) for i in range(4)]
1632
+ widths = [max(len(r[i]) for r in rows) for i in range(5)]
1522
1633
  for i, r in enumerate(rows):
1523
- line = " ".join(r[j].ljust(widths[j]) for j in range(4))
1634
+ line = " ".join(r[j].ljust(widths[j]) for j in range(5))
1524
1635
  print(line)
1525
1636
  if i == 0:
1526
- print(" ".join("-" * widths[j] for j in range(4)))
1637
+ print(" ".join("-" * widths[j] for j in range(5)))
1527
1638
  return 0
1528
1639
 
1529
1640
 
@@ -1596,6 +1707,13 @@ def main(argv: list[str] | None = None) -> int:
1596
1707
  "(16-char sha256(session_id), '<legacy>', or '<unknown>'); "
1597
1708
  "defaults to the most recent session"),
1598
1709
  )
1710
+ p_read.add_argument(
1711
+ "--agent", default=None,
1712
+ help=("filter to entries whose body 'agent' field matches "
1713
+ f"(exact match; use '{AGENT_UNKNOWN}' for unattributed "
1714
+ "entries). When --session is omitted, the filter applies "
1715
+ "to the most recent session."),
1716
+ )
1599
1717
  p_read.set_defaults(func=_cmd_read)
1600
1718
  p_sess = sub.add_parser("sessions")
1601
1719
  p_sess.add_argument("--limit", type=int, default=20,
@@ -1641,6 +1759,12 @@ def main(argv: list[str] | None = None) -> int:
1641
1759
  default=None,
1642
1760
  help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
1643
1761
  )
1762
+ p_hook.add_argument(
1763
+ "--dry-run",
1764
+ action="store_true",
1765
+ help=("resolve cadence + entry shape and print the plan; "
1766
+ "skip every file write (header init, append, prune)"),
1767
+ )
1644
1768
  p_hook.set_defaults(func=_cmd_hook_append)
1645
1769
  p_disp = sub.add_parser(
1646
1770
  "hook-dispatch",
@@ -1665,6 +1789,12 @@ def main(argv: list[str] | None = None) -> int:
1665
1789
  default=None,
1666
1790
  help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
1667
1791
  )
1792
+ p_disp.add_argument(
1793
+ "--dry-run",
1794
+ action="store_true",
1795
+ help=("resolve platform → event → cadence chain and print the "
1796
+ "plan; skip every file write (no header, no body row)"),
1797
+ )
1668
1798
  p_disp.set_defaults(func=_cmd_hook_dispatch)
1669
1799
  args = ap.parse_args(argv)
1670
1800
  return args.func(args)
@@ -93,14 +93,23 @@ def main() -> int:
93
93
  # Shim-specific messaging only applies during a deprecation window.
94
94
  # When shims == 0 the clauses are dropped from public docs entirely;
95
95
  # re-add these patterns when a new deprecation cycle starts.
96
+ #
97
+ # AGENTS.md is Thin-Root (per agents-md-thin-root skill) — it carries
98
+ # pointers, not an inventory tree. Tree-shaped shim messaging only
99
+ # applies when AGENTS.md actually contains a `commands/` tree block
100
+ # (legacy form). README absorbs the deprecation advertisement either way.
96
101
  if shims > 0:
97
102
  checks.extend([
98
103
  (README, r"\((\d+) files total ", total, "browse meta · total files"),
99
104
  (README, r"— (\d+) are deprecation shims", shims, "browse meta · shims"),
100
- (AGENTS, r"commands/\s+\((\d+) files —", total, "tree · total files"),
101
- (AGENTS, r"files — (\d+) active", active, "tree · active"),
102
- (AGENTS, r"active \+ (\d+) deprecation shims", shims, "tree · shims"),
103
105
  ])
106
+ agents_text = AGENTS.read_text(encoding="utf-8") if AGENTS.exists() else ""
107
+ if re.search(r"commands/\s+\(", agents_text):
108
+ checks.extend([
109
+ (AGENTS, r"commands/\s+\((\d+) files —", total, "tree · total files"),
110
+ (AGENTS, r"files — (\d+) active", active, "tree · active"),
111
+ (AGENTS, r"active \+ (\d+) deprecation shims", shims, "tree · shims"),
112
+ ])
104
113
 
105
114
  errors: list[str] = []
106
115
  for path, pattern, expected, label in checks:
@@ -318,6 +318,7 @@ _TASK_DETECTOR_SKIP = (
318
318
  "contexts/communication/rules-auto/augment-portability-mechanics.md",
319
319
  "rules/package-ci-checks.md",
320
320
  "contexts/communication/rules-auto/package-ci-checks-mechanics.md",
321
+ "contexts/contracts/agents-md-anatomy.md",
321
322
  )
322
323
 
323
324
 
@@ -9,6 +9,9 @@ contract from `.agent-src.uncompressed/skills/agents-md-thin-root/SKILL.md`:
9
9
  (c) every pointer's *why* clause >= 60 chars
10
10
  (d) every pointer target resolves on disk (anchor validity)
11
11
  (e) emergency-triage section present with the five canonical questions
12
+ (f) path-enumeration WARN — bare `path/` bullets without a *why*
13
+ clause and without a markdown link signal Capability-over-Structure
14
+ drift; >= 3 such lines emits a WARN (not FAIL).
12
15
 
13
16
  Exit non-zero on any (a) FAIL, (b)–(e) error. WARN is informational.
14
17
  """
@@ -23,6 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent
23
26
  QUIET = "--quiet" in sys.argv
24
27
 
25
28
  LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
29
+ PATH_BACKTICK_RE = re.compile(r"`[^`]*/[^`]*`")
30
+ BULLET_RE = re.compile(r"^\s*[-*+]\s+")
31
+ PATH_ENUM_THRESHOLD = 3
26
32
  TRIAGE_KEYWORDS = (
27
33
  "what is this repo",
28
34
  "what language",
@@ -32,6 +38,21 @@ TRIAGE_KEYWORDS = (
32
38
  )
33
39
 
34
40
 
41
+ def _is_path_enumeration(line: str) -> bool:
42
+ """A bullet line with a backticked path-like token and no link.
43
+
44
+ These lines describe **where** a file lives without explaining
45
+ **why** the agent should care — the Capabilities-over-Structure
46
+ Iron Law forbids them in AGENTS.md. We accept up to two such
47
+ lines (illustration / contrast); >= 3 = warn.
48
+ """
49
+ if not BULLET_RE.match(line):
50
+ return False
51
+ if LINK_RE.search(line):
52
+ return False
53
+ return bool(PATH_BACKTICK_RE.search(line))
54
+
55
+
35
56
  @dataclass
36
57
  class Target:
37
58
  path: Path
@@ -113,8 +134,11 @@ def lint_file(t: Target) -> tuple[bool, list[str], list[str]]:
113
134
 
114
135
  non_blank = prose
115
136
  pointer_lines = 0
137
+ path_enum_lines: list[str] = []
116
138
 
117
139
  for ln in non_blank:
140
+ if _is_path_enumeration(ln):
141
+ path_enum_lines.append(ln.strip())
118
142
  m = LINK_RE.search(ln)
119
143
  if not m:
120
144
  continue
@@ -136,6 +160,15 @@ def lint_file(t: Target) -> tuple[bool, list[str], list[str]]:
136
160
  f"({pointer_lines}/{len(non_blank)} non-blank lines)"
137
161
  )
138
162
 
163
+ # (f) path-enumeration WARN
164
+ if len(path_enum_lines) >= PATH_ENUM_THRESHOLD:
165
+ sample = path_enum_lines[0][:80]
166
+ warnings.append(
167
+ f"{t.label}: {len(path_enum_lines)} path-enumeration lines "
168
+ f"(>= {PATH_ENUM_THRESHOLD}) — Capabilities-over-Structure drift; "
169
+ f"first: {sample!r}"
170
+ )
171
+
139
172
  # (e) emergency-triage block
140
173
  lower = text.lower()
141
174
  missing = [k for k in TRIAGE_KEYWORDS if k not in lower]
@@ -319,8 +319,15 @@ def render_changelog_entry(
319
319
  prev: str | None,
320
320
  commits: list[Commit],
321
321
  today: str,
322
+ *,
323
+ test_trend_line: str | None = None,
322
324
  ) -> tuple[str, str]:
323
- """Return (heading-aware full entry, body-only for GitHub Release notes)."""
325
+ """Return (heading-aware full entry, body-only for GitHub Release notes).
326
+
327
+ ``test_trend_line`` — optional pre-computed ``Tests: N (+M …)`` footer
328
+ (road-to-feedback-followups P3.2). Computed by the caller so tests
329
+ don't trigger a recursive pytest collection.
330
+ """
324
331
  if prev:
325
332
  heading = (
326
333
  f"## [{version}](https://github.com/{REPO_SLUG}/compare/"
@@ -364,6 +371,12 @@ def render_changelog_entry(
364
371
  for c in other:
365
372
  body_lines.append(_changelog_line(c))
366
373
 
374
+ # Test-count trend footer (road-to-feedback-followups P3.2). Silent
375
+ # on errors — never a release blocker.
376
+ if test_trend_line:
377
+ body_lines.append("")
378
+ body_lines.append(test_trend_line)
379
+
367
380
  body = "\n".join(body_lines).lstrip("\n")
368
381
  full = heading + "\n\n" + body + "\n"
369
382
  return full, body
@@ -376,6 +389,65 @@ def _changelog_line(c: Commit) -> str:
376
389
  return f"* {scope}{c.subject} ([{short}]({link}))"
377
390
 
378
391
 
392
+ # ─── test-count trend (road-to-feedback-followups P3.2) ───────────────────────
393
+
394
+ _TEST_COUNT_LINE_RE = re.compile(r"^Tests:\s+(\d+)", re.MULTILINE)
395
+ _PYTEST_COLLECTED_RE = re.compile(r"^(\d+)\s+tests?\s+collected", re.MULTILINE)
396
+
397
+
398
+ def _count_tests_current() -> int | None:
399
+ """Return the count from `pytest --collect-only -q` on the current
400
+ tree. Returns None when pytest isn't available or collection fails —
401
+ the trend line is informational, never a release blocker.
402
+ """
403
+ try:
404
+ result = subprocess.run(
405
+ ["python3", "-m", "pytest", "--collect-only", "-q"],
406
+ cwd=str(REPO_ROOT),
407
+ capture_output=True,
408
+ text=True,
409
+ timeout=120,
410
+ )
411
+ except (FileNotFoundError, subprocess.TimeoutExpired):
412
+ return None
413
+ if result.returncode != 0:
414
+ return None
415
+ match = _PYTEST_COLLECTED_RE.search(result.stdout)
416
+ return int(match.group(1)) if match else None
417
+
418
+
419
+ def _previous_test_count_from_changelog(prev_tag: str | None) -> int | None:
420
+ """Read CHANGELOG.md and return the most recent ``Tests: N`` footer
421
+ under the ``prev_tag`` heading, or None when not found.
422
+ """
423
+ if not prev_tag or not CHANGELOG.exists():
424
+ return None
425
+ text = CHANGELOG.read_text(encoding="utf-8")
426
+ heading_re = re.compile(rf"^##\s+\[?{re.escape(prev_tag)}\b", re.MULTILINE)
427
+ m = heading_re.search(text)
428
+ if not m:
429
+ return None
430
+ next_heading = re.search(r"^##\s+\[?\d+\.\d+\.\d+", text[m.end():], re.MULTILINE)
431
+ section = text[m.end(): m.end() + (next_heading.start() if next_heading else len(text))]
432
+ count_match = _TEST_COUNT_LINE_RE.search(section)
433
+ return int(count_match.group(1)) if count_match else None
434
+
435
+
436
+ def _render_test_trend_line(prev_tag: str | None) -> str | None:
437
+ """Return the ``Tests: N (+M since X.Y.Z)`` footer line, or None when
438
+ the current count cannot be determined. Silent on collection errors.
439
+ """
440
+ current = _count_tests_current()
441
+ if current is None:
442
+ return None
443
+ previous = _previous_test_count_from_changelog(prev_tag)
444
+ if previous is None or not prev_tag:
445
+ return f"Tests: {current}"
446
+ delta = current - previous
447
+ sign = "+" if delta >= 0 else ""
448
+ return f"Tests: {current} ({sign}{delta} since {prev_tag})"
449
+
450
+
379
451
  def prepend_changelog(path: Path, entry: str) -> None:
380
452
  """Insert `entry` directly above the most recent `## [` heading."""
381
453
  text = path.read_text(encoding="utf-8")
@@ -787,7 +859,10 @@ def main(argv: list[str] | None = None) -> int:
787
859
  preflight(target, resume=args.resume)
788
860
 
789
861
  today = _date.today().isoformat()
790
- full, body = render_changelog_entry(target, prev, commits, today)
862
+ test_trend_line = _render_test_trend_line(prev)
863
+ full, body = render_changelog_entry(
864
+ target, prev, commits, today, test_trend_line=test_trend_line
865
+ )
791
866
  plan = Plan(
792
867
  current=current,
793
868
  target=target,
@@ -1291,12 +1291,19 @@ def lint_command(path: Path, text: str) -> LintResult:
1291
1291
  if not H1_PATTERN.search(text):
1292
1292
  issues.append(Issue("error", "missing_h1", "Command is missing an H1 heading (# Title)"))
1293
1293
 
1294
- # Must have at least one ## section with steps
1294
+ # Must have at least one ## section with steps. Cluster-head and
1295
+ # router-style commands (frontmatter cluster:/routes_to: or ≥ 3 .md
1296
+ # links) are exempt — they delegate procedure to sub-commands or
1297
+ # skills (road-to-feedback-followups P2.1).
1295
1298
  sections = extract_sections(text)
1296
1299
  has_steps = any(s.lower().startswith("step") for s in sections)
1297
- has_numbered = bool(re.search(r"^###?\s+\d+\.\s+", text, re.MULTILINE))
1300
+ # Accept both ``## 1.`` / ``### 1.`` numbered headings AND
1301
+ # ``### Step N`` / ``## Step N`` step-prefixed sub-headings.
1302
+ has_numbered = bool(re.search(r"^###?\s+(?:\d+\.|step\s+\d+)\s+", text, re.MULTILINE | re.IGNORECASE))
1298
1303
  if not has_steps and not has_numbered:
1299
- issues.append(Issue("warning", "no_steps", "Command has no Steps section or numbered sub-headings"))
1304
+ delegated = _command_delegation_signal(text, frontmatter)
1305
+ if not delegated:
1306
+ issues.append(Issue("warning", "no_steps", "Command has no Steps section or numbered sub-headings"))
1300
1307
 
1301
1308
  # --- Size check (docs/contracts/linter-structural-model.md) ---
1302
1309
  # Structural-density gate replaces sub-section + code-block heuristic