@event4u/agent-config 1.34.0 → 1.36.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 (47) hide show
  1. package/.agent-src/commands/memory/load.md +69 -0
  2. package/.agent-src/commands/memory/mine-session.md +151 -0
  3. package/.agent-src/commands/memory/promote.md +35 -0
  4. package/.agent-src/commands/memory/propose.md +10 -1
  5. package/.agent-src/commands/memory.md +5 -3
  6. package/.agent-src/commands/roadmap/process-full.md +20 -15
  7. package/.agent-src/contexts/authority/scope-mechanics.md +36 -0
  8. package/.agent-src/contexts/execution/autonomy-detection.md +7 -7
  9. package/.agent-src/contexts/execution/roadmap-process-loop.md +16 -10
  10. package/.agent-src/personas/discovery-lead.md +99 -0
  11. package/.agent-src/personas/product-owner.md +71 -52
  12. package/.agent-src/personas/revops-maintainer.md +100 -0
  13. package/.agent-src/personas/tech-writer.md +99 -0
  14. package/.agent-src/rules/autonomous-execution.md +25 -0
  15. package/.agent-src/rules/scope-control.md +12 -5
  16. package/.agent-src/skills/competitive-positioning/SKILL.md +152 -0
  17. package/.agent-src/skills/customer-research/SKILL.md +116 -0
  18. package/.agent-src/skills/decision-record/SKILL.md +78 -3
  19. package/.agent-src/skills/discovery-interview/SKILL.md +152 -0
  20. package/.agent-src/skills/launch-readiness/SKILL.md +156 -0
  21. package/.agent-src/skills/memory-consolidation/SKILL.md +216 -0
  22. package/.agent-src/skills/release-comms/SKILL.md +123 -0
  23. package/.agent-src/skills/roadmap-writing/SKILL.md +1 -1
  24. package/.agent-src/skills/stakeholder-tradeoff/SKILL.md +91 -3
  25. package/.agent-src/skills/voc-extract/SKILL.md +164 -0
  26. package/.agent-src/templates/roadmaps.md +14 -0
  27. package/.claude-plugin/marketplace.json +9 -1
  28. package/CHANGELOG.md +64 -0
  29. package/README.md +3 -3
  30. package/config/agent-settings.template.yml +35 -0
  31. package/docs/architecture.md +3 -3
  32. package/docs/catalog.md +14 -5
  33. package/docs/contracts/agent-memory-contract.md +15 -1
  34. package/docs/contracts/command-clusters.md +1 -1
  35. package/docs/contracts/context-spine.md +133 -0
  36. package/docs/contracts/file-ownership-matrix.json +388 -0
  37. package/docs/contracts/mental-models.md +336 -0
  38. package/docs/getting-started.md +1 -1
  39. package/docs/guidelines/agent-infra/engineering-memory-data-format.md +52 -0
  40. package/docs/guidelines/cross-role-handoff.md +127 -0
  41. package/package.json +1 -1
  42. package/scripts/check_memory.py +106 -4
  43. package/scripts/check_references.py +1 -0
  44. package/scripts/lint_context_spine_usage.py +133 -0
  45. package/scripts/lint_roadmap_complexity.py +87 -3
  46. package/scripts/mine_session.py +279 -0
  47. package/scripts/schemas/skill.schema.json +9 -0
@@ -49,6 +49,19 @@ REQUIRED_KEYS = {
49
49
  }
50
50
  VALID_STATUS = {"active", "deprecated", "archived"}
51
51
  VALID_CONFIDENCE = {"low", "medium", "high"}
52
+ # `priority` is optional (default `normal`); enum is the smallest set that
53
+ # solves the tier-0 surfacing use case. See `road-to-dream-skill-adoption.md`
54
+ # § B2 and the Phase 2 council brief for why the `high` tier was rejected.
55
+ VALID_PRIORITY = {"critical", "normal", "low"}
56
+ # Soft-cap on `priority: critical` entries per memory type. Tier-0 inflation
57
+ # is the failure mode: when too many entries claim "always surface", the
58
+ # slice loses signal. Warn (not fail) when the cap is exceeded so curators
59
+ # notice without being blocked.
60
+ CRITICAL_WARN_THRESHOLD = 10
61
+ # Stale-critical guard: a `priority: critical` entry that hasn't been
62
+ # re-validated in this many days emits a warning. Surfaced separately
63
+ # from the generic `stale:` info so reviewers see it before merge.
64
+ CRITICAL_STALE_DAYS = 90
52
65
  KNOWN_TYPES = {
53
66
  "domain-invariants", "architecture-decisions",
54
67
  "incident-learnings", "product-rules",
@@ -69,6 +82,19 @@ REDACTION_PATTERNS = [
69
82
  (re.compile(r"\b192\.168\.\d{1,3}\.\d{1,3}\b"), "internal ipv4 range"),
70
83
  ]
71
84
 
85
+ # Date-discipline — relative-date phrases without an ISO YYYY-MM-DD anchor
86
+ # within ±20 chars are rejected. Memory entries that say "yesterday" or
87
+ # "last week" rot the moment the file is re-read on another day; the
88
+ # anchor pins meaning. See `road-to-dream-skill-adoption.md` § A5.
89
+ RELATIVE_DATE_PATTERN = re.compile(
90
+ r"(?i)\b(yesterday|today|tomorrow|"
91
+ r"last\s+(?:week|month|year)|"
92
+ r"next\s+(?:week|month|year)|"
93
+ r"this\s+(?:week|month|year))\b"
94
+ )
95
+ ISO_DATE_PATTERN = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
96
+ DATE_ANCHOR_WINDOW = 20
97
+
72
98
 
73
99
  @dataclass
74
100
  class Finding:
@@ -105,7 +131,13 @@ def _memory_type(path: Path) -> str:
105
131
  return stem[:-len(".example")] if stem.endswith(".example") else stem
106
132
 
107
133
 
108
- def _validate_entry(entry: dict, path: Path, seen_ids: set, findings: List[Finding]):
134
+ def _validate_entry(
135
+ entry: dict,
136
+ path: Path,
137
+ seen_ids: set,
138
+ findings: List[Finding],
139
+ critical_counts: Optional[dict] = None,
140
+ ):
109
141
  eid = entry.get("id", "")
110
142
  missing = REQUIRED_KEYS - set(entry.keys())
111
143
  for key in sorted(missing):
@@ -116,6 +148,14 @@ def _validate_entry(entry: dict, path: Path, seen_ids: set, findings: List[Findi
116
148
  if entry.get("confidence") and entry["confidence"] not in VALID_CONFIDENCE:
117
149
  findings.append(Finding(str(path), 0, "error",
118
150
  f"invalid confidence '{entry['confidence']}'", eid))
151
+ # Priority is optional (defaults to `normal` at read time). When present
152
+ # it MUST be one of the three-tier enum — see VALID_PRIORITY for the
153
+ # rationale on rejecting a fourth `high` tier.
154
+ priority = entry.get("priority")
155
+ if priority is not None and priority not in VALID_PRIORITY:
156
+ findings.append(Finding(str(path), 0, "error",
157
+ f"invalid priority '{priority}' "
158
+ f"(expected one of {sorted(VALID_PRIORITY)})", eid))
119
159
  sources = entry.get("source") or []
120
160
  if not isinstance(sources, list) or len(sources) < 1:
121
161
  findings.append(Finding(str(path), 0, "error",
@@ -131,6 +171,26 @@ def _validate_entry(entry: dict, path: Path, seen_ids: set, findings: List[Findi
131
171
  if age > days and entry.get("status") == "active":
132
172
  findings.append(Finding(str(path), 0, "info",
133
173
  f"stale: last_validated {age} days ago (limit {days})", eid))
174
+ # Critical-stale guard: a `priority: critical` entry that has not been
175
+ # re-validated within CRITICAL_STALE_DAYS surfaces as a warning, even
176
+ # when the entry's own `review_after_days` is more lenient. Critical
177
+ # entries surface on every /memory:load — they have a tighter SLA.
178
+ if (
179
+ priority == "critical"
180
+ and entry.get("status") == "active"
181
+ and isinstance(lv, _dt.date)
182
+ ):
183
+ crit_age = (_dt.date.today() - lv).days
184
+ if crit_age > CRITICAL_STALE_DAYS:
185
+ findings.append(Finding(
186
+ str(path), 0, "warning",
187
+ f"critical-stale: last_validated {crit_age} days ago "
188
+ f"(critical SLA is {CRITICAL_STALE_DAYS} days)", eid))
189
+ # Tier-0 inflation tracking — increment per memory type. The aggregate
190
+ # warning is emitted in main() after all files are validated.
191
+ if critical_counts is not None and priority == "critical" and entry.get("status") == "active":
192
+ mtype = _memory_type(path)
193
+ critical_counts[mtype] = critical_counts.get(mtype, 0) + 1
134
194
 
135
195
 
136
196
  def _check_redaction(path: Path, findings: List[Finding]):
@@ -144,12 +204,43 @@ def _check_redaction(path: Path, findings: List[Finding]):
144
204
  f"possible leak: {label}"))
145
205
 
146
206
 
147
- def _validate_file(path: Path, findings: List[Finding]):
207
+ def _check_date_discipline(path: Path, findings: List[Finding]):
208
+ """Reject relative-date phrases without an ISO YYYY-MM-DD anchor.
209
+
210
+ A curated memory entry that says "fixed yesterday" rots silently
211
+ the moment the file is re-read on a different day. We require an
212
+ ISO date within ±20 chars of every relative phrase so the meaning
213
+ survives the calendar.
214
+ """
215
+ for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
216
+ # Skip comments and the YAML key for `last_validated` itself.
217
+ stripped = line.lstrip()
218
+ if stripped.startswith("#") or stripped.startswith("last_validated"):
219
+ continue
220
+ for match in RELATIVE_DATE_PATTERN.finditer(line):
221
+ start = max(0, match.start() - DATE_ANCHOR_WINDOW)
222
+ end = min(len(line), match.end() + DATE_ANCHOR_WINDOW)
223
+ window = line[start:end]
224
+ if ISO_DATE_PATTERN.search(window):
225
+ continue
226
+ phrase = match.group(0)
227
+ findings.append(Finding(
228
+ str(path), line_no, "error",
229
+ f"relative date '{phrase}' without an ISO YYYY-MM-DD anchor "
230
+ f"within ±{DATE_ANCHOR_WINDOW} chars (re-anchor before commit)"))
231
+
232
+
233
+ def _validate_file(
234
+ path: Path,
235
+ findings: List[Finding],
236
+ critical_counts: Optional[dict] = None,
237
+ ):
148
238
  mtype = _memory_type(path)
149
239
  if mtype not in KNOWN_TYPES:
150
240
  findings.append(Finding(str(path), 0, "warning",
151
241
  f"unknown memory type '{mtype}'"))
152
242
  _check_redaction(path, findings)
243
+ _check_date_discipline(path, findings)
153
244
  try:
154
245
  data = _load_yaml(path) or {}
155
246
  except Exception as exc: # yaml.YAMLError or anything else
@@ -163,7 +254,7 @@ def _validate_file(path: Path, findings: List[Finding]):
163
254
  seen_ids: set = set()
164
255
  for entry in data.get("entries") or []:
165
256
  if isinstance(entry, dict):
166
- _validate_entry(entry, path, seen_ids, findings)
257
+ _validate_entry(entry, path, seen_ids, findings, critical_counts)
167
258
 
168
259
 
169
260
  INTAKE_GLOB = "agents/memory/intake/*.jsonl"
@@ -316,8 +407,19 @@ def main() -> int:
316
407
  else:
317
408
  print(f"ℹ️ {root} not found — nothing to validate")
318
409
  return 0
410
+ critical_counts: dict = {}
319
411
  for yml in sorted(root.rglob("*.yml")):
320
- _validate_file(yml, findings)
412
+ _validate_file(yml, findings, critical_counts)
413
+ # Tier-0 inflation warning — soft cap on `priority: critical` per type.
414
+ # Council convergence (Phase 2 B2): warn rather than block, because the
415
+ # right answer to "too many criticals" is curator review, not CI failure.
416
+ for mtype, count in sorted(critical_counts.items()):
417
+ if count > CRITICAL_WARN_THRESHOLD:
418
+ findings.append(Finding(
419
+ f"agents/memory/{mtype}", 0, "warning",
420
+ f"tier-0 inflation: {count} active 'priority: critical' "
421
+ f"entries (threshold {CRITICAL_WARN_THRESHOLD}) — review "
422
+ f"whether all still warrant always-surface treatment"))
321
423
  return _emit(findings, args.format)
322
424
 
323
425
 
@@ -35,6 +35,7 @@ SCAN_DIRS = [".agent-src", "agents"]
35
35
  SKIP_DIRS = [
36
36
  "agents/roadmaps/archive", # archived roadmaps have historical refs
37
37
  "agents/council-sessions", # per-user audit trail (gitignored), captured provider output
38
+ "agents/council-responses", # paired council output (gitignored), captured provider output
38
39
  "agents/council-questions", # design Q&A trail — forward-refs to planned artifacts
39
40
  "agents/analysis", # plate-comparison working docs — forward-refs to planned artifacts
40
41
  ]
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """Context-spine usage linter.
3
+
4
+ Closes the lint gap left after `scripts/schemas/skill.schema.json`
5
+ gained the `context_spine` enum: a skill can declare
6
+ `context_spine: [product]` in frontmatter without ever citing the
7
+ slot in its body, and the schema check will not catch it.
8
+
9
+ This linter enforces the author checklist in
10
+ `docs/contracts/context-spine.md` § 6: for every slot declared in
11
+ frontmatter, the skill body MUST cite the slot at least once.
12
+ A citation is any of these tokens:
13
+
14
+ - the literal path `agents/context-spine/<slot>.md`
15
+ - the slot name in bold: ``**<slot>**``
16
+ - the slot name in inline code: `` `<slot>` ``
17
+
18
+ Cap: ≤ 150 LOC, stdlib only. Hooked into `task ci` via
19
+ `task lint-context-spine-usage`.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ QUIET = "--quiet" in sys.argv
28
+
29
+ REPO_ROOT = Path(__file__).resolve().parent.parent
30
+ SKILL_GLOBS = (
31
+ ".agent-src.uncompressed/skills/**/SKILL.md",
32
+ ".agent-src/skills/**/SKILL.md",
33
+ )
34
+ VALID_SLOTS = ("product", "team", "repo")
35
+
36
+ CONTEXT_SPINE_PAT = re.compile(
37
+ r"^context_spine:\s*\[([^\]]*)\]\s*$", re.MULTILINE
38
+ )
39
+
40
+
41
+ def _frontmatter_and_body(text: str) -> tuple[str, str]:
42
+ if not text.startswith("---\n"):
43
+ return "", text
44
+ end = text.find("\n---\n", 4)
45
+ if end == -1:
46
+ return "", text
47
+ return text[4:end], text[end + 5 :]
48
+
49
+
50
+ def _read_spine(fm: str) -> list[str] | None:
51
+ m = CONTEXT_SPINE_PAT.search(fm)
52
+ if m is None:
53
+ return None
54
+ raw = m.group(1).strip()
55
+ if not raw:
56
+ return []
57
+ return [s.strip().strip("'\"") for s in raw.split(",") if s.strip()]
58
+
59
+
60
+ def _slot_cited(body: str, slot: str) -> bool:
61
+ """A slot is cited if any of three forms appears in the body."""
62
+ forms = (
63
+ f"agents/context-spine/{slot}.md",
64
+ f"**{slot}**",
65
+ f"`{slot}`",
66
+ )
67
+ return any(form in body for form in forms)
68
+
69
+
70
+ def lint_skill(path: Path) -> list[str]:
71
+ text = path.read_text(encoding="utf-8")
72
+ fm, body = _frontmatter_and_body(text)
73
+ if not fm:
74
+ return []
75
+ slots = _read_spine(fm)
76
+ if slots is None:
77
+ return []
78
+ problems: list[str] = []
79
+ for slot in slots:
80
+ if slot not in VALID_SLOTS:
81
+ problems.append(
82
+ f"unknown_context_spine_slot: '{slot}' "
83
+ f"(valid: {', '.join(VALID_SLOTS)})"
84
+ )
85
+ continue
86
+ if not _slot_cited(body, slot):
87
+ problems.append(
88
+ f"declared context_spine slot '{slot}' is never cited "
89
+ f"in the skill body — add `**{slot}**`, `` `{slot}` ``, "
90
+ f"or a link to `agents/context-spine/{slot}.md` "
91
+ f"(see docs/contracts/context-spine.md § 6)"
92
+ )
93
+ return problems
94
+
95
+
96
+ def main() -> int:
97
+ skills: list[Path] = []
98
+ for pattern in SKILL_GLOBS:
99
+ skills.extend(sorted(REPO_ROOT.glob(pattern)))
100
+ if not skills:
101
+ print("❌ no SKILL.md files matched", file=sys.stderr)
102
+ return 1
103
+ failed = 0
104
+ declared = 0
105
+ for skill in skills:
106
+ rel = skill.relative_to(REPO_ROOT)
107
+ problems = lint_skill(skill)
108
+ text = skill.read_text(encoding="utf-8")
109
+ fm, _ = _frontmatter_and_body(text)
110
+ if fm and CONTEXT_SPINE_PAT.search(fm):
111
+ declared += 1
112
+ if problems:
113
+ failed += 1
114
+ print(f"❌ {rel}", file=sys.stderr)
115
+ for p in problems:
116
+ print(f" - {p}", file=sys.stderr)
117
+ if failed:
118
+ print(
119
+ f"\n❌ {failed} skill(s) failed context-spine usage lint "
120
+ f"({declared} skill(s) declare a spine)",
121
+ file=sys.stderr,
122
+ )
123
+ return 1
124
+ if not QUIET:
125
+ print(
126
+ f"✅ {declared} skill(s) declare context_spine; "
127
+ f"all declared slots are cited in the body"
128
+ )
129
+ return 0
130
+
131
+
132
+ if __name__ == "__main__":
133
+ sys.exit(main())
@@ -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)
@@ -35,6 +42,29 @@ COMPLEXITY_PAT = re.compile(
35
42
  r"^complexity:\s*(lightweight|structural)\s*$", re.MULTILINE
36
43
  )
37
44
 
45
+ # Plate / horizon detection — template rule 16 forbids time-boxed plates
46
+ # in roadmaps. Patterns match the authoring devices we are retiring.
47
+ PLATE_PATS: tuple[tuple[re.Pattern[str], str], ...] = (
48
+ (re.compile(r"^##\s+Horizon\b", re.MULTILINE | re.IGNORECASE),
49
+ "'## Horizon' section header"),
50
+ (re.compile(r"\b\d+-week\s+(visible\s+)?plate\b", re.IGNORECASE),
51
+ "'N-week (visible) plate' phrasing"),
52
+ (re.compile(r"\bvisible\s+plate\b", re.IGNORECASE),
53
+ "'visible plate' phrasing"),
54
+ (re.compile(r"\b(in|out)-of-plate\b", re.IGNORECASE),
55
+ "'in-of-plate' / 'out-of-plate' marker"),
56
+ (re.compile(r"\bout-of-horizon\b", re.IGNORECASE),
57
+ "'out-of-horizon' marker"),
58
+ (re.compile(r"\bIn-plate\??\b"),
59
+ "'In-plate' / 'In-plate?' label"),
60
+ (re.compile(r"\bOut-of-plate\b"),
61
+ "'Out-of-plate' label"),
62
+ (re.compile(r"inside\s+(the\s+|\d+-week\s+)?plate", re.IGNORECASE),
63
+ "'inside the plate' phrasing"),
64
+ (re.compile(r"outside\s+(the\s+|\d+-week\s+)?plate", re.IGNORECASE),
65
+ "'outside the plate' phrasing"),
66
+ )
67
+
38
68
 
39
69
  def _frontmatter(text: str) -> str:
40
70
  if not text.startswith("---\n"):
@@ -43,6 +73,38 @@ def _frontmatter(text: str) -> str:
43
73
  return text[4:end] if end != -1 else ""
44
74
 
45
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
+
46
108
  def _read_complexity(fm: str) -> str | None:
47
109
  m = COMPLEXITY_PAT.search(fm)
48
110
  return m.group(1) if m else None
@@ -73,7 +135,26 @@ def _check_lightweight(text: str, line_count: int, problems: list[str]) -> None:
73
135
  )
74
136
 
75
137
 
76
- def lint_roadmap(path: Path) -> list[str]:
138
+ def _check_no_plate(text: str, problems: list[str]) -> None:
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
+ """
144
+ for pat, label in PLATE_PATS:
145
+ m = pat.search(text)
146
+ if m is None:
147
+ continue
148
+ line = text.count("\n", 0, m.start()) + 1
149
+ problems.append(
150
+ f"plate/horizon convention detected ({label}) at line {line} — "
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"
154
+ )
155
+
156
+
157
+ def lint_roadmap(path: Path, horizon_weeks: int) -> list[str]:
77
158
  text = path.read_text(encoding="utf-8")
78
159
  line_count = text.count("\n") + (1 if text and not text.endswith("\n") else 0)
79
160
  problems: list[str] = []
@@ -87,11 +168,14 @@ def lint_roadmap(path: Path) -> list[str]:
87
168
  return problems
88
169
  if complexity == "lightweight":
89
170
  _check_lightweight(text, line_count, problems)
171
+ if horizon_weeks <= 0:
172
+ _check_no_plate(text, problems)
90
173
  return problems
91
174
 
92
175
 
93
176
  def main() -> int:
94
177
  roadmaps = sorted(REPO_ROOT.glob(ROADMAP_GLOB))
178
+ horizon_weeks = _read_horizon_weeks()
95
179
  if not roadmaps:
96
180
  print(f"❌ no roadmaps matched {ROADMAP_GLOB}", file=sys.stderr)
97
181
  return 1
@@ -99,7 +183,7 @@ def main() -> int:
99
183
  summary: list[tuple[str, str]] = []
100
184
  for roadmap in roadmaps:
101
185
  rel = roadmap.relative_to(REPO_ROOT)
102
- problems = lint_roadmap(roadmap)
186
+ problems = lint_roadmap(roadmap, horizon_weeks)
103
187
  text = roadmap.read_text(encoding="utf-8")
104
188
  complexity = _read_complexity(_frontmatter(text)) or "untagged"
105
189
  summary.append((str(rel), complexity))