riskrails 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +12 -11
  5. package/bin/riskrails.js +109 -18
  6. package/hooks/ai-path-guard-defaults.json +3 -2
  7. package/hooks/context-guard.py +14 -11
  8. package/hooks/dialog-gate.py +1 -1
  9. package/hooks/guard/dialog.py +107 -27
  10. package/hooks/guard/git.py +12 -3
  11. package/hooks/guard/paths.py +109 -7
  12. package/hooks/project-scaffold.py +2 -1
  13. package/hooks/runtime-gate.py +128 -27
  14. package/hooks/update_notice.py +3 -3
  15. package/package.json +1 -1
  16. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  17. package/skills/ai-task/rr_state/__init__.py +1 -0
  18. package/skills/ai-task/rr_state/cli.py +210 -0
  19. package/skills/ai-task/rr_state/close.py +171 -0
  20. package/skills/ai-task/rr_state/dialog.py +541 -0
  21. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  22. package/skills/ai-task/rr_state/fs.py +220 -0
  23. package/skills/ai-task/rr_state/gate.py +125 -0
  24. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  25. package/skills/ai-task/rr_state/handoff.py +226 -0
  26. package/skills/ai-task/rr_state/journal.py +126 -0
  27. package/skills/ai-task/rr_state/labels.py +99 -0
  28. package/skills/ai-task/rr_state/ledger.py +145 -0
  29. package/skills/ai-task/rr_state/locks.py +84 -0
  30. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  31. package/skills/ai-task/rr_state/policy.py +378 -0
  32. package/skills/ai-task/rr_state/profile.py +373 -0
  33. package/skills/ai-task/rr_state/questions.py +336 -0
  34. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  35. package/skills/ai-task/rr_state/report.py +271 -0
  36. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  37. package/skills/ai-task/rr_state/review.py +165 -0
  38. package/skills/ai-task/rr_state/runtime.py +80 -0
  39. package/skills/ai-task/rr_state/start.py +257 -0
  40. package/skills/ai-task/rr_state/steps.py +131 -0
  41. package/skills/ai-task/rr_state/task.py +396 -0
  42. package/skills/ai-task/rr_state/verify.py +231 -0
  43. package/skills/ai-task/sensors.py +16 -3
  44. package/skills/ai-task/state.py +24 -5161
  45. package/skills/project-update/adopt.py +38 -24
  46. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  47. package/skills/project-update/history/index.json +2 -1
  48. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  49. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  50. package/skills/project-update/migrations/README.md +16 -1
  51. package/skills/project-update/update.py +202 -26
  52. package/skills/rr-setup/SKILL.md +9 -2
  53. 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
- # two still disagree on a dirs: entry under a RULES_SKIP name or through a
112
- # symlinked directory (.ai/project/known-risks.md).
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
- after = fn(copy.deepcopy(before))
688
- if after == before:
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
- policy = leaf_changes(before, after) if "/policies/" in "/" + path else None
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 (ValueError, TypeError, AttributeError, KeyError):
869
- fresh = None # not a table the renderer reads: the text merge decides
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
- base = closest(bases, o, json_score) if bases else MISSING
905
- conflicts = []
906
- merged = merge_json(base, o, t, [], conflicts)
907
- policy = leaf_changes(o, merged) if "/policies/" in "/" + target else None
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=dump_json(merged), policy=policy)
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
- if deletions:
1825
- tail += ", %d deletion(s) awaiting --confirm-delete" % len(deletions)
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
 
@@ -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 4 `ADOPT_INCOMPLETE`:
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/risk-tiers.json
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 /project-update" if behind else "")
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
- policy = read_json(os.path.join(root, ".ai", "policies", "risk-tiers.json"))
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.get("tests") if isinstance(state.get("tests"), dict) else {}).get("suite_runs", 0),
126
- tests_cfg.get("max_suite_runs", 5), state.get("e2e_status") or "?"),
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.get("reviews") or 0,
129
- policy.get("review_rounds_max", 2), state.get("security_status") or "?"),
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; /ai-init sets one up" % os.path.abspath(args.root))
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))