mapify-cli 3.31.4__py3-none-any.whl → 3.33.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.
Files changed (176) hide show
  1. mapify_cli/__init__.py +1 -1
  2. mapify_cli/skills_eval/apply_patcher.py +252 -213
  3. mapify_cli/templates/agents/monitor.md +21 -0
  4. mapify_cli/templates/codex/agents/monitor.toml +34 -2
  5. mapify_cli/templates/codex/hooks/context-meter.py +3 -3
  6. mapify_cli/templates/codex/hooks/detect-clarification-triggers.py +3 -3
  7. mapify_cli/templates/codex/hooks/end-of-turn.sh +11 -3
  8. mapify_cli/templates/codex/hooks/map-memory-capture.py +3 -3
  9. mapify_cli/templates/codex/hooks/map-memory-endmark.py +3 -3
  10. mapify_cli/templates/codex/hooks/map-memory-recall.py +3 -3
  11. mapify_cli/templates/codex/hooks/map-memory-session.py +3 -3
  12. mapify_cli/templates/codex/hooks/map-stop.py +3 -3
  13. mapify_cli/templates/codex/hooks/map-token-meter.py +3 -3
  14. mapify_cli/templates/codex/hooks/post-compact-context.py +3 -3
  15. mapify_cli/templates/codex/hooks/pre-compact-save-transcript.py +3 -3
  16. mapify_cli/templates/codex/hooks/ralph-context-pruner.py +3 -3
  17. mapify_cli/templates/codex/hooks/ralph-iteration-logger.py +3 -3
  18. mapify_cli/templates/codex/hooks/safety-guardrails.py +3 -3
  19. mapify_cli/templates/codex/hooks/scrub-internal-ids.py +3 -3
  20. mapify_cli/templates/codex/hooks/workflow-context-injector.py +3 -3
  21. mapify_cli/templates/codex/hooks/workflow-gate.py +86 -5
  22. mapify_cli/templates/codex/skills/map-efficient/SKILL.md +54 -3
  23. mapify_cli/templates/codex/skills/map-efficient/efficient-reference.md +71 -23
  24. mapify_cli/templates/codex/skills/map-explain/SKILL.md +3 -1
  25. mapify_cli/templates/codex/skills/map-learn/SKILL.md +5 -0
  26. mapify_cli/templates/codex/skills/map-memory-now/SKILL.md +8 -8
  27. mapify_cli/templates/codex/skills/map-plan/SKILL.md +12 -0
  28. mapify_cli/templates/codex/skills/map-release/SKILL.md +23 -30
  29. mapify_cli/templates/codex/skills/map-release/release-reference.md +145 -44
  30. mapify_cli/templates/codex/skills/map-resume/SKILL.md +8 -0
  31. mapify_cli/templates/codex/skills/map-resume/resume-reference.md +83 -0
  32. mapify_cli/templates/codex/skills/map-review/SKILL.md +542 -256
  33. mapify_cli/templates/codex/skills/map-review/adversarial-reference.md +87 -128
  34. mapify_cli/templates/codex/skills/map-review/review-reference.md +243 -335
  35. mapify_cli/templates/codex/skills/map-skill-eval/SKILL.md +1 -1
  36. mapify_cli/templates/codex/skills/map-wayfind/SKILL.md +20 -8
  37. mapify_cli/templates/codex/skills/map-wayfind/wayfind-reference.md +131 -3
  38. mapify_cli/templates/hooks/context-meter.py +3 -3
  39. mapify_cli/templates/hooks/detect-clarification-triggers.py +3 -3
  40. mapify_cli/templates/hooks/end-of-turn.sh +11 -3
  41. mapify_cli/templates/hooks/map-memory-capture.py +3 -3
  42. mapify_cli/templates/hooks/map-memory-endmark.py +3 -3
  43. mapify_cli/templates/hooks/map-memory-finalize.py +3 -3
  44. mapify_cli/templates/hooks/map-memory-recall.py +3 -3
  45. mapify_cli/templates/hooks/map-statusline.py +3 -3
  46. mapify_cli/templates/hooks/map-token-meter.py +3 -3
  47. mapify_cli/templates/hooks/post-compact-context.py +3 -3
  48. mapify_cli/templates/hooks/pre-compact-save-transcript.py +3 -3
  49. mapify_cli/templates/hooks/ralph-context-pruner.py +3 -3
  50. mapify_cli/templates/hooks/ralph-iteration-logger.py +3 -3
  51. mapify_cli/templates/hooks/safety-guardrails.py +3 -3
  52. mapify_cli/templates/hooks/scrub-internal-ids.py +3 -3
  53. mapify_cli/templates/hooks/workflow-context-injector.py +3 -3
  54. mapify_cli/templates/hooks/workflow-gate.py +90 -6
  55. mapify_cli/templates/map/scripts/map_interruption.py +981 -0
  56. mapify_cli/templates/map/scripts/map_orchestrator.py +224 -9
  57. mapify_cli/templates/map/scripts/map_step_runner.py +979 -289
  58. mapify_cli/templates/map/scripts/validate_spec_citations.py +3 -3
  59. mapify_cli/templates/map/scripts/wayfind_runner.py +575 -4
  60. mapify_cli/templates/skills/map-efficient/SKILL.md +5 -5
  61. mapify_cli/templates/skills/map-efficient/efficient-reference.md +123 -18
  62. mapify_cli/templates/skills/map-explain/SKILL.md +1 -1
  63. mapify_cli/templates/skills/map-memory-now/SKILL.md +8 -8
  64. mapify_cli/templates/skills/map-plan/SKILL.md +2 -0
  65. mapify_cli/templates/skills/map-release/SKILL.md +22 -29
  66. mapify_cli/templates/skills/map-release/release-reference.md +138 -52
  67. mapify_cli/templates/skills/map-resume/SKILL.md +8 -0
  68. mapify_cli/templates/skills/map-resume/resume-reference.md +83 -0
  69. mapify_cli/templates/skills/map-review/SKILL.md +67 -67
  70. mapify_cli/templates/skills/map-review/adversarial-reference.md +39 -41
  71. mapify_cli/templates/skills/map-review/review-reference.md +48 -32
  72. mapify_cli/templates/skills/map-wayfind/SKILL.md +19 -7
  73. mapify_cli/templates/skills/map-wayfind/wayfind-reference.md +131 -3
  74. mapify_cli/templates_src/_partials/monitor-review-context.md.jinja +19 -0
  75. mapify_cli/templates_src/_partials/python-version-guard.py.jinja +3 -3
  76. mapify_cli/templates_src/_partials/serial-blocker-repair.md.jinja +49 -0
  77. mapify_cli/templates_src/_partials/stale-attribution-recovery.md.jinja +30 -0
  78. mapify_cli/templates_src/agents/monitor.md.jinja +2 -0
  79. mapify_cli/templates_src/codex/agents/monitor.toml.jinja +15 -2
  80. mapify_cli/templates_src/codex/hooks/workflow-gate.py.jinja +83 -2
  81. mapify_cli/templates_src/codex/skills/map-auto/auto-reference.md.jinja +1 -78
  82. mapify_cli/templates_src/codex/skills/map-check/SKILL.md.jinja +1 -309
  83. mapify_cli/templates_src/codex/skills/map-check/check-reference.md.jinja +1 -70
  84. mapify_cli/templates_src/codex/skills/map-debug/SKILL.md.jinja +1 -406
  85. mapify_cli/templates_src/codex/skills/map-efficient/SKILL.md.jinja +1 -357
  86. mapify_cli/templates_src/codex/skills/map-efficient/efficient-reference.md.jinja +1 -351
  87. mapify_cli/templates_src/codex/skills/map-explain/SKILL.md.jinja +1 -280
  88. mapify_cli/templates_src/codex/skills/map-fast/SKILL.md.jinja +1 -191
  89. mapify_cli/templates_src/codex/skills/map-learn/SKILL.md.jinja +1 -417
  90. mapify_cli/templates_src/codex/skills/map-learn/templates/example-rules.md.jinja +1 -19
  91. mapify_cli/templates_src/codex/skills/map-learn/templates/rules-unconditional.md.jinja +1 -5
  92. mapify_cli/templates_src/codex/skills/map-learn/templates/rules-with-paths.md.jinja +1 -10
  93. mapify_cli/templates_src/codex/skills/map-memory-now/SKILL.md.jinja +1 -136
  94. mapify_cli/templates_src/codex/skills/map-plan/SKILL.md.jinja +1 -827
  95. mapify_cli/templates_src/codex/skills/map-plan/plan-reference.md.jinja +1 -226
  96. mapify_cli/templates_src/codex/skills/map-prd-review/SKILL.md.jinja +1 -204
  97. mapify_cli/templates_src/codex/skills/map-release/SKILL.md.jinja +1 -177
  98. mapify_cli/templates_src/codex/skills/map-release/release-reference.md.jinja +1 -456
  99. mapify_cli/templates_src/codex/skills/map-resume/SKILL.md.jinja +1 -294
  100. mapify_cli/templates_src/codex/skills/map-resume/resume-reference.md.jinja +1 -218
  101. mapify_cli/templates_src/codex/skills/map-review/SKILL.md.jinja +1 -412
  102. mapify_cli/templates_src/codex/skills/map-review/adversarial-reference.md.jinja +1 -224
  103. mapify_cli/templates_src/codex/skills/map-review/review-reference.md.jinja +1 -477
  104. mapify_cli/templates_src/codex/skills/map-skill-eval/SKILL.md.jinja +1 -178
  105. mapify_cli/templates_src/codex/skills/map-so-search/SKILL.md.jinja +1 -122
  106. mapify_cli/templates_src/codex/skills/map-so-search/scripts/sofa_search.py.jinja +1 -573
  107. mapify_cli/templates_src/codex/skills/map-state/SKILL.md.jinja +1 -217
  108. mapify_cli/templates_src/codex/skills/map-state/scripts/check-complete.sh.jinja +1 -68
  109. mapify_cli/templates_src/codex/skills/map-state/scripts/get-plan-path.sh.jinja +1 -40
  110. mapify_cli/templates_src/codex/skills/map-state/scripts/init-session.sh.jinja +1 -78
  111. mapify_cli/templates_src/codex/skills/map-state/scripts/show-focus.sh.jinja +1 -67
  112. mapify_cli/templates_src/codex/skills/map-state/templates/findings.md.jinja +1 -62
  113. mapify_cli/templates_src/codex/skills/map-state/templates/iteration_history.md.jinja +1 -93
  114. mapify_cli/templates_src/codex/skills/map-state/templates/progress.md.jinja +1 -75
  115. mapify_cli/templates_src/codex/skills/map-state/templates/task_plan.md.jinja +1 -70
  116. mapify_cli/templates_src/codex/skills/map-task/SKILL.md.jinja +1 -295
  117. mapify_cli/templates_src/codex/skills/map-tdd/SKILL.md.jinja +1 -536
  118. mapify_cli/templates_src/codex/skills/map-tokenreport/SKILL.md.jinja +1 -245
  119. mapify_cli/templates_src/codex/skills/map-understand/SKILL.md.jinja +1 -113
  120. mapify_cli/templates_src/codex/skills/map-upgrade/SKILL.md.jinja +1 -5
  121. mapify_cli/templates_src/codex/skills/map-wayfind/SKILL.md.jinja +1 -129
  122. mapify_cli/templates_src/codex/skills/map-wayfind/wayfind-reference.md.jinja +1 -134
  123. mapify_cli/templates_src/hooks/end-of-turn.sh.jinja +11 -3
  124. mapify_cli/templates_src/hooks/workflow-gate.py.jinja +87 -3
  125. mapify_cli/templates_src/map/scripts/map_interruption.py.jinja +981 -0
  126. mapify_cli/templates_src/map/scripts/map_orchestrator.py.jinja +221 -6
  127. mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja +976 -286
  128. mapify_cli/templates_src/map/scripts/wayfind_runner.py.jinja +575 -4
  129. mapify_cli/templates_src/skills/map-auto/auto-reference.md.jinja +9 -8
  130. mapify_cli/templates_src/skills/map-check/SKILL.md.jinja +18 -12
  131. mapify_cli/templates_src/skills/map-check/check-reference.md.jinja +7 -6
  132. mapify_cli/templates_src/skills/map-debug/SKILL.md.jinja +77 -36
  133. mapify_cli/templates_src/skills/map-efficient/SKILL.md.jinja +364 -110
  134. mapify_cli/templates_src/skills/map-efficient/efficient-reference.md.jinja +373 -113
  135. mapify_cli/templates_src/skills/map-explain/SKILL.md.jinja +125 -35
  136. mapify_cli/templates_src/skills/map-fast/SKILL.md.jinja +37 -17
  137. mapify_cli/templates_src/skills/map-learn/SKILL.md.jinja +108 -58
  138. mapify_cli/templates_src/skills/map-learn/templates/example-rules.md.jinja +2 -1
  139. mapify_cli/templates_src/skills/map-learn/templates/rules-unconditional.md.jinja +2 -1
  140. mapify_cli/templates_src/skills/map-learn/templates/rules-with-paths.md.jinja +2 -1
  141. mapify_cli/templates_src/skills/map-memory-now/SKILL.md.jinja +35 -25
  142. mapify_cli/templates_src/skills/map-plan/SKILL.md.jinja +911 -245
  143. mapify_cli/templates_src/skills/map-plan/plan-reference.md.jinja +5 -4
  144. mapify_cli/templates_src/skills/map-prd-review/SKILL.md.jinja +88 -46
  145. mapify_cli/templates_src/skills/map-release/SKILL.md.jinja +33 -33
  146. mapify_cli/templates_src/skills/map-release/release-reference.md.jinja +154 -56
  147. mapify_cli/templates_src/skills/map-resume/SKILL.md.jinja +28 -14
  148. mapify_cli/templates_src/skills/map-resume/resume-reference.md.jinja +36 -26
  149. mapify_cli/templates_src/skills/map-review/SKILL.md.jinja +116 -69
  150. mapify_cli/templates_src/skills/map-review/adversarial-reference.md.jinja +62 -41
  151. mapify_cli/templates_src/skills/map-review/review-reference.md.jinja +55 -38
  152. mapify_cli/templates_src/skills/map-skill-eval/SKILL.md.jinja +62 -42
  153. mapify_cli/templates_src/skills/map-so-search/SKILL.md.jinja +11 -9
  154. mapify_cli/templates_src/skills/map-so-search/scripts/sofa_search.py.jinja +3 -3
  155. mapify_cli/templates_src/skills/map-state/SKILL.md.jinja +20 -14
  156. mapify_cli/templates_src/skills/map-state/scripts/check-complete.sh.jinja +3 -2
  157. mapify_cli/templates_src/skills/map-state/scripts/get-plan-path.sh.jinja +6 -4
  158. mapify_cli/templates_src/skills/map-state/scripts/init-session.sh.jinja +6 -4
  159. mapify_cli/templates_src/skills/map-state/scripts/show-focus.sh.jinja +19 -8
  160. mapify_cli/templates_src/skills/map-state/templates/findings.md.jinja +10 -5
  161. mapify_cli/templates_src/skills/map-state/templates/iteration_history.md.jinja +1 -0
  162. mapify_cli/templates_src/skills/map-state/templates/progress.md.jinja +1 -0
  163. mapify_cli/templates_src/skills/map-state/templates/task_plan.md.jinja +1 -0
  164. mapify_cli/templates_src/skills/map-task/SKILL.md.jinja +42 -35
  165. mapify_cli/templates_src/skills/map-tdd/SKILL.md.jinja +62 -38
  166. mapify_cli/templates_src/skills/map-tokenreport/SKILL.md.jinja +25 -19
  167. mapify_cli/templates_src/skills/map-understand/SKILL.md.jinja +91 -46
  168. mapify_cli/templates_src/skills/map-upgrade/SKILL.md.jinja +3 -1
  169. mapify_cli/templates_src/skills/map-wayfind/SKILL.md.jinja +36 -21
  170. mapify_cli/templates_src/skills/map-wayfind/wayfind-reference.md.jinja +148 -14
  171. mapify_cli/tools/validate_dependencies.py +5 -5
  172. {mapify_cli-3.31.4.dist-info → mapify_cli-3.33.0.dist-info}/METADATA +19 -7
  173. {mapify_cli-3.31.4.dist-info → mapify_cli-3.33.0.dist-info}/RECORD +176 -171
  174. {mapify_cli-3.31.4.dist-info → mapify_cli-3.33.0.dist-info}/WHEEL +0 -0
  175. {mapify_cli-3.31.4.dist-info → mapify_cli-3.33.0.dist-info}/entry_points.txt +0 -0
  176. {mapify_cli-3.31.4.dist-info → mapify_cli-3.33.0.dist-info}/licenses/LICENSE +0 -0
mapify_cli/__init__.py CHANGED
@@ -23,7 +23,7 @@ Or install globally:
23
23
  mapify check
24
24
  """
25
25
 
26
- __version__ = "3.31.4"
26
+ __version__ = "3.33.0"
27
27
 
28
28
  # MUST stay the first import: it turns "this interpreter is older than 3.11" into
29
29
  # a message that says so, instead of the ImportError raised by `from datetime
@@ -1,238 +1,277 @@
1
- """Apply an optimized skill description to the single-source .jinja template.
2
-
3
- Invariants enforced by this module
4
- -----------------------------------
5
- INV-5 / source-untouched: only the specified .jinja file is written;
6
- generated trees are updated via render_repo_trees.
7
- INV-9 / path-safety: target .jinja must resolve under templates_src/;
8
- paths under .git/ are rejected.
9
- VC3 / no-op messages: two distinct messages for the two no-write outcomes;
10
- baseline is NEVER written back (no_improvement guard).
11
- VC4 / fail-loud: patch_skill_description raises ValueError on bad input
12
- with NO partial write; YAML block scalar format enforced.
13
- """
1
+ """Apply a provider-specific winner without modifying the opposite skill."""
14
2
 
15
3
  from __future__ import annotations
16
4
 
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import stat
17
10
  import subprocess
11
+ import tempfile
18
12
  from pathlib import Path
19
-
20
- from mapify_cli.provider_registry import template_skill_root
13
+ from typing import TypedDict
14
+
15
+
16
+ class _JournalEntry(TypedDict):
17
+ path: str
18
+ exists: bool
19
+ mode: int
20
+ backup: str
21
+
22
+
23
+ def _plain_description(text: str, winner: str) -> str:
24
+ """Replace one complete, supported YAML description scalar."""
25
+ if not text.startswith("---\n") or "\n---" not in text:
26
+ raise ValueError("No YAML frontmatter found or no closing '---'")
27
+ end = text.index("\n---", 4)
28
+ lines = text[4:end].splitlines(keepends=True)
29
+ starts = [i for i, line in enumerate(lines) if line.startswith("description:")]
30
+ if len(starts) != 1:
31
+ raise ValueError("Expected exactly one description key")
32
+ i = starts[0]
33
+ scalar = lines[i].split(":", 1)[1].strip()
34
+ if not scalar or scalar.startswith(("[", "{", "&", "*")):
35
+ raise ValueError("Unsupported description scalar")
36
+ j = i + 1
37
+ while j < len(lines) and (not lines[j].strip() or lines[j].startswith((" ", "\t"))):
38
+ j += 1
39
+ if j > i + 1 and scalar not in ("|", "|-", "|+", ">", ">-", ">+"):
40
+ raise ValueError("Unsupported multiline description scalar")
41
+ while j > i + 1 and not lines[j - 1].strip():
42
+ j -= 1
43
+ replacement = "description: |-\n" + "".join(" " + line + "\n" for line in winner.split("\n"))
44
+ header = "".join(lines[:i]) + replacement + "".join(lines[j:])
45
+ return "---\n" + header.rstrip("\n") + text[end:]
21
46
 
22
47
 
23
48
  def patch_skill_description(skill_md_path: Path, new_description: str) -> None:
24
- """Rewrite the ``description:`` block scalar in a SKILL.md.jinja frontmatter.
25
-
26
- The file MUST begin with ``---\\n`` and contain a closing ``\\n---``.
27
- The ``description:`` key MUST exist at column 0 in the frontmatter.
28
-
29
- All computation is done in-memory; the file is written ONCE at the end.
30
- On any validation error the file is left completely untouched.
31
-
32
- Args:
33
- skill_md_path: Path to the .jinja file to patch (or any SKILL.md).
34
- new_description: Replacement description text (already .strip()'d).
35
-
36
- Raises:
37
- ValueError: frontmatter missing, description key absent, or path rejected.
38
- FileNotFoundError: skill_md_path does not exist.
39
- """
40
- # Intent: read-then-compute-then-write-once; no partial write on failure.
49
+ """Preserved plain YAML API; validate fully before the single write."""
41
50
  text = skill_md_path.read_text(encoding="utf-8")
42
-
43
- # --- Validate frontmatter presence ---
44
- if not text.startswith("---\n"):
45
- raise ValueError(
46
- f"No YAML frontmatter found in {skill_md_path}: "
47
- "file must start with '---\\n'"
48
- )
49
- # Find closing --- (must have \n--- to be valid)
50
- close_idx = text.find("\n---", 3)
51
- if close_idx == -1:
52
- raise ValueError(
53
- f"No closing '---' found in frontmatter of {skill_md_path}"
54
- )
55
- # The closing marker: we want the \n--- (possibly \n---\n or \n--- at EOF)
56
- # close_idx points to the '\n' before '---'
57
- frontmatter_end = close_idx + 4 # len('\n---') = 4
58
-
59
- frontmatter = text[4:close_idx] # content between the two ---
60
- rest_of_file = text[frontmatter_end:] # everything from closing --- onward
61
-
62
- # --- Locate description: key at column 0 ---
63
- fm_lines = frontmatter.split("\n")
64
- desc_line_idx: int | None = None
65
- for i, line in enumerate(fm_lines):
66
- if line.startswith("description:"):
67
- desc_line_idx = i
68
- break
69
-
70
- if desc_line_idx is None:
71
- raise ValueError(
72
- f"No 'description:' key found at column 0 in frontmatter of {skill_md_path}"
73
- )
74
-
75
- # --- Find the indented body lines that belong to description ---
76
- # A YAML block scalar body includes indented lines AND interior blank lines
77
- # (blank lines are part of a multi-paragraph '|' block), ending at the first
78
- # NON-blank 0-indent line (the next key) or end of frontmatter.
79
- body_start = desc_line_idx + 1
80
- body_end = body_start # exclusive end index
81
- for i in range(body_start, len(fm_lines)):
82
- line = fm_lines[i]
83
- if line == "" or line.startswith((" ", "\t")):
84
- body_end = i + 1
85
- else:
86
- break
87
- # Trim TRAILING blank lines back out of the replaced span so a blank separator
88
- # between the description block and the next key is preserved (no whitespace drift).
89
- # Interior blanks are kept because the trim stops at the first non-blank line.
90
- while body_end > body_start and fm_lines[body_end - 1] == "":
91
- body_end -= 1
92
-
93
- # --- Build the new description block scalar (|- strip chomping) ---
94
- # Use '|-' so YAML-parsed value == new_description exactly (no trailing newline).
95
- new_desc_lines = ["description: |-"]
96
- for desc_line in new_description.split("\n"):
97
- new_desc_lines.append(f" {desc_line}")
98
-
99
- # --- Reassemble frontmatter ---
100
- new_fm_lines = (
101
- fm_lines[:desc_line_idx]
102
- + new_desc_lines
103
- + fm_lines[body_end:]
104
- )
105
- new_frontmatter = "\n".join(new_fm_lines)
106
- new_text = "---\n" + new_frontmatter + "\n---" + rest_of_file
107
-
108
- # Intent: single atomic write after all validation passes.
109
- skill_md_path.write_text(new_text, encoding="utf-8")
51
+ patched = _plain_description(text, new_description)
52
+ _atomic_bytes(skill_md_path, patched.encode(), stat.S_IMODE(skill_md_path.stat().st_mode))
53
+
54
+
55
+ def _provider_description(text: str, winner: str, provider: str) -> str:
56
+ """Bounded provider-header contract, not a general Jinja source parser."""
57
+ from mapify_cli.delivery.template_renderer import get_environment
58
+ from mapify_cli.skill_ir import parse_frontmatter
59
+
60
+ if not text.startswith("---\n") or "\n---\n" not in text:
61
+ raise ValueError("Unsupported description structure: missing frontmatter")
62
+ header, body = text[4:].split("\n---\n", 1)
63
+ for directive in re.findall(r"\[%.*?%\]", header, flags=re.DOTALL):
64
+ token = directive[2:-2].strip().strip("-").strip()
65
+ if token not in ("else", "endif") and not re.fullmatch(
66
+ r"if PROVIDER == ([\"'])(?:claude|codex)\1|set cmd = \"/\" if PROVIDER == \"claude\" else \"\$\"", token
67
+ ):
68
+ raise ValueError(f"Unsupported description directive: {directive}")
69
+ if re.search(r"<%(?!\s*cmd\s*%>)|\[#", header):
70
+ raise ValueError("Unsupported description expression")
71
+ if any(t in winner for t in ("[%", "<%", "[#")) or not winner.strip():
72
+ raise ValueError("Unsupported winner description")
73
+ env = get_environment()
74
+ rendered = env.from_string("---\n" + header + "\n---\n").render(PROVIDER=provider)
75
+ parse_frontmatter(rendered.split("---", 2)[1])
76
+ patched = _plain_description(rendered, winner)
77
+ parsed = parse_frontmatter(patched.split("---", 2)[1])
78
+ if parsed.get("description") != winner:
79
+ raise ValueError("Unsupported description structure: winner did not round-trip")
80
+ opposite = "codex" if provider == "claude" else "claude"
81
+ original_other = env.from_string("---\n" + header + "\n---\n").render(PROVIDER=opposite)
82
+ # Independent provider descriptions have unambiguous scalar spans. Edit
83
+ # only the selected block, retaining every other source byte.
84
+ branches = list(re.finditer(r'\[% if PROVIDER == ([\"\'])(claude|codex)\1 %\]description:', header))
85
+ for branch in branches:
86
+ stop = header.find("[% endif", branch.end())
87
+ alternate = header.find("[% else %]description:", branch.end(), stop)
88
+ if alternate < 0:
89
+ continue
90
+ first = branch.end() - len("description:")
91
+ second = alternate + len("[% else %]")
92
+ start, limit = (first, alternate) if provider == branch.group(2) else (second, stop)
93
+ segment = header[start:limit]
94
+ patched_segment = _plain_description("---\n" + segment + "\n---\n", winner)[4:-5]
95
+ if segment.endswith("\n"):
96
+ patched_segment += "\n"
97
+ candidate_header = header[:start] + patched_segment + header[limit:]
98
+ result = "---\n" + candidate_header + "\n---\n" + body
99
+ if env.from_string("---\n" + candidate_header + "\n---\n").render(PROVIDER=opposite) != original_other:
100
+ raise ValueError("Unsupported conditional description whitespace")
101
+ return result
102
+ binding = '[% set cmd = "/" if PROVIDER == "claude" else "$" -%]'
103
+ combined = ("---\n" + binding + f'[% if PROVIDER == "{provider}" %]'
104
+ + patched[4:-4] + "[% else %]" + original_other[4:-4] + "[% endif -%]\n---\n")
105
+ if env.from_string(combined).render(PROVIDER=opposite) != original_other:
106
+ raise ValueError("Unsupported description whitespace structure")
107
+ return combined + body
108
+
109
+
110
+ def _atomic_bytes(path: Path, data: bytes, mode: int) -> None:
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ fd, name = tempfile.mkstemp(prefix=".mapify-apply-", dir=path.parent)
113
+ try:
114
+ with os.fdopen(fd, "wb") as stream:
115
+ stream.write(data)
116
+ stream.flush()
117
+ os.fsync(stream.fileno())
118
+ os.chmod(name, mode)
119
+ os.replace(name, path)
120
+ directory_fd = os.open(path.parent, os.O_RDONLY)
121
+ try:
122
+ os.fsync(directory_fd)
123
+ finally:
124
+ os.close(directory_fd)
125
+ finally:
126
+ Path(name).unlink(missing_ok=True)
127
+
128
+
129
+ def _recover_transaction(root: Path) -> None:
130
+ journal = root / ".map/skill-eval-apply-transaction"
131
+ manifest = journal / "manifest.json"
132
+ if any(p.is_symlink() for p in (journal, journal.parent, manifest)):
133
+ raise ValueError(f"Unsafe apply recovery journal: {journal}")
134
+ if not manifest.exists():
135
+ return
136
+ entries = json.loads(manifest.read_text(encoding="utf-8"))
137
+ if not (journal / "complete").exists():
138
+ for entry in entries:
139
+ target = root / entry["path"]
140
+ backup = journal / entry["backup"]
141
+ if (not target.resolve().is_relative_to(root.resolve())
142
+ or not backup.resolve().is_relative_to(journal.resolve())
143
+ or any(p.is_symlink() for p in (target, *target.parents, backup))):
144
+ raise ValueError(f"Unsafe recovery path in {manifest}")
145
+ if entry["exists"]:
146
+ _atomic_bytes(target, (journal / entry["backup"]).read_bytes(), entry["mode"])
147
+ else:
148
+ target.unlink(missing_ok=True)
149
+ shutil.rmtree(journal)
150
+
151
+
152
+ def _commit_transaction(root: Path, updates: dict[Path, bytes]) -> list[Path]:
153
+ journal = root / ".map/skill-eval-apply-transaction"
154
+ for path in updates:
155
+ if any(p.is_symlink() for p in (path, *path.parents) if p != root.parent):
156
+ raise ValueError(f"Refusing symlink output: {path}")
157
+ if journal.exists():
158
+ raise ValueError(f"Incomplete apply journal requires inspection: {journal}")
159
+ journal.mkdir(parents=True, exist_ok=False)
160
+ entries: list[_JournalEntry] = []
161
+ for i, (path, data) in enumerate(updates.items()):
162
+ exists = path.exists()
163
+ if exists and path.read_bytes() == data:
164
+ continue
165
+ mode = stat.S_IMODE(path.stat().st_mode) if exists else 0o644
166
+ entry: _JournalEntry = {"path": str(path.relative_to(root)), "exists": exists, "mode": mode, "backup": str(i)}
167
+ if exists:
168
+ _atomic_bytes(journal / str(i), path.read_bytes(), 0o600)
169
+ entries.append(entry)
170
+ _atomic_bytes(journal / "manifest.json", json.dumps(entries).encode(), 0o600)
171
+ fd = os.open(journal, os.O_RDONLY)
172
+ try:
173
+ os.fsync(fd)
174
+ finally:
175
+ os.close(fd)
176
+ try:
177
+ for entry in entries:
178
+ path = root / str(entry["path"])
179
+ _atomic_bytes(path, updates[path], int(entry["mode"]))
180
+ _atomic_bytes(journal / "complete", b"complete", 0o600)
181
+ except BaseException:
182
+ _recover_transaction(root)
183
+ raise
184
+ shutil.rmtree(journal)
185
+ return [root / str(entry["path"]) for entry in entries]
110
186
 
111
187
 
112
188
  def apply_optimized_description(
113
- *,
114
- skill: str,
115
- winner: str,
116
- current_description: str,
117
- no_improvement: bool,
118
- repo_root: Path,
119
- stage: bool = True,
120
- provider: str = "claude",
189
+ *, skill: str, winner: str, current_description: str, no_improvement: bool,
190
+ repo_root: Path, stage: bool = True, provider: str = "claude",
121
191
  ) -> str:
122
- """Apply ``winner`` description to the skill's single-source .jinja template.
123
-
124
- Prints a user-facing message and returns an outcome code string.
125
-
126
- Outcome codes:
127
- ``'no_improvement'`` — baseline wins; nothing written.
128
- ``'identical'`` — winner == current; nothing written.
129
- ``'applied'`` — .jinja patched and trees re-rendered.
130
-
131
- Args:
132
- skill: Skill name (e.g. ``'map-skill-eval'``).
133
- winner: Candidate description text that won the eval.
134
- current_description: Current description text from the .jinja.
135
- no_improvement: True when baseline beat every candidate on held-out test.
136
- repo_root: Absolute path to the repository root.
137
- stage: If True, stage the patched .jinja + existing gate trees via a scoped
138
- ``git add -- <paths>`` after rendering (warn-only on failure; never commits).
139
-
140
- Returns:
141
- Outcome code string.
142
-
143
- Raises:
144
- ValueError: Path safety violation (outside templates_src or under .git/).
145
- FileNotFoundError: .jinja source file does not exist.
146
- """
147
- # --- VC3: no_improvement guard — NEVER write baseline back ---
192
+ """Serialize recovery/promotion across processes using the installer barrier."""
148
193
  if no_improvement:
149
- print(
150
- "No improvement found: current description already optimal on held-out test"
151
- )
194
+ print("No improvement found: current description already optimal on held-out test")
152
195
  return "no_improvement"
153
-
154
- # --- VC3: winner identical guard ---
155
196
  if winner == current_description:
156
197
  print("Winner identical to current description; no file changes")
157
198
  return "identical"
199
+ from mapify_cli.update_state import installer_process_lock
158
200
 
159
- # --- Resolve and validate target path BEFORE any filesystem touch (INV-9) ---
160
- # Path-safety runs first so a traversal/.git target is rejected even when the
161
- # file does not exist (resolve() normalises '..' without requiring existence).
162
- templates_src_root = (repo_root / "src" / "mapify_cli" / "templates_src").resolve()
163
- provider_root = template_skill_root(provider)
164
- jinja = (
165
- repo_root
166
- / "src"
167
- / "mapify_cli"
168
- / "templates_src"
169
- / provider_root
170
- / skill
171
- / "SKILL.md.jinja"
172
- )
173
- resolved = jinja.resolve()
174
-
175
- # Intent: reject paths that escape templates_src or touch .git/
176
- try:
177
- resolved.relative_to(templates_src_root)
178
- except ValueError:
179
- raise ValueError(
180
- f"Path safety violation: {resolved} is not under {templates_src_root}"
181
- ) from None
182
-
183
- if ".git" in resolved.parts:
184
- raise ValueError(f"Path safety violation: {resolved} is under .git/")
185
-
186
- if not jinja.exists():
187
- raise FileNotFoundError(
188
- f"SKILL.md.jinja not found for skill '{skill}': {jinja}"
201
+ repo_root = repo_root.resolve()
202
+ with installer_process_lock(repo_root, timeout_s=30):
203
+ return _apply_optimized_description(
204
+ skill=skill, winner=winner, current_description=current_description,
205
+ no_improvement=no_improvement, repo_root=repo_root, stage=stage, provider=provider,
189
206
  )
190
207
 
191
- # --- Patch the .jinja source ---
192
- patch_skill_description(jinja, winner)
193
-
194
- # --- Re-render both providers (INV-5) ---
195
- # Intent: import inline to keep module importable without jinja2 at module load time.
196
- # Pass templates_src_root explicitly so that when repo_root is a temp dir
197
- # the renderer reads from the temp copy, not the installed package source.
198
- from mapify_cli.delivery.template_renderer import render_repo_trees
199
-
200
- render_templates_src = repo_root / "src" / "mapify_cli" / "templates_src"
201
- render_repo_trees("claude", repo_root=repo_root, templates_src_root=render_templates_src)
202
- render_repo_trees("codex", repo_root=repo_root, templates_src_root=render_templates_src)
203
-
204
- # --- Optionally stage ONLY the patched source + rendered gate trees (warn-only) ---
205
- # Scoped staging (never `git add -A`): avoids sweeping unrelated dirty files
206
- # (in-progress work, .map/ workflow state) into the caller's next commit.
207
- if stage:
208
- gate_relpaths = (
209
- ".claude",
210
- ".codex",
211
- "src/mapify_cli/templates",
212
- ".agents/skills",
208
+
209
+ def _apply_optimized_description(
210
+ *, skill: str, winner: str, current_description: str, no_improvement: bool,
211
+ repo_root: Path, stage: bool = True, provider: str = "claude",
212
+ ) -> str:
213
+ """Render in isolation, then transactionally promote only selected skill files."""
214
+ if no_improvement:
215
+ print("No improvement found: current description already optimal on held-out test")
216
+ return "no_improvement"
217
+ if winner == current_description:
218
+ print("Winner identical to current description; no file changes")
219
+ return "identical"
220
+ if provider not in ("claude", "codex"):
221
+ raise ValueError(f"Unsupported provider: {provider}")
222
+ source_root = (repo_root / "src/mapify_cli/templates_src").resolve()
223
+ jinja = source_root / "skills" / skill / "SKILL.md.jinja"
224
+ if not jinja.resolve().is_relative_to(source_root) or ".git" in jinja.resolve().parts:
225
+ raise ValueError(f"Path safety violation outside templates_src or under .git: {jinja}")
226
+ _recover_transaction(repo_root)
227
+ original = jinja.read_text(encoding="utf-8")
228
+ candidate = _provider_description(original, winner, provider)
229
+ from mapify_cli.delivery.template_renderer import get_environment, render_repo_trees
230
+
231
+ with tempfile.TemporaryDirectory(prefix="mapify-apply-") as tmp:
232
+ sandbox = Path(tmp)
233
+ source = sandbox / "src/mapify_cli/templates_src"
234
+ shutil.copytree(source_root, source)
235
+ target = source / jinja.relative_to(source_root)
236
+ target.write_text(candidate, encoding="utf-8")
237
+ env = get_environment(source)
238
+ name = jinja.relative_to(source_root).as_posix()
239
+ opposite = "codex" if provider == "claude" else "claude"
240
+ before_env = get_environment(source_root)
241
+ if env.get_template(name).render(PROVIDER=opposite) != before_env.get_template(name).render(PROVIDER=opposite):
242
+ raise ValueError("Unsupported description structure: opposite provider changed")
243
+ from mapify_cli.skill_ir import parse_frontmatter
244
+
245
+ selected = env.get_template(name).render(PROVIDER=provider)
246
+ if parse_frontmatter(selected.split("---", 2)[1]).get("description") != winner:
247
+ raise ValueError("Unsupported description structure: selected winner mismatch")
248
+ for runtime in ("claude", "codex"):
249
+ render_repo_trees(runtime, repo_root=sandbox, templates_src_root=source)
250
+ roots = (".claude/skills", "src/mapify_cli/templates/skills") if provider == "claude" else (
251
+ ".agents/skills", "src/mapify_cli/templates/codex/skills"
213
252
  )
214
- to_stage: list[str] = [str(jinja.relative_to(repo_root))]
215
- for rel in gate_relpaths:
216
- if (repo_root / rel).exists():
217
- to_stage.append(rel)
253
+ updates = {jinja: target.read_bytes()}
254
+ for root in roots:
255
+ rel = Path(root) / skill / "SKILL.md"
256
+ rendered_bytes = (sandbox / rel).read_bytes()
257
+ generated_text = rendered_bytes.decode()
258
+ if parse_frontmatter(generated_text.split("---", 2)[1]).get("description") != winner:
259
+ raise ValueError("Unsupported provider source: generated winner differs from common source")
260
+ provider_template = ("codex/" if provider == "codex" else "") + name
261
+ baseline = before_env.get_template(provider_template).render(PROVIDER=provider)
262
+ destination = repo_root / rel
263
+ if destination.exists() and destination.read_bytes() != baseline.encode():
264
+ raise ValueError(f"Refusing modified or stale generated skill: {destination}; preserve your edits and render explicitly first")
265
+ if _plain_description(baseline, winner) != generated_text:
266
+ raise ValueError("Unsupported provider source: non-description generated content changed")
267
+ updates[repo_root / rel] = rendered_bytes
268
+ changed = _commit_transaction(repo_root, updates)
269
+ if stage and changed:
218
270
  result = subprocess.run(
219
- ["git", "add", "--", *to_stage],
220
- cwd=repo_root,
221
- capture_output=True,
222
- text=True,
223
- check=False,
271
+ ["git", "add", "--", *(str(p.relative_to(repo_root)) for p in changed)],
272
+ cwd=repo_root, capture_output=True, text=True, check=False,
224
273
  )
225
- if result.returncode != 0:
226
- print(
227
- f"Warning: 'git add' failed (exit {result.returncode}): "
228
- f"{result.stderr.strip()}"
229
- )
230
-
231
- # --- Reminder: skill-rules.json is NOT auto-patched ---
232
- print(
233
- f"Applied new description for '{skill}'. "
234
- "Note: skill-rules.json description is NOT auto-patched — "
235
- "update it by hand or via ST-008."
236
- )
237
-
274
+ if result.returncode:
275
+ print(f"Warning: 'git add' failed (exit {result.returncode}): {result.stderr.strip()}")
276
+ print(f"Applied new description for '{skill}'. Note: skill-rules.json description is NOT auto-patched")
238
277
  return "applied"
@@ -40,6 +40,27 @@ You are a **validation agent**, NOT a code editor. Your role:
40
40
 
41
41
  ---
42
42
 
43
+ ## Review context (MANDATORY)
44
+
45
+ - **Actor subtask validation:** When dispatched to validate an Actor subtask,
46
+ enforce its AAG contract and mutation boundary below. Missing or invalid
47
+ blueprint/subtask metadata is a blocking setup error, not a standalone review.
48
+ - **Standalone /map-review:** When dispatched to review a diff/PR using a review
49
+ bundle rather than validate an Actor subtask, assess correctness, security,
50
+ tests, regressions, and any supplied requirements against that diff.
51
+ Do not require an Actor AAG contract, blueprint, or subtask ID, and do not run
52
+ the Actor mutation-boundary command. Missing planning artifacts are context
53
+ limitations, not defects in the reviewed PR. Continue all applicable build,
54
+ test, and quality gates; report concrete defects with source evidence.
55
+ - Never infer standalone review from a missing blueprint. An explicit Actor
56
+ subtask validation assignment takes precedence over generic review wording.
57
+ If the assignment is ambiguous, ask the parent to clarify before a verdict.
58
+ Do not create a synthetic blueprint or reset/migrate workflow state to review.
59
+
60
+ The Actor-specific sequence and rejection rules below (AAG, allowed scope,
61
+ subtask metadata) apply only to Actor subtask validation, not standalone review.
62
+
63
+
43
64
  <Monitor_Contract_Verification_v2_9>
44
65
 
45
66
  ## Contract-Based Verification Protocol
@@ -31,6 +31,27 @@ Your output: JSON with valid: true|false and issues[] array.
31
31
 
32
32
  ---
33
33
 
34
+ ## Review context (MANDATORY)
35
+
36
+ - **Actor subtask validation:** When dispatched to validate an Actor subtask,
37
+ enforce its AAG contract and mutation boundary below. Missing or invalid
38
+ blueprint/subtask metadata is a blocking setup error, not a standalone review.
39
+ - **Standalone /map-review:** When dispatched to review a diff/PR using a review
40
+ bundle rather than validate an Actor subtask, assess correctness, security,
41
+ tests, regressions, and any supplied requirements against that diff.
42
+ Do not require an Actor AAG contract, blueprint, or subtask ID, and do not run
43
+ the Actor mutation-boundary command. Missing planning artifacts are context
44
+ limitations, not defects in the reviewed PR. Continue all applicable build,
45
+ test, and quality gates; report concrete defects with source evidence.
46
+ - Never infer standalone review from a missing blueprint. An explicit Actor
47
+ subtask validation assignment takes precedence over generic review wording.
48
+ If the assignment is ambiguous, ask the parent to clarify before a verdict.
49
+ Do not create a synthetic blueprint or reset/migrate workflow state to review.
50
+
51
+ The Actor-specific sequence and rejection rules below (AAG, allowed scope,
52
+ subtask metadata) apply only to Actor subtask validation, not standalone review.
53
+
54
+
34
55
  # Contract-Based Verification Protocol
35
56
 
36
57
  Primary Mission: Verify that Actor's implementation exactly matches the AAG contract
@@ -52,8 +73,19 @@ Verification sequence (execute in order):
52
73
 
53
74
  3. Verify Goal is achieved -- trace code path to confirm the stated outcome
54
75
  4. Verify Action is implemented -- check that the specified method/operation exists
55
- 5. Verify scope -- confirm changes stay within Actor's allowed_scope
56
- 6. Run quality gates below
76
+ 5. Verify mutation boundary (MANDATORY): Run
77
+ python3 .map/scripts/map_step_runner.py validate_mutation_boundary <branch> <subtask_id>
78
+ to compare the actual git diff against the subtask's declared affected_files.
79
+ - status="clean" -> continue.
80
+ - status="warning" -> record the unexpected files; do NOT auto-reject.
81
+ The CLI also appends to .map/<branch>/scope-violations.log for audit.
82
+ - status="violation" (MAP_STRICT_SCOPE=1) -> valid: false with the unexpected
83
+ list. The Actor must re-scope.
84
+ - status="error" (missing blueprint, unknown subtask, git failure, not a git
85
+ repo) -> valid: false with the returned message. The CLI exits non-zero;
86
+ repair the setup before re-running Monitor. Never silently skip this gate.
87
+ 6. Verify scope -- confirm changes stay within Actor's allowed_scope
88
+ 7. Run quality gates below
57
89
 
58
90
  Deterministic REJECT rule:
59
91
  If implementation deviates from the AAG contract -> valid: false -- regardless of how
@@ -36,13 +36,13 @@ import sys
36
36
  # evaluated annotations) fails with a message that never mentions the version.
37
37
  # Name the real cause instead. Kept in sync with
38
38
  # mapify_cli/python_runtime.MINIMUM_PYTHON.
39
- # UP036 is suppressed on purpose: the project targets 3.11, but the interpreter
40
- # executing this shipped file is the user's `python3`, not the project's.
39
+ # Compare the runtime tuple: the executing interpreter is the user's `python3`,
40
+ # not necessarily the Python version targeted by the project's static checker.
41
41
  #
42
42
  # FAIL-OPEN mode. Exit 1 is a non-blocking hook error for Claude Code (only
43
43
  # exit 2, or a JSON deny, blocks a tool call), so the reason reaches the user
44
44
  # and the session continues -- a broken interpreter is not a policy decision.
45
- if sys.version_info < (3, 11): # noqa: UP036
45
+ if tuple(sys.version_info) < (3, 11):
46
46
  _MAP_PYTHON_PROBLEM = (
47
47
  f"MAP requires Python 3.11 or newer, but {sys.executable} is "
48
48
  f"Python {sys.version_info[0]}.{sys.version_info[1]}.\n"
@@ -37,13 +37,13 @@ import sys
37
37
  # evaluated annotations) fails with a message that never mentions the version.
38
38
  # Name the real cause instead. Kept in sync with
39
39
  # mapify_cli/python_runtime.MINIMUM_PYTHON.
40
- # UP036 is suppressed on purpose: the project targets 3.11, but the interpreter
41
- # executing this shipped file is the user's `python3`, not the project's.
40
+ # Compare the runtime tuple: the executing interpreter is the user's `python3`,
41
+ # not necessarily the Python version targeted by the project's static checker.
42
42
  #
43
43
  # FAIL-OPEN mode. Exit 1 is a non-blocking hook error for Claude Code (only
44
44
  # exit 2, or a JSON deny, blocks a tool call), so the reason reaches the user
45
45
  # and the session continues -- a broken interpreter is not a policy decision.
46
- if sys.version_info < (3, 11): # noqa: UP036
46
+ if tuple(sys.version_info) < (3, 11):
47
47
  _MAP_PYTHON_PROBLEM = (
48
48
  f"MAP requires Python 3.11 or newer, but {sys.executable} is "
49
49
  f"Python {sys.version_info[0]}.{sys.version_info[1]}.\n"