@misterhuydo/sentinel 1.6.1 → 1.6.3
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/.cairn/.hint-lock +1 -1
- package/.cairn/session.json +2 -2
- package/package.json +1 -1
- package/python/sentinel/__init__.py +1 -1
- package/python/sentinel/__pycache__/__init__.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/fix_engine.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/git_manager.cpython-311.pyc +0 -0
- package/python/sentinel/fix_engine.py +50 -9
- package/python/sentinel/git_manager.py +32 -0
- package/python/tests/test_fix_engine_cmd.py +37 -0
- package/python/tests/test_pull_all_repos.py +94 -0
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-04-
|
|
1
|
+
2026-04-24T11:26:01.385Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-04-
|
|
3
|
-
"checkpoint_at": "2026-04-
|
|
2
|
+
"message": "Auto-checkpoint at 2026-04-24T11:34:54.330Z",
|
|
3
|
+
"checkpoint_at": "2026-04-24T11:34:54.331Z",
|
|
4
4
|
"active_files": [
|
|
5
5
|
"J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js",
|
|
6
6
|
"J:\\Projects\\Sentinel\\cli\\lib\\test.js",
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.6.
|
|
1
|
+
__version__ = "1.6.3"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -177,6 +177,14 @@ def _build_prompt(
|
|
|
177
177
|
"2. Use your available tools to explore the codebase and identify the root cause.",
|
|
178
178
|
" You can read across ALL listed repos — use that visibility to follow type",
|
|
179
179
|
" definitions, callers, or shared library code that may be involved.",
|
|
180
|
+
"",
|
|
181
|
+
"CRITICAL — fresh reads only",
|
|
182
|
+
" Before you write ANY diff line, use the Read tool to view the CURRENT content",
|
|
183
|
+
" of every file you intend to modify. Do NOT rely on prior memory of the file",
|
|
184
|
+
" from earlier turns in this conversation: the working tree may have been",
|
|
185
|
+
" updated by a previous Sentinel fix, a human commit, or a `git pull` that ran",
|
|
186
|
+
" moments ago. A patch generated from stale memory will fail dry-run.",
|
|
187
|
+
"",
|
|
180
188
|
f"3. {marker_instruction}",
|
|
181
189
|
"4. Consider all possible fix approaches. For each, weigh:",
|
|
182
190
|
" - Confidence: is this definitely the root cause?",
|
|
@@ -254,6 +262,9 @@ def _is_auth_error(output: str) -> bool:
|
|
|
254
262
|
return any(hint in low for hint in _AUTH_ERROR_HINTS)
|
|
255
263
|
|
|
256
264
|
|
|
265
|
+
_CAIRN_ONLY_MCP_CONFIG = '{"mcpServers":{"cairn":{"command":"cairn-mcp"}}}'
|
|
266
|
+
|
|
267
|
+
|
|
257
268
|
def _claude_cmd(
|
|
258
269
|
bin_path: str,
|
|
259
270
|
prompt: str,
|
|
@@ -270,6 +281,14 @@ def _claude_cmd(
|
|
|
270
281
|
and MUST be False for the OAuth attempt (otherwise claude refuses to read
|
|
271
282
|
the cached `claude login` token). The caller picks per attempt.
|
|
272
283
|
|
|
284
|
+
For the OAuth path (use_bare=False) we ALSO pass:
|
|
285
|
+
--setting-sources project,local (skip user settings.json — bypasses the
|
|
286
|
+
cairn `minify` / `edit-guard` PreToolUse hooks that block Read/Edit
|
|
287
|
+
with `exit 2` and force Claude to fall back to hand-crafting diffs)
|
|
288
|
+
--mcp-config '{...cairn...}' (re-add cairn MCP tools that we just
|
|
289
|
+
bypassed by skipping user settings — the prompt's cairn_checkpoint
|
|
290
|
+
instruction needs them)
|
|
291
|
+
|
|
273
292
|
Output is forced to `--output-format json` so the caller can extract the
|
|
274
293
|
session_id, cost, and result text deterministically.
|
|
275
294
|
"""
|
|
@@ -283,6 +302,10 @@ def _claude_cmd(
|
|
|
283
302
|
cmd.append("--bare")
|
|
284
303
|
if skip:
|
|
285
304
|
cmd.append("--dangerously-skip-permissions")
|
|
305
|
+
if not use_bare:
|
|
306
|
+
# OAuth-mode isolation: skip user-scope cairn hooks, re-load cairn MCP.
|
|
307
|
+
cmd += ["--setting-sources", "project,local"]
|
|
308
|
+
cmd += ["--mcp-config", _CAIRN_ONLY_MCP_CONFIG]
|
|
286
309
|
if session_id:
|
|
287
310
|
cmd += ["--resume", session_id]
|
|
288
311
|
cmd += ["--output-format", "json", "--print", prompt]
|
|
@@ -492,11 +515,28 @@ def generate_fix(
|
|
|
492
515
|
except Exception as _e:
|
|
493
516
|
logger.debug("fix_engine: git log check failed: %s", _e)
|
|
494
517
|
|
|
495
|
-
#
|
|
518
|
+
# Pre-pull every project repo so Claude reads up-to-date content. This
|
|
519
|
+
# closes the window where a previous sentinel commit (or human commit)
|
|
520
|
+
# has landed on remote but the local working tree hasn't been refreshed.
|
|
521
|
+
if all_repos:
|
|
522
|
+
from .git_manager import pull_all_repos
|
|
523
|
+
pull_results = pull_all_repos(all_repos)
|
|
524
|
+
n_failed = sum(1 for ok in pull_results.values() if not ok)
|
|
525
|
+
if n_failed:
|
|
526
|
+
logger.warning(
|
|
527
|
+
"fix_engine: pre-fix pull failed for %d/%d repo(s): %s",
|
|
528
|
+
n_failed, len(pull_results),
|
|
529
|
+
[n for n, ok in pull_results.items() if not ok][:5],
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
# Pull saved session id — keyed per (project, target_repo) so a prior fix
|
|
533
|
+
# targeting repo A doesn't contaminate Claude's memory for a fix targeting
|
|
534
|
+
# repo B (their files differ; resumed memory leads to stale-context patches).
|
|
535
|
+
session_key = f"{getattr(cfg, 'project_name', '') or '_default'}/{repo.repo_name}"
|
|
496
536
|
session_id = ""
|
|
497
|
-
if store is not None
|
|
537
|
+
if store is not None:
|
|
498
538
|
try:
|
|
499
|
-
saved = store.get_claude_session(
|
|
539
|
+
saved = store.get_claude_session(session_key)
|
|
500
540
|
if saved:
|
|
501
541
|
session_id = saved.get("session_id", "") or ""
|
|
502
542
|
except Exception as _se:
|
|
@@ -506,8 +546,8 @@ def generate_fix(
|
|
|
506
546
|
claude_logs_dir = Path(cfg.workspace_dir).parent / "logs" / "claude"
|
|
507
547
|
claude_log_path = claude_logs_dir / f"{event.fingerprint[:8]}-{ts}.log"
|
|
508
548
|
logger.info(
|
|
509
|
-
"Invoking Claude Code for %s (fp=%s) — log: %s — resume=%s",
|
|
510
|
-
event.source, event.fingerprint, claude_log_path,
|
|
549
|
+
"Invoking Claude Code for %s (fp=%s, route=%s) — log: %s — resume=%s",
|
|
550
|
+
event.source, event.fingerprint, repo.repo_name, claude_log_path,
|
|
511
551
|
session_id[:8] if session_id else "(new)",
|
|
512
552
|
)
|
|
513
553
|
|
|
@@ -586,15 +626,16 @@ def generate_fix(
|
|
|
586
626
|
|
|
587
627
|
# Persist the session id (and cost delta) regardless of fix outcome — even
|
|
588
628
|
# NEEDS_HUMAN / SKIP turns count toward the conversation history.
|
|
589
|
-
|
|
629
|
+
# Same composite key as the read above so per-route memory stays separated.
|
|
630
|
+
if store is not None and parsed["session_id"]:
|
|
590
631
|
try:
|
|
591
632
|
store.set_claude_session(
|
|
592
|
-
|
|
633
|
+
session_key, parsed["session_id"],
|
|
593
634
|
cost_delta=parsed["total_cost_usd"],
|
|
594
635
|
)
|
|
595
636
|
logger.info(
|
|
596
|
-
"fix_engine: saved claude session %s for
|
|
597
|
-
parsed["session_id"][:8],
|
|
637
|
+
"fix_engine: saved claude session %s for %s (turn cost $%.4f)",
|
|
638
|
+
parsed["session_id"][:8], session_key, parsed["total_cost_usd"],
|
|
598
639
|
)
|
|
599
640
|
except Exception as _se:
|
|
600
641
|
logger.warning("fix_engine: set_claude_session failed: %s", _se)
|
|
@@ -192,6 +192,38 @@ def maven_compile_check(local_path: str, timeout: int = 300) -> tuple[bool, str]
|
|
|
192
192
|
return r.returncode == 0, output
|
|
193
193
|
|
|
194
194
|
|
|
195
|
+
def pull_all_repos(repos: list[RepoConfig]) -> dict[str, bool]:
|
|
196
|
+
"""Discard local edits and `git pull --rebase` every repo in the list.
|
|
197
|
+
|
|
198
|
+
Used right before invoking Claude so it reads up-to-date file content. A
|
|
199
|
+
failure for any single repo is logged as a warning but never raised — the
|
|
200
|
+
return dict tells callers which repos pulled cleanly so they can decide
|
|
201
|
+
what to do (e.g. fix engine continues anyway; the target-repo dry-run in
|
|
202
|
+
apply_and_commit_multi will catch a stale-on-disk patch later).
|
|
203
|
+
"""
|
|
204
|
+
results: dict[str, bool] = {}
|
|
205
|
+
for repo in repos:
|
|
206
|
+
if not repo.local_path:
|
|
207
|
+
results[repo.repo_name] = False
|
|
208
|
+
continue
|
|
209
|
+
env = _git_env(repo)
|
|
210
|
+
try:
|
|
211
|
+
_git(["checkout", "."], cwd=repo.local_path, env=env)
|
|
212
|
+
r = _git(["pull", "--rebase", "origin", repo.branch],
|
|
213
|
+
cwd=repo.local_path, env=env)
|
|
214
|
+
ok = (r.returncode == 0)
|
|
215
|
+
results[repo.repo_name] = ok
|
|
216
|
+
if not ok:
|
|
217
|
+
logger.warning(
|
|
218
|
+
"pull_all_repos: %s pull failed: %s",
|
|
219
|
+
repo.repo_name, r.stderr.strip()[:200],
|
|
220
|
+
)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.warning("pull_all_repos: %s exception: %s", repo.repo_name, e)
|
|
223
|
+
results[repo.repo_name] = False
|
|
224
|
+
return results
|
|
225
|
+
|
|
226
|
+
|
|
195
227
|
def _check_protected_paths(patch_path: Path) -> bool:
|
|
196
228
|
text = patch_path.read_text(encoding="utf-8", errors="replace")
|
|
197
229
|
for line in text.splitlines():
|
|
@@ -51,3 +51,40 @@ def test_print_and_prompt_are_last():
|
|
|
51
51
|
cmd = _claude_cmd("claude", "the actual prompt", use_bare=False)
|
|
52
52
|
assert cmd[-2] == "--print"
|
|
53
53
|
assert cmd[-1] == "the actual prompt"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── OAuth-mode isolation (skip user settings, keep cairn MCP) ─────────────────
|
|
57
|
+
|
|
58
|
+
def test_oauth_mode_skips_user_settings_to_avoid_cairn_hooks():
|
|
59
|
+
"""Cairn PreToolUse hooks live in user settings.json and block Read/Edit
|
|
60
|
+
for OAuth mode. --setting-sources project,local bypasses them."""
|
|
61
|
+
cmd = _claude_cmd("claude", "x", use_bare=False)
|
|
62
|
+
assert "--setting-sources" in cmd
|
|
63
|
+
i = cmd.index("--setting-sources")
|
|
64
|
+
assert cmd[i + 1] == "project,local"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_bare_mode_does_not_set_setting_sources():
|
|
68
|
+
"""--bare already skips hooks; no need for --setting-sources, and avoiding
|
|
69
|
+
it keeps the cmd surface identical to legacy behaviour."""
|
|
70
|
+
cmd = _claude_cmd("claude", "x", use_bare=True)
|
|
71
|
+
assert "--setting-sources" not in cmd
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_oauth_mode_loads_cairn_mcp_via_inline_config():
|
|
75
|
+
"""When user settings are skipped, cairn MCP tools must be re-enabled
|
|
76
|
+
explicitly via --mcp-config so the prompt's cairn_checkpoint instruction works."""
|
|
77
|
+
cmd = _claude_cmd("claude", "x", use_bare=False)
|
|
78
|
+
# Either --mcp-config <json> or --mcp-config=<json>
|
|
79
|
+
found = False
|
|
80
|
+
for i, tok in enumerate(cmd):
|
|
81
|
+
if tok == "--mcp-config" and i + 1 < len(cmd) and "cairn" in cmd[i + 1]:
|
|
82
|
+
found = True; break
|
|
83
|
+
if tok.startswith("--mcp-config=") and "cairn" in tok:
|
|
84
|
+
found = True; break
|
|
85
|
+
assert found, f"--mcp-config with cairn missing from cmd: {cmd}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_bare_mode_does_not_set_mcp_config():
|
|
89
|
+
cmd = _claude_cmd("claude", "x", use_bare=True)
|
|
90
|
+
assert not any(tok.startswith("--mcp-config") for tok in cmd)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_pull_all_repos.py — Tests for the pre-fix git pull helper.
|
|
3
|
+
|
|
4
|
+
pull_all_repos() runs `git checkout . && git pull --rebase` on every project repo
|
|
5
|
+
so Claude reads up-to-date content. Per-repo failures are non-fatal — they get
|
|
6
|
+
logged as warnings and recorded in the result dict, not raised.
|
|
7
|
+
"""
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
from sentinel import git_manager
|
|
13
|
+
from sentinel.config_loader import RepoConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ok():
|
|
17
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fail(msg="pull rejected"):
|
|
21
|
+
return SimpleNamespace(returncode=1, stdout="", stderr=msg)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _mk_repo(tmp_path: Path, name: str) -> RepoConfig:
|
|
25
|
+
p = tmp_path / "repos" / name
|
|
26
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return RepoConfig(repo_name=name, local_path=str(p), branch="main")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_empty_repo_list_returns_empty(tmp_path):
|
|
31
|
+
assert git_manager.pull_all_repos([]) == {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_single_successful_repo(tmp_path):
|
|
35
|
+
repo = _mk_repo(tmp_path, "r1")
|
|
36
|
+
with patch.object(git_manager, "_git", return_value=_ok()):
|
|
37
|
+
result = git_manager.pull_all_repos([repo])
|
|
38
|
+
assert result == {"r1": True}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_single_failing_repo_recorded_not_raised(tmp_path):
|
|
42
|
+
repo = _mk_repo(tmp_path, "r1")
|
|
43
|
+
with patch.object(git_manager, "_git", return_value=_fail()):
|
|
44
|
+
result = git_manager.pull_all_repos([repo])
|
|
45
|
+
assert result == {"r1": False}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_mixed_results_per_repo(tmp_path):
|
|
49
|
+
a = _mk_repo(tmp_path, "ok-repo")
|
|
50
|
+
b = _mk_repo(tmp_path, "bad-repo")
|
|
51
|
+
c = _mk_repo(tmp_path, "ok-repo-2")
|
|
52
|
+
|
|
53
|
+
# Each repo gets two _git calls (checkout + pull). We make 'bad-repo' fail on pull.
|
|
54
|
+
call_log = []
|
|
55
|
+
def fake_git(args, cwd, env=None, timeout=git_manager.GIT_TIMEOUT):
|
|
56
|
+
call_log.append((args[0], cwd))
|
|
57
|
+
if "bad-repo" in cwd and args[0] == "pull":
|
|
58
|
+
return _fail("conflict")
|
|
59
|
+
return _ok()
|
|
60
|
+
|
|
61
|
+
with patch.object(git_manager, "_git", side_effect=fake_git):
|
|
62
|
+
result = git_manager.pull_all_repos([a, b, c])
|
|
63
|
+
|
|
64
|
+
assert result == {"ok-repo": True, "bad-repo": False, "ok-repo-2": True}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_repo_with_empty_local_path_is_skipped(tmp_path):
|
|
68
|
+
repo = RepoConfig(repo_name="ghost", local_path="", branch="main")
|
|
69
|
+
with patch.object(git_manager, "_git") as g:
|
|
70
|
+
result = git_manager.pull_all_repos([repo])
|
|
71
|
+
assert result == {"ghost": False}
|
|
72
|
+
g.assert_not_called()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_subprocess_exception_caught_and_recorded(tmp_path):
|
|
76
|
+
repo = _mk_repo(tmp_path, "r1")
|
|
77
|
+
with patch.object(git_manager, "_git",
|
|
78
|
+
side_effect=RuntimeError("git binary missing")):
|
|
79
|
+
result = git_manager.pull_all_repos([repo])
|
|
80
|
+
assert result == {"r1": False}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_calls_checkout_then_pull_per_repo(tmp_path):
|
|
84
|
+
"""Order matters: checkout (discard local edits) before pull."""
|
|
85
|
+
repo = _mk_repo(tmp_path, "r1")
|
|
86
|
+
seq = []
|
|
87
|
+
def fake_git(args, cwd, env=None, timeout=git_manager.GIT_TIMEOUT):
|
|
88
|
+
seq.append(args[0])
|
|
89
|
+
return _ok()
|
|
90
|
+
with patch.object(git_manager, "_git", side_effect=fake_git):
|
|
91
|
+
git_manager.pull_all_repos([repo])
|
|
92
|
+
assert seq[0] == "checkout"
|
|
93
|
+
assert "pull" in seq
|
|
94
|
+
assert seq.index("checkout") < seq.index("pull")
|