@event4u/agent-config 1.29.0 → 1.32.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/commands/agents/audit.md +101 -197
- package/.agent-src/commands/{copilot-agents → agents}/init.md +18 -10
- package/.agent-src/commands/agents/optimize.md +181 -0
- package/.agent-src/commands/agents.md +19 -12
- package/.agent-src/commands/optimize/agents-dir.md +111 -0
- package/.agent-src/commands/optimize.md +10 -8
- package/.agent-src/contexts/communication/rules-auto/guidelines-mechanics.md +6 -0
- package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +2 -3
- package/.agent-src/contexts/contracts/agents-md-anatomy.md +132 -0
- package/.agent-src/skills/agents-md-thin-root/SKILL.md +8 -1
- package/.agent-src/skills/command-writing/SKILL.md +49 -0
- package/.agent-src/skills/copilot-agents-optimization/SKILL.md +3 -3
- package/.agent-src/skills/error-handling-patterns/SKILL.md +2 -2
- package/.agent-src/skills/feature-planning/SKILL.md +43 -7
- package/.agent-src/skills/judge-test-coverage/SKILL.md +4 -0
- package/.agent-src/skills/pest-testing/SKILL.md +13 -6
- package/.agent-src/skills/quality-tools/SKILL.md +4 -0
- package/.agent-src/skills/refine-prompt/SKILL.md +10 -0
- package/.agent-src/skills/refine-ticket/SKILL.md +12 -0
- package/.agent-src/skills/{repomix → repomix-packer}/SKILL.md +8 -8
- package/.agent-src/skills/roadmap-writing/SKILL.md +9 -0
- package/.agent-src/skills/rule-writing/SKILL.md +21 -0
- package/.agent-src/skills/skill-writing/SKILL.md +19 -0
- package/.agent-src/skills/subagent-orchestration/SKILL.md +77 -12
- package/.agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
- package/.agent-src/skills/subagent-orchestration/prompts/do-and-judge-two-stage.md +121 -0
- package/.agent-src/skills/subagent-orchestration/prompts/do-and-judge.md +60 -0
- package/.agent-src/skills/subagent-orchestration/prompts/do-competitively.md +65 -0
- package/.agent-src/skills/subagent-orchestration/prompts/do-in-parallel.md +62 -0
- package/.agent-src/skills/subagent-orchestration/prompts/do-in-steps.md +62 -0
- package/.agent-src/skills/subagent-orchestration/prompts/do-in-worktrees.md +70 -0
- package/.agent-src/skills/subagent-orchestration/prompts/judge-with-debate.md +63 -0
- package/.agent-src/skills/subagent-orchestration/schemas/subagent-status.json +63 -0
- package/.agent-src/skills/test-driven-development/SKILL.md +25 -13
- package/.agent-src/skills/testing-anti-patterns/SKILL.md +14 -0
- package/.agent-src/skills/testing-anti-patterns/process-anti-patterns.md +67 -0
- package/.agent-src/templates/AGENTS.md +9 -10
- package/.claude-plugin/marketplace.json +5 -8
- package/AGENTS.md +1 -2
- package/CHANGELOG.md +110 -0
- package/CONTRIBUTING.md +90 -0
- package/README.md +3 -3
- package/docs/architecture.md +2 -2
- package/docs/catalog.md +12 -14
- package/docs/contracts/command-clusters.md +20 -3
- package/docs/contracts/file-ownership-matrix.json +546 -56
- package/docs/getting-started.md +1 -1
- package/docs/guidelines/code-clarity.md +95 -0
- package/docs/guidelines/php/general.md +8 -0
- package/docs/guidelines/php/php-coding-patterns.md +1 -0
- package/docs/skills-catalog.md +27 -3
- package/llms.txt +26 -2
- package/package.json +1 -1
- package/scripts/chat_history.py +166 -36
- package/scripts/check_bite_sized_granularity.py +99 -0
- package/scripts/check_command_count_messaging.py +12 -3
- package/scripts/check_portability.py +1 -0
- package/scripts/lint_agents_md.py +33 -0
- package/scripts/release.py +77 -2
- package/scripts/skill_linter.py +10 -3
- package/.agent-src/commands/agents/cleanup.md +0 -194
- package/.agent-src/commands/agents/prepare.md +0 -141
- package/.agent-src/commands/copilot-agents/optimize.md +0 -255
- package/.agent-src/commands/copilot-agents.md +0 -44
- package/.agent-src/commands/optimize/agents.md +0 -144
package/scripts/chat_history.py
CHANGED
|
@@ -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
|
|
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
|
|
415
|
-
the trailing N within the selected
|
|
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
|
-
|
|
577
|
+
entry_count = 0
|
|
578
|
+
per_agent: dict[str, int] = {}
|
|
554
579
|
with p.open(encoding="utf-8") as fh:
|
|
555
|
-
|
|
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":
|
|
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
|
|
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
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
990
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bite-sized task granularity gate for structural roadmaps (P1.5).
|
|
3
|
+
|
|
4
|
+
Adopted from `obra/superpowers` `writing-plans/SKILL.md` § Task Structure +
|
|
5
|
+
§ No Placeholders (v5.1.0). Complexity-gating is our addition (Council
|
|
6
|
+
Round 1, Q4) — only roadmaps tagged `complexity: structural` in frontmatter
|
|
7
|
+
are subject to the granularity rules; `complexity: lightweight` skips.
|
|
8
|
+
|
|
9
|
+
Public API (stdlib-only):
|
|
10
|
+
|
|
11
|
+
read_complexity(text) -> 'structural' | 'lightweight' | None
|
|
12
|
+
scan_placeholders(text) -> list[Placeholder]
|
|
13
|
+
check_granularity(text) -> Result(complexity, gated, violations)
|
|
14
|
+
|
|
15
|
+
`gated` is True only when `complexity == 'structural'`. Violations are
|
|
16
|
+
empty when the gate is not active, regardless of placeholder presence.
|
|
17
|
+
|
|
18
|
+
The CI contract for P1.5 is the pytest harness in
|
|
19
|
+
`tests/test_bite_sized_granularity.py`; this module is the test surface.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
|
|
26
|
+
PLACEHOLDER_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
27
|
+
("angle-placeholder", re.compile(r"<[a-z][a-z0-9 _\-/]*>", re.IGNORECASE)),
|
|
28
|
+
("todo", re.compile(r"\bTODO\b")),
|
|
29
|
+
("fixme", re.compile(r"\bFIXME\b")),
|
|
30
|
+
("xxx", re.compile(r"\bXXX\b")),
|
|
31
|
+
("tbd", re.compile(r"\btbd\b", re.IGNORECASE)),
|
|
32
|
+
("triple-question", re.compile(r"\?\?\?")),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
COMPLEXITY_PAT = re.compile(
|
|
36
|
+
r"^complexity:\s*(lightweight|structural)\s*$", re.MULTILINE
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Placeholder:
|
|
42
|
+
kind: str
|
|
43
|
+
line: int
|
|
44
|
+
text: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Result:
|
|
49
|
+
complexity: str | None
|
|
50
|
+
gated: bool
|
|
51
|
+
violations: list[Placeholder] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _frontmatter(text: str) -> str:
|
|
55
|
+
if not text.startswith("---\n"):
|
|
56
|
+
return ""
|
|
57
|
+
end = text.find("\n---\n", 4)
|
|
58
|
+
return text[4:end] if end != -1 else ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_complexity(text: str) -> str | None:
|
|
62
|
+
"""Return the `complexity:` value from the roadmap frontmatter, or None."""
|
|
63
|
+
fm = _frontmatter(text)
|
|
64
|
+
if not fm:
|
|
65
|
+
return None
|
|
66
|
+
m = COMPLEXITY_PAT.search(fm)
|
|
67
|
+
return m.group(1) if m else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def scan_placeholders(text: str) -> list[Placeholder]:
|
|
71
|
+
"""Return every placeholder hit in task-bullet lines (`- [ ]` / `- [x]`)."""
|
|
72
|
+
hits: list[Placeholder] = []
|
|
73
|
+
for line_no, line in enumerate(text.splitlines(), start=1):
|
|
74
|
+
stripped = line.lstrip()
|
|
75
|
+
if not stripped.startswith(("- [ ]", "- [x]", "- [/]", "- [-]")):
|
|
76
|
+
continue
|
|
77
|
+
for kind, pat in PLACEHOLDER_PATTERNS:
|
|
78
|
+
if pat.search(line):
|
|
79
|
+
hits.append(Placeholder(kind=kind, line=line_no, text=line.rstrip()))
|
|
80
|
+
break
|
|
81
|
+
return hits
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_granularity(text: str) -> Result:
|
|
85
|
+
"""Run the granularity gate.
|
|
86
|
+
|
|
87
|
+
Structural roadmaps fail on any placeholder hit in task bullets.
|
|
88
|
+
Lightweight or untagged roadmaps skip the gate (gated=False) and
|
|
89
|
+
return an empty violation list even when placeholders are present.
|
|
90
|
+
"""
|
|
91
|
+
complexity = read_complexity(text)
|
|
92
|
+
gated = complexity == "structural"
|
|
93
|
+
if not gated:
|
|
94
|
+
return Result(complexity=complexity, gated=False, violations=[])
|
|
95
|
+
return Result(
|
|
96
|
+
complexity=complexity,
|
|
97
|
+
gated=True,
|
|
98
|
+
violations=scan_placeholders(text),
|
|
99
|
+
)
|
|
@@ -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]
|