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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +22 -13
- package/bin/menu.js +116 -0
- package/bin/riskrails.js +119 -20
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- package/hooks/guard/dialog.py +14 -2
- package/hooks/guard/paths.py +48 -1406
- package/hooks/guard/paths_base.py +59 -0
- package/hooks/guard/paths_bash.py +176 -0
- package/hooks/guard/paths_classify.py +205 -0
- package/hooks/guard/paths_fs.py +137 -0
- package/hooks/guard/paths_human.py +105 -0
- package/hooks/guard/paths_interp.py +162 -0
- package/hooks/guard/paths_movers.py +393 -0
- package/hooks/guard/paths_shell.py +288 -0
- package/package.json +1 -1
- package/skills/AGENTS.md +0 -6
- package/skills/CLAUDE.md +0 -6
- package/skills/ai-init/scaffold-ai.sh +2 -1
- package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
- package/skills/ai-init/templates/gitignore.snippet +2 -0
- package/skills/ai-task/memory_cleanup.py +213 -0
- package/skills/ai-task/memory_compact.py +25 -0
- package/skills/ai-task/memory_config.py +120 -0
- package/skills/ai-task/memory_export.py +246 -0
- package/skills/ai-task/memory_format.py +7 -3
- package/skills/ai-task/memory_hook.py +172 -0
- package/skills/ai-task/memory_io.py +122 -0
- package/skills/ai-task/memory_migrate.py +187 -0
- package/skills/ai-task/memory_packages.py +83 -0
- package/skills/ai-task/memory_risks.py +84 -0
- package/skills/ai-task/memory_status.py +76 -0
- package/skills/ai-task/memory_sync.py +20 -1481
- package/skills/ai-task/memory_verify.py +316 -0
- package/skills/ai-task/rr_state/cli.py +2 -1
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
- package/skills/ai-task/rr_state/labels.py +14 -2
- package/skills/ai-task/rr_state/memory_cmd.py +91 -68
- package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +4 -2
- package/skills/project-update/rr_update/__init__.py +1 -0
- package/skills/project-update/rr_update/apply.py +188 -0
- package/skills/project-update/rr_update/blocks.py +38 -0
- package/skills/project-update/rr_update/build.py +96 -0
- package/skills/project-update/rr_update/cleanup.py +194 -0
- package/skills/project-update/rr_update/cli.py +102 -0
- package/skills/project-update/rr_update/files.py +106 -0
- package/skills/project-update/rr_update/gate.py +60 -0
- package/skills/project-update/rr_update/hints.py +128 -0
- package/skills/project-update/rr_update/history.py +117 -0
- package/skills/project-update/rr_update/kept.py +120 -0
- package/skills/project-update/rr_update/paths.py +108 -0
- package/skills/project-update/rr_update/plan.py +123 -0
- package/skills/project-update/rr_update/report.py +51 -0
- package/skills/project-update/rr_update/rules.py +178 -0
- package/skills/project-update/rr_update/shared.py +74 -0
- package/skills/project-update/rr_update/siblings.py +37 -0
- package/skills/project-update/rr_update/weights.py +171 -0
- package/skills/project-update/update.py +8 -1682
- package/skills/rr-setup/SKILL.md +79 -21
- package/skills/rr-setup/scaffold_ai.py +8 -2
- package/skills/rr-setup/setup.py +371 -4
|
@@ -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
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Where things are: the skill's directories, the environment's overrides, and the literal path tables the update reads.
|
|
2
|
+
|
|
3
|
+
shell: env, HERE, SKILLS, TEMPLATES, HISTORY, PLUGIN_HOME, SHARED
|
|
4
|
+
core: LAST_MIGRATING_VERSION, EXIT_KEPT_UNREADABLE, REFUSED, VERSION_FILE, ROUTER_FILE, STALE_DAYS, STATE_FILE, TESTING_MD, VERIFICATION_MD, WEIGHT_JSON, WEIGHT_MD, WEIGHT_PROJECT, LOCAL_CACHE, GENERATED, CREATE_ONLY, CREATE_ONLY_DIRS, NOT_COPIED, KEPT, is_kept, SHARED_SET, RULES_DIR, RULES_SKIP, RULES_SKIP_FOLDED, RUNTIME_MAP, SDLC_CREATE, INSTRUCTION_FILE
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # the skill directory, one above this package
|
|
10
|
+
SKILLS = os.path.dirname(HERE)
|
|
11
|
+
LAST_MIGRATING_VERSION = "3.1.0" # the last riskrails that converts older trees; I3 names it
|
|
12
|
+
EXIT_KEPT_UNREADABLE = 6
|
|
13
|
+
REFUSED = "CONFIRM_REFUSED"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def env(name, default=None):
|
|
17
|
+
"""RISKRAILS_<name>."""
|
|
18
|
+
return os.environ.get("RISKRAILS_" + name, default)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
TEMPLATES = {
|
|
23
|
+
"ai-init": env("TEMPLATES", os.path.join(SKILLS, "ai-init", "templates")),
|
|
24
|
+
"project-init": os.environ.get("CLAUDE_ROUTING_TEMPLATES", os.path.join(SKILLS, "project-init", "templates")),
|
|
25
|
+
}
|
|
26
|
+
HISTORY = env("HISTORY", os.path.join(HERE, "history"))
|
|
27
|
+
# The shared tree every project reads unless it keeps its own copy: installed
|
|
28
|
+
# beside the skills as riskrails/ai, or ai/ at the root of a checkout.
|
|
29
|
+
PLUGIN_HOME = os.path.dirname(SKILLS)
|
|
30
|
+
SHARED = env("SHARED") or next(
|
|
31
|
+
(p for p in (os.path.join(PLUGIN_HOME, "riskrails", "ai"), os.path.join(PLUGIN_HOME, "ai"))
|
|
32
|
+
if os.path.isdir(p)), os.path.join(PLUGIN_HOME, "riskrails", "ai"))
|
|
33
|
+
VERSION_FILE = ".ai/VERSION"
|
|
34
|
+
ROUTER_FILE = ".ai/AGENTS.md"
|
|
35
|
+
STALE_DAYS = 7 # an unclosed task whose last event is older is proposed for cleanup
|
|
36
|
+
STATE_FILE = ".ai/state/state.json"
|
|
37
|
+
TESTING_MD = ".ai/policies/testing.md"
|
|
38
|
+
VERIFICATION_MD = ".ai/policies/verification.md"
|
|
39
|
+
WEIGHT_JSON, WEIGHT_MD = ".ai/policies/weight.json", ".ai/policies/weight.md"
|
|
40
|
+
WEIGHT_PROJECT = ".ai/policies/weight.project.json"
|
|
41
|
+
LOCAL_CACHE = ".ai/local/plugin-update"
|
|
42
|
+
|
|
43
|
+
# D2. GENERATED targets copied from a template as they are. weight.md is rendered,
|
|
44
|
+
# .ai/VERSION is written last, the managed blocks and the runtimes' memory READMEs
|
|
45
|
+
# come from the maps below.
|
|
46
|
+
GENERATED = (ROUTER_FILE, ".ai/policies/path-guard.json", ".ai/policies/coding.md", ".ai/policies/safety.md",
|
|
47
|
+
".ai/policies/security.md", TESTING_MD, ".ai/rules/README.md", ".ai/state/README.md")
|
|
48
|
+
# Template files created when missing and never touched again: the project's own from then on.
|
|
49
|
+
CREATE_ONLY = (VERIFICATION_MD, WEIGHT_PROJECT, ".ai/reports/.gitkeep")
|
|
50
|
+
CREATE_ONLY_DIRS = (".ai/project/",)
|
|
51
|
+
# Template files that are not copied: written last, the plugin's own table, the human's switch.
|
|
52
|
+
NOT_COPIED = (VERSION_FILE, WEIGHT_JSON, WEIGHT_MD, ".ai/policies/dialog.json")
|
|
53
|
+
KEPT = (".ai/project/", ".ai/reports/", ".ai/memory/", ".ai/state/", "docs/sdlc/", ".ai/local/memory/",
|
|
54
|
+
WEIGHT_PROJECT, VERIFICATION_MD, ".ai/policies/dialog.json", ".ai/policies/memory.json",
|
|
55
|
+
".claude/settings.json", ".codex/config.toml", ".gitignore")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_kept(path):
|
|
59
|
+
"""True for a path update.py never rewrites. A GENERATED path under a KEPT
|
|
60
|
+
directory (.ai/state/README.md) is generated: the two sets are disjoint."""
|
|
61
|
+
if path in GENERATED:
|
|
62
|
+
return False
|
|
63
|
+
return any(path == k or (k.endswith("/") and path.startswith(k)) for k in KEPT)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# The shared set, literally: the plugin's tree must equal it (tested). A project
|
|
67
|
+
# copy identical to a shipped version is offered for deletion; an edited one is an override.
|
|
68
|
+
SHARED_SET = frozenset((
|
|
69
|
+
".ai/policies/production.md", ".ai/policies/database.md", ".ai/policies/git.md", ".ai/policies/release.md",
|
|
70
|
+
".ai/policies/context-management.md", ".ai/policies/tooling.md", ".ai/policies/model-routing.md",
|
|
71
|
+
".ai/policies/review-economy.md",
|
|
72
|
+
".ai/agents/context.md", ".ai/agents/discovery.md", ".ai/agents/implementer.md",
|
|
73
|
+
".ai/agents/planner.md", ".ai/agents/release.md", ".ai/agents/reviewer.md", ".ai/agents/risk.md",
|
|
74
|
+
".ai/agents/security.md", ".ai/agents/tester.md",
|
|
75
|
+
".ai/templates/task-context.md", ".ai/templates/impact-report.md", ".ai/templates/review-report.md",
|
|
76
|
+
".ai/templates/release-report.md", ".ai/templates/architecture-decision.md",
|
|
77
|
+
".ai/templates/implementation-plan.md",
|
|
78
|
+
".ai/using-riskrails.md", ".ai/workflows.md",
|
|
79
|
+
))
|
|
80
|
+
RULES_DIR = ".ai/rules"
|
|
81
|
+
# Directories a stale rule block is never hunted in: a dependency's instruction
|
|
82
|
+
# file is not ours (the path guard refuses it too), and .git is not text.
|
|
83
|
+
# A child holding a .git entry (worktree, nested repository, submodule) is pruned
|
|
84
|
+
# too, by the take-back walk (rendered_rules) and by the writer (nested_tree). The
|
|
85
|
+
# writer also skips a RULES_SKIP name or a symlinked directory (unwalked_dir), so it
|
|
86
|
+
# never writes where the take-back never walks.
|
|
87
|
+
RULES_SKIP = {".git", ".ai", "vendor", "node_modules", "bower_components",
|
|
88
|
+
"third_party", "Pods", "site-packages", ".venv", ".pnpm", ".yarn"}
|
|
89
|
+
# Compared case-insensitively: on a case-insensitive filesystem `Vendor/` is `vendor/`.
|
|
90
|
+
RULES_SKIP_FOLDED = frozenset(n.casefold() for n in RULES_SKIP)
|
|
91
|
+
|
|
92
|
+
RUNTIME_MAP = { # the per-runtime layer of the project-init scaffold: template, target, kind
|
|
93
|
+
"claude": [("memory-README.md", ".claude/memory/README.md", "regenerate"),
|
|
94
|
+
("project-settings.json", ".claude/settings.json", "create")],
|
|
95
|
+
"codex": [("memory-README.md", ".codex/memory/README.md", "regenerate"),
|
|
96
|
+
("project-config.toml", ".codex/config.toml", "create")],
|
|
97
|
+
"gemini": [("memory-README.md", ".gemini/memory/README.md", "regenerate")],
|
|
98
|
+
"junie": [("memory-README.md", ".junie/memory/README.md", "regenerate")],
|
|
99
|
+
}
|
|
100
|
+
SDLC_CREATE = (("sdlc-README.md", "docs/sdlc/README.md"), ("constitution.md", "docs/sdlc/constitution.md"))
|
|
101
|
+
INSTRUCTION_FILE = { # runtime -> (file, block template, minimal template)
|
|
102
|
+
"claude": ("CLAUDE.md", "CLAUDE.block.md", "CLAUDE.minimal.md"),
|
|
103
|
+
"codex": ("AGENTS.md", "AGENTS.block.md", "AGENTS.minimal.md"),
|
|
104
|
+
"gemini": ("GEMINI.md", "GEMINI.block.md", "GEMINI.minimal.md"),
|
|
105
|
+
"junie": (".junie/guidelines.md", "junie-guidelines.block.md",
|
|
106
|
+
"junie-guidelines.minimal.md"),
|
|
107
|
+
}
|
|
108
|
+
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The plan one run builds and the decisions about one file: lost lines, patterns, the note on an edited copy, the regenerate rule.
|
|
2
|
+
|
|
3
|
+
shell: Plan, regenerate
|
|
4
|
+
core: lost_lines, patterns_of, edited_note
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from rr_update import files
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------- planning
|
|
15
|
+
class Plan:
|
|
16
|
+
def __init__(self, root):
|
|
17
|
+
self.root = root
|
|
18
|
+
self.items = [] # dicts: action, target, note, content, reason, keep, expect
|
|
19
|
+
self.hints = []
|
|
20
|
+
self.confirm_delete = None # the human who confirmed this run's gated rows
|
|
21
|
+
self.confirm_via = None # why that gate passed: "terminal" or "unattended"
|
|
22
|
+
self.confirm_cleanup = None # the human who confirmed this run's [cleanup] deletions
|
|
23
|
+
self.cleanup_via = None
|
|
24
|
+
self.version_from = None # the .ai/VERSION this run started from
|
|
25
|
+
self.final = {} # target -> bytes after this run (for cross-file fixes)
|
|
26
|
+
self.removed = set() # targets this run takes away
|
|
27
|
+
self.seen = {} # target -> sha of what was on disk when it was first read,
|
|
28
|
+
# or None when nothing was there: apply's precondition
|
|
29
|
+
self.git_kept = set() # [cleanup] targets git holds or should hold: a hint and an original/ copy
|
|
30
|
+
self.report_dir = ".ai/reports/project-update-%s" % datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
31
|
+
|
|
32
|
+
# The project as this run leaves it: planned content first, then the disk.
|
|
33
|
+
# File readers in the planning path go through these, not the disk.
|
|
34
|
+
def exists(self, target):
|
|
35
|
+
if target in self.final:
|
|
36
|
+
return True
|
|
37
|
+
return target not in self.removed and os.path.exists(os.path.join(self.root, target))
|
|
38
|
+
|
|
39
|
+
def read(self, target):
|
|
40
|
+
if target in self.final:
|
|
41
|
+
return self.final[target]
|
|
42
|
+
if not self.exists(target):
|
|
43
|
+
return None
|
|
44
|
+
data = files.read(os.path.join(self.root, target))
|
|
45
|
+
self.seen.setdefault(target, files.sha(data)) # what apply must find there
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
def remove(self, target):
|
|
49
|
+
self.final.pop(target, None)
|
|
50
|
+
self.removed.add(target)
|
|
51
|
+
|
|
52
|
+
def before(self, target):
|
|
53
|
+
"""The sha of the target as this run first read it, None when it was
|
|
54
|
+
absent. Recorded by the read that planned the change, so the precondition
|
|
55
|
+
and the planning input are the same bytes."""
|
|
56
|
+
if target not in self.seen:
|
|
57
|
+
path = os.path.join(self.root, target)
|
|
58
|
+
self.seen[target] = files.sha(files.read(path)) if os.path.isfile(path) and not os.path.islink(path) else None
|
|
59
|
+
return self.seen[target]
|
|
60
|
+
|
|
61
|
+
def add(self, action, target, note="", content=None, reason=None, keep=False):
|
|
62
|
+
self.items.append(dict(action=action, target=target, note=note, content=content,
|
|
63
|
+
reason=reason, keep=keep, expect=self.before(target)))
|
|
64
|
+
if content is not None:
|
|
65
|
+
self.final[target] = content
|
|
66
|
+
self.removed.discard(target) # planned content wins over an earlier removal
|
|
67
|
+
|
|
68
|
+
def original_of(self, target):
|
|
69
|
+
return "%s/original/%s" % (self.report_dir, target)
|
|
70
|
+
|
|
71
|
+
def gated(self, action, target, note, content=None, reason=None):
|
|
72
|
+
"""A change that waits for a human: `regenerate?` / `delete?` until
|
|
73
|
+
--apply --confirm-delete NAME, then `regenerate` / `delete` with the original kept
|
|
74
|
+
under original/ (R7)."""
|
|
75
|
+
if self.confirm_delete:
|
|
76
|
+
self.add(action, target, note, content=content, reason=reason, keep=True)
|
|
77
|
+
if action == "delete":
|
|
78
|
+
self.remove(target)
|
|
79
|
+
else:
|
|
80
|
+
self.add(action + "?", target, note, reason=reason)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------- regenerate
|
|
84
|
+
def lost_lines(ours, theirs):
|
|
85
|
+
"""The lines of the project's copy that the plugin's version does not have."""
|
|
86
|
+
kept = set(theirs.decode("utf-8", "replace").splitlines())
|
|
87
|
+
return [line.strip() for line in ours.decode("utf-8", "replace").splitlines()
|
|
88
|
+
if line.strip() and line not in kept]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def patterns_of(data):
|
|
92
|
+
"""The count of non-empty deny and allow patterns in a path-guard.json, or None when it is not one."""
|
|
93
|
+
try:
|
|
94
|
+
doc = json.loads(data)
|
|
95
|
+
return sum(1 for key in ("deny_patterns", "allow_patterns") for p in doc.get(key) or []
|
|
96
|
+
if isinstance(p, str) and p.strip())
|
|
97
|
+
except (ValueError, AttributeError, TypeError, RecursionError):
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def edited_note(plan, target, ours, theirs):
|
|
102
|
+
"""What a gated regenerate would lose, and where the original goes (R6)."""
|
|
103
|
+
lost = lost_lines(ours, theirs)
|
|
104
|
+
shown = "; ".join("`%s`" % (line[:48] + ("…" if len(line) > 48 else "")) for line in lost[:3])
|
|
105
|
+
note = "edited here: %d line(s) of yours are not in the plugin's version%s" % (len(lost), " (" + shown + ")" if shown else "")
|
|
106
|
+
if target.endswith("path-guard.json"):
|
|
107
|
+
count = patterns_of(ours)
|
|
108
|
+
note += "; drops %s deny/allow pattern(s)" % ("an unreadable number of" if count is None else count)
|
|
109
|
+
return "%s; original kept at %s" % (note, plan.original_of(target))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def regenerate(plan, history, key, target, theirs):
|
|
113
|
+
"""D5: absent -> create; equal -> nothing; a version the plugin once shipped -> regenerate;
|
|
114
|
+
anything else is the human's edit -> regenerate? behind the gate."""
|
|
115
|
+
ours = plan.read(target)
|
|
116
|
+
if ours is None:
|
|
117
|
+
plan.add("create", target, content=theirs)
|
|
118
|
+
elif ours == theirs:
|
|
119
|
+
return
|
|
120
|
+
elif history.shipped(key, ours):
|
|
121
|
+
plan.add("regenerate", target, "unchanged since it was installed", content=theirs)
|
|
122
|
+
else:
|
|
123
|
+
plan.gated("regenerate", target, edited_note(plan, target, ours, theirs), content=theirs)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The report of a run: one line per item, the hints and the summary.
|
|
2
|
+
|
|
3
|
+
shell: report
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def report(plan, applied):
|
|
8
|
+
auto = [i for i in plan.items if i["action"] not in ("delete?", "regenerate?")]
|
|
9
|
+
gated = [i for i in plan.items if i["action"] in ("delete?", "regenerate?")]
|
|
10
|
+
head = "applied" if applied else "dry run, nothing written; --apply to write"
|
|
11
|
+
print("project-update: %s (%s)" % (plan.root, head))
|
|
12
|
+
if not plan.items:
|
|
13
|
+
print(" up to date with the installed plugin")
|
|
14
|
+
# a cleanup is one line per directory, however many files it holds
|
|
15
|
+
rows, groups = [], {}
|
|
16
|
+
for i in plan.items:
|
|
17
|
+
if (i["reason"] or "").startswith("[cleanup]"):
|
|
18
|
+
parts = i["target"].split("/")
|
|
19
|
+
where = "/".join(parts[:4] if i["target"].startswith(".ai/state/logs/") else parts[:3])
|
|
20
|
+
key = (where, i["action"], i["note"])
|
|
21
|
+
if key in groups:
|
|
22
|
+
groups[key]["count"] += 1
|
|
23
|
+
continue
|
|
24
|
+
row = dict(i, count=1)
|
|
25
|
+
groups[key] = row
|
|
26
|
+
row["target"] = where
|
|
27
|
+
i = row
|
|
28
|
+
rows.append(i)
|
|
29
|
+
for (where, _action, _note), row in groups.items():
|
|
30
|
+
row["target"] = "%s/ (%d file%s)" % (where, row["count"], "" if row["count"] == 1 else "s")
|
|
31
|
+
width = max([len(i["target"]) for i in rows] + [10])
|
|
32
|
+
for i in rows:
|
|
33
|
+
note = i["note"]
|
|
34
|
+
if i["action"] in ("delete?", "regenerate?"):
|
|
35
|
+
if (i["reason"] or "").startswith("[cleanup]"):
|
|
36
|
+
note += "; needs --apply --confirm-cleanup NAME"
|
|
37
|
+
if i["target"].startswith(".ai/state/"):
|
|
38
|
+
note += " (git-ignored: not recoverable through git)"
|
|
39
|
+
else:
|
|
40
|
+
note += "; needs --apply --confirm-delete NAME"
|
|
41
|
+
print(" %-9s %-*s %s" % (i["action"], width, i["target"], note))
|
|
42
|
+
for h in plan.hints:
|
|
43
|
+
print(" hint " + h)
|
|
44
|
+
tail = ""
|
|
45
|
+
# the summary names the flag each row names: [cleanup] rows settle with --confirm-cleanup only
|
|
46
|
+
cleanups = [i for i in gated if (i["reason"] or "").startswith("[cleanup]")]
|
|
47
|
+
if len(gated) > len(cleanups):
|
|
48
|
+
tail += ", %d change(s) awaiting --confirm-delete" % (len(gated) - len(cleanups))
|
|
49
|
+
if cleanups:
|
|
50
|
+
tail += ", %d cleanup file(s) awaiting --confirm-cleanup" % len(cleanups)
|
|
51
|
+
print("%d automatic%s" % (len(auto), tail))
|