@blxzer/cursor-trellis 0.3.5 → 0.4.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.
- package/CHANGELOG.md +69 -0
- package/README.md +13 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +3 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/goal/index.d.ts +3 -0
- package/dist/commands/goal/index.d.ts.map +1 -0
- package/dist/commands/goal/index.js +159 -0
- package/dist/commands/goal/index.js.map +1 -0
- package/dist/commands/goal/review.d.ts +6 -0
- package/dist/commands/goal/review.d.ts.map +1 -0
- package/dist/commands/goal/review.js +28 -0
- package/dist/commands/goal/review.js.map +1 -0
- package/dist/commands/sdk/run.d.ts.map +1 -1
- package/dist/commands/sdk/run.js +2 -27
- package/dist/commands/sdk/run.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +5 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +51 -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/goal/action-packet.d.ts +89 -0
- package/dist/goal/action-packet.d.ts.map +1 -0
- package/dist/goal/action-packet.js +113 -0
- package/dist/goal/action-packet.js.map +1 -0
- package/dist/goal/audit.d.ts +14 -0
- package/dist/goal/audit.d.ts.map +1 -0
- package/dist/goal/audit.js +12 -0
- package/dist/goal/audit.js.map +1 -0
- package/dist/goal/constants.d.ts +26 -0
- package/dist/goal/constants.d.ts.map +1 -0
- package/dist/goal/constants.js +24 -0
- package/dist/goal/constants.js.map +1 -0
- package/dist/goal/paths.d.ts +8 -0
- package/dist/goal/paths.d.ts.map +1 -0
- package/dist/goal/paths.js +36 -0
- package/dist/goal/paths.js.map +1 -0
- package/dist/goal/preflight.d.ts +21 -0
- package/dist/goal/preflight.d.ts.map +1 -0
- package/dist/goal/preflight.js +97 -0
- package/dist/goal/preflight.js.map +1 -0
- package/dist/goal/review-seam.d.ts +44 -0
- package/dist/goal/review-seam.d.ts.map +1 -0
- package/dist/goal/review-seam.js +153 -0
- package/dist/goal/review-seam.js.map +1 -0
- package/dist/goal/reviewer-rules.d.ts +13 -0
- package/dist/goal/reviewer-rules.d.ts.map +1 -0
- package/dist/goal/reviewer-rules.js +153 -0
- package/dist/goal/reviewer-rules.js.map +1 -0
- package/dist/goal/reviewer.d.ts +9 -0
- package/dist/goal/reviewer.d.ts.map +1 -0
- package/dist/goal/reviewer.js +54 -0
- package/dist/goal/reviewer.js.map +1 -0
- package/dist/goal/runtime.d.ts +32 -0
- package/dist/goal/runtime.d.ts.map +1 -0
- package/dist/goal/runtime.js +242 -0
- package/dist/goal/runtime.js.map +1 -0
- package/dist/goal/sdk-client.d.ts +10 -0
- package/dist/goal/sdk-client.d.ts.map +1 -0
- package/dist/goal/sdk-client.js +34 -0
- package/dist/goal/sdk-client.js.map +1 -0
- package/dist/goal/state.d.ts +40 -0
- package/dist/goal/state.d.ts.map +1 -0
- package/dist/goal/state.js +37 -0
- package/dist/goal/state.js.map +1 -0
- package/dist/goal/task-triggers.d.ts +15 -0
- package/dist/goal/task-triggers.d.ts.map +1 -0
- package/dist/goal/task-triggers.js +23 -0
- package/dist/goal/task-triggers.js.map +1 -0
- package/dist/goal/walls.d.ts +12 -0
- package/dist/goal/walls.d.ts.map +1 -0
- package/dist/goal/walls.js +65 -0
- package/dist/goal/walls.js.map +1 -0
- package/dist/goal/worker-prompt.d.ts +13 -0
- package/dist/goal/worker-prompt.d.ts.map +1 -0
- package/dist/goal/worker-prompt.js +41 -0
- package/dist/goal/worker-prompt.js.map +1 -0
- package/dist/goal/worker.d.ts +42 -0
- package/dist/goal/worker.d.ts.map +1 -0
- package/dist/goal/worker.js +180 -0
- package/dist/goal/worker.js.map +1 -0
- package/dist/migrations/manifests/0.3.5.json +9 -0
- package/dist/migrations/manifests/0.3.6.json +9 -0
- package/dist/migrations/manifests/0.4.0.json +9 -0
- package/dist/templates/common/commands/continue.md +15 -3
- package/dist/templates/common/skills/check.md +7 -4
- package/dist/templates/cursor/agents/cstl-check.md +1 -0
- package/dist/templates/cursor/fixtures/expected-rules.d.ts.map +1 -1
- package/dist/templates/cursor/fixtures/expected-rules.js +6 -1
- package/dist/templates/cursor/fixtures/expected-rules.js.map +1 -1
- package/dist/templates/cursor/rules/cstl-cursor-modes.mdc +32 -0
- package/dist/templates/cursor/rules/cstl-triage.mdc +2 -4
- package/dist/templates/cursor/rules/retrieval-routing.mdc +26 -7
- package/dist/templates/markdown/index.d.ts +9 -0
- package/dist/templates/markdown/index.d.ts.map +1 -1
- package/dist/templates/markdown/index.js +9 -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-context-injection-guide.md.txt +38 -21
- package/dist/templates/markdown/spec/guides/cursor-native-modes-guide.md.txt +125 -0
- 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 +144 -104
- package/dist/templates/markdown/spec/guides/injection-budget-guide.md.txt +111 -0
- package/dist/templates/markdown/spec/guides/prototype-guide.md.txt +139 -0
- package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +28 -6
- package/dist/templates/markdown/spec/guides/test-discipline-guide.md.txt +138 -0
- package/dist/templates/markdown/spec/guides/verification-strength-guide.md.txt +185 -0
- package/dist/templates/shared-hooks/session-start.py +10 -0
- package/dist/templates/trellis/config.yaml +18 -0
- package/dist/templates/trellis/index.d.ts +17 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +40 -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 +278 -0
- package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +37 -2
- package/dist/templates/trellis/scripts/common/config.py +39 -0
- package/dist/templates/trellis/scripts/common/injection_budget.py +317 -0
- package/dist/templates/trellis/scripts/common/parent_orchestration.py +161 -5
- package/dist/templates/trellis/scripts/common/pool_store.py +702 -0
- package/dist/templates/trellis/scripts/common/retrieval_agent_instructions.py +9 -3
- package/dist/templates/trellis/scripts/common/session_context.py +32 -4
- package/dist/templates/trellis/scripts/common/session_memory.py +26 -0
- package/dist/templates/trellis/scripts/common/subagent_dispatch.py +74 -9
- package/dist/templates/trellis/scripts/common/task_context.py +18 -0
- package/dist/templates/trellis/scripts/common/task_dashboard.py +24 -1
- 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_map.py +40 -8
- package/dist/templates/trellis/scripts/common/task_store.py +400 -39
- package/dist/templates/trellis/scripts/common/test_depends_mode_block.py +489 -0
- package/dist/templates/trellis/scripts/common/test_observable_defaults.py +135 -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/cursor_retrieval_probe_prompt.md +14 -12
- package/dist/templates/trellis/scripts/generate_dispatch_prompt.py +182 -0
- package/dist/templates/trellis/scripts/injection_budget_probe.py +69 -0
- package/dist/templates/trellis/scripts/pool.py +192 -0
- package/dist/templates/trellis/scripts/retrieval_probe_matrix_template.json +3 -3
- package/dist/templates/trellis/scripts/spec_health_outcomes.py +160 -0
- package/dist/templates/trellis/scripts/task.py +85 -1
- package/dist/templates/trellis/scripts/verify_evidence_probe.py +138 -0
- package/dist/templates/trellis/tasks/locale/en/default-prd.md +19 -0
- package/dist/templates/trellis/tasks/locale/zh/default-prd.md +19 -0
- package/dist/templates/trellis/workflow.md +55 -5
- package/package.json +2 -2
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Task-level depends_on resolution (Plan A: declare + soft checks).
|
|
4
|
+
|
|
5
|
+
Pure functions only: no task.json writes, no CLI, no repository state changes.
|
|
6
|
+
Consumers: start-execution --check warnings (task_gates), dashboard summary
|
|
7
|
+
(task_dashboard), set-deps dangling warnings (task_store).
|
|
8
|
+
|
|
9
|
+
Semantics per adjudication 08-09-depends-on-generalize-research (Plan A):
|
|
10
|
+
- Bare ids resolve in scope order: same-Parent task-map children first, then
|
|
11
|
+
global task dirs (.cstl/tasks/<id>, then archive/<month>/<id>).
|
|
12
|
+
- "pool:XXX" refs resolve against the review pool (.cstl/pool): the item's
|
|
13
|
+
linked_tasks decide satisfaction. Missing item or no linked tasks stays
|
|
14
|
+
UNRESOLVED; linked tasks use the same task-level satisfaction semantics
|
|
15
|
+
(all satisfied -> SATISFIED, any not satisfied -> NOT_SATISFIED, all
|
|
16
|
+
dangling -> UNRESOLVED).
|
|
17
|
+
- Task-level satisfaction: status `completed` (archive counts as completed)
|
|
18
|
+
or `cancelled`. Child-level satisfaction: task-map state in
|
|
19
|
+
PARENT_TERMINAL_STATES (integrated | cancelled). Everything else is
|
|
20
|
+
NOT_SATISFIED.
|
|
21
|
+
- Cycles and dangling refs are warning-level only in Plan A; nothing here
|
|
22
|
+
blocks a transition.
|
|
23
|
+
- Plan B (opt-in `meta.depends_mode: block`) promotes NOT_SATISFIED /
|
|
24
|
+
UNRESOLVED / cycles to errors at mutation gates via DependencyReport.
|
|
25
|
+
blocking_errors() (never SATISFIED+cancelled); the policy itself lives in
|
|
26
|
+
task_gates / task_store. read_depends_mode() resolves the task-level mode.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from .io import read_json
|
|
35
|
+
from .paths import get_repo_root, get_tasks_dir
|
|
36
|
+
from .task_map import PARENT_TERMINAL_STATES
|
|
37
|
+
|
|
38
|
+
POOL_PREFIX = "pool:"
|
|
39
|
+
|
|
40
|
+
SATISFIED = "SATISFIED"
|
|
41
|
+
NOT_SATISFIED = "NOT_SATISFIED"
|
|
42
|
+
UNRESOLVED = "UNRESOLVED"
|
|
43
|
+
|
|
44
|
+
TASK_SATISFIED_STATUSES = {"completed", "cancelled"}
|
|
45
|
+
|
|
46
|
+
MAX_GRAPH_NODES = 50
|
|
47
|
+
|
|
48
|
+
# Kinds of a resolved dependency reference.
|
|
49
|
+
KIND_TASK = "task"
|
|
50
|
+
KIND_CHILD = "child"
|
|
51
|
+
KIND_POOL = "pool"
|
|
52
|
+
KIND_MISSING = "missing"
|
|
53
|
+
|
|
54
|
+
# Where a resolved dependency came from (display annotation).
|
|
55
|
+
SOURCE_TASK_JSON = "task.json"
|
|
56
|
+
SOURCE_TASK_MAP = "task-map"
|
|
57
|
+
SOURCE_ARCHIVE = "archive"
|
|
58
|
+
|
|
59
|
+
# Plan B: meta.depends_mode values (missing / invalid -> warn = Plan A).
|
|
60
|
+
DEPENDS_MODE_DEFAULT = "warn"
|
|
61
|
+
DEPENDS_MODES = ("warn", "block", "off")
|
|
62
|
+
|
|
63
|
+
# Cap for meta.depends_ignore_events (append-only, FIFO drop oldest).
|
|
64
|
+
MAX_IGNORE_EVENTS = 20
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_depends_mode(task_data: object) -> str:
|
|
68
|
+
"""Resolve meta.depends_mode -> warn|block|off.
|
|
69
|
+
|
|
70
|
+
Missing key, null, non-dict meta, or an invalid value all fall back to
|
|
71
|
+
``warn`` so Plan B is strictly opt-in and never changes Plan A behavior.
|
|
72
|
+
"""
|
|
73
|
+
if not isinstance(task_data, dict):
|
|
74
|
+
return DEPENDS_MODE_DEFAULT
|
|
75
|
+
meta = task_data.get("meta")
|
|
76
|
+
if not isinstance(meta, dict):
|
|
77
|
+
return DEPENDS_MODE_DEFAULT
|
|
78
|
+
mode = meta.get("depends_mode")
|
|
79
|
+
if mode in DEPENDS_MODES:
|
|
80
|
+
return mode
|
|
81
|
+
return DEPENDS_MODE_DEFAULT
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ResolvedDep:
|
|
86
|
+
"""One declared dependency, resolved as far as Plan A can go."""
|
|
87
|
+
|
|
88
|
+
ref: str
|
|
89
|
+
kind: str
|
|
90
|
+
source: str | None = None
|
|
91
|
+
task_dir: Path | None = None
|
|
92
|
+
status: str | None = None
|
|
93
|
+
note: str | None = None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def is_cancelled(self) -> bool:
|
|
97
|
+
return self.kind in (KIND_TASK, KIND_CHILD, KIND_POOL) and self.status == "cancelled"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def is_archived(self) -> bool:
|
|
101
|
+
return self.source == SOURCE_ARCHIVE
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class DependencyReport:
|
|
106
|
+
"""Resolved dependencies plus task-level graph cycles for one task."""
|
|
107
|
+
|
|
108
|
+
task_id: str
|
|
109
|
+
deps: list[ResolvedDep] = field(default_factory=list)
|
|
110
|
+
cycles: list[list[str]] = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
def has_deps(self) -> bool:
|
|
113
|
+
return bool(self.deps)
|
|
114
|
+
|
|
115
|
+
def warnings(self) -> list[str]:
|
|
116
|
+
"""Warning strings for start-execution --check (never errors)."""
|
|
117
|
+
warnings: list[str] = []
|
|
118
|
+
for dep in self.deps:
|
|
119
|
+
status, note = satisfaction_status(dep)
|
|
120
|
+
if status == NOT_SATISFIED:
|
|
121
|
+
warnings.append(
|
|
122
|
+
f"dependency not satisfied: {dep.ref} ({note})"
|
|
123
|
+
)
|
|
124
|
+
elif status == UNRESOLVED:
|
|
125
|
+
warnings.append(_unresolved_issue_text(dep, note))
|
|
126
|
+
elif dep.is_cancelled:
|
|
127
|
+
warnings.append(
|
|
128
|
+
f"dependency cancelled: {dep.ref} ({note})"
|
|
129
|
+
)
|
|
130
|
+
for cycle in self.cycles:
|
|
131
|
+
warnings.append(f"dependency cycle detected: {' -> '.join(cycle)}")
|
|
132
|
+
return warnings
|
|
133
|
+
|
|
134
|
+
def blocking_errors(self) -> list[str]:
|
|
135
|
+
"""Errors for block-mode mutation gates (Plan B).
|
|
136
|
+
|
|
137
|
+
NOT_SATISFIED / UNRESOLVED (missing + pool) / cycles all block;
|
|
138
|
+
SATISFIED deps, including cancelled ones, never block. Every returned
|
|
139
|
+
string is also produced by warnings(), so the blocking set is always
|
|
140
|
+
a subset of the warning set for the same report (T14 invariant).
|
|
141
|
+
"""
|
|
142
|
+
errors: list[str] = []
|
|
143
|
+
for dep in self.deps:
|
|
144
|
+
status, note = satisfaction_status(dep)
|
|
145
|
+
if status == NOT_SATISFIED:
|
|
146
|
+
errors.append(
|
|
147
|
+
f"dependency not satisfied: {dep.ref} ({note})"
|
|
148
|
+
)
|
|
149
|
+
elif status == UNRESOLVED:
|
|
150
|
+
errors.append(_unresolved_issue_text(dep, note))
|
|
151
|
+
for cycle in self.cycles:
|
|
152
|
+
errors.append(f"dependency cycle detected: {' -> '.join(cycle)}")
|
|
153
|
+
return errors
|
|
154
|
+
|
|
155
|
+
def summary_parts(self) -> list[str]:
|
|
156
|
+
"""Compact per-dep badge strings for the dashboard line."""
|
|
157
|
+
parts: list[str] = []
|
|
158
|
+
for dep in self.deps:
|
|
159
|
+
status, _ = satisfaction_status(dep)
|
|
160
|
+
badge = {
|
|
161
|
+
SATISFIED: "✅",
|
|
162
|
+
NOT_SATISFIED: "⏳",
|
|
163
|
+
UNRESOLVED: "⚠️",
|
|
164
|
+
}[status]
|
|
165
|
+
if dep.is_cancelled:
|
|
166
|
+
badge += "已取消"
|
|
167
|
+
parts.append(f"{dep.ref} {badge}")
|
|
168
|
+
return parts
|
|
169
|
+
|
|
170
|
+
def sources(self) -> list[str]:
|
|
171
|
+
"""Distinct source annotations, e.g. [task.json, task-map]."""
|
|
172
|
+
seen: list[str] = []
|
|
173
|
+
for dep in self.deps:
|
|
174
|
+
if dep.source and dep.source not in seen:
|
|
175
|
+
seen.append(dep.source)
|
|
176
|
+
return seen
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _unresolved_issue_text(dep: ResolvedDep, note: str | None) -> str:
|
|
180
|
+
"""Shared text for UNRESOLVED deps (same wording in warnings and errors)."""
|
|
181
|
+
if dep.kind == KIND_POOL:
|
|
182
|
+
return f"dependency unresolved: {dep.ref} ({note})"
|
|
183
|
+
return f"dangling dependency: {dep.ref} ({note})"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def normalize_dep_list(raw: object) -> list[str]:
|
|
187
|
+
"""Normalize a declared depends_on value to a list of non-empty strings."""
|
|
188
|
+
if raw is None:
|
|
189
|
+
return []
|
|
190
|
+
if not isinstance(raw, list):
|
|
191
|
+
return []
|
|
192
|
+
normalized: list[str] = []
|
|
193
|
+
seen: set[str] = set()
|
|
194
|
+
for item in raw:
|
|
195
|
+
if not isinstance(item, str):
|
|
196
|
+
continue
|
|
197
|
+
value = item.strip()
|
|
198
|
+
if not value or value in seen:
|
|
199
|
+
continue
|
|
200
|
+
seen.add(value)
|
|
201
|
+
normalized.append(value)
|
|
202
|
+
return normalized
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def satisfaction_status(dep: ResolvedDep) -> tuple[str, str | None]:
|
|
206
|
+
"""Return (SATISFIED | NOT_SATISFIED | UNRESOLVED, note_or_None).
|
|
207
|
+
|
|
208
|
+
- pool: -> status decided by the linked tasks (resolved at dep resolution).
|
|
209
|
+
- missing -> UNRESOLVED (dangling; Plan A warns, never errors).
|
|
210
|
+
- task: completed (archive counts as completed) or cancelled -> SATISFIED.
|
|
211
|
+
- child: integrated or cancelled (PARENT_TERMINAL_STATES) -> SATISFIED.
|
|
212
|
+
"""
|
|
213
|
+
if dep.kind == KIND_POOL:
|
|
214
|
+
return _pool_satisfaction_status(dep)
|
|
215
|
+
if dep.kind == KIND_MISSING:
|
|
216
|
+
return (
|
|
217
|
+
UNRESOLVED,
|
|
218
|
+
f"no task or child with id {dep.ref!r} under tasks/ or archive/",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if dep.kind == KIND_CHILD:
|
|
222
|
+
if dep.status in PARENT_TERMINAL_STATES:
|
|
223
|
+
note = "dependency cancelled" if dep.is_cancelled else None
|
|
224
|
+
return SATISFIED, note
|
|
225
|
+
return (
|
|
226
|
+
NOT_SATISFIED,
|
|
227
|
+
f"state={dep.status!r}, need integrated or cancelled",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if dep.kind == KIND_TASK and dep.is_archived:
|
|
231
|
+
return SATISFIED, "archived task counts as completed"
|
|
232
|
+
|
|
233
|
+
if dep.status in TASK_SATISFIED_STATUSES:
|
|
234
|
+
note = "dependency cancelled" if dep.is_cancelled else None
|
|
235
|
+
return SATISFIED, note
|
|
236
|
+
return (
|
|
237
|
+
NOT_SATISFIED,
|
|
238
|
+
f"status={dep.status!r}, need completed or cancelled",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _pool_satisfaction_status(dep: ResolvedDep) -> tuple[str, str | None]:
|
|
243
|
+
"""Map the pool resolution result stored on dep.status to a status."""
|
|
244
|
+
if dep.status in ("satisfied", "cancelled"):
|
|
245
|
+
note = (
|
|
246
|
+
"pool entry satisfied via cancelled linked task(s)"
|
|
247
|
+
if dep.status == "cancelled"
|
|
248
|
+
else None
|
|
249
|
+
)
|
|
250
|
+
return SATISFIED, note
|
|
251
|
+
if dep.status == "not_satisfied":
|
|
252
|
+
return NOT_SATISFIED, _pool_note(
|
|
253
|
+
dep, "pool item has linked tasks not yet completed"
|
|
254
|
+
)
|
|
255
|
+
message = {
|
|
256
|
+
"missing_item": "pool item not found",
|
|
257
|
+
"unlinked": "pool item has no linked tasks",
|
|
258
|
+
}.get(dep.status, "pool item linked task(s) unresolved")
|
|
259
|
+
return UNRESOLVED, _pool_note(dep, message)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _pool_note(dep: ResolvedDep, message: str) -> str:
|
|
263
|
+
if dep.note:
|
|
264
|
+
return f"{message} ({dep.note})"
|
|
265
|
+
return message
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def find_dependency_cycles(graph: dict[str, list[str]]) -> list[list[str]]:
|
|
269
|
+
"""Return simple cycles in a task-level dependency graph.
|
|
270
|
+
|
|
271
|
+
Each cycle is a list of node ids in dependency order, e.g.
|
|
272
|
+
["task-a", "task-b", "task-a"]. Nodes with no outgoing edges are leaves.
|
|
273
|
+
"""
|
|
274
|
+
cycles: list[list[str]] = []
|
|
275
|
+
visited: set[str] = set()
|
|
276
|
+
path: list[str] = []
|
|
277
|
+
path_set: set[str] = set()
|
|
278
|
+
|
|
279
|
+
def dfs(node: str) -> None:
|
|
280
|
+
visited.add(node)
|
|
281
|
+
path.append(node)
|
|
282
|
+
path_set.add(node)
|
|
283
|
+
for neighbor in graph.get(node, []):
|
|
284
|
+
if neighbor in path_set:
|
|
285
|
+
start = path.index(neighbor)
|
|
286
|
+
cycle = path[start:] + [neighbor]
|
|
287
|
+
if not _has_same_cycle(cycles, cycle):
|
|
288
|
+
cycles.append(cycle)
|
|
289
|
+
elif neighbor not in visited:
|
|
290
|
+
dfs(neighbor)
|
|
291
|
+
path.pop()
|
|
292
|
+
path_set.discard(node)
|
|
293
|
+
|
|
294
|
+
for node in sorted(graph):
|
|
295
|
+
if node not in visited:
|
|
296
|
+
dfs(node)
|
|
297
|
+
return cycles
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _has_same_cycle(cycles: list[list[str]], candidate: list[str]) -> bool:
|
|
301
|
+
"""True when candidate is the same directed cycle as an existing one.
|
|
302
|
+
|
|
303
|
+
Comparison is rotation-agnostic: ["a","b","a"] == ["b","a","b"].
|
|
304
|
+
"""
|
|
305
|
+
for cycle in cycles:
|
|
306
|
+
if len(cycle) != len(candidate):
|
|
307
|
+
continue
|
|
308
|
+
double = cycle + cycle
|
|
309
|
+
for start in range(len(cycle)):
|
|
310
|
+
if all(
|
|
311
|
+
double[start + i] == candidate[i]
|
|
312
|
+
for i in range(len(candidate))
|
|
313
|
+
):
|
|
314
|
+
return True
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def resolve_dep_ref(
|
|
319
|
+
ref: str,
|
|
320
|
+
*,
|
|
321
|
+
repo_root: Path | None = None,
|
|
322
|
+
tasks_dir: Path | None = None,
|
|
323
|
+
scope_children: dict[str, str] | None = None,
|
|
324
|
+
) -> ResolvedDep:
|
|
325
|
+
"""Resolve one declared dependency reference (pure, read-only).
|
|
326
|
+
|
|
327
|
+
Scope order: explicit `pool:` prefix (never falls through) -> same-Parent
|
|
328
|
+
task-map children -> global task dirs -> archive dirs -> missing.
|
|
329
|
+
"""
|
|
330
|
+
value = ref.strip()
|
|
331
|
+
if not value:
|
|
332
|
+
return ResolvedDep(ref=ref, kind=KIND_MISSING)
|
|
333
|
+
|
|
334
|
+
if value.startswith(POOL_PREFIX):
|
|
335
|
+
return _resolve_pool_dep(
|
|
336
|
+
value, repo_root=repo_root, tasks_dir=tasks_dir
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if scope_children and value in scope_children:
|
|
340
|
+
state = scope_children.get(value)
|
|
341
|
+
return ResolvedDep(
|
|
342
|
+
ref=value,
|
|
343
|
+
kind=KIND_CHILD,
|
|
344
|
+
source=SOURCE_TASK_MAP,
|
|
345
|
+
status=state if isinstance(state, str) else None,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if tasks_dir is None:
|
|
349
|
+
if repo_root is None:
|
|
350
|
+
repo_root = get_repo_root()
|
|
351
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
352
|
+
|
|
353
|
+
task_dir = _find_task_dir(value, tasks_dir)
|
|
354
|
+
if task_dir is not None:
|
|
355
|
+
data = read_json(task_dir / "task.json") or {}
|
|
356
|
+
source = (
|
|
357
|
+
SOURCE_ARCHIVE if _is_under_archive(task_dir, tasks_dir) else SOURCE_TASK_JSON
|
|
358
|
+
)
|
|
359
|
+
return ResolvedDep(
|
|
360
|
+
ref=value,
|
|
361
|
+
kind=KIND_TASK,
|
|
362
|
+
source=source,
|
|
363
|
+
task_dir=task_dir,
|
|
364
|
+
status=data.get("status"),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return ResolvedDep(ref=value, kind=KIND_MISSING)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _resolve_pool_dep(
|
|
371
|
+
ref: str,
|
|
372
|
+
*,
|
|
373
|
+
repo_root: Path | None = None,
|
|
374
|
+
tasks_dir: Path | None = None,
|
|
375
|
+
) -> ResolvedDep:
|
|
376
|
+
"""Resolve a `pool:XXX` reference against the review pool.
|
|
377
|
+
|
|
378
|
+
The item's linked_tasks are resolved with the same task-level
|
|
379
|
+
satisfaction semantics; the aggregate is stored on dep.status:
|
|
380
|
+
missing_item / unlinked -> UNRESOLVED
|
|
381
|
+
satisfied / cancelled (all linked tasks done, one cancelled) -> SATISFIED
|
|
382
|
+
not_satisfied (any linked task incomplete) -> NOT_SATISFIED
|
|
383
|
+
unresolved (linked tasks all dangling) -> UNRESOLVED
|
|
384
|
+
"""
|
|
385
|
+
from .pool_store import get_linked_tasks, load_item
|
|
386
|
+
|
|
387
|
+
if tasks_dir is None:
|
|
388
|
+
if repo_root is None:
|
|
389
|
+
repo_root = get_repo_root()
|
|
390
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
391
|
+
if repo_root is None:
|
|
392
|
+
repo_root = tasks_dir.parent.parent
|
|
393
|
+
|
|
394
|
+
item = load_item(repo_root, ref[len(POOL_PREFIX) :].strip())
|
|
395
|
+
if item is None:
|
|
396
|
+
return ResolvedDep(ref=ref, kind=KIND_POOL, status="missing_item")
|
|
397
|
+
linked = get_linked_tasks(item)
|
|
398
|
+
if not linked:
|
|
399
|
+
return ResolvedDep(ref=ref, kind=KIND_POOL, status="unlinked")
|
|
400
|
+
|
|
401
|
+
leg_statuses: list[str] = []
|
|
402
|
+
any_cancelled = False
|
|
403
|
+
for task_ref in linked:
|
|
404
|
+
leg = resolve_dep_ref(task_ref, repo_root=repo_root, tasks_dir=tasks_dir)
|
|
405
|
+
leg_status, _ = satisfaction_status(leg)
|
|
406
|
+
leg_statuses.append(leg_status)
|
|
407
|
+
any_cancelled = any_cancelled or leg.is_cancelled
|
|
408
|
+
|
|
409
|
+
if any(status == NOT_SATISFIED for status in leg_statuses):
|
|
410
|
+
aggregate = "not_satisfied"
|
|
411
|
+
elif all(status == SATISFIED for status in leg_statuses):
|
|
412
|
+
aggregate = "cancelled" if any_cancelled else "satisfied"
|
|
413
|
+
else:
|
|
414
|
+
aggregate = "unresolved"
|
|
415
|
+
return ResolvedDep(
|
|
416
|
+
ref=ref,
|
|
417
|
+
kind=KIND_POOL,
|
|
418
|
+
status=aggregate,
|
|
419
|
+
note=f"linked: {', '.join(linked)}",
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _find_task_dir(ref: str, tasks_dir: Path) -> Path | None:
|
|
424
|
+
"""Exact dir-name match first, then suffix match, then archive dirs."""
|
|
425
|
+
candidates = [tasks_dir / ref]
|
|
426
|
+
if tasks_dir.is_dir():
|
|
427
|
+
for d in sorted(tasks_dir.iterdir()):
|
|
428
|
+
if d.is_dir() and d.name != "archive" and d.name.endswith(f"-{ref}"):
|
|
429
|
+
candidates.append(d)
|
|
430
|
+
archive_root = tasks_dir / "archive"
|
|
431
|
+
if archive_root.is_dir():
|
|
432
|
+
for month_dir in sorted(archive_root.iterdir()):
|
|
433
|
+
if not month_dir.is_dir():
|
|
434
|
+
continue
|
|
435
|
+
candidates.append(month_dir / ref)
|
|
436
|
+
for d in sorted(month_dir.iterdir()):
|
|
437
|
+
if d.is_dir() and d.name.endswith(f"-{ref}"):
|
|
438
|
+
candidates.append(d)
|
|
439
|
+
for candidate in candidates:
|
|
440
|
+
if candidate.is_dir() and (candidate / "task.json").is_file():
|
|
441
|
+
return candidate
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _is_under_archive(task_dir: Path, tasks_dir: Path) -> bool:
|
|
446
|
+
try:
|
|
447
|
+
return task_dir.resolve().is_relative_to(
|
|
448
|
+
(tasks_dir / "archive").resolve()
|
|
449
|
+
)
|
|
450
|
+
except (OSError, ValueError):
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def scope_children_for_task(
|
|
455
|
+
task_dir: Path,
|
|
456
|
+
task_data: dict | None,
|
|
457
|
+
*,
|
|
458
|
+
repo_root: Path | None = None,
|
|
459
|
+
tasks_dir: Path | None = None,
|
|
460
|
+
) -> dict[str, str] | None:
|
|
461
|
+
"""Build an id->state scope map from the closest Parent task-map.
|
|
462
|
+
|
|
463
|
+
A child task resolves against its parent's task-map children; a Parent
|
|
464
|
+
task resolves against its own children (a Parent may depend on a Child).
|
|
465
|
+
Returns None when there is no task-map to scope against.
|
|
466
|
+
"""
|
|
467
|
+
from .task_map import load_task_map
|
|
468
|
+
|
|
469
|
+
if tasks_dir is None:
|
|
470
|
+
if repo_root is None:
|
|
471
|
+
repo_root = get_repo_root()
|
|
472
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
473
|
+
|
|
474
|
+
parent_name = None
|
|
475
|
+
if isinstance(task_data, dict):
|
|
476
|
+
parent = task_data.get("parent")
|
|
477
|
+
if isinstance(parent, str) and parent.strip():
|
|
478
|
+
parent_name = parent.strip()
|
|
479
|
+
scope_dir = (
|
|
480
|
+
tasks_dir / parent_name if parent_name else task_dir
|
|
481
|
+
)
|
|
482
|
+
if parent_name is not None and not scope_dir.is_dir():
|
|
483
|
+
found = _find_task_dir(parent_name, tasks_dir)
|
|
484
|
+
scope_dir = found if found is not None else scope_dir
|
|
485
|
+
|
|
486
|
+
data, _ = load_task_map(scope_dir)
|
|
487
|
+
if not isinstance(data, dict):
|
|
488
|
+
return None
|
|
489
|
+
scope: dict[str, str] = {}
|
|
490
|
+
for child in data.get("children", []):
|
|
491
|
+
if not isinstance(child, dict):
|
|
492
|
+
continue
|
|
493
|
+
child_id = child.get("id")
|
|
494
|
+
if not isinstance(child_id, str) or not child_id:
|
|
495
|
+
continue
|
|
496
|
+
state = child.get("state")
|
|
497
|
+
scope[child_id] = state if isinstance(state, str) else None
|
|
498
|
+
return scope or None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def describe_dependencies(
|
|
502
|
+
task_dir: Path,
|
|
503
|
+
task_data: dict | None,
|
|
504
|
+
*,
|
|
505
|
+
repo_root: Path | None = None,
|
|
506
|
+
tasks_dir: Path | None = None,
|
|
507
|
+
scope_children: dict[str, str] | None = None,
|
|
508
|
+
) -> DependencyReport:
|
|
509
|
+
"""Describe the declared dependencies of one task (pure, read-only).
|
|
510
|
+
|
|
511
|
+
The dependency graph is expanded through resolved task dirs (their own
|
|
512
|
+
task.json depends_on) so cross-task cycles can be detected; the declared
|
|
513
|
+
task's scope applies only to its own refs.
|
|
514
|
+
"""
|
|
515
|
+
if repo_root is None:
|
|
516
|
+
repo_root = get_repo_root()
|
|
517
|
+
if tasks_dir is None:
|
|
518
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
519
|
+
|
|
520
|
+
task_id = task_dir.name
|
|
521
|
+
raw = task_data.get("depends_on") if isinstance(task_data, dict) else None
|
|
522
|
+
deps = [resolve_dep_ref(ref, tasks_dir=tasks_dir, scope_children=scope_children) for ref in normalize_dep_list(raw)]
|
|
523
|
+
|
|
524
|
+
graph = build_dependency_graph(
|
|
525
|
+
task_id,
|
|
526
|
+
deps,
|
|
527
|
+
tasks_dir=tasks_dir,
|
|
528
|
+
max_nodes=MAX_GRAPH_NODES,
|
|
529
|
+
)
|
|
530
|
+
cycles = find_dependency_cycles(graph)
|
|
531
|
+
return DependencyReport(task_id=task_id, deps=deps, cycles=cycles)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def describe_child_dependencies(
|
|
535
|
+
child_task_dir: Path,
|
|
536
|
+
child_data: dict | None,
|
|
537
|
+
task_map_entry: dict | None,
|
|
538
|
+
*,
|
|
539
|
+
repo_root: Path | None = None,
|
|
540
|
+
tasks_dir: Path | None = None,
|
|
541
|
+
) -> DependencyReport:
|
|
542
|
+
"""Describe the union of a Child's dependency edges (Plan B, set-child-state).
|
|
543
|
+
|
|
544
|
+
Both sources are combined into one report so the blocking check and cycle
|
|
545
|
+
detection see the merged graph:
|
|
546
|
+
1. Child task.json `depends_on` (task-level, may cross Parents).
|
|
547
|
+
2. Parent task-map `children[].depends_on` (Child-level ordering).
|
|
548
|
+
|
|
549
|
+
Either source blocking blocks the mutation (AND semantics); duplicates
|
|
550
|
+
between the sources are resolved once.
|
|
551
|
+
"""
|
|
552
|
+
merged = dict(child_data) if isinstance(child_data, dict) else {}
|
|
553
|
+
task_json_deps = normalize_dep_list(merged.get("depends_on"))
|
|
554
|
+
map_deps = normalize_dep_list((task_map_entry or {}).get("depends_on"))
|
|
555
|
+
merged["depends_on"] = task_json_deps + [
|
|
556
|
+
ref for ref in map_deps if ref not in task_json_deps
|
|
557
|
+
]
|
|
558
|
+
return describe_dependencies(
|
|
559
|
+
child_task_dir,
|
|
560
|
+
merged,
|
|
561
|
+
repo_root=repo_root,
|
|
562
|
+
tasks_dir=tasks_dir,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def build_dependency_graph(
|
|
567
|
+
start_id: str,
|
|
568
|
+
start_deps: list[ResolvedDep],
|
|
569
|
+
*,
|
|
570
|
+
tasks_dir: Path,
|
|
571
|
+
max_nodes: int = MAX_GRAPH_NODES,
|
|
572
|
+
) -> dict[str, list[str]]:
|
|
573
|
+
"""Expand a task-level dependency graph starting from start_deps.
|
|
574
|
+
|
|
575
|
+
Neighbor ids are task dir names (kind=task) or scoped child ids
|
|
576
|
+
(kind=child). Each newly discovered task node is itself expanded from its
|
|
577
|
+
task.json depends_on so cross-task cycles are visible. Scoped children
|
|
578
|
+
are leaves (their ordering lives in the Parent task-map, not task.json).
|
|
579
|
+
"""
|
|
580
|
+
graph: dict[str, list[str]] = {}
|
|
581
|
+
seen: set[str] = set()
|
|
582
|
+
queue: list[tuple[str, list[ResolvedDep]]] = [(start_id, start_deps)]
|
|
583
|
+
|
|
584
|
+
while queue and len(seen) < max_nodes:
|
|
585
|
+
node_id, node_deps = queue.pop(0)
|
|
586
|
+
if node_id in seen:
|
|
587
|
+
continue
|
|
588
|
+
seen.add(node_id)
|
|
589
|
+
neighbors: list[str] = []
|
|
590
|
+
for dep in node_deps:
|
|
591
|
+
neighbor_id = None
|
|
592
|
+
if dep.kind == KIND_TASK and dep.task_dir is not None:
|
|
593
|
+
neighbor_id = dep.task_dir.name
|
|
594
|
+
elif dep.kind == KIND_CHILD:
|
|
595
|
+
neighbor_id = dep.ref
|
|
596
|
+
if neighbor_id is None:
|
|
597
|
+
continue
|
|
598
|
+
neighbors.append(neighbor_id)
|
|
599
|
+
if neighbor_id in seen or neighbor_id == node_id:
|
|
600
|
+
continue
|
|
601
|
+
if dep.kind == KIND_TASK and dep.task_dir is not None:
|
|
602
|
+
data = read_json(dep.task_dir / "task.json") or {}
|
|
603
|
+
nested = [
|
|
604
|
+
resolve_dep_ref(ref, tasks_dir=tasks_dir)
|
|
605
|
+
for ref in normalize_dep_list(data.get("depends_on"))
|
|
606
|
+
]
|
|
607
|
+
queue.append((neighbor_id, nested))
|
|
608
|
+
graph[node_id] = neighbors
|
|
609
|
+
return graph
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def dashboard_deps_line(
|
|
613
|
+
task_dir: Path,
|
|
614
|
+
task_data: dict,
|
|
615
|
+
*,
|
|
616
|
+
repo_root: Path | None = None,
|
|
617
|
+
tasks_dir: Path | None = None,
|
|
618
|
+
parent_dir: Path | None = None,
|
|
619
|
+
) -> str | None:
|
|
620
|
+
"""One dashboard summary line for non-empty dependencies, else None.
|
|
621
|
+
|
|
622
|
+
Combines task.json depends_on with the Parent task-map child depends_on
|
|
623
|
+
when parent_dir is provided (Child level keeps task-map as single source
|
|
624
|
+
of truth; sources are annotated on the line).
|
|
625
|
+
"""
|
|
626
|
+
if repo_root is None:
|
|
627
|
+
repo_root = get_repo_root()
|
|
628
|
+
if tasks_dir is None:
|
|
629
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
630
|
+
|
|
631
|
+
scope = scope_children_for_task(
|
|
632
|
+
task_dir, task_data, tasks_dir=tasks_dir
|
|
633
|
+
)
|
|
634
|
+
report = describe_dependencies(
|
|
635
|
+
task_dir,
|
|
636
|
+
task_data,
|
|
637
|
+
tasks_dir=tasks_dir,
|
|
638
|
+
scope_children=scope,
|
|
639
|
+
)
|
|
640
|
+
if not report.has_deps() and parent_dir is None:
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
from .task_map import load_task_map
|
|
644
|
+
|
|
645
|
+
if parent_dir is not None:
|
|
646
|
+
map_data, _ = load_task_map(parent_dir)
|
|
647
|
+
if isinstance(map_data, dict):
|
|
648
|
+
child_entry = next(
|
|
649
|
+
(
|
|
650
|
+
child
|
|
651
|
+
for child in map_data.get("children", [])
|
|
652
|
+
if isinstance(child, dict)
|
|
653
|
+
and child.get("id") == task_dir.name
|
|
654
|
+
),
|
|
655
|
+
None,
|
|
656
|
+
)
|
|
657
|
+
if isinstance(child_entry, dict):
|
|
658
|
+
for raw_ref in normalize_dep_list(child_entry.get("depends_on")):
|
|
659
|
+
dep = resolve_dep_ref(
|
|
660
|
+
raw_ref,
|
|
661
|
+
tasks_dir=tasks_dir,
|
|
662
|
+
scope_children=scope,
|
|
663
|
+
)
|
|
664
|
+
if not any(existing.ref == dep.ref for existing in report.deps):
|
|
665
|
+
report.deps.append(dep)
|
|
666
|
+
|
|
667
|
+
if not report.has_deps():
|
|
668
|
+
return None
|
|
669
|
+
|
|
670
|
+
parts = report.summary_parts()
|
|
671
|
+
sources = report.sources()
|
|
672
|
+
suffix = f" [{', '.join(sources)}]" if sources else ""
|
|
673
|
+
return f"deps: {' · '.join(parts)}{suffix}"
|