delimit-cli 4.3.4 → 4.5.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 (46) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +25 -18
  3. package/adapters/codex-security.js +64 -0
  4. package/adapters/codex-skill.js +78 -0
  5. package/adapters/cursor-rules.js +73 -0
  6. package/bin/delimit-setup.js +23 -0
  7. package/gateway/ai/backends/governance_bridge.py +168 -2
  8. package/gateway/ai/backends/memory_bridge.py +218 -3
  9. package/gateway/ai/backends/tools_design.py +563 -83
  10. package/gateway/ai/backends/tools_infra.py +21 -7
  11. package/gateway/ai/backends/tools_real.py +3 -1
  12. package/gateway/ai/content_grounding/__init__.py +98 -0
  13. package/gateway/ai/content_grounding/build.py +350 -0
  14. package/gateway/ai/content_grounding/consume.py +280 -0
  15. package/gateway/ai/content_grounding/features.py +218 -0
  16. package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +9 -0
  17. package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +9 -0
  18. package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +17 -0
  19. package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +17 -0
  20. package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +17 -0
  21. package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +18 -0
  22. package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +18 -0
  23. package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +23 -0
  24. package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +16 -0
  25. package/gateway/ai/content_grounding/schemas/claim.schema.json +40 -0
  26. package/gateway/ai/content_grounding/schemas/event.schema.json +23 -0
  27. package/gateway/ai/content_grounding/schemas.py +276 -0
  28. package/gateway/ai/content_grounding/telemetry.py +221 -0
  29. package/gateway/ai/governance.py +89 -0
  30. package/gateway/ai/hot_reload.py +148 -7
  31. package/gateway/ai/inbox_drafts/__init__.py +61 -0
  32. package/gateway/ai/inbox_drafts/registry.py +412 -0
  33. package/gateway/ai/inbox_drafts/schema.py +374 -0
  34. package/gateway/ai/inbox_executor.py +565 -0
  35. package/gateway/ai/ledger_manager.py +1483 -25
  36. package/gateway/ai/license_core.py +3 -1
  37. package/gateway/ai/mcp_bridge.py +1 -1
  38. package/gateway/ai/reddit_proxy.py +8 -6
  39. package/gateway/ai/server.py +451 -9
  40. package/gateway/ai/supabase_sync.py +47 -7
  41. package/gateway/ai/swarm.py +1 -1
  42. package/gateway/ai/workers/executor.py +1 -1
  43. package/gateway/core/diff_engine_v2.py +45 -10
  44. package/gateway/core/zero_spec/express_extractor.py +1 -1
  45. package/lib/delimit-template.js +5 -0
  46. package/package.json +1 -1
@@ -9,6 +9,7 @@ Ledger lives at {project}/.delimit/ledger/ (project-local).
9
9
  Ventures auto-registered at ~/.delimit/ventures.json on first use.
10
10
  """
11
11
 
12
+ import base64
12
13
  import json
13
14
  import hashlib
14
15
  import os
@@ -20,6 +21,58 @@ from typing import Any, Dict, List, Optional
20
21
  GLOBAL_DIR = Path.home() / ".delimit"
21
22
  VENTURES_FILE = GLOBAL_DIR / "ventures.json"
22
23
 
24
+ # LED-1145 Phase 2 #3: P0 quota soft warning. The soft block fires when an
25
+ # add_item call would push the unresolved-P0 count over the quota. Item is
26
+ # still added — this is policy nudge, not enforcement, per the strategic
27
+ # deliberation's hierarchy ("policy after primitives"). Override via env var:
28
+ # DELIMIT_P0_SOFT_QUOTA=80 # raise the gate
29
+ # DELIMIT_P0_SOFT_QUOTA=0 # disable warning entirely
30
+ P0_SOFT_QUOTA_DEFAULT = 50
31
+
32
+
33
+ def _p0_soft_quota() -> int:
34
+ """Resolve the active P0 quota threshold from env. 0 disables warnings."""
35
+ raw = os.environ.get("DELIMIT_P0_SOFT_QUOTA", "")
36
+ if raw == "":
37
+ return P0_SOFT_QUOTA_DEFAULT
38
+ try:
39
+ n = int(raw)
40
+ return max(0, n)
41
+ except (TypeError, ValueError):
42
+ return P0_SOFT_QUOTA_DEFAULT
43
+
44
+
45
+ def _count_unresolved_p0(project_path: str = ".") -> int:
46
+ """Count P0 items currently in an unresolved state (open / in_progress /
47
+ blocked) across both ops and strategy ledgers. Excludes done / cancelled /
48
+ archived. Used by the add_item soft-quota nudge."""
49
+ ledger_dir = _project_ledger_dir(project_path)
50
+ unresolved = {"open", "in_progress", "blocked"}
51
+ count = 0
52
+ for filename in ("operations.jsonl", "strategy.jsonl"):
53
+ path = ledger_dir / filename
54
+ if not path.exists():
55
+ continue
56
+ items = _read_ledger(path)
57
+ # Replay events to current state
58
+ state: Dict[str, Dict[str, Any]] = {}
59
+ for item in items:
60
+ iid = item.get("id", "")
61
+ if not iid:
62
+ continue
63
+ if item.get("type") == "update":
64
+ if iid in state:
65
+ if "status" in item:
66
+ state[iid]["status"] = item["status"]
67
+ if "priority" in item:
68
+ state[iid]["priority"] = item["priority"]
69
+ else:
70
+ state[iid] = {**item}
71
+ for it in state.values():
72
+ if it.get("priority") == "P0" and it.get("status") in unresolved:
73
+ count += 1
74
+ return count
75
+
23
76
 
24
77
  def _detect_venture(project_path: str = ".") -> Dict[str, str]:
25
78
  """Auto-detect venture/project info from the directory."""
@@ -280,13 +333,32 @@ def add_item(
280
333
  except Exception:
281
334
  pass # Never let cloud sync break ledger operations
282
335
 
283
- return {
336
+ response: Dict[str, Any] = {
284
337
  "added": result,
285
338
  "ledger": ledger,
286
339
  "venture": venture["name"],
287
340
  "total_items": len(_read_ledger(path)),
288
341
  }
289
342
 
343
+ # LED-1145 Phase 2 #3: P0 soft quota nudge. Soft (item still added),
344
+ # not hard. Surfaces a warning when the unresolved-P0 count crosses
345
+ # the quota — gives the founder a signal to groom before piling on.
346
+ if priority == "P0":
347
+ quota = _p0_soft_quota()
348
+ if quota > 0:
349
+ current_p0 = _count_unresolved_p0(project_path)
350
+ if current_p0 > quota:
351
+ response["warning"] = (
352
+ f"P0 quota soft-block: {current_p0} unresolved P0 items "
353
+ f"(threshold {quota}). Item was still added. Consider running "
354
+ f"delimit_ledger_groom to triage existing P0s before adding more, "
355
+ f"or set DELIMIT_P0_SOFT_QUOTA={current_p0 + 50} to raise the gate."
356
+ )
357
+ response["p0_count"] = current_p0
358
+ response["p0_quota"] = quota
359
+
360
+ return response
361
+
290
362
 
291
363
  def _find_item_in_ledger_dir(item_id: str, ledger_dir: Path) -> Optional[Dict[str, Any]]:
292
364
  """Search a ledger directory for an item by ID. Returns (ledger_name, path) or None."""
@@ -386,19 +458,215 @@ def update_item(
386
458
  return {"error": f"Item {item_id} not found in project ledger"}
387
459
 
388
460
 
461
+ # LED-1145 Phase 1 PR-A: known-good slim projection for AI agent triage.
462
+ # Strips description / acceptance_criteria / context / tags / hash etc. so the
463
+ # response fits in an MCP tool result without truncation. Default behaviour
464
+ # stays full (callers depending on description still get it); pass
465
+ # fields="slim" or an explicit allowlist to opt in.
466
+ SLIM_FIELDS = ("id", "title", "status", "priority", "type", "venture", "updated_at")
467
+ _VALID_FIELDS = SLIM_FIELDS + (
468
+ "description", "acceptance_criteria", "context", "tags", "created_at",
469
+ "worked_by", "last_worked_by", "last_note", "hash", "source", "tools_needed",
470
+ "estimated_complexity", "ledger",
471
+ )
472
+ _VALID_SORT = ("updated_at", "created_at", "priority")
473
+ _VALID_ORDER = ("asc", "desc")
474
+ _PRIORITY_ORDER = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
475
+
476
+
477
+ def _normalize_filter_list(value):
478
+ """Accept None / str / list and produce a list of strings (or None)."""
479
+ if value is None:
480
+ return None
481
+ if isinstance(value, str):
482
+ if not value.strip():
483
+ return None
484
+ return [v.strip() for v in value.split(",") if v.strip()]
485
+ return list(value) if value else None
486
+
487
+
488
+ def _resolve_fields(fields):
489
+ """Map the `fields` parameter to a concrete projection set.
490
+
491
+ Returns:
492
+ (projection: set[str] | None, error: str | None)
493
+ - projection=None means "return all fields" (backward-compat default)
494
+ - error is set when an unknown field name was requested
495
+ """
496
+ if fields is None or fields == "":
497
+ return None, None
498
+ if isinstance(fields, str):
499
+ if fields == "slim":
500
+ return set(SLIM_FIELDS), None
501
+ if fields == "*":
502
+ return None, None
503
+ # comma-separated string from the MCP boundary
504
+ names = [f.strip() for f in fields.split(",") if f.strip()]
505
+ else:
506
+ names = list(fields)
507
+ if not names:
508
+ return None, None
509
+ if names == ["*"]:
510
+ return None, None
511
+ if names == ["slim"]:
512
+ return set(SLIM_FIELDS), None
513
+ unknown = [n for n in names if n not in _VALID_FIELDS and n != "*"]
514
+ if unknown:
515
+ return None, f"unknown field(s) requested: {sorted(unknown)}; valid: {sorted(_VALID_FIELDS)}"
516
+ return set(names), None
517
+
518
+
519
+ def _ts_to_iso(value):
520
+ """Coerce a timestamp value into a comparable ISO string. Empty → ''."""
521
+ return value or ""
522
+
523
+
524
+ def _compare_iso(a: str, b: str, op: str) -> bool:
525
+ """Lexical ISO comparison; both sides must be naively-comparable strings.
526
+ Empty string sorts before any real timestamp so '<X' is True for missing."""
527
+ if op == "before":
528
+ return a < b
529
+ if op == "after":
530
+ return a > b
531
+ return False
532
+
533
+
534
+ def _make_cursor(offset: int, filter_sig: str) -> str:
535
+ payload = f"{offset}:{filter_sig}"
536
+ return base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii")
537
+
538
+
539
+ def _decode_cursor(cursor: str) -> tuple[int, str]:
540
+ try:
541
+ decoded = base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8")
542
+ offset_str, filter_sig = decoded.split(":", 1)
543
+ return int(offset_str), filter_sig
544
+ except (ValueError, UnicodeDecodeError):
545
+ return 0, ""
546
+
547
+
548
+ def _filter_signature(spec: Dict[str, Any]) -> str:
549
+ """Short, deterministic hash of the filter spec; used to invalidate cursors
550
+ when the caller changes filters between page requests."""
551
+ canonical = json.dumps(spec, sort_keys=True, default=str)
552
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
553
+
554
+
389
555
  def list_items(
390
556
  ledger: str = "both",
557
+ # Backward-compat single-value filters:
391
558
  status: Optional[str] = None,
392
559
  priority: Optional[str] = None,
560
+ # New multi-value filters:
561
+ status__in=None,
562
+ priority__in=None,
563
+ tags__contains_all=None,
564
+ text: Optional[str] = None,
565
+ linked_external_id: Optional[str] = None,
566
+ created_before: Optional[str] = None,
567
+ created_after: Optional[str] = None,
568
+ updated_before: Optional[str] = None,
569
+ updated_after: Optional[str] = None,
570
+ # Sort + projection + pagination:
571
+ sort: str = "updated_at",
572
+ order: str = "desc",
573
+ fields=None,
393
574
  limit: int = 50,
575
+ cursor: Optional[str] = None,
394
576
  project_path: str = ".",
395
577
  ) -> Dict[str, Any]:
396
- """List ledger items with optional filters."""
578
+ """List ledger items with optional filters, sort, projection, and cursor pagination.
579
+
580
+ LED-1145 Phase 1 PR-A: extended from the original 3-filter signature.
581
+ Backward compatible — old callers passing only `status` / `priority`
582
+ continue to work without change.
583
+
584
+ Args:
585
+ ledger: "ops" | "strategy" | "both".
586
+ status: single-value status filter (back-compat).
587
+ priority: single-value priority filter (back-compat).
588
+ status__in: list (or comma-separated string) of statuses to match.
589
+ priority__in: list (or comma-separated string) of priorities to match.
590
+ tags__contains_all: list (or comma-separated string); item must contain ALL these tags.
591
+ text: case-insensitive substring match against title + description.
592
+ linked_external_id: exact substring match in description or tags
593
+ (e.g. "github.com/owner/repo/issues/123").
594
+ created_before / created_after / updated_before / updated_after:
595
+ ISO timestamps (e.g. "2026-04-01T00:00:00Z"). Lexical compare.
596
+ sort: "updated_at" | "created_at" | "priority". Default updated_at.
597
+ order: "asc" | "desc". Default desc.
598
+ fields: response projection. None or "*" = full (default, back-compat).
599
+ "slim" = SLIM_FIELDS only. List/CSV of field names = those only.
600
+ Unknown field names → ERROR (no silent no-op).
601
+ limit: page size (default 50).
602
+ cursor: opaque pagination token from a prior call's `next_cursor`.
603
+ If filters change between calls, the cursor is invalidated and the
604
+ response begins at offset 0 with `cursor_invalidated=True`.
605
+ project_path: ledger root (auto-detect when ".").
606
+
607
+ Returns:
608
+ {
609
+ "venture": str,
610
+ "items": {"ops": [...], "strategy": [...]},
611
+ "summary": {"total": int, "open": int, "done": int, "in_progress": int},
612
+ "next_cursor": str | None,
613
+ "cursor_invalidated": bool (only when True),
614
+ }
615
+ """
397
616
  _ensure(project_path)
398
617
  ledger_dir = _project_ledger_dir(project_path)
399
618
  venture = _detect_venture(project_path)
400
- results = {}
401
619
 
620
+ if sort not in _VALID_SORT:
621
+ return {"error": f"sort must be one of {list(_VALID_SORT)}"}
622
+ if order not in _VALID_ORDER:
623
+ return {"error": f"order must be one of {list(_VALID_ORDER)}"}
624
+
625
+ projection, projection_err = _resolve_fields(fields)
626
+ if projection_err:
627
+ return {"error": projection_err}
628
+
629
+ # Normalise filter list params (accept str / list / None)
630
+ status_list = _normalize_filter_list(status__in)
631
+ priority_list = _normalize_filter_list(priority__in)
632
+ tags_list = _normalize_filter_list(tags__contains_all)
633
+
634
+ # Backward compat: status="open" → status_list=["open"]; same for priority.
635
+ if status and not status_list:
636
+ status_list = [status]
637
+ if priority and not priority_list:
638
+ priority_list = [priority]
639
+
640
+ # Filter signature for cursor invalidation
641
+ filter_spec = {
642
+ "ledger": ledger,
643
+ "status_list": sorted(status_list) if status_list else None,
644
+ "priority_list": sorted(priority_list) if priority_list else None,
645
+ "tags_list": sorted(tags_list) if tags_list else None,
646
+ "text": text,
647
+ "linked_external_id": linked_external_id,
648
+ "created_before": created_before,
649
+ "created_after": created_after,
650
+ "updated_before": updated_before,
651
+ "updated_after": updated_after,
652
+ "sort": sort,
653
+ "order": order,
654
+ }
655
+ current_sig = _filter_signature(filter_spec)
656
+
657
+ # Decode cursor if provided
658
+ start_offset = 0
659
+ cursor_invalidated = False
660
+ if cursor:
661
+ decoded_offset, decoded_sig = _decode_cursor(cursor)
662
+ if decoded_sig == current_sig:
663
+ start_offset = decoded_offset
664
+ else:
665
+ cursor_invalidated = True
666
+
667
+ text_lower = (text or "").lower() if text else None
668
+
669
+ results: Dict[str, list] = {}
402
670
  for ledger_name, filename in [("ops", "operations.jsonl"), ("strategy", "strategy.jsonl")]:
403
671
  if ledger not in ("both", ledger_name):
404
672
  continue
@@ -406,8 +674,8 @@ def list_items(
406
674
  path = ledger_dir / filename
407
675
  items = _read_ledger(path)
408
676
 
409
- # Build current state by replaying events
410
- state = {}
677
+ # Build current state by replaying events (event-sourced)
678
+ state: Dict[str, Dict[str, Any]] = {}
411
679
  for item in items:
412
680
  item_id = item.get("id", "")
413
681
  if item.get("type") == "update":
@@ -420,35 +688,122 @@ def list_items(
420
688
  state[item_id]["priority"] = item["priority"]
421
689
  if "worked_by" in item:
422
690
  state[item_id]["last_worked_by"] = item["worked_by"]
691
+ if "tags" in item and item["tags"] is not None:
692
+ # Tag updates replace the existing tag set when present
693
+ state[item_id]["tags"] = item["tags"]
423
694
  state[item_id]["updated_at"] = item.get("updated_at")
424
695
  else:
425
696
  state[item_id] = {**item}
426
697
 
427
698
  filtered = list(state.values())
428
- if status:
429
- filtered = [i for i in filtered if i.get("status") == status]
430
- if priority:
431
- filtered = [i for i in filtered if i.get("priority") == priority]
432
-
433
- priority_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
434
- filtered.sort(key=lambda x: (priority_order.get(x.get("priority", "P2"), 9), x.get("created_at", "")))
435
-
436
- results[ledger_name] = filtered[:limit]
437
699
 
438
- all_items = []
439
- for v in results.values():
440
- all_items.extend(v)
700
+ # Apply filters
701
+ if status_list:
702
+ statuses = set(status_list)
703
+ filtered = [i for i in filtered if i.get("status") in statuses]
704
+ if priority_list:
705
+ priorities = set(priority_list)
706
+ filtered = [i for i in filtered if i.get("priority") in priorities]
707
+ if tags_list:
708
+ required_tags = set(tags_list)
709
+ filtered = [
710
+ i for i in filtered
711
+ if required_tags.issubset(set(i.get("tags") or []))
712
+ ]
713
+ if text_lower:
714
+ filtered = [
715
+ i for i in filtered
716
+ if text_lower in (i.get("title") or "").lower()
717
+ or text_lower in (i.get("description") or "").lower()
718
+ ]
719
+ if linked_external_id:
720
+ needle = linked_external_id
721
+ filtered = [
722
+ i for i in filtered
723
+ if needle in (i.get("description") or "")
724
+ or needle in " ".join(i.get("tags") or [])
725
+ or needle in (i.get("context") or "")
726
+ ]
727
+ if created_before:
728
+ filtered = [i for i in filtered if _compare_iso(_ts_to_iso(i.get("created_at")), created_before, "before")]
729
+ if created_after:
730
+ filtered = [i for i in filtered if _compare_iso(_ts_to_iso(i.get("created_at")), created_after, "after")]
731
+ if updated_before:
732
+ filtered = [i for i in filtered if _compare_iso(_ts_to_iso(i.get("updated_at") or i.get("created_at")), updated_before, "before")]
733
+ if updated_after:
734
+ filtered = [i for i in filtered if _compare_iso(_ts_to_iso(i.get("updated_at") or i.get("created_at")), updated_after, "after")]
735
+
736
+ # Sort
737
+ reverse = order == "desc"
738
+ if sort == "priority":
739
+ filtered.sort(
740
+ key=lambda x: (
741
+ _PRIORITY_ORDER.get(x.get("priority", "P2"), 9),
742
+ x.get("created_at", ""),
743
+ ),
744
+ reverse=reverse,
745
+ )
746
+ else:
747
+ sort_key = "updated_at" if sort == "updated_at" else "created_at"
748
+ filtered.sort(
749
+ key=lambda x: x.get(sort_key) or x.get("created_at") or "",
750
+ reverse=reverse,
751
+ )
441
752
 
442
- return {
753
+ results[ledger_name] = filtered
754
+
755
+ # Apply projection + pagination across the combined result.
756
+ # Combine per-ledger lists in stable order (ops first, then strategy).
757
+ combined: list = []
758
+ for ledger_name in ("ops", "strategy"):
759
+ if ledger_name in results:
760
+ for it in results[ledger_name]:
761
+ # Tag each item with its ledger source so the projected
762
+ # response retains the routing info even when "ledger" itself
763
+ # isn't part of the original record.
764
+ if "ledger" not in it:
765
+ it = {**it, "ledger": ledger_name}
766
+ combined.append(it)
767
+
768
+ total_pre_page = len(combined)
769
+ page = combined[start_offset:start_offset + limit]
770
+ next_offset = start_offset + len(page)
771
+ has_more = next_offset < total_pre_page
772
+ next_cursor = _make_cursor(next_offset, current_sig) if has_more else None
773
+
774
+ # Apply projection to page items.
775
+ if projection is not None:
776
+ page = [{k: v for k, v in i.items() if k in projection} for i in page]
777
+
778
+ # Re-bucket projected page items back into ops / strategy for response shape.
779
+ paged_results: Dict[str, list] = {"ops": [], "strategy": []}
780
+ if ledger != "strategy":
781
+ paged_results.setdefault("ops", [])
782
+ if ledger != "ops":
783
+ paged_results.setdefault("strategy", [])
784
+ # Walk the page using the still-tagged combined data to know which bucket;
785
+ # we kept "ledger" in the projection step only when it was already there.
786
+ for src, dst in zip(combined[start_offset:start_offset + limit], page):
787
+ bucket = src.get("ledger") or "ops"
788
+ paged_results.setdefault(bucket, []).append(dst)
789
+
790
+ summary_total = total_pre_page
791
+ response = {
443
792
  "venture": venture["name"],
444
- "items": results,
793
+ "items": {k: v for k, v in paged_results.items() if k in results},
445
794
  "summary": {
446
- "total": len(all_items),
447
- "open": sum(1 for i in all_items if i.get("status") == "open"),
448
- "done": sum(1 for i in all_items if i.get("status") == "done"),
449
- "in_progress": sum(1 for i in all_items if i.get("status") == "in_progress"),
795
+ "total": summary_total,
796
+ "open": sum(1 for i in combined if i.get("status") == "open"),
797
+ "done": sum(1 for i in combined if i.get("status") == "done"),
798
+ "in_progress": sum(1 for i in combined if i.get("status") == "in_progress"),
799
+ "blocked": sum(1 for i in combined if i.get("status") == "blocked"),
800
+ "archived": sum(1 for i in combined if i.get("status") == "archived"),
450
801
  },
802
+ "next_cursor": next_cursor,
451
803
  }
804
+ if cursor_invalidated:
805
+ response["cursor_invalidated"] = True
806
+ return response
452
807
 
453
808
 
454
809
  def get_context(project_path: str = ".") -> Dict[str, Any]:
@@ -591,7 +946,7 @@ def _parse_ts(ts_str: str) -> float:
591
946
  # ═══════════════════════════════════════════════════════════════════════
592
947
 
593
948
  LINKS_FILE_NAME = "links.jsonl"
594
- VALID_LINK_TYPES = {"blocks", "blocked_by", "parent", "child", "relates_to", "duplicates"}
949
+ VALID_LINK_TYPES = {"blocks", "blocked_by", "parent", "child", "relates_to", "duplicates", "supersedes", "superseded_by"}
595
950
 
596
951
 
597
952
  def link_items(
@@ -621,7 +976,14 @@ def link_items(
621
976
  f.write(json.dumps(link) + "\n")
622
977
 
623
978
  # Auto-create reverse link for bidirectional types
624
- reverse_map = {"blocks": "blocked_by", "blocked_by": "blocks", "parent": "child", "child": "parent"}
979
+ reverse_map = {
980
+ "blocks": "blocked_by",
981
+ "blocked_by": "blocks",
982
+ "parent": "child",
983
+ "child": "parent",
984
+ "supersedes": "superseded_by",
985
+ "superseded_by": "supersedes",
986
+ }
625
987
  if link_type in reverse_map:
626
988
  reverse = {
627
989
  "from": to_id,
@@ -747,3 +1109,1099 @@ def session_history(limit: int = 5) -> Dict[str, Any]:
747
1109
  continue
748
1110
 
749
1111
  return {"sessions": sessions, "count": len(sessions)}
1112
+
1113
+
1114
+ # ── LED-1145 Phase 1 PR-B: bulk_action ───────────────────────────────────
1115
+
1116
+ # Single allowlisted enum keeps the API surface tiny and predictable.
1117
+ # `archive` is a soft transition (status="archived", appended to JSONL); items
1118
+ # stay in replay forever. NO hard delete. Per-item failures don't block others.
1119
+ BULK_ACTIONS = ("archive", "set_status", "set_priority", "add_tag", "mark_done", "cancel")
1120
+ _VALID_BULK_STATUSES = ("open", "in_progress", "blocked", "done", "cancelled", "archived", "completed")
1121
+ _VALID_BULK_PRIORITIES = ("P0", "P1", "P2", "P3")
1122
+
1123
+
1124
+ def _normalize_id_list(value):
1125
+ """Accept None / str (CSV) / list and return list[str]. Used by bulk_action
1126
+ and the MCP tool wrapper."""
1127
+ if value is None:
1128
+ return []
1129
+ if isinstance(value, str):
1130
+ return [v.strip() for v in value.split(",") if v.strip()]
1131
+ return [str(v).strip() for v in value if str(v).strip()]
1132
+
1133
+
1134
+ def _replay_current_state(item_id: str, ledger_dir: Path) -> Optional[Dict[str, Any]]:
1135
+ """Walk the ledger and return the CURRENT replayed state of `item_id`,
1136
+ or None if the item doesn't exist. Used by bulk_action for dry-run
1137
+ diff preview AND by auto_close_linked_external to verify state changes.
1138
+
1139
+ Replays the same fields list_items does so callers see a consistent view."""
1140
+ for ledger_name, filename in [("ops", "operations.jsonl"), ("strategy", "strategy.jsonl")]:
1141
+ path = ledger_dir / filename
1142
+ items = _read_ledger(path)
1143
+ state: Optional[Dict[str, Any]] = None
1144
+ for item in items:
1145
+ if item.get("id") != item_id:
1146
+ continue
1147
+ if item.get("type") == "update":
1148
+ if state is not None:
1149
+ if "status" in item:
1150
+ state["status"] = item["status"]
1151
+ if "priority" in item:
1152
+ state["priority"] = item["priority"]
1153
+ if "tags" in item and item["tags"] is not None:
1154
+ state["tags"] = item["tags"]
1155
+ if "note" in item:
1156
+ state["last_note"] = item["note"]
1157
+ if "worked_by" in item:
1158
+ state["last_worked_by"] = item["worked_by"]
1159
+ if "updated_at" in item:
1160
+ state["updated_at"] = item["updated_at"]
1161
+ else:
1162
+ state = {**item}
1163
+ if state is not None:
1164
+ return state
1165
+ return None
1166
+
1167
+
1168
+ def bulk_action(
1169
+ item_ids,
1170
+ action: str,
1171
+ dry_run: bool = True,
1172
+ note: Optional[str] = None,
1173
+ new_status: Optional[str] = None,
1174
+ new_priority: Optional[str] = None,
1175
+ tag: Optional[str] = None,
1176
+ project_path: str = ".",
1177
+ ) -> Dict[str, Any]:
1178
+ """Apply one action to many items. Default `dry_run=True` returns what
1179
+ would change without writing. Per-item failures are reported but don't
1180
+ block other items in the batch.
1181
+
1182
+ LED-1145 Phase 1 PR-B. The deliberation locked these defaults: a single
1183
+ enum-dispatched tool (not bulk_close/bulk_done/...), dry_run=True default,
1184
+ no hard delete, archive is a soft status transition.
1185
+
1186
+ Args:
1187
+ item_ids: list of LED-XXX ids (str or list-of-str; CSV string accepted).
1188
+ action: one of BULK_ACTIONS.
1189
+ dry_run: when True (default), don't write. When False, apply.
1190
+ note: optional note attached to every successful update event.
1191
+ new_status: required when action="set_status".
1192
+ new_priority: required when action="set_priority".
1193
+ tag: required when action="add_tag" (single tag string).
1194
+ project_path: ledger root.
1195
+
1196
+ Returns:
1197
+ {
1198
+ "dry_run": bool,
1199
+ "action": str,
1200
+ "would_change": [{id, field, old, new}, ...] # if dry_run
1201
+ "changed": [{id, field, old, new}, ...] # if not dry_run
1202
+ "errors": [{id, reason}]
1203
+ "summary": {"requested": int, "would_change": int (or "changed"), "errors": int}
1204
+ }
1205
+ """
1206
+ if action not in BULK_ACTIONS:
1207
+ return {
1208
+ "error": f"unknown action {action!r}; allowed: {list(BULK_ACTIONS)}",
1209
+ }
1210
+
1211
+ ids = _normalize_id_list(item_ids)
1212
+ if not ids:
1213
+ return {"error": "item_ids must contain at least one id"}
1214
+
1215
+ # Per-action argument validation
1216
+ if action == "set_status":
1217
+ if not new_status:
1218
+ return {"error": "set_status requires new_status"}
1219
+ if new_status not in _VALID_BULK_STATUSES:
1220
+ return {"error": f"new_status must be one of {list(_VALID_BULK_STATUSES)}"}
1221
+ if action == "set_priority":
1222
+ if not new_priority:
1223
+ return {"error": "set_priority requires new_priority"}
1224
+ if new_priority not in _VALID_BULK_PRIORITIES:
1225
+ return {"error": f"new_priority must be one of {list(_VALID_BULK_PRIORITIES)}"}
1226
+ if action == "add_tag":
1227
+ if not tag or not str(tag).strip():
1228
+ return {"error": "add_tag requires a non-empty tag"}
1229
+
1230
+ _ensure(project_path)
1231
+ ledger_dir = _project_ledger_dir(project_path)
1232
+
1233
+ # Build the per-item change description.
1234
+ # archive → status: <current> → archived
1235
+ # set_status → status: <current> → <new_status>
1236
+ # set_priority → priority: <current> → <new_priority>
1237
+ # add_tag → tags: [..current..] → [..current.., <tag>] (if not already present)
1238
+ # mark_done → status: <current> → done
1239
+ # cancel → status: <current> → cancelled
1240
+ changes: List[Dict[str, Any]] = []
1241
+ errors: List[Dict[str, str]] = []
1242
+
1243
+ for item_id in ids:
1244
+ state = _replay_current_state(item_id, ledger_dir)
1245
+ if state is None:
1246
+ errors.append({"id": item_id, "reason": "not_found"})
1247
+ continue
1248
+
1249
+ if action in ("archive", "mark_done", "cancel", "set_status"):
1250
+ new_val = {
1251
+ "archive": "archived",
1252
+ "mark_done": "done",
1253
+ "cancel": "cancelled",
1254
+ "set_status": new_status,
1255
+ }[action]
1256
+ old_val = state.get("status")
1257
+ if old_val == new_val:
1258
+ # No-op; skip silently (idempotent action). Don't record.
1259
+ continue
1260
+ changes.append({"id": item_id, "field": "status", "old": old_val, "new": new_val})
1261
+ elif action == "set_priority":
1262
+ old_val = state.get("priority")
1263
+ if old_val == new_priority:
1264
+ continue
1265
+ changes.append({"id": item_id, "field": "priority", "old": old_val, "new": new_priority})
1266
+ elif action == "add_tag":
1267
+ existing_tags = state.get("tags") or []
1268
+ if tag in existing_tags:
1269
+ continue
1270
+ changes.append({
1271
+ "id": item_id, "field": "tags",
1272
+ "old": list(existing_tags),
1273
+ "new": list(existing_tags) + [tag],
1274
+ })
1275
+
1276
+ if dry_run:
1277
+ return {
1278
+ "dry_run": True,
1279
+ "action": action,
1280
+ "would_change": changes,
1281
+ "errors": errors,
1282
+ "summary": {
1283
+ "requested": len(ids),
1284
+ "would_change": len(changes),
1285
+ "errors": len(errors),
1286
+ },
1287
+ }
1288
+
1289
+ # Apply mode: write one update event per change.
1290
+ applied: List[Dict[str, Any]] = []
1291
+ for change in changes:
1292
+ item_id = change["id"]
1293
+ field = change["field"]
1294
+ try:
1295
+ if field == "status":
1296
+ update_item(item_id=item_id, status=change["new"], note=note, project_path=project_path)
1297
+ elif field == "priority":
1298
+ update_item(item_id=item_id, priority=change["new"], note=note, project_path=project_path)
1299
+ elif field == "tags":
1300
+ _apply_tag_update(item_id, change["new"], note=note, project_path=project_path)
1301
+ applied.append(change)
1302
+ except Exception as exc: # noqa: BLE001 — per-item isolation
1303
+ errors.append({"id": item_id, "reason": f"write_failed: {exc}"})
1304
+
1305
+ return {
1306
+ "dry_run": False,
1307
+ "action": action,
1308
+ "changed": applied,
1309
+ "errors": errors,
1310
+ "summary": {
1311
+ "requested": len(ids),
1312
+ "changed": len(applied),
1313
+ "errors": len(errors),
1314
+ },
1315
+ }
1316
+
1317
+
1318
+ def _apply_tag_update(item_id: str, new_tags: List[str], note: Optional[str], project_path: str) -> None:
1319
+ """Append a tags-update event for an item. Used by bulk_action(add_tag).
1320
+
1321
+ update_item() doesn't support a tags param today, so we write the update
1322
+ event directly through the same path it uses. The replay logic in
1323
+ list_items already handles `tags` updates.
1324
+ """
1325
+ ledger_dir = _project_ledger_dir(project_path)
1326
+ found = _find_item_in_ledger_dir(item_id, ledger_dir)
1327
+ if not found:
1328
+ raise RuntimeError(f"item {item_id} disappeared during apply")
1329
+ path = found["path"]
1330
+ update_event = {
1331
+ "id": item_id,
1332
+ "type": "update",
1333
+ "tags": new_tags,
1334
+ "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1335
+ }
1336
+ if note:
1337
+ update_event["note"] = note
1338
+ _append(path, update_event)
1339
+
1340
+
1341
+ # ── LED-1145 Phase 2 #1: linked-external auto-close ──────────────────────
1342
+
1343
+ import re as _re
1344
+
1345
+ # Long form: https://github.com/<owner>/<repo>/(issues|pull)/<num>
1346
+ _GH_URL_RE = _re.compile(r"github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/(?:issues|pull)/(\d+)")
1347
+ # Short form: <owner>/<repo>#<num> (avoid matching plain markdown headings)
1348
+ _GH_SHORT_RE = _re.compile(r"\b([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)\b")
1349
+ # Explicit tag form: gh:<owner>/<repo>/<num> — for callers that want unambiguous linkage
1350
+ _GH_TAG_RE = _re.compile(r"^gh:([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/(\d+)$")
1351
+
1352
+
1353
+ def _extract_external_link(item: Dict[str, Any]) -> Optional[tuple]:
1354
+ """Find the FIRST github issue/PR reference inside an item.
1355
+
1356
+ Scans description, context, last_note, and tags (in that order). Returns
1357
+ `(owner, repo, number)` or None. Long URLs > short forms > explicit tags.
1358
+ """
1359
+ haystacks = []
1360
+ for field in ("description", "context", "last_note"):
1361
+ v = item.get(field)
1362
+ if isinstance(v, str) and v:
1363
+ haystacks.append(v)
1364
+ # Tag matches: each tag string standalone; explicit gh: form first
1365
+ for t in (item.get("tags") or []):
1366
+ if not isinstance(t, str):
1367
+ continue
1368
+ m = _GH_TAG_RE.match(t)
1369
+ if m:
1370
+ return m.group(1), m.group(2), int(m.group(3))
1371
+ haystacks.append(t)
1372
+
1373
+ text = "\n".join(haystacks)
1374
+ if not text:
1375
+ return None
1376
+
1377
+ # Long URL takes precedence
1378
+ m = _GH_URL_RE.search(text)
1379
+ if m:
1380
+ return m.group(1), m.group(2), int(m.group(3))
1381
+ m = _GH_SHORT_RE.search(text)
1382
+ if m:
1383
+ return m.group(1), m.group(2), int(m.group(3))
1384
+ return None
1385
+
1386
+
1387
+ def _gh_fetch_issue_state(owner: str, repo: str, number: int, _runner=None) -> Dict[str, Any]:
1388
+ """Query the GitHub API for issue/PR state. Returns:
1389
+ {ok: bool, state: str, merged: bool|None, closed_at: str|None,
1390
+ merge_commit_sha: str|None, state_reason: str|None}
1391
+ or {ok: False, error: str} on failure.
1392
+
1393
+ `_runner` is for test injection; defaults to subprocess.run.
1394
+ """
1395
+ if _runner is None:
1396
+ _runner = subprocess.run
1397
+
1398
+ try:
1399
+ result = _runner(
1400
+ ["gh", "api", f"/repos/{owner}/{repo}/issues/{number}",
1401
+ "--jq", '{state, closed_at, state_reason, pull_request: (.pull_request != null)}'],
1402
+ capture_output=True, text=True, timeout=15, check=False,
1403
+ )
1404
+ if result.returncode != 0:
1405
+ return {"ok": False, "error": (result.stderr or result.stdout)[:200]}
1406
+ meta = json.loads(result.stdout)
1407
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as exc:
1408
+ return {"ok": False, "error": str(exc)[:200]}
1409
+
1410
+ out = {
1411
+ "ok": True,
1412
+ "state": meta.get("state"),
1413
+ "closed_at": meta.get("closed_at"),
1414
+ "state_reason": meta.get("state_reason"),
1415
+ "merged": None,
1416
+ "merge_commit_sha": None,
1417
+ }
1418
+
1419
+ # If it's a PR, fetch the merge bit separately. We skip this on
1420
+ # non-PR issues to save an API call.
1421
+ if meta.get("pull_request"):
1422
+ try:
1423
+ pr_result = _runner(
1424
+ ["gh", "api", f"/repos/{owner}/{repo}/pulls/{number}",
1425
+ "--jq", '{merged, merge_commit_sha}'],
1426
+ capture_output=True, text=True, timeout=15, check=False,
1427
+ )
1428
+ if pr_result.returncode == 0:
1429
+ pr_meta = json.loads(pr_result.stdout)
1430
+ out["merged"] = bool(pr_meta.get("merged"))
1431
+ out["merge_commit_sha"] = pr_meta.get("merge_commit_sha")
1432
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
1433
+ pass
1434
+ return out
1435
+
1436
+
1437
+ def _resolve_action_for_external(state_meta: Dict[str, Any]) -> Optional[Dict[str, str]]:
1438
+ """Map GitHub issue/PR state -> (action, note) for bulk_action.
1439
+
1440
+ Returns None when the LED should be left alone (still open / fetch error).
1441
+
1442
+ Per LED-1146 deliberation:
1443
+ - PR merged → mark_done with merge SHA
1444
+ - issue/PR closed with state_reason='completed' → mark_done with closed_at
1445
+ - issue/PR closed with state_reason='not_planned' (or no reason) → archive
1446
+ - state='open' → None (leave alone)
1447
+ - fetch error → None (leave alone)
1448
+ """
1449
+ if not state_meta.get("ok"):
1450
+ return None
1451
+ if state_meta.get("state") != "closed":
1452
+ return None
1453
+
1454
+ # PR with merged=True is unambiguous "we shipped this"
1455
+ if state_meta.get("merged"):
1456
+ sha = (state_meta.get("merge_commit_sha") or "")[:8]
1457
+ return {"action": "mark_done", "note": f"AUTO-CLOSE: merged {sha}".rstrip()}
1458
+
1459
+ state_reason = state_meta.get("state_reason")
1460
+ closed_at = (state_meta.get("closed_at") or "")[:19]
1461
+
1462
+ if state_reason == "completed":
1463
+ return {"action": "mark_done", "note": f"AUTO-CLOSE: closed-completed {closed_at}".rstrip()}
1464
+
1465
+ # not_planned, duplicate, bot triage, no-reason → archive
1466
+ return {"action": "archive", "note": f"AUTO-CLOSE: closed-not-planned {closed_at}".rstrip()}
1467
+
1468
+
1469
+ def auto_close_linked_external(
1470
+ project_path: str = ".",
1471
+ dry_run: bool = True,
1472
+ max_items: int = 200,
1473
+ _gh_runner=None,
1474
+ ) -> Dict[str, Any]:
1475
+ """Walk open ledger items, detect linked GitHub issues/PRs, and propose
1476
+ closing any whose external counterpart already resolved.
1477
+
1478
+ LED-1145 Phase 2 #1. Built on top of bulk_action() from PR-B (uses its
1479
+ `mark_done` and `archive` actions).
1480
+
1481
+ Args:
1482
+ project_path: ledger root.
1483
+ dry_run: True (default) returns a plan without writing.
1484
+ max_items: hard cap on the number of items processed in one call.
1485
+ When the candidate set exceeds this, we process the first N and
1486
+ mark `truncated=True` in the response.
1487
+ _gh_runner: test-only hook for stubbing the gh CLI.
1488
+
1489
+ Returns:
1490
+ {
1491
+ "dry_run": bool,
1492
+ "scanned": int, # items walked
1493
+ "linked": int, # items with a recognised github reference
1494
+ "would_close" or "closed": [
1495
+ {"id", "external": "owner/repo#num", "action", "note", "state"},
1496
+ ...
1497
+ ],
1498
+ "left_open": [{"id", "external", "reason"}], # external still open / fetch error
1499
+ "errors": [{"id", "reason"}],
1500
+ "truncated": bool,
1501
+ "summary": {...},
1502
+ }
1503
+ """
1504
+ _ensure(project_path)
1505
+ listing = list_items(status__in=["open", "in_progress", "blocked"], limit=10_000, project_path=project_path)
1506
+
1507
+ candidates: List[Dict[str, Any]] = []
1508
+ for ledger_name in ("ops", "strategy"):
1509
+ candidates.extend(listing.get("items", {}).get(ledger_name, []))
1510
+
1511
+ truncated = False
1512
+ if len(candidates) > max_items:
1513
+ candidates = candidates[:max_items]
1514
+ truncated = True
1515
+
1516
+ # Per-call cache so the same external URL referenced by multiple LEDs
1517
+ # only triggers one gh API call.
1518
+ fetch_cache: Dict[tuple, Dict[str, Any]] = {}
1519
+
1520
+ would_close: List[Dict[str, Any]] = []
1521
+ left_open: List[Dict[str, Any]] = []
1522
+ errors: List[Dict[str, Any]] = []
1523
+ scanned = 0
1524
+ linked = 0
1525
+
1526
+ for item in candidates:
1527
+ scanned += 1
1528
+ ext = _extract_external_link(item)
1529
+ if ext is None:
1530
+ continue
1531
+ linked += 1
1532
+ owner, repo, number = ext
1533
+ external_label = f"{owner}/{repo}#{number}"
1534
+
1535
+ cache_key = (owner.lower(), repo.lower(), number)
1536
+ if cache_key not in fetch_cache:
1537
+ fetch_cache[cache_key] = _gh_fetch_issue_state(owner, repo, number, _runner=_gh_runner)
1538
+ state_meta = fetch_cache[cache_key]
1539
+
1540
+ if not state_meta.get("ok"):
1541
+ errors.append({
1542
+ "id": item.get("id"),
1543
+ "external": external_label,
1544
+ "reason": f"gh_api_failed: {state_meta.get('error', 'unknown')}",
1545
+ })
1546
+ continue
1547
+
1548
+ if state_meta.get("state") != "closed":
1549
+ left_open.append({
1550
+ "id": item.get("id"),
1551
+ "external": external_label,
1552
+ "reason": "external_still_open",
1553
+ })
1554
+ continue
1555
+
1556
+ action_plan = _resolve_action_for_external(state_meta)
1557
+ if action_plan is None:
1558
+ left_open.append({
1559
+ "id": item.get("id"),
1560
+ "external": external_label,
1561
+ "reason": "no_action_resolved",
1562
+ })
1563
+ continue
1564
+
1565
+ would_close.append({
1566
+ "id": item.get("id"),
1567
+ "external": external_label,
1568
+ "action": action_plan["action"],
1569
+ "note": action_plan["note"],
1570
+ "state": state_meta.get("state"),
1571
+ "merged": state_meta.get("merged"),
1572
+ })
1573
+
1574
+ if dry_run:
1575
+ return {
1576
+ "dry_run": True,
1577
+ "scanned": scanned,
1578
+ "linked": linked,
1579
+ "would_close": would_close,
1580
+ "left_open": left_open,
1581
+ "errors": errors,
1582
+ "truncated": truncated,
1583
+ "max_items": max_items,
1584
+ "summary": {
1585
+ "scanned": scanned,
1586
+ "linked": linked,
1587
+ "would_close": len(would_close),
1588
+ "left_open": len(left_open),
1589
+ "errors": len(errors),
1590
+ "truncated": truncated,
1591
+ },
1592
+ }
1593
+
1594
+ # Apply mode: dispatch each plan through bulk_action one item at a time
1595
+ # (so per-item action and note can vary).
1596
+ closed_results: List[Dict[str, Any]] = []
1597
+ for plan in would_close:
1598
+ ba = bulk_action(
1599
+ item_ids=[plan["id"]],
1600
+ action=plan["action"],
1601
+ dry_run=False,
1602
+ note=plan["note"],
1603
+ project_path=project_path,
1604
+ )
1605
+ if ba.get("summary", {}).get("changed"):
1606
+ closed_results.append(plan)
1607
+ else:
1608
+ errors.append({
1609
+ "id": plan["id"],
1610
+ "reason": f"bulk_action_failed: {ba.get('errors') or ba.get('error') or 'unknown'}",
1611
+ })
1612
+
1613
+ return {
1614
+ "dry_run": False,
1615
+ "scanned": scanned,
1616
+ "linked": linked,
1617
+ "closed": closed_results,
1618
+ "left_open": left_open,
1619
+ "errors": errors,
1620
+ "truncated": truncated,
1621
+ "max_items": max_items,
1622
+ "summary": {
1623
+ "scanned": scanned,
1624
+ "linked": linked,
1625
+ "closed": len(closed_results),
1626
+ "left_open": len(left_open),
1627
+ "errors": len(errors),
1628
+ "truncated": truncated,
1629
+ },
1630
+ }
1631
+
1632
+
1633
+ # ── LED-1145 Phase 2 #2: proposal-first grooming ─────────────────────────
1634
+
1635
+ import datetime as _dt
1636
+ from collections import defaultdict as _defaultdict
1637
+
1638
+
1639
+ def _is_garbage_venture(name: str) -> bool:
1640
+ """Detect test/scratch venture buckets that should be archived in bulk.
1641
+
1642
+ Pattern matches: tmp*, test_*, venture_<single-letter>, custom-venture.
1643
+ Deliberately does NOT match "unknown" — those are orphaned items that
1644
+ warrant a separate review pass, not auto-archive.
1645
+ """
1646
+ if not name:
1647
+ return False
1648
+ if name.startswith(("tmp", "test_")):
1649
+ return True
1650
+ if name == "custom-venture":
1651
+ return True
1652
+ # venture_a / venture_b / venture_z (test fixture pattern)
1653
+ if _re.match(r"^venture_[a-z]$", name):
1654
+ return True
1655
+ return False
1656
+
1657
+
1658
+ def _title_prefix(title: str, length: int = 50) -> str:
1659
+ """Normalise a title for fuzzy duplicate detection.
1660
+
1661
+ Strips bracketed prefixes like '[DELIMIT]' and lowercases so
1662
+ "[DELIMIT] GitHub outreach: foo" and "GitHub outreach: foo" group.
1663
+ """
1664
+ if not title:
1665
+ return ""
1666
+ cleaned = _re.sub(r"^\s*\[[^\]]+\]\s*", "", title)
1667
+ return cleaned.strip().lower()[:length]
1668
+
1669
+
1670
+ def _parse_iso(ts: str) -> Optional[_dt.datetime]:
1671
+ """Parse an ISO-8601 timestamp. Tolerates the trailing 'Z' suffix and
1672
+ naive timestamps. Returns None for unparseable input."""
1673
+ if not ts:
1674
+ return None
1675
+ try:
1676
+ # 2026-04-13T08:00:00Z and 2026-04-13T08:00:00 both supported
1677
+ v = ts.rstrip("Z")
1678
+ # Strip fractional seconds if present
1679
+ if "." in v:
1680
+ v = v.split(".", 1)[0]
1681
+ return _dt.datetime.fromisoformat(v).replace(tzinfo=_dt.timezone.utc)
1682
+ except (ValueError, TypeError):
1683
+ return None
1684
+
1685
+
1686
+ def groom_proposal(
1687
+ project_path: str = ".",
1688
+ stale_days: int = 30,
1689
+ dup_min_count: int = 3,
1690
+ max_per_category: int = 50,
1691
+ ) -> Dict[str, Any]:
1692
+ """Read-only grooming proposal: surfaces stale / duplicate / garbage-venture
1693
+ items as a structured plan for the founder to review and apply.
1694
+
1695
+ LED-1145 Phase 2 #2. The deliberation explicitly said "risky operations
1696
+ like deduplication and mass-cancellation must not be a single atomic
1697
+ action. The AI proposes a plan; the founder approves; execution goes
1698
+ through the safe bulk_action tool." This function is the proposal half.
1699
+
1700
+ Categories detected:
1701
+ - stale_open: status open|in_progress|blocked AND updated_at older
1702
+ than `stale_days`. Suggested action: archive.
1703
+ - duplicate_titles: groups of >= `dup_min_count` items sharing the
1704
+ same normalised title prefix (50 chars, brackets stripped). Suggested
1705
+ action: archive (de-duped). The first item in the group is kept.
1706
+ - garbage_venture: items whose venture matches the test/scratch
1707
+ pattern (tmp*, test*, venture_<letter>, custom-venture, unknown).
1708
+ Suggested action: archive.
1709
+
1710
+ Out of scope (separate detectors / future PRs):
1711
+ - linked-external auto-close — already shipped as a separate tool
1712
+ (delimit_ledger_auto_close_external)
1713
+ - P0 inflation review — surfaced separately via list_items
1714
+ - cross-venture orphan cleanup — needs portfolio policy
1715
+
1716
+ Args:
1717
+ project_path: ledger root.
1718
+ stale_days: threshold for "stale_open" detector. Default 30.
1719
+ dup_min_count: minimum group size for "duplicate_titles". Default 3.
1720
+ max_per_category: cap per category in the response. Default 50.
1721
+
1722
+ Returns:
1723
+ {
1724
+ "proposals": [
1725
+ {
1726
+ "category": str,
1727
+ "rationale": str,
1728
+ "items": [{"id", "title", "venture", "status", "updated_at"}, ...],
1729
+ "suggested_action": str,
1730
+ "ready_to_apply": str, # copy-pasteable bulk_action invocation hint
1731
+ "truncated": bool,
1732
+ "total_in_category": int,
1733
+ },
1734
+ ...
1735
+ ],
1736
+ "summary": {
1737
+ "total_categories": int,
1738
+ "total_items": int,
1739
+ "stale_open": int,
1740
+ "duplicate_titles": int,
1741
+ "garbage_venture": int,
1742
+ }
1743
+ }
1744
+ """
1745
+ listing = list_items(
1746
+ status__in=["open", "in_progress", "blocked"],
1747
+ limit=10_000,
1748
+ project_path=project_path,
1749
+ )
1750
+ candidates: List[Dict[str, Any]] = []
1751
+ for ledger_name in ("ops", "strategy"):
1752
+ candidates.extend(listing.get("items", {}).get(ledger_name, []))
1753
+
1754
+ now = _dt.datetime.now(_dt.timezone.utc)
1755
+ stale_threshold = now - _dt.timedelta(days=stale_days)
1756
+
1757
+ stale_items: List[Dict[str, Any]] = []
1758
+ garbage_items: List[Dict[str, Any]] = []
1759
+ title_groups: Dict[str, List[Dict[str, Any]]] = _defaultdict(list)
1760
+
1761
+ for item in candidates:
1762
+ # garbage_venture (highest precedence — short-circuits other categories)
1763
+ if _is_garbage_venture(item.get("venture", "") or ""):
1764
+ garbage_items.append(item)
1765
+ continue
1766
+
1767
+ # stale_open
1768
+ ts = item.get("updated_at") or item.get("created_at")
1769
+ parsed = _parse_iso(ts)
1770
+ if parsed and parsed < stale_threshold:
1771
+ stale_items.append(item)
1772
+
1773
+ # duplicate_titles (always grouped — overlapping with stale is OK,
1774
+ # the apply-side de-dupe relies on the founder picking one category)
1775
+ prefix = _title_prefix(item.get("title", ""))
1776
+ if prefix:
1777
+ title_groups[prefix].append(item)
1778
+
1779
+ duplicate_groups = {
1780
+ prefix: items for prefix, items in title_groups.items() if len(items) >= dup_min_count
1781
+ }
1782
+
1783
+ def _trim(items):
1784
+ total = len(items)
1785
+ truncated = total > max_per_category
1786
+ return items[:max_per_category], total, truncated
1787
+
1788
+ proposals = []
1789
+
1790
+ if stale_items:
1791
+ sliced, total, truncated = _trim(stale_items)
1792
+ proposals.append({
1793
+ "category": "stale_open",
1794
+ "rationale": f"{total} items have been open for >{stale_days} days with no update; "
1795
+ f"either the work needs revival or the LED was set-and-forget debt.",
1796
+ "items": [
1797
+ {
1798
+ "id": i.get("id"),
1799
+ "title": (i.get("title") or "")[:80],
1800
+ "venture": i.get("venture"),
1801
+ "status": i.get("status"),
1802
+ "updated_at": i.get("updated_at") or i.get("created_at"),
1803
+ }
1804
+ for i in sliced
1805
+ ],
1806
+ "suggested_action": "archive",
1807
+ "ready_to_apply": (
1808
+ f"delimit_ledger_bulk(item_ids='{','.join(i.get('id', '') for i in sliced)}', "
1809
+ f"action='archive', dry_run=True)"
1810
+ ),
1811
+ "truncated": truncated,
1812
+ "total_in_category": total,
1813
+ })
1814
+
1815
+ if duplicate_groups:
1816
+ # One proposal entry per group so the founder can decide group-by-group
1817
+ for prefix, group in sorted(duplicate_groups.items()):
1818
+ sliced, total, truncated = _trim(group)
1819
+ # Keep the most-recent (or first) item, archive the rest
1820
+ sliced_sorted = sorted(
1821
+ sliced,
1822
+ key=lambda x: x.get("updated_at") or x.get("created_at") or "",
1823
+ reverse=True,
1824
+ )
1825
+ keep = sliced_sorted[0]
1826
+ archive = sliced_sorted[1:]
1827
+ proposals.append({
1828
+ "category": "duplicate_titles",
1829
+ "rationale": (
1830
+ f"{total} items share the title prefix {prefix!r}. "
1831
+ f"Suggesting we keep {keep.get('id')} (most recent) "
1832
+ f"and archive the other {len(archive)}."
1833
+ ),
1834
+ "items": [
1835
+ {
1836
+ "id": i.get("id"),
1837
+ "title": (i.get("title") or "")[:80],
1838
+ "venture": i.get("venture"),
1839
+ "status": i.get("status"),
1840
+ "updated_at": i.get("updated_at") or i.get("created_at"),
1841
+ "_role": "keep" if i.get("id") == keep.get("id") else "archive",
1842
+ }
1843
+ for i in sliced_sorted
1844
+ ],
1845
+ "suggested_action": "archive",
1846
+ "ready_to_apply": (
1847
+ f"delimit_ledger_bulk(item_ids='{','.join(i.get('id', '') for i in archive)}', "
1848
+ f"action='archive', dry_run=True)"
1849
+ ),
1850
+ "truncated": truncated,
1851
+ "total_in_category": total,
1852
+ })
1853
+
1854
+ if garbage_items:
1855
+ sliced, total, truncated = _trim(garbage_items)
1856
+ proposals.append({
1857
+ "category": "garbage_venture",
1858
+ "rationale": (
1859
+ f"{total} items belong to test/scratch venture buckets "
1860
+ f"(tmp*, test*, venture_<letter>, custom-venture, unknown) that "
1861
+ f"shouldn't be in production data. Safe to archive in bulk."
1862
+ ),
1863
+ "items": [
1864
+ {
1865
+ "id": i.get("id"),
1866
+ "title": (i.get("title") or "")[:80],
1867
+ "venture": i.get("venture"),
1868
+ "status": i.get("status"),
1869
+ "updated_at": i.get("updated_at") or i.get("created_at"),
1870
+ }
1871
+ for i in sliced
1872
+ ],
1873
+ "suggested_action": "archive",
1874
+ "ready_to_apply": (
1875
+ f"delimit_ledger_bulk(item_ids='{','.join(i.get('id', '') for i in sliced)}', "
1876
+ f"action='archive', dry_run=True)"
1877
+ ),
1878
+ "truncated": truncated,
1879
+ "total_in_category": total,
1880
+ })
1881
+
1882
+ summary = {
1883
+ "total_categories": len(proposals),
1884
+ "total_items": sum(p["total_in_category"] for p in proposals),
1885
+ "stale_open": len(stale_items),
1886
+ "duplicate_titles": sum(len(g) for g in duplicate_groups.values()),
1887
+ "garbage_venture": len(garbage_items),
1888
+ }
1889
+
1890
+ return {
1891
+ "proposals": proposals,
1892
+ "summary": summary,
1893
+ }
1894
+
1895
+
1896
+ # ── LED-1145 Phase 2 #4: stale-TTL auto-cancel ────────────────────────────
1897
+
1898
+ # After this many days of dormancy, an open item is considered stale enough
1899
+ # that auto-archive is the safe default. groom_proposal still flags items
1900
+ # at 30d for triage; this stricter cap is for items that crossed the
1901
+ # triage threshold but never got reviewed. Override via env or arg.
1902
+ STALE_TTL_DEFAULT_DAYS = 60
1903
+
1904
+
1905
+ def _stale_ttl_default() -> int:
1906
+ """Resolve the active stale-TTL threshold from env. 0 disables auto-cancel."""
1907
+ raw = os.environ.get("DELIMIT_STALE_TTL_DAYS", "")
1908
+ if raw == "":
1909
+ return STALE_TTL_DEFAULT_DAYS
1910
+ try:
1911
+ n = int(raw)
1912
+ return max(0, n)
1913
+ except (TypeError, ValueError):
1914
+ return STALE_TTL_DEFAULT_DAYS
1915
+
1916
+
1917
+ def auto_cancel_stale(
1918
+ project_path: str = ".",
1919
+ threshold_days: Optional[int] = None,
1920
+ dry_run: bool = True,
1921
+ max_items: int = 200,
1922
+ ) -> Dict[str, Any]:
1923
+ """Auto-archive open items that have been dormant past the stale TTL.
1924
+
1925
+ LED-1145 Phase 2 #4. Composes Phase 2 #2's stale-detection logic with
1926
+ Phase 1 PR-B's bulk_action(action="archive"). Same dry_run-default
1927
+ pattern as auto_close_linked_external — caller passes dry_run=False
1928
+ explicitly to apply.
1929
+
1930
+ Distinct from groom_proposal's stale_open category because:
1931
+ - The threshold is stricter (default 60d vs groom's 30d)
1932
+ - It auto-applies on dry_run=False (groom is purely propose)
1933
+ - It's intended for nightly automation; groom is for interactive use
1934
+
1935
+ Args:
1936
+ project_path: ledger root.
1937
+ threshold_days: dormancy threshold. Default reads
1938
+ DELIMIT_STALE_TTL_DAYS env (60 if unset). 0 disables.
1939
+ dry_run: True (default) returns a plan; False applies via bulk_action.
1940
+ max_items: cap per call. Items beyond the cap surface in
1941
+ `truncated=True` with `total_candidates` set.
1942
+
1943
+ Returns:
1944
+ {
1945
+ "dry_run": bool,
1946
+ "threshold_days": int,
1947
+ "would_cancel" or "cancelled": [{id, title, venture, status, last_seen}, ...],
1948
+ "errors": [...],
1949
+ "summary": {"scanned": int, "stale": int, "cancelled": int, "errors": int, "truncated": bool},
1950
+ }
1951
+ """
1952
+ if threshold_days is None:
1953
+ threshold_days = _stale_ttl_default()
1954
+ if threshold_days == 0:
1955
+ return {
1956
+ "dry_run": dry_run,
1957
+ "threshold_days": 0,
1958
+ "would_cancel" if dry_run else "cancelled": [],
1959
+ "errors": [],
1960
+ "summary": {
1961
+ "scanned": 0, "stale": 0, "cancelled": 0,
1962
+ "errors": 0, "truncated": False,
1963
+ "note": "DELIMIT_STALE_TTL_DAYS=0 disables auto-cancel",
1964
+ },
1965
+ }
1966
+
1967
+ listing = list_items(
1968
+ status__in=["open", "in_progress", "blocked"],
1969
+ limit=10_000,
1970
+ project_path=project_path,
1971
+ )
1972
+ candidates: List[Dict[str, Any]] = []
1973
+ for ledger_name in ("ops", "strategy"):
1974
+ candidates.extend(listing.get("items", {}).get(ledger_name, []))
1975
+
1976
+ now = _dt.datetime.now(_dt.timezone.utc)
1977
+ stale_threshold = now - _dt.timedelta(days=threshold_days)
1978
+
1979
+ stale_items: List[Dict[str, Any]] = []
1980
+ for item in candidates:
1981
+ ts = item.get("updated_at") or item.get("created_at")
1982
+ parsed = _parse_iso(ts)
1983
+ if parsed and parsed < stale_threshold:
1984
+ stale_items.append(item)
1985
+
1986
+ total_candidates = len(stale_items)
1987
+ truncated = total_candidates > max_items
1988
+ stale_items = stale_items[:max_items]
1989
+
1990
+ summary_records = [
1991
+ {
1992
+ "id": i.get("id"),
1993
+ "title": (i.get("title") or "")[:80],
1994
+ "venture": i.get("venture"),
1995
+ "status": i.get("status"),
1996
+ "last_seen": i.get("updated_at") or i.get("created_at"),
1997
+ }
1998
+ for i in stale_items
1999
+ ]
2000
+
2001
+ if dry_run:
2002
+ return {
2003
+ "dry_run": True,
2004
+ "threshold_days": threshold_days,
2005
+ "would_cancel": summary_records,
2006
+ "errors": [],
2007
+ "summary": {
2008
+ "scanned": len(candidates),
2009
+ "stale": total_candidates,
2010
+ "would_cancel": len(stale_items),
2011
+ "errors": 0,
2012
+ "truncated": truncated,
2013
+ },
2014
+ }
2015
+
2016
+ # Apply via bulk_action(action="archive") so the same audit / no-hard-
2017
+ # delete invariants used everywhere else apply here too.
2018
+ if not stale_items:
2019
+ return {
2020
+ "dry_run": False,
2021
+ "threshold_days": threshold_days,
2022
+ "cancelled": [],
2023
+ "errors": [],
2024
+ "summary": {
2025
+ "scanned": len(candidates), "stale": 0,
2026
+ "cancelled": 0, "errors": 0, "truncated": False,
2027
+ },
2028
+ }
2029
+
2030
+ ids = [i.get("id") for i in stale_items if i.get("id")]
2031
+ bulk_result = bulk_action(
2032
+ item_ids=ids,
2033
+ action="archive",
2034
+ dry_run=False,
2035
+ note=f"auto-cancel: dormant > {threshold_days} days",
2036
+ project_path=project_path,
2037
+ )
2038
+
2039
+ return {
2040
+ "dry_run": False,
2041
+ "threshold_days": threshold_days,
2042
+ "cancelled": summary_records,
2043
+ "errors": bulk_result.get("errors", []),
2044
+ "summary": {
2045
+ "scanned": len(candidates),
2046
+ "stale": total_candidates,
2047
+ "cancelled": bulk_result.get("summary", {}).get("changed", 0),
2048
+ "errors": len(bulk_result.get("errors", [])),
2049
+ "truncated": truncated,
2050
+ },
2051
+ }
2052
+
2053
+
2054
+ # ── LED-1145 Phase 2 capstone: ledger health summary ─────────────────────
2055
+
2056
+ # Traffic-light thresholds for each category. Tuned from observed
2057
+ # real-world ledger sizes — ~50 stale items is when the "groom me"
2058
+ # signal becomes loud; ~20 is yellow heads-up.
2059
+ _HEALTH_STALE_RED = 50
2060
+ _HEALTH_STALE_YELLOW = 20
2061
+ _HEALTH_GARBAGE_RED = 10
2062
+ _HEALTH_GARBAGE_YELLOW = 1
2063
+ _HEALTH_DUPE_RED = 5
2064
+ _HEALTH_DUPE_YELLOW = 2
2065
+
2066
+
2067
+ def _grade(value: int, yellow: int, red: int) -> str:
2068
+ if value >= red:
2069
+ return "red"
2070
+ if value >= yellow:
2071
+ return "yellow"
2072
+ return "green"
2073
+
2074
+
2075
+ def _worst_grade(*grades: str) -> str:
2076
+ """Worst-of N traffic lights: red > yellow > green."""
2077
+ if "red" in grades:
2078
+ return "red"
2079
+ if "yellow" in grades:
2080
+ return "yellow"
2081
+ return "green"
2082
+
2083
+
2084
+ def health_summary(
2085
+ project_path: str = ".",
2086
+ stale_days: int = 30,
2087
+ dup_min_count: int = 3,
2088
+ ) -> Dict[str, Any]:
2089
+ """One-shot health check for the ledger. Composes list_items +
2090
+ groom_proposal + the P0 quota helper into a single traffic-light
2091
+ verdict and a list of concrete next actions.
2092
+
2093
+ LED-1145 capstone — closes the loop on the entire ledger-tooling
2094
+ refactor. Designed for nightly/weekly review or for the founder's
2095
+ session-start status snapshot.
2096
+
2097
+ Args:
2098
+ project_path: ledger root.
2099
+ stale_days: threshold passed through to groom_proposal.
2100
+ dup_min_count: threshold passed through to groom_proposal.
2101
+
2102
+ Returns:
2103
+ {
2104
+ "venture": str,
2105
+ "totals": {"unresolved": int, "open": int, "in_progress": int, "blocked": int, ...},
2106
+ "p0": {"count": int, "quota": int, "health": str},
2107
+ "stale": {"count": int, "health": str},
2108
+ "duplicates": {"groups": int, "items": int, "health": str},
2109
+ "garbage": {"count": int, "health": str},
2110
+ "overall_health": "green" | "yellow" | "red",
2111
+ "next_actions": [
2112
+ {"reason": str, "tool": str, "args": dict},
2113
+ ...
2114
+ ],
2115
+ }
2116
+ """
2117
+ listing = list_items(
2118
+ status__in=["open", "in_progress", "blocked"],
2119
+ limit=10_000,
2120
+ project_path=project_path,
2121
+ )
2122
+ summary = listing.get("summary", {})
2123
+ venture = listing.get("venture", "unknown")
2124
+
2125
+ p0_count = _count_unresolved_p0(project_path=project_path)
2126
+ p0_quota = _p0_soft_quota()
2127
+ p0_health = "green"
2128
+ if p0_quota > 0:
2129
+ if p0_count > p0_quota * 1.5:
2130
+ p0_health = "red"
2131
+ elif p0_count > p0_quota:
2132
+ p0_health = "yellow"
2133
+
2134
+ proposal = groom_proposal(
2135
+ project_path=project_path,
2136
+ stale_days=stale_days,
2137
+ dup_min_count=dup_min_count,
2138
+ max_per_category=10_000,
2139
+ )
2140
+ proposal_summary = proposal.get("summary", {})
2141
+ stale_count = proposal_summary.get("stale_open", 0)
2142
+ dup_items = proposal_summary.get("duplicate_titles", 0)
2143
+ garbage_count = proposal_summary.get("garbage_venture", 0)
2144
+ dup_groups = sum(
2145
+ 1 for p in proposal.get("proposals", [])
2146
+ if p.get("category") == "duplicate_titles"
2147
+ )
2148
+
2149
+ stale_health = _grade(stale_count, _HEALTH_STALE_YELLOW, _HEALTH_STALE_RED)
2150
+ dup_health = _grade(dup_groups, _HEALTH_DUPE_YELLOW, _HEALTH_DUPE_RED)
2151
+ garbage_health = _grade(garbage_count, _HEALTH_GARBAGE_YELLOW, _HEALTH_GARBAGE_RED)
2152
+
2153
+ overall = _worst_grade(p0_health, stale_health, dup_health, garbage_health)
2154
+
2155
+ # Build next-actions list ordered by impact
2156
+ next_actions: List[Dict[str, Any]] = []
2157
+ if garbage_count > 0:
2158
+ next_actions.append({
2159
+ "reason": f"{garbage_count} item(s) in test/scratch venture buckets",
2160
+ "tool": "delimit_ledger_groom",
2161
+ "args": {"venture": venture},
2162
+ "follow_up": "Apply the garbage_venture proposal via delimit_ledger_bulk(action='archive')",
2163
+ })
2164
+ if dup_groups > 0:
2165
+ next_actions.append({
2166
+ "reason": f"{dup_groups} duplicate-title group(s) covering {dup_items} items",
2167
+ "tool": "delimit_ledger_groom",
2168
+ "args": {"venture": venture, "dup_min_count": dup_min_count},
2169
+ "follow_up": "Review duplicate_titles proposals; archive all but most-recent in each group",
2170
+ })
2171
+ if stale_count > 0:
2172
+ next_actions.append({
2173
+ "reason": f"{stale_count} item(s) dormant >{stale_days}d",
2174
+ "tool": "delimit_ledger_auto_cancel_stale",
2175
+ "args": {"venture": venture, "threshold_days": 60, "dry_run": True},
2176
+ "follow_up": "Run with dry_run=False after reviewing the plan",
2177
+ })
2178
+ if p0_quota > 0 and p0_count > p0_quota:
2179
+ next_actions.append({
2180
+ "reason": f"P0 inflation: {p0_count} unresolved P0s (threshold {p0_quota})",
2181
+ "tool": "delimit_ledger_list",
2182
+ "args": {"venture": venture, "priority_in": "P0", "status_in": "open,in_progress,blocked", "fields": "slim"},
2183
+ "follow_up": "Triage each: ship, demote to P1, or archive",
2184
+ })
2185
+ if not next_actions:
2186
+ next_actions.append({
2187
+ "reason": "All categories green",
2188
+ "tool": None,
2189
+ "args": {},
2190
+ "follow_up": "No grooming required",
2191
+ })
2192
+
2193
+ return {
2194
+ "venture": venture,
2195
+ "totals": {
2196
+ "unresolved": summary.get("total", 0),
2197
+ "open": summary.get("open", 0),
2198
+ "in_progress": summary.get("in_progress", 0),
2199
+ "blocked": summary.get("blocked", 0),
2200
+ },
2201
+ "p0": {"count": p0_count, "quota": p0_quota, "health": p0_health},
2202
+ "stale": {"count": stale_count, "health": stale_health, "threshold_days": stale_days},
2203
+ "duplicates": {"groups": dup_groups, "items": dup_items, "health": dup_health},
2204
+ "garbage": {"count": garbage_count, "health": garbage_health},
2205
+ "overall_health": overall,
2206
+ "next_actions": next_actions,
2207
+ }