@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.
Files changed (156) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +13 -0
  3. package/dist/cli/index.d.ts.map +1 -1
  4. package/dist/cli/index.js +3 -0
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/commands/goal/index.d.ts +3 -0
  7. package/dist/commands/goal/index.d.ts.map +1 -0
  8. package/dist/commands/goal/index.js +159 -0
  9. package/dist/commands/goal/index.js.map +1 -0
  10. package/dist/commands/goal/review.d.ts +6 -0
  11. package/dist/commands/goal/review.d.ts.map +1 -0
  12. package/dist/commands/goal/review.js +28 -0
  13. package/dist/commands/goal/review.js.map +1 -0
  14. package/dist/commands/sdk/run.d.ts.map +1 -1
  15. package/dist/commands/sdk/run.js +2 -27
  16. package/dist/commands/sdk/run.js.map +1 -1
  17. package/dist/commands/update.d.ts.map +1 -1
  18. package/dist/commands/update.js +5 -1
  19. package/dist/commands/update.js.map +1 -1
  20. package/dist/configurators/workflow.d.ts.map +1 -1
  21. package/dist/configurators/workflow.js +51 -2
  22. package/dist/configurators/workflow.js.map +1 -1
  23. package/dist/constants/paths.d.ts +4 -0
  24. package/dist/constants/paths.d.ts.map +1 -1
  25. package/dist/constants/paths.js +4 -0
  26. package/dist/constants/paths.js.map +1 -1
  27. package/dist/goal/action-packet.d.ts +89 -0
  28. package/dist/goal/action-packet.d.ts.map +1 -0
  29. package/dist/goal/action-packet.js +113 -0
  30. package/dist/goal/action-packet.js.map +1 -0
  31. package/dist/goal/audit.d.ts +14 -0
  32. package/dist/goal/audit.d.ts.map +1 -0
  33. package/dist/goal/audit.js +12 -0
  34. package/dist/goal/audit.js.map +1 -0
  35. package/dist/goal/constants.d.ts +26 -0
  36. package/dist/goal/constants.d.ts.map +1 -0
  37. package/dist/goal/constants.js +24 -0
  38. package/dist/goal/constants.js.map +1 -0
  39. package/dist/goal/paths.d.ts +8 -0
  40. package/dist/goal/paths.d.ts.map +1 -0
  41. package/dist/goal/paths.js +36 -0
  42. package/dist/goal/paths.js.map +1 -0
  43. package/dist/goal/preflight.d.ts +21 -0
  44. package/dist/goal/preflight.d.ts.map +1 -0
  45. package/dist/goal/preflight.js +97 -0
  46. package/dist/goal/preflight.js.map +1 -0
  47. package/dist/goal/review-seam.d.ts +44 -0
  48. package/dist/goal/review-seam.d.ts.map +1 -0
  49. package/dist/goal/review-seam.js +153 -0
  50. package/dist/goal/review-seam.js.map +1 -0
  51. package/dist/goal/reviewer-rules.d.ts +13 -0
  52. package/dist/goal/reviewer-rules.d.ts.map +1 -0
  53. package/dist/goal/reviewer-rules.js +153 -0
  54. package/dist/goal/reviewer-rules.js.map +1 -0
  55. package/dist/goal/reviewer.d.ts +9 -0
  56. package/dist/goal/reviewer.d.ts.map +1 -0
  57. package/dist/goal/reviewer.js +54 -0
  58. package/dist/goal/reviewer.js.map +1 -0
  59. package/dist/goal/runtime.d.ts +32 -0
  60. package/dist/goal/runtime.d.ts.map +1 -0
  61. package/dist/goal/runtime.js +242 -0
  62. package/dist/goal/runtime.js.map +1 -0
  63. package/dist/goal/sdk-client.d.ts +10 -0
  64. package/dist/goal/sdk-client.d.ts.map +1 -0
  65. package/dist/goal/sdk-client.js +34 -0
  66. package/dist/goal/sdk-client.js.map +1 -0
  67. package/dist/goal/state.d.ts +40 -0
  68. package/dist/goal/state.d.ts.map +1 -0
  69. package/dist/goal/state.js +37 -0
  70. package/dist/goal/state.js.map +1 -0
  71. package/dist/goal/task-triggers.d.ts +15 -0
  72. package/dist/goal/task-triggers.d.ts.map +1 -0
  73. package/dist/goal/task-triggers.js +23 -0
  74. package/dist/goal/task-triggers.js.map +1 -0
  75. package/dist/goal/walls.d.ts +12 -0
  76. package/dist/goal/walls.d.ts.map +1 -0
  77. package/dist/goal/walls.js +65 -0
  78. package/dist/goal/walls.js.map +1 -0
  79. package/dist/goal/worker-prompt.d.ts +13 -0
  80. package/dist/goal/worker-prompt.d.ts.map +1 -0
  81. package/dist/goal/worker-prompt.js +41 -0
  82. package/dist/goal/worker-prompt.js.map +1 -0
  83. package/dist/goal/worker.d.ts +42 -0
  84. package/dist/goal/worker.d.ts.map +1 -0
  85. package/dist/goal/worker.js +180 -0
  86. package/dist/goal/worker.js.map +1 -0
  87. package/dist/migrations/manifests/0.3.5.json +9 -0
  88. package/dist/migrations/manifests/0.3.6.json +9 -0
  89. package/dist/migrations/manifests/0.4.0.json +9 -0
  90. package/dist/templates/common/commands/continue.md +15 -3
  91. package/dist/templates/common/skills/check.md +7 -4
  92. package/dist/templates/cursor/agents/cstl-check.md +1 -0
  93. package/dist/templates/cursor/fixtures/expected-rules.d.ts.map +1 -1
  94. package/dist/templates/cursor/fixtures/expected-rules.js +6 -1
  95. package/dist/templates/cursor/fixtures/expected-rules.js.map +1 -1
  96. package/dist/templates/cursor/rules/cstl-cursor-modes.mdc +32 -0
  97. package/dist/templates/cursor/rules/cstl-triage.mdc +2 -4
  98. package/dist/templates/cursor/rules/retrieval-routing.mdc +26 -7
  99. package/dist/templates/markdown/index.d.ts +9 -0
  100. package/dist/templates/markdown/index.d.ts.map +1 -1
  101. package/dist/templates/markdown/index.js +9 -0
  102. package/dist/templates/markdown/index.js.map +1 -1
  103. package/dist/templates/markdown/spec/guides/artifact-locale-guide.md.txt +93 -0
  104. package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +7 -7
  105. package/dist/templates/markdown/spec/guides/cursor-context-injection-guide.md.txt +38 -21
  106. package/dist/templates/markdown/spec/guides/cursor-native-modes-guide.md.txt +125 -0
  107. package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +10 -8
  108. package/dist/templates/markdown/spec/guides/debug-loop-guide.md.txt +227 -0
  109. package/dist/templates/markdown/spec/guides/goal-release-regression-runbook.md.txt +132 -0
  110. package/dist/templates/markdown/spec/guides/index.md.txt +144 -104
  111. package/dist/templates/markdown/spec/guides/injection-budget-guide.md.txt +111 -0
  112. package/dist/templates/markdown/spec/guides/prototype-guide.md.txt +139 -0
  113. package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +28 -6
  114. package/dist/templates/markdown/spec/guides/test-discipline-guide.md.txt +138 -0
  115. package/dist/templates/markdown/spec/guides/verification-strength-guide.md.txt +185 -0
  116. package/dist/templates/shared-hooks/session-start.py +10 -0
  117. package/dist/templates/trellis/config.yaml +18 -0
  118. package/dist/templates/trellis/index.d.ts +17 -0
  119. package/dist/templates/trellis/index.d.ts.map +1 -1
  120. package/dist/templates/trellis/index.js +40 -0
  121. package/dist/templates/trellis/index.js.map +1 -1
  122. package/dist/templates/trellis/pool/README.md +103 -0
  123. package/dist/templates/trellis/pool/items/.gitkeep +0 -0
  124. package/dist/templates/trellis/pool/plan.md +26 -0
  125. package/dist/templates/trellis/scripts/common/artifact_locale.py +278 -0
  126. package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +37 -2
  127. package/dist/templates/trellis/scripts/common/config.py +39 -0
  128. package/dist/templates/trellis/scripts/common/injection_budget.py +317 -0
  129. package/dist/templates/trellis/scripts/common/parent_orchestration.py +161 -5
  130. package/dist/templates/trellis/scripts/common/pool_store.py +702 -0
  131. package/dist/templates/trellis/scripts/common/retrieval_agent_instructions.py +9 -3
  132. package/dist/templates/trellis/scripts/common/session_context.py +32 -4
  133. package/dist/templates/trellis/scripts/common/session_memory.py +26 -0
  134. package/dist/templates/trellis/scripts/common/subagent_dispatch.py +74 -9
  135. package/dist/templates/trellis/scripts/common/task_context.py +18 -0
  136. package/dist/templates/trellis/scripts/common/task_dashboard.py +24 -1
  137. package/dist/templates/trellis/scripts/common/task_dependencies.py +673 -0
  138. package/dist/templates/trellis/scripts/common/task_gates.py +58 -5
  139. package/dist/templates/trellis/scripts/common/task_map.py +40 -8
  140. package/dist/templates/trellis/scripts/common/task_store.py +400 -39
  141. package/dist/templates/trellis/scripts/common/test_depends_mode_block.py +489 -0
  142. package/dist/templates/trellis/scripts/common/test_observable_defaults.py +135 -0
  143. package/dist/templates/trellis/scripts/common/test_pool_store.py +428 -0
  144. package/dist/templates/trellis/scripts/common/test_task_dependencies.py +345 -0
  145. package/dist/templates/trellis/scripts/cursor_retrieval_probe_prompt.md +14 -12
  146. package/dist/templates/trellis/scripts/generate_dispatch_prompt.py +182 -0
  147. package/dist/templates/trellis/scripts/injection_budget_probe.py +69 -0
  148. package/dist/templates/trellis/scripts/pool.py +192 -0
  149. package/dist/templates/trellis/scripts/retrieval_probe_matrix_template.json +3 -3
  150. package/dist/templates/trellis/scripts/spec_health_outcomes.py +160 -0
  151. package/dist/templates/trellis/scripts/task.py +85 -1
  152. package/dist/templates/trellis/scripts/verify_evidence_probe.py +138 -0
  153. package/dist/templates/trellis/tasks/locale/en/default-prd.md +19 -0
  154. package/dist/templates/trellis/tasks/locale/zh/default-prd.md +19 -0
  155. package/dist/templates/trellis/workflow.md +55 -5
  156. 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}"