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.
- package/CHANGELOG.md +71 -8
- package/bin/delimit-cli.js +59 -9
- package/bin/delimit-setup.js +7 -3
- package/gateway/ai/agent_dispatch.py +5 -0
- package/gateway/ai/backends/gateway_core.py +6 -0
- package/gateway/ai/backends/git_health.py +175 -0
- package/gateway/ai/backends/memory_bridge.py +210 -53
- package/gateway/ai/backends/tools_infra.py +93 -0
- package/gateway/ai/backends/tools_real.py +53 -7
- package/gateway/ai/cli_contract.py +185 -0
- package/gateway/ai/governance.py +181 -0
- package/gateway/ai/heartbeat.py +290 -0
- package/gateway/ai/ledger_manager.py +81 -4
- package/gateway/ai/ledger_proof.py +127 -0
- package/gateway/ai/license.py +132 -47
- package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
- package/gateway/ai/license_core.pyi +1 -1
- package/gateway/ai/outreach_loop_daemon.py +349 -0
- package/gateway/ai/outreach_substantive.py +768 -7
- package/gateway/ai/pro_tools.yaml +167 -0
- package/gateway/ai/reddit_scanner.py +7 -1
- package/gateway/ai/server.py +295 -116
- package/gateway/ai/session_phoenix.py +121 -0
- package/gateway/ai/social_queue.py +166 -10
- package/gateway/ai/tenant_auth.py +329 -0
- package/gateway/ai/tenant_data.py +339 -0
- package/gateway/ai/tenant_paths.py +150 -0
- package/gateway/core/diff_engine_v2.py +517 -54
- package/gateway/core/semver_classifier.py +52 -6
- package/package.json +4 -1
- package/scripts/build-license-core.sh +0 -85
- package/scripts/security-check.sh +0 -66
- package/scripts/test-license-core-so.sh +0 -107
package/gateway/ai/server.py
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
6077
|
-
#
|
|
6078
|
-
#
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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.",
|