@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/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 files before story execution |
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
- No third-party pip dependencies — standard library only (json, sys, os, re, datetime).
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 = ["version", "generated_at", "project_understanding", "scope", "privacy"]
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "3.625.2",
3
+ "version": "3.627.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "packageManager": "pnpm@11.1.3",
6
6
  "scripts": {
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` — 网页黑盒诊断探针