wikifier 4.5.6__tar.gz → 4.5.8__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 (53) hide show
  1. {wikifier-4.5.6/wikifier.egg-info → wikifier-4.5.8}/PKG-INFO +1 -1
  2. {wikifier-4.5.6 → wikifier-4.5.8}/skills/run.md +15 -8
  3. {wikifier-4.5.6 → wikifier-4.5.8}/tests/test_gap_closure.py +78 -0
  4. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/__init__.py +1 -1
  5. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/cli.py +68 -11
  6. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/daemon.py +113 -21
  7. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/health.py +423 -9
  8. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/mcp/server.py +38 -8
  9. {wikifier-4.5.6 → wikifier-4.5.8/wikifier.egg-info}/PKG-INFO +1 -1
  10. {wikifier-4.5.6 → wikifier-4.5.8}/CONTRIBUTING.md +0 -0
  11. {wikifier-4.5.6 → wikifier-4.5.8}/LICENSE +0 -0
  12. {wikifier-4.5.6 → wikifier-4.5.8}/MANIFEST.in +0 -0
  13. {wikifier-4.5.6 → wikifier-4.5.8}/README.md +0 -0
  14. {wikifier-4.5.6 → wikifier-4.5.8}/diagnostics.html +0 -0
  15. {wikifier-4.5.6 → wikifier-4.5.8}/docs/spec.md +0 -0
  16. {wikifier-4.5.6 → wikifier-4.5.8}/index.html +0 -0
  17. {wikifier-4.5.6 → wikifier-4.5.8}/pyproject.toml +0 -0
  18. {wikifier-4.5.6 → wikifier-4.5.8}/setup.cfg +0 -0
  19. {wikifier-4.5.6 → wikifier-4.5.8}/tests/test_barrel_invalidation.py +0 -0
  20. {wikifier-4.5.6 → wikifier-4.5.8}/tests/test_health.py +0 -0
  21. {wikifier-4.5.6 → wikifier-4.5.8}/tests/test_import_cache.py +0 -0
  22. {wikifier-4.5.6 → wikifier-4.5.8}/tests/test_multi_lang_parsers.py +0 -0
  23. {wikifier-4.5.6 → wikifier-4.5.8}/tests/test_parsers.py +0 -0
  24. {wikifier-4.5.6 → wikifier-4.5.8}/tests/test_selftest_wrappers.py +0 -0
  25. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/__main__.py +0 -0
  26. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/contracts.py +0 -0
  27. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/diagnostics.py +0 -0
  28. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/import_cache.py +0 -0
  29. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/index.html +0 -0
  30. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/library.py +0 -0
  31. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/locking.py +0 -0
  32. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/mcp/__init__.py +0 -0
  33. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/parsers/__init__.py +0 -0
  34. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/parsers/_edge.py +0 -0
  35. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/parsers/bree.py +0 -0
  36. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/parsers/c_cpp.py +0 -0
  37. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/parsers/cdia.py +0 -0
  38. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/parsers/csharp.py +0 -0
  39. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/parsers/go_lang.py +0 -0
  40. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/parsers/java.py +0 -0
  41. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/parsers/javascript.py +0 -0
  42. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/parsers/python.py +0 -0
  43. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/parsers/rust.py +0 -0
  44. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/resolution.py +0 -0
  45. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/scripts/wikifier.bat +0 -0
  46. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/scripts/wikifier.ps1 +0 -0
  47. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/scripts/wikifier.sh +0 -0
  48. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier/serve.py +0 -0
  49. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier.egg-info/SOURCES.txt +0 -0
  50. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier.egg-info/dependency_links.txt +0 -0
  51. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier.egg-info/entry_points.txt +0 -0
  52. {wikifier-4.5.6 → wikifier-4.5.8}/wikifier.egg-info/requires.txt +0 -0
  53. {wikifier-4.5.6 → wikifier-4.5.8}/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.8
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.
@@ -190,6 +190,84 @@ class TestPendingAndPollutionHygiene(TempProjectTestCase):
190
190
  self.assertIn("gone_real.py", entries2)
191
191
 
192
192
 
193
+ class TestMapFirstTaxonomyAndReadiness(TempProjectTestCase):
194
+ def test_stub_yellow_is_map_ready_not_needs_attention(self):
195
+ health_mod = importlib.import_module("wikifier.health")
196
+ health_mod.upsert_entry(
197
+ self.root, "a.py", "🟡 Yellow",
198
+ "Initial stub — present in dependency map; agent should wiki + mark-green when editing",
199
+ )
200
+ health_mod.upsert_entry(
201
+ self.root, "b.py", "🟡 Yellow",
202
+ "Initial stub — parseable source under monitored_paths; agent should wiki + mark-green when editing",
203
+ )
204
+ s = health_mod.get_summary(self.root)
205
+ self.assertEqual(s.get("stub_yellow"), 2)
206
+ self.assertEqual(s.get("actionable_yellow"), 0)
207
+ self.assertEqual(s.get("health_score"), "Map Ready")
208
+
209
+ def test_actionable_yellow_needs_attention(self):
210
+ health_mod = importlib.import_module("wikifier.health")
211
+ health_mod.upsert_entry(
212
+ self.root, "a.py", "🟡 Yellow",
213
+ "mtime changed since last check-changes (Python primary auto-detected)",
214
+ )
215
+ s = health_mod.get_summary(self.root)
216
+ self.assertEqual(s.get("actionable_yellow"), 1)
217
+ self.assertEqual(s.get("health_score"), "Needs Attention")
218
+
219
+ def test_suggest_does_not_bulk_wiki_stubs(self):
220
+ health_mod = importlib.import_module("wikifier.health")
221
+ health_mod.upsert_entry(self.root, "a.py", "🟡 Yellow", "Initial stub — present in dependency map")
222
+ text = cli.suggest_next_actions(project_root=self.root, format="text")
223
+ self.assertIn("Map-first", text)
224
+ self.assertNotIn("Review the 1 🟡 Yellow file(s) only", text)
225
+
226
+ def test_detect_multi_project_container(self):
227
+ health_mod = importlib.import_module("wikifier.health")
228
+ for name in ("proj_a", "proj_b", "proj_c"):
229
+ d = self.root / name
230
+ d.mkdir()
231
+ (d / ".git").mkdir()
232
+ scope = health_mod.detect_scope_risks(self.root)
233
+ self.assertGreaterEqual(scope.get("child_project_count"), 3)
234
+ self.assertFalse(scope.get("ok"))
235
+ self.assertTrue(any("multi-project" in w.lower() for w in scope.get("warnings") or []))
236
+
237
+ def test_autonomous_readiness_shape(self):
238
+ health_mod = importlib.import_module("wikifier.health")
239
+ (self.root / "monitored_paths.txt").write_text("src\n", encoding="utf-8")
240
+ self.write("src/app.py", "x=1\n")
241
+ health_mod.upsert_entry(self.root, "src/app.py", "🟡 Yellow", "Initial stub — present in dependency map")
242
+ r = health_mod.assess_autonomous_readiness(self.root)
243
+ self.assertTrue(r.get("success"))
244
+ self.assertIn(r.get("readiness"), (
245
+ "blocked", "map_ok_scope_risk", "ready_for_daemon",
246
+ "ready_with_agent_wiki_work", "not_ready",
247
+ ))
248
+ self.assertIn("long_horizon_note", r)
249
+ self.assertIn("metrics", r)
250
+
251
+ def test_write_metrics_snapshot_history(self):
252
+ health_mod = importlib.import_module("wikifier.health")
253
+ (self.root / "monitored_paths.txt").write_text("src\n", encoding="utf-8")
254
+ self.write("src/app.py", "x=1\n")
255
+ health_mod.upsert_entry(self.root, "src/app.py", "🟢 Green", "ok")
256
+ s1 = health_mod.write_metrics_snapshot(self.root, source="test1")
257
+ self.assertTrue(s1.get("success"))
258
+ latest = self.root / ".wikifier_staging" / "metrics_latest.json"
259
+ hist = self.root / ".wikifier_staging" / "metrics_history.jsonl"
260
+ self.assertTrue(latest.exists())
261
+ self.assertTrue(hist.exists())
262
+ s2 = health_mod.write_metrics_snapshot(self.root, source="test2")
263
+ self.assertTrue(s2.get("success"))
264
+ rows = health_mod.read_metrics_history(self.root, limit=10)
265
+ self.assertGreaterEqual(len(rows), 2)
266
+ self.assertEqual(rows[-1].get("source"), "test2")
267
+ self.assertIn("staging_bytes", rows[-1])
268
+ self.assertIn("health_score", rows[-1])
269
+
270
+
193
271
  class TestMapFirstHealthAndValidate(TempProjectTestCase):
194
272
  def test_deep_relative_keys_are_under_root(self):
195
273
  """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.8"
@@ -728,18 +728,20 @@ def main():
728
728
  i = 0
729
729
  while i < len(argv):
730
730
  arg = argv[i]
731
+ # Consume --target / --project-root so the *command* remains filtered_argv[0]
732
+ # (previously left --target as argv[0] → "Unknown command: --target").
731
733
  if arg in ("--target", "--project-root") and i + 1 < len(argv):
732
734
  project_root = argv[i + 1]
733
- filtered_argv.append(arg)
734
- filtered_argv.append(argv[i + 1])
735
735
  i += 2
736
736
  continue
737
737
  elif arg.startswith("--target="):
738
738
  project_root = arg.split("=", 1)[1]
739
- filtered_argv.append(arg)
739
+ i += 1
740
+ continue
740
741
  elif arg.startswith("--project-root="):
741
742
  project_root = arg.split("=", 1)[1]
742
- filtered_argv.append(arg)
743
+ i += 1
744
+ continue
743
745
  elif arg in ("--use-canonical", "--use_canonical"):
744
746
  use_canonical = True
745
747
  filtered_argv.append(arg)
@@ -895,6 +897,30 @@ def main():
895
897
  res = _health_mod.prune_health_outside_monitored(root)
896
898
  print(_json.dumps(res, indent=2, default=str))
897
899
  return 0 if res.get("success") else 1
900
+ if _cmd0 in (
901
+ "autonomous-status",
902
+ "autonomous_status",
903
+ "readiness",
904
+ "long-horizon",
905
+ ):
906
+ import json as _json
907
+ if _health_mod is None or not hasattr(_health_mod, "assess_autonomous_readiness"):
908
+ print("[wikifier] health.assess_autonomous_readiness unavailable", file=sys.stderr)
909
+ return 1
910
+ root = _get_effective_root(project_root)
911
+ res = _health_mod.assess_autonomous_readiness(root)
912
+ print(_json.dumps(res, indent=2, default=str))
913
+ # exit 0 only when not blocked
914
+ return 0 if res.get("readiness") != "blocked" else 2
915
+ if _cmd0 in ("metrics-snapshot", "metrics_snapshot", "metrics"):
916
+ import json as _json
917
+ if _health_mod is None or not hasattr(_health_mod, "write_metrics_snapshot"):
918
+ print("[wikifier] write_metrics_snapshot unavailable", file=sys.stderr)
919
+ return 1
920
+ root = _get_effective_root(project_root)
921
+ res = _health_mod.write_metrics_snapshot(root, source="cli")
922
+ print(_json.dumps(res, indent=2, default=str))
923
+ return 0 if res.get("success") else 1
898
924
  except Exception as e:
899
925
  print(f"[wikifier] Python-primary {_cmd0} failed: {e}", file=sys.stderr)
900
926
  return 1
@@ -1419,12 +1445,14 @@ def suggest_next_actions(
1419
1445
  """
1420
1446
  root = _get_effective_root(project_root)
1421
1447
  try:
1422
- red = yellow = 0
1448
+ red = yellow = stub_y = action_y = 0
1423
1449
  health_sum: Dict[str, Any] = {}
1424
1450
  if _health_mod is not None:
1425
1451
  health_sum = _health_mod.get_summary(root, directory) or {}
1426
- red = health_sum.get("red", 0)
1427
- yellow = health_sum.get("yellow", 0)
1452
+ red = int(health_sum.get("red", 0) or 0)
1453
+ yellow = int(health_sum.get("yellow", 0) or 0)
1454
+ stub_y = int(health_sum.get("stub_yellow", 0) or 0)
1455
+ action_y = int(health_sum.get("actionable_yellow", yellow - stub_y) or 0)
1428
1456
 
1429
1457
  suggestions: List[str] = []
1430
1458
  n = 1
@@ -1434,10 +1462,23 @@ def suggest_next_actions(
1434
1462
  "Do not re-wiki 🟢 Green files."
1435
1463
  )
1436
1464
  n += 1
1437
- if yellow > 0:
1465
+ if action_y > 0:
1466
+ suggestions.append(
1467
+ f"{n}. Review {action_y} *actionable* 🟡 Yellow file(s) "
1468
+ "(mtime/record-change/barrel — not Initial stubs). "
1469
+ "record-change → wiki that file → mark-green. Skip green."
1470
+ )
1471
+ n += 1
1472
+ if stub_y > 0 and action_y == 0 and red == 0:
1473
+ suggestions.append(
1474
+ f"{n}. Map-first OK: {stub_y} 🟡 Initial stubs mean \"on the map\", "
1475
+ "NOT \"wiki this tree now\". Lookup via get_file_wiki/deps; "
1476
+ "write prose only when you edit a file, then mark-green."
1477
+ )
1478
+ n += 1
1479
+ elif stub_y > 0 and action_y > 0:
1438
1480
  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."
1481
+ f"{n}. Ignore {stub_y} map-first stubs for bulk work; only actionable yellows need wiki."
1441
1482
  )
1442
1483
  n += 1
1443
1484
  if red == 0 and yellow == 0:
@@ -1445,6 +1486,15 @@ def suggest_next_actions(
1445
1486
  f"{n}. Health is clean (no red/yellow). Do not re-summarize the tree; use the map for lookup only."
1446
1487
  )
1447
1488
  n += 1
1489
+ # Scope hygiene
1490
+ if _health_mod is not None and hasattr(_health_mod, "detect_scope_risks"):
1491
+ try:
1492
+ scope = _health_mod.detect_scope_risks(root) or {}
1493
+ for w in (scope.get("warnings") or [])[:2]:
1494
+ suggestions.append(f"{n}. SCOPE: {w}")
1495
+ n += 1
1496
+ except Exception:
1497
+ pass
1448
1498
  suggestions.append(
1449
1499
  f"{n}. Run `update_maps(directory=...)` only if imports/structure changed (not for wiki-only edits)."
1450
1500
  )
@@ -1453,7 +1503,10 @@ def suggest_next_actions(
1453
1503
  f"{n}. On yellow/red hotspots, query dependents (get_dependents) before editing callers."
1454
1504
  )
1455
1505
  n += 1
1456
- suggestions.append(f"{n}. Review journal/ for recent record-change intent (self-audit).")
1506
+ suggestions.append(
1507
+ f"{n}. Long-horizon: `wikifier autonomous-status` before unattended daemon; "
1508
+ "lean monitored_paths; never parent multi-repo folders as project_root."
1509
+ )
1457
1510
 
1458
1511
  acs_note = ""
1459
1512
  if _ic_mod is not None:
@@ -1488,10 +1541,14 @@ def suggest_next_actions(
1488
1541
  "project_root": str(root),
1489
1542
  "red": red,
1490
1543
  "yellow": yellow,
1544
+ "stub_yellow": stub_y,
1545
+ "actionable_yellow": action_y,
1546
+ "health_score": health_sum.get("health_score"),
1491
1547
  "suggestions": suggestions,
1492
1548
  "health_summary": health_sum,
1493
1549
  "acs_note": acs_note,
1494
1550
  "selective_work": True,
1551
+ "map_first": True,
1495
1552
  }
1496
1553
  return "\n".join(suggestions) + (acs_note or "")
1497
1554
  except Exception as e:
@@ -166,6 +166,42 @@ def log(msg: str, also_to_file: bool = True) -> None:
166
166
  # Core check runner (used by both initial, periodic, and post-sleep paths)
167
167
  # =============================================================================
168
168
 
169
+ def _write_heartbeat(event: str, ok: bool, detail: str = "") -> None:
170
+ """Persist last daemon success/failure for autonomous-status / long-horizon ops."""
171
+ try:
172
+ import json as _json
173
+ path = ensure_state_dir() / "daemon_heartbeat.json"
174
+ prev = {}
175
+ if path.exists():
176
+ try:
177
+ prev = _json.loads(path.read_text(encoding="utf-8")) or {}
178
+ except Exception:
179
+ prev = {}
180
+ fail_streak = int(prev.get("consecutive_failures", 0) or 0)
181
+ if ok:
182
+ fail_streak = 0
183
+ else:
184
+ fail_streak += 1
185
+ payload = {
186
+ "ts": datetime.now().isoformat(timespec="seconds"),
187
+ "event": event,
188
+ "ok": ok,
189
+ "detail": (detail or "")[:400],
190
+ "consecutive_failures": fail_streak,
191
+ "project_root": str(
192
+ (discover_project_root() if discover_project_root else Path.cwd()).resolve()
193
+ ),
194
+ }
195
+ path.write_text(_json.dumps(payload, indent=2) + "\n", encoding="utf-8")
196
+ if fail_streak >= 5:
197
+ log(
198
+ f"LONG-HORIZON WARN: {fail_streak} consecutive daemon step failures — "
199
+ "inspect daemon.log; consider lean monitored_paths / WIKIFIER_DAEMON_MAPS=0"
200
+ )
201
+ except Exception:
202
+ pass
203
+
204
+
169
205
  def _run_check_changes(reason: str) -> None:
170
206
  """
171
207
  Run the check-changes command in a robust way.
@@ -185,16 +221,18 @@ def _run_check_changes(reason: str) -> None:
185
221
  env=os.environ.copy(),
186
222
  )
187
223
  if result.returncode != 0:
188
- log(
189
- f"check-changes failed (reason={reason}): "
190
- f"{(result.stderr or '')[:500]}"
191
- )
224
+ msg = (result.stderr or "")[:500]
225
+ log(f"check-changes failed (reason={reason}): {msg}")
226
+ _write_heartbeat(f"check-changes:{reason}", False, msg)
192
227
  return
193
228
  log(f"check-changes completed (reason={reason})")
229
+ _write_heartbeat(f"check-changes:{reason}", True)
194
230
  except subprocess.TimeoutExpired:
195
231
  log(f"check-changes timed out (reason={reason})")
232
+ _write_heartbeat(f"check-changes:{reason}", False, "timeout")
196
233
  except Exception as e:
197
234
  log(f"check-changes error (reason={reason}): {e}")
235
+ _write_heartbeat(f"check-changes:{reason}", False, str(e))
198
236
 
199
237
 
200
238
  def _run_python_primary_update(reason: str, force_full: bool = False) -> None:
@@ -215,13 +253,20 @@ def _run_python_primary_update(reason: str, force_full: bool = False) -> None:
215
253
  files = res.get("files_to_reparse", 0)
216
254
  persisted = res.get("sample_persisted_pairs", 0)
217
255
  tied = res.get("barrel_creative_tied_in_pure_path", False)
256
+ ok = bool(res.get("success", True))
218
257
  log(
219
258
  f"python-primary update completed (reason={reason}): "
220
259
  f"files_to_reparse={files} persisted_pairs={persisted} barrel_creative_tied={tied} "
221
- f"root={res.get('root')}"
260
+ f"root={res.get('root')} success={ok}"
261
+ )
262
+ _write_heartbeat(
263
+ f"update-maps:{reason}",
264
+ ok,
265
+ f"parsed={res.get('files_parsed')} edges={res.get('edges_persisted')}",
222
266
  )
223
267
  except Exception as e:
224
268
  log(f"python-primary update error (reason={reason}): {e}")
269
+ _write_heartbeat(f"update-maps:{reason}", False, str(e))
225
270
 
226
271
 
227
272
  # =============================================================================
@@ -246,29 +291,76 @@ def daemon_loop() -> None:
246
291
 
247
292
  last_check = time.time()
248
293
  last_maps = 0.0
294
+ last_metrics = 0.0
295
+ # Metrics cadence (seconds); default 1h for soak growth without spam
296
+ try:
297
+ metrics_iv = max(0, int(os.environ.get("WIKIFIER_DAEMON_METRICS_INTERVAL", "3600") or "3600"))
298
+ except ValueError:
299
+ metrics_iv = 3600
300
+
301
+ def _maybe_metrics(reason: str, force: bool = False) -> None:
302
+ nonlocal last_metrics
303
+ if metrics_iv <= 0 and not force:
304
+ return
305
+ now_m = time.time()
306
+ if not force and metrics_iv > 0 and (now_m - last_metrics) < metrics_iv:
307
+ return
308
+ try:
309
+ from . import health as health_mod
310
+ if hasattr(health_mod, "write_metrics_snapshot"):
311
+ root = None
312
+ if discover_project_root is not None:
313
+ try:
314
+ root = discover_project_root()
315
+ except Exception:
316
+ root = None
317
+ snap = health_mod.write_metrics_snapshot(
318
+ root or Path.cwd(),
319
+ source=f"daemon:{reason}",
320
+ )
321
+ log(
322
+ f"metrics-snapshot (reason={reason}): "
323
+ f"staging_bytes={snap.get('staging_bytes')} "
324
+ f"score={snap.get('health_score')} ok={snap.get('success')}"
325
+ )
326
+ last_metrics = now_m
327
+ except Exception as me:
328
+ log(f"metrics-snapshot error (reason={reason}): {me}")
329
+
249
330
  _run_check_changes("initial start / resume")
250
331
  if maps_on:
251
332
  _run_python_primary_update("initial start / resume", force_full=False)
252
333
  last_maps = time.time()
334
+ _maybe_metrics("initial", force=True)
253
335
 
254
336
  while True:
255
- time.sleep(POLL_INTERVAL_DEFAULT)
256
-
257
- now = time.time()
258
- gap = now - last_check
259
-
260
- if gap > SLEEP_THRESHOLD_SECONDS:
261
- log(f"Wake detected (gap of {int(gap)}s). Forced check-changes + optional maps.")
262
- _run_check_changes("post-sleep resume")
263
- if maps_on:
264
- _run_python_primary_update("post-sleep resume", force_full=False)
337
+ try:
338
+ time.sleep(POLL_INTERVAL_DEFAULT)
339
+
340
+ now = time.time()
341
+ gap = now - last_check
342
+
343
+ if gap > SLEEP_THRESHOLD_SECONDS:
344
+ log(f"Wake detected (gap of {int(gap)}s). Forced check-changes + optional maps.")
345
+ _run_check_changes("post-sleep resume")
346
+ if maps_on:
347
+ _run_python_primary_update("post-sleep resume", force_full=False)
348
+ last_maps = time.time()
349
+ _maybe_metrics("post-sleep", force=True)
350
+
351
+ _run_check_changes("periodic")
352
+ if maps_on and maps_iv > 0 and (time.time() - last_maps) >= maps_iv:
353
+ _run_python_primary_update("periodic maps interval", force_full=False)
265
354
  last_maps = time.time()
266
-
267
- _run_check_changes("periodic")
268
- if maps_on and maps_iv > 0 and (time.time() - last_maps) >= maps_iv:
269
- _run_python_primary_update("periodic maps interval", force_full=False)
270
- last_maps = time.time()
271
- last_check = time.time()
355
+ _maybe_metrics("periodic")
356
+ last_check = time.time()
357
+ except KeyboardInterrupt:
358
+ raise
359
+ except Exception as loop_err:
360
+ # Never die on a single cycle — long-horizon autonomous ops requirement
361
+ log(f"daemon loop error (continuing): {loop_err}")
362
+ _write_heartbeat("loop", False, str(loop_err))
363
+ time.sleep(POLL_INTERVAL_DEFAULT)
272
364
 
273
365
 
274
366
  # =============================================================================
@@ -3,13 +3,15 @@ Documentation health matrix (agent-first).
3
3
 
4
4
  AGENT MAP:
5
5
  load_health / save_health / upsert_entry — file_health.json SSOT (+ regenerate .md)
6
- get_summary / get_files_needing_attention — 🟢🟡🔴 counts and lists
6
+ get_summary — 🟢🟡🔴 + stub_yellow/actionable_yellow + health_score (Map Ready|…)
7
+ assess_autonomous_readiness / detect_scope_risks — long-horizon + dual-scope
7
8
  find_ghost_entries / validate_health — map-first gaps + ghosts (parseable sources only)
8
9
  seed_health_from_map — backfill 🟡 stubs from import_cache (warm-cache safe)
9
10
  prune_pending_to_monitored / prune_health_outside_monitored — lean-monitor hygiene
10
11
  mark_green / heal_stubs / apply_barrel_invalidation_reports — status mutations
11
- CLI: seed-health | prune-pending | prune-health | validate | health --summary
12
+ CLI: autonomous-status | seed-health | prune-pending | prune-health | validate
12
13
  Agents: prefer MCP health / check_changes / mark_green; only open this for matrix policy.
14
+ Map Ready ≠ wiki-done — never bulk-wiki Initial stubs.
13
15
 
14
16
  JSON Schema (v2 - additive from v1):
15
17
  {
@@ -1149,6 +1151,359 @@ def validate_health(root: Path) -> Dict[str, Any]:
1149
1151
  }
1150
1152
 
1151
1153
 
1154
+ def detect_scope_risks(root: Path) -> Dict[str, Any]:
1155
+ """Dual-scope + multi-project root misuse detector (agent hygiene)."""
1156
+ root = _coerce_root(root)
1157
+ warnings: List[str] = []
1158
+ monitored = _read_monitored_rel_roots(root)
1159
+ bare_dot = monitored == ["."] or monitored == []
1160
+ if bare_dot:
1161
+ warnings.append(
1162
+ "monitored_paths is bare '.' — check-changes will thrash on large trees. "
1163
+ "Prefer lean package roots (e.g. src/, library/std/src)."
1164
+ )
1165
+
1166
+ # Parent multi-project container heuristic
1167
+ child_projects = 0
1168
+ sample_children: List[str] = []
1169
+ try:
1170
+ for p in root.iterdir():
1171
+ if not p.is_dir() or p.name.startswith("."):
1172
+ continue
1173
+ if (p / ".git").exists() or (p / "file_health.json").exists() or (
1174
+ p / ".wikifier_staging" / "import_cache.json"
1175
+ ).exists():
1176
+ child_projects += 1
1177
+ if len(sample_children) < 8:
1178
+ sample_children.append(p.name)
1179
+ except Exception:
1180
+ pass
1181
+ if child_projects >= 3:
1182
+ warnings.append(
1183
+ f"project_root looks like a multi-project container ({child_projects} child "
1184
+ f"projects e.g. {sample_children}). Always target a *child* project path, "
1185
+ "never the parent folder (e.g. cloned_sample_projects)."
1186
+ )
1187
+ name = root.name.lower()
1188
+ if name in ("cloned_sample_projects", "coding_projects", "repos", "samples"):
1189
+ warnings.append(
1190
+ f"Directory name '{root.name}' often holds many repos — confirm project_root "
1191
+ "is a single project, not the container."
1192
+ )
1193
+
1194
+ return {
1195
+ "monitored_roots": monitored,
1196
+ "bare_dot_monitor": bare_dot,
1197
+ "child_project_count": child_projects,
1198
+ "child_project_sample": sample_children,
1199
+ "warnings": warnings,
1200
+ "ok": len(warnings) == 0,
1201
+ }
1202
+
1203
+
1204
+ def _staging_byte_total(staging: Path, cache_path: Path) -> int:
1205
+ if not staging.exists():
1206
+ return 0
1207
+ total = 0
1208
+ try:
1209
+ for p in staging.rglob("*"):
1210
+ if p.is_file():
1211
+ try:
1212
+ total += p.stat().st_size
1213
+ except Exception:
1214
+ pass
1215
+ except Exception:
1216
+ try:
1217
+ return cache_path.stat().st_size if cache_path.exists() else 0
1218
+ except Exception:
1219
+ return 0
1220
+ return total
1221
+
1222
+
1223
+ def write_metrics_snapshot(
1224
+ root: Path,
1225
+ source: str = "manual",
1226
+ extra: Optional[Dict[str, Any]] = None,
1227
+ ) -> Dict[str, Any]:
1228
+ """Append a long-horizon metrics sample under .wikifier_staging/.
1229
+
1230
+ Files:
1231
+ - metrics_latest.json (last sample, overwrite)
1232
+ - metrics_history.jsonl (append-only; pruned to last 500 lines)
1233
+
1234
+ Call from CLI, readiness assessment, or daemon periodic ticks.
1235
+ Does not claim multi-day soak; enables measurable growth over calendar time.
1236
+ """
1237
+ root = _coerce_root(root)
1238
+ staging = root / ".wikifier_staging"
1239
+ staging.mkdir(parents=True, exist_ok=True)
1240
+ cache_path = staging / "import_cache.json"
1241
+ summary = get_summary(root)
1242
+ scope = detect_scope_risks(root)
1243
+ ghosts = find_ghost_entries(root)
1244
+ cache_bytes = cache_path.stat().st_size if cache_path.exists() else 0
1245
+ staging_bytes = _staging_byte_total(staging, cache_path)
1246
+ journal_files = 0
1247
+ journal_root = root / "journal"
1248
+ if journal_root.exists():
1249
+ journal_files = sum(1 for _ in journal_root.rglob("*.md"))
1250
+
1251
+ heartbeat = None
1252
+ hb_path = staging / "daemon_heartbeat.json"
1253
+ if hb_path.exists():
1254
+ try:
1255
+ heartbeat = json.loads(hb_path.read_text(encoding="utf-8"))
1256
+ except Exception:
1257
+ heartbeat = {"error": "unreadable"}
1258
+
1259
+ snap: Dict[str, Any] = {
1260
+ "ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
1261
+ "source": source,
1262
+ "root": str(root),
1263
+ "health_score": summary.get("health_score"),
1264
+ "total": summary.get("total"),
1265
+ "green": summary.get("green"),
1266
+ "yellow": summary.get("yellow"),
1267
+ "red": summary.get("red"),
1268
+ "stub_yellow": summary.get("stub_yellow"),
1269
+ "actionable_yellow": summary.get("actionable_yellow"),
1270
+ "pending_updates": summary.get("pending_updates"),
1271
+ "ghost_count": len(ghosts),
1272
+ "cache_bytes": cache_bytes,
1273
+ "staging_bytes": staging_bytes,
1274
+ "journal_files": journal_files,
1275
+ "bare_dot_monitor": scope.get("bare_dot_monitor"),
1276
+ "scope_ok": scope.get("ok"),
1277
+ "daemon_heartbeat_ok": (heartbeat or {}).get("ok") if isinstance(heartbeat, dict) else None,
1278
+ "daemon_fail_streak": (heartbeat or {}).get("consecutive_failures")
1279
+ if isinstance(heartbeat, dict)
1280
+ else None,
1281
+ }
1282
+ if extra:
1283
+ snap["extra"] = extra
1284
+
1285
+ latest_path = staging / "metrics_latest.json"
1286
+ hist_path = staging / "metrics_history.jsonl"
1287
+ try:
1288
+ latest_path.write_text(json.dumps(snap, indent=2) + "\n", encoding="utf-8")
1289
+ with open(hist_path, "a", encoding="utf-8") as f:
1290
+ f.write(json.dumps(snap, ensure_ascii=False) + "\n")
1291
+ # Bound history (last 500 samples)
1292
+ try:
1293
+ lines = hist_path.read_text(encoding="utf-8").splitlines()
1294
+ if len(lines) > 500:
1295
+ hist_path.write_text("\n".join(lines[-500:]) + "\n", encoding="utf-8")
1296
+ except Exception:
1297
+ pass
1298
+ snap["success"] = True
1299
+ snap["metrics_latest_path"] = str(latest_path)
1300
+ snap["metrics_history_path"] = str(hist_path)
1301
+ except Exception as e:
1302
+ snap["success"] = False
1303
+ snap["error"] = str(e)
1304
+ return snap
1305
+
1306
+
1307
+ def read_metrics_history(root: Path, limit: int = 20) -> List[Dict[str, Any]]:
1308
+ """Return up to `limit` most recent metrics samples (newest last)."""
1309
+ root = _coerce_root(root)
1310
+ hist_path = root / ".wikifier_staging" / "metrics_history.jsonl"
1311
+ if not hist_path.exists():
1312
+ return []
1313
+ out: List[Dict[str, Any]] = []
1314
+ try:
1315
+ lines = hist_path.read_text(encoding="utf-8").splitlines()
1316
+ for ln in lines[-max(1, min(limit, 500)) :]:
1317
+ ln = ln.strip()
1318
+ if not ln:
1319
+ continue
1320
+ try:
1321
+ out.append(json.loads(ln))
1322
+ except Exception:
1323
+ continue
1324
+ except Exception:
1325
+ return []
1326
+ return out
1327
+
1328
+
1329
+ def assess_autonomous_readiness(
1330
+ root: Path,
1331
+ write_metrics: bool = True,
1332
+ ) -> Dict[str, Any]:
1333
+ """Long-horizon autonomous ops checklist (map + daemon + scope + state).
1334
+
1335
+ Does not replace multi-day soak evidence; surfaces *whether* a target is
1336
+ configured safely enough for unattended check-changes/daemon loops.
1337
+ Optionally appends a metrics snapshot for growth tracking over calendar time.
1338
+ """
1339
+ root = _coerce_root(root)
1340
+ summary = get_summary(root)
1341
+ scope = detect_scope_risks(root)
1342
+ ghosts = find_ghost_entries(root)
1343
+ pending_n = count_pending(root)
1344
+
1345
+ staging = root / ".wikifier_staging"
1346
+ cache_path = staging / "import_cache.json"
1347
+ lock_path = staging / ".lock"
1348
+ pid_path = staging / "wikifier.pid"
1349
+ heartbeat_path = staging / "daemon_heartbeat.json"
1350
+ log_path = staging / "daemon.log"
1351
+ metrics_latest_path = staging / "metrics_latest.json"
1352
+
1353
+ cache_bytes = cache_path.stat().st_size if cache_path.exists() else 0
1354
+ staging_bytes = _staging_byte_total(staging, cache_path)
1355
+
1356
+ journal_root = root / "journal"
1357
+ journal_days = 0
1358
+ journal_files = 0
1359
+ if journal_root.exists():
1360
+ for p in journal_root.rglob("*.md"):
1361
+ journal_files += 1
1362
+ try:
1363
+ journal_days = len({p.parent for p in journal_root.rglob("*.md")})
1364
+ except Exception:
1365
+ pass
1366
+
1367
+ daemon_running = False
1368
+ daemon_pid = None
1369
+ if pid_path.exists():
1370
+ try:
1371
+ daemon_pid = int(pid_path.read_text(encoding="utf-8").strip())
1372
+ os.kill(daemon_pid, 0)
1373
+ daemon_running = True
1374
+ except Exception:
1375
+ daemon_running = False
1376
+
1377
+ heartbeat = None
1378
+ if heartbeat_path.exists():
1379
+ try:
1380
+ with open(heartbeat_path, "r", encoding="utf-8") as f:
1381
+ heartbeat = json.load(f)
1382
+ except Exception:
1383
+ heartbeat = {"error": "unreadable"}
1384
+
1385
+ has_health = (root / "file_health.json").exists() or (root / "file_health.md").exists()
1386
+ has_library = (root / "library.md").exists()
1387
+ has_cache = cache_path.exists()
1388
+
1389
+ metrics_snap = None
1390
+ if write_metrics:
1391
+ try:
1392
+ metrics_snap = write_metrics_snapshot(root, source="autonomous-status")
1393
+ except Exception as e:
1394
+ metrics_snap = {"success": False, "error": str(e)}
1395
+
1396
+ history = read_metrics_history(root, limit=5)
1397
+ growth_note = None
1398
+ if len(history) >= 2:
1399
+ try:
1400
+ a, b = history[0], history[-1]
1401
+ da = int(b.get("staging_bytes") or 0) - int(a.get("staging_bytes") or 0)
1402
+ growth_note = {
1403
+ "samples": len(history),
1404
+ "staging_bytes_delta": da,
1405
+ "first_ts": a.get("ts"),
1406
+ "last_ts": b.get("ts"),
1407
+ }
1408
+ except Exception:
1409
+ growth_note = None
1410
+
1411
+ blockers: List[str] = []
1412
+ recs: List[str] = []
1413
+ if not has_cache and not has_library:
1414
+ blockers.append("No import map (run update-maps first).")
1415
+ if not has_health:
1416
+ blockers.append("No file_health — run seed-health or update-maps.")
1417
+ if scope["warnings"]:
1418
+ recs.extend(scope["warnings"])
1419
+ if len(ghosts) > 50:
1420
+ blockers.append(f"Many ghost health keys ({len(ghosts)}); run record-deletion / prune.")
1421
+ elif ghosts:
1422
+ recs.append(f"{len(ghosts)} ghost health entries — clean with record-deletion.")
1423
+ if pending_n > 500:
1424
+ recs.append(
1425
+ f"pending_updates={pending_n} is large; run prune-pending (auto-detected noise)."
1426
+ )
1427
+ if cache_bytes > 200 * 1024 * 1024:
1428
+ recs.append(
1429
+ f"import_cache is large ({cache_bytes // (1024*1024)}MB); "
1430
+ "prefer scoped update-maps and lean monitors for long runs."
1431
+ )
1432
+ if staging_bytes > 500 * 1024 * 1024:
1433
+ recs.append(f".wikifier_staging ~{staging_bytes // (1024*1024)}MB — watch disk growth.")
1434
+ if not daemon_running:
1435
+ recs.append(
1436
+ "Daemon not running. For long-horizon: "
1437
+ "`WIKIFIER_PROJECT_ROOT=… wikifier daemon start` "
1438
+ "(maps interval default 600s; WIKIFIER_DAEMON_MAPS=0 for check-only)."
1439
+ )
1440
+ else:
1441
+ recs.append(f"Daemon running (PID {daemon_pid}).")
1442
+ recs.append(
1443
+ "Metrics: write_metrics_snapshot / `wikifier metrics-snapshot`; "
1444
+ "history at .wikifier_staging/metrics_history.jsonl for soak growth."
1445
+ )
1446
+
1447
+ score = summary.get("health_score") or "Unknown"
1448
+ # Readiness tier for unattended ops (not M5 95% soak claim)
1449
+ if blockers:
1450
+ readiness = "blocked"
1451
+ elif scope["warnings"] and summary.get("actionable_yellow", 0) == 0 and summary.get("red", 0) == 0:
1452
+ readiness = "map_ok_scope_risk"
1453
+ elif score in ("Map Ready", "Good") and not scope["bare_dot_monitor"]:
1454
+ readiness = "ready_for_daemon"
1455
+ elif score == "Needs Attention" and summary.get("red", 0) == 0:
1456
+ readiness = "ready_with_agent_wiki_work"
1457
+ else:
1458
+ readiness = "not_ready"
1459
+
1460
+ return {
1461
+ "success": True,
1462
+ "root": str(root),
1463
+ "readiness": readiness,
1464
+ "health_score": score,
1465
+ "health_summary": summary,
1466
+ "scope": scope,
1467
+ "ghost_count": len(ghosts),
1468
+ "pending_updates": pending_n,
1469
+ "artifacts": {
1470
+ "has_health": has_health,
1471
+ "has_library": has_library,
1472
+ "has_cache": has_cache,
1473
+ "cache_bytes": cache_bytes,
1474
+ "staging_bytes": staging_bytes,
1475
+ "journal_files": journal_files,
1476
+ "journal_day_dirs": journal_days,
1477
+ "metrics_latest": str(metrics_latest_path) if metrics_latest_path.exists() else None,
1478
+ },
1479
+ "daemon": {
1480
+ "running": daemon_running,
1481
+ "pid": daemon_pid,
1482
+ "heartbeat": heartbeat,
1483
+ "log": str(log_path) if log_path.exists() else None,
1484
+ "maps_env": {
1485
+ "WIKIFIER_DAEMON_MAPS": os.environ.get("WIKIFIER_DAEMON_MAPS", "1"),
1486
+ "WIKIFIER_DAEMON_MAPS_INTERVAL": os.environ.get(
1487
+ "WIKIFIER_DAEMON_MAPS_INTERVAL", "600"
1488
+ ),
1489
+ },
1490
+ },
1491
+ "metrics": metrics_snap,
1492
+ "metrics_growth": growth_note,
1493
+ "lock_path": str(lock_path),
1494
+ "blockers": blockers,
1495
+ "recommendations": recs,
1496
+ "long_horizon_note": (
1497
+ "ready_for_daemon = safe config for unattended check-changes/maps heartbeat. "
1498
+ "It is NOT a multi-day 0-corruption soak proof (M5.3). "
1499
+ "For soak: run daemon on 1–3 lean-monitored targets, append metrics-snapshot "
1500
+ "periodically, watch staging_bytes delta in metrics_history.jsonl, "
1501
+ "daemon_heartbeat consecutive_failures, and journal continuity over >=72h."
1502
+ ),
1503
+ "map_first_note": summary.get("map_first_note"),
1504
+ }
1505
+
1506
+
1152
1507
  # ----------------------------- apply_barrel... (continues) -----------------------------
1153
1508
 
1154
1509
  def apply_barrel_invalidation_reports(
@@ -1203,33 +1558,84 @@ def apply_barrel_invalidation_reports(
1203
1558
  return updated
1204
1559
 
1205
1560
 
1561
+ def _is_map_first_stub_entry(entry: Dict[str, Any]) -> bool:
1562
+ """True for map-first Initial stubs (not agent-action work items)."""
1563
+ if not isinstance(entry, dict):
1564
+ return False
1565
+ reason = str(entry.get("reason") or "")
1566
+ prov = str(entry.get("freshness_provenance") or "")
1567
+ blob = (reason + " " + prov).lower()
1568
+ if "initial stub" in blob:
1569
+ return True
1570
+ if "seed_health_from_map" in blob or "seed_health_for_monitored" in blob:
1571
+ return True
1572
+ if "present in dependency map" in blob:
1573
+ return True
1574
+ if "parseable source under monitored" in blob:
1575
+ return True
1576
+ return False
1577
+
1578
+
1579
+ def _compute_health_score(
1580
+ red: int,
1581
+ yellow: int,
1582
+ stub_yellow: int,
1583
+ actionable_yellow: int,
1584
+ ) -> str:
1585
+ """Agent-facing readiness label (map-first aware).
1586
+
1587
+ Map Ready — no reds, only map-first stubs (or empty); do NOT treat as unfinished wiki.
1588
+ Good — clean green-heavy tree, few actionable yellows
1589
+ Needs Attention — actionable yellows / pending work (not mere stubs)
1590
+ Critical — reds
1591
+ """
1592
+ if red >= 3:
1593
+ return "Critical"
1594
+ if red > 0:
1595
+ return "Needs Attention"
1596
+ if actionable_yellow > 0:
1597
+ return "Needs Attention"
1598
+ if yellow > 0 and stub_yellow >= yellow and actionable_yellow == 0:
1599
+ return "Map Ready"
1600
+ if yellow == 0:
1601
+ return "Good"
1602
+ if yellow < 5:
1603
+ return "Good"
1604
+ return "Needs Attention"
1605
+
1606
+
1206
1607
  def get_summary(root: Path, directory: Optional[str] = None, include_stale: bool = False) -> Dict[str, Any]:
1207
1608
  """Return a summary of the health matrix (fast even for large repos; sharded by dir).
1208
1609
 
1209
- If directory is provided, only counts files under that subdirectory.
1210
- This enables scalable views in massive monorepos (e.g. health per package).
1211
- M2 B: include_stale=True adds bounded stale_wiki count (via B3 detector) for
1212
- practical summary-only views without full materialization.
1610
+ Map-first taxonomy (additive fields):
1611
+ stub_yellow Initial stub / map-seed rows (lookup OK; wiki on edit)
1612
+ actionable_yellow real work (mtime, record-change, barrel stale, )
1613
+ health_score \"Map Ready\" | \"Good\" | \"Needs Attention\" | \"Critical\"
1614
+
1615
+ Agents must not treat stub_yellow as \"I understand this file.\"
1213
1616
  """
1214
1617
  health = load_health(root)
1215
- # Phase 5e (66): health.get_summary (format=summary path) promoted as first-class default/recommended for 20k+ creative monorepos (O(k) sharded, complements compute_acs_summary + ACS/CIABRE/BRC bounded via deque; per crit2/5 + 48/58).
1216
1618
  entries = health.get("entries", {})
1217
1619
 
1218
1620
  green = yellow = red = 0
1621
+ stub_yellow = actionable_yellow = 0
1219
1622
  total = 0
1220
1623
 
1221
1624
  for file_path, entry in entries.items():
1222
1625
  if directory:
1223
- # Normalize paths for comparison
1224
1626
  if not file_path.startswith(directory.rstrip('/') + '/'):
1225
1627
  continue
1226
1628
 
1227
1629
  total += 1
1228
- status = entry.get("status", "")
1630
+ status = entry.get("status", "") if isinstance(entry, dict) else ""
1229
1631
  if "🟢" in status:
1230
1632
  green += 1
1231
1633
  elif "🟡" in status:
1232
1634
  yellow += 1
1635
+ if _is_map_first_stub_entry(entry if isinstance(entry, dict) else {}):
1636
+ stub_yellow += 1
1637
+ else:
1638
+ actionable_yellow += 1
1233
1639
  elif "🔴" in status:
1234
1640
  red += 1
1235
1641
 
@@ -1237,12 +1643,20 @@ def get_summary(root: Path, directory: Optional[str] = None, include_stale: bool
1237
1643
  pending_n = count_pending(root)
1238
1644
  except Exception:
1239
1645
  pending_n = 0
1646
+ score = _compute_health_score(red, yellow, stub_yellow, actionable_yellow)
1240
1647
  out = {
1241
1648
  "total": total,
1242
1649
  "green": green,
1243
1650
  "yellow": yellow,
1244
1651
  "red": red,
1652
+ "stub_yellow": stub_yellow,
1653
+ "actionable_yellow": actionable_yellow,
1245
1654
  "pending_updates": pending_n,
1655
+ "health_score": score,
1656
+ "map_first_note": (
1657
+ "Yellow Initial stubs = map coverage only; wiki prose is optional until you edit. "
1658
+ "Prefer actionable_yellow / red for work. health_score Map Ready ≠ wiki-done."
1659
+ ),
1246
1660
  "directory": directory or ".",
1247
1661
  "version": health.get("version", 2),
1248
1662
  "last_updated": health.get("last_updated")
@@ -1709,14 +1709,34 @@ def get_project_status(
1709
1709
  yellow=summary["yellow"],
1710
1710
  red=summary["red"],
1711
1711
  pending_updates=pending_count,
1712
- health_score="Good" if summary["red"] == 0 and summary["yellow"] < 5 else "Needs Attention" if summary["red"] < 3 else "Critical"
1712
+ health_score=str(
1713
+ summary.get("health_score")
1714
+ or (
1715
+ "Good"
1716
+ if summary["red"] == 0 and summary["yellow"] < 5
1717
+ else "Needs Attention"
1718
+ if summary["red"] < 3
1719
+ else "Critical"
1720
+ )
1721
+ ),
1713
1722
  )
1714
- # attach dep intel (additive, agents can now use for ACS filtering without separate calls)
1723
+ # attach dep intel + map-first taxonomy (additive)
1715
1724
  if isinstance(base, dict):
1716
1725
  base["dependency_intel"] = dep_intel
1726
+ for k in (
1727
+ "stub_yellow",
1728
+ "actionable_yellow",
1729
+ "map_first_note",
1730
+ "health_score",
1731
+ ):
1732
+ if k in summary:
1733
+ base[k] = summary[k]
1717
1734
  else:
1718
1735
  try:
1719
1736
  base.dependency_intel = dep_intel # type: ignore[attr-defined]
1737
+ for k in ("stub_yellow", "actionable_yellow", "map_first_note"):
1738
+ if k in summary:
1739
+ setattr(base, k, summary[k])
1720
1740
  except Exception:
1721
1741
  pass
1722
1742
  return base
@@ -1784,14 +1804,15 @@ Use get_files_needing_attention() for the actual list. Use get_cycles(analysis=T
1784
1804
  if l.strip().startswith("- ") and not l.strip().startswith("- (")
1785
1805
  ])
1786
1806
  green = yellow = red = total = 0
1807
+ summary_fb: dict = {}
1787
1808
  try:
1788
1809
  import importlib
1789
1810
  health_module = importlib.import_module("wikifier.health")
1790
- summary = health_module.get_summary(root, directory)
1791
- total = int(summary.get("total", 0) or 0)
1792
- green = int(summary.get("green", 0) or 0)
1793
- yellow = int(summary.get("yellow", 0) or 0)
1794
- red = int(summary.get("red", 0) or 0)
1811
+ summary_fb = health_module.get_summary(root, directory) or {}
1812
+ total = int(summary_fb.get("total", 0) or 0)
1813
+ green = int(summary_fb.get("green", 0) or 0)
1814
+ yellow = int(summary_fb.get("yellow", 0) or 0)
1815
+ red = int(summary_fb.get("red", 0) or 0)
1795
1816
  except Exception:
1796
1817
  # Last resort: emoji-aware (+ legacy tag) counts from health text.
1797
1818
  try:
@@ -1804,13 +1825,22 @@ Use get_files_needing_attention() for the actual list. Use get_cycles(analysis=T
1804
1825
  pass
1805
1826
 
1806
1827
  if format == "json":
1828
+ _hs = summary_fb.get("health_score")
1829
+ if not _hs:
1830
+ _hs = (
1831
+ "Good"
1832
+ if red == 0 and yellow < 5
1833
+ else "Needs Attention"
1834
+ if red < 3
1835
+ else "Critical"
1836
+ )
1807
1837
  return ProjectHealthSummary(
1808
1838
  total_files=total or (green + yellow + red),
1809
1839
  green=green,
1810
1840
  yellow=yellow,
1811
1841
  red=red,
1812
1842
  pending_updates=pending_count,
1813
- health_score="Good" if red == 0 and yellow < 5 else "Needs Attention" if red < 3 else "Critical"
1843
+ health_score=str(_hs)
1814
1844
  )
1815
1845
 
1816
1846
  dir_str = f" (in {directory})" if directory else ""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wikifier
3
- Version: 4.5.6
3
+ Version: 4.5.8
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes