@event4u/agent-config 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.agent-src/personas/engineering-manager.md +133 -0
  2. package/.agent-src/personas/finance-partner.md +129 -0
  3. package/.agent-src/personas/people-strategist.md +126 -0
  4. package/.agent-src/personas/strategist.md +129 -0
  5. package/.agent-src/rules/no-roadmap-references.md +19 -0
  6. package/.agent-src/skills/build-buy-partner/SKILL.md +145 -0
  7. package/.agent-src/skills/comp-banding/SKILL.md +160 -0
  8. package/.agent-src/skills/competitive-moat-analysis/SKILL.md +152 -0
  9. package/.agent-src/skills/contracts-cognition/SKILL.md +147 -0
  10. package/.agent-src/skills/data-handling-judgment/SKILL.md +155 -0
  11. package/.agent-src/skills/forecasting/SKILL.md +164 -0
  12. package/.agent-src/skills/hiring-loop-design/SKILL.md +167 -0
  13. package/.agent-src/skills/market-entry-analysis/SKILL.md +144 -0
  14. package/.agent-src/skills/onboarding-program/SKILL.md +157 -0
  15. package/.agent-src/skills/one-on-one-cadence/SKILL.md +161 -0
  16. package/.agent-src/skills/org-design/SKILL.md +158 -0
  17. package/.agent-src/skills/perf-feedback-craft/SKILL.md +157 -0
  18. package/.agent-src/skills/privacy-review/SKILL.md +160 -0
  19. package/.agent-src/skills/runway-cognition/SKILL.md +136 -0
  20. package/.agent-src/skills/scenario-modeling/SKILL.md +139 -0
  21. package/.agent-src/skills/throughput-vs-morale-tradeoff/SKILL.md +165 -0
  22. package/.agent-src/skills/unit-economics-modeling/SKILL.md +54 -7
  23. package/.agent-src/skills/vision-articulation/SKILL.md +146 -0
  24. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/.agent-src/templates/scripts/telemetry/settings.py +65 -0
  26. package/.agent-src/templates/scripts/tier_usage_report.py +183 -0
  27. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +32 -3
  28. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +147 -1
  29. package/.claude-plugin/marketplace.json +18 -1
  30. package/AGENTS.md +1 -1
  31. package/CHANGELOG.md +134 -0
  32. package/README.md +34 -14
  33. package/config/agent-settings.template.yml +28 -0
  34. package/docs/architecture.md +37 -11
  35. package/docs/catalog.md +22 -4
  36. package/docs/contracts/adr-forecast-construction-shape.md +89 -0
  37. package/docs/contracts/adr-wing4-context-spine.md +125 -0
  38. package/docs/contracts/command-clusters.md +41 -0
  39. package/docs/contracts/command-surface-tiers.md +25 -9
  40. package/docs/contracts/context-spine.md +8 -0
  41. package/docs/contracts/decision-trace-v1.md +30 -0
  42. package/docs/contracts/hook-architecture-v1.md +46 -0
  43. package/docs/contracts/mcp-beta-criteria.md +129 -0
  44. package/docs/contracts/memory-visibility-v1.md +33 -0
  45. package/docs/contracts/settings-sync-yaml-subset.md +138 -0
  46. package/docs/guidelines/wing4-handoff.md +127 -0
  47. package/docs/mcp-server.md +1 -1
  48. package/docs/readme-split-plan.md +102 -0
  49. package/package.json +1 -1
  50. package/scripts/_cli/cmd_doctor.py +527 -14
  51. package/scripts/_cli/cmd_settings_check.py +171 -0
  52. package/scripts/_cli/cmd_validate.py +10 -0
  53. package/scripts/agent-config +59 -18
  54. package/scripts/chat_history.py +19 -0
  55. package/scripts/check_council_references.py +46 -5
  56. package/scripts/hooks/dispatch_hook.py +5 -1
  57. package/scripts/hooks/replay_hook.py +144 -0
  58. package/scripts/hooks/state_io.py +24 -1
  59. package/scripts/hooks_doctor.py +184 -0
  60. package/scripts/install.py +5 -0
  61. package/scripts/lint_context_spine_usage.py +1 -0
  62. package/scripts/lint_hook_concern_budget.py +203 -0
  63. package/scripts/mcp_server/__init__.py +1 -0
  64. package/scripts/mcp_server/server.py +4 -3
  65. package/scripts/roadmap_progress_hook.py +11 -0
  66. package/scripts/schemas/skill.schema.json +2 -2
  67. package/scripts/skill_linter.py +107 -3
@@ -0,0 +1,171 @@
1
+ """``agent-config settings:check`` — validate ``.agent-settings.yml`` against the supported YAML subset.
2
+
3
+ Read-only. Implements P3.2 of road-to-proof-not-features.md. The contract
4
+ this checks against is pinned in
5
+ ``docs/contracts/settings-sync-yaml-subset.md``; out-of-subset constructs
6
+ cause :class:`sync_yaml_rt` to raise ``ValueError`` during a sync. This
7
+ CLI surfaces the same findings *before* a sync runs, so users can fix
8
+ their file without watching the merge fail.
9
+
10
+ Output line format::
11
+
12
+ line:N <kind> <verdict> <fix hint>
13
+
14
+ Exit codes:
15
+
16
+ * ``0`` — file is inside the supported subset (or absent and ``--allow-missing``).
17
+ * ``1`` — one or more findings (verdict ``not supported``).
18
+ * ``2`` — file absent (without ``--allow-missing``) or unreadable.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ # Imported lazily inside ``main`` so a missing engine cannot break ``--help``.
28
+
29
+ DEFAULT_PATH = ".agent-settings.yml"
30
+
31
+ # Out-of-subset patterns detected by a line-level pre-scan. Each rule is
32
+ # (label, regex, fix hint). The regex is applied to the *stripped* body
33
+ # of each non-comment line so leading indent does not affect matching.
34
+ _PRESCAN_RULES: tuple[tuple[str, re.Pattern[str], str], ...] = (
35
+ (
36
+ "multi-doc separator",
37
+ re.compile(r"^(---|\.\.\.)\s*(#.*)?$"),
38
+ "remove the separator — one YAML document per file only.",
39
+ ),
40
+ (
41
+ "complex key",
42
+ re.compile(r"^\?\s"),
43
+ "rewrite as a plain ``key: value`` mapping line.",
44
+ ),
45
+ (
46
+ "block-scalar indicator",
47
+ re.compile(r":\s*[|>][+-]?\s*(#.*)?$"),
48
+ "inline the value as a single-line quoted scalar.",
49
+ ),
50
+ (
51
+ "tagged scalar",
52
+ re.compile(r":\s*!!?[A-Za-z_]"),
53
+ "remove the ``!tag``; the parser does not honour it.",
54
+ ),
55
+ (
56
+ "anchor / alias",
57
+ re.compile(r":\s*[&*][A-Za-z_]"),
58
+ "expand the anchor inline — anchors / aliases are not supported.",
59
+ ),
60
+ (
61
+ "nested flow-mapping",
62
+ re.compile(r":\s*\{[^}]*:[^}]*\}"),
63
+ "rewrite as a block-style nested mapping (indented child keys).",
64
+ ),
65
+ )
66
+
67
+
68
+ def _scan_line(stripped: str) -> tuple[str, str] | None:
69
+ if not stripped or stripped.startswith("#"):
70
+ return None
71
+ for label, pattern, hint in _PRESCAN_RULES:
72
+ if pattern.search(stripped):
73
+ return label, hint
74
+ return None
75
+
76
+
77
+ def _scan_text(text: str) -> list[dict]:
78
+ findings: list[dict] = []
79
+ for lineno, raw in enumerate(text.splitlines(), 1):
80
+ stripped = raw.strip()
81
+ if "\t" in raw[: len(raw) - len(raw.lstrip(" \t"))]:
82
+ findings.append({
83
+ "line": lineno,
84
+ "kind": "tab in indent",
85
+ "verdict": "not supported",
86
+ "hint": "replace leading tabs with 2 or 4 spaces.",
87
+ })
88
+ continue
89
+ hit = _scan_line(stripped)
90
+ if hit is not None:
91
+ label, hint = hit
92
+ findings.append({
93
+ "line": lineno,
94
+ "kind": label,
95
+ "verdict": "not supported",
96
+ "hint": hint,
97
+ })
98
+ return findings
99
+
100
+
101
+ def _format(finding: dict) -> str:
102
+ return (
103
+ f" ❌ line:{finding['line']:<4} "
104
+ f"{finding['kind']:<22} {finding['verdict']:<14} {finding['hint']}"
105
+ )
106
+
107
+
108
+ def _parse(argv: list[str]) -> argparse.Namespace:
109
+ parser = argparse.ArgumentParser(
110
+ prog="agent-config settings:check",
111
+ description=(
112
+ "Validate .agent-settings.yml against the supported YAML subset "
113
+ "(docs/contracts/settings-sync-yaml-subset.md). Read-only."
114
+ ),
115
+ )
116
+ parser.add_argument("--path", default=DEFAULT_PATH,
117
+ help=f"target settings file (default: ./{DEFAULT_PATH})")
118
+ parser.add_argument("--allow-missing", action="store_true",
119
+ help="exit 0 when the file is absent (CI-friendly)")
120
+ parser.add_argument("--quiet", action="store_true",
121
+ help="suppress non-essential output")
122
+ return parser.parse_args(argv)
123
+
124
+
125
+ def main(argv: list[str]) -> int:
126
+ opts = _parse(argv)
127
+ target = Path(opts.path)
128
+ if not target.is_file():
129
+ if opts.allow_missing:
130
+ if not opts.quiet:
131
+ print(f"✅ {target}: file absent (allow-missing).")
132
+ return 0
133
+ print(f"❌ {target}: file not found.", file=sys.stderr)
134
+ print(" Run `./agent-config sync-agent-settings` to create it.", file=sys.stderr)
135
+ return 2
136
+ try:
137
+ text = target.read_text(encoding="utf-8")
138
+ except OSError as exc:
139
+ print(f"❌ {target}: cannot read: {exc}", file=sys.stderr)
140
+ return 2
141
+
142
+ findings = _scan_text(text)
143
+ if not findings:
144
+ # Final gate: run the round-trip parser to catch anything the
145
+ # pre-scan missed (mismatched indent, malformed mapping lines).
146
+ from scripts import sync_yaml_rt as _rt # noqa: PLC0415
147
+ try:
148
+ _rt.parse(text)
149
+ except ValueError as exc:
150
+ findings.append({
151
+ "line": 0, "kind": "parser",
152
+ "verdict": "not supported", "hint": str(exc),
153
+ })
154
+
155
+ if not findings:
156
+ if not opts.quiet:
157
+ print(f"✅ {target}: inside the supported subset "
158
+ "(docs/contracts/settings-sync-yaml-subset.md).")
159
+ return 0
160
+ print(f"❌ {target}: {len(findings)} finding(s) outside the supported subset.",
161
+ file=sys.stderr)
162
+ for finding in findings:
163
+ print(_format(finding), file=sys.stderr)
164
+ print("", file=sys.stderr)
165
+ print(" Contract: docs/contracts/settings-sync-yaml-subset.md",
166
+ file=sys.stderr)
167
+ return 1
168
+
169
+
170
+ if __name__ == "__main__":
171
+ sys.exit(main(sys.argv[1:]))
@@ -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
 
@@ -62,17 +62,6 @@ Tier 0 — daily-driver (init → sync → validate → work):
62
62
  (Option-A loop; called by the /work command)
63
63
  implement-ticket Drive the work_engine Python engine on a ticket envelope
64
64
  (Option-A loop; called by the /implement-ticket command)
65
- first-run Guided first-run setup — cost profile, settings, tooling
66
- keys:install-anthropic Install the Anthropic API key for the AI Council
67
- (interactive, /dev/tty only, writes ~/.config/agent-config/anthropic.key 0600)
68
- keys:install-openai Install the OpenAI API key for the AI Council
69
- (interactive, /dev/tty only, writes ~/.config/agent-config/openai.key 0600)
70
- council:estimate Pre-call council cost preview (no API call, no spend)
71
- Usage: council:estimate <question> [--input-mode prompt|roadmap]
72
- council:run Run the council. Requires --confirm to spend.
73
- Usage: council:run <question> --output <path> --confirm
74
- council:render Re-render a saved council responses JSON to markdown
75
- Usage: council:render <responses.json>
76
65
  help Show this help (default Tier-0; --tier=1|all expands)
77
66
  --version, -V Print package version
78
67
  EOF
@@ -110,6 +99,17 @@ Tier 1 — power-user (release shape, audit, migration):
110
99
  Flags: --json | --project=<path>
111
100
  migrate One-shot migration off legacy composer / npm install paths
112
101
  Flags: --dry-run (detect only)
102
+ first-run Guided first-run setup — cost profile, settings, tooling
103
+ keys:install-anthropic Install the Anthropic API key for the AI Council
104
+ (interactive, /dev/tty only, writes ~/.config/agent-config/anthropic.key 0600)
105
+ keys:install-openai Install the OpenAI API key for the AI Council
106
+ (interactive, /dev/tty only, writes ~/.config/agent-config/openai.key 0600)
107
+ council:estimate Pre-call council cost preview (no API call, no spend)
108
+ Usage: council:estimate <question> [--input-mode prompt|roadmap]
109
+ council:run Run the council. Requires --confirm to spend.
110
+ Usage: council:run <question> --output <path> --confirm
111
+ council:render Re-render a saved council responses JSON to markdown
112
+ Usage: council:render <responses.json>
113
113
  EOF
114
114
  fi
115
115
 
@@ -124,12 +124,25 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
124
124
  (one-line MCP server onboarding; idempotent)
125
125
  mcp:run Run the built-in MCP server over stdio
126
126
  (requires `mcp:setup` first; see docs/mcp-server.md)
127
+ (experimental — beta gates: docs/contracts/mcp-beta-criteria.md)
127
128
  roadmap:progress Regenerate agents/roadmaps-progress.md from open roadmaps
128
129
  roadmap:progress-check Fail if agents/roadmaps-progress.md is stale (for CI)
130
+ settings:check Validate .agent-settings.yml against the YAML-subset contract
131
+ (docs/contracts/settings-sync-yaml-subset.md). Read-only.
132
+ Exit 0 clean, 1 finding(s), 2 file absent / unreadable.
129
133
  hooks:install Install the pre-commit roadmap-progress hook
130
134
  (use --print to dump it, --force to overwrite an existing hook)
131
135
  hooks:status Print the runtime hook matrix (per-platform install + bindings)
132
136
  Flags: --format json|table, --strict (CI), --project-root <path>
137
+ hooks:doctor Diagnose hook health: concerns + fail-open/closed posture,
138
+ last dispatcher feedback per concern, missing trampolines.
139
+ Wraps hooks:status. Read-only.
140
+ Flags: --format json|table, --strict (CI), --project-root <path>
141
+ hooks:replay Replay a fixture through the universal dispatcher with
142
+ AGENT_CONFIG_REPLAY=1 (no writes under agents/state/).
143
+ Usage: hooks:replay --platform <name> --event <event>
144
+ --payload <path|event-name> [--native-event <native>]
145
+ [--manifest <path>] [--json] [--dry-run]
133
146
  migrate-state Migrate a legacy .implement-ticket-state.json file
134
147
  to the v1 .work-state.json schema (preserves .bak)
135
148
  memory:lookup Retrieve memory entries (text or JSON envelope)
@@ -161,7 +174,7 @@ EOF
161
174
  if [[ "$tier" == "0" ]]; then
162
175
  cat <<'EOF'
163
176
 
164
- (Hidden: 9 Tier-1 + 26 Tier-2 commands. Run `./agent-config --help --tier=1`
177
+ (Hidden: 15 Tier-1 + 26 Tier-2 commands. Run `./agent-config --help --tier=1`
165
178
  or `--tier=all` to see them. Tier criteria: docs/contracts/command-surface-tiers.md.)
166
179
  EOF
167
180
  fi
@@ -173,14 +186,8 @@ Examples (Tier 0):
173
186
  ./agent-config sync --dry-run
174
187
  ./agent-config sync
175
188
  ./agent-config validate
176
- ./agent-config first-run
177
189
  ./agent-config work --state-file .work-state.json --prompt-file prompt.txt
178
190
  ./agent-config implement-ticket --state-file .work-state.json
179
- ./agent-config keys:install-anthropic
180
- ./agent-config keys:install-openai
181
- ./agent-config council:estimate prompt.txt
182
- ./agent-config council:run prompt.txt --output agents/council-sessions/out.json --confirm
183
- ./agent-config council:render agents/council-sessions/out.json
184
191
  EOF
185
192
 
186
193
  if [[ "$tier" == "1" || "$tier" == "all" ]]; then
@@ -203,6 +210,12 @@ Examples (Tier 1):
203
210
  ./agent-config versions --json
204
211
  ./agent-config init --offline --tools=claude-code,cursor --yes
205
212
  ./agent-config update --offline --to=2.2.0
213
+ ./agent-config first-run
214
+ ./agent-config keys:install-anthropic
215
+ ./agent-config keys:install-openai
216
+ ./agent-config council:estimate prompt.txt
217
+ ./agent-config council:run prompt.txt --output agents/council-sessions/out.json --confirm
218
+ ./agent-config council:render agents/council-sessions/out.json
206
219
  EOF
207
220
  fi
208
221
 
@@ -216,7 +229,9 @@ Examples (Tier 2):
216
229
  ./agent-config mcp:setup
217
230
  ./agent-config mcp:run
218
231
  ./agent-config roadmap:progress
232
+ ./agent-config settings:check
219
233
  ./agent-config hooks:install
234
+ ./agent-config hooks:replay --platform augment --event post_tool_use --payload post_tool_use --json
220
235
  ./agent-config migrate-state
221
236
  ./agent-config memory:lookup --types domain-invariants --key billing
222
237
  ./agent-config memory:signal --type architecture-decision --path src/Foo.php --body "…"
@@ -506,6 +521,20 @@ cmd_hooks_status() {
506
521
  exec python3 "$script" "$@"
507
522
  }
508
523
 
524
+ cmd_hooks_doctor() {
525
+ require_python3
526
+ local script
527
+ script="$(resolve_script "scripts/hooks_doctor.py")" || return 1
528
+ exec python3 "$script" "$@"
529
+ }
530
+
531
+ cmd_hooks_replay() {
532
+ require_python3
533
+ local script
534
+ script="$(resolve_script "scripts/hooks/replay_hook.py")" || return 1
535
+ exec python3 "$script" "$@"
536
+ }
537
+
509
538
  cmd_chat_history_checkpoint() {
510
539
  require_python3
511
540
  local script
@@ -675,6 +704,15 @@ cmd_validate() {
675
704
  exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_validate "$@"
676
705
  }
677
706
 
707
+ # `agent-config settings:check` — read-only YAML-subset validator for
708
+ # `.agent-settings.yml` (P3.2 of road-to-proof-not-features.md). Contract
709
+ # pinned in docs/contracts/settings-sync-yaml-subset.md. Exit 0 clean,
710
+ # 1 finding(s), 2 file absent / unreadable.
711
+ cmd_settings_check() {
712
+ require_python3
713
+ exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_settings_check "$@"
714
+ }
715
+
678
716
  # `agent-config uninstall` — remove bridge markers (project) or lockfile
679
717
  # entries (global). Idempotent. Pass `--purge` to also delete deployed
680
718
  # content directories under user-scope anchors (destructive). See
@@ -743,6 +781,8 @@ main() {
743
781
  context-hygiene:hook) cmd_context_hygiene_hook "$@" ;;
744
782
  dispatch:hook) cmd_dispatch_hook "$@" ;;
745
783
  hooks:status) cmd_hooks_status "$@" ;;
784
+ hooks:doctor) cmd_hooks_doctor "$@" ;;
785
+ hooks:replay) cmd_hooks_replay "$@" ;;
746
786
  telemetry:record) cmd_telemetry_record "$@" ;;
747
787
  telemetry:status) cmd_telemetry_status "$@" ;;
748
788
  telemetry:report) cmd_telemetry_report "$@" ;;
@@ -756,6 +796,7 @@ main() {
756
796
  export) cmd_export "$@" ;;
757
797
  sync) cmd_sync "$@" ;;
758
798
  validate) cmd_validate "$@" ;;
799
+ settings:check) cmd_settings_check "$@" ;;
759
800
  uninstall) cmd_uninstall "$@" ;;
760
801
  prune) cmd_prune "$@" ;;
761
802
  doctor) cmd_doctor "$@" ;;
@@ -48,6 +48,15 @@ SCHEMA_VERSION = 4
48
48
  DEFAULT_MAX_SESSIONS = 5
49
49
  VALID_FREQS = {"per_turn", "per_phase", "per_tool"}
50
50
  VALID_OVERFLOW = {"rotate", "compress"}
51
+
52
+ # Replay-mode signal — when set, every write to the on-disk transcript
53
+ # is a no-op. Honoured per `docs/contracts/hook-architecture-v1.md`
54
+ # § Replay mode so fixture dispatches never mutate real session state.
55
+ REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY"
56
+
57
+
58
+ def _is_replay_mode() -> bool:
59
+ return os.environ.get(REPLAY_ENV_VAR, "").strip() == "1"
51
60
  _WS_RE = re.compile(r"\s+")
52
61
  SESSION_ID_LEN = 16
53
62
  SESSION_ID_UNKNOWN = "<unknown>"
@@ -247,6 +256,8 @@ def init(freq: str = "per_phase", *,
247
256
  raise ValueError(f"freq must be one of {sorted(VALID_FREQS)}")
248
257
  p = path or file_path()
249
258
  header = _build_header(freq)
259
+ if _is_replay_mode():
260
+ return header
250
261
  p.parent.mkdir(parents=True, exist_ok=True)
251
262
  with p.open("w", encoding="utf-8") as fh:
252
263
  fh.write(json.dumps(header, ensure_ascii=False) + "\n")
@@ -318,6 +329,8 @@ def append(entry: dict[str, Any], *, path: Path | None = None,
318
329
  entry["s"] = session
319
330
  elif "s" not in entry and _session_tag_enabled():
320
331
  entry["s"] = _last_body_session_id(p)
332
+ if _is_replay_mode():
333
+ return
321
334
  with p.open("a", encoding="utf-8") as fh:
322
335
  fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
323
336
 
@@ -328,7 +341,11 @@ def _atomic_write_text(p: Path, text: str) -> None:
328
341
  Multiple processes writing to the same target use disjoint tmp paths
329
342
  (PID + uuid), so concurrent writes no longer collide on a shared
330
343
  ``.tmp`` file. The final ``replace`` is atomic on POSIX.
344
+
345
+ Under `AGENT_CONFIG_REPLAY=1` the call is a no-op.
331
346
  """
347
+ if _is_replay_mode():
348
+ return
332
349
  tmp = p.with_suffix(
333
350
  f"{p.suffix}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp",
334
351
  )
@@ -405,6 +422,8 @@ def prepend_entries(entries: list[dict[str, Any]], *,
405
422
 
406
423
 
407
424
  def clear(*, path: Path | None = None) -> None:
425
+ if _is_replay_mode():
426
+ return
408
427
  p = path or file_path()
409
428
  if p.exists():
410
429
  p.unlink()
@@ -14,8 +14,10 @@ council files. Directory mentions and placeholder paths
14
14
  output-path convention, not a live reference.
15
15
 
16
16
  Forbidden hits in this codebase exist today (kernel-membership ADRs
17
- cite real session JSONs as decision traces). Suppress them with an
18
- inline pragma at the end of the line:
17
+ cite real session JSONs as decision traces). Two source/target shapes
18
+ are exempt structurally see STRUCTURAL_CARVEOUTS below — because
19
+ they encode immutable decision provenance, not transient drafting
20
+ state. Anything else needs an inline pragma at the end of the line:
19
21
 
20
22
  `agents/council-sessions/...json` <!-- council-ref-allowed: <reason> -->
21
23
 
@@ -82,6 +84,31 @@ ALLOWLIST_FILES: frozenset[str] = frozenset({
82
84
 
83
85
  INLINE_PRAGMA = re.compile(r"<!--\s*council-ref-allowed:[^>]*-->")
84
86
 
87
+ # Structural carve-outs — (source_pattern, target_pattern) pairs where
88
+ # the reference is immutable decision provenance rather than transient
89
+ # drafting state. Driven by the 2026-05-14 P3.4 council round
90
+ # (agents/council-sessions/2026-05-14-p3-4-references/synthesis.md).
91
+ #
92
+ # Each entry: source file matches `source` regex AND the captured
93
+ # reference path matches `target` regex → reference is allowed without
94
+ # an inline pragma.
95
+ STRUCTURAL_CARVEOUTS: tuple[tuple[re.Pattern[str], re.Pattern[str]], ...] = (
96
+ # (a) evaluation-context → council-question:
97
+ # the question file is a frozen function-parameter / spend-gate
98
+ # input, not a documentation link.
99
+ (
100
+ re.compile(r"^agents/contexts/evaluation-[^/]+\.md$"),
101
+ re.compile(r"^agents/council-questions/[^/]+\.md$"),
102
+ ),
103
+ # (b) contract → council-session-synthesis:
104
+ # the synthesis file is the audit-trail receipt the contract cites
105
+ # as decision provenance; the contract inlines the decision body.
106
+ (
107
+ re.compile(r"^docs/contracts/[^/]+\.md$"),
108
+ re.compile(r"^agents/council-sessions/[^/]+/synthesis\.md$"),
109
+ ),
110
+ )
111
+
85
112
 
86
113
  def _is_allowlisted(rel: str) -> bool:
87
114
  if rel in ALLOWLIST_FILES:
@@ -89,8 +116,17 @@ def _is_allowlisted(rel: str) -> bool:
89
116
  return any(rel.startswith(prefix) for prefix in ALLOWLIST_PREFIXES)
90
117
 
91
118
 
119
+ def _is_structurally_allowed(source_rel: str, target_capture: str) -> bool:
120
+ """True when (source, target) matches a structural carve-out pair."""
121
+ for src_re, tgt_re in STRUCTURAL_CARVEOUTS:
122
+ if src_re.match(source_rel) and tgt_re.match(target_capture):
123
+ return True
124
+ return False
125
+
126
+
92
127
  def _scan_file(path: Path) -> list[tuple[int, str]]:
93
128
  findings: list[tuple[int, str]] = []
129
+ rel = path.as_posix()
94
130
  try:
95
131
  text = path.read_text(encoding="utf-8")
96
132
  except (OSError, UnicodeDecodeError):
@@ -99,6 +135,8 @@ def _scan_file(path: Path) -> list[tuple[int, str]]:
99
135
  if INLINE_PRAGMA.search(line):
100
136
  continue
101
137
  for m in PATTERN.finditer(line):
138
+ if _is_structurally_allowed(rel, m.group(0)):
139
+ continue
102
140
  findings.append((ln, m.group(0)))
103
141
  return findings
104
142
 
@@ -136,10 +174,13 @@ def main() -> int:
136
174
  print(
137
175
  "\nRule: .agent-src/rules/no-roadmap-references.md (council clause)\n"
138
176
  "Fix: inline the convergence summary (members + date) instead of\n"
139
- "linking the file. Append "
177
+ "linking the file. Two source/target shapes are exempt structurally\n"
178
+ "(evaluation-context → council-question, contract →\n"
179
+ "council-session-synthesis) — see STRUCTURAL_CARVEOUTS in this\n"
180
+ "script. Otherwise append "
140
181
  "<!-- council-ref-allowed: <reason> --> on the same line to\n"
141
- "suppress when the reference is genuinely required (ADR / contract\n"
142
- "decision trace)."
182
+ "suppress when the reference is genuinely required (ADR decision\n"
183
+ "trace)."
143
184
  )
144
185
  return 1
145
186
 
@@ -37,7 +37,7 @@ MANIFEST_PATH = REPO_ROOT / "scripts" / "hook_manifest.yaml"
37
37
  # Lazy import — we want this module to be importable even if the
38
38
  # hooks package state_io has changed (test isolation).
39
39
  sys.path.insert(0, str(Path(__file__).resolve().parent))
40
- from state_io import atomic_write_json, feedback_dir # noqa: E402
40
+ from state_io import atomic_write_json, feedback_dir, is_replay_mode # noqa: E402
41
41
 
42
42
  EXIT_ALLOW = 0
43
43
  EXIT_BLOCK = 1
@@ -272,6 +272,10 @@ def _write_feedback(envelope: dict, session_id: str, entries: list[dict],
272
272
  not control flow. We only swallow IO errors here; fail-open
273
273
  matches the dispatcher's overall posture.
274
274
  """
275
+ # Replay mode skips feedback emission entirely so fixture replays
276
+ # never create per-session dirs under agents/state/.dispatcher/.
277
+ if is_replay_mode():
278
+ return
275
279
  workspace = envelope.get("workspace_root") or str(Path.cwd())
276
280
  state_root = Path(workspace) / "agents" / "state"
277
281
  fb_dir = feedback_dir(state_root, session_id)
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ """Fixture-driven hook replay — read-only dispatch through the runtime.
3
+
4
+ Reads a stdin payload fixture from `tests/fixtures/hooks/` (one file per
5
+ event in `EVENT_VOCABULARY`), sets `AGENT_CONFIG_REPLAY=1`, and invokes
6
+ the universal dispatcher with the platform / event / payload tuple. The
7
+ replay flag tells `state_io` (and concerns that honour it) to skip every
8
+ write under `agents/state/` so the replay never mutates real session
9
+ state.
10
+
11
+ Invocation:
12
+
13
+ python3 scripts/hooks/replay_hook.py \\
14
+ --platform <name> \\
15
+ --event <agent-config-event> \\
16
+ --payload tests/fixtures/hooks/<event>.json \\
17
+ [--native-event <native>] \\
18
+ [--manifest <path>] \\
19
+ [--json]
20
+
21
+ The `--json` flag prints a structured replay summary on stdout
22
+ (platform, event, dispatcher exit code, captured stderr lines).
23
+ Non-zero exit is propagated from the dispatcher.
24
+
25
+ Contract reference: `docs/contracts/hook-architecture-v1.md` § Replay
26
+ mode. Roadmap step: P2.4b of `agents/roadmaps/road-to-proof-not-features.md`.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ REPO_ROOT = Path(__file__).resolve().parents[2]
38
+ DISPATCHER = REPO_ROOT / "scripts" / "hooks" / "dispatch_hook.py"
39
+ DEFAULT_MANIFEST = REPO_ROOT / "scripts" / "hook_manifest.yaml"
40
+ FIXTURE_DIR = REPO_ROOT / "tests" / "fixtures" / "hooks"
41
+ REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY"
42
+
43
+
44
+ def _resolve_payload(arg: str) -> Path:
45
+ """Accept either an absolute path, a path relative to CWD, or a bare
46
+ event name that resolves to `tests/fixtures/hooks/<name>.json`."""
47
+ candidate = Path(arg)
48
+ if candidate.is_file():
49
+ return candidate
50
+ bare = FIXTURE_DIR / f"{arg}.json"
51
+ if bare.is_file():
52
+ return bare
53
+ raise FileNotFoundError(
54
+ f"replay_hook: payload not found — tried '{candidate}' and '{bare}'")
55
+
56
+
57
+ def _build_argparser() -> argparse.ArgumentParser:
58
+ p = argparse.ArgumentParser(description=__doc__)
59
+ p.add_argument("--platform", required=True,
60
+ help="Platform key as declared in hook_manifest.yaml "
61
+ "(augment, claude, cursor, cline, windsurf, gemini, copilot).")
62
+ p.add_argument("--event", required=True,
63
+ help="agent-config event (see EVENT_VOCABULARY in "
64
+ "scripts/hooks/dispatch_hook.py).")
65
+ p.add_argument("--payload", required=True,
66
+ help="Path to a fixture JSON file, or a bare event name "
67
+ "resolved under tests/fixtures/hooks/.")
68
+ p.add_argument("--native-event", default="",
69
+ help="Optional native event name for diagnostics.")
70
+ p.add_argument("--manifest", default=str(DEFAULT_MANIFEST),
71
+ help=f"Hook manifest path (default: {DEFAULT_MANIFEST}).")
72
+ p.add_argument("--json", action="store_true",
73
+ help="Emit a structured summary on stdout.")
74
+ p.add_argument("--dry-run", action="store_true",
75
+ help="Resolve concerns and print the dispatch plan; "
76
+ "do not invoke concerns.")
77
+ return p
78
+
79
+
80
+ def main(argv: list[str] | None = None) -> int:
81
+ args = _build_argparser().parse_args(argv)
82
+
83
+ try:
84
+ payload_path = _resolve_payload(args.payload)
85
+ except FileNotFoundError as exc:
86
+ sys.stderr.write(f"❌ {exc}\n")
87
+ return 2
88
+
89
+ payload_text = payload_path.read_text(encoding="utf-8")
90
+ # Validate JSON early so dispatcher stderr stays focused on real
91
+ # concern problems. Empty / non-object payloads are still dispatched
92
+ # — that mirrors the platform contract (stdin can be empty).
93
+ try:
94
+ decoded = json.loads(payload_text) if payload_text.strip() else {}
95
+ except (ValueError, TypeError) as exc:
96
+ sys.stderr.write(f"❌ replay_hook: invalid JSON in {payload_path}: {exc}\n")
97
+ return 2
98
+
99
+ env = dict(os.environ)
100
+ env[REPLAY_ENV_VAR] = "1"
101
+
102
+ cmd = [sys.executable, str(DISPATCHER),
103
+ "--platform", args.platform,
104
+ "--event", args.event,
105
+ "--manifest", args.manifest]
106
+ if args.native_event:
107
+ cmd.extend(["--native-event", args.native_event])
108
+ if args.dry_run:
109
+ cmd.append("--dry-run")
110
+
111
+ proc = subprocess.run(
112
+ cmd, input=payload_text, capture_output=True, text=True, env=env,
113
+ check=False,
114
+ )
115
+
116
+ if args.json:
117
+ summary = {
118
+ "platform": args.platform,
119
+ "event": args.event,
120
+ "native_event": args.native_event or "",
121
+ "payload": str(payload_path.relative_to(REPO_ROOT)
122
+ if str(payload_path).startswith(str(REPO_ROOT))
123
+ else payload_path),
124
+ "session_id": decoded.get("session_id") if isinstance(decoded, dict) else None,
125
+ "exit_code": proc.returncode,
126
+ "dispatcher_stdout": (proc.stdout or "").strip(),
127
+ "dispatcher_stderr": (proc.stderr or "").strip(),
128
+ "replay_mode": True,
129
+ }
130
+ print(json.dumps(summary, indent=2))
131
+ else:
132
+ if proc.stdout:
133
+ sys.stdout.write(proc.stdout)
134
+ if proc.stderr:
135
+ sys.stderr.write(proc.stderr)
136
+ sys.stderr.write(
137
+ f"replay_hook: platform={args.platform} event={args.event} "
138
+ f"payload={payload_path.name} rc={proc.returncode} "
139
+ f"(AGENT_CONFIG_REPLAY=1, no writes)\n")
140
+ return proc.returncode
141
+
142
+
143
+ if __name__ == "__main__": # pragma: no cover
144
+ sys.exit(main())