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/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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
package/bin/delimit-cli.js
CHANGED
|
@@ -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 →
|
|
4633
|
-
//
|
|
4634
|
-
//
|
|
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
|
|
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
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
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
|
|
6920
|
-
|
|
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
|
|
package/bin/delimit-setup.js
CHANGED
|
@@ -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
|
+
}
|