code-context-control 2.152.0__py3-none-any.whl → 2.153.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.152.0"
94
+ __version__ = "2.153.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
@@ -481,7 +481,13 @@ c3_filter(text=<span class="str">"&lt;webpack build output&gt;"</span>, pattern=
481
481
  <td class="param-name">edits</td>
482
482
  <td class="param-type">array</td>
483
483
  <td class="param-default">null</td>
484
- <td class="param-desc">List of <code>{old_string, new_string}</code> objects for batch edits to the same file</td>
484
+ <td class="param-desc">List of <code>{old_string, new_string}</code> objects for batch edits to the same file. Applied in order, all or none: if one patch cannot be placed, nothing is written</td>
485
+ </tr>
486
+ <tr>
487
+ <td class="param-name">overwrite</td>
488
+ <td class="param-type">bool</td>
489
+ <td class="param-default">false</td>
490
+ <td class="param-desc">Replace all of an existing file with <code>new_string</code>. Refused unless this session has read the file with c3_read and the file has not changed since. Keeps the file's line endings and BOM, stores a pre-image, and can be reverted</td>
485
491
  </tr>
486
492
  </tbody>
487
493
  </table>
@@ -514,7 +520,7 @@ c3_filter(text=<span class="str">"&lt;webpack build output&gt;"</span>, pattern=
514
520
  <div class="callout callout-info">
515
521
  <span class="callout-icon">💡</span>
516
522
  <div class="callout-body">
517
- <strong>Unicode lookalikes are matched automatically.</strong> If <code>old_string</code> uses straight quotes but the file has curly quotes (or vice versa), or the two differ only by ASCII vs. unicode dashes (<code>-</code>/<code>–</code>/<code>—</code>) or non-breaking spaces, c3_edit retries with a normalized match and splices your <code>new_string</code> in at the matched offsets; unrelated lookalikes elsewhere are left intact. The response is tagged <code>[unicode-normalized]</code> when the fallback was used.
523
+ <strong>Unicode lookalikes are reported, not applied.</strong> If <code>old_string</code> uses straight quotes but the file has curly quotes (or vice versa), or the two differ only by ASCII vs. unicode dashes (<code>-</code>/<code>–</code>/<code>—</code>) or non-breaking spaces, c3_edit writes nothing and answers <code>[c3_edit:lookalike]</code> with the file's exact text for that region, so the retry can copy it. Applying such a match would replace the file's characters with the retyped ones.
518
524
  </div>
519
525
  </div>
520
526
  </div>
cli/guide/workflow.html CHANGED
@@ -623,7 +623,7 @@ c3_impact(target=<span class="str">'MemoryStore'</span>, mode=<span class="str">
623
623
  </div>
624
624
  <div class="workflow-step-body">
625
625
 
626
- <p><code>c3_edit</code> does read + patch + write + edit ledger log in one step. It also validates the old_string match before writing. If the literal match fails but the strings differ only by unicode lookalikes (curly quotes, em/en-dashes, NBSP), c3_edit retries with a 1:1 normalized match and tags the response <code>[unicode-normalized]</code>.</p>
626
+ <p><code>c3_edit</code> does read + patch + write + edit ledger log in one step. It also validates the old_string match before writing. If the literal match fails but the strings differ only by unicode lookalikes (curly quotes, em/en-dashes, NBSP), c3_edit writes nothing and returns the file's exact text under <code>[c3_edit:lookalike]</code>.</p>
627
627
 
628
628
  <pre><code>c3_edit(
629
629
  file_path=<span class="str">'services/auth.py'</span>,
cli/mcp_server.py CHANGED
@@ -543,7 +543,9 @@ def _with_hint(resp, make_hint) -> str:
543
543
 
544
544
  def _finalize_response(ctx: Context, tool_name: str, args: dict,
545
545
  response: str, summary: str = "",
546
- response_tokens: int = 0) -> str:
546
+ response_tokens: int = 0,
547
+ ok: bool | None = None) -> str:
548
+ """ok: the tool's own verdict on the call; None reads it off the response text."""
547
549
  global _last_tool_call_time, _last_badge_count
548
550
 
549
551
  deferred_snapshot = False
@@ -586,11 +588,14 @@ def _finalize_response(ctx: Context, tool_name: str, args: dict,
586
588
  svc.session_mgr.log_tool_call(tool_name, args, summary)
587
589
  # ok=False lets hook_pretool_enforce's activity scan skip a failed call
588
590
  # (ISSUE-3: "Error: File not found" used to count as "c3 was used").
589
- try:
590
- from cli._hook_utils import response_text_failed as _failed
591
- call_ok = not _failed(response)
592
- except Exception:
593
- call_ok = True
591
+ if ok is not None:
592
+ call_ok = ok
593
+ else:
594
+ try:
595
+ from cli._hook_utils import response_text_failed as _failed
596
+ call_ok = not _failed(response)
597
+ except Exception:
598
+ call_ok = True
594
599
  # Both session ids on every tool_call row (D0b): C3's own id joins the
595
600
  # row to session_start / the saved session file, the host id joins it to
596
601
  # the hooks' session_open / session_end rows. Either may be absent.
@@ -903,12 +908,13 @@ async def c3_agent(workflow: str, scope: str = "", context: str = "",
903
908
  @mcp.tool()
904
909
  async def c3_edit(file_path: str, old_string: str = "", new_string: str = "",
905
910
  summary: str = "", tags: str = "", replace_all: bool = False,
906
- edits: str | list[dict] = "",
911
+ edits: str | list[dict] = "", overwrite: bool = False,
907
912
  ctx: Context = None) -> str:
908
913
  """EDIT — read+patch+write+log in one step. Primary code-change tool; always prefer over native Edit.
909
914
  old_string: text to replace. new_string: replacement. summary: ledger description.
910
- edits: list (or JSON string) of {old_string, new_string, summary?} for multi-hunk batch on one file.
911
- Parallel across files. Create new file: non-existent file_path + old_string='' + new_string=<content>.
915
+ edits: list (or JSON string) of {old_string, new_string, summary?} for multi-hunk batch on one file,
916
+ applied all or none. Parallel across files. Create new file: non-existent file_path + old_string='' + new_string=<content>.
917
+ Rewrite a whole existing file: overwrite=true + new_string=<content>, after a c3_read of it.
912
918
  If this call ERRORS OR TIMES OUT, do not retry blind — a failed c3_edit may still have
913
919
  written the file. Re-send the same args to c3_edits(action='verify') for a verdict."""
914
920
  path_err = validate_file_path(file_path)
@@ -920,7 +926,8 @@ async def c3_edit(file_path: str, old_string: str = "", new_string: str = "",
920
926
  return _finalize_response(ctx, name, args, resp, summ, **kw)
921
927
 
922
928
  resp = await asyncio.to_thread(handle_edit, file_path, old_string, new_string,
923
- summary, tags, replace_all, svc, finalize, edits)
929
+ summary, tags, replace_all, svc, finalize, edits,
930
+ overwrite)
924
931
  return _with_hint(resp, lambda: delegate_hints.after_edit(
925
932
  svc, file_path, delegate_hints.edit_chars(new_string, edits)))
926
933
 
cli/tools/_edit_report.py CHANGED
@@ -4,8 +4,9 @@ Two concerns, both read-only:
4
4
  - ``locate``: a RELATIVE file_path resolves against the project root, which is
5
5
  the main checkout. When the repo has other git worktrees, the caller may have
6
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.
7
+ holding the same relative path. It refuses outright when the path exists only
8
+ in a worktree, or when this process last wrote to a worktree that the path
9
+ could equally mean.
9
10
  - ``diff_block``: a compact unified diff of the whole file, before → after.
10
11
  """
11
12
  import difflib
@@ -19,6 +20,7 @@ from pathlib import Path
19
20
  _WORKTREE_TTL_S = 60.0
20
21
  _GIT_TIMEOUT_S = 3.0
21
22
  _worktree_cache: dict[str, tuple[float, list[Path]]] = {}
23
+ _last_tree: dict[str, Path] = {}
22
24
  _cache_lock = threading.Lock()
23
25
 
24
26
  DIFF_MAX_LINES = 24
@@ -98,13 +100,26 @@ def _other_trees(project_root: Path, roots: list[Path]) -> list[tuple[Path, Path
98
100
  return [(root, root / prefix) for root in roots if root != own]
99
101
 
100
102
 
103
+ def note_write(path: Path, project_path: str) -> None:
104
+ """Record the worktree a write landed in, for ``locate`` to compare the
105
+ next relative path against."""
106
+ holding = [r for r in worktree_roots(project_path)
107
+ if r == path or r in path.parents]
108
+ if holding:
109
+ with _cache_lock:
110
+ _last_tree[str(Path(project_path).resolve())] = max(
111
+ holding, key=lambda r: len(r.parts))
112
+
113
+
101
114
  def locate(file_path: str, path: Path, project_path: str) -> tuple[str, str]:
102
115
  """Check a resolved edit target against the repo's other worktrees.
103
116
 
104
117
  Returns ``(refusal, note)``, at most one non-empty. ``refusal`` is set when a
105
118
  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 ``("", "")``.
119
+ worktree, or when the last write went to another worktree where the path
120
+ exists or would be created. ``note`` is extra response text for a relative
121
+ path in a repo with other worktrees. Absolute paths always return
122
+ ``("", "")``.
108
123
  """
109
124
  if Path(file_path).is_absolute():
110
125
  return "", ""
@@ -117,6 +132,18 @@ def locate(file_path: str, path: Path, project_path: str) -> tuple[str, str]:
117
132
  except ValueError:
118
133
  return "", ""
119
134
  hits = [proj / rel for _, proj in others if (proj / rel).exists()]
135
+ with _cache_lock:
136
+ last = _last_tree.get(str(project_root))
137
+ recent = next((proj / rel for root, proj in others if root == last), None)
138
+ if recent is not None and (recent.exists() or not path.exists()):
139
+ there = ("where the same path is" if recent.exists()
140
+ else "where this path would be")
141
+ return (f"[c3_edit:wrong-tree] {file_path} is relative, so it means "
142
+ f"{path} in this checkout, but this session's last edit went "
143
+ f"to another git worktree, {there}:\n"
144
+ f" {recent}\n"
145
+ f" Nothing was written. Pass the absolute path of the one "
146
+ f"you mean."), ""
120
147
  if not path.exists() and hits:
121
148
  listed = "\n".join(f" {h}" for h in hits)
122
149
  return (f"[c3_edit:wrong-tree] {file_path} does not exist in this checkout "
cli/tools/edit.py CHANGED
@@ -15,21 +15,108 @@ Parallel safety:
15
15
  See docs/agent-locks.md §5 (Layer A).
16
16
  """
17
17
  import bisect
18
+ import codecs
18
19
  import difflib
19
20
  import hashlib
20
21
  import json
21
22
  import os
22
23
  import re
23
24
  import threading
25
+ import time
24
26
  from contextlib import contextmanager
25
27
  from pathlib import Path
26
28
 
27
29
  from cli.tools import _edit_report, _grants
30
+ from cli.tools._helpers import finalize_with_tokens
28
31
  from services import access_guard, agent_locks, edit_blobs, read_stamps
29
32
  from services import credential_store as _cs
30
33
  from services.atomic_json import write_bytes_atomic
31
34
  from services.task_store import _FileLock
32
35
 
36
+ _FAILURES = {
37
+ "missing param": "invalid",
38
+ "bad edits param": "invalid",
39
+ "empty old_string": "invalid",
40
+ "bad overwrite": "invalid",
41
+ "vault-protected": "denied",
42
+ "access-denied": "denied",
43
+ "wrong tree": "wrong_tree",
44
+ "lock held": "locked",
45
+ "lock busy": "locked",
46
+ "stale read": "stale",
47
+ "not found": "not_found",
48
+ "ambiguous": "ambiguous",
49
+ "lookalike": "lookalike",
50
+ "encoding": "encoding",
51
+ "unread": "unread",
52
+ "read error": "io_error",
53
+ "write error": "io_error",
54
+ "create error": "io_error",
55
+ }
56
+ _NOOP = "noop"
57
+ _BATCH_FAILURES = {"miss": "not_found", "ambiguous": "ambiguous",
58
+ "lookalike": "lookalike", "skipped": "invalid"}
59
+
60
+
61
+ class _Report:
62
+ """`finalize` for one c3_edit call: records the outcome and wall time of
63
+ whichever exit is taken, and remembers in `wrote` whether it wrote.
64
+
65
+ The outcome is the `outcome` keyword when a call site passes one, else the
66
+ code `_FAILURES` gives the summary slug, else "success". Other keywords
67
+ land in the telemetry detail. `ok` reaches the activity log, which the
68
+ enforcement hook reads to decide whether this call unlocks native Edit.
69
+ """
70
+
71
+ def __init__(self, finalize, svc):
72
+ self._finalize = finalize
73
+ self._svc = svc
74
+ self._started = time.monotonic()
75
+ self.wrote = False
76
+
77
+ def __call__(self, tool, args, text, summary="", **detail):
78
+ outcome = detail.pop("outcome", "") or _FAILURES.get(summary, "success")
79
+ self.wrote = outcome == "success"
80
+ return finalize_with_tokens(
81
+ self._finalize, self._svc, tool, args, text, summary,
82
+ duration_ms=round((time.monotonic() - self._started) * 1000, 1),
83
+ detail={"outcome": outcome, **detail},
84
+ ok=outcome in ("success", _NOOP))
85
+
86
+
87
+ _in_flight: dict[tuple[str, str], dict] = {}
88
+
89
+
90
+ @contextmanager
91
+ def _lease_scope(path: Path, project_path: str, session_id: str, report):
92
+ """Give back the lease on `path` when this process's calls took it and
93
+ none of them wrote.
94
+
95
+ Calls on one path overlap (each runs on its own thread until
96
+ `_edit_lock`), so they are counted as a group: whether the session held
97
+ the lease is sampled when the group forms, and the lease is judged when
98
+ the last call leaves. A call that fails cannot drop the lease a
99
+ concurrent call is writing under.
100
+ """
101
+ key = (session_id, str(path))
102
+ with _locks_lock:
103
+ group = _in_flight.get(key)
104
+ if group is None:
105
+ group = _in_flight[key] = {
106
+ "calls": 0,
107
+ "keep": agent_locks.holds(str(path), project_path, session_id)}
108
+ group["calls"] += 1
109
+ try:
110
+ yield
111
+ finally:
112
+ with _locks_lock:
113
+ group["calls"] -= 1
114
+ group["keep"] = group["keep"] or report.wrote
115
+ if group["calls"] == 0:
116
+ del _in_flight[key]
117
+ if not group["keep"]:
118
+ agent_locks.give_back(str(path), project_path, session_id)
119
+
33
120
 
34
121
  def _session_id(svc) -> str:
35
122
  """This agent's lease identity — see `cli.tools._grants.session_id`.
@@ -107,6 +194,24 @@ def _read_preserving_newlines(path: Path) -> str:
107
194
  return path.read_bytes().decode("utf-8", errors="surrogateescape")
108
195
 
109
196
 
197
+ # UTF-32 first: its little-endian BOM begins with the UTF-16 one.
198
+ _FOREIGN_BOMS = ((codecs.BOM_UTF32_LE, "UTF-32"), (codecs.BOM_UTF32_BE, "UTF-32"),
199
+ (codecs.BOM_UTF16_LE, "UTF-16"), (codecs.BOM_UTF16_BE, "UTF-16"))
200
+
201
+
202
+ def _encoding_refusal(raw: bytes, file_label: str) -> str:
203
+ """A refusal when `raw` opens with a UTF-16 or UTF-32 byte-order mark,
204
+ else "". Read as UTF-8 such a file is NUL-separated characters that no
205
+ old_string matches."""
206
+ name = next((n for bom, n in _FOREIGN_BOMS if raw.startswith(bom)), "")
207
+ if not name:
208
+ return ""
209
+ return (f"[c3_edit:encoding] {file_label} is {name} text. c3_edit and "
210
+ f"c3_read work on UTF-8 only, so nothing was written.\n"
211
+ f" Convert the file to UTF-8 first, or change it with the tool "
212
+ f"that owns it.")
213
+
214
+
110
215
  def _write_preserving_newlines(path: Path, content: str) -> None:
111
216
  """Atomically replace `path` with `content`, encoded the way it was read.
112
217
 
@@ -137,10 +242,10 @@ def _dominant_eol(text: str, default: str = "\n") -> str:
137
242
  return max(_EOLS, key=counts.__getitem__)
138
243
 
139
244
 
140
- # Unicode lookalike substitutions used as a fallback when the literal
141
- # old_string is not found. Strictly 1:1 (same-length) substitutions so
142
- # positions are preserved — we locate the match on the normalized string
143
- # and splice the replacement into the original content at the same offsets.
245
+ # Typographic characters a model tends to retype as ASCII. An old_string that
246
+ # matches only after this fold is refused, not applied: new_string would
247
+ # overwrite the file's characters inside the span with the retyped ones.
248
+ # Strictly 1:1 so a folded offset is also an offset in the file text.
144
249
  _LOOKALIKE_TRANS = str.maketrans({
145
250
  "‘": "'", "’": "'", "‚": "'", "‛": "'", # single curly quotes
146
251
  "“": '"', "”": '"', "„": '"', "‟": '"', # double curly quotes
@@ -173,8 +278,7 @@ def _positional_replace(content: str, view: str, crlf_at: list, needle: str,
173
278
  new: str, replace_all: bool, file_eol: str) -> str:
174
279
  """Replace `needle` found in `view` by splicing `new` into `content`.
175
280
 
176
- `view` is `content` with every EOL as ``\\n`` (optionally lookalike-folded,
177
- which is 1:1); `crlf_at` holds the sorted view offsets of the ``\\n``
281
+ `view` is `content` with every EOL as ``\\n``; `crlf_at` holds the sorted view offsets of the ``\\n``
178
282
  characters that are ``\\r\\n`` in `content`, so view offset v is raw
179
283
  offset v + bisect_left(crlf_at, v). `new` uses ``\\n`` and is written with
180
284
  the dominant EOL of the span it replaces (`file_eol` when that span has
@@ -203,15 +307,16 @@ def _apply_replacement(content: str, old: str, new: str, replace_all: bool):
203
307
  """Replace `old` with `new` in `content` without disturbing other bytes.
204
308
 
205
309
  Matching ignores EOL style (``\\r\\n``, ``\\r`` and ``\\n`` are equal), so a
206
- LF old_string matches a CRLF, CR-only or mixed file; on zero matches it
207
- retries with unicode-lookalike normalization (curly quotes, unicode
208
- dashes, NBSP). Only the matched spans change.
209
-
210
- Returns (new_content, count, used_fallback):
211
- - (str, count, bool) when at least one match was applied
212
- - (None, 0, False) when no match is found (even after fallback), or
213
- `old` is empty
214
- - (None, count, bool) when count > 1 and replace_all is False
310
+ LF old_string matches a CRLF, CR-only or mixed file, and reads an
311
+ undecodable byte as the U+FFFD that c3_read showed for it. Only the
312
+ matched spans change.
313
+
314
+ Returns (new_content, count, lookalike):
315
+ - (str, count, False) when at least one match was applied
316
+ - (None, 0, False) when no match is found, or `old` is empty
317
+ - (None, count, False) when count > 1 and replace_all is False
318
+ - (None, count, True) when `old` matches only after the lookalike
319
+ fold; never applied
215
320
  """
216
321
  if not old:
217
322
  return (None, 0, False)
@@ -220,21 +325,18 @@ def _apply_replacement(content: str, old: str, new: str, replace_all: bool):
220
325
  crlf_at = [m.start() - k for k, m in enumerate(re.finditer("\r\n", content))]
221
326
  file_eol = _dominant_eol(content)
222
327
 
223
- used_fallback = False
328
+ # c3_read shows an undecodable byte and a real U+FFFD alike, so the two
329
+ # are one character here: an old_string that fits both is ambiguous.
330
+ view = view.translate(_SURROGATE_TRANS)
331
+ old = old.translate(_SURROGATE_TRANS)
224
332
  count = view.count(old)
225
333
  if count == 0:
226
- nv, no = _norm(view), _norm(old)
227
- if nv == view and no == old:
228
- # Neither side contained any lookalike chars — genuinely not found.
229
- return (None, 0, False)
230
- view, old, used_fallback = nv, no, True
231
- count = view.count(old)
232
- if count == 0:
233
- return (None, 0, False)
334
+ folded = _norm(view).count(_norm(old))
335
+ return (None, folded, folded > 0)
234
336
  if count > 1 and not replace_all:
235
- return (None, count, used_fallback)
337
+ return (None, count, False)
236
338
  return (_positional_replace(content, view, crlf_at, old, new, replace_all,
237
- file_eol), count, used_fallback)
339
+ file_eol), count, False)
238
340
 
239
341
 
240
342
  def _closest_region(content: str, old: str,
@@ -293,35 +395,80 @@ def _closest_region(content: str, old: str,
293
395
  _REGION_CAP = 4000
294
396
 
295
397
 
296
- def _not_found_payload(near, file_label: str) -> str:
297
- """Render a _closest_region result as an error-message appendix. The
298
- region text is emitted verbatim (no indentation) so it can be copied
299
- straight into a retry old_string."""
300
- if not near:
301
- return ""
302
- lo, hi, region, ratio = near
398
+ def _marked_region(lo: int, hi: int, region: str, file_label: str) -> str:
399
+ """File lines lo..hi between markers, verbatim and unindented so they can
400
+ be copied straight into a retry old_string."""
303
401
  region = _display_safe(region)
304
402
  if len(region) > _REGION_CAP:
305
403
  region = (region[:_REGION_CAP]
306
404
  + f"\n⟦trimmed — run c3_read(file_path='{file_label}', "
307
405
  f"lines=[{lo},{hi}]) for the rest⟧")
406
+ return f"⟦L{lo}-L{hi}⟧\n{region}\n⟦end⟧\n"
407
+
408
+
409
+ def _not_found_payload(near, file_label: str) -> str:
410
+ """Render a _closest_region result as an error-message appendix."""
411
+ if not near:
412
+ return ""
413
+ lo, hi, region, ratio = near
308
414
  return (f"\n closest match: L{lo}-L{hi} ({ratio:.0%} similar). "
309
415
  f"Actual file text between the markers:\n"
310
- f"⟦L{lo}-L{hi}⟧\n{region}\n⟦end⟧\n"
311
- f" Retry with old_string copied exactly from the text above "
312
- f"(markers excluded) — no need to re-read the file.")
416
+ + _marked_region(lo, hi, region, file_label)
417
+ + " Retry with old_string copied exactly from the text above "
418
+ "(markers excluded) — no need to re-read the file.")
419
+
420
+
421
+ def _revert_note(project_path, images: dict) -> str:
422
+ """Response text for a write that c3_edits(action='revert') will refuse:
423
+ no post-image hash on the row, or a pre-image that is not in the store."""
424
+ blob = images.get("blob") or ""
425
+ if not blob.startswith("skipped:"):
426
+ return ""
427
+ pre = images.get("pre_sha256")
428
+ if "post_sha256" in images and (
429
+ pre is None or edit_blobs.has(project_path, pre)):
430
+ return ""
431
+ return (f"\n ⚠ c3_edits cannot revert this edit: the file as it was "
432
+ f"before is not kept ({blob.partition(':')[2]}).")
433
+
434
+
435
+ def _lookalike_payload(content: str, old: str, count: int,
436
+ file_label: str) -> str:
437
+ """The file's own text for the first place `old` matches after the
438
+ lookalike fold, as an error-message appendix."""
439
+ view, old = _eol_norm(content), _eol_norm(old)
440
+ pos = _norm(view).find(_norm(old))
441
+ lo = view.count("\n", 0, pos) + 1
442
+ hi = lo + old.removesuffix("\n").count("\n")
443
+ region = "\n".join(view.split("\n")[lo - 1:hi])
444
+ places = f" ({count} places; the first is shown)" if count > 1 else ""
445
+ return (f"\n It matches only when curly quotes, dashes and non-breaking "
446
+ f"spaces are read as their ASCII lookalikes{places}. "
447
+ f"Nothing was written.\n"
448
+ f" Actual file text between the markers:\n"
449
+ + _marked_region(lo, hi, region, file_label)
450
+ + " Retry with old_string copied exactly from the text above "
451
+ "(markers excluded), and keep those characters in new_string "
452
+ "unless you mean to change them.")
313
453
 
314
454
 
315
455
  def handle_edit(file_path: str, old_string: str, new_string: str,
316
456
  summary: str, tags: str, replace_all: bool,
317
- svc, finalize, edits: str = "") -> str:
457
+ svc, finalize, edits: str = "", overwrite: bool = False) -> str:
318
458
  """Find old_string in file, replace with new_string, write back, log to ledger.
319
459
 
320
460
  edits: optional JSON list of {old_string, new_string, summary?} dicts for
321
461
  batch same-file patching in a single read/write cycle.
462
+ overwrite: replace all of an existing file with new_string. The session
463
+ must have read the file, and it must not have changed since.
322
464
  """
465
+ finalize = _Report(finalize, svc)
323
466
  if not file_path:
324
467
  return finalize("c3_edit", {}, "file_path is required", "missing param")
468
+ if overwrite and (old_string or edits):
469
+ return finalize("c3_edit", {"file": file_path},
470
+ "overwrite replaces the whole file: pass new_string "
471
+ "only, without old_string or edits", "bad overwrite")
325
472
 
326
473
  # Resolve path
327
474
  path = Path(file_path)
@@ -345,6 +492,16 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
345
492
  if wrong_tree:
346
493
  return finalize("c3_edit", {"file": file_path}, wrong_tree, "wrong tree")
347
494
 
495
+ with _lease_scope(path, svc.project_path, _session_id(svc), finalize):
496
+ return _gated_edit(path, rel, file_path, old_string, new_string,
497
+ summary, tags, replace_all, svc, finalize, edits,
498
+ where, overwrite)
499
+
500
+
501
+ def _gated_edit(path: Path, rel: str, file_path: str, old_string: str,
502
+ new_string: str, summary: str, tags: str, replace_all: bool,
503
+ svc, finalize, edits: str, where: str, overwrite: bool) -> str:
504
+ """The write gates, then the edit under `_edit_lock`."""
348
505
  op = "write" if path.exists() else "create"
349
506
  refusal = _write_gate(svc, path, rel, file_path, op, "c3_edit", summary,
350
507
  finalize)
@@ -357,6 +514,9 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
357
514
  # loser's content is silently gone.
358
515
  try:
359
516
  with _edit_lock(path):
517
+ if overwrite and path.exists():
518
+ return _overwrite_locked(path, rel, file_path, new_string,
519
+ summary, tags, svc, finalize, where)
360
520
  return _edit_locked(path, rel, file_path, old_string, new_string,
361
521
  summary, tags, replace_all, svc, finalize, edits,
362
522
  where)
@@ -368,6 +528,9 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
368
528
  f" This is contention, not an error — do not route around it via "
369
529
  f"c3_shell or native Write. Retry, or edit a different file.",
370
530
  "lock busy")
531
+ finally:
532
+ if finalize.wrote:
533
+ _edit_report.note_write(path, svc.project_path)
371
534
 
372
535
 
373
536
  def _write_gate(svc, path: Path, rel: str, file_path: str, op: str,
@@ -398,12 +561,69 @@ def _write_gate(svc, path: Path, rel: str, file_path: str, op: str,
398
561
  session_id = _session_id(svc)
399
562
  holder = agent_locks.check(str(path), svc.project_path, session_id)
400
563
  if holder:
401
- return finalize(tool, {"file": file_path},
564
+ held_for = round(time.time() - holder.get("acquired_at", time.time()))
565
+ return finalize(tool, {"file": file_path,
566
+ "holder": holder.get("agent_id", ""),
567
+ "held_for_s": held_for},
402
568
  agent_locks.refusal(holder, rel), "lock held")
403
569
  agent_locks.lease(str(path), svc.project_path, session_id, intent=intent)
404
570
  return None
405
571
 
406
572
 
573
+ def _overwrite_locked(path: Path, rel: str, file_path: str, new_string: str,
574
+ summary: str, tags: str, svc, finalize, where: str) -> str:
575
+ """Replace all of an existing file, keeping its line endings and BOM.
576
+ Always called under _edit_lock."""
577
+ args = {"file": file_path, "overwrite": True}
578
+ try:
579
+ pre_image = path.read_bytes()
580
+ except OSError as e:
581
+ return finalize("c3_edit", args, f"Read error: {e}", "read error")
582
+ slug = "unread" if read_stamps.get(path) is None else "stale read"
583
+ guard = read_stamps.check_whole(path, file_path, pre_image)
584
+ if guard.refusal:
585
+ return finalize("c3_edit", args, guard.refusal, slug)
586
+ foreign = _encoding_refusal(pre_image, file_path)
587
+ if foreign:
588
+ return finalize("c3_edit", args, foreign, "encoding")
589
+
590
+ content = pre_image.decode("utf-8", errors="surrogateescape")
591
+ eol = _dominant_eol(content)
592
+ new_content = _eol_norm(new_string)
593
+ if eol != "\n":
594
+ new_content = new_content.replace("\n", eol)
595
+ if content.startswith("") and not new_content.startswith(""):
596
+ new_content = "" + new_content
597
+ if new_content == content:
598
+ return finalize("c3_edit", args,
599
+ f"{rel} unchanged — new_string is the file's current "
600
+ f"text; nothing was written or logged.",
601
+ f"{rel} unchanged", outcome=_NOOP, n_attempted=1,
602
+ n_applied=0)
603
+
604
+ edit_blobs.keep_pre(svc.project_path, path, pre_image)
605
+ try:
606
+ _write_preserving_newlines(path, new_content)
607
+ guard.written()
608
+ except Exception as e:
609
+ return finalize("c3_edit", args, f"Write error: {e}", "write error")
610
+
611
+ images = edit_blobs.record_edit(svc.project_path, path, pre_image)
612
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
613
+ n_old = len(content.splitlines())
614
+ n_new = len(new_content.splitlines())
615
+ deferred = _log_to_ledger(
616
+ rel, summary or f"Overwrote {rel} ({n_new}L)", tag_list, svc,
617
+ detail={"old_string": "", "new_string": new_string[:_DETAIL_CAP],
618
+ "overwritten": True, **images})
619
+ short = (f"✓ {rel} [overwritten, -{n_old}+{n_new}L]"
620
+ + (f" — {summary}" if summary else "")
621
+ + where + _revert_note(svc.project_path, images) + _display_safe(
622
+ _edit_report.diff_block(content, new_content, svc.project_path)))
623
+ return finalize("c3_edit", args, short + deferred, f"{rel} overwritten",
624
+ n_attempted=1, n_applied=1, blob=images.get("blob"))
625
+
626
+
407
627
  def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
408
628
  new_string: str, summary: str, tags: str, replace_all: bool,
409
629
  svc, finalize, edits: str, where: str = "") -> str:
@@ -444,9 +664,11 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
444
664
  rel, create_summary, tag_list, svc,
445
665
  detail={"old_string": "", "new_string": new_string[:_DETAIL_CAP],
446
666
  "created": True, **images})
447
- short = f"✓ {rel} [created, +{n_new}L]" + (f" — {summary}" if summary else "") + where
667
+ short = (f"✓ {rel} [created, +{n_new}L]" + (f" — {summary}" if summary else "")
668
+ + where + _revert_note(svc.project_path, images))
448
669
  return finalize("c3_edit", {"file": file_path}, short + deferred,
449
- f"{rel} created")
670
+ f"{rel} created", n_attempted=1, n_applied=1,
671
+ blob=images.get("blob"))
450
672
 
451
673
  # Parse tag list once
452
674
  tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
@@ -475,11 +697,13 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
475
697
  except Exception as e:
476
698
  return finalize("c3_edit", {"file": file_path},
477
699
  f"Read error: {e}", "read error")
700
+ foreign = _encoding_refusal(pre_image, file_path)
701
+ if foreign:
702
+ return finalize("c3_edit", {"file": file_path}, foreign, "encoding")
478
703
  original = content
479
704
 
480
705
  results = []
481
- statuses = [] # parallel to results: 'ok' | 'miss' | 'ambiguous' | 'skipped' | 'noop'
482
- any_normalized = False
706
+ statuses = [] # parallel to results: 'ok' | 'miss' | 'lookalike' | 'ambiguous' | 'skipped' | 'noop'
483
707
  first_miss = ""
484
708
  for i, patch in enumerate(edit_list):
485
709
  old = patch.get("old_string", "")
@@ -492,9 +716,22 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
492
716
  statuses.append("skipped")
493
717
  continue
494
718
 
495
- new_content, count, used_fallback = _apply_replacement(content, old, new, r_all)
719
+ new_content, count, lookalike = _apply_replacement(content, old, new, r_all)
720
+ if lookalike:
721
+ results.append(f" patch[{i}]: LOOKALIKE ONLY — {old[:80]!r}")
722
+ statuses.append("lookalike")
723
+ if not first_miss:
724
+ on_disk = _apply_replacement(original, old, new, r_all)
725
+ first_miss = (
726
+ _lookalike_payload(original, old, on_disk[1], file_path)
727
+ if on_disk[2] else
728
+ _lookalike_payload(content, old, count, file_path))
729
+ continue
496
730
  if new_content is None and count == 0:
497
- near = _closest_region(_eol_norm(content), _eol_norm(old))
731
+ # Nothing is written on a miss, so line numbers are the
732
+ # file's; an earlier patch's result is the second choice.
733
+ near = (_closest_region(_eol_norm(original), _eol_norm(old))
734
+ or _closest_region(_eol_norm(content), _eol_norm(old)))
498
735
  loc = (f" (closest: L{near[0]}-L{near[1]}, {near[3]:.0%} similar)"
499
736
  if near else "")
500
737
  results.append(f" patch[{i}]: NOT FOUND — {old[:80]!r}{loc}")
@@ -503,8 +740,7 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
503
740
  first_miss = _not_found_payload(near, file_path)
504
741
  continue
505
742
  if new_content is None:
506
- tag = " (unicode-normalized)" if used_fallback else ""
507
- results.append(f" patch[{i}]: AMBIGUOUS ({count} matches){tag} — {old[:60]!r}")
743
+ results.append(f" patch[{i}]: AMBIGUOUS ({count} matches) — {old[:60]!r}")
508
744
  statuses.append("ambiguous")
509
745
  continue
510
746
  if new_content == content:
@@ -515,65 +751,73 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
515
751
 
516
752
  content = new_content
517
753
  n = count if r_all else 1
518
- if used_fallback:
519
- any_normalized = True
520
754
 
521
755
  n_old = old.count("\n") + 1
522
756
  n_new = new.count("\n") + 1
523
757
  desc = patch_summary or f"{old[:50]!r} → {new[:50]!r}"
524
758
  results.append(f" patch[{i}]: -{n_old}L +{n_new}L"
525
759
  + (f" ({n}x)" if n > 1 else "")
526
- + (" [norm]" if used_fallback else "")
527
760
  + f" | {desc}")
528
761
  statuses.append("ok")
529
762
 
530
- # Only touch the file when the patches changed it — no rewrite and no
531
- # phantom ledger entry when every patch missed or was a no-op.
532
- changed = content != original
533
- if changed:
534
- try:
535
- _write_preserving_newlines(path, content)
536
- stale.written()
537
- except Exception as e:
538
- return finalize("c3_edit", {"file": file_path},
539
- f"Write error: {e}", "write error")
540
-
541
- # Log batch to ledger as one entry (store each patch's old/new for diff view)
542
- deferred = ""
543
- if changed:
544
- batch_detail = {"patches": [
545
- {
546
- "old_string": p.get("old_string", "")[:_DETAIL_CAP],
547
- "new_string": p.get("new_string", "")[:_DETAIL_CAP],
548
- **({"summary": p["summary"]} if p.get("summary") else {}),
549
- }
550
- for p in edit_list if p.get("old_string") is not None
551
- ]}
552
- batch_detail.update(
553
- edit_blobs.record_edit(svc.project_path, path, pre_image))
554
- deferred = _log_to_ledger(
555
- rel, summary or f"Batch edit: {len(edit_list)} patches",
556
- tag_list, svc, detail=batch_detail)
557
-
558
- # Classify from the structured status list, not by substring-scanning
559
- # the human-readable result lines — a patch *summary* containing words
560
- # like 'NOT FOUND' used to be miscounted as a failure.
763
+ # All or nothing: the patches that did match are half of a change
764
+ # once one of them cannot be placed.
765
+ total = len(edit_list)
766
+ failed = [r for r, s in zip(results, statuses) if s in _BATCH_FAILURES]
767
+ if failed:
768
+ outcome = next(_BATCH_FAILURES[s] for s in statuses
769
+ if s in _BATCH_FAILURES)
770
+ return finalize(
771
+ "c3_edit", {"file": file_path},
772
+ f"{rel} unchanged — {len(failed)} of {total} patches could not "
773
+ f"be placed, so none were applied.\n" + "\n".join(failed)
774
+ + first_miss
775
+ + "\n Resend the whole batch with those patches corrected.",
776
+ f"{rel} batch refused", outcome=outcome, n_attempted=total,
777
+ n_applied=0)
778
+
779
+ unchanged = [r for r, s in zip(results, statuses) if s == "noop"]
780
+ if content == original:
781
+ return finalize(
782
+ "c3_edit", {"file": file_path},
783
+ f"{rel} unchanged — no patch changes the text; nothing was "
784
+ f"written or logged.\n" + "\n".join(unchanged),
785
+ f"{rel} unchanged", outcome=_NOOP, n_attempted=total,
786
+ n_applied=0)
787
+
788
+ edit_blobs.keep_pre(svc.project_path, path, pre_image)
789
+ try:
790
+ _write_preserving_newlines(path, content)
791
+ stale.written()
792
+ except Exception as e:
793
+ return finalize("c3_edit", {"file": file_path},
794
+ f"Write error: {e}", "write error")
795
+
796
+ # One ledger entry for the batch, with each patch's old/new for the diff view.
797
+ batch_detail = {"patches": [
798
+ {
799
+ "old_string": p.get("old_string", "")[:_DETAIL_CAP],
800
+ "new_string": p.get("new_string", "")[:_DETAIL_CAP],
801
+ **({"summary": p["summary"]} if p.get("summary") else {}),
802
+ }
803
+ for p in edit_list if p.get("old_string") is not None
804
+ ]}
805
+ batch_detail.update(
806
+ edit_blobs.record_edit(svc.project_path, path, pre_image))
807
+ deferred = _log_to_ledger(
808
+ rel, summary or f"Batch edit: {total} patches",
809
+ tag_list, svc, detail=batch_detail)
810
+
561
811
  applied = statuses.count("ok")
562
- norm_tag = " [unicode-normalized]" if any_normalized else ""
563
- short = f"✓ {rel} — {applied}/{len(edit_list)} patches applied{norm_tag}"
564
- if not changed:
565
- short = (f"{rel} unchanged — {applied}/{len(edit_list)} patches "
566
- f"applied; nothing was written or logged.")
567
- if applied < len(edit_list):
568
- failed = [r for r, s in zip(results, statuses) if s != "ok"]
569
- short += "\n" + "\n".join(failed)
570
- if first_miss:
571
- short += first_miss
572
- if changed:
573
- short += where + _display_safe(
574
- _edit_report.diff_block(original, content, svc.project_path))
812
+ short = (f"✓ {rel} — {applied}/{total} patches applied"
813
+ + "".join(f"\n{r}" for r in unchanged)
814
+ + where + _revert_note(svc.project_path, batch_detail)
815
+ + _display_safe(
816
+ _edit_report.diff_block(original, content, svc.project_path)))
575
817
  return finalize("c3_edit", {"file": file_path}, short + deferred,
576
- f"{rel} patched ({len(edit_list)} patches)")
818
+ f"{rel} patched ({applied}/{total} patches)",
819
+ n_attempted=total, n_applied=applied,
820
+ blob=batch_detail.get("blob"))
577
821
 
578
822
  # ── Single-edit mode ──────────────────────────────────────────────────────
579
823
  if not old_string:
@@ -587,34 +831,41 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
587
831
  except Exception as e:
588
832
  return finalize("c3_edit", {"file": file_path},
589
833
  f"Read error: {e}", "read error")
834
+ foreign = _encoding_refusal(pre_image, file_path)
835
+ if foreign:
836
+ return finalize("c3_edit", {"file": file_path}, foreign, "encoding")
590
837
 
591
- new_content, count, used_fallback = _apply_replacement(
838
+ new_content, count, lookalike = _apply_replacement(
592
839
  content, old_string, new_string, replace_all)
593
840
 
841
+ if lookalike:
842
+ return finalize("c3_edit", {"file": file_path},
843
+ f"[c3_edit:lookalike] old_string is not in {file_path} "
844
+ f"as written."
845
+ + _lookalike_payload(content, old_string, count, file_path),
846
+ "lookalike")
594
847
  if new_content is None and count == 0:
595
- hint = ""
596
- if _norm(old_string) != old_string or _norm(content) != content:
597
- hint = "\n hint: unicode-lookalike normalization also failed to match."
598
- hint += _not_found_payload(
848
+ hint = _not_found_payload(
599
849
  _closest_region(_eol_norm(content), _eol_norm(old_string)), file_path)
600
850
  return finalize("c3_edit", {"file": file_path},
601
851
  f"old_string not found in {file_path}\n"
602
852
  f" searched for: {old_string[:120]!r}{hint}",
603
853
  "not found")
604
854
  if new_content is None:
605
- hint = " (after unicode-lookalike normalization)" if used_fallback else ""
606
855
  return finalize("c3_edit", {"file": file_path},
607
- f"old_string matches {count} locations{hint} — add more context to make it unique, "
856
+ f"old_string matches {count} locations — add more context to make it unique, "
608
857
  f"or pass replace_all=true to replace all occurrences.",
609
858
  "ambiguous")
610
859
 
611
860
  if new_content == content:
612
861
  return finalize("c3_edit", {"file": file_path},
613
862
  f"{rel} unchanged — new_string equals old_string; "
614
- f"nothing was written or logged.", f"{rel} unchanged")
863
+ f"nothing was written or logged.", f"{rel} unchanged",
864
+ outcome=_NOOP, n_attempted=1, n_applied=0)
615
865
 
616
866
  occurrences = count if replace_all else 1
617
867
 
868
+ edit_blobs.keep_pre(svc.project_path, path, pre_image)
618
869
  try:
619
870
  _write_preserving_newlines(path, new_content)
620
871
  stale.written()
@@ -629,8 +880,6 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
629
880
  "old_string": old_string[:_DETAIL_CAP],
630
881
  "new_string": new_string[:_DETAIL_CAP],
631
882
  }
632
- if used_fallback:
633
- single_detail["unicode_normalized"] = True
634
883
  single_detail.update(
635
884
  edit_blobs.record_edit(svc.project_path, path, pre_image))
636
885
  deferred = _log_to_ledger(rel, auto_summary, tag_list, svc,
@@ -640,12 +889,13 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
640
889
  n_new = new_string.count("\n") + 1
641
890
  delta = f"-{n_old}+{n_new}L"
642
891
  occ = f" ({occurrences}x)" if occurrences > 1 else ""
643
- norm_tag = " [unicode-normalized]" if used_fallback else ""
644
- short = (f"✓ {rel} [{delta}]{occ}{norm_tag}" + (f" — {summary}" if summary else "")
645
- + where + _display_safe(
892
+ short = (f"✓ {rel} [{delta}]{occ}" + (f" — {summary}" if summary else "")
893
+ + where + _revert_note(svc.project_path, single_detail)
894
+ + _display_safe(
646
895
  _edit_report.diff_block(content, new_content, svc.project_path)))
647
896
  return finalize("c3_edit", {"file": file_path}, short + deferred,
648
- f"{rel} patched")
897
+ f"{rel} patched", n_attempted=1, n_applied=1,
898
+ blob=single_detail.get("blob"))
649
899
 
650
900
 
651
901
  _DETAIL_CAP = 2000 # chars stored per old/new string in the ledger
cli/tools/edit_verify.py CHANGED
@@ -244,12 +244,11 @@ def verify(file_path: str, old_string: str, new_string: str, edits: str,
244
244
  str(patch.get("new_string", "")), ledger, rel, limit)
245
245
  verdicts.append(r["verdict"])
246
246
  lines.append(f" patch[{i}]: {r['verdict']} — {r['why']}")
247
- # A batch writes once, so a mixed result means individual hunks did not
248
- # match — not that the file was left half-written. Say so, rather than
249
- # letting the caller infer a torn write that cannot happen.
250
- lines.append(" note: c3_edit's batch mode writes the file once after "
251
- "applying every hunk in memory, so a mixed result means "
252
- "some hunks did not match — not a partial write.")
247
+ # Say what a mixed result means, rather than letting the caller infer
248
+ # a torn write that cannot happen.
249
+ lines.append(" note: c3_edit applies a batch whole or not at all, so "
250
+ "a mixed result means the batch was refused or the file "
251
+ "changed afterwards — not a partial write.")
253
252
  counts = {v: verdicts.count(v) for v in sorted(set(verdicts))}
254
253
  summary = ", ".join(f"{n} {v.lower()}" for v, n in counts.items())
255
254
  return "\n".join(lines), f"{rel}: {summary}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.152.0
3
+ Version: 2.153.0
4
4
  Summary: Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub.
5
5
  Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,7 +1,7 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
2
  cli/_hook_utils.py,sha256=RhfS2WS5VMSi2w2Jxcts1wg5WC-UFp89JOQvcO5Jjb8,23002
3
3
  cli/_shell_writes.py,sha256=XzUNlUDljmg-wZxK03xHnRe36WeQxe4tpO9HKVFwP5o,5090
4
- cli/c3.py,sha256=RiJDVqJAqlnm1tjqM-ytMi8O3VPf-Lx2eAVQeNgqCxE,431810
4
+ cli/c3.py,sha256=CPRYdoG-9oXxk71V6ISYoMSjIFmq3FC9li8NxYS5ixY,431810
5
5
  cli/docs.html,sha256=pPxcP5ieY0DcfEDvegwIcRxbz1woLZoKLVSF-4JVy0Q,146784
6
6
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
7
7
  cli/hook_access_guard.py,sha256=tzK3pQBGehDDZi6fECcYk8nRhy0QkmUtTF2TuJG7nbY,27928
@@ -26,7 +26,7 @@ cli/hook_terse_advisor.py,sha256=W_zaTfX2akgX-ifuUTsLu2H1atM8XmWt6jpPoeU7Pm0,625
26
26
  cli/hub_server.py,sha256=VgDAlVmWgvh57KUbMgL2PT4303fmlr578QLk6ehe2pA,227863
27
27
  cli/hub_ui.html,sha256=v41R_JAH4Jwodpy3tbeBUel-80FbNmffBrey-QH5Q64,2963
28
28
  cli/mcp_proxy.py,sha256=r-Qhlie7gCtotex8yQUudCyBLRUK-IW6cNIW5RhBW0o,17648
29
- cli/mcp_server.py,sha256=0Gi0ajZ1ZUOJPlBhQxrGfxaVNsuXWUhPCActgltaGRk,74559
29
+ cli/mcp_server.py,sha256=VzzsJFckLdtwXYnX_CH-57eObOUP-IN-i5IIb8BWu8Y,74956
30
30
  cli/progress.py,sha256=qTkNy0howOD3hXraMo4ok3dYTpBW5yG_tenKfF4UDM0,1470
31
31
  cli/server.py,sha256=dJOOXYU1zmN-UBuMmHhv8XlnknSyO8xj7mXWSDAGIBY,173188
32
32
  cli/ui.html,sha256=uuPwIJNKDQH_fsaR3xNB2AKky9WzKfaXnKr_k3aTtxo,5715
@@ -47,8 +47,8 @@ cli/guide/masking.html,sha256=BcsTxlnpRD-fdJKBKn7naUfXPfNFnU0lM4mPi4GLOqs,20126
47
47
  cli/guide/oracle.html,sha256=yRNN9xH8KPIT72C1bl9ZHv8m0ENvaFMvreN-gbtXXoo,33352
48
48
  cli/guide/sessions.html,sha256=fBcbX61mvy2T5fX9Lkojb8Gw7n2RZ4Oo95EE_SC5Q6A,31707
49
49
  cli/guide/shared.css,sha256=Mmm7W6aYxrkDq2RPOcyXiQFYGR-yB3auwX1uIWi6C74,15967
50
- cli/guide/tools.html,sha256=xi84IbAarhTNN7c1jYqUTwwYOKxf7F8LkdaA1kU2Z_8,133740
51
- cli/guide/workflow.html,sha256=T_5HW4YOPng02vrxBWknz4LYSx2vC9Iogdwb81YlWqA,78604
50
+ cli/guide/tools.html,sha256=0XcaKfUZSt7oSHfjnhJcEAT8wtdGLO7w14LXZUnZfcA,134275
51
+ cli/guide/workflow.html,sha256=NTTVmP0Ru8Y4xoL6Zmvk__eUgnpUD3Egvw2uaNMojJY,78600
52
52
  cli/hub_ui/app.js,sha256=AWzlFht1Fmn4559KfooKC03CvoG2I6AXbYUEZ2w8ZfU,7362
53
53
  cli/hub_ui/state.js,sha256=OrIQ_kx_wUdyiyXlKmMKC8bbKzMPve1ZuFClRXOrDEw,7593
54
54
  cli/hub_ui/components/add_project.js,sha256=j5m0mX0gePcEYQW_0vodJkw5v8Vn2lQqPAI0miIGdv4,3182
@@ -80,7 +80,7 @@ cli/hub_ui/components/task_board.js,sha256=QiWKmj1NkhkloGlfzZj_tWd7Sgz3xSxwVLp8I
80
80
  cli/hub_ui/components/toasts.js,sha256=caj_x3npcM_wMv9E_xEde88jyfXI-jt8RrDQW_yXz5k,3376
81
81
  cli/hub_ui/components/topbar.js,sha256=xg8RqDmJpweISw55NFT07tMiwrFaG9fpDEHHblzkLk4,4817
82
82
  cli/tools/__init__.py,sha256=HO6eVKzDm1KPqsKTHY6lfIsnFnv3C7Bd9o3Q_V88idg,127
83
- cli/tools/_edit_report.py,sha256=3TIhPoBDpqeXfB_oheAC0PhHi4k6f_fjDcGhM3wJ-vM,7058
83
+ cli/tools/_edit_report.py,sha256=uZuhMQABN6z9R_FXusoYNNrdYrES3rhbADXmJupwc0c,8392
84
84
  cli/tools/_grants.py,sha256=xJwEZWC9r4exq0OuabQy-fFMZFayUYZe0Eub7NS3ikQ,4974
85
85
  cli/tools/_helpers.py,sha256=xhF6sSc9wcJjLQmFFipnUgxQusU1PDPcl0DGMGcgK6E,9420
86
86
  cli/tools/agent.py,sha256=RmEEh5HhCZLvYu0gSRcwA_NENpd4iIYJW3exBzT8kVs,47983
@@ -90,8 +90,8 @@ cli/tools/ci.py,sha256=1--Ak18ikHkO7q4HWlDCVac-U969eEFejkd-24T0xuY,20057
90
90
  cli/tools/compress.py,sha256=PcgxEw7KfofmJsTLfRf3mjWMxyNlqkcSNzMTlT27SW0,12534
91
91
  cli/tools/credentials.py,sha256=NczO0KT3LTJlD3ZedXWRAtH_8YTPhya0Txja3ystdzE,20039
92
92
  cli/tools/delegate.py,sha256=TrHPXEyufQh0in3Ay7XcOG5wQwCYbnbz9r4naIuhbN8,126990
93
- cli/tools/edit.py,sha256=vn1mOwSHmhoj48dnvi893c7Ou0hm4Jcu_wwj2gkt0Vg,33281
94
- cli/tools/edit_verify.py,sha256=lbuk9rLgOb3YnJmnG_eexGMPCAvduJmZbYxrIykVCqA,11972
93
+ cli/tools/edit.py,sha256=YPcrXfOvaUCiddg9qABFXqEk9kb9TNHMLFEo2Xq-sBQ,44715
94
+ cli/tools/edit_verify.py,sha256=3Y156LikO3Q4v0OSNjKDJUaOB3nRfceFvXCmpSV8amA,11858
95
95
  cli/tools/edits.py,sha256=Qkcl7Asa_qyIH2aIn5IYamfKavtfEZF429Bmg8TF0aY,10963
96
96
  cli/tools/federate.py,sha256=tE65p06gM6O_yzg08SKeficFCZUH0yOFhCHFUjlTRo0,5126
97
97
  cli/tools/filter.py,sha256=mwNabPe7bCvdsIGFsWc4E1HsfiRYx5ZevnxdsitJTAw,12702
@@ -132,7 +132,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
132
132
  cli/ui/components/settings.js,sha256=FXcn-UQE2mXC6HFsLeEas-wAUFKt0OJaLySroZQMhGA,84095
133
133
  cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
134
134
  cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
135
- code_context_control-2.152.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
135
+ code_context_control-2.153.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
136
136
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
137
137
  core/codex_patch.py,sha256=CxvT_rQIwPNTRJZj9EHJmx0ZdOeSLxjiixuLI3Z7lPc,2107
138
138
  core/config.py,sha256=wCjrZKXRywUbR6WRcO7optyOirAQ2DeRNdPCU0X5zIY,23762
@@ -194,7 +194,7 @@ services/access_telemetry.py,sha256=9pOCLS9LgDtM7Z4cCEyl7osxhj-m3wsPubcXolBKuV4,
194
194
  services/activity_log.py,sha256=c_jTOsR-d1d5B3u67eg8mRA7LZhtP6H1-6leeGa0Go4,7990
195
195
  services/agent_base.py,sha256=a-gdSd_jtZtbjXo1WS8CnWCagXgKaGZd5ShcG6s0kT4,4809
196
196
  services/agent_downshift.py,sha256=hGFg-knqa6NUqmZUijyTDV82Fz88ieh1YjN_mG5tz8U,8444
197
- services/agent_locks.py,sha256=nRAPuRSoet7BG9oOsNyA5WhlNy-UMhpsq6z5ggIPmLA,22421
197
+ services/agent_locks.py,sha256=1eCOiJ4UkpandhlgHqtO5DpwOjN7bGWYrEEE1puoFc0,23524
198
198
  services/agents.py,sha256=q3GgxyzomyjhbixARDQrSM3EpjIhboeKQY5jsQeHQ0g,83609
199
199
  services/agents_workflow.py,sha256=ttve887E6SIY2twHx_OxwFBEtLUcuok72_8g3ZkOAkE,502
200
200
  services/artifact_defs.py,sha256=4bJAcg2maJCtMhY3ub_IxZg49bVg1mD_B5Z4Bn5NF0o,9805
@@ -234,7 +234,7 @@ services/doc_index.py,sha256=cIb1jk4GrKK7cLwD6phoc04TvKA7Ee94kKHXxB7tT_k,19223
234
234
  services/e2e_benchmark.py,sha256=5Z3rE42o08MKVbIX_769V0RX2yAzSVcApCvdAQ9YLAU,134988
235
235
  services/e2e_evaluator.py,sha256=FsB4vMzcFUcmicfIwRtA8JZ_XeMpuk2_-86F2iVNEDc,17783
236
236
  services/e2e_tasks.py,sha256=Ln5VbGDIcS3NY3aml5ERbgjfjLxEslmRZ8AyaYxpWEo,34524
237
- services/edit_blobs.py,sha256=GnAeKFyxTduLaBSMtZJcjI1aRXZBpZqKNEDG37kQfQk,6041
237
+ services/edit_blobs.py,sha256=iPxHo7k_FMq8tsAKmEyb1e7yVlDZmt5_udmTYJPrlNA,6548
238
238
  services/edit_ledger.py,sha256=Tj8Q14o19VHQHpofRHdbZKKcVs17v-ctZ3LOeVTqvdQ,29362
239
239
  services/embedding_index.py,sha256=wqq7odpGzAxyO-lxr6N1WusyEGInGgEQUjuEw445Bxs,27711
240
240
  services/enforcement_policy.py,sha256=znjABRB-8TSl0ELazY2ALzux2YJ5nWf8TvxLRhDsL2w,18905
@@ -281,7 +281,7 @@ services/project_manager.py,sha256=7NvQau5x0PCVagQsXpuXZ4VtXCkCfy9es_v1oEHmzgs,4
281
281
  services/project_runtime.py,sha256=yyftX48BFjq4B2KcR4KGszDAAa4xR6gn6j0SgTyro1s,11603
282
282
  services/protocol.py,sha256=CWZ0BWWd-jPui-11hZlvGgkNk18gMFhhLWbp-BP35Kk,12861
283
283
  services/proxy_state.py,sha256=u5rd0k6CrOsywZA8FpRu_hMLwhR0TAJhZjy5MdWbCGc,6107
284
- services/read_stamps.py,sha256=k1O9OikG26uVAYDKju3K8k5DmowA3zyabnyOTFK_yK8,9557
284
+ services/read_stamps.py,sha256=CTAy350FJ079-mDBXFZXU5OXhUdPobGnYelkC28W65Q,10654
285
285
  services/repo_map.py,sha256=o-Z3c7ZqIxRKPyqquGafTngdooe1n-yRIcqpmOz5i2M,23208
286
286
  services/repo_shape.py,sha256=QF0mXkY-bnBU8Q8mc5iimw2YBhMgZ4IYhoPc09Z8OBQ,4978
287
287
  services/reranker.py,sha256=pnyNSzM7gXQ3ZodjY3rGIRCwpD2OIjookYkAJXb_LVE,4694
@@ -337,8 +337,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
337
337
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
338
338
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
339
339
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
340
- code_context_control-2.152.0.dist-info/METADATA,sha256=W6GTn1zrn0N3Jp7mvhGalkGBBDAwxImBNz2pJ5XNuEI,28902
341
- code_context_control-2.152.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
342
- code_context_control-2.152.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
343
- code_context_control-2.152.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
344
- code_context_control-2.152.0.dist-info/RECORD,,
340
+ code_context_control-2.153.0.dist-info/METADATA,sha256=bdkGNw00IveYo5nIYOYSVzPnK_1f3BwZsQhAWrYdpG4,28902
341
+ code_context_control-2.153.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
342
+ code_context_control-2.153.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
343
+ code_context_control-2.153.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
344
+ code_context_control-2.153.0.dist-info/RECORD,,
services/agent_locks.py CHANGED
@@ -282,6 +282,18 @@ class LockStore:
282
282
  return dict(row)
283
283
  return None
284
284
 
285
+ def held_by(self, path, session_id: str) -> bool:
286
+ """True when ``session_id`` has a live lease on ``path``."""
287
+ try:
288
+ rel = self._rel(path)
289
+ except UnsupportedPathError:
290
+ return False
291
+ now = self._clock()
292
+ return any(row.get("relpath") == rel
293
+ and row.get("expires_at", 0) > now
294
+ and row.get("session_id") == session_id
295
+ for row in self._load()["locks"])
296
+
285
297
  # -- mutations ---------------------------------------------------------
286
298
 
287
299
  def acquire(self, paths, *, agent_id: str, session_id: str,
@@ -507,6 +519,24 @@ def lease(path, project_path=".", session_id: str = "", intent: str = "") -> boo
507
519
  return False
508
520
 
509
521
 
522
+ def holds(path, project_path=".", session_id: str = "") -> bool:
523
+ """True when this session already has a live lease on ``path``. Never raises."""
524
+ try:
525
+ return store_for(project_path).held_by(path, session_id)
526
+ except Exception:
527
+ return False
528
+
529
+
530
+ def give_back(path, project_path=".", session_id: str = "") -> bool:
531
+ """Drop this session's lease on ``path``, for a c3_edit call that took one
532
+ and then wrote nothing. Never raises."""
533
+ try:
534
+ res = store_for(project_path).release([path], session_id=session_id)
535
+ return bool(res.get("count"))
536
+ except Exception:
537
+ return False
538
+
539
+
510
540
  def refusal(holder: dict, path) -> str:
511
541
  """The agent-facing refusal. The last line is load-bearing: without an
512
542
  explicit 'do not route around this', models reach for c3_shell with a
services/edit_blobs.py CHANGED
@@ -88,6 +88,11 @@ def get(project_path, sha: str) -> bytes | None:
88
88
  return data if sha256(data) == sha else None
89
89
 
90
90
 
91
+ def has(project_path, sha: str) -> bool:
92
+ """True when a blob for ``sha`` is in the store."""
93
+ return bool(sha) and _blob_path(project_path, sha).exists()
94
+
95
+
91
96
  def sweep(project_path) -> int:
92
97
  """Evict oldest blobs until the store is within 90% of its cap.
93
98
 
@@ -153,6 +158,17 @@ def _skip_reason(project_path, path, pre: bytes | None,
153
158
  return ""
154
159
 
155
160
 
161
+ def keep_pre(project_path, path, pre: bytes) -> None:
162
+ """Store the pre-image of a write that is about to happen, when policy
163
+ allows, so a crash after the write still leaves it. Never raises."""
164
+ if _skip_reason(project_path, path, pre, None):
165
+ return
166
+ try:
167
+ put(project_path, pre)
168
+ except OSError:
169
+ pass
170
+
171
+
156
172
  def record(project_path, path, pre: bytes | None,
157
173
  post: bytes | None) -> dict:
158
174
  """Ledger ``detail`` fields for a write that took ``path`` from ``pre`` to
services/read_stamps.py CHANGED
@@ -227,6 +227,33 @@ def _region_block(text: str, lo: int, hi: int, label: str) -> str:
227
227
  f"⟦L{lo}-L{hi}⟧\n{region}\n⟦end⟧\n")
228
228
 
229
229
 
230
+ def check_whole(path, label: str, raw: bytes) -> Guard:
231
+ """Compare `raw`, the bytes of `path` about to be replaced, to this
232
+ session's stamp.
233
+
234
+ Refuses when the session holds no stamp for the file, or when `raw`
235
+ differs from the stamp at all: every line is in the region being edited.
236
+ """
237
+ guard = Guard(path)
238
+ stamp = get(path)
239
+ if stamp is None:
240
+ guard.refusal = (
241
+ f"[c3_edit:unread] {label} has not been read in this session, and "
242
+ f"overwrite replaces all of it. Nothing was written.\n"
243
+ f" Read it with c3_read, then resend.")
244
+ return guard
245
+ if hashlib.sha256(raw).hexdigest() == stamp.sha256:
246
+ return guard
247
+ where = (f": {_fmt(changed_ranges(stamp.text, _decode(raw)))}"
248
+ if stamp.text is not None else "")
249
+ guard.refusal = (
250
+ f"[c3_edit:stale] {label} changed outside this session since your "
251
+ f"last read{where}.\n"
252
+ f" overwrite would discard that change. Nothing was written.\n"
253
+ f" Read the file again with c3_read, then resend.")
254
+ return guard
255
+
256
+
230
257
  def check(path, project_path, label: str, old_string: str, edits,
231
258
  replace_all: bool, norm=None) -> Guard:
232
259
  """Compare `path` to this session's stamp before an edit is applied.