@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.
- package/.agent-src/commands/memory/load.md +69 -0
- package/.agent-src/commands/memory/mine-session.md +151 -0
- package/.agent-src/commands/memory/promote.md +35 -0
- package/.agent-src/commands/memory/propose.md +10 -1
- package/.agent-src/commands/memory.md +5 -3
- package/.agent-src/commands/roadmap/process-full.md +20 -15
- package/.agent-src/contexts/authority/scope-mechanics.md +36 -0
- package/.agent-src/contexts/execution/autonomy-detection.md +7 -7
- package/.agent-src/contexts/execution/roadmap-process-loop.md +16 -10
- package/.agent-src/personas/discovery-lead.md +99 -0
- package/.agent-src/personas/product-owner.md +71 -52
- package/.agent-src/personas/revops-maintainer.md +100 -0
- package/.agent-src/personas/tech-writer.md +99 -0
- package/.agent-src/rules/autonomous-execution.md +25 -0
- package/.agent-src/rules/scope-control.md +12 -5
- package/.agent-src/skills/competitive-positioning/SKILL.md +152 -0
- package/.agent-src/skills/customer-research/SKILL.md +116 -0
- package/.agent-src/skills/decision-record/SKILL.md +78 -3
- package/.agent-src/skills/discovery-interview/SKILL.md +152 -0
- package/.agent-src/skills/launch-readiness/SKILL.md +156 -0
- package/.agent-src/skills/memory-consolidation/SKILL.md +216 -0
- package/.agent-src/skills/release-comms/SKILL.md +123 -0
- package/.agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/.agent-src/skills/stakeholder-tradeoff/SKILL.md +91 -3
- package/.agent-src/skills/voc-extract/SKILL.md +164 -0
- package/.agent-src/templates/roadmaps.md +14 -0
- package/.claude-plugin/marketplace.json +9 -1
- package/CHANGELOG.md +64 -0
- package/README.md +3 -3
- package/config/agent-settings.template.yml +35 -0
- package/docs/architecture.md +3 -3
- package/docs/catalog.md +14 -5
- package/docs/contracts/agent-memory-contract.md +15 -1
- package/docs/contracts/command-clusters.md +1 -1
- package/docs/contracts/context-spine.md +133 -0
- package/docs/contracts/file-ownership-matrix.json +388 -0
- package/docs/contracts/mental-models.md +336 -0
- package/docs/getting-started.md +1 -1
- package/docs/guidelines/agent-infra/engineering-memory-data-format.md +52 -0
- package/docs/guidelines/cross-role-handoff.md +127 -0
- package/package.json +1 -1
- package/scripts/check_memory.py +106 -4
- package/scripts/check_references.py +1 -0
- package/scripts/lint_context_spine_usage.py +133 -0
- package/scripts/lint_roadmap_complexity.py +87 -3
- package/scripts/mine_session.py +279 -0
- package/scripts/schemas/skill.schema.json +9 -0
package/scripts/check_memory.py
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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))
|