@devcxl/opencode-cabbage 1.2.0 → 2.0.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.
Files changed (104) hide show
  1. package/.codex-plugin/plugin.json +36 -0
  2. package/README.md +67 -21
  3. package/README.zh.md +26 -13
  4. package/assets/agents/dev-lifecycle.md +6 -0
  5. package/assets/prompts/bootstrap.md +4 -0
  6. package/assets/skills/flow-change/SKILL.md +109 -0
  7. package/assets/skills/flow-design/SKILL.md +15 -0
  8. package/assets/skills/flow-docs/SKILL.md +112 -0
  9. package/assets/skills/flow-incident/SKILL.md +98 -0
  10. package/assets/skills/flow-research/SKILL.md +9 -0
  11. package/assets/skills/flow-setup/SKILL.md +13 -0
  12. package/codex-skills/agent-architect/SKILL.md +79 -0
  13. package/codex-skills/agent-dev-lifecycle/SKILL.md +311 -0
  14. package/codex-skills/agent-developer/SKILL.md +51 -0
  15. package/codex-skills/agent-goal-verify/SKILL.md +45 -0
  16. package/codex-skills/agent-researcher/SKILL.md +32 -0
  17. package/codex-skills/agent-reviewer/SKILL.md +109 -0
  18. package/codex-skills/flow-change/SKILL.md +109 -0
  19. package/codex-skills/flow-code/SKILL.md +71 -0
  20. package/codex-skills/flow-design/SKILL.md +161 -0
  21. package/codex-skills/flow-docs/SKILL.md +112 -0
  22. package/codex-skills/flow-incident/SKILL.md +98 -0
  23. package/codex-skills/flow-release/SKILL.md +90 -0
  24. package/codex-skills/flow-requirements/SKILL.md +90 -0
  25. package/codex-skills/flow-research/SKILL.md +86 -0
  26. package/codex-skills/flow-review/SKILL.md +110 -0
  27. package/codex-skills/flow-setup/SKILL.md +88 -0
  28. package/codex-skills/flow-tasks/SKILL.md +220 -0
  29. package/codex-skills/flow-tdd/SKILL.md +235 -0
  30. package/dist/hooks/session-start.d.ts +1 -0
  31. package/dist/hooks/session-start.js +72 -0
  32. package/dist/hooks/session-start.js.map +1 -0
  33. package/dist/index.d.ts +2 -5
  34. package/dist/index.js +1 -4
  35. package/dist/index.js.map +1 -1
  36. package/dist/plugin/agents.d.ts +20 -0
  37. package/dist/plugin/agents.js +79 -0
  38. package/dist/plugin/agents.js.map +1 -1
  39. package/dist/plugin/cabbage-cli.d.ts +28 -0
  40. package/dist/plugin/cabbage-cli.js +92 -0
  41. package/dist/plugin/cabbage-cli.js.map +1 -0
  42. package/dist/plugin/cabbage-tool.d.ts +103 -0
  43. package/dist/plugin/cabbage-tool.js +169 -0
  44. package/dist/plugin/cabbage-tool.js.map +1 -0
  45. package/dist/plugin/commands.d.ts +10 -0
  46. package/dist/plugin/commands.js +1 -1
  47. package/dist/plugin/commands.js.map +1 -1
  48. package/dist/plugin/continuation.d.ts +22 -0
  49. package/dist/plugin/continuation.js +233 -0
  50. package/dist/plugin/continuation.js.map +1 -0
  51. package/dist/plugin/goal-store.d.ts +64 -0
  52. package/dist/plugin/goal-store.js +57 -0
  53. package/dist/plugin/goal-store.js.map +1 -0
  54. package/dist/plugin/goal.d.ts +95 -38
  55. package/dist/plugin/goal.js +208 -199
  56. package/dist/plugin/goal.js.map +1 -1
  57. package/dist/plugin/install-dir.d.ts +13 -0
  58. package/dist/plugin/install-dir.js +46 -0
  59. package/dist/plugin/install-dir.js.map +1 -0
  60. package/dist/plugin/server.d.ts +4 -12
  61. package/dist/plugin/server.js +279 -423
  62. package/dist/plugin/server.js.map +1 -1
  63. package/dist/plugin/session-abort.d.ts +5 -0
  64. package/dist/plugin/session-abort.js +26 -0
  65. package/dist/plugin/session-abort.js.map +1 -0
  66. package/dist/plugin/skills.d.ts +15 -0
  67. package/dist/plugin/skills.js +47 -50
  68. package/dist/plugin/skills.js.map +1 -1
  69. package/dist/plugin.d.ts +2 -2
  70. package/dist/plugin.js +6 -2
  71. package/dist/plugin.js.map +1 -1
  72. package/dist/util/paths.d.ts +0 -6
  73. package/dist/util/paths.js +0 -15
  74. package/dist/util/paths.js.map +1 -1
  75. package/hooks/hooks.json +17 -0
  76. package/package.json +13 -8
  77. package/vendor/cabbage/MANIFEST.json +8 -0
  78. package/vendor/cabbage/cabbage_cli/__init__.py +1 -0
  79. package/vendor/cabbage/cabbage_cli/__main__.py +2 -0
  80. package/vendor/cabbage/cabbage_cli/assets/docs-site/.vitepress/config.ts +78 -0
  81. package/vendor/cabbage/cabbage_cli/assets/docs-site/README.md +31 -0
  82. package/vendor/cabbage/cabbage_cli/assets/docs-site/package.json +16 -0
  83. package/vendor/cabbage/cabbage_cli/assets/docs-site/pnpm-lock.yaml +2504 -0
  84. package/vendor/cabbage/cabbage_cli/assets/integrations/cabbage.yml +127 -0
  85. package/vendor/cabbage/cabbage_cli/assets/templates/adr.md +47 -0
  86. package/vendor/cabbage/cabbage_cli/assets/templates/api-design.md +45 -0
  87. package/vendor/cabbage/cabbage_cli/assets/templates/change-record.md +21 -0
  88. package/vendor/cabbage/cabbage_cli/assets/templates/database-design.md +37 -0
  89. package/vendor/cabbage/cabbage_cli/assets/templates/generic.md +20 -0
  90. package/vendor/cabbage/cabbage_cli/assets/templates/impact.md +41 -0
  91. package/vendor/cabbage/cabbage_cli/assets/templates/incident.md +48 -0
  92. package/vendor/cabbage/cabbage_cli/assets/templates/postmortem.md +43 -0
  93. package/vendor/cabbage/cabbage_cli/assets/templates/prd.md +71 -0
  94. package/vendor/cabbage/cabbage_cli/assets/templates/release-plan.md +45 -0
  95. package/vendor/cabbage/cabbage_cli/assets/templates/rfc.md +44 -0
  96. package/vendor/cabbage/cabbage_cli/assets/templates/security-review.md +35 -0
  97. package/vendor/cabbage/cabbage_cli/assets/templates/tasks.md +62 -0
  98. package/vendor/cabbage/cabbage_cli/assets/templates/tech-spec.md +87 -0
  99. package/vendor/cabbage/cabbage_cli/assets/templates/test-plan.md +44 -0
  100. package/vendor/cabbage/cabbage_cli/assets/workflows/change.yaml +11 -0
  101. package/vendor/cabbage/cabbage_cli/assets/workflows/incident.yaml +4 -0
  102. package/vendor/cabbage/cabbage_cli/cli.py +283 -0
  103. package/vendor/cabbage/cabbage_cli/core.py +606 -0
  104. package/vendor/cabbage/cabbage_cli/scaffold.py +313 -0
@@ -0,0 +1,606 @@
1
+ from __future__ import annotations
2
+ import hashlib, json, os, re, subprocess, tempfile
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ import yaml
6
+
7
+ CABBAGE_DIR = ".cabbage"
8
+ LEGACY_PLACEHOLDERS = (
9
+ "Describe the architectural decision context.",
10
+ "Record the chosen decision.",
11
+ "Record positive and negative consequences.",
12
+ "Describe endpoints/events, inputs, outputs and errors.",
13
+ "Describe versioning and backward compatibility.",
14
+ "Describe schema changes.",
15
+ "Describe forward migration.",
16
+ "Describe rollback and data-safety constraints.",
17
+ "Describe the artifact.",
18
+ "Describe material risks and mitigations.",
19
+ "Describe user/business impact.",
20
+ "Record key timestamps and actions.",
21
+ "Record containment and recovery.",
22
+ "Describe the root cause.",
23
+ "Describe systemic contributing factors.",
24
+ "List owned corrective actions.",
25
+ "Replace this text with the product goal.",
26
+ "Describe in-scope and out-of-scope behavior.",
27
+ "Define observable acceptance criteria.",
28
+ "Describe deployment steps and ordering.",
29
+ "Describe rollback triggers and steps.",
30
+ "Describe post-deploy verification.",
31
+ "Describe the problem.",
32
+ "Describe the proposal.",
33
+ "Describe alternatives and trade-offs.",
34
+ "Describe trust boundaries, abuse cases and sensitive assets.",
35
+ "Describe authorization, validation, secrets, audit and mitigations.",
36
+ "Describe current state and constraints.",
37
+ "Describe the proposed design. Use Mermaid for reviewable diagrams.",
38
+ "Describe failure handling and recovery.",
39
+ "Describe incremental rollout and compatibility.",
40
+ "Describe unit, integration, E2E, regression and non-functional coverage.",
41
+ "Define the critical test cases.",
42
+ )
43
+
44
+ class CabbageError(RuntimeError):
45
+ pass
46
+
47
+ def write_text_atomic(path: Path, text: str, encoding: str = "utf-8") -> None:
48
+ path.parent.mkdir(parents=True, exist_ok=True)
49
+ with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False, encoding=encoding) as tf:
50
+ tf.write(text)
51
+ temp_name = tf.name
52
+ os.replace(temp_name, path)
53
+
54
+ def load_yaml(path: Path) -> dict:
55
+ if not path.exists():
56
+ raise CabbageError(f"missing file: {path}")
57
+ try:
58
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
59
+ except yaml.YAMLError as e:
60
+ raise CabbageError(f"invalid YAML in {path}: {e}")
61
+ return data or {}
62
+
63
+ def dump_yaml(path: Path, data: dict) -> None:
64
+ content = yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
65
+ write_text_atomic(path, content, encoding="utf-8")
66
+
67
+ def dump_json(path: Path, data: dict) -> None:
68
+ content = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
69
+ write_text_atomic(path, content, encoding="utf-8")
70
+
71
+ def sha256_bytes(data: bytes) -> str:
72
+ return hashlib.sha256(data).hexdigest()
73
+
74
+ def sha256_file(path: Path) -> str:
75
+ return sha256_bytes(path.read_bytes()) if path.exists() else "MISSING"
76
+
77
+ def now_iso() -> str:
78
+ return datetime.now(timezone.utc).isoformat()
79
+
80
+ def project_root(start: Path | None = None) -> Path:
81
+ p = (start or Path.cwd()).resolve()
82
+ for cur in [p, *p.parents]:
83
+ if (cur / CABBAGE_DIR / "config.yaml").exists():
84
+ return cur
85
+ raise CabbageError("not a cabbage project; run `cabbage init`")
86
+
87
+ def load_config(root: Path) -> dict:
88
+ return load_yaml(root / CABBAGE_DIR / "config.yaml")
89
+
90
+ def change_dir(root: Path, change_id: str) -> Path:
91
+ return changes_root(root) / change_id
92
+
93
+ def changes_root(root: Path) -> Path:
94
+ return root / CABBAGE_DIR / "changes"
95
+
96
+ def archive_root(root: Path) -> Path:
97
+ return root / CABBAGE_DIR / "archive"
98
+
99
+ def change_spec(root: Path, change_id: str) -> dict:
100
+ return load_yaml(change_dir(root, change_id) / "change.yaml")
101
+
102
+ def workflow(root: Path, change_type: str) -> tuple[dict, Path]:
103
+ p = root / CABBAGE_DIR / "workflows" / f"{change_type}.yaml"
104
+ if not p.exists():
105
+ available = sorted(x.stem for x in p.parent.glob("*.yaml")) if p.parent.exists() else []
106
+ hint = f"; available: {', '.join(available)}" if available else ""
107
+ raise CabbageError(f"unknown change type: {change_type}{hint}")
108
+ return load_yaml(p), p
109
+
110
+ def state_path(root: Path, change_id: str) -> Path:
111
+ return change_dir(root, change_id) / "state.json"
112
+
113
+ def load_state(root: Path, change_id: str) -> dict:
114
+ p = state_path(root, change_id)
115
+ if not p.exists():
116
+ return {"version": 1, "completed": {}}
117
+ try:
118
+ data = json.loads(p.read_text(encoding="utf-8"))
119
+ except json.JSONDecodeError as e:
120
+ raise CabbageError(f"invalid state.json: {e}")
121
+ data.setdefault("completed", {})
122
+ return data
123
+
124
+ def save_state(root: Path, change_id: str, state: dict) -> None:
125
+ dump_json(state_path(root, change_id), state)
126
+
127
+ def stage_context(spec: dict, stage: dict) -> dict:
128
+ ctx={"type": spec.get("type")}
129
+ cond=stage.get("when")
130
+ if isinstance(cond,str) and cond.startswith("impact."):
131
+ key=cond.split(".",1)[1]
132
+ ctx["condition"]={key: bool(spec.get("impact",{}).get(key,False))}
133
+ return ctx
134
+
135
+ def condition_enabled(stage: dict, spec: dict) -> bool:
136
+ cond = stage.get("when")
137
+ if not cond or cond == "always":
138
+ return True
139
+ if cond.startswith("impact."):
140
+ return bool(spec.get("impact", {}).get(cond.split(".", 1)[1], False))
141
+ raise CabbageError(f"unsupported condition: {cond}")
142
+
143
+ def stage_map(wf: dict) -> dict[str, dict]:
144
+ return {s["id"]: s for s in wf.get("stages", [])}
145
+
146
+ def artifact_path(root: Path, change_id: str, stage: dict) -> Path | None:
147
+ artifact = stage.get("artifact")
148
+ return change_dir(root, change_id) / artifact if artifact else None
149
+
150
+ def current_signature(root: Path, change_id: str, stage_id: str, memo: dict | None = None, visiting: set[str] | None = None) -> str:
151
+ memo = memo if memo is not None else {}
152
+ if stage_id in memo:
153
+ return memo[stage_id]
154
+ visiting = visiting if visiting is not None else set()
155
+ if stage_id in visiting:
156
+ raise CabbageError(f"dependency cycle detected involving stage: `{stage_id}`")
157
+ visiting.add(stage_id)
158
+
159
+ spec = change_spec(root, change_id)
160
+ wf, wf_path = workflow(root, spec["type"])
161
+ smap = stage_map(wf)
162
+ if stage_id not in smap:
163
+ raise CabbageError(f"unknown stage: {stage_id}")
164
+ stage = smap[stage_id]
165
+ ap = artifact_path(root, change_id, stage)
166
+ payload = {
167
+ "workflow": sha256_file(wf_path),
168
+ "context": stage_context(spec, stage),
169
+ "stage": stage,
170
+ "artifact": sha256_file(ap) if ap else None,
171
+ "dependencies": {d: current_signature(root, change_id, d, memo, visiting.copy()) for d in stage.get("depends_on", []) if d in smap and condition_enabled(smap[d], spec)},
172
+ }
173
+ sig = sha256_bytes(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode())
174
+ memo[stage_id] = sig
175
+ visiting.remove(stage_id)
176
+ return sig
177
+
178
+ def stage_statuses(root: Path, change_id: str) -> list[dict]:
179
+ spec = change_spec(root, change_id)
180
+ wf, _ = workflow(root, spec["type"])
181
+ state = load_state(root, change_id)
182
+ completed = state.get("completed", {})
183
+ out=[]
184
+ memo={}
185
+ for stage in wf.get("stages", []):
186
+ sid=stage["id"]
187
+ enabled=condition_enabled(stage, spec)
188
+ if not enabled:
189
+ status="skipped"
190
+ elif sid not in completed:
191
+ status="pending"
192
+ else:
193
+ status="done" if completed[sid].get("signature") == current_signature(root, change_id, sid, memo) else "stale"
194
+ out.append({"id": sid, "status": status, "artifact": stage.get("artifact"), "description": stage.get("description", "")})
195
+ return out
196
+
197
+ def parse_frontmatter(text: str) -> tuple[dict, str]:
198
+ if not text.startswith("---\n"):
199
+ return {}, text
200
+ end=text.find("\n---\n", 4)
201
+ if end < 0:
202
+ return {}, text
203
+ try:
204
+ meta=yaml.safe_load(text[4:end]) or {}
205
+ except yaml.YAMLError as e:
206
+ raise CabbageError(f"invalid frontmatter YAML: {e}")
207
+ return meta, text[end+5:]
208
+
209
+ def extract_headings_and_slugs(markdown_text: str) -> set[str]:
210
+ slugs = set()
211
+ for line in markdown_text.splitlines():
212
+ m = re.match(r"^#+\s+(.+)$", line)
213
+ if m:
214
+ title = m.group(1).strip()
215
+ slug = re.sub(r"[^\w\s-]", "", title).strip().lower()
216
+ slug = re.sub(r"[\s_]+", "-", slug)
217
+ if slug:
218
+ slugs.add(slug)
219
+ slugs.add(title.lower())
220
+ return slugs
221
+
222
+ def validate_markdown(root: Path, change_id: str, stage: dict, verification: bool=False) -> list[str]:
223
+ errors=[]
224
+ ap=artifact_path(root, change_id, stage)
225
+ if not ap:
226
+ return errors
227
+ if not ap.exists():
228
+ return [f"{stage['id']}: missing artifact {ap.relative_to(root)}"]
229
+ text=ap.read_text(encoding="utf-8")
230
+ meta, body=parse_frontmatter(text)
231
+ if meta.get("change") != change_id:
232
+ errors.append(f"{stage['id']}: frontmatter `change` must be `{change_id}`")
233
+ if meta.get("cabbage_stage") != stage["id"]:
234
+ errors.append(f"{stage['id']}: frontmatter `cabbage_stage` must be `{stage['id']}`")
235
+ for heading in stage.get("required_headings", []):
236
+ if not re.search(rf"^#+\s+{re.escape(heading)}\s*$", body, flags=re.M|re.I):
237
+ errors.append(f"{stage['id']}: missing heading `{heading}`")
238
+ if verification:
239
+ if re.search(r"\b(TODO|TBD|FIXME)\b", body, flags=re.I):
240
+ errors.append(f"{stage['id']}: unresolved TODO/TBD/FIXME")
241
+ body_lines = {
242
+ re.sub(r"^\s*[-*]\s+", "", line).strip() for line in body.splitlines()
243
+ }
244
+ has_placeholder = re.search(r"<!--\s*CABBAGE:", body, flags=re.I) or any(
245
+ placeholder in body_lines for placeholder in LEGACY_PLACEHOLDERS
246
+ )
247
+ if has_placeholder:
248
+ errors.append(
249
+ f"{stage['id']}: placeholder content remains; replace all template prompts"
250
+ )
251
+
252
+ # Mermaid verification
253
+ in_mermaid = False
254
+ mermaid_blocks = []
255
+ cur_block = []
256
+ for line in text.splitlines():
257
+ stripped = line.strip()
258
+ if not in_mermaid and stripped.startswith("```mermaid"):
259
+ in_mermaid = True
260
+ cur_block = []
261
+ elif in_mermaid and stripped == "```":
262
+ in_mermaid = False
263
+ mermaid_blocks.append("\n".join(cur_block))
264
+ elif in_mermaid:
265
+ cur_block.append(line)
266
+ if in_mermaid:
267
+ errors.append(f"{stage['id']}: unclosed Mermaid block")
268
+
269
+ MERMAID_TYPES = (
270
+ "graph", "flowchart", "sequenceDiagram", "classDiagram", "stateDiagram",
271
+ "stateDiagram-v2", "erDiagram", "journey", "gantt", "pie", "quadrantChart",
272
+ "requirementDiagram", "gitGraph", "c4Context", "mindmap", "timeline", "sankey-beta", "xychart-beta"
273
+ )
274
+ for block in mermaid_blocks:
275
+ clean_lines = [l.strip() for l in block.splitlines() if l.strip() and not l.strip().startswith("%%")]
276
+ if not clean_lines:
277
+ errors.append(f"{stage['id']}: empty Mermaid diagram block")
278
+ elif not any(clean_lines[0].startswith(mtype) for mtype in MERMAID_TYPES):
279
+ errors.append(f"{stage['id']}: Mermaid block missing recognized diagram type (e.g. flowchart, sequenceDiagram)")
280
+
281
+ # Local markdown links and anchors
282
+ for target in re.findall(r"\[[^\]]*\]\(([^)]+)\)", text):
283
+ target=target.strip().split()[0].strip("<>")
284
+ if not target or target.startswith(("http://", "https://", "mailto:")):
285
+ continue
286
+ raw, *anchor = target.split("#", 1)
287
+ anchor_name = anchor[0].strip().lower() if anchor else None
288
+
289
+ if raw:
290
+ p=(ap.parent/raw).resolve()
291
+ try:
292
+ p.relative_to(root.resolve())
293
+ except ValueError:
294
+ errors.append(f"{stage['id']}: link escapes project root: {target}")
295
+ continue
296
+ if not p.exists():
297
+ errors.append(f"{stage['id']}: broken local link: {target}")
298
+ continue
299
+ if p.is_dir():
300
+ if not (p / "README.md").exists() and not (p / "index.md").exists():
301
+ errors.append(f"{stage['id']}: directory link missing README.md/index.md: {target}")
302
+ elif anchor_name and p.suffix.lower() in {".md", ".mdx"}:
303
+ target_slugs = extract_headings_and_slugs(p.read_text(encoding="utf-8"))
304
+ if anchor_name not in target_slugs:
305
+ errors.append(f"{stage['id']}: broken anchor in link: {target}")
306
+ elif anchor_name:
307
+ current_slugs = extract_headings_and_slugs(text)
308
+ if anchor_name not in current_slugs:
309
+ errors.append(f"{stage['id']}: broken internal anchor: #{anchor_name}")
310
+
311
+ if stage.get("validator") == "checklist":
312
+ boxes=re.findall(r"^\s*[-*]\s+\[([ xX\-\/])\]", body, flags=re.M)
313
+ if not boxes:
314
+ errors.append(f"{stage['id']}: expected at least one task checkbox")
315
+ if verification and any(x.strip() == "" for x in boxes):
316
+ errors.append(f"{stage['id']}: unchecked implementation tasks remain")
317
+ return errors
318
+
319
+ def dependency_errors(root: Path, change_id: str, stage: dict) -> list[str]:
320
+ spec=change_spec(root, change_id); wf,_=workflow(root,spec["type"]); smap=stage_map(wf)
321
+ statuses={x["id"]:x["status"] for x in stage_statuses(root,change_id)}
322
+ errors=[]
323
+ for dep in stage.get("depends_on", []):
324
+ if dep in smap and condition_enabled(smap[dep], spec) and statuses.get(dep) != "done":
325
+ errors.append(f"{stage['id']}: dependency `{dep}` is {statuses.get(dep)}")
326
+ return errors
327
+
328
+ def verify_stage(root: Path, change_id: str, stage_id: str) -> None:
329
+ spec=change_spec(root,change_id); wf,_=workflow(root,spec["type"]); smap=stage_map(wf)
330
+ if stage_id not in smap: raise CabbageError(f"unknown stage: {stage_id}")
331
+ st=smap[stage_id]
332
+ if not condition_enabled(st,spec): raise CabbageError(f"stage `{stage_id}` is skipped by current impact")
333
+ errors=dependency_errors(root,change_id,st)+validate_markdown(root,change_id,st,verification=True)
334
+ if errors: raise CabbageError("\n".join(errors))
335
+ state=load_state(root,change_id)
336
+ state["completed"][stage_id]={"signature":current_signature(root,change_id,stage_id),"verified_at":now_iso(),"completed_at":now_iso()}
337
+ save_state(root,change_id,state)
338
+
339
+ DEFAULT_STAGE_DOCS_MAPPING = {
340
+ "requirement": "01-product/{change_id}.md",
341
+ "design": "03-architecture/system-design/{change_id}.md",
342
+ "adr": "03-architecture/adr/{change_id}.md",
343
+ "rfc": "03-architecture/rfc/{change_id}.md",
344
+ "api": "05-api/{change_id}.md",
345
+ "database": "04-domain/{change_id}.md",
346
+ "security": "09-security/{change_id}.md",
347
+ "tests": "08-testing/{change_id}.md",
348
+ "release": "12-release/{change_id}.md",
349
+ "incident": "15-incidents/{change_id}.md",
350
+ "postmortem": "15-incidents/{change_id}-postmortem.md",
351
+ }
352
+
353
+ def stage_docs_mapping(root: Path) -> dict[str, str]:
354
+ cfg = load_config(root)
355
+ custom = cfg.get("docs", {}).get("mapping", {})
356
+ mapping = dict(DEFAULT_STAGE_DOCS_MAPPING)
357
+ mapping.update(custom)
358
+ return mapping
359
+
360
+ def sync_change_to_docs(root: Path, change_id: str) -> list[str]:
361
+ spec=change_spec(root, change_id); wf,_=workflow(root, spec["type"])
362
+ docs_name=load_config(root).get("docs",{}).get("dir","docs")
363
+ docs_root=root/docs_name
364
+ mapping=stage_docs_mapping(root)
365
+ synced=[]
366
+ for st in wf.get("stages",[]):
367
+ sid=st["id"]
368
+ if not condition_enabled(st, spec):
369
+ continue
370
+ rel_target_tmpl=mapping.get(sid)
371
+ if not rel_target_tmpl:
372
+ continue
373
+ ap=artifact_path(root, change_id, st)
374
+ if not ap or not ap.exists():
375
+ continue
376
+ text=ap.read_text(encoding="utf-8")
377
+ meta, body=parse_frontmatter(text)
378
+ dest_meta={
379
+ "origin_change": change_id,
380
+ "change_type": spec.get("type", "feature"),
381
+ "cabbage_stage": sid,
382
+ "synced_at": now_iso(),
383
+ }
384
+ dest_text=f"---\n{yaml.safe_dump(dest_meta, sort_keys=False, allow_unicode=True)}---\n\n{body.lstrip()}"
385
+ dest_path=docs_root/rel_target_tmpl.format(change_id=change_id)
386
+ write_text_atomic(dest_path, dest_text, encoding="utf-8")
387
+ synced.append(str(dest_path.relative_to(root)).replace("\\","/"))
388
+ return synced
389
+
390
+ def validate_change(root: Path, change_id: str, verification: bool=False) -> list[str]:
391
+ errors=[]
392
+ spec=change_spec(root,change_id)
393
+ if spec.get("id") != change_id: errors.append(f"change.yaml id must be `{change_id}`")
394
+ if spec.get("status") not in {"active","archived"}: errors.append("change.yaml status must be active or archived")
395
+ cfg=load_config(root); known=set(cfg.get("impact_fields",[])); actual=set(spec.get("impact",{}))
396
+ missing=known-actual
397
+ if missing: errors.append("missing impact fields: "+", ".join(sorted(missing)))
398
+ wf,_=workflow(root,spec["type"])
399
+ for st in wf.get("stages",[]):
400
+ if condition_enabled(st,spec):
401
+ errors.extend(validate_markdown(root,change_id,st,verification=verification))
402
+ return errors
403
+
404
+ def gate(root: Path, change_id: str, target: str) -> list[str]:
405
+ spec=change_spec(root,change_id); wf,_=workflow(root,spec["type"])
406
+ statuses=stage_statuses(root,change_id)
407
+ order=[x["id"] for x in statuses]
408
+ if target in {"merge","archive"}:
409
+ required=statuses
410
+ elif target in order:
411
+ # 阶段 id 作为目标:校验其全部前置阶段(条件未启用的阶段为 skipped,不阻塞)
412
+ required=statuses[:order.index(target)]
413
+ else:
414
+ return [f"unknown gate target: {target} (use a stage id, merge, or archive)"]
415
+ return [f"{x['id']} is {x['status']}" for x in required if x["status"] not in {"done","skipped"}]
416
+
417
+ def ready_to_archive(statuses: list[dict]) -> bool:
418
+ """merge/archive 判定:全部门禁阶段均为 done/skipped(status 提示与门禁共用同一语义)。"""
419
+ return bool(statuses) and not [x for x in statuses if x["status"] not in {"done","skipped"}]
420
+
421
+ def git_changed_files(root: Path, base: str) -> list[str]:
422
+ proc=subprocess.run(["git","diff","--name-only",f"{base}...HEAD"],cwd=root,text=True,capture_output=True)
423
+ if proc.returncode != 0:
424
+ raise CabbageError(proc.stderr.strip() or f"git diff failed against {base}")
425
+ return [x.strip() for x in proc.stdout.splitlines() if x.strip()]
426
+
427
+ KNOWN_EXCLUDE_FILES = {
428
+ "README.md", "README_zh.md", "LICENSE", "LICENSE.md", "LICENSE.txt",
429
+ ".gitignore", ".gitattributes", ".editorconfig", ".prettierrc", ".prettierignore",
430
+ ".eslintignore", ".eslintrc", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml",
431
+ "requirements.txt", "pyproject.toml", "Makefile", "Dockerfile", "docker-compose.yml"
432
+ }
433
+
434
+ def is_code_change(path: str, cfg: dict) -> bool:
435
+ normalized=path.replace("\\","/")
436
+ excludes=tuple(cfg.get("ci",{}).get("exclude_prefixes",["docs/",".cabbage/",".github/"]))
437
+ if any(normalized.startswith(x) for x in excludes): return False
438
+ filename = Path(normalized).name
439
+ if filename in KNOWN_EXCLUDE_FILES: return False
440
+ if normalized.endswith((".md",".mdx",".txt")): return False
441
+ return True
442
+
443
+ def ci_check(root: Path, base: str) -> list[str]:
444
+ cfg=load_config(root); files=git_changed_files(root,base); errors=[]
445
+ changed_changes=set(); archived_changes=set()
446
+ rx=re.compile(r"^\.cabbage/changes/([^/]+)/")
447
+ arx=re.compile(r"^\.cabbage/archive/[^/]+/([^/]+)/")
448
+ for f in files:
449
+ m=rx.match(f)
450
+ if m: changed_changes.add(m.group(1))
451
+ am=arx.match(f)
452
+ if am: archived_changes.add(am.group(1))
453
+ code=[f for f in files if is_code_change(f,cfg)]
454
+ existing_changes={cid for cid in changed_changes if change_dir(root,cid).exists()}
455
+ if code and cfg.get("ci",{}).get("require_change_for_code",True) and not existing_changes:
456
+ errors.append("code changed but no active .cabbage/changes/<id>/ entry changed")
457
+ for cid in sorted(changed_changes-existing_changes):
458
+ if cid not in archived_changes:
459
+ errors.append(f"change record `{cid}` was removed from active changes; use `cabbage archive` rather than deleting workflow history")
460
+ for cid in sorted(existing_changes):
461
+ errors.extend(f"{cid}: {e}" for e in validate_change(root,cid))
462
+ errors.extend(f"{cid}: merge gate: {e}" for e in gate(root,cid,"merge"))
463
+ if cfg.get("ci",{}).get("require_current_state_docs",True):
464
+ spec=change_spec(root,cid)
465
+ wf,_=workflow(root,spec["type"])
466
+ record_covers=wf.get("record_covers",[])
467
+ rules=cfg.get("ci",{}).get("current_state_rules",{})
468
+ for area,prefixes in rules.items():
469
+ if area in record_covers:
470
+ continue
471
+ if spec.get("impact",{}).get(area,False) and not any(any(f.startswith(prefix) for prefix in prefixes) for f in files):
472
+ errors.append(f"{cid}: impact `{area}=true` requires a current-state docs change under: {', '.join(prefixes)}")
473
+ return errors
474
+
475
+ def parse_tasks_markdown(text: str) -> dict:
476
+ # 1. Parse mermaid diagram
477
+ mermaid_match = re.search(r"```mermaid\s*\n(flowchart\s+[\s\S]*?)\n```", text)
478
+ mermaid_code = mermaid_match.group(1).strip() if mermaid_match else None
479
+
480
+ # 2. Parse Task sections (## Task <slug>)
481
+ task_sections = re.findall(r"(?m)^##\s+(Task\b[^\n]*)\n([\s\S]*?)(?=(?:^##\s+|\Z))", text)
482
+ tasks = []
483
+ structured = True
484
+
485
+ if task_sections:
486
+ for header, body in task_sections:
487
+ header_clean = header.strip()
488
+ # Extract id and title
489
+ m = re.match(r"^Task\s*([0-9A-Za-z_-]+)?(?::\s*(.*))?$", header_clean, flags=re.I)
490
+ raw_id = m.group(1) if m and m.group(1) else ""
491
+ task_id = f"Task {raw_id}".strip() if raw_id else header_clean
492
+ title = (m.group(2).strip() if m and m.group(2) else header_clean)
493
+
494
+ # Builds
495
+ builds_match = re.search(r"-\s+\*\*Builds\*\*:\s*([^\n]+)", body)
496
+ builds = builds_match.group(1).strip() if builds_match else title
497
+
498
+ # Blocked By
499
+ blocked_match = re.search(r"-\s+\*\*Blocked By\*\*:\s*([^\n]+)", body)
500
+ blocked_raw = blocked_match.group(1).strip() if blocked_match else "None"
501
+ if blocked_raw.lower() in {"none", "n/a", "nil", ""}:
502
+ blocked_by = []
503
+ else:
504
+ # Split by comma or semicolon
505
+ blocked_by = [x.strip() for x in re.split(r"[,;]+", blocked_raw) if x.strip()]
506
+
507
+ # Parallel Group
508
+ group_match = re.search(r"-\s+\*\*Parallel Group\*\*:\s*([^\n]+)", body)
509
+ group = group_match.group(1).strip() if group_match else "Default"
510
+
511
+ # Verification command
512
+ verif_match = re.search(r"-\s+\*\*Verification\*\*:\s*`?([^`\n]+)`?", body)
513
+ verification = verif_match.group(1).strip() if verif_match else "N/A"
514
+
515
+ # Standard Operating Procedure (SOP)
516
+ sop_match = re.search(r"-\s+\*\*Standard Operating Procedure[^\n]*\*\*:\s*\n((?:\s*(?:\d+\.|\*|-)\s+(?!\[[ xX\-\/]\])[^\n]+\n?)+)", body)
517
+ sop_steps = []
518
+ if sop_match:
519
+ sop_steps = [re.sub(r"^\s*(?:\d+\.|\*|-)\s+", "", line).strip() for line in sop_match.group(1).splitlines() if line.strip()]
520
+
521
+ # Checkboxes
522
+ cb_matches = re.findall(r"^\s*[-*]\s+\[([ xX\-\/])\]\s*([^\n]+)", body, flags=re.M)
523
+ checkboxes = [{"checked": val.lower() == "x", "text": desc.strip()} for val, desc in cb_matches]
524
+ is_completed = bool(checkboxes) and all(cb["checked"] for cb in checkboxes)
525
+
526
+ tasks.append({
527
+ "task_id": task_id,
528
+ "title": title,
529
+ "builds": builds,
530
+ "blocked_by": blocked_by,
531
+ "parallel_group": group,
532
+ "verification": verification,
533
+ "sop": sop_steps,
534
+ "checkboxes": checkboxes,
535
+ "is_completed": is_completed,
536
+ })
537
+ else:
538
+ # Fallback: scan checkboxes in # Tasks section. These tasks declare no
539
+ # dependencies or verification, so they are not dispatchable.
540
+ structured = False
541
+ tasks_block_match = re.search(r"(?m)^#\s+Tasks\b([\s\S]*?)(?=(?:^#\s+|\Z))", text)
542
+ block = tasks_block_match.group(1) if tasks_block_match else text
543
+ cb_matches = re.findall(r"^\s*[-*]\s+\[([ xX\-\/])\]\s*([^\n]+)", block, flags=re.M)
544
+ for idx, (val, desc) in enumerate(cb_matches, start=1):
545
+ is_done = val.lower() == "x"
546
+ tasks.append({
547
+ "task_id": f"Task {idx}",
548
+ "title": desc.strip(),
549
+ "builds": desc.strip(),
550
+ "blocked_by": [],
551
+ "parallel_group": "Default",
552
+ "verification": "N/A",
553
+ "sop": [],
554
+ "checkboxes": [{"checked": is_done, "text": desc.strip()}],
555
+ "is_completed": is_done,
556
+ })
557
+
558
+ # 3. Calculate status, topological readiness
559
+ completed_ids = {t["task_id"] for t in tasks if t["is_completed"]}
560
+
561
+ for t in tasks:
562
+ if t["is_completed"]:
563
+ t["status"] = "done"
564
+ t["is_ready"] = False
565
+ else:
566
+ unresolved = [d for d in t["blocked_by"] if d not in completed_ids and d.lower() not in {"none", "preparation", ""}]
567
+ if not unresolved:
568
+ t["status"] = "ready"
569
+ t["is_ready"] = True
570
+ t["unresolved_deps"] = []
571
+ else:
572
+ t["status"] = "blocked"
573
+ t["is_ready"] = False
574
+ t["unresolved_deps"] = unresolved
575
+
576
+ # 4. Group by parallel_group
577
+ parallel_groups: dict[str, list[dict]] = {}
578
+ for t in tasks:
579
+ grp = t.get("parallel_group", "Default")
580
+ parallel_groups.setdefault(grp, []).append(t)
581
+
582
+ total_tasks = len(tasks)
583
+ completed_count = sum(1 for t in tasks if t["is_completed"])
584
+ ready_count = sum(1 for t in tasks if t["is_ready"])
585
+ blocked_count = total_tasks - completed_count - ready_count
586
+
587
+ return {
588
+ "mermaid": mermaid_code,
589
+ "structured": structured,
590
+ "total_tasks": total_tasks,
591
+ "completed_tasks": completed_count,
592
+ "ready_tasks": ready_count,
593
+ "blocked_tasks": blocked_count,
594
+ "tasks": tasks,
595
+ "parallel_groups": parallel_groups,
596
+ }
597
+
598
+ def get_change_tasks_dag(root: Path, change_id: str) -> dict:
599
+ tasks_file = change_dir(root, change_id) / "tasks.md"
600
+ if not tasks_file.exists():
601
+ raise CabbageError(f"tasks.md not found for change `{change_id}`")
602
+ content = tasks_file.read_text(encoding="utf-8")
603
+ body = parse_frontmatter(content)[1]
604
+ result = parse_tasks_markdown(body)
605
+ result["change"] = change_id
606
+ return result