riskrails 4.1.0 → 4.2.0
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +11 -11
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- package/hooks/guard/paths.py +48 -1406
- package/hooks/guard/paths_base.py +59 -0
- package/hooks/guard/paths_bash.py +176 -0
- package/hooks/guard/paths_classify.py +205 -0
- package/hooks/guard/paths_fs.py +137 -0
- package/hooks/guard/paths_human.py +105 -0
- package/hooks/guard/paths_interp.py +162 -0
- package/hooks/guard/paths_movers.py +393 -0
- package/hooks/guard/paths_shell.py +288 -0
- package/package.json +1 -1
- package/skills/AGENTS.md +0 -6
- package/skills/CLAUDE.md +0 -6
- package/skills/ai-init/templates/gitignore.snippet +2 -0
- package/skills/ai-task/memory_cleanup.py +213 -0
- package/skills/ai-task/memory_compact.py +25 -0
- package/skills/ai-task/memory_config.py +120 -0
- package/skills/ai-task/memory_export.py +246 -0
- package/skills/ai-task/memory_format.py +7 -3
- package/skills/ai-task/memory_hook.py +172 -0
- package/skills/ai-task/memory_io.py +122 -0
- package/skills/ai-task/memory_migrate.py +187 -0
- package/skills/ai-task/memory_packages.py +83 -0
- package/skills/ai-task/memory_risks.py +84 -0
- package/skills/ai-task/memory_status.py +76 -0
- package/skills/ai-task/memory_sync.py +20 -1481
- package/skills/ai-task/memory_verify.py +316 -0
- package/skills/ai-task/rr_state/cli.py +1 -0
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/memory_cmd.py +84 -64
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +2 -1
- package/skills/project-update/rr_update/__init__.py +1 -0
- package/skills/project-update/rr_update/apply.py +188 -0
- package/skills/project-update/rr_update/blocks.py +38 -0
- package/skills/project-update/rr_update/build.py +96 -0
- package/skills/project-update/rr_update/cleanup.py +194 -0
- package/skills/project-update/rr_update/cli.py +102 -0
- package/skills/project-update/rr_update/files.py +106 -0
- package/skills/project-update/rr_update/gate.py +60 -0
- package/skills/project-update/rr_update/hints.py +128 -0
- package/skills/project-update/rr_update/history.py +117 -0
- package/skills/project-update/rr_update/kept.py +120 -0
- package/skills/project-update/rr_update/paths.py +108 -0
- package/skills/project-update/rr_update/plan.py +123 -0
- package/skills/project-update/rr_update/report.py +51 -0
- package/skills/project-update/rr_update/rules.py +178 -0
- package/skills/project-update/rr_update/shared.py +74 -0
- package/skills/project-update/rr_update/siblings.py +37 -0
- package/skills/project-update/rr_update/weights.py +171 -0
- package/skills/project-update/update.py +8 -1682
- package/skills/rr-setup/SKILL.md +4 -2
- package/skills/rr-setup/setup.py +13 -12
|
@@ -45,1689 +45,15 @@ A project uses the runtime(s) it declares: CLAUDE.md or .claude/ means Claude
|
|
|
45
45
|
Code, AGENTS.md or .codex/ means Codex, both means both. A project that declares
|
|
46
46
|
neither gets the runtime this copy of the plugin was installed for.
|
|
47
47
|
"""
|
|
48
|
-
import
|
|
49
|
-
|
|
50
|
-
from datetime import datetime, timedelta, timezone
|
|
51
|
-
|
|
52
|
-
try:
|
|
53
|
-
import tomllib
|
|
54
|
-
except ImportError: # Python before 3.11: .codex/config.toml is not checked
|
|
55
|
-
tomllib = None
|
|
48
|
+
import os
|
|
49
|
+
import sys
|
|
56
50
|
|
|
57
51
|
sys.dont_write_bytecode = True # never leave __pycache__ in an installed plugin
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
EXIT_KEPT_UNREADABLE = 6
|
|
64
|
-
REFUSED = "CONFIRM_REFUSED"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class SchemaError(Exception):
|
|
68
|
-
"""The project's schema cannot be read or is newer than this plugin."""
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def env(name, default=None):
|
|
72
|
-
"""RISKRAILS_<name>."""
|
|
73
|
-
return os.environ.get("RISKRAILS_" + name, default)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
TEMPLATES = {
|
|
77
|
-
"ai-init": env("TEMPLATES", os.path.join(SKILLS, "ai-init", "templates")),
|
|
78
|
-
"project-init": os.environ.get("CLAUDE_ROUTING_TEMPLATES", os.path.join(SKILLS, "project-init", "templates")),
|
|
79
|
-
}
|
|
80
|
-
HISTORY = env("HISTORY", os.path.join(HERE, "history"))
|
|
81
|
-
# The shared tree every project reads unless it keeps its own copy: installed
|
|
82
|
-
# beside the skills as riskrails/ai, or ai/ at the root of a checkout.
|
|
83
|
-
PLUGIN_HOME = os.path.dirname(SKILLS)
|
|
84
|
-
SHARED = env("SHARED") or next(
|
|
85
|
-
(p for p in (os.path.join(PLUGIN_HOME, "riskrails", "ai"), os.path.join(PLUGIN_HOME, "ai"))
|
|
86
|
-
if os.path.isdir(p)), os.path.join(PLUGIN_HOME, "riskrails", "ai"))
|
|
87
|
-
VERSION_FILE = ".ai/VERSION"
|
|
88
|
-
ROUTER_FILE = ".ai/AGENTS.md"
|
|
89
|
-
STALE_DAYS = 7 # an unclosed task whose last event is older is proposed for cleanup
|
|
90
|
-
STATE_FILE = ".ai/state/state.json"
|
|
91
|
-
TESTING_MD = ".ai/policies/testing.md"
|
|
92
|
-
VERIFICATION_MD = ".ai/policies/verification.md"
|
|
93
|
-
WEIGHT_JSON, WEIGHT_MD = ".ai/policies/weight.json", ".ai/policies/weight.md"
|
|
94
|
-
WEIGHT_PROJECT = ".ai/policies/weight.project.json"
|
|
95
|
-
LOCAL_CACHE = ".ai/local/plugin-update"
|
|
96
|
-
|
|
97
|
-
# D2. GENERATED targets copied from a template as they are. weight.md is rendered,
|
|
98
|
-
# .ai/VERSION is written last, the managed blocks and the runtimes' memory READMEs
|
|
99
|
-
# come from the maps below.
|
|
100
|
-
GENERATED = (ROUTER_FILE, ".ai/policies/path-guard.json", ".ai/policies/coding.md", ".ai/policies/safety.md",
|
|
101
|
-
".ai/policies/security.md", TESTING_MD, ".ai/rules/README.md", ".ai/state/README.md")
|
|
102
|
-
# Template files created when missing and never touched again: the project's own from then on.
|
|
103
|
-
CREATE_ONLY = (VERIFICATION_MD, WEIGHT_PROJECT, ".ai/reports/.gitkeep")
|
|
104
|
-
CREATE_ONLY_DIRS = (".ai/project/",)
|
|
105
|
-
# Template files that are not copied: written last, the plugin's own table, the human's switch.
|
|
106
|
-
NOT_COPIED = (VERSION_FILE, WEIGHT_JSON, WEIGHT_MD, ".ai/policies/dialog.json")
|
|
107
|
-
KEPT = (".ai/project/", ".ai/reports/", ".ai/memory/", ".ai/state/", "docs/sdlc/", ".ai/local/memory/",
|
|
108
|
-
WEIGHT_PROJECT, VERIFICATION_MD, ".ai/policies/dialog.json", ".ai/policies/memory.json",
|
|
109
|
-
".claude/settings.json", ".codex/config.toml", ".gitignore")
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def is_kept(path):
|
|
113
|
-
"""True for a path update.py never rewrites. A GENERATED path under a KEPT
|
|
114
|
-
directory (.ai/state/README.md) is generated: the two sets are disjoint."""
|
|
115
|
-
if path in GENERATED:
|
|
116
|
-
return False
|
|
117
|
-
return any(path == k or (k.endswith("/") and path.startswith(k)) for k in KEPT)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
# The shared set, literally: the plugin's tree must equal it (tested). A project
|
|
121
|
-
# copy identical to a shipped version is offered for deletion; an edited one is an override.
|
|
122
|
-
SHARED_SET = frozenset((
|
|
123
|
-
".ai/policies/production.md", ".ai/policies/database.md", ".ai/policies/git.md", ".ai/policies/release.md",
|
|
124
|
-
".ai/policies/context-management.md", ".ai/policies/tooling.md", ".ai/policies/model-routing.md",
|
|
125
|
-
".ai/policies/review-economy.md",
|
|
126
|
-
".ai/agents/context.md", ".ai/agents/discovery.md", ".ai/agents/implementer.md",
|
|
127
|
-
".ai/agents/planner.md", ".ai/agents/release.md", ".ai/agents/reviewer.md", ".ai/agents/risk.md",
|
|
128
|
-
".ai/agents/security.md", ".ai/agents/tester.md",
|
|
129
|
-
".ai/templates/task-context.md", ".ai/templates/impact-report.md", ".ai/templates/review-report.md",
|
|
130
|
-
".ai/templates/release-report.md", ".ai/templates/architecture-decision.md",
|
|
131
|
-
".ai/templates/implementation-plan.md",
|
|
132
|
-
".ai/using-riskrails.md", ".ai/workflows.md",
|
|
133
|
-
))
|
|
134
|
-
RULES_DIR = ".ai/rules"
|
|
135
|
-
# Directories a stale rule block is never hunted in: a dependency's instruction
|
|
136
|
-
# file is not ours (the path guard refuses it too), and .git is not text.
|
|
137
|
-
# A child holding a .git entry (worktree, nested repository, submodule) is pruned
|
|
138
|
-
# too, by the take-back walk (rendered_rules) and by the writer (nested_tree). The
|
|
139
|
-
# writer also skips a RULES_SKIP name or a symlinked directory (unwalked_dir), so it
|
|
140
|
-
# never writes where the take-back never walks.
|
|
141
|
-
RULES_SKIP = {".git", ".ai", "vendor", "node_modules", "bower_components",
|
|
142
|
-
"third_party", "Pods", "site-packages", ".venv", ".pnpm", ".yarn"}
|
|
143
|
-
# Compared case-insensitively: on a case-insensitive filesystem `Vendor/` is `vendor/`.
|
|
144
|
-
RULES_SKIP_FOLDED = frozenset(n.casefold() for n in RULES_SKIP)
|
|
145
|
-
find_block = render_instructions.find_block
|
|
146
|
-
|
|
147
|
-
RUNTIME_MAP = { # the per-runtime layer of the project-init scaffold: template, target, kind
|
|
148
|
-
"claude": [("memory-README.md", ".claude/memory/README.md", "regenerate"),
|
|
149
|
-
("project-settings.json", ".claude/settings.json", "create")],
|
|
150
|
-
"codex": [("memory-README.md", ".codex/memory/README.md", "regenerate"),
|
|
151
|
-
("project-config.toml", ".codex/config.toml", "create")],
|
|
152
|
-
"gemini": [("memory-README.md", ".gemini/memory/README.md", "regenerate")],
|
|
153
|
-
"junie": [("memory-README.md", ".junie/memory/README.md", "regenerate")],
|
|
154
|
-
}
|
|
155
|
-
SDLC_CREATE = (("sdlc-README.md", "docs/sdlc/README.md"), ("constitution.md", "docs/sdlc/constitution.md"))
|
|
156
|
-
INSTRUCTION_FILE = { # runtime -> (file, block template, minimal template)
|
|
157
|
-
"claude": ("CLAUDE.md", "CLAUDE.block.md", "CLAUDE.minimal.md"),
|
|
158
|
-
"codex": ("AGENTS.md", "AGENTS.block.md", "AGENTS.minimal.md"),
|
|
159
|
-
"gemini": ("GEMINI.md", "GEMINI.block.md", "GEMINI.minimal.md"),
|
|
160
|
-
"junie": (".junie/guidelines.md", "junie-guidelines.block.md",
|
|
161
|
-
"junie-guidelines.minimal.md"),
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def project_runtimes(root):
|
|
166
|
-
"""The runtimes a project declares, or the one this plugin copy serves."""
|
|
167
|
-
out = []
|
|
168
|
-
if os.path.exists(os.path.join(root, "CLAUDE.md")) or os.path.isdir(os.path.join(root, ".claude")):
|
|
169
|
-
out.append("claude")
|
|
170
|
-
if os.path.exists(os.path.join(root, "AGENTS.md")) or os.path.isdir(os.path.join(root, ".codex")):
|
|
171
|
-
out.append("codex")
|
|
172
|
-
if os.path.exists(os.path.join(root, "GEMINI.md")) or os.path.isdir(os.path.join(root, ".gemini")):
|
|
173
|
-
out.append("gemini")
|
|
174
|
-
if os.path.isdir(os.path.join(root, ".junie")):
|
|
175
|
-
out.append("junie")
|
|
176
|
-
if not out:
|
|
177
|
-
out.append("codex" if "/.codex/" in HERE + "/" else "claude")
|
|
178
|
-
return out
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def utc_now():
|
|
182
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def sha(data):
|
|
186
|
-
return hashlib.sha256(data).hexdigest()
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def read(path):
|
|
190
|
-
with open(path, "rb") as fh:
|
|
191
|
-
return fh.read()
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
class History:
|
|
195
|
-
"""index.json answers "was this ever shipped" from hashes alone; the blobs
|
|
196
|
-
give the one-time split its base values."""
|
|
197
|
-
|
|
198
|
-
def __init__(self, root):
|
|
199
|
-
try:
|
|
200
|
-
with open(os.path.join(root, "index.json"), encoding="utf-8") as fh:
|
|
201
|
-
self.index = json.load(fh)
|
|
202
|
-
except (OSError, ValueError, RecursionError):
|
|
203
|
-
self.index = {}
|
|
204
|
-
self.root = root
|
|
205
|
-
|
|
206
|
-
def versions(self, key):
|
|
207
|
-
"""Every shipped version of a template, oldest first, as bytes."""
|
|
208
|
-
out = []
|
|
209
|
-
for h in self.index.get(key, []):
|
|
210
|
-
try:
|
|
211
|
-
out.append(read(os.path.join(self.root, "blobs", h)))
|
|
212
|
-
except OSError:
|
|
213
|
-
pass
|
|
214
|
-
return out
|
|
215
|
-
|
|
216
|
-
def hashes(self, key):
|
|
217
|
-
"""The sha of every version shipped under this key; a version whose blob is gone still counts."""
|
|
218
|
-
return set(self.index.get(key, []))
|
|
219
|
-
|
|
220
|
-
def shipped(self, key, data):
|
|
221
|
-
"""True when these bytes are a version the plugin shipped, also after a checkout turned its line endings into CRLF."""
|
|
222
|
-
known = self.hashes(key)
|
|
223
|
-
return sha(data) in known or sha(data.replace(b"\r\n", b"\n")) in known
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def shared_files():
|
|
227
|
-
"""{".ai/<rel>": bytes} for every shared file the shared tree holds; empty when the tree is not installed."""
|
|
228
|
-
out = {}
|
|
229
|
-
for path in sorted(SHARED_SET):
|
|
230
|
-
full = os.path.join(SHARED, path[len(".ai/"):])
|
|
231
|
-
if os.path.isfile(full):
|
|
232
|
-
out[path] = read(full)
|
|
233
|
-
return out
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def undeclared_shared():
|
|
237
|
-
"""Files in the shared tree outside the shared set, as .ai/ paths."""
|
|
238
|
-
out = []
|
|
239
|
-
for here, _dirs, files in os.walk(SHARED):
|
|
240
|
-
for name in files:
|
|
241
|
-
rel = ".ai/" + os.path.relpath(os.path.join(here, name), SHARED).replace(os.sep, "/")
|
|
242
|
-
if rel not in SHARED_SET:
|
|
243
|
-
out.append(rel)
|
|
244
|
-
return sorted(out)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def git_dirty_ai(root):
|
|
248
|
-
"""True when .ai/ has changes git does not hold yet: git is the rollback of
|
|
249
|
-
a migration that deletes, so it must hold them first. False outside a work
|
|
250
|
-
tree, where there is nothing to say."""
|
|
251
|
-
try:
|
|
252
|
-
res = subprocess.run(["git", "-C", root, "status", "--porcelain", "--", ".ai"],
|
|
253
|
-
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30, check=False)
|
|
254
|
-
except (OSError, subprocess.SubprocessError):
|
|
255
|
-
return False
|
|
256
|
-
return res.returncode == 0 and bool(res.stdout.strip())
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def git_states(root, rel_dir):
|
|
260
|
-
"""A function path -> how git holds a file under `rel_dir` (project-relative):
|
|
261
|
-
"tracked" (committed and unchanged), "uncommitted" (changed, staged or not yet
|
|
262
|
-
tracked, so git has no copy to restore) or "ignored"; None outside a work tree
|
|
263
|
-
or for a path git does not know."""
|
|
264
|
-
def run(*args):
|
|
265
|
-
res = subprocess.run(["git", "-C", root] + list(args), stdout=subprocess.PIPE,
|
|
266
|
-
stderr=subprocess.DEVNULL, timeout=30, check=False)
|
|
267
|
-
return res.stdout.decode("utf-8", "replace") if res.returncode == 0 else None
|
|
268
|
-
try:
|
|
269
|
-
prefix = run("rev-parse", "--show-prefix")
|
|
270
|
-
tracked = run("ls-files", "-z", "--", rel_dir)
|
|
271
|
-
status = run("status", "--porcelain=v1", "-z", "--ignored", "-uall", "--", rel_dir)
|
|
272
|
-
except (OSError, subprocess.SubprocessError):
|
|
273
|
-
return lambda path: None
|
|
274
|
-
if prefix is None or tracked is None or status is None:
|
|
275
|
-
return lambda path: None
|
|
276
|
-
prefix = prefix.strip()
|
|
277
|
-
tracked = set(tracked.split("\0"))
|
|
278
|
-
dirty, ignored = set(), []
|
|
279
|
-
entries = status.split("\0")
|
|
280
|
-
skip = False
|
|
281
|
-
for entry in entries:
|
|
282
|
-
if skip: # the origin path of a rename
|
|
283
|
-
skip = False
|
|
284
|
-
continue
|
|
285
|
-
if len(entry) < 4:
|
|
286
|
-
continue
|
|
287
|
-
xy, path = entry[:2], entry[3:]
|
|
288
|
-
if prefix and path.startswith(prefix):
|
|
289
|
-
path = path[len(prefix):]
|
|
290
|
-
if "R" in xy or "C" in xy:
|
|
291
|
-
skip = True
|
|
292
|
-
if xy == "!!":
|
|
293
|
-
ignored.append(path)
|
|
294
|
-
else:
|
|
295
|
-
dirty.add(path)
|
|
296
|
-
|
|
297
|
-
def classify(path):
|
|
298
|
-
if path in dirty:
|
|
299
|
-
return "uncommitted"
|
|
300
|
-
if any(path == i or (i.endswith("/") and path.startswith(i)) for i in ignored):
|
|
301
|
-
return "ignored"
|
|
302
|
-
return "tracked" if path in tracked else None
|
|
303
|
-
return classify
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
def dump_json(value):
|
|
307
|
-
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def unsafe_path(root, path):
|
|
311
|
-
"""Why a project-relative path must not be deleted: a symlink on the way to it,
|
|
312
|
-
or a real path outside the project; None when it is safe. Run at plan time and
|
|
313
|
-
again right before the delete."""
|
|
314
|
-
real_root = os.path.realpath(root)
|
|
315
|
-
here = root
|
|
316
|
-
for part in path.split("/"):
|
|
317
|
-
here = os.path.join(here, part)
|
|
318
|
-
if os.path.islink(here):
|
|
319
|
-
return "%s is a symlink" % os.path.relpath(here, root).replace(os.sep, "/")
|
|
320
|
-
real = os.path.realpath(os.path.join(root, path))
|
|
321
|
-
if real != real_root and not real.startswith(real_root + os.sep):
|
|
322
|
-
return "%s resolves outside the project (%s)" % (path, real)
|
|
323
|
-
return None
|
|
324
|
-
|
|
325
|
-
# ---------------------------------------------------------------- the one gate
|
|
326
|
-
# Set in the environment of a shell an agent runs: Claude Code's CLAUDECODE,
|
|
327
|
-
# and the names Codex gives its tool calls. Present counts, an empty value too.
|
|
328
|
-
AGENT_MARKERS = ("CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CODEX_CI",
|
|
329
|
-
"CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_VERSION")
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
def ai_unattended():
|
|
333
|
-
"""AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
|
|
334
|
-
and blanks ignored): one rule in every reader (risk 0587)."""
|
|
335
|
-
return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def human_gate():
|
|
339
|
-
"""(passed, via, why) for --confirm-delete. A terminal outside any agent
|
|
340
|
-
session is a human: via "terminal". Otherwise a launcher that declared the
|
|
341
|
-
run unattended (AI_UNATTENDED) passes: via "unattended", as state.py's
|
|
342
|
-
approve records it. Anything else is refused, and `why` says what was seen —
|
|
343
|
-
a terminal alone is not enough, since an agent can open a pty (F3).
|
|
344
|
-
|
|
345
|
-
Duplicated in spirit from skills/ai-task/state.py (human_present) on purpose:
|
|
346
|
-
the two skills are installed independently and neither imports the other."""
|
|
347
|
-
markers = [name for name in AGENT_MARKERS if name in os.environ]
|
|
348
|
-
if os.isatty(0) and not markers:
|
|
349
|
-
return True, "terminal", ""
|
|
350
|
-
if ai_unattended():
|
|
351
|
-
return True, "unattended", ""
|
|
352
|
-
if markers:
|
|
353
|
-
return False, None, ("this run is inside an agent session (%s %s set); run it in a terminal "
|
|
354
|
-
"outside Claude Code or Codex, not through the `!` prefix"
|
|
355
|
-
% (", ".join(markers), "is" if len(markers) == 1 else "are"))
|
|
356
|
-
return False, None, "this run has no terminal"
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
# The name typed into --confirm-delete is the audit trail; the template's own
|
|
360
|
-
# "<your name>" copied verbatim names no one. The whole value, not a substring.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
# The name typed into --confirm-delete is the audit trail; the template's own
|
|
364
|
-
# "<your name>" copied verbatim names no one. The whole value, not a substring.
|
|
365
|
-
PLACEHOLDER_NAME_RE = re.compile(r"\A\s*(?:<[^>]*>|your\s+name)\s*\Z", re.IGNORECASE)
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
def placeholder_name(name):
|
|
369
|
-
return bool(PLACEHOLDER_NAME_RE.match(name or ""))
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def refuse(reason, how):
|
|
373
|
-
"""Print the refusal the way every gate does — the marker on the first
|
|
374
|
-
stdout line, then what to do — and return exit code 5."""
|
|
375
|
-
print("%s: %s" % (REFUSED, reason))
|
|
376
|
-
print(" %s" % how)
|
|
377
|
-
return 5
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def budget_offenders(root, files, caps, block_of):
|
|
381
|
-
"""R21: one line per root instruction file over budget — its managed block
|
|
382
|
-
over `caps["project"]`, or the whole file over `caps["skeleton"]`. Files that
|
|
383
|
-
do not exist are skipped; a file under both budgets prints nothing."""
|
|
384
|
-
out = []
|
|
385
|
-
for name in files:
|
|
386
|
-
path = os.path.join(root, name)
|
|
387
|
-
if not os.path.isfile(path):
|
|
388
|
-
continue
|
|
389
|
-
with open(path, encoding="utf-8") as fh:
|
|
390
|
-
text = fh.read()
|
|
391
|
-
block = block_of(text)
|
|
392
|
-
size = len(text.encode("utf-8"))
|
|
393
|
-
if block is not None and caps.get("project") and len(block.encode("utf-8")) > caps["project"]:
|
|
394
|
-
out.append("%s block %d B over the project budget of %d B"
|
|
395
|
-
% (name, len(block.encode("utf-8")), caps["project"]))
|
|
396
|
-
if caps.get("skeleton") and size > caps["skeleton"]:
|
|
397
|
-
out.append("%s file %d B over the skeleton budget of %d B" % (name, size, caps["skeleton"]))
|
|
398
|
-
return out
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
# ---------------------------------------------------------------- the kept files
|
|
404
|
-
def template_version():
|
|
405
|
-
"""The schema number this plugin ships (its .ai/VERSION template)."""
|
|
406
|
-
return int(read(os.path.join(TEMPLATES["ai-init"], VERSION_FILE)).decode().strip())
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
def project_version(root):
|
|
410
|
-
"""The project's .ai/VERSION: 0 when there is none; SchemaError when it cannot
|
|
411
|
-
be read, is not a number, or is newer than this plugin's."""
|
|
412
|
-
path = os.path.join(root, VERSION_FILE)
|
|
413
|
-
try:
|
|
414
|
-
raw = read(path) if os.path.lexists(path) else None
|
|
415
|
-
except OSError as exc: # a directory, a mode-000 file, a dangling link
|
|
416
|
-
raise SchemaError("%s cannot be read: %s" % (VERSION_FILE, exc)) from exc
|
|
417
|
-
if raw is None:
|
|
418
|
-
return 0
|
|
419
|
-
try:
|
|
420
|
-
text = raw.decode("utf-8").strip()
|
|
421
|
-
except UnicodeDecodeError:
|
|
422
|
-
text = ""
|
|
423
|
-
if not re.fullmatch(r"\d+", text):
|
|
424
|
-
raise SchemaError("%s is not a schema number: %r" % (VERSION_FILE, raw[:40]))
|
|
425
|
-
ours = template_version()
|
|
426
|
-
if int(text) > ours:
|
|
427
|
-
raise SchemaError("%s says schema %s, this plugin ships schema %d — update the plugin "
|
|
428
|
-
"(riskrails/install.sh)" % (VERSION_FILE, text, ours))
|
|
429
|
-
return int(text)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
_MODULES = {}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
def sibling(name, *parts):
|
|
436
|
-
"""A module of another skill, loaded by path once per run (skills are installed independently)."""
|
|
437
|
-
if name not in _MODULES:
|
|
438
|
-
spec = importlib.util.spec_from_file_location("riskrails_" + name, os.path.join(SKILLS, *parts))
|
|
439
|
-
_MODULES[name] = importlib.util.module_from_spec(spec)
|
|
440
|
-
spec.loader.exec_module(_MODULES[name])
|
|
441
|
-
return _MODULES[name]
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
def weight_module():
|
|
445
|
-
"""skills/ai-task/weight.py, the renderer behind `state.py weight --mirror`."""
|
|
446
|
-
return sibling("weight", "ai-task", "weight.py")
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
def weight_overlay():
|
|
450
|
-
"""skills/ai-task/weight_overlay.py: the plugin's table with the project's laid over it."""
|
|
451
|
-
return sibling("weight_overlay", "ai-task", "weight_overlay.py")
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
def shipped_weight():
|
|
455
|
-
return os.path.join(TEMPLATES["ai-init"], WEIGHT_JSON)
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
# Old shapes the update no longer converts: literal checks, nothing parsed (D4).
|
|
459
|
-
OLD_MARKERS = (b"<!-- claude-agentic:", b"<!-- Codex-agentic:")
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
def check_kept(root):
|
|
463
|
-
"""[(path, reason)] for every kept file its own reader cannot take and every old
|
|
464
|
-
shape this version no longer converts. Reads only: the caller refuses on a
|
|
465
|
-
non-empty answer before anything is written (R12)."""
|
|
466
|
-
bad = []
|
|
467
|
-
|
|
468
|
-
def raw_of(rel):
|
|
469
|
-
path = os.path.join(root, rel)
|
|
470
|
-
if not os.path.lexists(path):
|
|
471
|
-
return None
|
|
472
|
-
try:
|
|
473
|
-
return read(path)
|
|
474
|
-
except OSError as exc:
|
|
475
|
-
bad.append((rel, "cannot be read: %s" % (exc.strerror or exc)))
|
|
476
|
-
return None
|
|
477
|
-
|
|
478
|
-
def json_object(rel, data):
|
|
479
|
-
try:
|
|
480
|
-
doc = json.loads(data.decode("utf-8"))
|
|
481
|
-
except (ValueError, RecursionError) as exc: # UnicodeDecodeError is a ValueError
|
|
482
|
-
bad.append((rel, "is not valid JSON (%s)" % (type(exc).__name__ if isinstance(exc, RecursionError) else exc)))
|
|
483
|
-
return None
|
|
484
|
-
if not isinstance(doc, dict):
|
|
485
|
-
bad.append((rel, "is not a JSON object"))
|
|
486
|
-
return None
|
|
487
|
-
return doc
|
|
488
|
-
|
|
489
|
-
for rel in (STATE_FILE, ".ai/policies/dialog.json", ".ai/policies/memory.json", ".claude/settings.json"):
|
|
490
|
-
data = raw_of(rel)
|
|
491
|
-
if data is not None:
|
|
492
|
-
json_object(rel, data)
|
|
493
|
-
data = raw_of(WEIGHT_PROJECT)
|
|
494
|
-
if data is not None:
|
|
495
|
-
doc = json_object(WEIGHT_PROJECT, data)
|
|
496
|
-
if doc is not None and doc.get("version") != 1:
|
|
497
|
-
bad.append((WEIGHT_PROJECT, "has no \"version\": 1"))
|
|
498
|
-
elif doc is not None:
|
|
499
|
-
try:
|
|
500
|
-
weight_overlay().load(shipped_weight(), os.path.join(root, WEIGHT_PROJECT))
|
|
501
|
-
except ValueError as exc:
|
|
502
|
-
bad.append((WEIGHT_PROJECT, str(exc).split(": ", 1)[-1]))
|
|
503
|
-
data = raw_of(VERIFICATION_MD)
|
|
504
|
-
if data is not None:
|
|
505
|
-
try:
|
|
506
|
-
data.decode("utf-8")
|
|
507
|
-
except UnicodeDecodeError:
|
|
508
|
-
bad.append((VERIFICATION_MD, "is not UTF-8 text"))
|
|
509
|
-
data = raw_of(".codex/config.toml")
|
|
510
|
-
if data is not None and tomllib is not None:
|
|
511
|
-
try:
|
|
512
|
-
tomllib.loads(data.decode("utf-8"))
|
|
513
|
-
except ValueError as exc: # TOMLDecodeError and UnicodeDecodeError
|
|
514
|
-
bad.append((".codex/config.toml", "is not valid TOML (%s)" % exc))
|
|
515
|
-
# the old weight.json is read once, by the split, so it must parse then
|
|
516
|
-
if not os.path.lexists(os.path.join(root, WEIGHT_PROJECT)):
|
|
517
|
-
data = raw_of(WEIGHT_JSON)
|
|
518
|
-
if data is not None:
|
|
519
|
-
json_object(WEIGHT_JSON, data)
|
|
520
|
-
old_shapes(root, bad)
|
|
521
|
-
return bad
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
def old_shapes(root, bad):
|
|
525
|
-
"""The four literal detectors of an older riskrails' files (D4)."""
|
|
526
|
-
exists = lambda rel: os.path.lexists(os.path.join(root, rel)) # noqa: E731
|
|
527
|
-
if exists(".ai/state/current.json") and not exists(STATE_FILE):
|
|
528
|
-
bad.append((".ai/state/current.json", "is the task state of a riskrails before 3.4b (there is no state.json)"))
|
|
529
|
-
if exists(".ai/policies/risk-tiers.json") and not exists(WEIGHT_JSON) and not exists(WEIGHT_PROJECT):
|
|
530
|
-
bad.append((".ai/policies/risk-tiers.json", "is the risk-tier policy of a riskrails before 2.2 (there is no weight table)"))
|
|
531
|
-
names = set(render_instructions.RULE_FILE.values()) | {e[0] for e in INSTRUCTION_FILE.values()}
|
|
532
|
-
for dirpath, dirnames, filenames in os.walk(root):
|
|
533
|
-
dirnames[:] = sorted(d for d in dirnames if d not in RULES_SKIP
|
|
534
|
-
and not os.path.lexists(os.path.join(dirpath, d, ".git")))
|
|
535
|
-
for name in filenames:
|
|
536
|
-
rel = os.path.relpath(os.path.join(dirpath, name), root).replace(os.sep, "/")
|
|
537
|
-
if name not in names and rel not in names:
|
|
538
|
-
continue
|
|
539
|
-
try:
|
|
540
|
-
data = read(os.path.join(root, rel))
|
|
541
|
-
except OSError:
|
|
542
|
-
continue # an unreadable instruction file is the plan's to report, not an old shape
|
|
543
|
-
found = next((m for m in OLD_MARKERS if m in data), None)
|
|
544
|
-
if found:
|
|
545
|
-
bad.append((rel, "carries %s of a riskrails before 3.4b" % found.decode()))
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
def kept_refusal(bad):
|
|
549
|
-
"""I3, verbatim: one line per failing file, then the rest."""
|
|
550
|
-
for rel, why in bad:
|
|
551
|
-
print("KEPT_UNREADABLE: %s: %s" % (rel, why))
|
|
552
|
-
print("project-update: nothing was deleted or written. This version regenerates its own files and no longer "
|
|
553
|
-
"converts older ones.")
|
|
554
|
-
print("Install riskrails@%s (npx riskrails@%s), run /rr-setup --update with it, then install this version "
|
|
555
|
-
"and run /rr-setup --update again." % (LAST_MIGRATING_VERSION, LAST_MIGRATING_VERSION))
|
|
556
|
-
return EXIT_KEPT_UNREADABLE
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
# ---------------------------------------------------------------- planning
|
|
560
|
-
class Plan:
|
|
561
|
-
def __init__(self, root):
|
|
562
|
-
self.root = root
|
|
563
|
-
self.items = [] # dicts: action, target, note, content, reason, keep, expect
|
|
564
|
-
self.hints = []
|
|
565
|
-
self.confirm_delete = None # the human who confirmed this run's gated rows
|
|
566
|
-
self.confirm_via = None # why that gate passed: "terminal" or "unattended"
|
|
567
|
-
self.confirm_cleanup = None # the human who confirmed this run's [cleanup] deletions
|
|
568
|
-
self.cleanup_via = None
|
|
569
|
-
self.version_from = None # the .ai/VERSION this run started from
|
|
570
|
-
self.final = {} # target -> bytes after this run (for cross-file fixes)
|
|
571
|
-
self.removed = set() # targets this run takes away
|
|
572
|
-
self.seen = {} # target -> sha of what was on disk when it was first read,
|
|
573
|
-
# or None when nothing was there: apply's precondition
|
|
574
|
-
self.git_kept = set() # [cleanup] targets git holds or should hold: a hint and an original/ copy
|
|
575
|
-
self.report_dir = ".ai/reports/project-update-%s" % datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
576
|
-
|
|
577
|
-
# The project as this run leaves it: planned content first, then the disk.
|
|
578
|
-
# File readers in the planning path go through these, not the disk.
|
|
579
|
-
def exists(self, target):
|
|
580
|
-
if target in self.final:
|
|
581
|
-
return True
|
|
582
|
-
return target not in self.removed and os.path.exists(os.path.join(self.root, target))
|
|
583
|
-
|
|
584
|
-
def read(self, target):
|
|
585
|
-
if target in self.final:
|
|
586
|
-
return self.final[target]
|
|
587
|
-
if not self.exists(target):
|
|
588
|
-
return None
|
|
589
|
-
data = read(os.path.join(self.root, target))
|
|
590
|
-
self.seen.setdefault(target, sha(data)) # what apply must find there
|
|
591
|
-
return data
|
|
592
|
-
|
|
593
|
-
def remove(self, target):
|
|
594
|
-
self.final.pop(target, None)
|
|
595
|
-
self.removed.add(target)
|
|
596
|
-
|
|
597
|
-
def before(self, target):
|
|
598
|
-
"""The sha of the target as this run first read it, None when it was
|
|
599
|
-
absent. Recorded by the read that planned the change, so the precondition
|
|
600
|
-
and the planning input are the same bytes."""
|
|
601
|
-
if target not in self.seen:
|
|
602
|
-
path = os.path.join(self.root, target)
|
|
603
|
-
self.seen[target] = sha(read(path)) if os.path.isfile(path) and not os.path.islink(path) else None
|
|
604
|
-
return self.seen[target]
|
|
605
|
-
|
|
606
|
-
def add(self, action, target, note="", content=None, reason=None, keep=False):
|
|
607
|
-
self.items.append(dict(action=action, target=target, note=note, content=content,
|
|
608
|
-
reason=reason, keep=keep, expect=self.before(target)))
|
|
609
|
-
if content is not None:
|
|
610
|
-
self.final[target] = content
|
|
611
|
-
self.removed.discard(target) # planned content wins over an earlier removal
|
|
612
|
-
|
|
613
|
-
def original_of(self, target):
|
|
614
|
-
return "%s/original/%s" % (self.report_dir, target)
|
|
615
|
-
|
|
616
|
-
def gated(self, action, target, note, content=None, reason=None):
|
|
617
|
-
"""A change that waits for a human: `regenerate?` / `delete?` until
|
|
618
|
-
--apply --confirm-delete NAME, then `regenerate` / `delete` with the original kept
|
|
619
|
-
under original/ (R7)."""
|
|
620
|
-
if self.confirm_delete:
|
|
621
|
-
self.add(action, target, note, content=content, reason=reason, keep=True)
|
|
622
|
-
if action == "delete":
|
|
623
|
-
self.remove(target)
|
|
624
|
-
else:
|
|
625
|
-
self.add(action + "?", target, note, reason=reason)
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
# ---------------------------------------------------------------- regenerate
|
|
629
|
-
def lost_lines(ours, theirs):
|
|
630
|
-
"""The lines of the project's copy that the plugin's version does not have."""
|
|
631
|
-
kept = set(theirs.decode("utf-8", "replace").splitlines())
|
|
632
|
-
return [line.strip() for line in ours.decode("utf-8", "replace").splitlines()
|
|
633
|
-
if line.strip() and line not in kept]
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
def patterns_of(data):
|
|
637
|
-
"""The count of non-empty deny and allow patterns in a path-guard.json, or None when it is not one."""
|
|
638
|
-
try:
|
|
639
|
-
doc = json.loads(data)
|
|
640
|
-
return sum(1 for key in ("deny_patterns", "allow_patterns") for p in doc.get(key) or []
|
|
641
|
-
if isinstance(p, str) and p.strip())
|
|
642
|
-
except (ValueError, AttributeError, TypeError, RecursionError):
|
|
643
|
-
return None
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
def edited_note(plan, target, ours, theirs):
|
|
647
|
-
"""What a gated regenerate would lose, and where the original goes (R6)."""
|
|
648
|
-
lost = lost_lines(ours, theirs)
|
|
649
|
-
shown = "; ".join("`%s`" % (line[:48] + ("…" if len(line) > 48 else "")) for line in lost[:3])
|
|
650
|
-
note = "edited here: %d line(s) of yours are not in the plugin's version%s" % (len(lost), " (" + shown + ")" if shown else "")
|
|
651
|
-
if target.endswith("path-guard.json"):
|
|
652
|
-
count = patterns_of(ours)
|
|
653
|
-
note += "; drops %s deny/allow pattern(s)" % ("an unreadable number of" if count is None else count)
|
|
654
|
-
return "%s; original kept at %s" % (note, plan.original_of(target))
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
def regenerate(plan, history, key, target, theirs):
|
|
658
|
-
"""D5: absent -> create; equal -> nothing; a version the plugin once shipped -> regenerate;
|
|
659
|
-
anything else is the human's edit -> regenerate? behind the gate."""
|
|
660
|
-
ours = plan.read(target)
|
|
661
|
-
if ours is None:
|
|
662
|
-
plan.add("create", target, content=theirs)
|
|
663
|
-
elif ours == theirs:
|
|
664
|
-
return
|
|
665
|
-
elif history.shipped(key, ours):
|
|
666
|
-
plan.add("regenerate", target, "unchanged since it was installed", content=theirs)
|
|
667
|
-
else:
|
|
668
|
-
plan.gated("regenerate", target, edited_note(plan, target, ours, theirs), content=theirs)
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
def effective_weight(plan):
|
|
672
|
-
"""The weight table as this run leaves it: the plugin's with weight.project.json laid over it."""
|
|
673
|
-
own = plan.read(WEIGHT_PROJECT)
|
|
674
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
675
|
-
path = os.path.join(tmp, "weight.project.json")
|
|
676
|
-
if own is not None:
|
|
677
|
-
with open(path, "wb") as fh:
|
|
678
|
-
fh.write(own)
|
|
679
|
-
return weight_overlay().load(shipped_weight(), path)
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
GENERATED_HEADER = b"generated by `state.py weight --mirror`"
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
def weight_md(plan, history):
|
|
686
|
-
"""weight.md is always rendered from the shipped table and the overlay (spec :161). A file that is
|
|
687
|
-
a rendering or a shipped version is replaced; one a human wrote is gated."""
|
|
688
|
-
try:
|
|
689
|
-
theirs = weight_module().mirror(effective_weight(plan)).encode("utf-8")
|
|
690
|
-
except (ValueError, OSError, KeyError, TypeError, AttributeError, RecursionError) as exc:
|
|
691
|
-
plan.hints.append("%s: not rendered (%s: %s); fix %s, then run this again"
|
|
692
|
-
% (WEIGHT_MD, type(exc).__name__, exc, WEIGHT_PROJECT))
|
|
693
|
-
return
|
|
694
|
-
ours = plan.read(WEIGHT_MD)
|
|
695
|
-
if ours is None:
|
|
696
|
-
plan.add("create", WEIGHT_MD, "rendered from the weight table", content=theirs)
|
|
697
|
-
elif ours == theirs:
|
|
698
|
-
return
|
|
699
|
-
elif GENERATED_HEADER in ours[:600] or history.shipped("ai-init/" + WEIGHT_MD, ours):
|
|
700
|
-
plan.add("regenerate", WEIGHT_MD, "rendered from the weight table", content=theirs)
|
|
701
|
-
else:
|
|
702
|
-
plan.gated("regenerate", WEIGHT_MD, edited_note(plan, WEIGHT_MD, ours, theirs), content=theirs)
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
def block_update(plan, history, runtime):
|
|
706
|
-
"""R15, D5: the managed block of one instruction file. Rewritten between its markers; no block
|
|
707
|
-
-> appended; no file -> a minimal one with the block. Text outside the markers is never touched."""
|
|
708
|
-
name, block_tpl, minimal_tpl = INSTRUCTION_FILE[runtime]
|
|
709
|
-
tpl = read(os.path.join(TEMPLATES["ai-init"], block_tpl))
|
|
710
|
-
text = plan.read(name)
|
|
711
|
-
if text is None:
|
|
712
|
-
created = read(os.path.join(TEMPLATES["ai-init"], minimal_tpl)).replace(
|
|
713
|
-
b"{{PROJECT}}", os.path.basename(plan.root).encode())
|
|
714
|
-
plan.add("create", name, "with the managed block", content=created.rstrip(b"\n") + b"\n\n" + tpl)
|
|
715
|
-
return
|
|
716
|
-
found = find_block(text)
|
|
717
|
-
if found is None:
|
|
718
|
-
plan.add("regenerate", name, "managed block appended", content=text.rstrip(b"\n") + b"\n\n" + tpl)
|
|
719
|
-
return
|
|
720
|
-
s, e = found[0], found[1]
|
|
721
|
-
ours = text[s:e] + b"\n"
|
|
722
|
-
if ours == tpl:
|
|
723
|
-
return
|
|
724
|
-
content = text[:s] + tpl.rstrip(b"\n") + text[e:]
|
|
725
|
-
if history.shipped("ai-init/" + block_tpl, ours):
|
|
726
|
-
plan.add("regenerate", name, "managed block replaced (unchanged since it was installed)", content=content)
|
|
727
|
-
else:
|
|
728
|
-
plan.gated("regenerate", name, "managed block " + edited_note(plan, name, ours, tpl), content=content)
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
# ---------------------------------------------------------------- the one-time split
|
|
732
|
-
FIELD_LINE = re.compile(r"(?:%s):" % "|".join(
|
|
733
|
-
("verify_command", "step_test_command", "e2e_command", "lint_command", "typecheck_command",
|
|
734
|
-
"single_test", "doc_tests", "bite_codes", "healthy_output", "runtime")))
|
|
735
|
-
SPECIFICS_RE = re.compile(r"(?m)^## Project specifics[ \t]*$")
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
def split_testing(testing, template):
|
|
739
|
-
"""R11: verification.md from testing.md. The field lines are copied verbatim with their continuation
|
|
740
|
-
lines (indented, after a field line), then `## Project specifics` to the end of the file."""
|
|
741
|
-
lines = testing.decode("utf-8").splitlines(keepends=True)
|
|
742
|
-
fields, taking = [], False
|
|
743
|
-
for line in lines:
|
|
744
|
-
if FIELD_LINE.match(line):
|
|
745
|
-
fields.append(line)
|
|
746
|
-
taking = True
|
|
747
|
-
elif taking and line[:1] in (" ", "\t") and line.strip():
|
|
748
|
-
fields.append(line)
|
|
749
|
-
else:
|
|
750
|
-
taking = False
|
|
751
|
-
text = template.decode("utf-8")
|
|
752
|
-
if not fields:
|
|
753
|
-
return template, 0
|
|
754
|
-
head = text[:re.search(r"(?m)^verify_command:", text).start()]
|
|
755
|
-
tail = SPECIFICS_RE.search(testing.decode("utf-8"))
|
|
756
|
-
specifics = testing.decode("utf-8")[tail.start():] if tail else text[SPECIFICS_RE.search(text).start():]
|
|
757
|
-
body = "".join(fields)
|
|
758
|
-
return (head + body + ("" if body.endswith("\n") else "\n") + "\n" + specifics).encode("utf-8"), len(fields)
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
def leaves(value, prefix=()):
|
|
762
|
-
"""{path tuple: leaf} — objects are walked, a list or scalar (or an empty object) is one leaf."""
|
|
763
|
-
if isinstance(value, dict) and value:
|
|
764
|
-
out = {}
|
|
765
|
-
for k, v in value.items():
|
|
766
|
-
out.update(leaves(v, prefix + (k,)))
|
|
767
|
-
return out
|
|
768
|
-
return {prefix: value}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
def split_weight(old, history, template):
|
|
772
|
-
"""R11: weight.project.json from the old weight.json: the leaves that differ from that leaf in every
|
|
773
|
-
version the plugin shipped (lists whole). -> (bytes, carried paths, uncarried paths that differ from today's table)."""
|
|
774
|
-
mine = json.loads(old.decode("utf-8"), object_pairs_hook=OrderedDict)
|
|
775
|
-
tables = []
|
|
776
|
-
for blob in history.versions("ai-init/" + WEIGHT_JSON) + [read(shipped_weight())]:
|
|
777
|
-
try:
|
|
778
|
-
tables.append(leaves(json.loads(blob.decode("utf-8"))))
|
|
779
|
-
except (ValueError, RecursionError):
|
|
780
|
-
continue
|
|
781
|
-
today = tables[-1] if tables else {}
|
|
782
|
-
carried, uncarried = OrderedDict(), []
|
|
783
|
-
for path, value in leaves(mine).items():
|
|
784
|
-
if path[:1] in (("version",), ("_comment",)) or not path:
|
|
785
|
-
continue
|
|
786
|
-
if any(path in t and t[path] == value for t in tables):
|
|
787
|
-
if today.get(path, ...) != value:
|
|
788
|
-
uncarried.append(".".join(map(str, path)))
|
|
789
|
-
continue
|
|
790
|
-
here = carried
|
|
791
|
-
for part in path[:-1]:
|
|
792
|
-
here = here.setdefault(part, OrderedDict())
|
|
793
|
-
here[path[-1]] = value
|
|
794
|
-
doc = json.loads(template.decode("utf-8"), object_pairs_hook=OrderedDict)
|
|
795
|
-
doc.update(carried)
|
|
796
|
-
paths = [".".join(map(str, p)) for p in leaves(carried) if p]
|
|
797
|
-
return dump_json(doc), paths, uncarried
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
def listed(names, limit=6):
|
|
801
|
-
shown = ", ".join(names[:limit])
|
|
802
|
-
return shown + (" and %d more" % (len(names) - limit) if len(names) > limit else "")
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
def unwritten(plan, history, target, template):
|
|
806
|
-
"""True when the new file is absent, or is still a shipped template (today's or any in the history index):
|
|
807
|
-
a scaffold run over an old tree can leave one, and it carries nothing of the project."""
|
|
808
|
-
if not plan.exists(target):
|
|
809
|
-
return True
|
|
810
|
-
data = plan.read(target)
|
|
811
|
-
return data is not None and (data.replace(b"\r\n", b"\n") == template.replace(b"\r\n", b"\n")
|
|
812
|
-
or history.shipped("ai-init/" + target, data))
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
def split_once(plan, history):
|
|
816
|
-
"""R11: when verification.md / weight.project.json are absent (or only a shipped template), make them from
|
|
817
|
-
testing.md / weight.json (once); with a project's own file present the old file is not read again.
|
|
818
|
-
The old weight.json is then REMOVED, gated."""
|
|
819
|
-
tpl = TEMPLATES["ai-init"]
|
|
820
|
-
template = read(os.path.join(tpl, VERIFICATION_MD))
|
|
821
|
-
if unwritten(plan, history, VERIFICATION_MD, template):
|
|
822
|
-
absent = not plan.exists(VERIFICATION_MD)
|
|
823
|
-
testing = plan.read(TESTING_MD)
|
|
824
|
-
content, count = split_testing(testing, template) if testing is not None else (template, 0)
|
|
825
|
-
if count:
|
|
826
|
-
plan.add("split", VERIFICATION_MD, "%d command line(s) of testing.md copied verbatim, with its "
|
|
827
|
-
"Project specifics" % count, content=content)
|
|
828
|
-
elif absent:
|
|
829
|
-
plan.add("create", VERIFICATION_MD, content=content)
|
|
830
|
-
template = read(os.path.join(tpl, WEIGHT_PROJECT))
|
|
831
|
-
if unwritten(plan, history, WEIGHT_PROJECT, template):
|
|
832
|
-
absent = not plan.exists(WEIGHT_PROJECT)
|
|
833
|
-
old = plan.read(WEIGHT_JSON)
|
|
834
|
-
if old is None:
|
|
835
|
-
if absent:
|
|
836
|
-
plan.add("create", WEIGHT_PROJECT, content=template)
|
|
837
|
-
else:
|
|
838
|
-
content, carried, uncarried = split_weight(old, history, template)
|
|
839
|
-
if absent or content != plan.read(WEIGHT_PROJECT):
|
|
840
|
-
note = "%d leaf/leaves of weight.json that differ from the plugin's table carried (%s)" % (
|
|
841
|
-
len(carried), listed(carried) or "none")
|
|
842
|
-
if uncarried:
|
|
843
|
-
note += "; %d not carried, equal to an older shipped value: %s" % (len(uncarried), listed(uncarried))
|
|
844
|
-
plan.add("split", WEIGHT_PROJECT, note, content=content)
|
|
845
|
-
if plan.exists(WEIGHT_JSON):
|
|
846
|
-
why = "[split] superseded by %s; the plugin's table is read from the plugin" % WEIGHT_PROJECT
|
|
847
|
-
plan.gated("delete", WEIGHT_JSON, why, reason=why)
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
# ---------------------------------------------------------------- shared files, rules, caches
|
|
851
|
-
def require_shared(paths, runtimes):
|
|
852
|
-
"""Refuse, before anything is written, to drop a project copy whose shared fallback is missing:
|
|
853
|
-
in this plugin's tree, or in another installed home of a runtime the project declares. A home
|
|
854
|
-
this machine does not have is not checked."""
|
|
855
|
-
undeclared = sorted(p for p in paths if p not in SHARED_SET)
|
|
856
|
-
if undeclared:
|
|
857
|
-
raise SchemaError("not in the shared set, never deleted as a shared copy: %s" % ", ".join(undeclared))
|
|
858
|
-
rels = sorted(p[len(".ai/"):] for p in paths)
|
|
859
|
-
missing = [r for r in rels if not os.path.isfile(os.path.join(SHARED, r))]
|
|
860
|
-
if missing:
|
|
861
|
-
raise SchemaError("the plugin's shared tree %s lacks %d file(s) a project copy would fall back to: %s — "
|
|
862
|
-
"reinstall the plugin (install.sh), then run this again" % (SHARED, len(missing), ", ".join(missing)))
|
|
863
|
-
homes = {"claude": os.path.join(os.path.expanduser("~"), ".claude"),
|
|
864
|
-
"codex": os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")}
|
|
865
|
-
mine = os.path.realpath(PLUGIN_HOME)
|
|
866
|
-
for runtime in runtimes:
|
|
867
|
-
home = homes.get(runtime)
|
|
868
|
-
if (not home or os.path.realpath(home) == mine
|
|
869
|
-
or not os.path.isdir(os.path.join(home, "skills", "project-update"))):
|
|
870
|
-
continue
|
|
871
|
-
tree = os.path.join(home, "riskrails", "ai")
|
|
872
|
-
missing = [r for r in rels if not os.path.isfile(os.path.join(tree, r))]
|
|
873
|
-
if missing:
|
|
874
|
-
raise SchemaError("%s has this plugin installed for %s, but %d of the %d shared file(s) a project copy "
|
|
875
|
-
"would fall back to are missing from %s — run `install.sh --target %s` from this "
|
|
876
|
-
"build, then run this again" % (home, runtime, len(missing), len(rels), tree, runtime))
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
def shared_update(plan, history, runtimes):
|
|
880
|
-
"""R17: an unedited copy of a shared file is offered for deletion; an edited one is an
|
|
881
|
-
override, kept, and named once (OQ8)."""
|
|
882
|
-
edited, candidates = [], []
|
|
883
|
-
extra = undeclared_shared()
|
|
884
|
-
if extra:
|
|
885
|
-
plan.hints.append("the shared tree %s holds %d file(s) outside the shared set, ignored — reinstall the "
|
|
886
|
-
"plugin (install.sh) if you did not put them there: %s" % (SHARED, len(extra), ", ".join(extra)))
|
|
887
|
-
for path in sorted(shared_files()):
|
|
888
|
-
data = plan.read(path)
|
|
889
|
-
if data is None:
|
|
890
|
-
continue
|
|
891
|
-
why = unsafe_path(plan.root, path)
|
|
892
|
-
if why:
|
|
893
|
-
plan.hints.append("%s: %s, so this update never deletes it — check by hand whether it is still "
|
|
894
|
-
"needed" % (path, why))
|
|
895
|
-
elif not history.shipped("ai-init/" + path, data):
|
|
896
|
-
edited.append(path)
|
|
897
|
-
else:
|
|
898
|
-
candidates.append(path)
|
|
899
|
-
if candidates:
|
|
900
|
-
try:
|
|
901
|
-
require_shared(candidates, runtimes)
|
|
902
|
-
except SchemaError as exc:
|
|
903
|
-
plan.hints.append("%s — the copies stay until then" % exc)
|
|
904
|
-
candidates = []
|
|
905
|
-
for path in candidates:
|
|
906
|
-
why = "[shared] identical to a shipped version"
|
|
907
|
-
plan.gated("delete", path, why, reason=why)
|
|
908
|
-
if edited:
|
|
909
|
-
plan.hints.append("%d file(s) override the plugin's shared copy and no longer receive its changes: %s — "
|
|
910
|
-
"diff each against %s/<path>; `ai-path <path>` shows which applies; delete one when it "
|
|
911
|
-
"adds nothing" % (len(edited), ", ".join(edited), SHARED))
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
def rule_sources(plan):
|
|
915
|
-
"""OQ1: a rule source is not kept. Each `.ai/rules/<slug>.md` is `delete?` behind the gate with its
|
|
916
|
-
original copied; until it is confirmed the rule stays rendered."""
|
|
917
|
-
rules = os.path.join(plan.root, RULES_DIR)
|
|
918
|
-
for name in sorted(os.listdir(rules)) if os.path.isdir(rules) else []:
|
|
919
|
-
rel = "%s/%s" % (RULES_DIR, name)
|
|
920
|
-
if name.endswith(".md") and name != "README.md" and os.path.isfile(os.path.join(rules, name)) \
|
|
921
|
-
and not unsafe_path(plan.root, rel):
|
|
922
|
-
why = "[rules] rule sources are not kept by an update: the rendered blocks go with it"
|
|
923
|
-
plan.gated("delete", rel, why, reason=why)
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
def local_cache(plan):
|
|
927
|
-
"""R18: .ai/local/plugin-update/ (the old merge's conflict copies) is removed; .ai/local/memory/ is never touched."""
|
|
928
|
-
for here, _dirs, names in os.walk(os.path.join(plan.root, LOCAL_CACHE)):
|
|
929
|
-
for name in sorted(names):
|
|
930
|
-
rel = os.path.relpath(os.path.join(here, name), plan.root).replace(os.sep, "/")
|
|
931
|
-
if not unsafe_path(plan.root, rel):
|
|
932
|
-
why = "[local] the old merge's cache, not written any more"
|
|
933
|
-
plan.gated("delete", rel, why, reason=why)
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
def task_journal(root, task_id):
|
|
937
|
-
"""(finished, abandoned, last event as a datetime) from the task's journal,
|
|
938
|
-
or None when it holds no readable event. A task is finished when its journal
|
|
939
|
-
has task_closed or gate_approved, or when logs/<id>/state.json records a
|
|
940
|
-
result (so a torn task_closed line does not make a closed task look unclosed)."""
|
|
941
|
-
closed = abandoned = False
|
|
942
|
-
last = None
|
|
943
|
-
try:
|
|
944
|
-
with open(os.path.join(root, ".ai", "reports", task_id, "events.jsonl"), encoding="utf-8") as fh:
|
|
945
|
-
lines = fh.read().splitlines()
|
|
946
|
-
except (OSError, UnicodeDecodeError):
|
|
947
|
-
return None
|
|
948
|
-
for line in lines:
|
|
949
|
-
try:
|
|
950
|
-
ev = json.loads(line)
|
|
951
|
-
when = datetime.strptime(ev["ts"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
|
952
|
-
except (ValueError, KeyError, TypeError, RecursionError):
|
|
953
|
-
continue
|
|
954
|
-
if last is None or when > last:
|
|
955
|
-
last = when
|
|
956
|
-
if ev.get("event") == "task_closed":
|
|
957
|
-
closed = True
|
|
958
|
-
data = ev.get("data")
|
|
959
|
-
abandoned = (isinstance(data, dict) and data.get("abandoned") is True) or ev.get("detail") == "abandoned"
|
|
960
|
-
elif ev.get("event") == "gate_approved":
|
|
961
|
-
closed = True
|
|
962
|
-
try:
|
|
963
|
-
with open(os.path.join(root, ".ai", "state", "logs", task_id, "state.json"), encoding="utf-8") as fh:
|
|
964
|
-
result = json.load(fh).get("result")
|
|
965
|
-
except (OSError, ValueError, AttributeError, UnicodeDecodeError, RecursionError):
|
|
966
|
-
result = None
|
|
967
|
-
if isinstance(result, str) and result:
|
|
968
|
-
closed = True
|
|
969
|
-
abandoned = abandoned or result == "abandoned"
|
|
970
|
-
return None if last is None else (closed, abandoned, last)
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
def cleanup_update(plan, now=None):
|
|
974
|
-
"""Old run logs and stale unclosed tasks, proposed for deletion and never
|
|
975
|
-
deleted unasked (only with --confirm-cleanup). A closed task's `*.log` files
|
|
976
|
-
go (its state.json stays); an abandoned task loses every file under
|
|
977
|
-
.ai/reports/<id>/ and .ai/state/logs/<id>/ except that state.json; an unclosed
|
|
978
|
-
one idle for STALE_DAYS loses every file in both. The task in flight is
|
|
979
|
-
never touched. `now` is a datetime; RISKRAILS_NOW (ISO, Z) overrides it."""
|
|
980
|
-
if now is None:
|
|
981
|
-
try:
|
|
982
|
-
now = datetime.strptime(env("NOW") or utc_now(), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
983
|
-
except ValueError:
|
|
984
|
-
print("project-update: RISKRAILS_NOW %r is not a UTC time like 2026-01-31T12:00:00Z" % env("NOW"),
|
|
985
|
-
file=sys.stderr)
|
|
986
|
-
raise SystemExit(2) from None
|
|
987
|
-
flight = None
|
|
988
|
-
raw = plan.read(STATE_FILE)
|
|
989
|
-
if raw is not None:
|
|
990
|
-
try:
|
|
991
|
-
flight = json.loads(raw).get("task_id")
|
|
992
|
-
except (ValueError, AttributeError, RecursionError):
|
|
993
|
-
return # unreadable: a task may be in flight, so no task is proposed as stale
|
|
994
|
-
reports =os.path.join(plan.root, ".ai", "reports")
|
|
995
|
-
logs = os.path.join(plan.root, ".ai", "state", "logs")
|
|
996
|
-
|
|
997
|
-
def files(base, rel, only_logs=False, keep=()):
|
|
998
|
-
out = []
|
|
999
|
-
for here, _dirs, names in os.walk(os.path.join(base, rel)):
|
|
1000
|
-
for n in names:
|
|
1001
|
-
full = os.path.join(here, n)
|
|
1002
|
-
if os.path.isfile(full) and not os.path.islink(full) and (not only_logs or n.endswith(".log")) \
|
|
1003
|
-
and n not in keep:
|
|
1004
|
-
out.append(os.path.relpath(full, plan.root).replace(os.sep, "/"))
|
|
1005
|
-
return out
|
|
1006
|
-
|
|
1007
|
-
for task_id in sorted(os.listdir(reports)) if os.path.isdir(reports) else []:
|
|
1008
|
-
journal = task_journal(plan.root, task_id)
|
|
1009
|
-
if journal is None or task_id == flight:
|
|
1010
|
-
continue
|
|
1011
|
-
closed, abandoned, last = journal
|
|
1012
|
-
if abandoned:
|
|
1013
|
-
why, targets = "[cleanup] abandoned task %s" % task_id, \
|
|
1014
|
-
files(reports, task_id) + files(logs, task_id, keep=("state.json",))
|
|
1015
|
-
elif not closed and now - last > timedelta(days=STALE_DAYS):
|
|
1016
|
-
why = "[cleanup] unclosed task %s, last event %s" % (task_id, last.strftime("%Y-%m-%d"))
|
|
1017
|
-
targets = files(reports, task_id) + files(logs, task_id)
|
|
1018
|
-
elif closed:
|
|
1019
|
-
why, targets = "[cleanup] log of closed task %s" % task_id, files(logs, task_id, only_logs=True)
|
|
1020
|
-
else:
|
|
1021
|
-
continue
|
|
1022
|
-
# .ai/reports/ is usually git-tracked; only .ai/state/ is git-ignored run state
|
|
1023
|
-
held = git_states(plan.root, ".ai/reports/" + task_id)
|
|
1024
|
-
skipped = set()
|
|
1025
|
-
for target in sorted(targets):
|
|
1026
|
-
unsafe = unsafe_path(plan.root, target)
|
|
1027
|
-
if unsafe: # --apply would abort on it: say so instead of proposing it
|
|
1028
|
-
if unsafe not in skipped:
|
|
1029
|
-
skipped.add(unsafe)
|
|
1030
|
-
plan.hints.append("%s: not proposed for deletion, %s — remove it yourself if it should go"
|
|
1031
|
-
% (target, unsafe))
|
|
1032
|
-
continue
|
|
1033
|
-
note = why
|
|
1034
|
-
state = held(target) if target.startswith(".ai/reports/") else None
|
|
1035
|
-
if state == "tracked":
|
|
1036
|
-
note += "; tracked: git can restore a committed copy"
|
|
1037
|
-
elif state == "uncommitted":
|
|
1038
|
-
note += "; uncommitted: git holds no copy of it"
|
|
1039
|
-
elif state == "ignored":
|
|
1040
|
-
note += "; git-ignored: not recoverable through git"
|
|
1041
|
-
if state in ("tracked", "uncommitted"):
|
|
1042
|
-
plan.git_kept.add(target)
|
|
1043
|
-
if plan.confirm_cleanup:
|
|
1044
|
-
plan.add("delete", target, note, reason=why)
|
|
1045
|
-
plan.remove(target)
|
|
1046
|
-
else:
|
|
1047
|
-
plan.add("delete?", target, note, reason=why)
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
def deletion_hint(plan):
|
|
1051
|
-
"""M4: git is the rollback of a deletion or an overwrite; it must hold .ai/ first.
|
|
1052
|
-
A [cleanup] deletion of git-ignored run state is not counted: git does not roll it back;
|
|
1053
|
-
one under .ai/reports/ that git tracks, or should, is."""
|
|
1054
|
-
if any(i["action"] in ("delete?", "delete", "regenerate?") and i["target"].startswith(".ai/")
|
|
1055
|
-
and (not (i.get("reason") or "").startswith("[cleanup]") or i["target"] in plan.git_kept)
|
|
1056
|
-
for i in plan.items) \
|
|
1057
|
-
and git_dirty_ai(plan.root):
|
|
1058
|
-
plan.hints.append("commit .ai/ first: git is this update's rollback "
|
|
1059
|
-
"(`git status -- .ai` lists what it does not hold yet)")
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
TRACKED_IGNORED_SHOWN = 10 # per-file hints before tracked_ignored_hint counts the rest
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
def tracked_ignored_hint(plan):
|
|
1066
|
-
"""A file under .ai/state that git tracks although an ignore rule lists it: the rule
|
|
1067
|
-
has no effect on it. Read-only: the human untracks it; nothing is planned.
|
|
1068
|
-
At most TRACKED_IGNORED_SHOWN files get their own hint; one more line counts the rest."""
|
|
1069
|
-
try:
|
|
1070
|
-
res = subprocess.run(["git", "-C", plan.root, "ls-files", "-z", "-ci", "--exclude-standard",
|
|
1071
|
-
"--", ".ai/state"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
1072
|
-
timeout=30, check=False)
|
|
1073
|
-
except (OSError, subprocess.SubprocessError):
|
|
1074
|
-
return
|
|
1075
|
-
if res.returncode != 0:
|
|
1076
|
-
return
|
|
1077
|
-
# --literal-pathspecs: a * ? [ ] in a name is that name, not a pattern that matches its neighbours.
|
|
1078
|
-
all_cmd = "git ls-files -z -ci --exclude-standard -- .ai/state | xargs -0 -r git --literal-pathspecs rm --cached --"
|
|
1079
|
-
names = [os.fsdecode(raw) for raw in res.stdout.split(b"\0") if raw]
|
|
1080
|
-
for path in names[:TRACKED_IGNORED_SHOWN]:
|
|
1081
|
-
# A control character (or a byte that is not UTF-8, which fsdecode keeps as a lone
|
|
1082
|
-
# surrogate) in the name would forge a report line or break the copy-paste command:
|
|
1083
|
-
# show it escaped, and untrack by the NUL-safe listing.
|
|
1084
|
-
shown = "".join(c if c.isprintable() else repr(c)[1:-1] for c in path)
|
|
1085
|
-
if shown == path:
|
|
1086
|
-
plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
|
|
1087
|
-
"effect on a tracked file, so every run leaves the tree dirty and "
|
|
1088
|
-
"`git commit -a` sweeps it in; untrack it yourself, from the project root: "
|
|
1089
|
-
"`git --literal-pathspecs rm --cached -- %s` and commit (the file stays on disk)"
|
|
1090
|
-
% (shown, shlex.quote(path)))
|
|
1091
|
-
else:
|
|
1092
|
-
plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
|
|
1093
|
-
"effect on a tracked file, so every run leaves the tree dirty and "
|
|
1094
|
-
"`git commit -a` sweeps it in; this name cannot be typed safely, so "
|
|
1095
|
-
"untrack it, from the project root, with `%s`, which untracks EVERY tracked, ignored file "
|
|
1096
|
-
"under .ai/state, not only this one, and commit (the files stay on disk)"
|
|
1097
|
-
% (shown, all_cmd))
|
|
1098
|
-
if len(names) > TRACKED_IGNORED_SHOWN:
|
|
1099
|
-
plan.hints.append(".ai/state: %d more file(s) tracked by git but ignored by an ignore rule, "
|
|
1100
|
-
"not listed; untrack ALL tracked, ignored files under .ai/state "
|
|
1101
|
-
"(the ones listed above too), from the project root, with `%s` and commit (the files stay on disk)"
|
|
1102
|
-
% (len(names) - TRACKED_IGNORED_SHOWN, all_cmd))
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
def rendered_rules(plan):
|
|
1106
|
-
"""Every (file, slug) in the project that carries a rule block today, plus
|
|
1107
|
-
every path-scoped copy under .claude/rules/. Walked rather than remembered:
|
|
1108
|
-
a manifest would be one more file to keep true, and the markers already say
|
|
1109
|
-
who wrote them."""
|
|
1110
|
-
blocks, docs = [], []
|
|
1111
|
-
names = set(render_instructions.RULE_FILE.values())
|
|
1112
|
-
for dirpath, dirnames, filenames in os.walk(plan.root):
|
|
1113
|
-
dirnames[:] = sorted(d for d in dirnames if d not in RULES_SKIP
|
|
1114
|
-
and not os.path.lexists(os.path.join(dirpath, d, ".git")))
|
|
1115
|
-
rel_dir = os.path.relpath(dirpath, plan.root)
|
|
1116
|
-
for name in sorted(filenames):
|
|
1117
|
-
rel = name if rel_dir == "." else os.path.join(rel_dir, name)
|
|
1118
|
-
rel = rel.replace(os.sep, "/")
|
|
1119
|
-
if name in names:
|
|
1120
|
-
text = plan.read(rel)
|
|
1121
|
-
if text is None:
|
|
1122
|
-
continue
|
|
1123
|
-
for slug in render_instructions.rule_slugs_in(text.decode("utf-8", "replace")):
|
|
1124
|
-
blocks.append((rel, slug))
|
|
1125
|
-
elif rel.startswith(render_instructions.CLAUDE_RULES_DIR + "/") and name.endswith(".md"):
|
|
1126
|
-
text = plan.read(rel)
|
|
1127
|
-
if text is not None and render_instructions.RULE_GENERATED % name[:-3] in \
|
|
1128
|
-
text.decode("utf-8", "replace"):
|
|
1129
|
-
docs.append(rel)
|
|
1130
|
-
return blocks, docs
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
def nested_tree(root, rel_dir):
|
|
1134
|
-
"""The first directory below the root on the way to `rel_dir`, `rel_dir`
|
|
1135
|
-
included, that holds a .git entry (a worktree, a nested repository, a
|
|
1136
|
-
submodule), or None. The same predicate rendered_rules() prunes on."""
|
|
1137
|
-
# "." and "" are the root itself (dirs: ["."] reaches here as ""), whose
|
|
1138
|
-
# own .git is not a nested tree.
|
|
1139
|
-
parts = [p for p in rel_dir.split("/") if p not in ("", ".")]
|
|
1140
|
-
for i in range(1, len(parts) + 1):
|
|
1141
|
-
candidate = "/".join(parts[:i])
|
|
1142
|
-
if os.path.lexists(os.path.join(root, candidate, ".git")):
|
|
1143
|
-
return candidate
|
|
1144
|
-
return None
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
def unwalked_dir(root, rel_dir):
|
|
1148
|
-
"""Why the take-back walk (rendered_rules) never enters `rel_dir`, or None:
|
|
1149
|
-
a RULES_SKIP name on the way, or a symlinked directory on the way."""
|
|
1150
|
-
here = root
|
|
1151
|
-
for part in (p for p in rel_dir.split("/") if p not in ("", ".")):
|
|
1152
|
-
here = os.path.join(here, part)
|
|
1153
|
-
if part.casefold() in RULES_SKIP_FOLDED:
|
|
1154
|
-
return "%s is under a directory the take-back never walks (%s)" % (rel_dir, part)
|
|
1155
|
-
if os.path.islink(here):
|
|
1156
|
-
return "%s is a symlink, which the take-back never follows" % os.path.relpath(here, root).replace(os.sep, "/")
|
|
1157
|
-
return None
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
def _frozen_block(plan, target, slug):
|
|
1161
|
-
"""True when `target` is a readable regular file inside the project, holding a
|
|
1162
|
-
block for `slug`, at a place the take-back walk (rendered_rules) never enters: a
|
|
1163
|
-
RULES_SKIP name or a nested git tree on its real path. A symlink that leads inside
|
|
1164
|
-
the walked part of the project is removed by the same run; one that leads outside
|
|
1165
|
-
the project, a directory, a FIFO or an unreadable file is not read at all."""
|
|
1166
|
-
full = os.path.join(plan.root, target)
|
|
1167
|
-
try:
|
|
1168
|
-
real = os.path.realpath(full)
|
|
1169
|
-
base = os.path.realpath(plan.root)
|
|
1170
|
-
if os.path.commonpath([real, base]) != base or not os.path.isfile(real):
|
|
1171
|
-
return False
|
|
1172
|
-
rel_dir = os.path.relpath(os.path.dirname(real), base).replace(os.sep, "/")
|
|
1173
|
-
parts = [p for p in rel_dir.split("/") if p not in ("", ".")]
|
|
1174
|
-
if not (any(p in RULES_SKIP for p in parts) or nested_tree(base, rel_dir) is not None):
|
|
1175
|
-
return False
|
|
1176
|
-
held = plan.read(target)
|
|
1177
|
-
except (OSError, ValueError):
|
|
1178
|
-
return False
|
|
1179
|
-
return held is not None and slug in render_instructions.rule_slugs_in(held.decode("utf-8", "replace"))
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
def rules_update(plan, runtimes):
|
|
1183
|
-
"""Render `.ai/rules/<slug>.md` into the instruction file of every directory
|
|
1184
|
-
it names, and take back what a rule no longer asks for. The text around a
|
|
1185
|
-
block belongs to the project and is never read for meaning, only preserved.
|
|
1186
|
-
A rule whose source this run deletes is no longer rendered."""
|
|
1187
|
-
rules_path = os.path.join(plan.root, RULES_DIR)
|
|
1188
|
-
if not os.path.isdir(rules_path):
|
|
1189
|
-
return
|
|
1190
|
-
try:
|
|
1191
|
-
rules = [r for r in render_instructions.load_rules(rules_path)
|
|
1192
|
-
if "%s/%s.md" % (RULES_DIR, r.slug) not in plan.removed]
|
|
1193
|
-
except render_instructions.RenderError as exc:
|
|
1194
|
-
plan.hints.append("%s — that rule is not rendered; the rest are" % exc)
|
|
1195
|
-
return
|
|
1196
|
-
wanted = render_instructions.rule_targets(rules, runtimes)
|
|
1197
|
-
managed = {(i["target"], i["slug"]) for i in wanted if i["kind"] == "block"}
|
|
1198
|
-
kept_docs = {i["target"] for i in wanted if i["kind"] == "doc"}
|
|
1199
|
-
|
|
1200
|
-
for item in wanted:
|
|
1201
|
-
source = "%s/%s.md" % (RULES_DIR, item["slug"])
|
|
1202
|
-
target, content = item["target"], item["content"]
|
|
1203
|
-
tree = nested_tree(plan.root, os.path.dirname(target)) if item["kind"] == "block" else None
|
|
1204
|
-
if tree is not None:
|
|
1205
|
-
# Not this project's file, and the take-back never walks there: a
|
|
1206
|
-
# block written there could never be taken back again.
|
|
1207
|
-
plan.hints.append("%s: %s is a nested git tree (worktree, repository or submodule)"
|
|
1208
|
-
" — rule %s is not rendered there" % (target, tree, item["slug"]))
|
|
1209
|
-
continue
|
|
1210
|
-
skip = unwalked_dir(plan.root, os.path.dirname(target)) if item["kind"] == "block" else None
|
|
1211
|
-
if skip is not None:
|
|
1212
|
-
hint = "%s: %s — rule %s is not rendered there" % (target, skip, item["slug"])
|
|
1213
|
-
if _frozen_block(plan, target, item["slug"]):
|
|
1214
|
-
hint += ("; an earlier rendering of it is still there, the take-back never walks there:"
|
|
1215
|
-
" remove that block by hand")
|
|
1216
|
-
plan.hints.append(hint)
|
|
1217
|
-
continue
|
|
1218
|
-
current = plan.read(target)
|
|
1219
|
-
if item["kind"] == "doc":
|
|
1220
|
-
new = content.encode()
|
|
1221
|
-
if current is None:
|
|
1222
|
-
plan.add("create", target, "rendered from " + source, content=new)
|
|
1223
|
-
elif current != new:
|
|
1224
|
-
plan.add("regenerate", target, "rendered from " + source, content=new)
|
|
1225
|
-
continue
|
|
1226
|
-
text = "" if current is None else current.decode("utf-8")
|
|
1227
|
-
new = render_instructions.splice_rule(text, item["slug"], content).encode()
|
|
1228
|
-
if current is None:
|
|
1229
|
-
plan.add("create", target, "rule block %s from %s" % (item["slug"], source),
|
|
1230
|
-
content=new)
|
|
1231
|
-
elif current != new:
|
|
1232
|
-
plan.add("regenerate", target, "rule block %s from %s" % (item["slug"], source),
|
|
1233
|
-
content=new)
|
|
1234
|
-
|
|
1235
|
-
blocks, docs = rendered_rules(plan)
|
|
1236
|
-
for target, slug in blocks:
|
|
1237
|
-
if (target, slug) in managed:
|
|
1238
|
-
continue
|
|
1239
|
-
text = plan.read(target).decode("utf-8")
|
|
1240
|
-
# The block is ours; the file is not. Take the block, leave the file.
|
|
1241
|
-
plan.add("regenerate", target, "rule block %s removed (%s/%s.md no longer renders here)"
|
|
1242
|
-
% (slug, RULES_DIR, slug),
|
|
1243
|
-
content=render_instructions.strip_rule(text, slug).encode())
|
|
1244
|
-
for target in docs:
|
|
1245
|
-
if target in kept_docs:
|
|
1246
|
-
continue
|
|
1247
|
-
# The whole file is a rendered copy, so taking it away is a deletion, and
|
|
1248
|
-
# a deletion waits for a human exactly as a migration's does.
|
|
1249
|
-
slug = os.path.basename(target)[:-3]
|
|
1250
|
-
why = "[rules] %s" % ("%s/%s.md no longer sets paths:" % (RULES_DIR, slug)
|
|
1251
|
-
if slug in {r.slug for r in rules}
|
|
1252
|
-
else "source %s/%s.md is gone" % (RULES_DIR, slug))
|
|
1253
|
-
if plan.confirm_delete:
|
|
1254
|
-
plan.add("delete", target, why, reason=why)
|
|
1255
|
-
plan.remove(target)
|
|
1256
|
-
else:
|
|
1257
|
-
plan.add("delete?", target, why, reason=why)
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
def gitignore_update(plan, snippet_path):
|
|
1261
|
-
snippet = read(snippet_path).decode().splitlines()
|
|
1262
|
-
current = plan.read(".gitignore") or b""
|
|
1263
|
-
have = set(current.decode().splitlines())
|
|
1264
|
-
missing = [l for l in snippet if l.strip() and not l.startswith("#") and l not in have]
|
|
1265
|
-
if not missing:
|
|
1266
|
-
return
|
|
1267
|
-
header = [l for l in snippet if l.startswith("#") and l not in have]
|
|
1268
|
-
add = "\n".join(header + missing) + "\n"
|
|
1269
|
-
sep = b"\n" if current and not current.endswith(b"\n") else b""
|
|
1270
|
-
new = current + sep + add.encode()
|
|
1271
|
-
# Replace an earlier .gitignore item from this run instead of stacking two.
|
|
1272
|
-
plan.items = [i for i in plan.items if i["target"] != ".gitignore"]
|
|
1273
|
-
plan.add("regenerate", ".gitignore", "entries appended: " + ", ".join(missing), content=new)
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
def budget_hints(plan, runtimes):
|
|
1277
|
-
"""What the always-loaded block costs this project after this run.
|
|
1278
|
-
|
|
1279
|
-
Downstream the budget is advisory (R4): the plugin measures its own block
|
|
1280
|
-
and says what it costs, but a project's own file is never refused. A block
|
|
1281
|
-
that is over budget and still the one the plugin installed is the plugin's
|
|
1282
|
-
to trim; an edited one is the project's."""
|
|
1283
|
-
try:
|
|
1284
|
-
cap = render_instructions.budgets(render_instructions.DEFAULT_SOURCE).get("project")
|
|
1285
|
-
except render_instructions.RenderError:
|
|
1286
|
-
return
|
|
1287
|
-
if not cap:
|
|
1288
|
-
return
|
|
1289
|
-
for rt in runtimes:
|
|
1290
|
-
name, block_tpl, _ = INSTRUCTION_FILE[rt]
|
|
1291
|
-
text = plan.read(name)
|
|
1292
|
-
if text is None:
|
|
1293
|
-
continue
|
|
1294
|
-
block = render_instructions.block_of(text.decode("utf-8", "replace"))
|
|
1295
|
-
if block is None:
|
|
1296
|
-
continue
|
|
1297
|
-
size = len(block.encode("utf-8"))
|
|
1298
|
-
if size <= cap:
|
|
1299
|
-
continue
|
|
1300
|
-
shipped = read(os.path.join(TEMPLATES["ai-init"], block_tpl)).rstrip(b"\n")
|
|
1301
|
-
whose = ("the plugin ships it that size — report it"
|
|
1302
|
-
if block.encode("utf-8").rstrip() == shipped
|
|
1303
|
-
else "it was edited here, so the plugin will not rewrite it: trimming it is yours")
|
|
1304
|
-
plan.hints.append("%s: the managed block is %d B, budget %d B — %s"
|
|
1305
|
-
% (name, size, cap, whose))
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
def constitution_hint(plan):
|
|
1309
|
-
"""R10: the cap is hard for the shipped template and advisory for a project.
|
|
1310
|
-
The rule lives in render_instructions; this only relays what it says."""
|
|
1311
|
-
rel = "docs/sdlc/constitution.md"
|
|
1312
|
-
path = os.path.join(plan.root, rel)
|
|
1313
|
-
if not os.path.isfile(path):
|
|
1314
|
-
return
|
|
1315
|
-
err = io.StringIO()
|
|
1316
|
-
with contextlib.redirect_stderr(err):
|
|
1317
|
-
render_instructions.constitution(path, out=io.StringIO())
|
|
1318
|
-
for line in err.getvalue().splitlines():
|
|
1319
|
-
plan.hints.append(line.replace(path, rel))
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
def verification_hints(plan):
|
|
1323
|
-
"""R10: update's empty-field hints read verification.md."""
|
|
1324
|
-
text = plan.read(VERIFICATION_MD)
|
|
1325
|
-
if text is None:
|
|
1326
|
-
return
|
|
1327
|
-
for field, why in (
|
|
1328
|
-
("verify_command", "the feedback loop needs it"),
|
|
1329
|
-
("step_test_command", "a step runs only its own tests"),
|
|
1330
|
-
("e2e_command", "e2e runs once at the end of a task; write 'none' if there is no suite"),
|
|
1331
|
-
("lint_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
|
|
1332
|
-
("typecheck_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
|
|
1333
|
-
):
|
|
1334
|
-
if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", text):
|
|
1335
|
-
plan.hints.append("%s: %s is empty — %s" % (VERIFICATION_MD, field, why))
|
|
1336
|
-
elif not re.search(rb"(?m)^" + field.encode() + rb":", text):
|
|
1337
|
-
plan.hints.append("%s: %s is missing — %s" % (VERIFICATION_MD, field, why))
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
PHASE = {"split": 0, "delete": 2, "delete?": 2}
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None, cleanup_via=None):
|
|
1344
|
-
"""D3: split_once -> regenerate -> shared -> block per runtime -> rules -> local -> gitignore, cleanup, hints.
|
|
1345
|
-
The caller has run check_kept: nothing here reads a kept file for meaning."""
|
|
1346
|
-
plan = Plan(root)
|
|
1347
|
-
plan.confirm_cleanup = confirm_cleanup
|
|
1348
|
-
plan.cleanup_via = cleanup_via
|
|
1349
|
-
plan.confirm_delete = confirm_delete
|
|
1350
|
-
plan.confirm_via = confirm_via
|
|
1351
|
-
has_ai = os.path.isdir(os.path.join(root, ".ai"))
|
|
1352
|
-
has_sdlc = os.path.isdir(os.path.join(root, "docs", "sdlc"))
|
|
1353
|
-
if not (has_ai or has_sdlc):
|
|
1354
|
-
return None
|
|
1355
|
-
history = History(HISTORY)
|
|
1356
|
-
runtimes = project_runtimes(root)
|
|
1357
|
-
plan.version_from = project_version(root) if has_ai else None
|
|
1358
|
-
|
|
1359
|
-
if has_sdlc:
|
|
1360
|
-
tpl = TEMPLATES["project-init"]
|
|
1361
|
-
for src, target in SDLC_CREATE: # the project's own from then on
|
|
1362
|
-
if not plan.exists(target):
|
|
1363
|
-
plan.add("create", target, content=read(os.path.join(tpl, src)).replace(
|
|
1364
|
-
b"{{PROJECT}}", os.path.basename(root).encode()))
|
|
1365
|
-
for rt in runtimes:
|
|
1366
|
-
for src, target, kind in RUNTIME_MAP[rt]:
|
|
1367
|
-
content = read(os.path.join(tpl, src))
|
|
1368
|
-
if kind == "create":
|
|
1369
|
-
if not plan.exists(target):
|
|
1370
|
-
plan.add("create", target, content=content)
|
|
1371
|
-
else:
|
|
1372
|
-
regenerate(plan, history, "project-init/" + src, target, content)
|
|
1373
|
-
gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
1374
|
-
|
|
1375
|
-
if has_ai:
|
|
1376
|
-
tpl = TEMPLATES["ai-init"]
|
|
1377
|
-
split_once(plan, history)
|
|
1378
|
-
files = []
|
|
1379
|
-
for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
|
|
1380
|
-
for n in names:
|
|
1381
|
-
files.append(os.path.relpath(os.path.join(dirpath, n), tpl).replace(os.sep, "/"))
|
|
1382
|
-
for rel in sorted(files):
|
|
1383
|
-
if rel in NOT_COPIED or rel in (VERIFICATION_MD, WEIGHT_PROJECT):
|
|
1384
|
-
continue
|
|
1385
|
-
content = read(os.path.join(tpl, rel))
|
|
1386
|
-
if rel in CREATE_ONLY or rel.startswith(CREATE_ONLY_DIRS):
|
|
1387
|
-
if not plan.exists(rel):
|
|
1388
|
-
plan.add("create", rel, content=content)
|
|
1389
|
-
else:
|
|
1390
|
-
regenerate(plan, history, "ai-init/" + rel, rel, content)
|
|
1391
|
-
weight_md(plan, history)
|
|
1392
|
-
shared_update(plan, history, runtimes)
|
|
1393
|
-
rule_sources(plan)
|
|
1394
|
-
local_cache(plan)
|
|
1395
|
-
cleanup_update(plan)
|
|
1396
|
-
for rt in runtimes:
|
|
1397
|
-
block_update(plan, history, rt)
|
|
1398
|
-
rules_update(plan, runtimes)
|
|
1399
|
-
deletion_hint(plan)
|
|
1400
|
-
gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
1401
|
-
tracked_ignored_hint(plan)
|
|
1402
|
-
verification_hints(plan)
|
|
1403
|
-
budget_hints(plan, runtimes)
|
|
1404
|
-
if plan.items and plan.exists(STATE_FILE):
|
|
1405
|
-
plan.hints.append("%s: a task is in flight while files are regenerated — check that the files "
|
|
1406
|
-
"its current step may touch still exist afterwards" % STATE_FILE)
|
|
1407
|
-
# Last of the items: an apply that stops short leaves the version alone, and the next run plans the rest again.
|
|
1408
|
-
ours = template_version()
|
|
1409
|
-
if plan.version_from != ours:
|
|
1410
|
-
plan.add("regenerate" if plan.exists(VERSION_FILE) else "create", VERSION_FILE,
|
|
1411
|
-
"schema %d" % ours, content=b"%d\n" % ours)
|
|
1412
|
-
if has_sdlc:
|
|
1413
|
-
constitution_hint(plan)
|
|
1414
|
-
plan.items.sort(key=lambda i: 3 if i["target"] == VERSION_FILE else PHASE.get(i["action"], 1))
|
|
1415
|
-
return plan
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
# ---------------------------------------------------------------- output
|
|
1419
|
-
class Abort(Exception):
|
|
1420
|
-
"""A precondition failed: the project is not what the dry run described."""
|
|
1421
|
-
|
|
1422
|
-
def __init__(self, item, why):
|
|
1423
|
-
super().__init__(why)
|
|
1424
|
-
self.item, self.why = item, why
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
def current_sha(root, target):
|
|
1428
|
-
"""The sha of a target now, or None when it is absent. A path that is not a
|
|
1429
|
-
regular file has no content this tool may replace."""
|
|
1430
|
-
path = os.path.join(root, target)
|
|
1431
|
-
if os.path.islink(path) or (os.path.exists(path) and not os.path.isfile(path)):
|
|
1432
|
-
raise Abort(None, "%s is not a regular file" % target)
|
|
1433
|
-
return sha(read(path)) if os.path.isfile(path) else None
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
def check(plan, item, target, expect, written):
|
|
1437
|
-
"""The target must still be what the dry run read, or what this run wrote."""
|
|
1438
|
-
try:
|
|
1439
|
-
now_sha = current_sha(plan.root, target)
|
|
1440
|
-
except Abort as exc:
|
|
1441
|
-
raise Abort(item, exc.why) from exc
|
|
1442
|
-
want = written.get(target, expect)
|
|
1443
|
-
if now_sha != want:
|
|
1444
|
-
raise Abort(item, "%s changed since the dry run read it" % target)
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
def default_mode():
|
|
1448
|
-
mask = os.umask(0)
|
|
1449
|
-
os.umask(mask)
|
|
1450
|
-
return 0o666 & ~mask
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
def write_file(root, target, content, mode=None):
|
|
1454
|
-
"""Atomic, like state.py: a crash leaves the old file or the new one, never
|
|
1455
|
-
half of one. The temp file is created with mkstemp in the target's directory,
|
|
1456
|
-
so a planted name cannot redirect the write, and a symlinked target is
|
|
1457
|
-
refused rather than followed. The target keeps the mode it had. A symlinked
|
|
1458
|
-
*directory* on the way to it is still followed, as every writer here always
|
|
1459
|
-
has."""
|
|
1460
|
-
path = os.path.join(root, target)
|
|
1461
|
-
if os.path.islink(path) or (os.path.exists(path) and not os.path.isfile(path)):
|
|
1462
|
-
raise Abort(None, "%s is not a regular file" % target)
|
|
1463
|
-
parent = os.path.dirname(path) or "."
|
|
1464
|
-
os.makedirs(parent, exist_ok=True)
|
|
1465
|
-
if mode is None:
|
|
1466
|
-
mode = os.stat(path).st_mode & 0o7777 if os.path.isfile(path) else default_mode()
|
|
1467
|
-
fd, tmp = tempfile.mkstemp(dir=parent, prefix=os.path.basename(path) + ".", suffix=".tmp")
|
|
1468
|
-
try:
|
|
1469
|
-
with os.fdopen(fd, "wb") as fh:
|
|
1470
|
-
fh.write(content)
|
|
1471
|
-
os.chmod(tmp, mode)
|
|
1472
|
-
os.replace(tmp, path)
|
|
1473
|
-
except BaseException:
|
|
1474
|
-
if os.path.exists(tmp):
|
|
1475
|
-
os.unlink(tmp)
|
|
1476
|
-
raise
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
def keep_original(plan, target):
|
|
1480
|
-
"""A faithful copy of what a move or a delete takes away, mode included.
|
|
1481
|
-
Nothing else is ever lost, so nothing else is copied."""
|
|
1482
|
-
path = os.path.join(plan.root, target)
|
|
1483
|
-
write_file(plan.root, os.path.join(plan.report_dir, "original", target),
|
|
1484
|
-
read(path), mode=os.stat(path).st_mode & 0o7777)
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
def record_run(plan, ops, deletions):
|
|
1489
|
-
"""The record of what was taken away or overwritten, rewritten after every such operation:
|
|
1490
|
-
an abort must not lose the note of a change a human approved. A second run on the same
|
|
1491
|
-
day appends to the record instead of replacing it."""
|
|
1492
|
-
path = os.path.join(plan.report_dir, "migration.json")
|
|
1493
|
-
existing = {}
|
|
1494
|
-
full = os.path.join(plan.root, path)
|
|
1495
|
-
if os.path.isfile(full):
|
|
1496
|
-
try:
|
|
1497
|
-
existing = json.loads(read(full), object_pairs_hook=OrderedDict)
|
|
1498
|
-
except (ValueError, RecursionError): # too deep reads as malformed
|
|
1499
|
-
existing = {}
|
|
1500
|
-
if not isinstance(existing, dict): # hand-edited into another shape
|
|
1501
|
-
existing = {}
|
|
1502
|
-
record = OrderedDict()
|
|
1503
|
-
record["from"] = existing.get("from", plan.version_from)
|
|
1504
|
-
# The version the project is actually at now, read from the disk: this record is
|
|
1505
|
-
# written before the files change, and an aborted run never reaches the template's.
|
|
1506
|
-
version_path = os.path.join(plan.root, VERSION_FILE)
|
|
1507
|
-
on_disk = read(version_path).decode().strip() if os.path.isfile(version_path) else ""
|
|
1508
|
-
record["to"] = int(on_disk) if on_disk.isdigit() else record["from"]
|
|
1509
|
-
record["applied_at"] = utc_now()
|
|
1510
|
-
# A recovery re-run replays the same operation: record it once.
|
|
1511
|
-
record["ops"] = list(existing.get("ops", []))
|
|
1512
|
-
record["ops"] += [o for o in ops if o not in record["ops"]]
|
|
1513
|
-
record["deletions"] = list(existing.get("deletions", []))
|
|
1514
|
-
record["deletions"] += [d for d in deletions
|
|
1515
|
-
if not any(e.get("path") == d["path"] for e in record["deletions"])]
|
|
1516
|
-
if existing and all(existing.get(k) == record[k] for k in ("from", "to", "ops", "deletions")):
|
|
1517
|
-
return # a run that changed nothing leaves the record as it was
|
|
1518
|
-
write_file(plan.root, path, dump_json(record))
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
def apply(plan):
|
|
1522
|
-
"""Write the plan, in order. Every item checks its target first; a violation
|
|
1523
|
-
or a failed write aborts before the next item, leaving everything after it
|
|
1524
|
-
unwritten. Every operation is idempotent, so the re-run is the recovery."""
|
|
1525
|
-
written, recorded = {}, []
|
|
1526
|
-
for item in plan.items:
|
|
1527
|
-
try:
|
|
1528
|
-
apply_item(plan, item, written, recorded)
|
|
1529
|
-
except Abort as exc:
|
|
1530
|
-
raise Abort(exc.item or item, exc.why) from exc
|
|
1531
|
-
except OSError as exc:
|
|
1532
|
-
raise Abort(item, str(exc)) from exc
|
|
1533
|
-
# The run finished: say which version it actually reached.
|
|
1534
|
-
try:
|
|
1535
|
-
if recorded or os.path.isfile(os.path.join(plan.root, plan.report_dir, "migration.json")):
|
|
1536
|
-
record_run(plan, [], [])
|
|
1537
|
-
except OSError as exc:
|
|
1538
|
-
raise Abort(None, str(exc)) from exc
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
def apply_item(plan, item, written, recorded):
|
|
1542
|
-
target, action = item["target"], item["action"]
|
|
1543
|
-
reason = item.get("reason") or ""
|
|
1544
|
-
cleanup = reason.startswith("[cleanup]")
|
|
1545
|
-
if action in ("delete?", "regenerate?"): # listed only, until a human confirms it
|
|
1546
|
-
return
|
|
1547
|
-
by = dict(confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
|
|
1548
|
-
via=plan.cleanup_via if cleanup else plan.confirm_via, at=utc_now())
|
|
1549
|
-
if action == "delete":
|
|
1550
|
-
# the plan-time symlink check is repeated: a path swapped since is refused
|
|
1551
|
-
why = unsafe_path(plan.root, target)
|
|
1552
|
-
if why:
|
|
1553
|
-
raise Abort(item, why)
|
|
1554
|
-
check(plan, item, target, item["expect"], written)
|
|
1555
|
-
# git-ignored task state: a [cleanup] deletion of it is NOT recoverable through git, and
|
|
1556
|
-
# copying what it removes would defeat the cleanup, so no original/ copy is kept. A report
|
|
1557
|
-
# file git tracks, or should, is kept like any other deletion.
|
|
1558
|
-
if not target.startswith(".ai/state/") and (not cleanup or target in plan.git_kept):
|
|
1559
|
-
keep_original(plan, target)
|
|
1560
|
-
recorded.append(target)
|
|
1561
|
-
# Recorded before the file is gone: an abort later must not lose the note
|
|
1562
|
-
# of what was taken away, or of who approved it.
|
|
1563
|
-
record_run(plan, [dict(action=action, path=target)], [dict(path=target, **by)])
|
|
1564
|
-
os.remove(os.path.join(plan.root, target))
|
|
1565
|
-
written[target] = None
|
|
1566
|
-
if reason.startswith(("[cleanup]", "[local]")):
|
|
1567
|
-
prune_empty(plan.root, target)
|
|
1568
|
-
return
|
|
1569
|
-
check(plan, item, target, item["expect"], written)
|
|
1570
|
-
if item["keep"] and item["expect"] is not None and target not in written:
|
|
1571
|
-
# A change a human confirmed overwrites what they wrote: keep it, so it can be read back.
|
|
1572
|
-
if not os.path.exists(os.path.join(plan.root, plan.original_of(target))):
|
|
1573
|
-
keep_original(plan, target)
|
|
1574
|
-
recorded.append(target)
|
|
1575
|
-
record_run(plan, [dict(action=action, path=target, **by)], [])
|
|
1576
|
-
write_file(plan.root, target, item["content"])
|
|
1577
|
-
written[target] = sha(item["content"])
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
def prune_empty(root, target):
|
|
1581
|
-
"""The directories a cleanup or cache deletion left empty, up to (not including)
|
|
1582
|
-
.ai/reports, .ai/state/logs and .ai/local; a directory with anything in it stays."""
|
|
1583
|
-
top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else \
|
|
1584
|
-
".ai/local" if target.startswith(LOCAL_CACHE + "/") else ".ai/reports"
|
|
1585
|
-
here = os.path.dirname(target)
|
|
1586
|
-
while here.startswith(top + "/"):
|
|
1587
|
-
try:
|
|
1588
|
-
os.rmdir(os.path.join(root, here))
|
|
1589
|
-
except OSError:
|
|
1590
|
-
return
|
|
1591
|
-
here = os.path.dirname(here)
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
def report(plan, applied):
|
|
1595
|
-
auto = [i for i in plan.items if i["action"] not in ("delete?", "regenerate?")]
|
|
1596
|
-
gated = [i for i in plan.items if i["action"] in ("delete?", "regenerate?")]
|
|
1597
|
-
head = "applied" if applied else "dry run, nothing written; --apply to write"
|
|
1598
|
-
print("project-update: %s (%s)" % (plan.root, head))
|
|
1599
|
-
if not plan.items:
|
|
1600
|
-
print(" up to date with the installed plugin")
|
|
1601
|
-
# a cleanup is one line per directory, however many files it holds
|
|
1602
|
-
rows, groups = [], {}
|
|
1603
|
-
for i in plan.items:
|
|
1604
|
-
if (i["reason"] or "").startswith("[cleanup]"):
|
|
1605
|
-
parts = i["target"].split("/")
|
|
1606
|
-
where = "/".join(parts[:4] if i["target"].startswith(".ai/state/logs/") else parts[:3])
|
|
1607
|
-
key = (where, i["action"], i["note"])
|
|
1608
|
-
if key in groups:
|
|
1609
|
-
groups[key]["count"] += 1
|
|
1610
|
-
continue
|
|
1611
|
-
row = dict(i, count=1)
|
|
1612
|
-
groups[key] = row
|
|
1613
|
-
row["target"] = where
|
|
1614
|
-
i = row
|
|
1615
|
-
rows.append(i)
|
|
1616
|
-
for (where, _action, _note), row in groups.items():
|
|
1617
|
-
row["target"] = "%s/ (%d file%s)" % (where, row["count"], "" if row["count"] == 1 else "s")
|
|
1618
|
-
width = max([len(i["target"]) for i in rows] + [10])
|
|
1619
|
-
for i in rows:
|
|
1620
|
-
note = i["note"]
|
|
1621
|
-
if i["action"] in ("delete?", "regenerate?"):
|
|
1622
|
-
if (i["reason"] or "").startswith("[cleanup]"):
|
|
1623
|
-
note += "; needs --apply --confirm-cleanup NAME"
|
|
1624
|
-
if i["target"].startswith(".ai/state/"):
|
|
1625
|
-
note += " (git-ignored: not recoverable through git)"
|
|
1626
|
-
else:
|
|
1627
|
-
note += "; needs --apply --confirm-delete NAME"
|
|
1628
|
-
print(" %-9s %-*s %s" % (i["action"], width, i["target"], note))
|
|
1629
|
-
for h in plan.hints:
|
|
1630
|
-
print(" hint " + h)
|
|
1631
|
-
tail = ""
|
|
1632
|
-
# the summary names the flag each row names: [cleanup] rows settle with --confirm-cleanup only
|
|
1633
|
-
cleanups = [i for i in gated if (i["reason"] or "").startswith("[cleanup]")]
|
|
1634
|
-
if len(gated) > len(cleanups):
|
|
1635
|
-
tail += ", %d change(s) awaiting --confirm-delete" % (len(gated) - len(cleanups))
|
|
1636
|
-
if cleanups:
|
|
1637
|
-
tail += ", %d cleanup file(s) awaiting --confirm-cleanup" % len(cleanups)
|
|
1638
|
-
print("%d automatic%s" % (len(auto), tail))
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
def main():
|
|
1642
|
-
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
1643
|
-
ap.add_argument("root", nargs="?", default=".")
|
|
1644
|
-
ap.add_argument("--apply", action="store_true")
|
|
1645
|
-
ap.add_argument("--check", action="store_true")
|
|
1646
|
-
ap.add_argument("--budget", action="store_true",
|
|
1647
|
-
help="with --check: exit 1 when a root instruction file is over its byte budget")
|
|
1648
|
-
ap.add_argument("--confirm-delete", metavar="NAME",
|
|
1649
|
-
help="a human confirms this run's gated rows (regenerate?, delete?), and is recorded in "
|
|
1650
|
-
"the migration report; only with --apply")
|
|
1651
|
-
ap.add_argument("--confirm-cleanup", metavar="NAME",
|
|
1652
|
-
help="a human confirms this run's [cleanup] deletions (run logs and stale task files, "
|
|
1653
|
-
"git-ignored and not recoverable through git); only with --apply. "
|
|
1654
|
-
"--confirm-delete does not settle them")
|
|
1655
|
-
args = ap.parse_args()
|
|
1656
|
-
confirm_via = cleanup_via = None
|
|
1657
|
-
for flag, name in (("--confirm-delete", args.confirm_delete), ("--confirm-cleanup", args.confirm_cleanup)):
|
|
1658
|
-
if name is None:
|
|
1659
|
-
continue
|
|
1660
|
-
if not args.apply:
|
|
1661
|
-
ap.error("%s only makes sense with --apply" % flag)
|
|
1662
|
-
if not name.strip():
|
|
1663
|
-
ap.error("%s needs the name of the human who confirmed the change" % flag)
|
|
1664
|
-
if placeholder_name(name):
|
|
1665
|
-
ap.error("%s got the placeholder %r; type your own name, it is the audit trail"
|
|
1666
|
-
% (flag, name.strip()))
|
|
1667
|
-
passed, via, why = human_gate()
|
|
1668
|
-
if not passed:
|
|
1669
|
-
# Under an agent-session marker the AI_UNATTENDED route is the launcher's
|
|
1670
|
-
# to declare, not the agent's to type: the hint names it only otherwise.
|
|
1671
|
-
hint = "run the same command yourself in a terminal outside Claude Code or Codex"
|
|
1672
|
-
if "agent session" not in why:
|
|
1673
|
-
hint += ", or set AI_UNATTENDED=1 if a launcher runs it unattended on your behalf"
|
|
1674
|
-
return refuse("%s is typed by a human, and %s" % (flag, why), hint)
|
|
1675
|
-
if flag == "--confirm-delete":
|
|
1676
|
-
confirm_via = via
|
|
1677
|
-
else:
|
|
1678
|
-
cleanup_via = via
|
|
1679
|
-
if args.budget and not args.check:
|
|
1680
|
-
ap.error("--budget only makes sense with --check")
|
|
1681
|
-
root = os.path.abspath(args.root)
|
|
1682
|
-
if args.check and args.budget:
|
|
1683
|
-
offenders = budget_offenders(
|
|
1684
|
-
root, [INSTRUCTION_FILE[rt][0] for rt in project_runtimes(root)],
|
|
1685
|
-
render_instructions.budgets(render_instructions.DEFAULT_SOURCE), render_instructions.block_of)
|
|
1686
|
-
for line in offenders:
|
|
1687
|
-
print(line)
|
|
1688
|
-
return 1 if offenders else 0
|
|
1689
|
-
for name, tpl in TEMPLATES.items():
|
|
1690
|
-
if not os.path.isdir(tpl):
|
|
1691
|
-
print("project-update: %s templates not found at %s (run riskrails/install.sh)" % (name, tpl), file=sys.stderr)
|
|
1692
|
-
return 2
|
|
1693
|
-
if not (os.path.isdir(os.path.join(root, ".ai")) or os.path.isdir(os.path.join(root, "docs", "sdlc"))):
|
|
1694
|
-
print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /rr-setup first" % root, file=sys.stderr)
|
|
1695
|
-
return 2
|
|
1696
|
-
try:
|
|
1697
|
-
if os.path.isdir(os.path.join(root, ".ai")):
|
|
1698
|
-
project_version(root)
|
|
1699
|
-
bad = check_kept(root)
|
|
1700
|
-
if bad:
|
|
1701
|
-
return kept_refusal(bad)
|
|
1702
|
-
plan = build_plan(root, confirm_delete=args.confirm_delete, confirm_via=confirm_via,
|
|
1703
|
-
confirm_cleanup=args.confirm_cleanup, cleanup_via=cleanup_via)
|
|
1704
|
-
except SchemaError as exc:
|
|
1705
|
-
# --check is read by /rr-status, which reads stdout: it must see the reason.
|
|
1706
|
-
print("project-update: %s" % exc, file=sys.stdout if args.check else sys.stderr)
|
|
1707
|
-
return 2
|
|
1708
|
-
if args.check:
|
|
1709
|
-
pending = [i for i in plan.items if not i["action"].endswith("?")]
|
|
1710
|
-
waiting = [i for i in plan.items if i["action"].endswith("?") and not (i["reason"] or "").startswith("[cleanup]")]
|
|
1711
|
-
if pending:
|
|
1712
|
-
bits = ["%d file(s) to update" % len(pending)]
|
|
1713
|
-
if waiting:
|
|
1714
|
-
bits.append("%d change(s) a human must confirm" % len(waiting))
|
|
1715
|
-
print("project is behind the installed plugin: %s — run /rr-setup --update" % ", ".join(bits))
|
|
1716
|
-
return 1
|
|
1717
|
-
print("project matches the installed plugin"
|
|
1718
|
-
+ (" (%d change(s) wait for --confirm-delete)" % len(waiting) if waiting else ""))
|
|
1719
|
-
return 0
|
|
1720
|
-
if args.apply:
|
|
1721
|
-
try:
|
|
1722
|
-
apply(plan)
|
|
1723
|
-
except Abort as exc:
|
|
1724
|
-
where = "%s %s" % (exc.item["action"], exc.item["target"]) if exc.item else "the update"
|
|
1725
|
-
print("project-update: aborted at %s: %s — nothing after it was written; "
|
|
1726
|
-
"run the dry run again" % (where, exc.why), file=sys.stderr)
|
|
1727
|
-
return 3
|
|
1728
|
-
report(plan, args.apply)
|
|
1729
|
-
return 0
|
|
1730
|
-
|
|
52
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
53
|
+
try:
|
|
54
|
+
from rr_update import cli
|
|
55
|
+
except ImportError:
|
|
56
|
+
sys.exit("rr_update/ is missing beside update.py: re-run `npx riskrails`")
|
|
1731
57
|
|
|
1732
58
|
if __name__ == "__main__":
|
|
1733
|
-
sys.exit(main())
|
|
59
|
+
sys.exit(cli.main())
|