@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +6 -6
  3. package/dist/commands/update.d.ts.map +1 -1
  4. package/dist/commands/update.js +5 -1
  5. package/dist/commands/update.js.map +1 -1
  6. package/dist/configurators/workflow.d.ts.map +1 -1
  7. package/dist/configurators/workflow.js +39 -2
  8. package/dist/configurators/workflow.js.map +1 -1
  9. package/dist/constants/paths.d.ts +4 -0
  10. package/dist/constants/paths.d.ts.map +1 -1
  11. package/dist/constants/paths.js +4 -0
  12. package/dist/constants/paths.js.map +1 -1
  13. package/dist/migrations/manifests/0.4.0.json +9 -0
  14. package/dist/templates/markdown/index.d.ts +6 -0
  15. package/dist/templates/markdown/index.d.ts.map +1 -1
  16. package/dist/templates/markdown/index.js +6 -0
  17. package/dist/templates/markdown/index.js.map +1 -1
  18. package/dist/templates/markdown/spec/guides/artifact-locale-guide.md.txt +93 -0
  19. package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +7 -7
  20. package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +10 -8
  21. package/dist/templates/markdown/spec/guides/debug-loop-guide.md.txt +227 -0
  22. package/dist/templates/markdown/spec/guides/goal-release-regression-runbook.md.txt +132 -0
  23. package/dist/templates/markdown/spec/guides/index.md.txt +37 -0
  24. package/dist/templates/markdown/spec/guides/prototype-guide.md.txt +139 -0
  25. package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +4 -0
  26. package/dist/templates/markdown/spec/guides/test-discipline-guide.md.txt +138 -0
  27. package/dist/templates/markdown/spec/guides/verification-strength-guide.md.txt +1 -0
  28. package/dist/templates/trellis/index.d.ts +12 -0
  29. package/dist/templates/trellis/index.d.ts.map +1 -1
  30. package/dist/templates/trellis/index.js +26 -0
  31. package/dist/templates/trellis/index.js.map +1 -1
  32. package/dist/templates/trellis/pool/README.md +103 -0
  33. package/dist/templates/trellis/pool/items/.gitkeep +0 -0
  34. package/dist/templates/trellis/pool/plan.md +26 -0
  35. package/dist/templates/trellis/scripts/common/pool_store.py +702 -0
  36. package/dist/templates/trellis/scripts/common/task_dashboard.py +8 -0
  37. package/dist/templates/trellis/scripts/common/task_dependencies.py +673 -0
  38. package/dist/templates/trellis/scripts/common/task_gates.py +58 -5
  39. package/dist/templates/trellis/scripts/common/task_store.py +256 -0
  40. package/dist/templates/trellis/scripts/common/test_depends_mode_block.py +489 -0
  41. package/dist/templates/trellis/scripts/common/test_pool_store.py +428 -0
  42. package/dist/templates/trellis/scripts/common/test_task_dependencies.py +345 -0
  43. package/dist/templates/trellis/scripts/pool.py +192 -0
  44. package/dist/templates/trellis/scripts/task.py +66 -1
  45. package/dist/templates/trellis/scripts/verify_evidence_probe.py +138 -0
  46. package/dist/templates/trellis/workflow.md +32 -2
  47. 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