@blxzer/cursor-trellis 0.3.6 → 0.4.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +10 -6
  3. package/dist/commands/update.d.ts.map +1 -1
  4. package/dist/commands/update.js +11 -1
  5. package/dist/commands/update.js.map +1 -1
  6. package/dist/configurators/shared.d.ts.map +1 -1
  7. package/dist/configurators/shared.js +2 -1
  8. package/dist/configurators/shared.js.map +1 -1
  9. package/dist/configurators/workflow.d.ts +7 -0
  10. package/dist/configurators/workflow.d.ts.map +1 -1
  11. package/dist/configurators/workflow.js +61 -2
  12. package/dist/configurators/workflow.js.map +1 -1
  13. package/dist/constants/paths.d.ts +4 -0
  14. package/dist/constants/paths.d.ts.map +1 -1
  15. package/dist/constants/paths.js +4 -0
  16. package/dist/constants/paths.js.map +1 -1
  17. package/dist/migrations/manifests/0.4.0.json +9 -0
  18. package/dist/migrations/manifests/0.4.1.json +9 -0
  19. package/dist/templates/common/{skills/check.md → bundled-skills/cstl-check/SKILL.md} +55 -7
  20. package/dist/templates/common/bundled-skills/cstl-check/references/fowler-12-smells.md +28 -0
  21. package/dist/templates/common/bundled-skills/cstl-handoff/SKILL.md +117 -0
  22. package/dist/templates/common/bundled-skills/cstl-micro-grill/SKILL.md +6 -0
  23. package/dist/templates/common/skills/brainstorm.md +15 -0
  24. package/dist/templates/cursor/agents/cstl-check.md +14 -0
  25. package/dist/templates/cursor/commands/handoff.md +99 -0
  26. package/dist/templates/markdown/index.d.ts +6 -0
  27. package/dist/templates/markdown/index.d.ts.map +1 -1
  28. package/dist/templates/markdown/index.js +6 -0
  29. package/dist/templates/markdown/index.js.map +1 -1
  30. package/dist/templates/markdown/spec/guides/artifact-locale-guide.md.txt +93 -0
  31. package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +7 -7
  32. package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +10 -8
  33. package/dist/templates/markdown/spec/guides/debug-loop-guide.md.txt +227 -0
  34. package/dist/templates/markdown/spec/guides/goal-release-regression-runbook.md.txt +132 -0
  35. package/dist/templates/markdown/spec/guides/index.md.txt +37 -0
  36. package/dist/templates/markdown/spec/guides/prototype-guide.md.txt +139 -0
  37. package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +4 -0
  38. package/dist/templates/markdown/spec/guides/test-discipline-guide.md.txt +138 -0
  39. package/dist/templates/markdown/spec/guides/verification-strength-guide.md.txt +1 -0
  40. package/dist/templates/trellis/CONTEXT.md +53 -0
  41. package/dist/templates/trellis/docs/adr/README.md +21 -0
  42. package/dist/templates/trellis/index.d.ts +14 -0
  43. package/dist/templates/trellis/index.d.ts.map +1 -1
  44. package/dist/templates/trellis/index.js +29 -0
  45. package/dist/templates/trellis/index.js.map +1 -1
  46. package/dist/templates/trellis/pool/README.md +103 -0
  47. package/dist/templates/trellis/pool/items/.gitkeep +0 -0
  48. package/dist/templates/trellis/pool/plan.md +26 -0
  49. package/dist/templates/trellis/scripts/common/artifact_locale.py +16 -0
  50. package/dist/templates/trellis/scripts/common/pool_store.py +702 -0
  51. package/dist/templates/trellis/scripts/common/task_dashboard.py +8 -0
  52. package/dist/templates/trellis/scripts/common/task_dependencies.py +673 -0
  53. package/dist/templates/trellis/scripts/common/task_gates.py +58 -5
  54. package/dist/templates/trellis/scripts/common/task_store.py +256 -0
  55. package/dist/templates/trellis/scripts/common/test_depends_mode_block.py +489 -0
  56. package/dist/templates/trellis/scripts/common/test_pool_store.py +428 -0
  57. package/dist/templates/trellis/scripts/common/test_task_dependencies.py +345 -0
  58. package/dist/templates/trellis/scripts/pool.py +192 -0
  59. package/dist/templates/trellis/scripts/task.py +66 -1
  60. package/dist/templates/trellis/scripts/verify_evidence_probe.py +138 -0
  61. package/dist/templates/trellis/tasks/locale/en/default-prd.md +8 -0
  62. package/dist/templates/trellis/tasks/locale/zh/default-prd.md +8 -0
  63. package/dist/templates/trellis/workflow.md +34 -2
  64. package/dist/utils/manifest-prune.d.ts.map +1 -1
  65. package/dist/utils/manifest-prune.js +7 -0
  66. package/dist/utils/manifest-prune.js.map +1 -1
  67. package/package.json +2 -2
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for the review-pool store (read, write, links, validation, plan)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
13
+
14
+ from common.pool_store import (
15
+ check_plan,
16
+ find_item_path,
17
+ get_linked_tasks,
18
+ get_pool_items,
19
+ link_item_task,
20
+ list_items,
21
+ load_item,
22
+ normalize_id_list,
23
+ parse_item_file,
24
+ plan_referenced_ids,
25
+ unlink_item_task,
26
+ validate_pool,
27
+ write_item_frontmatter,
28
+ )
29
+
30
+ ITEM_TEMPLATE = """\
31
+ ---
32
+ id: {item_id}
33
+ title: {title}
34
+ status: {status}
35
+ type: mechanism
36
+ locale: zh
37
+ created: 2026-08-08
38
+ approved: 2026-08-08
39
+ ---
40
+
41
+ ## 意图
42
+
43
+ one line
44
+
45
+ ## 动机
46
+
47
+ why
48
+
49
+ ## 粗验收
50
+
51
+ - a
52
+ - b
53
+
54
+ ## 非目标
55
+
56
+ - c
57
+ """
58
+
59
+
60
+ def _repo(tmp_path: Path) -> Path:
61
+ repo = tmp_path / "repo"
62
+ (repo / ".cstl" / "pool" / "items").mkdir(parents=True)
63
+ (repo / ".cstl" / "tasks").mkdir(parents=True)
64
+ return repo
65
+
66
+
67
+ def _write_item(repo: Path, item_id: str, *, title: str = "t", status: str = "accepted") -> Path:
68
+ path = repo / ".cstl" / "pool" / "items" / f"{item_id}.md"
69
+ path.write_text(
70
+ ITEM_TEMPLATE.format(item_id=item_id, title=title, status=status),
71
+ encoding="utf-8",
72
+ )
73
+ return path
74
+
75
+
76
+ def _write_task(
77
+ repo: Path,
78
+ dir_name: str,
79
+ status: str = "completed",
80
+ meta: dict | None = None,
81
+ ) -> Path:
82
+ task_dir = repo / ".cstl" / "tasks" / dir_name
83
+ task_dir.mkdir(parents=True, exist_ok=True)
84
+ data: dict = {"id": dir_name, "name": dir_name, "status": status}
85
+ if meta is not None:
86
+ data["meta"] = meta
87
+ (task_dir / "task.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
88
+ return task_dir
89
+
90
+
91
+ def _write_plan(repo: Path, text: str) -> Path:
92
+ plan_file = repo / ".cstl" / "pool" / "plan.md"
93
+ plan_file.write_text(text, encoding="utf-8")
94
+ return plan_file
95
+
96
+
97
+ # =============================================================================
98
+ # Load / parse
99
+ # =============================================================================
100
+
101
+ def test_parse_item_file_loads_fields(tmp_path: Path) -> None:
102
+ repo = _repo(tmp_path)
103
+ path = _write_item(repo, "P01", title="审核池状态机")
104
+ item = parse_item_file(path)
105
+ assert item.id == "P01"
106
+ assert item.title == "审核池状态机"
107
+ assert item.status == "accepted"
108
+ assert item.type == "mechanism"
109
+ assert item.linked_tasks == []
110
+ assert "## 动机" in item.body
111
+
112
+
113
+ def test_list_items_and_load_item(tmp_path: Path) -> None:
114
+ repo = _repo(tmp_path)
115
+ _write_item(repo, "P02")
116
+ _write_item(repo, "P01")
117
+ items = list_items(repo)
118
+ assert [item.id for item in items] == ["P01", "P02"]
119
+
120
+ loaded = load_item(repo, "P02")
121
+ assert loaded is not None and loaded.id == "P02"
122
+ assert load_item(repo, "P99") is None
123
+ assert find_item_path(repo, "P01") == repo / ".cstl" / "pool" / "items" / "P01.md"
124
+ assert find_item_path(repo, "P99") is None
125
+ assert list_items(tmp_path / "empty") == [] # no pool dir -> empty
126
+
127
+
128
+ def test_normalize_id_list_dedupes_preserves_order() -> None:
129
+ assert normalize_id_list(["b", " a ", "b", "", "c", 5]) == ["b", "a", "c"]
130
+ assert normalize_id_list(None) == []
131
+ assert normalize_id_list("single") == ["single"]
132
+ assert normalize_id_list(" ") == []
133
+
134
+
135
+ # =============================================================================
136
+ # Frontmatter write
137
+ # =============================================================================
138
+
139
+ def test_write_frontmatter_preserves_body_and_key_order(tmp_path: Path) -> None:
140
+ repo = _repo(tmp_path)
141
+ path = _write_item(repo, "P01")
142
+ original = path.read_text(encoding="utf-8")
143
+ original_body = original[original.index("---\n", 4) + 5 :]
144
+
145
+ write_item_frontmatter(path, {"status": "review", "linked_tasks": ["task-x"]})
146
+
147
+ rewritten = path.read_text(encoding="utf-8")
148
+ assert rewritten[rewritten.index("---\n", 4) + 5 :] == original_body # body byte-identical
149
+ item = parse_item_file(path)
150
+ assert item.status == "review"
151
+ assert item.linked_tasks == ["task-x"]
152
+
153
+ fm_lines = [line for line in rewritten.splitlines() if ":" in line and not line.startswith("-")]
154
+ keys = [line.split(":", 1)[0] for line in fm_lines]
155
+ assert keys == ["id", "title", "status", "type", "locale", "created", "approved", "linked_tasks"]
156
+ assert keys == list(item.frontmatter.keys())
157
+
158
+
159
+ def test_write_frontmatter_keeps_unknown_key_relative_order(tmp_path: Path) -> None:
160
+ repo = _repo(tmp_path)
161
+ path = repo / ".cstl" / "pool" / "items" / "P05.md"
162
+ path.write_text(
163
+ "---\n"
164
+ "id: P05\n"
165
+ "title: t\n"
166
+ "status: inbox\n"
167
+ "type: task\n"
168
+ "decision_note: keep-me\n"
169
+ "created: 2026-08-08\n"
170
+ "---\n\n## 意图\nx\n",
171
+ encoding="utf-8",
172
+ )
173
+ write_item_frontmatter(path, {"status": "review"})
174
+ item = parse_item_file(path)
175
+ assert item.frontmatter["decision_note"] == "keep-me"
176
+ # known keys first, then the unknown key keeps its original position
177
+ assert list(item.frontmatter.keys()) == [
178
+ "id", "title", "status", "type", "created", "decision_note",
179
+ ]
180
+
181
+
182
+ def test_write_frontmatter_empty_list_round_trips(tmp_path: Path) -> None:
183
+ repo = _repo(tmp_path)
184
+ path = _write_item(repo, "P01")
185
+ write_item_frontmatter(path, {"linked_tasks": []})
186
+ assert parse_item_file(path).linked_tasks == []
187
+ assert "linked_tasks:" in path.read_text(encoding="utf-8")
188
+
189
+
190
+ # =============================================================================
191
+ # Bidirectional link / unlink
192
+ # =============================================================================
193
+
194
+ def test_link_item_task_writes_both_sides_idempotent(tmp_path: Path) -> None:
195
+ repo = _repo(tmp_path)
196
+ _write_item(repo, "P01")
197
+ task_dir = _write_task(repo, "08-09-some-task", status="in_progress")
198
+
199
+ first = link_item_task(repo, "P01", "some-task") # suffix ref -> canonical dir name
200
+ assert first.ok
201
+ assert first.task_ref == "08-09-some-task"
202
+
203
+ item = load_item(repo, "P01")
204
+ assert item is not None
205
+ assert get_linked_tasks(item) == ["08-09-some-task"]
206
+ data = json.loads((task_dir / "task.json").read_text(encoding="utf-8"))
207
+ assert get_pool_items(data) == ["P01"]
208
+
209
+ second = link_item_task(repo, "P01", "08-09-some-task")
210
+ assert second.ok
211
+ item = load_item(repo, "P01")
212
+ assert item is not None
213
+ assert get_linked_tasks(item) == ["08-09-some-task"] # no duplicate
214
+ assert get_pool_items(json.loads((task_dir / "task.json").read_text(encoding="utf-8"))) == ["P01"]
215
+
216
+
217
+ def test_link_multiple_tasks_and_items(tmp_path: Path) -> None:
218
+ repo = _repo(tmp_path)
219
+ _write_item(repo, "P01")
220
+ _write_item(repo, "P02")
221
+ task_a = _write_task(repo, "task-a")
222
+ task_b = _write_task(repo, "task-b")
223
+
224
+ link_item_task(repo, "P01", "task-a")
225
+ link_item_task(repo, "P01", "task-b")
226
+ link_item_task(repo, "P02", "task-a")
227
+
228
+ item_p01 = load_item(repo, "P01")
229
+ assert item_p01 is not None
230
+ assert item_p01.linked_tasks == ["task-a", "task-b"]
231
+ data_a = json.loads((task_a / "task.json").read_text(encoding="utf-8"))
232
+ assert get_pool_items(data_a) == ["P01", "P02"]
233
+
234
+
235
+ def test_link_missing_item_or_task_is_error(tmp_path: Path) -> None:
236
+ repo = _repo(tmp_path)
237
+ _write_item(repo, "P01")
238
+ result = link_item_task(repo, "P99", "task-a")
239
+ assert not result.ok
240
+ assert result.errors[0].code == "item-not-found"
241
+
242
+ result = link_item_task(repo, "P01", "ghost-task")
243
+ assert not result.ok
244
+ assert result.errors[0].code == "task-not-found"
245
+
246
+
247
+ def test_unlink_removes_both_sides_and_warns_on_missing_side(tmp_path: Path) -> None:
248
+ repo = _repo(tmp_path)
249
+ _write_item(repo, "P01")
250
+ task_dir = _write_task(repo, "task-a")
251
+ link_item_task(repo, "P01", "task-a")
252
+
253
+ result = unlink_item_task(repo, "P01", "task-a")
254
+ assert result.ok
255
+ assert result.warnings == []
256
+ item = load_item(repo, "P01")
257
+ assert item is not None
258
+ assert item.linked_tasks == []
259
+ data = json.loads((task_dir / "task.json").read_text(encoding="utf-8"))
260
+ assert get_pool_items(data) == []
261
+
262
+ # task side already clean -> warning, still ok
263
+ result = unlink_item_task(repo, "P01", "task-a")
264
+ assert result.ok
265
+ assert any(w.code == "no-task-side-link" for w in result.warnings)
266
+
267
+
268
+ def test_unlink_missing_task_still_cleans_item_side(tmp_path: Path) -> None:
269
+ repo = _repo(tmp_path)
270
+ _write_item(repo, "P01")
271
+ task_dir = _write_task(repo, "task-a")
272
+ link_item_task(repo, "P01", "task-a")
273
+ # task dir disappears (simulates dangling state)
274
+ (task_dir / "task.json").unlink()
275
+
276
+ result = unlink_item_task(repo, "P01", "task-a")
277
+ assert result.ok
278
+ assert any(w.code == "task-not-found" for w in result.warnings)
279
+ item = load_item(repo, "P01")
280
+ assert item is not None
281
+ assert item.linked_tasks == []
282
+
283
+
284
+ def test_unlink_missing_item_is_error(tmp_path: Path) -> None:
285
+ repo = _repo(tmp_path)
286
+ result = unlink_item_task(repo, "P99", "task-a")
287
+ assert not result.ok
288
+ assert result.errors[0].code == "item-not-found"
289
+
290
+
291
+ # =============================================================================
292
+ # validate_pool
293
+ # =============================================================================
294
+
295
+ def test_validate_pool_missing_sections_and_keys(tmp_path: Path) -> None:
296
+ repo = _repo(tmp_path)
297
+ path = repo / ".cstl" / "pool" / "items" / "P03.md"
298
+ path.write_text(
299
+ "---\nid: P03\nstatus: accepted\n---\n\n## 意图\nx\n\n## 动机\nx\n",
300
+ encoding="utf-8",
301
+ )
302
+ issues = validate_pool(repo)
303
+ codes = [issue.code for issue in issues]
304
+ assert "missing-section" in codes # 粗验收 / 非目标 missing
305
+ assert "missing-frontmatter-key" in codes # type missing
306
+
307
+
308
+ def test_validate_pool_inbox_missing_section_is_warning(tmp_path: Path) -> None:
309
+ repo = _repo(tmp_path)
310
+ path = repo / ".cstl" / "pool" / "items" / "P11.md"
311
+ path.write_text(
312
+ "---\nid: P11\nstatus: inbox\ntype: mechanism\n---\n\n## 意图\nx\n\n## 动机\nx\n",
313
+ encoding="utf-8",
314
+ )
315
+ issues = validate_pool(repo)
316
+ missing = [issue for issue in issues if issue.code == "missing-section"]
317
+ assert len(missing) == 2 # 粗验收 / 非目标 missing
318
+ assert all(not issue.is_error for issue in missing)
319
+ assert not any(issue.is_error for issue in issues) # 纯 WARN → CLI exit 0
320
+
321
+
322
+ def test_validate_pool_accepted_missing_section_is_error(tmp_path: Path) -> None:
323
+ repo = _repo(tmp_path)
324
+ path = repo / ".cstl" / "pool" / "items" / "P12.md"
325
+ path.write_text(
326
+ "---\nid: P12\nstatus: accepted\ntype: mechanism\n---\n\n## 意图\nx\n\n## 动机\nx\n",
327
+ encoding="utf-8",
328
+ )
329
+ issues = validate_pool(repo)
330
+ missing = [issue for issue in issues if issue.code == "missing-section"]
331
+ assert len(missing) == 2
332
+ assert all(issue.is_error for issue in missing)
333
+
334
+
335
+ def test_validate_pool_invalid_status_and_duplicate_id(tmp_path: Path) -> None:
336
+ repo = _repo(tmp_path)
337
+ _write_item(repo, "P01", status="accepted")
338
+ dup = repo / ".cstl" / "pool" / "items" / "P01-dup.md"
339
+ dup.write_text(
340
+ ITEM_TEMPLATE.format(item_id="P01", title="dup", status="weird"),
341
+ encoding="utf-8",
342
+ )
343
+
344
+ issues = validate_pool(repo)
345
+ codes = [issue.code for issue in issues]
346
+ assert "duplicate-id" in codes
347
+ assert "invalid-status" in codes
348
+
349
+
350
+ def test_validate_pool_dangling_link_is_error(tmp_path: Path) -> None:
351
+ repo = _repo(tmp_path)
352
+ path = _write_item(repo, "P01")
353
+ write_item_frontmatter(path, {"linked_tasks": ["ghost-task"]})
354
+
355
+ issues = validate_pool(repo)
356
+ assert any(issue.code == "dangling-link" for issue in issues)
357
+ assert all(issue.is_error for issue in issues if issue.code == "dangling-link")
358
+
359
+
360
+ def test_validate_pool_one_sided_link_is_error(tmp_path: Path) -> None:
361
+ repo = _repo(tmp_path)
362
+ path = _write_item(repo, "P01")
363
+ _write_task(repo, "task-a")
364
+ write_item_frontmatter(path, {"linked_tasks": ["task-a"]}) # item side only
365
+
366
+ issues = validate_pool(repo)
367
+ assert any(issue.code == "link-missing-task-side" for issue in issues)
368
+
369
+ # task side only, item side missing
370
+ write_item_frontmatter(path, {"linked_tasks": []})
371
+ _write_task(repo, "task-b", meta={"pool_items": ["P01"]})
372
+ issues = validate_pool(repo)
373
+ assert any(issue.code == "link-missing-item-side" for issue in issues)
374
+
375
+ # task side references an unknown item
376
+ _write_task(repo, "task-c", meta={"pool_items": ["P99"]})
377
+ issues = validate_pool(repo)
378
+ assert any(issue.code == "link-item-missing" for issue in issues)
379
+
380
+
381
+ def test_validate_pool_clean_pool_has_no_issues(tmp_path: Path) -> None:
382
+ repo = _repo(tmp_path)
383
+ _write_item(repo, "P01")
384
+ task_dir = _write_task(repo, "task-a")
385
+ link_item_task(repo, "P01", "task-a")
386
+ assert validate_pool(repo) == []
387
+
388
+
389
+ # =============================================================================
390
+ # check_plan
391
+ # =============================================================================
392
+
393
+ def test_check_plan_ghost_id_is_error(tmp_path: Path) -> None:
394
+ repo = _repo(tmp_path)
395
+ _write_item(repo, "P01")
396
+ _write_plan(repo, "# 计划\n\n- P01 已落地\n- P99 幽灵引用\n")
397
+ issues = check_plan(repo)
398
+ assert any(issue.code == "plan-ghost-id" for issue in issues)
399
+ ghost = next(issue for issue in issues if issue.code == "plan-ghost-id")
400
+ assert "P99" in ghost.message
401
+ assert ghost.is_error
402
+
403
+
404
+ def test_check_plan_ignores_code_blocks(tmp_path: Path) -> None:
405
+ repo = _repo(tmp_path)
406
+ _write_item(repo, "P01")
407
+ _write_plan(
408
+ repo,
409
+ "# 计划\n\n```\nP99 inside code block\n```\n\nP01 outside\n",
410
+ )
411
+ issues = check_plan(repo)
412
+ assert not any(issue.code == "plan-ghost-id" for issue in issues)
413
+
414
+
415
+ def test_plan_referenced_ids_intersects_known() -> None:
416
+ text = "P01 done, P02 later, P99 unknown"
417
+ assert plan_referenced_ids(text, {"P01", "P02"}) == {"P01", "P02"}
418
+ assert plan_referenced_ids(text, {"P01"}) == {"P01"}
419
+
420
+
421
+ def test_check_plan_accepted_item_not_in_plan_warns(tmp_path: Path) -> None:
422
+ repo = _repo(tmp_path)
423
+ _write_item(repo, "P01")
424
+ _write_item(repo, "P07")
425
+ _write_plan(repo, "# 计划\n\nP01 落地\n")
426
+ issues = check_plan(repo)
427
+ assert any(issue.code == "accepted-not-in-plan" for issue in issues)
428
+ assert not any(issue.code == "plan-ghost-id" for issue in issues)