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 CHANGED
@@ -1,5 +1,75 @@
1
1
  # Changelog
2
2
 
3
+
4
+ ## [4.5.1] - 2026-04-28
5
+
6
+ ### Security — attestation `canonicalize()` strengthened (LED-1180)
7
+
8
+ The `canonicalize()` helper used to derive attestation IDs and HMAC signatures was passing `Object.keys(bundle).sort()` as the second argument to `JSON.stringify`. JSON.stringify treats that argument as a property **allowlist**, not a sort order, and the allowlist contained only top-level keys — so nested objects serialised as `{}` and the HMAC committed only to the bundle's top-level shape.
9
+
10
+ Practical effect: a bundle with `{governance: {violations: ["safe"]}}` and one with `{governance: {violations: ["malicious"]}}` produced **identical signatures**. Tampering nested fields was undetectable through signature verification.
11
+
12
+ **This release replaces canonicalize with a proper recursive sorted-key serializer.** Old (v4.3 – v4.5.0) attestations remain readable but verify with the new canonicalize and will report `signature_mismatch`. New attestations produced by v4.5.1+ commit to the full content of the bundle.
13
+
14
+ - `lib/wrap-engine.js` — fixed `canonicalize()`, exported it for reuse
15
+ - `lib/trust-page-engine.js` — verifier now imports the corrected canonicalize
16
+ - `tests/v43-wrap-engine.test.js` — added LED-1180 regression: tampering a nested field MUST change the signature; if it doesn't, canonicalize is silently dropping nested keys
17
+ - `tests/v43-trust-page-engine.test.js` — test fixtures sign with the corrected canonicalize
18
+
19
+ If you have a corpus of v4.5.0 or earlier attestations and need them re-signed under the new primitive, the migration tool is on the LED-1180 follow-up. For most users, attestations are short-lived merge-decision artifacts and re-signing is unnecessary.
20
+
21
+ ### Other
22
+
23
+ - (Internal) LED-1175 + LED-1177 MVP shipped in `delimit-private`: signed deliberation attestations + Scanner Input v0 schema. Public docs: [delimit.ai/docs/scanner-input](https://delimit.ai/docs/scanner-input), [delimit.ai/docs/vs-bugcrawl](https://delimit.ai/docs/vs-bugcrawl). No customer-facing CLI changes in 4.5.1.
24
+
25
+ ## [4.5.0] - 2026-04-27
26
+
27
+ ### Added — Ledger hygiene toolkit (LED-1145, 7 PRs)
28
+
29
+ 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.
30
+
31
+ - **`delimit_ledger_health`** — one-shot traffic-light status (P0 inflation / stale / duplicates / garbage venture) with ranked next-action list
32
+ - **`delimit_ledger_groom`** — read-only proposal: stale-open + duplicate-titles + garbage-venture detection. Each proposal includes a copy-pasteable `delimit_ledger_bulk` invocation
33
+ - **`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
34
+ - **`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)
35
+ - **`delimit_ledger_auto_cancel_stale`** — auto-archive items dormant past `DELIMIT_STALE_TTL_DAYS` (default 60). Composes `bulk_action(archive)`. dry_run default
36
+ - **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
37
+ - **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
38
+
39
+ ### Added — Memory-system convergence (LED-1165)
40
+
41
+ `delimit_memory` is now the canonical durable memory; Claude Code auto-memory becomes a one-way client projection.
42
+
43
+ - **`hot_load: bool` parameter** on `delimit_memory_store` — opt-in, default False. Marks an entry for projection
44
+ - **`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
45
+
46
+ ### Fixed — Secret-scanner false positives in `tools_infra`
47
+
48
+ 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:
49
+ - `\w+\.get(` (any object-method getter, was tokens-only)
50
+ - `if not <var>:` (Python control-flow with credential variable)
51
+ - `:\n` matched-text (block-opener colon, not key-value separator)
52
+
53
+ ### Fixed — OpenAPI diff engine defensive coverage
54
+
55
+ Real-world specs can ship malformed shapes. The diff engine now defends against the entire dict-iteration crash class without losing any actual finding:
56
+
57
+ - **`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
58
+ - **`properties: [...]` instead of dict** (Kong-class) — `.keys()` no longer crashes; treats as empty properties and continues
59
+ - **`paths: []`, `responses: []`, `content: []` (request and response)** — all coerce to `{}` at function entry. Diffs against the well-formed side still produce correct findings
60
+
61
+ ### Tests
62
+ - 108 ledger-manager tests (15 originals + 93 new across 7 features + E2E hygiene-loop integration)
63
+ - 24 memory-bridge tests (new test file)
64
+ - 60 diff-engine tests (49 + 11 new defensive)
65
+ - All backward-compatible — existing test suites pass without modification
66
+
67
+ ### Backward compatibility
68
+ - All MCP tool parameter additions are optional with safe defaults
69
+ - Existing storage format is unchanged (`hot_load` and `archived` are additive on the entry/status side)
70
+ - No CLI command renamed or removed
71
+ - Default `delimit_ledger_list` response shape preserved (full record by default; `fields="slim"` opts into the 90% payload reduction)
72
+
3
73
  ## [4.4.0] - 2026-04-25
4
74
 
5
75
  ### Added — Pre-external-PR duplicate guard
@@ -10,6 +10,7 @@
10
10
 
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
+ const { upsertManagedSection } = require('../lib/managed-section');
13
14
 
14
15
  // LED-213: Import canonical template for cross-model parity
15
16
  const { getDelimitSection } = require('../lib/delimit-template');
@@ -26,14 +27,26 @@ const CURSORRULES_FILE = path.join(HOME, '.cursorrules');
26
27
  function installRules(version) {
27
28
  const rules = getDelimitRules(version);
28
29
 
29
- // Install to .cursor/rules/delimit.md (new location, Cursor 0.45+)
30
+ // Install to .cursor/rules/delimit.md (new location, Cursor 0.45+).
31
+ // LED-1180 follow-up: use upsertManagedSection so user-customized
32
+ // content above/below the delimit:start/end markers is preserved.
33
+ // The previous implementation did fs.writeFileSync(rulesFile, rules)
34
+ // — full overwrite — which clobbered any user customizations on every
35
+ // `delimit-cli setup`.
36
+ let action = 'unchanged';
37
+ let rulesFile = null;
30
38
  if (fs.existsSync(CURSOR_DIR)) {
31
39
  fs.mkdirSync(CURSOR_RULES_DIR, { recursive: true });
32
- const rulesFile = path.join(CURSOR_RULES_DIR, 'delimit.md');
33
- fs.writeFileSync(rulesFile, rules);
40
+ rulesFile = path.join(CURSOR_RULES_DIR, 'delimit.md');
41
+ const result = upsertManagedSection(rulesFile, rules, version);
42
+ action = result.action;
34
43
  }
35
44
 
36
- return { installed: true, paths: [CURSORRULES_FILE, path.join(CURSOR_RULES_DIR, 'delimit.md')] };
45
+ return {
46
+ installed: true,
47
+ action,
48
+ paths: [CURSORRULES_FILE, path.join(CURSOR_RULES_DIR, 'delimit.md')],
49
+ };
37
50
  }
38
51
 
39
52
  /**
@@ -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
+ ]