code-context-control 2.149.1__py3-none-any.whl → 2.152.0__py3-none-any.whl

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.
cli/c3.py CHANGED
@@ -91,7 +91,7 @@ console = Console() if HAS_RICH else None
91
91
  # Config
92
92
  CONFIG_DIR = ".c3"
93
93
  CONFIG_FILE = ".c3/config.json"
94
- __version__ = "2.149.1"
94
+ __version__ = "2.152.0"
95
95
 
96
96
  # The PreToolUse matcher for native subagent calls (installer and hub migration).
97
97
  AGENT_MATCHER = "Agent|Task"
cli/guide/tools.html CHANGED
@@ -1507,6 +1507,7 @@ c3_agent(workflow=<span class="str">'investigate'</span>, context=<span class="s
1507
1507
  <tr><td class="param-name">stats</td><td class="param-desc">Aggregate stats: most-edited files, change frequency</td></tr>
1508
1508
  <tr><td class="param-name">tag</td><td class="param-desc">Tag an edit entry with a label</td></tr>
1509
1509
  <tr><td class="param-name">verify</td><td class="param-desc"><strong>v2.75.0</strong> — did an edit land? Pass the SAME <code>file</code>/<code>old_string</code>/<code>new_string</code> (or <code>edits</code>) a failed or timed-out <code>c3_edit</code> call took. Answers APPLIED (do not retry) / NOT_APPLIED (safe to retry) / INCONCLUSIVE (read the file)</td></tr>
1510
+ <tr><td class="param-name">revert</td><td class="param-desc">Put a file back to the bytes it had before <code>edit_id</code>, from the pre-image <code>c3_edit</code> stored in <code>.c3/edit_blobs/</code>. Refuses if the file changed since (lists the later edit ids to revert first) or if no pre-image was kept (native Edit/shell rows, files over 5 MB, read-denied or masked paths, evicted blobs). Logs its own <code>reverted</code> row, which can itself be reverted.</td></tr>
1510
1511
  </tbody>
1511
1512
  </table>
1512
1513
 
cli/mcp_server.py CHANGED
@@ -903,11 +903,11 @@ async def c3_agent(workflow: str, scope: str = "", context: str = "",
903
903
  @mcp.tool()
904
904
  async def c3_edit(file_path: str, old_string: str = "", new_string: str = "",
905
905
  summary: str = "", tags: str = "", replace_all: bool = False,
906
- edits: str = "",
906
+ edits: str | list[dict] = "",
907
907
  ctx: Context = None) -> str:
908
908
  """EDIT — read+patch+write+log in one step. Primary code-change tool; always prefer over native Edit.
909
909
  old_string: text to replace. new_string: replacement. summary: ledger description.
910
- edits: JSON list of {old_string, new_string, summary?} for multi-hunk batch on one file.
910
+ edits: list (or JSON string) of {old_string, new_string, summary?} for multi-hunk batch on one file.
911
911
  Parallel across files. Create new file: non-existent file_path + old_string='' + new_string=<content>.
912
912
  If this call ERRORS OR TIMES OUT, do not retry blind — a failed c3_edit may still have
913
913
  written the file. Re-send the same args to c3_edits(action='verify') for a verdict."""
@@ -930,10 +930,13 @@ async def c3_edits(action: str, file: str = "", change_type: str = "modified",
930
930
  summary: str = "", lines_changed: str = "", tags: str = "",
931
931
  limit: int = 50, since: str = "", edit_id: str = "",
932
932
  tag: str = "", branch: str = "", old_string: str = "",
933
- new_string: str = "", edits: str = "", ctx: Context = None) -> str:
933
+ new_string: str = "", edits: str | list[dict] = "",
934
+ ctx: Context = None) -> str:
934
935
  """EDIT HISTORY — inspect the ledger. Different from c3_edit (which writes); this one reads.
935
936
  actions: log (append entry), history (recent edits), versions (per-file), stats, tag (mark edit_id),
936
- verify (did an edit land?).
937
+ verify (did an edit land?), revert (edit_id: put the file back to that c3_edit's pre-image).
938
+ revert refuses when the file changed since that edit (revert the later edit_ids it lists first)
939
+ or when no pre-image was kept (native Edit/shell rows, big or read-denied files, evicted).
937
940
  branch: filter history to edits stamped with a given git branch.
938
941
  verify: pass the SAME file/old_string/new_string (or edits) you gave the c3_edit that errored or
939
942
  timed out. Answers APPLIED (do not retry) / NOT_APPLIED (safe to retry) / INCONCLUSIVE (read the
@@ -0,0 +1,186 @@
1
+ """What c3_edit says about an edit besides the ✓ line: where it landed, what changed.
2
+
3
+ Two concerns, both read-only:
4
+ - ``locate``: a RELATIVE file_path resolves against the project root, which is
5
+ the main checkout. When the repo has other git worktrees, the caller may have
6
+ meant one of them, so the response names the resolved path and any worktree
7
+ holding the same relative path, and refuses outright when the path exists only
8
+ in a worktree.
9
+ - ``diff_block``: a compact unified diff of the whole file, before → after.
10
+ """
11
+ import difflib
12
+ import json
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+
19
+ _WORKTREE_TTL_S = 60.0
20
+ _GIT_TIMEOUT_S = 3.0
21
+ _worktree_cache: dict[str, tuple[float, list[Path]]] = {}
22
+ _cache_lock = threading.Lock()
23
+
24
+ DIFF_MAX_LINES = 24
25
+ DIFF_MAX_CHARS = 1500
26
+ _LINE_MAX_CHARS = 200
27
+
28
+
29
+ def _git_worktree_roots(cwd: Path) -> list[Path]:
30
+ """Every non-bare worktree root of the repo containing ``cwd``, main first.
31
+
32
+ Returns [] when git is missing, times out, or ``cwd`` is not in a repo.
33
+ """
34
+ kwargs = {}
35
+ if sys.platform == "win32":
36
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
37
+ try:
38
+ proc = subprocess.Popen(
39
+ ["git", "worktree", "list", "--porcelain"], cwd=str(cwd),
40
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
41
+ stdin=subprocess.DEVNULL, text=True, encoding="utf-8",
42
+ errors="replace", **kwargs)
43
+ except OSError:
44
+ return []
45
+ try:
46
+ out, _ = proc.communicate(timeout=_GIT_TIMEOUT_S)
47
+ except subprocess.TimeoutExpired:
48
+ if sys.platform == "win32":
49
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
50
+ capture_output=True, stdin=subprocess.DEVNULL, **kwargs)
51
+ else:
52
+ proc.kill()
53
+ proc.communicate()
54
+ return []
55
+ if proc.returncode != 0:
56
+ return []
57
+ roots, current, bare = [], None, False
58
+ for line in (out or "").splitlines() + [""]:
59
+ if line.startswith("worktree "):
60
+ current, bare = line[len("worktree "):].strip(), False
61
+ elif line == "bare":
62
+ bare = True
63
+ elif not line and current:
64
+ if not bare:
65
+ roots.append(Path(current).resolve())
66
+ current = None
67
+ return roots
68
+
69
+
70
+ def worktree_roots(project_path: str) -> list[Path]:
71
+ """Cached ``_git_worktree_roots`` for a project, refreshed every 60s."""
72
+ key = str(Path(project_path).resolve())
73
+ now = time.monotonic()
74
+ with _cache_lock:
75
+ hit = _worktree_cache.get(key)
76
+ if hit and now - hit[0] < _WORKTREE_TTL_S:
77
+ return hit[1]
78
+ roots = _git_worktree_roots(Path(key))
79
+ with _cache_lock:
80
+ _worktree_cache[key] = (now, roots)
81
+ return roots
82
+
83
+
84
+ def _other_trees(project_root: Path, roots: list[Path]) -> list[tuple[Path, Path]]:
85
+ """(worktree root, project dir inside it) for every worktree but our own.
86
+
87
+ A project that is a subdirectory of its repo maps to the same subdirectory
88
+ in each worktree.
89
+ """
90
+ own = None
91
+ for root in roots:
92
+ if (project_root == root or root in project_root.parents) and (
93
+ own is None or len(root.parts) > len(own.parts)):
94
+ own = root
95
+ if own is None:
96
+ return []
97
+ prefix = project_root.relative_to(own)
98
+ return [(root, root / prefix) for root in roots if root != own]
99
+
100
+
101
+ def locate(file_path: str, path: Path, project_path: str) -> tuple[str, str]:
102
+ """Check a resolved edit target against the repo's other worktrees.
103
+
104
+ Returns ``(refusal, note)``, at most one non-empty. ``refusal`` is set when a
105
+ relative ``file_path`` does not exist in this checkout but does in another
106
+ worktree. ``note`` is extra response text for a relative path in a repo
107
+ with other worktrees. Absolute paths always return ``("", "")``.
108
+ """
109
+ if Path(file_path).is_absolute():
110
+ return "", ""
111
+ project_root = Path(project_path).resolve()
112
+ others = _other_trees(project_root, worktree_roots(project_path))
113
+ if not others:
114
+ return "", ""
115
+ try:
116
+ rel = path.relative_to(project_root)
117
+ except ValueError:
118
+ return "", ""
119
+ hits = [proj / rel for _, proj in others if (proj / rel).exists()]
120
+ if not path.exists() and hits:
121
+ listed = "\n".join(f" {h}" for h in hits)
122
+ return (f"[c3_edit:wrong-tree] {file_path} does not exist in this checkout "
123
+ f"({project_root}) but does in another git worktree:\n{listed}\n"
124
+ f" Nothing was written. Pass the absolute path of the copy you mean; "
125
+ f"to create a new file here, pass its absolute path in this checkout."), ""
126
+ if not path.exists():
127
+ return "", (f"\n ⚠ created in the MAIN checkout: {path} — this repo has "
128
+ f"{len(others)} other worktree(s). If you meant one of them, "
129
+ f"delete this file and pass an absolute path.")
130
+ note = f"\n → {path}"
131
+ if hits:
132
+ note += (f"\n ⚠ relative path edited this checkout; the same path also exists "
133
+ f"in: {', '.join(str(h) for h in hits)}. Pass an absolute path to "
134
+ f"edit a worktree.")
135
+ return "", note
136
+
137
+
138
+ def show_diff(project_path: str) -> bool:
139
+ """``.c3/config.json`` key ``edit.show_diff``; true unless set to false."""
140
+ cfg = Path(project_path) / ".c3" / "config.json"
141
+ if not cfg.is_file():
142
+ return True
143
+ try:
144
+ section = (json.loads(cfg.read_text(encoding="utf-8")) or {}).get("edit")
145
+ except (OSError, ValueError, AttributeError):
146
+ return True
147
+ if isinstance(section, dict) and isinstance(section.get("show_diff"), bool):
148
+ return section["show_diff"]
149
+ return True
150
+
151
+
152
+ def _lines(text: str) -> list[str]:
153
+ lines = text.split("\n")
154
+ if lines[-1] == "":
155
+ lines.pop()
156
+ return [ln[:-1] if ln.endswith("\r") else ln for ln in lines]
157
+
158
+
159
+ def _clip(line: str) -> str:
160
+ return line if len(line) <= _LINE_MAX_CHARS else line[:_LINE_MAX_CHARS] + "…"
161
+
162
+
163
+ def diff_block(before: str, after: str, project_path: str) -> str:
164
+ """Response text for a whole-file before → after diff, or "" when disabled/unchanged.
165
+
166
+ Unified diff, 2 context lines, ``@@`` hunk headers with real line numbers,
167
+ no file headers, EOL-stripped lines, capped at DIFF_MAX_LINES lines and
168
+ DIFF_MAX_CHARS characters with a trailing count of what was cut.
169
+ """
170
+ if not show_diff(project_path):
171
+ return ""
172
+ body = list(difflib.unified_diff(_lines(before), _lines(after), n=2,
173
+ lineterm=""))[2:]
174
+ if not body:
175
+ return ""
176
+ shown, used = [], 0
177
+ for line in body:
178
+ line = _clip(line)
179
+ if len(shown) >= DIFF_MAX_LINES or used + len(line) + 3 > DIFF_MAX_CHARS:
180
+ break
181
+ shown.append(" " + line)
182
+ used += len(line) + 3
183
+ out = "\n" + "\n".join(shown)
184
+ if len(shown) < len(body):
185
+ out += f"\n … {len(body) - len(shown)} more diff lines"
186
+ return out