@event4u/agent-config 1.18.0 → 1.20.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 (181) hide show
  1. package/.agent-src/commands/agent-handoff.md +14 -10
  2. package/.agent-src/commands/chat-history/import.md +170 -0
  3. package/.agent-src/commands/chat-history/learn.md +178 -0
  4. package/.agent-src/commands/chat-history/show.md +17 -18
  5. package/.agent-src/commands/chat-history.md +26 -25
  6. package/.agent-src/commands/council/default.md +77 -82
  7. package/.agent-src/commands/create-pr.md +28 -8
  8. package/.agent-src/commands/feature/roadmap.md +22 -0
  9. package/.agent-src/commands/roadmap/create.md +38 -6
  10. package/.agent-src/commands/roadmap/execute.md +36 -9
  11. package/.agent-src/commands/sync-gitignore.md +1 -1
  12. package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
  13. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
  14. package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
  15. package/.agent-src/rules/agent-authority.md +1 -0
  16. package/.agent-src/rules/agent-docs.md +1 -0
  17. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  18. package/.agent-src/rules/architecture.md +1 -0
  19. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  20. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  21. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  22. package/.agent-src/rules/augment-portability.md +1 -0
  23. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  24. package/.agent-src/rules/autonomous-execution.md +1 -0
  25. package/.agent-src/rules/capture-learnings.md +1 -0
  26. package/.agent-src/rules/cli-output-handling.md +2 -2
  27. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  28. package/.agent-src/rules/commit-conventions.md +1 -0
  29. package/.agent-src/rules/commit-policy.md +1 -0
  30. package/.agent-src/rules/context-hygiene.md +22 -0
  31. package/.agent-src/rules/direct-answers.md +11 -2
  32. package/.agent-src/rules/docker-commands.md +1 -0
  33. package/.agent-src/rules/docs-sync.md +1 -0
  34. package/.agent-src/rules/downstream-changes.md +1 -0
  35. package/.agent-src/rules/e2e-testing.md +1 -0
  36. package/.agent-src/rules/guidelines.md +1 -0
  37. package/.agent-src/rules/improve-before-implement.md +1 -0
  38. package/.agent-src/rules/language-and-tone.md +38 -6
  39. package/.agent-src/rules/laravel-translations.md +1 -0
  40. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  41. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  42. package/.agent-src/rules/missing-tool-handling.md +1 -0
  43. package/.agent-src/rules/model-recommendation.md +1 -0
  44. package/.agent-src/rules/no-attribution-footers.md +48 -0
  45. package/.agent-src/rules/no-cheap-questions.md +1 -0
  46. package/.agent-src/rules/no-roadmap-references.md +2 -1
  47. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  48. package/.agent-src/rules/onboarding-gate.md +26 -0
  49. package/.agent-src/rules/package-ci-checks.md +1 -0
  50. package/.agent-src/rules/php-coding.md +1 -0
  51. package/.agent-src/rules/preservation-guard.md +1 -0
  52. package/.agent-src/rules/review-routing-awareness.md +1 -0
  53. package/.agent-src/rules/reviewer-awareness.md +1 -0
  54. package/.agent-src/rules/roadmap-progress-sync.md +22 -0
  55. package/.agent-src/rules/role-mode-adherence.md +2 -2
  56. package/.agent-src/rules/rule-type-governance.md +1 -0
  57. package/.agent-src/rules/runtime-safety.md +1 -0
  58. package/.agent-src/rules/scope-control.md +1 -0
  59. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  60. package/.agent-src/rules/size-enforcement.md +1 -0
  61. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  62. package/.agent-src/rules/skill-quality.md +50 -0
  63. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  64. package/.agent-src/rules/think-before-action.md +1 -0
  65. package/.agent-src/rules/token-efficiency.md +1 -0
  66. package/.agent-src/rules/tool-safety.md +1 -0
  67. package/.agent-src/rules/ui-audit-gate.md +1 -0
  68. package/.agent-src/rules/upstream-proposal.md +1 -0
  69. package/.agent-src/rules/user-interaction.md +22 -5
  70. package/.agent-src/rules/verify-before-complete.md +1 -0
  71. package/.agent-src/skills/ai-council/SKILL.md +4 -5
  72. package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
  73. package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
  74. package/.agent-src/skills/md-language-check/SKILL.md +1 -1
  75. package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
  76. package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
  77. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  78. package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
  79. package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
  80. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
  81. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  82. package/.agent-src/templates/agent-settings.md +21 -26
  83. package/.agent-src/templates/roadmaps.md +8 -3
  84. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +16 -5
  85. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -4
  86. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -4
  87. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
  88. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
  89. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
  90. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  91. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +110 -0
  92. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  93. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  94. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  95. package/.agent-src/templates/skill.md +30 -1
  96. package/.claude-plugin/marketplace.json +8 -4
  97. package/AGENTS.md +44 -3
  98. package/CHANGELOG.md +173 -0
  99. package/README.md +22 -22
  100. package/config/agent-settings.template.yml +42 -13
  101. package/config/gitignore-block.txt +4 -4
  102. package/docs/architecture.md +3 -3
  103. package/docs/catalog.md +18 -13
  104. package/docs/contracts/adr-chat-history-split.md +10 -1
  105. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  106. package/docs/contracts/command-clusters.md +1 -1
  107. package/docs/contracts/cross-wing-handoff.md +133 -0
  108. package/docs/contracts/decision-trace-v1.md +146 -0
  109. package/docs/contracts/file-ownership-matrix.json +348 -126
  110. package/docs/contracts/hook-architecture-v1.md +220 -0
  111. package/docs/contracts/memory-visibility-v1.md +122 -0
  112. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  113. package/docs/contracts/rule-interactions.yml +22 -0
  114. package/docs/customization.md +2 -1
  115. package/docs/development.md +4 -1
  116. package/docs/getting-started.md +21 -29
  117. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
  118. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  119. package/docs/hook-payload-capture.md +221 -0
  120. package/docs/migrations/commands-1.15.0.md +17 -12
  121. package/docs/skills-catalog.md +5 -4
  122. package/llms.txt +4 -3
  123. package/package.json +1 -1
  124. package/scripts/agent-config +45 -1
  125. package/scripts/ai_council/_default_prices.py +4 -4
  126. package/scripts/ai_council/bundler.py +3 -3
  127. package/scripts/ai_council/clients.py +25 -9
  128. package/scripts/ai_council/modes.py +3 -4
  129. package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
  130. package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
  131. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  132. package/scripts/ai_council/pricing.py +10 -9
  133. package/scripts/ai_council/session.py +92 -0
  134. package/scripts/build_rule_trigger_matrix.py +1 -9
  135. package/scripts/capture_showcase_session.py +361 -0
  136. package/scripts/chat_history.py +963 -597
  137. package/scripts/check_always_budget.py +7 -2
  138. package/scripts/check_references.py +12 -2
  139. package/scripts/context_hygiene_hook.py +14 -6
  140. package/scripts/council_cli.py +407 -0
  141. package/scripts/hook_manifest.yaml +217 -0
  142. package/scripts/hooks/__init__.py +1 -0
  143. package/scripts/hooks/augment-chat-history.sh +10 -0
  144. package/scripts/hooks/augment-dispatcher.sh +72 -0
  145. package/scripts/hooks/cline-dispatcher.sh +86 -0
  146. package/scripts/hooks/cowork-dispatcher.sh +98 -0
  147. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  148. package/scripts/hooks/dispatch_hook.py +383 -0
  149. package/scripts/hooks/envelope.py +98 -0
  150. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  151. package/scripts/hooks/state_io.py +122 -0
  152. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  153. package/scripts/hooks_status.py +157 -0
  154. package/scripts/install-hooks.sh +2 -2
  155. package/scripts/install.py +725 -87
  156. package/scripts/install.sh +38 -1
  157. package/scripts/lint_handoffs.py +214 -0
  158. package/scripts/lint_hook_manifest.py +217 -0
  159. package/scripts/lint_one_off_age.py +184 -0
  160. package/scripts/lint_rule_tiers.py +78 -0
  161. package/scripts/lint_showcase_sessions.py +148 -0
  162. package/scripts/minimal_safe_diff_hook.py +245 -0
  163. package/scripts/onboarding_gate_hook.py +13 -8
  164. package/scripts/readme_linter.py +12 -3
  165. package/scripts/redact_hook_capture.py +148 -0
  166. package/scripts/roadmap_progress_hook.py +5 -0
  167. package/scripts/schemas/skill.schema.json +5 -0
  168. package/scripts/skill_linter.py +163 -1
  169. package/scripts/sync_agent_settings.py +32 -129
  170. package/scripts/sync_yaml_rt.py +734 -0
  171. package/scripts/update_prices.py +3 -3
  172. package/scripts/verify_before_complete_hook.py +216 -0
  173. package/.agent-src/commands/chat-history/checkpoint.md +0 -126
  174. package/.agent-src/commands/chat-history/clear.md +0 -103
  175. package/.agent-src/commands/chat-history/resume.md +0 -183
  176. package/.agent-src/rules/chat-history-cadence.md +0 -109
  177. package/.agent-src/rules/chat-history-ownership.md +0 -123
  178. package/.agent-src/rules/chat-history-visibility.md +0 -96
  179. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
  180. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
  181. package/scripts/check_phase_coupling.py +0 -148
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """One-off — tier-bulk-retrofit (Phase 2.1 + 2.2 of road-to-feedback-consolidation).
3
+
4
+ Parses agents/contexts/rule-trigger-matrix.md, emits tmp/tier-classification.md,
5
+ and inserts a `tier:` frontmatter key into every rule under
6
+ .agent-src.uncompressed/rules/. Idempotent — re-runs are a no-op when a rule
7
+ already declares the same tier value.
8
+
9
+ Lifecycle: scripts/_one_off/2026-05/. Purge eligible after 2026-08-04 per
10
+ docs/contracts/one-off-script-lifecycle.md.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ REPO = Path(__file__).resolve().parents[3]
19
+ MATRIX = REPO / "agents" / "contexts" / "rule-trigger-matrix.md"
20
+ RULES_DIR = REPO / ".agent-src.uncompressed" / "rules"
21
+ COMPRESSED_RULES_DIR = REPO / ".agent-src" / "rules"
22
+ SPREADSHEET = REPO / "tmp" / "tier-classification.md"
23
+
24
+ VALID_TIERS = {"1", "2a", "2b", "3", "safety-floor", "mechanical-already"}
25
+
26
+
27
+ def parse_matrix() -> dict[str, tuple[str, str]]:
28
+ """Return {rule_filename: (tier, notes)} from the matrix table."""
29
+ out: dict[str, tuple[str, str]] = {}
30
+ full = MATRIX.read_text(encoding="utf-8")
31
+ # Slice between '## Matrix' and the next '## ' heading.
32
+ start = full.find("\n## Matrix\n")
33
+ if start == -1:
34
+ sys.exit("matrix: '## Matrix' section not found")
35
+ end = full.find("\n## ", start + 1)
36
+ text = full[start:end] if end != -1 else full[start:]
37
+ # Table rows look like: | `agent-authority.md` | always | 1468 | … | 3 | no | Priority index, … |
38
+ row_re = re.compile(
39
+ r"^\|\s*`([a-z0-9-]+\.md)`\s*\|" # rule filename
40
+ r"[^|]*\|" # type
41
+ r"[^|]*\|" # raw
42
+ r"[^|]*\|" # ext
43
+ r"[^|]*\|" # trigger
44
+ r"[^|]*\|" # obs
45
+ r"[^|]*\|" # enforce
46
+ r"[^|]*\|" # hook-cost
47
+ r"\s*([^|]+?)\s*\|" # tier
48
+ r"[^|]*\|" # dormant?
49
+ r"\s*(.*?)\s*\|\s*$", # notes
50
+ re.MULTILINE,
51
+ )
52
+ for m in row_re.finditer(text):
53
+ name, tier, notes = m.group(1), m.group(2).strip(), m.group(3).strip()
54
+ if tier not in VALID_TIERS:
55
+ sys.exit(f"unknown tier '{tier}' for {name}")
56
+ out[name] = (tier, notes)
57
+ return out
58
+
59
+
60
+ def write_spreadsheet(classifications: dict[str, tuple[str, str]]) -> None:
61
+ SPREADSHEET.parent.mkdir(parents=True, exist_ok=True)
62
+ lines = [
63
+ "# Tier classification — Phase 2.1 of road-to-feedback-consolidation",
64
+ "",
65
+ "Source: `agents/contexts/rule-trigger-matrix.md` (manual classifications",
66
+ "in `scripts/build_rule_trigger_matrix.py`'s `CLASSIFICATION` table).",
67
+ "Generated by `scripts/_one_off/2026-05/_one_off_tier-retrofit.py`.",
68
+ "",
69
+ "Tier rubric: see `agents/contexts/hardening-pattern.md`.",
70
+ "",
71
+ f"Total: {len(classifications)} rules.",
72
+ "",
73
+ "| Rule | Tier | Rationale |",
74
+ "|---|---|---|",
75
+ ]
76
+ for name in sorted(classifications):
77
+ tier, notes = classifications[name]
78
+ lines.append(f"| `{name}` | `{tier}` | {notes} |")
79
+ SPREADSHEET.write_text("\n".join(lines) + "\n", encoding="utf-8")
80
+
81
+
82
+ def parse_frontmatter(text: str) -> tuple[dict[str, str], str, str]:
83
+ """Return (kv, raw_block, body). raw_block excludes the --- fences."""
84
+ if not text.startswith("---\n"):
85
+ return {}, "", text
86
+ end = text.find("\n---\n", 4)
87
+ if end == -1:
88
+ return {}, "", text
89
+ raw = text[4:end]
90
+ body = text[end + 5 :]
91
+ kv: dict[str, str] = {}
92
+ for line in raw.splitlines():
93
+ if ":" in line:
94
+ k, _, v = line.partition(":")
95
+ kv[k.strip()] = v.strip()
96
+ return kv, raw, body
97
+
98
+
99
+ def apply_tier(rule_path: Path, tier: str) -> str:
100
+ """Return one of: 'unchanged', 'inserted', 'updated'.
101
+
102
+ Tier is always written as a quoted string in YAML (`tier: "<value>"`) so the
103
+ schema enum check (string-only) holds for numeric tiers like `1` and `3`.
104
+ """
105
+ text = rule_path.read_text(encoding="utf-8")
106
+ kv, raw, body = parse_frontmatter(text)
107
+ if not raw:
108
+ sys.exit(f"{rule_path}: no frontmatter found")
109
+ existing_raw = kv.get("tier")
110
+ existing = existing_raw.strip('"').strip("'") if existing_raw else None
111
+ quoted = f'"{tier}"'
112
+ target_line = f"tier: {quoted}"
113
+ if existing == tier and existing_raw == quoted:
114
+ return "unchanged"
115
+ new_lines: list[str] = []
116
+ inserted = False
117
+ for line in raw.splitlines():
118
+ new_lines.append(line)
119
+ if not inserted and line.startswith("type:"):
120
+ new_lines.append(target_line)
121
+ inserted = True
122
+ if existing is not None:
123
+ new_lines = [
124
+ l if not l.lstrip().startswith("tier:") else target_line
125
+ for l in new_lines
126
+ ]
127
+ seen_tier = False
128
+ deduped: list[str] = []
129
+ for l in new_lines:
130
+ if l == target_line:
131
+ if seen_tier:
132
+ continue
133
+ seen_tier = True
134
+ deduped.append(l)
135
+ new_lines = deduped
136
+ result = "updated" if existing != tier or existing_raw != quoted else "unchanged"
137
+ else:
138
+ if not inserted:
139
+ new_lines.insert(0, target_line)
140
+ result = "inserted"
141
+ new_raw = "\n".join(new_lines)
142
+ rule_path.write_text(f"---\n{new_raw}\n---\n{body}", encoding="utf-8")
143
+ return result
144
+
145
+
146
+ def main() -> int:
147
+ classifications = parse_matrix()
148
+ if len(classifications) != 58:
149
+ sys.exit(f"expected 58 rules in matrix, got {len(classifications)}")
150
+
151
+ on_disk = {p.name for p in RULES_DIR.glob("*.md")}
152
+ missing = on_disk - classifications.keys()
153
+ extra = classifications.keys() - on_disk
154
+ if missing or extra:
155
+ sys.exit(f"matrix/disk mismatch: missing={missing} extra={extra}")
156
+
157
+ write_spreadsheet(classifications)
158
+
159
+ counts: dict[str, int] = {"unchanged": 0, "inserted": 0, "updated": 0}
160
+ mirror_counts = {"unchanged": 0, "inserted": 0, "updated": 0, "skipped": 0}
161
+ for name, (tier, _) in classifications.items():
162
+ result = apply_tier(RULES_DIR / name, tier)
163
+ counts[result] += 1
164
+ compressed = COMPRESSED_RULES_DIR / name
165
+ if compressed.exists():
166
+ mirror_counts[apply_tier(compressed, tier)] += 1
167
+ else:
168
+ mirror_counts["skipped"] += 1
169
+ print(
170
+ f"tier-retrofit: spreadsheet={SPREADSHEET.relative_to(REPO)} "
171
+ f"src(unchanged={counts['unchanged']} inserted={counts['inserted']} "
172
+ f"updated={counts['updated']}) "
173
+ f"mirror(unchanged={mirror_counts['unchanged']} inserted={mirror_counts['inserted']} "
174
+ f"updated={mirror_counts['updated']} skipped={mirror_counts['skipped']})"
175
+ )
176
+ return 0
177
+
178
+
179
+ if __name__ == "__main__":
180
+ raise SystemExit(main())
@@ -1,18 +1,19 @@
1
1
  """Runtime pricing layer for the AI Council.
2
2
 
3
- Reads `.agent-prices.md` from the repo root, parses YAML frontmatter
4
- and the Markdown table, and exposes:
3
+ Reads `agents/.agent-prices.md` from the repo root, parses YAML
4
+ frontmatter and the Markdown table, and exposes:
5
5
 
6
- - `load_prices()` — parse `.agent-prices.md` (bootstraps if missing)
6
+ - `load_prices()` — parse `agents/.agent-prices.md` (bootstraps if missing)
7
7
  - `estimate_input_tokens()` — chars / 4 heuristic
8
8
  - `estimate_cost()` — input + output USD for a single member
9
9
  - `is_stale()` — True if `last_updated` is older than the
10
10
  most recent UTC Monday 00:00
11
- - `bootstrap_from_defaults()` — write a fresh `.agent-prices.md` from
12
- `_default_prices.DEFAULT_PRICES`
11
+ - `bootstrap_from_defaults()` — write a fresh `agents/.agent-prices.md`
12
+ from `_default_prices.DEFAULT_PRICES`
13
13
 
14
14
  The orchestrator never reads `_default_prices` directly. It always
15
- goes through `load_prices()` so user edits to `.agent-prices.md` win.
15
+ goes through `load_prices()` so user edits to
16
+ `agents/.agent-prices.md` win.
16
17
  """
17
18
 
18
19
  from __future__ import annotations
@@ -24,7 +25,7 @@ from pathlib import Path
24
25
  from scripts.ai_council._default_prices import DEFAULT_PRICES, LAST_UPDATED, as_rows
25
26
 
26
27
  REPO_ROOT = Path(__file__).resolve().parents[2]
27
- PRICES_FILE = REPO_ROOT / ".agent-prices.md"
28
+ PRICES_FILE = REPO_ROOT / "agents" / ".agent-prices.md"
28
29
 
29
30
  # Heuristic: 1 token ≈ 4 characters of English text. OpenAI's tiktoken
30
31
  # is more accurate but pulls in a heavy dep we explicitly avoid.
@@ -115,14 +116,14 @@ def is_stale(table: PriceTable, now: _dt.datetime | None = None) -> bool:
115
116
 
116
117
 
117
118
  def load_prices(path: Path = PRICES_FILE) -> PriceTable:
118
- """Parse `.agent-prices.md`; bootstrap from defaults if missing."""
119
+ """Parse `agents/.agent-prices.md`; bootstrap from defaults if missing."""
119
120
  if not path.exists():
120
121
  bootstrap_from_defaults(path)
121
122
  return _parse(path.read_text(encoding="utf-8"))
122
123
 
123
124
 
124
125
  def bootstrap_from_defaults(path: Path = PRICES_FILE) -> None:
125
- """Write a fresh `.agent-prices.md` from `_default_prices.py`."""
126
+ """Write a fresh `agents/.agent-prices.md` from `_default_prices.py`."""
126
127
  rows = as_rows()
127
128
  body = _render_markdown(LAST_UPDATED, "shipped-default", rows)
128
129
  path.write_text(body, encoding="utf-8")
@@ -23,6 +23,8 @@ from __future__ import annotations
23
23
 
24
24
  import datetime as _dt
25
25
  import json
26
+ import re
27
+ import shutil
26
28
  import sys
27
29
  from dataclasses import dataclass, field
28
30
  from pathlib import Path
@@ -33,6 +35,10 @@ from scripts.ai_council.orchestrator import render
33
35
 
34
36
  REPO_ROOT = Path(__file__).resolve().parents[2]
35
37
  SESSIONS_DIR = REPO_ROOT / "agents" / "council-sessions"
38
+ SETTINGS_FILE = REPO_ROOT / ".agent-settings.yml"
39
+
40
+ DEFAULT_RETENTION_DAYS = 14
41
+ _TS_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z$")
36
42
 
37
43
 
38
44
  @dataclass
@@ -69,12 +75,90 @@ def _serialise_response(r: CouncilResponse) -> dict[str, object]:
69
75
  }
70
76
 
71
77
 
78
+ def _load_retention_days(settings_path: Path | None = None) -> int:
79
+ """Read `ai_council.session_retention_days` from `.agent-settings.yml`.
80
+
81
+ Returns `DEFAULT_RETENTION_DAYS` on any read/parse failure (missing
82
+ file, invalid YAML, missing key, non-int value). Pruning never
83
+ blocks the council on a settings error.
84
+ """
85
+ path = settings_path or SETTINGS_FILE
86
+ if not path.exists():
87
+ return DEFAULT_RETENTION_DAYS
88
+ try:
89
+ import yaml # type: ignore[import-not-found]
90
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
91
+ except Exception: # noqa: BLE001 - never block on settings parse
92
+ return DEFAULT_RETENTION_DAYS
93
+ ai = data.get("ai_council") if isinstance(data, dict) else None
94
+ if not isinstance(ai, dict):
95
+ return DEFAULT_RETENTION_DAYS
96
+ raw = ai.get("session_retention_days", DEFAULT_RETENTION_DAYS)
97
+ try:
98
+ return int(raw)
99
+ except (TypeError, ValueError):
100
+ return DEFAULT_RETENTION_DAYS
101
+
102
+
103
+ def _parse_session_timestamp(name: str) -> _dt.datetime | None:
104
+ """Parse `YYYY-MM-DDTHH-MM-SSZ` directory name to a UTC datetime."""
105
+ m = _TS_RE.match(name)
106
+ if not m:
107
+ return None
108
+ try:
109
+ y, mo, d, h, mi, s = (int(g) for g in m.groups())
110
+ return _dt.datetime(y, mo, d, h, mi, s, tzinfo=_dt.timezone.utc)
111
+ except ValueError:
112
+ return None
113
+
114
+
115
+ def prune_old_sessions(
116
+ sessions_dir: Path,
117
+ retention_days: int,
118
+ *,
119
+ now: _dt.datetime | None = None,
120
+ ) -> list[Path]:
121
+ """Delete session subdirectories older than `retention_days`.
122
+
123
+ A session is "old" when its directory-name timestamp predates
124
+ `now - retention_days`. Non-matching names (e.g. JSON reports at
125
+ the root, custom folders) are skipped. Never raises — disk
126
+ failures are logged to stderr.
127
+
128
+ Returns the list of deleted directories. `retention_days <= 0`
129
+ disables pruning and returns an empty list.
130
+ """
131
+ if retention_days <= 0 or not sessions_dir.exists():
132
+ return []
133
+ cutoff = (now or _dt.datetime.now(_dt.timezone.utc)) - _dt.timedelta(days=retention_days)
134
+ removed: list[Path] = []
135
+ try:
136
+ entries = list(sessions_dir.iterdir())
137
+ except OSError as exc: # noqa: BLE001 - never block the report
138
+ print(f"[council:session] prune iterdir failed: {exc}", file=sys.stderr)
139
+ return removed
140
+ for entry in entries:
141
+ if not entry.is_dir():
142
+ continue
143
+ ts = _parse_session_timestamp(entry.name)
144
+ if ts is None or ts >= cutoff:
145
+ continue
146
+ try:
147
+ shutil.rmtree(entry)
148
+ removed.append(entry)
149
+ except OSError as exc: # noqa: BLE001 - never block the report
150
+ print(f"[council:session] prune rmtree failed for {entry}: {exc}",
151
+ file=sys.stderr)
152
+ return removed
153
+
154
+
72
155
  def save(
73
156
  *,
74
157
  manifest: SessionManifest,
75
158
  responses: list[CouncilResponse] | Iterable[list[CouncilResponse]],
76
159
  sessions_dir: Path | None = None,
77
160
  timestamp: str | None = None,
161
+ retention_days: int | None = None,
78
162
  ) -> Path:
79
163
  """Persist a council call. Returns the session directory.
80
164
 
@@ -83,6 +167,11 @@ def save(
83
167
  - `Iterable[list[CouncilResponse]]` — multi-round, one list per
84
168
  round in execution order.
85
169
 
170
+ `retention_days` controls auto-pruning of older sibling sessions
171
+ after the new one is written. `None` reads the value from
172
+ `.agent-settings.yml` (`ai_council.session_retention_days`,
173
+ default `14`); `0` disables pruning.
174
+
86
175
  Disk-write failures are surfaced via a stderr line but do not
87
176
  raise; the caller's text report is the source of truth.
88
177
  """
@@ -141,4 +230,7 @@ def save(
141
230
  except OSError as exc: # noqa: BLE001 - never block the report
142
231
  print(f"[council:session] write failed: {exc}", file=sys.stderr)
143
232
 
233
+ days = _load_retention_days() if retention_days is None else retention_days
234
+ prune_old_sessions(base, days)
235
+
144
236
  return session_dir
@@ -71,14 +71,6 @@ add("no-cheap-questions.md", "pre-send Q&A check", "agent-only", "output",
71
71
  "NA-soft", "3", notes="Pre-send self-check, no platform surface")
72
72
 
73
73
  # ── Auto-rules — Tier 1 candidates (mechanizable, deterministic) ──────
74
- add("chat-history-cadence.md", "per-turn / per-tool / per-phase", "mechanical-already",
75
- "hook", "NA-mechanical", "mechanical-already",
76
- notes="PRECEDENT — heartbeat + chat_history.py + hooks. Reference pattern.")
77
- add("chat-history-ownership.md", "first turn", "hook", "state",
78
- "low", "1", notes="Detectable: ownership classification at session start")
79
- add("chat-history-visibility.md", "heartbeat marker emit", "mechanical-already",
80
- "hook", "NA-mechanical", "mechanical-already",
81
- notes="Subprocess marker print is already mechanical")
82
74
  add("onboarding-gate.md", "first turn (settings.onboarded == false)", "settings",
83
75
  "state", "low", "1",
84
76
  notes="Pilot candidate — frequency 100% on un-onboarded projects, binary verifiable")
@@ -341,7 +333,7 @@ def emit():
341
333
  lines.append("## Cross-references")
342
334
  lines.append("")
343
335
  lines.append("- Budget contract: [`docs/contracts/load-context-budget-model.md`](../../docs/contracts/load-context-budget-model.md)")
344
- lines.append("- Pattern precedent: `chat-history-cadence` (heartbeat hook + `scripts/chat_history.py`)")
336
+ lines.append("- Pattern precedent: `roadmap-progress-sync` (PostToolUse path-filter hook)")
345
337
  lines.append("- Phase 2A finding: [`adr-always-rule-context-split-not-viable.md`](adr-always-rule-context-split-not-viable.md)")
346
338
  lines.append("")
347
339