wikifier 4.6.4__tar.gz → 4.6.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {wikifier-4.6.4/wikifier.egg-info → wikifier-4.6.5}/PKG-INFO +4 -3
  2. {wikifier-4.6.4 → wikifier-4.6.5}/README.md +3 -2
  3. {wikifier-4.6.4 → wikifier-4.6.5}/skills/run.md +2 -1
  4. wikifier-4.6.5/tests/test_walk_coverage_resolvers.py +203 -0
  5. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/__init__.py +2 -1
  6. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/agent_loop.py +40 -3
  7. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/cache_store.py +37 -0
  8. wikifier-4.6.5/wikifier/candidates.py +409 -0
  9. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/cli.py +102 -174
  10. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/parsers/c_cpp.py +33 -10
  11. wikifier-4.6.5/wikifier/parsers/csharp.py +168 -0
  12. {wikifier-4.6.4 → wikifier-4.6.5/wikifier.egg-info}/PKG-INFO +4 -3
  13. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier.egg-info/SOURCES.txt +3 -0
  14. wikifier-4.6.4/wikifier/parsers/csharp.py +0 -64
  15. {wikifier-4.6.4 → wikifier-4.6.5}/CONTRIBUTING.md +0 -0
  16. {wikifier-4.6.4 → wikifier-4.6.5}/LICENSE +0 -0
  17. {wikifier-4.6.4 → wikifier-4.6.5}/MANIFEST.in +0 -0
  18. {wikifier-4.6.4 → wikifier-4.6.5}/diagnostics.html +0 -0
  19. {wikifier-4.6.4 → wikifier-4.6.5}/docs/spec.md +0 -0
  20. {wikifier-4.6.4 → wikifier-4.6.5}/index.html +0 -0
  21. {wikifier-4.6.4 → wikifier-4.6.5}/pyproject.toml +0 -0
  22. {wikifier-4.6.4 → wikifier-4.6.5}/setup.cfg +0 -0
  23. {wikifier-4.6.4 → wikifier-4.6.5}/tests/test_agent_loop.py +0 -0
  24. {wikifier-4.6.4 → wikifier-4.6.5}/tests/test_agent_scale.py +0 -0
  25. {wikifier-4.6.4 → wikifier-4.6.5}/tests/test_barrel_invalidation.py +0 -0
  26. {wikifier-4.6.4 → wikifier-4.6.5}/tests/test_cache_store.py +0 -0
  27. {wikifier-4.6.4 → wikifier-4.6.5}/tests/test_gap_closure.py +0 -0
  28. {wikifier-4.6.4 → wikifier-4.6.5}/tests/test_health.py +0 -0
  29. {wikifier-4.6.4 → wikifier-4.6.5}/tests/test_import_cache.py +0 -0
  30. {wikifier-4.6.4 → wikifier-4.6.5}/tests/test_multi_lang_parsers.py +0 -0
  31. {wikifier-4.6.4 → wikifier-4.6.5}/tests/test_parsers.py +0 -0
  32. {wikifier-4.6.4 → wikifier-4.6.5}/tests/test_selftest_wrappers.py +0 -0
  33. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/__main__.py +0 -0
  34. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/contracts.py +0 -0
  35. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/daemon.py +0 -0
  36. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/diagnostics.py +0 -0
  37. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/health.py +0 -0
  38. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/import_cache.py +0 -0
  39. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/index.html +0 -0
  40. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/library.py +0 -0
  41. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/locking.py +0 -0
  42. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/mcp/__init__.py +0 -0
  43. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/mcp/server.py +0 -0
  44. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/parsers/__init__.py +0 -0
  45. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/parsers/_edge.py +0 -0
  46. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/parsers/bree.py +0 -0
  47. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/parsers/cdia.py +0 -0
  48. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/parsers/go_lang.py +0 -0
  49. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/parsers/java.py +0 -0
  50. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/parsers/javascript.py +0 -0
  51. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/parsers/python.py +0 -0
  52. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/parsers/rust.py +0 -0
  53. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/project_root.py +0 -0
  54. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/resolution.py +0 -0
  55. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/scripts/wikifier.bat +0 -0
  56. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/scripts/wikifier.ps1 +0 -0
  57. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/scripts/wikifier.sh +0 -0
  58. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier/serve.py +0 -0
  59. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier.egg-info/dependency_links.txt +0 -0
  60. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier.egg-info/entry_points.txt +0 -0
  61. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier.egg-info/requires.txt +0 -0
  62. {wikifier-4.6.4 → wikifier-4.6.5}/wikifier.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wikifier
3
- Version: 4.6.4
3
+ Version: 4.6.5
4
4
  Summary: Zero-dependency agent-to-agent codebase wiki for LLMs and AI agents. Autonomously maintained via record-change and mark-green for token-efficient lookup of files, dependencies, health, and summaries — across tiny scripts to 50k+ monorepos. Optional MCP server with rich tools for agents.
5
5
  Author-email: Aron Amos <aron@example.com>
6
6
  Maintainer: Aron Amos
@@ -109,8 +109,9 @@ Advanced intel as needed: `get_dependencies`, `get_dependents`, `get_cycles`, ba
109
109
 
110
110
  - **Import analysis** — Python, JS/TS (ESM/CJS, barrels), Rust (`use`/`mod` + best-effort `crate::` paths), Go, C/C++ includes, C# usings; per-edge confidence; barrel expansion for TS/JS
111
111
  - **Incremental pipeline** — pure-Python `update-maps`: dirty parse → import cache → reverse deps → cycles → `library.md`
112
- - **Warm agent maps (4.6.3+ / 4.6.4+)** — zero-dirty fast path skips full graph/`library.md` rewrite; **stdlib SQLite** import cache (dual-read legacy JSON) so warm dirty scans use mtime/hash index not multi‑MB pair JSON; content-hash dirty; `--directory=` scopes walks
113
- - **Partial-map honesty** — `map_coverage` on `update_maps` / bootstrap (`complete`, `files_remaining_dirty`, `acs_version`, `cache_backend`); budgeted runs are not “done”
112
+ - **Warm agent maps (4.6.34.6.5)** — zero-dirty fast path; **stdlib SQLite** cache; **candidate list reuse** + scoped/monitored-first walks (no whole-tree thrash); content-hash dirty
113
+ - **Partial-map honesty** — `map_coverage` on `update_maps` / bootstrap / **`suggest_next`**; incomplete maps yield `update_maps_until_complete` actions
114
+ - **Cache ops** — `wikifier cache-status` (backend, bytes, ACS, coverage, dual-write policy)
114
115
  - **Selective agent work** — health + suggest bias to 🔴/actionable 🟡 only; **ACS v1.3** `reason_code` / `agent_signal`; prefer `actionable_low_conf_edges` + reason codes — **never** raw `low_conf_edges` alone
115
116
  - **Scale** — reverse index + barrel invalidation so one edit doesn’t re-scan the monorepo
116
117
  - **MCP tools** — optional server for Claude, Cursor, Cline, and other MCP clients
@@ -77,8 +77,9 @@ Advanced intel as needed: `get_dependencies`, `get_dependents`, `get_cycles`, ba
77
77
 
78
78
  - **Import analysis** — Python, JS/TS (ESM/CJS, barrels), Rust (`use`/`mod` + best-effort `crate::` paths), Go, C/C++ includes, C# usings; per-edge confidence; barrel expansion for TS/JS
79
79
  - **Incremental pipeline** — pure-Python `update-maps`: dirty parse → import cache → reverse deps → cycles → `library.md`
80
- - **Warm agent maps (4.6.3+ / 4.6.4+)** — zero-dirty fast path skips full graph/`library.md` rewrite; **stdlib SQLite** import cache (dual-read legacy JSON) so warm dirty scans use mtime/hash index not multi‑MB pair JSON; content-hash dirty; `--directory=` scopes walks
81
- - **Partial-map honesty** — `map_coverage` on `update_maps` / bootstrap (`complete`, `files_remaining_dirty`, `acs_version`, `cache_backend`); budgeted runs are not “done”
80
+ - **Warm agent maps (4.6.34.6.5)** — zero-dirty fast path; **stdlib SQLite** cache; **candidate list reuse** + scoped/monitored-first walks (no whole-tree thrash); content-hash dirty
81
+ - **Partial-map honesty** — `map_coverage` on `update_maps` / bootstrap / **`suggest_next`**; incomplete maps yield `update_maps_until_complete` actions
82
+ - **Cache ops** — `wikifier cache-status` (backend, bytes, ACS, coverage, dual-write policy)
82
83
  - **Selective agent work** — health + suggest bias to 🔴/actionable 🟡 only; **ACS v1.3** `reason_code` / `agent_signal`; prefer `actionable_low_conf_edges` + reason codes — **never** raw `low_conf_edges` alone
83
84
  - **Scale** — reverse index + barrel invalidation so one edit doesn’t re-scan the monorepo
84
85
  - **MCP tools** — optional server for Claude, Cursor, Cline, and other MCP clients
@@ -12,7 +12,8 @@
12
12
  - **First-run:** `init` → `update-maps` → `health --summary` → `suggest_next_actions`. Map is automatic; wiki *prose* is agent-filled over time.
13
13
  - **Map-first ≠ wiki-done:** `health_score` **Map Ready** + many 🟡 *Initial stubs* means structural coverage only. Do **not** bulk-wiki stubs. Wiki a file when you edit it, then `mark-green`. Prefer `actionable_yellow` / 🔴 over raw yellow counts.
14
14
  - **Steady-state selective work:** only 🔴 and *actionable* 🟡 (never re-wiki 🟢; never re-wiki the whole stub set). **ACS v1.3:** use `actionable_low_conf_edges` + `reason_code_counts` / `agent_signal` (`skip`|`investigate`). Do **not** thrash on raw `low_conf_edges` (includes external/bare scores ~0.48). Prefer `map_coverage.complete` / `files_remaining_dirty` after budgeted `update-maps` — `success: true` ≠ map complete.
15
- - **Warm maps:** import cache prefers stdlib SQLite (`.wikifier_staging/import_cache.sqlite`); zero-dirty path uses mtime index + meta, not full pair payload.
15
+ - **Warm maps:** import cache prefers stdlib SQLite (`.wikifier_staging/import_cache.sqlite`); zero-dirty path uses mtime index + meta, not full pair payload. Candidate lists reuse when scope fingerprint is unchanged; prefer `--directory=` / lean `monitored_paths` (not bare `.`).
16
+ - **Cache ops:** `wikifier cache-status` / `from wikifier import cache_status` — backend, sizes, `acs_version`, `map_coverage`. Dual-write JSON only ≤400 files or `WIKIFIER_CACHE_JSON=1` (set `0` to never dual-write).
16
17
  - **Dual scope:** `monitored_paths` = check-changes/health scope (keep lean). `update-maps --directory=` / `--max-files=` = map walk. Never set `project_root` to a multi-repo parent (e.g. `cloned_sample_projects`).
17
18
  - **CLI target:** `wikifier --target /abs/project health --summary` (flags stripped before the command). Or `WIKIFIER_PROJECT_ROOT=…`.
18
19
  - **Long-horizon:** `wikifier autonomous-status` (or `readiness`) before unattended runs; daemon writes `.wikifier_staging/daemon_heartbeat.json`. Soak ≥72h is still evidence work, not automatic.
@@ -0,0 +1,203 @@
1
+ """Walk cost, Core map_coverage, thin C#/C++ resolve, cache-status (shipped APIs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ from tests._base import TempProjectTestCase
11
+
12
+ from wikifier import cli
13
+ from wikifier import cache_store as cs
14
+ from wikifier.candidates import (
15
+ collect_candidate_source_files,
16
+ try_cached_candidate_rels,
17
+ candidate_list_meta,
18
+ )
19
+ from wikifier.agent_loop import build_structured_actions
20
+
21
+
22
+ class TestScopedCollect(TempProjectTestCase):
23
+ def test_directory_scope_limits_candidates(self):
24
+ self.write("pkg/a.py", "x=1\n")
25
+ self.write("other/b.py", "y=1\n")
26
+ cands = collect_candidate_source_files(self.root, directory="pkg")
27
+ rels = []
28
+ for p in cands:
29
+ try:
30
+ rels.append(str(Path(p).resolve().relative_to(self.root.resolve())))
31
+ except Exception:
32
+ rels.append(os.path.realpath(str(p)))
33
+ self.assertTrue(any("pkg" in r and r.endswith("a.py") for r in rels), rels)
34
+ self.assertFalse(any("other" in r for r in rels), rels)
35
+
36
+ def test_wiki_monitored_paths_do_not_collapse_map(self):
37
+ """monitored_paths with only wiki files must not yield a 1-file map."""
38
+ self.write("pkg/a.py", "x=1\n")
39
+ self.write("pkg/b.py", "y=1\n")
40
+ self.write("skills/run.md", "# wiki\n")
41
+ self.write(
42
+ "monitored_paths.txt",
43
+ "# wiki only\nskills/run.md\nREADME.md\npkg/a.py\n",
44
+ )
45
+ self.write("README.md", "# r\n")
46
+ cands = collect_candidate_source_files(self.root, directory=None, use_monitored=True)
47
+ # Must walk full tree, not just a.py from monitored
48
+ py = [p for p in cands if p.suffix == ".py"]
49
+ self.assertGreaterEqual(len(py), 2, [str(p) for p in py])
50
+
51
+ def test_candidate_reuse_true_when_git_clean(self):
52
+ """Second warm reuses candidate list when scope clean — honest assert."""
53
+ self.write("m.py", "print(1)\n")
54
+ r1 = cli.update_maps(project_root=self.root, full=True)
55
+ self.assertTrue(r1.get("success"), r1)
56
+ n1 = int(r1.get("parseable_files") or 0)
57
+ self.assertGreaterEqual(n1, 1)
58
+ r2 = cli.update_maps(project_root=self.root, full=False)
59
+ self.assertTrue(r2.get("success"), r2)
60
+ r3 = cli.update_maps(project_root=self.root, full=False)
61
+ self.assertTrue(r3.get("success"), r3)
62
+ self.assertTrue(
63
+ r3.get("candidates_reused") is True,
64
+ f"expected reuse on third warm; r2={r2.get('candidates_reused')} r3={r3}",
65
+ )
66
+ self.assertEqual(int(r3.get("parseable_files") or 0), n1)
67
+
68
+ def test_nested_new_file_invalidates_reuse(self):
69
+ """Adding a nested source file must expand candidates (not stale reuse)."""
70
+ self.write("pkg/a.py", "x=1\n")
71
+ r1 = cli.update_maps(project_root=self.root, full=True)
72
+ self.assertTrue(r1.get("success"), r1)
73
+ n1 = int(r1.get("parseable_files") or 0)
74
+ # Nested new file under existing package
75
+ self.write("pkg/nested/b.py", "y=2\n")
76
+ time.sleep(0.05)
77
+ r2 = cli.update_maps(project_root=self.root, full=False)
78
+ self.assertTrue(r2.get("success"), r2)
79
+ n2 = int(r2.get("parseable_files") or 0)
80
+ self.assertGreater(
81
+ n2, n1,
82
+ f"nested add must expand parseable_files: before={n1} after={n2} reused={r2.get('candidates_reused')}",
83
+ )
84
+ # Must not have reused stale list of size n1
85
+ if r2.get("candidates_reused"):
86
+ self.fail("must not reuse candidate list after nested source add")
87
+
88
+ def test_poisoned_one_file_blob_not_reused(self):
89
+ """Plant 1-file candidate blob with matching fp; live tree has 2 sources → no reuse."""
90
+ self.write("pkg/a.py", "x=1\n")
91
+ self.write("pkg/b.py", "y=2\n")
92
+ r1 = cli.update_maps(project_root=self.root, full=True)
93
+ self.assertTrue(r1.get("success"), r1)
94
+ live_n = int(r1.get("parseable_files") or 0)
95
+ self.assertGreaterEqual(live_n, 2)
96
+ # Poison meta: same directory scope, current fp, but only one rel
97
+ from wikifier.candidates import scope_fingerprint, candidate_list_meta
98
+ from wikifier import cache_store as cs
99
+ fp = scope_fingerprint(self.root, None)
100
+ poison = {
101
+ "fp": fp,
102
+ "directory": None,
103
+ "count": 1,
104
+ "rels": ["pkg/a.py"],
105
+ }
106
+ cs.save_meta_key(self.root, "_candidate_list", poison)
107
+ # Direct API: try_cached must return None
108
+ meta = cs.load_meta(self.root, keys=("_candidate_list",))
109
+ from wikifier.candidates import try_cached_candidate_rels
110
+ cached = try_cached_candidate_rels(meta, self.root, None)
111
+ self.assertIsNone(
112
+ cached,
113
+ f"poisoned 1-file blob must not reuse when live has {live_n} sources",
114
+ )
115
+ # Shipped update_maps must not report parseable_files=1 with reused=true
116
+ r2 = cli.update_maps(project_root=self.root, full=False)
117
+ self.assertTrue(r2.get("success"), r2)
118
+ self.assertGreaterEqual(int(r2.get("parseable_files") or 0), 2, r2)
119
+ self.assertFalse(
120
+ r2.get("candidates_reused") is True and int(r2.get("parseable_files") or 0) == 1,
121
+ r2,
122
+ )
123
+
124
+
125
+ class TestCoverageInCore(TempProjectTestCase):
126
+ def test_suggest_surfaces_incomplete_map_action(self):
127
+ for i in range(5):
128
+ self.write(f"f{i}.py", f"v={i}\n")
129
+ r = cli.update_maps(project_root=self.root, full=True, max_files=2)
130
+ self.assertTrue(r.get("success"), r)
131
+ cov = r.get("map_coverage") or {}
132
+ self.assertFalse(cov.get("complete"), cov)
133
+ sug = cli.suggest_next_actions(project_root=self.root, format="json")
134
+ self.assertTrue(sug.get("success"), sug)
135
+ self.assertIn("map_coverage", sug)
136
+ self.assertFalse((sug.get("map_coverage") or {}).get("complete"))
137
+ actions = sug.get("actions") or []
138
+ kinds = [a.get("action") for a in actions]
139
+ self.assertIn("update_maps_until_complete", kinds, actions)
140
+ text = " ".join(sug.get("suggestions") or [])
141
+ self.assertIn("INCOMPLETE", text.upper())
142
+
143
+ def test_build_structured_actions_incomplete(self):
144
+ acts = build_structured_actions(
145
+ clean=True,
146
+ map_coverage={"complete": False, "files_remaining_dirty": 9},
147
+ )
148
+ self.assertTrue(any(a["action"] == "update_maps_until_complete" for a in acts))
149
+
150
+
151
+ class TestCacheStatus(TempProjectTestCase):
152
+ def test_cache_status_shape(self):
153
+ self.write("z.py", "z=1\n")
154
+ cli.update_maps(project_root=self.root, full=True)
155
+ st = cli.cache_status(project_root=self.root)
156
+ self.assertTrue(st.get("success"), st)
157
+ self.assertIn(st.get("cache_backend"), ("sqlite", "sqlite+json", "json"))
158
+ self.assertIn("sqlite_bytes", st)
159
+ self.assertIn("dual_write_policy", st)
160
+ self.assertIn("migrate_note", st)
161
+ st2 = cs.cache_status(self.root)
162
+ self.assertEqual(st2.get("cache_backend"), st.get("cache_backend"))
163
+
164
+
165
+ class TestCsharpProjectResolve(TempProjectTestCase):
166
+ def test_project_namespace_resolves(self):
167
+ from wikifier.parsers import csharp as cs_parser
168
+
169
+ self.write(
170
+ "Demo.csproj",
171
+ "<Project><PropertyGroup><RootNamespace>Demo</RootNamespace></PropertyGroup></Project>\n",
172
+ )
173
+ self.write("Util/Helper.cs", "namespace Demo.Util { class Helper {} }\n")
174
+ main = self.write(
175
+ "Program.cs",
176
+ "using Demo.Util;\nnamespace Demo { class Program {} }\n",
177
+ )
178
+ edges = cs_parser.parse_csharp_imports(str(main))
179
+ self.assertTrue(edges)
180
+ resolved = [e for e in edges if e.get("resolved_path")]
181
+ self.assertTrue(resolved, msg=f"edges={edges}")
182
+ self.assertTrue(
183
+ any("Util" in (e.get("resolved_path") or "") for e in resolved),
184
+ resolved,
185
+ )
186
+
187
+
188
+ class TestCCppLocalInclude(TempProjectTestCase):
189
+ def test_include_dir_resolve(self):
190
+ from wikifier.parsers import c_cpp as cc
191
+
192
+ self.write("include/foo.h", "#pragma once\n")
193
+ src = self.write("src/main.c", '#include "foo.h"\nint main(){return 0;}\n')
194
+ edges = cc.parse_c_cpp_imports(str(src))
195
+ self.assertTrue(edges)
196
+ self.assertTrue(
197
+ any(e.get("resolved_path") and "foo.h" in e["resolved_path"] for e in edges),
198
+ edges,
199
+ )
200
+
201
+
202
+ if __name__ == "__main__":
203
+ unittest.main()
@@ -48,6 +48,7 @@ from .cli import (
48
48
  why_file,
49
49
  seed_source_content_hashes,
50
50
  list_core_tools,
51
+ cache_status,
51
52
  )
52
53
  from . import agent_loop
53
54
 
@@ -78,4 +79,4 @@ from .contracts import (
78
79
  compute_acs_confidence,
79
80
  )
80
81
 
81
- __version__ = "4.6.4"
82
+ __version__ = "4.6.5"
@@ -176,6 +176,7 @@ def build_structured_actions(
176
176
  scope_warnings: Optional[List[str]] = None,
177
177
  clean: bool = False,
178
178
  max_file_actions: int = 12,
179
+ map_coverage: Optional[Dict[str, Any]] = None,
179
180
  ) -> List[Dict[str, Any]]:
180
181
  """Build dispatchable action objects for agents (not prose-only).
181
182
 
@@ -183,6 +184,28 @@ def build_structured_actions(
183
184
  """
184
185
  actions: List[Dict[str, Any]] = []
185
186
  prio = 1
187
+ cov = map_coverage if isinstance(map_coverage, dict) else {}
188
+ remaining = int(cov.get("files_remaining_dirty") or 0)
189
+ complete = cov.get("complete")
190
+ # Incomplete map under budget — higher priority than ACS thrash
191
+ if complete is False or remaining > 0:
192
+ actions.append({
193
+ "action": "update_maps_until_complete",
194
+ "file": None,
195
+ "priority": 3,
196
+ "reason": (
197
+ f"Map incomplete: files_remaining_dirty={remaining}, complete={complete}. "
198
+ "Re-run update_maps (same directory/max_files) until map_coverage.complete=true; "
199
+ "success alone does not mean the map is done."
200
+ ),
201
+ "preflight": ["update_maps"],
202
+ "map_coverage": {
203
+ "complete": complete,
204
+ "files_remaining_dirty": remaining,
205
+ "files_skipped": cov.get("files_skipped"),
206
+ "budget_max_files": cov.get("budget_max_files"),
207
+ },
208
+ })
186
209
  for f in (red_files or [])[:max_file_actions]:
187
210
  actions.append({
188
211
  "action": "investigate_red",
@@ -201,7 +224,7 @@ def build_structured_actions(
201
224
  "preflight": ["prepare_edit", "why_file"],
202
225
  })
203
226
  prio = min(prio + 1, 9)
204
- if red == 0 and actionable_yellow == 0 and clean:
227
+ if red == 0 and actionable_yellow == 0 and clean and remaining == 0:
205
228
  actions.append({
206
229
  "action": "lookup_only",
207
230
  "file": None,
@@ -222,7 +245,10 @@ def build_structured_actions(
222
245
  "action": "review_acs",
223
246
  "file": None,
224
247
  "priority": 4,
225
- "reason": f"{acs_actionable} actionable low-confidence project edges (not external noise)",
248
+ "reason": (
249
+ f"{acs_actionable} actionable low-confidence project edges "
250
+ "(prefer actionable_low_conf_edges + reason_code_counts; not raw low_conf_edges)"
251
+ ),
226
252
  "preflight": ["get_dependencies"],
227
253
  })
228
254
  for w in (scope_warnings or [])[:2]:
@@ -233,7 +259,10 @@ def build_structured_actions(
233
259
  "reason": str(w),
234
260
  "preflight": [],
235
261
  })
236
- if not any(a["action"] == "update_maps" for a in actions):
262
+ if not any(
263
+ a["action"] in ("update_maps", "update_maps_until_complete", "update_maps_if_structure")
264
+ for a in actions
265
+ ):
237
266
  actions.append({
238
267
  "action": "update_maps_if_structure",
239
268
  "file": None,
@@ -609,6 +638,7 @@ def session_bootstrap(
609
638
  if isinstance(out.get("scope"), dict):
610
639
  scope_warnings = list(out["scope"].get("warnings") or [])
611
640
 
641
+ map_cov = out.get("map_coverage") if isinstance(out.get("map_coverage"), dict) else {}
612
642
  out["actions"] = build_structured_actions(
613
643
  red_files=red_files,
614
644
  actionable_yellow_files=actionable_yellow_files,
@@ -618,12 +648,19 @@ def session_bootstrap(
618
648
  acs_actionable=acs_actionable,
619
649
  scope_warnings=scope_warnings,
620
650
  clean=clean,
651
+ map_coverage=map_cov,
621
652
  )
622
653
  out["work_items"] = out["actions"] # alias
623
654
  out["selective_work"] = True
624
655
  out["map_first"] = True
656
+ rem = int(map_cov.get("files_remaining_dirty") or 0) if map_cov else 0
625
657
  out["message"] = (
626
658
  "session_bootstrap ready — use actions[] / attention; "
627
659
  "prepare_edit(file) before large edits; record_change after edits."
660
+ + (
661
+ f" MAP INCOMPLETE: files_remaining_dirty={rem} — re-run update_maps until complete."
662
+ if rem > 0 or map_cov.get("complete") is False
663
+ else ""
664
+ )
628
665
  )
629
666
  return out
@@ -346,3 +346,40 @@ def get_map_coverage_from_meta(root: Path) -> Dict[str, Any]:
346
346
  "actionable_low_conf_edges": acs.get("actionable_low_conf_edges"),
347
347
  "reason_code_counts": acs.get("reason_code_counts"),
348
348
  }
349
+
350
+
351
+ def cache_status(root: Path) -> Dict[str, Any]:
352
+ """Machine-readable dual-cache status for agents/ops (zero full pair load)."""
353
+ root = Path(root)
354
+ sp = sqlite_path(root)
355
+ jp = json_path(root)
356
+ backend = backend_name(root)
357
+ sqlite_bytes = int(sp.stat().st_size) if sp.is_file() else 0
358
+ json_bytes = int(jp.stat().st_size) if jp.is_file() else 0
359
+ snap = get_map_coverage_from_meta(root)
360
+ meta = load_meta(root, keys=("_acs_summary", "_map_coverage", "_candidate_list"))
361
+ acs = meta.get("_acs_summary") if isinstance(meta.get("_acs_summary"), dict) else {}
362
+ cand = meta.get("_candidate_list") if isinstance(meta.get("_candidate_list"), dict) else {}
363
+ return {
364
+ "success": True,
365
+ "project_root": str(root.resolve()) if root.exists() else str(root),
366
+ "cache_backend": backend,
367
+ "sqlite_path": str(sp) if sp.is_file() else None,
368
+ "json_path": str(jp) if jp.is_file() else None,
369
+ "sqlite_bytes": sqlite_bytes,
370
+ "json_bytes": json_bytes,
371
+ "acs_version": acs.get("acs_version") or snap.get("acs_version"),
372
+ "actionable_low_conf_edges": acs.get("actionable_low_conf_edges"),
373
+ "map_coverage": snap.get("map_coverage") or meta.get("_map_coverage"),
374
+ "candidate_list_count": cand.get("count"),
375
+ "candidate_list_directory": cand.get("directory"),
376
+ "dual_write_policy": (
377
+ "SQLite is primary. Legacy JSON dual-read remains. "
378
+ "Dual-write JSON only when file count ≤400 or WIKIFIER_CACHE_JSON=1; "
379
+ "set WIKIFIER_CACHE_JSON=0 to never dual-write. Prefer sqlite for warm agents."
380
+ ),
381
+ "migrate_note": (
382
+ "update_maps migrates legacy import_cache.json → import_cache.sqlite once "
383
+ "when sqlite is missing."
384
+ ),
385
+ }