wikifier 4.5.6__tar.gz → 4.5.9__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 (54) hide show
  1. {wikifier-4.5.6/wikifier.egg-info → wikifier-4.5.9}/PKG-INFO +1 -1
  2. {wikifier-4.5.6 → wikifier-4.5.9}/skills/run.md +15 -8
  3. {wikifier-4.5.6 → wikifier-4.5.9}/tests/test_gap_closure.py +157 -0
  4. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/__init__.py +1 -1
  5. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/cli.py +71 -141
  6. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/daemon.py +113 -21
  7. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/health.py +423 -9
  8. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/import_cache.py +68 -10
  9. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/mcp/server.py +38 -8
  10. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/parsers/bree.py +2 -2
  11. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/parsers/javascript.py +1 -1
  12. wikifier-4.5.9/wikifier/project_root.py +144 -0
  13. {wikifier-4.5.6 → wikifier-4.5.9/wikifier.egg-info}/PKG-INFO +1 -1
  14. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier.egg-info/SOURCES.txt +2 -0
  15. {wikifier-4.5.6 → wikifier-4.5.9}/CONTRIBUTING.md +0 -0
  16. {wikifier-4.5.6 → wikifier-4.5.9}/LICENSE +0 -0
  17. {wikifier-4.5.6 → wikifier-4.5.9}/MANIFEST.in +0 -0
  18. {wikifier-4.5.6 → wikifier-4.5.9}/README.md +0 -0
  19. {wikifier-4.5.6 → wikifier-4.5.9}/diagnostics.html +0 -0
  20. {wikifier-4.5.6 → wikifier-4.5.9}/docs/spec.md +0 -0
  21. {wikifier-4.5.6 → wikifier-4.5.9}/index.html +0 -0
  22. {wikifier-4.5.6 → wikifier-4.5.9}/pyproject.toml +0 -0
  23. {wikifier-4.5.6 → wikifier-4.5.9}/setup.cfg +0 -0
  24. {wikifier-4.5.6 → wikifier-4.5.9}/tests/test_barrel_invalidation.py +0 -0
  25. {wikifier-4.5.6 → wikifier-4.5.9}/tests/test_health.py +0 -0
  26. {wikifier-4.5.6 → wikifier-4.5.9}/tests/test_import_cache.py +0 -0
  27. {wikifier-4.5.6 → wikifier-4.5.9}/tests/test_multi_lang_parsers.py +0 -0
  28. {wikifier-4.5.6 → wikifier-4.5.9}/tests/test_parsers.py +0 -0
  29. {wikifier-4.5.6 → wikifier-4.5.9}/tests/test_selftest_wrappers.py +0 -0
  30. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/__main__.py +0 -0
  31. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/contracts.py +0 -0
  32. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/diagnostics.py +0 -0
  33. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/index.html +0 -0
  34. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/library.py +0 -0
  35. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/locking.py +0 -0
  36. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/mcp/__init__.py +0 -0
  37. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/parsers/__init__.py +0 -0
  38. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/parsers/_edge.py +0 -0
  39. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/parsers/c_cpp.py +0 -0
  40. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/parsers/cdia.py +0 -0
  41. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/parsers/csharp.py +0 -0
  42. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/parsers/go_lang.py +0 -0
  43. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/parsers/java.py +0 -0
  44. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/parsers/python.py +0 -0
  45. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/parsers/rust.py +0 -0
  46. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/resolution.py +0 -0
  47. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/scripts/wikifier.bat +0 -0
  48. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/scripts/wikifier.ps1 +0 -0
  49. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/scripts/wikifier.sh +0 -0
  50. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier/serve.py +0 -0
  51. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier.egg-info/dependency_links.txt +0 -0
  52. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier.egg-info/entry_points.txt +0 -0
  53. {wikifier-4.5.6 → wikifier-4.5.9}/wikifier.egg-info/requires.txt +0 -0
  54. {wikifier-4.5.6 → wikifier-4.5.9}/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.5.6
3
+ Version: 4.5.9
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
@@ -10,12 +10,15 @@
10
10
  **Package 4.5.x + gap-closure notes (additive; protocol still v0.6):**
11
11
  - File Tree + `wikifier serve`; MCP status/attention use library + emoji (not `[GREEN]` tags).
12
12
  - **First-run:** `init` → `update-maps` → `health --summary` → `suggest_next_actions`. Map is automatic; wiki *prose* is agent-filled over time.
13
- - **Steady-state selective work:** only 🔴/🟡 (never re-wiki 🟢 unless required). ACS suggestions use `actionable_low_conf_edges` (stdlib/external bare demoted).
14
- - **Deletions:** `record-deletion` + check-changes ghost detection (missing paths Red DELETED).
15
- - **Languages:** deep import maps = Python, JS/TS, Rust, Go, C/C++, C#, Java (regex; not full toolchain resolution). journal/pending = audit queue, not Jira.
16
- - **Huge monorepos:** lean `monitored_paths.txt` (package roots, not bare `.`). Optional `WIKIFIER_CHECK_CHANGES_MAX` (default 2000). `update-maps` seeds Yellow health stubs for newly parsed files (map-first).
17
- - **Monitor:** optional background `monitor`/`daemon` for mtime heartbeat; agents still own wiki/mark-green.
18
- - Health module: use `importlib.import_module("wikifier.health")` or `wikifier.health_module` `from wikifier import health` is the *function*.
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
+ - **Steady-state selective work:** only 🔴 and *actionable* 🟡 (never re-wiki 🟢; never re-wiki the whole stub set). ACS uses `actionable_low_conf_edges`.
15
+ - **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`).
16
+ - **CLI target:** `wikifier --target /abs/project health --summary` (flags stripped before the command). Or `WIKIFIER_PROJECT_ROOT=…`.
17
+ - **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.
18
+ - **Deletions:** `record-deletion` + ghost detection. **Languages:** py/js/ts/rust/go/c/c#/java (regex). journal/pending = audit queue, not Jira.
19
+ - **Huge monorepos:** lean `monitored_paths.txt` (not bare `.`). `WIKIFIER_CHECK_CHANGES_MAX` default 2000. Hygiene: `seed-health`, `prune-pending`, `prune-health`.
20
+ - **Monitor/daemon:** mtime heartbeat; agents still own wiki/mark-green. `WIKIFIER_DAEMON_MAPS_INTERVAL` default 600s; `WIKIFIER_DAEMON_MAPS=0` for check-only.
21
+ - Health module: `importlib.import_module("wikifier.health")` or `wikifier.health_module` — `from wikifier import health` is the *function*.
19
22
 
20
23
  **v0.6 migration notes (packages v4.2.0–v4.3.0)** — changes are additive or strictly-better defaults; v0.5 agent behavior keeps working:
21
24
  - `update-maps` (CLI) now runs the **pure-Python full pipeline**: every dirty file is parsed in-process, the canonical per-file cache is persisted, reverse deps + cycles + ACS are computed, and `library.md` is regenerated atomically. `--python-primary` is accepted but redundant; the in-shell first-pass was retired entirely (wikifier.sh's update-maps delegates to this pipeline; `--sh`/`--legacy-sh` are deprecated no-ops). Scoping is explicit via `--directory=`/`--max-files=` and reported in the result (`files_skipped`) — there are no silent caps.
@@ -39,11 +42,15 @@ Do **not** open megamodules (`javascript.py`, `import_cache.py`, `bree.py`) to d
39
42
  | Need | Use |
40
43
  |------|-----|
41
44
  | First-run map | `init` → `update-maps` → `health --summary` → `suggest-next` |
42
- | Steady-state | `check-changes` → edit 🔴/🟡 only → `record-change` → wiki → `mark-green` |
45
+ | Steady-state | `check-changes` → edit 🔴/actionable 🟡 only → `record-change` → wiki → `mark-green` |
43
46
  | Lookup | MCP Core 6: status, check_changes, needing_attention, get_file_wiki, suggest, record/mark |
44
47
  | Deps / cycles | `get_dependencies`, `get_dependents`, `get_cycles` (json) |
45
48
  | Deletion | `record-deletion` |
46
- | Code entry | `wikifier/cli.py` (dispatch) + `wikifier/health.py` + parsers self-tests live in `tests/` + `tests/selftest/` |
49
+ | Long-horizon prep | `autonomous-status` / `readiness`; then `daemon start` with lean monitor |
50
+ | Hygiene | `seed-health`, `prune-pending`, `prune-health`, `validate` |
51
+ | Code entry | `cli.py` dispatch + `health.py` + parsers — self-tests in `tests/` + `tests/selftest/` |
52
+
53
+ **Do not open megamodules** (`javascript.py` ~2.6k, `import_cache.py` ~2.3k, `bree.py` ~2k) for workflow decisions — use tools + this table.
47
54
 
48
55
  **CLI pure-Python vs shell:** mutators and maps prefer `python -m wikifier …`. Shell still owns `init`, `monitor`, `daemon`, `serve`, `journal`.
49
56
  **Scope:** `monitored_paths` = change detection; `exclude_patterns` + optional `--directory` = map walk.
@@ -103,6 +103,85 @@ class TestAcsActionableG4(TempProjectTestCase):
103
103
  for p in action
104
104
  ))
105
105
 
106
+ def test_dynamic_literal_noise_demoted_from_actionable(self):
107
+ """ACS v1.2: importlib.import_module(\"pkg\") static dynamics are not actionable."""
108
+ dyn_lit = {
109
+ "raw": "\"wikifier.health\"",
110
+ "resolved": "wikifier.health",
111
+ "resolved_path": None,
112
+ "is_dynamic": True,
113
+ "dynamic_type": "static",
114
+ "confidence_score": 0.05,
115
+ "confidence_reasons": [
116
+ "base:low", "conditional_context", "dynamic",
117
+ "detector:TernaryDetector",
118
+ ],
119
+ "confidence_explanation": (
120
+ 'Base low (0.05). TernaryDetector=importlib.import_module("wikifier.health"). '
121
+ "Recommendation: Runtime optional load is expected noise."
122
+ ),
123
+ "resolution_metadata": {"strategy": "python-dynamic"},
124
+ }
125
+ project_fragile = {
126
+ "raw": "./missing_local",
127
+ "resolved": "missing_local",
128
+ "resolved_path": None,
129
+ "is_dynamic": False,
130
+ "confidence_score": 0.35,
131
+ "confidence_reasons": ["base:low", "no_resolved_path"],
132
+ "confidence_explanation": "Unresolved project-local. Recommendation: fix path.",
133
+ "diagnostic": {"category": "project"},
134
+ "resolution_metadata": {"strategy": "relative-fs"},
135
+ }
136
+ cache = {
137
+ "mod.py": {
138
+ "mtime": 1,
139
+ "resolved_pairs": [dyn_lit, project_fragile],
140
+ }
141
+ }
142
+ self.assertTrue(ic._edge_is_dynamic_literal_noise(dyn_lit))
143
+ self.assertFalse(ic._edge_is_dynamic_literal_noise(project_fragile))
144
+ summary = ic.compute_acs_summary(cache)
145
+ self.assertEqual(summary.get("acs_version"), "1.2")
146
+ self.assertGreaterEqual(summary["low_conf_edges"], 2)
147
+ self.assertEqual(summary["actionable_low_conf_edges"], 1)
148
+ self.assertGreaterEqual(summary.get("dynamic_literal_noise_edges", 0), 1)
149
+ action = ic.get_low_confidence_edges(cache, actionable_only=True)
150
+ self.assertEqual(len(action), 1)
151
+ self.assertIn("missing_local", str(action[0].get("raw") or action[0].get("resolved")))
152
+
153
+
154
+ class TestLoadSafetyNoImportCycle(unittest.TestCase):
155
+ """Load-time safety: core modules import without circular-import failure."""
156
+
157
+ def test_import_wikifier_and_cycle_trio(self):
158
+ import importlib
159
+ import wikifier
160
+ self.assertTrue(hasattr(wikifier, "discover_project_root"))
161
+ # Fresh submodule loads (project_root breaks former cli↔cache↔bree SCC)
162
+ for name in (
163
+ "wikifier.project_root",
164
+ "wikifier.cli",
165
+ "wikifier.import_cache",
166
+ "wikifier.parsers.bree",
167
+ ):
168
+ mod = importlib.import_module(name)
169
+ self.assertIsNotNone(mod)
170
+ from wikifier.project_root import discover_project_root as dpr
171
+ from wikifier.cli import discover_project_root as dpr_cli
172
+ self.assertIs(dpr, dpr_cli)
173
+ root = dpr()
174
+ self.assertTrue(root.exists())
175
+
176
+ def test_bree_does_not_import_cli_at_module_level(self):
177
+ """Static check: bree source must not load-time import wikifier.cli."""
178
+ from pathlib import Path
179
+ bree_src = Path(__file__).resolve().parents[1] / "wikifier" / "parsers" / "bree.py"
180
+ text = bree_src.read_text(encoding="utf-8")
181
+ self.assertNotIn("from ..cli import", text)
182
+ self.assertNotIn("from wikifier.cli import", text)
183
+ self.assertIn("from ..project_root import", text)
184
+
106
185
 
107
186
  class TestGhostAndDeletionG7(TempProjectTestCase):
108
187
  def test_find_ghost_and_record_deletion(self):
@@ -190,6 +269,84 @@ class TestPendingAndPollutionHygiene(TempProjectTestCase):
190
269
  self.assertIn("gone_real.py", entries2)
191
270
 
192
271
 
272
+ class TestMapFirstTaxonomyAndReadiness(TempProjectTestCase):
273
+ def test_stub_yellow_is_map_ready_not_needs_attention(self):
274
+ health_mod = importlib.import_module("wikifier.health")
275
+ health_mod.upsert_entry(
276
+ self.root, "a.py", "🟡 Yellow",
277
+ "Initial stub — present in dependency map; agent should wiki + mark-green when editing",
278
+ )
279
+ health_mod.upsert_entry(
280
+ self.root, "b.py", "🟡 Yellow",
281
+ "Initial stub — parseable source under monitored_paths; agent should wiki + mark-green when editing",
282
+ )
283
+ s = health_mod.get_summary(self.root)
284
+ self.assertEqual(s.get("stub_yellow"), 2)
285
+ self.assertEqual(s.get("actionable_yellow"), 0)
286
+ self.assertEqual(s.get("health_score"), "Map Ready")
287
+
288
+ def test_actionable_yellow_needs_attention(self):
289
+ health_mod = importlib.import_module("wikifier.health")
290
+ health_mod.upsert_entry(
291
+ self.root, "a.py", "🟡 Yellow",
292
+ "mtime changed since last check-changes (Python primary auto-detected)",
293
+ )
294
+ s = health_mod.get_summary(self.root)
295
+ self.assertEqual(s.get("actionable_yellow"), 1)
296
+ self.assertEqual(s.get("health_score"), "Needs Attention")
297
+
298
+ def test_suggest_does_not_bulk_wiki_stubs(self):
299
+ health_mod = importlib.import_module("wikifier.health")
300
+ health_mod.upsert_entry(self.root, "a.py", "🟡 Yellow", "Initial stub — present in dependency map")
301
+ text = cli.suggest_next_actions(project_root=self.root, format="text")
302
+ self.assertIn("Map-first", text)
303
+ self.assertNotIn("Review the 1 🟡 Yellow file(s) only", text)
304
+
305
+ def test_detect_multi_project_container(self):
306
+ health_mod = importlib.import_module("wikifier.health")
307
+ for name in ("proj_a", "proj_b", "proj_c"):
308
+ d = self.root / name
309
+ d.mkdir()
310
+ (d / ".git").mkdir()
311
+ scope = health_mod.detect_scope_risks(self.root)
312
+ self.assertGreaterEqual(scope.get("child_project_count"), 3)
313
+ self.assertFalse(scope.get("ok"))
314
+ self.assertTrue(any("multi-project" in w.lower() for w in scope.get("warnings") or []))
315
+
316
+ def test_autonomous_readiness_shape(self):
317
+ health_mod = importlib.import_module("wikifier.health")
318
+ (self.root / "monitored_paths.txt").write_text("src\n", encoding="utf-8")
319
+ self.write("src/app.py", "x=1\n")
320
+ health_mod.upsert_entry(self.root, "src/app.py", "🟡 Yellow", "Initial stub — present in dependency map")
321
+ r = health_mod.assess_autonomous_readiness(self.root)
322
+ self.assertTrue(r.get("success"))
323
+ self.assertIn(r.get("readiness"), (
324
+ "blocked", "map_ok_scope_risk", "ready_for_daemon",
325
+ "ready_with_agent_wiki_work", "not_ready",
326
+ ))
327
+ self.assertIn("long_horizon_note", r)
328
+ self.assertIn("metrics", r)
329
+
330
+ def test_write_metrics_snapshot_history(self):
331
+ health_mod = importlib.import_module("wikifier.health")
332
+ (self.root / "monitored_paths.txt").write_text("src\n", encoding="utf-8")
333
+ self.write("src/app.py", "x=1\n")
334
+ health_mod.upsert_entry(self.root, "src/app.py", "🟢 Green", "ok")
335
+ s1 = health_mod.write_metrics_snapshot(self.root, source="test1")
336
+ self.assertTrue(s1.get("success"))
337
+ latest = self.root / ".wikifier_staging" / "metrics_latest.json"
338
+ hist = self.root / ".wikifier_staging" / "metrics_history.jsonl"
339
+ self.assertTrue(latest.exists())
340
+ self.assertTrue(hist.exists())
341
+ s2 = health_mod.write_metrics_snapshot(self.root, source="test2")
342
+ self.assertTrue(s2.get("success"))
343
+ rows = health_mod.read_metrics_history(self.root, limit=10)
344
+ self.assertGreaterEqual(len(rows), 2)
345
+ self.assertEqual(rows[-1].get("source"), "test2")
346
+ self.assertIn("staging_bytes", rows[-1])
347
+ self.assertIn("health_score", rows[-1])
348
+
349
+
193
350
  class TestMapFirstHealthAndValidate(TempProjectTestCase):
194
351
  def test_deep_relative_keys_are_under_root(self):
195
352
  """Regression: deep monorepo rel paths must not be treated as abs pollution."""
@@ -71,4 +71,4 @@ from .contracts import (
71
71
  compute_acs_confidence,
72
72
  )
73
73
 
74
- __version__ = "4.5.6"
74
+ __version__ = "4.5.9"
@@ -21,136 +21,9 @@ from pathlib import Path
21
21
  from typing import Any, Dict, List, Optional
22
22
  from contextlib import nullcontext as _nullcontext
23
23
 
24
-
25
- def discover_project_root() -> Path:
26
- """
27
- Unified discovery helper for the *target project root* (user codebase / monorepo).
28
-
29
- This is the canonical implementation for CLI, MCP, direct Python use, and
30
- is mirrored (same priority + markers) by the shell script's discover_project_root().
31
-
32
- Highest-leverage fix for External / Packaged Full-Update Robustness (Gap #1):
33
- Makes `pip install wikifier` + `wikifier ...` (or direct packaged sh) reliable
34
- from any external monorepo cwd without requiring manual WIKIFIER_PROJECT_ROOT
35
- or --target on every invocation. Zero-dependency.
36
-
37
- Wave 3 hardening (symlinks + pnpm/yarn stores):
38
- - Consults $PWD (and $OLDPWD) logical cwd in addition to Path.cwd() / .resolve().
39
- - Walks parent chains from *logical* start points first; this ensures that when
40
- cwd is deep inside a pnpm symlinked store layout (e.g. monorepo/node_modules/.pnpm/pkg@1/.../pkg
41
- whose physical target is /global-store/...), the logical ancestor chain still reaches
42
- the real project root markers (.git, package.json etc.) outside the store.
43
- - Falls back to resolved physical; returns the outermost marker root found.
44
- - Prevents packaged/parser/daemon from accidentally using a store dir as PROJECT_ROOT.
45
-
46
- Wave 4 further hardening for complex monorepo layouts:
47
- - Collects *all* candidate marker roots across logical/physical/realpath chains (no early return).
48
- - Selects the outermost (shallowest fs path) preferring .git roots, then lockfiles (pnpm/yarn), then other.
49
- This correctly picks true monorepo root even when deep sub-packages have their own package.json / yarn workspaces.
50
- - Expanded common markers: pnpm-lock.yaml, yarn.lock, lerna.json, nx.json, turbo.json, rush.json, workspace yamls.
51
- - Explicit skipping of node_modules/.pnpm / .yarn / .pnp internals as candidate roots (even on logical paths).
52
- - Additional os.path.realpath chains for nested/broken symlink monorepo layouts.
53
- - Daemon, parsers (via _get_*_fallback), run_full_update all inherit automatically.
54
- """
55
- # 1. Explicit env var (highest priority, supports all the R6 --target flows)
56
- env_root = os.environ.get("WIKIFIER_PROJECT_ROOT")
57
- if env_root:
58
- p = Path(env_root).expanduser().resolve()
59
- if p.exists():
60
- return p
61
-
62
- # Wave 3/4: logical cwd (PWD/OLDPWD) + physical + realpath fallbacks for symlink/pnpm/yarn/nested
63
- cwd = Path.cwd()
64
- logical_cwd_str = os.environ.get("PWD") or os.environ.get("OLDPWD") or str(cwd)
65
- try:
66
- logical_cwd = Path(logical_cwd_str).expanduser()
67
- except Exception:
68
- logical_cwd = cwd
69
-
70
- start_points: List[Path] = []
71
- for start in (logical_cwd, cwd, cwd.resolve()):
72
- if start and start not in start_points:
73
- start_points.append(start)
74
-
75
- # Wave 4: realpath variants for deeper symlink nest robustness (e.g. git submodules + pnpm links)
76
- for sp in list(start_points):
77
- try:
78
- rp = Path(os.path.realpath(str(sp)))
79
- if rp not in start_points:
80
- start_points.append(rp)
81
- except Exception:
82
- continue
83
-
84
- # 2/3. Markers: wikifier-specific + expanded common monorepo (Wave 4)
85
- wikifier_specific = [".wikifier", "monitored_paths.txt", ".wikifier/config"]
86
- common_markers = [
87
- ".git", "package.json", "pyproject.toml", "setup.py", "setup.cfg",
88
- "Cargo.toml", "go.mod", ".hg",
89
- # Wave 4 monorepo root indicators (for workspace/subpkg layouts + yarn/pnpm detection)
90
- "pnpm-lock.yaml", "yarn.lock", "lerna.json", "nx.json", "turbo.json",
91
- "rush.json", "workspace.json", "pnpm-workspace.yaml", ".yarnrc.yml"
92
- ]
93
- store_indicators = (".pnpm", ".yarn", ".pnp", ".pnp.cjs", "virtual-store", ".store")
94
-
95
- candidates: List[Path] = []
96
- for start in start_points:
97
- try:
98
- parent_chain = [start] + list(start.parents)
99
- except Exception:
100
- parent_chain = [start]
101
-
102
- # Wikifier markers (post-init)
103
- for parent in parent_chain:
104
- try:
105
- pstr = str(parent)
106
- if any(ind in pstr for ind in store_indicators) and "node_modules" in pstr:
107
- continue # never treat deep pnpm/yarn store paths as project root
108
- if ((parent / ".wikifier").is_dir() or
109
- (parent / "monitored_paths.txt").exists() or
110
- (parent / ".wikifier/config").exists()):
111
- if parent not in candidates:
112
- candidates.append(parent)
113
- except Exception:
114
- continue
115
-
116
- # Common + monorepo markers (any-scale external)
117
- for parent in parent_chain:
118
- try:
119
- pstr = str(parent)
120
- if any(ind in pstr for ind in store_indicators) and "node_modules" in pstr:
121
- continue
122
- for marker in common_markers:
123
- if (parent / marker).exists():
124
- if parent not in candidates:
125
- candidates.append(parent)
126
- except Exception:
127
- continue
128
-
129
- if candidates:
130
- # Wave 4: pick outermost/shallowest, with strong preference for .git (true monorepo root)
131
- # even if a deeper packages/*/package.json also matches. Solves workspace subdir case.
132
- def _root_key(p: Path):
133
- try:
134
- rp = p.resolve()
135
- has_git = (rp / ".git").exists() or (rp / ".git").is_file() # worktree support
136
- depth = len(rp.parts)
137
- return (0 if has_git else 1, depth, str(rp))
138
- except Exception:
139
- return (99, 99999, str(p))
140
-
141
- best = min(candidates, key=_root_key)
142
- try:
143
- return best.resolve() if best.exists() else best
144
- except Exception:
145
- return best
146
-
147
- # 4. Last-resort sensible default: the directory the user is running from.
148
- # This is the key robustness fix vs. old "fall back to package dir".
149
- # Prefer resolved for the final fallback.
150
- try:
151
- return cwd.resolve()
152
- except Exception:
153
- return cwd
24
+ # Canonical discovery lives in project_root (no cli↔cache↔bree load cycle).
25
+ # Re-export for public API: `from wikifier.cli import discover_project_root`.
26
+ from .project_root import discover_project_root # noqa: F401
154
27
 
155
28
 
156
29
  # =============================================================================
@@ -728,18 +601,20 @@ def main():
728
601
  i = 0
729
602
  while i < len(argv):
730
603
  arg = argv[i]
604
+ # Consume --target / --project-root so the *command* remains filtered_argv[0]
605
+ # (previously left --target as argv[0] → "Unknown command: --target").
731
606
  if arg in ("--target", "--project-root") and i + 1 < len(argv):
732
607
  project_root = argv[i + 1]
733
- filtered_argv.append(arg)
734
- filtered_argv.append(argv[i + 1])
735
608
  i += 2
736
609
  continue
737
610
  elif arg.startswith("--target="):
738
611
  project_root = arg.split("=", 1)[1]
739
- filtered_argv.append(arg)
612
+ i += 1
613
+ continue
740
614
  elif arg.startswith("--project-root="):
741
615
  project_root = arg.split("=", 1)[1]
742
- filtered_argv.append(arg)
616
+ i += 1
617
+ continue
743
618
  elif arg in ("--use-canonical", "--use_canonical"):
744
619
  use_canonical = True
745
620
  filtered_argv.append(arg)
@@ -895,6 +770,30 @@ def main():
895
770
  res = _health_mod.prune_health_outside_monitored(root)
896
771
  print(_json.dumps(res, indent=2, default=str))
897
772
  return 0 if res.get("success") else 1
773
+ if _cmd0 in (
774
+ "autonomous-status",
775
+ "autonomous_status",
776
+ "readiness",
777
+ "long-horizon",
778
+ ):
779
+ import json as _json
780
+ if _health_mod is None or not hasattr(_health_mod, "assess_autonomous_readiness"):
781
+ print("[wikifier] health.assess_autonomous_readiness unavailable", file=sys.stderr)
782
+ return 1
783
+ root = _get_effective_root(project_root)
784
+ res = _health_mod.assess_autonomous_readiness(root)
785
+ print(_json.dumps(res, indent=2, default=str))
786
+ # exit 0 only when not blocked
787
+ return 0 if res.get("readiness") != "blocked" else 2
788
+ if _cmd0 in ("metrics-snapshot", "metrics_snapshot", "metrics"):
789
+ import json as _json
790
+ if _health_mod is None or not hasattr(_health_mod, "write_metrics_snapshot"):
791
+ print("[wikifier] write_metrics_snapshot unavailable", file=sys.stderr)
792
+ return 1
793
+ root = _get_effective_root(project_root)
794
+ res = _health_mod.write_metrics_snapshot(root, source="cli")
795
+ print(_json.dumps(res, indent=2, default=str))
796
+ return 0 if res.get("success") else 1
898
797
  except Exception as e:
899
798
  print(f"[wikifier] Python-primary {_cmd0} failed: {e}", file=sys.stderr)
900
799
  return 1
@@ -1419,12 +1318,14 @@ def suggest_next_actions(
1419
1318
  """
1420
1319
  root = _get_effective_root(project_root)
1421
1320
  try:
1422
- red = yellow = 0
1321
+ red = yellow = stub_y = action_y = 0
1423
1322
  health_sum: Dict[str, Any] = {}
1424
1323
  if _health_mod is not None:
1425
1324
  health_sum = _health_mod.get_summary(root, directory) or {}
1426
- red = health_sum.get("red", 0)
1427
- yellow = health_sum.get("yellow", 0)
1325
+ red = int(health_sum.get("red", 0) or 0)
1326
+ yellow = int(health_sum.get("yellow", 0) or 0)
1327
+ stub_y = int(health_sum.get("stub_yellow", 0) or 0)
1328
+ action_y = int(health_sum.get("actionable_yellow", yellow - stub_y) or 0)
1428
1329
 
1429
1330
  suggestions: List[str] = []
1430
1331
  n = 1
@@ -1434,10 +1335,23 @@ def suggest_next_actions(
1434
1335
  "Do not re-wiki 🟢 Green files."
1435
1336
  )
1436
1337
  n += 1
1437
- if yellow > 0:
1338
+ if action_y > 0:
1339
+ suggestions.append(
1340
+ f"{n}. Review {action_y} *actionable* 🟡 Yellow file(s) "
1341
+ "(mtime/record-change/barrel — not Initial stubs). "
1342
+ "record-change → wiki that file → mark-green. Skip green."
1343
+ )
1344
+ n += 1
1345
+ if stub_y > 0 and action_y == 0 and red == 0:
1438
1346
  suggestions.append(
1439
- f"{n}. Review the {yellow} 🟡 Yellow file(s) only record-change intent, refresh wiki, mark-green. "
1440
- "Skip green unless a dependent of a yellow/red requires it."
1347
+ f"{n}. Map-first OK: {stub_y} 🟡 Initial stubs mean \"on the map\", "
1348
+ "NOT \"wiki this tree now\". Lookup via get_file_wiki/deps; "
1349
+ "write prose only when you edit a file, then mark-green."
1350
+ )
1351
+ n += 1
1352
+ elif stub_y > 0 and action_y > 0:
1353
+ suggestions.append(
1354
+ f"{n}. Ignore {stub_y} map-first stubs for bulk work; only actionable yellows need wiki."
1441
1355
  )
1442
1356
  n += 1
1443
1357
  if red == 0 and yellow == 0:
@@ -1445,6 +1359,15 @@ def suggest_next_actions(
1445
1359
  f"{n}. Health is clean (no red/yellow). Do not re-summarize the tree; use the map for lookup only."
1446
1360
  )
1447
1361
  n += 1
1362
+ # Scope hygiene
1363
+ if _health_mod is not None and hasattr(_health_mod, "detect_scope_risks"):
1364
+ try:
1365
+ scope = _health_mod.detect_scope_risks(root) or {}
1366
+ for w in (scope.get("warnings") or [])[:2]:
1367
+ suggestions.append(f"{n}. SCOPE: {w}")
1368
+ n += 1
1369
+ except Exception:
1370
+ pass
1448
1371
  suggestions.append(
1449
1372
  f"{n}. Run `update_maps(directory=...)` only if imports/structure changed (not for wiki-only edits)."
1450
1373
  )
@@ -1453,7 +1376,10 @@ def suggest_next_actions(
1453
1376
  f"{n}. On yellow/red hotspots, query dependents (get_dependents) before editing callers."
1454
1377
  )
1455
1378
  n += 1
1456
- suggestions.append(f"{n}. Review journal/ for recent record-change intent (self-audit).")
1379
+ suggestions.append(
1380
+ f"{n}. Long-horizon: `wikifier autonomous-status` before unattended daemon; "
1381
+ "lean monitored_paths; never parent multi-repo folders as project_root."
1382
+ )
1457
1383
 
1458
1384
  acs_note = ""
1459
1385
  if _ic_mod is not None:
@@ -1488,10 +1414,14 @@ def suggest_next_actions(
1488
1414
  "project_root": str(root),
1489
1415
  "red": red,
1490
1416
  "yellow": yellow,
1417
+ "stub_yellow": stub_y,
1418
+ "actionable_yellow": action_y,
1419
+ "health_score": health_sum.get("health_score"),
1491
1420
  "suggestions": suggestions,
1492
1421
  "health_summary": health_sum,
1493
1422
  "acs_note": acs_note,
1494
1423
  "selective_work": True,
1424
+ "map_first": True,
1495
1425
  }
1496
1426
  return "\n".join(suggestions) + (acs_note or "")
1497
1427
  except Exception as e: