delimit-cli 4.4.0 → 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 CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+
4
+ ## [4.5.0] - 2026-04-27
5
+
6
+ ### Added — Ledger hygiene toolkit (LED-1145, 7 PRs)
7
+
8
+ The full hygiene loop is now a single MCP surface — call `delimit_ledger_health` to see what's wrong, then run the suggested tool to fix it.
9
+
10
+ - **`delimit_ledger_health`** — one-shot traffic-light status (P0 inflation / stale / duplicates / garbage venture) with ranked next-action list
11
+ - **`delimit_ledger_groom`** — read-only proposal: stale-open + duplicate-titles + garbage-venture detection. Each proposal includes a copy-pasteable `delimit_ledger_bulk` invocation
12
+ - **`delimit_ledger_bulk(item_ids, action, dry_run=True)`** — batch operations (`archive`, `set_status`, `set_priority`, `add_tag`, `mark_done`, `cancel`). `dry_run=True` is the safety default. **No hard delete** — archive is an append-only soft transition
13
+ - **`delimit_ledger_auto_close_external`** — find LEDs linked to a GitHub issue/PR and auto-close when the upstream resolves (mark_done for merged PRs / closed-as-completed; archive for not_planned closures)
14
+ - **`delimit_ledger_auto_cancel_stale`** — auto-archive items dormant past `DELIMIT_STALE_TTL_DAYS` (default 60). Composes `bulk_action(archive)`. dry_run default
15
+ - **Extended `delimit_ledger_list`** — new filters: `status_in`, `priority_in`, `tags_contains_all`, `text`, `linked_external_id`, `created_before/after`, `updated_before/after`, `sort`, `order`, `fields` projection (`"slim"` / explicit list / unknown=error), cursor pagination
16
+ - **P0 soft-quota** on `delimit_ledger_add` — soft warning when count > `DELIMIT_P0_SOFT_QUOTA` (default 50). Item still added; surfaces a nudge to groom
17
+
18
+ ### Added — Memory-system convergence (LED-1165)
19
+
20
+ `delimit_memory` is now the canonical durable memory; Claude Code auto-memory becomes a one-way client projection.
21
+
22
+ - **`hot_load: bool` parameter** on `delimit_memory_store` — opt-in, default False. Marks an entry for projection
23
+ - **`delimit_memory_index(target_path, dry_run, limit)`** — projects hot_load=True entries into a managed section of MEMORY.md (`<!-- delimit:start -->` / `<!-- delimit:end -->` markers). User content outside markers is preserved verbatim. **One-way only** — never reads MEMORY.md back into delimit_memory
24
+
25
+ ### Fixed — Secret-scanner false positives in `tools_infra`
26
+
27
+ The `_CREDENTIAL_FALSE_POSITIVES` regex now suppresses three additional benign patterns so legitimate credential-loading code (env-var lookup, dict-getter, control-flow guards) doesn't trip the audit:
28
+ - `\w+\.get(` (any object-method getter, was tokens-only)
29
+ - `if not <var>:` (Python control-flow with credential variable)
30
+ - `:\n` matched-text (block-opener colon, not key-value separator)
31
+
32
+ ### Fixed — OpenAPI diff engine defensive coverage
33
+
34
+ Real-world specs can ship malformed shapes. The diff engine now defends against the entire dict-iteration crash class without losing any actual finding:
35
+
36
+ - **`required: bool` in object schemas** (legal in parameter objects but seen leaking into nested schemas) — was raising `TypeError: 'bool' object is not iterable`. Treats as no-required-fields and continues
37
+ - **`properties: [...]` instead of dict** (Kong-class) — `.keys()` no longer crashes; treats as empty properties and continues
38
+ - **`paths: []`, `responses: []`, `content: []` (request and response)** — all coerce to `{}` at function entry. Diffs against the well-formed side still produce correct findings
39
+
40
+ ### Tests
41
+ - 108 ledger-manager tests (15 originals + 93 new across 7 features + E2E hygiene-loop integration)
42
+ - 24 memory-bridge tests (new test file)
43
+ - 60 diff-engine tests (49 + 11 new defensive)
44
+ - All backward-compatible — existing test suites pass without modification
45
+
46
+ ### Backward compatibility
47
+ - All MCP tool parameter additions are optional with safe defaults
48
+ - Existing storage format is unchanged (`hot_load` and `archived` are additive on the entry/status side)
49
+ - No CLI command renamed or removed
50
+ - Default `delimit_ledger_list` response shape preserved (full record by default; `fields="slim"` opts into the 90% payload reduction)
51
+
3
52
  ## [4.4.0] - 2026-04-25
4
53
 
5
54
  ### Added — Pre-external-PR duplicate guard
@@ -19,8 +19,28 @@ def _ensure_dir():
19
19
  MEMORY_DIR.mkdir(parents=True, exist_ok=True)
20
20
 
21
21
 
22
- def store(content: str, tags: Optional[list] = None, context: Optional[str] = None) -> Dict[str, Any]:
23
- """Store a memory entry."""
22
+ def store(
23
+ content: str,
24
+ tags: Optional[list] = None,
25
+ context: Optional[str] = None,
26
+ hot_load: bool = False,
27
+ ) -> Dict[str, Any]:
28
+ """Store a memory entry.
29
+
30
+ LED-1165 Phase 2 #5 PR-A: opt-in `hot_load` flag marks an entry for
31
+ one-way projection into the Claude Code auto-memory `MEMORY.md` file
32
+ (managed-section). The projection writer is shipped in PR-B; this PR
33
+ only persists the flag.
34
+
35
+ Args:
36
+ content: The content to remember.
37
+ tags: Optional categorization tags.
38
+ context: Optional context about when/why this was stored.
39
+ hot_load: When True, mark the entry for projection into the
40
+ Claude Code MEMORY.md hot-load index. Default False — entries
41
+ are durable in delimit_memory but not projected. Existing
42
+ entries are unaffected (treated as hot_load=False).
43
+ """
24
44
  _ensure_dir()
25
45
 
26
46
  # Generate ID from content hash
@@ -33,12 +53,18 @@ def store(content: str, tags: Optional[list] = None, context: Optional[str] = No
33
53
  "tags": tags or [],
34
54
  "context": context or "",
35
55
  "created_at": ts,
56
+ "hot_load": bool(hot_load),
36
57
  }
37
58
 
38
59
  path = MEMORY_DIR / f"{mem_id}.json"
39
60
  path.write_text(json.dumps(entry, indent=2))
40
61
 
41
- return {"stored": mem_id, "path": str(path), "created_at": ts}
62
+ return {
63
+ "stored": mem_id,
64
+ "path": str(path),
65
+ "created_at": ts,
66
+ "hot_load": bool(hot_load),
67
+ }
42
68
 
43
69
 
44
70
  def search(query: str, limit: int = 10) -> Dict[str, Any]:
@@ -88,8 +114,197 @@ def get_recent(limit: int = 5) -> Dict[str, Any]:
88
114
  "content": entry.get("content", "")[:500],
89
115
  "tags": entry.get("tags", []),
90
116
  "created_at": entry.get("created_at", ""),
117
+ "hot_load": bool(entry.get("hot_load", False)),
118
+ })
119
+ except Exception:
120
+ pass
121
+
122
+ return {"results": entries, "count": len(entries)}
123
+
124
+
125
+ def list_hot(limit: int = 200) -> Dict[str, Any]:
126
+ """Return all entries marked hot_load=True, newest first.
127
+
128
+ LED-1165 Phase 2 #5 PR-A: backing query for the MEMORY.md projection
129
+ writer that PR-B will introduce. Returned entries are full content
130
+ (not truncated) so the projection writer can render them faithfully.
131
+
132
+ Args:
133
+ limit: cap on entries returned. Default 200; the projection
134
+ writer hard-caps the rendered MEMORY.md size so this is
135
+ mostly belt-and-braces.
136
+
137
+ Returns:
138
+ {
139
+ "results": [{id, content, tags, context, created_at, hot_load}, ...],
140
+ "count": int,
141
+ }
142
+ """
143
+ _ensure_dir()
144
+ entries = []
145
+
146
+ for f in sorted(MEMORY_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
147
+ if len(entries) >= limit:
148
+ break
149
+ try:
150
+ entry = json.loads(f.read_text())
151
+ if not entry.get("hot_load"):
152
+ continue
153
+ entries.append({
154
+ "id": entry.get("id", f.stem),
155
+ "content": entry.get("content", ""),
156
+ "tags": entry.get("tags", []),
157
+ "context": entry.get("context", ""),
158
+ "created_at": entry.get("created_at", ""),
159
+ "hot_load": True,
91
160
  })
92
161
  except Exception:
93
162
  pass
94
163
 
95
164
  return {"results": entries, "count": len(entries)}
165
+
166
+
167
+ # ── LED-1165 Phase 2 #5 PR-B: MEMORY.md projection writer ──────────────
168
+
169
+ # Default target — Claude Code's auto-memory MEMORY.md for the current
170
+ # project (this is where Claude Code reads memory entries on session
171
+ # start). Override via env or function arg for testing / non-default
172
+ # project layouts.
173
+ DEFAULT_MEMORY_MD = Path.home() / ".claude" / "projects" / "-root" / "memory" / "MEMORY.md"
174
+
175
+ PROJECTION_START_MARKER = "<!-- delimit:start -->"
176
+ PROJECTION_END_MARKER = "<!-- delimit:end -->"
177
+
178
+
179
+ def _format_hot_entry_as_markdown(entry: Dict[str, Any]) -> str:
180
+ """Render one delimit_memory entry as a Markdown bullet for the
181
+ MEMORY.md hot-load index. Intentionally compact — the index loads
182
+ into every Claude Code session, so each entry should fit on a
183
+ line or two."""
184
+ mid = entry.get("id", "?")
185
+ content = (entry.get("content") or "").strip()
186
+ # Single-line the content but cap at ~280 chars so the line stays
187
+ # readable. Long content stays in delimit_memory; index is just
188
+ # the hook for Claude to know it exists.
189
+ one_line = " ".join(content.split())
190
+ if len(one_line) > 280:
191
+ one_line = one_line[:277] + "..."
192
+ tags = entry.get("tags") or []
193
+ tag_str = (" [tags: " + ", ".join(tags) + "]") if tags else ""
194
+ ctx = (entry.get("context") or "").strip()
195
+ ctx_line = f"\n > {ctx[:200]}" if ctx else ""
196
+ return f"- **{mid}**{tag_str} — {one_line}{ctx_line}"
197
+
198
+
199
+ def _render_managed_block(entries: List[Dict[str, Any]]) -> str:
200
+ """Render the full managed section (between markers) for the
201
+ MEMORY.md hot-load index. Includes a one-line preamble explaining
202
+ the section so anyone editing the file understands what it is."""
203
+ if not entries:
204
+ body = (
205
+ "_No hot-load memory entries. Add one with "
206
+ "`delimit_memory_store(content=\"...\", hot_load=True)`._\n"
207
+ )
208
+ else:
209
+ bullets = "\n".join(_format_hot_entry_as_markdown(e) for e in entries)
210
+ body = bullets + "\n"
211
+
212
+ # Brief header + body + caveat
213
+ header = (
214
+ "## Delimit hot memory (auto-projected from delimit_memory)\n"
215
+ "\n"
216
+ f"_Auto-managed by `delimit_memory_index` — projection of {len(entries)} "
217
+ "entry/entries flagged `hot_load=True`. Edits inside this block are "
218
+ "overwritten on next projection. Add an entry via "
219
+ "`delimit_memory_store(content=\"...\", hot_load=True)`._\n"
220
+ "\n"
221
+ )
222
+ return PROJECTION_START_MARKER + "\n" + header + body + PROJECTION_END_MARKER
223
+
224
+
225
+ def project_to_memory_md(
226
+ target_path: Optional[Path] = None,
227
+ dry_run: bool = False,
228
+ limit: int = 200,
229
+ ) -> Dict[str, Any]:
230
+ """One-way projection: render hot_load=True entries from delimit_memory
231
+ into a managed section of Claude Code's MEMORY.md.
232
+
233
+ LED-1165 Phase 2 #5 PR-B. Composes PR-A's `list_hot` helper with a
234
+ managed-section markdown writer. NEVER reads MEMORY.md back into
235
+ delimit_memory — that's the explicit deliberation rule (Anthropic
236
+ owns auto-memory's format; we don't risk drift).
237
+
238
+ Behavior:
239
+ - If target_path's file does NOT exist, create it with just the
240
+ managed section (no other content).
241
+ - If target_path's file exists and contains markers, replace the
242
+ section between them. Content outside markers is preserved.
243
+ - If target_path's file exists but has no markers, APPEND a new
244
+ managed section to the end. Does NOT touch existing content.
245
+
246
+ Args:
247
+ target_path: where to write. Default DEFAULT_MEMORY_MD.
248
+ dry_run: True returns the rendered content without writing.
249
+ limit: cap on entries projected. Default 200 (matches list_hot).
250
+
251
+ Returns:
252
+ {
253
+ "target": str,
254
+ "dry_run": bool,
255
+ "entries": int,
256
+ "wrote_chars": int (or "would_write_chars"),
257
+ "had_existing_block": bool,
258
+ "had_existing_file": bool,
259
+ "preserved_user_content": bool,
260
+ }
261
+ """
262
+ if target_path is None:
263
+ target_path = DEFAULT_MEMORY_MD
264
+
265
+ target_path = Path(target_path)
266
+ hot = list_hot(limit=limit)
267
+ entries = hot.get("results", [])
268
+
269
+ block = _render_managed_block(entries)
270
+
271
+ had_existing_file = target_path.exists()
272
+ had_existing_block = False
273
+ preserved_user_content = False
274
+
275
+ if had_existing_file:
276
+ existing = target_path.read_text()
277
+ start_idx = existing.find(PROJECTION_START_MARKER)
278
+ end_idx = existing.find(PROJECTION_END_MARKER)
279
+ if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
280
+ had_existing_block = True
281
+ preserved_user_content = bool(
282
+ existing[:start_idx].strip() or existing[end_idx + len(PROJECTION_END_MARKER):].strip()
283
+ )
284
+ new_content = (
285
+ existing[:start_idx]
286
+ + block
287
+ + existing[end_idx + len(PROJECTION_END_MARKER):]
288
+ )
289
+ else:
290
+ # No markers — append to end, preserving everything above
291
+ preserved_user_content = bool(existing.strip())
292
+ sep = "" if existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n")
293
+ new_content = existing + sep + block + "\n"
294
+ else:
295
+ # Brand new file — just the managed section
296
+ new_content = block + "\n"
297
+
298
+ if not dry_run:
299
+ target_path.parent.mkdir(parents=True, exist_ok=True)
300
+ target_path.write_text(new_content)
301
+
302
+ return {
303
+ "target": str(target_path),
304
+ "dry_run": dry_run,
305
+ "entries": len(entries),
306
+ "wrote_chars" if not dry_run else "would_write_chars": len(new_content),
307
+ "had_existing_block": had_existing_block,
308
+ "had_existing_file": had_existing_file,
309
+ "preserved_user_content": preserved_user_content,
310
+ }
@@ -65,11 +65,18 @@ _CREDENTIAL_FALSE_POSITIVES = re.compile(
65
65
  r"-demo['\"]|"
66
66
  # Function-call RHS (reading from parsed JSON, env, getters, slicing strings)
67
67
  r"json\.loads|\.read_text\(|\.slice\(|"
68
- r"tokens\.get\(|token\s*=\s*_make_token|"
68
+ r"\w+\.get\(|token\s*=\s*_make_token|"
69
69
  # RHS that is a parameter reference like token=tokens.get("access_token"...
70
- r"=\s*tokens\.get\(|"
70
+ r"=\s*\w+\.get\(|"
71
71
  # Dict index dereference: token_data["token"], result["secret"], etc.
72
- r"_data\[|_result\[)",
72
+ r"_data\[|_result\[|"
73
+ # Bare `if not <var>:` and similar control-flow lines that mention
74
+ # the credential variable name but contain no value.
75
+ r"if\s+not\s+\w+:|"
76
+ # Python control-flow block-opener: a colon immediately followed by
77
+ # a newline (no quoted value on the same line). Such a colon is an
78
+ # if/while/def/class block-opener, not a key-value separator.
79
+ r":\s*\n)",
73
80
  re.IGNORECASE,
74
81
  )
75
82
 
@@ -192,7 +192,7 @@ def unreleased_feature_detector(
192
192
  if token in feats:
193
193
  continue
194
194
  # Don't spam: only flag once per token per text.
195
- if token not in unknown_specifics and len(token) > 8 and "-" in token:
195
+ if token not in unknown_specifics and len(token) > 8 and "-" in token: # nosec B-secret-detection: `token` here is a Python variable holding one word from the text being grounded, not a credential
196
196
  unknown_specifics.append(token)
197
197
 
198
198
  # If we have no whitelist and triggers fired, flag regardless — the
@@ -0,0 +1,61 @@
1
+ """Inbox drafts registry — LED-1129 Phase 1.
2
+
3
+ Foundation for the autonomous-executor that closes the email→action loop.
4
+ Phase 1 (this module): schema, canonicalization, HMAC binding, SQLite registry.
5
+ NO behavior change — drafts get registered + signed; nobody consumes them yet.
6
+ Phase 2 will add the separate-process executor that reads this registry.
7
+
8
+ See docs/inbox_executor_v1.md for the canonicalization + state-machine spec.
9
+ """
10
+
11
+ from ai.inbox_drafts.schema import (
12
+ DEFAULT_TTL_SECONDS,
13
+ HMAC_KEY_PATH,
14
+ DraftKind,
15
+ DraftStatus,
16
+ SignedDraft,
17
+ canonicalize,
18
+ content_hash,
19
+ new_draft_id,
20
+ sign_draft,
21
+ verify_draft,
22
+ )
23
+ from ai.inbox_drafts.registry import (
24
+ DEFAULT_DB_PATH,
25
+ DraftRow,
26
+ expire_pending,
27
+ find_draft_by_led_ref,
28
+ get_draft,
29
+ insert_draft,
30
+ list_attempts,
31
+ list_drafts,
32
+ migrate,
33
+ record_attempt,
34
+ transition,
35
+ )
36
+
37
+ __all__ = [
38
+ # schema
39
+ "DEFAULT_TTL_SECONDS",
40
+ "HMAC_KEY_PATH",
41
+ "DraftKind",
42
+ "DraftStatus",
43
+ "SignedDraft",
44
+ "canonicalize",
45
+ "content_hash",
46
+ "new_draft_id",
47
+ "sign_draft",
48
+ "verify_draft",
49
+ # registry
50
+ "DEFAULT_DB_PATH",
51
+ "DraftRow",
52
+ "expire_pending",
53
+ "find_draft_by_led_ref",
54
+ "get_draft",
55
+ "insert_draft",
56
+ "list_attempts",
57
+ "list_drafts",
58
+ "migrate",
59
+ "record_attempt",
60
+ "transition",
61
+ ]