delimit-cli 4.6.0 → 4.6.2

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.
@@ -325,8 +325,11 @@ CRITICAL_RISK_TOOLS = {
325
325
  'deploy_rollback', 'data_migrate',
326
326
  }
327
327
 
328
- # Rate limits per tool per hour - prevents runaway loops
329
- _TOOL_RATE_LIMITS = {
328
+ # Rate limits per tool per hour - prevents runaway loops in autonomous
329
+ # code paths. Defaults are conservative; founder-operator sessions set
330
+ # DELIMIT_RATE_LIMITS_DISABLED=1 (global bypass) or
331
+ # DELIMIT_RATE_LIMIT_<TOOL>=N (per-tool override) to lift the cap.
332
+ _DEFAULT_TOOL_RATE_LIMITS = {
330
333
  'social_post': 10,
331
334
  'social_target': 20,
332
335
  'social_approve': 10,
@@ -336,6 +339,35 @@ _TOOL_RATE_LIMITS = {
336
339
  'ledger_add': 30,
337
340
  }
338
341
 
342
+
343
+ def _resolve_rate_limit(clean_tool_name: str) -> Optional[int]:
344
+ """Resolve the per-hour rate limit for a tool, in order:
345
+ 1. DELIMIT_RATE_LIMITS_DISABLED=1 → None (no limit)
346
+ 2. DELIMIT_RATE_LIMIT_<TOOL>=N → N (per-tool override; 0 = no limit)
347
+ 3. _DEFAULT_TOOL_RATE_LIMITS[clean_tool_name] → default
348
+ 4. None (no limit for unconfigured tools)
349
+
350
+ The env-var bypass exists so the founder's interactive sessions can
351
+ call delimit_deliberate freely while autonomous loops keep the 5/hour
352
+ safety cap. Set the env var at MCP-server-process startup time.
353
+ """
354
+ if os.environ.get("DELIMIT_RATE_LIMITS_DISABLED", "").lower() in ("1", "true", "yes"):
355
+ return None
356
+ env_key = f"DELIMIT_RATE_LIMIT_{clean_tool_name.upper()}"
357
+ override = os.environ.get(env_key, "").strip()
358
+ if override:
359
+ try:
360
+ n = int(override)
361
+ return n if n > 0 else None
362
+ except ValueError:
363
+ pass # malformed override → fall through to default
364
+ return _DEFAULT_TOOL_RATE_LIMITS.get(clean_tool_name)
365
+
366
+
367
+ # Back-compat: callers that introspect _TOOL_RATE_LIMITS still see the
368
+ # raw defaults. Use _resolve_rate_limit() for the env-aware value.
369
+ _TOOL_RATE_LIMITS = dict(_DEFAULT_TOOL_RATE_LIMITS)
370
+
339
371
  _tool_call_counts: Dict[str, list] = {}
340
372
  _tool_rate_lock = threading.Lock()
341
373
 
@@ -343,7 +375,7 @@ _tool_rate_lock = threading.Lock()
343
375
  def _check_rate_limit(tool_name: str) -> Optional[Dict]:
344
376
  """Enforce per-tool rate limits. Returns error dict if over limit, None if allowed."""
345
377
  clean = tool_name.replace('delimit_', '')
346
- limit = _TOOL_RATE_LIMITS.get(clean)
378
+ limit = _resolve_rate_limit(clean)
347
379
  if not limit:
348
380
  return None
349
381
 
@@ -357,7 +389,9 @@ def _check_rate_limit(tool_name: str) -> Optional[Dict]:
357
389
  return {
358
390
  "status": "rate_limited",
359
391
  "reason": f"Tool '{tool_name}' called {len(calls)} times in the last hour (limit: {limit}). "
360
- f"This prevents runaway loops. Wait or check your scan configuration.",
392
+ f"This prevents runaway loops. Wait, or in an operator session set "
393
+ f"DELIMIT_RATE_LIMITS_DISABLED=1 (global bypass) or "
394
+ f"DELIMIT_RATE_LIMIT_{clean.upper()}=N (per-tool override) and restart the MCP server.",
361
395
  "calls_this_hour": len(calls),
362
396
  "limit": limit,
363
397
  }
@@ -2586,6 +2620,118 @@ def delimit_external_pr_check(
2586
2620
  )
2587
2621
 
2588
2622
 
2623
+ @mcp.tool()
2624
+ def delimit_substantive_content_check(
2625
+ body: str,
2626
+ proposed_action: str = "comment",
2627
+ repo: str = "",
2628
+ repo_description: str = "",
2629
+ repo_topics: Optional[List[str]] = None,
2630
+ ) -> Dict[str, Any]:
2631
+ """Pre-submit gate for autonomous github outreach (LED-2214b).
2632
+
2633
+ When to use: as the LAST step before any agent submits a comment,
2634
+ issue body, or PR description to a third-party github repo via
2635
+ the outreach_substantive task path. Mandatory under CLAUDE.md
2636
+ SHIFT-1; bypass requires explicit founder approval.
2637
+ When NOT to use: for internal repo content, for posts on
2638
+ platforms other than github, or for non-outreach submissions
2639
+ (use the surface's own validators instead).
2640
+
2641
+ Sibling contrast: delimit_external_pr_check guards PR
2642
+ duplication; this guards the substantive-content boundary itself.
2643
+ For a PR submission the agent calls BOTH — this one first to
2644
+ refuse covert-commercial drafts, then external_pr_check to
2645
+ refuse duplicates.
2646
+
2647
+ Side effects: read-only. Pure validator over the body string and
2648
+ target metadata; no network, no ledger writes, no notifications.
2649
+
2650
+ The gate runs in two stages:
2651
+
2652
+ 1. Target-side veto — if repo / repo_description / repo_topics
2653
+ contain a banking / fintech / regulator-adjacent keyword,
2654
+ the gate blocks regardless of content quality (SHIFT-1 hard
2655
+ veto; KYC would deanonymize the operating account).
2656
+ 2. Content shape — bans forbidden phrases (incl. our own
2657
+ product names), requires at least one technical anchor
2658
+ (commit hash, issue number, CVE, spec path, source file
2659
+ path), enforces minimum body length.
2660
+
2661
+ Args:
2662
+ body: The draft body to validate. Required.
2663
+ proposed_action: "comment", "issue", or "pr". Default
2664
+ "comment".
2665
+ repo: Target "owner/name" if known (used in target veto).
2666
+ repo_description: Repo description string (target veto).
2667
+ repo_topics: List of repo topic tags (target veto).
2668
+
2669
+ Returns:
2670
+ Dict with verdict ("allow" | "block"), reason, violations
2671
+ list, anchors dict, stage ("target" | "content"), and
2672
+ next_steps.
2673
+ """
2674
+ from ai.outreach_substantive import evaluate_substantive_payload
2675
+
2676
+ result = evaluate_substantive_payload(
2677
+ body=body,
2678
+ proposed_action=proposed_action,
2679
+ repo=repo,
2680
+ repo_description=repo_description,
2681
+ repo_topics=repo_topics,
2682
+ )
2683
+ return _with_next_steps("substantive_content_check", result)
2684
+
2685
+
2686
+ @mcp.tool()
2687
+ def delimit_outreach_loop_tick(
2688
+ venture: str = "delimit",
2689
+ max_dispatch: int = 3,
2690
+ max_monitor: int = 50,
2691
+ ) -> Dict[str, Any]:
2692
+ """Run one tick of the autonomous github-outreach loop (LED-2214b).
2693
+
2694
+ When to use: from an external scheduler (cron, loop_daemon) or
2695
+ for an ad-hoc manual cycle. The tick monitors existing outreach
2696
+ LEDs for new activity AND scans for new substantive candidates.
2697
+ When NOT to use: as a backfill for thousands of stale items —
2698
+ the per-tick caps are intentional. Multiple ticks at the
2699
+ scheduler interval is the right pattern.
2700
+
2701
+ Sibling contrast: delimit_social_target scans a broader platform
2702
+ set; this is github-only and dispatches via the substantive-
2703
+ outreach path (with the SHIFT-1 gates). delimit_sensor_github_
2704
+ issue watches a single issue; this orchestrates the sensor over
2705
+ every open outreach LED.
2706
+
2707
+ Side effects: reads ledger, network reads (gh CLI) for the
2708
+ monitor phase, writes new intel-class LEDs + dispatches new
2709
+ substantive tasks for the scan phase. Honours the
2710
+ DELIMIT_GITHUB_OUTREACH_DISABLED env var and the
2711
+ ~/.delimit/outreach_pause sentinel file as kill switches.
2712
+
2713
+ Args:
2714
+ venture: Sourcing venture (default "delimit").
2715
+ max_dispatch: Per-tick substantive-dispatch cap (default 3).
2716
+ Targets beyond the cap still file intel LEDs but are
2717
+ not dispatched on this tick.
2718
+ max_monitor: Per-tick monitor-call cap (default 50).
2719
+
2720
+ Returns:
2721
+ Dict with venture, started_at, ended_at, kill_switch,
2722
+ monitor records, scan summary, dispatch_count, status, and
2723
+ next_steps.
2724
+ """
2725
+ from ai.outreach_loop_daemon import tick
2726
+
2727
+ result = tick(
2728
+ venture=venture,
2729
+ max_dispatch=max_dispatch,
2730
+ max_monitor=max_monitor,
2731
+ )
2732
+ return _with_next_steps("outreach_loop_tick", result)
2733
+
2734
+
2589
2735
  @mcp.tool()
2590
2736
  def delimit_tdqs_lint(
2591
2737
  target_file: Annotated[str, Field(description="Path to a Python file with @mcp.tool() decorators. Default \"ai/server.py\", resolved against cwd.")] = "ai/server.py",
@@ -3017,6 +3163,24 @@ def _deploy_plan_chain(app: str = "", env: str = "", git_ref: Optional[str] = No
3017
3163
 
3018
3164
  chain: Dict[str, Any] = {"id": "deploy_plan_chain", "steps": []}
3019
3165
 
3166
+ # Step 0 (LED-1418): worktree-sanity precheck. If the deploy target
3167
+ # is a corrupt worktree (LED-1401 class — bare-mode .git/config +
3168
+ # stranded sibling worktree) then security_audit and the build steps
3169
+ # below would all read from misleading state. Halt before any chain
3170
+ # step runs.
3171
+ from backends.git_health import check_worktree_sanity
3172
+ worktree_target = app if app and ("/" in app or app == "." or app.startswith(".")) else "."
3173
+ health = check_worktree_sanity(worktree_target)
3174
+ chain["steps"].append({"step": "worktree_precheck", "ok": health["ok"]})
3175
+ if not health["ok"]:
3176
+ chain["status"] = "blocked_worktree_unhealthy"
3177
+ return _with_next_steps("deploy_plan", {
3178
+ "status": "blocked",
3179
+ "reason": f"Deploy plan halted: worktree unhealthy ({health['reason']})",
3180
+ "worktree_health": health,
3181
+ "chain": chain,
3182
+ })
3183
+
3020
3184
  # Step 1: Security audit preflight
3021
3185
  audit_target = app if app else "."
3022
3186
  audit_result = _chain_call("deploy_plan", "security_audit", security_audit,
@@ -4755,6 +4919,23 @@ def delimit_evidence_collect(target: Annotated[str, Field(description="Repositor
4755
4919
  gate = require_premium("evidence_collect")
4756
4920
  if gate:
4757
4921
  return gate
4922
+
4923
+ # LED-1411 / LED-1418: worktree-sanity precheck. Evidence bundles
4924
+ # written against a corrupt worktree (bare-mode .git + stranded
4925
+ # sibling worktree) capture stale state that can mislead a future
4926
+ # evidence_verify call. Same precheck as delimit_test_smoke.
4927
+ from backends.git_health import check_worktree_sanity
4928
+ health = check_worktree_sanity(target)
4929
+ if not health["ok"]:
4930
+ return _with_next_steps("evidence_collect", {
4931
+ "error": "worktree_unhealthy",
4932
+ "reason": health["reason"],
4933
+ "detail": health["detail"],
4934
+ "path": health["path"],
4935
+ "tool": "evidence.collect",
4936
+ "status": "blocked_worktree_unhealthy",
4937
+ })
4938
+
4758
4939
  from backends.repo_bridge import evidence_collect
4759
4940
  options = {}
4760
4941
  if evidence_type:
@@ -5589,6 +5770,60 @@ def delimit_obs_status() -> Dict[str, Any]:
5589
5770
  return _delimit_obs_impl(action="status")
5590
5771
 
5591
5772
 
5773
+ @mcp.tool()
5774
+ def delimit_heartbeat_check(
5775
+ heartbeat_dir: Annotated[Optional[str], Field(description="Override the heartbeat directory. Default: $DELIMIT_HEARTBEAT_DIR env var or ~/.delimit/heartbeats/.")] = None,
5776
+ ) -> Dict[str, Any]:
5777
+ """Walk the heartbeat directory and report which scheduled services are stale (LED-1412).
5778
+
5779
+ When to use: as part of the session-start ritual to surface silent
5780
+ daemon staleness before it becomes a customer-visible incident. The
5781
+ 2026-05-15 incident — `delimit-reddit-proxy.service` inactive for 13
5782
+ days, all reddit scans 429-failing silently, founder noticing only
5783
+ via "3 day old posts" — is the failure mode this prevents. Each
5784
+ scheduled task writes `~/.delimit/heartbeats/<service>.json` after
5785
+ every run; this tool walks the dir and classifies each service.
5786
+ When NOT to use: for one-off liveness checks (just read the file
5787
+ yourself) or for full-host metrics (delimit_obs_status). Phase 2
5788
+ will add an external deadman ping for full-host outages —
5789
+ heartbeats here are local-only.
5790
+
5791
+ Sibling contrast: delimit_obs_status reports composed runtime
5792
+ observability metrics; this reports per-service liveness based on
5793
+ last_run timestamps written by each daemon. delimit_gov_health
5794
+ reports the kernel layer.
5795
+
5796
+ Side effects: read-only on the heartbeat directory. No network, no
5797
+ write, no ledger, no notification.
5798
+
5799
+ Classification (most-severe-first):
5800
+ - parse_error: heartbeat file unreadable
5801
+ - failed: status='failed' in the record
5802
+ - stale: last_run older than service-specific threshold
5803
+ - degraded: status='degraded' in the record
5804
+ - never_seen: configured service has no heartbeat file yet
5805
+ - unknown_age: heartbeat exists but timestamp won't parse
5806
+ - ok: status='ok' AND last_run within threshold
5807
+
5808
+ Per-service thresholds default to sensible values (reddit/social-loop
5809
+ 2h, inbox 30min, daily timers 36h). Override via
5810
+ `<dir>/_thresholds.json` — JSON map of {service_name: seconds}.
5811
+
5812
+ Args:
5813
+ heartbeat_dir: optional override of the directory to scan.
5814
+
5815
+ Returns:
5816
+ Dict with checked_at, summary {ok/stale/degraded/failed/parse_error/never_seen/unknown_age},
5817
+ services (list of per-service classifications), and stale_services
5818
+ (convenience list of names needing attention).
5819
+ """
5820
+ from ai.heartbeat import check_staleness
5821
+ return _with_next_steps(
5822
+ "heartbeat_check",
5823
+ _safe_call(check_staleness, heartbeat_dir=heartbeat_dir),
5824
+ )
5825
+
5826
+
5592
5827
  # ─── DesignSystem (UI Tooling) ──────────────────────────────────────────
5593
5828
 
5594
5829
  @mcp.tool()
@@ -5972,6 +6207,23 @@ def delimit_test_smoke(project_path: Annotated[str, Field(description="Project p
5972
6207
  Returns:
5973
6208
  Dict with pass/fail/error counts, framework detected, output.
5974
6209
  """
6210
+ # LED-1411: worktree-sanity precheck. The LED-1403/LED-1401 incident
6211
+ # showed delimit_test_smoke can run against a corrupt worktree
6212
+ # (bare-mode .git + stranded sibling) and report phantom failures
6213
+ # against stale code. Fail fast with actionable remediation BEFORE
6214
+ # invoking the test runner so the caller knows the report is real.
6215
+ from backends.git_health import check_worktree_sanity
6216
+ health = check_worktree_sanity(project_path)
6217
+ if not health["ok"]:
6218
+ return _with_next_steps("test_smoke", {
6219
+ "error": "worktree_unhealthy",
6220
+ "reason": health["reason"],
6221
+ "detail": health["detail"],
6222
+ "path": health["path"],
6223
+ "tool": "test.smoke",
6224
+ "status": "blocked_worktree_unhealthy",
6225
+ })
6226
+
5975
6227
  from backends.ui_bridge import test_smoke
5976
6228
  return _with_next_steps("test_smoke", _safe_call(test_smoke, project_path=project_path, test_suite=test_suite))
5977
6229
 
@@ -6073,113 +6325,20 @@ async def delimit_sensor_github_issue(
6073
6325
  Dict with new comments, issue state, severity classification,
6074
6326
  next_steps. Returns {error: ...} on validation failure.
6075
6327
  """
6076
- import re as _re
6077
- # Validate inputs defense-in-depth even though subprocess.run with
6078
- # list argv (no shell=True) makes classic injection inert. See #40.
6079
- if not _re.match(r'^[\w.-]+/[\w.-]+$', repo):
6080
- return _with_next_steps("sensor_github_issue", {"error": f"Invalid repo format: {repo}. Use owner/repo."})
6081
- if '..' in repo:
6082
- return _with_next_steps("sensor_github_issue", {"error": f"Invalid repo: path traversal sequences not allowed"})
6083
- if not isinstance(issue_number, int) or issue_number <= 0:
6084
- return _with_next_steps("sensor_github_issue", {"error": f"Invalid issue number: {issue_number}"})
6085
-
6086
- # LED-881 / #40 confused-deputy guard
6087
- refusal = _check_repo_allowlist(repo)
6088
- if refusal is not None:
6089
- return _with_next_steps("sensor_github_issue", refusal)
6090
-
6091
- try:
6092
- # Fetch comments
6093
- comments_jq = (
6094
- "[.[] | {id: .id, author: .user.login, "
6095
- "created_at: .created_at, body: (.body | .[0:500])}]"
6096
- )
6097
- comments_proc = subprocess.run(
6098
- [
6099
- "gh", "api",
6100
- f"repos/{repo}/issues/{issue_number}/comments",
6101
- "--jq", comments_jq,
6102
- ],
6103
- capture_output=True,
6104
- text=True,
6105
- timeout=30,
6106
- )
6107
- if comments_proc.returncode != 0:
6108
- return _with_next_steps("sensor_github_issue", {
6109
- "error": f"gh api comments failed: {comments_proc.stderr.strip()}",
6110
- "has_new_activity": False,
6111
- })
6112
-
6113
- all_comments = json.loads(comments_proc.stdout) if comments_proc.stdout.strip() else []
6114
-
6115
- # Filter to new comments only
6116
- new_comments = [c for c in all_comments if c["id"] > since_comment_id]
6117
-
6118
- # Fetch issue state
6119
- issue_jq = "{state: .state, labels: [.labels[].name], reactions: .reactions.total_count}"
6120
- issue_proc = subprocess.run(
6121
- [
6122
- "gh", "api",
6123
- f"repos/{repo}/issues/{issue_number}",
6124
- "--jq", issue_jq,
6125
- ],
6126
- capture_output=True,
6127
- text=True,
6128
- timeout=30,
6129
- )
6130
- if issue_proc.returncode != 0:
6131
- return _with_next_steps("sensor_github_issue", {
6132
- "error": f"gh api issue failed: {issue_proc.stderr.strip()}",
6133
- "has_new_activity": False,
6134
- })
6135
-
6136
- issue_info = json.loads(issue_proc.stdout) if issue_proc.stdout.strip() else {}
6137
- issue_state = issue_info.get("state", "unknown")
6138
-
6139
- # Determine severity
6140
- severity = "green"
6141
-
6142
- # Check for negative signals in new comments
6143
- combined_body = " ".join(c.get("body", "") for c in new_comments).lower()
6144
- has_negative = any(kw in combined_body for kw in _NEGATIVE_KEYWORDS)
6145
-
6146
- if has_negative:
6147
- severity = "red"
6148
- elif issue_state == "closed" and len(all_comments) == 0:
6149
- # Closed with no engagement at all
6150
- severity = "amber"
6151
- elif issue_state == "closed":
6152
- # Closed but had some engagement -- could be resolved or rejected
6153
- severity = "amber"
6154
-
6155
- latest_comment_id = max((c["id"] for c in all_comments), default=since_comment_id)
6156
-
6157
- repo_key = repo.replace("/", "_")
6158
- return _with_next_steps("sensor_github_issue", {
6159
- "repo": repo,
6160
- "issue_number": str(issue_number),
6161
- "signal": {
6162
- "id": f"sensor:github_issue:{repo_key}:{issue_number}",
6163
- "venture": "delimit",
6164
- "metric": "outreach_issue_activity",
6165
- "source": f"https://github.com/{repo}/issues/{issue_number}",
6166
- "timestamp": datetime.now(timezone.utc).isoformat(),
6167
- "severity": severity,
6168
- },
6169
- "issue_state": issue_state,
6170
- "new_comments": new_comments,
6171
- "latest_comment_id": latest_comment_id,
6172
- "total_comments": len(all_comments),
6173
- "has_new_activity": len(new_comments) > 0,
6174
- })
6175
-
6176
- except subprocess.TimeoutExpired:
6177
- return _with_next_steps("sensor_github_issue", {"error": "gh command timed out after 30s", "has_new_activity": False})
6178
- except json.JSONDecodeError as e:
6179
- return _with_next_steps("sensor_github_issue", {"error": f"Failed to parse gh output: {e}", "has_new_activity": False})
6180
- except Exception as e:
6181
- logger.error("Sensor error: %s\n%s", e, traceback.format_exc())
6182
- return _with_next_steps("sensor_github_issue", {"error": str(e), "has_new_activity": False})
6328
+ # LED-2214b-followup: delegate to the shared sync impl in
6329
+ # ai.governance so the outreach daemon's monitor_phase can use the
6330
+ # same logic via a normal Python import (no MCP wrapper). The MCP
6331
+ # tool's return schema is unchanged — we just wrap the impl's raw
6332
+ # dict with _with_next_steps as before.
6333
+ from ai.governance import _sensor_github_issue_impl
6334
+ return _with_next_steps(
6335
+ "sensor_github_issue",
6336
+ _sensor_github_issue_impl(
6337
+ repo=repo,
6338
+ issue_number=issue_number,
6339
+ since_comment_id=since_comment_id,
6340
+ ),
6341
+ )
6183
6342
 
6184
6343
 
6185
6344
  # --- STR-062: Migration Pattern Detector ---
@@ -7755,7 +7914,13 @@ def delimit_ledger_update(
7755
7914
 
7756
7915
 
7757
7916
  @mcp.tool()
7758
- def delimit_ledger_done(item_id: Annotated[str, Field(description="Ledger item id (e.g. \"LED-001\"). Required.")], note: Annotated[str, Field(description="Optional completion note.")] = "", venture: Annotated[str, Field(description="Project name or path. Empty = auto-detect.")] = "") -> Dict[str, Any]:
7917
+ def delimit_ledger_done(
7918
+ item_id: Annotated[str, Field(description="Ledger item id (e.g. \"LED-001\"). Required.")],
7919
+ note: Annotated[str, Field(description="Optional completion note. If the note contains a GitHub PR URL, it will be auto-extracted as ship proof.")] = "",
7920
+ venture: Annotated[str, Field(description="Project name or path. Empty = auto-detect.")] = "",
7921
+ commit_sha: Annotated[str, Field(description="LED-1408: optional merge-commit SHA proving the fix shipped. Recorded as ship_proof on the event; verified=True flag set on the item.")] = "",
7922
+ pr_url: Annotated[str, Field(description="LED-1408: optional GitHub PR URL proving the fix shipped. Parsed into pr_owner/pr_repo/pr_number; verified=True flag set on the item.")] = "",
7923
+ ) -> Dict[str, Any]:
7759
7924
  """Mark a ledger item as done (convenience wrapper).
7760
7925
 
7761
7926
  When to use: to close out a ledger item with one call instead of
@@ -7767,19 +7932,33 @@ def delimit_ledger_done(item_id: Annotated[str, Field(description="Ledger item i
7767
7932
  this is the close-out shortcut.
7768
7933
 
7769
7934
  Side effects: writes status="done" + optional note via
7770
- ai.ledger_manager.update_item.
7935
+ ai.ledger_manager.update_item. LED-1408 Phase 1: when commit_sha or
7936
+ pr_url is provided (or a PR URL is detected in the note), attaches
7937
+ a ship_proof block to the event with verified=True. Future audits
7938
+ use this flag to distinguish trustworthy-done from
7939
+ marked-done-but-never-verified. Phase 2 will tighten enforcement.
7771
7940
 
7772
7941
  Args:
7773
7942
  item_id: Ledger item id (e.g. "LED-001"). Required.
7774
- note: Optional completion note.
7943
+ note: Optional completion note. PR URLs in the note are auto-extracted.
7775
7944
  venture: Project name or path. Empty = auto-detect.
7945
+ commit_sha: Optional merge-commit SHA (LED-1408 ship proof).
7946
+ pr_url: Optional GitHub PR URL (LED-1408 ship proof).
7776
7947
 
7777
7948
  Returns:
7778
- Dict with the update result and next_steps.
7949
+ Dict with the update result (including ship_proof when proof was
7950
+ supplied) and next_steps.
7779
7951
  """
7780
7952
  from ai.ledger_manager import update_item
7781
7953
  project = _resolve_venture(venture)
7782
- result = update_item(item_id=item_id, status="done", note=note, project_path=project)
7954
+ result = update_item(
7955
+ item_id=item_id,
7956
+ status="done",
7957
+ note=note,
7958
+ project_path=project,
7959
+ commit_sha=commit_sha or None,
7960
+ pr_url=pr_url or None,
7961
+ )
7783
7962
  return _with_next_steps("ledger_done", result)
7784
7963
 
7785
7964
 
@@ -234,6 +234,101 @@ def get_latest_soul(project_path: str = "") -> Optional[SessionSoul]:
234
234
  return None
235
235
 
236
236
 
237
+ def _soul_sort_key(soul: SessionSoul, fallback_path: Path) -> str:
238
+ """Sort key for global recency ranking. Prefer the soul's own
239
+ created_at (ISO-8601, lexically sortable); fall back to the file's
240
+ mtime when created_at is missing so a malformed/legacy soul still
241
+ orders sensibly rather than sinking to the bottom unconditionally."""
242
+ if soul.created_at:
243
+ return soul.created_at
244
+ try:
245
+ # Fall back to the file mtime, rendered as an ISO-8601 string so it
246
+ # compares lexically against real created_at values on the same
247
+ # scale. Only reached when created_at is empty.
248
+ return datetime.fromtimestamp(
249
+ fallback_path.stat().st_mtime, timezone.utc
250
+ ).isoformat()
251
+ except (OSError, ValueError):
252
+ return ""
253
+
254
+
255
+ def find_most_recent_soul_across_projects(
256
+ exclude_project_path: str = "",
257
+ ) -> Optional[Dict[str, Any]]:
258
+ """Scan every project-hash soul directory under SOULS_BASE_DIR and
259
+ return the globally-most-recent soul, with its originating project.
260
+
261
+ LED-218 FIX D: cross-venture fallback for `revive()` when the current
262
+ working directory resolves to a project that has no souls (e.g. running
263
+ from /root). Read-only; never writes. Returns None when no souls exist
264
+ anywhere.
265
+
266
+ Args:
267
+ exclude_project_path: if set, the soul directory for this project
268
+ is skipped (it already had no usable soul, so re-scanning it is
269
+ wasted work and could otherwise re-surface a stale latest.json).
270
+
271
+ Returns:
272
+ {"soul": SessionSoul, "project_hash": str, "project_path": str}
273
+ for the most recent soul found, or None.
274
+ """
275
+ if not SOULS_BASE_DIR.exists():
276
+ return None
277
+
278
+ exclude_hash = _project_hash(exclude_project_path) if exclude_project_path else None
279
+
280
+ best: Optional[SessionSoul] = None
281
+ best_key: str = ""
282
+ best_hash: str = ""
283
+
284
+ for proj_dir in SOULS_BASE_DIR.iterdir():
285
+ if not proj_dir.is_dir():
286
+ continue
287
+ if exclude_hash and proj_dir.name == exclude_hash:
288
+ continue
289
+
290
+ # Prefer the per-project latest.json; fall back to scanning the
291
+ # timestamped soul files if latest.json is absent/corrupt.
292
+ candidate: Optional[SessionSoul] = None
293
+ candidate_path: Optional[Path] = None
294
+
295
+ latest = proj_dir / "latest.json"
296
+ if latest.exists():
297
+ candidate = _load_soul(latest)
298
+ candidate_path = latest
299
+
300
+ if candidate is None:
301
+ soul_files = sorted(
302
+ [f for f in proj_dir.iterdir()
303
+ if f.name != "latest.json" and f.suffix == ".json"],
304
+ key=lambda f: f.name,
305
+ reverse=True,
306
+ )
307
+ for f in soul_files:
308
+ candidate = _load_soul(f)
309
+ if candidate is not None:
310
+ candidate_path = f
311
+ break
312
+
313
+ if candidate is None or candidate_path is None:
314
+ continue
315
+
316
+ key = _soul_sort_key(candidate, candidate_path)
317
+ if best is None or key > best_key:
318
+ best = candidate
319
+ best_key = key
320
+ best_hash = proj_dir.name
321
+
322
+ if best is None:
323
+ return None
324
+
325
+ return {
326
+ "soul": best,
327
+ "project_hash": best_hash,
328
+ "project_path": best.project_path,
329
+ }
330
+
331
+
237
332
  def _format_revival(soul: SessionSoul) -> str:
238
333
  """Format a soul into a readable context string for any AI model."""
239
334
  lines = []
@@ -339,6 +434,32 @@ def revive(project_path: str = "", soul_id: str = "") -> Dict[str, Any]:
339
434
  # Get latest
340
435
  soul = get_latest_soul(project_path)
341
436
  if not soul:
437
+ # FIX D — cross-venture fallback. The current working directory
438
+ # resolved to a project with no soul (common when reviving from a
439
+ # neutral dir like /root). Rather than dead-ending at "no_souls",
440
+ # surface the globally-most-recent soul from any other venture /
441
+ # project so the operator still gets continuity. Clearly labeled
442
+ # via `recovered_from_venture` so the caller knows it came from a
443
+ # different project. This ADDITIVE path only fires when the
444
+ # resolved project itself is empty AND no explicit soul_id was
445
+ # given, so existing single-project users see no change.
446
+ fallback = find_most_recent_soul_across_projects(
447
+ exclude_project_path=project_path
448
+ )
449
+ if fallback:
450
+ recovered = fallback["soul"]
451
+ return {
452
+ "status": "revived",
453
+ "soul": asdict(recovered),
454
+ "context": _format_revival(recovered),
455
+ "recovered_from_venture": recovered.project_path
456
+ or fallback.get("project_hash", ""),
457
+ "recovered_project_hash": fallback.get("project_hash", ""),
458
+ "note": (
459
+ f"No soul for {project_path}; recovered the most recent "
460
+ f"soul from {recovered.project_path or fallback.get('project_hash', '')}."
461
+ ),
462
+ }
342
463
  return {
343
464
  "status": "no_souls",
344
465
  "message": f"No session souls found for {project_path}. Nothing to revive.",