riskrails 3.1.0 → 3.2.2
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 +12 -11
- package/bin/riskrails.js +109 -18
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +14 -11
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +109 -7
- package/hooks/project-scaffold.py +2 -1
- package/hooks/runtime-gate.py +128 -27
- package/hooks/update_notice.py +3 -3
- package/package.json +1 -1
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- package/skills/ai-task/rr_state/__init__.py +1 -0
- package/skills/ai-task/rr_state/cli.py +210 -0
- package/skills/ai-task/rr_state/close.py +171 -0
- package/skills/ai-task/rr_state/dialog.py +541 -0
- package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
- package/skills/ai-task/rr_state/fs.py +220 -0
- package/skills/ai-task/rr_state/gate.py +125 -0
- package/skills/ai-task/rr_state/gate_cmd.py +128 -0
- package/skills/ai-task/rr_state/handoff.py +226 -0
- package/skills/ai-task/rr_state/journal.py +126 -0
- package/skills/ai-task/rr_state/labels.py +99 -0
- package/skills/ai-task/rr_state/ledger.py +145 -0
- package/skills/ai-task/rr_state/locks.py +84 -0
- package/skills/ai-task/rr_state/memory_cmd.py +310 -0
- package/skills/ai-task/rr_state/policy.py +378 -0
- package/skills/ai-task/rr_state/profile.py +373 -0
- package/skills/ai-task/rr_state/questions.py +336 -0
- package/skills/ai-task/rr_state/questions_cmd.py +321 -0
- package/skills/ai-task/rr_state/report.py +271 -0
- package/skills/ai-task/rr_state/report_cmd.py +230 -0
- package/skills/ai-task/rr_state/review.py +165 -0
- package/skills/ai-task/rr_state/runtime.py +80 -0
- package/skills/ai-task/rr_state/start.py +257 -0
- package/skills/ai-task/rr_state/steps.py +131 -0
- package/skills/ai-task/rr_state/task.py +396 -0
- package/skills/ai-task/rr_state/verify.py +231 -0
- package/skills/ai-task/sensors.py +16 -3
- package/skills/ai-task/state.py +24 -5161
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/index.json +2 -1
- package/skills/project-update/migrations/0003_context_diet.py +1 -1
- package/skills/project-update/migrations/0007_weight_model.py +130 -23
- package/skills/project-update/migrations/README.md +16 -1
- package/skills/project-update/update.py +202 -26
- package/skills/rr-setup/SKILL.md +9 -2
- package/skills/rr-status/status.py +36 -9
|
@@ -48,7 +48,7 @@ version closest to it (git merge-file for text, per key for JSON), keeping every
|
|
|
48
48
|
edit. A real conflict leaves the project file alone and puts the plugin's version
|
|
49
49
|
in a git-ignored local directory for a human or /rr-setup --update to merge.
|
|
50
50
|
"""
|
|
51
|
-
import argparse, contextlib, copy, difflib, hashlib, importlib.util, io, json, os, re, subprocess, sys, tempfile
|
|
51
|
+
import argparse, contextlib, copy, difflib, hashlib, importlib.util, io, json, os, re, shlex, subprocess, sys, tempfile
|
|
52
52
|
from collections import OrderedDict
|
|
53
53
|
from types import SimpleNamespace
|
|
54
54
|
from datetime import datetime, timedelta, timezone
|
|
@@ -108,10 +108,12 @@ RULES_DIR = ".ai/rules"
|
|
|
108
108
|
# file is not ours (the path guard refuses it too), and .git is not text.
|
|
109
109
|
# A child holding a .git entry (worktree, nested repository, submodule) is pruned
|
|
110
110
|
# too, by the take-back walk (rendered_rules) and by the writer (nested_tree). The
|
|
111
|
-
#
|
|
112
|
-
#
|
|
111
|
+
# writer also skips a RULES_SKIP name or a symlinked directory (unwalked_dir), so it
|
|
112
|
+
# never writes where the take-back never walks.
|
|
113
113
|
RULES_SKIP = {".git", ".ai", "vendor", "node_modules", "bower_components",
|
|
114
114
|
"third_party", "Pods", "site-packages", ".venv", ".pnpm", ".yarn"}
|
|
115
|
+
# Compared case-insensitively: on a case-insensitive filesystem `Vendor/` is `vendor/`.
|
|
116
|
+
RULES_SKIP_FOLDED = frozenset(n.casefold() for n in RULES_SKIP)
|
|
115
117
|
START, END = "<!-- riskrails:start -->", "<!-- riskrails:end -->"
|
|
116
118
|
OLD_START, OLD_END = b"<!-- claude-agentic:start -->", b"<!-- claude-agentic:end -->" # R35, one version
|
|
117
119
|
|
|
@@ -214,7 +216,7 @@ class History:
|
|
|
214
216
|
try:
|
|
215
217
|
with open(os.path.join(root, "index.json"), encoding="utf-8") as fh:
|
|
216
218
|
self.index = json.load(fh)
|
|
217
|
-
except (OSError, ValueError):
|
|
219
|
+
except (OSError, ValueError, RecursionError):
|
|
218
220
|
self.index = {}
|
|
219
221
|
self.root = root
|
|
220
222
|
# {new key: [old keys]} from the migrations' MOVES: a template that moved
|
|
@@ -302,7 +304,7 @@ def git_dirty_ai(root):
|
|
|
302
304
|
tree, where there is nothing to say."""
|
|
303
305
|
try:
|
|
304
306
|
res = subprocess.run(["git", "-C", root, "status", "--porcelain", "--", ".ai"],
|
|
305
|
-
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30)
|
|
307
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30, check=False)
|
|
306
308
|
except (OSError, subprocess.SubprocessError):
|
|
307
309
|
return False
|
|
308
310
|
return res.returncode == 0 and bool(res.stdout.strip())
|
|
@@ -315,7 +317,7 @@ def git_states(root, rel_dir):
|
|
|
315
317
|
or for a path git does not know."""
|
|
316
318
|
def run(*args):
|
|
317
319
|
res = subprocess.run(["git", "-C", root] + list(args), stdout=subprocess.PIPE,
|
|
318
|
-
stderr=subprocess.DEVNULL, timeout=30)
|
|
320
|
+
stderr=subprocess.DEVNULL, timeout=30, check=False)
|
|
319
321
|
return res.stdout.decode("utf-8", "replace") if res.returncode == 0 else None
|
|
320
322
|
try:
|
|
321
323
|
prefix = run("rev-parse", "--show-prefix")
|
|
@@ -606,6 +608,10 @@ class MigrationContext:
|
|
|
606
608
|
if text not in self.plan.hints:
|
|
607
609
|
self.plan.hints.append(text)
|
|
608
610
|
|
|
611
|
+
def conflict(self, path, note):
|
|
612
|
+
"""Hold the schema version on a file this migration could not handle; `note` says why."""
|
|
613
|
+
self._add("conflict", path, note)
|
|
614
|
+
|
|
609
615
|
def exists(self, path):
|
|
610
616
|
return self.read(path) is not None
|
|
611
617
|
|
|
@@ -629,6 +635,12 @@ class MigrationContext:
|
|
|
629
635
|
return json.loads(raw, object_pairs_hook=OrderedDict)
|
|
630
636
|
except ValueError:
|
|
631
637
|
return None
|
|
638
|
+
except RecursionError:
|
|
639
|
+
# Not "absent": a file nested past the parser's limit is there. The migration goes on
|
|
640
|
+
# as if it were absent (0007 then creates a default weight.json beside it, overwriting
|
|
641
|
+
# nothing); the conflict holds the schema version, so the next run tries again.
|
|
642
|
+
self._add("conflict", path, "nested too deeply, so this migration could not read it")
|
|
643
|
+
return None
|
|
632
644
|
|
|
633
645
|
# ---- operations. Each is idempotent: a target already in its final state
|
|
634
646
|
# records nothing, so a re-run after an interrupted apply is a no-op.
|
|
@@ -637,6 +649,16 @@ class MigrationContext:
|
|
|
637
649
|
if not isinstance(kw.get("content", b""), bytes):
|
|
638
650
|
raise SchemaError("%04d: %s content must be bytes, not %s"
|
|
639
651
|
% (self.module.VERSION, target, type(kw["content"]).__name__))
|
|
652
|
+
if not isinstance(note, str):
|
|
653
|
+
raise SchemaError("%04d: %s note must be str, not %s"
|
|
654
|
+
% (self.module.VERSION, target, type(note).__name__))
|
|
655
|
+
deep = action == "conflict" and isinstance(note, str) and note.startswith("nested too deeply")
|
|
656
|
+
if action == "conflict" and any(i["action"] == "conflict" and i["target"] == target
|
|
657
|
+
and i["migration"] == self.module.VERSION
|
|
658
|
+
and (i["note"] == note or deep and isinstance(i["note"], str)
|
|
659
|
+
and i["note"].startswith("nested too deeply"))
|
|
660
|
+
for i in self.plan.items):
|
|
661
|
+
return # one deep file read twice, or read then edited: one row, one held schema
|
|
640
662
|
self.plan.add(action, target, note, migration=self.module.VERSION, **kw)
|
|
641
663
|
|
|
642
664
|
def create(self, path, content):
|
|
@@ -673,7 +695,12 @@ class MigrationContext:
|
|
|
673
695
|
|
|
674
696
|
def edit_json(self, path, fn):
|
|
675
697
|
"""Rewrite a JSON file the project has. A policy file's leaf changes are
|
|
676
|
-
listed for confirmation, exactly as a merged policy change is.
|
|
698
|
+
listed for confirmation, exactly as a merged policy change is. fn gets a
|
|
699
|
+
deep copy of the document and returns the new one. It must not walk the
|
|
700
|
+
document with Python recursion (a file that just passed copy.deepcopy can
|
|
701
|
+
be 470-490 levels deep at the default limit), or it guards RecursionError
|
|
702
|
+
itself: an unguarded fn's RecursionError is reported as its own, the run
|
|
703
|
+
fails and no file is blamed."""
|
|
677
704
|
raw = self.read(path)
|
|
678
705
|
if raw is None:
|
|
679
706
|
return
|
|
@@ -684,11 +711,25 @@ class MigrationContext:
|
|
|
684
711
|
# the schema version, so the next run tries again.
|
|
685
712
|
self._add("conflict", path, "is not valid JSON, so this migration could not run (%s)" % exc)
|
|
686
713
|
return
|
|
687
|
-
|
|
688
|
-
|
|
714
|
+
except RecursionError:
|
|
715
|
+
self._add("conflict", path, "nested too deeply, so this migration could not run")
|
|
716
|
+
return
|
|
717
|
+
try:
|
|
718
|
+
copied = copy.deepcopy(before)
|
|
719
|
+
except RecursionError:
|
|
720
|
+
self._add("conflict", path, "nested too deeply, so this migration could not run")
|
|
721
|
+
return
|
|
722
|
+
# Outside the guard: an unguarded fn's RecursionError is reported as its own, not the file's.
|
|
723
|
+
after = fn(copied)
|
|
724
|
+
try:
|
|
725
|
+
if after == before:
|
|
726
|
+
return
|
|
727
|
+
policy = leaf_changes(before, after) if "/policies/" in "/" + path else None
|
|
728
|
+
content = dump_json(after)
|
|
729
|
+
except RecursionError: # comparing or writing past the limit
|
|
730
|
+
self._add("conflict", path, "nested too deeply, so this migration could not run")
|
|
689
731
|
return
|
|
690
|
-
|
|
691
|
-
self._add("update", path, "migrated", content=dump_json(after), policy=policy)
|
|
732
|
+
self._add("update", path, "migrated", content=content, policy=policy)
|
|
692
733
|
|
|
693
734
|
def patch_state(self, fn): # noqa: D401
|
|
694
735
|
"""Change the task in flight. update.py writes the state file the
|
|
@@ -746,6 +787,8 @@ def run_migrations(plan, version, history):
|
|
|
746
787
|
state = json.loads(raw, object_pairs_hook=OrderedDict)
|
|
747
788
|
except ValueError as exc:
|
|
748
789
|
raise SchemaError("%s is not valid JSON (%s) — inspect it by hand" % (path, exc)) from exc
|
|
790
|
+
except RecursionError as exc:
|
|
791
|
+
raise SchemaError("%s is nested too deeply to parse — inspect it by hand" % path) from exc
|
|
749
792
|
if not isinstance(state, dict):
|
|
750
793
|
raise SchemaError("%s is not a task state object" % path)
|
|
751
794
|
for mod in mods:
|
|
@@ -861,18 +904,44 @@ def weight_mirror(plan, history, theirs):
|
|
|
861
904
|
disk = read(path) if os.path.isfile(path) and not os.path.islink(path) else None
|
|
862
905
|
after = plan.read(WEIGHT_JSON)
|
|
863
906
|
fresh = None
|
|
907
|
+
rendering_raised = False
|
|
864
908
|
if ours is not None and disk is not None and after is not None:
|
|
865
909
|
try:
|
|
866
910
|
if ours == render_weight(disk):
|
|
867
911
|
fresh = render_weight(after)
|
|
868
|
-
except
|
|
869
|
-
|
|
912
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
913
|
+
# not a table the renderer reads (or too deep, or no temp dir, or no renderer):
|
|
914
|
+
# the text merge decides, and it is always safe
|
|
915
|
+
fresh = None
|
|
916
|
+
rendering_raised = True
|
|
917
|
+
# L3: say so, so a renderer bug is not read as a hand-edited mirror
|
|
918
|
+
plan.hints.append("%s: not rendered from weight.json (%s); merged as text"
|
|
919
|
+
% (WEIGHT_MD, type(exc).__name__))
|
|
870
920
|
if fresh is None:
|
|
871
921
|
three_way(plan, history, "ai-init/" + WEIGHT_MD, WEIGHT_MD, theirs)
|
|
922
|
+
# L4: not when the renderer raised: `state.py weight --mirror` would fail the same way
|
|
923
|
+
stale = None if rendering_raised else stale_mirror_hint(ours, disk)
|
|
924
|
+
if stale:
|
|
925
|
+
plan.hints.append(stale)
|
|
872
926
|
elif fresh != ours:
|
|
873
927
|
plan.add("update", WEIGHT_MD, "rendered from weight.json", content=fresh)
|
|
874
928
|
|
|
875
929
|
|
|
930
|
+
def stale_mirror_hint(ours, disk):
|
|
931
|
+
"""L3: the sha256 in weight.md's header names a weight.json other than the one on disk
|
|
932
|
+
(an --apply interrupted between the two writes, or a JSON edited without regenerating)."""
|
|
933
|
+
if ours is None or disk is None:
|
|
934
|
+
return None
|
|
935
|
+
head = ours[:2000]
|
|
936
|
+
found = re.search(r"^<!--.*\bsha256:\s*([0-9a-fA-F]{64})\b.*-->", head if isinstance(head, str) else head.decode("utf-8", "replace"), re.M)
|
|
937
|
+
if not found or found.group(1).lower() == hashlib.sha256(disk if isinstance(disk, bytes) else disk.encode("utf-8")).hexdigest():
|
|
938
|
+
return None
|
|
939
|
+
return ("%s: rendered from a different weight.json than the one on disk (stale: an interrupted "
|
|
940
|
+
"--apply, or the JSON edited without regenerating) — `state.py weight --mirror` "
|
|
941
|
+
"regenerates it from the JSON and REPLACES any hand edits in it, so keep or move "
|
|
942
|
+
"them first" % WEIGHT_MD)
|
|
943
|
+
|
|
944
|
+
|
|
876
945
|
def three_way(plan, history, key, target, theirs):
|
|
877
946
|
ours = plan.read(target)
|
|
878
947
|
if ours is None:
|
|
@@ -887,7 +956,7 @@ def three_way(plan, history, key, target, theirs):
|
|
|
887
956
|
try:
|
|
888
957
|
policy = leaf_changes(json.loads(ours, object_pairs_hook=OrderedDict),
|
|
889
958
|
json.loads(theirs, object_pairs_hook=OrderedDict))
|
|
890
|
-
except ValueError:
|
|
959
|
+
except (ValueError, RecursionError): # not JSON, or too deep to read or flatten: no policy lines
|
|
891
960
|
policy = None
|
|
892
961
|
plan.add("update", target, "unchanged since it was installed",
|
|
893
962
|
content=theirs, policy=policy)
|
|
@@ -901,14 +970,22 @@ def three_way(plan, history, key, target, theirs):
|
|
|
901
970
|
except ValueError:
|
|
902
971
|
plan.add("conflict", target, "not valid JSON; not merged", conflict_copy=theirs)
|
|
903
972
|
return
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
973
|
+
except RecursionError: # parsing, flatten or merge_json past the recursion limit
|
|
974
|
+
plan.add("conflict", target, "nested too deeply; not merged", conflict_copy=theirs)
|
|
975
|
+
return
|
|
976
|
+
try:
|
|
977
|
+
base = closest(bases, o, json_score) if bases else MISSING
|
|
978
|
+
conflicts = []
|
|
979
|
+
merged = merge_json(base, o, t, [], conflicts)
|
|
980
|
+
policy = leaf_changes(o, merged) if "/policies/" in "/" + target else None
|
|
981
|
+
content = dump_json(merged) if merged != o else None
|
|
982
|
+
except RecursionError:
|
|
983
|
+
plan.add("conflict", target, "nested too deeply; not merged", conflict_copy=theirs)
|
|
984
|
+
return
|
|
908
985
|
if merged == o and not conflicts:
|
|
909
986
|
return
|
|
910
987
|
if merged != o:
|
|
911
|
-
plan.add("merge", target, "your edits kept", content=
|
|
988
|
+
plan.add("merge", target, "your edits kept", content=content, policy=policy)
|
|
912
989
|
if conflicts:
|
|
913
990
|
plan.add("conflict", target, "kept your value for: " + ", ".join(conflicts), conflict_copy=theirs)
|
|
914
991
|
return
|
|
@@ -1129,7 +1206,7 @@ def task_journal(root, task_id):
|
|
|
1129
1206
|
try:
|
|
1130
1207
|
ev = json.loads(line)
|
|
1131
1208
|
when = datetime.strptime(ev["ts"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
|
1132
|
-
except (ValueError, KeyError, TypeError):
|
|
1209
|
+
except (ValueError, KeyError, TypeError, RecursionError):
|
|
1133
1210
|
continue
|
|
1134
1211
|
if last is None or when > last:
|
|
1135
1212
|
last = when
|
|
@@ -1142,7 +1219,7 @@ def task_journal(root, task_id):
|
|
|
1142
1219
|
try:
|
|
1143
1220
|
with open(os.path.join(root, ".ai", "state", "logs", task_id, "state.json"), encoding="utf-8") as fh:
|
|
1144
1221
|
result = json.load(fh).get("result")
|
|
1145
|
-
except (OSError, ValueError, AttributeError, UnicodeDecodeError):
|
|
1222
|
+
except (OSError, ValueError, AttributeError, UnicodeDecodeError, RecursionError):
|
|
1146
1223
|
result = None
|
|
1147
1224
|
if isinstance(result, str) and result:
|
|
1148
1225
|
closed = True
|
|
@@ -1163,14 +1240,14 @@ def cleanup_update(plan, now=None):
|
|
|
1163
1240
|
except ValueError:
|
|
1164
1241
|
print("project-update: RISKRAILS_NOW %r is not a UTC time like 2026-01-31T12:00:00Z" % env("NOW"),
|
|
1165
1242
|
file=sys.stderr)
|
|
1166
|
-
raise SystemExit(2)
|
|
1243
|
+
raise SystemExit(2) from None
|
|
1167
1244
|
flight = None
|
|
1168
1245
|
for name in (STATE_FILE, LEGACY_STATE_FILE):
|
|
1169
1246
|
raw = plan.read(name)
|
|
1170
1247
|
if raw is not None:
|
|
1171
1248
|
try:
|
|
1172
1249
|
flight = json.loads(raw).get("task_id")
|
|
1173
|
-
except (ValueError, AttributeError):
|
|
1250
|
+
except (ValueError, AttributeError, RecursionError):
|
|
1174
1251
|
return # unreadable: a task may be in flight, so no task is proposed as stale
|
|
1175
1252
|
break
|
|
1176
1253
|
reports =os.path.join(plan.root, ".ai", "reports")
|
|
@@ -1203,7 +1280,15 @@ def cleanup_update(plan, now=None):
|
|
|
1203
1280
|
continue
|
|
1204
1281
|
# .ai/reports/ is usually git-tracked; only .ai/state/ is git-ignored run state
|
|
1205
1282
|
held = git_states(plan.root, ".ai/reports/" + task_id)
|
|
1283
|
+
skipped = set()
|
|
1206
1284
|
for target in sorted(targets):
|
|
1285
|
+
unsafe = unsafe_path(plan.root, target)
|
|
1286
|
+
if unsafe: # --apply would abort on it: say so instead of proposing it
|
|
1287
|
+
if unsafe not in skipped:
|
|
1288
|
+
skipped.add(unsafe)
|
|
1289
|
+
plan.hints.append("%s: not proposed for deletion, %s — remove it yourself if it should go"
|
|
1290
|
+
% (target, unsafe))
|
|
1291
|
+
continue
|
|
1207
1292
|
note = why
|
|
1208
1293
|
state = held(target) if target.startswith(".ai/reports/") else None
|
|
1209
1294
|
if state == "tracked":
|
|
@@ -1233,6 +1318,49 @@ def deletion_hint(plan):
|
|
|
1233
1318
|
"(`git status -- .ai` lists what it does not hold yet)")
|
|
1234
1319
|
|
|
1235
1320
|
|
|
1321
|
+
TRACKED_IGNORED_SHOWN = 10 # per-file hints before tracked_ignored_hint counts the rest
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def tracked_ignored_hint(plan):
|
|
1325
|
+
"""A file under .ai/state that git tracks although an ignore rule lists it: the rule
|
|
1326
|
+
has no effect on it. Read-only: the human untracks it; nothing is planned.
|
|
1327
|
+
At most TRACKED_IGNORED_SHOWN files get their own hint; one more line counts the rest."""
|
|
1328
|
+
try:
|
|
1329
|
+
res = subprocess.run(["git", "-C", plan.root, "ls-files", "-z", "-ci", "--exclude-standard",
|
|
1330
|
+
"--", ".ai/state"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
1331
|
+
timeout=30, check=False)
|
|
1332
|
+
except (OSError, subprocess.SubprocessError):
|
|
1333
|
+
return
|
|
1334
|
+
if res.returncode != 0:
|
|
1335
|
+
return
|
|
1336
|
+
# --literal-pathspecs: a * ? [ ] in a name is that name, not a pattern that matches its neighbours.
|
|
1337
|
+
all_cmd = "git ls-files -z -ci --exclude-standard -- .ai/state | xargs -0 -r git --literal-pathspecs rm --cached --"
|
|
1338
|
+
names = [os.fsdecode(raw) for raw in res.stdout.split(b"\0") if raw]
|
|
1339
|
+
for path in names[:TRACKED_IGNORED_SHOWN]:
|
|
1340
|
+
# A control character (or a byte that is not UTF-8, which fsdecode keeps as a lone
|
|
1341
|
+
# surrogate) in the name would forge a report line or break the copy-paste command:
|
|
1342
|
+
# show it escaped, and untrack by the NUL-safe listing.
|
|
1343
|
+
shown = "".join(c if c.isprintable() else repr(c)[1:-1] for c in path)
|
|
1344
|
+
if shown == path:
|
|
1345
|
+
plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
|
|
1346
|
+
"effect on a tracked file, so every run leaves the tree dirty and "
|
|
1347
|
+
"`git commit -a` sweeps it in; untrack it yourself, from the project root: "
|
|
1348
|
+
"`git --literal-pathspecs rm --cached -- %s` and commit (the file stays on disk)"
|
|
1349
|
+
% (shown, shlex.quote(path)))
|
|
1350
|
+
else:
|
|
1351
|
+
plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
|
|
1352
|
+
"effect on a tracked file, so every run leaves the tree dirty and "
|
|
1353
|
+
"`git commit -a` sweeps it in; this name cannot be typed safely, so "
|
|
1354
|
+
"untrack it, from the project root, with `%s`, which untracks EVERY tracked, ignored file "
|
|
1355
|
+
"under .ai/state, not only this one, and commit (the files stay on disk)"
|
|
1356
|
+
% (shown, all_cmd))
|
|
1357
|
+
if len(names) > TRACKED_IGNORED_SHOWN:
|
|
1358
|
+
plan.hints.append(".ai/state: %d more file(s) tracked by git but ignored by an ignore rule, "
|
|
1359
|
+
"not listed; untrack ALL tracked, ignored files under .ai/state "
|
|
1360
|
+
"(the ones listed above too), from the project root, with `%s` and commit (the files stay on disk)"
|
|
1361
|
+
% (len(names) - TRACKED_IGNORED_SHOWN, all_cmd))
|
|
1362
|
+
|
|
1363
|
+
|
|
1236
1364
|
def rendered_rules(plan):
|
|
1237
1365
|
"""Every (file, slug) in the project that carries a rule block today, plus
|
|
1238
1366
|
every path-scoped copy under .claude/rules/. Walked rather than remembered:
|
|
@@ -1275,6 +1403,41 @@ def nested_tree(root, rel_dir):
|
|
|
1275
1403
|
return None
|
|
1276
1404
|
|
|
1277
1405
|
|
|
1406
|
+
def unwalked_dir(root, rel_dir):
|
|
1407
|
+
"""Why the take-back walk (rendered_rules) never enters `rel_dir`, or None:
|
|
1408
|
+
a RULES_SKIP name on the way, or a symlinked directory on the way."""
|
|
1409
|
+
here = root
|
|
1410
|
+
for part in (p for p in rel_dir.split("/") if p not in ("", ".")):
|
|
1411
|
+
here = os.path.join(here, part)
|
|
1412
|
+
if part.casefold() in RULES_SKIP_FOLDED:
|
|
1413
|
+
return "%s is under a directory the take-back never walks (%s)" % (rel_dir, part)
|
|
1414
|
+
if os.path.islink(here):
|
|
1415
|
+
return "%s is a symlink, which the take-back never follows" % os.path.relpath(here, root).replace(os.sep, "/")
|
|
1416
|
+
return None
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _frozen_block(plan, target, slug):
|
|
1420
|
+
"""True when `target` is a readable regular file inside the project, holding a
|
|
1421
|
+
block for `slug`, at a place the take-back walk (rendered_rules) never enters: a
|
|
1422
|
+
RULES_SKIP name or a nested git tree on its real path. A symlink that leads inside
|
|
1423
|
+
the walked part of the project is removed by the same run; one that leads outside
|
|
1424
|
+
the project, a directory, a FIFO or an unreadable file is not read at all."""
|
|
1425
|
+
full = os.path.join(plan.root, target)
|
|
1426
|
+
try:
|
|
1427
|
+
real = os.path.realpath(full)
|
|
1428
|
+
base = os.path.realpath(plan.root)
|
|
1429
|
+
if os.path.commonpath([real, base]) != base or not os.path.isfile(real):
|
|
1430
|
+
return False
|
|
1431
|
+
rel_dir = os.path.relpath(os.path.dirname(real), base).replace(os.sep, "/")
|
|
1432
|
+
parts = [p for p in rel_dir.split("/") if p not in ("", ".")]
|
|
1433
|
+
if not (any(p in RULES_SKIP for p in parts) or nested_tree(base, rel_dir) is not None):
|
|
1434
|
+
return False
|
|
1435
|
+
held = plan.read(target)
|
|
1436
|
+
except (OSError, ValueError):
|
|
1437
|
+
return False
|
|
1438
|
+
return held is not None and slug in render_instructions.rule_slugs_in(held.decode("utf-8", "replace"))
|
|
1439
|
+
|
|
1440
|
+
|
|
1278
1441
|
def rules_update(plan, runtimes, planned=None):
|
|
1279
1442
|
"""Render `.ai/rules/<slug>.md` into the instruction file of every directory
|
|
1280
1443
|
it names, and take back what a rule no longer asks for. The text around a
|
|
@@ -1311,6 +1474,14 @@ def rules_update(plan, runtimes, planned=None):
|
|
|
1311
1474
|
plan.hints.append("%s: %s is a nested git tree (worktree, repository or submodule)"
|
|
1312
1475
|
" — rule %s is not rendered there" % (target, tree, item["slug"]))
|
|
1313
1476
|
continue
|
|
1477
|
+
skip = unwalked_dir(plan.root, os.path.dirname(target)) if item["kind"] == "block" else None
|
|
1478
|
+
if skip is not None:
|
|
1479
|
+
hint = "%s: %s — rule %s is not rendered there" % (target, skip, item["slug"])
|
|
1480
|
+
if _frozen_block(plan, target, item["slug"]):
|
|
1481
|
+
hint += ("; an earlier rendering of it is still there, the take-back never walks there:"
|
|
1482
|
+
" remove that block by hand")
|
|
1483
|
+
plan.hints.append(hint)
|
|
1484
|
+
continue
|
|
1314
1485
|
current = plan.read(target)
|
|
1315
1486
|
if item["kind"] == "doc":
|
|
1316
1487
|
new = content.encode()
|
|
@@ -1510,6 +1681,7 @@ def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None
|
|
|
1510
1681
|
block_update(plan, history, rt)
|
|
1511
1682
|
rules_update(plan, runtimes)
|
|
1512
1683
|
gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
1684
|
+
tracked_ignored_hint(plan)
|
|
1513
1685
|
|
|
1514
1686
|
testing = plan.read(".ai/policies/testing.md")
|
|
1515
1687
|
if testing is not None:
|
|
@@ -1610,7 +1782,7 @@ def record_migration(plan, ops, deletions):
|
|
|
1610
1782
|
if os.path.isfile(full):
|
|
1611
1783
|
try:
|
|
1612
1784
|
existing = json.loads(read(full), object_pairs_hook=OrderedDict)
|
|
1613
|
-
except ValueError:
|
|
1785
|
+
except (ValueError, RecursionError): # too deep reads as malformed
|
|
1614
1786
|
existing = {}
|
|
1615
1787
|
if not isinstance(existing, dict): # hand-edited into another shape
|
|
1616
1788
|
existing = {}
|
|
@@ -1821,8 +1993,12 @@ def report(plan, applied):
|
|
|
1821
1993
|
tail = ""
|
|
1822
1994
|
if any(i["policy"] for i in auto):
|
|
1823
1995
|
tail += ", %d policy change(s) to confirm" % sum(len(i["policy"] or []) for i in auto)
|
|
1824
|
-
|
|
1825
|
-
|
|
1996
|
+
# the summary names the flag each row names: [cleanup] rows settle with --confirm-cleanup only
|
|
1997
|
+
cleanups = [i for i in deletions if (i["reason"] or "").startswith("[cleanup]")]
|
|
1998
|
+
if len(deletions) > len(cleanups):
|
|
1999
|
+
tail += ", %d deletion(s) awaiting --confirm-delete" % (len(deletions) - len(cleanups))
|
|
2000
|
+
if cleanups:
|
|
2001
|
+
tail += ", %d cleanup file(s) awaiting --confirm-cleanup" % len(cleanups)
|
|
1826
2002
|
print("%d automatic, %d conflict(s)%s" % (len(auto), len(conflicts), tail))
|
|
1827
2003
|
|
|
1828
2004
|
|
package/skills/rr-setup/SKILL.md
CHANGED
|
@@ -170,7 +170,12 @@ it cannot.
|
|
|
170
170
|
`that rule is not rendered`: name the `.ai/rules/` file and what it lacks
|
|
171
171
|
(`paths:` and a body). `overrides the plugin's shared copy`: offer to diff
|
|
172
172
|
each and delete those that add nothing. `foreign structure detected`: offer
|
|
173
|
-
**--adopt**.
|
|
173
|
+
**--adopt**. `tracked by git but ignored`: show the line as printed and give
|
|
174
|
+
the human the command it prints, exactly as printed, plus a commit, for their
|
|
175
|
+
own terminal (never rebuilt from the path; only the xargs form untracks every
|
|
176
|
+
such file under `.ai/state`, a printed `rm --cached -- <path>` one file; a
|
|
177
|
+
second machine runs the same before its next pull, else a modify/delete
|
|
178
|
+
conflict); never run it yourself.
|
|
174
179
|
7. `$UPDATE --check` and `git diff --stat`; say what changed, what you merged
|
|
175
180
|
and what is the human's. Suggest `git add -A .ai docs/sdlc .gitignore` plus
|
|
176
181
|
the runtime directories and instruction files.
|
|
@@ -190,7 +195,9 @@ overwrite. A foreign tool's structure, below, can be present as well.
|
|
|
190
195
|
|
|
191
196
|
Every command is `$UPDATE --adopt …`, with `--mode coexist` on each when the team
|
|
192
197
|
still uses the other tool (router rows only, nothing moves). Exit 5
|
|
193
|
-
`ADOPT_REFUSED`: show the first line and stop. Exit
|
|
198
|
+
`ADOPT_REFUSED`: show the first line and stop. Exit 2: the routing table or a
|
|
199
|
+
migration could not be read (reason on stderr, prefixed `project-update:`),
|
|
200
|
+
nothing written. Exit 4 `ADOPT_INCOMPLETE`:
|
|
194
201
|
`unmapped` files and a `split?` are yours to settle, then dry-run again; a
|
|
195
202
|
`conflict` or a check `FAIL` left after that is the human's — never edit a
|
|
196
203
|
destination or a source to make it pass.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
Read-only and model-free. A schema-7 task is described by `state.py status`;
|
|
7
7
|
for any other task, and whenever that call fails, it reads the files directly —
|
|
8
|
-
.ai/state/state.json (or the legacy current.json), the task's events.jsonl, .ai/policies/
|
|
8
|
+
.ai/state/state.json (or the legacy current.json), the task's events.jsonl, .ai/policies/weight.json (else the shipped table)
|
|
9
9
|
and .ai/VERSION — so it answers in a project whose state.py is older, newer or
|
|
10
10
|
broken. The root is found the way the guards find it (find_ai_root in
|
|
11
11
|
hooks/lib/ai-hook-common.sh): the nearest ancestor holding .ai/, not stopping at
|
|
@@ -81,7 +81,7 @@ def schema_line(root):
|
|
|
81
81
|
numbers = [int(name[:4]) for name in names if re.match(r"\d{4}_", name)]
|
|
82
82
|
plugin = str(max(numbers)) if numbers else "?"
|
|
83
83
|
behind = project.isdigit() and plugin.isdigit() and int(project) < int(plugin)
|
|
84
|
-
return "schema: project %s · plugin %s%s" % (project, plugin, " — behind: run /
|
|
84
|
+
return "schema: project %s · plugin %s%s" % (project, plugin, " — behind: run /rr-setup --update" if behind else "")
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
def one_line(value, limit=160):
|
|
@@ -97,13 +97,40 @@ def short(items, limit=4):
|
|
|
97
97
|
return ", ".join(items[:limit]) + more if items else "none"
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
def budgets(root, state):
|
|
101
|
+
"""(max_suite_runs, review_rounds_max) as state.py's budget() enforces them:
|
|
102
|
+
weight.json's batch section while the batch loop is on, else tests.max_suite_runs
|
|
103
|
+
and review_rounds_max. A project without weight.json runs the shipped table."""
|
|
104
|
+
policy = read_json(os.path.join(root, ".ai", "policies", "weight.json")) \
|
|
105
|
+
or read_json(os.path.join(SKILLS, "ai-init", "templates", ".ai", "policies", "weight.json"))
|
|
106
|
+
|
|
107
|
+
def whole(value, default):
|
|
108
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 1 else default
|
|
109
|
+
|
|
110
|
+
if (state.get("batch") or {}).get("on"):
|
|
111
|
+
section = policy.get("batch") if isinstance(policy.get("batch"), dict) else {}
|
|
112
|
+
return whole(section.get("max_suite_runs"), 8), whole(section.get("review_rounds_max"), 3)
|
|
113
|
+
tests = policy.get("tests") if isinstance(policy.get("tests"), dict) else {}
|
|
114
|
+
return tests.get("max_suite_runs", 2), policy.get("review_rounds_max", 2)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def counter(state, section, key, legacy):
|
|
118
|
+
"""A schema-7 counter (verify.suite_runs, review.passes), else the legacy one
|
|
119
|
+
(tests.suite_runs, reviews); 0 when neither is a number."""
|
|
120
|
+
new = state.get(section)
|
|
121
|
+
if isinstance(new, dict) and isinstance(new.get(key), int):
|
|
122
|
+
return new[key]
|
|
123
|
+
old = state.get(legacy)
|
|
124
|
+
old = old.get(key) if isinstance(old, dict) else old
|
|
125
|
+
return old if isinstance(old, int) else 0
|
|
126
|
+
|
|
127
|
+
|
|
100
128
|
def report(root):
|
|
101
129
|
lines = ["root: %s" % root]
|
|
102
130
|
state = read_json(state_file(root))
|
|
103
131
|
if not state.get("task_id"):
|
|
104
132
|
return lines + ["task: none — no task in flight", schema_line(root)], None
|
|
105
|
-
|
|
106
|
-
tests_cfg = ((policy.get("sensors") or {}).get("tests") or {})
|
|
133
|
+
max_runs, max_rounds = budgets(root, state)
|
|
107
134
|
plan = state.get("approved_plan") or {}
|
|
108
135
|
steps = plan.get("steps") or []
|
|
109
136
|
current = plan.get("current_step_id")
|
|
@@ -122,11 +149,11 @@ def report(root):
|
|
|
122
149
|
else "step: none current (%d of %d done)" % (done, len(steps))),
|
|
123
150
|
"verify: tests %s · suite runs %s/%s · e2e %s"
|
|
124
151
|
% (state.get("test_status") or "?",
|
|
125
|
-
(state
|
|
126
|
-
|
|
152
|
+
counter(state, "verify", "suite_runs", "tests"),
|
|
153
|
+
max_runs, state.get("e2e_status") or "?"),
|
|
127
154
|
"review: %s (%s of %s) · security %s"
|
|
128
|
-
% (state.get("review_status") or "?", state
|
|
129
|
-
|
|
155
|
+
% (state.get("review_status") or "?", counter(state, "review", "passes", "reviews"),
|
|
156
|
+
max_rounds, state.get("security_status") or "?"),
|
|
130
157
|
"approval: %s" % ("granted by %s" % approval.get("granted_by") if approval.get("granted")
|
|
131
158
|
else "requested, waiting for the human" if approval.get("requested_at")
|
|
132
159
|
else "not requested"),
|
|
@@ -146,7 +173,7 @@ def main(argv=None):
|
|
|
146
173
|
args = parser.parse_args(argv)
|
|
147
174
|
root = find_ai_root(args.root)
|
|
148
175
|
if not root:
|
|
149
|
-
print("root: none — no .ai/ in %s or above it; /
|
|
176
|
+
print("root: none — no .ai/ in %s or above it; run /rr-setup first" % os.path.abspath(args.root))
|
|
150
177
|
return 1
|
|
151
178
|
lines, task_id = None, None
|
|
152
179
|
state = read_json(state_file(root))
|