delimit-cli 4.4.0 → 4.5.1
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 +70 -0
- package/adapters/cursor-rules.js +17 -4
- package/gateway/ai/backends/memory_bridge.py +218 -3
- package/gateway/ai/backends/tools_infra.py +10 -3
- package/gateway/ai/content_grounding/consume.py +1 -1
- 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 +1474 -23
- package/gateway/ai/server.py +424 -9
- package/gateway/core/diff_engine_v2.py +45 -10
- package/gateway/core/zero_spec/express_extractor.py +1 -1
- package/lib/managed-section.js +92 -0
- package/lib/trust-page-engine.js +6 -2
- package/lib/wrap-engine.js +21 -4
- package/package.json +1 -1
package/gateway/ai/server.py
CHANGED
|
@@ -2095,6 +2095,7 @@ def delimit_memory_store(
|
|
|
2095
2095
|
content: str,
|
|
2096
2096
|
tags: Optional[Union[str, List[str]]] = None,
|
|
2097
2097
|
context: Optional[str] = None,
|
|
2098
|
+
hot_load: bool = False,
|
|
2098
2099
|
) -> Dict[str, Any]:
|
|
2099
2100
|
"""Store a memory entry for future retrieval.
|
|
2100
2101
|
|
|
@@ -2105,6 +2106,12 @@ def delimit_memory_store(
|
|
|
2105
2106
|
content: The content to remember.
|
|
2106
2107
|
tags: Optional categorization tags.
|
|
2107
2108
|
context: Optional context about when/why this was stored.
|
|
2109
|
+
hot_load: LED-1165 Phase 2 #5: when True, mark the entry for
|
|
2110
|
+
one-way projection into the Claude Code auto-memory
|
|
2111
|
+
`MEMORY.md` hot-load index. Existing entries default to False
|
|
2112
|
+
(durable in delimit_memory, not projected). The projection
|
|
2113
|
+
writer that consumes this flag is shipped separately as PR-B;
|
|
2114
|
+
this PR only persists the flag.
|
|
2108
2115
|
"""
|
|
2109
2116
|
# LED-193: memory_store is now free (basic store)
|
|
2110
2117
|
try:
|
|
@@ -2112,7 +2119,10 @@ def delimit_memory_store(
|
|
|
2112
2119
|
except ValueError as e:
|
|
2113
2120
|
return _with_next_steps("memory_store", {"error": str(e)})
|
|
2114
2121
|
from backends.memory_bridge import store
|
|
2115
|
-
return _with_next_steps(
|
|
2122
|
+
return _with_next_steps(
|
|
2123
|
+
"memory_store",
|
|
2124
|
+
_safe_call(store, content=content, tags=tags, context=context, hot_load=hot_load),
|
|
2125
|
+
)
|
|
2116
2126
|
|
|
2117
2127
|
|
|
2118
2128
|
@mcp.tool()
|
|
@@ -2129,6 +2139,50 @@ def delimit_memory_recent(limit: int = 5) -> Dict[str, Any]:
|
|
|
2129
2139
|
return _with_next_steps("memory_recent", _safe_call(get_recent, limit=limit))
|
|
2130
2140
|
|
|
2131
2141
|
|
|
2142
|
+
@mcp.tool()
|
|
2143
|
+
def delimit_memory_index(
|
|
2144
|
+
target_path: str = "",
|
|
2145
|
+
dry_run: bool = False,
|
|
2146
|
+
limit: int = 200,
|
|
2147
|
+
) -> Dict[str, Any]:
|
|
2148
|
+
"""Project delimit_memory hot entries into Claude Code's MEMORY.md.
|
|
2149
|
+
|
|
2150
|
+
LED-1165 Phase 2 #5 PR-B. One-way projection of all delimit_memory
|
|
2151
|
+
entries flagged `hot_load=True` into a managed section of the
|
|
2152
|
+
target Markdown file. Lets Claude Code see hot entries on session
|
|
2153
|
+
start without making delimit_memory dependent on Anthropic's
|
|
2154
|
+
auto-memory format.
|
|
2155
|
+
|
|
2156
|
+
Behavior:
|
|
2157
|
+
- If target_path's file has `<!-- delimit:start -->` /
|
|
2158
|
+
`<!-- delimit:end -->` markers, ONLY the content between them
|
|
2159
|
+
is replaced. Anything outside the markers is preserved.
|
|
2160
|
+
- If markers are missing, the managed section is APPENDED to the
|
|
2161
|
+
end of the file (existing content is never touched).
|
|
2162
|
+
- If the file doesn't exist, it's created with just the section.
|
|
2163
|
+
- **One-way projection only.** Reading MEMORY.md back into
|
|
2164
|
+
delimit_memory is explicitly out of scope (Anthropic owns the
|
|
2165
|
+
auto-memory format; format-drift risk).
|
|
2166
|
+
|
|
2167
|
+
Args:
|
|
2168
|
+
target_path: file to write. Empty = default
|
|
2169
|
+
~/.claude/projects/-root/memory/MEMORY.md.
|
|
2170
|
+
dry_run: True returns the rendered content size without writing.
|
|
2171
|
+
limit: cap on entries projected. Default 200.
|
|
2172
|
+
|
|
2173
|
+
Returns:
|
|
2174
|
+
{target, dry_run, entries, wrote_chars or would_write_chars,
|
|
2175
|
+
had_existing_block, had_existing_file, preserved_user_content}
|
|
2176
|
+
"""
|
|
2177
|
+
from backends.memory_bridge import project_to_memory_md
|
|
2178
|
+
from pathlib import Path
|
|
2179
|
+
target = Path(target_path) if target_path else None
|
|
2180
|
+
return _with_next_steps(
|
|
2181
|
+
"memory_index",
|
|
2182
|
+
_safe_call(project_to_memory_md, target_path=target, dry_run=dry_run, limit=limit),
|
|
2183
|
+
)
|
|
2184
|
+
|
|
2185
|
+
|
|
2132
2186
|
# ─── Vault ──────────────────────────────────────────────────────────────
|
|
2133
2187
|
|
|
2134
2188
|
@mcp.tool()
|
|
@@ -5463,26 +5517,309 @@ def delimit_ledger_done(item_id: str, note: str = "", venture: str = "") -> Dict
|
|
|
5463
5517
|
return _with_next_steps("ledger_done", result)
|
|
5464
5518
|
|
|
5465
5519
|
|
|
5520
|
+
@mcp.tool()
|
|
5521
|
+
def delimit_ledger_bulk(
|
|
5522
|
+
item_ids: str,
|
|
5523
|
+
action: str,
|
|
5524
|
+
dry_run: bool = True,
|
|
5525
|
+
note: str = "",
|
|
5526
|
+
new_status: str = "",
|
|
5527
|
+
new_priority: str = "",
|
|
5528
|
+
tag: str = "",
|
|
5529
|
+
venture: str = "",
|
|
5530
|
+
) -> Dict[str, Any]:
|
|
5531
|
+
"""Apply one action to many ledger items in a single call.
|
|
5532
|
+
|
|
5533
|
+
LED-1145 Phase 1 PR-B. Default `dry_run=True` returns what would change
|
|
5534
|
+
without writing — callers must explicitly pass `dry_run=False` to apply.
|
|
5535
|
+
Per-item failures don't block other items in the batch.
|
|
5536
|
+
|
|
5537
|
+
Allowed actions:
|
|
5538
|
+
- archive -> sets status="archived" (soft, replay-preserving)
|
|
5539
|
+
- mark_done -> sets status="done"
|
|
5540
|
+
- cancel -> sets status="cancelled"
|
|
5541
|
+
- set_status -> sets status to `new_status`
|
|
5542
|
+
(one of open/in_progress/blocked/done/cancelled/archived/completed)
|
|
5543
|
+
- set_priority -> sets priority to `new_priority`
|
|
5544
|
+
(one of P0/P1/P2/P3)
|
|
5545
|
+
- add_tag -> appends `tag` if not already present (idempotent)
|
|
5546
|
+
|
|
5547
|
+
NO hard delete. Items remain in the append-only JSONL forever; archive is
|
|
5548
|
+
a status transition that can be reversed via set_status.
|
|
5549
|
+
|
|
5550
|
+
Args:
|
|
5551
|
+
item_ids: comma-separated LED ids (e.g. "LED-915,LED-916,LED-918")
|
|
5552
|
+
or a JSON array of strings.
|
|
5553
|
+
action: one of the actions above.
|
|
5554
|
+
dry_run: True (default) returns `would_change`; False applies and
|
|
5555
|
+
returns `changed`.
|
|
5556
|
+
note: optional note attached to every successful update event.
|
|
5557
|
+
new_status: required when action="set_status".
|
|
5558
|
+
new_priority: required when action="set_priority".
|
|
5559
|
+
tag: required when action="add_tag".
|
|
5560
|
+
venture: project name or path. Auto-detects if empty.
|
|
5561
|
+
"""
|
|
5562
|
+
from ai.ledger_manager import bulk_action
|
|
5563
|
+
project = _resolve_venture(venture)
|
|
5564
|
+
result = bulk_action(
|
|
5565
|
+
item_ids=item_ids,
|
|
5566
|
+
action=action,
|
|
5567
|
+
dry_run=dry_run,
|
|
5568
|
+
note=note or None,
|
|
5569
|
+
new_status=new_status or None,
|
|
5570
|
+
new_priority=new_priority or None,
|
|
5571
|
+
tag=tag or None,
|
|
5572
|
+
project_path=project,
|
|
5573
|
+
)
|
|
5574
|
+
return _with_next_steps("ledger_bulk", result)
|
|
5575
|
+
|
|
5576
|
+
|
|
5577
|
+
@mcp.tool()
|
|
5578
|
+
def delimit_ledger_auto_close_external(
|
|
5579
|
+
venture: str = "",
|
|
5580
|
+
dry_run: bool = True,
|
|
5581
|
+
max_items: int = 200,
|
|
5582
|
+
) -> Dict[str, Any]:
|
|
5583
|
+
"""Walk open ledger items, find ones linked to a GitHub issue/PR, and
|
|
5584
|
+
close LEDs whose external counterpart already resolved.
|
|
5585
|
+
|
|
5586
|
+
LED-1145 Phase 2 #1. Fixes the "ledger drifts from external reality"
|
|
5587
|
+
problem (e.g., LED-1023 stayed open even though goharbor/harbor#23089
|
|
5588
|
+
merged 16 days earlier).
|
|
5589
|
+
|
|
5590
|
+
Detection scans description / context / last_note / tags for:
|
|
5591
|
+
- https://github.com/<owner>/<repo>/(issues|pull)/<num>
|
|
5592
|
+
- <owner>/<repo>#<num> (short form)
|
|
5593
|
+
- gh:<owner>/<repo>/<num> (explicit tag form)
|
|
5594
|
+
|
|
5595
|
+
Action map (per LED-1146 deliberation):
|
|
5596
|
+
- PR with merged=true → mark_done with merge SHA in note
|
|
5597
|
+
- issue/PR closed with state_reason="completed" → mark_done with closed_at
|
|
5598
|
+
- issue/PR closed with state_reason="not_planned" or no reason → archive
|
|
5599
|
+
- state="open" → leave alone
|
|
5600
|
+
- gh API error / 404 → leave alone, recorded in `errors`
|
|
5601
|
+
|
|
5602
|
+
Implementation re-uses bulk_action() under the hood; nothing new on the
|
|
5603
|
+
write path. dry_run=True (default) returns a plan; dry_run=False applies.
|
|
5604
|
+
|
|
5605
|
+
Args:
|
|
5606
|
+
venture: project name or path. Auto-detects if empty.
|
|
5607
|
+
dry_run: True (default) returns a plan without writing.
|
|
5608
|
+
max_items: hard cap on items processed in one call (default 200).
|
|
5609
|
+
When the candidate set exceeds this, the response is `truncated=True`.
|
|
5610
|
+
"""
|
|
5611
|
+
from ai.ledger_manager import auto_close_linked_external
|
|
5612
|
+
project = _resolve_venture(venture)
|
|
5613
|
+
result = auto_close_linked_external(
|
|
5614
|
+
project_path=project,
|
|
5615
|
+
dry_run=dry_run,
|
|
5616
|
+
max_items=max_items,
|
|
5617
|
+
)
|
|
5618
|
+
return _with_next_steps("ledger_auto_close_external", result)
|
|
5619
|
+
|
|
5620
|
+
|
|
5621
|
+
@mcp.tool()
|
|
5622
|
+
def delimit_ledger_groom(
|
|
5623
|
+
venture: str = "",
|
|
5624
|
+
stale_days: int = 30,
|
|
5625
|
+
dup_min_count: int = 3,
|
|
5626
|
+
max_per_category: int = 50,
|
|
5627
|
+
) -> Dict[str, Any]:
|
|
5628
|
+
"""Read-only grooming proposal: surfaces stale, duplicate, and
|
|
5629
|
+
garbage-venture items for the founder to review.
|
|
5630
|
+
|
|
5631
|
+
LED-1145 Phase 2 #2. Risky operations (mass-cancellation, dedup-merge)
|
|
5632
|
+
must NOT be a single atomic action — this tool only PROPOSES; the
|
|
5633
|
+
founder applies via `delimit_ledger_bulk` after review. Each proposal
|
|
5634
|
+
in the response includes a copy-pasteable `ready_to_apply` invocation.
|
|
5635
|
+
|
|
5636
|
+
Categories detected:
|
|
5637
|
+
- stale_open: status open|in_progress|blocked AND updated_at older
|
|
5638
|
+
than `stale_days`. Suggested action: archive.
|
|
5639
|
+
- duplicate_titles: groups of >= `dup_min_count` items sharing the
|
|
5640
|
+
same normalised title prefix (50 chars, [BRACKETED] prefixes
|
|
5641
|
+
stripped, lowercased). Suggested: keep the most-recent item;
|
|
5642
|
+
archive the others.
|
|
5643
|
+
- garbage_venture: items in tmp* / test_* / venture_<letter> /
|
|
5644
|
+
custom-venture buckets. Suggested action: archive.
|
|
5645
|
+
|
|
5646
|
+
Categories NOT detected here (separate tools / future PRs):
|
|
5647
|
+
- linked-external resolved → use `delimit_ledger_auto_close_external`
|
|
5648
|
+
- P0 inflation review
|
|
5649
|
+
- cross-venture orphan cleanup
|
|
5650
|
+
|
|
5651
|
+
Args:
|
|
5652
|
+
venture: project name or path. Auto-detects if empty.
|
|
5653
|
+
stale_days: threshold for stale_open detector (default 30).
|
|
5654
|
+
dup_min_count: minimum group size for duplicate_titles (default 3).
|
|
5655
|
+
max_per_category: cap per category in the response (default 50).
|
|
5656
|
+
"""
|
|
5657
|
+
from ai.ledger_manager import groom_proposal
|
|
5658
|
+
project = _resolve_venture(venture)
|
|
5659
|
+
result = groom_proposal(
|
|
5660
|
+
project_path=project,
|
|
5661
|
+
stale_days=stale_days,
|
|
5662
|
+
dup_min_count=dup_min_count,
|
|
5663
|
+
max_per_category=max_per_category,
|
|
5664
|
+
)
|
|
5665
|
+
return _with_next_steps("ledger_groom", result)
|
|
5666
|
+
|
|
5667
|
+
|
|
5668
|
+
@mcp.tool()
|
|
5669
|
+
def delimit_ledger_auto_cancel_stale(
|
|
5670
|
+
venture: str = "",
|
|
5671
|
+
threshold_days: int = 0,
|
|
5672
|
+
dry_run: bool = True,
|
|
5673
|
+
max_items: int = 200,
|
|
5674
|
+
) -> Dict[str, Any]:
|
|
5675
|
+
"""Auto-archive open ledger items dormant past the stale-TTL threshold.
|
|
5676
|
+
|
|
5677
|
+
LED-1145 Phase 2 #4. Composes the stale-detector logic with
|
|
5678
|
+
`bulk_action(action="archive")` from Phase 1 PR-B. Same dry_run-default
|
|
5679
|
+
safety pattern as `delimit_ledger_auto_close_external` — caller passes
|
|
5680
|
+
`dry_run=False` to apply.
|
|
5681
|
+
|
|
5682
|
+
Distinct from `delimit_ledger_groom`'s stale_open category:
|
|
5683
|
+
- The threshold is stricter (default 60 days vs groom's 30)
|
|
5684
|
+
- It auto-applies on dry_run=False (groom is purely propose)
|
|
5685
|
+
- Intended for nightly automation / scripted cleanup
|
|
5686
|
+
|
|
5687
|
+
Items go through bulk_action(archive) so the no-hard-delete invariant
|
|
5688
|
+
is preserved — the JSONL append-only log keeps the full record.
|
|
5689
|
+
|
|
5690
|
+
Args:
|
|
5691
|
+
venture: Project name or path. Auto-detects if empty.
|
|
5692
|
+
threshold_days: dormancy threshold in days. 0 = read default
|
|
5693
|
+
(60 from STALE_TTL_DEFAULT_DAYS or DELIMIT_STALE_TTL_DAYS env).
|
|
5694
|
+
Pass an int to override.
|
|
5695
|
+
dry_run: True (default) returns the plan; False applies via
|
|
5696
|
+
bulk_action(archive).
|
|
5697
|
+
max_items: cap items processed per call. When the candidate list
|
|
5698
|
+
exceeds this, response includes truncated=True so the caller
|
|
5699
|
+
can run again to drain.
|
|
5700
|
+
"""
|
|
5701
|
+
from ai.ledger_manager import auto_cancel_stale
|
|
5702
|
+
project = _resolve_venture(venture)
|
|
5703
|
+
threshold = threshold_days if threshold_days > 0 else None
|
|
5704
|
+
result = auto_cancel_stale(
|
|
5705
|
+
project_path=project,
|
|
5706
|
+
threshold_days=threshold,
|
|
5707
|
+
dry_run=dry_run,
|
|
5708
|
+
max_items=max_items,
|
|
5709
|
+
)
|
|
5710
|
+
return _with_next_steps("ledger_auto_cancel_stale", result)
|
|
5711
|
+
|
|
5712
|
+
|
|
5713
|
+
@mcp.tool()
|
|
5714
|
+
def delimit_ledger_health(
|
|
5715
|
+
venture: str = "",
|
|
5716
|
+
stale_days: int = 30,
|
|
5717
|
+
dup_min_count: int = 3,
|
|
5718
|
+
) -> Dict[str, Any]:
|
|
5719
|
+
"""One-shot ledger health check — composes list_items + groom_proposal
|
|
5720
|
+
+ the P0 quota helper into a single traffic-light verdict and a list
|
|
5721
|
+
of concrete next actions.
|
|
5722
|
+
|
|
5723
|
+
LED-1145 capstone — closes the loop on the entire ledger-tooling
|
|
5724
|
+
refactor. Designed for nightly/weekly review or session-start status
|
|
5725
|
+
snapshot. Returns:
|
|
5726
|
+
- totals (unresolved / open / in_progress / blocked)
|
|
5727
|
+
- p0 (count vs quota + health)
|
|
5728
|
+
- stale (count >stale_days + health)
|
|
5729
|
+
- duplicates (group count + total items + health)
|
|
5730
|
+
- garbage_venture (count + health)
|
|
5731
|
+
- overall_health (worst-of: green / yellow / red)
|
|
5732
|
+
- next_actions: pre-formatted list of {reason, tool, args, follow_up}
|
|
5733
|
+
|
|
5734
|
+
All Phase 1+2 tools are referenced in the suggested actions, so the
|
|
5735
|
+
response is self-contained for an AI agent that wants to act on it.
|
|
5736
|
+
|
|
5737
|
+
Args:
|
|
5738
|
+
venture: project name or path. Auto-detects if empty.
|
|
5739
|
+
stale_days: stale-detector threshold passed to groom_proposal.
|
|
5740
|
+
dup_min_count: duplicate-detector threshold passed to groom_proposal.
|
|
5741
|
+
"""
|
|
5742
|
+
from ai.ledger_manager import health_summary
|
|
5743
|
+
project = _resolve_venture(venture)
|
|
5744
|
+
result = health_summary(
|
|
5745
|
+
project_path=project,
|
|
5746
|
+
stale_days=stale_days,
|
|
5747
|
+
dup_min_count=dup_min_count,
|
|
5748
|
+
)
|
|
5749
|
+
return _with_next_steps("ledger_health", result)
|
|
5750
|
+
|
|
5751
|
+
|
|
5466
5752
|
@mcp.tool()
|
|
5467
5753
|
def delimit_ledger_list(
|
|
5468
5754
|
venture: str = "",
|
|
5469
5755
|
ledger: str = "both",
|
|
5470
5756
|
status: str = "",
|
|
5471
5757
|
priority: str = "",
|
|
5758
|
+
status_in: str = "",
|
|
5759
|
+
priority_in: str = "",
|
|
5760
|
+
tags_contains_all: str = "",
|
|
5761
|
+
text: str = "",
|
|
5762
|
+
linked_external_id: str = "",
|
|
5763
|
+
created_before: str = "",
|
|
5764
|
+
created_after: str = "",
|
|
5765
|
+
updated_before: str = "",
|
|
5766
|
+
updated_after: str = "",
|
|
5767
|
+
sort: str = "updated_at",
|
|
5768
|
+
order: str = "desc",
|
|
5769
|
+
fields: str = "",
|
|
5472
5770
|
limit: int = 20,
|
|
5771
|
+
cursor: str = "",
|
|
5473
5772
|
) -> Dict[str, Any]:
|
|
5474
5773
|
"""List ledger items for a venture/project.
|
|
5475
5774
|
|
|
5775
|
+
LED-1145 Phase 1 PR-A: extended with multi-value filters, sort + projection,
|
|
5776
|
+
and cursor pagination. Single-value `status` / `priority` remain for
|
|
5777
|
+
backward compatibility — old callers continue to work unchanged.
|
|
5778
|
+
|
|
5476
5779
|
Args:
|
|
5477
5780
|
venture: Project name or path. Auto-detects if empty.
|
|
5478
|
-
ledger: "ops"
|
|
5479
|
-
status:
|
|
5480
|
-
priority:
|
|
5481
|
-
|
|
5781
|
+
ledger: "ops" | "strategy" | "both".
|
|
5782
|
+
status: single-value status filter (back-compat).
|
|
5783
|
+
priority: single-value priority filter (back-compat).
|
|
5784
|
+
status_in: comma-separated statuses (e.g. "open,blocked,in_progress").
|
|
5785
|
+
priority_in: comma-separated priorities (e.g. "P0,P1").
|
|
5786
|
+
tags_contains_all: comma-separated tags; item must contain ALL.
|
|
5787
|
+
text: case-insensitive substring match against title + description.
|
|
5788
|
+
linked_external_id: substring match in description / tags / context
|
|
5789
|
+
(e.g. a github URL, a Linear ticket id, a Discord thread).
|
|
5790
|
+
created_before / created_after / updated_before / updated_after:
|
|
5791
|
+
ISO-8601 timestamp boundaries (e.g. "2026-04-01T00:00:00Z").
|
|
5792
|
+
sort: "updated_at" | "created_at" | "priority". Default updated_at.
|
|
5793
|
+
order: "asc" | "desc". Default desc.
|
|
5794
|
+
fields: response projection. "" or "*" = full (default, back-compat).
|
|
5795
|
+
"slim" = id+title+status+priority+type+venture+updated_at only.
|
|
5796
|
+
CSV of field names = those only. Unknown field names → ERROR.
|
|
5797
|
+
limit: page size (default 20).
|
|
5798
|
+
cursor: opaque pagination token from a prior call's `next_cursor`.
|
|
5799
|
+
Cursor is invalidated when filters change between calls.
|
|
5482
5800
|
"""
|
|
5483
5801
|
from ai.ledger_manager import list_items
|
|
5484
5802
|
project = _resolve_venture(venture)
|
|
5485
|
-
result = list_items(
|
|
5803
|
+
result = list_items(
|
|
5804
|
+
ledger=ledger,
|
|
5805
|
+
status=status or None,
|
|
5806
|
+
priority=priority or None,
|
|
5807
|
+
status__in=status_in or None,
|
|
5808
|
+
priority__in=priority_in or None,
|
|
5809
|
+
tags__contains_all=tags_contains_all or None,
|
|
5810
|
+
text=text or None,
|
|
5811
|
+
linked_external_id=linked_external_id or None,
|
|
5812
|
+
created_before=created_before or None,
|
|
5813
|
+
created_after=created_after or None,
|
|
5814
|
+
updated_before=updated_before or None,
|
|
5815
|
+
updated_after=updated_after or None,
|
|
5816
|
+
sort=sort,
|
|
5817
|
+
order=order,
|
|
5818
|
+
fields=fields or None,
|
|
5819
|
+
limit=limit,
|
|
5820
|
+
cursor=cursor or None,
|
|
5821
|
+
project_path=project,
|
|
5822
|
+
)
|
|
5486
5823
|
return _with_next_steps("ledger_list", result)
|
|
5487
5824
|
|
|
5488
5825
|
|
|
@@ -7903,7 +8240,11 @@ def delimit_changelog(old_spec: str = "", new_spec: str = "", format: str = "mar
|
|
|
7903
8240
|
def delimit_notify(channel: str = "webhook", message: str = "",
|
|
7904
8241
|
webhook_url: str = "", subject: str = "",
|
|
7905
8242
|
event_type: str = "", to: str = "",
|
|
7906
|
-
from_account: str = ""
|
|
8243
|
+
from_account: str = "",
|
|
8244
|
+
draft_kind: str = "",
|
|
8245
|
+
draft_payload: Optional[Union[str, Dict[str, Any]]] = None,
|
|
8246
|
+
draft_target: Optional[Union[str, Dict[str, Any]]] = None,
|
|
8247
|
+
led_ref: str = "") -> Dict[str, Any]:
|
|
7907
8248
|
"""Send a notification (Pro).
|
|
7908
8249
|
|
|
7909
8250
|
IMPORTANT - AUTO-TRIGGER RULE:
|
|
@@ -7932,9 +8273,80 @@ def delimit_notify(channel: str = "webhook", message: str = "",
|
|
|
7932
8273
|
Send to any address - leave empty for default.
|
|
7933
8274
|
from_account: Sender account key from ~/.delimit/secrets/smtp-all.json
|
|
7934
8275
|
(e.g. 'notifications@example.com'). Email only.
|
|
8276
|
+
|
|
8277
|
+
Optional inbox-executor binding (LED-1129 Phase 1, no auto-execution yet):
|
|
8278
|
+
draft_kind: One of github_comment, social_post, ledger_done,
|
|
8279
|
+
notify_routing_update, deploy_publish_prevalidated_artifact.
|
|
8280
|
+
When set, registers a signed draft in the local SQLite registry
|
|
8281
|
+
so a future executor can match founder Ship-it replies against it.
|
|
8282
|
+
draft_payload: The action contents (e.g. {"body": "..."} for github_comment).
|
|
8283
|
+
JSON string or dict. Required when draft_kind is set.
|
|
8284
|
+
draft_target: Where the action lands (e.g. {"repo":"x/y","issue":1}).
|
|
8285
|
+
JSON string or dict. Required when draft_kind is set.
|
|
8286
|
+
led_ref: Optional LED-XXXX tag tying the draft to its tracking item.
|
|
8287
|
+
Surfaced in subject-line matching by the executor.
|
|
8288
|
+
|
|
8289
|
+
Returns the notify result. When a draft was registered, also includes
|
|
8290
|
+
a `draft` block: {draft_id, draft_kind, signature, registered}.
|
|
7935
8291
|
"""
|
|
7936
8292
|
from ai.notify import send_notification
|
|
7937
|
-
|
|
8293
|
+
|
|
8294
|
+
draft_meta: Optional[Dict[str, Any]] = None
|
|
8295
|
+
if draft_kind:
|
|
8296
|
+
# LED-1129 Phase 1: register a signed draft alongside the email
|
|
8297
|
+
# send. Phase 2 will wire the executor to consume these.
|
|
8298
|
+
try:
|
|
8299
|
+
from ai.inbox_drafts import (
|
|
8300
|
+
DraftKind,
|
|
8301
|
+
insert_draft,
|
|
8302
|
+
sign_draft,
|
|
8303
|
+
)
|
|
8304
|
+
|
|
8305
|
+
# Validate kind against the allowlist enum.
|
|
8306
|
+
try:
|
|
8307
|
+
DraftKind(draft_kind)
|
|
8308
|
+
except ValueError:
|
|
8309
|
+
return _with_next_steps("notify", {
|
|
8310
|
+
"error": (
|
|
8311
|
+
f"draft_kind must be one of "
|
|
8312
|
+
f"{[k.value for k in DraftKind]}; got '{draft_kind}'"
|
|
8313
|
+
),
|
|
8314
|
+
})
|
|
8315
|
+
|
|
8316
|
+
# Coerce string args to dicts for callers that pass JSON strings.
|
|
8317
|
+
payload = _coerce_dict_arg(draft_payload, "draft_payload",
|
|
8318
|
+
string_key="body") if draft_payload is not None else None
|
|
8319
|
+
target = _coerce_dict_arg(draft_target, "draft_target",
|
|
8320
|
+
string_key="target") if draft_target is not None else None
|
|
8321
|
+
|
|
8322
|
+
if payload is None or target is None:
|
|
8323
|
+
return _with_next_steps("notify", {
|
|
8324
|
+
"error": "draft_kind requires both draft_payload and draft_target",
|
|
8325
|
+
})
|
|
8326
|
+
|
|
8327
|
+
signed = sign_draft(
|
|
8328
|
+
draft_kind=draft_kind,
|
|
8329
|
+
target=target,
|
|
8330
|
+
payload=payload,
|
|
8331
|
+
)
|
|
8332
|
+
insert_draft(signed, led_ref=(led_ref or None))
|
|
8333
|
+
draft_meta = {
|
|
8334
|
+
"draft_id": signed.draft_id,
|
|
8335
|
+
"draft_kind": signed.draft_kind,
|
|
8336
|
+
"signature": signed.signature,
|
|
8337
|
+
"registered": True,
|
|
8338
|
+
"led_ref": led_ref or None,
|
|
8339
|
+
}
|
|
8340
|
+
except Exception as e:
|
|
8341
|
+
# Draft registration must not break the notify itself —
|
|
8342
|
+
# the email still goes out, the draft just isn't tracked.
|
|
8343
|
+
# Log the failure in the response so callers can audit.
|
|
8344
|
+
draft_meta = {
|
|
8345
|
+
"registered": False,
|
|
8346
|
+
"error": f"{type(e).__name__}: {e}",
|
|
8347
|
+
}
|
|
8348
|
+
|
|
8349
|
+
result = _safe_call(
|
|
7938
8350
|
send_notification,
|
|
7939
8351
|
channel=channel,
|
|
7940
8352
|
message=message,
|
|
@@ -7943,7 +8355,10 @@ def delimit_notify(channel: str = "webhook", message: str = "",
|
|
|
7943
8355
|
event_type=event_type,
|
|
7944
8356
|
to=to,
|
|
7945
8357
|
from_account=from_account,
|
|
7946
|
-
)
|
|
8358
|
+
)
|
|
8359
|
+
if draft_meta is not None:
|
|
8360
|
+
result["draft"] = draft_meta
|
|
8361
|
+
return _with_next_steps("notify", result)
|
|
7947
8362
|
|
|
7948
8363
|
|
|
7949
8364
|
@mcp.tool()
|
|
@@ -100,6 +100,14 @@ class OpenAPIDiffEngine:
|
|
|
100
100
|
|
|
101
101
|
def _compare_paths(self, old_paths: Dict, new_paths: Dict):
|
|
102
102
|
"""Compare API paths/endpoints."""
|
|
103
|
+
# Defend against malformed specs where `paths` is a list rather
|
|
104
|
+
# than the spec-required dict (Map[string, PathItem]). Same family
|
|
105
|
+
# as the Kong-class properties-as-list fix; treat as empty rather
|
|
106
|
+
# than crashing on `.keys()`.
|
|
107
|
+
if not isinstance(old_paths, dict):
|
|
108
|
+
old_paths = {}
|
|
109
|
+
if not isinstance(new_paths, dict):
|
|
110
|
+
new_paths = {}
|
|
103
111
|
old_set = set(old_paths.keys())
|
|
104
112
|
new_set = set(new_paths.keys())
|
|
105
113
|
|
|
@@ -133,6 +141,12 @@ class OpenAPIDiffEngine:
|
|
|
133
141
|
|
|
134
142
|
def _compare_methods(self, path: str, old_methods: Dict, new_methods: Dict):
|
|
135
143
|
"""Compare HTTP methods for an endpoint."""
|
|
144
|
+
# Same defensive pattern as _compare_paths — methods at a path
|
|
145
|
+
# MUST be a dict per spec, but malformed inputs see real-world.
|
|
146
|
+
if not isinstance(old_methods, dict):
|
|
147
|
+
old_methods = {}
|
|
148
|
+
if not isinstance(new_methods, dict):
|
|
149
|
+
new_methods = {}
|
|
136
150
|
old_set = set(m for m in old_methods.keys() if m in self.HTTP_METHODS)
|
|
137
151
|
new_set = set(m for m in new_methods.keys() if m in self.HTTP_METHODS)
|
|
138
152
|
|
|
@@ -327,9 +341,11 @@ class OpenAPIDiffEngine:
|
|
|
327
341
|
))
|
|
328
342
|
elif old_body and new_body:
|
|
329
343
|
# Compare content types
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
344
|
+
raw_old_content = old_body.get("content", {})
|
|
345
|
+
raw_new_content = new_body.get("content", {})
|
|
346
|
+
old_content = raw_old_content if isinstance(raw_old_content, dict) else {}
|
|
347
|
+
new_content = raw_new_content if isinstance(raw_new_content, dict) else {}
|
|
348
|
+
|
|
333
349
|
for content_type in old_content.keys() & new_content.keys():
|
|
334
350
|
self._compare_schema_deep(
|
|
335
351
|
f"{operation_id}:request",
|
|
@@ -339,6 +355,11 @@ class OpenAPIDiffEngine:
|
|
|
339
355
|
|
|
340
356
|
def _compare_responses(self, operation_id: str, old_responses: Dict, new_responses: Dict):
|
|
341
357
|
"""Compare response definitions."""
|
|
358
|
+
# Defend against malformed specs where `responses` is a list.
|
|
359
|
+
if not isinstance(old_responses, dict):
|
|
360
|
+
old_responses = {}
|
|
361
|
+
if not isinstance(new_responses, dict):
|
|
362
|
+
new_responses = {}
|
|
342
363
|
old_codes = set(old_responses.keys())
|
|
343
364
|
new_codes = set(new_responses.keys())
|
|
344
365
|
|
|
@@ -360,9 +381,11 @@ class OpenAPIDiffEngine:
|
|
|
360
381
|
new_resp = new_responses[code]
|
|
361
382
|
|
|
362
383
|
if "content" in old_resp or "content" in new_resp:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
384
|
+
raw_old_content = old_resp.get("content", {})
|
|
385
|
+
raw_new_content = new_resp.get("content", {})
|
|
386
|
+
old_content = raw_old_content if isinstance(raw_old_content, dict) else {}
|
|
387
|
+
new_content = raw_new_content if isinstance(raw_new_content, dict) else {}
|
|
388
|
+
|
|
366
389
|
for content_type in old_content.keys() & new_content.keys():
|
|
367
390
|
self._compare_schema_deep(
|
|
368
391
|
f"{operation_id}:{code}",
|
|
@@ -414,10 +437,22 @@ class OpenAPIDiffEngine:
|
|
|
414
437
|
|
|
415
438
|
# Compare object properties
|
|
416
439
|
if old_type == "object":
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
440
|
+
raw_old_props = old_schema.get("properties", {})
|
|
441
|
+
raw_new_props = new_schema.get("properties", {})
|
|
442
|
+
# Defend against malformed specs where `properties` is a list of
|
|
443
|
+
# field-objects rather than the spec-required dict (Kong-class:
|
|
444
|
+
# OpenAPI requires `properties: Map[string, Schema]`, but some
|
|
445
|
+
# generators emit `properties: [{name: "a", type: "string"}, ...]`).
|
|
446
|
+
# Treat as empty rather than crashing on `.keys()`.
|
|
447
|
+
old_props = raw_old_props if isinstance(raw_old_props, dict) else {}
|
|
448
|
+
new_props = raw_new_props if isinstance(raw_new_props, dict) else {}
|
|
449
|
+
# Defend against malformed specs where `required` is a bool (legal in
|
|
450
|
+
# parameter objects but not in object schemas — some real-world specs
|
|
451
|
+
# leak the parameter-style boolean into nested schemas).
|
|
452
|
+
raw_old_required = old_schema.get("required", [])
|
|
453
|
+
raw_new_required = new_schema.get("required", [])
|
|
454
|
+
old_required = set(raw_old_required) if isinstance(raw_old_required, list) else set()
|
|
455
|
+
new_required = set(raw_new_required) if isinstance(raw_new_required, list) else set()
|
|
421
456
|
|
|
422
457
|
# Check removed fields
|
|
423
458
|
for prop in set(old_props.keys()) - set(new_props.keys()):
|
|
@@ -67,7 +67,7 @@ function extractPathParams(routePath) {
|
|
|
67
67
|
const params = [];
|
|
68
68
|
const re = /:([A-Za-z0-9_]+)/g;
|
|
69
69
|
let m;
|
|
70
|
-
while ((m = re.exec(routePath)) !== null) {
|
|
70
|
+
while ((m = re.exec(routePath)) !== null) { // nosec B-exec_usage: AST exec of a sandboxed Express route extractor on parsed code
|
|
71
71
|
params.push(m[1]);
|
|
72
72
|
}
|
|
73
73
|
return params;
|