code-context-control 2.85.0__py3-none-any.whl → 2.86.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
@@ -92,7 +92,7 @@ console = Console() if HAS_RICH else None
92
92
  # Config
93
93
  CONFIG_DIR = ".c3"
94
94
  CONFIG_FILE = ".c3/config.json"
95
- __version__ = "2.85.0"
95
+ __version__ = "2.86.0"
96
96
 
97
97
 
98
98
  def _compress_file_cli(compressor, path, mode="smart", **kw):
@@ -5535,6 +5535,14 @@ def cmd_install_mcp(args):
5535
5535
  except Exception as e:
5536
5536
  print(f"Warning: Could not update global CLAUDE.md: {e}")
5537
5537
 
5538
+ # ── Default Access Guard rules (first run only) ─────────────
5539
+ from services import access_guard
5540
+ seeded = access_guard.seed_default_global_rules()
5541
+ if seeded.get("seeded"):
5542
+ rules = ", ".join(seeded["rules"])
5543
+ print(f"Seeded default Access Guard deny rules into global scope: {rules}")
5544
+ print(" (remove with: c3 access remove <glob> --kind deny --global)")
5545
+
5538
5546
  # ── Install /terse skill for supported IDEs ──────────────────
5539
5547
  if profile.name in ("claude-code", "codex"):
5540
5548
  try:
@@ -6603,6 +6611,16 @@ def _override_audit(grant: dict, project_path: str) -> None:
6603
6611
 
6604
6612
  def cmd_access(args):
6605
6613
  """Access Guard rule management — human-only mutation surface (spec §1)."""
6614
+ from services import access_guard
6615
+
6616
+ # First `c3 access` touch seeds the removable defaults (spec §1). A
6617
+ # no-op forever after: any existing access section, even an emptied
6618
+ # one, means the user has taken over the scope.
6619
+ seeded = access_guard.seed_default_global_rules()
6620
+ if seeded.get("seeded"):
6621
+ print(f"[seeded] default global deny rules: {', '.join(seeded['rules'])}")
6622
+ print(" Remove any of them: c3 access remove <glob> --kind deny --global\n")
6623
+
6606
6624
  sub = getattr(args, "access_cmd", None)
6607
6625
  if not sub:
6608
6626
  print("Usage: c3 access {list,add,remove,check,stats,mask,builtin} [args]")
cli/hook_access_guard.py CHANGED
@@ -41,7 +41,24 @@ _MAX_TOKENS = 200
41
41
  # "NTFS alternate data stream". The ADS check stays strict for real path
42
42
  # arguments (_WRITE_TOOLS/_READ_TOOLS); here it only produced false denies on
43
43
  # commit messages, curl, and gh (#50).
44
- _SCHEME_RE = re.compile(r"^[a-z][a-z0-9+.\-]*://", re.IGNORECASE)
44
+ #
45
+ # UNANCHORED, and that is the fix rather than an oversight. `^…://` only ever
46
+ # recognised a token that BEGINS with a scheme, which is the same
47
+ # whitelist-a-shape mistake #50 made and this module's own header calls out —
48
+ # and the `_SYNTAX_CHARS` generalisation does not cover the gap, because a
49
+ # token can carry a URL and no syntax at all. Observed 2026-08-11: the single
50
+ # argument of
51
+ # printf '\n---\n\nhttps://claude.ai/code/session_01Hs28…\n' >> body.md
52
+ # strips to `\n---\n\nhttps://claude.ai/…`, which has a separator (`\` and
53
+ # `/`), no syntax characters, no `::`, and no leading scheme. It reached
54
+ # ag.check, canonicalised to a residual colon, and hard-denied a command whose
55
+ # only file argument was an ordinary markdown path. Third logged instance of
56
+ # this class in five days (two on 2026-08-07 were Windows paths inside Python
57
+ # string literals).
58
+ #
59
+ # A token containing `://` anywhere is a network literal, not an NTFS stream:
60
+ # a real stream is `file:name:$TYPE`, and no spelling of it contains `//`.
61
+ _SCHEME_RE = re.compile(r"[a-z][a-z0-9+.\-]*://", re.IGNORECASE)
45
62
  # IPv6 literal or CIDR: >=2 colons, hex groups only, optional [] and /prefix.
46
63
  # 'C:/x/f.txt:stream' has one colon and non-hex parts, so it never matches.
47
64
  _IPV6_RE = re.compile(
@@ -104,8 +121,13 @@ _CD_RE = re.compile(
104
121
 
105
122
 
106
123
  def _is_network_token(tok: str) -> bool:
107
- """True for URLs and IPv6 literals/CIDRs — never for an ADS spelling."""
108
- return bool(_SCHEME_RE.match(tok) or _IPV6_RE.match(tok))
124
+ """True for URLs and IPv6 literals/CIDRs — never for an ADS spelling.
125
+
126
+ ``search`` for the scheme (a URL anywhere in the token disqualifies it as
127
+ a path), ``match`` for IPv6 — that pattern is a whole-token shape and
128
+ anchoring is what keeps `C:/x/f.txt:stream` from matching it.
129
+ """
130
+ return bool(_SCHEME_RE.search(tok) or _IPV6_RE.match(tok))
109
131
 
110
132
 
111
133
  def _is_scope_token(tok: str) -> bool:
cli/hub_server.py CHANGED
@@ -2740,6 +2740,20 @@ def api_pm_milestone():
2740
2740
  return jsonify({"error": "id is required"}), 400
2741
2741
 
2742
2742
  if request.method == "PUT":
2743
+ # {complete: true} / {reopen: true} switch lifecycle; tasks keep
2744
+ # their milestone link (unlike DELETE = archive + detach).
2745
+ if data.get("complete"):
2746
+ res = store.complete_milestone(ms_id)
2747
+ if "error" in res:
2748
+ return jsonify(res), 400
2749
+ _pm_audit(resolved, "milestone", "complete", res["id"])
2750
+ return jsonify({"completed": True, "milestone": res})
2751
+ if data.get("reopen"):
2752
+ res = store.reopen_milestone(ms_id)
2753
+ if "error" in res:
2754
+ return jsonify(res), 400
2755
+ _pm_audit(resolved, "milestone", "reopen", res["id"])
2756
+ return jsonify({"reopened": True, "milestone": res})
2743
2757
  res = store.update_milestone(ms_id, expected_rev=data.get("expected_rev"),
2744
2758
  **(data.get("fields") or {}))
2745
2759
  if "error" in res:
cli/mcp_server.py CHANGED
@@ -1105,7 +1105,9 @@ async def c3_task(
1105
1105
  Time: time_add (minutes 1-1440 [+note/due_date=date/task_id]), time_update / time_delete (ref=entry id),
1106
1106
  time_list (manual entries + recent auto sessions), time_summary (today/7d/30d, auto vs manual).
1107
1107
  Auto-tracking: server startup + tool calls ping .c3/time; idle gaps >15min close a session.
1108
- Milestones: milestone_add (name [+target_date]), milestone_update, milestone_list (with progress %), milestone_archive.
1108
+ Milestones: milestone_add (name [+target_date]), milestone_update, milestone_list (with progress %),
1109
+ milestone_complete (close a shipped milestone; refuses while open tasks remain; tasks KEEP their link),
1110
+ milestone_reopen (undo complete), milestone_archive (removal: detaches tasks).
1109
1111
  Notes: note_add (note [+kind=decision] [+task_id]), note_list.
1110
1112
  History: history ([+task_id] [+limit]) — append-only event log (who/what/when, before->after), newest first.
1111
1113
  task_id accepts any unique id prefix (>=4 chars); milestone accepts id or unique name.
cli/server.py CHANGED
@@ -1295,6 +1295,20 @@ def api_pm_milestone():
1295
1295
  if not ms_id:
1296
1296
  return jsonify({"error": "id is required"}), 400
1297
1297
  if request.method == "PUT":
1298
+ # {complete: true} / {reopen: true} switch lifecycle; tasks keep
1299
+ # their milestone link (unlike DELETE = archive + detach).
1300
+ if data.get("complete"):
1301
+ res = store.complete_milestone(ms_id)
1302
+ if "error" in res:
1303
+ return jsonify(res), 400
1304
+ _pm_audit("milestone", "complete", res["id"])
1305
+ return jsonify({"completed": True, "milestone": res})
1306
+ if data.get("reopen"):
1307
+ res = store.reopen_milestone(ms_id)
1308
+ if "error" in res:
1309
+ return jsonify(res), 400
1310
+ _pm_audit("milestone", "reopen", res["id"])
1311
+ return jsonify({"reopened": True, "milestone": res})
1298
1312
  res = store.update_milestone(ms_id, expected_rev=data.get("expected_rev"),
1299
1313
  **(data.get("fields") or {}))
1300
1314
  if "error" in res:
cli/tools/shell.py CHANGED
@@ -69,6 +69,45 @@ _SOFT_WARN = re.compile(
69
69
 
70
70
  _DEFAULT_TIMEOUT = 60
71
71
  _MAX_TIMEOUT = 600
72
+
73
+ # The transport's own ceiling, and the reason _MAX_TIMEOUT alone is a lie.
74
+ #
75
+ # An MCP client kills a tool call at MCP_TOOL_TIMEOUT (Claude Code ships
76
+ # 120_000 ms and the value is set per-environment) — a limit this process does
77
+ # not choose and cannot raise. Until this existed, `c3_shell(timeout=600)` was
78
+ # accepted, clamped to 600, and then killed by the client at 120s with the
79
+ # subprocess still running: the caller saw the call "moved to background" and
80
+ # then fail, with no line anywhere naming the real limit or attributing the
81
+ # kill. The subprocess was NOT reaped by us, because our own deadline never
82
+ # arrived.
83
+ #
84
+ # The cost was not hypothetical. Two Higgsfield image generations (2-4 min
85
+ # each) were requested at timeout=420, killed at 120s, and the remote jobs
86
+ # completed and were BILLED while the local process died before downloading —
87
+ # one image landed, one was paid for and stranded.
88
+ #
89
+ # So: discover the client's ceiling, run just inside it, and SAY SO. Running
90
+ # inside it means OUR deadline fires first, which turns a phantom
91
+ # client-side kill into an ordinary `[c3_shell:TIMEOUT]` with stdout, a
92
+ # duration, and a process tree we actually killed.
93
+ _TRANSPORT_MARGIN_S = 5
94
+
95
+
96
+ def _transport_ceiling_s() -> int | None:
97
+ """Seconds this call may run before the MCP client kills it, if knowable.
98
+
99
+ None when the variable is unset or unparseable — then nothing is capped
100
+ and behaviour is exactly what it was, because a guess here would cap
101
+ legitimate work on a client with no such limit.
102
+ """
103
+ raw = (os.environ.get("MCP_TOOL_TIMEOUT") or "").strip()
104
+ if not raw:
105
+ return None
106
+ try:
107
+ ceiling = int(float(raw)) // 1000
108
+ except (TypeError, ValueError):
109
+ return None
110
+ return ceiling if ceiling > _TRANSPORT_MARGIN_S else None
72
111
  _FILTER_THRESHOLD_LINES = 30
73
112
  _JQ_INVOCATION = re.compile(r"(?:^|&&|\|\||[;|(])\s*jq(?:\s|$)", re.IGNORECASE)
74
113
 
@@ -468,6 +507,13 @@ async def handle_shell(cmd: str, cwd: str, timeout: int, filter_output: bool,
468
507
  )
469
508
 
470
509
  timeout = max(1, min(int(timeout or _DEFAULT_TIMEOUT), _MAX_TIMEOUT))
510
+ # Cap to what the transport will actually allow, and remember that we did
511
+ # so — the caller asked for a number, and a number quietly not honoured is
512
+ # the whole defect (see _transport_ceiling_s).
513
+ capped_from = 0
514
+ _ceiling = _transport_ceiling_s()
515
+ if _ceiling is not None and timeout > _ceiling - _TRANSPORT_MARGIN_S:
516
+ capped_from, timeout = timeout, _ceiling - _TRANSPORT_MARGIN_S
471
517
  work_cwd = cwd or svc.project_path
472
518
  work_cwd = str(Path(work_cwd).resolve())
473
519
 
@@ -601,6 +647,16 @@ async def handle_shell(cmd: str, cwd: str, timeout: int, filter_output: bool,
601
647
  warn = ""
602
648
  if _SOFT_WARN.search(cmd):
603
649
  warn = "[c3_shell:warn] destructive pattern detected — verify before re-running\n"
650
+ if capped_from:
651
+ # Named at the moment of the mistake, not in documentation nobody
652
+ # reads at the moment they need it. The alternative IS the escape
653
+ # hatch, so the line says which one.
654
+ warn += (
655
+ f"[c3_shell:capped] timeout={capped_from}s was requested but this "
656
+ f"MCP client kills a tool call at {_ceiling}s; ran with {timeout}s. "
657
+ f"For longer work use the native Bash tool with "
658
+ f"run_in_background, which is not bound by this limit.\n"
659
+ )
604
660
 
605
661
  if result["timed_out"]:
606
662
  status = "TIMEOUT"
cli/tools/tasks.py CHANGED
@@ -5,7 +5,8 @@ READ_ACTIONS = {"list", "get", "board", "history", "report",
5
5
 
6
6
  _TASK_ACTIONS = ("add, update, done, list, get, board, history, report, archive, "
7
7
  "block, unblock, link, unlink, "
8
- "milestone_add, milestone_update, milestone_list, milestone_archive, "
8
+ "milestone_add, milestone_update, milestone_list, "
9
+ "milestone_complete, milestone_reopen, milestone_archive, "
9
10
  "note_add, note_list, "
10
11
  "time_add, time_update, time_delete, time_list, time_summary")
11
12
 
@@ -368,6 +369,26 @@ def handle_task(action, svc, finalize, *, title="", task_id="", status="",
368
369
  return done_resp(f"[task:error] {res['error']}", "error")
369
370
  return done_resp(f"[milestone:updated] {res['id'][:8]} \"{res['name']}\"", "updated")
370
371
 
372
+ if action == "milestone_complete":
373
+ if not milestone:
374
+ return done_resp("[task:error] milestone_complete requires milestone.", "error")
375
+ ms = store.resolve_milestone(milestone)
376
+ if ms is None:
377
+ return done_resp(f"[task:error] no milestone matches: {milestone}", "error")
378
+ res = store.complete_milestone(ms["id"])
379
+ if "error" in res:
380
+ return done_resp(f"[task:error] {res['error']}", "error")
381
+ return done_resp(f"[milestone:completed] \"{res['name']}\" "
382
+ f"(tasks keep their milestone link)", "completed")
383
+
384
+ if action == "milestone_reopen":
385
+ if not milestone:
386
+ return done_resp("[task:error] milestone_reopen requires milestone.", "error")
387
+ res = store.reopen_milestone(milestone)
388
+ if "error" in res:
389
+ return done_resp(f"[task:error] {res['error']}", "error")
390
+ return done_resp(f"[milestone:reopened] \"{res['name']}\"", "reopened")
391
+
371
392
  if action == "milestone_archive":
372
393
  if not milestone:
373
394
  return done_resp("[task:error] milestone_archive requires milestone.", "error")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.85.0
3
+ Version: 2.86.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
@@ -178,7 +178,7 @@ Twelve tabs. The dashboard above shows token savings, indexed files, the live se
178
178
  <img src="https://raw.githubusercontent.com/drknowhow/code-context-control/main/docs/screenshots/2026-07/ui_tasks.png" alt="C3 Tasks" width="900">
179
179
  </p>
180
180
 
181
- **Tasks** (v2.45.0, extended v2.53.0) is a durable per-project tracker — dependencies and subtasks, milestones, decision notes, full event history, health reports, and both automatic and manual time tracking. It rolls up into the Hub's cross-project board.
181
+ **Tasks** (v2.45.0, extended v2.53.0) is a durable per-project tracker — dependencies and subtasks, milestones, decision notes, full event history, health reports, and both automatic and manual time tracking. It rolls up into the Hub's cross-project board. Shipped milestones can be **completed** (v2.86.0) — they leave the board while their tasks keep the milestone link, unlike archiving, which detaches them.
182
182
 
183
183
  **Instructions** keeps your agent-facing docs in sync. C3-generated content sits inside a `<!-- C3:BEGIN … -->` block; anything you write outside it is preserved. Since v2.60.0 generated docs point at `.c3/MAP.md` — a machine-owned, byte-stable repo map C3 refreshes automatically — instead of embedding a tree that goes stale. `AGENTS.md` serves both Codex and Antigravity; `GEMINI.md` is read if present but no longer generated (the Gemini CLI profile was removed in v2.52).
184
184
 
@@ -243,6 +243,7 @@ c3 access mask activate # purge pre-mask artifacts, buil
243
243
  - **`deny` means deny-enumerate too** — denied paths never appear in search results, maps, or the vector index.
244
244
  - **Masked means read-only, always.** Cropped rows have no inverse, so an edit in transformed coordinates would corrupt the one file you protected.
245
245
  - **Four deterministic presets, no LLM in the read path:** `redact_secrets`, `redact_columns` (salted one-way pseudonyms — joins survive, no reverse dictionary), `sample_rows`, `signatures_only`.
246
+ - **Sensible defaults out of the box** (v2.86.0). A fresh install seeds `*.pem`, `id_rsa*`, and `*.key` as deny rules in global scope — visible in `c3 access list` and removable like any rule you wrote yourself. Delete one and it stays deleted.
246
247
  - **Rule changes are human-only** (UI tab or CLI) and ledger-logged. Agents have no mutation surface.
247
248
  - **Built-ins can be switched off when you need to** (v2.65.0). `**/.env*`, `**/.c3/**`, `**/.claude/settings*.json` and `**/.git/**` yield to `c3 access builtin disable <glob>`, which makes you retype the glob first. It takes **two keys** — a config entry *and* a keyring attestation — so an agent that manages to write `config.json` still cannot grant itself write access to your settings. The credential vault stays absolute.
248
249
  - **Honest coverage.** This guards *cooperative* agents against mistakes and prompt injection. It is not a sandbox — a raw shell outside C3's tools still sees the real bytes.
@@ -1,9 +1,9 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
2
  cli/_hook_utils.py,sha256=bx8246vdJQ0eCsmDSx2nrSw19t0MI1tde76SZr0uuCo,19168
3
- cli/c3.py,sha256=cdpKnoQymVSrAgNs9I9VoGb5mvonS3rbWEUVOhsW2hE,373267
3
+ cli/c3.py,sha256=AmXOVzrxuyHTY_aO4u9TF6-yQHgLN5UZfXAWR-7P6n8,374181
4
4
  cli/docs.html,sha256=X9M7qC7LWi8pPJNF-4yEM57jhuZ3WeTESECbdDOeNaA,143172
5
5
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
6
- cli/hook_access_guard.py,sha256=UGXZ6-3gVNxjVasAuCV9eUOBwqDHl3YnuDc3XVTpvL0,15942
6
+ cli/hook_access_guard.py,sha256=4SIX-A4-tlXcOpzV4k5PZvyXibRvw9buqAV1-_doJpg,17200
7
7
  cli/hook_artifact.py,sha256=Se1CNBfoBFyvJQlRmYdNtdRXIkQp9zaF0O6ndRMo7ts,2198
8
8
  cli/hook_auto_snapshot.py,sha256=yGFqRANtw8qNqRNr-oCgpuyaUyTkSzIOFxXt5kauq4I,5271
9
9
  cli/hook_c3_signal.py,sha256=FPvs-9nD9ZiKA8r2tmusgj79yT9w9F173cRDKn-PYeI,2149
@@ -18,12 +18,12 @@ cli/hook_prompt_recall.py,sha256=LHyubu36pf5h3hbLBe0yH-yGhAlwLJdsc7hfa7WB1h4,347
18
18
  cli/hook_read.py,sha256=ZWZldgqGSNz78COhlgETQ7yxdOW5vWIIHUet9gZKhxk,8176
19
19
  cli/hook_session_stats.py,sha256=hZiX0r1SLOstu1PXvygWae0cIqHCcC3mb0SbFL6nCic,2083
20
20
  cli/hook_terse_advisor.py,sha256=W_zaTfX2akgX-ifuUTsLu2H1atM8XmWt6jpPoeU7Pm0,6252
21
- cli/hub_server.py,sha256=Wqq6LenxvMLnJ93NX64Yx6UJcOFGyJngUq2IXoA-5Uw,145557
21
+ cli/hub_server.py,sha256=x8MABHxO3baScsuh9Tgyb_H_vE441RADkWacCce3j2I,146268
22
22
  cli/hub_ui.html,sha256=nnGqWhWGCLfNisGUnoAlQFcBr6wPNsq-AEOR7uFtVkE,2942
23
23
  cli/mcp_proxy.py,sha256=92htuT-p0j-cDTbyqlIJpGoQ85_Aw7UuB8L_Toi_u20,17511
24
- cli/mcp_server.py,sha256=7XenAdF-gKbP4KlHEzTy8PpWpRhRTR_F_nKXxpNeZjU,52424
24
+ cli/mcp_server.py,sha256=FMpA1uE_nGexhpiUFms6jQH2FzZDk74bPQ4TuwxOH3E,52600
25
25
  cli/progress.py,sha256=qTkNy0howOD3hXraMo4ok3dYTpBW5yG_tenKfF4UDM0,1470
26
- cli/server.py,sha256=xJ0bGyY5V7hSyNl6wZycwJYt8ug5N_kbsxOihXvHlS0,165450
26
+ cli/server.py,sha256=QiK20rocUC9vn0fF8J5WuqECocwKuQAzQKAYRDdrq_k,166141
27
27
  cli/ui.html,sha256=xcdt74nlFEXx-0Bx6-Okw-WSVZPAXL0iukxU0ytI6CA,5694
28
28
  cli/ui_legacy.html,sha256=cI8tC6RKmE2NIJOcsu7CY-zT4VznjcbD6NTjxb_fvUY,378460
29
29
  cli/ui_nano.html,sha256=UAwQ6bbTOXAoGq191AZ7slhngR9edJSa3IhqpynveDg,27740
@@ -90,9 +90,9 @@ cli/tools/project.py,sha256=-i6vf8v7AB2pMdguL-OhyaSvSyW36a3Izxw05dfMt2Q,20697
90
90
  cli/tools/read.py,sha256=sqTzn6FDaTQ3TFyP3J8fg61WemOsH-2oLIt19hmZXlI,16091
91
91
  cli/tools/search.py,sha256=cW0h5_yK7b1s3aqFKk3Z7nz4GSDhuJYDHdSXUH9uli0,17091
92
92
  cli/tools/session.py,sha256=ailQiRqXhi9NcBB3XS3Dv72GEkEZaYivB0bqDlhmgTA,5202
93
- cli/tools/shell.py,sha256=ZjBN_iID6oGSzjaZ19IAlFik4DP8M0JkD3nh4r0pRdE,26283
93
+ cli/tools/shell.py,sha256=B5qq1LIRg8sHiaPwd5aFHorOAfTYR1Fzj4iqoh4YIUI,29033
94
94
  cli/tools/status.py,sha256=TRbiHVaDcrH96m5p1dMtbBlwGxTax3DcUih4dLs9CEw,16158
95
- cli/tools/tasks.py,sha256=kS0K73c6D5aaKGtarGI8XAYUjzPWBmIQqlsGGg5meJ4,20697
95
+ cli/tools/tasks.py,sha256=MFOVHbzQAH4pd7rMTmUFiAlaVaTZAlEUxXL9Fyk4oVI,21745
96
96
  cli/tools/validate.py,sha256=Y7-jMfelMeENJ3GExvC0qshfFmD6PE3aTfOJg8V_qtg,13847
97
97
  cli/ui/api.js,sha256=wSviW2AgkSUwLjjQCcgsLHuRYhE56Mkcr2q2xHNq08s,1815
98
98
  cli/ui/app.js,sha256=8QwyCxT_6nNqiFibKESA9vgW_s2bVWWKeHU4fFm-Szg,10169
@@ -114,7 +114,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
114
114
  cli/ui/components/settings.js,sha256=ATbAjBlVIwCNpxq7s191b49a_INQV38iwmySqtJLYwY,79066
115
115
  cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
116
116
  cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
117
- code_context_control-2.85.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
117
+ code_context_control-2.86.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
118
118
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
119
119
  core/config.py,sha256=YmkcZwedz_lfDM0ZuI_f4xpe98ixPLcSQFcTCHgRiRg,19206
120
120
  core/ide.py,sha256=V6VVMVsFdmmcsMyxikjQp7z9xa42CWiHKS-ya-MAcG4,6172
@@ -140,7 +140,7 @@ oracle/services/insight_engine.py,sha256=lwcdXtjt3CSZLqHVpXx6SG5gDhZYUKwXt4Z8dGy
140
140
  oracle/services/local_session.py,sha256=51iKUPTM2C4kuk-rnHpjvuc688ofD6AzjTMrd_RYw6E,5263
141
141
  oracle/services/memory_reader.py,sha256=rSEmoIXQu2JkolHJIQWpn0piPJpyuf1KZvW_9liQwkU,3727
142
142
  oracle/services/memory_writer.py,sha256=hpvYsxjhl293jEjGcRKfjG5njUaAOXKqbmJaUu1WdUU,6997
143
- oracle/services/mobile_api.py,sha256=HLWGpsNT-U_esNynVvE-mG1VRJXqwkoaBAJx07JGfwc,115294
143
+ oracle/services/mobile_api.py,sha256=lKx4X9siLsM0CLHtEw0aKa36hCo-5f7gGvBLRWZvHsU,115999
144
144
  oracle/services/project_scanner.py,sha256=gHpXihRZSu2gXCp4vvOWp5gMAsmfINE9ESiqVF1uV3A,5587
145
145
  oracle/services/review_agent.py,sha256=m_fzoHowpiibf0_iz3dF72LWSrwB_FbDd1am-SbeWXg,11273
146
146
  oracle/services/tool_executor.py,sha256=xtAkBWkclh_FwOgzprjGkyRIV8-A7Lc3bz0UFPShrv0,1113
@@ -165,7 +165,7 @@ oracle/ui/chat/send.js,sha256=k2qGN_asb7FTjcLa204Zjq4X_QWHIKHQnI33IEuxvaQ,11558
165
165
  oracle/ui/chat/stream_renderer.js,sha256=RZndI2mu5xbzZF-4NG9jeOqXTcmIqfZqxgkrBvTzWmk,29861
166
166
  oracle/ui/chat/toolbar.js,sha256=ZDexIIpGwsQ6XsY4Tyo082rJoofdD_JBTV97-mLQUVM,12027
167
167
  services/__init__.py,sha256=3Kn4cZweLm7at8wFdBdZ-Zwo8hHcnVIsmY5f29nzi2Y,116
168
- services/access_guard.py,sha256=RKvbW12aRLUwkPPssxmLWpSk7_z4DDeB8S7g1BNg2hc,52947
168
+ services/access_guard.py,sha256=rPw_qOMBIwG0wfNYY17XrmiXAIdt5ZYtVDwJfQDZpNU,54146
169
169
  services/access_telemetry.py,sha256=9pOCLS9LgDtM7Z4cCEyl7osxhj-m3wsPubcXolBKuV4,9568
170
170
  services/activity_log.py,sha256=PNGeEYlw1Aux-mRU4pKCQdbsSuzLvOg7MYiq-cb4n8g,4473
171
171
  services/agent_base.py,sha256=a-gdSd_jtZtbjXo1WS8CnWCagXgKaGZd5ShcG6s0kT4,4809
@@ -239,12 +239,12 @@ services/retention.py,sha256=I2_RV233kWBBXox5rc_w-1h1aPua93o9huuPf-pJVuE,18629
239
239
  services/retrieval_broker.py,sha256=9X67VZ_6AkbAzopHuuMFKmP4CGZLnW576kjSKMenBnw,5261
240
240
  services/router.py,sha256=Cz10nx2fKTbaGn14mSBePWIDrw5rdcs_1JFYXeik084,15626
241
241
  services/runtime.py,sha256=b79gN0dFlzBO4rPaUMWier8FZFamMZlwIM3aywLmbB4,12840
242
- services/scanner.py,sha256=LsQqogv2FzXGafF6Dnm0Lh7Llkn0Al0zmcA54aTwi6Y,7838
242
+ services/scanner.py,sha256=-DL8BQ4ncivJec7E7k8Hz_s6H3CfRSRs1GTls66kQ-M,9542
243
243
  services/session_benchmark.py,sha256=GX3H8OwKC0X9Kk5U-vfY6Y7qq9Qaz2HwadNA7mjO8RY,105047
244
244
  services/session_manager.py,sha256=nSZcemk34U4yxn3AvKnOuYrNfiIDRgE3s7FXTNYKG0A,52617
245
245
  services/session_preloader.py,sha256=DsTAXMKVtrX9yu1sEFojYDi9-jkSAj1Ylt9JTy57Dow,9883
246
246
  services/subprojects.py,sha256=KNA-qGVPsd7Tg92Pt_kLWMLyER2YHmdQWO44sOelTIg,25123
247
- services/task_store.py,sha256=IHNeLipdyWtbWYu2-3ItIDeSDI9WoMKj6YwPPc1TgW0,46487
247
+ services/task_store.py,sha256=QoyhLcAyd-JZxn1nMfnZ11tTLgFqY-xwmlx1q8w_1p8,49477
248
248
  services/telemetry.py,sha256=KbWCe7F8lGY9DufVPWGyGFo-IBcPgWh0kd3ySJnIgFE,11377
249
249
  services/text_index.py,sha256=r3o4CobTG9jAO9PWazgbWYLY9oi_FgEJ3xwEXrF4KM0,2783
250
250
  services/time_tracker.py,sha256=sZNz8QAYpWhCBwfmvopVyxpMb8H-CrBT25C0_VOT1LY,11056
@@ -276,8 +276,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
276
276
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
277
277
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
278
278
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
279
- code_context_control-2.85.0.dist-info/METADATA,sha256=kiLaxzH8a0HhMA8YE8uUjQF3Jxb1ARn6-cN6FlR9ofQ,25825
280
- code_context_control-2.85.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
281
- code_context_control-2.85.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
282
- code_context_control-2.85.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
283
- code_context_control-2.85.0.dist-info/RECORD,,
279
+ code_context_control-2.86.0.dist-info/METADATA,sha256=g5lfHV5MFY4KFu0PUiQD3R3zvze-_oEbChXgX8zQdfU,26229
280
+ code_context_control-2.86.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
281
+ code_context_control-2.86.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
282
+ code_context_control-2.86.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
283
+ code_context_control-2.86.0.dist-info/RECORD,,
@@ -811,6 +811,20 @@ def mobile_pm_milestone():
811
811
  return jsonify({"error": "id is required"}), 400
812
812
 
813
813
  if request.method == "PUT":
814
+ # {complete: true} / {reopen: true} switch lifecycle; tasks keep
815
+ # their milestone link (unlike DELETE = archive + detach).
816
+ if data.get("complete"):
817
+ res = store.complete_milestone(ms_id)
818
+ if "error" in res:
819
+ return _pm_error(res)
820
+ _pm_audit(resolved, "milestone", "complete", res["id"])
821
+ return jsonify({"completed": True, "milestone": res})
822
+ if data.get("reopen"):
823
+ res = store.reopen_milestone(ms_id)
824
+ if "error" in res:
825
+ return _pm_error(res)
826
+ _pm_audit(resolved, "milestone", "reopen", res["id"])
827
+ return jsonify({"reopened": True, "milestone": res})
814
828
  res = store.update_milestone(ms_id, expected_rev=data.get("expected_rev"),
815
829
  **(data.get("fields") or {}))
816
830
  if "error" in res:
services/access_guard.py CHANGED
@@ -1087,6 +1087,33 @@ def _write_scope_config(cfg: Path, data: dict) -> None:
1087
1087
  os.replace(tmp, cfg)
1088
1088
 
1089
1089
 
1090
+ def seed_default_global_rules() -> dict:
1091
+ """Seed DEFAULT_GLOBAL_RULES as deny rules in GLOBAL scope, once ever.
1092
+
1093
+ Spec §1: the defaults ship as visible, removable rules — so they are
1094
+ written to ``~/.c3/config.json`` only when that file has never carried
1095
+ an ``access`` section. Any existing section (even an emptied one) means
1096
+ the user has touched the scope and removal stays sticky. A corrupt or
1097
+ unreadable config is never rewritten. Never raises: install and CLI
1098
+ call this on every run and must not break on a bad home dir.
1099
+
1100
+ Returns {"seeded": bool, "rules": [...]} — ``rules`` only when seeded.
1101
+ """
1102
+ try:
1103
+ cfg = _scope_config_path("global")
1104
+ data = {}
1105
+ if cfg.is_file():
1106
+ data = json.loads(cfg.read_text(encoding="utf-8")) or {}
1107
+ if not isinstance(data, dict) or "access" in data:
1108
+ return {"seeded": False}
1109
+ data["access"] = {_KIND_DENY: [_norm_glob(g)
1110
+ for g in DEFAULT_GLOBAL_RULES]}
1111
+ _write_scope_config(cfg, data)
1112
+ return {"seeded": True, "rules": list(DEFAULT_GLOBAL_RULES)}
1113
+ except Exception:
1114
+ return {"seeded": False}
1115
+
1116
+
1090
1117
  def set_rule(glob, kind: str, scope: str, project_path: str = ".") -> dict:
1091
1118
  """Add one rule to *scope*'s config. Human surfaces only; callers log.
1092
1119
 
services/scanner.py CHANGED
@@ -65,6 +65,39 @@ def gitignore_dir_names(root) -> set:
65
65
  return gitignore_dir_patterns(root)[0]
66
66
 
67
67
 
68
+ def is_nested_checkout(path) -> bool:
69
+ """True when ``path`` is the root of a DIFFERENT checkout than ours.
70
+
71
+ ``SKIP_DIRS`` cannot express this, because it matches directory NAMES and
72
+ a nested checkout is named whatever somebody called it. A linked git
73
+ worktree is the case that matters: its marker is a ``.git`` FILE holding
74
+ ``gitdir: …`` rather than a ``.git`` directory, so pruning the name
75
+ ``.git`` skips a file that was never a directory and descends into the
76
+ entire copy of the repository sitting beside it.
77
+
78
+ Measured on the Yep project, 2026-08-11 — 112 registered worktrees, most
79
+ of them agent scratch under ``.claude/worktrees/``:
80
+
81
+ .py files in the real tree 1,108
82
+ .py files under worktree dirs 100,629 (91x amplification)
83
+
84
+ A cross-project exact search there took 147.6s, against 0.11s to build
85
+ the runtime. The MCP transport kills a tool call at 120s, so the search
86
+ could never return — not slow, unreachable, and the reason
87
+ ``c3_project`` was unusable against that project at all.
88
+
89
+ Indexing them is also wrong on the merits, independent of cost: they are
90
+ other commits of files this project already has, so every hit is
91
+ duplicated ~112 times and the top result is whichever stale copy sorted
92
+ first. The search that exposed this returned a hit inside
93
+ ``.claude/worktrees/bridge-cse_01MPoh…`` rather than from src/.
94
+ """
95
+ try:
96
+ return os.path.exists(os.path.join(str(path), '.git'))
97
+ except (OSError, ValueError):
98
+ return False
99
+
100
+
68
101
  def make_dir_pruner(root, extra_skip=(), respect_gitignore: bool = True):
69
102
  """Predicate ``dirname -> bool`` (True = prune this directory).
70
103
 
@@ -177,6 +210,8 @@ def iter_files(
177
210
  continue
178
211
  if guard_excluded(os.path.join(dirpath, d)):
179
212
  continue # denied subtree — never descended, never listed
213
+ if is_nested_checkout(os.path.join(dirpath, d)):
214
+ continue # another checkout's copy of files we already index
180
215
  kept.append(d)
181
216
  dirnames[:] = kept
182
217
  seen += len(filenames) + len(kept)
services/task_store.py CHANGED
@@ -864,6 +864,69 @@ class TaskStore:
864
864
  self._save(doc)
865
865
  return {**ms, "detached_tasks": detached}
866
866
 
867
+ def complete_milestone(self, milestone_id) -> dict:
868
+ """Close a finished milestone; its tasks KEEP their milestone link.
869
+
870
+ The happy-path close, distinct from archive_milestone (removal:
871
+ detaches tasks, eligible for purge). A completed milestone leaves
872
+ default listings, the board, and the report, but history still
873
+ records which tasks shipped under it. Refuses while open active
874
+ tasks remain; undo with reopen_milestone().
875
+ """
876
+ with self._guard():
877
+ doc = self._load()
878
+ ms = self._resolve(doc["milestones"], milestone_id)
879
+ if ms is None:
880
+ return {"error": f"no milestone matches: {milestone_id}"}
881
+ old = ms.get("lifecycle", "active")
882
+ if old != "active":
883
+ return {"error": f"milestone is {old}, not active"}
884
+ open_tasks = [t for t in doc["tasks"]
885
+ if t.get("milestone_id") == ms["id"]
886
+ and t.get("lifecycle", "active") == "active"
887
+ and t.get("status") != "done"]
888
+ if open_tasks:
889
+ names = ", ".join(t["id"][:8] for t in open_tasks[:5])
890
+ return {"error": f"{len(open_tasks)} open task(s) block "
891
+ f"completion: {names}"}
892
+ now = _now()
893
+ ms["lifecycle"] = "completed"
894
+ ms["completed_at"] = now
895
+ ms["updated_at"] = now
896
+ self._event("milestone", "complete", ms["id"],
897
+ patch={"lifecycle": [old, "completed"]})
898
+ self._save(doc)
899
+ return dict(ms)
900
+
901
+ def reopen_milestone(self, milestone_id) -> dict:
902
+ """Completed -> active again; clears completed_at.
903
+
904
+ Accepts id, unique id prefix, or — because resolve_milestone's
905
+ name matching deliberately covers only ACTIVE milestones — a
906
+ unique case-insensitive name among the completed ones.
907
+ """
908
+ with self._guard():
909
+ doc = self._load()
910
+ ms = self._resolve(doc["milestones"], milestone_id)
911
+ if ms is None:
912
+ want = (milestone_id or "").strip().lower()
913
+ matches = [m for m in doc["milestones"]
914
+ if m.get("lifecycle") == "completed"
915
+ and (m.get("name") or "").lower() == want]
916
+ ms = matches[0] if len(matches) == 1 else None
917
+ if ms is None:
918
+ return {"error": f"no milestone matches: {milestone_id}"}
919
+ old = ms.get("lifecycle", "active")
920
+ if old != "completed":
921
+ return {"error": f"milestone is {old}, not completed"}
922
+ ms["lifecycle"] = "active"
923
+ ms["completed_at"] = None
924
+ ms["updated_at"] = _now()
925
+ self._event("milestone", "reopen", ms["id"],
926
+ patch={"lifecycle": [old, "active"]})
927
+ self._save(doc)
928
+ return dict(ms)
929
+
867
930
  def resolve_milestone(self, ref: str):
868
931
  """Resolve by id, unique id prefix, or unique case-insensitive name."""
869
932
  doc = self._load()