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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +11 -11
  5. package/hooks/AGENTS.md +0 -6
  6. package/hooks/CLAUDE.md +0 -6
  7. package/hooks/guard/paths.py +48 -1406
  8. package/hooks/guard/paths_base.py +59 -0
  9. package/hooks/guard/paths_bash.py +176 -0
  10. package/hooks/guard/paths_classify.py +205 -0
  11. package/hooks/guard/paths_fs.py +137 -0
  12. package/hooks/guard/paths_human.py +105 -0
  13. package/hooks/guard/paths_interp.py +162 -0
  14. package/hooks/guard/paths_movers.py +393 -0
  15. package/hooks/guard/paths_shell.py +288 -0
  16. package/package.json +1 -1
  17. package/skills/AGENTS.md +0 -6
  18. package/skills/CLAUDE.md +0 -6
  19. package/skills/ai-init/templates/gitignore.snippet +2 -0
  20. package/skills/ai-task/memory_cleanup.py +213 -0
  21. package/skills/ai-task/memory_compact.py +25 -0
  22. package/skills/ai-task/memory_config.py +120 -0
  23. package/skills/ai-task/memory_export.py +246 -0
  24. package/skills/ai-task/memory_format.py +7 -3
  25. package/skills/ai-task/memory_hook.py +172 -0
  26. package/skills/ai-task/memory_io.py +122 -0
  27. package/skills/ai-task/memory_migrate.py +187 -0
  28. package/skills/ai-task/memory_packages.py +83 -0
  29. package/skills/ai-task/memory_risks.py +84 -0
  30. package/skills/ai-task/memory_status.py +76 -0
  31. package/skills/ai-task/memory_sync.py +20 -1481
  32. package/skills/ai-task/memory_verify.py +316 -0
  33. package/skills/ai-task/rr_state/cli.py +1 -0
  34. package/skills/ai-task/rr_state/close.py +2 -2
  35. package/skills/ai-task/rr_state/memory_cmd.py +84 -64
  36. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  37. package/skills/project-update/history/index.json +2 -1
  38. package/skills/project-update/rr_update/__init__.py +1 -0
  39. package/skills/project-update/rr_update/apply.py +188 -0
  40. package/skills/project-update/rr_update/blocks.py +38 -0
  41. package/skills/project-update/rr_update/build.py +96 -0
  42. package/skills/project-update/rr_update/cleanup.py +194 -0
  43. package/skills/project-update/rr_update/cli.py +102 -0
  44. package/skills/project-update/rr_update/files.py +106 -0
  45. package/skills/project-update/rr_update/gate.py +60 -0
  46. package/skills/project-update/rr_update/hints.py +128 -0
  47. package/skills/project-update/rr_update/history.py +117 -0
  48. package/skills/project-update/rr_update/kept.py +120 -0
  49. package/skills/project-update/rr_update/paths.py +108 -0
  50. package/skills/project-update/rr_update/plan.py +123 -0
  51. package/skills/project-update/rr_update/report.py +51 -0
  52. package/skills/project-update/rr_update/rules.py +178 -0
  53. package/skills/project-update/rr_update/shared.py +74 -0
  54. package/skills/project-update/rr_update/siblings.py +37 -0
  55. package/skills/project-update/rr_update/weights.py +171 -0
  56. package/skills/project-update/update.py +8 -1682
  57. package/skills/rr-setup/SKILL.md +4 -2
  58. package/skills/rr-setup/setup.py +13 -12
@@ -0,0 +1,8 @@
1
+ # claude-agentic (.ai)
2
+ .ai/state/*.json
3
+ .ai/state/handoff.md
4
+ .ai/state/*.tmp
5
+ .ai/state/*.jsonl
6
+ .ai/local/
7
+ .ai/memory/
8
+ .ai/reports/
@@ -395,7 +395,8 @@
395
395
  "ai-init/gitignore.snippet": [
396
396
  "5c0619c1ee059477c9d92cf70b72da5d238d9176fc27ab0d38104ecba795624c",
397
397
  "a00db51859bb7943174017a34b4a100f98e53d6c3b32f73d8f87d6072016a709",
398
- "5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c"
398
+ "5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c",
399
+ "fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997"
399
400
  ],
400
401
  "ai-init/junie-guidelines.block.md": [
401
402
  "e8df28f07cb784d768bcafbced70fd15771afa61dbec7dc4c1c3f858f9002fec",
@@ -0,0 +1 @@
1
+ """rr_update: the pieces of skills/project-update/update.py, one module per concern, lower layers first."""
@@ -0,0 +1,188 @@
1
+ """Writing the plan: the preconditions, the atomic write, the record of what was taken away, and the abort.
2
+
3
+ shell: current_sha, check, default_mode, write_file, keep_original, record_run, apply, apply_item, prune_empty
4
+ core: Abort
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import tempfile
10
+ from collections import OrderedDict
11
+
12
+ from rr_update import files
13
+ from rr_update import paths
14
+
15
+
16
+ class Abort(Exception):
17
+ """A precondition failed: the project is not what the dry run described."""
18
+
19
+ def __init__(self, item, why):
20
+ super().__init__(why)
21
+ self.item, self.why = item, why
22
+
23
+
24
+ def current_sha(root, target):
25
+ """The sha of a target now, or None when it is absent. A path that is not a
26
+ regular file has no content this tool may replace."""
27
+ path = os.path.join(root, target)
28
+ if os.path.islink(path) or (os.path.exists(path) and not os.path.isfile(path)):
29
+ raise Abort(None, "%s is not a regular file" % target)
30
+ return files.sha(files.read(path)) if os.path.isfile(path) else None
31
+
32
+
33
+ def check(plan, item, target, expect, written):
34
+ """The target must still be what the dry run read, or what this run wrote."""
35
+ try:
36
+ now_sha = current_sha(plan.root, target)
37
+ except Abort as exc:
38
+ raise Abort(item, exc.why) from exc
39
+ want = written.get(target, expect)
40
+ if now_sha != want:
41
+ raise Abort(item, "%s changed since the dry run read it" % target)
42
+
43
+
44
+ def default_mode():
45
+ mask = os.umask(0)
46
+ os.umask(mask)
47
+ return 0o666 & ~mask
48
+
49
+
50
+ def write_file(root, target, content, mode=None):
51
+ """Atomic, like state.py: a crash leaves the old file or the new one, never
52
+ half of one. The temp file is created with mkstemp in the target's directory,
53
+ so a planted name cannot redirect the write, and a symlinked target is
54
+ refused rather than followed. The target keeps the mode it had. A symlinked
55
+ *directory* on the way to it is still followed, as every writer here always
56
+ has."""
57
+ path = os.path.join(root, target)
58
+ if os.path.islink(path) or (os.path.exists(path) and not os.path.isfile(path)):
59
+ raise Abort(None, "%s is not a regular file" % target)
60
+ parent = os.path.dirname(path) or "."
61
+ os.makedirs(parent, exist_ok=True)
62
+ if mode is None:
63
+ mode = os.stat(path).st_mode & 0o7777 if os.path.isfile(path) else default_mode()
64
+ fd, tmp = tempfile.mkstemp(dir=parent, prefix=os.path.basename(path) + ".", suffix=".tmp")
65
+ try:
66
+ with os.fdopen(fd, "wb") as fh:
67
+ fh.write(content)
68
+ os.chmod(tmp, mode)
69
+ os.replace(tmp, path)
70
+ except BaseException:
71
+ if os.path.exists(tmp):
72
+ os.unlink(tmp)
73
+ raise
74
+
75
+
76
+ def keep_original(plan, target):
77
+ """A faithful copy of what a move or a delete takes away, mode included.
78
+ Nothing else is ever lost, so nothing else is copied."""
79
+ path = os.path.join(plan.root, target)
80
+ write_file(plan.root, os.path.join(plan.report_dir, "original", target),
81
+ files.read(path), mode=os.stat(path).st_mode & 0o7777)
82
+
83
+
84
+
85
+ def record_run(plan, ops, deletions):
86
+ """The record of what was taken away or overwritten, rewritten after every such operation:
87
+ an abort must not lose the note of a change a human approved. A second run on the same
88
+ day appends to the record instead of replacing it."""
89
+ path = os.path.join(plan.report_dir, "migration.json")
90
+ existing = {}
91
+ full = os.path.join(plan.root, path)
92
+ if os.path.isfile(full):
93
+ try:
94
+ existing = json.loads(files.read(full), object_pairs_hook=OrderedDict)
95
+ except (ValueError, RecursionError): # too deep reads as malformed
96
+ existing = {}
97
+ if not isinstance(existing, dict): # hand-edited into another shape
98
+ existing = {}
99
+ record = OrderedDict()
100
+ record["from"] = existing.get("from", plan.version_from)
101
+ # The version the project is actually at now, read from the disk: this record is
102
+ # written before the files change, and an aborted run never reaches the template's.
103
+ version_path = os.path.join(plan.root, paths.VERSION_FILE)
104
+ on_disk = files.read(version_path).decode().strip() if os.path.isfile(version_path) else ""
105
+ record["to"] = int(on_disk) if on_disk.isdigit() else record["from"]
106
+ record["applied_at"] = files.utc_now()
107
+ # A recovery re-run replays the same operation: record it once.
108
+ record["ops"] = list(existing.get("ops", []))
109
+ record["ops"] += [o for o in ops if o not in record["ops"]]
110
+ record["deletions"] = list(existing.get("deletions", []))
111
+ record["deletions"] += [d for d in deletions
112
+ if not any(e.get("path") == d["path"] for e in record["deletions"])]
113
+ if existing and all(existing.get(k) == record[k] for k in ("from", "to", "ops", "deletions")):
114
+ return # a run that changed nothing leaves the record as it was
115
+ write_file(plan.root, path, files.dump_json(record))
116
+
117
+
118
+ def apply(plan):
119
+ """Write the plan, in order. Every item checks its target first; a violation
120
+ or a failed write aborts before the next item, leaving everything after it
121
+ unwritten. Every operation is idempotent, so the re-run is the recovery."""
122
+ written, recorded = {}, []
123
+ for item in plan.items:
124
+ try:
125
+ apply_item(plan, item, written, recorded)
126
+ except Abort as exc:
127
+ raise Abort(exc.item or item, exc.why) from exc
128
+ except OSError as exc:
129
+ raise Abort(item, str(exc)) from exc
130
+ # The run finished: say which version it actually reached.
131
+ try:
132
+ if recorded or os.path.isfile(os.path.join(plan.root, plan.report_dir, "migration.json")):
133
+ record_run(plan, [], [])
134
+ except OSError as exc:
135
+ raise Abort(None, str(exc)) from exc
136
+
137
+
138
+ def apply_item(plan, item, written, recorded):
139
+ target, action = item["target"], item["action"]
140
+ reason = item.get("reason") or ""
141
+ cleanup = reason.startswith("[cleanup]")
142
+ if action in ("delete?", "regenerate?"): # listed only, until a human confirms it
143
+ return
144
+ by = dict(confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
145
+ via=plan.cleanup_via if cleanup else plan.confirm_via, at=files.utc_now())
146
+ if action == "delete":
147
+ # the plan-time symlink check is repeated: a path swapped since is refused
148
+ why = files.unsafe_path(plan.root, target)
149
+ if why:
150
+ raise Abort(item, why)
151
+ check(plan, item, target, item["expect"], written)
152
+ # git-ignored task state: a [cleanup] deletion of it is NOT recoverable through git, and
153
+ # copying what it removes would defeat the cleanup, so no original/ copy is kept. A report
154
+ # file git tracks, or should, is kept like any other deletion.
155
+ if not target.startswith(".ai/state/") and (not cleanup or target in plan.git_kept):
156
+ keep_original(plan, target)
157
+ recorded.append(target)
158
+ # Recorded before the file is gone: an abort later must not lose the note
159
+ # of what was taken away, or of who approved it.
160
+ record_run(plan, [dict(action=action, path=target)], [dict(path=target, **by)])
161
+ os.remove(os.path.join(plan.root, target))
162
+ written[target] = None
163
+ if reason.startswith(("[cleanup]", "[local]")):
164
+ prune_empty(plan.root, target)
165
+ return
166
+ check(plan, item, target, item["expect"], written)
167
+ if item["keep"] and item["expect"] is not None and target not in written:
168
+ # A change a human confirmed overwrites what they wrote: keep it, so it can be read back.
169
+ if not os.path.exists(os.path.join(plan.root, plan.original_of(target))):
170
+ keep_original(plan, target)
171
+ recorded.append(target)
172
+ record_run(plan, [dict(action=action, path=target, **by)], [])
173
+ write_file(plan.root, target, item["content"])
174
+ written[target] = files.sha(item["content"])
175
+
176
+
177
+ def prune_empty(root, target):
178
+ """The directories a cleanup or cache deletion left empty, up to (not including)
179
+ .ai/reports, .ai/state/logs and .ai/local; a directory with anything in it stays."""
180
+ top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else \
181
+ ".ai/local" if target.startswith(paths.LOCAL_CACHE + "/") else ".ai/reports"
182
+ here = os.path.dirname(target)
183
+ while here.startswith(top + "/"):
184
+ try:
185
+ os.rmdir(os.path.join(root, here))
186
+ except OSError:
187
+ return
188
+ here = os.path.dirname(here)
@@ -0,0 +1,38 @@
1
+ """The managed block of one instruction file.
2
+
3
+ shell: block_update
4
+ """
5
+
6
+ import os
7
+
8
+ import render_instructions # next to the entry, whose directory the entry puts on sys.path
9
+
10
+ from rr_update import files
11
+ from rr_update import paths
12
+ from rr_update import plan as plan_mod
13
+
14
+
15
+ def block_update(plan, history, runtime):
16
+ """R15, D5: the managed block of one instruction file. Rewritten between its markers; no block
17
+ -> appended; no file -> a minimal one with the block. Text outside the markers is never touched."""
18
+ name, block_tpl, minimal_tpl = paths.INSTRUCTION_FILE[runtime]
19
+ tpl = files.read(os.path.join(paths.TEMPLATES["ai-init"], block_tpl))
20
+ text = plan.read(name)
21
+ if text is None:
22
+ created = files.read(os.path.join(paths.TEMPLATES["ai-init"], minimal_tpl)).replace(
23
+ b"{{PROJECT}}", os.path.basename(plan.root).encode())
24
+ plan.add("create", name, "with the managed block", content=created.rstrip(b"\n") + b"\n\n" + tpl)
25
+ return
26
+ found = render_instructions.find_block(text)
27
+ if found is None:
28
+ plan.add("regenerate", name, "managed block appended", content=text.rstrip(b"\n") + b"\n\n" + tpl)
29
+ return
30
+ s, e = found[0], found[1]
31
+ ours = text[s:e] + b"\n"
32
+ if ours == tpl:
33
+ return
34
+ content = text[:s] + tpl.rstrip(b"\n") + text[e:]
35
+ if history.shipped("ai-init/" + block_tpl, ours):
36
+ plan.add("regenerate", name, "managed block replaced (unchanged since it was installed)", content=content)
37
+ else:
38
+ plan.gated("regenerate", name, "managed block " + plan_mod.edited_note(plan, name, ours, tpl), content=content)
@@ -0,0 +1,96 @@
1
+ """The plan of one run: every rule, in the order D3 sets, collected into one Plan.
2
+
3
+ shell: build_plan
4
+ core: PHASE
5
+ """
6
+
7
+ import os
8
+
9
+ from rr_update import blocks
10
+ from rr_update import cleanup
11
+ from rr_update import files as files_mod
12
+ from rr_update import hints
13
+ from rr_update import history as history_mod
14
+ from rr_update import paths
15
+ from rr_update import plan as plan_mod
16
+ from rr_update import rules
17
+ from rr_update import shared
18
+ from rr_update import weights
19
+
20
+
21
+ PHASE = {"split": 0, "delete": 2, "delete?": 2}
22
+
23
+
24
+ def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None, cleanup_via=None):
25
+ """D3: split_once -> regenerate -> shared -> block per runtime -> rules -> local -> gitignore, cleanup, hints.
26
+ The caller has run check_kept: nothing here reads a kept file for meaning."""
27
+ plan = plan_mod.Plan(root)
28
+ plan.confirm_cleanup = confirm_cleanup
29
+ plan.cleanup_via = cleanup_via
30
+ plan.confirm_delete = confirm_delete
31
+ plan.confirm_via = confirm_via
32
+ has_ai = os.path.isdir(os.path.join(root, ".ai"))
33
+ has_sdlc = os.path.isdir(os.path.join(root, "docs", "sdlc"))
34
+ if not (has_ai or has_sdlc):
35
+ return None
36
+ history = history_mod.History(paths.HISTORY)
37
+ runtimes = history_mod.project_runtimes(root)
38
+ plan.version_from = history_mod.project_version(root) if has_ai else None
39
+
40
+ if has_sdlc:
41
+ tpl = paths.TEMPLATES["project-init"]
42
+ for src, target in paths.SDLC_CREATE: # the project's own from then on
43
+ if not plan.exists(target):
44
+ plan.add("create", target, content=files_mod.read(os.path.join(tpl, src)).replace(
45
+ b"{{PROJECT}}", os.path.basename(root).encode()))
46
+ for rt in runtimes:
47
+ for src, target, kind in paths.RUNTIME_MAP[rt]:
48
+ content = files_mod.read(os.path.join(tpl, src))
49
+ if kind == "create":
50
+ if not plan.exists(target):
51
+ plan.add("create", target, content=content)
52
+ else:
53
+ plan_mod.regenerate(plan, history, "project-init/" + src, target, content)
54
+ hints.gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
55
+
56
+ if has_ai:
57
+ tpl = paths.TEMPLATES["ai-init"]
58
+ weights.split_once(plan, history)
59
+ files = []
60
+ for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
61
+ for n in names:
62
+ files.append(os.path.relpath(os.path.join(dirpath, n), tpl).replace(os.sep, "/"))
63
+ for rel in sorted(files):
64
+ if rel in paths.NOT_COPIED or rel in (paths.VERIFICATION_MD, paths.WEIGHT_PROJECT):
65
+ continue
66
+ content = files_mod.read(os.path.join(tpl, rel))
67
+ if rel in paths.CREATE_ONLY or rel.startswith(paths.CREATE_ONLY_DIRS):
68
+ if not plan.exists(rel):
69
+ plan.add("create", rel, content=content)
70
+ else:
71
+ plan_mod.regenerate(plan, history, "ai-init/" + rel, rel, content)
72
+ weights.weight_md(plan, history)
73
+ shared.shared_update(plan, history, runtimes)
74
+ rules.rule_sources(plan)
75
+ cleanup.local_cache(plan)
76
+ cleanup.cleanup_update(plan)
77
+ for rt in runtimes:
78
+ blocks.block_update(plan, history, rt)
79
+ rules.rules_update(plan, runtimes)
80
+ cleanup.deletion_hint(plan)
81
+ hints.gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
82
+ cleanup.tracked_ignored_hint(plan)
83
+ hints.verification_hints(plan)
84
+ hints.budget_hints(plan, runtimes)
85
+ if plan.items and plan.exists(paths.STATE_FILE):
86
+ plan.hints.append("%s: a task is in flight while files are regenerated — check that the files "
87
+ "its current step may touch still exist afterwards" % paths.STATE_FILE)
88
+ # Last of the items: an apply that stops short leaves the version alone, and the next run plans the rest again.
89
+ ours = history_mod.template_version()
90
+ if plan.version_from != ours:
91
+ plan.add("regenerate" if plan.exists(paths.VERSION_FILE) else "create", paths.VERSION_FILE,
92
+ "schema %d" % ours, content=b"%d\n" % ours)
93
+ if has_sdlc:
94
+ hints.constitution_hint(plan)
95
+ plan.items.sort(key=lambda i: 3 if i["target"] == paths.VERSION_FILE else PHASE.get(i["action"], 1))
96
+ return plan
@@ -0,0 +1,194 @@
1
+ """The cleanup proposals: the local cache, the task journals, the [cleanup] rows and the hints about deletion and tracked ignored files.
2
+
3
+ shell: local_cache, task_journal, cleanup_update, deletion_hint, tracked_ignored_hint
4
+ core: TRACKED_IGNORED_SHOWN
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import shlex
10
+ import subprocess
11
+ import sys
12
+ from datetime import datetime, timedelta, timezone
13
+
14
+ from rr_update import files
15
+ from rr_update import paths
16
+
17
+
18
+ def local_cache(plan):
19
+ """R18: .ai/local/plugin-update/ (the old merge's conflict copies) is removed; .ai/local/memory/ is never touched."""
20
+ for here, _dirs, names in os.walk(os.path.join(plan.root, paths.LOCAL_CACHE)):
21
+ for name in sorted(names):
22
+ rel = os.path.relpath(os.path.join(here, name), plan.root).replace(os.sep, "/")
23
+ if not files.unsafe_path(plan.root, rel):
24
+ why = "[local] the old merge's cache, not written any more"
25
+ plan.gated("delete", rel, why, reason=why)
26
+
27
+
28
+ def task_journal(root, task_id):
29
+ """(finished, abandoned, last event as a datetime) from the task's journal,
30
+ or None when it holds no readable event. A task is finished when its journal
31
+ has task_closed or gate_approved, or when logs/<id>/state.json records a
32
+ result (so a torn task_closed line does not make a closed task look unclosed)."""
33
+ closed = abandoned = False
34
+ last = None
35
+ try:
36
+ with open(os.path.join(root, ".ai", "reports", task_id, "events.jsonl"), encoding="utf-8") as fh:
37
+ lines = fh.read().splitlines()
38
+ except (OSError, UnicodeDecodeError):
39
+ return None
40
+ for line in lines:
41
+ try:
42
+ ev = json.loads(line)
43
+ when = datetime.strptime(ev["ts"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
44
+ except (ValueError, KeyError, TypeError, RecursionError):
45
+ continue
46
+ if last is None or when > last:
47
+ last = when
48
+ if ev.get("event") == "task_closed":
49
+ closed = True
50
+ data = ev.get("data")
51
+ abandoned = (isinstance(data, dict) and data.get("abandoned") is True) or ev.get("detail") == "abandoned"
52
+ elif ev.get("event") == "gate_approved":
53
+ closed = True
54
+ try:
55
+ with open(os.path.join(root, ".ai", "state", "logs", task_id, "state.json"), encoding="utf-8") as fh:
56
+ result = json.load(fh).get("result")
57
+ except (OSError, ValueError, AttributeError, UnicodeDecodeError, RecursionError):
58
+ result = None
59
+ if isinstance(result, str) and result:
60
+ closed = True
61
+ abandoned = abandoned or result == "abandoned"
62
+ return None if last is None else (closed, abandoned, last)
63
+
64
+
65
+ def cleanup_update(plan, now=None):
66
+ """Old run logs and stale unclosed tasks, proposed for deletion and never
67
+ deleted unasked (only with --confirm-cleanup). A closed task's `*.log` files
68
+ go (its state.json stays); an abandoned task loses every file under
69
+ .ai/reports/<id>/ and .ai/state/logs/<id>/ except that state.json; an unclosed
70
+ one idle for STALE_DAYS loses every file in both. The task in flight is
71
+ never touched. `now` is a datetime; RISKRAILS_NOW (ISO, Z) overrides it."""
72
+ if now is None:
73
+ try:
74
+ now = datetime.strptime(paths.env("NOW") or files.utc_now(), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
75
+ except ValueError:
76
+ print("project-update: RISKRAILS_NOW %r is not a UTC time like 2026-01-31T12:00:00Z" % paths.env("NOW"),
77
+ file=sys.stderr)
78
+ raise SystemExit(2) from None
79
+ flight = None
80
+ raw = plan.read(paths.STATE_FILE)
81
+ if raw is not None:
82
+ try:
83
+ flight = json.loads(raw).get("task_id")
84
+ except (ValueError, AttributeError, RecursionError):
85
+ return # unreadable: a task may be in flight, so no task is proposed as stale
86
+ reports =os.path.join(plan.root, ".ai", "reports")
87
+ logs = os.path.join(plan.root, ".ai", "state", "logs")
88
+
89
+ def files_of(base, rel, only_logs=False, keep=()):
90
+ out = []
91
+ for here, _dirs, names in os.walk(os.path.join(base, rel)):
92
+ for n in names:
93
+ full = os.path.join(here, n)
94
+ if os.path.isfile(full) and not os.path.islink(full) and (not only_logs or n.endswith(".log")) \
95
+ and n not in keep:
96
+ out.append(os.path.relpath(full, plan.root).replace(os.sep, "/"))
97
+ return out
98
+
99
+ for task_id in sorted(os.listdir(reports)) if os.path.isdir(reports) else []:
100
+ journal = task_journal(plan.root, task_id)
101
+ if journal is None or task_id == flight:
102
+ continue
103
+ closed, abandoned, last = journal
104
+ if abandoned:
105
+ why, targets = "[cleanup] abandoned task %s" % task_id, \
106
+ files_of(reports, task_id) + files_of(logs, task_id, keep=("state.json",))
107
+ elif not closed and now - last > timedelta(days=paths.STALE_DAYS):
108
+ why = "[cleanup] unclosed task %s, last event %s" % (task_id, last.strftime("%Y-%m-%d"))
109
+ targets = files_of(reports, task_id) + files_of(logs, task_id)
110
+ elif closed:
111
+ why, targets = "[cleanup] log of closed task %s" % task_id, files_of(logs, task_id, only_logs=True)
112
+ else:
113
+ continue
114
+ # .ai/reports/ is usually git-tracked; only .ai/state/ is git-ignored run state
115
+ held = files.git_states(plan.root, ".ai/reports/" + task_id)
116
+ skipped = set()
117
+ for target in sorted(targets):
118
+ unsafe = files.unsafe_path(plan.root, target)
119
+ if unsafe: # --apply would abort on it: say so instead of proposing it
120
+ if unsafe not in skipped:
121
+ skipped.add(unsafe)
122
+ plan.hints.append("%s: not proposed for deletion, %s — remove it yourself if it should go"
123
+ % (target, unsafe))
124
+ continue
125
+ note = why
126
+ state = held(target) if target.startswith(".ai/reports/") else None
127
+ if state == "tracked":
128
+ note += "; tracked: git can restore a committed copy"
129
+ elif state == "uncommitted":
130
+ note += "; uncommitted: git holds no copy of it"
131
+ elif state == "ignored":
132
+ note += "; git-ignored: not recoverable through git"
133
+ if state in ("tracked", "uncommitted"):
134
+ plan.git_kept.add(target)
135
+ if plan.confirm_cleanup:
136
+ plan.add("delete", target, note, reason=why)
137
+ plan.remove(target)
138
+ else:
139
+ plan.add("delete?", target, note, reason=why)
140
+
141
+
142
+ def deletion_hint(plan):
143
+ """M4: git is the rollback of a deletion or an overwrite; it must hold .ai/ first.
144
+ A [cleanup] deletion of git-ignored run state is not counted: git does not roll it back;
145
+ one under .ai/reports/ that git tracks, or should, is."""
146
+ if any(i["action"] in ("delete?", "delete", "regenerate?") and i["target"].startswith(".ai/")
147
+ and (not (i.get("reason") or "").startswith("[cleanup]") or i["target"] in plan.git_kept)
148
+ for i in plan.items) \
149
+ and files.git_dirty_ai(plan.root):
150
+ plan.hints.append("commit .ai/ first: git is this update's rollback "
151
+ "(`git status -- .ai` lists what it does not hold yet)")
152
+
153
+
154
+ TRACKED_IGNORED_SHOWN = 10 # per-file hints before tracked_ignored_hint counts the rest
155
+
156
+
157
+ def tracked_ignored_hint(plan):
158
+ """A file under .ai/state that git tracks although an ignore rule lists it: the rule
159
+ has no effect on it. Read-only: the human untracks it; nothing is planned.
160
+ At most TRACKED_IGNORED_SHOWN files get their own hint; one more line counts the rest."""
161
+ try:
162
+ res = subprocess.run(["git", "-C", plan.root, "ls-files", "-z", "-ci", "--exclude-standard",
163
+ "--", ".ai/state"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
164
+ timeout=30, check=False)
165
+ except (OSError, subprocess.SubprocessError):
166
+ return
167
+ if res.returncode != 0:
168
+ return
169
+ # --literal-pathspecs: a * ? [ ] in a name is that name, not a pattern that matches its neighbours.
170
+ all_cmd = "git ls-files -z -ci --exclude-standard -- .ai/state | xargs -0 -r git --literal-pathspecs rm --cached --"
171
+ names = [os.fsdecode(raw) for raw in res.stdout.split(b"\0") if raw]
172
+ for path in names[:TRACKED_IGNORED_SHOWN]:
173
+ # A control character (or a byte that is not UTF-8, which fsdecode keeps as a lone
174
+ # surrogate) in the name would forge a report line or break the copy-paste command:
175
+ # show it escaped, and untrack by the NUL-safe listing.
176
+ shown = "".join(c if c.isprintable() else repr(c)[1:-1] for c in path)
177
+ if shown == path:
178
+ plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
179
+ "effect on a tracked file, so every run leaves the tree dirty and "
180
+ "`git commit -a` sweeps it in; untrack it yourself, from the project root: "
181
+ "`git --literal-pathspecs rm --cached -- %s` and commit (the file stays on disk)"
182
+ % (shown, shlex.quote(path)))
183
+ else:
184
+ plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
185
+ "effect on a tracked file, so every run leaves the tree dirty and "
186
+ "`git commit -a` sweeps it in; this name cannot be typed safely, so "
187
+ "untrack it, from the project root, with `%s`, which untracks EVERY tracked, ignored file "
188
+ "under .ai/state, not only this one, and commit (the files stay on disk)"
189
+ % (shown, all_cmd))
190
+ if len(names) > TRACKED_IGNORED_SHOWN:
191
+ plan.hints.append(".ai/state: %d more file(s) tracked by git but ignored by an ignore rule, "
192
+ "not listed; untrack ALL tracked, ignored files under .ai/state "
193
+ "(the ones listed above too), from the project root, with `%s` and commit (the files stay on disk)"
194
+ % (len(names) - TRACKED_IGNORED_SHOWN, all_cmd))
@@ -0,0 +1,102 @@
1
+ """Bring a project's RiskRails files up to date with the installed plugin.
2
+
3
+ shell: main
4
+ """
5
+ # argparse's description is the first line of this docstring, which is the entry's first line.
6
+ import argparse
7
+ import os
8
+ import sys
9
+
10
+ import render_instructions # next to the entry, whose directory the entry puts on sys.path
11
+ from rr_update import apply, build, gate, hints, history, kept, paths, report
12
+
13
+
14
+ def main():
15
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
16
+ ap.add_argument("root", nargs="?", default=".")
17
+ ap.add_argument("--apply", action="store_true")
18
+ ap.add_argument("--check", action="store_true")
19
+ ap.add_argument("--budget", action="store_true",
20
+ help="with --check: exit 1 when a root instruction file is over its byte budget")
21
+ ap.add_argument("--confirm-delete", metavar="NAME",
22
+ help="a human confirms this run's gated rows (regenerate?, delete?), and is recorded in "
23
+ "the migration report; only with --apply")
24
+ ap.add_argument("--confirm-cleanup", metavar="NAME",
25
+ help="a human confirms this run's [cleanup] deletions (run logs and stale task files, "
26
+ "git-ignored and not recoverable through git); only with --apply. "
27
+ "--confirm-delete does not settle them")
28
+ args = ap.parse_args()
29
+ confirm_via = cleanup_via = None
30
+ for flag, name in (("--confirm-delete", args.confirm_delete), ("--confirm-cleanup", args.confirm_cleanup)):
31
+ if name is None:
32
+ continue
33
+ if not args.apply:
34
+ ap.error("%s only makes sense with --apply" % flag)
35
+ if not name.strip():
36
+ ap.error("%s needs the name of the human who confirmed the change" % flag)
37
+ if gate.placeholder_name(name):
38
+ ap.error("%s got the placeholder %r; type your own name, it is the audit trail"
39
+ % (flag, name.strip()))
40
+ passed, via, why = gate.human_gate()
41
+ if not passed:
42
+ # Under an agent-session marker the AI_UNATTENDED route is the launcher's
43
+ # to declare, not the agent's to type: the hint names it only otherwise.
44
+ hint = "run the same command yourself in a terminal outside Claude Code or Codex"
45
+ if "agent session" not in why:
46
+ hint += ", or set AI_UNATTENDED=1 if a launcher runs it unattended on your behalf"
47
+ return gate.refuse("%s is typed by a human, and %s" % (flag, why), hint)
48
+ if flag == "--confirm-delete":
49
+ confirm_via = via
50
+ else:
51
+ cleanup_via = via
52
+ if args.budget and not args.check:
53
+ ap.error("--budget only makes sense with --check")
54
+ root = os.path.abspath(args.root)
55
+ if args.check and args.budget:
56
+ offenders = hints.budget_offenders(
57
+ root, [paths.INSTRUCTION_FILE[rt][0] for rt in history.project_runtimes(root)],
58
+ render_instructions.budgets(render_instructions.DEFAULT_SOURCE), render_instructions.block_of)
59
+ for line in offenders:
60
+ print(line)
61
+ return 1 if offenders else 0
62
+ for name, tpl in paths.TEMPLATES.items():
63
+ if not os.path.isdir(tpl):
64
+ print("project-update: %s templates not found at %s (run riskrails/install.sh)" % (name, tpl), file=sys.stderr)
65
+ return 2
66
+ if not (os.path.isdir(os.path.join(root, ".ai")) or os.path.isdir(os.path.join(root, "docs", "sdlc"))):
67
+ print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /rr-setup first" % root, file=sys.stderr)
68
+ return 2
69
+ try:
70
+ if os.path.isdir(os.path.join(root, ".ai")):
71
+ history.project_version(root)
72
+ bad = kept.check_kept(root)
73
+ if bad:
74
+ return kept.kept_refusal(bad)
75
+ plan = build.build_plan(root, confirm_delete=args.confirm_delete, confirm_via=confirm_via,
76
+ confirm_cleanup=args.confirm_cleanup, cleanup_via=cleanup_via)
77
+ except history.SchemaError as exc:
78
+ # --check is read by /rr-status, which reads stdout: it must see the reason.
79
+ print("project-update: %s" % exc, file=sys.stdout if args.check else sys.stderr)
80
+ return 2
81
+ if args.check:
82
+ pending = [i for i in plan.items if not i["action"].endswith("?")]
83
+ waiting = [i for i in plan.items if i["action"].endswith("?") and not (i["reason"] or "").startswith("[cleanup]")]
84
+ if pending:
85
+ bits = ["%d file(s) to update" % len(pending)]
86
+ if waiting:
87
+ bits.append("%d change(s) a human must confirm" % len(waiting))
88
+ print("project is behind the installed plugin: %s — run /rr-setup --update" % ", ".join(bits))
89
+ return 1
90
+ print("project matches the installed plugin"
91
+ + (" (%d change(s) wait for --confirm-delete)" % len(waiting) if waiting else ""))
92
+ return 0
93
+ if args.apply:
94
+ try:
95
+ apply.apply(plan)
96
+ except apply.Abort as exc:
97
+ where = "%s %s" % (exc.item["action"], exc.item["target"]) if exc.item else "the update"
98
+ print("project-update: aborted at %s: %s — nothing after it was written; "
99
+ "run the dry run again" % (where, exc.why), file=sys.stderr)
100
+ return 3
101
+ report.report(plan, args.apply)
102
+ return 0