@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,345 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for Plan A task-level depends_on resolution (declare + soft checks)."""
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.task_dependencies import (
15
+ KIND_CHILD,
16
+ KIND_MISSING,
17
+ KIND_POOL,
18
+ KIND_TASK,
19
+ NOT_SATISFIED,
20
+ SATISFIED,
21
+ UNRESOLVED,
22
+ dashboard_deps_line,
23
+ describe_dependencies,
24
+ find_dependency_cycles,
25
+ normalize_dep_list,
26
+ resolve_dep_ref,
27
+ satisfaction_status,
28
+ )
29
+
30
+
31
+ def _write_task(tasks_dir: Path, dir_name: str, status: str, depends_on=None) -> Path:
32
+ task_dir = tasks_dir / dir_name
33
+ task_dir.mkdir(parents=True, exist_ok=True)
34
+ data = {
35
+ "id": dir_name,
36
+ "name": dir_name,
37
+ "title": dir_name,
38
+ "status": status,
39
+ }
40
+ if depends_on is not None:
41
+ data["depends_on"] = depends_on
42
+ (task_dir / "task.json").write_text(json.dumps(data), encoding="utf-8")
43
+ return task_dir
44
+
45
+
46
+ def _tasks_fixture(tmp_path: Path) -> Path:
47
+ tasks_dir = tmp_path / "repo" / ".cstl" / "tasks"
48
+ tasks_dir.mkdir(parents=True)
49
+ return tasks_dir
50
+
51
+
52
+ def test_completed_task_is_satisfied(tmp_path: Path) -> None:
53
+ tasks_dir = _tasks_fixture(tmp_path)
54
+ _write_task(tasks_dir, "dep-a", "completed")
55
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["dep-a"])
56
+
57
+ report = describe_dependencies(task_dir, {"depends_on": ["dep-a"]}, tasks_dir=tasks_dir)
58
+ dep = report.deps[0]
59
+ assert dep.kind == KIND_TASK
60
+ assert satisfaction_status(dep)[0] == SATISFIED
61
+ assert report.warnings() == []
62
+
63
+
64
+ def test_archived_task_counts_as_completed(tmp_path: Path) -> None:
65
+ tasks_dir = _tasks_fixture(tmp_path)
66
+ archive_dir = tasks_dir / "archive" / "2026-08"
67
+ archive_dir.mkdir(parents=True)
68
+ # Anomaly: archived task.json still says planning — location is the rule.
69
+ _write_task(archive_dir, "dep-archived", "planning")
70
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["dep-archived"])
71
+
72
+ dep = resolve_dep_ref("dep-archived", tasks_dir=tasks_dir)
73
+ assert dep.kind == KIND_TASK
74
+ assert dep.is_archived
75
+ status, note = satisfaction_status(dep)
76
+ assert status == SATISFIED
77
+ assert note == "archived task counts as completed"
78
+
79
+
80
+ def test_cancelled_task_satisfied_with_cancelled_warning(tmp_path: Path) -> None:
81
+ tasks_dir = _tasks_fixture(tmp_path)
82
+ _write_task(tasks_dir, "dep-c", "cancelled")
83
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["dep-c"])
84
+
85
+ report = describe_dependencies(task_dir, {"depends_on": ["dep-c"]}, tasks_dir=tasks_dir)
86
+ dep = report.deps[0]
87
+ assert dep.is_cancelled
88
+ assert satisfaction_status(dep)[0] == SATISFIED
89
+ assert any("cancelled" in w for w in report.warnings())
90
+
91
+
92
+ def test_in_progress_dependency_is_not_satisfied(tmp_path: Path) -> None:
93
+ tasks_dir = _tasks_fixture(tmp_path)
94
+ _write_task(tasks_dir, "dep-b", "in_progress")
95
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["dep-b"])
96
+
97
+ report = describe_dependencies(task_dir, {"depends_on": ["dep-b"]}, tasks_dir=tasks_dir)
98
+ dep = report.deps[0]
99
+ assert satisfaction_status(dep)[0] == NOT_SATISFIED
100
+ assert any("not satisfied" in w for w in report.warnings())
101
+
102
+
103
+ def test_dangling_reference_is_unresolved_warning(tmp_path: Path) -> None:
104
+ tasks_dir = _tasks_fixture(tmp_path)
105
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["ghost-task"])
106
+
107
+ dep = resolve_dep_ref("ghost-task", tasks_dir=tasks_dir)
108
+ assert dep.kind == KIND_MISSING
109
+ assert satisfaction_status(dep)[0] == UNRESOLVED
110
+ report = describe_dependencies(task_dir, {"depends_on": ["ghost-task"]}, tasks_dir=tasks_dir)
111
+ assert any("dangling" in w for w in report.warnings())
112
+
113
+
114
+ def test_pool_prefix_is_unresolved_and_never_falls_through(tmp_path: Path) -> None:
115
+ tasks_dir = _tasks_fixture(tmp_path)
116
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["pool:P09"])
117
+
118
+ dep = resolve_dep_ref("pool:P09", tasks_dir=tasks_dir)
119
+ assert dep.kind == KIND_POOL
120
+ assert satisfaction_status(dep)[0] == UNRESOLVED
121
+ report = describe_dependencies(task_dir, {"depends_on": ["pool:P09"]}, tasks_dir=tasks_dir)
122
+ assert any("unresolved" in w for w in report.warnings())
123
+
124
+
125
+ def test_cycle_detected_across_tasks(tmp_path: Path) -> None:
126
+ tasks_dir = _tasks_fixture(tmp_path)
127
+ task_a = _write_task(tasks_dir, "task-a", "planning", ["task-b"])
128
+ _write_task(tasks_dir, "task-b", "planning", ["task-a"])
129
+
130
+ report = describe_dependencies(task_a, {"depends_on": ["task-b"]}, tasks_dir=tasks_dir)
131
+ assert len(report.cycles) == 1
132
+ assert report.cycles[0][0] == report.cycles[0][-1]
133
+ assert any("cycle" in w for w in report.warnings())
134
+
135
+
136
+ def test_self_reference_cycle_detected(tmp_path: Path) -> None:
137
+ tasks_dir = _tasks_fixture(tmp_path)
138
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["task-x"])
139
+
140
+ report = describe_dependencies(task_dir, {"depends_on": ["task-x"]}, tasks_dir=tasks_dir)
141
+ assert report.cycles == [["task-x", "task-x"]]
142
+
143
+
144
+ def test_find_dependency_cycles_pure(tmp_path: Path) -> None:
145
+ graph = {"a": ["b"], "b": ["c"], "c": ["a"], "d": []}
146
+ cycles = find_dependency_cycles(graph)
147
+ assert len(cycles) == 1
148
+ cycle = cycles[0]
149
+ assert cycle[0] == cycle[-1]
150
+ assert set(cycle) == {"a", "b", "c"}
151
+
152
+
153
+ def test_scope_child_wins_over_global_task_with_same_name(tmp_path: Path) -> None:
154
+ tasks_dir = _tasks_fixture(tmp_path)
155
+ # Global task named "shared" that IS completed — would be satisfied globally.
156
+ _write_task(tasks_dir, "shared", "completed")
157
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["shared"])
158
+ scope = {"shared": "open"}
159
+
160
+ dep = resolve_dep_ref("shared", tasks_dir=tasks_dir, scope_children=scope)
161
+ assert dep.kind == KIND_CHILD
162
+ assert dep.source == "task-map"
163
+ assert satisfaction_status(dep)[0] == NOT_SATISFIED
164
+
165
+ report = describe_dependencies(
166
+ task_dir, {"depends_on": ["shared"]}, tasks_dir=tasks_dir, scope_children=scope
167
+ )
168
+ assert report.deps[0].kind == KIND_CHILD
169
+ assert any("not satisfied" in w for w in report.warnings())
170
+
171
+
172
+ def test_child_integrated_is_satisfied_via_scope(tmp_path: Path) -> None:
173
+ tasks_dir = _tasks_fixture(tmp_path)
174
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["child-a"])
175
+ scope = {"child-a": "integrated", "child-b": "cancelled"}
176
+
177
+ dep = resolve_dep_ref("child-a", tasks_dir=tasks_dir, scope_children=scope)
178
+ assert dep.kind == KIND_CHILD
179
+ assert satisfaction_status(dep)[0] == SATISFIED
180
+
181
+ report = describe_dependencies(
182
+ task_dir, {"depends_on": ["child-a", "child-b"]}, tasks_dir=tasks_dir, scope_children=scope
183
+ )
184
+ assert satisfaction_status(report.deps[0])[0] == SATISFIED
185
+ assert report.deps[1].is_cancelled
186
+ assert any("cancelled" in w for w in report.warnings())
187
+
188
+
189
+ def test_empty_depends_on_has_no_warnings_or_dashboard_line(tmp_path: Path) -> None:
190
+ tasks_dir = _tasks_fixture(tmp_path)
191
+ task_dir = _write_task(tasks_dir, "task-x", "planning")
192
+ data = {"id": "task-x", "status": "planning"}
193
+
194
+ report = describe_dependencies(task_dir, data, tasks_dir=tasks_dir)
195
+ assert not report.has_deps()
196
+ assert report.warnings() == []
197
+ assert dashboard_deps_line(task_dir, data, tasks_dir=tasks_dir) is None
198
+
199
+
200
+ def test_dashboard_deps_line_renders_badges(tmp_path: Path) -> None:
201
+ tasks_dir = _tasks_fixture(tmp_path)
202
+ _write_task(tasks_dir, "dep-done", "completed")
203
+ _write_task(tasks_dir, "dep-wait", "in_progress")
204
+ _write_task(tasks_dir, "dep-gone", "cancelled")
205
+ task_dir = _write_task(
206
+ tasks_dir, "task-x", "planning", ["dep-done", "dep-wait", "dep-gone", "pool:P09"]
207
+ )
208
+ data = {
209
+ "id": "task-x",
210
+ "status": "planning",
211
+ "depends_on": ["dep-done", "dep-wait", "dep-gone", "pool:P09"],
212
+ }
213
+
214
+ line = dashboard_deps_line(task_dir, data, tasks_dir=tasks_dir)
215
+ assert line is not None
216
+ assert "dep-done ✅" in line
217
+ assert "dep-wait ⏳" in line
218
+ assert "dep-gone ✅已取消" in line
219
+ assert "pool:P09 ⚠️" in line
220
+
221
+
222
+ def test_normalize_dep_list_dedupes_and_strips(tmp_path: Path) -> None:
223
+ assert normalize_dep_list(None) == []
224
+ assert normalize_dep_list(["a", " a ", "b", "a", "", 5]) == ["a", "b"]
225
+ assert normalize_dep_list("not-a-list") == []
226
+
227
+
228
+ # =============================================================================
229
+ # pool: real status (08-09-pool-links-and-cli)
230
+ # =============================================================================
231
+
232
+ def _pool_repo(tmp_path: Path, linked_tasks: list[str] | None) -> Path:
233
+ repo = tmp_path / "repo"
234
+ items_dir = repo / ".cstl" / "pool" / "items"
235
+ items_dir.mkdir(parents=True)
236
+ lines = ["id: P09", "title: t", "status: accepted", "type: mechanism"]
237
+ if linked_tasks:
238
+ lines.append("linked_tasks:")
239
+ lines += [f" - {task}" for task in linked_tasks]
240
+ (items_dir / "P09.md").write_text(
241
+ "---\n" + "\n".join(lines) + "\n---\n\n## 意图\nx\n",
242
+ encoding="utf-8",
243
+ )
244
+ return repo
245
+
246
+
247
+ def test_pool_missing_item_is_unresolved(tmp_path: Path) -> None:
248
+ tasks_dir = _tasks_fixture(tmp_path)
249
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["pool:P09"])
250
+
251
+ dep = resolve_dep_ref("pool:P09", tasks_dir=tasks_dir)
252
+ assert dep.kind == KIND_POOL
253
+ status, note = satisfaction_status(dep)
254
+ assert status == UNRESOLVED
255
+ assert "not found" in (note or "")
256
+ report = describe_dependencies(task_dir, {"depends_on": ["pool:P09"]}, tasks_dir=tasks_dir)
257
+ assert any("unresolved" in w for w in report.warnings())
258
+
259
+
260
+ def test_pool_unlinked_is_unresolved(tmp_path: Path) -> None:
261
+ repo = _pool_repo(tmp_path, linked_tasks=None)
262
+ tasks_dir = repo / ".cstl" / "tasks"
263
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["pool:P09"])
264
+
265
+ dep = resolve_dep_ref("pool:P09", tasks_dir=tasks_dir)
266
+ assert dep.kind == KIND_POOL
267
+ status, note = satisfaction_status(dep)
268
+ assert status == UNRESOLVED
269
+ assert "no linked tasks" in (note or "")
270
+
271
+
272
+ def test_pool_linked_planning_task_is_not_satisfied(tmp_path: Path) -> None:
273
+ repo = _pool_repo(tmp_path, linked_tasks=["task-a"])
274
+ tasks_dir = repo / ".cstl" / "tasks"
275
+ _write_task(tasks_dir, "task-a", "planning")
276
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["pool:P09"])
277
+
278
+ dep = resolve_dep_ref("pool:P09", tasks_dir=tasks_dir)
279
+ assert dep.status == "not_satisfied"
280
+ status, note = satisfaction_status(dep)
281
+ assert status == NOT_SATISFIED
282
+ assert "not yet completed" in (note or "")
283
+ assert "task-a" in (note or "")
284
+ report = describe_dependencies(task_dir, {"depends_on": ["pool:P09"]}, tasks_dir=tasks_dir)
285
+ assert any("not satisfied" in w for w in report.warnings())
286
+
287
+
288
+ def test_pool_linked_completed_task_is_satisfied(tmp_path: Path) -> None:
289
+ repo = _pool_repo(tmp_path, linked_tasks=["task-a"])
290
+ tasks_dir = repo / ".cstl" / "tasks"
291
+ _write_task(tasks_dir, "task-a", "completed")
292
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["pool:P09"])
293
+
294
+ dep = resolve_dep_ref("pool:P09", tasks_dir=tasks_dir)
295
+ assert dep.status == "satisfied"
296
+ status, _ = satisfaction_status(dep)
297
+ assert status == SATISFIED
298
+ report = describe_dependencies(task_dir, {"depends_on": ["pool:P09"]}, tasks_dir=tasks_dir)
299
+ assert report.warnings() == []
300
+
301
+
302
+ def test_pool_multilink_partial_then_complete(tmp_path: Path) -> None:
303
+ repo = _pool_repo(tmp_path, linked_tasks=["task-a", "task-b"])
304
+ tasks_dir = repo / ".cstl" / "tasks"
305
+ _write_task(tasks_dir, "task-a", "completed")
306
+ _write_task(tasks_dir, "task-b", "in_progress")
307
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["pool:P09"])
308
+
309
+ dep = resolve_dep_ref("pool:P09", tasks_dir=tasks_dir)
310
+ status, note = satisfaction_status(dep)
311
+ assert status == NOT_SATISFIED
312
+ assert "task-a, task-b" in (note or "")
313
+
314
+ _write_task(tasks_dir, "task-b", "completed")
315
+ dep = resolve_dep_ref("pool:P09", tasks_dir=tasks_dir)
316
+ assert satisfaction_status(dep)[0] == SATISFIED
317
+
318
+
319
+ def test_pool_all_dangling_linked_tasks_is_unresolved(tmp_path: Path) -> None:
320
+ repo = _pool_repo(tmp_path, linked_tasks=["ghost-a"])
321
+ tasks_dir = repo / ".cstl" / "tasks"
322
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["pool:P09"])
323
+
324
+ dep = resolve_dep_ref("pool:P09", tasks_dir=tasks_dir)
325
+ assert dep.status == "unresolved"
326
+ status, note = satisfaction_status(dep)
327
+ assert status == UNRESOLVED
328
+ report = describe_dependencies(task_dir, {"depends_on": ["pool:P09"]}, tasks_dir=tasks_dir)
329
+ assert any("unresolved" in w for w in report.warnings())
330
+
331
+
332
+ def test_pool_cancelled_linked_task_is_satisfied_with_cancelled_note(tmp_path: Path) -> None:
333
+ repo = _pool_repo(tmp_path, linked_tasks=["task-a"])
334
+ tasks_dir = repo / ".cstl" / "tasks"
335
+ _write_task(tasks_dir, "task-a", "cancelled")
336
+ task_dir = _write_task(tasks_dir, "task-x", "planning", ["pool:P09"])
337
+
338
+ dep = resolve_dep_ref("pool:P09", tasks_dir=tasks_dir)
339
+ assert dep.status == "cancelled"
340
+ assert dep.is_cancelled
341
+ status, note = satisfaction_status(dep)
342
+ assert status == SATISFIED
343
+ assert "cancelled" in (note or "")
344
+ report = describe_dependencies(task_dir, {"depends_on": ["pool:P09"]}, tasks_dir=tasks_dir)
345
+ assert any("cancelled" in w for w in report.warnings())
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Review-pool CLI: validate / plan-check / link / unlink / show.
5
+
6
+ Usage:
7
+ python pool.py validate [--root <repo>]
8
+ python pool.py plan-check [--root <repo>]
9
+ python pool.py link <item-id> <task-ref> [--root <repo>]
10
+ python pool.py unlink <item-id> <task-ref> [--root <repo>]
11
+ python pool.py show <item-id> [--root <repo>]
12
+
13
+ Exit codes: any error-level finding (or a failed link/unlink/show) -> 1;
14
+ warnings only -> 0.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from common.io import read_json
24
+ from common.log import Colors, colored
25
+ from common.paths import get_repo_root
26
+ from common import pool_store
27
+
28
+
29
+ def _print_issue(issue: pool_store.Issue) -> None:
30
+ color = Colors.RED if issue.is_error else Colors.YELLOW
31
+ level = "error" if issue.is_error else "warning"
32
+ location = f" ({issue.path})" if issue.path is not None else ""
33
+ print(colored(f"[{level}] {issue.code}: {issue.message}{location}", color))
34
+
35
+
36
+ def _exit_for_issues(issues: list[pool_store.Issue]) -> int:
37
+ errors = [issue for issue in issues if issue.is_error]
38
+ warnings = [issue for issue in issues if not issue.is_error]
39
+ for issue in issues:
40
+ _print_issue(issue)
41
+ if not issues:
42
+ print(colored("OK", Colors.GREEN))
43
+ elif not errors:
44
+ print(colored(f"OK with {len(warnings)} warning(s)", Colors.GREEN))
45
+ else:
46
+ print(colored(f"{len(errors)} error(s), {len(warnings)} warning(s)", Colors.RED))
47
+ return 1 if errors else 0
48
+
49
+
50
+ def cmd_validate(args: argparse.Namespace) -> int:
51
+ issues = pool_store.validate_pool(args.root)
52
+ return _exit_for_issues(issues)
53
+
54
+
55
+ def cmd_plan_check(args: argparse.Namespace) -> int:
56
+ issues = pool_store.check_plan(args.root)
57
+ return _exit_for_issues(issues)
58
+
59
+
60
+ def cmd_link(args: argparse.Namespace) -> int:
61
+ result = pool_store.link_item_task(args.root, args.item_id, args.task_ref)
62
+ for issue in result.errors:
63
+ _print_issue(issue)
64
+ if not result.ok:
65
+ return 1
66
+ print(colored(f"linked {result.item_id} <-> {result.task_ref}", Colors.GREEN))
67
+ item = pool_store.load_item(args.root, result.item_id)
68
+ if item is not None:
69
+ print(f" item.linked_tasks: {item.linked_tasks or '[]'}")
70
+ data = read_json(result.task_path / "task.json") if result.task_path else None
71
+ if data is not None:
72
+ print(f" task.meta.pool_items: {pool_store.get_pool_items(data) or '[]'}")
73
+ return 0
74
+
75
+
76
+ def cmd_unlink(args: argparse.Namespace) -> int:
77
+ result = pool_store.unlink_item_task(args.root, args.item_id, args.task_ref)
78
+ for issue in result.errors:
79
+ _print_issue(issue)
80
+ if not result.ok:
81
+ return 1
82
+ for issue in result.warnings:
83
+ _print_issue(issue)
84
+ print(colored(f"unlinked {result.item_id} <-> {result.task_ref}", Colors.GREEN))
85
+ return 0
86
+
87
+
88
+ def cmd_show(args: argparse.Namespace) -> int:
89
+ item = pool_store.load_item(args.root, args.item_id)
90
+ if item is None:
91
+ print(
92
+ colored(
93
+ f"[error] item-not-found: pool item {args.item_id!r} not found",
94
+ Colors.RED,
95
+ ),
96
+ file=sys.stderr,
97
+ )
98
+ return 1
99
+ tasks_dir = args.root / ".cstl" / "tasks"
100
+ print(f"id: {item.id or '<missing>'}")
101
+ print(f"title: {item.title or '<missing>'}")
102
+ print(f"status: {item.status or '<missing>'}")
103
+ print(f"type: {item.type or '<missing>'}")
104
+ print(f"path: {item.path}")
105
+ if not item.linked_tasks:
106
+ print("linked_tasks: []")
107
+ for ref in item.linked_tasks:
108
+ task_dir = pool_store.find_task_dir(ref, tasks_dir)
109
+ if task_dir is None:
110
+ print(f"linked_tasks: {ref} (missing)")
111
+ continue
112
+ data = read_json(task_dir / "task.json") or {}
113
+ status = data.get("status", "?")
114
+ archived = "archive/" in task_dir.as_posix()
115
+ suffix = " (archived)" if archived else ""
116
+ print(f"linked_tasks: {ref} -> {task_dir.name} ({status}){suffix}")
117
+ return 0
118
+
119
+
120
+ def build_parser() -> argparse.ArgumentParser:
121
+ parser = argparse.ArgumentParser(
122
+ prog="pool.py",
123
+ description="Review-pool maintenance CLI: validate, plan-check, link, unlink, show.",
124
+ )
125
+ parser.add_argument(
126
+ "--root",
127
+ type=Path,
128
+ default=None,
129
+ help="Repository root. Defaults to nearest parent containing .cstl/.",
130
+ )
131
+ subparsers = parser.add_subparsers(dest="command", required=True)
132
+
133
+ def add_root(entry_parser: argparse.ArgumentParser) -> None:
134
+ # SUPPRESS so a missing subcommand --root never clobbers the global
135
+ # --root with None (argparse subparser defaults overwrite parent values).
136
+ entry_parser.add_argument(
137
+ "--root",
138
+ type=Path,
139
+ default=argparse.SUPPRESS,
140
+ help="Repository root (overrides the global --root).",
141
+ )
142
+
143
+ validate_parser = subparsers.add_parser(
144
+ "validate",
145
+ help="Validate pool entries (sections, frontmatter, links, consistency).",
146
+ )
147
+ add_root(validate_parser)
148
+ plan_parser = subparsers.add_parser(
149
+ "plan-check",
150
+ help="Check plan.md references against known pool item ids.",
151
+ )
152
+ add_root(plan_parser)
153
+ link_parser = subparsers.add_parser(
154
+ "link",
155
+ help="Link a pool item to a task on both sides (idempotent).",
156
+ )
157
+ add_root(link_parser)
158
+ link_parser.add_argument("item_id", help="Pool item id, e.g. P01")
159
+ link_parser.add_argument("task_ref", help="Task dir name or resolvable ref")
160
+ unlink_parser = subparsers.add_parser(
161
+ "unlink",
162
+ help="Remove the item<->task link on both sides (best-effort).",
163
+ )
164
+ add_root(unlink_parser)
165
+ unlink_parser.add_argument("item_id", help="Pool item id, e.g. P01")
166
+ unlink_parser.add_argument("task_ref", help="Task dir name or resolvable ref")
167
+ show_parser = subparsers.add_parser(
168
+ "show",
169
+ help="Print one pool item with linked task statuses.",
170
+ )
171
+ add_root(show_parser)
172
+ show_parser.add_argument("item_id", help="Pool item id, e.g. P01")
173
+ return parser
174
+
175
+
176
+ def main(argv: list[str] | None = None) -> int:
177
+ args = build_parser().parse_args(argv)
178
+ root = getattr(args, "root", None)
179
+ args.root = root.resolve() if root is not None else get_repo_root()
180
+
181
+ handlers = {
182
+ "validate": cmd_validate,
183
+ "plan-check": cmd_plan_check,
184
+ "link": cmd_link,
185
+ "unlink": cmd_unlink,
186
+ "show": cmd_show,
187
+ }
188
+ return handlers[args.command](args)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ sys.exit(main())
@@ -17,6 +17,8 @@ Usage:
17
17
  python task.py set-branch <dir> <branch> # Set git branch
18
18
  python task.py set-base-branch <dir> <branch> # Set PR target branch
19
19
  python task.py set-scope <dir> <scope> # Set scope for PR title
20
+ python task.py set-deps <dir> <id...> # Set task-level depends_on (soft checks)
21
+ python task.py set-depends-mode <dir> <warn|block|off> # Dependency policy mode
20
22
  python task.py archive <task-dir> [--check] [--archive-integrated-children] # Check or archive completed task
21
23
  python task.py prepare-archive-evidence <task-dir> [--dry-run] # Draft missing verify.md archive evidence
22
24
  python task.py prepare-learning-scaffold <task-dir> [--trigger <text>] # Print spec-capture checklist (stdout only)
@@ -69,6 +71,7 @@ from common.task_gates import (
69
71
  write_gate_record,
70
72
  )
71
73
  from common.task_utils import resolve_task_dir, run_task_hooks
74
+ from common.task_dependencies import read_depends_mode
72
75
  from common.tasks import (
73
76
  children_progress,
74
77
  format_child_task_display,
@@ -86,6 +89,9 @@ from common.task_store import (
86
89
  cmd_set_branch,
87
90
  cmd_set_base_branch,
88
91
  cmd_set_scope,
92
+ cmd_set_deps,
93
+ cmd_set_depends_mode,
94
+ append_depends_ignore_event,
89
95
  cmd_add_subtask,
90
96
  cmd_remove_subtask,
91
97
  cmd_prepare_child_worktree,
@@ -241,6 +247,15 @@ def _print_guard_errors(items: list[str], stream=None) -> None:
241
247
  print(f" - {item}", file=stream)
242
248
 
243
249
 
250
+ def _print_dependency_warnings(guard) -> None:
251
+ """Print dependency warnings (Plan A soft checks; never affect PASS/FAIL)."""
252
+ for warning in guard.warnings:
253
+ print(
254
+ colored(f"[dependencies] WARN: {warning}", Colors.YELLOW),
255
+ file=sys.stderr,
256
+ )
257
+
258
+
244
259
  def cmd_start_execution(args: argparse.Namespace) -> int:
245
260
  """Start approved task execution after a non-mutating readiness check."""
246
261
  repo_root = get_repo_root()
@@ -259,6 +274,7 @@ def cmd_start_execution(args: argparse.Namespace) -> int:
259
274
  for hint in start_execution_repair_hints(guard.errors, task_dir):
260
275
  print(f" Hint: {hint}")
261
276
  return 1
277
+ _print_dependency_warnings(guard)
262
278
  print(colored("Start-execution check: PASS", Colors.GREEN))
263
279
  print(f"Contract fingerprint: {guard.contract_fingerprint}")
264
280
  baseline_fingerprint = guard.artifact_fingerprints.get(BASELINE_GATE)
@@ -294,13 +310,29 @@ def cmd_start_execution(args: argparse.Namespace) -> int:
294
310
  print("Run with --check for non-mutating preflight or --approved after explicit user approval.", file=sys.stderr)
295
311
  return 1
296
312
 
297
- guard = validate_start_execution(task_dir, task_data, approved=True)
313
+ ignore_deps = bool(getattr(args, "ignore_deps", False))
314
+ guard = validate_start_execution(
315
+ task_dir,
316
+ task_data,
317
+ approved=True,
318
+ ignore_deps=ignore_deps,
319
+ enforce_deps_block=True,
320
+ )
298
321
  if not guard.ok:
299
322
  print(colored("Error: cannot start execution; readiness check failed.", Colors.RED), file=sys.stderr)
300
323
  _print_guard_errors(guard.errors, stream=sys.stderr)
301
324
  return 1
302
325
 
326
+ _print_dependency_warnings(guard)
327
+
303
328
  assert task_data is not None
329
+ if ignore_deps and guard.deps_blocking_summary:
330
+ append_depends_ignore_event(
331
+ task_data,
332
+ command="start-execution",
333
+ mode=read_depends_mode(task_data),
334
+ blocking_summary=guard.deps_blocking_summary,
335
+ )
304
336
  if guard.baseline_record:
305
337
  write_gate_record(task_data, "start-execution", BASELINE_GATE, guard.baseline_record)
306
338
  for gate, record in guard.auto_gate_records.items():
@@ -537,6 +569,7 @@ Usage:
537
569
  python task.py set-branch <dir> <branch> Set git branch
538
570
  python task.py set-base-branch <dir> <branch> Set PR target branch
539
571
  python task.py set-scope <dir> <scope> Set scope for PR title
572
+ python task.py set-deps <dir> [dep ...] Set depends_on (no deps clears to [])
540
573
  python task.py archive <task-dir> [--check] Check or archive completed task
541
574
  python task.py add-subtask <parent> <child> Link child task to parent
542
575
  python task.py remove-subtask <parent> <child> Unlink child from parent
@@ -672,6 +705,11 @@ def main() -> int:
672
705
  help="Run non-mutating execution readiness check")
673
706
  p_start_execution.add_argument("--approved", action="store_true",
674
707
  help="Record explicit approval and start execution")
708
+ p_start_execution.add_argument(
709
+ "--ignore-deps",
710
+ action="store_true",
711
+ help="Override blocking dependencies (depends_mode=block) and record an audit event",
712
+ )
675
713
 
676
714
  # record-gate
677
715
  p_record_gate = subparsers.add_parser("record-gate", help="Record reviewer quality gate")
@@ -708,6 +746,31 @@ def main() -> int:
708
746
  p_scope.add_argument("dir", help="Task directory")
709
747
  p_scope.add_argument("scope", help="Scope name")
710
748
 
749
+ # set-deps
750
+ p_deps = subparsers.add_parser(
751
+ "set-deps",
752
+ help="Set task-level depends_on (declare only; soft checks in Plan A)",
753
+ )
754
+ p_deps.add_argument("dir", help="Task directory or name")
755
+ p_deps.add_argument(
756
+ "dep",
757
+ nargs="*",
758
+ help="Dependency task ids (bare id or pool:Pxx). No deps clears to [].",
759
+ )
760
+
761
+ # set-depends-mode
762
+ p_depends_mode = subparsers.add_parser(
763
+ "set-depends-mode",
764
+ help="Set meta.depends_mode (warn | block | off) for dependency policy",
765
+ )
766
+ p_depends_mode.add_argument("dir", help="Task directory or name")
767
+ p_depends_mode.add_argument(
768
+ "mode",
769
+ choices=["warn", "block", "off"],
770
+ help="warn = Plan A soft checks (default); block = hard gates at "
771
+ "start-execution --approved and set-child-state working; off = silent",
772
+ )
773
+
711
774
  # artifact-locale
712
775
  p_artifact_locale = subparsers.add_parser(
713
776
  "artifact-locale",
@@ -939,6 +1002,8 @@ def main() -> int:
939
1002
  "set-branch": cmd_set_branch,
940
1003
  "set-base-branch": cmd_set_base_branch,
941
1004
  "set-scope": cmd_set_scope,
1005
+ "set-deps": cmd_set_deps,
1006
+ "set-depends-mode": cmd_set_depends_mode,
942
1007
  "archive": cmd_archive,
943
1008
  "prepare-archive-evidence": cmd_prepare_archive_evidence,
944
1009
  "prepare-learning-scaffold": cmd_prepare_learning_scaffold,