@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.
- package/CHANGELOG.md +31 -0
- package/README.md +26 -14
- package/bin/roll +683 -81
- package/conventions/global/AGENTS.md +6 -6
- package/conventions/templates/backend-service/AGENTS.md +4 -4
- package/conventions/templates/cli/AGENTS.md +4 -4
- package/conventions/templates/frontend-only/AGENTS.md +4 -4
- package/conventions/templates/fullstack/AGENTS.md +4 -4
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/roll-backlog.py +3 -3
- package/lib/roll-brief.py +2 -2
- package/lib/roll-help.py +3 -3
- package/lib/roll-home.py +28 -8
- package/lib/roll-init.py +101 -0
- package/lib/roll-loop-status.py +24 -8
- package/lib/roll-plan-validate.py +221 -0
- package/lib/roll-status.py +4 -4
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +13 -13
- package/skills/roll-.dream/SKILL.md +21 -21
- package/skills/roll-brief/SKILL.md +12 -12
- package/skills/roll-build/SKILL.md +15 -15
- package/skills/roll-debug/SKILL.md +1 -1
- package/skills/roll-design/SKILL.md +46 -46
- package/skills/roll-doc/SKILL.md +10 -10
- package/skills/roll-fix/SKILL.md +7 -7
- package/skills/roll-idea/SKILL.md +4 -4
- package/skills/roll-loop/SKILL.md +11 -11
- package/skills/roll-onboard/SKILL.md +143 -0
- package/skills/roll-peer/SKILL.md +1 -1
- package/skills/roll-propose/SKILL.md +13 -13
- package/skills/roll-sentinel/SKILL.md +1 -1
- package/template/AGENTS.md +2 -2
- /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
|
@@ -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**:
|
|
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
|
|
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**:
|
|
105
|
-
- **Story details**:
|
|
106
|
-
- **Design decisions**:
|
|
107
|
-
- When
|
|
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**:
|
|
32
|
+
- **Workspace**: `.roll/backlog.md` + `.roll/features/`.
|
|
33
33
|
|
|
34
34
|
## 5. Where to Look
|
|
35
|
-
- **Domain model**:
|
|
36
|
-
- **Story details**:
|
|
37
|
-
- **Design decisions**:
|
|
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**:
|
|
34
|
+
- **Workspace**: `.roll/backlog.md` + `.roll/features/`.
|
|
35
35
|
|
|
36
36
|
## 5. Where to Look
|
|
37
|
-
- **Domain model**:
|
|
38
|
-
- **Story details**:
|
|
39
|
-
- **Design decisions**:
|
|
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**:
|
|
31
|
+
- **Workspace**: `.roll/backlog.md` + `.roll/features/`.
|
|
32
32
|
|
|
33
33
|
## 5. Where to Look
|
|
34
|
-
- **Domain model**:
|
|
35
|
-
- **Story details**:
|
|
36
|
-
- **Design decisions**:
|
|
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**:
|
|
34
|
+
- **Workspace**: `.roll/backlog.md` + `.roll/features/`.
|
|
35
35
|
|
|
36
36
|
## 5. Where to Look
|
|
37
|
-
- **Domain model**:
|
|
38
|
-
- **Story details**:
|
|
39
|
-
- **Design decisions**:
|
|
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
|
|
Binary file
|
package/lib/roll-backlog.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""
|
|
3
3
|
roll-backlog — v2 terminal view for `roll backlog`.
|
|
4
4
|
|
|
5
|
-
Parses
|
|
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 = "
|
|
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](
|
|
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
|
|
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 = "
|
|
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 +
|
|
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/
|
|
135
|
+
print(" " + c("dim", "docs: ") + c("blue", "github.com/seanyao/roll") +
|
|
136
136
|
c("muted", " · ") +
|
|
137
|
-
c("dim", "issues: ") + c("blue", "github.com/seanyao/
|
|
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("
|
|
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"
|
|
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("
|
|
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("
|
|
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", "
|
|
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(),
|
package/lib/roll-init.py
ADDED
|
@@ -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()
|
package/lib/roll-loop-status.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
141
|
-
path = (project_root or Path()) / "
|
|
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/
|
|
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
|
-
"""
|
|
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 =
|
|
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))
|