@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.
- package/CHANGELOG.md +39 -1
- package/README.md +10 -6
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +11 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/configurators/shared.d.ts.map +1 -1
- package/dist/configurators/shared.js +2 -1
- package/dist/configurators/shared.js.map +1 -1
- package/dist/configurators/workflow.d.ts +7 -0
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +61 -2
- package/dist/configurators/workflow.js.map +1 -1
- package/dist/constants/paths.d.ts +4 -0
- package/dist/constants/paths.d.ts.map +1 -1
- package/dist/constants/paths.js +4 -0
- package/dist/constants/paths.js.map +1 -1
- package/dist/migrations/manifests/0.4.0.json +9 -0
- package/dist/migrations/manifests/0.4.1.json +9 -0
- package/dist/templates/common/{skills/check.md → bundled-skills/cstl-check/SKILL.md} +55 -7
- package/dist/templates/common/bundled-skills/cstl-check/references/fowler-12-smells.md +28 -0
- package/dist/templates/common/bundled-skills/cstl-handoff/SKILL.md +117 -0
- package/dist/templates/common/bundled-skills/cstl-micro-grill/SKILL.md +6 -0
- package/dist/templates/common/skills/brainstorm.md +15 -0
- package/dist/templates/cursor/agents/cstl-check.md +14 -0
- package/dist/templates/cursor/commands/handoff.md +99 -0
- package/dist/templates/markdown/index.d.ts +6 -0
- package/dist/templates/markdown/index.d.ts.map +1 -1
- package/dist/templates/markdown/index.js +6 -0
- package/dist/templates/markdown/index.js.map +1 -1
- package/dist/templates/markdown/spec/guides/artifact-locale-guide.md.txt +93 -0
- package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +7 -7
- package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +10 -8
- package/dist/templates/markdown/spec/guides/debug-loop-guide.md.txt +227 -0
- package/dist/templates/markdown/spec/guides/goal-release-regression-runbook.md.txt +132 -0
- package/dist/templates/markdown/spec/guides/index.md.txt +37 -0
- package/dist/templates/markdown/spec/guides/prototype-guide.md.txt +139 -0
- package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +4 -0
- package/dist/templates/markdown/spec/guides/test-discipline-guide.md.txt +138 -0
- package/dist/templates/markdown/spec/guides/verification-strength-guide.md.txt +1 -0
- package/dist/templates/trellis/CONTEXT.md +53 -0
- package/dist/templates/trellis/docs/adr/README.md +21 -0
- package/dist/templates/trellis/index.d.ts +14 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +29 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/pool/README.md +103 -0
- package/dist/templates/trellis/pool/items/.gitkeep +0 -0
- package/dist/templates/trellis/pool/plan.md +26 -0
- package/dist/templates/trellis/scripts/common/artifact_locale.py +16 -0
- package/dist/templates/trellis/scripts/common/pool_store.py +702 -0
- package/dist/templates/trellis/scripts/common/task_dashboard.py +8 -0
- package/dist/templates/trellis/scripts/common/task_dependencies.py +673 -0
- package/dist/templates/trellis/scripts/common/task_gates.py +58 -5
- package/dist/templates/trellis/scripts/common/task_store.py +256 -0
- package/dist/templates/trellis/scripts/common/test_depends_mode_block.py +489 -0
- package/dist/templates/trellis/scripts/common/test_pool_store.py +428 -0
- package/dist/templates/trellis/scripts/common/test_task_dependencies.py +345 -0
- package/dist/templates/trellis/scripts/pool.py +192 -0
- package/dist/templates/trellis/scripts/task.py +66 -1
- package/dist/templates/trellis/scripts/verify_evidence_probe.py +138 -0
- package/dist/templates/trellis/tasks/locale/en/default-prd.md +8 -0
- package/dist/templates/trellis/tasks/locale/zh/default-prd.md +8 -0
- package/dist/templates/trellis/workflow.md +34 -2
- package/dist/utils/manifest-prune.d.ts.map +1 -1
- package/dist/utils/manifest-prune.js +7 -0
- package/dist/utils/manifest-prune.js.map +1 -1
- 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
|
-
|
|
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,
|