riskrails 3.2.1 → 4.0.1

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 (141) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +40 -83
  5. package/agents/ai-reviewer.md.tmpl +1 -1
  6. package/ai/using-riskrails.md +0 -3
  7. package/bin/riskrails.js +13 -59
  8. package/hooks/ai-path-guard-defaults.json +1 -1
  9. package/hooks/context-guard.py +2 -4
  10. package/hooks/guard/paths.py +3 -6
  11. package/hooks/guard/scope.py +1 -3
  12. package/hooks/runtime-gate.py +18 -58
  13. package/instructions/routing.md +2 -2
  14. package/package.json +1 -1
  15. package/scripts/ai-path +1 -1
  16. package/scripts/resolve-profile.py +0 -3
  17. package/skills/ai-audit/templates/ai-sdlc-adoption-plan.md +1 -1
  18. package/skills/ai-audit/templates/play-scoring-rubric.md +1 -1
  19. package/skills/ai-init/templates/.ai/AGENTS.md +6 -6
  20. package/skills/ai-init/templates/.ai/VERSION +1 -1
  21. package/skills/ai-init/templates/.ai/policies/security.md +1 -2
  22. package/skills/ai-init/templates/.ai/policies/testing.md +5 -12
  23. package/skills/ai-init/templates/.ai/policies/verification.md +18 -0
  24. package/skills/ai-init/templates/.ai/policies/weight.json +0 -1
  25. package/skills/ai-init/templates/.ai/policies/weight.md +1 -2
  26. package/skills/ai-init/templates/.ai/policies/weight.project.json +4 -0
  27. package/skills/ai-init/templates/.ai/project/architecture.md +1 -1
  28. package/skills/ai-init/templates/.ai/project/initial-assessment.md +2 -2
  29. package/skills/ai-init/templates/.ai/project/overview.md +1 -1
  30. package/skills/ai-init/templates/.ai/rules/README.md +2 -2
  31. package/skills/ai-init/templates/.ai/state/README.md +1 -1
  32. package/skills/ai-task/memory_store.py +8 -9
  33. package/skills/ai-task/rr_state/cli.py +1 -1
  34. package/skills/ai-task/rr_state/dialog.py +2 -4
  35. package/skills/ai-task/rr_state/fs.py +5 -13
  36. package/skills/ai-task/rr_state/profile.py +2 -4
  37. package/skills/ai-task/rr_state/questions_cmd.py +1 -1
  38. package/skills/ai-task/rr_state/report.py +3 -4
  39. package/skills/ai-task/rr_state/runtime.py +1 -1
  40. package/skills/ai-task/rr_state/start.py +7 -7
  41. package/skills/ai-task/sensors.py +10 -11
  42. package/skills/ai-task/state.py +4 -24
  43. package/skills/ai-task/weight.py +17 -17
  44. package/skills/ai-task/weight_overlay.py +38 -0
  45. package/skills/project-init/templates/constitution.md +2 -2
  46. package/skills/project-update/history/blobs/00f5e27ce2d66e3a1d8b222b013ae8fa1fed32612d91a551e0c661cee3ef9c2d +34 -0
  47. package/skills/project-update/history/blobs/0a6b1b6dd06a35582b2e9989a1f41c40abc29254b8e482714fcc6f0a7b16c406 +32 -0
  48. package/skills/project-update/history/blobs/0aeb04b04262683cac6513545683486a73dc473b447bba43b5aaab18403957a6 +34 -0
  49. package/skills/project-update/history/blobs/0ce913a28996f546f72d570b0e5a376362e993ce4d805bc4aae57d38caa44212 +41 -0
  50. package/skills/project-update/history/blobs/0ea9e36446b624b73b2a821231b2ee66a357b43d584a5b23da48b3c800855e65 +41 -0
  51. package/skills/project-update/history/blobs/0fb1d68678a1349195b2e79433bb20174d25874e3bfd0b70410bb4e428b4e4b8 +35 -0
  52. package/skills/project-update/history/blobs/119b2b9d0f9c647a243540724edac6b29afa020c69aa205797bcfa9eb06bc021 +35 -0
  53. package/skills/project-update/history/blobs/1d0fe3ee5fb8260ba610942d4bbdfff7410fbe80ed826ac07f32985031dc2b3f +34 -0
  54. package/skills/project-update/history/blobs/1de9a37ed6402893dcab6e61ddbf15ffd35f15a6d84b18e675a0c5bf4e981f85 +42 -0
  55. package/skills/project-update/history/blobs/1f44e1a236233dcbab58064f39b69f798b35d1d9a29f8c615b5b12854ce633cb +15 -0
  56. package/skills/project-update/history/blobs/1f824645299579cbdd8141642ddbb4bca9fd5fe9d3112f036d8221f0811e0abf +40 -0
  57. package/skills/project-update/history/blobs/235fb88393cfe20dd58b35fe9785cc45baf5ffe21a13d926652d76ec68cea8b7 +35 -0
  58. package/skills/project-update/history/blobs/3918b4ee011d9477b9d2779e2893ab599749c406d4b09439a9748b64fbff58f3 +18 -0
  59. package/skills/project-update/history/blobs/449273200dfec2a56885c881ff34d40e8ecc00da53c24c2d09b7fbeb1004d255 +40 -0
  60. package/skills/project-update/history/blobs/4c45aee9b9fc5f6b4e1d9739e434c4094975a5a8dec6b9703fb25f3080680c77 +124 -0
  61. package/skills/project-update/history/blobs/4dd3fa12bc45acc8c836ab65d790248ca7504a021bca6677499d17bb3d798ed9 +64 -0
  62. package/skills/project-update/history/blobs/51e3f15095d7123ea8566b512479ee10a6cb2f336b7352a259a083a887bd7116 +33 -0
  63. package/skills/project-update/history/blobs/5497a204967bea7dab29079a6e1dfa3d8713b2d2cb294c2779378039f03a55fc +36 -0
  64. package/skills/project-update/history/blobs/60c4d7e7fde7049d92d84295fc13ca9a91eb2de423be08f093b3ee5d5e817ff8 +64 -0
  65. package/skills/project-update/history/blobs/6221b7c14cc04634e485e562d464c6860d45666a7f63e293c06bb2944ff556cc +27 -0
  66. package/skills/project-update/history/blobs/637971cfb844ef4285e2886a4b1c834fb2ed5c46e773adb88c2c147fea626ad3 +33 -0
  67. package/skills/project-update/history/blobs/65af2282eea2a51fcc27a4acf2c26c727b0961c30e1be13176369200c460c219 +34 -0
  68. package/skills/project-update/history/blobs/66ecd73e303e7bbc1aa8ee72435d0d10812ff7b2eff0adac59bbf0a303e2cc01 +41 -0
  69. package/skills/project-update/history/blobs/6a43bf32461b22b87fafe2865529dc04fa66cd00182a2a7598aa071667ca255a +35 -0
  70. package/skills/project-update/history/blobs/6d6dee8634abf9ac057bb206828b83a893a2afcf4f86bbe69e839a3b47d0adde +48 -0
  71. package/skills/project-update/history/blobs/7224ede803ea81e65a456e21b6028a0e848e8a880456c6503eebe7534ca4f6aa +74 -0
  72. package/skills/project-update/history/blobs/765a39a202617096c232017822ae738d37e4405430c71700dc7bc14bf1ba794e +33 -0
  73. package/skills/project-update/history/blobs/77577440a933d1cabd8c6f4a6401cf361a784aa09a25500a6f7b44ead8ab5e55 +41 -0
  74. package/skills/project-update/history/blobs/7bae90789625d6fb9071ecb412d14a8d1eb0bc5659d8d284c4eaf213e1b5de56 +67 -0
  75. package/skills/project-update/history/blobs/7bcdf9a0e6b85277b9b60669150224b9414d2e32821ff06f4d148eb1d18785bb +42 -0
  76. package/skills/project-update/history/blobs/7f834103e9d97aea44e76717bcdcbbe349c560e5b81a42ce85a1cec341041281 +4 -0
  77. package/skills/project-update/history/blobs/7fb3acb0211818efebc2234863db0b8c846022a7280a6d2d69f267dbf63db48c +34 -0
  78. package/skills/project-update/history/blobs/87f0855859ff68cc6c988cc0b631ee7f56af7fbba062172bf80e01f4982cc054 +35 -0
  79. package/skills/project-update/history/blobs/8abab74759766d02b5cb7b0c3e59a3d440b25a454216cd8fea325c23fd822d36 +33 -0
  80. package/skills/project-update/history/blobs/a1fb50e6c86fae1679ef3351296fd6713411a08cf8dd1790a4fd05fae8688164 +1 -0
  81. package/skills/project-update/history/blobs/a3df7268feb0780be0d2cf9acced6ed81780a7790f36fe024b6ea0b1bd6eb7a6 +35 -0
  82. package/skills/project-update/history/blobs/b28721136482dafb9fd2801af340364cb97e9f6eaa987908606c786045b8d334 +28 -0
  83. package/skills/project-update/history/blobs/b3726422d767f9219fea633bea77f0c99ad9af843c591e6e928e1ccd2a47f374 +27 -0
  84. package/skills/project-update/history/blobs/b3839425f9d0ab2efa42a6f5e9488ec2d26ca5fb8dcd694b8ab098e10743ca46 +32 -0
  85. package/skills/project-update/history/blobs/ba14a457e83f9dde65e4190fdfab83b912f7398c1442a2a1cb3d89bfac741cf3 +8 -0
  86. package/skills/project-update/history/blobs/bceb7189444bb6291b262ca08e34db01010dda2f6e64d696231ec3c343c95c66 +34 -0
  87. package/skills/project-update/history/blobs/bf82a2bb4fce1bd349ff66118930a01d61a7af8e2bd8d200c721d43afc58c713 +34 -0
  88. package/skills/project-update/history/blobs/c4dbeb180292a6199ad73a3be861ece07c1f8bf6bf0cc7ca204412bc8e1bb1d8 +31 -0
  89. package/skills/project-update/history/blobs/c644838866df2001a42501a988dd7b77aacf862bf09d22b465f3cd8a22c6b6eb +33 -0
  90. package/skills/project-update/history/blobs/c980d74f76a4bdfb38c94b573ac10a54eb50b850b052e15fa6f2442889b36ccb +32 -0
  91. package/skills/project-update/history/blobs/ceed2f3bd937f862d0e6ca59ce1b1e3603e19a0d7f85d90d6dfeb7bf690153be +29 -0
  92. package/skills/project-update/history/blobs/d51ea2e893482a20c5882056cf8043327ca1dc1c957927f6746929a47ee8368e +45 -0
  93. package/skills/project-update/history/blobs/d78f5e0bb1380259fce2cce009785a9a11f6de8b87a160080b9872c059f95060 +34 -0
  94. package/skills/project-update/history/blobs/d97f46a2cca020406252b1f7baa4184617fa71faca3885e2379c44ae4e1d9327 +67 -0
  95. package/skills/project-update/history/blobs/da3ecd4c4c414c93d04488685931df39a56bf959bfeb9c45cb1aea2c13bd991b +38 -0
  96. package/skills/project-update/history/blobs/e129bf71a5a922e24b2c1bc8daa8a5d2b5589b0069770e17ce17e7db5ed4e7a4 +27 -0
  97. package/skills/project-update/history/blobs/e504529519a371892838725f8b89b5d32e3300e345bcbf2643a3a1a48434b3dd +24 -0
  98. package/skills/project-update/history/blobs/e9e62d03ca70b3dffccef8dceeb214173352cfef005145e519f0e037993f1c03 +36 -0
  99. package/skills/project-update/history/blobs/effefbaaf4beb8f1ba55081d6b7e74bf25ebb91045dd10613418f17b95fa7182 +33 -0
  100. package/skills/project-update/history/blobs/f6af4581bafbf62b257d57e06c3ae2c5998aeca608ee7df037fcc3c3e4b563a9 +34 -0
  101. package/skills/project-update/history/blobs/fc35045777d29630303430752a651b3e553580a581036a02cc103eb96a46f4b0 +34 -0
  102. package/skills/project-update/history/blobs/fdc21345c77f92a42d244aa104f393f439a0ff9981a9fd462bd837e8a47931bb +48 -0
  103. package/skills/project-update/history/index.json +32 -13
  104. package/skills/project-update/render_instructions.py +12 -45
  105. package/skills/project-update/update.py +757 -1172
  106. package/skills/rr-setup/SKILL.md +42 -81
  107. package/skills/rr-setup/scaffold_ai.py +7 -7
  108. package/skills/rr-setup/setup.py +96 -164
  109. package/skills/rr-status/status.py +7 -10
  110. package/skills/usage-report/usage-report.py +4 -5
  111. package/skills/ai-audit/SKILL.md +0 -9
  112. package/skills/ai-init/SKILL.md +0 -9
  113. package/skills/ai-status/SKILL.md +0 -9
  114. package/skills/ai-task/SKILL.md +0 -9
  115. package/skills/project-init/SKILL.md +0 -9
  116. package/skills/project-update/SKILL.md +0 -9
  117. package/skills/project-update/adopt-map.json +0 -91
  118. package/skills/project-update/adopt.py +0 -2225
  119. package/skills/project-update/migrations/0001_schema_version.py +0 -13
  120. package/skills/project-update/migrations/0002_task_journal.py +0 -123
  121. package/skills/project-update/migrations/0003_context_diet.py +0 -65
  122. package/skills/project-update/migrations/0004_deterministic_gates.py +0 -57
  123. package/skills/project-update/migrations/0005_runtimes_and_plans.py +0 -35
  124. package/skills/project-update/migrations/0006_shared_files.py +0 -154
  125. package/skills/project-update/migrations/0007_weight_model.py +0 -295
  126. package/skills/project-update/migrations/0008_renames.py +0 -43
  127. package/skills/project-update/migrations/0009_state_file.py +0 -25
  128. package/skills/project-update/migrations/0010_dialog_switch.py +0 -30
  129. package/skills/project-update/migrations/0011_retire_risk_tiers.py +0 -37
  130. package/skills/project-update/migrations/README.md +0 -76
  131. package/skills/project-update/migrations/__init__.py +0 -186
  132. package/skills/sdlc-intent/SKILL.md +0 -9
  133. package/skills/sdlc-plan/SKILL.md +0 -9
  134. package/skills/sdlc-spec/SKILL.md +0 -9
  135. package/skills/usage-report/SKILL.md +0 -10
  136. /package/{ai/workflows/refactoring.md → skills/project-update/history/blobs/36cb593293496fbcf38df828a9b9b2bc0d5a53333f584a5319ebc3d799016e24} +0 -0
  137. /package/{ai/workflows/feature.md → skills/project-update/history/blobs/56e16072c2f3edc6c8897c690d09a11d12fc414c3e3f361f359f7dc609a21282} +0 -0
  138. /package/{ai/workflows/hotfix.md → skills/project-update/history/blobs/8cbefbf1aa412bae3e5019acd40b8fe0598fceba6c781906b3bc6dbd014f6b23} +0 -0
  139. /package/{ai/workflows/bugfix.md → skills/project-update/history/blobs/af56dc16e44ae1d8c3dac4c3ca82e2163b719553c02ea455e77430bab9a14089} +0 -0
  140. /package/{ai/workflows/investigation.md → skills/project-update/history/blobs/b9e09cb24958cd6831589997558974bd6356305306e8f9685e226bcdc30d60ac} +0 -0
  141. /package/{ai/agents/manager.md → skills/project-update/history/blobs/d0cf6388f35a92a0cf607d33909278382f6d7b6a4c32e281b9f9e0ea652c7b91} +0 -0
@@ -1,2225 +0,0 @@
1
- """Adopt a foreign AI-tool structure into RiskRails' layout (`update.py --adopt`).
2
-
3
- This module grows over WP6's plan steps; update.py keeps only flag parsing and the
4
- calls into it, so new behaviour lives here.
5
-
6
- Exit codes used by the adopt family (update.py returns them):
7
- 4 unmapped, a failed check or an invalid proposal: a decision is needed
8
- 5 refused: a gate that needs a human, a clean tree or a finished task did not hold
9
- """
10
- import hashlib
11
- import json
12
- import os
13
- import re
14
- import subprocess
15
- import sys
16
- from collections import OrderedDict
17
- from datetime import datetime, timezone
18
-
19
- import render_instructions
20
-
21
- REFUSED = "ADOPT_REFUSED"
22
-
23
-
24
- # Set in the environment of a shell an agent runs: Claude Code's CLAUDECODE,
25
- # and the names Codex gives its tool calls. Present counts, an empty value too.
26
- AGENT_MARKERS = ("CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CODEX_CI",
27
- "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_VERSION")
28
-
29
-
30
- def test_mode():
31
- """RISKRAILS_TEST=1, or CLAUDE_AGENTIC_TEST=1 before 3.4b (R35, one version)."""
32
- return os.environ.get("RISKRAILS_TEST", os.environ.get("CLAUDE_AGENTIC_TEST")) == "1"
33
-
34
-
35
- def ai_unattended():
36
- """AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
37
- and blanks ignored): one rule in every reader (risk 0587)."""
38
- return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
39
-
40
-
41
- def human_gate():
42
- """(passed, via, why) for --confirm-delete. A terminal outside any agent
43
- session is a human: via "terminal". Otherwise a launcher that declared the
44
- run unattended (AI_UNATTENDED) passes: via "unattended", as state.py's
45
- approve records it. Anything else is refused, and `why` says what was seen —
46
- a terminal alone is not enough, since an agent can open a pty (F3).
47
-
48
- Duplicated in spirit from skills/ai-task/state.py (human_present) on purpose:
49
- the two skills are installed independently and neither imports the other."""
50
- markers = [name for name in AGENT_MARKERS if name in os.environ]
51
- if os.isatty(0) and not markers:
52
- return True, "terminal", ""
53
- if ai_unattended():
54
- return True, "unattended", ""
55
- if markers:
56
- return False, None, ("this run is inside an agent session (%s %s set); run it in a terminal "
57
- "outside Claude Code or Codex, not through the `!` prefix"
58
- % (", ".join(markers), "is" if len(markers) == 1 else "are"))
59
- return False, None, "this run has no terminal"
60
-
61
-
62
- # The name typed into --confirm-delete is the audit trail; the template's own
63
- # "<your name>" copied verbatim names no one. The whole value, not a substring.
64
- PLACEHOLDER_NAME_RE = re.compile(r"\A\s*(?:<[^>]*>|your\s+name)\s*\Z", re.IGNORECASE)
65
-
66
-
67
- def placeholder_name(name):
68
- return bool(PLACEHOLDER_NAME_RE.match(name or ""))
69
-
70
-
71
- def clean_tree(root):
72
- """The paths `git status` reports as changed or untracked under root, or
73
- None when root is not inside a git work tree. `-z` keeps paths with spaces
74
- or quotes intact; a rename entry carries its source as a second record,
75
- which is skipped."""
76
- try:
77
- proc = subprocess.run(["git", "-C", root, "status", "--porcelain", "-z", "--untracked-files=all"],
78
- capture_output=True, check=False)
79
- except OSError:
80
- return None
81
- if proc.returncode != 0:
82
- return None
83
- records = proc.stdout.decode("utf-8", "surrogateescape").split("\0")
84
- paths, skip = [], False
85
- for rec in records:
86
- if skip:
87
- skip = False
88
- continue
89
- if len(rec) < 4:
90
- continue
91
- status, path = rec[:2], rec[3:]
92
- paths.append(path)
93
- if "R" in status or "C" in status:
94
- skip = True
95
- return paths
96
-
97
-
98
- def refuse(reason, how):
99
- """Print the refusal the way every adopt gate does — the marker on the first
100
- stdout line, then what to do — and return exit code 5."""
101
- print("%s: %s" % (REFUSED, reason))
102
- print(" %s" % how)
103
- return 5
104
-
105
-
106
- def safe_rel(path):
107
- """A path adopt may read, write or delete: relative, normalised, inside the
108
- project and outside .git/. adopt.json, decisions.json and proposals are
109
- project files anyone can edit, so every path taken from them passes this."""
110
- if not isinstance(path, str) or not path or path.startswith("/") or "\\" in path or "\0" in path:
111
- return False
112
- parts = path.split("/")
113
- return all(p not in ("", ".", "..") for p in parts) and parts[0] != ".git"
114
-
115
-
116
- def budget_offenders(root, files, caps, block_of):
117
- """R21: one line per root instruction file over budget — its managed block
118
- over `caps["project"]`, or the whole file over `caps["skeleton"]`. Files that
119
- do not exist are skipped; a file under both budgets prints nothing."""
120
- out = []
121
- for name in files:
122
- path = os.path.join(root, name)
123
- if not os.path.isfile(path):
124
- continue
125
- with open(path, encoding="utf-8") as fh:
126
- text = fh.read()
127
- block = block_of(text)
128
- size = len(text.encode("utf-8"))
129
- if block is not None and caps.get("project") and len(block.encode("utf-8")) > caps["project"]:
130
- out.append("%s block %d B over the project budget of %d B"
131
- % (name, len(block.encode("utf-8")), caps["project"]))
132
- if caps.get("skeleton") and size > caps["skeleton"]:
133
- out.append("%s file %d B over the skeleton budget of %d B" % (name, size, caps["skeleton"]))
134
- return out
135
-
136
-
137
- # ---------------------------------------------------------------------------
138
- # The mapping table (spec I2) and its matcher
139
- # ---------------------------------------------------------------------------
140
-
141
- TRANSFORMS = ("copy", "append-section", "rule", "instruction-file", "drop", "ignore")
142
- MAP_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "adopt-map.json")
143
- INCOMPLETE = "ADOPT_INCOMPLETE"
144
- # Never walked for foreign files: not ours to read, or not text.
145
- # A child holding a .git entry (worktree, nested repository, submodule) is pruned too.
146
- WALK_SKIP = {".git", ".ai", "vendor", "node_modules", "bower_components", "third_party",
147
- "Pods", "site-packages", ".venv", ".pnpm", ".yarn", "__pycache__"}
148
- SECRET_RE = re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*\S{8,}"
149
- r"|-----BEGIN [A-Z ]*PRIVATE KEY")
150
- PLACEHOLDER_RE = re.compile(r"\{(dir|stem|rel)\}")
151
-
152
-
153
- class TableError(Exception):
154
- """adopt-map.json is not a table this code can run: exit 2, like a bad
155
- migration registry."""
156
-
157
-
158
- def glob_re(pattern):
159
- """`*` stops at `/`, `**` crosses it, `**/` also matches no directory at all."""
160
- out, i = [], 0
161
- while i < len(pattern):
162
- if pattern.startswith("**/", i):
163
- out.append("(?:.*/)?")
164
- i += 3
165
- elif pattern.startswith("**", i):
166
- out.append(".*")
167
- i += 2
168
- elif pattern[i] == "*":
169
- out.append("[^/]*")
170
- i += 1
171
- elif pattern[i] == "?":
172
- out.append("[^/]")
173
- i += 1
174
- else:
175
- out.append(re.escape(pattern[i]))
176
- i += 1
177
- return re.compile("".join(out) + r"\Z")
178
-
179
-
180
- def load_table(path=MAP_FILE):
181
- try:
182
- with open(path, encoding="utf-8") as fh:
183
- table = json.load(fh)
184
- except (OSError, ValueError, RecursionError) as exc:
185
- raise TableError("%s: %s" % (path, exc)) from exc
186
- tools = table.get("tools")
187
- rows = table.get("rows")
188
- if table.get("version") != 1 or not isinstance(tools, dict) or not isinstance(rows, list):
189
- raise TableError("%s: needs version 1, a tools object and a rows list" % path)
190
- for n, row in enumerate(rows, 1):
191
- where = "%s row %d" % (path, n)
192
- if row.get("tool") not in tools:
193
- raise TableError("%s: unknown tool %r" % (where, row.get("tool")))
194
- if row.get("transform") not in TRANSFORMS:
195
- raise TableError("%s: unknown transform %r" % (where, row.get("transform")))
196
- source = row.get("source")
197
- if not isinstance(source, str) or not source or source.startswith("/") or ".." in source.split("/"):
198
- raise TableError("%s: bad source glob %r" % (where, source))
199
- if row["transform"] in ("copy", "append-section", "rule") and not row.get("dest"):
200
- raise TableError("%s: a %s row needs a dest" % (where, row["transform"]))
201
- if row["transform"] in ("drop", "ignore") and not row.get("why"):
202
- raise TableError("%s: a %s row needs a why" % (where, row["transform"]))
203
- row["_re"] = glob_re(source)
204
- row["_n"] = n
205
- return table
206
-
207
-
208
- def under_root(path, root):
209
- """A root is a directory (`x/`), an exact file, or a glob naming either."""
210
- if root.endswith("/"):
211
- return path.startswith(root)
212
- if any(c in root for c in "*?"):
213
- rx = glob_re(root)
214
- parts = path.split("/")
215
- return any(rx.match("/".join(parts[:k])) for k in range(1, len(parts) + 1))
216
- return path == root or path.startswith(root + "/")
217
-
218
-
219
- def walk(root):
220
- """Every file under root, relative with `/`, skipping WALK_SKIP directories
221
- and nested git trees."""
222
- out = []
223
- for here, dirs, files in os.walk(root):
224
- dirs[:] = sorted(d for d in dirs if d not in WALK_SKIP
225
- and not os.path.lexists(os.path.join(here, d, ".git")))
226
- rel = os.path.relpath(here, root)
227
- for name in sorted(files):
228
- out.append(name if rel == "." else "%s/%s" % (rel.replace(os.sep, "/"), name))
229
- return out
230
-
231
-
232
- def has_signature(root, sig):
233
- path = os.path.join(root, sig.rstrip("/"))
234
- return os.path.isdir(path) if sig.endswith("/") else os.path.isfile(path)
235
-
236
-
237
- # ---------------------------------------------------------------------------
238
- # Planning
239
- # ---------------------------------------------------------------------------
240
-
241
- class Adoption:
242
- """What an adopt run found, beside the shared Plan: the lines that are not
243
- writes (spec I3) and the facts later steps record."""
244
-
245
- def __init__(self, mode):
246
- self.mode = mode
247
- self.detected = OrderedDict() # tool -> OrderedDict(group -> count)
248
- self.notes = [] # (action, subject, note), printed in I3 order
249
- self.unmapped = [] # (path, tool)
250
- self.split = [] # split candidates awaiting a proposal or the fallback
251
- self.candidates = [] # (Candidate, row number): every split candidate
252
- self.split_errors = [] # (file, reason): a split that cannot be applied, exit 4
253
- self.split_done = OrderedDict() # file -> what plan_split recorded (adopt.json.split)
254
- self.split_before = {} # target -> bytes before the split wrote it (R9)
255
- self.split_dests = [] # every target a split writes, the instruction file included
256
- self.dropped = [] # dropped.jsonl records (spec I7)
257
- self.sources = [] # (path, tool, transform, row number, cleanup)
258
- self.destinations = [] # (dest-or-path, tool, router, coexist bool); router is the
259
- # row's text or None, or a dict (A5): {"text", "file"} for a
260
- # row of its own, {"text", "globs"} in coexist
261
- self.rows_used = set()
262
- self.checks = {} # "no-line-lost"/"no-dangling" -> (status, detail text)
263
- self.moves = {} # A3: source path -> its one destination, for the rewrite
264
- self.adopted_lines = {} # A3: destination -> normalised lines that came from a source
265
- self.instruction_targets = [] # A4: the root instruction files that point at ALWAYS_FILE
266
- self.wants_pointer = False # A4: an always rule was planned
267
-
268
- def note(self, action, subject, text):
269
- self.notes.append((action, subject, text))
270
-
271
-
272
- def slugify(text):
273
- slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
274
- return slug or "rule"
275
-
276
-
277
- def expand(dest, path, roots):
278
- """Fill {dir}, {stem} and {rel} (the path under the tool's longest matching
279
- directory root, without its extension, `/` -> `-`)."""
280
- parent, name = os.path.split(path)
281
- stem = os.path.splitext(name)[0]
282
- under = [r for r in roots if r.endswith("/") and path.startswith(r)]
283
- rel = path[len(max(under, key=len)):] if under else path
284
- rel = os.path.splitext(rel)[0].replace("/", "-")
285
- values = {"dir": os.path.basename(parent), "stem": stem, "rel": rel}
286
- return PLACEHOLDER_RE.sub(lambda m: values[m.group(1)], dest)
287
-
288
-
289
- def frontmatter(text):
290
- """(dict of key -> [values], body, the frontmatter lines) or ({}, text, [])."""
291
- match = render_instructions.FRONTMATTER_RE.match(text)
292
- if not match:
293
- return {}, text, []
294
- front = render_instructions._frontmatter(match.group("body"), "frontmatter") # pylint: disable=protected-access
295
- return front, text[match.end():], match.group("body").split("\n")
296
-
297
-
298
- def literal_dir(pattern):
299
- parts = pattern.split("/")
300
- lead = []
301
- for part in parts[:-1]:
302
- if any(c in part for c in "*?[{"):
303
- break
304
- lead.append(part)
305
- return "/".join(lead)
306
-
307
-
308
- def rule_content(plan, path, text, row, tool, adoption):
309
- """The `rule` transform: frontmatter rewritten, body verbatim. Returns
310
- (dest, content, note). Every frontmatter line is logged as rewritten."""
311
- fmap = row.get("frontmatter", {})
312
- try:
313
- front, body, raw = frontmatter(text)
314
- except render_instructions.RenderError as exc:
315
- return None, None, "frontmatter not readable: %s" % exc
316
- globs = []
317
- for value in front.get(fmap.get("paths", ""), []):
318
- globs.extend(g.strip() for g in value.split(",") if g.strip())
319
- always = False
320
- if fmap.get("always"):
321
- key, _, want = fmap["always"].partition("=")
322
- always = [v.lower() for v in front.get(key, [])] == [want.lower()]
323
- if not raw and fmap.get("always_when_absent"):
324
- always = True
325
- title = " ".join(front.get(fmap.get("title", ""), [])).strip()
326
- for n, line in enumerate(raw, 2):
327
- if line.strip():
328
- adoption.dropped.append({"source": path, "line": n, "text": line,
329
- "reason": "rewritten: frontmatter", "by": "transform:rule"})
330
- name = os.path.basename(path)
331
- stem = name[:-len(".instructions.md")] if name.endswith(".instructions.md") else os.path.splitext(name)[0]
332
- base = row["source"].split("*")[0]
333
- sub = os.path.dirname(path[len(base):]) if path.startswith(base) else ""
334
- slug = slugify((sub + "-" if sub else "") + stem)
335
- dirs = []
336
- for g in globs:
337
- d = literal_dir(g)
338
- if d and os.path.isdir(os.path.join(plan.root, d)) and d not in dirs:
339
- dirs.append(d)
340
- head = ("# %s\n\n" % title) if title else ""
341
- body = body.lstrip("\n")
342
- if dirs and not always:
343
- front_out = "---\ndirs: [%s]\npaths: [%s]\n---\n\n" % (
344
- ", ".join(dirs), ", ".join('"%s"' % g for g in globs))
345
- dest = ".ai/rules/%s.md" % slug
346
- note = "rule: %s -> paths:, dirs: [%s]" % (fmap.get("paths"), ", ".join(dirs))
347
- else:
348
- front_out = ("---\npaths: [%s]\n---\n\n" % ", ".join('"%s"' % g for g in globs)) if globs else ""
349
- dest = ".ai/policies/adopted/%s-%s.md" % (tool, slug)
350
- note = "rule: %s" % ("always" if always else "on demand, router row")
351
- return dest, (front_out + head + body).encode("utf-8"), note
352
-
353
-
354
- DECISION_DOCS_RE = re.compile(r"\Adocs/sdlc/(?:intent|specs|plans)/[^/]")
355
-
356
-
357
- def decision_dest_ok(dest):
358
- """I6: a human's `copy` goes to the I5 allow-list or under docs/sdlc/{intent,specs,plans}/."""
359
- return safe_rel(dest) and bool(ALLOWED_DEST_RE.match(dest) or DECISION_DOCS_RE.match(dest))
360
-
361
-
362
- def plan_file(plan, adoption, path, row, tool, roots):
363
- """Plan one matched source."""
364
- transform = row["transform"]
365
- adoption.rows_used.add(row["_n"])
366
- if transform == "ignore":
367
- adoption.note("ignored", path, "[%s] %s" % (tool, row["why"]))
368
- return
369
- adoption.sources.append((path, tool, transform, row["_n"], bool(row.get("cleanup"))))
370
- data = plan.read(path) or b""
371
- for n, line in enumerate(data.decode("utf-8", "replace").split("\n"), 1):
372
- if SECRET_RE.search(line):
373
- adoption.note("hint", "%s:%d" % (path, n), "looks like a secret (value not shown)")
374
- if transform == "drop":
375
- adoption.dropped.append({"source": path, "line": "*", "sha": "sha256:" + hashlib.sha256(data).hexdigest(),
376
- "reason": row["why"], "by": "transform:drop"})
377
- adoption.note("dropped", path, "[%s] %s" % (tool, row["why"]))
378
- return
379
- if adoption.mode == "coexist":
380
- adoption.note("kept", path, "[%s] %s, kept in place (coexist)" % (tool, transform))
381
- globs = transform == "rule" and has_globs(data, row)
382
- adoption.destinations.append((path, tool, {"text": row.get("router"), "globs": globs}, True))
383
- return
384
- text = data.decode("utf-8", "replace")
385
- if not is_binary(data) and is_utf8(data): # a lossy re-encode would change bytes nobody rewrote
386
- new_text = rewrite_refs(path, text, adoption.moves, adoption, transform)
387
- if new_text != text:
388
- text, data = new_text, new_text.encode("utf-8")
389
- if transform == "rule":
390
- dest, content, note = rule_content(plan, path, text, row, tool, adoption)
391
- if dest is None:
392
- adoption.unmapped.append((path, tool))
393
- adoption.note("unmapped", path, "[%s] %s" % (tool, note))
394
- return
395
- if note == "rule: always" and adoption.mode == "migrate":
396
- append_always(plan, adoption, path, text, tool, row)
397
- return
398
- elif transform == "append-section":
399
- dest = expand(row["dest"], path, roots)
400
- # Not "## Adopted from <path>" (spec I2): that heading is itself a
401
- # reference to the old path, and would dangle after cleanup (I8).
402
- heading = "## Adopted from %s (%s)" % (TOOL_TITLE.get(tool, tool), os.path.basename(path))
403
- current = (plan.read(dest) or b"").decode("utf-8", "replace")
404
- note_adopted(adoption, dest, text)
405
- updated = rewrite_adopted(adoption, dest, current)
406
- if heading in current.split("\n"):
407
- if updated != current:
408
- plan.add("adopt", dest, note="[%s] append-section%s" % (tool, REFS_UPDATED),
409
- content=updated.encode("utf-8"), src=path, tool=tool)
410
- adoption.destinations.append((dest, tool, row.get("router"), False))
411
- return # already appended by an earlier run
412
- content = (updated.rstrip("\n") + "\n\n" + heading + "\n\n" + text.strip("\n") + "\n").lstrip("\n").encode("utf-8")
413
- principles = sum(1 for line in text.split("\n") if line.startswith("### "))
414
- note = "append-section" + (" (hint: %d principles, at most 15)" % principles if principles > 15 else "") \
415
- + (REFS_UPDATED if updated != current else "")
416
- else: # copy
417
- dest = expand(row["dest"], path, roots)
418
- content, note = data, "copy"
419
- note_adopted(adoption, dest, text)
420
- router = row.get("router")
421
- if transform == "rule" and dest.startswith(".ai/policies/adopted/"):
422
- # A5: an on-demand rule is found by what it is about, so it gets a row of its own.
423
- router = {"text": rule_trigger(content), "file": True}
424
- existing = plan.read(dest)
425
- tag = "[%s] %s" % (tool, note)
426
- if transform == "append-section":
427
- plan.add("adopt", dest, note=tag, content=content, src=path, tool=tool)
428
- adoption.destinations.append((dest, tool, router, False))
429
- elif existing is not None and existing != content and only_new_refs(existing, content, adoption.moves):
430
- # A3: adopted earlier, untouched since; a file moved by this run is
431
- # named in it, and only that reference changes.
432
- plan.add("adopt", dest, note=tag + "; references to newly moved files updated", content=content,
433
- src=path, tool=tool)
434
- adoption.destinations.append((dest, tool, router, False))
435
- elif existing is not None and existing != content:
436
- plan.add("conflict", dest, note=tag + "; the destination exists and differs", src=path, tool=tool)
437
- elif existing != content:
438
- plan.add("adopt", dest, note=tag, content=content, src=path, tool=tool)
439
- adoption.destinations.append((dest, tool, router, False))
440
- else:
441
- adoption.destinations.append((dest, tool, router, False)) # already there
442
-
443
-
444
- def latest_with(root, name):
445
- """The greatest `.ai/reports/adopt-*` directory holding `name`, or None."""
446
- base = os.path.join(root, ".ai", "reports")
447
- try:
448
- dirs = sorted((d for d in os.listdir(base) if d.startswith("adopt-")), reverse=True)
449
- except OSError:
450
- return None
451
- for d in dirs:
452
- if os.path.isfile(os.path.join(base, d, name)):
453
- return ".ai/reports/%s" % d
454
- return None
455
-
456
-
457
- def load_decisions(root):
458
- where = latest_with(root, "decisions.json")
459
- if where is None:
460
- return {}
461
- try:
462
- with open(os.path.join(root, where, "decisions.json"), encoding="utf-8") as fh:
463
- return json.load(fh)
464
- except (OSError, ValueError, RecursionError) as exc:
465
- raise TableError("%s/decisions.json: %s" % (where, exc)) from exc
466
-
467
-
468
- def moves_of(plan, planned):
469
- """A3: every source this run writes to exactly one destination, found
470
- before anything is planned so a text can name a file planned after it."""
471
- moves = {}
472
- scratch = Adoption("migrate") # rule_content logs into it; nothing is kept
473
- for path, row, tool, roots in planned:
474
- transform = row["transform"]
475
- if transform in ("copy", "append-section") and row.get("dest"):
476
- moves[path] = expand(row["dest"], path, roots)
477
- elif transform == "rule":
478
- text = (plan.read(path) or b"").decode("utf-8", "replace")
479
- dest, _content, note = rule_content(plan, path, text, row, tool, scratch)
480
- if dest and note == "rule: always":
481
- moves[path] = ALWAYS_FILE # A4: where plan_file will really put it
482
- elif dest:
483
- moves[path] = dest
484
- return moves
485
-
486
-
487
- ALWAYS_FILE = ".ai/policies/adopted/always.md"
488
- ALWAYS_POINTER = "Read `%s` first." % ALWAYS_FILE
489
- ALWAYS_HEAD = "# Always applied\n\nRules their own tool loaded on every request, adopted as they were.\n"
490
-
491
-
492
- def append_always(plan, adoption, path, text, tool, row):
493
- """A4 as amended: a rule its tool loads on every request goes into one
494
- file, ALWAYS_FILE, under a heading of its own — the rule's path under its
495
- row's root, so `a/index.mdc` and `b/index.mdc` stay apart — idempotent by
496
- that heading, as append-section is. Every root instruction file with the
497
- managed block gets ALWAYS_POINTER, outside the block; the planned pointer
498
- is placed after the splits, which keep it (Candidate, `rewritten`)."""
499
- front, body, _raw = frontmatter(text)
500
- body = body.strip("\n")
501
- base = row["source"].split("*")[0]
502
- label = path[len(base):] if path.startswith(base) and len(path) > len(base) else os.path.basename(path)
503
- heading = "## Adopted from %s (%s)" % (TOOL_TITLE.get(tool, tool), label)
504
- title = " ".join(front.get(row.get("frontmatter", {}).get("title", ""), [])).strip()
505
- section = (("_%s_\n\n" % title) if title else "") + body
506
- note_adopted(adoption, ALWAYS_FILE, section)
507
- current = (plan.read(ALWAYS_FILE) or b"").decode("utf-8", "replace") or ALWAYS_HEAD
508
- updated = rewrite_adopted(adoption, ALWAYS_FILE, current)
509
- refs = REFS_UPDATED if updated != current else ""
510
- if heading not in current.split("\n"):
511
- content = (updated.rstrip("\n") + "\n\n" + heading + "\n\n" + section + "\n").encode("utf-8")
512
- plan.add("adopt", ALWAYS_FILE, note="[%s] rule: always%s" % (tool, refs), content=content, src=path, tool=tool)
513
- elif refs: # adopted by an earlier run; a file this run moves is named in it
514
- plan.add("adopt", ALWAYS_FILE, note="[%s] rule: always%s" % (tool, refs),
515
- content=updated.encode("utf-8"), src=path, tool=tool)
516
- if adoption.instruction_targets:
517
- adoption.destinations.append((ALWAYS_FILE, tool, {"skip": True}, False))
518
- adoption.wants_pointer = True
519
- else: # no instruction file to point from: the router's first row does it
520
- adoption.destinations.append((ALWAYS_FILE, tool, {"text": "every task, first", "file": True}, False))
521
-
522
-
523
- def pointer_bytes(adoption, target, text):
524
- """The bytes plan_pointers will add to `target`: 0 when it adds none."""
525
- if not adoption.wants_pointer or target not in adoption.instruction_targets \
526
- or ALWAYS_POINTER in text.split("\n"):
527
- return 0
528
- return len(ALWAYS_POINTER.encode("utf-8")) + 2
529
-
530
-
531
- def plan_pointers(plan, adoption):
532
- """A4: the pointer to ALWAYS_FILE in each root instruction file, once."""
533
- for target in adoption.instruction_targets if adoption.wants_pointer else ():
534
- current = (plan.read(target) or b"").decode("utf-8", "replace")
535
- if ALWAYS_POINTER in current.split("\n"):
536
- continue
537
- content = (current.rstrip("\n") + "\n\n" + ALWAYS_POINTER + "\n").encode("utf-8")
538
- plan.add("adopt", target, note="pointer to %s" % ALWAYS_FILE, content=content)
539
-
540
-
541
- def has_globs(data, row):
542
- """L: does this rule file itself carry a globs/paths value (coexist suffix)."""
543
- key = row.get("frontmatter", {}).get("paths")
544
- if not key:
545
- return False
546
- try:
547
- front = frontmatter(data.decode("utf-8", "replace"))[0]
548
- except render_instructions.RenderError:
549
- return False
550
- return any(v.strip() for v in front.get(key, []))
551
-
552
-
553
- def is_utf8(data):
554
- try:
555
- data.decode("utf-8")
556
- except UnicodeDecodeError:
557
- return False
558
- return True
559
-
560
-
561
- def only_new_refs(existing, content, moves):
562
- """A3, a later run: the destination on disk is what an earlier run wrote,
563
- and today's content differs from it only by references to files this run
564
- moves — the same text once those references are rewritten on disk too."""
565
- try:
566
- old = existing.decode("utf-8")
567
- except UnicodeDecodeError:
568
- return False
569
- return rewrite_refs(None, old, moves, None, None).encode("utf-8") == content
570
-
571
-
572
- def rule_trigger(content):
573
- """A5: what a router row says an adopted rule is for — its title (the
574
- frontmatter description, rendered as the first `# ` heading), or None."""
575
- for line in content.decode("utf-8", "replace").split("\n"):
576
- if line.startswith("# "):
577
- return line[2:].strip().replace("|", "/") or None
578
- return None
579
-
580
-
581
- def rewrite_refs(path, text, moves, adoption, transform):
582
- """A3: rewrite each reference to a moved file to its destination, line by
583
- line, logging every rewritten source line so no-line-lost skips it as it
584
- skips a rewritten frontmatter line. A directory reference has no single
585
- destination and is left alone (check_refs names it)."""
586
- if not moves:
587
- return text
588
- rules = [(ref_pattern(old), new) for old, new in sorted(moves.items(), key=lambda m: -len(m[0]))
589
- if old != path]
590
- out = []
591
- for n, line in enumerate(text.split("\n"), 1):
592
- new = line
593
- for rx, dest in rules:
594
- new = rx.sub(lambda m, d=dest: m.group(1) + d, new)
595
- if new != line and adoption is not None:
596
- adoption.dropped.append({"source": path, "line": n, "text": line,
597
- "reason": "rewritten: path reference", "by": "transform:%s" % transform})
598
- out.append(new)
599
- return "\n".join(out)
600
-
601
-
602
- REFS_UPDATED = "; references to newly moved files updated"
603
-
604
-
605
- def rewrite_adopted(adoption, dest, current):
606
- """A3 for a destination several sources share (append-section, ALWAYS_FILE):
607
- a line an earlier run adopted names a file this run moves. Only a line whose
608
- rewrite is a line this run adopts into `dest` changes; the project's own
609
- lines stay as written, and check_refs names them (M-new-1)."""
610
- foreign = adoption.adopted_lines.get(dest, ())
611
- out = []
612
- for line in current.split("\n"):
613
- new = rewrite_refs(None, line, adoption.moves, None, None)
614
- out.append(new if new != line and adopted_key(new) in foreign else line)
615
- return "\n".join(out)
616
-
617
-
618
- def adopted_key(line):
619
- """A3's line identity: the stripped line. Not `normalise`, which drops
620
- headings — and a foreign heading can carry a path too."""
621
- return line.strip()
622
-
623
-
624
- def note_adopted(adoption, dest, text):
625
- """A3: remember which lines of `dest` came from a foreign source."""
626
- lines = adoption.adopted_lines.setdefault(dest, set())
627
- for raw in text.split("\n"):
628
- if adopted_key(raw):
629
- lines.add(adopted_key(raw))
630
-
631
-
632
- def decision_for(path, decisions):
633
- """A2: the human's decision for `path` — an exact key first, else the
634
- longest glob key that matches it (the table's own `*` / `**` rules).
635
- Returns (decision or None, the key it came from)."""
636
- if path in decisions:
637
- return decisions[path], path
638
- globs = sorted((k for k in decisions if any(c in k for c in "*?")), key=len, reverse=True)
639
- for key in globs:
640
- if glob_re(key).match(path):
641
- return decisions[key], key
642
- return None, None
643
-
644
-
645
- def plan_adopt(plan, table, mode="migrate", tools=None, shipped_block=None, skeleton_cap=None,
646
- instruction_files=frozenset(), shipped_files=None, run_checks=True, split_mode=None,
647
- splits=True):
648
- """Detect every foreign structure at the root and plan it onto `plan`.
649
- Returns the Adoption. Nothing is written."""
650
- root = plan.root
651
- adoption = Adoption(mode)
652
- wanted = [t for t in table["tools"] if tools is None or t in tools]
653
- # Instruction files (plan step 4, rules a and b): the plugin's own carry the
654
- # managed block and are only ever split candidates; a file without it is foreign.
655
- instruction = {}
656
- for tool in wanted:
657
- spec = table["tools"][tool]
658
- if spec["roots"]:
659
- continue
660
- for sig in spec["signature"]:
661
- path = os.path.join(root, sig)
662
- if not os.path.isfile(path):
663
- continue
664
- instruction[tool] = sig
665
- detected = [t for t in wanted if table["tools"][t]["roots"]
666
- and any(has_signature(root, s) for s in table["tools"][t]["signature"])]
667
- files = walk(root)
668
- all_decisions = load_decisions(root)
669
- decisions = all_decisions.get("unmapped", {})
670
- by_tool = OrderedDict((t, []) for t in detected)
671
- rows_of = {t: [r for r in table["rows"] if r["tool"] == t] for t in table["tools"]}
672
- for path in files:
673
- owners = [t for t in detected if any(under_root(path, r) for r in table["tools"][t]["roots"])]
674
- if not owners:
675
- continue
676
- taken = None
677
- for tool in owners:
678
- row = next((r for r in rows_of[tool] if r["_re"].match(path)), None)
679
- if row is not None:
680
- taken = (tool, row)
681
- break
682
- tool = taken[0] if taken else owners[0]
683
- by_tool[tool].append((path, taken[1] if taken else None))
684
- planned = []
685
- for tool, entries in by_tool.items():
686
- groups = OrderedDict()
687
- for path, _ in entries:
688
- group = next((r for r in table["tools"][tool]["roots"] if under_root(path, r)), path)
689
- groups[group] = groups.get(group, 0) + 1
690
- adoption.detected[tool] = groups
691
- roots = table["tools"][tool]["roots"]
692
- for path, row in entries:
693
- if row is None:
694
- decided, key = decision_for(path, decisions)
695
- if decided and decided.get("action") == "copy" and key != path:
696
- adoption.unmapped.append((path, tool))
697
- adoption.note("unmapped", path, "[%s] decisions.json key %r: a glob decision cannot copy — "
698
- "one destination per file, so name the file" % (tool, key))
699
- continue
700
- if decided and decided.get("action") == "copy" and not decision_dest_ok(decided.get("dest")):
701
- adoption.unmapped.append((path, tool))
702
- adoption.note("unmapped", path, "[%s] decisions.json copies it to %r, not an allowed destination"
703
- % (tool, decided.get("dest")))
704
- continue
705
- if decided and decided.get("action") in ("drop", "ignore", "copy"):
706
- row = {"tool": tool, "source": path, "transform": decided["action"], "_n": 0,
707
- "why": decided.get("why", "decided by a human"), "dest": decided.get("dest"),
708
- "cleanup": decided["action"] != "ignore"}
709
- else:
710
- adoption.unmapped.append((path, tool))
711
- adoption.note("unmapped", path, "[%s] no mapping row — settle it in %s/decisions.json"
712
- % (tool, report_dir(plan)))
713
- continue
714
- planned.append((path, row, tool, roots))
715
- adoption.moves = moves_of(plan, planned)
716
- if adoption.mode == "migrate": # A4: where the pointer to ALWAYS_FILE goes
717
- adoption.instruction_targets = [
718
- f for f in sorted(instruction_files)
719
- if render_instructions.block_of((plan.read(f) or b"").decode("utf-8", "replace")) is not None]
720
- for path, row, tool, roots in planned:
721
- plan_file(plan, adoption, path, row, tool, roots)
722
- # Foreign signatures below the root are never adopted, only listed (R4).
723
- for path in files:
724
- nested = nested_signature(path, table, wanted)
725
- if nested and decision_for(path, decisions)[0] is None and all(p != path for p, _ in adoption.unmapped):
726
- tool, head = nested
727
- adoption.unmapped.append((path, tool))
728
- adoption.note("unmapped", path, "[%s] below the root (%s/) — only the root is adopted" % (tool, head))
729
- # A4: a file outside --tool still gets the pointer, so its budget is checked too (M-new-2).
730
- pointed = [(t, s) for t in table["tools"] if t not in instruction and not table["tools"][t]["roots"]
731
- for s in table["tools"][t]["signature"]
732
- if pointer_bytes(adoption, s, (plan.read(s) or b"").decode("utf-8", "replace"))]
733
- for tool, sig in list(instruction.items()) + pointed:
734
- text = (plan.read(sig) or b"").decode("utf-8", "replace")
735
- block = render_instructions.block_of(text)
736
- row = next(r for r in rows_of[tool])
737
- if block is None:
738
- adoption.detected[tool] = OrderedDict([(sig, 1)])
739
- adoption.rows_used.add(row["_n"])
740
- adoption.sources.append((sig, tool, "instruction-file", row["_n"], False))
741
- for n, line in enumerate(text.split("\n"), 1):
742
- if SECRET_RE.search(line):
743
- adoption.note("hint", "%s:%d" % (sig, n), "looks like a secret (value not shown)")
744
- if shipped_block is None or not skeleton_cap:
745
- continue
746
- shipped = shipped_block(tool)
747
- whole = (text.replace(block, shipped) if block is not None else text.rstrip("\n") + "\n\n" + shipped)
748
- # A4: the pointer this run will add counts against the budget, and a
749
- # split keeps it and leaves room for it (Candidate).
750
- reserve = pointer_bytes(adoption, sig, text)
751
- size = len(whole.encode("utf-8")) + reserve
752
- if size > skeleton_cap:
753
- adoption.rows_used.add(row["_n"])
754
- adoption.candidates.append((Candidate(sig, tool, text, shipped, skeleton_cap - reserve), row["_n"]))
755
- if splits and adoption.mode == "migrate":
756
- plan_splits(plan, adoption, adoption.candidates, split_mode, all_decisions)
757
- elif not splits:
758
- adoption.split = [c.path for c, _ in adoption.candidates]
759
- plan_pointers(plan, adoption)
760
- add_router_rows(plan, router_rows_text(adoption))
761
- if run_checks and not adoption.unmapped:
762
- line_status, lines, missing, missing_n = check_lines(plan, adoption)
763
- adoption.checks["no-line-lost"] = (line_status, lines, missing, missing_n)
764
- ref_status, hard_misses, warns = check_refs(plan, adoption, table, instruction_files, shipped_files)
765
- adoption.checks["no-dangling"] = (ref_status, hard_misses, warns)
766
- return adoption
767
-
768
-
769
- def nested_signature(path, table, tools):
770
- """(tool, directory) when `path` sits under a foreign signature that is not
771
- at the root — `packages/x/.cursor/rules/a.mdc` -> ("cursor", "packages/x")."""
772
- parts = path.split("/")
773
- for tool in tools:
774
- spec = table["tools"][tool]
775
- if not spec["roots"] or any(under_root(path, r) for r in spec["roots"]):
776
- continue
777
- for sig in spec["signature"]:
778
- want = sig.rstrip("/").split("/")
779
- is_dir = sig.endswith("/")
780
- for k in range(1, len(parts) - len(want) + 1):
781
- if parts[k:k + len(want)] != want:
782
- continue
783
- last = k + len(want)
784
- if (is_dir and last < len(parts)) or (not is_dir and last == len(parts)):
785
- return tool, "/".join(parts[:k])
786
- return None
787
-
788
-
789
- def today():
790
- """UTC date; ADOPT_TODAY overrides it only under RISKRAILS_TEST=1."""
791
- if test_mode() and os.environ.get("ADOPT_TODAY"):
792
- return os.environ["ADOPT_TODAY"]
793
- return datetime.now(timezone.utc).strftime("%Y-%m-%d")
794
-
795
-
796
- def report_dir(plan):
797
- return getattr(plan, "adopt_report_dir", None) or ".ai/reports/adopt-%s" % today()
798
-
799
-
800
- NOTE_ORDER = ("kept", "split?", "dropped", "ignored", "unmapped", "hint")
801
-
802
-
803
- def report(plan, adoption, applied=False):
804
- """The adopt report in spec I3's layout."""
805
- head = "adopt, applied" if applied else \
806
- "adopt dry run, mode: %s, nothing written; --apply to write" % adoption.mode
807
- print("project-update: %s (%s)" % (plan.root, head))
808
- if not (adoption.detected or adoption.candidates or adoption.unmapped):
809
- print(" no foreign structure detected")
810
-
811
- def shown(i):
812
- return "%s -> %s" % (i["src"], i["target"]) if i.get("src") else i["target"]
813
- width = max([len(shown(i)) for i in plan.items] + [len(n[1]) for n in adoption.notes]
814
- + [len(t) for t in adoption.detected] + [10])
815
-
816
- def row(action, subject, text):
817
- print(" %-9s %-*s %s" % (action, width, subject, text))
818
-
819
- for tool, groups in adoption.detected.items():
820
- text = ", ".join("%s (%d)" % (g, c) if c > 1 or g.endswith("/") else g for g, c in groups.items())
821
- row("detect", tool, text)
822
- for i in plan.items:
823
- row(i["action"], shown(i), i["note"])
824
- for action in NOTE_ORDER:
825
- for act, subject, text in adoption.notes:
826
- if act == action:
827
- row(act, subject, text)
828
- if "no-line-lost" in adoption.checks:
829
- status, lines, missing, missing_n = adoption.checks["no-line-lost"]
830
- if status == "not_applicable":
831
- row("check", "no-line-lost", "not applicable (coexist)")
832
- elif status == "pass":
833
- row("check", "no-line-lost",
834
- "PASS (%d line(s), %d rewritten -> dropped.jsonl)" % (lines, len(adoption.dropped)))
835
- else:
836
- row("check", "no-line-lost", "FAIL: %d line(s): %s" % (missing_n, ", ".join(missing)))
837
- if "no-dangling" in adoption.checks:
838
- status, hard_misses, warns = adoption.checks["no-dangling"]
839
- if status == "pass":
840
- row("check", "no-dangling", "PASS (%s)" % warnings_text(warns))
841
- elif adoption.mode == "coexist":
842
- row("check", "no-dangling", "FAIL: %s" % ", ".join(p for _tag, p in hard_misses[:20]))
843
- else:
844
- row("check", "no-dangling", "FAIL: %s" % ", ".join("%s:%s -> %s" % m for m in hard_misses[:20]))
845
- cleanup_n = sum(1 for _p, _t, _tr, _n, cleanup in adoption.sources if cleanup)
846
- both_pass = all(c[0] in ("pass", "not_applicable") for c in adoption.checks.values())
847
- if adoption.mode == "migrate" and cleanup_n and both_pass and not adoption.unmapped:
848
- row("cleanup?", "", "%d file(s) offered after both checks pass: --adopt --cleanup" % cleanup_n)
849
- auto = [i for i in plan.items if i["action"] not in ("conflict", "delete?")]
850
- conflicts = [i for i in plan.items if i["action"] == "conflict"]
851
- tail = ""
852
- if adoption.split:
853
- tail += ", %d split(s) awaiting a proposal" % len(adoption.split)
854
- if adoption.unmapped:
855
- tail += ", %d unmapped" % len(adoption.unmapped)
856
- print("%d automatic, %d conflict(s)%s" % (len(auto), len(conflicts), tail))
857
-
858
-
859
- def run(plan, args, shipped_block, skeleton_cap, instruction_files=frozenset(), shipped_files=None):
860
- """`update.py --adopt` without --apply: plan, report, and the exit code."""
861
- try:
862
- table = load_table()
863
- except TableError as exc:
864
- print("project-update: %s" % exc, file=sys.stderr)
865
- return 2
866
- tools = [t.strip() for t in args.tool.split(",")] if args.tool else None
867
- if tools:
868
- unknown = [t for t in tools if t not in table["tools"]]
869
- if unknown:
870
- print("project-update: unknown --tool %s (known: %s)" % (", ".join(unknown), ", ".join(table["tools"])),
871
- file=sys.stderr)
872
- return 2
873
- if getattr(args, "check", False):
874
- return check_state(plan.root, table, shipped_block, skeleton_cap, instruction_files, shipped_files)
875
- requesting = getattr(args, "split_request", False)
876
- try:
877
- adoption = plan_adopt(plan, table, args.mode, tools, shipped_block, skeleton_cap, instruction_files,
878
- shipped_files, run_checks=not requesting, split_mode=getattr(args, "split", None),
879
- splits=not requesting)
880
- except TableError as exc:
881
- print("project-update: %s" % exc, file=sys.stderr)
882
- return 2
883
- if requesting:
884
- return write_requests(plan, adoption.candidates)
885
- # I1: the dry run answers what --apply would, so exit 4 covers a missing
886
- # proposal, a conflict and a failed check as well as the unmapped files.
887
- problems = incomplete_reasons(plan, adoption)
888
- if problems:
889
- print("%s: %s" % (INCOMPLETE, "; ".join(problems)))
890
- report(plan, adoption)
891
- if getattr(args, "diff", False):
892
- print_diff(plan)
893
- return 4 if problems else 0
894
-
895
-
896
- def incomplete_reasons(plan, adoption, conflicts=True, checks=True):
897
- """Why an adopt cannot be applied as planned (exit 4), in the order a
898
- human settles them."""
899
- problems = []
900
- if adoption.unmapped:
901
- problems.append("%d file(s) have no mapping row" % len(adoption.unmapped))
902
- problems += ["%s: %s" % err for err in adoption.split_errors]
903
- if conflicts:
904
- if adoption.split:
905
- problems.append("%s: a split needs a proposal or --split fallback" % ", ".join(adoption.split))
906
- clash = [i["target"] for i in plan.items if i["action"] == "conflict"]
907
- if clash:
908
- problems.append("destination(s) exist and differ: %s" % ", ".join(clash))
909
- if checks:
910
- failed = [name for name, c in adoption.checks.items() if c[0] == "fail"]
911
- if failed:
912
- problems.append("%s FAIL on the plan" % ", ".join(failed))
913
- return problems
914
-
915
-
916
- # ---------------------------------------------------------------------------
917
- # I11 --adopt --check
918
- # ---------------------------------------------------------------------------
919
-
920
- def check_state(root, table, shipped_block, skeleton_cap, instruction_files, shipped_files=None):
921
- """`update.py --adopt --check`: exactly one of I11's five lines."""
922
- code, line = state_line(root, shipped_block, skeleton_cap, table, instruction_files, shipped_files)
923
- print(line)
924
- return code
925
-
926
-
927
- def state_line(root, shipped_block, skeleton_cap, table=None, instruction_files=frozenset(), shipped_files=None):
928
- """(exit code, one I11 line). Detection only — no reference scan — so the
929
- plain dry run (D5) can afford it on every call. A broken adopt artefact is
930
- one line, never a traceback: /ai-status and the plain update read this."""
931
- try:
932
- return _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shipped_files)
933
- except (TableError, OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
934
- return 1, "adopt state unreadable (%s) — run /rr-setup --adopt" % exc
935
-
936
-
937
- def _state_line(root, shipped_block, skeleton_cap, table, instruction_files, shipped_files):
938
- if table is None:
939
- try:
940
- table = load_table()
941
- except TableError:
942
- return 0, "no foreign structure detected"
943
- where = latest_with(root, "adopt.json")
944
- if where is None:
945
- plan = _ThrowawayPlan(root)
946
- adoption = plan_adopt(plan, table, "migrate", None, shipped_block, skeleton_cap,
947
- instruction_files, shipped_files, run_checks=False, splits=False)
948
- if not adoption.detected and not adoption.candidates:
949
- return 0, "no foreign structure detected"
950
- bits = ["%s (%d file(s))" % (t, sum(g.values())) for t, g in adoption.detected.items()]
951
- bits += ["%s (split?)" % c.path for c, _ in adoption.candidates]
952
- return 1, "foreign structure detected: %s — run /rr-setup --adopt" % ", ".join(bits)
953
- try:
954
- with open(os.path.join(root, where, "adopt.json"), encoding="utf-8") as fh:
955
- record_data = json.load(fh)
956
- except (OSError, ValueError, RecursionError):
957
- return 0, "no foreign structure detected"
958
- date = where.rsplit("adopt-", 1)[-1]
959
- regenerated = []
960
- deleted = set((record_data.get("cleanup") or {}).get("deleted") or [])
961
- for src in record_data.get("sources", []):
962
- if not safe_rel(src.get("path")):
963
- continue
964
- full = os.path.join(root, src["path"])
965
- if src["path"] in deleted and os.path.isfile(full):
966
- regenerated.append(src["path"]) # it reappeared after cleanup (R16)
967
- continue
968
- if src.get("transform") == "instruction-file" or not os.path.isfile(full):
969
- # An instruction file stays, and the plain update re-renders its
970
- # block; growing again shows as split? in the dry run, not here.
971
- continue
972
- with open(full, "rb") as fh:
973
- current = "sha256:" + hashlib.sha256(fh.read()).hexdigest()
974
- if current != src.get("sha"):
975
- regenerated.append(src["path"])
976
- if regenerated:
977
- return 1, ("foreign files regenerated since the adopt of %s: %s — run /rr-setup --adopt"
978
- % (date, ", ".join(sorted(regenerated))))
979
- checks = record_data.get("checks", {})
980
- failed = [name for name, c in checks.items() if c.get("status") not in ("pass", "not_applicable")]
981
- if failed or record_data.get("status") == "partial":
982
- what = failed[0].replace("_", "-") + " FAIL" if failed else "the apply was interrupted"
983
- return 1, "adoption of %s incomplete: %s — run /rr-setup --adopt" % (date, what)
984
- tools = ", ".join(sorted(record_data.get("tools", {})))
985
- pending = sum(1 for s in record_data.get("sources", []) if s.get("cleanup") and safe_rel(s.get("path"))
986
- and os.path.isfile(os.path.join(root, s["path"])))
987
- offered = (record_data.get("cleanup") or {}).get("offered", True)
988
- tail = "; %d file(s) await cleanup" % pending if pending and offered else ""
989
- return 0, "adopted %s: %s — up to date%s" % (date, tools, tail)
990
-
991
-
992
- class _ThrowawayPlan:
993
- """The minimal reader `plan_adopt` needs, for `--adopt --check` when no
994
- record exists yet: nothing is ever written through it."""
995
-
996
- def __init__(self, root):
997
- self.root = root
998
- self.items = []
999
- self.hints = []
1000
- self.final = {}
1001
-
1002
- def exists(self, target):
1003
- return os.path.exists(os.path.join(self.root, target))
1004
-
1005
- def read(self, target):
1006
- path = os.path.join(self.root, target)
1007
- if not os.path.isfile(path):
1008
- return None
1009
- with open(path, "rb") as fh:
1010
- return fh.read()
1011
-
1012
- def add(self, *_a, **_k):
1013
- pass # a check never writes
1014
-
1015
-
1016
- # ---------------------------------------------------------------------------
1017
- # I7 no-line-lost: normalise, source/destination sets, dropped.jsonl coverage
1018
- # ---------------------------------------------------------------------------
1019
-
1020
- LIST_MARKER_RE = re.compile(r"^([-*+]|\d+[.)])\s+")
1021
- CHECKBOX_RE = re.compile(r"^\[[ xX]\]\s+")
1022
- HEADING_RE = re.compile(r"^#{1,6}\s")
1023
- ALNUM_RE = re.compile(r"[A-Za-z0-9]")
1024
-
1025
-
1026
- def normalise(line):
1027
- """I7: strip, ignorable (empty / bare heading / no alphanumeric character)
1028
- is None, otherwise one list marker and one checkbox marker stripped,
1029
- whitespace collapsed, casefolded."""
1030
- line = line.strip()
1031
- if not line or HEADING_RE.match(line) or not ALNUM_RE.search(line):
1032
- return None
1033
- line = LIST_MARKER_RE.sub("", line, count=1)
1034
- line = CHECKBOX_RE.sub("", line, count=1)
1035
- return re.sub(r"\s+", " ", line).casefold()
1036
-
1037
-
1038
- def text_of(content):
1039
- return content.decode("utf-8", "replace") if isinstance(content, bytes) else content
1040
-
1041
-
1042
- def check_lines(plan, adoption):
1043
- """I7 no-line-lost: source lines, minus what a transform or a decision
1044
- dropped, must all be in some destination the run writes. `plan.final`
1045
- covers the whole file, per target, so a router row's own text never
1046
- counts against a source (it is not a source, and dest coverage only ever
1047
- helps). Returns (status "pass"/"fail", lines, missing[:20] as "file:line",
1048
- missing_count)."""
1049
- if adoption.mode == "coexist":
1050
- return "not_applicable", 0, [], 0
1051
- targets = {d for d, _t, _r, coexist in adoption.destinations if not coexist}
1052
- targets |= {i["target"] for i in plan.items if i["content"] is not None}
1053
- # A conflict leaves the destination as it is on disk: its lines are still there.
1054
- targets |= {i["target"] for i in plan.items if i["action"] == "conflict"}
1055
- targets |= set(adoption.split_dests) # on disk after --apply, `plan` has no items
1056
- dest_lines = set()
1057
- for target in targets:
1058
- for raw in text_of(plan.read(target) or b"").split("\n"):
1059
- n = normalise(raw)
1060
- if n:
1061
- dest_lines.add(n)
1062
- dropped_star = {d["source"] for d in adoption.dropped if d.get("line") == "*"}
1063
- dropped_at = {(d["source"], d["line"]) for d in adoption.dropped if d.get("line") != "*"}
1064
- lines, missing = 0, []
1065
- for path, _tool, transform, _n, _cleanup in adoption.sources:
1066
- only = None
1067
- if transform == "instruction-file":
1068
- if path not in adoption.split_done:
1069
- continue # not a split: the file stays where it is, whole
1070
- # The text as the split read it: on disk it is already rewritten.
1071
- text = adoption.split_done[path]["text"]
1072
- only = set(adoption.split_done[path]["nums"])
1073
- elif transform == "ignore":
1074
- continue
1075
- else:
1076
- text = text_of(plan.read(path) or b"")
1077
- whole_dropped = path in dropped_star
1078
- for i, raw in enumerate(text.split("\n"), 1):
1079
- if only is not None and i not in only:
1080
- continue
1081
- n = normalise(raw)
1082
- if n is None:
1083
- continue
1084
- lines += 1
1085
- if whole_dropped or (path, i) in dropped_at:
1086
- continue
1087
- if n not in dest_lines:
1088
- missing.append("%s:%d" % (path, i))
1089
- status = "fail" if missing else "pass"
1090
- return status, lines, missing[:20], len(missing)
1091
-
1092
-
1093
- # ---------------------------------------------------------------------------
1094
- # I8 no-dangling-reference
1095
- # ---------------------------------------------------------------------------
1096
-
1097
- REF_BOUNDARY_BEFORE = r"(^|[\s`\"'(@=:,])"
1098
- REF_BOUNDARY_AFTER = r"(?=$|[\s`\"')>,:;])"
1099
- MAX_SCAN_BYTES = 20 * 1024 * 1024
1100
-
1101
-
1102
- def ref_pattern(old_path):
1103
- """The reference pattern for a path (and for `./path`); a directory root
1104
- (trailing `/`) also matches everything below it."""
1105
- escaped = re.escape(old_path.rstrip("/"))
1106
- if old_path.endswith("/"):
1107
- return re.compile(REF_BOUNDARY_BEFORE + r"\.?/?" + escaped + r"/\S*")
1108
- return re.compile(REF_BOUNDARY_BEFORE + r"\.?/?" + escaped + REF_BOUNDARY_AFTER)
1109
-
1110
-
1111
- def old_paths_for(adoption, table):
1112
- """I8: every source whose row has cleanup: true, plus every detected
1113
- tool's `roots` entries."""
1114
- paths = {path for path, _tool, _transform, _n, cleanup in adoption.sources if cleanup}
1115
- for tool in adoption.detected:
1116
- paths |= set(table["tools"][tool]["roots"])
1117
- return sorted(paths)
1118
-
1119
-
1120
- HARD_PREFIXES = (".ai/", "docs/sdlc/", ".claude/", ".codex/", ".gemini/", ".junie/",
1121
- ".github/prompts/", ".github/instructions/")
1122
- HARD_AI_EXCEPT = (".ai/reports/adopt-", ".ai/state/", ".ai/local/")
1123
-
1124
-
1125
- def is_hard_scope(path, text, instruction_files):
1126
- if path in instruction_files:
1127
- return True
1128
- if os.path.basename(path) in (".junie/guidelines.md".rsplit("/", 1)[-1], "CLAUDE.md", "AGENTS.md") and render_instructions.RULE_MARKER_RE.search(text):
1129
- return True # a nested instruction file carrying a rule block (I8)
1130
- if path.startswith(".ai/"):
1131
- return not any(path.startswith(p) for p in HARD_AI_EXCEPT)
1132
- return any(path.startswith(p) for p in HARD_PREFIXES if p != ".ai/")
1133
-
1134
-
1135
- def is_binary(data):
1136
- return b"\0" in data[:8192]
1137
-
1138
-
1139
- def git_files(root):
1140
- try:
1141
- proc = subprocess.run(["git", "-C", root, "ls-files", "-z"], capture_output=True, check=False)
1142
- except OSError:
1143
- return None
1144
- if proc.returncode != 0:
1145
- return None
1146
- return [p for p in proc.stdout.decode("utf-8", "surrogateescape").split("\0") if p]
1147
-
1148
-
1149
- COEXIST_LINK_RE = re.compile(r"^\| .* \| (?P<path>\S+) \(kept in place(?:; its globs are "
1150
- r"not applied by this runtime)?\) \|$", re.M)
1151
-
1152
-
1153
- def warnings_text(warns):
1154
- """The warn-scope references, named: a count alone tells a human nothing
1155
- they can judge."""
1156
- if not warns:
1157
- return "0 warning(s) outside the new structure"
1158
- shown = ", ".join("%s:%s -> %s" % w for w in warns[:10])
1159
- more = " and %d more" % (len(warns) - 10) if len(warns) > 10 else ""
1160
- return "%d warning(s) outside the new structure: %s%s" % (len(warns), shown, more)
1161
-
1162
-
1163
- def check_refs(plan, adoption, table, instruction_files=frozenset(), shipped_files=None):
1164
- """I8 no-dangling-reference: every old path (or, in coexist, every linked
1165
- foreign path) still readable, and no reference to a path this run took
1166
- away is left behind in the hard scope; a reference in the warn scope is
1167
- reported but does not fail. Returns (status, hard_misses, warn_misses),
1168
- each miss a (path, line, old path)."""
1169
- if adoption.mode == "coexist":
1170
- linked = {path for path, _tool, _router, _coexist in adoption.destinations}
1171
- linked |= set(COEXIST_LINK_RE.findall(text_of(plan.read(ROUTER_FILE) or b"")))
1172
- missing = sorted(path for path in linked if not plan.exists(path))
1173
- return ("fail" if missing else "pass"), [("<coexist>", p) for p in missing], []
1174
- old = old_paths_for(adoption, table)
1175
- if not old:
1176
- return "pass", [], []
1177
- patterns = [ref_pattern(p) for p in old]
1178
- moved = {path for path, _tool, _transform, _n, cleanup in adoption.sources if cleanup}
1179
- files = git_files(plan.root)
1180
- if files is None:
1181
- files = walk(plan.root)
1182
- # What the run leaves behind: planned content first (plan.final), then disk.
1183
- files = sorted(set(files) | set(plan.final))
1184
- hard_misses, warn_misses = [], []
1185
- for path in files:
1186
- if path in moved or path in old:
1187
- continue
1188
- if path.startswith(".ai/reports/adopt-"):
1189
- continue # the adopt's own record: it names the old paths on purpose
1190
- if path in plan.final:
1191
- data = plan.final[path]
1192
- else:
1193
- full = os.path.join(plan.root, path)
1194
- try:
1195
- if os.path.getsize(full) > MAX_SCAN_BYTES:
1196
- continue
1197
- with open(full, "rb") as fh:
1198
- data = fh.read()
1199
- except OSError:
1200
- continue
1201
- if is_binary(data):
1202
- continue
1203
- # A file whose bytes still match what the plugin ships is the plugin's
1204
- # own words, not this project's: a generic example ("a `.cursorrules`
1205
- # or the like", `.ai/policies/security.md`) is not a dependency on
1206
- # this project's foreign structure, so it never fails hard scope —
1207
- # it still counts toward warn (harmless: warn never fails the run).
1208
- unedited = shipped_files is not None and shipped_files.get(path) == data
1209
- text = data.decode("utf-8", "replace")
1210
- hard = is_hard_scope(path, text, instruction_files) and not unedited
1211
- foreign = adoption.adopted_lines.get(path, ())
1212
- for i, line in enumerate(text.split("\n"), 1):
1213
- hit = next((p for p, rx in zip(old, patterns) if rx.search(line)), None)
1214
- if hit is None:
1215
- continue
1216
- # A3: a line that came verbatim from a foreign source this run
1217
- # adopts is that tool's own wording, named, not the project's dependency.
1218
- # Every line is classified: with A3 a warned line can come first in
1219
- # a file, and a file-level stop there would hide the project's own
1220
- # stale reference further down. (`next` above already takes one hit per line.)
1221
- if hard and adopted_key(line) not in foreign:
1222
- hard_misses.append((path, i, hit))
1223
- else:
1224
- warn_misses.append((path, i, hit))
1225
- return ("fail" if hard_misses else "pass"), hard_misses, warn_misses
1226
-
1227
-
1228
- # ---------------------------------------------------------------------------
1229
- # I9 router rows
1230
- # ---------------------------------------------------------------------------
1231
-
1232
- TOOL_TITLE = {"speckit": "Spec Kit", "kiro": "Kiro", "aidlc": "AI-DLC", "cursor": "Cursor",
1233
- "copilot": "Copilot", "junie": "Junie", "gemini": "Gemini", "claude": "Claude", "codex": "Codex"}
1234
- ROUTER_FILE = ".ai/AGENTS.md"
1235
-
1236
-
1237
- def router_rows_text(adoption):
1238
- """I9 as amended (A5). Migrate: one row per (tool, destination directory),
1239
- and one per file for an on-demand rule, triggered by its title. Coexist:
1240
- one row per (tool, directory of the kept path), with the globs suffix only
1241
- when a rule with a globs key is among them. Returns the row texts, in
1242
- first-seen order."""
1243
- rows = OrderedDict()
1244
- for dest, tool, router, coexist in adoption.destinations:
1245
- spec = router if isinstance(router, dict) else {"text": router}
1246
- if spec.get("skip"):
1247
- continue # A4: the instruction files point at it; it needs no row
1248
- title = spec.get("text") or ("an adopted %s file" % TOOL_TITLE.get(tool, tool))
1249
- if coexist:
1250
- rel_dir = os.path.dirname(dest)
1251
- key = (tool, rel_dir)
1252
- shown = rel_dir + "/" if rel_dir else dest
1253
- row = rows.setdefault(key, [title, shown, False])
1254
- row[2] = row[2] or bool(spec.get("globs"))
1255
- continue
1256
- target = dest if spec.get("file") else os.path.dirname(dest) + "/"
1257
- shown = target[len(".ai/"):] if target.startswith(".ai/") else target
1258
- rows.setdefault((tool, target), [title, shown, None])
1259
- out = []
1260
- for title, shown, globs in rows.values():
1261
- if globs is None:
1262
- out.append("| %s | %s |" % (title, shown))
1263
- else:
1264
- out.append("| %s | %s (kept in place%s) |" % (
1265
- title, shown, "; its globs are not applied by this runtime" if globs else ""))
1266
- return out
1267
-
1268
-
1269
- def add_router_rows(plan, rows):
1270
- """Append new router rows after the last row of `.ai/AGENTS.md`'s routing
1271
- table, idempotent by exact text. Returns the count actually added."""
1272
- if not rows:
1273
- return 0
1274
- text = text_of(plan.read(ROUTER_FILE) or b"")
1275
- if not text:
1276
- return 0
1277
- lines = text.split("\n")
1278
- # A5: a coexist row per file, the form before 2026-09-27, is this tool's
1279
- # own and is superseded by the row for its directory.
1280
- dir_rows = {m.group("path") for r in rows for m in [COEXIST_LINK_RE.match(r)] if m
1281
- and m.group("path").endswith("/")}
1282
- stale = [l for l in lines for m in [COEXIST_LINK_RE.match(l)] if m
1283
- and not m.group("path").endswith("/") and os.path.dirname(m.group("path")) + "/" in dir_rows]
1284
- lines = [l for l in lines if l not in stale]
1285
- last = max((i for i, l in enumerate(lines) if l.startswith("|")), default=None)
1286
- if last is None:
1287
- return 0
1288
- to_add = [r for r in rows if r not in lines]
1289
- if not to_add and not stale:
1290
- return 0
1291
- new_lines = lines[:last + 1] + to_add + lines[last + 1:]
1292
- note = "+%d row(s)" % len(to_add) + (", -%d superseded" % len(stale) if stale else "")
1293
- plan.add("router", ROUTER_FILE, note=note, content="\n".join(new_lines).encode("utf-8"))
1294
- return len(to_add)
1295
-
1296
-
1297
- # ---------------------------------------------------------------------------
1298
- # --adopt --apply (R5, R6, R15, R16)
1299
- # ---------------------------------------------------------------------------
1300
-
1301
- # Dirt an adopt tolerates: its own record, and the two git-ignored directories.
1302
- ALLOWED_DIRT = (".ai/reports/adopt-", ".ai/state/", ".ai/local/")
1303
- ORIGINAL_CAP = 1024 * 1024
1304
- INSTRUCTION_ROOT = ("CLAUDE.md", "AGENTS.md", "GEMINI.md", ".junie/guidelines.md")
1305
-
1306
-
1307
- def sha256(data):
1308
- return "sha256:" + hashlib.sha256(data).hexdigest()
1309
-
1310
-
1311
- def read_record(root, where):
1312
- try:
1313
- with open(os.path.join(root, where, "adopt.json"), encoding="utf-8") as fh:
1314
- return json.load(fh)
1315
- except (OSError, ValueError, RecursionError):
1316
- return None
1317
-
1318
-
1319
- def partial_record(root):
1320
- """(directory, record) of the latest adopt.json when it is `partial` — an
1321
- apply that stopped part-way — else (None, None)."""
1322
- where = latest_with(root, "adopt.json")
1323
- if where is None:
1324
- return None, None
1325
- rec = read_record(root, where)
1326
- if rec and rec.get("status") == "partial":
1327
- return where, rec
1328
- return None, None
1329
-
1330
-
1331
- def task_in_flight(root):
1332
- """The task in .ai/state/state.json (or the legacy current.json) when its
1333
- stage is not `done`."""
1334
- path = os.path.join(root, ".ai", "state", "state.json")
1335
- if not os.path.isfile(path):
1336
- path = os.path.join(root, ".ai", "state", "current.json")
1337
- try:
1338
- with open(path, encoding="utf-8") as fh:
1339
- state = json.load(fh)
1340
- except (OSError, ValueError, RecursionError):
1341
- return None
1342
- stage = state.get("current_stage")
1343
- return None if stage in (None, "done") else "%s at stage %s" % (state.get("task_id", "a task"), stage)
1344
-
1345
-
1346
- def gates(root, u, planned_writes):
1347
- """R5, in the order a human fixes them. Returns a refusal (reason, how) or None."""
1348
- if not os.path.isdir(os.path.join(root, ".ai")):
1349
- return "the project has no .ai/", "run /rr-setup first, then /rr-setup --adopt"
1350
- dirty = clean_tree(root)
1351
- if dirty is None:
1352
- return "the project is not a git work tree", "run `git init` and commit, so the adopt can be undone"
1353
- running = task_in_flight(root)
1354
- if running:
1355
- return "a task is in flight (%s)" % running, "finish or abandon it with /rr-build first"
1356
- outside = [p for p in dirty if not p.startswith(ALLOWED_DIRT) and p not in planned_writes]
1357
- if outside:
1358
- more = " and %d more" % (len(outside) - 3) if len(outside) > 3 else ""
1359
- how = "commit or stash them first, so the adopt is one reviewable change"
1360
- if any(p.startswith(".ai/reports/T-") for p in outside):
1361
- how += ("; an archived or abandoned task leaves its .ai/reports/<task-id>/ untracked — "
1362
- "commit it or remove it")
1363
- return ("the tree has uncommitted changes: %s%s" % (", ".join(outside[:3]), more), how)
1364
- if u.plain_pending(root, ignore=planned_writes):
1365
- return "the project is behind the installed plugin", "run /rr-setup --update --apply first, then adopt"
1366
- return None
1367
-
1368
-
1369
- def keep_original_bounded(u, plan, rel, record):
1370
- """R6: a copy with its mode under <record dir>/original/, unless it is over
1371
- 1 MiB or binary — then git keeps it, and it is listed."""
1372
- full = os.path.join(plan.root, rel)
1373
- if not os.path.isfile(full):
1374
- return
1375
- target = os.path.join(plan.report_dir, "original", rel)
1376
- if os.path.exists(os.path.join(plan.root, target)):
1377
- return # a resumed apply already kept it
1378
- size = os.path.getsize(full)
1379
- with open(full, "rb") as fh:
1380
- head = fh.read(8192)
1381
- if size > ORIGINAL_CAP or b"\0" in head:
1382
- if rel not in record["original_skipped"]:
1383
- record["original_skipped"].append(rel)
1384
- return
1385
- u.write_file(plan.root, target, u.read(full), mode=os.stat(full).st_mode & 0o7777)
1386
- record["original_bytes"] += size
1387
-
1388
-
1389
- def phase_of(item):
1390
- """1: destinations, router rows, rendered rule blocks; 2: a rewrite of a
1391
- root instruction file. A source never loses a line before its destination
1392
- exists (plan step 6)."""
1393
- return 2 if item["target"] in INSTRUCTION_ROOT and item["action"] == "adopt" else 1
1394
-
1395
-
1396
- def test_stop_after():
1397
- """ADOPT_STOP_AFTER=<phase>, read only under RISKRAILS_TEST=1."""
1398
- if not test_mode():
1399
- return None
1400
- try:
1401
- return int(os.environ.get("ADOPT_STOP_AFTER", ""))
1402
- except ValueError:
1403
- return None
1404
-
1405
-
1406
- def write_json(u, root, rel, data):
1407
- u.write_file(root, rel, (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
1408
-
1409
-
1410
- def apply_run(plan, args, u, skeleton_cap, instruction_files=frozenset(), shipped_files=None):
1411
- """`update.py --adopt --apply`: gates, originals, phased writes, the checks
1412
- on disk, the record. Exit 0, 2 (the routing table or a migration could not be
1413
- read), 3 (aborted), 4 (incomplete) or 5 (refused)."""
1414
- root = plan.root
1415
- try:
1416
- table = load_table()
1417
- except TableError as exc:
1418
- print("project-update: %s" % exc, file=sys.stderr)
1419
- return 2
1420
- tools = [t.strip() for t in args.tool.split(",")] if args.tool else None
1421
- resume_dir, resume = partial_record(root)
1422
- planned_writes = set(resume.get("planned_writes", [])) if resume else set()
1423
- try:
1424
- refusal = gates(root, u, planned_writes)
1425
- except u.SchemaError as exc: # plain_pending builds the plan: a migration's error is exit 2, as in update.py
1426
- print("project-update: %s" % exc, file=sys.stderr)
1427
- return 2
1428
- if refusal:
1429
- return refuse(*refusal)
1430
- plan.report_dir = plan.adopt_report_dir = resume_dir or ".ai/reports/adopt-%s" % today()
1431
- adoption = plan_adopt(plan, table, args.mode, tools, u.shipped_block, skeleton_cap,
1432
- instruction_files, shipped_files, split_mode=getattr(args, "split", None))
1433
- problems = incomplete_reasons(plan, adoption)
1434
- if problems:
1435
- print("%s: %s — nothing written" % (INCOMPLETE, "; ".join(problems)))
1436
- report(plan, adoption)
1437
- return 4
1438
- # Nested rule blocks for the rules this run writes (plan step 6, HIGH 5).
1439
- planned_rules = {i["target"]: text_of(i["content"]) for i in plan.items
1440
- if i["content"] is not None and i["target"].startswith(".ai/rules/")}
1441
- if planned_rules:
1442
- u.rules_update(plan, u.project_runtimes(root), planned=planned_rules)
1443
- writes = [i for i in plan.items if i["content"] is not None and i["action"] != "delete?"]
1444
- record = resume or {"version": 1, "mode": args.mode, "adopted_at": u.utc_now(),
1445
- "original_bytes": 0, "original_skipped": []}
1446
- record["status"] = "partial"
1447
- record["planned_writes"] = sorted(set(record.get("planned_writes", [])) | {i["target"] for i in writes})
1448
- record.setdefault("original_bytes", 0)
1449
- record.setdefault("original_skipped", [])
1450
- rec_path = plan.report_dir + "/adopt.json"
1451
- written, recorded = {}, []
1452
- try:
1453
- write_json(u, root, rec_path, record)
1454
- # Every source cleanup may take away, and every project file outside
1455
- # .ai/ this run rewrites, is kept before anything is written.
1456
- for path, _tool, _transform, _n, cleanup in adoption.sources:
1457
- if cleanup:
1458
- keep_original_bounded(u, plan, path, record)
1459
- for item in writes:
1460
- if not item["target"].startswith(".ai/") and item["expect"] is not None:
1461
- keep_original_bounded(u, plan, item["target"], record)
1462
- write_json(u, root, rec_path, record)
1463
- stop = test_stop_after()
1464
- for phase in (1, 2):
1465
- for item in writes:
1466
- if phase_of(item) == phase:
1467
- u.apply_item(plan, item, written, recorded)
1468
- if stop == phase:
1469
- print("project-update: stopped after phase %d (ADOPT_STOP_AFTER, test only)" % phase)
1470
- return 3
1471
- except u.Abort as exc:
1472
- print("project-update: aborted: %s — re-run --adopt --apply to resume" % exc.why, file=sys.stderr)
1473
- return 3
1474
- except OSError as exc:
1475
- print("project-update: aborted: %s — re-run --adopt --apply to resume" % exc, file=sys.stderr)
1476
- return 3
1477
- return finish(plan, adoption, record, rec_path, u, table, instruction_files, shipped_files)
1478
-
1479
-
1480
- def finish(plan, adoption, record, rec_path, u, table, instruction_files, shipped_files):
1481
- """The two checks on disk, then the record: adopt.json, report.md, dropped.jsonl."""
1482
- root = plan.root
1483
- disk = u.Plan(root) # reads the tree as it is now
1484
- line_status, lines, missing, missing_n = check_lines(disk, adoption)
1485
- ref_status, hard_misses, warns = check_refs(disk, adoption, table, instruction_files, shipped_files)
1486
- added = check_added(disk, adoption)
1487
- now = u.utc_now()
1488
- tools = OrderedDict((t, {"files": sum(g.values())}) for t, g in adoption.detected.items())
1489
- for cand, _row in adoption.candidates: # a split is never `detect`ed, but it is adopted
1490
- if cand.path in adoption.split_done:
1491
- tools.setdefault(cand.tool, {"files": 1})
1492
- sources = []
1493
- for path, tool, transform, _n, cleanup in adoption.sources:
1494
- entry = {"path": path, "sha": sha256(disk.read(path) or b""), "tool": tool,
1495
- "transform": transform, "cleanup": cleanup}
1496
- item = next((i for i in plan.items if i.get("src") == path and i["content"] is not None), None)
1497
- if item is not None:
1498
- entry["dest"] = item["target"]
1499
- entry["dest_sha"] = sha256(disk.read(item["target"]) or b"")
1500
- if os.path.exists(os.path.join(root, plan.report_dir, "original", path)):
1501
- entry["original"] = "original/" + path
1502
- sources.append(entry)
1503
- merged = OrderedDict((s["path"], s) for s in record.get("sources", []))
1504
- for entry in sources: # same-day runs merge by source path (I10)
1505
- merged[entry["path"]] = entry
1506
- status = "applied" if "fail" not in (line_status, ref_status) and not added else "incomplete"
1507
- split = dict(record.get("split", {}))
1508
- for path, done in adoption.split_done.items():
1509
- split[path] = {k: done[k] for k in ("by", "proposal_sha", "kept_bytes", "moves", "dropped")}
1510
- if split:
1511
- record["split"] = split
1512
- record.update({
1513
- "status": status, "mode": adoption.mode, "tools": dict(record.get("tools", {}), **tools),
1514
- "sources": list(merged.values()), "dropped": len(adoption.dropped),
1515
- "ignored": sorted({s for a, s, _t in adoption.notes if a == "ignored"}),
1516
- "unmapped": [p for p, _t in adoption.unmapped],
1517
- "checks": record_checks(line_status, lines, missing_n, ref_status, hard_misses, warns, now),
1518
- "cleanup": record.get("cleanup") or {"offered": adoption.mode == "migrate" and status == "applied",
1519
- "confirmed_by": None, "at": None, "via": None, "unattended": None,
1520
- "tty": None, "deleted": []},
1521
- })
1522
- if adoption.split_done: # R9 for a split, on disk
1523
- record["checks"]["no_line_added"] = {"status": "fail" if added else "pass", "checked_at": now,
1524
- "added": len(added)}
1525
- record.pop("planned_writes", None)
1526
- write_json(u, root, rec_path, record)
1527
- u.write_file(root, plan.report_dir + "/dropped.jsonl",
1528
- "".join(json.dumps(d, ensure_ascii=False) + "\n" for d in adoption.dropped).encode("utf-8"))
1529
- # The disk re-check is what the record holds, so report.md names the same warnings.
1530
- adoption.checks["no-line-lost"] = (line_status, lines, missing, missing_n)
1531
- adoption.checks["no-dangling"] = (ref_status, hard_misses, warns)
1532
- u.write_file(root, plan.report_dir + "/report.md", report_md(plan, adoption, record).encode("utf-8"))
1533
- report(plan, adoption, applied=True)
1534
- skipped = record["original_skipped"]
1535
- print(" original %d B kept in %s/original/%s" % (
1536
- record["original_bytes"], plan.report_dir,
1537
- "; %d skipped (git has them): %s" % (len(skipped), ", ".join(skipped)) if skipped else ""))
1538
- print(" record %s" % rec_path)
1539
- if added:
1540
- row_fmt = " %-9s %s"
1541
- print(row_fmt % ("check", "no-line-added FAIL: %s" % ", ".join(added[:20])))
1542
- if status != "applied":
1543
- print("%s: a check failed on disk — see %s" % (INCOMPLETE, rec_path))
1544
- return 4
1545
- return 0
1546
-
1547
-
1548
- def record_checks(line_status, lines, missing_n, ref_status, hard_misses, warn_misses, now):
1549
- return {"no_line_lost": {"status": line_status, "checked_at": now, "lines": lines, "missing": missing_n},
1550
- "no_dangling": {"status": ref_status, "checked_at": now, "hard": len(hard_misses), "warn": len(warn_misses)}}
1551
-
1552
-
1553
- def report_md(plan, adoption, record):
1554
- """The human rendering of the record, in the intent's order."""
1555
- out = ["# Adopt %s (%s)" % (plan.report_dir.rsplit("adopt-", 1)[-1], adoption.mode), ""]
1556
- out += ["## Detected", ""] + ["- %s: %d file(s)" % (t, v["files"]) for t, v in record["tools"].items()]
1557
- out += ["", "## Where each piece went", ""]
1558
- out += ["- `%s` -> `%s` (%s)" % (s["path"], s["dest"], s["transform"]) for s in record["sources"] if s.get("dest")]
1559
- out += ["", "## Dropped", ""]
1560
- out += ["- `%s%s`: %s" % (d["source"], "" if d["line"] == "*" else ":%s" % d["line"], d["reason"])
1561
- for d in adoption.dropped]
1562
- out += ["", "## Ignored or unmapped", ""] + ["- ignored `%s`" % p for p in record["ignored"]]
1563
- out += ["- unmapped `%s`" % p for p in record["unmapped"]]
1564
- checks = record["checks"]
1565
- out += ["", "## Checks", "",
1566
- "- no-line-lost: %s (%d lines, %d missing)" % (
1567
- checks["no_line_lost"]["status"], checks["no_line_lost"]["lines"], checks["no_line_lost"]["missing"]),
1568
- "- no-dangling: %s (%d hard, %d warning(s))" % (
1569
- checks["no_dangling"]["status"], checks["no_dangling"]["hard"], checks["no_dangling"]["warn"])]
1570
- warns = adoption.checks.get("no-dangling", (None, [], []))[2]
1571
- out += [" - warning `%s:%s` -> `%s`" % w for w in warns[:20]]
1572
- if len(warns) > 20:
1573
- out += [" - and %d more" % (len(warns) - 20)]
1574
- offered = [s["path"] for s in record["sources"]
1575
- if s.get("cleanup") and os.path.exists(os.path.join(plan.root, s["path"]))]
1576
- out += ["", "## Proposed for deletion", ""]
1577
- if offered and record["cleanup"]["offered"]:
1578
- out += ["- `%s`" % p for p in offered]
1579
- out += ["", REVIEW_LINE]
1580
- else:
1581
- out += ["- nothing"]
1582
- return "\n".join(out) + "\n"
1583
-
1584
-
1585
- # ---------------------------------------------------------------------------
1586
- # --adopt --cleanup (R13, R14's refusal): the one deletion adopt makes, behind
1587
- # the record, a clean tree, both checks recomputed, and a human
1588
- # ---------------------------------------------------------------------------
1589
-
1590
- CLEANUP_HEADING = "## Cleanup"
1591
- REVIEW_LINE = "Run `/rr-setup --adopt --cleanup` to review; a human confirms the deletion."
1592
- # Reports written by v2.0.2-v3.1.0 carry this text; cleanup still replaces it.
1593
- LEGACY_REVIEW_LINE = "Run `/project-update --adopt --cleanup` to review; a human confirms the deletion."
1594
-
1595
-
1596
- def plan_cleanup(plan, args, u, table, skeleton_cap, instruction_files, shipped_files):
1597
- """(refusal, where, record, targets, adoption). `refusal` is (reason, how)
1598
- or None; every gate runs before anything is listed, so a refusal never
1599
- shows a list to confirm. `adoption` is set once the checks were recomputed."""
1600
- root = plan.root
1601
-
1602
- def no(reason, how, adoption=None):
1603
- return (reason, how), None, None, None, adoption
1604
- if not os.path.isdir(os.path.join(root, ".ai")):
1605
- return no("the project has no .ai/", "run /rr-setup first")
1606
- where = latest_with(root, "adopt.json")
1607
- record = read_record(root, where) if where else None
1608
- if record is None:
1609
- return no("no adopt record to clean up", "run /rr-setup --adopt --apply first")
1610
- if args.mode == "coexist" or record.get("mode") == "coexist":
1611
- return no("coexist keeps the foreign files",
1612
- "to move them, run /rr-setup --adopt --apply (mode migrate), then --cleanup")
1613
- checks = record.get("checks", {})
1614
- if record.get("status") != "applied" or any(c.get("status") != "pass" for c in checks.values()):
1615
- return no("the adopt of %s is not complete (R12)" % where.rsplit("adopt-", 1)[-1],
1616
- "run /rr-setup --adopt --apply until both checks pass")
1617
- running = task_in_flight(root)
1618
- if running:
1619
- return no("a task is in flight (%s)" % running, "finish or abandon it with /rr-build first")
1620
- dirty = clean_tree(root)
1621
- if dirty is None:
1622
- return no("the project is not a git work tree", "cleanup deletes only what git can restore")
1623
- if dirty: # no exceptions: `git checkout -- <path>` is the rollback
1624
- more = " and %d more" % (len(dirty) - 3) if len(dirty) > 3 else ""
1625
- return no("the tree has uncommitted changes: %s%s" % (", ".join(dirty[:3]), more),
1626
- "commit the adopt (its record included) first, so the deletion is its own change")
1627
- unsafe = [str(s.get("path")) for s in record.get("sources", []) if s.get("cleanup") and not safe_rel(s.get("path"))]
1628
- if unsafe:
1629
- return no("adopt.json names path(s) outside the project: %s" % ", ".join(unsafe[:3]),
1630
- "the record was edited; restore it from git, then --cleanup")
1631
- targets, changed = [], []
1632
- for src in record.get("sources", []):
1633
- if not src.get("cleanup"):
1634
- continue # safe_rel above holds for every cleanup entry, and only for those
1635
- path = src["path"]
1636
- if path in INSTRUCTION_ROOT or not os.path.isfile(os.path.join(root, path)):
1637
- continue # R17: an instruction file is never deleted
1638
- if sha256(plan.read(path) or b"") != src.get("sha"):
1639
- changed.append(path)
1640
- targets.append(src)
1641
- if changed:
1642
- return no("source(s) changed since the adopt: %s" % ", ".join(changed[:3]),
1643
- "an adopted destination is never rewritten, so another --apply cannot take the change; "
1644
- "restore the adopted version from git (`git log -p` on that file) and then --cleanup, "
1645
- "or carry the change into the destination by hand and keep the foreign file")
1646
- # R13: both checks recomputed on the tree as it is now. A pending adopt
1647
- # write would let them pass against the plan instead of the disk.
1648
- adoption = plan_adopt(plan, table, "migrate", None, u.shipped_block, skeleton_cap,
1649
- instruction_files, shipped_files)
1650
- what = ["%s FAIL" % name for name, c in adoption.checks.items() if c[0] == "fail"]
1651
- pending = [i for i in plan.items if i["action"] != "delete?"]
1652
- if pending:
1653
- what.append("%d adopt item(s) pending" % len(pending))
1654
- if adoption.unmapped or adoption.split:
1655
- what.append("%d unmapped, %d split(s) waiting" % (len(adoption.unmapped), len(adoption.split)))
1656
- if what:
1657
- return no("the adopt no longer holds on the current tree: %s" % "; ".join(what),
1658
- "run /rr-setup --adopt to see why, settle it, then --cleanup", adoption)
1659
- # Only what the adopt finds again as a cleanup source, and only what git
1660
- # can restore: an ignored file skipped from original/ would be gone for good.
1661
- found = {path for path, _t, _tr, _n, cleanup in adoption.sources if cleanup}
1662
- stranger = [s["path"] for s in targets if s["path"] not in found]
1663
- if stranger:
1664
- return no("adopt.json lists file(s) the adopt does not map for cleanup: %s" % ", ".join(stranger[:3]),
1665
- "the record was edited; restore it from git, then --cleanup")
1666
- tracked = set(git_files(root) or [])
1667
- untracked = [s["path"] for s in targets if s["path"] not in tracked]
1668
- if untracked:
1669
- return no("file(s) git does not track (ignored?): %s" % ", ".join(untracked[:3]),
1670
- "cleanup deletes only what git can restore; commit them or delete them yourself")
1671
- return None, where, record, targets, adoption
1672
-
1673
-
1674
- def cleanup_run(plan, args, u, skeleton_cap, instruction_files=frozenset(), shipped_files=None):
1675
- """`update.py --adopt --cleanup [--apply --confirm-delete NAME]`. Exit 0 or
1676
- 5 (refused); a failed removal is exit 3 with the record naming what went."""
1677
- try:
1678
- table = load_table()
1679
- except TableError as exc:
1680
- print("project-update: %s" % exc, file=sys.stderr)
1681
- return 2
1682
- refusal, where, record, targets, adoption = plan_cleanup(
1683
- plan, args, u, table, skeleton_cap, instruction_files, shipped_files)
1684
- if refusal:
1685
- code = refuse(*refusal)
1686
- if adoption is not None:
1687
- report(plan, adoption) # the recomputed checks, so the human sees what fails
1688
- return code
1689
- confirm = (getattr(args, "confirm_delete", None) or "").strip()
1690
- if confirm and placeholder_name(confirm): # update.py checks too; this module stands alone
1691
- return refuse("--confirm-delete got the placeholder %r" % confirm,
1692
- "type your own name: it is the audit trail")
1693
- passed, via, why = human_gate() if confirm else (False, None, "")
1694
- if confirm and not passed: # update.py checks too; this module stands alone
1695
- return refuse("--confirm-delete is typed by a human, and %s" % why,
1696
- "run the same command yourself in a terminal outside Claude Code or Codex")
1697
- date = where.rsplit("adopt-", 1)[-1]
1698
- head = "adopt cleanup of %s, applied" % date if confirm else \
1699
- "adopt cleanup dry run of %s, nothing deleted; a human confirms with --apply --confirm-delete NAME" % date
1700
- print("project-update: %s (%s)" % (plan.root, head))
1701
- width = max([len(s["path"]) for s in targets] + [12])
1702
- for name in ("no-line-lost", "no-dangling"):
1703
- print(" %-9s %-*s PASS (recomputed on the current tree)" % ("check", width, name))
1704
- if not targets:
1705
- print("0 deletion(s): nothing left to clean up")
1706
- return 0
1707
- if not confirm:
1708
- for src in targets:
1709
- print(" %-9s %-*s [%s] %s; needs --apply --confirm-delete NAME"
1710
- % ("delete?", width, src["path"], src["tool"], src["transform"]))
1711
- print("%d deletion(s) awaiting --confirm-delete" % len(targets))
1712
- return 0
1713
- tty = os.isatty(0)
1714
- prior = record.get("cleanup") or {}
1715
- history = list(prior.get("history") or [])
1716
- if prior.get("at"): # an earlier, interrupted cleanup keeps its line in the trail
1717
- history.append({k: prior.get(k) for k in ("confirmed_by", "at", "via", "unattended", "tty")})
1718
- record["cleanup"] = {"offered": True, "confirmed_by": confirm, "at": u.utc_now(), "via": via,
1719
- "unattended": via == "unattended", "tty": tty,
1720
- "deleted": list(prior.get("deleted") or [])}
1721
- if history:
1722
- record["cleanup"]["history"] = history
1723
- plan.report_dir = plan.adopt_report_dir = where # the record's own directory, not today's
1724
- rec_path = where + "/adopt.json"
1725
- write_json(u, plan.root, rec_path, record) # who and when, before the first removal
1726
- code = 0
1727
- try:
1728
- for src in targets:
1729
- remove_confirmed(u, plan, src["path"], record, table)
1730
- print(" %-9s %-*s [%s] confirmed by %s" % ("delete", width, src["path"], src["tool"], confirm))
1731
- except OSError as exc:
1732
- print("project-update: aborted: %s — %s names what was deleted" % (exc, rec_path), file=sys.stderr)
1733
- code = 3
1734
- write_json(u, plan.root, rec_path, record)
1735
- cleanup_report_md(u, plan.root, where, record)
1736
- print("%d deleted, confirmed by %s%s; originals in %s/original/" % (
1737
- len(record["cleanup"]["deleted"]), confirm,
1738
- " (deleted unattended)" if record["cleanup"]["unattended"] else "", where))
1739
- return code
1740
-
1741
-
1742
- def cleanup_top(rel, tool_roots):
1743
- """The highest directory a cleanup of `rel` may remove once it is empty: a
1744
- directory root (`.specify/`) itself, or the directory a glob root
1745
- (`.claude/skills/speckit-*`) matches — with everything below it, nested
1746
- directories included, and nothing above it. None when `rel` is under no
1747
- root that names a directory."""
1748
- for root in tool_roots:
1749
- if root.endswith("/") and rel.startswith(root):
1750
- return root.rstrip("/")
1751
- globs = [glob_re(r) for r in tool_roots if "*" in r]
1752
- ancestor = os.path.dirname(rel)
1753
- while ancestor:
1754
- if any(rx.match(ancestor) for rx in globs):
1755
- return ancestor
1756
- ancestor = os.path.dirname(ancestor)
1757
- return None
1758
-
1759
-
1760
- def remove_confirmed(u, plan, rel, record, table):
1761
- """Delete one confirmed source: its original kept first (bounded, into the
1762
- record's directory), then the file, then any directory left empty inside
1763
- its tool's roots — so the signature goes with the files."""
1764
- if not safe_rel(rel): # plan_cleanup refused it already; never trust one caller
1765
- raise OSError("refusing to delete %r: not a path inside the project" % rel)
1766
- keep_original_bounded(u, plan, rel, record)
1767
- os.remove(os.path.join(plan.root, rel))
1768
- record["cleanup"]["deleted"].append(rel)
1769
- tool = next((s["tool"] for s in record.get("sources", []) if s["path"] == rel), None)
1770
- top = cleanup_top(rel, table["tools"].get(tool, {}).get("roots", []))
1771
- parent = os.path.dirname(rel)
1772
- while top and parent and (parent == top or parent.startswith(top + "/")):
1773
- full = os.path.join(plan.root, parent)
1774
- if not os.path.isdir(full) or os.listdir(full):
1775
- break
1776
- os.rmdir(full)
1777
- parent = os.path.dirname(parent)
1778
-
1779
-
1780
- def cleanup_report_md(u, root, where, record):
1781
- """report.md gains (or replaces) its Cleanup section."""
1782
- path = os.path.join(root, where, "report.md")
1783
- try:
1784
- with open(path, encoding="utf-8") as fh:
1785
- text = fh.read()
1786
- except OSError:
1787
- text = ""
1788
- text = text.split("\n" + CLEANUP_HEADING + "\n", 1)[0].rstrip("\n") + "\n"
1789
- for line in (REVIEW_LINE, LEGACY_REVIEW_LINE):
1790
- text = text.replace(line, "The deletion is recorded under Cleanup below.")
1791
- c = record["cleanup"]
1792
- runs = list(c.get("history") or []) + [c]
1793
-
1794
- def who(entry):
1795
- return "%s, confirmed by %s%s" % (entry.get("at"), entry.get("confirmed_by"),
1796
- " (deleted unattended)" if entry.get("unattended") else "")
1797
- # deleted is cumulative and not per run, so an earlier interrupted run is
1798
- # named, never credited with a count it cannot prove.
1799
- out = ["", CLEANUP_HEADING, "", "- %d file(s) deleted over %d run(s)" % (len(c["deleted"]), len(runs))]
1800
- out += ["- run at %s" % who(entry) for entry in runs]
1801
- out += ["- `%s`" % p for p in c["deleted"]]
1802
- u.write_file(root, where + "/report.md", (text + "\n".join(out) + "\n").encode("utf-8"))
1803
-
1804
-
1805
- # ---------------------------------------------------------------------------
1806
- # The instruction-file split (R7, R8, R9, R17, R19): a request with no text,
1807
- # a proposal of line ranges, the deterministic fallback, the diff
1808
- # ---------------------------------------------------------------------------
1809
-
1810
- SPLIT_KEYS = frozenset(("version", "source", "source_sha", "keep", "moves", "dropped"))
1811
- MOVE_KEYS = frozenset(("lines", "dest", "heading", "dirs", "paths"))
1812
- DROP_KEYS = frozenset(("lines", "why"))
1813
- ALLOWED_DEST = (".ai/policies/adopted/<slug>.md", ".ai/rules/<slug>.md", ".ai/project/<slug>.md",
1814
- "docs/sdlc/constitution.md")
1815
- ALLOWED_DEST_RE = re.compile(r"\A(?:\.ai/(?:policies/adopted|rules|project)/[a-z0-9][a-z0-9-]*\.md"
1816
- r"|docs/sdlc/constitution\.md)\Z")
1817
-
1818
-
1819
- class SplitError(Exception):
1820
- """A proposal the tool will not apply: exit 4 with this reason."""
1821
-
1822
-
1823
- class Candidate:
1824
- """A root instruction file over `_budgets.skeleton` once its block is the
1825
- shipped one. `nums` are the 1-based lines a split must place: every line
1826
- outside the block, or only the markers left out when the block was edited
1827
- (WP3 R13's case), so the human's edits inside it are split too."""
1828
-
1829
- def __init__(self, path, tool, text, shipped, cap):
1830
- self.path, self.tool, self.text, self.shipped = path, tool, text, shipped
1831
- self.sha = sha256(text.encode("utf-8"))
1832
- self.lines = text.split("\n")
1833
- if self.lines and self.lines[-1] == "":
1834
- self.lines.pop()
1835
- found = render_instructions.find_block(text)
1836
- block = self.block = None
1837
- if found is not None:
1838
- block = text[found[0]:found[1]]
1839
- start = next(i for i, l in enumerate(self.lines, 1) if found[2] in l)
1840
- end = next(i for i, l in enumerate(self.lines, 1) if found[3] in l)
1841
- self.block = (start, end)
1842
- # A legacy pair is compared with its markers renamed: a pristine legacy
1843
- # block is the shipped block, not an edit.
1844
- self.edited = block is not None and render_instructions.current_markers(block, found[2], found[3]) != shipped
1845
- pointer = {n for n, l in enumerate(self.lines, 1) if l == ALWAYS_POINTER}
1846
- if self.block is None:
1847
- self.nums = list(range(1, len(self.lines) + 1))
1848
- elif self.edited:
1849
- self.nums = [n for n in range(1, len(self.lines) + 1) if n not in self.block]
1850
- else:
1851
- self.nums = [n for n in range(1, len(self.lines) + 1) if not self.block[0] <= n <= self.block[1]]
1852
- # A4: the pointer to ALWAYS_FILE is the plugin's own line; a split keeps
1853
- # it where it is (`rewritten`), and it is paid for out of the budget.
1854
- self.nums = [n for n in self.nums if n not in pointer]
1855
- self.keep_budget = cap - len(shipped.encode("utf-8")) - sum(len(ALWAYS_POINTER.encode("utf-8")) + 1
1856
- for _ in pointer)
1857
- self.outline = [{"line": n, "heading": self.lines[n - 1]} for n in self.nums
1858
- if HEADING_RE.match(self.lines[n - 1])]
1859
-
1860
- def line(self, n):
1861
- return self.lines[n - 1]
1862
-
1863
-
1864
- def file_slug(path):
1865
- return slugify(path) # CLAUDE.md -> claude-md, .junie/guidelines.md -> junie-guidelines-md
1866
-
1867
-
1868
- def spans(nums):
1869
- """[3, 4, 5, 9] -> "3-5, 9"."""
1870
- out, nums = [], sorted(nums)
1871
- for n in nums:
1872
- if out and n == out[-1][1] + 1:
1873
- out[-1][1] = n
1874
- else:
1875
- out.append([n, n])
1876
- return ", ".join("%d" % a if a == b else "%d-%d" % (a, b) for a, b in out)
1877
-
1878
-
1879
- def request_name(cand, all_candidates, kind):
1880
- """`split-request.json` / `split-proposal.json` for one candidate, with the
1881
- file slug in the name when several files are split in one adopt."""
1882
- if len(all_candidates) == 1:
1883
- return "split-%s.json" % kind
1884
- return "split-%s-%s.json" % (kind, file_slug(cand.path))
1885
-
1886
-
1887
- def split_request(cand, where, all_candidates):
1888
- """I4: the outline, the allow-list and the keep budget. No text."""
1889
- return OrderedDict([
1890
- ("version", 1), ("source", cand.path), ("source_sha", cand.sha), ("lines", len(cand.lines)),
1891
- ("block", list(cand.block) if cand.block else None), ("block_edited", cand.edited),
1892
- ("cover", spans(cand.nums)), ("outline", cand.outline), ("keep_budget_bytes", cand.keep_budget),
1893
- ("allowed_dest", list(ALLOWED_DEST)),
1894
- ("proposal", "%s/%s" % (where, request_name(cand, all_candidates, "proposal")))])
1895
-
1896
-
1897
- def find_proposal(root, cand, stale=False):
1898
- """(where, data) of a split-proposal*.json in any adopt-* directory whose
1899
- `source` and `source_sha` match the file on disk; newest first. One
1900
- proposal per sha: a match is always reused, never re-requested (R7).
1901
- With `stale`, the newest proposal for this file whatever its sha."""
1902
- base = os.path.join(root, ".ai", "reports")
1903
- try:
1904
- dirs = sorted((d for d in os.listdir(base) if d.startswith("adopt-")), reverse=True)
1905
- except OSError:
1906
- return None, None
1907
- for d in dirs:
1908
- try:
1909
- names = sorted(n for n in os.listdir(os.path.join(base, d))
1910
- if n.startswith("split-proposal") and n.endswith(".json"))
1911
- except OSError:
1912
- continue
1913
- for name in names:
1914
- try:
1915
- with open(os.path.join(base, d, name), encoding="utf-8") as fh:
1916
- data = json.load(fh)
1917
- except (OSError, ValueError, RecursionError):
1918
- continue
1919
- if isinstance(data, dict) and data.get("source") == cand.path and (stale or data.get("source_sha") == cand.sha):
1920
- return ".ai/reports/%s/%s" % (d, name), data
1921
- return None, None
1922
-
1923
-
1924
- def _range(value, cand, what):
1925
- if (not isinstance(value, list) or len(value) != 2 or not all(isinstance(v, int) and not isinstance(v, bool)
1926
- for v in value)):
1927
- raise SplitError("%s: a range is [first, last], two line numbers" % what)
1928
- first, last = value
1929
- if not 1 <= first <= last <= len(cand.lines):
1930
- raise SplitError("%s: range [%d, %d] is outside lines 1-%d" % (what, first, last, len(cand.lines)))
1931
- return list(range(first, last + 1))
1932
-
1933
-
1934
- def _strings(value, what):
1935
- if not isinstance(value, list) or not value or not all(isinstance(v, str) and v for v in value):
1936
- raise SplitError("%s must be a non-empty list of strings" % what)
1937
- return value
1938
-
1939
-
1940
- def load_proposal(data, cand, root, tracked):
1941
- """Validate an I5 proposal against the file on disk. Returns
1942
- (keep nums, moves, dropped) where each move is a dict with `nums`; raises
1943
- SplitError naming the first reason it cannot be applied. No key carries
1944
- text into a destination: `heading` must be empty or an outline heading,
1945
- byte for byte (plan-review MEDIUM 14)."""
1946
- if not isinstance(data, dict):
1947
- raise SplitError("the proposal is not a JSON object")
1948
- extra = sorted(set(data) - SPLIT_KEYS)
1949
- if extra:
1950
- raise SplitError("unknown key(s) %s — a proposal carries line ranges, never text" % ", ".join(extra))
1951
- if data.get("version") != 1:
1952
- raise SplitError("version must be 1")
1953
- if data.get("source") != cand.path:
1954
- raise SplitError("source is %r, not %s" % (data.get("source"), cand.path))
1955
- if data.get("source_sha") != cand.sha:
1956
- raise SplitError("source_sha does not match %s on disk — ask for a new --split-request" % cand.path)
1957
- covered = {}
1958
-
1959
- def claim(nums, what):
1960
- for n in nums:
1961
- if n in covered:
1962
- raise SplitError("line %d is in both %s and %s" % (n, covered[n], what))
1963
- if n not in cand_nums:
1964
- raise SplitError("%s: line %d is %s" % (what, n, "a managed-block marker" if cand.edited
1965
- else "inside the managed block"))
1966
- covered[n] = what
1967
- return nums
1968
- cand_nums = set(cand.nums)
1969
- for key in ("keep", "moves", "dropped"):
1970
- if not isinstance(data.get(key, []), list):
1971
- raise SplitError("%s must be a list" % key)
1972
- keep = []
1973
- for i, rng in enumerate(data.get("keep", []), 1):
1974
- keep += claim(_range(rng, cand, "keep[%d]" % i), "keep[%d]" % i)
1975
- headings = {o["heading"] for o in cand.outline}
1976
- moves = []
1977
- for i, move in enumerate(data.get("moves", []), 1):
1978
- what = "moves[%d]" % i
1979
- if not isinstance(move, dict):
1980
- raise SplitError("%s is not an object" % what)
1981
- extra = sorted(set(move) - MOVE_KEYS)
1982
- if extra:
1983
- raise SplitError("%s: unknown key(s) %s — a move carries line ranges, never text" % (what, ", ".join(extra)))
1984
- dest = move.get("dest")
1985
- if not isinstance(dest, str) or not ALLOWED_DEST_RE.match(dest):
1986
- raise SplitError("%s: dest %r is not one of %s" % (what, dest, ", ".join(ALLOWED_DEST)))
1987
- heading = move.get("heading") or ""
1988
- if not isinstance(heading, str) or (heading and heading not in headings):
1989
- raise SplitError("%s: heading %r is not a heading of the request's outline" % (what, heading))
1990
- rule = dest.startswith(".ai/rules/")
1991
- dirs, paths = [], []
1992
- if rule:
1993
- dirs = _strings(move.get("dirs"), "%s: dirs (required for .ai/rules/)" % what)
1994
- for d in dirs:
1995
- d = d.rstrip("/")
1996
- if d.startswith("/") or ".." in d.split("/") or not os.path.isdir(os.path.join(root, d)):
1997
- raise SplitError("%s: dirs entry %r is not a directory of the tree" % (what, d))
1998
- if "paths" in move:
1999
- paths = _strings(move["paths"], "%s: paths" % what)
2000
- for p in paths:
2001
- pat = glob_re(p)
2002
- if not any(pat.match(f) for f in tracked or ()):
2003
- raise SplitError("%s: paths glob %r matches no tracked file" % (what, p))
2004
- elif "dirs" in move or "paths" in move:
2005
- raise SplitError("%s: dirs and paths belong to an .ai/rules/ destination only" % what)
2006
- nums = claim(_range(move.get("lines"), cand, what), what)
2007
- moves.append({"nums": nums, "dest": dest, "heading": heading, "dirs": [d.rstrip("/") for d in dirs],
2008
- "paths": paths})
2009
- dropped = []
2010
- for i, drop in enumerate(data.get("dropped", []), 1):
2011
- what = "dropped[%d]" % i
2012
- if not isinstance(drop, dict):
2013
- raise SplitError("%s is not an object" % what)
2014
- extra = sorted(set(drop) - DROP_KEYS)
2015
- if extra:
2016
- raise SplitError("%s: unknown key(s) %s" % (what, ", ".join(extra)))
2017
- why = drop.get("why")
2018
- if not isinstance(why, str) or not why.strip():
2019
- raise SplitError("%s: why must say why the lines are dropped" % what)
2020
- dropped.append({"nums": claim(_range(drop.get("lines"), cand, what), what), "why": why.strip()})
2021
- gap = cand_nums - set(covered)
2022
- if gap:
2023
- raise SplitError("lines %s are in no range — every line outside the block is kept, moved or dropped"
2024
- % spans(gap))
2025
- kept = sum(len(cand.line(n).encode("utf-8")) + 1 for n in keep)
2026
- if kept > cand.keep_budget:
2027
- raise SplitError("keep is %d B, over the keep budget of %d B" % (kept, cand.keep_budget))
2028
- return sorted(keep), moves, dropped
2029
-
2030
-
2031
- def fallback_split(cand):
2032
- """R8's deterministic split: every line to place goes, verbatim, to one
2033
- `.ai/policies/adopted/<file-slug>.md`. No model."""
2034
- return [], [{"nums": list(cand.nums), "dest": ".ai/policies/adopted/%s.md" % file_slug(cand.path),
2035
- "heading": "", "dirs": [], "paths": []}], []
2036
-
2037
-
2038
- def rewritten(cand, keep):
2039
- """The instruction file after the split: the kept lines where they were,
2040
- the shipped block in place of the old one (at the end when there was none)."""
2041
- keep = set(keep)
2042
- out, inside = [], []
2043
- for n, line in enumerate(cand.lines, 1):
2044
- if cand.block and n == cand.block[0]:
2045
- out.append(cand.shipped)
2046
- elif cand.block and cand.block[0] < n <= cand.block[1]:
2047
- if n in keep:
2048
- inside.append(line) # an edited block's kept lines follow the shipped block
2049
- if n == cand.block[1]:
2050
- out += inside
2051
- elif n in keep or line == ALWAYS_POINTER:
2052
- out.append(line)
2053
- if cand.block is None:
2054
- while out and not out[-1].strip():
2055
- out.pop()
2056
- out += ([""] if out else []) + [cand.shipped]
2057
- return "\n".join(out).strip("\n") + "\n"
2058
-
2059
-
2060
- def section_of(cand, move):
2061
- body = "\n".join(cand.line(n) for n in move["nums"]).strip("\n")
2062
- heading = move["heading"] or "## Adopted from %s, lines %s" % (cand.path, spans(move["nums"]))
2063
- first = body.split("\n", 1)[0]
2064
- return (body if first == heading else heading + "\n\n" + body), heading
2065
-
2066
-
2067
- def plan_split(plan, adoption, cand, row_n, how, keep, moves, dropped, proposal_sha=None):
2068
- """Put one split on `plan`: the destinations first, then the rewrite of
2069
- the instruction file (apply's phase 2, so no line leaves before it has
2070
- somewhere to be)."""
2071
- tag = "[%s] split (%s)" % (cand.tool, how)
2072
- generated = set()
2073
- by_dest = OrderedDict()
2074
- for move in moves:
2075
- by_dest.setdefault(move["dest"], []).append(move)
2076
- for dest, group in by_dest.items():
2077
- sections = []
2078
- for move in group:
2079
- text, heading = section_of(cand, move)
2080
- sections.append(text)
2081
- generated.add(normalise(heading))
2082
- body = "\n\n".join(sections)
2083
- current = plan.read(dest)
2084
- adoption.split_before.setdefault(dest, current)
2085
- current_text = text_of(current or b"")
2086
- ranges = spans([n for m in group for n in m["nums"]])
2087
- if body in current_text:
2088
- adoption.split_dests.append(dest) # already there: a resumed apply
2089
- continue
2090
- if dest.startswith(".ai/rules/"):
2091
- dirs = list(OrderedDict.fromkeys(d for m in group for d in m["dirs"]))
2092
- paths = list(OrderedDict.fromkeys(p for m in group for p in m["paths"]))
2093
- front = "---\ndirs: [%s]\n" % ", ".join(dirs)
2094
- front += ("paths: [%s]\n" % ", ".join('"%s"' % p for p in paths)) if paths else ""
2095
- front += "---\n"
2096
- generated.update(normalise(l) for l in front.split("\n"))
2097
- content = front + "\n" + body + "\n"
2098
- if current is not None:
2099
- plan.add("conflict", dest, note=tag + "; the rule exists and differs", src=cand.path, tool=cand.tool)
2100
- continue
2101
- else:
2102
- content = (current_text.rstrip("\n") + "\n\n" if current_text.strip() else "") + body + "\n"
2103
- plan.add("adopt", dest, note="%s lines %s" % (tag, ranges), content=content.encode("utf-8"),
2104
- src=cand.path, tool=cand.tool)
2105
- adoption.split_dests.append(dest)
2106
- if dest.startswith(".ai/policies/adopted/"):
2107
- adoption.destinations.append((dest, cand.tool, None, False))
2108
- for drop in dropped:
2109
- for n in drop["nums"]:
2110
- if normalise(cand.line(n)) is not None:
2111
- adoption.dropped.append({"source": cand.path, "line": n, "text": cand.line(n),
2112
- "reason": drop["why"], "by": "proposal"})
2113
- result = rewritten(cand, keep)
2114
- generated.update(normalise(l) for l in cand.shipped.split("\n"))
2115
- adoption.split_before.setdefault(cand.path, plan.read(cand.path))
2116
- if result.encode("utf-8") != plan.read(cand.path):
2117
- plan.add("adopt", cand.path, note="%s: %d B kept, managed block re-rendered" % (tag, len(result.encode("utf-8"))),
2118
- content=result.encode("utf-8"), src=cand.path, tool=cand.tool)
2119
- adoption.split_dests.append(cand.path)
2120
- if all(s[0] != cand.path for s in adoption.sources):
2121
- adoption.sources.append((cand.path, cand.tool, "instruction-file", row_n, False))
2122
- adoption.split_done[cand.path] = {
2123
- "by": how, "proposal_sha": proposal_sha, "kept_bytes": sum(len(cand.line(n).encode("utf-8")) + 1 for n in keep),
2124
- "moves": len(moves), "dropped": len(dropped), "text": cand.text, "nums": cand.nums, "generated": generated}
2125
-
2126
-
2127
- def check_added(plan, adoption):
2128
- """R9 for a split, the other direction of I7: every non-ignorable line of
2129
- a file the split writes is a source line, a generated line (a heading,
2130
- frontmatter, the shipped block) or content the file already had.
2131
- Returns the offending "file:line"s."""
2132
- if not adoption.split_done:
2133
- return []
2134
- allowed = {normalise(ALWAYS_POINTER)} # A4: the plugin's own line, generated like the block
2135
- for done in adoption.split_done.values():
2136
- allowed.update(normalise(l) for l in done["text"].split("\n"))
2137
- allowed.update(done["generated"])
2138
- bad = []
2139
- for target in OrderedDict.fromkeys(adoption.split_dests):
2140
- before = {normalise(l) for l in text_of(adoption.split_before.get(target) or b"").split("\n")}
2141
- for i, raw in enumerate(text_of(plan.read(target) or b"").split("\n"), 1):
2142
- n = normalise(raw)
2143
- if n is not None and n not in allowed and n not in before:
2144
- bad.append("%s:%d" % (target, i))
2145
- return bad
2146
-
2147
-
2148
- def plan_splits(plan, adoption, candidates, split_mode, decisions):
2149
- """Plan every candidate by proposal or fallback; what cannot be planned
2150
- stays `split?` (awaiting) or goes to `split_errors` (exit 4)."""
2151
- tracked = None
2152
- fallback = decisions.get("split")
2153
- for cand, row_n in candidates:
2154
- how = split_mode
2155
- wants_fallback = fallback == "fallback" or (isinstance(fallback, dict) and fallback.get(cand.path) == "fallback")
2156
- where, data = (None, None) if how == "fallback" else find_proposal(plan.root, cand)
2157
- if how is None:
2158
- how = "proposal" if data is not None else ("fallback" if wants_fallback else None)
2159
- if how is None:
2160
- where, data = find_proposal(plan.root, cand, stale=True)
2161
- how = "proposal" if data is not None else None # stale: validation names the sha
2162
- if how is None:
2163
- adoption.split.append(cand.path)
2164
- adoption.note("split?", cand.path, "%d B outside the block, keep budget %d B; needs --adopt "
2165
- "--split-request or --split fallback"
2166
- % (sum(len(cand.line(n).encode("utf-8")) + 1 for n in cand.nums), cand.keep_budget))
2167
- continue
2168
- if how == "fallback":
2169
- plan_split(plan, adoption, cand, row_n, "fallback", *fallback_split(cand))
2170
- continue
2171
- if data is None:
2172
- adoption.split_errors.append((cand.path, "--split proposal, and no split-proposal.json matches "
2173
- "its sha — run --adopt --split-request"))
2174
- continue
2175
- if tracked is None:
2176
- tracked = git_files(plan.root) or []
2177
- try:
2178
- keep, moves, dropped = load_proposal(data, cand, plan.root, tracked)
2179
- except SplitError as exc:
2180
- adoption.split_errors.append((cand.path, "%s: %s" % (where, exc)))
2181
- continue
2182
- with open(os.path.join(plan.root, where), "rb") as fh:
2183
- proposal_sha = sha256(fh.read())
2184
- plan_split(plan, adoption, cand, row_n, "proposal", keep, moves, dropped, proposal_sha)
2185
- bad = check_added(plan, adoption)
2186
- if bad:
2187
- adoption.split_errors.append((", ".join(sorted(adoption.split_done)),
2188
- "no-line-added FAIL: %d line(s) come from no source: %s"
2189
- % (len(bad), ", ".join(bad[:20]))))
2190
-
2191
-
2192
- def write_requests(plan, candidates):
2193
- """`--adopt --split-request`: one I4 file per candidate with no matching
2194
- proposal, into today's record. Writes nothing else. Returns the exit code."""
2195
- if not candidates:
2196
- print("project-update: no split candidate — nothing to request")
2197
- return 0
2198
- where = report_dir(plan)
2199
- for cand, _row in candidates:
2200
- found, _data = find_proposal(plan.root, cand)
2201
- if found:
2202
- print(" split %s a proposal for this sha exists, reused: %s" % (cand.path, found))
2203
- continue
2204
- rel = "%s/%s" % (where, request_name(cand, [c for c, _ in candidates], "request"))
2205
- full = os.path.join(plan.root, rel)
2206
- os.makedirs(os.path.dirname(full), exist_ok=True)
2207
- with open(full, "w", encoding="utf-8") as fh:
2208
- json.dump(split_request(cand, where, [c for c, _ in candidates]), fh, indent=2, ensure_ascii=False)
2209
- fh.write("\n")
2210
- print(" split %s request written: %s (the session writes the proposal next to it)" % (cand.path, rel))
2211
- return 0
2212
-
2213
-
2214
- def print_diff(plan):
2215
- """`--adopt --diff`: one unified diff per target this run would write."""
2216
- import difflib # pylint: disable=import-outside-toplevel
2217
- for target in OrderedDict.fromkeys(i["target"] for i in plan.items if i["content"] is not None):
2218
- path = os.path.join(plan.root, target)
2219
- before = ""
2220
- if os.path.isfile(path):
2221
- with open(path, "rb") as fh:
2222
- before = fh.read().decode("utf-8", "replace")
2223
- after = text_of(plan.final.get(target, b""))
2224
- sys.stdout.writelines(difflib.unified_diff(before.splitlines(True), after.splitlines(True),
2225
- "a/" + target, "b/" + target))