wikifier 4.6.8__tar.gz → 4.6.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 (64) hide show
  1. {wikifier-4.6.8/wikifier.egg-info → wikifier-4.6.9}/PKG-INFO +1 -1
  2. {wikifier-4.6.8 → wikifier-4.6.9}/skills/run.md +35 -22
  3. wikifier-4.6.9/tests/test_gap_amendment_2026_08.py +238 -0
  4. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_init_seed.py +10 -1
  5. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/__init__.py +1 -1
  6. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/agent_loop.py +101 -22
  7. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/cli.py +9 -0
  8. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/library.py +38 -6
  9. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/locking.py +63 -18
  10. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/scripts/wikifier.sh +7 -10
  11. {wikifier-4.6.8 → wikifier-4.6.9/wikifier.egg-info}/PKG-INFO +1 -1
  12. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier.egg-info/SOURCES.txt +1 -0
  13. {wikifier-4.6.8 → wikifier-4.6.9}/CONTRIBUTING.md +0 -0
  14. {wikifier-4.6.8 → wikifier-4.6.9}/LICENSE +0 -0
  15. {wikifier-4.6.8 → wikifier-4.6.9}/MANIFEST.in +0 -0
  16. {wikifier-4.6.8 → wikifier-4.6.9}/README.md +0 -0
  17. {wikifier-4.6.8 → wikifier-4.6.9}/diagnostics.html +0 -0
  18. {wikifier-4.6.8 → wikifier-4.6.9}/docs/spec.md +0 -0
  19. {wikifier-4.6.8 → wikifier-4.6.9}/index.html +0 -0
  20. {wikifier-4.6.8 → wikifier-4.6.9}/pyproject.toml +0 -0
  21. {wikifier-4.6.8 → wikifier-4.6.9}/setup.cfg +0 -0
  22. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_agent_loop.py +0 -0
  23. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_agent_scale.py +0 -0
  24. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_barrel_invalidation.py +0 -0
  25. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_cache_store.py +0 -0
  26. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_gap_closure.py +0 -0
  27. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_health.py +0 -0
  28. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_import_cache.py +0 -0
  29. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_index_map_paths.py +0 -0
  30. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_multi_lang_parsers.py +0 -0
  31. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_parsers.py +0 -0
  32. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_selftest_wrappers.py +0 -0
  33. {wikifier-4.6.8 → wikifier-4.6.9}/tests/test_walk_coverage_resolvers.py +0 -0
  34. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/__main__.py +0 -0
  35. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/cache_store.py +0 -0
  36. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/candidates.py +0 -0
  37. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/contracts.py +0 -0
  38. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/daemon.py +0 -0
  39. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/diagnostics.py +0 -0
  40. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/health.py +0 -0
  41. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/import_cache.py +0 -0
  42. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/index.html +0 -0
  43. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/mcp/__init__.py +0 -0
  44. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/mcp/server.py +0 -0
  45. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/parsers/__init__.py +0 -0
  46. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/parsers/_edge.py +0 -0
  47. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/parsers/bree.py +0 -0
  48. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/parsers/c_cpp.py +0 -0
  49. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/parsers/cdia.py +0 -0
  50. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/parsers/csharp.py +0 -0
  51. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/parsers/go_lang.py +0 -0
  52. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/parsers/java.py +0 -0
  53. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/parsers/javascript.py +0 -0
  54. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/parsers/python.py +0 -0
  55. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/parsers/rust.py +0 -0
  56. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/project_root.py +0 -0
  57. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/resolution.py +0 -0
  58. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/scripts/wikifier.bat +0 -0
  59. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/scripts/wikifier.ps1 +0 -0
  60. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier/serve.py +0 -0
  61. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier.egg-info/dependency_links.txt +0 -0
  62. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier.egg-info/entry_points.txt +0 -0
  63. {wikifier-4.6.8 → wikifier-4.6.9}/wikifier.egg-info/requires.txt +0 -0
  64. {wikifier-4.6.8 → wikifier-4.6.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.6.8
3
+ Version: 4.6.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
@@ -2,17 +2,17 @@
2
2
 
3
3
  **Formerly "Wikifier Skills & Commands". This is the authoritative, versioned specification for agent behavior when using Wikifier.**
4
4
 
5
- **Version**: v0.6 (v4.2.0 real-pipeline + reliability update; package current **4.5.x**)
6
- **Date**: 2026-06-10 (package notes refreshed 2026-07-09; gap-closure contract 2026-07-09)
5
+ **Version**: v0.6 (v4.2.0 real-pipeline + reliability update; package current **4.6.x**, live `__version__` in `wikifier/__init__.py`)
6
+ **Date**: 2026-06-10 (package notes refreshed 2026-08-01; gap-closure contract 2026-07-09)
7
7
  **Status**: Active. Supersedes v0.5.
8
- **See also**: `README.md`, `Findings/gap-closure-report.md`, and the library in wikifier/.
8
+ **See also**: `README.md`, `Findings/gap-closure-report.md`, `Findings/gap-amendment-plan-2026-08-01.md`, and the library in wikifier/.
9
9
 
10
- **Package 4.5.x + gap-closure notes (additive; protocol still v0.6):**
10
+ **Package 4.6.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
- - **First-run:** `init` → lean `monitored_paths.txt` + `map_paths.txt` (not bare `.`) → `update-maps` → `health --summary` → `suggest_next_actions`. Map is automatic; wiki *prose* is agent-filled over time.
13
- - **`session_bootstrap` readiness `blocked`:** not a crash — see **§ Readiness blocked** below. Typical causes: bare `.` monitor and/or no map/health yet. Fix scope `update-maps` before treating the project as map-ready.
12
+ - **First-run:** `init` → lean `monitored_paths.txt` + `map_paths.txt` (default active root `src/`, not bare `.`) → `update-maps` → `health --summary` → `suggest_next_actions`. Map is automatic; wiki *prose* is agent-filled over time.
13
+ - **`session_bootstrap` readiness:** message embeds the readiness tier. `blocked` is not a crash — see **§ Readiness blocked**. Hard blockers = missing map/health (or severe ghosts). Bare `.` monitor alone `map_ok_scope_risk` when map+health exist (scope warnings / fix_scope), not the same tier as missing map. Unattended ops require **`readiness=ready_for_daemon`**, not `health_score=Map Ready` alone.
14
14
  - **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.
15
- - **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
+ - **Steady-state selective work:** only 🔴 and *actionable* 🟡 (never re-wiki 🟢; never bulk-wiki Initial/map stubs). **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` / `map_ready` / `files_remaining_dirty` after budgeted `update-maps` — `success: true` ≠ map complete.
16
16
  - **Warm maps:** SQLite primary; zero-dirty uses mtime index + meta. **Index-first:** re-list candidates only when fingerprint / map-scoped index / live count disagree (`index_first_dirty` / `candidates_reused` on `update_maps`). Prefer `--directory=` or `map_paths.txt` package roots (not bare `.`). Collect, live count, index filter, and prune share **MapScope** so full-tree→`map_paths` narrow does not thrash.
17
17
  - **map_paths vs monitored_paths:** `map_paths.txt` = import-map package roots; `monitored_paths.txt` = wiki/health thrash bound. Independent. Wiki-only monitored lists do **not** define the map.
18
18
  - **Cache ops:** `wikifier cache-status`. Dual-write JSON **deprecated default-off** (`WIKIFIER_CACHE_JSON=1` to opt in). Dual-read legacy JSON for migrate remains.
@@ -48,7 +48,7 @@ Do **not** open megamodules (`javascript.py`, `import_cache.py`, `bree.py`) to d
48
48
  |------|-----|
49
49
  | First-run map | `init` → lean paths (not bare `.`) → `update-maps` → `health --summary` → `suggest-next` |
50
50
  | **Session start (4.6+)** | `session_bootstrap` (or CLI `session-bootstrap`) — one shot: root, health, attention, `actions[]`. If `readiness` is `blocked`, fix `actions[]` / `blockers[]` first (usually scope + maps). |
51
- | **Unblock readiness** | Write lean `monitored_paths.txt` + `map_paths.txt` → `update_maps` (full once) → re-`session_bootstrap` until `ready_for_daemon` / Map Ready |
51
+ | **Unblock readiness** | Write lean `monitored_paths.txt` + `map_paths.txt` → `update_maps` (full once) → re-`session_bootstrap` until **`readiness=ready_for_daemon`** (not Map Ready alone) |
52
52
  | **Core tool list** | `list_core_tools` / bootstrap `core_surface` — prefer Core 6; advanced is non-core |
53
53
  | Steady-state | `check-changes` (content-honest) → edit 🔴/actionable 🟡 only → `record-change` → wiki → `mark-green` |
54
54
  | **Hash migration** | `seed_source_content_hashes` / CLI `seed-source-hashes` — baseline Greens without mass Yellow |
@@ -80,15 +80,27 @@ actions: [ { "action": "fix_scope", ... }, ... ]
80
80
 
81
81
  **this is expected on a project that has never been scoped/mapped**, not a Wikifier install failure.
82
82
 
83
+ ### Readiness tiers (code truth — `assess_autonomous_readiness`)
84
+
85
+ | Tier | When |
86
+ |------|------|
87
+ | `blocked` | Hard **blockers[]**: no import map and/or no file_health (or severe ghost flood). |
88
+ | `map_ok_scope_risk` | Map+health present but **scope warnings** (often bare `.` monitor or multi-repo parent) and clean red/actionable yellow. |
89
+ | `ready_for_daemon` | Map Ready/Good **and** not bare_dot_monitor — only safe unattended tier. |
90
+ | `ready_with_agent_wiki_work` | Needs Attention, red=0 — daemon OK but agents still have wiki work. |
91
+ | `not_ready` | Other (e.g. reds). |
92
+
93
+ Bare `.` is a **scope warning** (`scope.warnings` / `fix_scope`), not always a hard blocker. Missing map/health **are** hard blockers and emit priority-1 `update_maps` / `seed_health` in `actions[]`.
94
+
83
95
  ### Why it happens (Grok-Bevy 2026-07 dogfood)
84
96
 
85
97
  On **Grok-Bevy** (Rust workspace), first bootstrap reported:
86
98
 
87
- | Blocker | Meaning |
88
- |---------|---------|
89
- | `monitored_paths is bare '.'` | Default/missing lean list → `check-changes` would walk the whole tree (`target/`, caches, etc.) and thrash. Action: **`fix_scope`**. |
90
- | `No import map (run update-maps first)` | No dependency/import map yet map-first tools have nothing to trust. |
91
- | `No file_health` | Health matrix not seeded (comes with map/health pipeline). |
99
+ | Signal | Field / tier | Meaning |
100
+ |--------|--------------|---------|
101
+ | `No import map (run update-maps first)` | `blockers[]` → `blocked` | No dependency/import map yet. Action: **`update_maps`**. |
102
+ | `No file_health` | `blockers[]` `blocked` | Health matrix not seeded. Action: **`seed_health` / update-maps**. |
103
+ | `monitored_paths is bare '.'` | `scope.warnings` (often `map_ok_scope_risk` once map exists) | check-changes thrash risk. Action: **`fix_scope`**. |
92
104
 
93
105
  Also: `bare_dot_monitor: true` and `scope.ok: false` until `monitored_paths.txt` lists **lean package roots**, not bare `.`.
94
106
 
@@ -110,16 +122,16 @@ Also: `bare_dot_monitor: true` and `scope.ok: false` until `monitored_paths.txt`
110
122
  3. **Build map + health** — from the target project (or with `project_root=` / `WIKIFIER_PROJECT_ROOT=`):
111
123
  ```bash
112
124
  wikifier update-maps --full # or MCP update_maps full=true
113
- # re-run session_bootstrap until readiness is ready_for_daemon / Map Ready
125
+ # re-run session_bootstrap until readiness == ready_for_daemon
114
126
  ```
115
127
  4. **Do not** bulk-re-wiki 🟡 *Initial stubs* after maps land — stubs = map coverage only.
116
128
 
117
- After a successful fix, bootstrap looks like: `scope.ok: true`, `blockers: []`, `health_score: Map Ready` (stubs OK), `readiness: ready_for_daemon`.
129
+ After a successful fix, bootstrap looks like: `scope.ok: true`, `blockers: []`, `health_score: Map Ready` (stubs OK), **`readiness: ready_for_daemon`**. Map Ready without `ready_for_daemon` is **not** unattended-ready.
118
130
 
119
131
  ### Product note for Wikifier maintainers
120
132
 
121
- - Default bare `.` is convenient for tiny toys and a footgun for real repos; agents must treat **`fix_scope` as P0** when `session_bootstrap` says blocked.
122
- - **`wikifier init` (4.6.8+)** seeds comment-guided lean templates for `monitored_paths.txt` and `map_paths.txt` (examples for `src/`, `crates/*/src/`, etc.) instead of a silent single-dot file. Tiny toys may keep `.`; multi-crate / monorepo agents must replace with package roots before map-ready work.
133
+ - Default bare `.` is a footgun for real repos; agents must treat **`fix_scope` as P0** when scope warns bare-dot, and **update_maps/seed_health as P0** when readiness is `blocked`.
134
+ - **`wikifier init` (4.6.8+ / 4.6.9 templates)** seeds comment-guided lean templates with active default **`src/`** (bare `.` is commented opt-in for tiny toys only). Multi-crate / monorepo agents must replace with package roots before map-ready work.
123
135
 
124
136
  See also: Findings note `Findings/readiness-blocked-bare-monitor-2026-07.md`.
125
137
 
@@ -132,7 +144,7 @@ You are now operating inside a Wikifier-managed codebase (Agent Protocol v0.6
132
144
 
133
145
  This is strictly an agent-to-agent wiki for token saving: map lookup (health + file wikis + deps) instead of full sources; selective wiki updates; not a human docs product or Jira.
134
146
 
135
- SELECTIVE WORK (mandatory): Only update/remove/re-wiki 🔴 Red and 🟡 Yellow files. Do not re-summarize 🟢 Green files or the whole tree. First-run builds the structural map; wiki depth is filled as you touch files. check-changes is content-honest (mtime-only thrash does not re-Yellow when source hash matches mark-green baseline).
147
+ SELECTIVE WORK (mandatory): Only update/remove/re-wiki 🔴 Red and *actionable* 🟡 Yellow files. Do **not** re-summarize 🟢 Green files, and do **not** bulk-wiki 🟡 Initial/map stubs (map coverage only — wiki a stub only when you edit that file). First-run builds the structural map; wiki depth is filled as you touch files. check-changes is content-honest (mtime-only thrash does not re-Yellow when source hash matches mark-green baseline).
136
148
 
137
149
  FIRST ACTIONS:
138
150
  1. Prefer MCP Core: session_bootstrap, check_changes, prepare_edit, suggest_next_actions (use actions[]), record_change, mark_green. Always pass project_root= for external projects.
@@ -189,10 +201,11 @@ However, if you are doing low-level direct writes to Wikifier state files (e.g.
189
201
  ### Limitations (Final M2-Rem-07 Assessment)
190
202
  - Locks are **advisory** — a broken or malicious process can ignore them.
191
203
  - Currently **project-level** (one lock for the entire project). This is the right tradeoff for current needs (including heavy multi-agent + monitor dogfooding) and keeps the implementation simple and fast.
192
- - Non-blocking / timeout queries and per-file locking are not implemented yet (advanced agents can use `wikifier.locking.is_project_locked()` for diagnostics).
204
+ - **Finite timeout** is supported: `file_lock(root, timeout=seconds)` raises `LockTimeoutError` if the lock is not free in time (`timeout=None` still blocks). `is_project_locked()` remains a diagnostic non-blocking probe.
205
+ - Per-file / sharded locking is a permanent non-goal unless extreme concurrency pressure appears.
193
206
  - Best-effort portability on non-Unix systems.
194
207
 
195
- The locking system (Python `file_lock` + shell `with_project_lock`) has received final polish. It is now considered production-ready for the M2 scope. Future extensions (fine-grained locking) can be added when real usage pressure appears on extremely large concurrent setups.
208
+ The locking system (Python `file_lock` + shell `with_project_lock`) is production-ready for the M2 scope.
196
209
 
197
210
  ## Protocol v0.4 Additions: I/O Contracts, Error Handling, Structured Output, Versioning
198
211
 
@@ -226,7 +239,7 @@ Pending/journal/health side effects are observable via the returned messages + d
226
239
  ### Error Handling Expectations
227
240
  - **Operational failures** (e.g., partial scan on huge tree, lock edge): return `{"success": False, "error": "...", "project_root": "...", ...partial_data }`. Agent must handle gracefully and log; continue where safe.
228
241
  - **Programming / contract errors** (bad types, missing required): raise (standard Python exceptions) — these indicate agent bug.
229
- - **Locking**: Mutators block on project lock (production default). Future non-blocking/timeout will be additive.
242
+ - **Locking**: Mutators block on project lock by default; optional finite `timeout=` raises on contention.
230
243
  - Never assume text output parsability. Always use structured returns + explicit format="json".
231
244
  - On external/packaged installs: discovery is robust; pass explicit project_root if cwd is ambiguous.
232
245
 
@@ -277,7 +290,7 @@ This project exposes a first-class MCP server (wikifier-mcp or python -m wikifie
277
290
 
278
291
  **M5.1+ reality (from dogfood on external 5k-79k+ projects)**: MCP tools are preferred when available (get_project_status, get_file_wiki, get_barrel_reports for deep BRC, check_changes, record_change, mark_green, suggest_next_actions, health equivalents, etc.). **Always pass project_root= (or use WIKIFIER_PROJECT_ROOT env)** for external/user projects (RecipeLab_alt BRC stress, ConsistencyHub, llvm subs, etc.).
279
292
 
280
- MCP timeouts are rare since v4.2.0 (the library workflow-call deadlock is fixed and barrel persistence is batched), but on very large/BRC-heavy targets they can still happen. In that case, **immediately fall back to CLI/library** (python -m wikifier health --summary or the library health(..., format="summary")) these are reliable and were the workhorse in M5 sustained/monitor work.
293
+ **CLI-at-scale policy (G15):** On barrel-heavy or large monorepos (BRC stress, multi-k source trees), treat **CLI/library as the primary reliability path** for `update_maps`, barrel reports, and long health/suggest loops. MCP subprocess tools use a ~60s cap and may still time out; that is **not** a product failure — **immediately fall back** to `python -m wikifier …` / library calls. Do not claim DoD2 (&lt;30s MCP on alt/Consistency/llama) closed without a fresh measured pack; default operator policy is CLI for scale.
281
294
 
282
295
  The server implements hardened external discovery (delegates to cli.py discover_project_root / _get_effective_root), 60s timeouts, actionable errors, and parity with library.
283
296
 
@@ -0,0 +1,238 @@
1
+ """Real-path tests for gap amendment plan G1–G13/G15 closed-when bars.
2
+
3
+ Drives shipped library functions (session_bootstrap, build_structured_actions,
4
+ file_lock, build_map_coverage / run_full_update, library ACS section, protocol
5
+ strings). No theater: asserts against live code outputs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import os
12
+ import unittest
13
+
14
+ from tests._base import REPO_ROOT, TempProjectTestCase
15
+
16
+ from wikifier import __version__
17
+ from wikifier.agent_loop import build_structured_actions, session_bootstrap
18
+ from wikifier import cli
19
+ from wikifier import locking
20
+ from wikifier.library import _generate_acs_section
21
+
22
+ health_mod = importlib.import_module("wikifier.health")
23
+
24
+
25
+ class TestProtocolVersionPointers(unittest.TestCase):
26
+ """G1: operator package pointers match live major.minor."""
27
+
28
+ def test_version_is_4_6(self):
29
+ self.assertTrue(
30
+ __version__.startswith("4.6."),
31
+ f"expected 4.6.x package, got {__version__}",
32
+ )
33
+
34
+ def test_skills_run_header_not_4_5(self):
35
+ text = (REPO_ROOT / "skills" / "run.md").read_text(encoding="utf-8")
36
+ # Header / package-notes must not claim current 4.5.x
37
+ first_30 = "\n".join(text.splitlines()[:30])
38
+ self.assertNotIn("package current **4.5.x**", first_30)
39
+ self.assertIn("4.6.x", first_30)
40
+ # SELECTIVE WORK excludes Initial stubs (G3)
41
+ self.assertIn("actionable", text)
42
+ self.assertIn("Initial", text)
43
+ self.assertRegex(
44
+ text,
45
+ r"SELECTIVE WORK.*actionable.*Initial",
46
+ )
47
+ # G15 CLI-at-scale policy present
48
+ self.assertIn("CLI-at-scale policy", text)
49
+ # G8 ready_for_daemon not Map Ready alone
50
+ self.assertIn("ready_for_daemon", text)
51
+ self.assertIn("not Map Ready alone", text)
52
+
53
+ def test_claude_md_not_4_5(self):
54
+ for name in ("Claude.md", "CLAUDE.md"):
55
+ p = REPO_ROOT / name
56
+ if not p.is_file():
57
+ continue
58
+ t = p.read_text(encoding="utf-8")
59
+ self.assertNotIn("currently 4.5.x", t)
60
+ self.assertIn("4.6.x", t)
61
+
62
+
63
+ class TestBootstrapMessageAndActions(TempProjectTestCase):
64
+ """G2/G5/G6: bootstrap message + blocker actions."""
65
+
66
+ def test_bootstrap_message_blocked_never_says_ready(self):
67
+ # Fresh temp root: no cache, no health → blocked
68
+ boot = session_bootstrap(project_root=self.root)
69
+ self.assertTrue(boot.get("success"))
70
+ readiness = boot.get("readiness")
71
+ msg = str(boot.get("message") or "")
72
+ if readiness == "blocked":
73
+ self.assertIn("readiness=blocked", msg)
74
+ self.assertNotRegex(msg, r"^session_bootstrap ready")
75
+ self.assertNotIn("session_bootstrap ready —", msg)
76
+ # Always embeds readiness=
77
+ self.assertIn("readiness=", msg)
78
+
79
+ def test_blockers_produce_priority_leq_2_map_or_health(self):
80
+ acts = build_structured_actions(
81
+ blockers=[
82
+ "No import map (run update-maps first).",
83
+ "No file_health — run seed-health or update-maps.",
84
+ ],
85
+ scope_warnings=["monitored_paths is bare '.' — thrash risk"],
86
+ red=0,
87
+ actionable_yellow=0,
88
+ clean=True,
89
+ )
90
+ kinds = {a["action"] for a in acts}
91
+ self.assertIn("update_maps", kinds)
92
+ self.assertIn("seed_health", kinds)
93
+ self.assertIn("fix_scope", kinds)
94
+ for a in acts:
95
+ if a["action"] in ("update_maps", "seed_health"):
96
+ self.assertLessEqual(int(a["priority"]), 2, a)
97
+ if a["action"] == "fix_scope":
98
+ self.assertLessEqual(int(a["priority"]), 2, a)
99
+
100
+ def test_map_incomplete_action_priority(self):
101
+ acts = build_structured_actions(
102
+ map_coverage={
103
+ "complete": False,
104
+ "files_remaining_dirty": 12,
105
+ "files_skipped": 12,
106
+ "budget_max_files": 5,
107
+ },
108
+ red=0,
109
+ actionable_yellow=0,
110
+ )
111
+ incomplete = [a for a in acts if a["action"] == "update_maps_until_complete"]
112
+ self.assertTrue(incomplete)
113
+ self.assertLessEqual(int(incomplete[0]["priority"]), 2)
114
+ self.assertIn("success alone", incomplete[0]["reason"].lower())
115
+
116
+ def test_stub_only_map_first_ok_no_wiki_refresh(self):
117
+ acts = build_structured_actions(
118
+ stub_yellow=100,
119
+ actionable_yellow=0,
120
+ red=0,
121
+ actionable_yellow_files=[],
122
+ clean=False,
123
+ )
124
+ kinds = {a["action"] for a in acts}
125
+ self.assertIn("map_first_ok", kinds)
126
+ self.assertNotIn("wiki_refresh", kinds)
127
+
128
+
129
+ class TestMapCoverageHonesty(TempProjectTestCase):
130
+ """G5: update_maps surfaces map_complete / map_ready."""
131
+
132
+ def test_run_full_update_exposes_map_ready_flags(self):
133
+ self.write("pkg/a.py", "import os\n")
134
+ (self.root / "monitored_paths.txt").write_text("pkg/\n", encoding="utf-8")
135
+ (self.root / "map_paths.txt").write_text("pkg/\n", encoding="utf-8")
136
+ res = cli.run_full_update(root=self.root, directory="pkg/")
137
+ self.assertTrue(res.get("success"), res)
138
+ self.assertIn("map_coverage", res)
139
+ self.assertIn("map_complete", res)
140
+ self.assertIn("map_ready", res)
141
+ # map_ready must track complete + remaining dirty
142
+ mc = res["map_coverage"]
143
+ expected_ready = bool(mc.get("complete")) and int(
144
+ mc.get("files_remaining_dirty") or 0
145
+ ) == 0
146
+ self.assertEqual(res["map_ready"], expected_ready)
147
+ self.assertEqual(res["map_complete"], bool(mc.get("complete")))
148
+
149
+ def test_budgeted_max_files_incomplete_flags(self):
150
+ for i in range(6):
151
+ self.write(f"src/m{i}.py", f"x={i}\n")
152
+ (self.root / "monitored_paths.txt").write_text("src/\n", encoding="utf-8")
153
+ (self.root / "map_paths.txt").write_text("src/\n", encoding="utf-8")
154
+ res = cli.run_full_update(
155
+ root=self.root, directory="src/", max_files=2
156
+ )
157
+ self.assertTrue(res.get("success"), res)
158
+ self.assertIn("map_coverage", res)
159
+ # With budget, remaining dirty may be > 0
160
+ rem = int((res.get("map_coverage") or {}).get("files_remaining_dirty") or 0)
161
+ if rem > 0 or (res.get("map_coverage") or {}).get("complete") is False:
162
+ self.assertFalse(res.get("map_ready"))
163
+ acts = build_structured_actions(map_coverage=res["map_coverage"])
164
+ self.assertTrue(
165
+ any(a["action"] == "update_maps_until_complete" for a in acts)
166
+ )
167
+
168
+
169
+ class TestLockTimeout(TempProjectTestCase):
170
+ """G13: file_lock honors finite timeout."""
171
+
172
+ def test_timeout_raises_when_held(self):
173
+ """Hold exclusive lock on a second fd; finite timeout must raise (G13)."""
174
+ if locking.fcntl is None:
175
+ self.skipTest("fcntl not available")
176
+ lock_path = self.root / locking.LOCK_FILE_NAME
177
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
178
+ holder_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
179
+ try:
180
+ locking.fcntl.flock(holder_fd, locking.fcntl.LOCK_EX)
181
+ contender_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
182
+ try:
183
+ with self.assertRaises(locking.LockTimeoutError):
184
+ locking._acquire_exclusive(contender_fd, 0.25)
185
+ finally:
186
+ os.close(contender_fd)
187
+ finally:
188
+ locking.fcntl.flock(holder_fd, locking.fcntl.LOCK_UN)
189
+ os.close(holder_fd)
190
+
191
+ def test_reentrant_still_works(self):
192
+ with locking.file_lock(self.root):
193
+ with locking.file_lock(self.root):
194
+ pass # nested re-entrant
195
+
196
+
197
+ class TestAcsLibrarySection(unittest.TestCase):
198
+ """G9: library ACS prefers actionable."""
199
+
200
+ def test_acs_section_prefers_actionable(self):
201
+ cache = {
202
+ "_acs_summary": {
203
+ "total_scored_edges": 100,
204
+ "avg_confidence": 0.7,
205
+ "low_conf_edges": 40,
206
+ "actionable_low_conf_edges": 5,
207
+ "low_conf_threshold": 0.65,
208
+ "reason_code_counts": {
209
+ "external_or_bare": 30,
210
+ "unresolved_project": 5,
211
+ },
212
+ "sample_low_conf_explanations": ["raw sample"],
213
+ }
214
+ }
215
+ lines = _generate_acs_section(cache)
216
+ text = "\n".join(lines)
217
+ self.assertIn("actionable_low_conf", text)
218
+ self.assertIn("reason_code_counts", text)
219
+ self.assertIn("thrash", text.lower())
220
+ self.assertIn("actionable_low_conf_edges", text)
221
+ self.assertIn("actionable", text.lower())
222
+
223
+
224
+ class TestReadinessTiersDocShape(TempProjectTestCase):
225
+ """G4: readiness tiers match code for missing map/health."""
226
+
227
+ def test_no_map_is_blocked(self):
228
+ ready = health_mod.assess_autonomous_readiness(self.root)
229
+ self.assertEqual(ready.get("readiness"), "blocked")
230
+ blockers = " ".join(ready.get("blockers") or [])
231
+ self.assertTrue(
232
+ "import map" in blockers.lower() or "file_health" in blockers.lower(),
233
+ blockers,
234
+ )
235
+
236
+
237
+ if __name__ == "__main__":
238
+ unittest.main()
@@ -64,13 +64,22 @@ class TestInitSeedTemplates(TempProjectTestCase):
64
64
  r"(?i)(lean|package root|thrash|map_paths)",
65
65
  "monitored_paths.txt should mention lean roots / thrash / map_paths",
66
66
  )
67
- # Active path may still be bare "." for tiny toys, but only with comments above.
67
+ # G7: active default is lean src/ — bare "." is opt-in comment only.
68
68
  active_mon = [
69
69
  ln.strip()
70
70
  for ln in mon_text.splitlines()
71
71
  if ln.strip() and not ln.strip().startswith("#")
72
72
  ]
73
73
  self.assertTrue(active_mon, "expected at least one non-comment monitored path")
74
+ self.assertNotIn(
75
+ ".",
76
+ active_mon,
77
+ "monitored_paths must not activate bare '.' by default (opt-in comment only)",
78
+ )
79
+ self.assertTrue(
80
+ any(p == "src/" or p == "src" for p in active_mon),
81
+ f"expected lean src/ active default, got {active_mon}",
82
+ )
74
83
 
75
84
  self.assertIn("#", map_text, "map_paths.txt must include guidance comments")
76
85
  self.assertRegex(
@@ -79,4 +79,4 @@ from .contracts import (
79
79
  compute_acs_confidence,
80
80
  )
81
81
 
82
- __version__ = "4.6.8"
82
+ __version__ = "4.6.9"
@@ -174,6 +174,7 @@ def build_structured_actions(
174
174
  red: int = 0,
175
175
  acs_actionable: int = 0,
176
176
  scope_warnings: Optional[List[str]] = None,
177
+ blockers: Optional[List[str]] = None,
177
178
  clean: bool = False,
178
179
  max_file_actions: int = 12,
179
180
  map_coverage: Optional[Dict[str, Any]] = None,
@@ -181,18 +182,56 @@ def build_structured_actions(
181
182
  """Build dispatchable action objects for agents (not prose-only).
182
183
 
183
184
  Each item: action, file?, priority (1=highest), reason, optional preflight.
185
+ Priority ≤2 is reserved for readiness blockers (scope + missing map/health).
184
186
  """
185
187
  actions: List[Dict[str, Any]] = []
186
- prio = 1
188
+ prio = 3 # file work starts after readiness fix actions (priority 1–2)
187
189
  cov = map_coverage if isinstance(map_coverage, dict) else {}
188
190
  remaining = int(cov.get("files_remaining_dirty") or 0)
189
191
  complete = cov.get("complete")
190
- # Incomplete map under budget higher priority than ACS thrash
192
+ blocker_list = [str(b) for b in (blockers or []) if b]
193
+ # G6: missing map / file_health always get priority ≤2
194
+ for b in blocker_list:
195
+ bl = b.lower()
196
+ # Prefer seed_health before generic update-maps (blocker text often mentions both)
197
+ if "file_health" in bl or "seed-health" in bl or "seed_health" in bl:
198
+ actions.append({
199
+ "action": "seed_health",
200
+ "file": None,
201
+ "priority": 1,
202
+ "reason": b,
203
+ "preflight": ["seed_health", "update_maps"],
204
+ })
205
+ elif "import map" in bl or "update-maps" in bl or "update_maps" in bl:
206
+ actions.append({
207
+ "action": "update_maps",
208
+ "file": None,
209
+ "priority": 1,
210
+ "reason": b,
211
+ "preflight": ["update_maps"],
212
+ })
213
+ else:
214
+ actions.append({
215
+ "action": "fix_blocker",
216
+ "file": None,
217
+ "priority": 2,
218
+ "reason": b,
219
+ "preflight": [],
220
+ })
221
+ for w in (scope_warnings or [])[:3]:
222
+ actions.append({
223
+ "action": "fix_scope",
224
+ "file": None,
225
+ "priority": 2,
226
+ "reason": str(w),
227
+ "preflight": [],
228
+ })
229
+ # Incomplete map under budget — agents must not treat success alone as done (G5)
191
230
  if complete is False or remaining > 0:
192
231
  actions.append({
193
232
  "action": "update_maps_until_complete",
194
233
  "file": None,
195
- "priority": 3,
234
+ "priority": 2,
196
235
  "reason": (
197
236
  f"Map incomplete: files_remaining_dirty={remaining}, complete={complete}. "
198
237
  "Re-run update_maps (same directory/max_files) until map_coverage.complete=true; "
@@ -224,7 +263,7 @@ def build_structured_actions(
224
263
  "preflight": ["prepare_edit", "why_file"],
225
264
  })
226
265
  prio = min(prio + 1, 9)
227
- if red == 0 and actionable_yellow == 0 and clean and remaining == 0:
266
+ if red == 0 and actionable_yellow == 0 and clean and remaining == 0 and not blocker_list:
228
267
  actions.append({
229
268
  "action": "lookup_only",
230
269
  "file": None,
@@ -232,12 +271,16 @@ def build_structured_actions(
232
271
  "reason": "Health clean — do not re-summarize tree; use prepare_edit/get_file_wiki for lookup",
233
272
  "preflight": [],
234
273
  })
274
+ # G10: stub-only yellows are map coverage — never bulk wiki_refresh
235
275
  if stub_yellow > 0 and actionable_yellow == 0 and red == 0:
236
276
  actions.append({
237
277
  "action": "map_first_ok",
238
278
  "file": None,
239
279
  "priority": 6,
240
- "reason": f"{stub_yellow} Initial stubs are map coverage only — not bulk wiki work",
280
+ "reason": (
281
+ f"{stub_yellow} Initial stubs are map coverage only — not bulk wiki work. "
282
+ "Wiki a file only when you edit it, then mark-green."
283
+ ),
241
284
  "preflight": [],
242
285
  })
243
286
  if acs_actionable > 0:
@@ -251,16 +294,12 @@ def build_structured_actions(
251
294
  ),
252
295
  "preflight": ["get_dependencies"],
253
296
  })
254
- for w in (scope_warnings or [])[:2]:
255
- actions.append({
256
- "action": "fix_scope",
257
- "file": None,
258
- "priority": 2,
259
- "reason": str(w),
260
- "preflight": [],
261
- })
262
297
  if not any(
263
- a["action"] in ("update_maps", "update_maps_until_complete", "update_maps_if_structure")
298
+ a["action"] in (
299
+ "update_maps",
300
+ "update_maps_until_complete",
301
+ "update_maps_if_structure",
302
+ )
264
303
  for a in actions
265
304
  ):
266
305
  actions.append({
@@ -639,6 +678,7 @@ def session_bootstrap(
639
678
  scope_warnings = list(out["scope"].get("warnings") or [])
640
679
 
641
680
  map_cov = out.get("map_coverage") if isinstance(out.get("map_coverage"), dict) else {}
681
+ blockers = list(out.get("blockers") or [])
642
682
  out["actions"] = build_structured_actions(
643
683
  red_files=red_files,
644
684
  actionable_yellow_files=actionable_yellow_files,
@@ -647,6 +687,7 @@ def session_bootstrap(
647
687
  red=red_n,
648
688
  acs_actionable=acs_actionable,
649
689
  scope_warnings=scope_warnings,
690
+ blockers=blockers,
650
691
  clean=clean,
651
692
  map_coverage=map_cov,
652
693
  )
@@ -654,13 +695,51 @@ def session_bootstrap(
654
695
  out["selective_work"] = True
655
696
  out["map_first"] = True
656
697
  rem = int(map_cov.get("files_remaining_dirty") or 0) if map_cov else 0
657
- out["message"] = (
658
- "session_bootstrap ready use actions[] / attention; "
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 ""
698
+ readiness = out.get("readiness") or "unknown"
699
+ map_incomplete = rem > 0 or map_cov.get("complete") is False
700
+ # G2: never claim "ready" when readiness is blocked
701
+ if readiness == "blocked":
702
+ out["message"] = (
703
+ f"session_bootstrap readiness=blocked fix actions[] / blockers[] first "
704
+ f"(typically fix_scope + update_maps / seed_health); do not treat project as map-ready."
705
+ + (
706
+ f" MAP INCOMPLETE: files_remaining_dirty={rem}."
707
+ if map_incomplete
708
+ else ""
709
+ )
710
+ )
711
+ elif readiness == "map_ok_scope_risk":
712
+ out["message"] = (
713
+ f"session_bootstrap readiness=map_ok_scope_risk — map/health present but scope risk "
714
+ f"(often bare '.' monitor); fix_scope before unattended daemon. "
715
+ f"Use actions[] / attention; prepare_edit(file) before large edits; record_change after."
716
+ + (
717
+ f" MAP INCOMPLETE: files_remaining_dirty={rem} — re-run update_maps until "
718
+ f"map_coverage.complete=true."
719
+ if map_incomplete
720
+ else ""
721
+ )
722
+ )
723
+ elif readiness == "ready_for_daemon":
724
+ out["message"] = (
725
+ "session_bootstrap readiness=ready_for_daemon — use actions[] / attention; "
726
+ "prepare_edit(file) before large edits; record_change after edits."
727
+ + (
728
+ f" MAP INCOMPLETE: files_remaining_dirty={rem} — re-run update_maps until "
729
+ f"map_coverage.complete=true (success alone is not map-ready)."
730
+ if map_incomplete
731
+ else ""
732
+ )
733
+ )
734
+ else:
735
+ out["message"] = (
736
+ f"session_bootstrap readiness={readiness} — use actions[] / attention; "
737
+ "prepare_edit(file) before large edits; record_change after edits. "
738
+ "Unattended ops require readiness=ready_for_daemon (not Map Ready alone)."
739
+ + (
740
+ f" MAP INCOMPLETE: files_remaining_dirty={rem} — re-run update_maps until complete."
741
+ if map_incomplete
742
+ else ""
743
+ )
664
744
  )
665
- )
666
745
  return out
@@ -424,6 +424,11 @@ def run_full_update(
424
424
  cache_backend=backend,
425
425
  directory=directory,
426
426
  )
427
+ _mc0 = result["map_coverage"] if isinstance(result["map_coverage"], dict) else {}
428
+ result["map_complete"] = bool(_mc0.get("complete"))
429
+ result["map_ready"] = bool(_mc0.get("complete")) and int(
430
+ _mc0.get("files_remaining_dirty") or 0
431
+ ) == 0
427
432
  try:
428
433
  if cs is not None:
429
434
  cs.save_meta_key(root, "_map_coverage", result["map_coverage"])
@@ -669,6 +674,10 @@ def run_full_update(
669
674
  cache_backend=backend,
670
675
  directory=directory,
671
676
  )
677
+ # G5: success alone is not map-ready — surface complete flag at top level
678
+ _mc = result["map_coverage"] if isinstance(result["map_coverage"], dict) else {}
679
+ result["map_complete"] = bool(_mc.get("complete"))
680
+ result["map_ready"] = bool(_mc.get("complete")) and int(_mc.get("files_remaining_dirty") or 0) == 0
672
681
  try:
673
682
  cache["_map_coverage"] = result["map_coverage"]
674
683
  if cands:
@@ -312,6 +312,7 @@ def _generate_cycles_section(cache: Dict[str, Any]) -> List[str]:
312
312
  # ---------------------------------------------------------------------------
313
313
 
314
314
  def _generate_acs_section(cache: Dict[str, Any]) -> List[str]:
315
+ """ACS section for library.md — prefer actionable + reason codes (G9)."""
315
316
  lines = ["## ACS Risk Snapshot", ""]
316
317
  acs = cache.get("_acs_summary") or {}
317
318
  if not isinstance(acs, dict) or not acs.get("total_scored_edges"):
@@ -319,17 +320,48 @@ def _generate_acs_section(cache: Dict[str, Any]) -> List[str]:
319
320
  "to score edge confidence.)")
320
321
  lines.append("")
321
322
  return lines
322
- lines.append("**Scored edges**: {0} | **avg_confidence**: {1} | **low-confidence**: {2}"
323
- " (threshold {3})".format(
324
- acs.get("total_scored_edges"), acs.get("avg_confidence"),
325
- acs.get("low_conf_edges", 0), acs.get("low_conf_threshold", 0.65)))
323
+ actionable = acs.get("actionable_low_conf_edges")
324
+ if actionable is None:
325
+ actionable = acs.get("low_conf_edges", 0)
326
+ lines.append(
327
+ "**Scored edges**: {0} | **avg_confidence**: {1} | "
328
+ "**actionable_low_conf**: {2} | raw low-conf (telemetry): {3} "
329
+ "(threshold {4})".format(
330
+ acs.get("total_scored_edges"),
331
+ acs.get("avg_confidence"),
332
+ actionable,
333
+ acs.get("low_conf_edges", 0),
334
+ acs.get("low_conf_threshold", 0.65),
335
+ )
336
+ )
337
+ lines.append(
338
+ "**Agent work queue**: use `actionable_low_conf_edges` + "
339
+ "`reason_code_counts` / `agent_signal=investigate` only — "
340
+ "do **not** thrash on raw `low_conf_edges` (includes external/bare)."
341
+ )
342
+ rcc = acs.get("reason_code_counts") or {}
343
+ if isinstance(rcc, dict) and rcc:
344
+ lines.append(
345
+ "**reason_code_counts**: "
346
+ + ", ".join("{0}:{1}".format(k, v) for k, v in list(rcc.items())[:8])
347
+ )
326
348
  top_reasons = acs.get("top_risk_reasons") or {}
327
349
  if top_reasons:
328
350
  lines.append("**Top risk reasons**: " + ", ".join(
329
351
  "{0}:{1}".format(k, v) for k, v in list(top_reasons.items())[:4]))
330
- samples = acs.get("sample_low_conf_explanations") or []
352
+ # Prefer actionable samples when present
353
+ samples = (
354
+ acs.get("sample_actionable_explanations")
355
+ or acs.get("sample_low_conf_explanations")
356
+ or []
357
+ )
331
358
  if samples:
332
- lines.append("**Sample low-confidence edges**:")
359
+ label = (
360
+ "Sample actionable edges"
361
+ if acs.get("sample_actionable_explanations")
362
+ else "Sample low-confidence edges (telemetry; prefer actionable)"
363
+ )
364
+ lines.append("**{0}**:".format(label))
333
365
  for i, sample in enumerate(samples[:3], 1):
334
366
  lines.append(" {0}. {1}".format(i, sample))
335
367
  lines.append("")
@@ -47,14 +47,18 @@ Limitations (Honest Assessment)
47
47
  -------------------------------
48
48
  - Advisory only.
49
49
  - Project-level (not per-file or per-agent yet).
50
- - Non-blocking check / timeout is not yet implemented (current behavior = blocking acquire).
51
- - Fine-grained (per-file) or sharded locking is a planned future extension for extreme concurrency on very large codebases.
50
+ - Finite ``timeout`` (seconds) is honored on Unix (non-blocking poll loop);
51
+ ``timeout=None`` still blocks until free. Per-file / sharded locks remain
52
+ out of scope for M2 scale.
53
+ - Fine-grained (per-file) locking is a permanent non-goal unless extreme
54
+ concurrency pressure appears on very large multi-agent setups.
52
55
 
53
56
  For the current scale (including heavy multi-agent dogfooding), project-level
54
57
  advisory locking is sufficient, safe, and has been battle-tested.
55
58
  """
56
59
 
57
60
  import os
61
+ import time
58
62
  from contextlib import contextmanager
59
63
  from pathlib import Path
60
64
  from typing import Optional
@@ -79,6 +83,57 @@ LOCK_FILE_NAME = ".wikifier_staging/.lock"
79
83
  _HELD_LOCKS: dict = {} # {resolved_root_str: {"fd": int, "depth": int}}
80
84
 
81
85
 
86
+ class LockTimeoutError(TimeoutError):
87
+ """Raised when file_lock cannot acquire within the given timeout."""
88
+
89
+
90
+ def _acquire_exclusive(lock_fd: int, timeout: Optional[float]) -> None:
91
+ """Acquire exclusive lock; honor finite timeout on Unix (G13)."""
92
+ if fcntl is not None:
93
+ if timeout is None:
94
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
95
+ return
96
+ if timeout < 0:
97
+ raise ValueError("timeout must be None or >= 0")
98
+ deadline = time.monotonic() + float(timeout)
99
+ while True:
100
+ try:
101
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
102
+ return
103
+ except (BlockingIOError, OSError):
104
+ if time.monotonic() >= deadline:
105
+ raise LockTimeoutError(
106
+ "project lock not acquired within {0}s".format(timeout)
107
+ )
108
+ # Short sleep to avoid busy-spin; remaining time may be < sleep.
109
+ remaining = deadline - time.monotonic()
110
+ time.sleep(min(0.05, max(0.001, remaining)))
111
+ elif msvcrt is not None:
112
+ # Windows: LK_LOCK retries ~10s then OSError; loop for blocking.
113
+ if timeout is None:
114
+ while True:
115
+ try:
116
+ os.lseek(lock_fd, 0, os.SEEK_SET)
117
+ msvcrt.locking(lock_fd, msvcrt.LK_LOCK, 1)
118
+ return
119
+ except OSError:
120
+ continue
121
+ deadline = time.monotonic() + float(timeout)
122
+ while True:
123
+ try:
124
+ os.lseek(lock_fd, 0, os.SEEK_SET)
125
+ msvcrt.locking(lock_fd, msvcrt.LK_NBLCK, 1)
126
+ return
127
+ except OSError:
128
+ if time.monotonic() >= deadline:
129
+ raise LockTimeoutError(
130
+ "project lock not acquired within {0}s".format(timeout)
131
+ )
132
+ remaining = deadline - time.monotonic()
133
+ time.sleep(min(0.05, max(0.001, remaining)))
134
+ # If neither primitive exists, proceed unlocked (advisory best-effort).
135
+
136
+
82
137
  @contextmanager
83
138
  def file_lock(root: Path, timeout: Optional[float] = None):
84
139
  """
@@ -86,13 +141,15 @@ def file_lock(root: Path, timeout: Optional[float] = None):
86
141
 
87
142
  This is the primary locking primitive used by health.py and import_cache.py.
88
143
 
89
- Behavior: blocking acquire (waits until the lock is free), re-entrant
90
- within the same process (nested acquires on the same root are refcounted).
91
- Timeout support is planned for a future iteration.
144
+ Behavior: blocking acquire when ``timeout is None``; with a finite
145
+ ``timeout`` (seconds) raises ``LockTimeoutError`` if the lock is not free
146
+ in time. Re-entrant within the same process (nested acquires refcounted).
92
147
 
93
148
  Usage (agents rarely need this directly):
94
149
  with file_lock(Path(project_root)):
95
150
  ... critical section ...
151
+ with file_lock(Path(project_root), timeout=5.0):
152
+ ... fail fast on contention ...
96
153
  """
97
154
  lock_path = root / LOCK_FILE_NAME
98
155
  lock_path.parent.mkdir(parents=True, exist_ok=True)
@@ -114,19 +171,7 @@ def file_lock(root: Path, timeout: Optional[float] = None):
114
171
  lock_fd = None
115
172
  try:
116
173
  lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
117
- if fcntl is not None:
118
- fcntl.flock(lock_fd, fcntl.LOCK_EX)
119
- elif msvcrt is not None:
120
- # Windows: lock the first byte of the lock file. msvcrt LK_LOCK
121
- # retries for ~10s then raises OSError, so loop until acquired
122
- # to match the blocking semantics of flock.
123
- while True:
124
- try:
125
- os.lseek(lock_fd, 0, os.SEEK_SET)
126
- msvcrt.locking(lock_fd, msvcrt.LK_LOCK, 1)
127
- break
128
- except OSError:
129
- continue
174
+ _acquire_exclusive(lock_fd, timeout)
130
175
  # If neither primitive exists, proceed unlocked (advisory best-effort).
131
176
  _HELD_LOCKS[key] = {"fd": lock_fd, "depth": 1}
132
177
  yield
@@ -684,18 +684,15 @@ cmd_init() {
684
684
  # node_modules, build/, dist/, caches, etc.
685
685
  # This is NOT the import map — see map_paths.txt for package roots used by update-maps.
686
686
  #
687
- # Examples (uncomment or add paths that match THIS project):
688
- # src/
687
+ # Active default: src/ (lean). Multi-crate / monorepo: replace with package roots first.
688
+ # Tiny single-file toys only: uncomment bare "." below (explicit opt-in — not default).
689
+ src/
690
+ # .
689
691
  # lib/
690
692
  # crates/my_crate/src/
691
693
  # packages/core/src/
692
694
  # README.md
693
695
  # docs/
694
- #
695
- # Tiny single-package toys only may keep bare "." below.
696
- # Multi-crate / monorepo / anything with build output: REPLACE with lean roots first
697
- # (session_bootstrap readiness "blocked" / fix_scope until you do).
698
- .
699
696
  EOT
700
697
  fi
701
698
  MAP_PATHS_FILE="$PROJECT_ROOT/map_paths.txt"
@@ -705,13 +702,13 @@ EOT
705
702
  # List directories that contain parseable source for the dependency map.
706
703
  # Do not use bare "." on large trees; do not list only wiki/docs files here.
707
704
  #
708
- # Examples (uncomment or add paths that match THIS project):
709
- # src/
705
+ # Active default: src/ (replace with this project's package roots).
706
+ src/
710
707
  # crates/my_crate/src/
711
708
  # packages/core/src/
712
709
  #
713
710
  # Leave empty (comments only) to fall back to monitored directory roots / full collect.
714
- # After editing: wikifier update-maps (then session_bootstrap until ready_for_daemon)
711
+ # After editing: wikifier update-maps (then session_bootstrap until readiness=ready_for_daemon)
715
712
  EOT
716
713
  fi
717
714
  [[ ! -f "$EXCLUDE_PATTERNS_FILE" ]] && cat > "$EXCLUDE_PATTERNS_FILE" << 'EOT'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wikifier
3
- Version: 4.6.8
3
+ Version: 4.6.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
@@ -44,6 +44,7 @@ tests/test_agent_loop.py
44
44
  tests/test_agent_scale.py
45
45
  tests/test_barrel_invalidation.py
46
46
  tests/test_cache_store.py
47
+ tests/test_gap_amendment_2026_08.py
47
48
  tests/test_gap_closure.py
48
49
  tests/test_health.py
49
50
  tests/test_import_cache.py
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