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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +25 -15
- package/agents/ai-reviewer.md.tmpl +14 -1
- package/ai/agents/implementer.md +6 -0
- package/ai/agents/reviewer.md +8 -1
- package/bin/ansi.js +12 -0
- package/bin/menu.js +71 -12
- package/bin/riskrails.js +240 -14
- package/bin/uninstall.js +115 -0
- package/bin/wizard.js +72 -0
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- package/hooks/context-guard.py +11 -1
- package/hooks/dialog-gate.py +3 -2
- package/hooks/guard/decision.py +4 -3
- package/hooks/guard/dialog.py +12 -2
- package/hooks/guard/ere.py +29 -10
- package/hooks/guard/git.py +2 -1
- package/hooks/guard/paths.py +48 -1406
- package/hooks/guard/paths_base.py +66 -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/hooks/runtime-gate.py +87 -0
- package/hooks/update_notice.py +23 -8
- package/package.json +1 -1
- package/skills/AGENTS.md +0 -6
- package/skills/CLAUDE.md +0 -6
- package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
- package/skills/ai-init/templates/gitignore.snippet +2 -0
- package/skills/ai-task/memory_cleanup.py +213 -0
- package/skills/ai-task/memory_compact.py +25 -0
- package/skills/ai-task/memory_config.py +120 -0
- package/skills/ai-task/memory_export.py +246 -0
- package/skills/ai-task/memory_format.py +7 -3
- package/skills/ai-task/memory_hook.py +172 -0
- package/skills/ai-task/memory_io.py +122 -0
- package/skills/ai-task/memory_migrate.py +187 -0
- package/skills/ai-task/memory_packages.py +83 -0
- package/skills/ai-task/memory_risks.py +84 -0
- package/skills/ai-task/memory_status.py +76 -0
- package/skills/ai-task/memory_sync.py +20 -1481
- package/skills/ai-task/memory_verify.py +316 -0
- package/skills/ai-task/rr_state/cli.py +1 -0
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/dialog.py +32 -7
- package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
- package/skills/ai-task/rr_state/gate.py +10 -4
- package/skills/ai-task/rr_state/labels.py +33 -4
- package/skills/ai-task/rr_state/memory_cmd.py +84 -64
- package/skills/ai-task/rr_state/questions_cmd.py +5 -4
- package/skills/ai-task/rr_state/report.py +73 -10
- package/skills/ai-task/rr_state/report_cmd.py +2 -0
- package/skills/ai-task/rr_state/review.py +18 -14
- package/skills/ai-task/rr_state/start.py +12 -9
- package/skills/ai-task/rr_state/steps.py +16 -7
- package/skills/ai-task/rr_state/verify.py +17 -12
- package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +8 -4
- 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-build/SKILL.md +5 -2
- package/skills/rr-setup/SKILL.md +4 -2
- package/skills/rr-setup/setup.py +13 -12
- package/skills/rr-ship/SKILL.md +2 -2
- package/skills/rr-spec/SKILL.md +6 -0
- package/skills/rr-status/SKILL.md +2 -0
- package/skills/rr-status/status.py +5 -3
|
@@ -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))
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The project rules: where they come from, how they render, the nested tree, and the rules rows of the plan.
|
|
2
|
+
|
|
3
|
+
shell: rule_sources, rendered_rules, nested_tree, unwalked_dir, _frozen_block, rules_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
|
+
|
|
13
|
+
|
|
14
|
+
def rule_sources(plan):
|
|
15
|
+
"""OQ1: a rule source is not kept. Each `.ai/rules/<slug>.md` is `delete?` behind the gate with its
|
|
16
|
+
original copied; until it is confirmed the rule stays rendered."""
|
|
17
|
+
rules = os.path.join(plan.root, paths.RULES_DIR)
|
|
18
|
+
for name in sorted(os.listdir(rules)) if os.path.isdir(rules) else []:
|
|
19
|
+
rel = "%s/%s" % (paths.RULES_DIR, name)
|
|
20
|
+
if name.endswith(".md") and name != "README.md" and os.path.isfile(os.path.join(rules, name)) \
|
|
21
|
+
and not files.unsafe_path(plan.root, rel):
|
|
22
|
+
why = "[rules] rule sources are not kept by an update: the rendered blocks go with it"
|
|
23
|
+
plan.gated("delete", rel, why, reason=why)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def rendered_rules(plan):
|
|
27
|
+
"""Every (file, slug) in the project that carries a rule block today, plus
|
|
28
|
+
every path-scoped copy under .claude/rules/. Walked rather than remembered:
|
|
29
|
+
a manifest would be one more file to keep true, and the markers already say
|
|
30
|
+
who wrote them."""
|
|
31
|
+
blocks, docs = [], []
|
|
32
|
+
names = set(render_instructions.RULE_FILE.values())
|
|
33
|
+
for dirpath, dirnames, filenames in os.walk(plan.root):
|
|
34
|
+
dirnames[:] = sorted(d for d in dirnames if d not in paths.RULES_SKIP
|
|
35
|
+
and not os.path.lexists(os.path.join(dirpath, d, ".git")))
|
|
36
|
+
rel_dir = os.path.relpath(dirpath, plan.root)
|
|
37
|
+
for name in sorted(filenames):
|
|
38
|
+
rel = name if rel_dir == "." else os.path.join(rel_dir, name)
|
|
39
|
+
rel = rel.replace(os.sep, "/")
|
|
40
|
+
if name in names:
|
|
41
|
+
text = plan.read(rel)
|
|
42
|
+
if text is None:
|
|
43
|
+
continue
|
|
44
|
+
for slug in render_instructions.rule_slugs_in(text.decode("utf-8", "replace")):
|
|
45
|
+
blocks.append((rel, slug))
|
|
46
|
+
elif rel.startswith(render_instructions.CLAUDE_RULES_DIR + "/") and name.endswith(".md"):
|
|
47
|
+
text = plan.read(rel)
|
|
48
|
+
if text is not None and render_instructions.RULE_GENERATED % name[:-3] in \
|
|
49
|
+
text.decode("utf-8", "replace"):
|
|
50
|
+
docs.append(rel)
|
|
51
|
+
return blocks, docs
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def nested_tree(root, rel_dir):
|
|
55
|
+
"""The first directory below the root on the way to `rel_dir`, `rel_dir`
|
|
56
|
+
included, that holds a .git entry (a worktree, a nested repository, a
|
|
57
|
+
submodule), or None. The same predicate rendered_rules() prunes on."""
|
|
58
|
+
# "." and "" are the root itself (dirs: ["."] reaches here as ""), whose
|
|
59
|
+
# own .git is not a nested tree.
|
|
60
|
+
parts = [p for p in rel_dir.split("/") if p not in ("", ".")]
|
|
61
|
+
for i in range(1, len(parts) + 1):
|
|
62
|
+
candidate = "/".join(parts[:i])
|
|
63
|
+
if os.path.lexists(os.path.join(root, candidate, ".git")):
|
|
64
|
+
return candidate
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def unwalked_dir(root, rel_dir):
|
|
69
|
+
"""Why the take-back walk (rendered_rules) never enters `rel_dir`, or None:
|
|
70
|
+
a RULES_SKIP name on the way, or a symlinked directory on the way."""
|
|
71
|
+
here = root
|
|
72
|
+
for part in (p for p in rel_dir.split("/") if p not in ("", ".")):
|
|
73
|
+
here = os.path.join(here, part)
|
|
74
|
+
if part.casefold() in paths.RULES_SKIP_FOLDED:
|
|
75
|
+
return "%s is under a directory the take-back never walks (%s)" % (rel_dir, part)
|
|
76
|
+
if os.path.islink(here):
|
|
77
|
+
return "%s is a symlink, which the take-back never follows" % os.path.relpath(here, root).replace(os.sep, "/")
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _frozen_block(plan, target, slug):
|
|
82
|
+
"""True when `target` is a readable regular file inside the project, holding a
|
|
83
|
+
block for `slug`, at a place the take-back walk (rendered_rules) never enters: a
|
|
84
|
+
RULES_SKIP name or a nested git tree on its real path. A symlink that leads inside
|
|
85
|
+
the walked part of the project is removed by the same run; one that leads outside
|
|
86
|
+
the project, a directory, a FIFO or an unreadable file is not read at all."""
|
|
87
|
+
full = os.path.join(plan.root, target)
|
|
88
|
+
try:
|
|
89
|
+
real = os.path.realpath(full)
|
|
90
|
+
base = os.path.realpath(plan.root)
|
|
91
|
+
if os.path.commonpath([real, base]) != base or not os.path.isfile(real):
|
|
92
|
+
return False
|
|
93
|
+
rel_dir = os.path.relpath(os.path.dirname(real), base).replace(os.sep, "/")
|
|
94
|
+
parts = [p for p in rel_dir.split("/") if p not in ("", ".")]
|
|
95
|
+
if not (any(p in paths.RULES_SKIP for p in parts) or nested_tree(base, rel_dir) is not None):
|
|
96
|
+
return False
|
|
97
|
+
held = plan.read(target)
|
|
98
|
+
except (OSError, ValueError):
|
|
99
|
+
return False
|
|
100
|
+
return held is not None and slug in render_instructions.rule_slugs_in(held.decode("utf-8", "replace"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def rules_update(plan, runtimes):
|
|
104
|
+
"""Render `.ai/rules/<slug>.md` into the instruction file of every directory
|
|
105
|
+
it names, and take back what a rule no longer asks for. The text around a
|
|
106
|
+
block belongs to the project and is never read for meaning, only preserved.
|
|
107
|
+
A rule whose source this run deletes is no longer rendered."""
|
|
108
|
+
rules_path = os.path.join(plan.root, paths.RULES_DIR)
|
|
109
|
+
if not os.path.isdir(rules_path):
|
|
110
|
+
return
|
|
111
|
+
try:
|
|
112
|
+
rules = [r for r in render_instructions.load_rules(rules_path)
|
|
113
|
+
if "%s/%s.md" % (paths.RULES_DIR, r.slug) not in plan.removed]
|
|
114
|
+
except render_instructions.RenderError as exc:
|
|
115
|
+
plan.hints.append("%s — that rule is not rendered; the rest are" % exc)
|
|
116
|
+
return
|
|
117
|
+
wanted = render_instructions.rule_targets(rules, runtimes)
|
|
118
|
+
managed = {(i["target"], i["slug"]) for i in wanted if i["kind"] == "block"}
|
|
119
|
+
kept_docs = {i["target"] for i in wanted if i["kind"] == "doc"}
|
|
120
|
+
|
|
121
|
+
for item in wanted:
|
|
122
|
+
source = "%s/%s.md" % (paths.RULES_DIR, item["slug"])
|
|
123
|
+
target, content = item["target"], item["content"]
|
|
124
|
+
tree = nested_tree(plan.root, os.path.dirname(target)) if item["kind"] == "block" else None
|
|
125
|
+
if tree is not None:
|
|
126
|
+
# Not this project's file, and the take-back never walks there: a
|
|
127
|
+
# block written there could never be taken back again.
|
|
128
|
+
plan.hints.append("%s: %s is a nested git tree (worktree, repository or submodule)"
|
|
129
|
+
" — rule %s is not rendered there" % (target, tree, item["slug"]))
|
|
130
|
+
continue
|
|
131
|
+
skip = unwalked_dir(plan.root, os.path.dirname(target)) if item["kind"] == "block" else None
|
|
132
|
+
if skip is not None:
|
|
133
|
+
hint = "%s: %s — rule %s is not rendered there" % (target, skip, item["slug"])
|
|
134
|
+
if _frozen_block(plan, target, item["slug"]):
|
|
135
|
+
hint += ("; an earlier rendering of it is still there, the take-back never walks there:"
|
|
136
|
+
" remove that block by hand")
|
|
137
|
+
plan.hints.append(hint)
|
|
138
|
+
continue
|
|
139
|
+
current = plan.read(target)
|
|
140
|
+
if item["kind"] == "doc":
|
|
141
|
+
new = content.encode()
|
|
142
|
+
if current is None:
|
|
143
|
+
plan.add("create", target, "rendered from " + source, content=new)
|
|
144
|
+
elif current != new:
|
|
145
|
+
plan.add("regenerate", target, "rendered from " + source, content=new)
|
|
146
|
+
continue
|
|
147
|
+
text = "" if current is None else current.decode("utf-8")
|
|
148
|
+
new = render_instructions.splice_rule(text, item["slug"], content).encode()
|
|
149
|
+
if current is None:
|
|
150
|
+
plan.add("create", target, "rule block %s from %s" % (item["slug"], source),
|
|
151
|
+
content=new)
|
|
152
|
+
elif current != new:
|
|
153
|
+
plan.add("regenerate", target, "rule block %s from %s" % (item["slug"], source),
|
|
154
|
+
content=new)
|
|
155
|
+
|
|
156
|
+
blocks, docs = rendered_rules(plan)
|
|
157
|
+
for target, slug in blocks:
|
|
158
|
+
if (target, slug) in managed:
|
|
159
|
+
continue
|
|
160
|
+
text = plan.read(target).decode("utf-8")
|
|
161
|
+
# The block is ours; the file is not. Take the block, leave the file.
|
|
162
|
+
plan.add("regenerate", target, "rule block %s removed (%s/%s.md no longer renders here)"
|
|
163
|
+
% (slug, paths.RULES_DIR, slug),
|
|
164
|
+
content=render_instructions.strip_rule(text, slug).encode())
|
|
165
|
+
for target in docs:
|
|
166
|
+
if target in kept_docs:
|
|
167
|
+
continue
|
|
168
|
+
# The whole file is a rendered copy, so taking it away is a deletion, and
|
|
169
|
+
# a deletion waits for a human exactly as a migration's does.
|
|
170
|
+
slug = os.path.basename(target)[:-3]
|
|
171
|
+
why = "[rules] %s" % ("%s/%s.md no longer sets paths:" % (paths.RULES_DIR, slug)
|
|
172
|
+
if slug in {r.slug for r in rules}
|
|
173
|
+
else "source %s/%s.md is gone" % (paths.RULES_DIR, slug))
|
|
174
|
+
if plan.confirm_delete:
|
|
175
|
+
plan.add("delete", target, why, reason=why)
|
|
176
|
+
plan.remove(target)
|
|
177
|
+
else:
|
|
178
|
+
plan.add("delete?", target, why, reason=why)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""The shared tree: refusing to drop a copy whose fallback is missing, and the shared-copy rows of the plan.
|
|
2
|
+
|
|
3
|
+
shell: require_shared, shared_update
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from rr_update import files
|
|
9
|
+
from rr_update import history as history_mod
|
|
10
|
+
from rr_update import paths as paths_mod
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------- shared files, rules, caches
|
|
14
|
+
def require_shared(paths, runtimes):
|
|
15
|
+
"""Refuse, before anything is written, to drop a project copy whose shared fallback is missing:
|
|
16
|
+
in this plugin's tree, or in another installed home of a runtime the project declares. A home
|
|
17
|
+
this machine does not have is not checked."""
|
|
18
|
+
undeclared = sorted(p for p in paths if p not in paths_mod.SHARED_SET)
|
|
19
|
+
if undeclared:
|
|
20
|
+
raise history_mod.SchemaError("not in the shared set, never deleted as a shared copy: %s" % ", ".join(undeclared))
|
|
21
|
+
rels = sorted(p[len(".ai/"):] for p in paths)
|
|
22
|
+
missing = [r for r in rels if not os.path.isfile(os.path.join(paths_mod.SHARED, r))]
|
|
23
|
+
if missing:
|
|
24
|
+
raise history_mod.SchemaError("the plugin's shared tree %s lacks %d file(s) a project copy would fall back to: %s — "
|
|
25
|
+
"reinstall the plugin (install.sh), then run this again" % (paths_mod.SHARED, len(missing), ", ".join(missing)))
|
|
26
|
+
homes = {"claude": os.path.join(os.path.expanduser("~"), ".claude"),
|
|
27
|
+
"codex": os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")}
|
|
28
|
+
mine = os.path.realpath(paths_mod.PLUGIN_HOME)
|
|
29
|
+
for runtime in runtimes:
|
|
30
|
+
home = homes.get(runtime)
|
|
31
|
+
if (not home or os.path.realpath(home) == mine
|
|
32
|
+
or not os.path.isdir(os.path.join(home, "skills", "project-update"))):
|
|
33
|
+
continue
|
|
34
|
+
tree = os.path.join(home, "riskrails", "ai")
|
|
35
|
+
missing = [r for r in rels if not os.path.isfile(os.path.join(tree, r))]
|
|
36
|
+
if missing:
|
|
37
|
+
raise history_mod.SchemaError("%s has this plugin installed for %s, but %d of the %d shared file(s) a project copy "
|
|
38
|
+
"would fall back to are missing from %s — run `install.sh --target %s` from this "
|
|
39
|
+
"build, then run this again" % (home, runtime, len(missing), len(rels), tree, runtime))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def shared_update(plan, history, runtimes):
|
|
43
|
+
"""R17: an unedited copy of a shared file is offered for deletion; an edited one is an
|
|
44
|
+
override, kept, and named once (OQ8)."""
|
|
45
|
+
edited, candidates = [], []
|
|
46
|
+
extra = history_mod.undeclared_shared()
|
|
47
|
+
if extra:
|
|
48
|
+
plan.hints.append("the shared tree %s holds %d file(s) outside the shared set, ignored — reinstall the "
|
|
49
|
+
"plugin (install.sh) if you did not put them there: %s" % (paths_mod.SHARED, len(extra), ", ".join(extra)))
|
|
50
|
+
for path in sorted(history_mod.shared_files()):
|
|
51
|
+
data = plan.read(path)
|
|
52
|
+
if data is None:
|
|
53
|
+
continue
|
|
54
|
+
why = files.unsafe_path(plan.root, path)
|
|
55
|
+
if why:
|
|
56
|
+
plan.hints.append("%s: %s, so this update never deletes it — check by hand whether it is still "
|
|
57
|
+
"needed" % (path, why))
|
|
58
|
+
elif not history.shipped("ai-init/" + path, data):
|
|
59
|
+
edited.append(path)
|
|
60
|
+
else:
|
|
61
|
+
candidates.append(path)
|
|
62
|
+
if candidates:
|
|
63
|
+
try:
|
|
64
|
+
require_shared(candidates, runtimes)
|
|
65
|
+
except history_mod.SchemaError as exc:
|
|
66
|
+
plan.hints.append("%s — the copies stay until then" % exc)
|
|
67
|
+
candidates = []
|
|
68
|
+
for path in candidates:
|
|
69
|
+
why = "[shared] identical to a shipped version"
|
|
70
|
+
plan.gated("delete", path, why, reason=why)
|
|
71
|
+
if edited:
|
|
72
|
+
plan.hints.append("%d file(s) override the plugin's shared copy and no longer receive its changes: %s — "
|
|
73
|
+
"diff each against %s/<path>; `ai-path <path>` shows which applies; delete one when it "
|
|
74
|
+
"adds nothing" % (len(edited), ", ".join(edited), paths_mod.SHARED))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Modules of the sibling skills, loaded by path once per run, and the weight files they serve.
|
|
2
|
+
|
|
3
|
+
shell: _MODULES, sibling, weight_module, weight_overlay, shipped_weight
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import importlib.util
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from rr_update import paths
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_MODULES = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sibling(name, *parts):
|
|
16
|
+
"""A module of another skill, loaded by path once per run (skills are installed independently)."""
|
|
17
|
+
if name not in _MODULES:
|
|
18
|
+
spec = importlib.util.spec_from_file_location("riskrails_" + name, os.path.join(paths.SKILLS, *parts))
|
|
19
|
+
_MODULES[name] = importlib.util.module_from_spec(spec)
|
|
20
|
+
spec.loader.exec_module(_MODULES[name])
|
|
21
|
+
return _MODULES[name]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def weight_module():
|
|
25
|
+
"""skills/ai-task/weight.py, the renderer behind `state.py weight --mirror`."""
|
|
26
|
+
return sibling("weight", "ai-task", "weight.py")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def weight_overlay():
|
|
30
|
+
"""skills/ai-task/weight_overlay.py: the plugin's table with the project's laid over it."""
|
|
31
|
+
return sibling("weight_overlay", "ai-task", "weight_overlay.py")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def shipped_weight():
|
|
35
|
+
return os.path.join(paths.TEMPLATES["ai-init"], paths.WEIGHT_JSON)
|
|
36
|
+
|
|
37
|
+
|