@seanyao/roll 2026.518.4 → 2026.519.2

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.
@@ -44,12 +44,12 @@
44
44
  draft with user. Do not write code until approved.
45
45
  - Before claiming completion: verify in the target environment mentioned by
46
46
  user (e.g., specific CLI tool, remote server, hardware platform).
47
- - **Workspace**: `BACKLOG.md` index. `docs/features/` for details.
47
+ - **Workspace**: `.roll/backlog.md` index. `.roll/features/` for details.
48
48
  - **Backlog descriptions** (US, FIX, REFACTOR, IDEA, PROPOSAL): one sentence in plain language.
49
49
  Say what changed and why it matters — not how it works internally.
50
50
  No file paths, function names, parameter lists, or architecture jargon.
51
51
  `depends-on:` and `manual-only:` functional tags are allowed; `Domain:` annotation tags are not.
52
- Technical details and AC go in `docs/features/`.
52
+ Technical details and AC go in `.roll/features/`.
53
53
  A well-written BACKLOG description can be used directly as a CHANGELOG entry.
54
54
  - **Convention layering**: project-level convention files extend the global SOT — see §9 below.
55
55
  - **Done**: Push + CI passes + deployed. Local-only is not done.
@@ -101,10 +101,10 @@ Confirm each phase clean before proceeding to the next.
101
101
  - Icons: Lucide React.
102
102
 
103
103
  ## 8. Where to Look
104
- - **Domain model**: `docs/domain/context-map.md` — Bounded Contexts and relationships
105
- - **Story details**: `docs/features/` — AC, implementation specs, dependencies
106
- - **Design decisions**: `docs/domain/` — DDD models, architecture records
107
- - When `docs/domain/` or `docs/features/` don't exist yet, run `$roll-doc` to bootstrap.
104
+ - **Domain model**: `.roll/domain/context-map.md` — Bounded Contexts and relationships
105
+ - **Story details**: `.roll/features/` — AC, implementation specs, dependencies
106
+ - **Design decisions**: `.roll/domain/` — DDD models, architecture records
107
+ - When `.roll/domain/` or `.roll/features/` don't exist yet, run `$roll-doc` to bootstrap.
108
108
 
109
109
  ## 9. Convention Architecture
110
110
 
@@ -29,9 +29,9 @@ src/
29
29
  ## 4. Discipline
30
30
  - **TCR**: Mandatory.
31
31
  - **Security**: Input validation (zod), Rate limiting, Secrets rotation.
32
- - **Workspace**: `BACKLOG.md` + `docs/features/`.
32
+ - **Workspace**: `.roll/backlog.md` + `.roll/features/`.
33
33
 
34
34
  ## 5. Where to Look
35
- - **Domain model**: `docs/domain/context-map.md` — Bounded Contexts and relationships
36
- - **Story details**: `docs/features/` — AC, implementation specs, dependencies
37
- - **Design decisions**: `docs/domain/` — DDD models, architecture records
35
+ - **Domain model**: `.roll/domain/context-map.md` — Bounded Contexts and relationships
36
+ - **Story details**: `.roll/features/` — AC, implementation specs, dependencies
37
+ - **Design decisions**: `.roll/domain/` — DDD models, architecture records
@@ -31,9 +31,9 @@ tests/
31
31
  ## 4. Discipline
32
32
  - **TCR**: Mandatory.
33
33
  - **Distribution**: `bin` in `package.json`, test `npm i -g`.
34
- - **Workspace**: `BACKLOG.md` + `docs/features/`.
34
+ - **Workspace**: `.roll/backlog.md` + `.roll/features/`.
35
35
 
36
36
  ## 5. Where to Look
37
- - **Domain model**: `docs/domain/context-map.md` — Bounded Contexts and relationships
38
- - **Story details**: `docs/features/` — AC, implementation specs, dependencies
39
- - **Design decisions**: `docs/domain/` — DDD models, architecture records
37
+ - **Domain model**: `.roll/domain/context-map.md` — Bounded Contexts and relationships
38
+ - **Story details**: `.roll/features/` — AC, implementation specs, dependencies
39
+ - **Design decisions**: `.roll/domain/` — DDD models, architecture records
@@ -28,9 +28,9 @@ src/
28
28
  ## 4. Discipline
29
29
  - **TCR**: Mandatory.
30
30
  - **Testing**: Unit (hooks/logic) >80%, E2E (Playwright).
31
- - **Workspace**: `BACKLOG.md` + `docs/features/`.
31
+ - **Workspace**: `.roll/backlog.md` + `.roll/features/`.
32
32
 
33
33
  ## 5. Where to Look
34
- - **Domain model**: `docs/domain/context-map.md` — Bounded Contexts and relationships
35
- - **Story details**: `docs/features/` — AC, implementation specs, dependencies
36
- - **Design decisions**: `docs/domain/` — DDD models, architecture records
34
+ - **Domain model**: `.roll/domain/context-map.md` — Bounded Contexts and relationships
35
+ - **Story details**: `.roll/features/` — AC, implementation specs, dependencies
36
+ - **Design decisions**: `.roll/domain/` — DDD models, architecture records
@@ -31,9 +31,9 @@ api/
31
31
  ## 4. Discipline
32
32
  - **TCR**: Mandatory.
33
33
  - **Testing**: Unit >80%, E2E for critical flows.
34
- - **Workspace**: `BACKLOG.md` + `docs/features/`.
34
+ - **Workspace**: `.roll/backlog.md` + `.roll/features/`.
35
35
 
36
36
  ## 5. Where to Look
37
- - **Domain model**: `docs/domain/context-map.md` — Bounded Contexts and relationships
38
- - **Story details**: `docs/features/` — AC, implementation specs, dependencies
39
- - **Design decisions**: `docs/domain/` — DDD models, architecture records
37
+ - **Domain model**: `.roll/domain/context-map.md` — Bounded Contexts and relationships
38
+ - **Story details**: `.roll/features/` — AC, implementation specs, dependencies
39
+ - **Design decisions**: `.roll/domain/` — DDD models, architecture records
@@ -2,7 +2,7 @@
2
2
  """
3
3
  roll-backlog — v2 terminal view for `roll backlog`.
4
4
 
5
- Parses BACKLOG.md and renders items grouped by type:
5
+ Parses .roll/backlog.md and renders items grouped by type:
6
6
  Bug Fixes (red) · User Stories (blue) · Refactors (amber) · Ideas (dim)
7
7
 
8
8
  In-progress items get a ⏵ purple marker.
@@ -215,7 +215,7 @@ def main() -> None:
215
215
  no_color = "--no-color" in args or not sys.stdout.isatty() or os.getenv("NO_COLOR")
216
216
  rr.USE_COLOR = not no_color
217
217
 
218
- backlog = "BACKLOG.md"
218
+ backlog = ".roll/backlog.md"
219
219
  if not demo and not os.path.isfile(backlog):
220
220
  print(f"Error: {backlog} not found — run 'roll init' first", file=sys.stderr)
221
221
  sys.exit(1)
@@ -244,7 +244,7 @@ def _write_demo(path: str) -> None:
244
244
  ### Feature: autonomous-evolution
245
245
  | Story | Description | Status |
246
246
  |-------|-------------|--------|
247
- | [US-AUTO-042](docs/features/autonomous-evolution.md) | Loop cost telemetry — write model and token data per cycle | 📋 Todo |
247
+ | [US-AUTO-042](.roll/features/autonomous-evolution.md) | Loop cost telemetry — write model and token data per cycle | 📋 Todo |
248
248
 
249
249
  ## ♻️ Refactor
250
250
  | ID | Description | Status |
package/lib/roll-brief.py CHANGED
@@ -2,7 +2,7 @@
2
2
  """
3
3
  roll-brief — v2 terminal view for `roll brief`.
4
4
 
5
- Parses the latest docs/briefs/<date>.md and renders it as three sections:
5
+ Parses the latest .roll/briefs/<date>.md and renders it as three sections:
6
6
  SUMMARY — eyebrow + shipped/watch/decide counts
7
7
  HIGHLIGHTS — completed story list
8
8
  DECIDE — action-required items with D1/D2/... numbering
@@ -269,7 +269,7 @@ def main() -> None:
269
269
  no_color = "--no-color" in args or not sys.stdout.isatty() or os.getenv("NO_COLOR")
270
270
  rr.USE_COLOR = not no_color
271
271
 
272
- briefs_dir = "docs/briefs"
272
+ briefs_dir = ".roll/briefs"
273
273
  briefs = sorted(
274
274
  f for f in os.listdir(briefs_dir) if f.endswith(".md")
275
275
  ) if os.path.isdir(briefs_dir) else []
package/lib/roll-help.py CHANGED
@@ -45,7 +45,7 @@ AUTONOMY = [
45
45
  ]
46
46
 
47
47
  PROJECT = [
48
- ("init", "", "create AGENTS.md + BACKLOG.md + docs/", "初始化项目工作流文件", False),
48
+ ("init", "", "create AGENTS.md + .roll/backlog.md + docs/", "初始化项目工作流文件", False),
49
49
  ("status", "", "show current state and drift", "显示当前状态和漂移项", False),
50
50
  ("agent", "[use <name>]", "per-project agent selection", "切换项目 agent", False),
51
51
  ("ci", "[--wait]", "show or wait for current commit's CI status", "查看 / 等待 CI 状态", False),
@@ -132,9 +132,9 @@ def render(version: str) -> None:
132
132
  for cmd, zh in EXAMPLES:
133
133
  print(" " + c("blue", cmd) + " " + c("dim", zh))
134
134
  print()
135
- print(" " + c("dim", "docs: ") + c("blue", "github.com/seanyao/Roll") +
135
+ print(" " + c("dim", "docs: ") + c("blue", "github.com/seanyao/roll") +
136
136
  c("muted", " · ") +
137
- c("dim", "issues: ") + c("blue", "github.com/seanyao/Roll/issues"))
137
+ c("dim", "issues: ") + c("blue", "github.com/seanyao/roll/issues"))
138
138
  print()
139
139
 
140
140
  # ════════════════════════════════════════════════════════════════════════════
package/lib/roll-home.py CHANGED
@@ -116,6 +116,26 @@ def _launchd_svc_state(service: str, slug: str) -> str:
116
116
  except Exception:
117
117
  return "installed-off"
118
118
 
119
+ def _read_plist_schedule(service: str, slug: str) -> Optional[Dict[str, int]]:
120
+ """FIX-063: read actual Minute/Hour from launchd plist (truth source).
121
+ Returns {'minute': N, 'hour': N|None} or None if plist missing.
122
+ Dashboard must reflect what launchd actually fires, not a hardcoded default.
123
+ """
124
+ label = f"com.roll.{service}.{slug}"
125
+ plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"{label}.plist"
126
+ if not plist.exists():
127
+ return None
128
+ try:
129
+ text = plist.read_text(errors="ignore")
130
+ except Exception:
131
+ return None
132
+ # Parse <key>Minute</key><integer>N</integer> (and Hour)
133
+ m = re.search(r"<key>Minute</key>\s*<integer>(\d+)</integer>", text)
134
+ h = re.search(r"<key>Hour</key>\s*<integer>(\d+)</integer>", text)
135
+ if not m:
136
+ return None
137
+ return {"minute": int(m.group(1)), "hour": int(h.group(1)) if h else None}
138
+
119
139
  def _dream_last_hours() -> Optional[int]:
120
140
  log = _shared_root() / "dream" / "log.md"
121
141
  if not log.exists():
@@ -145,7 +165,7 @@ def _peer_last() -> Optional[Tuple[str, int]]:
145
165
 
146
166
  def _backlog_counts() -> Tuple[int, int, int, str, str, str, int]:
147
167
  """(ideas, todo, in_progress, id, title, link, refactor_pending)."""
148
- bl = Path("BACKLOG.md")
168
+ bl = Path(".roll/backlog.md")
149
169
  if not bl.exists():
150
170
  return (0, 0, 0, "", "", "", 0)
151
171
  ideas = todo = in_prog = refactors = 0
@@ -167,7 +187,7 @@ def _backlog_counts() -> Tuple[int, int, int, str, str, str, int]:
167
187
  parts = [p.strip() for p in line.split("|")]
168
188
  if len(parts) >= 4:
169
189
  ip_title = parts[2][:60]
170
- m2 = re.search(r"docs/features/[^\)]+", line)
190
+ m2 = re.search(r".roll/features/[^\)]+", line)
171
191
  if m2:
172
192
  ip_link = m2.group(0)
173
193
  return (ideas, todo, in_prog, ip_id, ip_title, ip_link, refactors)
@@ -179,13 +199,13 @@ def _alert_count(slug: str) -> int:
179
199
  return sum(1 for l in af.read_text(errors="ignore").splitlines() if l.startswith("# ALERT"))
180
200
 
181
201
  def _proposal_count() -> int:
182
- p = Path("PROPOSALS.md")
202
+ p = Path(".roll/proposals.md")
183
203
  if not p.exists():
184
204
  return 0
185
205
  return sum(1 for l in p.read_text(errors="ignore").splitlines() if l.startswith("## PROPOSAL"))
186
206
 
187
207
  def _release_ready() -> bool:
188
- briefs_dir = Path("docs/briefs")
208
+ briefs_dir = Path(".roll/briefs")
189
209
  if not briefs_dir.exists():
190
210
  return False
191
211
  try:
@@ -417,7 +437,7 @@ def render(d: Dict[str, Any]) -> None:
417
437
  c("dim", " run: ") + c("blue", "roll alert"))
418
438
  if proposals:
419
439
  print(" " + c("amber", "▤") + " " + c("amber", f"{proposals} PROPOSAL", bold=True) +
420
- c("dim", " see: ") + c("blue", "PROPOSALS.md"))
440
+ c("dim", " see: ") + c("blue", ".roll/proposals.md"))
421
441
  if rr:
422
442
  print(" " + c("green", "✓") + " " + c("green", "Release ready", bold=True) +
423
443
  c("dim", " run: ") + c("blue", "roll release"))
@@ -469,12 +489,12 @@ def main() -> None:
469
489
  timestamp = datetime.now().strftime("%H:%M"),
470
490
  state = state,
471
491
  loop_state = _launchd_svc_state("loop", slug),
472
- loop_minute = _ci("loop_minute", 38),
492
+ loop_minute = (_read_plist_schedule("loop", slug) or {}).get("minute") or _ci("loop_minute", 38),
473
493
  loop_active_start = _ci("loop_active_start", 10),
474
494
  loop_active_end = _ci("loop_active_end", 18),
475
495
  dream_state = _launchd_svc_state("dream", slug),
476
- dream_hour = _ci("loop_dream_hour", 3),
477
- dream_minute = _ci("loop_dream_minute", 12),
496
+ dream_hour = (_read_plist_schedule("dream", slug) or {}).get("hour") or _ci("loop_dream_hour", 3),
497
+ dream_minute = (_read_plist_schedule("dream", slug) or {}).get("minute") or _ci("loop_dream_minute", 12),
478
498
  dream_last_hours = _dream_last_hours(),
479
499
  refactor_pending = refactor_pending,
480
500
  peer_last = _peer_last(),
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python3
2
+ """roll-init — v2 terminal view for `roll init` (US-VIEW-008)."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+
9
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
10
+ if _LIB_DIR not in sys.path:
11
+ sys.path.insert(0, _LIB_DIR)
12
+ import roll_render
13
+ from roll_render import c, row, COLS
14
+
15
+ # ════════════════════════════════════════════════════════════════════════════
16
+ # Demo data — 6 steps mirror cmd_init's actual phases.
17
+ # AC text: detect → AGENTS.md → BACKLOG.md → docs/features/ → merge CLAUDE.md → link skills
18
+ # Each entry: (label, [(op, filename)]) where op ∈ {"+", "~"}.
19
+ # ════════════════════════════════════════════════════════════════════════════
20
+
21
+ _DEMO_STEPS = [
22
+ ("Detect project type", []),
23
+ ("Create AGENTS.md", [("+", "AGENTS.md")]),
24
+ ("Create .roll/backlog.md", [("+", ".roll/backlog.md")]),
25
+ ("Create .roll/features/", [("+", ".roll/features/")]),
26
+ ("Merge existing CLAUDE.md", [("~", "CLAUDE.md")]),
27
+ ("Link skills to AI clients", [("+", "~/.claude/skills/roll-build"),
28
+ ("+", "~/.claude/skills/roll-fix")]),
29
+ ]
30
+
31
+ _NEXT_STEPS = [
32
+ ("Edit .roll/backlog.md", "open the backlog and add your first US"),
33
+ ("Run roll loop now", "execute one cycle manually to test the flow"),
34
+ ("Enable loop scheduling", "roll loop on — let it run hourly"),
35
+ ]
36
+
37
+
38
+ # ════════════════════════════════════════════════════════════════════════════
39
+ # Render
40
+ # ════════════════════════════════════════════════════════════════════════════
41
+
42
+ def _divider(char: str = "─") -> None:
43
+ print(c("dim", char * min(COLS, 80)))
44
+
45
+
46
+ def _op_marker(op: str) -> str:
47
+ if op == "+":
48
+ return c("green", "+", bold=True)
49
+ if op == "~":
50
+ return c("amber", "~", bold=True)
51
+ return c("dim", op)
52
+
53
+
54
+ def render_demo(project_path: str = "~/myproject") -> None:
55
+ left = " " + c("blue", "INIT", bold=True) + c("dim", " · ") + c("dim", "项目初始化")
56
+ right = c("dim", project_path) + " "
57
+ print(row(left, right))
58
+ _divider()
59
+ print()
60
+
61
+ for i, (label, files) in enumerate(_DEMO_STEPS, start=1):
62
+ num = c("dim", f" {i}.")
63
+ icon = c("green", "✓")
64
+ print(f"{num} {icon} {label}")
65
+ for op, fname in files:
66
+ mark = _op_marker(op)
67
+ color = "green" if op == "+" else "amber"
68
+ print(" " + mark + " " + c(color, fname))
69
+
70
+ print()
71
+ _divider()
72
+ print(" " + c("green", "✓") + " " + c("green", "Project ready", bold=True))
73
+ print()
74
+ print(" " + c("pink", "NEXT", bold=True) + c("dim", " · 下一步"))
75
+ for i, (label, hint) in enumerate(_NEXT_STEPS, start=1):
76
+ num = c("dim", f" {i}.")
77
+ print(f"{num} {c('fg', label, bold=True)}")
78
+ print(" " + c("dim", hint))
79
+ _divider("═")
80
+
81
+
82
+ # ════════════════════════════════════════════════════════════════════════════
83
+ # Entry point
84
+ # ════════════════════════════════════════════════════════════════════════════
85
+
86
+ def main() -> None:
87
+ ap = argparse.ArgumentParser(add_help=False)
88
+ ap.add_argument("--demo", action="store_true")
89
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
90
+ ap.add_argument("--en", action="store_true")
91
+ ap.add_argument("--zh", action="store_true")
92
+ args, _ = ap.parse_known_args()
93
+
94
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
95
+ roll_render.USE_COLOR = False
96
+
97
+ render_demo(project_path=os.getcwd())
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
@@ -6,7 +6,7 @@ Reads (all per-project, slug = <basename>-<md5_6chars> of project root):
6
6
  $ROLL_SHARED_ROOT/loop/events-<slug>.ndjson structured per-cycle events
7
7
  $ROLL_SHARED_ROOT/loop/cron-<slug>.log wall-clock dur + cost per cycle
8
8
  $ROLL_SHARED_ROOT/loop/state-<slug>.yaml idle | running | paused
9
- ./BACKLOG.md story id → description
9
+ ./.roll/backlog.md story id → description
10
10
 
11
11
  Writes (stdout):
12
12
  Static 100-col colored print, EN/ZH paired rows. Designed for a 5-10s glance,
@@ -137,8 +137,8 @@ def load_state(slug: str) -> Dict[str, str]:
137
137
  return out
138
138
 
139
139
  def load_backlog(project_root: Optional[Path] = None) -> Dict[str, str]:
140
- """Map story id → description from BACKLOG.md table rows."""
141
- path = (project_root or Path()) / "BACKLOG.md"
140
+ """Map story id → description from .roll/backlog.md table rows."""
141
+ path = (project_root or Path()) / ".roll/backlog.md"
142
142
  if not path.exists():
143
143
  return {}
144
144
  out: Dict[str, str] = {}
@@ -414,7 +414,7 @@ def repair_orphan_cycles_from_git(cycles: List[Dict[str, Any]], git_merges: Dict
414
414
  if cy.get("outcome") in ("running", "unknown"):
415
415
  cy["outcome"] = "done"
416
416
  if m["pr"] and not cy.get("pr"):
417
- cy["pr"] = f"https://github.com/seanyao/Roll/pull/{m['pr']}"
417
+ cy["pr"] = f"https://github.com/seanyao/roll/pull/{m['pr']}"
418
418
  # Fill stories when our existing sources didn't carry them. Filter
419
419
  # to ones that actually appear in BACKLOG so we don't pull in stray
420
420
  # tokens from the merge body (PR numbers, file paths, etc.).
@@ -688,7 +688,7 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
688
688
  sum(1 for c0 in day_cycles if c0["outcome"] == "fail"),
689
689
  now,
690
690
  in_progress=(day_key == today_key and is_partial))
691
- for cy in day_cycles:
691
+ for cy in reversed(day_cycles):
692
692
  cycle_row(cy, backlog)
693
693
  print()
694
694
 
@@ -701,11 +701,27 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
701
701
  c("muted", " ") +
702
702
  c("dim", "more ") + c("blue", "roll loop --days 7"))
703
703
 
704
+ def _read_plist_loop_minute() -> int:
705
+ """FIX-063: read actual loop Minute from launchd plist (truth source).
706
+ Falls back to 48 only when plist missing/unparseable.
707
+ """
708
+ import re as _re
709
+ slug = project_slug()
710
+ plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"com.roll.loop.{slug}.plist"
711
+ if not plist.exists():
712
+ return 48
713
+ try:
714
+ text = plist.read_text(errors="ignore")
715
+ except Exception:
716
+ return 48
717
+ m = _re.search(r"<key>Minute</key>\s*<integer>(\d+)</integer>", text)
718
+ return int(m.group(1)) if m else 48
719
+
720
+
704
721
  def _next_cron_hint(state: Dict[str, str], zh: bool = False) -> str:
705
- """Best-effort next-cron string. The real schedule lives in launchd/cron;
706
- we only have access to last_run here, so we approximate to the next :48."""
722
+ """Compute next cron fire time from the actual launchd plist Minute (FIX-063)."""
707
723
  now = datetime.now().astimezone()
708
- minute_target = 48 # bin/roll default; per-project may differ
724
+ minute_target = _read_plist_loop_minute()
709
725
  nxt = now.replace(minute=minute_target, second=0, microsecond=0)
710
726
  if nxt <= now:
711
727
  nxt += timedelta(hours=1)
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ US-ONBOARD-007: onboard-plan.yaml validator.
4
+
5
+ Validates that a plan file produced by $roll-onboard is structurally complete,
6
+ fresh (generated_at within 24h), and version-compatible with the consuming
7
+ bin/roll. Called by `roll init --apply` before any side effects.
8
+
9
+ Usage:
10
+ python3 roll-plan-validate.py <path-to-plan.yaml>
11
+
12
+ Exit codes:
13
+ 0 plan is valid
14
+ 1 schema / required field error
15
+ 2 plan is stale (generated_at > 24h)
16
+ 3 plan version not supported
17
+ 4 plan file unreadable / not YAML
18
+
19
+ Error messages are written to stderr in both English and Chinese.
20
+
21
+ Schema (v1):
22
+ version: 1
23
+ generated_at: ISO 8601 timestamp (UTC or with tz offset)
24
+ project_understanding:
25
+ type: backend-service | frontend-only | fullstack | cli
26
+ description: str
27
+ domains: [str]
28
+ key_modules: [str]
29
+ scope:
30
+ approved: [str] # subset of {backlog, features, domain, briefs}
31
+ declined: [str]
32
+ include_existing: [str]
33
+ privacy:
34
+ gitignore_dot_roll: bool
35
+ sync_targets: [str]
36
+ enable_loop: bool
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import sys
42
+ from datetime import datetime, timezone, timedelta
43
+ from pathlib import Path
44
+
45
+ try:
46
+ import yaml # PyYAML
47
+ except ImportError:
48
+ print(
49
+ "[plan-validate] PyYAML not installed. Install with: pip install pyyaml\n"
50
+ "[plan-validate] PyYAML 未安装,请运行: pip install pyyaml",
51
+ file=sys.stderr,
52
+ )
53
+ sys.exit(4)
54
+
55
+
56
+ SUPPORTED_VERSIONS = {1}
57
+ MAX_AGE_HOURS = 24
58
+ VALID_PROJECT_TYPES = {"backend-service", "frontend-only", "fullstack", "cli"}
59
+ VALID_SCOPE_ITEMS = {"backlog", "features", "domain", "briefs"}
60
+
61
+
62
+ def err(msg_en: str, msg_zh: str = "") -> None:
63
+ """Print bilingual error to stderr."""
64
+ print(f"[plan-validate] {msg_en}", file=sys.stderr)
65
+ if msg_zh:
66
+ print(f"[plan-validate] {msg_zh}", file=sys.stderr)
67
+
68
+
69
+ def validate_required_top_level(plan: dict) -> list[str]:
70
+ """Return list of missing/invalid top-level fields."""
71
+ errors = []
72
+ required = ["version", "generated_at", "project_understanding", "scope", "privacy"]
73
+ for key in required:
74
+ if key not in plan:
75
+ errors.append(f"missing required field: {key}")
76
+ return errors
77
+
78
+
79
+ def validate_version(plan: dict) -> list[str]:
80
+ v = plan.get("version")
81
+ if not isinstance(v, int):
82
+ return [f"version must be int, got {type(v).__name__}"]
83
+ if v not in SUPPORTED_VERSIONS:
84
+ return [f"version {v} not supported (supported: {sorted(SUPPORTED_VERSIONS)})"]
85
+ return []
86
+
87
+
88
+ def validate_freshness(plan: dict) -> tuple[list[str], bool]:
89
+ """Returns (errors, is_stale). Stale uses exit code 2."""
90
+ raw = plan.get("generated_at")
91
+ if not raw:
92
+ return ["generated_at missing"], False
93
+ try:
94
+ if isinstance(raw, datetime):
95
+ ts = raw
96
+ else:
97
+ ts = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
98
+ except (ValueError, TypeError) as e:
99
+ return [f"generated_at not a valid ISO 8601 timestamp: {e}"], False
100
+ if ts.tzinfo is None:
101
+ ts = ts.replace(tzinfo=timezone.utc)
102
+ now = datetime.now(timezone.utc)
103
+ age = now - ts
104
+ if age > timedelta(hours=MAX_AGE_HOURS):
105
+ return [
106
+ f"plan is stale: generated {age.total_seconds() / 3600:.1f}h ago "
107
+ f"(max allowed: {MAX_AGE_HOURS}h)"
108
+ ], True
109
+ if age < timedelta(seconds=-300):
110
+ # Plan in future >5 min — clock skew or fabricated timestamp
111
+ return [
112
+ f"plan timestamp is in the future (clock skew?): generated_at={ts.isoformat()}"
113
+ ], False
114
+ return [], False
115
+
116
+
117
+ def validate_project_understanding(plan: dict) -> list[str]:
118
+ errors = []
119
+ pu = plan.get("project_understanding")
120
+ if not isinstance(pu, dict):
121
+ return ["project_understanding must be a mapping"]
122
+ t = pu.get("type")
123
+ if t is None:
124
+ errors.append("project_understanding.type missing")
125
+ elif t not in VALID_PROJECT_TYPES:
126
+ errors.append(
127
+ f"project_understanding.type='{t}' not in {sorted(VALID_PROJECT_TYPES)}"
128
+ )
129
+ if "description" not in pu:
130
+ errors.append("project_understanding.description missing")
131
+ return errors
132
+
133
+
134
+ def validate_scope(plan: dict) -> list[str]:
135
+ errors = []
136
+ scope = plan.get("scope")
137
+ if not isinstance(scope, dict):
138
+ return ["scope must be a mapping"]
139
+ approved = scope.get("approved", [])
140
+ if not isinstance(approved, list):
141
+ errors.append("scope.approved must be a list")
142
+ else:
143
+ for item in approved:
144
+ if item not in VALID_SCOPE_ITEMS:
145
+ errors.append(
146
+ f"scope.approved contains unknown item '{item}' "
147
+ f"(valid: {sorted(VALID_SCOPE_ITEMS)})"
148
+ )
149
+ return errors
150
+
151
+
152
+ def validate_privacy(plan: dict) -> list[str]:
153
+ errors = []
154
+ privacy = plan.get("privacy")
155
+ if not isinstance(privacy, dict):
156
+ return ["privacy must be a mapping"]
157
+ g = privacy.get("gitignore_dot_roll")
158
+ if not isinstance(g, bool):
159
+ errors.append(
160
+ f"privacy.gitignore_dot_roll must be bool, got {type(g).__name__}"
161
+ )
162
+ return errors
163
+
164
+
165
+ def main(argv: list[str]) -> int:
166
+ if len(argv) < 2:
167
+ err("usage: roll-plan-validate.py <plan.yaml>", "用法: roll-plan-validate.py <plan.yaml>")
168
+ return 4
169
+
170
+ path = Path(argv[1])
171
+ if not path.is_file():
172
+ err(f"plan file not found: {path}", f"未找到 plan 文件:{path}")
173
+ return 4
174
+
175
+ try:
176
+ with path.open("r", encoding="utf-8") as f:
177
+ plan = yaml.safe_load(f)
178
+ except (yaml.YAMLError, OSError) as e:
179
+ err(f"failed to parse plan as YAML: {e}", "无法解析 plan YAML")
180
+ return 4
181
+
182
+ if not isinstance(plan, dict):
183
+ err("plan must be a top-level mapping", "plan 顶层必须是 mapping")
184
+ return 1
185
+
186
+ schema_errors: list[str] = []
187
+ schema_errors += validate_required_top_level(plan)
188
+ schema_errors += validate_version(plan)
189
+ schema_errors += validate_project_understanding(plan)
190
+ schema_errors += validate_scope(plan)
191
+ schema_errors += validate_privacy(plan)
192
+
193
+ freshness_errors, is_stale = validate_freshness(plan)
194
+
195
+ # Version errors take precedence — if version is wrong, the rest of the
196
+ # validation may be unreliable.
197
+ version_errors = [e for e in schema_errors if e.startswith("version")]
198
+ if version_errors:
199
+ for e in version_errors:
200
+ err(e)
201
+ return 3
202
+
203
+ if is_stale:
204
+ for e in freshness_errors:
205
+ err(e, "plan 已过期,请重新运行 $roll-onboard 生成新 plan")
206
+ return 2
207
+
208
+ all_errors = [e for e in schema_errors if not e.startswith("version")] + [
209
+ e for e in freshness_errors if not is_stale
210
+ ]
211
+ if all_errors:
212
+ for e in all_errors:
213
+ err(e)
214
+ return 1
215
+
216
+ # Valid — silent success (bash caller treats exit 0 as OK).
217
+ return 0
218
+
219
+
220
+ if __name__ == "__main__":
221
+ sys.exit(main(sys.argv))