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.
- package/CHANGELOG.md +96 -0
- package/README.md +25 -18
- package/adapters/codex-security.js +64 -0
- package/adapters/codex-skill.js +78 -0
- package/adapters/cursor-rules.js +73 -0
- package/bin/delimit-setup.js +23 -0
- package/gateway/ai/backends/governance_bridge.py +168 -2
- package/gateway/ai/backends/memory_bridge.py +218 -3
- package/gateway/ai/backends/tools_design.py +563 -83
- package/gateway/ai/backends/tools_infra.py +21 -7
- package/gateway/ai/backends/tools_real.py +3 -1
- package/gateway/ai/content_grounding/__init__.py +98 -0
- package/gateway/ai/content_grounding/build.py +350 -0
- package/gateway/ai/content_grounding/consume.py +280 -0
- package/gateway/ai/content_grounding/features.py +218 -0
- package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +9 -0
- package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +9 -0
- package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +18 -0
- package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +18 -0
- package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +23 -0
- package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +16 -0
- package/gateway/ai/content_grounding/schemas/claim.schema.json +40 -0
- package/gateway/ai/content_grounding/schemas/event.schema.json +23 -0
- package/gateway/ai/content_grounding/schemas.py +276 -0
- package/gateway/ai/content_grounding/telemetry.py +221 -0
- package/gateway/ai/governance.py +89 -0
- package/gateway/ai/hot_reload.py +148 -7
- package/gateway/ai/inbox_drafts/__init__.py +61 -0
- package/gateway/ai/inbox_drafts/registry.py +412 -0
- package/gateway/ai/inbox_drafts/schema.py +374 -0
- package/gateway/ai/inbox_executor.py +565 -0
- package/gateway/ai/ledger_manager.py +1483 -25
- package/gateway/ai/license_core.py +3 -1
- package/gateway/ai/mcp_bridge.py +1 -1
- package/gateway/ai/reddit_proxy.py +8 -6
- package/gateway/ai/server.py +451 -9
- package/gateway/ai/supabase_sync.py +47 -7
- package/gateway/ai/swarm.py +1 -1
- package/gateway/ai/workers/executor.py +1 -1
- package/gateway/core/diff_engine_v2.py +45 -10
- package/gateway/core/zero_spec/express_extractor.py +1 -1
- package/lib/delimit-template.js +5 -0
- 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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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":
|
|
447
|
-
"open": sum(1 for i in
|
|
448
|
-
"done": sum(1 for i in
|
|
449
|
-
"in_progress": sum(1 for i in
|
|
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 = {
|
|
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
|
+
}
|