riskrails 4.1.0 → 4.3.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 (92) 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 +25 -15
  5. package/agents/ai-reviewer.md.tmpl +14 -1
  6. package/ai/agents/implementer.md +6 -0
  7. package/ai/agents/reviewer.md +8 -1
  8. package/bin/ansi.js +12 -0
  9. package/bin/menu.js +71 -12
  10. package/bin/riskrails.js +240 -14
  11. package/bin/uninstall.js +115 -0
  12. package/bin/wizard.js +72 -0
  13. package/hooks/AGENTS.md +0 -6
  14. package/hooks/CLAUDE.md +0 -6
  15. package/hooks/context-guard.py +11 -1
  16. package/hooks/dialog-gate.py +3 -2
  17. package/hooks/guard/decision.py +4 -3
  18. package/hooks/guard/dialog.py +12 -2
  19. package/hooks/guard/ere.py +29 -10
  20. package/hooks/guard/git.py +2 -1
  21. package/hooks/guard/paths.py +48 -1406
  22. package/hooks/guard/paths_base.py +66 -0
  23. package/hooks/guard/paths_bash.py +176 -0
  24. package/hooks/guard/paths_classify.py +205 -0
  25. package/hooks/guard/paths_fs.py +137 -0
  26. package/hooks/guard/paths_human.py +105 -0
  27. package/hooks/guard/paths_interp.py +162 -0
  28. package/hooks/guard/paths_movers.py +393 -0
  29. package/hooks/guard/paths_shell.py +288 -0
  30. package/hooks/runtime-gate.py +87 -0
  31. package/hooks/update_notice.py +23 -8
  32. package/package.json +1 -1
  33. package/skills/AGENTS.md +0 -6
  34. package/skills/CLAUDE.md +0 -6
  35. package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
  36. package/skills/ai-init/templates/gitignore.snippet +2 -0
  37. package/skills/ai-task/memory_cleanup.py +213 -0
  38. package/skills/ai-task/memory_compact.py +25 -0
  39. package/skills/ai-task/memory_config.py +120 -0
  40. package/skills/ai-task/memory_export.py +246 -0
  41. package/skills/ai-task/memory_format.py +7 -3
  42. package/skills/ai-task/memory_hook.py +172 -0
  43. package/skills/ai-task/memory_io.py +122 -0
  44. package/skills/ai-task/memory_migrate.py +187 -0
  45. package/skills/ai-task/memory_packages.py +83 -0
  46. package/skills/ai-task/memory_risks.py +84 -0
  47. package/skills/ai-task/memory_status.py +76 -0
  48. package/skills/ai-task/memory_sync.py +20 -1481
  49. package/skills/ai-task/memory_verify.py +316 -0
  50. package/skills/ai-task/rr_state/cli.py +1 -0
  51. package/skills/ai-task/rr_state/close.py +2 -2
  52. package/skills/ai-task/rr_state/dialog.py +32 -7
  53. package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
  54. package/skills/ai-task/rr_state/gate.py +10 -4
  55. package/skills/ai-task/rr_state/labels.py +33 -4
  56. package/skills/ai-task/rr_state/memory_cmd.py +84 -64
  57. package/skills/ai-task/rr_state/questions_cmd.py +5 -4
  58. package/skills/ai-task/rr_state/report.py +73 -10
  59. package/skills/ai-task/rr_state/report_cmd.py +2 -0
  60. package/skills/ai-task/rr_state/review.py +18 -14
  61. package/skills/ai-task/rr_state/start.py +12 -9
  62. package/skills/ai-task/rr_state/steps.py +16 -7
  63. package/skills/ai-task/rr_state/verify.py +17 -12
  64. package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
  65. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  66. package/skills/project-update/history/index.json +8 -4
  67. package/skills/project-update/rr_update/__init__.py +1 -0
  68. package/skills/project-update/rr_update/apply.py +188 -0
  69. package/skills/project-update/rr_update/blocks.py +38 -0
  70. package/skills/project-update/rr_update/build.py +96 -0
  71. package/skills/project-update/rr_update/cleanup.py +194 -0
  72. package/skills/project-update/rr_update/cli.py +102 -0
  73. package/skills/project-update/rr_update/files.py +106 -0
  74. package/skills/project-update/rr_update/gate.py +60 -0
  75. package/skills/project-update/rr_update/hints.py +128 -0
  76. package/skills/project-update/rr_update/history.py +117 -0
  77. package/skills/project-update/rr_update/kept.py +120 -0
  78. package/skills/project-update/rr_update/paths.py +108 -0
  79. package/skills/project-update/rr_update/plan.py +123 -0
  80. package/skills/project-update/rr_update/report.py +51 -0
  81. package/skills/project-update/rr_update/rules.py +178 -0
  82. package/skills/project-update/rr_update/shared.py +74 -0
  83. package/skills/project-update/rr_update/siblings.py +37 -0
  84. package/skills/project-update/rr_update/weights.py +171 -0
  85. package/skills/project-update/update.py +8 -1682
  86. package/skills/rr-build/SKILL.md +5 -2
  87. package/skills/rr-setup/SKILL.md +4 -2
  88. package/skills/rr-setup/setup.py +13 -12
  89. package/skills/rr-ship/SKILL.md +2 -2
  90. package/skills/rr-spec/SKILL.md +6 -0
  91. package/skills/rr-status/SKILL.md +2 -0
  92. package/skills/rr-status/status.py +5 -3
@@ -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
+
@@ -0,0 +1,128 @@
1
+ """The hints of a run: budget offenders and hints, the .gitignore rows, the constitution and verification hints.
2
+
3
+ shell: budget_offenders, gitignore_update, budget_hints, constitution_hint, verification_hints
4
+ """
5
+
6
+ import contextlib
7
+ import io
8
+ import os
9
+ import re
10
+
11
+ import render_instructions # next to the entry, whose directory the entry puts on sys.path
12
+
13
+ from rr_update import files as files_mod
14
+ from rr_update import paths
15
+
16
+
17
+ def budget_offenders(root, files, caps, block_of):
18
+ """R21: one line per root instruction file over budget — its managed block
19
+ over `caps["project"]`, or the whole file over `caps["skeleton"]`. Files that
20
+ do not exist are skipped; a file under both budgets prints nothing."""
21
+ out = []
22
+ for name in files:
23
+ path = os.path.join(root, name)
24
+ if not os.path.isfile(path):
25
+ continue
26
+ with open(path, encoding="utf-8") as fh:
27
+ text = fh.read()
28
+ block = block_of(text)
29
+ size = len(text.encode("utf-8"))
30
+ if block is not None and caps.get("project") and len(block.encode("utf-8")) > caps["project"]:
31
+ out.append("%s block %d B over the project budget of %d B"
32
+ % (name, len(block.encode("utf-8")), caps["project"]))
33
+ if caps.get("skeleton") and size > caps["skeleton"]:
34
+ out.append("%s file %d B over the skeleton budget of %d B" % (name, size, caps["skeleton"]))
35
+ return out
36
+
37
+
38
+ RECORD_HINT = (".ai/memory/ and .ai/reports/ are local now. Files git already tracks stay tracked: "
39
+ "'git rm -r --cached --ignore-unmatch .ai/memory .ai/reports' untracks them (yours to run); "
40
+ "'state.py memory enable --share git' keeps sharing them.")
41
+
42
+
43
+ GITIGNORE_NOTE = "entries appended: "
44
+
45
+
46
+ def gitignore_update(plan, snippet_path):
47
+ snippet = files_mod.read(snippet_path).decode().splitlines()
48
+ current = plan.read(".gitignore") or b""
49
+ have = set(current.decode().splitlines())
50
+ missing = [l for l in snippet if l.strip() and not l.startswith("#") and l not in have
51
+ and "!" + l not in have]
52
+ if not missing:
53
+ return
54
+ header = [l for l in snippet if l.startswith("#") and l not in have]
55
+ add = "\n".join(header + missing) + "\n"
56
+ sep = b"\n" if current and not current.endswith(b"\n") else b""
57
+ new = current + sep + add.encode()
58
+ # Replace an earlier .gitignore item from this run instead of stacking two.
59
+ earlier = [i["note"][len(GITIGNORE_NOTE):] for i in plan.items
60
+ if i["target"] == ".gitignore" and i["note"].startswith(GITIGNORE_NOTE)]
61
+ plan.items = [i for i in plan.items if i["target"] != ".gitignore"]
62
+ plan.add("regenerate", ".gitignore", GITIGNORE_NOTE + ", ".join(earlier + missing), content=new)
63
+ if {".ai/memory/", ".ai/reports/"} & set(missing) and RECORD_HINT not in plan.hints:
64
+ plan.hints.append(RECORD_HINT)
65
+
66
+
67
+ def budget_hints(plan, runtimes):
68
+ """What the always-loaded block costs this project after this run.
69
+
70
+ Downstream the budget is advisory (R4): the plugin measures its own block
71
+ and says what it costs, but a project's own file is never refused. A block
72
+ that is over budget and still the one the plugin installed is the plugin's
73
+ to trim; an edited one is the project's."""
74
+ try:
75
+ cap = render_instructions.budgets(render_instructions.DEFAULT_SOURCE).get("project")
76
+ except render_instructions.RenderError:
77
+ return
78
+ if not cap:
79
+ return
80
+ for rt in runtimes:
81
+ name, block_tpl, _ = paths.INSTRUCTION_FILE[rt]
82
+ text = plan.read(name)
83
+ if text is None:
84
+ continue
85
+ block = render_instructions.block_of(text.decode("utf-8", "replace"))
86
+ if block is None:
87
+ continue
88
+ size = len(block.encode("utf-8"))
89
+ if size <= cap:
90
+ continue
91
+ shipped = files_mod.read(os.path.join(paths.TEMPLATES["ai-init"], block_tpl)).rstrip(b"\n")
92
+ whose = ("the plugin ships it that size — report it"
93
+ if block.encode("utf-8").rstrip() == shipped
94
+ else "it was edited here, so the plugin will not rewrite it: trimming it is yours")
95
+ plan.hints.append("%s: the managed block is %d B, budget %d B — %s"
96
+ % (name, size, cap, whose))
97
+
98
+
99
+ def constitution_hint(plan):
100
+ """R10: the cap is hard for the shipped template and advisory for a project.
101
+ The rule lives in render_instructions; this only relays what it says."""
102
+ rel = "docs/sdlc/constitution.md"
103
+ path = os.path.join(plan.root, rel)
104
+ if not os.path.isfile(path):
105
+ return
106
+ err = io.StringIO()
107
+ with contextlib.redirect_stderr(err):
108
+ render_instructions.constitution(path, out=io.StringIO())
109
+ for line in err.getvalue().splitlines():
110
+ plan.hints.append(line.replace(path, rel))
111
+
112
+
113
+ def verification_hints(plan):
114
+ """R10: update's empty-field hints read verification.md."""
115
+ text = plan.read(paths.VERIFICATION_MD)
116
+ if text is None:
117
+ return
118
+ for field, why in (
119
+ ("verify_command", "the feedback loop needs it"),
120
+ ("step_test_command", "a step runs only its own tests"),
121
+ ("e2e_command", "e2e runs once at the end of a task; write 'none' if there is no suite"),
122
+ ("lint_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
123
+ ("typecheck_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
124
+ ):
125
+ if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", text):
126
+ plan.hints.append("%s: %s is empty — %s" % (paths.VERIFICATION_MD, field, why))
127
+ elif not re.search(rb"(?m)^" + field.encode() + rb":", text):
128
+ plan.hints.append("%s: %s is missing — %s" % (paths.VERIFICATION_MD, field, why))
@@ -0,0 +1,117 @@
1
+ """What the plugin shipped and what a project declares: the schema error, the project's runtimes, the shipped-template history, the shared tree, the schema numbers.
2
+
3
+ shell: project_runtimes, History, shared_files, undeclared_shared, template_version, project_version
4
+ core: SchemaError
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+
11
+ from rr_update import files
12
+ from rr_update import paths
13
+
14
+
15
+ class SchemaError(Exception):
16
+ """The project's schema cannot be read or is newer than this plugin."""
17
+
18
+
19
+
20
+ def project_runtimes(root):
21
+ """The runtimes a project declares, or the one this plugin copy serves."""
22
+ out = []
23
+ if os.path.exists(os.path.join(root, "CLAUDE.md")) or os.path.isdir(os.path.join(root, ".claude")):
24
+ out.append("claude")
25
+ if os.path.exists(os.path.join(root, "AGENTS.md")) or os.path.isdir(os.path.join(root, ".codex")):
26
+ out.append("codex")
27
+ if os.path.exists(os.path.join(root, "GEMINI.md")) or os.path.isdir(os.path.join(root, ".gemini")):
28
+ out.append("gemini")
29
+ if os.path.isdir(os.path.join(root, ".junie")):
30
+ out.append("junie")
31
+ if not out:
32
+ out.append("codex" if "/.codex/" in paths.HERE + "/" else "claude")
33
+ return out
34
+
35
+
36
+
37
+ class History:
38
+ """index.json answers "was this ever shipped" from hashes alone; the blobs
39
+ give the one-time split its base values."""
40
+
41
+ def __init__(self, root):
42
+ try:
43
+ with open(os.path.join(root, "index.json"), encoding="utf-8") as fh:
44
+ self.index = json.load(fh)
45
+ except (OSError, ValueError, RecursionError):
46
+ self.index = {}
47
+ self.root = root
48
+
49
+ def versions(self, key):
50
+ """Every shipped version of a template, oldest first, as bytes."""
51
+ out = []
52
+ for h in self.index.get(key, []):
53
+ try:
54
+ out.append(files.read(os.path.join(self.root, "blobs", h)))
55
+ except OSError:
56
+ pass
57
+ return out
58
+
59
+ def hashes(self, key):
60
+ """The sha of every version shipped under this key; a version whose blob is gone still counts."""
61
+ return set(self.index.get(key, []))
62
+
63
+ def shipped(self, key, data):
64
+ """True when these bytes are a version the plugin shipped, also after a checkout turned its line endings into CRLF."""
65
+ known = self.hashes(key)
66
+ return files.sha(data) in known or files.sha(data.replace(b"\r\n", b"\n")) in known
67
+
68
+
69
+ def shared_files():
70
+ """{".ai/<rel>": bytes} for every shared file the shared tree holds; empty when the tree is not installed."""
71
+ out = {}
72
+ for path in sorted(paths.SHARED_SET):
73
+ full = os.path.join(paths.SHARED, path[len(".ai/"):])
74
+ if os.path.isfile(full):
75
+ out[path] = files.read(full)
76
+ return out
77
+
78
+
79
+ def undeclared_shared():
80
+ """Files in the shared tree outside the shared set, as .ai/ paths."""
81
+ out = []
82
+ for here, _dirs, names in os.walk(paths.SHARED):
83
+ for name in names:
84
+ rel = ".ai/" + os.path.relpath(os.path.join(here, name), paths.SHARED).replace(os.sep, "/")
85
+ if rel not in paths.SHARED_SET:
86
+ out.append(rel)
87
+ return sorted(out)
88
+
89
+
90
+
91
+ def template_version():
92
+ """The schema number this plugin ships (its .ai/VERSION template)."""
93
+ return int(files.read(os.path.join(paths.TEMPLATES["ai-init"], paths.VERSION_FILE)).decode().strip())
94
+
95
+
96
+ def project_version(root):
97
+ """The project's .ai/VERSION: 0 when there is none; SchemaError when it cannot
98
+ be read, is not a number, or is newer than this plugin's."""
99
+ path = os.path.join(root, paths.VERSION_FILE)
100
+ try:
101
+ raw = files.read(path) if os.path.lexists(path) else None
102
+ except OSError as exc: # a directory, a mode-000 file, a dangling link
103
+ raise SchemaError("%s cannot be read: %s" % (paths.VERSION_FILE, exc)) from exc
104
+ if raw is None:
105
+ return 0
106
+ try:
107
+ text = raw.decode("utf-8").strip()
108
+ except UnicodeDecodeError:
109
+ text = ""
110
+ if not re.fullmatch(r"\d+", text):
111
+ raise SchemaError("%s is not a schema number: %r" % (paths.VERSION_FILE, raw[:40]))
112
+ ours = template_version()
113
+ if int(text) > ours:
114
+ raise SchemaError("%s says schema %s, this plugin ships schema %d — update the plugin "
115
+ "(riskrails/install.sh)" % (paths.VERSION_FILE, text, ours))
116
+ return int(text)
117
+
@@ -0,0 +1,120 @@
1
+ """What a regenerate must not overwrite: the old markers, the kept-file check, the old file shapes, the refusal text.
2
+
3
+ shell: check_kept, old_shapes, kept_refusal
4
+ core: tomllib, OLD_MARKERS
5
+ """
6
+
7
+ import json
8
+ import os
9
+
10
+ try:
11
+ import tomllib
12
+ except ImportError: # Python before 3.11: .codex/config.toml is not checked
13
+ tomllib = None
14
+
15
+ import render_instructions # next to the entry, whose directory the entry puts on sys.path
16
+
17
+ from rr_update import files
18
+ from rr_update import paths
19
+ from rr_update import siblings
20
+
21
+
22
+ # Old shapes the update no longer converts: literal checks, nothing parsed (D4).
23
+ OLD_MARKERS = (b"<!-- claude-agentic:", b"<!-- Codex-agentic:")
24
+
25
+
26
+ def check_kept(root):
27
+ """[(path, reason)] for every kept file its own reader cannot take and every old
28
+ shape this version no longer converts. Reads only: the caller refuses on a
29
+ non-empty answer before anything is written (R12)."""
30
+ bad = []
31
+
32
+ def raw_of(rel):
33
+ path = os.path.join(root, rel)
34
+ if not os.path.lexists(path):
35
+ return None
36
+ try:
37
+ return files.read(path)
38
+ except OSError as exc:
39
+ bad.append((rel, "cannot be read: %s" % (exc.strerror or exc)))
40
+ return None
41
+
42
+ def json_object(rel, data):
43
+ try:
44
+ doc = json.loads(data.decode("utf-8"))
45
+ except (ValueError, RecursionError) as exc: # UnicodeDecodeError is a ValueError
46
+ bad.append((rel, "is not valid JSON (%s)" % (type(exc).__name__ if isinstance(exc, RecursionError) else exc)))
47
+ return None
48
+ if not isinstance(doc, dict):
49
+ bad.append((rel, "is not a JSON object"))
50
+ return None
51
+ return doc
52
+
53
+ for rel in (paths.STATE_FILE, ".ai/policies/dialog.json", ".ai/policies/memory.json", ".claude/settings.json"):
54
+ data = raw_of(rel)
55
+ if data is not None:
56
+ json_object(rel, data)
57
+ data = raw_of(paths.WEIGHT_PROJECT)
58
+ if data is not None:
59
+ doc = json_object(paths.WEIGHT_PROJECT, data)
60
+ if doc is not None and doc.get("version") != 1:
61
+ bad.append((paths.WEIGHT_PROJECT, "has no \"version\": 1"))
62
+ elif doc is not None:
63
+ try:
64
+ siblings.weight_overlay().load(siblings.shipped_weight(), os.path.join(root, paths.WEIGHT_PROJECT))
65
+ except ValueError as exc:
66
+ bad.append((paths.WEIGHT_PROJECT, str(exc).split(": ", 1)[-1]))
67
+ data = raw_of(paths.VERIFICATION_MD)
68
+ if data is not None:
69
+ try:
70
+ data.decode("utf-8")
71
+ except UnicodeDecodeError:
72
+ bad.append((paths.VERIFICATION_MD, "is not UTF-8 text"))
73
+ data = raw_of(".codex/config.toml")
74
+ if data is not None and tomllib is not None:
75
+ try:
76
+ tomllib.loads(data.decode("utf-8"))
77
+ except ValueError as exc: # TOMLDecodeError and UnicodeDecodeError
78
+ bad.append((".codex/config.toml", "is not valid TOML (%s)" % exc))
79
+ # the old weight.json is read once, by the split, so it must parse then
80
+ if not os.path.lexists(os.path.join(root, paths.WEIGHT_PROJECT)):
81
+ data = raw_of(paths.WEIGHT_JSON)
82
+ if data is not None:
83
+ json_object(paths.WEIGHT_JSON, data)
84
+ old_shapes(root, bad)
85
+ return bad
86
+
87
+
88
+ def old_shapes(root, bad):
89
+ """The four literal detectors of an older riskrails' files (D4)."""
90
+ exists = lambda rel: os.path.lexists(os.path.join(root, rel)) # noqa: E731
91
+ if exists(".ai/state/current.json") and not exists(paths.STATE_FILE):
92
+ bad.append((".ai/state/current.json", "is the task state of a riskrails before 3.4b (there is no state.json)"))
93
+ if exists(".ai/policies/risk-tiers.json") and not exists(paths.WEIGHT_JSON) and not exists(paths.WEIGHT_PROJECT):
94
+ bad.append((".ai/policies/risk-tiers.json", "is the risk-tier policy of a riskrails before 2.2 (there is no weight table)"))
95
+ names = set(render_instructions.RULE_FILE.values()) | {e[0] for e in paths.INSTRUCTION_FILE.values()}
96
+ for dirpath, dirnames, filenames in os.walk(root):
97
+ dirnames[:] = sorted(d for d in dirnames if d not in paths.RULES_SKIP
98
+ and not os.path.lexists(os.path.join(dirpath, d, ".git")))
99
+ for name in filenames:
100
+ rel = os.path.relpath(os.path.join(dirpath, name), root).replace(os.sep, "/")
101
+ if name not in names and rel not in names:
102
+ continue
103
+ try:
104
+ data = files.read(os.path.join(root, rel))
105
+ except OSError:
106
+ continue # an unreadable instruction file is the plan's to report, not an old shape
107
+ found = next((m for m in OLD_MARKERS if m in data), None)
108
+ if found:
109
+ bad.append((rel, "carries %s of a riskrails before 3.4b" % found.decode()))
110
+
111
+
112
+ def kept_refusal(bad):
113
+ """I3, verbatim: one line per failing file, then the rest."""
114
+ for rel, why in bad:
115
+ print("KEPT_UNREADABLE: %s: %s" % (rel, why))
116
+ print("project-update: nothing was deleted or written. This version regenerates its own files and no longer "
117
+ "converts older ones.")
118
+ print("Install riskrails@%s (npx riskrails@%s), run /rr-setup --update with it, then install this version "
119
+ "and run /rr-setup --update again." % (paths.LAST_MIGRATING_VERSION, paths.LAST_MIGRATING_VERSION))
120
+ return paths.EXIT_KEPT_UNREADABLE