@blxzer/cursor-trellis 0.3.6 → 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 +23 -0
- package/README.md +6 -6
- 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 +39 -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/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/index.d.ts +12 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +26 -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/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/workflow.md +32 -2
- package/package.json +2 -2
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Review-pool data access layer (`.cstl/pool/`).
|
|
4
|
+
|
|
5
|
+
Pure read helpers plus the authoritative write path for item <-> task
|
|
6
|
+
bidirectional links. Consumers: scripts/pool.py (CLI) and
|
|
7
|
+
common/task_dependencies.py (pool: dependency resolution).
|
|
8
|
+
|
|
9
|
+
Frontmatter reading reuses artifact_search.split_frontmatter /
|
|
10
|
+
parse_frontmatter (the single simple-frontmatter parser; no PyYAML).
|
|
11
|
+
Frontmatter writes splice a re-serialized header between the raw `---`
|
|
12
|
+
fences so the body is preserved byte-for-byte (including the blank line
|
|
13
|
+
after the closing fence and the final newline), and key order stays stable:
|
|
14
|
+
known keys first (id, title, status, type, locale, created, approved,
|
|
15
|
+
linked_tasks), unknown keys keep their original relative order.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .artifact_search import parse_frontmatter, split_frontmatter
|
|
25
|
+
from .io import read_json, write_json
|
|
26
|
+
from .paths import get_repo_root, get_tasks_dir
|
|
27
|
+
|
|
28
|
+
POOL_STATUSES = frozenset({"inbox", "review", "accepted", "rejected", "rework"})
|
|
29
|
+
|
|
30
|
+
LINKED_TASKS_KEY = "linked_tasks"
|
|
31
|
+
POOL_ITEMS_KEY = "pool_items"
|
|
32
|
+
META_KEY = "meta"
|
|
33
|
+
|
|
34
|
+
KNOWN_FRONTMATTER_KEY_ORDER = (
|
|
35
|
+
"id",
|
|
36
|
+
"title",
|
|
37
|
+
"status",
|
|
38
|
+
"type",
|
|
39
|
+
"locale",
|
|
40
|
+
"created",
|
|
41
|
+
"approved",
|
|
42
|
+
LINKED_TASKS_KEY,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
PLAN_TOKEN_RE = re.compile(r"\bP\d+\b")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class PoolItem:
|
|
50
|
+
"""One parsed pool entry from `.cstl/pool/items/`."""
|
|
51
|
+
|
|
52
|
+
id: str
|
|
53
|
+
path: Path
|
|
54
|
+
status: str
|
|
55
|
+
type: str
|
|
56
|
+
title: str | None
|
|
57
|
+
linked_tasks: list[str]
|
|
58
|
+
frontmatter: dict[str, object]
|
|
59
|
+
body: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Issue:
|
|
64
|
+
"""One validation finding. severity is "error" or "warning"."""
|
|
65
|
+
|
|
66
|
+
severity: str
|
|
67
|
+
code: str
|
|
68
|
+
message: str
|
|
69
|
+
path: Path | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def is_error(self) -> bool:
|
|
73
|
+
return self.severity == "error"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class LinkResult:
|
|
78
|
+
"""Outcome of link_item_task / unlink_item_task."""
|
|
79
|
+
|
|
80
|
+
ok: bool
|
|
81
|
+
item_id: str
|
|
82
|
+
task_ref: str
|
|
83
|
+
item_path: Path | None = None
|
|
84
|
+
task_path: Path | None = None
|
|
85
|
+
errors: list[Issue] = field(default_factory=list)
|
|
86
|
+
warnings: list[Issue] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# =============================================================================
|
|
90
|
+
# Paths and listing
|
|
91
|
+
# =============================================================================
|
|
92
|
+
|
|
93
|
+
def get_pool_root(repo_root: Path | None = None) -> Path:
|
|
94
|
+
"""Return the `.cstl/pool/` directory for the repository."""
|
|
95
|
+
if repo_root is None:
|
|
96
|
+
repo_root = get_repo_root()
|
|
97
|
+
return repo_root / ".cstl" / "pool"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def list_items(repo_root: Path | None = None) -> list[PoolItem]:
|
|
101
|
+
"""Parse every entry in `.cstl/pool/items/`, sorted by id."""
|
|
102
|
+
if repo_root is None:
|
|
103
|
+
repo_root = get_repo_root()
|
|
104
|
+
items_dir = get_pool_root(repo_root) / "items"
|
|
105
|
+
if not items_dir.is_dir():
|
|
106
|
+
return []
|
|
107
|
+
items = [parse_item_file(path) for path in sorted(items_dir.glob("*.md"))]
|
|
108
|
+
return sorted(items, key=lambda item: item.id)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def find_item_path(repo_root: Path | None, item_id: str) -> Path | None:
|
|
112
|
+
"""Locate the item file whose frontmatter id equals item_id, else None."""
|
|
113
|
+
for item in list_items(repo_root):
|
|
114
|
+
if item.id == item_id:
|
|
115
|
+
return item.path
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_item(repo_root: Path | None, item_id: str) -> PoolItem | None:
|
|
120
|
+
"""Load one pool entry by exact frontmatter id, else None."""
|
|
121
|
+
for item in list_items(repo_root):
|
|
122
|
+
if item.id == item_id:
|
|
123
|
+
return item
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_item_file(path: Path) -> PoolItem:
|
|
128
|
+
"""Parse one pool entry file into a PoolItem (read-only)."""
|
|
129
|
+
content = path.read_text(encoding="utf-8")
|
|
130
|
+
frontmatter, body, _ = split_frontmatter(content)
|
|
131
|
+
return PoolItem(
|
|
132
|
+
id=_scalar_str(frontmatter.get("id"), ""),
|
|
133
|
+
path=path,
|
|
134
|
+
status=_scalar_str(frontmatter.get("status"), ""),
|
|
135
|
+
type=_scalar_str(frontmatter.get("type"), ""),
|
|
136
|
+
title=_scalar_str(frontmatter.get("title"), None),
|
|
137
|
+
linked_tasks=normalize_id_list(frontmatter.get(LINKED_TASKS_KEY)),
|
|
138
|
+
frontmatter=frontmatter,
|
|
139
|
+
body=body,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _scalar_str(value: object, default: str | None) -> str | None:
|
|
144
|
+
if isinstance(value, str):
|
|
145
|
+
return value
|
|
146
|
+
return default
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# =============================================================================
|
|
150
|
+
# Link field helpers
|
|
151
|
+
# =============================================================================
|
|
152
|
+
|
|
153
|
+
def normalize_id_list(raw: object) -> list[str]:
|
|
154
|
+
"""Normalize a list field: strip, drop empties/non-strings, dedupe in order."""
|
|
155
|
+
if isinstance(raw, str):
|
|
156
|
+
raw = [raw]
|
|
157
|
+
if not isinstance(raw, list):
|
|
158
|
+
return []
|
|
159
|
+
normalized: list[str] = []
|
|
160
|
+
seen: set[str] = set()
|
|
161
|
+
for item in raw:
|
|
162
|
+
if not isinstance(item, str):
|
|
163
|
+
continue
|
|
164
|
+
value = item.strip()
|
|
165
|
+
if not value or value in seen:
|
|
166
|
+
continue
|
|
167
|
+
seen.add(value)
|
|
168
|
+
normalized.append(value)
|
|
169
|
+
return normalized
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_linked_tasks(item: PoolItem) -> list[str]:
|
|
173
|
+
"""Linked task dir names of one pool item (normalized)."""
|
|
174
|
+
return normalize_id_list(item.frontmatter.get(LINKED_TASKS_KEY))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_pool_items(task_data: dict | None) -> list[str]:
|
|
178
|
+
"""meta.pool_items of one task.json dict (normalized)."""
|
|
179
|
+
if not isinstance(task_data, dict):
|
|
180
|
+
return []
|
|
181
|
+
meta = task_data.get(META_KEY)
|
|
182
|
+
if not isinstance(meta, dict):
|
|
183
|
+
return []
|
|
184
|
+
return normalize_id_list(meta.get(POOL_ITEMS_KEY))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# =============================================================================
|
|
188
|
+
# Frontmatter write (body-preserving)
|
|
189
|
+
# =============================================================================
|
|
190
|
+
|
|
191
|
+
def write_item_frontmatter(path: Path, updates: dict[str, object]) -> None:
|
|
192
|
+
"""Merge `updates` into the item's frontmatter and write the file back.
|
|
193
|
+
|
|
194
|
+
Only the frontmatter block changes; the body is preserved byte-for-byte.
|
|
195
|
+
List values are whole-key replaced with a normalized list.
|
|
196
|
+
"""
|
|
197
|
+
content = path.read_text(encoding="utf-8")
|
|
198
|
+
span = _frontmatter_span(content)
|
|
199
|
+
if span is None:
|
|
200
|
+
raise ValueError(f"no frontmatter block in {path}")
|
|
201
|
+
start, end = span # line indices between the two `---` fences
|
|
202
|
+
lines = content.splitlines(keepends=True)
|
|
203
|
+
raw_fm_lines = lines[start:end]
|
|
204
|
+
eol = "\r\n" if raw_fm_lines and "\r\n" in raw_fm_lines[0] else "\n"
|
|
205
|
+
|
|
206
|
+
frontmatter = parse_frontmatter([line.rstrip("\r\n") for line in raw_fm_lines])
|
|
207
|
+
merged = dict(frontmatter)
|
|
208
|
+
for key, value in updates.items():
|
|
209
|
+
merged[key] = normalize_id_list(value) if isinstance(value, list) else value
|
|
210
|
+
header = _serialize_frontmatter(merged, original_keys=list(frontmatter.keys()))
|
|
211
|
+
|
|
212
|
+
body = "".join(lines[end + 1 :])
|
|
213
|
+
new_content = "---" + eol + header.replace("\n", eol) + eol + "---" + eol + body
|
|
214
|
+
if new_content != content:
|
|
215
|
+
path.write_text(new_content, encoding="utf-8")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def update_item_frontmatter(path: Path, mutator) -> None:
|
|
219
|
+
"""Mutator-style frontmatter write: call mutator(current_fm) -> updates."""
|
|
220
|
+
content = path.read_text(encoding="utf-8")
|
|
221
|
+
span = _frontmatter_span(content)
|
|
222
|
+
if span is None:
|
|
223
|
+
raise ValueError(f"no frontmatter block in {path}")
|
|
224
|
+
lines = content.splitlines(keepends=True)
|
|
225
|
+
frontmatter = parse_frontmatter(
|
|
226
|
+
[line.rstrip("\r\n") for line in lines[span[0] : span[1]]]
|
|
227
|
+
)
|
|
228
|
+
updates = mutator(frontmatter)
|
|
229
|
+
if not isinstance(updates, dict):
|
|
230
|
+
raise TypeError("frontmatter mutator must return a dict of updates")
|
|
231
|
+
write_item_frontmatter(path, updates)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _frontmatter_span(content: str) -> tuple[int, int] | None:
|
|
235
|
+
"""Line indices of the frontmatter content between the two fences."""
|
|
236
|
+
lines = content.splitlines(keepends=True)
|
|
237
|
+
if not lines or lines[0].strip() != "---":
|
|
238
|
+
return None
|
|
239
|
+
for idx in range(1, len(lines)):
|
|
240
|
+
if lines[idx].strip() == "---":
|
|
241
|
+
return 1, idx
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _serialize_frontmatter(data: dict[str, object], original_keys: list[str]) -> str:
|
|
246
|
+
known = [key for key in KNOWN_FRONTMATTER_KEY_ORDER if key in data]
|
|
247
|
+
rest = [
|
|
248
|
+
key
|
|
249
|
+
for key in original_keys
|
|
250
|
+
if key not in KNOWN_FRONTMATTER_KEY_ORDER and key in data
|
|
251
|
+
]
|
|
252
|
+
added = [
|
|
253
|
+
key
|
|
254
|
+
for key in data
|
|
255
|
+
if key not in KNOWN_FRONTMATTER_KEY_ORDER and key not in original_keys
|
|
256
|
+
]
|
|
257
|
+
lines: list[str] = []
|
|
258
|
+
for key in known + rest + added:
|
|
259
|
+
value = data[key]
|
|
260
|
+
if isinstance(value, list):
|
|
261
|
+
lines.append(f"{key}:")
|
|
262
|
+
for item in value:
|
|
263
|
+
lines.append(f" - {_format_scalar(item)}")
|
|
264
|
+
else:
|
|
265
|
+
lines.append(f"{key}: {_format_scalar(value)}")
|
|
266
|
+
return "\n".join(lines)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _format_scalar(value: object) -> str:
|
|
270
|
+
text = "" if value is None else str(value)
|
|
271
|
+
if text == "":
|
|
272
|
+
return '""'
|
|
273
|
+
if (
|
|
274
|
+
text != text.strip()
|
|
275
|
+
or ":" in text
|
|
276
|
+
or "#" in text
|
|
277
|
+
or text in ("null", "true", "false")
|
|
278
|
+
):
|
|
279
|
+
return f'"{text}"'
|
|
280
|
+
return text
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# =============================================================================
|
|
284
|
+
# Bidirectional link write path (authoritative: both sides in one call)
|
|
285
|
+
# =============================================================================
|
|
286
|
+
|
|
287
|
+
def find_task_dir(task_ref: str, tasks_dir: Path) -> Path | None:
|
|
288
|
+
"""Resolve a task ref to a task directory with task.json.
|
|
289
|
+
|
|
290
|
+
Order: exact `tasks/<ref>` -> suffix match -> archive/<month>/<ref> ->
|
|
291
|
+
archive suffix match. Mirrors task_dependencies._find_task_dir semantics.
|
|
292
|
+
"""
|
|
293
|
+
candidates = [tasks_dir / task_ref]
|
|
294
|
+
if tasks_dir.is_dir():
|
|
295
|
+
for d in sorted(tasks_dir.iterdir()):
|
|
296
|
+
if d.is_dir() and d.name != "archive" and d.name.endswith(f"-{task_ref}"):
|
|
297
|
+
candidates.append(d)
|
|
298
|
+
archive_root = tasks_dir / "archive"
|
|
299
|
+
if archive_root.is_dir():
|
|
300
|
+
for month_dir in sorted(archive_root.iterdir()):
|
|
301
|
+
if not month_dir.is_dir():
|
|
302
|
+
continue
|
|
303
|
+
candidates.append(month_dir / task_ref)
|
|
304
|
+
for d in sorted(month_dir.iterdir()):
|
|
305
|
+
if d.is_dir() and d.name.endswith(f"-{task_ref}"):
|
|
306
|
+
candidates.append(d)
|
|
307
|
+
for candidate in candidates:
|
|
308
|
+
if candidate.is_dir() and (candidate / "task.json").is_file():
|
|
309
|
+
return candidate
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _iter_task_dirs(tasks_dir: Path):
|
|
314
|
+
"""Yield task dirs with task.json: active tasks, then archive tasks."""
|
|
315
|
+
if not tasks_dir.is_dir():
|
|
316
|
+
return
|
|
317
|
+
for d in sorted(tasks_dir.iterdir()):
|
|
318
|
+
if d.is_dir() and d.name != "archive" and (d / "task.json").is_file():
|
|
319
|
+
yield d
|
|
320
|
+
archive_root = tasks_dir / "archive"
|
|
321
|
+
if archive_root.is_dir():
|
|
322
|
+
for month_dir in sorted(archive_root.iterdir()):
|
|
323
|
+
if not month_dir.is_dir():
|
|
324
|
+
continue
|
|
325
|
+
for d in sorted(month_dir.iterdir()):
|
|
326
|
+
if d.is_dir() and (d / "task.json").is_file():
|
|
327
|
+
yield d
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _task_meta_with_pool_items(task_dir: Path, item_id: str, *, remove: bool) -> bool:
|
|
331
|
+
"""Add/remove item_id in task.json meta.pool_items. True if file changed."""
|
|
332
|
+
data = read_json(task_dir / "task.json")
|
|
333
|
+
if not isinstance(data, dict):
|
|
334
|
+
return False
|
|
335
|
+
meta = data.get(META_KEY)
|
|
336
|
+
if not isinstance(meta, dict):
|
|
337
|
+
meta = {}
|
|
338
|
+
pool_items = normalize_id_list(meta.get(POOL_ITEMS_KEY))
|
|
339
|
+
if remove:
|
|
340
|
+
if item_id not in pool_items:
|
|
341
|
+
return False
|
|
342
|
+
pool_items = [value for value in pool_items if value != item_id]
|
|
343
|
+
else:
|
|
344
|
+
if item_id in pool_items:
|
|
345
|
+
return False
|
|
346
|
+
pool_items.append(item_id)
|
|
347
|
+
meta[POOL_ITEMS_KEY] = pool_items
|
|
348
|
+
data[META_KEY] = meta
|
|
349
|
+
return bool(write_json(task_dir / "task.json", data))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def link_item_task(
|
|
353
|
+
repo_root: Path | None,
|
|
354
|
+
item_id: str,
|
|
355
|
+
task_ref: str,
|
|
356
|
+
) -> LinkResult:
|
|
357
|
+
"""Link one pool item to one task on both sides (idempotent).
|
|
358
|
+
|
|
359
|
+
Item side stores the resolved task directory name in linked_tasks; task
|
|
360
|
+
side stores the item id in meta.pool_items. Missing item or task -> error.
|
|
361
|
+
"""
|
|
362
|
+
if repo_root is None:
|
|
363
|
+
repo_root = get_repo_root()
|
|
364
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
365
|
+
|
|
366
|
+
item = load_item(repo_root, item_id)
|
|
367
|
+
if item is None:
|
|
368
|
+
return LinkResult(
|
|
369
|
+
ok=False,
|
|
370
|
+
item_id=item_id,
|
|
371
|
+
task_ref=task_ref,
|
|
372
|
+
errors=[
|
|
373
|
+
Issue(
|
|
374
|
+
"error",
|
|
375
|
+
"item-not-found",
|
|
376
|
+
f"pool item {item_id!r} not found under {get_pool_root(repo_root) / 'items'}",
|
|
377
|
+
)
|
|
378
|
+
],
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
task_dir = find_task_dir(task_ref, tasks_dir)
|
|
382
|
+
if task_dir is None:
|
|
383
|
+
return LinkResult(
|
|
384
|
+
ok=False,
|
|
385
|
+
item_id=item_id,
|
|
386
|
+
task_ref=task_ref,
|
|
387
|
+
item_path=item.path,
|
|
388
|
+
errors=[
|
|
389
|
+
Issue(
|
|
390
|
+
"error",
|
|
391
|
+
"task-not-found",
|
|
392
|
+
f"task {task_ref!r} not found under {tasks_dir} or archive",
|
|
393
|
+
)
|
|
394
|
+
],
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
canonical_ref = task_dir.name
|
|
398
|
+
linked = get_linked_tasks(item)
|
|
399
|
+
if canonical_ref not in linked:
|
|
400
|
+
linked.append(canonical_ref)
|
|
401
|
+
write_item_frontmatter(item.path, {LINKED_TASKS_KEY: linked})
|
|
402
|
+
_task_meta_with_pool_items(task_dir, item_id, remove=False)
|
|
403
|
+
|
|
404
|
+
return LinkResult(
|
|
405
|
+
ok=True,
|
|
406
|
+
item_id=item_id,
|
|
407
|
+
task_ref=canonical_ref,
|
|
408
|
+
item_path=item.path,
|
|
409
|
+
task_path=task_dir,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def unlink_item_task(
|
|
414
|
+
repo_root: Path | None,
|
|
415
|
+
item_id: str,
|
|
416
|
+
task_ref: str,
|
|
417
|
+
) -> LinkResult:
|
|
418
|
+
"""Remove the item<->task link on both sides, best-effort.
|
|
419
|
+
|
|
420
|
+
Missing item -> error. When the task side is absent (unresolvable ref or
|
|
421
|
+
no link recorded), the item side is still cleaned and a warning is issued;
|
|
422
|
+
exit semantics are left to the CLI (warnings -> exit 0).
|
|
423
|
+
"""
|
|
424
|
+
if repo_root is None:
|
|
425
|
+
repo_root = get_repo_root()
|
|
426
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
427
|
+
|
|
428
|
+
item = load_item(repo_root, item_id)
|
|
429
|
+
if item is None:
|
|
430
|
+
return LinkResult(
|
|
431
|
+
ok=False,
|
|
432
|
+
item_id=item_id,
|
|
433
|
+
task_ref=task_ref,
|
|
434
|
+
errors=[
|
|
435
|
+
Issue(
|
|
436
|
+
"error",
|
|
437
|
+
"item-not-found",
|
|
438
|
+
f"pool item {item_id!r} not found under {get_pool_root(repo_root) / 'items'}",
|
|
439
|
+
)
|
|
440
|
+
],
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
warnings: list[Issue] = []
|
|
444
|
+
task_dir = find_task_dir(task_ref, tasks_dir)
|
|
445
|
+
canonical_ref = task_dir.name if task_dir is not None else task_ref.strip()
|
|
446
|
+
|
|
447
|
+
linked = get_linked_tasks(item)
|
|
448
|
+
removed_item_side = any(value in linked for value in (canonical_ref, task_ref.strip()))
|
|
449
|
+
if removed_item_side:
|
|
450
|
+
linked = [value for value in linked if value not in (canonical_ref, task_ref.strip())]
|
|
451
|
+
write_item_frontmatter(item.path, {LINKED_TASKS_KEY: linked})
|
|
452
|
+
|
|
453
|
+
removed_task_side = False
|
|
454
|
+
if task_dir is not None:
|
|
455
|
+
removed_task_side = _task_meta_with_pool_items(task_dir, item_id, remove=True)
|
|
456
|
+
|
|
457
|
+
if task_dir is None:
|
|
458
|
+
warnings.append(
|
|
459
|
+
Issue(
|
|
460
|
+
"warning",
|
|
461
|
+
"task-not-found",
|
|
462
|
+
f"task {task_ref!r} not found; could not clean task side (item side cleaned)",
|
|
463
|
+
item.path,
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
elif not removed_task_side:
|
|
467
|
+
warnings.append(
|
|
468
|
+
Issue(
|
|
469
|
+
"warning",
|
|
470
|
+
"no-task-side-link",
|
|
471
|
+
f"task {task_dir.name} had no meta.pool_items entry for {item_id!r}",
|
|
472
|
+
task_dir,
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
if not removed_item_side and task_dir is not None:
|
|
476
|
+
warnings.append(
|
|
477
|
+
Issue(
|
|
478
|
+
"warning",
|
|
479
|
+
"no-item-side-link",
|
|
480
|
+
f"item {item_id} had no linked_tasks entry for {canonical_ref!r}",
|
|
481
|
+
item.path,
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
return LinkResult(
|
|
486
|
+
ok=True,
|
|
487
|
+
item_id=item_id,
|
|
488
|
+
task_ref=canonical_ref,
|
|
489
|
+
item_path=item.path,
|
|
490
|
+
task_path=task_dir,
|
|
491
|
+
warnings=warnings,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# =============================================================================
|
|
496
|
+
# Validation
|
|
497
|
+
# =============================================================================
|
|
498
|
+
|
|
499
|
+
REQUIRED_SECTIONS = ("意图", "动机", "粗验收", "非目标")
|
|
500
|
+
_SECTION_RE = {
|
|
501
|
+
section: re.compile(rf"(?im)^\s{{0,3}}#{{1,6}}\s*{section}\s*$")
|
|
502
|
+
for section in REQUIRED_SECTIONS
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _has_section(body: str, section: str) -> bool:
|
|
507
|
+
return _SECTION_RE[section].search(body) is not None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def validate_pool(repo_root: Path | None = None) -> list[Issue]:
|
|
511
|
+
"""Full item validation: required keys, status, sections, id uniqueness,
|
|
512
|
+
dangling links, and bidirectional link consistency."""
|
|
513
|
+
if repo_root is None:
|
|
514
|
+
repo_root = get_repo_root()
|
|
515
|
+
issues: list[Issue] = []
|
|
516
|
+
seen_ids: dict[str, Path] = {}
|
|
517
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
518
|
+
|
|
519
|
+
for item in list_items(repo_root):
|
|
520
|
+
label = item.id or item.path.name
|
|
521
|
+
for key in ("id", "status", "type"):
|
|
522
|
+
if not item.frontmatter.get(key):
|
|
523
|
+
issues.append(
|
|
524
|
+
Issue(
|
|
525
|
+
"error",
|
|
526
|
+
"missing-frontmatter-key",
|
|
527
|
+
f"item {label}: required frontmatter key {key!r} missing",
|
|
528
|
+
item.path,
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
if item.status and item.status not in POOL_STATUSES:
|
|
532
|
+
issues.append(
|
|
533
|
+
Issue(
|
|
534
|
+
"error",
|
|
535
|
+
"invalid-status",
|
|
536
|
+
f"item {label}: status {item.status!r} not in {sorted(POOL_STATUSES)}",
|
|
537
|
+
item.path,
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
for section in REQUIRED_SECTIONS:
|
|
541
|
+
if not _has_section(item.body, section):
|
|
542
|
+
severity = (
|
|
543
|
+
"warning"
|
|
544
|
+
if item.status in {"inbox", "review"}
|
|
545
|
+
else "error"
|
|
546
|
+
)
|
|
547
|
+
issues.append(
|
|
548
|
+
Issue(
|
|
549
|
+
severity,
|
|
550
|
+
"missing-section",
|
|
551
|
+
f"item {label}: missing required section {section!r}",
|
|
552
|
+
item.path,
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
for ref in item.linked_tasks:
|
|
556
|
+
if find_task_dir(ref, tasks_dir) is None:
|
|
557
|
+
issues.append(
|
|
558
|
+
Issue(
|
|
559
|
+
"error",
|
|
560
|
+
"dangling-link",
|
|
561
|
+
f"item {label}: linked_tasks entry {ref!r} does not resolve to a task",
|
|
562
|
+
item.path,
|
|
563
|
+
)
|
|
564
|
+
)
|
|
565
|
+
if item.id:
|
|
566
|
+
if item.id in seen_ids:
|
|
567
|
+
issues.append(
|
|
568
|
+
Issue(
|
|
569
|
+
"error",
|
|
570
|
+
"duplicate-id",
|
|
571
|
+
f"pool item id {item.id!r} duplicated at {seen_ids[item.id]} and {item.path}",
|
|
572
|
+
item.path,
|
|
573
|
+
)
|
|
574
|
+
)
|
|
575
|
+
else:
|
|
576
|
+
seen_ids[item.id] = item.path
|
|
577
|
+
|
|
578
|
+
issues.extend(check_link_consistency(repo_root))
|
|
579
|
+
return issues
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def check_link_consistency(repo_root: Path | None = None) -> list[Issue]:
|
|
583
|
+
"""Detect one-sided links: item links a task whose meta.pool_items lacks
|
|
584
|
+
the item, or a task lists a pool item the entry does not link back."""
|
|
585
|
+
if repo_root is None:
|
|
586
|
+
repo_root = get_repo_root()
|
|
587
|
+
issues: list[Issue] = []
|
|
588
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
589
|
+
|
|
590
|
+
for item in list_items(repo_root):
|
|
591
|
+
for ref in item.linked_tasks:
|
|
592
|
+
task_dir = find_task_dir(ref, tasks_dir)
|
|
593
|
+
if task_dir is None:
|
|
594
|
+
continue # dangling links are flagged by validate_pool
|
|
595
|
+
data = read_json(task_dir / "task.json") or {}
|
|
596
|
+
if item.id and item.id not in get_pool_items(data):
|
|
597
|
+
issues.append(
|
|
598
|
+
Issue(
|
|
599
|
+
"error",
|
|
600
|
+
"link-missing-task-side",
|
|
601
|
+
f"item {item.id} links task {task_dir.name} but task.json meta.pool_items is missing {item.id!r}",
|
|
602
|
+
item.path,
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
for task_dir in _iter_task_dirs(tasks_dir):
|
|
607
|
+
data = read_json(task_dir / "task.json") or {}
|
|
608
|
+
for pool_id in get_pool_items(data):
|
|
609
|
+
item = load_item(repo_root, pool_id)
|
|
610
|
+
if item is None:
|
|
611
|
+
issues.append(
|
|
612
|
+
Issue(
|
|
613
|
+
"error",
|
|
614
|
+
"link-item-missing",
|
|
615
|
+
f"task {task_dir.name} meta.pool_items references unknown pool item {pool_id!r}",
|
|
616
|
+
task_dir,
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
elif task_dir.name not in item.linked_tasks:
|
|
620
|
+
issues.append(
|
|
621
|
+
Issue(
|
|
622
|
+
"error",
|
|
623
|
+
"link-missing-item-side",
|
|
624
|
+
f"task {task_dir.name} meta.pool_items lists {pool_id} but item linked_tasks is missing {task_dir.name!r}",
|
|
625
|
+
item.path,
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
return issues
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# =============================================================================
|
|
632
|
+
# plan.md reference checking
|
|
633
|
+
# =============================================================================
|
|
634
|
+
|
|
635
|
+
def _strip_code_blocks(text: str) -> str:
|
|
636
|
+
lines: list[str] = []
|
|
637
|
+
in_block = False
|
|
638
|
+
for line in text.splitlines():
|
|
639
|
+
if line.lstrip().startswith("```"):
|
|
640
|
+
in_block = not in_block
|
|
641
|
+
continue
|
|
642
|
+
if not in_block:
|
|
643
|
+
lines.append(line)
|
|
644
|
+
return "\n".join(lines)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _plan_tokens(plan_text: str) -> set[str]:
|
|
648
|
+
"""All `P<digits>` tokens in plan text, outside fenced code blocks."""
|
|
649
|
+
return set(PLAN_TOKEN_RE.findall(_strip_code_blocks(plan_text)))
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def plan_referenced_ids(plan_text: str, known_ids: set[str]) -> set[str]:
|
|
653
|
+
"""Plan tokens that intersect the known item ids (conservative scan)."""
|
|
654
|
+
return _plan_tokens(plan_text) & known_ids
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _known_item_ids(repo_root: Path) -> set[str]:
|
|
658
|
+
"""Item ids from items/ plus any archived entries under pool/archive/."""
|
|
659
|
+
ids = {item.id for item in list_items(repo_root) if item.id}
|
|
660
|
+
archive_dir = get_pool_root(repo_root) / "archive"
|
|
661
|
+
if archive_dir.is_dir():
|
|
662
|
+
for path in sorted(archive_dir.rglob("*.md")):
|
|
663
|
+
item = parse_item_file(path)
|
|
664
|
+
if item.id:
|
|
665
|
+
ids.add(item.id)
|
|
666
|
+
return ids
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def check_plan(repo_root: Path | None = None) -> list[Issue]:
|
|
670
|
+
"""plan.md checks: ghost references -> error; accepted items not mentioned
|
|
671
|
+
-> warning."""
|
|
672
|
+
if repo_root is None:
|
|
673
|
+
repo_root = get_repo_root()
|
|
674
|
+
plan_file = get_pool_root(repo_root) / "plan.md"
|
|
675
|
+
if not plan_file.is_file():
|
|
676
|
+
return [
|
|
677
|
+
Issue("error", "plan-missing", "plan.md not found", plan_file)
|
|
678
|
+
]
|
|
679
|
+
|
|
680
|
+
known_ids = _known_item_ids(repo_root)
|
|
681
|
+
tokens = _plan_tokens(plan_file.read_text(encoding="utf-8"))
|
|
682
|
+
issues: list[Issue] = []
|
|
683
|
+
for ghost in sorted(tokens - known_ids):
|
|
684
|
+
issues.append(
|
|
685
|
+
Issue(
|
|
686
|
+
"error",
|
|
687
|
+
"plan-ghost-id",
|
|
688
|
+
f"plan.md references unknown pool id {ghost!r}",
|
|
689
|
+
plan_file,
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
for item in list_items(repo_root):
|
|
693
|
+
if item.status == "accepted" and item.id and item.id not in tokens:
|
|
694
|
+
issues.append(
|
|
695
|
+
Issue(
|
|
696
|
+
"warning",
|
|
697
|
+
"accepted-not-in-plan",
|
|
698
|
+
f"accepted item {item.id} not referenced in plan.md",
|
|
699
|
+
item.path,
|
|
700
|
+
)
|
|
701
|
+
)
|
|
702
|
+
return issues
|