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 CHANGED
@@ -1,6 +1,66 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [4.6.1] - 2026-05-22
5
+
6
+ Patch release: bundle hygiene + gateway sync carrying 7 upstream improvements.
7
+
8
+ ### Fixed
9
+
10
+ - **`delimit setup hooks`** (LED-1207, #100): bypass broken `npx` fallback in
11
+ the pre-commit / pre-push hook generator. Fresh installs now produce hooks
12
+ that work even when the npm-resolved `delimit` shim is absent from `PATH`
13
+ (the fallback used to fail silently and produce no-op hooks).
14
+
15
+ ### Improved (carried from the gateway)
16
+
17
+ These ship inside the gateway/ tree that bundles with delimit-cli. They are
18
+ in effect for anyone running the MCP server from a 4.6.1 install:
19
+
20
+ - **Cross-post dedup for Reddit drafts** — same-author + same-normalized-title
21
+ within a 7-day window dedupes manual re-submits across subreddits. Reddit's
22
+ native `crosspost_parent` field is *also* now captured: when set, an O(1)
23
+ fingerprint lookup catches the native cross-post case before the heuristic.
24
+ Two-layer coverage: native UI cross-posts (rare, O(1)) + manual re-submits
25
+ (common, O(N)).
26
+
27
+ - **Inline follow-up draft in reply-alert emails** — both Reddit reply-alerts
28
+ and GitHub outreach-reply-alerts now ship with a pre-composed, voice-matched
29
+ follow-up reply inside the email body. The original "Draft a follow-up
30
+ reply and post from mobile" sentinel remains as graceful degradation if
31
+ the drafter falls through. Designed for mobile copy-paste-and-post flow.
32
+
33
+ - **STR-195 binding founder decisions auto-injected into `delimit_deliberate`** —
34
+ when a panel question touches a venture name or decision keyword, matching
35
+ `feedback_*.md` memories surface as a `BINDING FOUNDER DECISIONS — CANNOT BE
36
+ RE-LITIGATED` block prepended to the panel context. Closed decisions stop
37
+ getting re-argued every deliberation. Memory walk is mtime-cached; portfolio
38
+ anchors can be extended via `~/.delimit/social_target_ventures.json` for
39
+ Pro users whose ventures aren't in the default delimit-portfolio set.
40
+
41
+ - **`delimit_security_audit` false-positive elimination** (LED-1278 (c)):
42
+ token-handling code (`const token = readCurrentToken()`,
43
+ `const token = (options.token || process.env.X)`) no longer trips the
44
+ `generic_secret` detector. Provider-specific detectors (aws_access_key,
45
+ github_token, jwt_token, sk-proj API key) remain fully armed. Verified
46
+ against a regression-guard test set including real AKIA, ghp_, JWT, and
47
+ bearer-token literal shapes.
48
+
49
+ ### Chores
50
+
51
+ - **Excluded dev-only build scripts from the customer tarball** (#101):
52
+ `build-license-core.sh`, `security-check.sh`, and `test-license-core-so.sh`
53
+ are publish-time hooks invoked from `prepublishOnly`; they never ran on
54
+ customer installs. Removed from the published bundle (166 → 163 files,
55
+ ~10KB unpacked) and closed the meta-leak where the scripts' own
56
+ leak-detection grep patterns contained the patterns they protected against.
57
+
58
+ ### Documentation
59
+
60
+ - **Retract incorrect 4.6.0 'known issue' line** (LED-1403): the prior
61
+ CHANGELOG entry referenced a regression that did not actually ship.
62
+
63
+
4
64
  ## [4.6.0] - 2026-05-15
5
65
 
6
66
  ### Added — Codex CLI + Gemini CLI auto-trigger directives (LED-1399)
@@ -36,14 +96,17 @@ customizations around our managed section).
36
96
  - Documentation refreshes: cross-agent-handoff worked example surfaced on README,
37
97
  test-count badge bumped, misleading version stamps removed.
38
98
 
39
- ### Known issue (pre-existing, fix tracked)
40
-
41
- - **`delimit attest mcp` exit codes** (LED-1403): on tool error (e.g. no
42
- lockfile → npm audit unavailable) and unknown attestation kind, the CLI
43
- currently returns exit 1 instead of the expected exit 2. CI/CD pipelines
44
- that gate on tier-2 (treating "tool unavailable" as a hard error vs.
45
- "fail" which is a soft check) should pin this expectation. Tracked for
46
- fix in a follow-up release.
99
+ ### Known issue (pre-existing, fix tracked) — **RETRACTED 2026-05-15**
100
+
101
+ > ~~**`delimit attest mcp` exit codes** (LED-1403): on tool error (e.g. no
102
+ > lockfile → npm audit unavailable) and unknown attestation kind, the CLI
103
+ > currently returns exit 1 instead of the expected exit 2.~~
104
+ >
105
+ > **Retraction:** the original report was a phantom test failure caused by a
106
+ > corrupted local git worktree (LED-1401), not a real CLI bug. On a clean
107
+ > clone, all 6 `attest-mcp` test suites pass and the CLI returns the correct
108
+ > exit codes (0 pass+skip / 1 fail / 2 error per STR-656). LED-1403 closed
109
+ > `not_reproducible`. No customer action required.
47
110
 
48
111
 
49
112
  ## [4.5.2] - 2026-05-02
@@ -4629,9 +4629,19 @@ program
4629
4629
  const prePushPath = path.join(hooksDir, 'pre-push');
4630
4630
  const marker = '# delimit-governance-hook';
4631
4631
 
4632
- // Resolution order: local node_modules → global PATH → npx fallback.
4633
- // npx is last because it can fail with Arborist 'extraneous' errors
4634
- // when a project's node_modules / lockfile drift (LED-1248).
4632
+ // Resolution order: local node_modules → global PATH →
4633
+ // global node_modules direct npx fallback.
4634
+ //
4635
+ // npx is the LAST resort because on some npm-arborist environments
4636
+ // it crashes with "Cannot read properties of undefined (reading
4637
+ // 'extraneous')" before reaching the CLI (LED-1207, LED-1248). That
4638
+ // failure mode silently breaks the gate and forces --no-verify,
4639
+ // which violates the no-silent-no-verify rule.
4640
+ //
4641
+ // The third tier (`node $(npm root -g)/delimit-cli/bin/delimit-cli.js`)
4642
+ // catches the case where delimit-cli is globally installed but its bin
4643
+ // shim isn't on PATH (npm-installed-but-symlink-missing, fresh CI
4644
+ // containers, etc.) — bypassing npm/npx entirely.
4635
4645
  const preCommitHook = `#!/bin/sh
4636
4646
  ${marker}
4637
4647
  # Delimit API governance gate
@@ -4640,6 +4650,8 @@ if [ -x ./node_modules/.bin/delimit-cli ]; then
4640
4650
  ./node_modules/.bin/delimit-cli check --staged
4641
4651
  elif command -v delimit-cli >/dev/null 2>&1; then
4642
4652
  delimit-cli check --staged
4653
+ elif _delimit_global="$(npm root -g 2>/dev/null)/delimit-cli/bin/delimit-cli.js" && [ -f "$_delimit_global" ]; then
4654
+ node "$_delimit_global" check --staged
4643
4655
  else
4644
4656
  npx delimit-cli check --staged
4645
4657
  fi
@@ -4653,6 +4665,8 @@ if [ -x ./node_modules/.bin/delimit-cli ]; then
4653
4665
  ./node_modules/.bin/delimit-cli check --base origin/main
4654
4666
  elif command -v delimit-cli >/dev/null 2>&1; then
4655
4667
  delimit-cli check --base origin/main
4668
+ elif _delimit_global="$(npm root -g 2>/dev/null)/delimit-cli/bin/delimit-cli.js" && [ -f "$_delimit_global" ]; then
4669
+ node "$_delimit_global" check --base origin/main
4656
4670
  else
4657
4671
  npx delimit-cli check --base origin/main
4658
4672
  fi
@@ -6902,12 +6916,43 @@ program
6902
6916
  results = results.filter(m => m.tags && m.tags.some(t => t.includes(tagFilter)));
6903
6917
  }
6904
6918
 
6905
- // Filter by query (case-insensitive substring on text + tags)
6919
+ // Filter by query — tokenized OR-match on text + tags + context.
6920
+ // Previously this required the entire query as one contiguous
6921
+ // substring (haystack.includes(query)), so any multi-word query
6922
+ // returned zero hits. Now we split on whitespace and keep an entry
6923
+ // if it matches at least one token, then rank by the number of
6924
+ // distinct tokens matched (descending), tie-broken by recency.
6906
6925
  if (query) {
6907
- results = results.filter(m => {
6908
- const haystack = (m.text + ' ' + (m.tags || []).join(' ')).toLowerCase();
6909
- return haystack.includes(query);
6926
+ const tokens = query.split(/\s+/).filter(Boolean);
6927
+ const scored = [];
6928
+ for (const m of results) {
6929
+ const haystack = (
6930
+ (m.text || '') + ' ' +
6931
+ (m.tags || []).join(' ') + ' ' +
6932
+ (m.context || '')
6933
+ ).toLowerCase();
6934
+ let matched = 0;
6935
+ let occurrences = 0;
6936
+ for (const tok of tokens) {
6937
+ const c = haystack.split(tok).length - 1;
6938
+ if (c > 0) {
6939
+ matched += 1;
6940
+ occurrences += c;
6941
+ }
6942
+ }
6943
+ if (matched >= 1) {
6944
+ scored.push({ mem: m, matched, occurrences });
6945
+ }
6946
+ }
6947
+ // Rank: most tokens matched, then most occurrences, then recency.
6948
+ scored.sort((a, b) => {
6949
+ if (b.matched !== a.matched) return b.matched - a.matched;
6950
+ if (b.occurrences !== a.occurrences) return b.occurrences - a.occurrences;
6951
+ const at = new Date(a.mem.created_at || a.mem.created || 0).getTime();
6952
+ const bt = new Date(b.mem.created_at || b.mem.created || 0).getTime();
6953
+ return bt - at;
6910
6954
  });
6955
+ results = scored.map(s => s.mem);
6911
6956
  }
6912
6957
 
6913
6958
  // Unless --all, limit to last 10
@@ -6916,8 +6961,13 @@ program
6916
6961
  results = results.slice(-10);
6917
6962
  }
6918
6963
 
6919
- // Display newest first
6920
- results.reverse();
6964
+ // Display order: newest-first for the unfiltered/tag views (memories
6965
+ // arrive newest-first, so reverse only when NOT ranking by query).
6966
+ // Query results are already ordered best-match-first by the ranking
6967
+ // above and must not be reversed.
6968
+ if (!query) {
6969
+ results.reverse();
6970
+ }
6921
6971
 
6922
6972
  console.log(chalk.bold('\n Delimit Memories\n'));
6923
6973
 
@@ -258,18 +258,22 @@ async function main() {
258
258
  const venvPy = fs.existsSync(venvPython) ? venvPython : venvPythonWin;
259
259
  if (fs.existsSync(reqFile)) {
260
260
  execSync(`"${venvPy}" -m pip install --quiet -r "${reqFile}" 2>/dev/null`, { stdio: 'pipe' });
261
+ // LED-1564: even when reqFile exists, ensure pytest is present.
262
+ // Older reqFiles (pre-2026-05-22) didn't list it; without pytest
263
+ // the delimit_test_smoke deploy-gate step fails to run cleanly.
264
+ execSync(`"${venvPy}" -m pip install --quiet pytest 2>/dev/null`, { stdio: 'pipe' });
261
265
  } else {
262
- execSync(`"${venvPy}" -m pip install --quiet fastmcp==3.1.0 pyyaml==6.0.3 pydantic==2.12.5 packaging==26.0 2>/dev/null`, { stdio: 'pipe' });
266
+ execSync(`"${venvPy}" -m pip install --quiet fastmcp==3.1.0 pyyaml==6.0.3 pydantic==2.12.5 packaging==26.0 pytest 2>/dev/null`, { stdio: 'pipe' });
263
267
  }
264
268
  python = venvPy; // Use venv python for MCP config
265
269
  await logp(` ${green('✓')} Python dependencies installed (isolated venv)`);
266
270
  } catch {
267
271
  log(` ${yellow('!')} venv install failed — trying global pip`);
268
272
  try {
269
- execSync(`${python} -m pip install --quiet fastmcp==3.1.0 pyyaml==6.0.3 pydantic==2.12.5 packaging==26.0 2>/dev/null`, { stdio: 'pipe' });
273
+ execSync(`${python} -m pip install --quiet fastmcp==3.1.0 pyyaml==6.0.3 pydantic==2.12.5 packaging==26.0 pytest 2>/dev/null`, { stdio: 'pipe' });
270
274
  await logp(` ${green('✓')} Python dependencies installed (global)`);
271
275
  } catch {
272
- log(` ${yellow('!')} pip install failed — run manually: pip install fastmcp pyyaml pydantic packaging`);
276
+ log(` ${yellow('!')} pip install failed — run manually: pip install fastmcp pyyaml pydantic packaging pytest`);
273
277
  }
274
278
  }
275
279
 
@@ -36,6 +36,11 @@ DLQ_AUTO_PAUSE_THRESHOLD = 20
36
36
  TASK_TYPE_ROUTER = {
37
37
  # Outreach and social work — Gemini Flash is fast and cheap
38
38
  "outreach": "gemini",
39
+ # LED-2214b: substantive github outreach gets the same default
40
+ # routing as generic outreach (cheap, fast drafter) but is named
41
+ # distinctly so a regression that resurrects the generic dispatch
42
+ # path does not silently land here.
43
+ "outreach_substantive": "gemini",
39
44
  "social": "gemini",
40
45
  "content": "gemini",
41
46
  "sensor": "gemini",
@@ -358,6 +358,12 @@ def run_diff(old_spec: str, new_spec: str) -> Dict[str, Any]:
358
358
  }
359
359
  for c in changes
360
360
  ],
361
+ # LED-1588: structured side-channel for fail-open skips (unresolvable
362
+ # refs, malformed nodes) so a clean `changes` list is not mistaken for
363
+ # "proven safe". Additive — existing keys are unchanged. The JSON
364
+ # Schema branch above intentionally omits this (engine does not yet
365
+ # populate advisories; a misleading empty field would imply it checked).
366
+ "advisories": engine.advisories,
361
367
  }
362
368
 
363
369
 
@@ -0,0 +1,175 @@
1
+ """Git worktree sanity checks (LED-1411).
2
+
3
+ Single source of truth for "is this directory a healthy git worktree?"
4
+ Used by delimit_test_smoke, delimit_deploy_plan, and delimit_evidence_collect
5
+ as a precheck before they trust ambient checkout state.
6
+
7
+ Background — LED-1403 / LED-1401 incident (2026-05-14):
8
+ `/home/delimit/npm-delimit/.git` was configured `bare = true` but had source
9
+ files alongside, AND a stranded sibling worktree at `/tmp/delimit-mcp-main`
10
+ where `git status` showed every file as both `D` and `??` (deleted from
11
+ index, untracked on disk). `delimit_test_smoke` ran against this corrupt
12
+ state and reported `attest-mcp Q2 3-tier exit codes` failures that did NOT
13
+ exist on real main. I almost shipped a "fix" for a non-bug (LED-1403,
14
+ closed `not_reproducible` after a fresh clone proved tests passed).
15
+
16
+ This module exists so the same class of phantom failure can't recur.
17
+ Precheck must:
18
+ - Add <100ms to caller startup (no network, no fetch)
19
+ - Emit a single actionable remediation line on failure
20
+ - Return a structured dict (callers may inline-handle or surface up)
21
+
22
+ Memory anchor: feedback_corrupted_worktree_phantom_failures.md
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import subprocess
28
+ from pathlib import Path
29
+ from typing import Any, Dict
30
+
31
+
32
+ def _run(cmd: list, cwd: str, timeout: float = 2.0) -> str:
33
+ """Run a git command with a tight timeout. Returns stdout stripped,
34
+ or empty string on any failure (intentional — caller decides what
35
+ constitutes a failure based on the structured result, not exceptions)."""
36
+ try:
37
+ return subprocess.check_output(
38
+ cmd,
39
+ cwd=cwd,
40
+ stderr=subprocess.DEVNULL,
41
+ timeout=timeout,
42
+ text=True,
43
+ ).strip()
44
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, OSError):
45
+ return ""
46
+
47
+
48
+ def check_worktree_sanity(repo_path: str) -> Dict[str, Any]:
49
+ """Verify the directory at `repo_path` is a healthy git worktree.
50
+
51
+ Checks (in order; cheapest first):
52
+ 1. Path exists and contains a `.git` directory (or file pointing to one)
53
+ 2. `git rev-parse --is-inside-work-tree` returns `true`
54
+ 3. `git rev-parse --is-bare-repository` returns `false`
55
+ 4. `git worktree list` includes the resolved CWD
56
+ 5. `git status --porcelain=v1` does NOT show every file as BOTH
57
+ deleted-from-index AND untracked (the LED-1401 corruption signature)
58
+
59
+ Returns a dict with:
60
+ - ok: bool — overall health
61
+ - reason: str — short failure code (`not_a_repo`, `bare_repo_with_files`,
62
+ `stranded_worktree`, `corrupt_status`) when ok=False, else `healthy`
63
+ - detail: str — actionable remediation message
64
+ - path: str — the path that was checked
65
+
66
+ Non-raising: errors return ok=False with a structured reason, so callers
67
+ can decide whether to halt or warn.
68
+ """
69
+ p = Path(repo_path)
70
+ if not p.exists() or not p.is_dir():
71
+ return {
72
+ "ok": False,
73
+ "reason": "not_a_directory",
74
+ "detail": f"{repo_path} is not a directory.",
75
+ "path": repo_path,
76
+ }
77
+
78
+ git_meta = p / ".git"
79
+ if not git_meta.exists():
80
+ return {
81
+ "ok": False,
82
+ "reason": "not_a_repo",
83
+ "detail": f"{repo_path} has no .git/ — not a git worktree.",
84
+ "path": repo_path,
85
+ }
86
+
87
+ # Bare-repo check first (LED-1401 signature: bare=true + source files
88
+ # alongside). Checked BEFORE is-inside-work-tree because a bare repo
89
+ # answers "false" to that question — we want the more informative
90
+ # bare-repo message to win when both conditions hold.
91
+ is_bare = _run(["git", "rev-parse", "--is-bare-repository"], cwd=repo_path)
92
+ if is_bare == "true":
93
+ return {
94
+ "ok": False,
95
+ "reason": "bare_repo_with_files",
96
+ "detail": (
97
+ f"{repo_path}/.git/ has `core.bare = true` but the directory "
98
+ f"holds source files. Tests against this state run stale "
99
+ f"code. Re-clone fresh: `git clone <url> /tmp/<repo>-fresh "
100
+ f"&& cd /tmp/<repo>-fresh`"
101
+ ),
102
+ "path": repo_path,
103
+ }
104
+
105
+ # Inside-work-tree check
106
+ inside = _run(["git", "rev-parse", "--is-inside-work-tree"], cwd=repo_path)
107
+ if inside != "true":
108
+ return {
109
+ "ok": False,
110
+ "reason": "not_a_worktree",
111
+ "detail": (
112
+ f"{repo_path} is not inside a git work tree "
113
+ f"(rev-parse --is-inside-work-tree returned {inside!r}). "
114
+ f"Re-clone fresh: `git clone <url> /tmp/<repo>-fresh && cd /tmp/<repo>-fresh`"
115
+ ),
116
+ "path": repo_path,
117
+ }
118
+
119
+ # Worktree-list membership check (catches stranded sibling worktrees)
120
+ worktrees = _run(["git", "worktree", "list", "--porcelain"], cwd=repo_path)
121
+ resolved = str(p.resolve())
122
+ if worktrees and resolved not in worktrees:
123
+ # The current directory isn't a registered worktree of its own
124
+ # .git/ — likely a stale checkout that was wiped+repopulated outside
125
+ # git's awareness. This is the LED-1401 stranded-sibling signature.
126
+ return {
127
+ "ok": False,
128
+ "reason": "stranded_worktree",
129
+ "detail": (
130
+ f"{resolved} is not a registered worktree of its own .git/. "
131
+ f"Run `git worktree list` to inspect; re-clone fresh if "
132
+ f"orphaned: `git clone <url> /tmp/<repo>-fresh && cd /tmp/<repo>-fresh`"
133
+ ),
134
+ "path": repo_path,
135
+ "worktree_list": worktrees,
136
+ }
137
+
138
+ # LED-1401 corrupt-status signature: every file appears as BOTH `D` and `??`
139
+ # (deleted from index, untracked on disk). Sample the first 50 status lines
140
+ # — if >=10 distinct paths show this pattern, it's pathological.
141
+ status = _run(["git", "status", "--porcelain=v1"], cwd=repo_path, timeout=3.0)
142
+ if status:
143
+ lines = status.split("\n")[:200]
144
+ deleted_paths = set()
145
+ untracked_paths = set()
146
+ for line in lines:
147
+ if len(line) < 4:
148
+ continue
149
+ xy = line[:2]
150
+ path = line[3:].lstrip()
151
+ if "D" in xy:
152
+ deleted_paths.add(path)
153
+ if xy == "??":
154
+ untracked_paths.add(path)
155
+ overlap = deleted_paths & untracked_paths
156
+ if len(overlap) >= 10:
157
+ return {
158
+ "ok": False,
159
+ "reason": "corrupt_status",
160
+ "detail": (
161
+ f"{repo_path} shows >={len(overlap)} files as both deleted-from-index "
162
+ f"AND untracked-on-disk — the worktree was wiped and repopulated "
163
+ f"outside git's awareness (LED-1401 signature). Re-clone fresh: "
164
+ f"`git clone <url> /tmp/<repo>-fresh && cd /tmp/<repo>-fresh`"
165
+ ),
166
+ "path": repo_path,
167
+ "overlap_count": len(overlap),
168
+ }
169
+
170
+ return {
171
+ "ok": True,
172
+ "reason": "healthy",
173
+ "detail": "git worktree is healthy",
174
+ "path": repo_path,
175
+ }