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,38 @@
|
|
|
1
|
+
"""The managed block of one instruction file.
|
|
2
|
+
|
|
3
|
+
shell: block_update
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import render_instructions # next to the entry, whose directory the entry puts on sys.path
|
|
9
|
+
|
|
10
|
+
from rr_update import files
|
|
11
|
+
from rr_update import paths
|
|
12
|
+
from rr_update import plan as plan_mod
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def block_update(plan, history, runtime):
|
|
16
|
+
"""R15, D5: the managed block of one instruction file. Rewritten between its markers; no block
|
|
17
|
+
-> appended; no file -> a minimal one with the block. Text outside the markers is never touched."""
|
|
18
|
+
name, block_tpl, minimal_tpl = paths.INSTRUCTION_FILE[runtime]
|
|
19
|
+
tpl = files.read(os.path.join(paths.TEMPLATES["ai-init"], block_tpl))
|
|
20
|
+
text = plan.read(name)
|
|
21
|
+
if text is None:
|
|
22
|
+
created = files.read(os.path.join(paths.TEMPLATES["ai-init"], minimal_tpl)).replace(
|
|
23
|
+
b"{{PROJECT}}", os.path.basename(plan.root).encode())
|
|
24
|
+
plan.add("create", name, "with the managed block", content=created.rstrip(b"\n") + b"\n\n" + tpl)
|
|
25
|
+
return
|
|
26
|
+
found = render_instructions.find_block(text)
|
|
27
|
+
if found is None:
|
|
28
|
+
plan.add("regenerate", name, "managed block appended", content=text.rstrip(b"\n") + b"\n\n" + tpl)
|
|
29
|
+
return
|
|
30
|
+
s, e = found[0], found[1]
|
|
31
|
+
ours = text[s:e] + b"\n"
|
|
32
|
+
if ours == tpl:
|
|
33
|
+
return
|
|
34
|
+
content = text[:s] + tpl.rstrip(b"\n") + text[e:]
|
|
35
|
+
if history.shipped("ai-init/" + block_tpl, ours):
|
|
36
|
+
plan.add("regenerate", name, "managed block replaced (unchanged since it was installed)", content=content)
|
|
37
|
+
else:
|
|
38
|
+
plan.gated("regenerate", name, "managed block " + plan_mod.edited_note(plan, name, ours, tpl), content=content)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The plan of one run: every rule, in the order D3 sets, collected into one Plan.
|
|
2
|
+
|
|
3
|
+
shell: build_plan
|
|
4
|
+
core: PHASE
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from rr_update import blocks
|
|
10
|
+
from rr_update import cleanup
|
|
11
|
+
from rr_update import files as files_mod
|
|
12
|
+
from rr_update import hints
|
|
13
|
+
from rr_update import history as history_mod
|
|
14
|
+
from rr_update import paths
|
|
15
|
+
from rr_update import plan as plan_mod
|
|
16
|
+
from rr_update import rules
|
|
17
|
+
from rr_update import shared
|
|
18
|
+
from rr_update import weights
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
PHASE = {"split": 0, "delete": 2, "delete?": 2}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None, cleanup_via=None):
|
|
25
|
+
"""D3: split_once -> regenerate -> shared -> block per runtime -> rules -> local -> gitignore, cleanup, hints.
|
|
26
|
+
The caller has run check_kept: nothing here reads a kept file for meaning."""
|
|
27
|
+
plan = plan_mod.Plan(root)
|
|
28
|
+
plan.confirm_cleanup = confirm_cleanup
|
|
29
|
+
plan.cleanup_via = cleanup_via
|
|
30
|
+
plan.confirm_delete = confirm_delete
|
|
31
|
+
plan.confirm_via = confirm_via
|
|
32
|
+
has_ai = os.path.isdir(os.path.join(root, ".ai"))
|
|
33
|
+
has_sdlc = os.path.isdir(os.path.join(root, "docs", "sdlc"))
|
|
34
|
+
if not (has_ai or has_sdlc):
|
|
35
|
+
return None
|
|
36
|
+
history = history_mod.History(paths.HISTORY)
|
|
37
|
+
runtimes = history_mod.project_runtimes(root)
|
|
38
|
+
plan.version_from = history_mod.project_version(root) if has_ai else None
|
|
39
|
+
|
|
40
|
+
if has_sdlc:
|
|
41
|
+
tpl = paths.TEMPLATES["project-init"]
|
|
42
|
+
for src, target in paths.SDLC_CREATE: # the project's own from then on
|
|
43
|
+
if not plan.exists(target):
|
|
44
|
+
plan.add("create", target, content=files_mod.read(os.path.join(tpl, src)).replace(
|
|
45
|
+
b"{{PROJECT}}", os.path.basename(root).encode()))
|
|
46
|
+
for rt in runtimes:
|
|
47
|
+
for src, target, kind in paths.RUNTIME_MAP[rt]:
|
|
48
|
+
content = files_mod.read(os.path.join(tpl, src))
|
|
49
|
+
if kind == "create":
|
|
50
|
+
if not plan.exists(target):
|
|
51
|
+
plan.add("create", target, content=content)
|
|
52
|
+
else:
|
|
53
|
+
plan_mod.regenerate(plan, history, "project-init/" + src, target, content)
|
|
54
|
+
hints.gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
55
|
+
|
|
56
|
+
if has_ai:
|
|
57
|
+
tpl = paths.TEMPLATES["ai-init"]
|
|
58
|
+
weights.split_once(plan, history)
|
|
59
|
+
files = []
|
|
60
|
+
for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
|
|
61
|
+
for n in names:
|
|
62
|
+
files.append(os.path.relpath(os.path.join(dirpath, n), tpl).replace(os.sep, "/"))
|
|
63
|
+
for rel in sorted(files):
|
|
64
|
+
if rel in paths.NOT_COPIED or rel in (paths.VERIFICATION_MD, paths.WEIGHT_PROJECT):
|
|
65
|
+
continue
|
|
66
|
+
content = files_mod.read(os.path.join(tpl, rel))
|
|
67
|
+
if rel in paths.CREATE_ONLY or rel.startswith(paths.CREATE_ONLY_DIRS):
|
|
68
|
+
if not plan.exists(rel):
|
|
69
|
+
plan.add("create", rel, content=content)
|
|
70
|
+
else:
|
|
71
|
+
plan_mod.regenerate(plan, history, "ai-init/" + rel, rel, content)
|
|
72
|
+
weights.weight_md(plan, history)
|
|
73
|
+
shared.shared_update(plan, history, runtimes)
|
|
74
|
+
rules.rule_sources(plan)
|
|
75
|
+
cleanup.local_cache(plan)
|
|
76
|
+
cleanup.cleanup_update(plan)
|
|
77
|
+
for rt in runtimes:
|
|
78
|
+
blocks.block_update(plan, history, rt)
|
|
79
|
+
rules.rules_update(plan, runtimes)
|
|
80
|
+
cleanup.deletion_hint(plan)
|
|
81
|
+
hints.gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
82
|
+
cleanup.tracked_ignored_hint(plan)
|
|
83
|
+
hints.verification_hints(plan)
|
|
84
|
+
hints.budget_hints(plan, runtimes)
|
|
85
|
+
if plan.items and plan.exists(paths.STATE_FILE):
|
|
86
|
+
plan.hints.append("%s: a task is in flight while files are regenerated — check that the files "
|
|
87
|
+
"its current step may touch still exist afterwards" % paths.STATE_FILE)
|
|
88
|
+
# Last of the items: an apply that stops short leaves the version alone, and the next run plans the rest again.
|
|
89
|
+
ours = history_mod.template_version()
|
|
90
|
+
if plan.version_from != ours:
|
|
91
|
+
plan.add("regenerate" if plan.exists(paths.VERSION_FILE) else "create", paths.VERSION_FILE,
|
|
92
|
+
"schema %d" % ours, content=b"%d\n" % ours)
|
|
93
|
+
if has_sdlc:
|
|
94
|
+
hints.constitution_hint(plan)
|
|
95
|
+
plan.items.sort(key=lambda i: 3 if i["target"] == paths.VERSION_FILE else PHASE.get(i["action"], 1))
|
|
96
|
+
return plan
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""The cleanup proposals: the local cache, the task journals, the [cleanup] rows and the hints about deletion and tracked ignored files.
|
|
2
|
+
|
|
3
|
+
shell: local_cache, task_journal, cleanup_update, deletion_hint, tracked_ignored_hint
|
|
4
|
+
core: TRACKED_IGNORED_SHOWN
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
|
|
14
|
+
from rr_update import files
|
|
15
|
+
from rr_update import paths
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def local_cache(plan):
|
|
19
|
+
"""R18: .ai/local/plugin-update/ (the old merge's conflict copies) is removed; .ai/local/memory/ is never touched."""
|
|
20
|
+
for here, _dirs, names in os.walk(os.path.join(plan.root, paths.LOCAL_CACHE)):
|
|
21
|
+
for name in sorted(names):
|
|
22
|
+
rel = os.path.relpath(os.path.join(here, name), plan.root).replace(os.sep, "/")
|
|
23
|
+
if not files.unsafe_path(plan.root, rel):
|
|
24
|
+
why = "[local] the old merge's cache, not written any more"
|
|
25
|
+
plan.gated("delete", rel, why, reason=why)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def task_journal(root, task_id):
|
|
29
|
+
"""(finished, abandoned, last event as a datetime) from the task's journal,
|
|
30
|
+
or None when it holds no readable event. A task is finished when its journal
|
|
31
|
+
has task_closed or gate_approved, or when logs/<id>/state.json records a
|
|
32
|
+
result (so a torn task_closed line does not make a closed task look unclosed)."""
|
|
33
|
+
closed = abandoned = False
|
|
34
|
+
last = None
|
|
35
|
+
try:
|
|
36
|
+
with open(os.path.join(root, ".ai", "reports", task_id, "events.jsonl"), encoding="utf-8") as fh:
|
|
37
|
+
lines = fh.read().splitlines()
|
|
38
|
+
except (OSError, UnicodeDecodeError):
|
|
39
|
+
return None
|
|
40
|
+
for line in lines:
|
|
41
|
+
try:
|
|
42
|
+
ev = json.loads(line)
|
|
43
|
+
when = datetime.strptime(ev["ts"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
|
44
|
+
except (ValueError, KeyError, TypeError, RecursionError):
|
|
45
|
+
continue
|
|
46
|
+
if last is None or when > last:
|
|
47
|
+
last = when
|
|
48
|
+
if ev.get("event") == "task_closed":
|
|
49
|
+
closed = True
|
|
50
|
+
data = ev.get("data")
|
|
51
|
+
abandoned = (isinstance(data, dict) and data.get("abandoned") is True) or ev.get("detail") == "abandoned"
|
|
52
|
+
elif ev.get("event") == "gate_approved":
|
|
53
|
+
closed = True
|
|
54
|
+
try:
|
|
55
|
+
with open(os.path.join(root, ".ai", "state", "logs", task_id, "state.json"), encoding="utf-8") as fh:
|
|
56
|
+
result = json.load(fh).get("result")
|
|
57
|
+
except (OSError, ValueError, AttributeError, UnicodeDecodeError, RecursionError):
|
|
58
|
+
result = None
|
|
59
|
+
if isinstance(result, str) and result:
|
|
60
|
+
closed = True
|
|
61
|
+
abandoned = abandoned or result == "abandoned"
|
|
62
|
+
return None if last is None else (closed, abandoned, last)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cleanup_update(plan, now=None):
|
|
66
|
+
"""Old run logs and stale unclosed tasks, proposed for deletion and never
|
|
67
|
+
deleted unasked (only with --confirm-cleanup). A closed task's `*.log` files
|
|
68
|
+
go (its state.json stays); an abandoned task loses every file under
|
|
69
|
+
.ai/reports/<id>/ and .ai/state/logs/<id>/ except that state.json; an unclosed
|
|
70
|
+
one idle for STALE_DAYS loses every file in both. The task in flight is
|
|
71
|
+
never touched. `now` is a datetime; RISKRAILS_NOW (ISO, Z) overrides it."""
|
|
72
|
+
if now is None:
|
|
73
|
+
try:
|
|
74
|
+
now = datetime.strptime(paths.env("NOW") or files.utc_now(), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
75
|
+
except ValueError:
|
|
76
|
+
print("project-update: RISKRAILS_NOW %r is not a UTC time like 2026-01-31T12:00:00Z" % paths.env("NOW"),
|
|
77
|
+
file=sys.stderr)
|
|
78
|
+
raise SystemExit(2) from None
|
|
79
|
+
flight = None
|
|
80
|
+
raw = plan.read(paths.STATE_FILE)
|
|
81
|
+
if raw is not None:
|
|
82
|
+
try:
|
|
83
|
+
flight = json.loads(raw).get("task_id")
|
|
84
|
+
except (ValueError, AttributeError, RecursionError):
|
|
85
|
+
return # unreadable: a task may be in flight, so no task is proposed as stale
|
|
86
|
+
reports =os.path.join(plan.root, ".ai", "reports")
|
|
87
|
+
logs = os.path.join(plan.root, ".ai", "state", "logs")
|
|
88
|
+
|
|
89
|
+
def files_of(base, rel, only_logs=False, keep=()):
|
|
90
|
+
out = []
|
|
91
|
+
for here, _dirs, names in os.walk(os.path.join(base, rel)):
|
|
92
|
+
for n in names:
|
|
93
|
+
full = os.path.join(here, n)
|
|
94
|
+
if os.path.isfile(full) and not os.path.islink(full) and (not only_logs or n.endswith(".log")) \
|
|
95
|
+
and n not in keep:
|
|
96
|
+
out.append(os.path.relpath(full, plan.root).replace(os.sep, "/"))
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
for task_id in sorted(os.listdir(reports)) if os.path.isdir(reports) else []:
|
|
100
|
+
journal = task_journal(plan.root, task_id)
|
|
101
|
+
if journal is None or task_id == flight:
|
|
102
|
+
continue
|
|
103
|
+
closed, abandoned, last = journal
|
|
104
|
+
if abandoned:
|
|
105
|
+
why, targets = "[cleanup] abandoned task %s" % task_id, \
|
|
106
|
+
files_of(reports, task_id) + files_of(logs, task_id, keep=("state.json",))
|
|
107
|
+
elif not closed and now - last > timedelta(days=paths.STALE_DAYS):
|
|
108
|
+
why = "[cleanup] unclosed task %s, last event %s" % (task_id, last.strftime("%Y-%m-%d"))
|
|
109
|
+
targets = files_of(reports, task_id) + files_of(logs, task_id)
|
|
110
|
+
elif closed:
|
|
111
|
+
why, targets = "[cleanup] log of closed task %s" % task_id, files_of(logs, task_id, only_logs=True)
|
|
112
|
+
else:
|
|
113
|
+
continue
|
|
114
|
+
# .ai/reports/ is usually git-tracked; only .ai/state/ is git-ignored run state
|
|
115
|
+
held = files.git_states(plan.root, ".ai/reports/" + task_id)
|
|
116
|
+
skipped = set()
|
|
117
|
+
for target in sorted(targets):
|
|
118
|
+
unsafe = files.unsafe_path(plan.root, target)
|
|
119
|
+
if unsafe: # --apply would abort on it: say so instead of proposing it
|
|
120
|
+
if unsafe not in skipped:
|
|
121
|
+
skipped.add(unsafe)
|
|
122
|
+
plan.hints.append("%s: not proposed for deletion, %s — remove it yourself if it should go"
|
|
123
|
+
% (target, unsafe))
|
|
124
|
+
continue
|
|
125
|
+
note = why
|
|
126
|
+
state = held(target) if target.startswith(".ai/reports/") else None
|
|
127
|
+
if state == "tracked":
|
|
128
|
+
note += "; tracked: git can restore a committed copy"
|
|
129
|
+
elif state == "uncommitted":
|
|
130
|
+
note += "; uncommitted: git holds no copy of it"
|
|
131
|
+
elif state == "ignored":
|
|
132
|
+
note += "; git-ignored: not recoverable through git"
|
|
133
|
+
if state in ("tracked", "uncommitted"):
|
|
134
|
+
plan.git_kept.add(target)
|
|
135
|
+
if plan.confirm_cleanup:
|
|
136
|
+
plan.add("delete", target, note, reason=why)
|
|
137
|
+
plan.remove(target)
|
|
138
|
+
else:
|
|
139
|
+
plan.add("delete?", target, note, reason=why)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def deletion_hint(plan):
|
|
143
|
+
"""M4: git is the rollback of a deletion or an overwrite; it must hold .ai/ first.
|
|
144
|
+
A [cleanup] deletion of git-ignored run state is not counted: git does not roll it back;
|
|
145
|
+
one under .ai/reports/ that git tracks, or should, is."""
|
|
146
|
+
if any(i["action"] in ("delete?", "delete", "regenerate?") and i["target"].startswith(".ai/")
|
|
147
|
+
and (not (i.get("reason") or "").startswith("[cleanup]") or i["target"] in plan.git_kept)
|
|
148
|
+
for i in plan.items) \
|
|
149
|
+
and files.git_dirty_ai(plan.root):
|
|
150
|
+
plan.hints.append("commit .ai/ first: git is this update's rollback "
|
|
151
|
+
"(`git status -- .ai` lists what it does not hold yet)")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
TRACKED_IGNORED_SHOWN = 10 # per-file hints before tracked_ignored_hint counts the rest
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def tracked_ignored_hint(plan):
|
|
158
|
+
"""A file under .ai/state that git tracks although an ignore rule lists it: the rule
|
|
159
|
+
has no effect on it. Read-only: the human untracks it; nothing is planned.
|
|
160
|
+
At most TRACKED_IGNORED_SHOWN files get their own hint; one more line counts the rest."""
|
|
161
|
+
try:
|
|
162
|
+
res = subprocess.run(["git", "-C", plan.root, "ls-files", "-z", "-ci", "--exclude-standard",
|
|
163
|
+
"--", ".ai/state"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
164
|
+
timeout=30, check=False)
|
|
165
|
+
except (OSError, subprocess.SubprocessError):
|
|
166
|
+
return
|
|
167
|
+
if res.returncode != 0:
|
|
168
|
+
return
|
|
169
|
+
# --literal-pathspecs: a * ? [ ] in a name is that name, not a pattern that matches its neighbours.
|
|
170
|
+
all_cmd = "git ls-files -z -ci --exclude-standard -- .ai/state | xargs -0 -r git --literal-pathspecs rm --cached --"
|
|
171
|
+
names = [os.fsdecode(raw) for raw in res.stdout.split(b"\0") if raw]
|
|
172
|
+
for path in names[:TRACKED_IGNORED_SHOWN]:
|
|
173
|
+
# A control character (or a byte that is not UTF-8, which fsdecode keeps as a lone
|
|
174
|
+
# surrogate) in the name would forge a report line or break the copy-paste command:
|
|
175
|
+
# show it escaped, and untrack by the NUL-safe listing.
|
|
176
|
+
shown = "".join(c if c.isprintable() else repr(c)[1:-1] for c in path)
|
|
177
|
+
if shown == path:
|
|
178
|
+
plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
|
|
179
|
+
"effect on a tracked file, so every run leaves the tree dirty and "
|
|
180
|
+
"`git commit -a` sweeps it in; untrack it yourself, from the project root: "
|
|
181
|
+
"`git --literal-pathspecs rm --cached -- %s` and commit (the file stays on disk)"
|
|
182
|
+
% (shown, shlex.quote(path)))
|
|
183
|
+
else:
|
|
184
|
+
plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
|
|
185
|
+
"effect on a tracked file, so every run leaves the tree dirty and "
|
|
186
|
+
"`git commit -a` sweeps it in; this name cannot be typed safely, so "
|
|
187
|
+
"untrack it, from the project root, with `%s`, which untracks EVERY tracked, ignored file "
|
|
188
|
+
"under .ai/state, not only this one, and commit (the files stay on disk)"
|
|
189
|
+
% (shown, all_cmd))
|
|
190
|
+
if len(names) > TRACKED_IGNORED_SHOWN:
|
|
191
|
+
plan.hints.append(".ai/state: %d more file(s) tracked by git but ignored by an ignore rule, "
|
|
192
|
+
"not listed; untrack ALL tracked, ignored files under .ai/state "
|
|
193
|
+
"(the ones listed above too), from the project root, with `%s` and commit (the files stay on disk)"
|
|
194
|
+
% (len(names) - TRACKED_IGNORED_SHOWN, all_cmd))
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Bring a project's RiskRails files up to date with the installed plugin.
|
|
2
|
+
|
|
3
|
+
shell: main
|
|
4
|
+
"""
|
|
5
|
+
# argparse's description is the first line of this docstring, which is the entry's first line.
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import render_instructions # next to the entry, whose directory the entry puts on sys.path
|
|
11
|
+
from rr_update import apply, build, gate, hints, history, kept, paths, report
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
16
|
+
ap.add_argument("root", nargs="?", default=".")
|
|
17
|
+
ap.add_argument("--apply", action="store_true")
|
|
18
|
+
ap.add_argument("--check", action="store_true")
|
|
19
|
+
ap.add_argument("--budget", action="store_true",
|
|
20
|
+
help="with --check: exit 1 when a root instruction file is over its byte budget")
|
|
21
|
+
ap.add_argument("--confirm-delete", metavar="NAME",
|
|
22
|
+
help="a human confirms this run's gated rows (regenerate?, delete?), and is recorded in "
|
|
23
|
+
"the migration report; only with --apply")
|
|
24
|
+
ap.add_argument("--confirm-cleanup", metavar="NAME",
|
|
25
|
+
help="a human confirms this run's [cleanup] deletions (run logs and stale task files, "
|
|
26
|
+
"git-ignored and not recoverable through git); only with --apply. "
|
|
27
|
+
"--confirm-delete does not settle them")
|
|
28
|
+
args = ap.parse_args()
|
|
29
|
+
confirm_via = cleanup_via = None
|
|
30
|
+
for flag, name in (("--confirm-delete", args.confirm_delete), ("--confirm-cleanup", args.confirm_cleanup)):
|
|
31
|
+
if name is None:
|
|
32
|
+
continue
|
|
33
|
+
if not args.apply:
|
|
34
|
+
ap.error("%s only makes sense with --apply" % flag)
|
|
35
|
+
if not name.strip():
|
|
36
|
+
ap.error("%s needs the name of the human who confirmed the change" % flag)
|
|
37
|
+
if gate.placeholder_name(name):
|
|
38
|
+
ap.error("%s got the placeholder %r; type your own name, it is the audit trail"
|
|
39
|
+
% (flag, name.strip()))
|
|
40
|
+
passed, via, why = gate.human_gate()
|
|
41
|
+
if not passed:
|
|
42
|
+
# Under an agent-session marker the AI_UNATTENDED route is the launcher's
|
|
43
|
+
# to declare, not the agent's to type: the hint names it only otherwise.
|
|
44
|
+
hint = "run the same command yourself in a terminal outside Claude Code or Codex"
|
|
45
|
+
if "agent session" not in why:
|
|
46
|
+
hint += ", or set AI_UNATTENDED=1 if a launcher runs it unattended on your behalf"
|
|
47
|
+
return gate.refuse("%s is typed by a human, and %s" % (flag, why), hint)
|
|
48
|
+
if flag == "--confirm-delete":
|
|
49
|
+
confirm_via = via
|
|
50
|
+
else:
|
|
51
|
+
cleanup_via = via
|
|
52
|
+
if args.budget and not args.check:
|
|
53
|
+
ap.error("--budget only makes sense with --check")
|
|
54
|
+
root = os.path.abspath(args.root)
|
|
55
|
+
if args.check and args.budget:
|
|
56
|
+
offenders = hints.budget_offenders(
|
|
57
|
+
root, [paths.INSTRUCTION_FILE[rt][0] for rt in history.project_runtimes(root)],
|
|
58
|
+
render_instructions.budgets(render_instructions.DEFAULT_SOURCE), render_instructions.block_of)
|
|
59
|
+
for line in offenders:
|
|
60
|
+
print(line)
|
|
61
|
+
return 1 if offenders else 0
|
|
62
|
+
for name, tpl in paths.TEMPLATES.items():
|
|
63
|
+
if not os.path.isdir(tpl):
|
|
64
|
+
print("project-update: %s templates not found at %s (run riskrails/install.sh)" % (name, tpl), file=sys.stderr)
|
|
65
|
+
return 2
|
|
66
|
+
if not (os.path.isdir(os.path.join(root, ".ai")) or os.path.isdir(os.path.join(root, "docs", "sdlc"))):
|
|
67
|
+
print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /rr-setup first" % root, file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
try:
|
|
70
|
+
if os.path.isdir(os.path.join(root, ".ai")):
|
|
71
|
+
history.project_version(root)
|
|
72
|
+
bad = kept.check_kept(root)
|
|
73
|
+
if bad:
|
|
74
|
+
return kept.kept_refusal(bad)
|
|
75
|
+
plan = build.build_plan(root, confirm_delete=args.confirm_delete, confirm_via=confirm_via,
|
|
76
|
+
confirm_cleanup=args.confirm_cleanup, cleanup_via=cleanup_via)
|
|
77
|
+
except history.SchemaError as exc:
|
|
78
|
+
# --check is read by /rr-status, which reads stdout: it must see the reason.
|
|
79
|
+
print("project-update: %s" % exc, file=sys.stdout if args.check else sys.stderr)
|
|
80
|
+
return 2
|
|
81
|
+
if args.check:
|
|
82
|
+
pending = [i for i in plan.items if not i["action"].endswith("?")]
|
|
83
|
+
waiting = [i for i in plan.items if i["action"].endswith("?") and not (i["reason"] or "").startswith("[cleanup]")]
|
|
84
|
+
if pending:
|
|
85
|
+
bits = ["%d file(s) to update" % len(pending)]
|
|
86
|
+
if waiting:
|
|
87
|
+
bits.append("%d change(s) a human must confirm" % len(waiting))
|
|
88
|
+
print("project is behind the installed plugin: %s — run /rr-setup --update" % ", ".join(bits))
|
|
89
|
+
return 1
|
|
90
|
+
print("project matches the installed plugin"
|
|
91
|
+
+ (" (%d change(s) wait for --confirm-delete)" % len(waiting) if waiting else ""))
|
|
92
|
+
return 0
|
|
93
|
+
if args.apply:
|
|
94
|
+
try:
|
|
95
|
+
apply.apply(plan)
|
|
96
|
+
except apply.Abort as exc:
|
|
97
|
+
where = "%s %s" % (exc.item["action"], exc.item["target"]) if exc.item else "the update"
|
|
98
|
+
print("project-update: aborted at %s: %s — nothing after it was written; "
|
|
99
|
+
"run the dry run again" % (where, exc.why), file=sys.stderr)
|
|
100
|
+
return 3
|
|
101
|
+
report.report(plan, args.apply)
|
|
102
|
+
return 0
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Files and the two outside readers: the clock, a hash, a read, git's view of a tree, JSON bytes, a path safe to delete.
|
|
2
|
+
|
|
3
|
+
shell: utc_now, read, git_dirty_ai, git_states, unsafe_path
|
|
4
|
+
core: sha, dump_json
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def utc_now():
|
|
15
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sha(data):
|
|
19
|
+
return hashlib.sha256(data).hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read(path):
|
|
23
|
+
with open(path, "rb") as fh:
|
|
24
|
+
return fh.read()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def git_dirty_ai(root):
|
|
29
|
+
"""True when .ai/ has changes git does not hold yet: git is the rollback of
|
|
30
|
+
a migration that deletes, so it must hold them first. False outside a work
|
|
31
|
+
tree, where there is nothing to say."""
|
|
32
|
+
try:
|
|
33
|
+
res = subprocess.run(["git", "-C", root, "status", "--porcelain", "--", ".ai"],
|
|
34
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30, check=False)
|
|
35
|
+
except (OSError, subprocess.SubprocessError):
|
|
36
|
+
return False
|
|
37
|
+
return res.returncode == 0 and bool(res.stdout.strip())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def git_states(root, rel_dir):
|
|
41
|
+
"""A function path -> how git holds a file under `rel_dir` (project-relative):
|
|
42
|
+
"tracked" (committed and unchanged), "uncommitted" (changed, staged or not yet
|
|
43
|
+
tracked, so git has no copy to restore) or "ignored"; None outside a work tree
|
|
44
|
+
or for a path git does not know."""
|
|
45
|
+
def run(*args):
|
|
46
|
+
res = subprocess.run(["git", "-C", root] + list(args), stdout=subprocess.PIPE,
|
|
47
|
+
stderr=subprocess.DEVNULL, timeout=30, check=False)
|
|
48
|
+
return res.stdout.decode("utf-8", "replace") if res.returncode == 0 else None
|
|
49
|
+
try:
|
|
50
|
+
prefix = run("rev-parse", "--show-prefix")
|
|
51
|
+
tracked = run("ls-files", "-z", "--", rel_dir)
|
|
52
|
+
status = run("status", "--porcelain=v1", "-z", "--ignored", "-uall", "--", rel_dir)
|
|
53
|
+
except (OSError, subprocess.SubprocessError):
|
|
54
|
+
return lambda path: None
|
|
55
|
+
if prefix is None or tracked is None or status is None:
|
|
56
|
+
return lambda path: None
|
|
57
|
+
prefix = prefix.strip()
|
|
58
|
+
tracked = set(tracked.split("\0"))
|
|
59
|
+
dirty, ignored = set(), []
|
|
60
|
+
entries = status.split("\0")
|
|
61
|
+
skip = False
|
|
62
|
+
for entry in entries:
|
|
63
|
+
if skip: # the origin path of a rename
|
|
64
|
+
skip = False
|
|
65
|
+
continue
|
|
66
|
+
if len(entry) < 4:
|
|
67
|
+
continue
|
|
68
|
+
xy, path = entry[:2], entry[3:]
|
|
69
|
+
if prefix and path.startswith(prefix):
|
|
70
|
+
path = path[len(prefix):]
|
|
71
|
+
if "R" in xy or "C" in xy:
|
|
72
|
+
skip = True
|
|
73
|
+
if xy == "!!":
|
|
74
|
+
ignored.append(path)
|
|
75
|
+
else:
|
|
76
|
+
dirty.add(path)
|
|
77
|
+
|
|
78
|
+
def classify(path):
|
|
79
|
+
if path in dirty:
|
|
80
|
+
return "uncommitted"
|
|
81
|
+
if any(path == i or (i.endswith("/") and path.startswith(i)) for i in ignored):
|
|
82
|
+
return "ignored"
|
|
83
|
+
return "tracked" if path in tracked else None
|
|
84
|
+
return classify
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def dump_json(value):
|
|
88
|
+
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def unsafe_path(root, path):
|
|
92
|
+
"""Why a project-relative path must not be deleted: a symlink on the way to it,
|
|
93
|
+
or a real path outside the project; None when it is safe. Run at plan time and
|
|
94
|
+
again right before the delete."""
|
|
95
|
+
real_root = os.path.realpath(root)
|
|
96
|
+
here = root
|
|
97
|
+
for part in path.split("/"):
|
|
98
|
+
here = os.path.join(here, part)
|
|
99
|
+
if os.path.islink(here):
|
|
100
|
+
return "%s is a symlink" % os.path.relpath(here, root).replace(os.sep, "/")
|
|
101
|
+
real = os.path.realpath(os.path.join(root, path))
|
|
102
|
+
if real != real_root and not real.startswith(real_root + os.sep):
|
|
103
|
+
return "%s resolves outside the project (%s)" % (path, real)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------- the one gate
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The one gate: who is at the keyboard, the audit-trail name, and the refusal every gate prints.
|
|
2
|
+
|
|
3
|
+
shell: ai_unattended, human_gate, refuse
|
|
4
|
+
core: AGENT_MARKERS, PLACEHOLDER_NAME_RE, placeholder_name
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from rr_update import paths
|
|
11
|
+
|
|
12
|
+
# Set in the environment of a shell an agent runs: Claude Code's CLAUDECODE,
|
|
13
|
+
# and the names Codex gives its tool calls. Present counts, an empty value too.
|
|
14
|
+
AGENT_MARKERS = ("CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CODEX_CI",
|
|
15
|
+
"CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_VERSION")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ai_unattended():
|
|
19
|
+
"""AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
|
|
20
|
+
and blanks ignored): one rule in every reader (risk 0587)."""
|
|
21
|
+
return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def human_gate():
|
|
25
|
+
"""(passed, via, why) for --confirm-delete. A terminal outside any agent
|
|
26
|
+
session is a human: via "terminal". Otherwise a launcher that declared the
|
|
27
|
+
run unattended (AI_UNATTENDED) passes: via "unattended", as state.py's
|
|
28
|
+
approve records it. Anything else is refused, and `why` says what was seen —
|
|
29
|
+
a terminal alone is not enough, since an agent can open a pty (F3).
|
|
30
|
+
|
|
31
|
+
Duplicated in spirit from skills/ai-task/state.py (human_present) on purpose:
|
|
32
|
+
the two skills are installed independently and neither imports the other."""
|
|
33
|
+
markers = [name for name in AGENT_MARKERS if name in os.environ]
|
|
34
|
+
if os.isatty(0) and not markers:
|
|
35
|
+
return True, "terminal", ""
|
|
36
|
+
if ai_unattended():
|
|
37
|
+
return True, "unattended", ""
|
|
38
|
+
if markers:
|
|
39
|
+
return False, None, ("this run is inside an agent session (%s %s set); run it in a terminal "
|
|
40
|
+
"outside Claude Code or Codex, not through the `!` prefix"
|
|
41
|
+
% (", ".join(markers), "is" if len(markers) == 1 else "are"))
|
|
42
|
+
return False, None, "this run has no terminal"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# The name typed into --confirm-delete is the audit trail; the template's own
|
|
46
|
+
# "<your name>" copied verbatim names no one. The whole value, not a substring.
|
|
47
|
+
PLACEHOLDER_NAME_RE = re.compile(r"\A\s*(?:<[^>]*>|your\s+name)\s*\Z", re.IGNORECASE)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def placeholder_name(name):
|
|
51
|
+
return bool(PLACEHOLDER_NAME_RE.match(name or ""))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def refuse(reason, how):
|
|
55
|
+
"""Print the refusal the way every gate does — the marker on the first
|
|
56
|
+
stdout line, then what to do — and return exit code 5."""
|
|
57
|
+
print("%s: %s" % (paths.REFUSED, reason))
|
|
58
|
+
print(" %s" % how)
|
|
59
|
+
return 5
|
|
60
|
+
|