@seanyao/roll 3.625.2 → 3.627.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +40 -4
- package/dist/roll.mjs +7408 -4270
- package/lib/README.md +2 -2
- package/lib/roll-plan-validate.py +147 -2
- package/package.json +1 -1
- package/skills/README.md +1 -1
- package/skills/roll-onboard/SKILL.md +200 -150
package/lib/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Python scripts and shell libraries that `bin/roll` delegates to for rendering-he
|
|
|
17
17
|
| `roll-backlog.py` | Backlog read/write helpers |
|
|
18
18
|
| `roll-peer.py` | Peer review coordination helpers |
|
|
19
19
|
| `roll-help.py` | Renders `roll --help` output |
|
|
20
|
-
| `roll-plan-validate.py` | Validates plan
|
|
20
|
+
| `roll-plan-validate.py` | Validates `.roll/onboard-plan.yaml` and paired `.roll/init-diagnosis.yaml` before `roll init --apply` mutates a project |
|
|
21
21
|
| `model_prices.py` | List-price table for AI model API pricing (per MTok, native currency) |
|
|
22
22
|
| `prices_fetcher.py` | Fetches fresh price snapshots from vendor APIs |
|
|
23
23
|
| `roll_render.py` | Shared rendering utilities (tables, color, formatting) |
|
|
@@ -38,4 +38,4 @@ Python scripts and shell libraries that `bin/roll` delegates to for rendering-he
|
|
|
38
38
|
## Dependencies
|
|
39
39
|
|
|
40
40
|
Imported by `bin/roll` via subprocess calls (`python3 lib/<script>.py`).
|
|
41
|
-
|
|
41
|
+
Most helpers are standard-library only; YAML-facing helpers such as `roll-plan-validate.py` and `roll-onboard-render.py` require PyYAML.
|
|
@@ -21,6 +21,15 @@ Error messages are written to stderr in both English and Chinese.
|
|
|
21
21
|
Schema (v1):
|
|
22
22
|
version: 1
|
|
23
23
|
generated_at: ISO 8601 timestamp (UTC or with tz offset)
|
|
24
|
+
factsHash: sha256:<64 lowercase hex chars>
|
|
25
|
+
file_operations:
|
|
26
|
+
- path: .roll/init-diagnosis.yaml | .roll/onboard-plan.yaml
|
|
27
|
+
operation: write
|
|
28
|
+
idempotent: true
|
|
29
|
+
merge_intents:
|
|
30
|
+
- target: str
|
|
31
|
+
owner: roll-init-apply
|
|
32
|
+
strategy: str
|
|
24
33
|
project_understanding:
|
|
25
34
|
type: backend-service | frontend-only | fullstack | cli
|
|
26
35
|
description: str
|
|
@@ -71,8 +80,9 @@ carries `evidence: detected` (not a third enum value).
|
|
|
71
80
|
from __future__ import annotations
|
|
72
81
|
|
|
73
82
|
import sys
|
|
83
|
+
import re
|
|
74
84
|
from datetime import datetime, timezone, timedelta
|
|
75
|
-
from pathlib import Path
|
|
85
|
+
from pathlib import Path, PurePosixPath
|
|
76
86
|
|
|
77
87
|
try:
|
|
78
88
|
import yaml # PyYAML
|
|
@@ -89,6 +99,21 @@ SUPPORTED_VERSIONS = {1}
|
|
|
89
99
|
MAX_AGE_HOURS = 24
|
|
90
100
|
VALID_PROJECT_TYPES = {"backend-service", "frontend-only", "fullstack", "cli"}
|
|
91
101
|
VALID_SCOPE_ITEMS = {"backlog", "features", "domain", "briefs"}
|
|
102
|
+
VALID_FACTS_HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
103
|
+
AGENT_WRITABLE_OUTPUTS = {".roll/init-diagnosis.yaml", ".roll/onboard-plan.yaml"}
|
|
104
|
+
SHELL_COMMAND_KEYS = {"cmd", "command", "commands", "exec", "run", "script", "shell", "shell_commands"}
|
|
105
|
+
VALID_MERGE_INTENT_TARGETS = {
|
|
106
|
+
"agent_routes",
|
|
107
|
+
"backlog",
|
|
108
|
+
"briefs",
|
|
109
|
+
"claude_conventions",
|
|
110
|
+
"domain",
|
|
111
|
+
"features",
|
|
112
|
+
"gitignore",
|
|
113
|
+
"phase2_markdown",
|
|
114
|
+
"roll_conventions",
|
|
115
|
+
"sync_targets",
|
|
116
|
+
}
|
|
92
117
|
|
|
93
118
|
# US-ONBOARD-016: anti-hallucination evidence tags. Every test_assessment claim
|
|
94
119
|
# must carry one of these; risks[].evidence (when present) uses the same enum.
|
|
@@ -109,7 +134,16 @@ def err(msg_en: str, msg_zh: str = "") -> None:
|
|
|
109
134
|
def validate_required_top_level(plan: dict) -> list[str]:
|
|
110
135
|
"""Return list of missing/invalid top-level fields."""
|
|
111
136
|
errors = []
|
|
112
|
-
required = [
|
|
137
|
+
required = [
|
|
138
|
+
"version",
|
|
139
|
+
"generated_at",
|
|
140
|
+
"factsHash",
|
|
141
|
+
"file_operations",
|
|
142
|
+
"merge_intents",
|
|
143
|
+
"project_understanding",
|
|
144
|
+
"scope",
|
|
145
|
+
"privacy",
|
|
146
|
+
]
|
|
113
147
|
for key in required:
|
|
114
148
|
if key not in plan:
|
|
115
149
|
errors.append(f"missing required field: {key}")
|
|
@@ -125,6 +159,113 @@ def validate_version(plan: dict) -> list[str]:
|
|
|
125
159
|
return []
|
|
126
160
|
|
|
127
161
|
|
|
162
|
+
def validate_facts_hash_value(value, where: str) -> list[str]:
|
|
163
|
+
if not isinstance(value, str):
|
|
164
|
+
return [f"{where} must be a string"]
|
|
165
|
+
if not VALID_FACTS_HASH_RE.match(value):
|
|
166
|
+
return [f"{where} must match sha256:<64 lowercase hex chars>"]
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def validate_shell_command_keys(value, where: str = "$") -> list[str]:
|
|
171
|
+
"""Reject arbitrary shell-command fields anywhere in the agent-authored plan."""
|
|
172
|
+
errors: list[str] = []
|
|
173
|
+
if isinstance(value, list):
|
|
174
|
+
for i, item in enumerate(value):
|
|
175
|
+
errors += validate_shell_command_keys(item, f"{where}[{i}]")
|
|
176
|
+
return errors
|
|
177
|
+
if not isinstance(value, dict):
|
|
178
|
+
return errors
|
|
179
|
+
for key, item in value.items():
|
|
180
|
+
child = f"{where}.{key}"
|
|
181
|
+
if key in SHELL_COMMAND_KEYS:
|
|
182
|
+
errors.append(f"{child} is not allowed in onboard artifacts")
|
|
183
|
+
errors += validate_shell_command_keys(item, child)
|
|
184
|
+
return errors
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def validate_file_operations(plan: dict) -> list[str]:
|
|
188
|
+
errors: list[str] = []
|
|
189
|
+
operations = plan.get("file_operations")
|
|
190
|
+
if not isinstance(operations, list):
|
|
191
|
+
return ["file_operations must be a list"]
|
|
192
|
+
seen: set[str] = set()
|
|
193
|
+
for i, op in enumerate(operations):
|
|
194
|
+
where = f"file_operations[{i}]"
|
|
195
|
+
if not isinstance(op, dict):
|
|
196
|
+
errors.append(f"{where} must be a mapping")
|
|
197
|
+
continue
|
|
198
|
+
path = op.get("path")
|
|
199
|
+
if not isinstance(path, str):
|
|
200
|
+
errors.append(f"{where}.path must be a string")
|
|
201
|
+
else:
|
|
202
|
+
parts = PurePosixPath(path).parts
|
|
203
|
+
normalized = PurePosixPath(path).as_posix()
|
|
204
|
+
if path.startswith("/") or "\\" in path or ".." in parts or normalized != path:
|
|
205
|
+
errors.append(f"{where}.path must be a normalized relative project path without traversal")
|
|
206
|
+
if path not in AGENT_WRITABLE_OUTPUTS:
|
|
207
|
+
errors.append(f"{where}.path '{path}' is outside the agent writable outputs")
|
|
208
|
+
elif path in seen:
|
|
209
|
+
errors.append(f"{where}.path '{path}' must not be duplicated")
|
|
210
|
+
else:
|
|
211
|
+
seen.add(path)
|
|
212
|
+
if op.get("operation") != "write":
|
|
213
|
+
errors.append(f"{where}.operation must be write")
|
|
214
|
+
if op.get("idempotent") is not True:
|
|
215
|
+
errors.append(f"{where}.idempotent must be true")
|
|
216
|
+
for expected in sorted(AGENT_WRITABLE_OUTPUTS):
|
|
217
|
+
if expected not in seen:
|
|
218
|
+
errors.append(f"file_operations must include {expected}")
|
|
219
|
+
return errors
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def validate_merge_intents(plan: dict) -> list[str]:
|
|
223
|
+
errors: list[str] = []
|
|
224
|
+
intents = plan.get("merge_intents")
|
|
225
|
+
if not isinstance(intents, list):
|
|
226
|
+
return ["merge_intents must be a list"]
|
|
227
|
+
for i, intent in enumerate(intents):
|
|
228
|
+
where = f"merge_intents[{i}]"
|
|
229
|
+
if not isinstance(intent, dict):
|
|
230
|
+
errors.append(f"{where} must be a mapping")
|
|
231
|
+
continue
|
|
232
|
+
if isinstance(intent.get("path"), str):
|
|
233
|
+
errors.append(f"{where} must describe a target, not a file path")
|
|
234
|
+
if intent.get("owner") != "roll-init-apply":
|
|
235
|
+
errors.append(f"{where}.owner must be roll-init-apply")
|
|
236
|
+
target = intent.get("target")
|
|
237
|
+
if not isinstance(target, str) or target not in VALID_MERGE_INTENT_TARGETS:
|
|
238
|
+
errors.append(
|
|
239
|
+
f"{where}.target must be one of {sorted(VALID_MERGE_INTENT_TARGETS)}"
|
|
240
|
+
)
|
|
241
|
+
strategy = intent.get("strategy")
|
|
242
|
+
if not isinstance(strategy, str) or strategy.strip() == "":
|
|
243
|
+
errors.append(f"{where}.strategy must be a non-empty string")
|
|
244
|
+
return errors
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def validate_diagnosis_pair(plan: dict, plan_path: Path) -> list[str]:
|
|
248
|
+
errors = validate_facts_hash_value(plan.get("factsHash"), "factsHash")
|
|
249
|
+
diagnosis_path = plan_path.parent / "init-diagnosis.yaml"
|
|
250
|
+
if not diagnosis_path.is_file():
|
|
251
|
+
return errors + [
|
|
252
|
+
"missing required paired artifact: .roll/init-diagnosis.yaml"
|
|
253
|
+
]
|
|
254
|
+
try:
|
|
255
|
+
with diagnosis_path.open("r", encoding="utf-8") as f:
|
|
256
|
+
diagnosis = yaml.safe_load(f)
|
|
257
|
+
except (yaml.YAMLError, OSError) as e:
|
|
258
|
+
return errors + [f"failed to parse paired .roll/init-diagnosis.yaml: {e}"]
|
|
259
|
+
if not isinstance(diagnosis, dict):
|
|
260
|
+
return errors + [".roll/init-diagnosis.yaml must be a top-level mapping"]
|
|
261
|
+
errors += validate_facts_hash_value(diagnosis.get("factsHash"), "init-diagnosis.factsHash")
|
|
262
|
+
if errors:
|
|
263
|
+
return errors
|
|
264
|
+
if diagnosis.get("factsHash") != plan.get("factsHash"):
|
|
265
|
+
errors.append("plan factsHash must match .roll/init-diagnosis.yaml factsHash")
|
|
266
|
+
return errors
|
|
267
|
+
|
|
268
|
+
|
|
128
269
|
def validate_freshness(plan: dict) -> tuple[list[str], bool]:
|
|
129
270
|
"""Returns (errors, is_stale). Stale uses exit code 2."""
|
|
130
271
|
raw = plan.get("generated_at")
|
|
@@ -346,6 +487,10 @@ def main(argv: list[str]) -> int:
|
|
|
346
487
|
schema_errors: list[str] = []
|
|
347
488
|
schema_errors += validate_required_top_level(plan)
|
|
348
489
|
schema_errors += validate_version(plan)
|
|
490
|
+
schema_errors += validate_diagnosis_pair(plan, path)
|
|
491
|
+
schema_errors += validate_shell_command_keys(plan)
|
|
492
|
+
schema_errors += validate_file_operations(plan)
|
|
493
|
+
schema_errors += validate_merge_intents(plan)
|
|
349
494
|
schema_errors += validate_project_understanding(plan)
|
|
350
495
|
schema_errors += validate_scope(plan)
|
|
351
496
|
schema_errors += validate_privacy(plan)
|
package/package.json
CHANGED
package/skills/README.md
CHANGED
|
@@ -32,7 +32,7 @@ roll 的**技能契约**仓——AI agent 在 roll 项目里工作时加载并
|
|
|
32
32
|
- `roll-doctor` — 工具链体检;`roll-doc-audit` — 文档/产品一致性审计与存量文档自动化
|
|
33
33
|
|
|
34
34
|
**Lifecycle & misc · 生命周期与杂项**
|
|
35
|
-
- `roll-onboard` —
|
|
35
|
+
- `roll-onboard` — 已有代码库交互式接入
|
|
36
36
|
- `roll-propose` — 产品视角提案生成(只进 proposals,不直写 backlog)
|
|
37
37
|
- `roll-.changelog` — Done 卡 → 用户 changelog(含验收证据 marker 约定)
|
|
38
38
|
- `roll-debug` — 网页黑盒诊断探针
|