riskrails 4.0.1 → 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 (67) 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 +22 -13
  5. package/bin/menu.js +116 -0
  6. package/bin/riskrails.js +119 -20
  7. package/hooks/AGENTS.md +0 -6
  8. package/hooks/CLAUDE.md +0 -6
  9. package/hooks/guard/dialog.py +14 -2
  10. package/hooks/guard/paths.py +48 -1406
  11. package/hooks/guard/paths_base.py +59 -0
  12. package/hooks/guard/paths_bash.py +176 -0
  13. package/hooks/guard/paths_classify.py +205 -0
  14. package/hooks/guard/paths_fs.py +137 -0
  15. package/hooks/guard/paths_human.py +105 -0
  16. package/hooks/guard/paths_interp.py +162 -0
  17. package/hooks/guard/paths_movers.py +393 -0
  18. package/hooks/guard/paths_shell.py +288 -0
  19. package/package.json +1 -1
  20. package/skills/AGENTS.md +0 -6
  21. package/skills/CLAUDE.md +0 -6
  22. package/skills/ai-init/scaffold-ai.sh +2 -1
  23. package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
  24. package/skills/ai-init/templates/gitignore.snippet +2 -0
  25. package/skills/ai-task/memory_cleanup.py +213 -0
  26. package/skills/ai-task/memory_compact.py +25 -0
  27. package/skills/ai-task/memory_config.py +120 -0
  28. package/skills/ai-task/memory_export.py +246 -0
  29. package/skills/ai-task/memory_format.py +7 -3
  30. package/skills/ai-task/memory_hook.py +172 -0
  31. package/skills/ai-task/memory_io.py +122 -0
  32. package/skills/ai-task/memory_migrate.py +187 -0
  33. package/skills/ai-task/memory_packages.py +83 -0
  34. package/skills/ai-task/memory_risks.py +84 -0
  35. package/skills/ai-task/memory_status.py +76 -0
  36. package/skills/ai-task/memory_sync.py +20 -1481
  37. package/skills/ai-task/memory_verify.py +316 -0
  38. package/skills/ai-task/rr_state/cli.py +2 -1
  39. package/skills/ai-task/rr_state/close.py +2 -2
  40. package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
  41. package/skills/ai-task/rr_state/labels.py +14 -2
  42. package/skills/ai-task/rr_state/memory_cmd.py +91 -68
  43. package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
  44. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  45. package/skills/project-update/history/index.json +4 -2
  46. package/skills/project-update/rr_update/__init__.py +1 -0
  47. package/skills/project-update/rr_update/apply.py +188 -0
  48. package/skills/project-update/rr_update/blocks.py +38 -0
  49. package/skills/project-update/rr_update/build.py +96 -0
  50. package/skills/project-update/rr_update/cleanup.py +194 -0
  51. package/skills/project-update/rr_update/cli.py +102 -0
  52. package/skills/project-update/rr_update/files.py +106 -0
  53. package/skills/project-update/rr_update/gate.py +60 -0
  54. package/skills/project-update/rr_update/hints.py +128 -0
  55. package/skills/project-update/rr_update/history.py +117 -0
  56. package/skills/project-update/rr_update/kept.py +120 -0
  57. package/skills/project-update/rr_update/paths.py +108 -0
  58. package/skills/project-update/rr_update/plan.py +123 -0
  59. package/skills/project-update/rr_update/report.py +51 -0
  60. package/skills/project-update/rr_update/rules.py +178 -0
  61. package/skills/project-update/rr_update/shared.py +74 -0
  62. package/skills/project-update/rr_update/siblings.py +37 -0
  63. package/skills/project-update/rr_update/weights.py +171 -0
  64. package/skills/project-update/update.py +8 -1682
  65. package/skills/rr-setup/SKILL.md +79 -21
  66. package/skills/rr-setup/scaffold_ai.py +8 -2
  67. package/skills/rr-setup/setup.py +371 -4
@@ -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
@@ -0,0 +1,106 @@
1
+ """Files and the two outside readers: the clock, a hash, a read, git's view of a tree, JSON bytes, a path safe to delete.
2
+
3
+ shell: utc_now, read, git_dirty_ai, git_states, unsafe_path
4
+ core: sha, dump_json
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import subprocess
11
+ from datetime import datetime, timezone
12
+
13
+
14
+ def utc_now():
15
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
16
+
17
+
18
+ def sha(data):
19
+ return hashlib.sha256(data).hexdigest()
20
+
21
+
22
+ def read(path):
23
+ with open(path, "rb") as fh:
24
+ return fh.read()
25
+
26
+
27
+
28
+ def git_dirty_ai(root):
29
+ """True when .ai/ has changes git does not hold yet: git is the rollback of
30
+ a migration that deletes, so it must hold them first. False outside a work
31
+ tree, where there is nothing to say."""
32
+ try:
33
+ res = subprocess.run(["git", "-C", root, "status", "--porcelain", "--", ".ai"],
34
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30, check=False)
35
+ except (OSError, subprocess.SubprocessError):
36
+ return False
37
+ return res.returncode == 0 and bool(res.stdout.strip())
38
+
39
+
40
+ def git_states(root, rel_dir):
41
+ """A function path -> how git holds a file under `rel_dir` (project-relative):
42
+ "tracked" (committed and unchanged), "uncommitted" (changed, staged or not yet
43
+ tracked, so git has no copy to restore) or "ignored"; None outside a work tree
44
+ or for a path git does not know."""
45
+ def run(*args):
46
+ res = subprocess.run(["git", "-C", root] + list(args), stdout=subprocess.PIPE,
47
+ stderr=subprocess.DEVNULL, timeout=30, check=False)
48
+ return res.stdout.decode("utf-8", "replace") if res.returncode == 0 else None
49
+ try:
50
+ prefix = run("rev-parse", "--show-prefix")
51
+ tracked = run("ls-files", "-z", "--", rel_dir)
52
+ status = run("status", "--porcelain=v1", "-z", "--ignored", "-uall", "--", rel_dir)
53
+ except (OSError, subprocess.SubprocessError):
54
+ return lambda path: None
55
+ if prefix is None or tracked is None or status is None:
56
+ return lambda path: None
57
+ prefix = prefix.strip()
58
+ tracked = set(tracked.split("\0"))
59
+ dirty, ignored = set(), []
60
+ entries = status.split("\0")
61
+ skip = False
62
+ for entry in entries:
63
+ if skip: # the origin path of a rename
64
+ skip = False
65
+ continue
66
+ if len(entry) < 4:
67
+ continue
68
+ xy, path = entry[:2], entry[3:]
69
+ if prefix and path.startswith(prefix):
70
+ path = path[len(prefix):]
71
+ if "R" in xy or "C" in xy:
72
+ skip = True
73
+ if xy == "!!":
74
+ ignored.append(path)
75
+ else:
76
+ dirty.add(path)
77
+
78
+ def classify(path):
79
+ if path in dirty:
80
+ return "uncommitted"
81
+ if any(path == i or (i.endswith("/") and path.startswith(i)) for i in ignored):
82
+ return "ignored"
83
+ return "tracked" if path in tracked else None
84
+ return classify
85
+
86
+
87
+ def dump_json(value):
88
+ return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
89
+
90
+
91
+ def unsafe_path(root, path):
92
+ """Why a project-relative path must not be deleted: a symlink on the way to it,
93
+ or a real path outside the project; None when it is safe. Run at plan time and
94
+ again right before the delete."""
95
+ real_root = os.path.realpath(root)
96
+ here = root
97
+ for part in path.split("/"):
98
+ here = os.path.join(here, part)
99
+ if os.path.islink(here):
100
+ return "%s is a symlink" % os.path.relpath(here, root).replace(os.sep, "/")
101
+ real = os.path.realpath(os.path.join(root, path))
102
+ if real != real_root and not real.startswith(real_root + os.sep):
103
+ return "%s resolves outside the project (%s)" % (path, real)
104
+ return None
105
+
106
+ # ---------------------------------------------------------------- the one gate
@@ -0,0 +1,60 @@
1
+ """The one gate: who is at the keyboard, the audit-trail name, and the refusal every gate prints.
2
+
3
+ shell: ai_unattended, human_gate, refuse
4
+ core: AGENT_MARKERS, PLACEHOLDER_NAME_RE, placeholder_name
5
+ """
6
+
7
+ import os
8
+ import re
9
+
10
+ from rr_update import paths
11
+
12
+ # Set in the environment of a shell an agent runs: Claude Code's CLAUDECODE,
13
+ # and the names Codex gives its tool calls. Present counts, an empty value too.
14
+ AGENT_MARKERS = ("CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CODEX_CI",
15
+ "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_VERSION")
16
+
17
+
18
+ def ai_unattended():
19
+ """AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
20
+ and blanks ignored): one rule in every reader (risk 0587)."""
21
+ return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
22
+
23
+
24
+ def human_gate():
25
+ """(passed, via, why) for --confirm-delete. A terminal outside any agent
26
+ session is a human: via "terminal". Otherwise a launcher that declared the
27
+ run unattended (AI_UNATTENDED) passes: via "unattended", as state.py's
28
+ approve records it. Anything else is refused, and `why` says what was seen —
29
+ a terminal alone is not enough, since an agent can open a pty (F3).
30
+
31
+ Duplicated in spirit from skills/ai-task/state.py (human_present) on purpose:
32
+ the two skills are installed independently and neither imports the other."""
33
+ markers = [name for name in AGENT_MARKERS if name in os.environ]
34
+ if os.isatty(0) and not markers:
35
+ return True, "terminal", ""
36
+ if ai_unattended():
37
+ return True, "unattended", ""
38
+ if markers:
39
+ return False, None, ("this run is inside an agent session (%s %s set); run it in a terminal "
40
+ "outside Claude Code or Codex, not through the `!` prefix"
41
+ % (", ".join(markers), "is" if len(markers) == 1 else "are"))
42
+ return False, None, "this run has no terminal"
43
+
44
+
45
+ # The name typed into --confirm-delete is the audit trail; the template's own
46
+ # "<your name>" copied verbatim names no one. The whole value, not a substring.
47
+ PLACEHOLDER_NAME_RE = re.compile(r"\A\s*(?:<[^>]*>|your\s+name)\s*\Z", re.IGNORECASE)
48
+
49
+
50
+ def placeholder_name(name):
51
+ return bool(PLACEHOLDER_NAME_RE.match(name or ""))
52
+
53
+
54
+ def refuse(reason, how):
55
+ """Print the refusal the way every gate does — the marker on the first
56
+ stdout line, then what to do — and return exit code 5."""
57
+ print("%s: %s" % (paths.REFUSED, reason))
58
+ print(" %s" % how)
59
+ return 5
60
+