@misterhuydo/sentinel 1.5.63 → 1.6.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.
Files changed (55) hide show
  1. package/.cairn/session.json +2 -2
  2. package/package.json +1 -1
  3. package/python/scripts/__pycache__/fix_ask_codebase_context.cpython-311.pyc +0 -0
  4. package/python/scripts/__pycache__/fix_ask_codebase_stdin.cpython-311.pyc +0 -0
  5. package/python/scripts/__pycache__/fix_chain_slack.cpython-311.pyc +0 -0
  6. package/python/scripts/__pycache__/fix_fstring.cpython-311.pyc +0 -0
  7. package/python/scripts/__pycache__/fix_knowledge_cache.cpython-311.pyc +0 -0
  8. package/python/scripts/__pycache__/fix_knowledge_cache_staleness.cpython-311.pyc +0 -0
  9. package/python/scripts/__pycache__/fix_merge_confirm.cpython-311.pyc +0 -0
  10. package/python/scripts/__pycache__/fix_permission_messages.cpython-311.pyc +0 -0
  11. package/python/scripts/__pycache__/fix_pr_check_head_detect.cpython-311.pyc +0 -0
  12. package/python/scripts/__pycache__/fix_pr_msg_newlines.cpython-311.pyc +0 -0
  13. package/python/scripts/__pycache__/fix_pr_tracking_boss.cpython-311.pyc +0 -0
  14. package/python/scripts/__pycache__/fix_pr_tracking_db.cpython-311.pyc +0 -0
  15. package/python/scripts/__pycache__/fix_pr_tracking_main.cpython-311.pyc +0 -0
  16. package/python/scripts/__pycache__/fix_project_isolation.cpython-311.pyc +0 -0
  17. package/python/scripts/__pycache__/fix_system_prompt.cpython-311.pyc +0 -0
  18. package/python/scripts/__pycache__/fix_two_bugs.cpython-311.pyc +0 -0
  19. package/python/scripts/__pycache__/patch_chain_release.cpython-311.pyc +0 -0
  20. package/python/sentinel/__init__.py +1 -1
  21. package/python/sentinel/__pycache__/__init__.cpython-311.pyc +0 -0
  22. package/python/sentinel/__pycache__/cairn_client.cpython-311.pyc +0 -0
  23. package/python/sentinel/__pycache__/cicd_trigger.cpython-311.pyc +0 -0
  24. package/python/sentinel/__pycache__/config_loader.cpython-311.pyc +0 -0
  25. package/python/sentinel/__pycache__/dependency_manager.cpython-311.pyc +0 -0
  26. package/python/sentinel/__pycache__/dev_watcher.cpython-311.pyc +0 -0
  27. package/python/sentinel/__pycache__/fix_engine.cpython-311.pyc +0 -0
  28. package/python/sentinel/__pycache__/git_manager.cpython-311.pyc +0 -0
  29. package/python/sentinel/__pycache__/health_checker.cpython-311.pyc +0 -0
  30. package/python/sentinel/__pycache__/issue_watcher.cpython-311.pyc +0 -0
  31. package/python/sentinel/__pycache__/log_fetcher.cpython-311.pyc +0 -0
  32. package/python/sentinel/__pycache__/log_parser.cpython-311.pyc +0 -0
  33. package/python/sentinel/__pycache__/log_syncer.cpython-311.pyc +0 -0
  34. package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
  35. package/python/sentinel/__pycache__/notify.cpython-311.pyc +0 -0
  36. package/python/sentinel/__pycache__/repo_router.cpython-311.pyc +0 -0
  37. package/python/sentinel/__pycache__/repo_task_engine.cpython-311.pyc +0 -0
  38. package/python/sentinel/__pycache__/reporter.cpython-311.pyc +0 -0
  39. package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
  40. package/python/sentinel/__pycache__/sentinel_dev.cpython-311.pyc +0 -0
  41. package/python/sentinel/__pycache__/slack_bot.cpython-311.pyc +0 -0
  42. package/python/sentinel/__pycache__/state_store.cpython-311.pyc +0 -0
  43. package/python/sentinel/cairn_client.py +30 -11
  44. package/python/sentinel/fix_engine.py +182 -43
  45. package/python/sentinel/git_manager.py +335 -0
  46. package/python/sentinel/main.py +189 -5
  47. package/python/sentinel/state_store.py +121 -0
  48. package/python/tests/test_cairn_client.py +72 -0
  49. package/python/tests/test_fix_engine_json.py +95 -0
  50. package/python/tests/test_fix_engine_prompt.py +93 -0
  51. package/python/tests/test_multi_repo_apply.py +254 -0
  52. package/python/tests/test_multi_repo_publish.py +175 -0
  53. package/python/tests/test_patch_parser.py +250 -0
  54. package/python/tests/test_project_lock.py +85 -0
  55. package/python/tests/test_state_store.py +87 -0
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-04-21T11:31:04.976Z",
3
- "checkpoint_at": "2026-04-21T11:31:04.977Z",
2
+ "message": "Auto-checkpoint at 2026-04-22T05:33:19.801Z",
3
+ "checkpoint_at": "2026-04-22T05:33:19.802Z",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.5.63",
3
+ "version": "1.6.0",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -1 +1 @@
1
- __version__ = "1.5.63"
1
+ __version__ = "1.6.0"
@@ -37,30 +37,49 @@ def ensure_installed() -> bool:
37
37
  return False
38
38
 
39
39
 
40
- def index_repo(repo: RepoConfig) -> bool:
41
- """Run `cairn install` in the repo if not already initialised.
40
+ def _install_cairn_at(path: str) -> bool:
41
+ """Run `cairn install` in `path` if not already initialised.
42
42
 
43
- Cairn indexes automatically via hooks once installed this just ensures
44
- the .cairn project file and MCP registration exist before the first fix attempt.
43
+ Idempotent: returns True (no-op) if the .cairn-project marker already exists.
44
+ Returns False if the directory doesn't exist or `cairn install` failed.
45
45
  """
46
46
  import os
47
- cairn_marker = os.path.join(repo.local_path, ".cairn", ".cairn-project")
48
- if os.path.exists(cairn_marker):
49
- logger.debug("cairn already installed in %s", repo.local_path)
47
+ if not os.path.isdir(path):
48
+ logger.warning("cairn init: directory missing: %s", path)
49
+ return False
50
+ marker = os.path.join(path, ".cairn", ".cairn-project")
51
+ if os.path.exists(marker):
52
+ logger.debug("cairn already installed in %s", path)
50
53
  return True
51
54
  try:
52
55
  r = subprocess.run(
53
56
  [CAIRN_BIN, "install"],
54
- cwd=repo.local_path,
57
+ cwd=path,
55
58
  capture_output=True,
56
59
  text=True,
57
60
  timeout=30,
58
61
  )
59
62
  if r.returncode == 0:
60
- logger.info("cairn installed in %s", repo.local_path)
63
+ logger.info("cairn installed in %s", path)
61
64
  return True
62
- logger.warning("cairn install failed in %s: %s", repo.local_path, r.stderr.strip())
65
+ logger.warning("cairn install failed in %s: %s", path, r.stderr.strip())
63
66
  return False
64
67
  except Exception as e:
65
- logger.warning("cairn install error in %s: %s", repo.local_path, e)
68
+ logger.warning("cairn install error in %s: %s", path, e)
66
69
  return False
70
+
71
+
72
+ def init_project_root(project_dir: str) -> bool:
73
+ """Initialise the project-root .cairn/ so child sub-repos federate up to it.
74
+
75
+ Once both the project root and its sub-repos have .cairn/, Cairn's parent
76
+ walk-up at query time auto-mounts every sibling sub-index — giving Claude
77
+ full cross-repo visibility from the project root cwd. See cairn db.js
78
+ `mountParentSubIndexes` for the federation mechanism.
79
+ """
80
+ return _install_cairn_at(project_dir)
81
+
82
+
83
+ def index_repo(repo: RepoConfig) -> bool:
84
+ """Run `cairn install` in the repo if not already initialised."""
85
+ return _install_cairn_at(repo.local_path)
@@ -8,6 +8,7 @@ connection — Sentinel does not need to query or inject it explicitly.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import json
11
12
  import logging
12
13
  import re
13
14
  import subprocess
@@ -22,13 +23,52 @@ from .notify import alert_if_rate_limited, slack_alert
22
23
  logger = logging.getLogger(__name__)
23
24
 
24
25
  SUBPROCESS_TIMEOUT = 600
25
- MAX_FILES_IN_PATCH = 5
26
- MAX_LINES_IN_PATCH = 200
27
26
 
28
27
  _DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
29
28
  _DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
30
29
 
31
30
 
31
+ def _parse_claude_json(stdout: str) -> dict:
32
+ """Parse `claude --print --output-format json` output.
33
+
34
+ Tolerant of leading garbage (debug lines, warnings) — locates the first JSON
35
+ object in the stream. Always returns the same shape regardless of which
36
+ fields claude emitted:
37
+
38
+ {
39
+ "result": str, # the model's response text (where the patch lives)
40
+ "session_id": str, # for --resume on the next call
41
+ "total_cost_usd": float,
42
+ "is_error": bool, # True if claude reported an error OR parse failed
43
+ }
44
+ """
45
+ empty = {"result": "", "session_id": "", "total_cost_usd": 0.0, "is_error": True}
46
+ if not stdout or not stdout.strip():
47
+ return empty
48
+
49
+ text = stdout.strip()
50
+ start = text.find("{")
51
+ if start < 0:
52
+ return empty
53
+ try:
54
+ obj = json.loads(text[start:])
55
+ except json.JSONDecodeError:
56
+ # Some claude versions append trailing data; consume only the leading object.
57
+ try:
58
+ obj, _end = json.JSONDecoder().raw_decode(text[start:])
59
+ except json.JSONDecodeError:
60
+ return empty
61
+
62
+ if not isinstance(obj, dict):
63
+ return empty
64
+ return {
65
+ "result": obj.get("result", "") or "",
66
+ "session_id": obj.get("session_id", "") or "",
67
+ "total_cost_usd": float(obj.get("total_cost_usd", 0.0) or 0.0),
68
+ "is_error": bool(obj.get("is_error", False)),
69
+ }
70
+
71
+
32
72
  def _extract_patch(output: str) -> str | None:
33
73
  """Extract a unified diff patch from Claude's output."""
34
74
  # 1. Prefer a fenced ```diff or ```patch block
@@ -48,8 +88,28 @@ def _extract_patch(output: str) -> str | None:
48
88
  return None
49
89
 
50
90
 
51
- def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers: list[str] = None, synced_files: list = None) -> str:
52
- if log_file and log_file.exists():
91
+ def _build_prompt(
92
+ event,
93
+ primary_repo: RepoConfig,
94
+ log_file,
95
+ marker: str,
96
+ stale_markers: list[str] | None = None,
97
+ synced_files: list | None = None,
98
+ all_repos: list[RepoConfig] | None = None,
99
+ project_root: str = "",
100
+ ) -> str:
101
+ """Build the fix prompt for the multi-repo project.
102
+
103
+ `primary_repo` is where the error originated; `all_repos` are every repo in
104
+ the project visible to Claude via Cairn federation. Patch paths must be
105
+ `repos/<repo-name>/...` so git_manager.parse_multi_repo_patch() can split
106
+ the result back per-repo.
107
+ """
108
+ if all_repos is None:
109
+ all_repos = [primary_repo]
110
+ project_root = project_root or str(Path(primary_repo.local_path).parent.parent)
111
+
112
+ if log_file and Path(log_file).exists():
53
113
  ctx = (
54
114
  "LOG FILE: " + str(log_file) + "\n"
55
115
  "Read this file first -- it contains the last 48h of logs from "
@@ -93,9 +153,15 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
93
153
  "Commit the cleanup separately with message: 'chore(sentinel): remove stale markers'\n"
94
154
  )
95
155
 
156
+ repo_listing = "\n".join(f" - {r.repo_name}" for r in all_repos)
157
+
96
158
  lines_out = [
97
- f"You are fixing a production bug in the repository at {repo.local_path}.",
98
- f"Repository: {repo.repo_name}",
159
+ f"You are fixing a production bug in the project at {project_root}.",
160
+ "",
161
+ "PROJECT REPOS (all visible to you via Cairn federation):",
162
+ repo_listing,
163
+ "",
164
+ f"The error originated in: {primary_repo.repo_name}",
99
165
  "",
100
166
  ]
101
167
  if cleanup:
@@ -109,6 +175,8 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
109
175
  "Task:",
110
176
  f"1. {step1}",
111
177
  "2. Use your available tools to explore the codebase and identify the root cause.",
178
+ " You can read across ALL listed repos — use that visibility to follow type",
179
+ " definitions, callers, or shared library code that may be involved.",
112
180
  f"3. {marker_instruction}",
113
181
  "4. Consider all possible fix approaches. For each, weigh:",
114
182
  " - Confidence: is this definitely the root cause?",
@@ -116,9 +184,22 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
116
184
  " - Scope: is it minimal and targeted?",
117
185
  " Choose the safest minimal approach. If multiple valid options exist, pick the one",
118
186
  " with highest confidence and lowest blast radius.",
119
- "5. Output ONLY a unified diff patch (git diff format) for the chosen fix.",
120
- "6. Do not explain. Output only the patch.",
121
- "7. Only if you truly cannot produce a safe fix e.g. the root cause requires a",
187
+ "",
188
+ "PATCH OUTPUT FORMAT (multi-repo aware)",
189
+ "5. Output a unified diff. EVERY path must be prefixed with `repos/<repo-name>/`",
190
+ " so Sentinel can split the patch back per repo. Examples:",
191
+ " diff --git a/repos/Whydah-TypeLib/src/Foo.java b/repos/Whydah-TypeLib/src/Foo.java",
192
+ " --- a/repos/1881-SSOLoginWebApp/pom.xml",
193
+ " +++ b/repos/1881-SSOLoginWebApp/pom.xml",
194
+ "6. If your fix touches MORE THAN ONE repo, prepend a single header line at the",
195
+ " very top of the patch (before any `diff --git`):",
196
+ " # Affected repos: <repo-a>, <repo-b>",
197
+ " Order matters: list the LIBRARY/dependency repo FIRST, the CONSUMER repo",
198
+ " AFTER. Sentinel will merge in this order.",
199
+ "7. Do not explain. Output only the patch (and the header if multi-repo).",
200
+ "",
201
+ "ESCALATION SIGNALS",
202
+ "8. Only if you truly cannot produce a safe fix — e.g. the root cause requires a",
122
203
  " DB schema change, infrastructure update, business logic decision, or is inside",
123
204
  " a third-party library — output exactly:",
124
205
  " NEEDS_HUMAN: <explanation>",
@@ -126,10 +207,15 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
126
207
  " was insufficient or unsafe, (c) exactly what a human needs to do or decide.",
127
208
  " Do NOT output NEEDS_HUMAN just because the fix is complex — only when human",
128
209
  " judgement or access is genuinely required.",
129
- "8. If the fix requires changing Sentinel's own source code (the monitoring/fix agent",
210
+ "9. If the fix requires changing Sentinel's own source code (the monitoring/fix agent",
130
211
  " itself, not the application being monitored) — output exactly:",
131
212
  " BOSS_ESCALATE: <description of what needs to change in Sentinel>",
132
213
  " This escalates to Patch, the Sentinel dev agent, who will implement it.",
214
+ "",
215
+ "FINAL STEP — checkpoint cairn",
216
+ "10. Before you stop, call the `cairn_checkpoint` MCP tool with a one-line summary",
217
+ " of what you changed (e.g. \"Broaden FirstName regex; bump consumer to 2.7.1\").",
218
+ " This lets the next session resume with full context of prior fixes.",
133
219
  ]
134
220
  return "\n".join(lines_out)
135
221
 
@@ -156,19 +242,6 @@ def _fix_blank_context_lines(patch: str) -> str:
156
242
  return "\n".join(result) + "\n"
157
243
 
158
244
 
159
- def _validate_patch(patch: str) -> tuple[bool, str]:
160
- files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
161
- lines_changed = len([
162
- l for l in patch.splitlines()
163
- if l.startswith(("+", "-")) and not l.startswith(("+++", "---"))
164
- ])
165
- if files_changed > MAX_FILES_IN_PATCH:
166
- return False, f"Patch touches {files_changed} files (limit {MAX_FILES_IN_PATCH})"
167
- if lines_changed > MAX_LINES_IN_PATCH:
168
- return False, f"Patch changes {lines_changed} lines (limit {MAX_LINES_IN_PATCH})"
169
- return True, ""
170
-
171
-
172
245
  _AUTH_ERROR_HINTS = (
173
246
  "not logged in", "please run claude login", "authentication failed",
174
247
  "api key is not set", "invalid x-api-key", "unauthorized", "please authenticate",
@@ -181,7 +254,15 @@ def _is_auth_error(output: str) -> bool:
181
254
  return any(hint in low for hint in _AUTH_ERROR_HINTS)
182
255
 
183
256
 
184
- def _claude_cmd(bin_path: str, prompt: str) -> list[str]:
257
+ def _claude_cmd(bin_path: str, prompt: str, session_id: str = "") -> list[str]:
258
+ """Build the `claude --print` command line.
259
+
260
+ `session_id` (if non-empty) attaches `--resume <id>` so Claude continues an
261
+ existing session — gives prompt-cache reuse and shared context across fixes.
262
+
263
+ Output is forced to `--output-format json` so the caller can extract the
264
+ session_id, cost, and result text deterministically.
265
+ """
185
266
  import os as _os
186
267
  try:
187
268
  skip = _os.getuid() != 0
@@ -190,9 +271,13 @@ def _claude_cmd(bin_path: str, prompt: str) -> list[str]:
190
271
  # --bare: forces ANTHROPIC_API_KEY-only auth, skips keychain/OAuth/hooks.
191
272
  # Required on headless servers (EC2) where Claude Code 2.x silently returns
192
273
  # empty output when keychain auth fails.
274
+ cmd = [bin_path, "--bare"]
193
275
  if skip:
194
- return [bin_path, "--bare", "--dangerously-skip-permissions", "--print", prompt]
195
- return [bin_path, "--bare", "--print", prompt]
276
+ cmd.append("--dangerously-skip-permissions")
277
+ if session_id:
278
+ cmd += ["--resume", session_id]
279
+ cmd += ["--output-format", "json", "--print", prompt]
280
+ return cmd
196
281
 
197
282
 
198
283
  # ── Claude output → human-readable progress ───────────────────────────────────
@@ -260,6 +345,7 @@ def _run_claude_attempt(
260
345
  claude_log_path: Path | None = None,
261
346
  on_progress=None,
262
347
  cmd_override: list | None = None,
348
+ session_id: str = "",
263
349
  ) -> tuple[str, bool]:
264
350
  """
265
351
  Run claude CLI with the given env. Returns (output, timed_out).
@@ -267,11 +353,12 @@ def _run_claude_attempt(
267
353
  If on_progress is given, calls on_progress(msg) for meaningful output lines
268
354
  (deduped — same message not repeated consecutively).
269
355
  cmd_override: if provided, use this command list instead of _claude_cmd() default.
356
+ session_id: if provided, passes --resume to Claude to continue an existing session.
270
357
  """
271
358
  import threading as _threading
272
359
 
273
360
  proc = subprocess.Popen(
274
- cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt),
361
+ cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt, session_id),
275
362
  stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
276
363
  text=True, env=env, cwd=cwd or None,
277
364
  )
@@ -334,6 +421,7 @@ def generate_fix(
334
421
  patches_dir: Path,
335
422
  store=None,
336
423
  on_progress=None,
424
+ all_repos: list[RepoConfig] | None = None,
337
425
  ) -> tuple[str, Path | None, str]:
338
426
  """
339
427
  Generate a fix for the given error event.
@@ -342,20 +430,35 @@ def generate_fix(
342
430
  (status, patch_path, marker)
343
431
  status: "patch" | "skip" | "needs_human" | "error"
344
432
 
345
- Auth strategy API key and Claude Pro (OAuth) are interchangeable:
433
+ Multi-repo & session resume:
434
+ - Claude is invoked from the project root with all sub-repos visible
435
+ through Cairn federation.
436
+ - The saved per-project session id (state_store.claude_sessions) is
437
+ passed via --resume so prompt cache and conversation history are reused.
438
+ - The new session id and cost from the response are persisted back.
439
+
440
+ Auth strategy:
346
441
  Primary : Claude Pro (OAuth) if claude_pro_for_tasks=True, else API key
347
442
  Fallback : the other method, if primary fails with an auth error
348
- On total auth failure: notify Slack admins + email report recipients
349
443
  """
350
444
  import os as _os
351
445
 
446
+ if all_repos is None:
447
+ all_repos = [repo]
448
+
449
+ project_root = str(Path(cfg.workspace_dir).parent)
352
450
  marker = f"sentinel-{event.fingerprint[:8]}"
353
451
  log_file = Path(cfg.workspace_dir) / "fetched" / f"{event.source}.log"
354
452
  if not log_file.exists():
355
453
  log_file = None
356
454
  from .log_syncer import get_synced_files
357
455
  synced = get_synced_files(event.source, cfg.workspace_dir)
358
- prompt = _build_prompt(event, repo, log_file, marker, synced_files=synced or None)
456
+ prompt = _build_prompt(
457
+ event, repo, log_file, marker,
458
+ synced_files=synced or None,
459
+ all_repos=all_repos,
460
+ project_root=project_root,
461
+ )
359
462
 
360
463
  # -- Cross-source dedup: skip if fingerprint already fixed in recent git commits ------
361
464
  if repo.local_path:
@@ -374,12 +477,23 @@ def generate_fix(
374
477
  except Exception as _e:
375
478
  logger.debug("fix_engine: git log check failed: %s", _e)
376
479
 
480
+ # Pull saved session id (per project) so Claude continues an existing session.
481
+ session_id = ""
482
+ if store is not None and getattr(cfg, "project_name", ""):
483
+ try:
484
+ saved = store.get_claude_session(cfg.project_name)
485
+ if saved:
486
+ session_id = saved.get("session_id", "") or ""
487
+ except Exception as _se:
488
+ logger.debug("fix_engine: get_claude_session failed: %s", _se)
489
+
377
490
  ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
378
491
  claude_logs_dir = Path(cfg.workspace_dir).parent / "logs" / "claude"
379
492
  claude_log_path = claude_logs_dir / f"{event.fingerprint[:8]}-{ts}.log"
380
493
  logger.info(
381
- "Invoking Claude Code for %s (fp=%s) — log: %s",
494
+ "Invoking Claude Code for %s (fp=%s) — log: %s — resume=%s",
382
495
  event.source, event.fingerprint, claude_log_path,
496
+ session_id[:8] if session_id else "(new)",
383
497
  )
384
498
 
385
499
  base_env = _os.environ.copy()
@@ -396,20 +510,23 @@ def generate_fix(
396
510
  else:
397
511
  attempts = [("Claude Pro (OAuth)", oauth_env)]
398
512
 
399
- output = ""
513
+ raw_output = ""
400
514
  try:
401
515
  for label, env in attempts:
402
516
  if env is None:
403
517
  continue
404
518
  logger.info("fix_engine: trying %s for %s", label, event.fingerprint)
405
- output, timed_out = _run_claude_attempt(
406
- cfg.claude_code_bin, prompt, env, cwd=repo.local_path,
407
- claude_log_path=claude_log_path, on_progress=on_progress,
519
+ raw_output, timed_out = _run_claude_attempt(
520
+ cfg.claude_code_bin, prompt, env,
521
+ cwd=project_root, # ← project root (cairn federation)
522
+ claude_log_path=claude_log_path,
523
+ on_progress=on_progress,
524
+ session_id=session_id, # ← resume previous session
408
525
  )
409
526
  if timed_out:
410
527
  logger.error("Claude Code timed out for %s", event.fingerprint)
411
528
  return "error", None, ""
412
- if not _is_auth_error(output):
529
+ if not _is_auth_error(raw_output):
413
530
  break
414
531
  logger.warning("fix_engine: %s auth error for %s — trying next method", label, event.fingerprint)
415
532
  else:
@@ -432,14 +549,41 @@ def generate_fix(
432
549
  slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)
433
550
  return "error", None, ""
434
551
 
435
- # Alert Slack immediately on rate-limit — never stay silent
436
552
  alert_if_rate_limited(
437
553
  cfg.slack_bot_token,
438
554
  cfg.slack_channel,
439
555
  source=f"fix_engine/{event.fingerprint}",
440
- output=output,
556
+ output=raw_output,
441
557
  )
442
558
 
559
+ # Parse the JSON envelope: get session_id, cost, and the actual result text.
560
+ parsed = _parse_claude_json(raw_output)
561
+ if parsed["is_error"] and not parsed["result"]:
562
+ # Fall back to legacy text parsing if JSON decode failed completely.
563
+ # (Older Claude CLI versions may not emit JSON; --output-format flag is ignored.)
564
+ logger.warning(
565
+ "fix_engine: failed to parse JSON output for %s — falling back to raw text",
566
+ event.fingerprint,
567
+ )
568
+ output = raw_output
569
+ else:
570
+ output = parsed["result"]
571
+
572
+ # Persist the session id (and cost delta) regardless of fix outcome — even
573
+ # NEEDS_HUMAN / SKIP turns count toward the conversation history.
574
+ if store is not None and getattr(cfg, "project_name", "") and parsed["session_id"]:
575
+ try:
576
+ store.set_claude_session(
577
+ cfg.project_name, parsed["session_id"],
578
+ cost_delta=parsed["total_cost_usd"],
579
+ )
580
+ logger.info(
581
+ "fix_engine: saved claude session %s for project %s (turn cost $%.4f)",
582
+ parsed["session_id"][:8], cfg.project_name, parsed["total_cost_usd"],
583
+ )
584
+ except Exception as _se:
585
+ logger.warning("fix_engine: set_claude_session failed: %s", _se)
586
+
443
587
  if output.strip().upper().startswith("NEEDS_HUMAN:"):
444
588
  reason = output.strip()[len("NEEDS_HUMAN:"):].strip()
445
589
  logger.info("Claude needs human for %s: %s", event.fingerprint, reason[:200])
@@ -478,11 +622,6 @@ def generate_fix(
478
622
 
479
623
  patch = _fix_blank_context_lines(patch)
480
624
 
481
- ok, reason = _validate_patch(patch)
482
- if not ok:
483
- logger.warning("Patch rejected for %s: %s", event.fingerprint, reason)
484
- return "skip", None, ""
485
-
486
625
  patches_dir.mkdir(parents=True, exist_ok=True)
487
626
  patch_path = patches_dir / f"{event.fingerprint}.diff"
488
627
  patch_path.write_text(patch, encoding="utf-8")