mapify-cli 3.19.0__py3-none-any.whl → 3.20.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mapify_cli/__init__.py CHANGED
@@ -23,7 +23,7 @@ Or install globally:
23
23
  mapify check
24
24
  """
25
25
 
26
- __version__ = "3.19.0"
26
+ __version__ = "3.20.0"
27
27
 
28
28
  import os
29
29
  import subprocess
@@ -122,6 +122,12 @@ class MapConfig:
122
122
  # .map/config.yaml. When enabled, the map-so-search skill is available.
123
123
  sofa_enabled: bool = False
124
124
 
125
+ # Strip MAP-internal workflow IDs (ST-/AC-/VC-/INV-/HC-) from run-changed
126
+ # code at workflow completion (Stop hook `scrub-internal-ids.py`). On by
127
+ # default; set `scrub_internal_ids: false` in .map/config.yaml to opt out
128
+ # and keep the IDs the framework wrote into comments/strings/test names.
129
+ scrub_internal_ids: bool = True
130
+
125
131
 
126
132
  def load_map_config(project_path: Path) -> MapConfig:
127
133
  """Load MAP config from .map/config.yaml with fallback to defaults.
@@ -384,6 +390,11 @@ minimality: lite
384
390
  # Stack Overflow for Agents (SOFA) integration — opt-in, off by default.
385
391
  # Enable via `mapify init --sofa` or uncomment the line below.
386
392
  # sofa.enabled: false
393
+
394
+ # Strip MAP-internal workflow IDs (ST-/AC-/VC-/INV-/HC-) from the code a run
395
+ # changed, at workflow completion (Stop hook). On by default; uncomment and set
396
+ # to false to keep the IDs the framework wrote into comments/strings/test names.
397
+ # scrub_internal_ids: true
387
398
  """
388
399
 
389
400
 
@@ -776,6 +776,7 @@ Follow this protocol exactly — do not infer "how seniors write" or add stylist
776
776
  4. **Intent comments**: Add a one-line `# Intent: <why>` comment above any non-obvious logic block. Do NOT comment obvious code.
777
777
  5. **Performance**: Clarity first, optimize only if proven necessary.
778
778
  6. **Imports**: Group by stdlib → third-party → local. One blank line between groups.
779
+ 7. **No internal workflow IDs in comments or strings**: NEVER write MAP-internal workflow identifiers — subtask `ST-001`, acceptance criteria `AC-3`, verification criteria `VC1`, invariants `INV-7`, hard constraints `HC-1` — into shipped code comments or string literals. They are workflow scaffolding, not user-facing documentation. State the *reason* without the ID (`# enforce single-writer invariant`, not `# INV-7 single writer`). The one exception is the transient `test_vc<n>_*` test-naming aid described above: keep it during the run — the framework strips the `vc<n>` segment from shipped tests automatically at completion.
779
780
 
780
781
  ## Error Handling Patterns
781
782
 
@@ -86,7 +86,7 @@ documented per event in the official Claude Code docs.
86
86
 
87
87
  ## Hook inventory
88
88
 
89
- All 15 hooks (14 `.py` + `end-of-turn.sh`) are classified against the
89
+ All 16 hooks (15 `.py` + `end-of-turn.sh`) are classified against the
90
90
  `MAP_INVOKED_BY` recursion-guard contract. **REQUIRE_GUARD** hooks early-exit
91
91
  when MAP spawns a nested subprocess; **FORBID_GUARD** hooks must always fire
92
92
  and may not carry the guard. Full contract and per-hook rationale:
@@ -107,6 +107,7 @@ classification is enforced by `scripts/lint-hooks.py` (in `make lint` /
107
107
  | `pre-compact-save-transcript.py` | `PreCompact` | No | REQUIRE_GUARD | Save full conversation transcript |
108
108
  | `detect-clarification-triggers.py` | `UserPromptSubmit` | No | REQUIRE_GUARD | Detect "ask if unclear" + async/durability language |
109
109
  | `end-of-turn.sh` | `Stop` | No | REQUIRE_GUARD | Auto-fix lint/format silently |
110
+ | `scrub-internal-ids.py` | `Stop` | No | REQUIRE_GUARD | On `WORKFLOW_COMPLETE`, strip leaked `ST-`/`AC-`/`VC-`/`INV-`/`HC-` IDs from run-changed code and commit the cleanup (gated, runs once) |
110
111
  | `map-memory-capture.py` | `Stop` | No | REQUIRE_GUARD | Append per-turn scratch WAL record (cross-session memory) |
111
112
  | `map-memory-endmark.py` | `SessionEnd` | No | REQUIRE_GUARD | Best-effort 'ended' marker for the session WAL |
112
113
  | `map-memory-finalize.py` | `SessionStart` | No | REQUIRE_GUARD | Finalize prior dirty session scratches into digests (claude -p) |
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env python3
2
+ """Strip MAP-internal workflow IDs from shipped code — Stop hook.
3
+
4
+ Runs when the main agent finishes a turn (Stop event). It is a thin, strictly
5
+ gated wrapper: the deterministic work lives in ``.map/scripts/scrub_internal_ids
6
+ .py`` (stdlib-only, importable-free, identical in this repo and installed
7
+ projects — same split as ``map-token-meter`` / ``map_step_runner``).
8
+
9
+ Why a Stop hook and not a skill step: the executor must not depend on the agent
10
+ remembering to call a command at close. The harness fires Stop deterministically;
11
+ the hook decides for itself whether the run is done.
12
+
13
+ Gating (no-op in ~all turns):
14
+ 1. ``MAP_INVOKED_BY`` set -> exit (don't run inside a sub-agent).
15
+ 2. no ``.map/<branch>/step_state.json`` OR ``workflow_status`` is not
16
+ ``WORKFLOW_COMPLETE`` -> exit (run not finished).
17
+ 3. marker ``.map/<branch>/.scrub_done`` present -> exit (already ran once).
18
+ 4. ``scrub_internal_ids: false`` in ``.map/config.yaml`` -> exit (opt-out).
19
+
20
+ When it does run: calls the engine in ``clean`` mode, commits the resulting
21
+ working-tree changes as a dedicated ``chore(map): strip internal workflow IDs``
22
+ commit (never amends), and writes the marker so it fires exactly once.
23
+
24
+ Synchronous by design (justified exception to the async-hook rule): the scrub
25
+ must finish and commit before the run is considered done; a detached run would
26
+ race the close/PR and could land after it. The work is fast — scoped to the
27
+ run's git diff, like ``end-of-turn.sh``.
28
+
29
+ Exit codes: always 0. The scrub is advisory and must never block a turn.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import os
35
+ import re
36
+ import subprocess
37
+ import sys
38
+ from pathlib import Path
39
+ from typing import NoReturn
40
+
41
+ PROJECT_DIR = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()))
42
+ ENGINE = PROJECT_DIR / ".map" / "scripts" / "scrub_internal_ids.py"
43
+
44
+
45
+ def _silent() -> NoReturn:
46
+ sys.stdout.write("{}")
47
+ sys.exit(0)
48
+
49
+
50
+ def _sanitize_branch(branch: str) -> str:
51
+ sanitized = branch.replace("/", "-")
52
+ sanitized = re.sub(r"[^a-zA-Z0-9_.-]", "-", sanitized)
53
+ sanitized = re.sub(r"-+", "-", sanitized).strip("-")
54
+ if ".." in sanitized or sanitized.startswith("."):
55
+ return "default"
56
+ return sanitized or "default"
57
+
58
+
59
+ def _get_branch() -> str:
60
+ try:
61
+ result = subprocess.run(
62
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
63
+ capture_output=True, text=True, cwd=PROJECT_DIR, timeout=2,
64
+ )
65
+ if result.returncode == 0:
66
+ return _sanitize_branch(result.stdout.strip())
67
+ except Exception:
68
+ pass
69
+ return "default"
70
+
71
+
72
+ def _scrub_enabled(branch_dir: Path) -> bool:
73
+ """Honor the ``scrub_internal_ids`` opt-out in .map/config.yaml (default on).
74
+
75
+ Minimal stdlib parse — PyYAML is not guaranteed in installed projects.
76
+ """
77
+ config = PROJECT_DIR / ".map" / "config.yaml"
78
+ if not config.exists():
79
+ return True
80
+ try:
81
+ text = config.read_text(encoding="utf-8")
82
+ except OSError:
83
+ return True
84
+ m = re.search(r"^\s*scrub[._]internal_ids\s*:\s*(\S+)", text, re.MULTILINE)
85
+ if m and m.group(1).strip().lower() in ("false", "no", "off", "0"):
86
+ return False
87
+ return True
88
+
89
+
90
+ def main() -> None:
91
+ if os.environ.get("MAP_INVOKED_BY"):
92
+ sys.exit(0)
93
+ # Stop payload is read defensively but not required.
94
+ try:
95
+ json.load(sys.stdin)
96
+ except (json.JSONDecodeError, ValueError):
97
+ pass
98
+
99
+ branch = _get_branch()
100
+ branch_dir = PROJECT_DIR / ".map" / branch
101
+ state_file = branch_dir / "step_state.json"
102
+ if not state_file.exists():
103
+ _silent()
104
+ try:
105
+ state = json.loads(state_file.read_text(encoding="utf-8"))
106
+ except (json.JSONDecodeError, OSError):
107
+ _silent()
108
+
109
+ status = str(state.get("workflow_status") or "").strip().upper()
110
+ if status != "WORKFLOW_COMPLETE":
111
+ _silent()
112
+
113
+ marker = branch_dir / ".scrub_done"
114
+ if marker.exists():
115
+ _silent()
116
+ if not _scrub_enabled(branch_dir):
117
+ marker.write_text("disabled\n", encoding="utf-8")
118
+ _silent()
119
+ if not ENGINE.exists():
120
+ _silent()
121
+
122
+ # Run the engine. MAP_INVOKED_BY guards any nested hook from re-entering.
123
+ env = {**os.environ, "MAP_INVOKED_BY": "scrub-internal-ids"}
124
+ try:
125
+ proc = subprocess.run(
126
+ [sys.executable, str(ENGINE), "clean", "--branch", branch],
127
+ capture_output=True, text=True, cwd=PROJECT_DIR, env=env, timeout=120,
128
+ )
129
+ except (OSError, subprocess.SubprocessError):
130
+ _silent()
131
+
132
+ report = {}
133
+ try:
134
+ report = json.loads(proc.stdout)
135
+ except (json.JSONDecodeError, ValueError):
136
+ report = {}
137
+
138
+ modified = report.get("files_modified") or []
139
+ if modified:
140
+ try:
141
+ subprocess.run(["git", "add", "--", *modified], cwd=PROJECT_DIR,
142
+ env=env, capture_output=True, text=True, timeout=15)
143
+ subprocess.run(
144
+ ["git", "commit", "-m", "chore(map): strip internal workflow IDs"],
145
+ cwd=PROJECT_DIR, env=env, capture_output=True, text=True, timeout=30,
146
+ )
147
+ except (OSError, subprocess.SubprocessError):
148
+ pass # leave the cleaned working tree in place; never block
149
+
150
+ # Mark done so the scrub fires exactly once for this completed run.
151
+ try:
152
+ marker.write_text(
153
+ json.dumps({
154
+ "files_modified": modified,
155
+ "tokens_removed": report.get("tokens_removed", 0),
156
+ "tests_renamed": report.get("tests_renamed", []),
157
+ "residual": report.get("residual", []),
158
+ }) + "\n",
159
+ encoding="utf-8",
160
+ )
161
+ except OSError:
162
+ pass
163
+
164
+ if report.get("residual"):
165
+ sys.stderr.write(
166
+ "[scrub-internal-ids] could not auto-remove some internal IDs; "
167
+ f"see {marker} (residual list).\n"
168
+ )
169
+ _silent()
170
+
171
+
172
+ if __name__ == "__main__":
173
+ main()