@event4u/agent-config 1.35.0 → 1.36.1

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.
@@ -10,7 +10,10 @@ Enforces the measurable subset of
10
10
  headings, and contain no `## Council Round N` / `### Verdict`
11
11
  sections;
12
12
  - structural roadmaps have no upper cap, but the tag must be
13
- declared.
13
+ declared;
14
+ - plate / horizon framing is forbidden when
15
+ `roadmap.horizon_weeks` in `.agent-settings.yml` is 0 (default)
16
+ and allowed when it is a positive integer.
14
17
 
15
18
  Cap: ≤ 150 LOC, stdlib only. Hooked into `task ci` via
16
19
  `task lint-roadmap-complexity`.
@@ -27,6 +30,10 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
27
30
  ROADMAP_GLOB = "agents/roadmaps/*.md"
28
31
  LIGHTWEIGHT_LINE_CAP = 600
29
32
  LIGHTWEIGHT_PHASE_CAP = 6
33
+ SETTINGS_FILE = REPO_ROOT / ".agent-settings.yml"
34
+ HORIZON_WEEKS_PAT = re.compile(
35
+ r"^\s*horizon_weeks:\s*(\d+)\s*(?:#.*)?$", re.MULTILINE
36
+ )
30
37
 
31
38
  PHASE_PAT = re.compile(r"^## Phase \d+\b", re.MULTILINE)
32
39
  COUNCIL_PAT = re.compile(r"^## Council Round \d+\b", re.MULTILINE)
@@ -66,6 +73,38 @@ def _frontmatter(text: str) -> str:
66
73
  return text[4:end] if end != -1 else ""
67
74
 
68
75
 
76
+ def _read_horizon_weeks() -> int:
77
+ """Read roadmap.horizon_weeks from .agent-settings.yml.
78
+
79
+ Default 0 (off) when file or key is missing or unparseable.
80
+ Positive integer = horizon framing allowed.
81
+ """
82
+ if not SETTINGS_FILE.is_file():
83
+ return 0
84
+ try:
85
+ text = SETTINGS_FILE.read_text(encoding="utf-8")
86
+ except OSError:
87
+ return 0
88
+ in_roadmap = False
89
+ for raw in text.splitlines():
90
+ if not raw.strip() or raw.lstrip().startswith("#"):
91
+ continue
92
+ if raw.startswith("roadmap:"):
93
+ in_roadmap = True
94
+ continue
95
+ if in_roadmap and raw and not raw.startswith((" ", "\t")):
96
+ in_roadmap = False
97
+ continue
98
+ if in_roadmap:
99
+ m = HORIZON_WEEKS_PAT.match(raw)
100
+ if m:
101
+ try:
102
+ return max(0, int(m.group(1)))
103
+ except ValueError:
104
+ return 0
105
+ return 0
106
+
107
+
69
108
  def _read_complexity(fm: str) -> str | None:
70
109
  m = COMPLEXITY_PAT.search(fm)
71
110
  return m.group(1) if m else None
@@ -97,7 +136,11 @@ def _check_lightweight(text: str, line_count: int, problems: list[str]) -> None:
97
136
 
98
137
 
99
138
  def _check_no_plate(text: str, problems: list[str]) -> None:
100
- """Detect time-boxed plate / horizon framing forbidden by template rule 16."""
139
+ """Detect time-boxed plate / horizon framing.
140
+
141
+ Forbidden by template rule 16 when `roadmap.horizon_weeks` is 0
142
+ (default). Allowed when the setting is a positive integer.
143
+ """
101
144
  for pat, label in PLATE_PATS:
102
145
  m = pat.search(text)
103
146
  if m is None:
@@ -105,11 +148,13 @@ def _check_no_plate(text: str, problems: list[str]) -> None:
105
148
  line = text.count("\n", 0, m.start()) + 1
106
149
  problems.append(
107
150
  f"plate/horizon convention detected ({label}) at line {line} — "
108
- f"forbidden by templates/roadmaps.md rule 16"
151
+ f"forbidden by templates/roadmaps.md rule 16 when "
152
+ f"`roadmap.horizon_weeks` is 0; set a positive integer in "
153
+ f".agent-settings.yml to opt in"
109
154
  )
110
155
 
111
156
 
112
- def lint_roadmap(path: Path) -> list[str]:
157
+ def lint_roadmap(path: Path, horizon_weeks: int) -> list[str]:
113
158
  text = path.read_text(encoding="utf-8")
114
159
  line_count = text.count("\n") + (1 if text and not text.endswith("\n") else 0)
115
160
  problems: list[str] = []
@@ -123,12 +168,14 @@ def lint_roadmap(path: Path) -> list[str]:
123
168
  return problems
124
169
  if complexity == "lightweight":
125
170
  _check_lightweight(text, line_count, problems)
126
- _check_no_plate(text, problems)
171
+ if horizon_weeks <= 0:
172
+ _check_no_plate(text, problems)
127
173
  return problems
128
174
 
129
175
 
130
176
  def main() -> int:
131
177
  roadmaps = sorted(REPO_ROOT.glob(ROADMAP_GLOB))
178
+ horizon_weeks = _read_horizon_weeks()
132
179
  if not roadmaps:
133
180
  print(f"❌ no roadmaps matched {ROADMAP_GLOB}", file=sys.stderr)
134
181
  return 1
@@ -136,7 +183,7 @@ def main() -> int:
136
183
  summary: list[tuple[str, str]] = []
137
184
  for roadmap in roadmaps:
138
185
  rel = roadmap.relative_to(REPO_ROOT)
139
- problems = lint_roadmap(roadmap)
186
+ problems = lint_roadmap(roadmap, horizon_weeks)
140
187
  text = roadmap.read_text(encoding="utf-8")
141
188
  complexity = _read_complexity(_frontmatter(text)) or "untagged"
142
189
  summary.append((str(rel), complexity))
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env python3
2
+ """Mine session transcripts for memory signals — Phase-1 single-host.
3
+
4
+ Implements the GATHER SIGNAL phase of the `memory-consolidation` skill
5
+ against Claude-Code-format JSONL transcripts. Default behaviour is
6
+ ``--preview`` (stdout only). ``--commit-intake`` appends one JSONL line
7
+ per fact to ``agents/memory/intake/<primary-tag>.jsonl`` per the
8
+ agent-memory contract.
9
+
10
+ Strict gates: opt-in transcript access (``--confirm-transcript-access``
11
+ required per invocation), ≤ 5 normalised facts per cycle, redaction
12
+ applied to every yielded text. See
13
+ ``.agent-src.uncompressed/commands/memory/mine-session.md`` for the
14
+ authored spec.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import datetime as dt
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import Any, Iterable
28
+
29
+ ROOT = Path(__file__).resolve().parent.parent
30
+ INTAKE_ROOT = Path("agents/memory/intake")
31
+ DEFAULT_WINDOW_DAYS = 14
32
+ MAX_FACTS = 5
33
+
34
+ SIGNAL_FAMILIES: dict[str, re.Pattern[str]] = {
35
+ # Correction first — explicit redirects beat ambient preference matches.
36
+ "gotcha": re.compile(
37
+ r"(?i)\b(actually|wrong|stop doing|don't do|that's not what|nicht so)\b"),
38
+ # Decision next — narrowest family.
39
+ "invariant": re.compile(
40
+ r"(?i)\b(let's go with|decided|we'll use|entschieden)\b"),
41
+ # Preference last — widest, must not eat correction/decision turns.
42
+ "convention": re.compile(
43
+ r"(?i)\b(prefer|always|never|standard|i want|ich will)\b"),
44
+ }
45
+ PATTERN_MIN_REPEATS = 3
46
+ PATTERN_WINDOW_HOURS = 24
47
+
48
+ NAME_REDACT = re.compile(r"\b(Matze|Mathias)\b")
49
+ PRONOUN_STRIP = re.compile(r"(?i)\b(I|me|my|mein|ich)\b\s*")
50
+ PATH_TOKEN = re.compile(r"\b[a-zA-Z][\w/.-]*/[\w./-]+\b")
51
+ SYMBOL_TOKEN = re.compile(r"\b[A-Z][a-zA-Z0-9]+(?:::|\.)[a-zA-Z_][\w]*\b")
52
+
53
+
54
+ def _redact(text: str, extra_patterns: list[re.Pattern[str]]) -> str:
55
+ out = NAME_REDACT.sub("<user>", text)
56
+ for p in extra_patterns:
57
+ out = p.sub("<redacted>", out)
58
+ return out.strip()
59
+
60
+
61
+ def _normalise(text: str, extra_patterns: list[re.Pattern[str]]) -> str | None:
62
+ """Strip pronouns and chrome; require a project-scoped key token."""
63
+ cleaned = _redact(text, extra_patterns)
64
+ cleaned = PRONOUN_STRIP.sub("", cleaned).strip()
65
+ if not (PATH_TOKEN.search(cleaned) or SYMBOL_TOKEN.search(cleaned)):
66
+ return None # user-scoped, drop
67
+ return re.sub(r"\s+", " ", cleaned)[:240]
68
+
69
+
70
+ def _key_of(text: str) -> str:
71
+ m = PATH_TOKEN.search(text) or SYMBOL_TOKEN.search(text)
72
+ return m.group(0) if m else "unknown"
73
+
74
+
75
+ def _iter_claude_code_jsonl(path: Path) -> Iterable[dict[str, Any]]:
76
+ with path.open(encoding="utf-8") as f:
77
+ for line in f:
78
+ line = line.strip()
79
+ if not line:
80
+ continue
81
+ try:
82
+ yield json.loads(line)
83
+ except json.JSONDecodeError:
84
+ continue
85
+
86
+
87
+ def _turn_text(turn: dict[str, Any]) -> str:
88
+ msg = turn.get("message") or {}
89
+ content = msg.get("content")
90
+ if isinstance(content, str):
91
+ return content
92
+ if isinstance(content, list):
93
+ return " ".join(c.get("text", "") for c in content
94
+ if isinstance(c, dict) and c.get("type") == "text")
95
+ return ""
96
+
97
+
98
+ def _turn_ts(turn: dict[str, Any]) -> str:
99
+ return turn.get("timestamp") or turn.get("ts") or ""
100
+
101
+
102
+ def _within_window(ts_str: str, since: dt.datetime) -> bool:
103
+ if not ts_str:
104
+ return True
105
+ try:
106
+ ts = dt.datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
107
+ except ValueError:
108
+ return True
109
+ if ts.tzinfo is None:
110
+ ts = ts.replace(tzinfo=dt.timezone.utc)
111
+ return ts >= since
112
+
113
+
114
+ def _detect_pattern(turns: list[dict[str, Any]]) -> list[tuple[str, str, str]]:
115
+ """Return [(key, observation, ts)] for paths/symbols seen ≥ 3× / 24h."""
116
+ seen: dict[str, list[tuple[dt.datetime, str]]] = {}
117
+ for t in turns:
118
+ text = _turn_text(t)
119
+ ts_str = _turn_ts(t)
120
+ try:
121
+ ts = dt.datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
122
+ except ValueError:
123
+ continue
124
+ if ts.tzinfo is None:
125
+ ts = ts.replace(tzinfo=dt.timezone.utc)
126
+ for m in PATH_TOKEN.findall(text) + SYMBOL_TOKEN.findall(text):
127
+ seen.setdefault(m, []).append((ts, ts_str))
128
+ out: list[tuple[str, str, str]] = []
129
+ window = dt.timedelta(hours=PATTERN_WINDOW_HOURS)
130
+ for key, hits in seen.items():
131
+ hits.sort()
132
+ for i in range(len(hits) - PATTERN_MIN_REPEATS + 1):
133
+ if hits[i + PATTERN_MIN_REPEATS - 1][0] - hits[i][0] <= window:
134
+ out.append((key, f"recurring reference to {key}",
135
+ hits[i + PATTERN_MIN_REPEATS - 1][1]))
136
+ break
137
+ return out
138
+
139
+
140
+ def _session_id(transcript: Path) -> str:
141
+ h = hashlib.sha256(str(transcript.resolve()).encode()).hexdigest()
142
+ return h[:16]
143
+
144
+
145
+ def mine(transcript: Path, since: dt.datetime,
146
+ extra_patterns: list[re.Pattern[str]]) -> list[dict[str, Any]]:
147
+ """Return up to MAX_FACTS normalised facts (preview shape)."""
148
+ turns_in_window = [t for t in _iter_claude_code_jsonl(transcript)
149
+ if _within_window(_turn_ts(t), since)]
150
+ facts: list[dict[str, Any]] = []
151
+ session_id = _session_id(transcript)
152
+ for turn in turns_in_window:
153
+ text = _turn_text(turn)
154
+ if not text:
155
+ continue
156
+ for tag, family in SIGNAL_FAMILIES.items():
157
+ if not family.search(text):
158
+ continue
159
+ obs = _normalise(text, extra_patterns)
160
+ if obs is None:
161
+ continue
162
+ facts.append({
163
+ "ts": _turn_ts(turn) or dt.datetime.now(
164
+ dt.timezone.utc).isoformat(timespec="seconds"),
165
+ "type": tag,
166
+ "key": _key_of(text),
167
+ "observation": obs,
168
+ "source": "agent",
169
+ "session_id": session_id,
170
+ "tags": [tag],
171
+ })
172
+ break
173
+ for key, obs, ts in _detect_pattern(turns_in_window):
174
+ facts.append({
175
+ "ts": ts, "type": "pattern", "key": key,
176
+ "observation": obs, "source": "agent",
177
+ "session_id": session_id, "tags": ["pattern"],
178
+ })
179
+ return facts[:MAX_FACTS]
180
+
181
+
182
+ def render_preview(facts: list[dict[str, Any]],
183
+ project: str, window: str, host: str) -> str:
184
+ if not facts:
185
+ return (f"## Mining preview — {project} · {window} · host={host}\n\n"
186
+ "_No signals matched. Tighten patterns or widen --since._\n")
187
+ lines = [f"## Mining preview — {project} · {window} · host={host}", "",
188
+ "| # | Tag | Key | Observation | Source turn |",
189
+ "|---|---|---|---|---|"]
190
+ for i, f in enumerate(facts, 1):
191
+ lines.append(f"| {i} | {f['type']} | {f['key']} | "
192
+ f"{f['observation']} | {f['ts']} |")
193
+ schemas = sorted({f["type"] for f in facts})
194
+ lines.append("")
195
+ lines.append(f"Schemas touched: {', '.join(schemas)}")
196
+ return "\n".join(lines) + "\n"
197
+
198
+
199
+ def commit_intake(facts: list[dict[str, Any]], intake_root: Path) -> int:
200
+ intake_root.mkdir(parents=True, exist_ok=True)
201
+ written = 0
202
+ for f in facts:
203
+ dest = intake_root / f"{f['type']}.jsonl"
204
+ with dest.open("a", encoding="utf-8") as fh:
205
+ fh.write(json.dumps(f, ensure_ascii=False) + "\n")
206
+ written += 1
207
+ return written
208
+
209
+
210
+ def _resolve_transcript(host: str, override: str | None) -> Path | None:
211
+ if override:
212
+ return Path(override)
213
+ if host != "claude-code":
214
+ return None
215
+ home = Path(os.path.expanduser("~/.claude/projects"))
216
+ if not home.exists():
217
+ return None
218
+ candidates = sorted(home.rglob("*.jsonl"),
219
+ key=lambda p: p.stat().st_mtime, reverse=True)
220
+ return candidates[0] if candidates else None
221
+
222
+
223
+ def main(argv: list[str] | None = None) -> int:
224
+ ap = argparse.ArgumentParser(description=__doc__)
225
+ ap.add_argument("--since", default=None,
226
+ help="ISO date; default 14 days ago")
227
+ ap.add_argument("--confirm-transcript-access", action="store_true")
228
+ ap.add_argument("--preview", action="store_true", default=True)
229
+ ap.add_argument("--commit-intake", action="store_true")
230
+ ap.add_argument("--host", default="claude-code")
231
+ ap.add_argument("--transcript", default=None,
232
+ help="Override transcript path (testing)")
233
+ ap.add_argument("--intake-root", default=str(INTAKE_ROOT))
234
+ ap.add_argument("--project", default=Path.cwd().name)
235
+ ns = ap.parse_args(argv)
236
+
237
+ if ns.commit_intake and not ns.preview:
238
+ ns.preview = False
239
+ if ns.commit_intake and ns.preview:
240
+ ns.preview = False # commit-intake wins
241
+
242
+ if not ns.confirm_transcript_access:
243
+ print("> Mining reads your session transcript files. Re-run with\n"
244
+ "> --confirm-transcript-access to proceed. The flag is "
245
+ "per-invocation\n> and not persisted.")
246
+ return 0
247
+
248
+ if ns.host != "claude-code":
249
+ print(f"> No TranscriptAdapter for host={ns.host}. Phase 1 supports: "
250
+ "claude-code.\n> Use /memory propose to record signals "
251
+ "manually.")
252
+ return 0
253
+
254
+ transcript = _resolve_transcript(ns.host, ns.transcript)
255
+ if transcript is None or not transcript.exists():
256
+ print("> No transcript found for host=claude-code. "
257
+ "Use /memory propose.")
258
+ return 0
259
+
260
+ since = (dt.datetime.fromisoformat(ns.since)
261
+ .replace(tzinfo=dt.timezone.utc) if ns.since
262
+ else dt.datetime.now(dt.timezone.utc)
263
+ - dt.timedelta(days=DEFAULT_WINDOW_DAYS))
264
+ facts = mine(transcript, since, extra_patterns=[])
265
+ window = f"since {since.date().isoformat()}"
266
+
267
+ if not ns.commit_intake:
268
+ print(render_preview(facts, ns.project, window, ns.host), end="")
269
+ return 0
270
+
271
+ written = commit_intake(facts, Path(ns.intake_root))
272
+ files_touched = len({f["type"] for f in facts})
273
+ print(f"✅ Appended {written} intake lines across {files_touched} files.\n"
274
+ " Next: /memory promote to lift validated lines into curated YAML.")
275
+ return 0
276
+
277
+
278
+ if __name__ == "__main__":
279
+ sys.exit(main())